Compare commits
10 Commits
71aaad03b9
...
14c83e182a
| Author | SHA1 | Date | |
|---|---|---|---|
| 14c83e182a | |||
| 445bca0bf6 | |||
| 062feeae2f | |||
| 50a3095ef3 | |||
| eb3dc18e67 | |||
| 06dab1e790 | |||
| 9956488fd5 | |||
| 49df2015c8 | |||
| c2f06a5d2a | |||
| 58d8f06ba0 |
+1
-20
@@ -1,24 +1,5 @@
|
|||||||
{
|
{
|
||||||
"hooks": {
|
"hooks": {
|
||||||
"PreToolUse": [
|
"PreToolUse": []
|
||||||
{
|
|
||||||
"matcher": "Bash",
|
|
||||||
"hooks": [
|
|
||||||
{
|
|
||||||
"type": "command",
|
|
||||||
"command": "CMD=$(python3 -c \"import json,sys; d=json.load(sys.stdin); print(d.get('tool_input',d).get('command',''))\" 2>/dev/null || true); case \"$CMD\" in *grep*|*rg\\ *|*ripgrep*|*find\\ *|*fd\\ *|*ack\\ *|*ag\\ *) [ -f graphify-out/graph.json ] && echo '{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"additionalContext\":\"MANDATORY: graphify-out/graph.json exists. You MUST run `graphify query \\\"<question>\\\"` before grepping raw files. Only grep after graphify has oriented you, or to modify/debug specific lines.\"}}' || true ;; esac"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"matcher": "Read|Glob",
|
|
||||||
"hooks": [
|
|
||||||
{
|
|
||||||
"type": "command",
|
|
||||||
"command": "HIT=$(python3 -c \"import json,sys;d=json.load(sys.stdin);t=d.get('tool_input',d);s=(str(t.get('file_path') or '')+' '+str(t.get('pattern') or '')+' '+str(t.get('path') or '')).lower().replace(chr(92),'/');exts=('.py','.js','.ts','.tsx','.jsx','.go','.rs','.java','.rb','.c','.h','.cpp','.hpp','.cc','.cs','.kt','.swift','.php','.scala','.lua','.sh','.md','.rst','.txt','.mdx');sys.stdout.write('1' if 'graphify-out/' not in s and any(e in s for e in exts) else '')\" 2>/dev/null || true); if [ \"$HIT\" = 1 ] && [ -f graphify-out/graph.json ]; then echo '{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"additionalContext\":\"MANDATORY: graphify-out/graph.json exists. You MUST run graphify before reading source files. Use: `graphify query \\\"<question>\\\"` (scoped subgraph), `graphify explain \\\"<concept>\\\"`, or `graphify path \\\"<A>\\\" \\\"<B>\\\"`. Only read raw files after graphify has oriented you, or to modify/debug specific lines. This rule applies to subagents too \u2014 include it in every subagent prompt involving code exploration.\"}}'; fi || true"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -86,13 +86,3 @@ For the full reasoning behind each, follow the links from `wiki/overview.md`.
|
|||||||
|
|
||||||
- TypeScript throughout. Match the style of surrounding code.
|
- TypeScript throughout. Match the style of surrounding code.
|
||||||
- Confirm before destructive or outward-facing actions. Commit/push only when asked.
|
- Confirm before destructive or outward-facing actions. Commit/push only when asked.
|
||||||
|
|
||||||
## graphify
|
|
||||||
|
|
||||||
This project has a knowledge graph at graphify-out/ with god nodes, community structure, and cross-file relationships.
|
|
||||||
|
|
||||||
Rules:
|
|
||||||
- For codebase questions, first run `graphify query "<question>"` when graphify-out/graph.json exists. Use `graphify path "<A>" "<B>"` for relationships and `graphify explain "<concept>"` for focused concepts. These return a scoped subgraph, usually much smaller than GRAPH_REPORT.md or raw grep output.
|
|
||||||
- If graphify-out/wiki/index.md exists, use it for broad navigation instead of raw source browsing.
|
|
||||||
- Read graphify-out/GRAPH_REPORT.md only for broad architecture review or when query/path/explain do not surface enough context.
|
|
||||||
- After modifying code, run `graphify update .` to keep the graph current (AST-only, no API cost).
|
|
||||||
|
|||||||
@@ -25,3 +25,7 @@ EVENT_SIGNING_KEY=
|
|||||||
# First admin (seed once): pnpm --filter @parking/server seed-admin
|
# First admin (seed once): pnpm --filter @parking/server seed-admin
|
||||||
# ADMIN_USER=admin
|
# ADMIN_USER=admin
|
||||||
# ADMIN_PASS=
|
# ADMIN_PASS=
|
||||||
|
|
||||||
|
# Comma-separated extra origins allowed to open the booth WebSocket (/api/ws).
|
||||||
|
# In dev, set the Vite SPA origin. Same-origin is always allowed without this.
|
||||||
|
WS_ALLOWED_ORIGINS=http://localhost:5173
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
"@fastify/cors": "11.2.0",
|
"@fastify/cors": "11.2.0",
|
||||||
"@fastify/jwt": "10.1.0",
|
"@fastify/jwt": "10.1.0",
|
||||||
"@fastify/static": "9.1.3",
|
"@fastify/static": "9.1.3",
|
||||||
|
"@fastify/websocket": "^11.2.0",
|
||||||
"@parking/db": "workspace:*",
|
"@parking/db": "workspace:*",
|
||||||
"@parking/devices": "workspace:*",
|
"@parking/devices": "workspace:*",
|
||||||
"@parking/shared": "workspace:*",
|
"@parking/shared": "workspace:*",
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { eq, siteConfig, type Db } from "@parking/db";
|
||||||
|
import {
|
||||||
|
printWithFailover,
|
||||||
|
registry,
|
||||||
|
type PrinterDevice,
|
||||||
|
type PrinterInstance,
|
||||||
|
type TicketData,
|
||||||
|
type TicketHeader,
|
||||||
|
} from "@parking/devices";
|
||||||
|
import type { FastifyBaseLogger } from "fastify";
|
||||||
|
import { devicesByDirection } from "./device-resolve.js";
|
||||||
|
|
||||||
|
// Booth-side printing for the EXIT VOUCHER ("biletë dalje"). When the booth is far
|
||||||
|
// from the exit, the customer pays at the booth and walks a printed voucher to the
|
||||||
|
// exit, where they self-scan it. The voucher reprints the SAME ticket id as a
|
||||||
|
// Code128 barcode (now a paid session) — so the exit reader runs the normal exit
|
||||||
|
// validation and opens. See wiki/concepts/booth-exit-flow.md, ticket-encoding.md.
|
||||||
|
//
|
||||||
|
// This mirrors the entry flow's printer selection + header build, but prints on the
|
||||||
|
// BOOTH printer (role "booth-receipt") since that's where the operator stands.
|
||||||
|
|
||||||
|
/** Park identity for the voucher header, from site_config (all fields optional). */
|
||||||
|
function ticketHeader(db: Db): TicketHeader | undefined {
|
||||||
|
const row = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
|
||||||
|
if (!row) return undefined;
|
||||||
|
return {
|
||||||
|
parkName: row.parkName,
|
||||||
|
operatorName: row.operatorName,
|
||||||
|
nius: row.nius,
|
||||||
|
address: row.address,
|
||||||
|
phone: row.phone,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build live printer instances for failover selection (entry direction covers the
|
||||||
|
* booth-receipt role too — the booth printer is configured on the entry side). */
|
||||||
|
function loadPrinters(db: Db): PrinterInstance[] {
|
||||||
|
const rows = devicesByDirection(db, "printer", "entry");
|
||||||
|
const out: PrinterInstance[] = [];
|
||||||
|
for (const row of rows) {
|
||||||
|
const driver = registry.get(row.driverId);
|
||||||
|
if (!driver) continue;
|
||||||
|
const cfg = row.config as Record<string, unknown>;
|
||||||
|
const role = cfg.role === "booth-receipt" ? "booth-receipt" : "entry-dispenser";
|
||||||
|
try {
|
||||||
|
out.push({
|
||||||
|
id: row.id,
|
||||||
|
role,
|
||||||
|
failoverRank: typeof cfg.failoverRank === "number" ? cfg.failoverRank : 0,
|
||||||
|
device: driver.create(cfg as never) as PrinterDevice,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// skip a printer whose config won't build
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Print an exit voucher for a paid session: the same ticket id reprinted as a
|
||||||
|
* barcode, on the booth printer (failing over to the entry dispenser). Returns the
|
||||||
|
* id of the printer that printed it. Throws NoPrinterAvailableError if none can.
|
||||||
|
*/
|
||||||
|
export async function printExitVoucher(
|
||||||
|
db: Db,
|
||||||
|
ticketId: string,
|
||||||
|
logger: FastifyBaseLogger,
|
||||||
|
): Promise<string> {
|
||||||
|
const printers = loadPrinters(db);
|
||||||
|
const ticket: TicketData = {
|
||||||
|
ticketId,
|
||||||
|
issuedAt: new Date().toISOString(),
|
||||||
|
header: ticketHeader(db),
|
||||||
|
};
|
||||||
|
// Prefer the booth printer (operator is at the booth); fall back to the dispenser.
|
||||||
|
const printedBy = await printWithFailover(printers, "booth-receipt", (d: PrinterDevice) =>
|
||||||
|
d.printTicket(ticket),
|
||||||
|
);
|
||||||
|
logger.info(`exit voucher for ${ticketId} printed on ${printedBy}`);
|
||||||
|
return printedBy;
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { EventEmitter } from "node:events";
|
import { EventEmitter } from "node:events";
|
||||||
import type { PrinterStatus } from "@parking/devices";
|
import type { PrinterStatus } from "@parking/devices";
|
||||||
|
import type { LedgerEventRow } from "@parking/db";
|
||||||
|
|
||||||
// Internal event bus for device-originated events (button presses, etc.).
|
// Internal event bus for device-originated events (button presses, etc.).
|
||||||
// Hardware drivers / inbound device pushes emit here; business logic (entry
|
// Hardware drivers / inbound device pushes emit here; business logic (entry
|
||||||
@@ -74,6 +75,21 @@ class DeviceEventBus extends EventEmitter {
|
|||||||
this.on("printer-status", cb);
|
this.on("printer-status", cb);
|
||||||
return () => this.off("printer-status", cb);
|
return () => this.off("printer-status", cb);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emitted AFTER a signed business event is appended to the ledger (entry, exit,
|
||||||
|
* payment, void, …). The payload is the persisted row — business facts only, no
|
||||||
|
* secrets — so it is safe to fan out to authenticated booth clients over the WS.
|
||||||
|
* This is a read-side notification ONLY: it never feeds back into append/sign/
|
||||||
|
* chain logic. See event-log.ts (emitted from EventLog.append) and routes/ws.ts.
|
||||||
|
*/
|
||||||
|
emitLedger(event: LedgerEventRow): void {
|
||||||
|
this.emit("ledger", event);
|
||||||
|
}
|
||||||
|
onLedger(cb: (event: LedgerEventRow) => void): () => void {
|
||||||
|
this.on("ledger", cb);
|
||||||
|
return () => this.off("ledger", cb);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Process-wide device event bus. */
|
/** Process-wide device event bus. */
|
||||||
|
|||||||
@@ -92,13 +92,23 @@ export class EventLog {
|
|||||||
* (JWT-fallback → dedicated key → ATECC608). Defaults to the append signer for
|
* (JWT-fallback → dedicated key → ATECC608). Defaults to the append signer for
|
||||||
* callers that don't pass one (single-key chains, tests). */
|
* callers that don't pass one (single-key chains, tests). */
|
||||||
readonly #resolveVerifier: SignerResolver;
|
readonly #resolveVerifier: SignerResolver;
|
||||||
|
/** Optional read-side notification, fired AFTER a row is durably inserted. Used
|
||||||
|
* to fan the event out to live booth clients (WS). It is best-effort and must
|
||||||
|
* NOT influence the append/sign/chain path — a throwing/absent sink is ignored. */
|
||||||
|
readonly #onAppended?: (row: LedgerEventRow) => void;
|
||||||
/** Serialize appends: each waits for the previous to finish. */
|
/** Serialize appends: each waits for the previous to finish. */
|
||||||
#tail: Promise<unknown> = Promise.resolve();
|
#tail: Promise<unknown> = Promise.resolve();
|
||||||
|
|
||||||
constructor(db: Db, signer: Signer, resolveVerifier?: SignerResolver) {
|
constructor(
|
||||||
|
db: Db,
|
||||||
|
signer: Signer,
|
||||||
|
resolveVerifier?: SignerResolver,
|
||||||
|
onAppended?: (row: LedgerEventRow) => void,
|
||||||
|
) {
|
||||||
this.#db = db;
|
this.#db = db;
|
||||||
this.#signer = signer;
|
this.#signer = signer;
|
||||||
this.#resolveVerifier = resolveVerifier ?? (() => signer);
|
this.#resolveVerifier = resolveVerifier ?? (() => signer);
|
||||||
|
this.#onAppended = onAppended;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Append one event to the chain. Returns the persisted row. Serialized. */
|
/** Append one event to the chain. Returns the persisted row. Serialized. */
|
||||||
@@ -106,7 +116,16 @@ export class EventLog {
|
|||||||
const run = this.#tail.then(() => this.#appendNow(input));
|
const run = this.#tail.then(() => this.#appendNow(input));
|
||||||
// Keep the chain going even if one append rejects (don't wedge the lock).
|
// Keep the chain going even if one append rejects (don't wedge the lock).
|
||||||
this.#tail = run.catch(() => undefined);
|
this.#tail = run.catch(() => undefined);
|
||||||
return run;
|
// Read-side notification, AFTER the row is durably written. Wrapped so a
|
||||||
|
// failing sink can never reject the append or break the chain lock above.
|
||||||
|
return run.then((row) => {
|
||||||
|
try {
|
||||||
|
this.#onAppended?.(row);
|
||||||
|
} catch {
|
||||||
|
// best-effort fan-out only — swallow.
|
||||||
|
}
|
||||||
|
return row;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
#appendNow(input: AppendInput): LedgerEventRow {
|
#appendNow(input: AppendInput): LedgerEventRow {
|
||||||
|
|||||||
+216
-13
@@ -1,6 +1,6 @@
|
|||||||
import { desc, eq, ledgerEvents, sessions, tariffVersions, tariffs, type Db, type DeviceRow } from "@parking/db";
|
import { desc, eq, ledgerEvents, sessions, tariffVersions, tariffs, type Db, type DeviceRow } from "@parking/db";
|
||||||
import { registry, type AccessControlDevice } from "@parking/devices";
|
import { registry, type AccessControlDevice } from "@parking/devices";
|
||||||
import type { ResolvedRelay } from "./device-resolve.js";
|
import { firstRelayByDirection, type ResolvedRelay } from "./device-resolve.js";
|
||||||
import { snapshotAsync } from "./snapshot.js";
|
import { snapshotAsync } from "./snapshot.js";
|
||||||
import { computeFee, type LedgerPayload, type TariffStructure } from "@parking/shared";
|
import { computeFee, type LedgerPayload, type TariffStructure } from "@parking/shared";
|
||||||
import type { FastifyBaseLogger } from "fastify";
|
import type { FastifyBaseLogger } from "fastify";
|
||||||
@@ -38,6 +38,21 @@ interface SessionView {
|
|||||||
readonly freeGrace: { tariffVersionId: string; currency: string; graceExitMin: number } | null;
|
readonly freeGrace: { tariffVersionId: string; currency: string; graceExitMin: number } | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Result of a booth-driven exit (POST /api/exit). `ok=false` = validation rejected
|
||||||
|
* (nothing signed beyond an anomaly). `ok=true, opened=false` = exit IS signed but
|
||||||
|
* the barrier didn't open (payment stands; operator opens manually). */
|
||||||
|
export type BoothExitResult =
|
||||||
|
| { ok: false; status: "invalid" | "no_session" | "closed" | "unpaid" | "grace_expired"; reason: string }
|
||||||
|
| { ok: true; opened: true }
|
||||||
|
| { ok: true; opened: false; reason: string };
|
||||||
|
|
||||||
|
/** Result of a human-intervention barrier re-open (POST /api/barrier/reopen).
|
||||||
|
* `ok=false` = refused (no session / unpaid). `ok=true, opened=false` = the
|
||||||
|
* intervention was recorded (signed anomaly) but the relay did not fire. */
|
||||||
|
export type BoothReopenResult =
|
||||||
|
| { ok: false; reason: string }
|
||||||
|
| { ok: true; opened: boolean; reason?: string };
|
||||||
|
|
||||||
export class ExitFlow {
|
export class ExitFlow {
|
||||||
readonly #db: Db;
|
readonly #db: Db;
|
||||||
readonly #log: EventLog;
|
readonly #log: EventLog;
|
||||||
@@ -50,6 +65,170 @@ export class ExitFlow {
|
|||||||
this.#logger = logger;
|
this.#logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BOOTH-driven exit: the operator (not a reader at the lane) opens the barrier for
|
||||||
|
* a ticket. Runs the SAME validation as the reader path — there is no booth-only
|
||||||
|
* bypass that admits an unpaid car (see wiki/concepts/booth-exit-flow.md +
|
||||||
|
* threat-model.md). On a valid session it signs vehicle_exit, resolves AN exit
|
||||||
|
* relay site-wide, pulses it, and fires the exit snapshot.
|
||||||
|
*
|
||||||
|
* Returns a discriminated result so the route can react precisely:
|
||||||
|
* - { ok: false, status } when validation rejects (unpaid / no session / closed)
|
||||||
|
* — nothing is signed beyond the existing anomaly; the operator takes payment.
|
||||||
|
* - { ok: true, opened: true } on a clean exit.
|
||||||
|
* - { ok: true, opened: false } when the exit IS signed but the relay open FAILED
|
||||||
|
* (offline controller / no exit relay). The signed payment + vehicle_exit STAND
|
||||||
|
* (money was taken, the car is owed an exit) and an `anomaly` is appended so the
|
||||||
|
* operator opens manually. Payment is never rolled back.
|
||||||
|
*/
|
||||||
|
async exitForBooth(identity: string): Promise<BoothExitResult> {
|
||||||
|
const id = identity.trim();
|
||||||
|
if (!id) return { ok: false, status: "invalid", reason: "ticket id required" };
|
||||||
|
|
||||||
|
const key = `booth:${id}`;
|
||||||
|
if (this.#inFlight.has(key)) return { ok: false, status: "invalid", reason: "exit already in progress" };
|
||||||
|
this.#inFlight.add(key);
|
||||||
|
try {
|
||||||
|
const view = this.#sessionFor(id);
|
||||||
|
|
||||||
|
// No open session — unknown/closed ticket. Sign an anomaly (same as the reader
|
||||||
|
// path) so a booth attempt on a bad ticket is auditable.
|
||||||
|
if (!view || !view.open) {
|
||||||
|
const reason = view ? "exit refused — session already closed" : "exit refused — no open session for ticket";
|
||||||
|
await this.#log.append({ type: "anomaly", identity: id, payload: { reason, exitRefused: true, source: "booth" } });
|
||||||
|
this.#logger.warn(`booth exit refused (${id}): ${reason}`);
|
||||||
|
return { ok: false, status: view ? "closed" : "no_session", reason };
|
||||||
|
}
|
||||||
|
|
||||||
|
// PAID + within grace, OR free entry-grace — the same checks the reader uses.
|
||||||
|
const freeGrace = view.paidAt == null && view.freeGrace != null;
|
||||||
|
const paid = view.paidAt != null;
|
||||||
|
const withinGrace =
|
||||||
|
paid && view.graceExitMin != null && Date.now() - Date.parse(view.paidAt!) <= view.graceExitMin * 60_000;
|
||||||
|
|
||||||
|
if (!freeGrace && (!paid || !withinGrace)) {
|
||||||
|
const reason = !paid
|
||||||
|
? "exit refused — not paid (take payment first)"
|
||||||
|
: "exit refused — walk-back grace expired (top-up required)";
|
||||||
|
await this.#log.append({ type: "anomaly", identity: id, payload: { reason, exitRefused: true, source: "booth" } });
|
||||||
|
this.#logger.warn(`booth exit refused (${id}): ${reason}`);
|
||||||
|
return { ok: false, status: paid ? "grace_expired" : "unpaid", reason };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Free entry-grace path: mint the $0 payment first (ledger invariant), as the
|
||||||
|
// reader path does.
|
||||||
|
if (freeGrace && view.freeGrace) {
|
||||||
|
await this.#log.append({
|
||||||
|
type: "payment",
|
||||||
|
identity: id,
|
||||||
|
payload: {
|
||||||
|
sessionRef: id,
|
||||||
|
amountMinor: 0,
|
||||||
|
currency: view.freeGrace.currency,
|
||||||
|
tariffVersionId: view.freeGrace.tariffVersionId,
|
||||||
|
graceExitMin: view.freeGrace.graceExitMin,
|
||||||
|
reason: "free entry-grace (no charge)",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve AN exit barrier site-wide (no reader binding to follow at the booth).
|
||||||
|
const resolved = firstRelayByDirection(this.#db, "exit");
|
||||||
|
|
||||||
|
// Sign the vehicle_exit regardless of whether a relay resolves — the decision
|
||||||
|
// to let the car out has been made and validated. Then attempt the open.
|
||||||
|
await this.#signExit(id);
|
||||||
|
|
||||||
|
if (!resolved) {
|
||||||
|
await this.#openFailedAnomaly(id, "no exit relay configured");
|
||||||
|
return { ok: true, opened: false, reason: "exit recorded, but no exit barrier is configured — open manually" };
|
||||||
|
}
|
||||||
|
const access = this.#buildAccess(resolved.controller);
|
||||||
|
if (!access) {
|
||||||
|
await this.#openFailedAnomaly(id, "exit controller would not build");
|
||||||
|
return { ok: true, opened: false, reason: "exit recorded, but the barrier is unavailable — open manually" };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await access.pulseOpen(resolved.relay);
|
||||||
|
} catch (err) {
|
||||||
|
await this.#openFailedAnomaly(id, `pulseOpen failed: ${(err as Error).message}`);
|
||||||
|
return { ok: true, opened: false, reason: "exit recorded, but the barrier did not open — open manually" };
|
||||||
|
}
|
||||||
|
|
||||||
|
this.#fireExitSnapshot(id);
|
||||||
|
this.#closeSessionCache(id);
|
||||||
|
return { ok: true, opened: true };
|
||||||
|
} finally {
|
||||||
|
this.#inFlight.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HUMAN-INTERVENTION barrier re-open for an ACTIVE session (booth Active Sessions
|
||||||
|
* list). The barrier is unconfirmed; a car may be stuck after a damaged-ticket
|
||||||
|
* read, a dead scanner, or a phantom re-close (animal / bag / box). The operator
|
||||||
|
* opens the barrier with a signed trace.
|
||||||
|
*
|
||||||
|
* Guard: requires a PAYMENT — no payment, no re-open (the no-unpaid-bypass rule;
|
||||||
|
* the UI also hides the button). Unlike exitForBooth this does NOT sign a
|
||||||
|
* `vehicle_exit` (the session may already be exited; a second exit would
|
||||||
|
* double-count occupancy). It re-pulses the exit relay and signs an `anomaly`
|
||||||
|
* ("manual barrier open", attributed). Idempotent-safe per identity via #inFlight.
|
||||||
|
* See wiki/concepts/booth-exit-flow.md.
|
||||||
|
*/
|
||||||
|
async reopenBarrier(identity: string, operator?: string): Promise<BoothReopenResult> {
|
||||||
|
const id = identity.trim();
|
||||||
|
if (!id) return { ok: false, reason: "ticket id required" };
|
||||||
|
|
||||||
|
const view = this.#sessionFor(id);
|
||||||
|
if (!view) return { ok: false, reason: "no session for ticket" };
|
||||||
|
// No payment → no re-open. The barrier-open action is only for sessions that
|
||||||
|
// have been paid (or paid-then-exited within grace). An unpaid car takes the
|
||||||
|
// pay/exit flow instead — enforced here, not just in the UI.
|
||||||
|
if (view.paidAt == null) {
|
||||||
|
return { ok: false, reason: "session not paid — no barrier open without payment" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = `reopen:${id}`;
|
||||||
|
if (this.#inFlight.has(key)) return { ok: false, reason: "re-open already in progress" };
|
||||||
|
this.#inFlight.add(key);
|
||||||
|
try {
|
||||||
|
const resolved = firstRelayByDirection(this.#db, "exit");
|
||||||
|
// Sign the audited anomaly FIRST (the intervention is recorded whether or not
|
||||||
|
// the physical open succeeds) — never a second vehicle_exit.
|
||||||
|
await this.#log.append({
|
||||||
|
type: "anomaly",
|
||||||
|
identity: id,
|
||||||
|
payload: {
|
||||||
|
reason: "manual barrier open (human intervention)",
|
||||||
|
source: "booth",
|
||||||
|
barrierReopen: true,
|
||||||
|
...(operator ? { operator } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!resolved) {
|
||||||
|
this.#logger.warn(`barrier re-open for ${id}: no exit relay configured`);
|
||||||
|
return { ok: true, opened: false, reason: "no exit barrier configured — open manually" };
|
||||||
|
}
|
||||||
|
const access = this.#buildAccess(resolved.controller);
|
||||||
|
if (!access) {
|
||||||
|
this.#logger.warn(`barrier re-open for ${id}: exit controller would not build`);
|
||||||
|
return { ok: true, opened: false, reason: "barrier unavailable — open manually" };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await access.pulseOpen(resolved.relay);
|
||||||
|
} catch (err) {
|
||||||
|
this.#logger.error(`barrier re-open pulseOpen failed (${id}): ${(err as Error).message}`);
|
||||||
|
return { ok: true, opened: false, reason: "barrier did not open — open manually" };
|
||||||
|
}
|
||||||
|
this.#logger.info(`manual barrier open for ${id}${operator ? ` by ${operator}` : ""}`);
|
||||||
|
return { ok: true, opened: true };
|
||||||
|
} finally {
|
||||||
|
this.#inFlight.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Handle a transient-ticket read at an exit barrier (the relay pre-resolved by the
|
/** Handle a transient-ticket read at an exit barrier (the relay pre-resolved by the
|
||||||
* read dispatcher from the reader's binding, which has ruled out a permit match). */
|
* read dispatcher from the reader's binding, which has ruled out a permit match). */
|
||||||
async handleAt(resolved: ResolvedRelay, e: DeviceReadEvent): Promise<ReadOutcome> {
|
async handleAt(resolved: ResolvedRelay, e: DeviceReadEvent): Promise<ReadOutcome> {
|
||||||
@@ -131,36 +310,60 @@ export class ExitFlow {
|
|||||||
* Shared by the paid-exit and free-entry-grace paths. The caller has already
|
* Shared by the paid-exit and free-entry-grace paths. The caller has already
|
||||||
* established the session is allowed out (and, for grace, minted the $0 payment). */
|
* established the session is allowed out (and, for grace, minted the $0 payment). */
|
||||||
async #signExitAndOpen(resolved: ResolvedRelay, e: DeviceReadEvent): Promise<ReadOutcome> {
|
async #signExitAndOpen(resolved: ResolvedRelay, e: DeviceReadEvent): Promise<ReadOutcome> {
|
||||||
await this.#log.append({
|
await this.#signExit(e.value, e.kind === "plate" ? "lpr" : "ticket");
|
||||||
type: "vehicle_exit",
|
|
||||||
direction: "exit",
|
|
||||||
source: e.kind === "plate" ? "lpr" : "ticket",
|
|
||||||
identity: e.value,
|
|
||||||
payload: { sessionRef: e.value },
|
|
||||||
});
|
|
||||||
|
|
||||||
const access = this.#buildAccess(resolved.controller);
|
const access = this.#buildAccess(resolved.controller);
|
||||||
if (access) await access.pulseOpen(resolved.relay);
|
if (access) await access.pulseOpen(resolved.relay);
|
||||||
else this.#logger.warn(`exit signed for ${e.value} but the exit relay won't build`);
|
else this.#logger.warn(`exit signed for ${e.value} but the exit relay won't build`);
|
||||||
|
|
||||||
// SNAPSHOT — fire the exit camera(s), never awaited (evidence, not a gate).
|
this.#fireExitSnapshot(e.value);
|
||||||
|
this.#closeSessionCache(e.value);
|
||||||
|
return { accepted: true, direction: "exit" };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Append the signed vehicle_exit. `source` defaults to "ticket" (booth/manual). */
|
||||||
|
async #signExit(identity: string, source: "ticket" | "lpr" = "ticket"): Promise<void> {
|
||||||
|
await this.#log.append({
|
||||||
|
type: "vehicle_exit",
|
||||||
|
direction: "exit",
|
||||||
|
source,
|
||||||
|
identity,
|
||||||
|
payload: { sessionRef: identity },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fire the exit camera(s); never awaited (evidence, not a gate). */
|
||||||
|
#fireExitSnapshot(identity: string): void {
|
||||||
void snapshotAsync({
|
void snapshotAsync({
|
||||||
db: this.#db,
|
db: this.#db,
|
||||||
direction: "exit",
|
direction: "exit",
|
||||||
identity: e.value,
|
identity,
|
||||||
logger: this.#logger,
|
logger: this.#logger,
|
||||||
}).catch((err) => this.#logger.error(`exit snapshot error: ${(err as Error).message}`));
|
}).catch((err) => this.#logger.error(`exit snapshot error: ${(err as Error).message}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Update the (rebuildable) session projection cache to closed. */
|
||||||
|
#closeSessionCache(identity: string): void {
|
||||||
try {
|
try {
|
||||||
this.#db
|
this.#db
|
||||||
.update(sessions)
|
.update(sessions)
|
||||||
.set({ exitedAt: new Date().toISOString(), state: "closed" })
|
.set({ exitedAt: new Date().toISOString(), state: "closed" })
|
||||||
.where(eq(sessions.id, e.value))
|
.where(eq(sessions.id, identity))
|
||||||
.run();
|
.run();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.#logger.error(`session-cache close failed for ${e.value}: ${(err as Error).message}`);
|
this.#logger.error(`session-cache close failed for ${identity}: ${(err as Error).message}`);
|
||||||
}
|
}
|
||||||
return { accepted: true, direction: "exit" };
|
}
|
||||||
|
|
||||||
|
/** Record an audited anomaly when an exit was signed but the barrier didn't open.
|
||||||
|
* The payment + exit STAND; this tells the operator to open manually. */
|
||||||
|
async #openFailedAnomaly(identity: string, detail: string): Promise<void> {
|
||||||
|
await this.#log.append({
|
||||||
|
type: "anomaly",
|
||||||
|
identity,
|
||||||
|
payload: { reason: "exit signed but barrier open failed", detail, source: "booth", exitOpenFailed: true },
|
||||||
|
});
|
||||||
|
this.#logger.error(`booth exit open failed (${identity}): ${detail}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Fold the signed ledger into a session view for one identity (authoritative). */
|
/** Fold the signed ledger into a session view for one identity (authoritative). */
|
||||||
|
|||||||
@@ -35,6 +35,45 @@ export interface Quote {
|
|||||||
readonly graceExitMin: number;
|
readonly graceExitMin: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One row in the booth Active Sessions list. A session is "active" while it is
|
||||||
|
* still open OR exited-but-within-grace — because the barrier is UNCONFIRMED, a
|
||||||
|
* paid/exited car is presumed possibly-still-present until grace expires. The
|
||||||
|
* "Open barrier" action is offered only when `paidAt != null` (no payment, no
|
||||||
|
* button — the no-unpaid-bypass rule). See wiki/concepts/booth-exit-flow.md. */
|
||||||
|
export interface ActiveSession {
|
||||||
|
readonly identity: string;
|
||||||
|
readonly source: string | null;
|
||||||
|
readonly enteredAt: string;
|
||||||
|
/** null while still inside; set once a vehicle_exit is signed (may still be present). */
|
||||||
|
readonly exitedAt: string | null;
|
||||||
|
readonly open: boolean;
|
||||||
|
readonly paidAt: string | null;
|
||||||
|
/** Amount owed now (open + unpaid only; null otherwise / no tariff). */
|
||||||
|
readonly amountMinor: number | null;
|
||||||
|
readonly currency: string | null;
|
||||||
|
readonly withinGrace: boolean;
|
||||||
|
readonly graceExpiresAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Booth session view: everything the pay/exit modal needs in one read. */
|
||||||
|
export interface SessionLookup {
|
||||||
|
readonly identity: string;
|
||||||
|
readonly found: boolean;
|
||||||
|
/** Open = entered, no exit yet. */
|
||||||
|
readonly open: boolean;
|
||||||
|
readonly enteredAt: string | null;
|
||||||
|
readonly exitedAt: string | null;
|
||||||
|
/** Latest payment time, if paid. */
|
||||||
|
readonly paidAt: string | null;
|
||||||
|
/** Amount owed right now (the quote). Null when no session / no active tariff. */
|
||||||
|
readonly amountMinor: number | null;
|
||||||
|
readonly currency: string | null;
|
||||||
|
/** True when paid AND still within the walk-back grace window. */
|
||||||
|
readonly withinGrace: boolean;
|
||||||
|
/** ISO time the walk-back grace expires (paidAt + graceExitMin), if paid. */
|
||||||
|
readonly graceExpiresAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export class PayStation {
|
export class PayStation {
|
||||||
readonly #db: Db;
|
readonly #db: Db;
|
||||||
readonly #log: EventLog;
|
readonly #log: EventLog;
|
||||||
@@ -109,6 +148,145 @@ export class PayStation {
|
|||||||
return { amountMinor, currency: q.currency };
|
return { amountMinor, currency: q.currency };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One-read session view for the booth pay/exit modal: entry/exit times, paid
|
||||||
|
* state, amount owed now, and walk-back-grace status. Read-only — folds the
|
||||||
|
* signed ledger (authoritative). A quote failure (no tariff) leaves amount null
|
||||||
|
* rather than throwing, so the modal can still show the session.
|
||||||
|
*/
|
||||||
|
lookup(identity: string): SessionLookup {
|
||||||
|
const id = identity.trim();
|
||||||
|
const rows = this.#db
|
||||||
|
.select()
|
||||||
|
.from(ledgerEvents)
|
||||||
|
.where(eq(ledgerEvents.identity, id))
|
||||||
|
.orderBy(ledgerEvents.index)
|
||||||
|
.all();
|
||||||
|
const entry = rows.find((r) => r.type === "vehicle_entry");
|
||||||
|
if (!entry) {
|
||||||
|
return {
|
||||||
|
identity: id, found: false, open: false, enteredAt: null, exitedAt: null,
|
||||||
|
paidAt: null, amountMinor: null, currency: null, withinGrace: false, graceExpiresAt: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const exitRow = rows.find((r) => r.type === "vehicle_exit");
|
||||||
|
const open = !exitRow;
|
||||||
|
|
||||||
|
let paidAt: string | null = null;
|
||||||
|
let graceExitMin: number | null = null;
|
||||||
|
for (const r of rows) {
|
||||||
|
if (r.type === "payment") {
|
||||||
|
paidAt = r.occurredAt;
|
||||||
|
const p = (r.payload ?? {}) as { graceExitMin?: number };
|
||||||
|
if (typeof p.graceExitMin === "number") graceExitMin = p.graceExitMin;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const graceExpiresAt =
|
||||||
|
paidAt && graceExitMin != null ? new Date(Date.parse(paidAt) + graceExitMin * 60_000).toISOString() : null;
|
||||||
|
const withinGrace = graceExpiresAt != null && Date.now() <= Date.parse(graceExpiresAt);
|
||||||
|
|
||||||
|
// Amount owed now (best-effort; null if no tariff resolves). Only meaningful while open.
|
||||||
|
let amountMinor: number | null = null;
|
||||||
|
let currency: string | null = null;
|
||||||
|
if (open) {
|
||||||
|
try {
|
||||||
|
const q = this.quote(id);
|
||||||
|
amountMinor = q.amountMinor;
|
||||||
|
currency = q.currency;
|
||||||
|
} catch {
|
||||||
|
/* no active tariff — leave null; modal shows session without a price */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
identity: id, found: true, open,
|
||||||
|
enteredAt: entry.occurredAt, exitedAt: exitRow?.occurredAt ?? null,
|
||||||
|
paidAt, amountMinor, currency, withinGrace, graceExpiresAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* All ACTIVE sessions for the booth list: still-open, OR exited-but-within-grace
|
||||||
|
* (the barrier is unconfirmed, so a paid/exited car is presumed possibly-present
|
||||||
|
* until grace expires). One ledger scan, grouped by identity (cheaper than N
|
||||||
|
* lookups). Sorted by entry time, newest first. Folds the SIGNED ledger
|
||||||
|
* (authoritative — not the sessions projection cache, which can drift).
|
||||||
|
* See wiki/concepts/booth-exit-flow.md.
|
||||||
|
*/
|
||||||
|
activeSessions(): ActiveSession[] {
|
||||||
|
const rows = this.#db.select().from(ledgerEvents).orderBy(ledgerEvents.index).all();
|
||||||
|
|
||||||
|
// Group the relevant events per identity in one pass.
|
||||||
|
type Acc = { enteredAt?: string; source: string | null; exitedAt?: string; paidAt?: string; graceExitMin?: number };
|
||||||
|
const byId = new Map<string, Acc>();
|
||||||
|
for (const r of rows) {
|
||||||
|
const id = r.identity;
|
||||||
|
if (!id) continue;
|
||||||
|
if (r.type === "vehicle_entry") {
|
||||||
|
const a = byId.get(id) ?? { source: r.source ?? null };
|
||||||
|
a.enteredAt = r.occurredAt;
|
||||||
|
a.source = r.source ?? a.source;
|
||||||
|
byId.set(id, a);
|
||||||
|
} else if (r.type === "vehicle_exit") {
|
||||||
|
const a = byId.get(id);
|
||||||
|
if (a) a.exitedAt = r.occurredAt;
|
||||||
|
} else if (r.type === "payment") {
|
||||||
|
const a = byId.get(id);
|
||||||
|
if (a) {
|
||||||
|
a.paidAt = r.occurredAt;
|
||||||
|
const p = (r.payload ?? {}) as { graceExitMin?: number };
|
||||||
|
if (typeof p.graceExitMin === "number") a.graceExitMin = p.graceExitMin;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const out: ActiveSession[] = [];
|
||||||
|
for (const [identity, a] of byId) {
|
||||||
|
if (!a.enteredAt) continue; // no entry → not a real session
|
||||||
|
const open = a.exitedAt == null;
|
||||||
|
const graceExpiresAt =
|
||||||
|
a.paidAt && a.graceExitMin != null
|
||||||
|
? new Date(Date.parse(a.paidAt) + a.graceExitMin * 60_000).toISOString()
|
||||||
|
: null;
|
||||||
|
const withinGrace = graceExpiresAt != null && now <= Date.parse(graceExpiresAt);
|
||||||
|
|
||||||
|
// ACTIVE = still inside, OR exited but still within the (unconfirmed) grace window.
|
||||||
|
// An exited session past grace is presumed truly gone → omitted.
|
||||||
|
if (!open && !withinGrace) continue;
|
||||||
|
|
||||||
|
// Amount owed now: only meaningful for an open + unpaid session.
|
||||||
|
let amountMinor: number | null = null;
|
||||||
|
let currency: string | null = null;
|
||||||
|
if (open && a.paidAt == null) {
|
||||||
|
try {
|
||||||
|
const q = this.quote(identity);
|
||||||
|
amountMinor = q.amountMinor;
|
||||||
|
currency = q.currency;
|
||||||
|
} catch {
|
||||||
|
/* no active tariff — leave null */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
out.push({
|
||||||
|
identity,
|
||||||
|
source: a.source,
|
||||||
|
enteredAt: a.enteredAt,
|
||||||
|
exitedAt: a.exitedAt ?? null,
|
||||||
|
open,
|
||||||
|
paidAt: a.paidAt ?? null,
|
||||||
|
amountMinor,
|
||||||
|
currency,
|
||||||
|
withinGrace,
|
||||||
|
graceExpiresAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Newest entry first.
|
||||||
|
out.sort((x, y) => Date.parse(y.enteredAt) - Date.parse(x.enteredAt));
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
/** The vehicle_entry of an OPEN session for this identity (no later exit), or null. */
|
/** The vehicle_entry of an OPEN session for this identity (no later exit), or null. */
|
||||||
#openEntry(identity: string) {
|
#openEntry(identity: string) {
|
||||||
const rows = this.#db
|
const rows = this.#db
|
||||||
|
|||||||
@@ -16,6 +16,12 @@ interface LoginBody {
|
|||||||
password: string;
|
password: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const LANGS = ["sq", "en"] as const;
|
||||||
|
type Lang = (typeof LANGS)[number];
|
||||||
|
interface LanguageBody {
|
||||||
|
language: Lang;
|
||||||
|
}
|
||||||
|
|
||||||
export async function authRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
export async function authRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
||||||
app.post<{ Body: LoginBody }>("/api/auth/login", async (req, reply) => {
|
app.post<{ Body: LoginBody }>("/api/auth/login", async (req, reply) => {
|
||||||
const { username, password } = req.body ?? {};
|
const { username, password } = req.body ?? {};
|
||||||
@@ -41,7 +47,9 @@ export async function authRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
|||||||
csrf,
|
csrf,
|
||||||
});
|
});
|
||||||
setAuthCookies(reply, token, csrf);
|
setAuthCookies(reply, token, csrf);
|
||||||
return { id: user.id, username: user.username, role: user.role };
|
// `language` is NOT in the JWT (identity/role only) — it's a mutable preference
|
||||||
|
// read from the DB, so changing it needs no token refresh.
|
||||||
|
return { id: user.id, username: user.username, role: user.role, language: user.language };
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/api/auth/logout", async (_req, reply) => {
|
app.post("/api/auth/logout", async (_req, reply) => {
|
||||||
@@ -49,13 +57,30 @@ export async function authRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
|||||||
return { ok: true };
|
return { ok: true };
|
||||||
});
|
});
|
||||||
|
|
||||||
// Who am I — used by the SPA to bootstrap session state on load.
|
// Who am I — used by the SPA to bootstrap session state on load. Reads the live
|
||||||
|
// `language` preference from the DB (not the token).
|
||||||
app.get(
|
app.get(
|
||||||
"/api/auth/me",
|
"/api/auth/me",
|
||||||
{ preHandler: requireRole("admin", "operator", "cashier", "readonly") },
|
{ preHandler: requireRole("admin", "operator", "cashier", "readonly") },
|
||||||
async (req) => {
|
async (req) => {
|
||||||
const { sub, username, role } = req.user;
|
const { sub, username, role } = req.user;
|
||||||
return { id: sub, username, role };
|
const row = await db.select().from(users).where(eq(users.id, sub)).get();
|
||||||
|
return { id: sub, username, role, language: row?.language ?? "sq" };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Change MY own UI language preference (any signed-in user). Persisted to the
|
||||||
|
// users row so it's restored on the next login, from any booth. See i18n.md.
|
||||||
|
app.put<{ Body: LanguageBody }>(
|
||||||
|
"/api/auth/language",
|
||||||
|
{ preHandler: requireRole("admin", "operator", "cashier", "readonly") },
|
||||||
|
async (req, reply) => {
|
||||||
|
const language = req.body?.language;
|
||||||
|
if (!language || !LANGS.includes(language)) {
|
||||||
|
return reply.code(400).send({ error: `language must be one of: ${LANGS.join(", ")}` });
|
||||||
|
}
|
||||||
|
await db.update(users).set({ language }).where(eq(users.id, req.user.sub)).run();
|
||||||
|
return { language };
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,21 @@
|
|||||||
import type { FastifyInstance } from "fastify";
|
import type { FastifyInstance } from "fastify";
|
||||||
|
import type { Db } from "@parking/db";
|
||||||
|
import { NoPrinterAvailableError } from "@parking/devices";
|
||||||
import { requireRole } from "../auth.js";
|
import { requireRole } from "../auth.js";
|
||||||
import {
|
import {
|
||||||
NoOpenSessionError,
|
NoOpenSessionError,
|
||||||
NoTariffError,
|
NoTariffError,
|
||||||
type PayStation,
|
type PayStation,
|
||||||
} from "../pay-station.js";
|
} from "../pay-station.js";
|
||||||
|
import type { ExitFlow } from "../exit-flow.js";
|
||||||
|
import { printExitVoucher } from "../booth-print.js";
|
||||||
|
|
||||||
// Pay-station endpoints (pay-on-foot). The terminal/operator UI quotes a session
|
// Booth endpoints (pay-on-foot): look up a session, quote it, take payment, and —
|
||||||
// then takes payment; the payment becomes a signed ledger event. PCI scope stays
|
// when the booth is at/near the exit — open the barrier. The payment becomes a
|
||||||
// OUT of the app — actual card capture is a standalone P2PE terminal; here `tender`
|
// signed ledger event; PCI scope stays OUT of the app (card capture is a standalone
|
||||||
// just records cash vs. card. See wiki/concepts/tariff.md, parking-session.md, bom.md.
|
// P2PE terminal; `tender` just records cash vs. card). The booth exit reuses the
|
||||||
|
// SAME validation as the reader path — no booth-only bypass admits an unpaid car.
|
||||||
|
// See wiki/concepts/tariff.md, parking-session.md, booth-exit-flow.md, bom.md.
|
||||||
|
|
||||||
interface QuoteQuery {
|
interface QuoteQuery {
|
||||||
identity: string;
|
identity: string;
|
||||||
@@ -20,11 +26,76 @@ interface PayBody {
|
|||||||
/** Operator-set amount (lost ticket / dispute) — overrides the computed fee. */
|
/** Operator-set amount (lost ticket / dispute) — overrides the computed fee. */
|
||||||
overrideMinor?: number;
|
overrideMinor?: number;
|
||||||
}
|
}
|
||||||
|
interface ExitBody {
|
||||||
|
identity: string;
|
||||||
|
}
|
||||||
|
interface VoucherBody {
|
||||||
|
identity: string;
|
||||||
|
}
|
||||||
|
|
||||||
export async function payRoutes(app: FastifyInstance, payStation: PayStation): Promise<void> {
|
export async function payRoutes(
|
||||||
// Cashier/operator/admin operate the pay station; readonly may not.
|
app: FastifyInstance,
|
||||||
|
db: Db,
|
||||||
|
payStation: PayStation,
|
||||||
|
exitFlow: ExitFlow,
|
||||||
|
): Promise<void> {
|
||||||
|
// Cashier/operator/admin operate the booth; readonly may not.
|
||||||
const guard = requireRole("admin", "operator", "cashier");
|
const guard = requireRole("admin", "operator", "cashier");
|
||||||
|
|
||||||
|
// Active sessions for the booth list: still-open OR exited-but-within-grace
|
||||||
|
// (barrier unconfirmed → a paid/exited car is presumed possibly-present until
|
||||||
|
// grace expires). Read-only. See wiki/concepts/booth-exit-flow.md.
|
||||||
|
app.get("/api/sessions/active", { preHandler: guard }, async () => ({
|
||||||
|
sessions: payStation.activeSessions(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Session lookup for the booth pay/exit modal: entry/exit times, paid state,
|
||||||
|
// amount owed now, walk-back-grace status. Read-only (no side effect).
|
||||||
|
app.get<{ Params: { identity: string } }>(
|
||||||
|
"/api/session/:identity",
|
||||||
|
{ preHandler: guard },
|
||||||
|
async (req, reply) => {
|
||||||
|
const identity = (req.params.identity ?? "").trim();
|
||||||
|
if (!identity) return reply.code(400).send({ error: "identity required" });
|
||||||
|
return payStation.lookup(identity);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Booth-driven exit: validate (paid + grace, or free entry-grace) THEN sign
|
||||||
|
// vehicle_exit + open the barrier. Maps the discriminated result to HTTP:
|
||||||
|
// - validation reject → 409 with a reason (operator takes payment first),
|
||||||
|
// - exit signed but barrier didn't open → 200 { opened:false } (payment stands;
|
||||||
|
// operator opens manually; an anomaly is already signed),
|
||||||
|
// - clean exit → 200 { opened:true }.
|
||||||
|
app.post<{ Body: ExitBody }>(
|
||||||
|
"/api/exit",
|
||||||
|
{ preHandler: guard },
|
||||||
|
async (req, reply) => {
|
||||||
|
const identity = (req.body?.identity ?? "").trim();
|
||||||
|
if (!identity) return reply.code(400).send({ error: "identity required" });
|
||||||
|
const res = await exitFlow.exitForBooth(identity);
|
||||||
|
if (!res.ok) return reply.code(409).send({ error: res.reason, status: res.status });
|
||||||
|
return reply.code(200).send(res);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Human-intervention barrier re-open for an ACTIVE (paid) session — damaged
|
||||||
|
// ticket / dead scanner / phantom re-close. Re-pulses the exit relay + signs an
|
||||||
|
// anomaly (attributed); NEVER a second vehicle_exit. Refused without a payment
|
||||||
|
// (no-unpaid-bypass). See wiki/concepts/booth-exit-flow.md.
|
||||||
|
app.post<{ Body: ExitBody }>(
|
||||||
|
"/api/barrier/reopen",
|
||||||
|
{ preHandler: guard },
|
||||||
|
async (req, reply) => {
|
||||||
|
const identity = (req.body?.identity ?? "").trim();
|
||||||
|
if (!identity) return reply.code(400).send({ error: "identity required" });
|
||||||
|
const operator = req.user?.username;
|
||||||
|
const res = await exitFlow.reopenBarrier(identity, operator);
|
||||||
|
if (!res.ok) return reply.code(409).send({ error: res.reason });
|
||||||
|
return reply.code(200).send(res);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// Quote: what does this session owe right now? (No side effect.)
|
// Quote: what does this session owe right now? (No side effect.)
|
||||||
app.get<{ Querystring: QuoteQuery }>(
|
app.get<{ Querystring: QuoteQuery }>(
|
||||||
"/api/pay/quote",
|
"/api/pay/quote",
|
||||||
@@ -60,6 +131,35 @@ export async function payRoutes(app: FastifyInstance, payStation: PayStation): P
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Print an exit voucher (the paid ticket id reprinted as a barcode) on the booth
|
||||||
|
// printer. Used when the booth is far from the exit — the customer self-scans the
|
||||||
|
// voucher at the exit reader, which runs the normal validated exit. Requires the
|
||||||
|
// session to be PAID (no free vouchers for unpaid sessions). See booth-exit-flow.md.
|
||||||
|
app.post<{ Body: VoucherBody }>(
|
||||||
|
"/api/voucher",
|
||||||
|
{ preHandler: guard },
|
||||||
|
async (req, reply) => {
|
||||||
|
const identity = (req.body?.identity ?? "").trim();
|
||||||
|
if (!identity) return reply.code(400).send({ error: "identity required" });
|
||||||
|
const view = payStation.lookup(identity);
|
||||||
|
if (!view.found || !view.open) {
|
||||||
|
return reply.code(404).send({ error: "no open session for ticket" });
|
||||||
|
}
|
||||||
|
if (view.paidAt == null) {
|
||||||
|
return reply.code(409).send({ error: "session not paid — take payment before printing a voucher" });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const printedBy = await printExitVoucher(db, identity, app.log);
|
||||||
|
return reply.code(200).send({ ok: true, printedBy });
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof NoPrinterAvailableError) {
|
||||||
|
return reply.code(503).send({ error: err.message });
|
||||||
|
}
|
||||||
|
return reply.code(500).send({ error: (err as Error).message });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function mapError(reply: import("fastify").FastifyReply, err: unknown) {
|
function mapError(reply: import("fastify").FastifyReply, err: unknown) {
|
||||||
|
|||||||
@@ -1,11 +1,19 @@
|
|||||||
import type { FastifyInstance } from "fastify";
|
import type { FastifyInstance } from "fastify";
|
||||||
import { requireRole } from "../auth.js";
|
import { requireRole } from "../auth.js";
|
||||||
import {
|
import {
|
||||||
|
InvalidCashMovementError,
|
||||||
NoOpenShiftError,
|
NoOpenShiftError,
|
||||||
ShiftAlreadyOpenError,
|
ShiftAlreadyOpenError,
|
||||||
type ShiftService,
|
type ShiftService,
|
||||||
} from "../shift-service.js";
|
} from "../shift-service.js";
|
||||||
|
|
||||||
|
interface CashMovementBody {
|
||||||
|
/** Signed minor units: positive = load INTO drawer, negative = remove FROM drawer. */
|
||||||
|
amountMinor: number;
|
||||||
|
reason?: string;
|
||||||
|
currency?: string;
|
||||||
|
}
|
||||||
|
|
||||||
// Shift endpoints (manned mode). The operator is the logged-in user; a shift is
|
// Shift endpoints (manned mode). The operator is the logged-in user; a shift is
|
||||||
// opened/closed explicitly (not time-based — see wiki/concepts/shift.md and
|
// opened/closed explicitly (not time-based — see wiki/concepts/shift.md and
|
||||||
// local-jwt-auth.md "until logout"). End Shift signs a shift_z_report + prints it.
|
// local-jwt-auth.md "until logout"). End Shift signs a shift_z_report + prints it.
|
||||||
@@ -15,12 +23,36 @@ export async function shiftRoutes(app: FastifyInstance, shift: ShiftService): Pr
|
|||||||
const guard = requireRole("admin", "operator", "cashier");
|
const guard = requireRole("admin", "operator", "cashier");
|
||||||
|
|
||||||
// Is the current operator's shift open? (For the UI to show Start vs. End.)
|
// Is the current operator's shift open? (For the UI to show Start vs. End.)
|
||||||
|
// Also returns the live drawer balance so the UI can show what's in the till.
|
||||||
app.get("/api/shift/current", { preHandler: guard }, async (req) => {
|
app.get("/api/shift/current", { preHandler: guard }, async (req) => {
|
||||||
const operator = req.user.username;
|
const operator = req.user.username;
|
||||||
const open = shift.openShiftFor(operator);
|
const open = shift.openShiftFor(operator);
|
||||||
return { operator, open: open ? { startedAt: open.occurredAt } : null };
|
const drawer = shift.drawerBalance();
|
||||||
|
return {
|
||||||
|
operator,
|
||||||
|
open: open ? { startedAt: open.occurredAt } : null,
|
||||||
|
drawerMinor: drawer.balanceMinor,
|
||||||
|
currency: drawer.currency,
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Admin loads/removes physical drawer cash (the float). Signed cash_movement
|
||||||
|
// event. ADMIN ONLY — an operator takes payments but cannot move the float.
|
||||||
|
// amountMinor is signed: + load IN, − remove OUT. See wiki/concepts/shift.md.
|
||||||
|
app.post<{ Body: CashMovementBody }>(
|
||||||
|
"/api/cash-movement",
|
||||||
|
{ preHandler: requireRole("admin") },
|
||||||
|
async (req, reply) => {
|
||||||
|
const { amountMinor, reason, currency } = req.body ?? ({} as CashMovementBody);
|
||||||
|
try {
|
||||||
|
return await shift.recordCashMovement(req.user.username, amountMinor, reason ?? "", currency);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof InvalidCashMovementError) return reply.code(400).send({ error: err.message });
|
||||||
|
return reply.code(500).send({ error: (err as Error).message });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
app.post("/api/shift/open", { preHandler: guard }, async (req, reply) => {
|
app.post("/api/shift/open", { preHandler: guard }, async (req, reply) => {
|
||||||
try {
|
try {
|
||||||
return await shift.open(req.user.username);
|
return await shift.open(req.user.username);
|
||||||
|
|||||||
@@ -21,13 +21,21 @@ type TextField = (typeof TEXT_FIELDS)[number];
|
|||||||
interface SiteConfigBody extends Partial<Record<TextField, string | null>> {
|
interface SiteConfigBody extends Partial<Record<TextField, string | null>> {
|
||||||
/** Nominal capacity; null = no limit. */
|
/** Nominal capacity; null = no limit. */
|
||||||
capacity?: number | null;
|
capacity?: number | null;
|
||||||
|
/** Default for the booth "print exit ticket" checkbox (booth-geography knob). */
|
||||||
|
exitVoucherDefault?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Shape returned by GET/PUT: capacity + every metadata field (null when unset). */
|
/** Shape returned by GET/PUT: capacity + the booth flag + every metadata field. */
|
||||||
type SiteConfig = { capacity: number | null } & Record<TextField, string | null>;
|
type SiteConfig = { capacity: number | null; exitVoucherDefault: boolean } & Record<
|
||||||
|
TextField,
|
||||||
|
string | null
|
||||||
|
>;
|
||||||
|
|
||||||
function toSiteConfig(row: typeof siteConfig.$inferSelect | undefined): SiteConfig {
|
function toSiteConfig(row: typeof siteConfig.$inferSelect | undefined): SiteConfig {
|
||||||
const out = { capacity: row?.capacity ?? null } as SiteConfig;
|
const out = {
|
||||||
|
capacity: row?.capacity ?? null,
|
||||||
|
exitVoucherDefault: row?.exitVoucherDefault ?? false,
|
||||||
|
} as SiteConfig;
|
||||||
for (const f of TEXT_FIELDS) out[f] = row?.[f] ?? null;
|
for (const f of TEXT_FIELDS) out[f] = row?.[f] ?? null;
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
@@ -65,6 +73,12 @@ export async function siteRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
|||||||
}
|
}
|
||||||
patch.capacity = c ?? null;
|
patch.capacity = c ?? null;
|
||||||
}
|
}
|
||||||
|
if ("exitVoucherDefault" in body) {
|
||||||
|
if (typeof body.exitVoucherDefault !== "boolean") {
|
||||||
|
return reply.code(400).send({ error: "exitVoucherDefault must be a boolean" });
|
||||||
|
}
|
||||||
|
patch.exitVoucherDefault = body.exitVoucherDefault;
|
||||||
|
}
|
||||||
for (const f of TEXT_FIELDS) {
|
for (const f of TEXT_FIELDS) {
|
||||||
if (f in body) patch[f] = normText(body[f]);
|
if (f in body) patch[f] = normText(body[f]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import type { FastifyInstance } from "fastify";
|
||||||
|
import type { Db } from "@parking/db";
|
||||||
|
import type { Role } from "@parking/shared";
|
||||||
|
import { deviceEvents } from "../device-events.js";
|
||||||
|
import { getOccupancy } from "../occupancy.js";
|
||||||
|
|
||||||
|
// Live booth feed over a WebSocket. The booth UI opens ONE socket and receives
|
||||||
|
// server-pushed updates instead of polling: each signed ledger append (entry,
|
||||||
|
// exit, payment, void) is fanned out, and the recomputed occupancy rides along
|
||||||
|
// so the screen's count stays exact (occupancy is a fold over the same ledger,
|
||||||
|
// never a counter). Printer-status changes are forwarded too.
|
||||||
|
//
|
||||||
|
// Auth: the handshake is a normal GET through Fastify's lifecycle, so the same
|
||||||
|
// HttpOnly JWT cookie that guards the REST API guards this. We verify the JWT and
|
||||||
|
// role here. A browser's WebSocket constructor cannot set custom headers, so the
|
||||||
|
// CSRF double-submit header the REST mutations use is unavailable — which would
|
||||||
|
// leave the socket open to Cross-Site WebSocket Hijacking: a malicious page in the
|
||||||
|
// operator's browser could open ws://<booth>/api/ws, the browser would auto-attach
|
||||||
|
// the HttpOnly cookie, and the attacker would receive the live entry/exit/payment
|
||||||
|
// stream. The cookie alone is NOT a control here. So we replace the CSRF check with
|
||||||
|
// an Origin allowlist: the handshake's Origin must be same-origin (or an explicitly
|
||||||
|
// allowed booth UI origin). Non-browser clients (no Origin) are rejected too.
|
||||||
|
// See auth.ts, event-log.ts (emitLedger), capacity-occupancy.md.
|
||||||
|
|
||||||
|
/** Roles allowed to watch the live feed (everyone signed in; readonly included —
|
||||||
|
* it's a read-only stream). */
|
||||||
|
const WATCH_ROLES: Role[] = ["admin", "operator", "cashier", "readonly"];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Is the handshake's Origin trusted? Same-origin (Origin host === Host header) is
|
||||||
|
* always allowed; additional origins can be allowlisted via WS_ALLOWED_ORIGINS
|
||||||
|
* (comma-separated) for a booth UI served from a different origin. A missing or
|
||||||
|
* mismatched Origin is rejected — that is the anti-CSWSH control.
|
||||||
|
*/
|
||||||
|
function isAllowedOrigin(origin: string | undefined, host: string | undefined): boolean {
|
||||||
|
if (!origin) return false; // no Origin → not a same-origin browser request
|
||||||
|
let originHost: string;
|
||||||
|
try {
|
||||||
|
originHost = new URL(origin).host;
|
||||||
|
} catch {
|
||||||
|
return false; // malformed Origin
|
||||||
|
}
|
||||||
|
if (host && originHost === host) return true; // same-origin (any scheme/port match via host)
|
||||||
|
const allow = (process.env.WS_ALLOWED_ORIGINS ?? "")
|
||||||
|
.split(",")
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
return allow.includes(origin);
|
||||||
|
}
|
||||||
|
|
||||||
|
type OutMsg =
|
||||||
|
| { kind: "hello"; occupancy: ReturnType<typeof getOccupancy> }
|
||||||
|
| { kind: "ledger"; event: unknown; occupancy: ReturnType<typeof getOccupancy> }
|
||||||
|
| { kind: "printer-status"; event: unknown };
|
||||||
|
|
||||||
|
export async function wsRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
||||||
|
app.get(
|
||||||
|
"/api/ws",
|
||||||
|
{
|
||||||
|
websocket: true,
|
||||||
|
// Origin allowlist (anti-CSWSH, replaces CSRF — see file header) THEN JWT +
|
||||||
|
// role. Reject a cross/absent origin before touching the token, so a hijack
|
||||||
|
// attempt never reaches an authenticated socket. jwtVerify reads the cookie.
|
||||||
|
preHandler: async (req) => {
|
||||||
|
if (!isAllowedOrigin(req.headers.origin, req.headers.host)) {
|
||||||
|
throw Object.assign(new Error("forbidden origin"), { statusCode: 403 });
|
||||||
|
}
|
||||||
|
await req.jwtVerify();
|
||||||
|
if (!req.user || !WATCH_ROLES.includes(req.user.role)) {
|
||||||
|
throw Object.assign(new Error("forbidden"), { statusCode: 403 });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
(socket) => {
|
||||||
|
const send = (msg: OutMsg) => {
|
||||||
|
// readyState 1 = OPEN; never throw out of an event-bus callback.
|
||||||
|
if (socket.readyState === 1) {
|
||||||
|
try {
|
||||||
|
socket.send(JSON.stringify(msg));
|
||||||
|
} catch {
|
||||||
|
/* drop on a broken socket */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initial snapshot so the client renders immediately, before any event.
|
||||||
|
send({ kind: "hello", occupancy: getOccupancy(db) });
|
||||||
|
|
||||||
|
// Subscribe to the live buses. Each handler recomputes occupancy from the
|
||||||
|
// ledger (cheap fold) so the pushed count is always authoritative.
|
||||||
|
const offLedger = deviceEvents.onLedger((event) => {
|
||||||
|
send({ kind: "ledger", event, occupancy: getOccupancy(db) });
|
||||||
|
});
|
||||||
|
const offPrinter = deviceEvents.onPrinterStatus((event) => {
|
||||||
|
send({ kind: "printer-status", event });
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on("close", () => {
|
||||||
|
offLedger();
|
||||||
|
offPrinter();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import cookie from "@fastify/cookie";
|
import cookie from "@fastify/cookie";
|
||||||
import jwt from "@fastify/jwt";
|
import jwt from "@fastify/jwt";
|
||||||
|
import websocket from "@fastify/websocket";
|
||||||
import Fastify, { type FastifyInstance } from "fastify";
|
import Fastify, { type FastifyInstance } from "fastify";
|
||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import { createDb, deviceEvents as deviceEventsTable, type Db } from "@parking/db";
|
import { createDb, deviceEvents as deviceEventsTable, type Db } from "@parking/db";
|
||||||
@@ -26,6 +27,7 @@ import { snapshotRoutes } from "./routes/snapshots.js";
|
|||||||
import { tariffRoutes } from "./routes/tariffs.js";
|
import { tariffRoutes } from "./routes/tariffs.js";
|
||||||
import { printerRoutes } from "./routes/printers.js";
|
import { printerRoutes } from "./routes/printers.js";
|
||||||
import { setupRoutes } from "./routes/setup.js";
|
import { setupRoutes } from "./routes/setup.js";
|
||||||
|
import { wsRoutes } from "./routes/ws.js";
|
||||||
|
|
||||||
// The backend is Fastify (Node). Hardware drivers live as isolated Fastify
|
// The backend is Fastify (Node). Hardware drivers live as isolated Fastify
|
||||||
// plugins emitting onto a shared internal event bus; auth is fully local
|
// plugins emitting onto a shared internal event bus; auth is fully local
|
||||||
@@ -44,6 +46,10 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
|
|||||||
|
|
||||||
await app.register(cookie);
|
await app.register(cookie);
|
||||||
|
|
||||||
|
// WebSocket support for the live booth feed (/api/ws). Registered before the
|
||||||
|
// routes so the `{ websocket: true }` route option is available.
|
||||||
|
await app.register(websocket);
|
||||||
|
|
||||||
// Local JWT signing with a local secret — no external identity provider.
|
// Local JWT signing with a local secret — no external identity provider.
|
||||||
// Fail fast rather than fall back to a known default: a booth machine started
|
// Fail fast rather than fall back to a known default: a booth machine started
|
||||||
// without a real secret would sign tokens anyone could forge (incl. an admin
|
// without a real secret would sign tokens anyone could forge (incl. an admin
|
||||||
@@ -86,9 +92,17 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
|
|||||||
// in device_events. The entry flow (TODO) turns an input into a signed
|
// in device_events. The entry flow (TODO) turns an input into a signed
|
||||||
// vehicle_entry once a ticket prints + the barrier is commanded.
|
// vehicle_entry once a ticket prints + the barrier is commanded.
|
||||||
// See wiki/decisions/event-streams-split.md.
|
// See wiki/decisions/event-streams-split.md.
|
||||||
const eventLog = new EventLog(db, buildSigner(app.log), buildVerifier);
|
// The 4th arg is a read-side fan-out fired AFTER each durable append — used to
|
||||||
|
// push the event to live booth clients (WS). It cannot affect the sign/chain path.
|
||||||
|
const eventLog = new EventLog(db, buildSigner(app.log), buildVerifier, (row) =>
|
||||||
|
deviceEvents.emitLedger(row),
|
||||||
|
);
|
||||||
await eventRoutes(app, db, eventLog);
|
await eventRoutes(app, db, eventLog);
|
||||||
|
|
||||||
|
// Live booth feed: server-pushed ledger + occupancy + printer-status over a
|
||||||
|
// single authenticated WebSocket (/api/ws). See routes/ws.ts.
|
||||||
|
await wsRoutes(app, db);
|
||||||
|
|
||||||
// Entry/exit camera snapshots (BLOB-in-DB), read-only. See snapshot.ts.
|
// Entry/exit camera snapshots (BLOB-in-DB), read-only. See snapshot.ts.
|
||||||
await snapshotRoutes(app, db);
|
await snapshotRoutes(app, db);
|
||||||
|
|
||||||
@@ -121,7 +135,7 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
|
|||||||
// Pay station (pay-on-foot): quote an open session against the active tariff +
|
// Pay station (pay-on-foot): quote an open session against the active tariff +
|
||||||
// take payment → signed `payment` event. See wiki/concepts/tariff.md.
|
// take payment → signed `payment` event. See wiki/concepts/tariff.md.
|
||||||
const payStation = new PayStation(db, eventLog, app.log);
|
const payStation = new PayStation(db, eventLog, app.log);
|
||||||
await payRoutes(app, payStation);
|
await payRoutes(app, db, payStation, exitFlow);
|
||||||
|
|
||||||
// Tariff composer: admin publishes effective-dated, immutable rate-card versions
|
// Tariff composer: admin publishes effective-dated, immutable rate-card versions
|
||||||
// the pay station prices against. See wiki/concepts/tariff.md.
|
// the pay station prices against. See wiki/concepts/tariff.md.
|
||||||
|
|||||||
@@ -31,9 +31,25 @@ export interface ShiftReport {
|
|||||||
readonly cardTotalMinor: number;
|
readonly cardTotalMinor: number;
|
||||||
readonly currency: string | null;
|
readonly currency: string | null;
|
||||||
readonly paymentCount: number;
|
readonly paymentCount: number;
|
||||||
|
// --- Drawer (physical cash till; carries across shifts) ---
|
||||||
|
/** Cash in the drawer at shift start = prior shift's expected closing drawer. */
|
||||||
|
readonly openingFloatMinor: number;
|
||||||
|
/** Admin cash LOADED into the drawer during the shift (sum of + movements). */
|
||||||
|
readonly cashAddedMinor: number;
|
||||||
|
/** Admin cash REMOVED from the drawer during the shift (sum of − movements, as +). */
|
||||||
|
readonly cashRemovedMinor: number;
|
||||||
|
/** Expected drawer at close = opening + cashTaken + added − removed. Carries forward. */
|
||||||
|
readonly expectedDrawerMinor: number;
|
||||||
readonly printed: boolean;
|
readonly printed: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class InvalidCashMovementError extends Error {
|
||||||
|
constructor(msg: string) {
|
||||||
|
super(msg);
|
||||||
|
this.name = "InvalidCashMovementError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export class ShiftService {
|
export class ShiftService {
|
||||||
readonly #db: Db;
|
readonly #db: Db;
|
||||||
readonly #log: EventLog;
|
readonly #log: EventLog;
|
||||||
@@ -45,6 +61,12 @@ export class ShiftService {
|
|||||||
this.#logger = logger;
|
this.#logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Current physical drawer balance (cash payments + cash_movements, by time). For
|
||||||
|
* the UI to show "inherited / in the drawer now". */
|
||||||
|
drawerBalance(): { balanceMinor: number; currency: string | null } {
|
||||||
|
return this.#drawerBalanceAt(new Date().toISOString());
|
||||||
|
}
|
||||||
|
|
||||||
/** Is there an open shift for this operator? Returns the open `shift_open` row or null. */
|
/** Is there an open shift for this operator? Returns the open `shift_open` row or null. */
|
||||||
openShiftFor(operator: string) {
|
openShiftFor(operator: string) {
|
||||||
// Scan shift events for this operator; the shift is open if the most recent
|
// Scan shift events for this operator; the shift is open if the most recent
|
||||||
@@ -60,19 +82,87 @@ export class ShiftService {
|
|||||||
return last && last.type === "shift_open" ? last : null;
|
return last && last.type === "shift_open" ? last : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Open a shift for the operator (explicit start). */
|
/**
|
||||||
async open(operator: string): Promise<{ startedAt: string }> {
|
* The physical drawer balance at `at`: a fold over the SIGNED chain BY TIME (not
|
||||||
|
* by operator — a cash_movement is the admin's, not the shift operator's). Cash
|
||||||
|
* payments add to the drawer; card payments never touch it; cash_movement amounts
|
||||||
|
* (signed: + load, − removal) adjust it. This is what carries across shifts.
|
||||||
|
*/
|
||||||
|
#drawerBalanceAt(at: string): { balanceMinor: number; currency: string | null } {
|
||||||
|
const rows = this.#db
|
||||||
|
.select()
|
||||||
|
.from(ledgerEvents)
|
||||||
|
.orderBy(ledgerEvents.index)
|
||||||
|
.all()
|
||||||
|
.filter((r) => r.occurredAt <= at && (r.type === "payment" || r.type === "cash_movement"));
|
||||||
|
let balanceMinor = 0;
|
||||||
|
let currency: string | null = null;
|
||||||
|
for (const r of rows) {
|
||||||
|
const pl = (r.payload ?? {}) as LedgerPayload;
|
||||||
|
const amt = typeof pl.amountMinor === "number" ? pl.amountMinor : 0;
|
||||||
|
if (r.type === "payment") {
|
||||||
|
// Only CASH enters the till; card settles to the bank.
|
||||||
|
if (pl.tender !== "card") balanceMinor += amt;
|
||||||
|
} else {
|
||||||
|
// cash_movement amount is signed (+ load, − removal).
|
||||||
|
balanceMinor += amt;
|
||||||
|
}
|
||||||
|
if (pl.currency) currency = pl.currency;
|
||||||
|
}
|
||||||
|
return { balanceMinor, currency };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record an admin cash movement (load/remove drawer float). `amountMinor` is
|
||||||
|
* signed: positive = cash loaded IN, negative = cash taken OUT. Signed +
|
||||||
|
* attributed. Admin-only is enforced at the route. Returns the new drawer balance.
|
||||||
|
*/
|
||||||
|
async recordCashMovement(
|
||||||
|
operator: string,
|
||||||
|
amountMinor: number,
|
||||||
|
reason: string,
|
||||||
|
currency?: string,
|
||||||
|
): Promise<{ amountMinor: number; balanceMinor: number }> {
|
||||||
|
if (!Number.isInteger(amountMinor) || amountMinor === 0) {
|
||||||
|
throw new InvalidCashMovementError("amountMinor must be a non-zero integer (minor units)");
|
||||||
|
}
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
await this.#log.append({
|
||||||
|
type: "cash_movement",
|
||||||
|
source: "manual",
|
||||||
|
identity: operator, // who moved the cash (admin)
|
||||||
|
payload: {
|
||||||
|
amountMinor,
|
||||||
|
...(reason ? { reason } : {}),
|
||||||
|
...(currency ? { currency } : {}),
|
||||||
|
operator,
|
||||||
|
},
|
||||||
|
occurredAt: now,
|
||||||
|
});
|
||||||
|
const { balanceMinor } = this.#drawerBalanceAt(now);
|
||||||
|
this.#logger.info(
|
||||||
|
`cash_movement ${amountMinor >= 0 ? "+" : ""}${amountMinor} by ${operator} (${reason || "no reason"}) → drawer ${balanceMinor}`,
|
||||||
|
);
|
||||||
|
return { amountMinor, balanceMinor };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Open a shift for the operator (explicit start). The opening float is auto-
|
||||||
|
* inherited from the chain = the drawer balance at the start instant. */
|
||||||
|
async open(operator: string): Promise<{ startedAt: string; openingFloatMinor: number }> {
|
||||||
if (this.openShiftFor(operator)) throw new ShiftAlreadyOpenError(operator);
|
if (this.openShiftFor(operator)) throw new ShiftAlreadyOpenError(operator);
|
||||||
const startedAt = new Date().toISOString();
|
const startedAt = new Date().toISOString();
|
||||||
|
const { balanceMinor: openingFloatMinor } = this.#drawerBalanceAt(startedAt);
|
||||||
await this.#log.append({
|
await this.#log.append({
|
||||||
type: "shift_open",
|
type: "shift_open",
|
||||||
source: "manual",
|
source: "manual",
|
||||||
identity: operator, // the shift's operator; `identity` keys the shift to them
|
identity: operator, // the shift's operator; `identity` keys the shift to them
|
||||||
payload: { operator },
|
// Record the inherited opening float on the shift_open so it's reproducible
|
||||||
|
// and the next operator's handover figure is fixed in the chain.
|
||||||
|
payload: { operator, openingFloatMinor },
|
||||||
occurredAt: startedAt,
|
occurredAt: startedAt,
|
||||||
});
|
});
|
||||||
this.#logger.info(`shift opened for ${operator}`);
|
this.#logger.info(`shift opened for ${operator} (opening float ${openingFloatMinor})`);
|
||||||
return { startedAt };
|
return { startedAt, openingFloatMinor };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Close the operator's open shift: sum payments in the window, sign + print the Z-report. */
|
/** Close the operator's open shift: sum payments in the window, sign + print the Z-report. */
|
||||||
@@ -102,6 +192,50 @@ export class ShiftService {
|
|||||||
if (pl.currency) currency = pl.currency;
|
if (pl.currency) currency = pl.currency;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Drawer figures ---
|
||||||
|
// Opening float was fixed on shift_open (inherited from the chain at start);
|
||||||
|
// fall back to a fresh fold if an older shift_open lacks it.
|
||||||
|
const openPl = (open.payload ?? {}) as LedgerPayload & { openingFloatMinor?: number };
|
||||||
|
const openingFloatMinor =
|
||||||
|
typeof openPl.openingFloatMinor === "number"
|
||||||
|
? openPl.openingFloatMinor
|
||||||
|
: this.#drawerBalanceAt(startedAt).balanceMinor;
|
||||||
|
|
||||||
|
// Cash movements within the shift window, split into added (+) and removed (−).
|
||||||
|
const movements = this.#db
|
||||||
|
.select()
|
||||||
|
.from(ledgerEvents)
|
||||||
|
.where(eq(ledgerEvents.type, "cash_movement"))
|
||||||
|
.all()
|
||||||
|
.filter((r) => r.occurredAt >= startedAt && r.occurredAt <= endedAt);
|
||||||
|
let cashAddedMinor = 0;
|
||||||
|
let cashRemovedMinor = 0;
|
||||||
|
for (const m of movements) {
|
||||||
|
const pl = (m.payload ?? {}) as LedgerPayload;
|
||||||
|
const amt = typeof pl.amountMinor === "number" ? pl.amountMinor : 0;
|
||||||
|
if (amt >= 0) cashAddedMinor += amt;
|
||||||
|
else cashRemovedMinor += -amt; // store as a positive magnitude
|
||||||
|
if (pl.currency) currency = pl.currency;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expected drawer at close = opening + cash taken + added − removed. This is the
|
||||||
|
// figure the NEXT shift inherits as its opening float.
|
||||||
|
const expectedDrawerMinor = openingFloatMinor + cashTotalMinor + cashAddedMinor - cashRemovedMinor;
|
||||||
|
|
||||||
|
const report: Omit<ShiftReport, "printed"> = {
|
||||||
|
operator,
|
||||||
|
startedAt,
|
||||||
|
endedAt,
|
||||||
|
cashTotalMinor,
|
||||||
|
cardTotalMinor,
|
||||||
|
currency,
|
||||||
|
paymentCount: payments.length,
|
||||||
|
openingFloatMinor,
|
||||||
|
cashAddedMinor,
|
||||||
|
cashRemovedMinor,
|
||||||
|
expectedDrawerMinor,
|
||||||
|
};
|
||||||
|
|
||||||
await this.#log.append({
|
await this.#log.append({
|
||||||
type: "shift_z_report",
|
type: "shift_z_report",
|
||||||
source: "manual",
|
source: "manual",
|
||||||
@@ -114,23 +248,20 @@ export class ShiftService {
|
|||||||
cardTotalMinor,
|
cardTotalMinor,
|
||||||
currency: currency ?? undefined,
|
currency: currency ?? undefined,
|
||||||
paymentCount: payments.length,
|
paymentCount: payments.length,
|
||||||
|
openingFloatMinor,
|
||||||
|
cashAddedMinor,
|
||||||
|
cashRemovedMinor,
|
||||||
|
expectedDrawerMinor,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const printed = await this.#printZReport({
|
const printed = await this.#printZReport(report);
|
||||||
operator,
|
|
||||||
startedAt,
|
|
||||||
endedAt,
|
|
||||||
cashTotalMinor,
|
|
||||||
cardTotalMinor,
|
|
||||||
currency,
|
|
||||||
paymentCount: payments.length,
|
|
||||||
});
|
|
||||||
|
|
||||||
this.#logger.info(
|
this.#logger.info(
|
||||||
`shift closed for ${operator}: cash ${cashTotalMinor} card ${cardTotalMinor} (${payments.length} payments)`,
|
`shift closed for ${operator}: cash ${cashTotalMinor} card ${cardTotalMinor} (${payments.length} payments); ` +
|
||||||
|
`drawer open ${openingFloatMinor} +${cashAddedMinor} −${cashRemovedMinor} → expected ${expectedDrawerMinor}`,
|
||||||
);
|
);
|
||||||
return { operator, startedAt, endedAt, cashTotalMinor, cardTotalMinor, currency, paymentCount: payments.length, printed };
|
return { ...report, printed };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Print the Z-report on a booth-receipt printer (best-effort; the signed event
|
/** Print the Z-report on a booth-receipt printer (best-effort; the signed event
|
||||||
@@ -151,6 +282,13 @@ export class ShiftService {
|
|||||||
`Payments: ${r.paymentCount}`,
|
`Payments: ${r.paymentCount}`,
|
||||||
`Cash: ${money(r.cashTotalMinor)} ${cur}`,
|
`Cash: ${money(r.cashTotalMinor)} ${cur}`,
|
||||||
`Card: ${money(r.cardTotalMinor)} ${cur}`,
|
`Card: ${money(r.cardTotalMinor)} ${cur}`,
|
||||||
|
"",
|
||||||
|
"-- Drawer --",
|
||||||
|
`Opening float: ${money(r.openingFloatMinor)} ${cur}`,
|
||||||
|
`Cash taken: ${money(r.cashTotalMinor)} ${cur}`,
|
||||||
|
`Cash added: ${money(r.cashAddedMinor)} ${cur}`,
|
||||||
|
`Cash removed: ${money(r.cashRemovedMinor)} ${cur}`,
|
||||||
|
`Expected drawer: ${money(r.expectedDrawerMinor)} ${cur}`,
|
||||||
];
|
];
|
||||||
try {
|
try {
|
||||||
await printer.printReport({ title: "SHIFT Z-REPORT", lines });
|
await printer.printReport({ title: "SHIFT Z-REPORT", lines });
|
||||||
|
|||||||
+12
-1
@@ -12,13 +12,24 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@parking/shared": "workspace:*",
|
"@parking/shared": "workspace:*",
|
||||||
|
"@radix-ui/react-dialog": "^1.1.17",
|
||||||
|
"@radix-ui/react-dropdown-menu": "^2.1.18",
|
||||||
|
"@radix-ui/react-tabs": "^1.1.15",
|
||||||
|
"@tanstack/react-query": "^5.101.0",
|
||||||
|
"@tanstack/react-router": "^1.170.16",
|
||||||
|
"i18next": "^26.3.1",
|
||||||
"react": "19.2.7",
|
"react": "19.2.7",
|
||||||
"react-dom": "19.2.7"
|
"react-dom": "19.2.7",
|
||||||
|
"react-i18next": "^17.0.8",
|
||||||
|
"zustand": "^5.0.14"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@tailwindcss/vite": "^4.3.1",
|
||||||
|
"@tanstack/react-router-devtools": "^1.167.0",
|
||||||
"@types/react": "19.2.17",
|
"@types/react": "19.2.17",
|
||||||
"@types/react-dom": "19.2.3",
|
"@types/react-dom": "19.2.3",
|
||||||
"@vitejs/plugin-react": "6.0.2",
|
"@vitejs/plugin-react": "6.0.2",
|
||||||
|
"tailwindcss": "^4.3.1",
|
||||||
"typescript": "6.0.3",
|
"typescript": "6.0.3",
|
||||||
"vite": "8.0.16"
|
"vite": "8.0.16"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { fetchActiveSessions, reopenBarrier, type ActiveSession } from "./api.js";
|
||||||
|
import { qk } from "./lib/query.js";
|
||||||
|
import { formatDuration, formatTime } from "./lib/format.js";
|
||||||
|
import { Panel } from "./ui/Panel.js";
|
||||||
|
|
||||||
|
// Active Sessions panel. A session is "active" while still inside OR exited-but-
|
||||||
|
// within-grace (the barrier is UNCONFIRMED, so a paid/exited car is presumed
|
||||||
|
// possibly-present until grace runs out). Lets the operator find a stuck car —
|
||||||
|
// damaged ticket, dead scanner, or a phantom barrier re-close — without a scan:
|
||||||
|
// - click a row → the pay/exit modal (pay an unpaid car, or review),
|
||||||
|
// - "Open barrier" (PAID sessions only) → an audited human-intervention re-pulse.
|
||||||
|
// No payment → no Open barrier button (the no-unpaid-bypass rule).
|
||||||
|
// See wiki/concepts/booth-exit-flow.md.
|
||||||
|
|
||||||
|
function statusBadge(s: ActiveSession): { key: string; cls: string } {
|
||||||
|
if (!s.open && s.withinGrace) return { key: "booth.badgeExiting", cls: "text-term-cyan" };
|
||||||
|
if (s.paidAt) return { key: "booth.badgePaid", cls: "text-term-green" };
|
||||||
|
return { key: "booth.badgeUnpaid", cls: "text-term-amber" };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ActiveSessions({ onPick }: { onPick: (identity: string) => void }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: qk.activeSessions,
|
||||||
|
queryFn: fetchActiveSessions,
|
||||||
|
// Belt-and-braces refresh in case a grace window expires with no ledger event
|
||||||
|
// to invalidate the cache (the WS only pushes on appends).
|
||||||
|
refetchInterval: 15_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const reopen = useMutation({
|
||||||
|
mutationFn: (identity: string) => reopenBarrier(identity),
|
||||||
|
onSettled: () => {
|
||||||
|
void qc.invalidateQueries({ queryKey: qk.activeSessions });
|
||||||
|
void qc.invalidateQueries({ queryKey: qk.events });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const [reopenMsg, setReopenMsg] = useState<{ id: string; text: string; ok: boolean } | null>(null);
|
||||||
|
|
||||||
|
const sessions = data?.sessions ?? [];
|
||||||
|
|
||||||
|
async function handleReopen(s: ActiveSession) {
|
||||||
|
setReopenMsg(null);
|
||||||
|
try {
|
||||||
|
const r = await reopen.mutateAsync(s.identity);
|
||||||
|
setReopenMsg({
|
||||||
|
id: s.identity,
|
||||||
|
ok: r.opened,
|
||||||
|
text: r.opened ? t("booth.barrierOpened") : r.reason ?? t("booth.openManually"),
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
setReopenMsg({ id: s.identity, ok: false, text: (e as Error).message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Panel
|
||||||
|
title={t("booth.activeSessions")}
|
||||||
|
right={
|
||||||
|
<span className="text-[10px] uppercase tracking-wider text-term-muted">
|
||||||
|
{sessions.length} {t("booth.insideCount")}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
className="min-h-0"
|
||||||
|
>
|
||||||
|
<div className="h-full overflow-y-auto pr-1">
|
||||||
|
{sessions.length === 0 ? (
|
||||||
|
<div className="text-term-muted">{isLoading ? t("common.loading") : t("booth.noActiveSessions")}</div>
|
||||||
|
) : (
|
||||||
|
sessions.map((s) => {
|
||||||
|
const badge = statusBadge(s);
|
||||||
|
const msg = reopenMsg?.id === s.identity ? reopenMsg : null;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={s.identity}
|
||||||
|
className="flex items-center gap-3 border-b border-term-border/50 py-1.5 text-[12px] tabular-nums"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onPick(s.identity)}
|
||||||
|
className="flex flex-1 items-center gap-3 text-left hover:text-term-amber"
|
||||||
|
title={t("booth.openPayExit")}
|
||||||
|
>
|
||||||
|
<span className="text-term-text">{s.identity}</span>
|
||||||
|
<span className="text-term-muted">
|
||||||
|
{t("booth.inAt")} {formatTime(s.enteredAt)}
|
||||||
|
</span>
|
||||||
|
<span className="text-term-muted">{formatDuration(s.enteredAt, new Date().toISOString())}</span>
|
||||||
|
<span className={`ml-auto w-16 text-right font-semibold uppercase ${badge.cls}`}>{t(badge.key)}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Open barrier — PAID sessions only (no payment, no button). */}
|
||||||
|
{s.paidAt ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={reopen.isPending}
|
||||||
|
onClick={() => handleReopen(s)}
|
||||||
|
className="shrink-0 rounded-term border border-term-cyan px-2 py-0.5 text-[10px] uppercase tracking-wider text-term-cyan hover:bg-term-cyan/10 disabled:opacity-50"
|
||||||
|
title={t("booth.openBarrierTitle")}
|
||||||
|
>
|
||||||
|
{t("booth.openBarrier")}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span className="w-[88px] shrink-0" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{msg && (
|
||||||
|
<span className={`shrink-0 text-[10px] ${msg.ok ? "text-term-green" : "text-term-red"}`}>
|
||||||
|
{msg.text}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
);
|
||||||
|
}
|
||||||
+29
-39
@@ -1,15 +1,16 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { fetchMe, logout, type SessionUser } from "./api.js";
|
import { QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { RouterProvider } from "@tanstack/react-router";
|
||||||
|
import { fetchMe, type SessionUser } from "./api.js";
|
||||||
import { Login } from "./Login.js";
|
import { Login } from "./Login.js";
|
||||||
import { PermitManager } from "./PermitManager.js";
|
import { queryClient } from "./lib/query.js";
|
||||||
import { SetupWizard } from "./SetupWizard.js";
|
import { setLanguage } from "./lib/i18n/index.js";
|
||||||
import { ShiftControl } from "./ShiftControl.js";
|
import { router } from "./router.js";
|
||||||
import { SiteSettings } from "./SiteSettings.js";
|
|
||||||
import { TariffComposer } from "./TariffComposer.js";
|
|
||||||
|
|
||||||
// Operator UI shell. Plain React (no admin framework) — the operator UI is
|
// App root: bootstraps the session (cookie-based, from /api/auth/me), then hands
|
||||||
// simple enough that a framework's abstractions cost more than they save.
|
// off to TanStack Router inside the QueryClient provider. The router renders the
|
||||||
// Auth is cookie-based; the SPA bootstraps the session from /api/auth/me.
|
// terminal chrome + screens; auth gating stays here (Login until signed in), and
|
||||||
|
// the signed-in user flows into the router context for role-based route guards.
|
||||||
// See wiki/entities/react-vite-spa.md and local-jwt-auth.md.
|
// See wiki/entities/react-vite-spa.md and local-jwt-auth.md.
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
@@ -22,37 +23,26 @@ export function App() {
|
|||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
if (loading) return <p style={{ fontFamily: "system-ui", padding: "2rem" }}>Loading…</p>;
|
// Apply the signed-in user's preferred language whenever it resolves/changes
|
||||||
if (!user) return <Login onLoggedIn={setUser} />;
|
// (login, bootstrap, or a toggle). Albanian is the default before auth resolves.
|
||||||
|
useEffect(() => {
|
||||||
|
if (user) setLanguage(user.language);
|
||||||
|
}, [user]);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <div className="flex h-screen items-center justify-center text-term-muted">loading…</div>;
|
||||||
|
}
|
||||||
|
if (!user) {
|
||||||
return (
|
return (
|
||||||
<main style={{ fontFamily: "system-ui", padding: "2rem", maxWidth: 720 }}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<header style={{ display: "flex", justifyContent: "space-between", alignItems: "baseline" }}>
|
<Login onLoggedIn={setUser} />
|
||||||
<h1 style={{ margin: 0 }}>Parking System</h1>
|
</QueryClientProvider>
|
||||||
<span style={{ color: "#555" }}>
|
);
|
||||||
{user.username} ({user.role}){" "}
|
}
|
||||||
<button
|
|
||||||
type="button"
|
return (
|
||||||
onClick={async () => {
|
<QueryClientProvider client={queryClient}>
|
||||||
await logout();
|
<RouterProvider router={router} context={{ user, setUser }} />
|
||||||
setUser(null);
|
</QueryClientProvider>
|
||||||
}}
|
|
||||||
>
|
|
||||||
Log out
|
|
||||||
</button>
|
|
||||||
</span>
|
|
||||||
</header>
|
|
||||||
<SiteSettings canEdit={user.role === "admin"} />
|
|
||||||
{user.role !== "readonly" && <ShiftControl />}
|
|
||||||
{user.role === "admin" ? (
|
|
||||||
<>
|
|
||||||
<SetupWizard />
|
|
||||||
<TariffComposer />
|
|
||||||
<PermitManager />
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<p style={{ marginTop: "1rem" }}>Signed in. (Operator console coming soon.)</p>
|
|
||||||
)}
|
|
||||||
</main>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,239 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import * as Dialog from "@radix-ui/react-dialog";
|
||||||
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
boothExit,
|
||||||
|
fetchSiteConfig,
|
||||||
|
lookupSession,
|
||||||
|
paySession,
|
||||||
|
printVoucher,
|
||||||
|
type SessionLookup,
|
||||||
|
} from "./api.js";
|
||||||
|
import { qk } from "./lib/query.js";
|
||||||
|
import { formatDuration, formatMoney, formatTime } from "./lib/format.js";
|
||||||
|
import { SnapshotStrip } from "./ui/SnapshotStrip.js";
|
||||||
|
|
||||||
|
// The booth pay/exit modal. Opened when the operator submits a ticket id. Shows the
|
||||||
|
// session (entry, exit=now, duration, total owed) + entry/exit snapshots, takes
|
||||||
|
// payment, then EITHER prints an exit voucher (customer self-exits at a distant
|
||||||
|
// exit) OR fires the exit immediately (booth at/near the exit) — controlled by a
|
||||||
|
// checkbox defaulting from site_config.exitVoucherDefault. See booth-exit-flow.md.
|
||||||
|
|
||||||
|
type Phase = "review" | "paying" | "finishing" | "done" | "error";
|
||||||
|
|
||||||
|
export function BoothPayModal({ identity, onClose }: { identity: string; onClose: () => void }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const session = useQuery({ queryKey: ["session", identity], queryFn: () => lookupSession(identity) });
|
||||||
|
const config = useQuery({ queryKey: qk.siteConfig, queryFn: fetchSiteConfig });
|
||||||
|
|
||||||
|
const [tender, setTender] = useState<"cash" | "card">("cash");
|
||||||
|
const [printVoucherChecked, setPrintVoucherChecked] = useState<boolean | null>(null);
|
||||||
|
const [phase, setPhase] = useState<Phase>("review");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [result, setResult] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const s: SessionLookup | undefined = session.data;
|
||||||
|
// Checkbox default comes from config the first time it loads; operator can toggle.
|
||||||
|
const voucher = printVoucherChecked ?? config.data?.exitVoucherDefault ?? false;
|
||||||
|
|
||||||
|
const alreadyPaid = s?.paidAt != null;
|
||||||
|
const canPay = s?.found && s.open && !alreadyPaid;
|
||||||
|
|
||||||
|
async function handlePayAndExit() {
|
||||||
|
if (!s) return;
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
// 1. Take payment (unless already paid — e.g. paid earlier at a kiosk).
|
||||||
|
if (!alreadyPaid) {
|
||||||
|
setPhase("paying");
|
||||||
|
await paySession(identity, tender);
|
||||||
|
}
|
||||||
|
// 2. Voucher OR immediate exit.
|
||||||
|
setPhase("finishing");
|
||||||
|
if (voucher) {
|
||||||
|
const r = await printVoucher(identity);
|
||||||
|
setResult(t("pay.voucherPrinted", { printer: r.printedBy }));
|
||||||
|
} else {
|
||||||
|
const r = await boothExit(identity);
|
||||||
|
setResult(
|
||||||
|
r.opened
|
||||||
|
? t("pay.paidBarrierOpened")
|
||||||
|
: t("pay.paidExitRecorded", { reason: r.reason ?? t("booth.openManually") }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Refresh the live views.
|
||||||
|
void qc.invalidateQueries({ queryKey: qk.events });
|
||||||
|
void qc.invalidateQueries({ queryKey: qk.occupancy });
|
||||||
|
setPhase("done");
|
||||||
|
} catch (e) {
|
||||||
|
setError((e as Error).message);
|
||||||
|
setPhase("error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog.Root open onOpenChange={(o) => !o && onClose()}>
|
||||||
|
<Dialog.Portal>
|
||||||
|
<Dialog.Overlay className="fixed inset-0 z-40 bg-black/70" />
|
||||||
|
<Dialog.Content
|
||||||
|
className="fixed left-1/2 top-1/2 z-50 w-[560px] max-w-[95vw] -translate-x-1/2 -translate-y-1/2 rounded-term border border-term-border bg-term-panel font-mono text-term-text shadow-2xl"
|
||||||
|
aria-describedby={undefined}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between border-b border-term-border bg-term-panel-2 px-4 py-2">
|
||||||
|
<Dialog.Title className="m-0 text-[12px] font-semibold uppercase tracking-wider text-term-amber">
|
||||||
|
{t("pay.ticket")} {identity}
|
||||||
|
</Dialog.Title>
|
||||||
|
<Dialog.Close className="text-term-muted hover:text-term-text" aria-label={t("common.close")}>
|
||||||
|
✕
|
||||||
|
</Dialog.Close>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-3 p-4">
|
||||||
|
{session.isLoading && <div className="text-term-muted">{t("pay.lookingUp")}</div>}
|
||||||
|
|
||||||
|
{s && !s.found && (
|
||||||
|
<div className="rounded-term border border-term-red px-3 py-2 text-term-red">
|
||||||
|
{t("pay.noSessionFound")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{s && s.found && !s.open && (
|
||||||
|
<div className="rounded-term border border-term-amber px-3 py-2 text-term-amber">
|
||||||
|
{t("pay.alreadyClosed", { time: formatTime(s.exitedAt) })}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{s && s.found && s.open && (
|
||||||
|
<>
|
||||||
|
{/* Session figures */}
|
||||||
|
<div className="grid grid-cols-2 gap-x-6 gap-y-1 tabular-nums">
|
||||||
|
<Row label={t("pay.entry")} value={formatTime(s.enteredAt)} />
|
||||||
|
<Row label={t("pay.now")} value={formatTime(new Date().toISOString())} />
|
||||||
|
<Row
|
||||||
|
label={t("pay.duration")}
|
||||||
|
value={s.enteredAt ? formatDuration(s.enteredAt, new Date().toISOString()) : "—"}
|
||||||
|
/>
|
||||||
|
<Row
|
||||||
|
label={t("pay.statusLabel")}
|
||||||
|
value={alreadyPaid ? t("pay.paid") : t("pay.unpaid")}
|
||||||
|
valueClass={alreadyPaid ? "text-term-green" : "text-term-amber"}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Total */}
|
||||||
|
<div className="flex items-end justify-between rounded-term bg-term-panel-2 px-3 py-2">
|
||||||
|
<span className="text-[11px] uppercase tracking-wider text-term-muted">{t("pay.total")}</span>
|
||||||
|
<span className="text-3xl font-bold text-term-cyan">
|
||||||
|
{s.amountMinor != null && s.currency
|
||||||
|
? formatMoney(s.amountMinor, s.currency)
|
||||||
|
: alreadyPaid
|
||||||
|
? t("booth.badgePaid")
|
||||||
|
: t("pay.noTariff")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Snapshots */}
|
||||||
|
<SnapshotStrip identity={identity} />
|
||||||
|
|
||||||
|
{phase !== "done" && (
|
||||||
|
<>
|
||||||
|
{/* Tender */}
|
||||||
|
{canPay && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-[11px] uppercase tracking-wider text-term-muted">{t("pay.tender")}</span>
|
||||||
|
{(["cash", "card"] as const).map((tn) => (
|
||||||
|
<button
|
||||||
|
key={tn}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setTender(tn)}
|
||||||
|
className={`rounded-term border px-3 py-1 text-[12px] uppercase tracking-wider ${
|
||||||
|
tender === tn
|
||||||
|
? "border-term-amber text-term-amber"
|
||||||
|
: "border-term-border text-term-muted hover:text-term-text"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t(`pay.${tn}`)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Voucher checkbox (default from site config) */}
|
||||||
|
<label className="flex items-center gap-2 text-[12px]">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={voucher}
|
||||||
|
onChange={(e) => setPrintVoucherChecked(e.target.checked)}
|
||||||
|
/>
|
||||||
|
{t("pay.printExitVoucher")}
|
||||||
|
<span className="text-term-muted">{t("pay.selfExitHint")}</span>
|
||||||
|
</label>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && <div className="rounded-term border border-term-red px-3 py-2 text-term-red">{error}</div>}
|
||||||
|
{result && (
|
||||||
|
<div className="rounded-term border border-term-green px-3 py-2 text-term-green">{result}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex justify-end gap-2 pt-1">
|
||||||
|
{phase === "done" ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="rounded-term border border-term-amber px-4 py-1.5 text-[12px] uppercase tracking-wider text-term-amber"
|
||||||
|
>
|
||||||
|
{t("common.close")}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="rounded-term border border-term-border px-3 py-1.5 text-[12px] uppercase tracking-wider text-term-muted hover:text-term-text"
|
||||||
|
>
|
||||||
|
{t("common.cancel")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handlePayAndExit}
|
||||||
|
disabled={phase === "paying" || phase === "finishing"}
|
||||||
|
className="rounded-term border border-term-green bg-term-green/10 px-4 py-1.5 text-[12px] font-semibold uppercase tracking-wider text-term-green disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{phase === "paying"
|
||||||
|
? t("pay.takingPayment")
|
||||||
|
: phase === "finishing"
|
||||||
|
? voucher
|
||||||
|
? t("pay.printingVoucher")
|
||||||
|
: t("pay.opening")
|
||||||
|
: alreadyPaid
|
||||||
|
? voucher
|
||||||
|
? t("pay.printVoucher")
|
||||||
|
: t("pay.openBarrier")
|
||||||
|
: voucher
|
||||||
|
? t("pay.payAndVoucher")
|
||||||
|
: t("pay.payAndOpen")}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Dialog.Content>
|
||||||
|
</Dialog.Portal>
|
||||||
|
</Dialog.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Row({ label, value, valueClass = "" }: { label: string; value: string; valueClass?: string }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-baseline justify-between">
|
||||||
|
<span className="text-[11px] uppercase tracking-wider text-term-muted">{label}</span>
|
||||||
|
<span className={`text-sm ${valueClass}`}>{value}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
import { useRef, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { fetchEvents, fetchOccupancy, type LedgerEvent, type Occupancy } from "./api.js";
|
||||||
|
import { qk } from "./lib/query.js";
|
||||||
|
import { useLiveStore } from "./lib/live-store.js";
|
||||||
|
import { Panel } from "./ui/Panel.js";
|
||||||
|
import { StatusDot } from "./ui/StatusDot.js";
|
||||||
|
import { BoothPayModal } from "./BoothPayModal.js";
|
||||||
|
import { ActiveSessions } from "./ActiveSessions.js";
|
||||||
|
|
||||||
|
// The live operator booth view — the real-time heart of the console. Occupancy
|
||||||
|
// gauge + a streaming entry/exit/payment ticker. Query owns the initial load and
|
||||||
|
// the authoritative numbers; the WS-fed live store overlays real-time updates so
|
||||||
|
// the screen reacts the instant a car enters or exits. Dense, dark, glanceable.
|
||||||
|
|
||||||
|
/** Per-event-type display: i18n label key + accent colour for the ticker. */
|
||||||
|
const EVENT_STYLE: Record<string, { labelKey: string; color: string }> = {
|
||||||
|
vehicle_entry: { labelKey: "booth.evtEntry", color: "text-term-green" },
|
||||||
|
vehicle_exit: { labelKey: "booth.evtExit", color: "text-term-red" },
|
||||||
|
payment: { labelKey: "booth.evtPay", color: "text-term-cyan" },
|
||||||
|
void: { labelKey: "booth.evtVoid", color: "text-term-amber" },
|
||||||
|
barrier_open_command: { labelKey: "booth.evtOpenCmd", color: "text-term-muted" },
|
||||||
|
barrier_open_observed: { labelKey: "booth.evtOpenObserved", color: "text-term-muted" },
|
||||||
|
shift_open: { labelKey: "booth.evtShiftOpen", color: "text-term-amber" },
|
||||||
|
shift_z_report: { labelKey: "booth.evtShiftZ", color: "text-term-amber" },
|
||||||
|
cash_movement: { labelKey: "booth.evtCashMovement", color: "text-term-cyan" },
|
||||||
|
anomaly: { labelKey: "booth.evtAnomaly", color: "text-term-red" },
|
||||||
|
};
|
||||||
|
|
||||||
|
function hhmmss(iso: string): string {
|
||||||
|
// Local time-of-day, terminal style. Defensive against a bad timestamp.
|
||||||
|
const d = new Date(iso);
|
||||||
|
return Number.isNaN(d.getTime()) ? "--:--:--" : d.toTimeString().slice(0, 8);
|
||||||
|
}
|
||||||
|
|
||||||
|
function OccupancyGauge({ occ }: { occ: Occupancy }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const pct = occ.capacity ? Math.min(100, Math.round((occ.count / occ.capacity) * 100)) : null;
|
||||||
|
const barColor = occ.full ? "bg-term-red" : pct != null && pct >= 85 ? "bg-term-amber" : "bg-term-green";
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<div className="flex items-end gap-4">
|
||||||
|
<div className="text-6xl font-bold leading-none tabular-nums text-term-text">{occ.count}</div>
|
||||||
|
<div className="pb-1 text-term-muted">
|
||||||
|
<div className="text-[11px] uppercase tracking-wider">{t("booth.inside")}</div>
|
||||||
|
<div className="text-sm tabular-nums">
|
||||||
|
{occ.capacity == null ? t("booth.uncapped") : `${t("booth.of")} ${occ.capacity}`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="ml-auto text-right">
|
||||||
|
<div className="text-[11px] uppercase tracking-wider text-term-muted">{t("booth.free")}</div>
|
||||||
|
<div className={`text-3xl font-bold tabular-nums ${occ.full ? "text-term-red" : "text-term-green"}`}>
|
||||||
|
{occ.free == null ? "∞" : occ.free}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{pct != null && (
|
||||||
|
<div className="h-2 w-full overflow-hidden rounded-term bg-term-panel-2">
|
||||||
|
<div className={`h-full ${barColor} transition-[width] duration-300`} style={{ width: `${pct}%` }} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{occ.full && (
|
||||||
|
<div className="rounded-term border border-term-red px-2 py-1 text-center text-[11px] font-bold uppercase tracking-widest text-term-red">
|
||||||
|
{t("booth.lotFull")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EventRow({ e }: { e: LedgerEvent }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const style = EVENT_STYLE[e.type];
|
||||||
|
const label = style ? t(style.labelKey) : e.type.toUpperCase();
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-3 border-b border-term-border/50 py-1 text-[12px] tabular-nums">
|
||||||
|
<span className="text-term-muted">{hhmmss(e.occurredAt)}</span>
|
||||||
|
<span className={`w-20 shrink-0 font-semibold ${style?.color ?? "text-term-text"}`}>{label}</span>
|
||||||
|
<span className="truncate text-term-text">{e.identity ?? "—"}</span>
|
||||||
|
<span className="ml-auto text-term-muted">#{e.index}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ticket entry: an HID barcode scanner types the id and presses Enter; a manual
|
||||||
|
* operator types it. Either way, submit opens the pay/exit modal for that id. The
|
||||||
|
* input auto-focuses and re-focuses after a scan so the scanner always lands here. */
|
||||||
|
function TicketInput({ onSubmit }: { onSubmit: (identity: string) => void }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [value, setValue] = useState("");
|
||||||
|
const ref = useRef<HTMLInputElement>(null);
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const id = value.trim();
|
||||||
|
if (id) {
|
||||||
|
onSubmit(id);
|
||||||
|
setValue("");
|
||||||
|
ref.current?.focus();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
ref={ref}
|
||||||
|
autoFocus
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => setValue(e.target.value)}
|
||||||
|
placeholder={t("booth.scanPlaceholder")}
|
||||||
|
inputMode="numeric"
|
||||||
|
className="flex-1 rounded-term border border-term-border bg-term-bg px-3 py-2 text-lg tabular-nums text-term-text placeholder:text-term-muted focus:border-term-amber"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="rounded-term border border-term-amber bg-term-amber/10 px-4 py-2 text-[12px] font-semibold uppercase tracking-wider text-term-amber"
|
||||||
|
>
|
||||||
|
{t("booth.open")}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BoothScreen() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
// Initial load via Query (also the fallback if the WS is briefly down).
|
||||||
|
const occQuery = useQuery({ queryKey: qk.occupancy, queryFn: fetchOccupancy });
|
||||||
|
const eventsQuery = useQuery({ queryKey: qk.events, queryFn: () => fetchEvents(100) });
|
||||||
|
|
||||||
|
// The ticket currently open in the pay/exit modal (null = no modal).
|
||||||
|
const [activeTicket, setActiveTicket] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Live overlays from the WS store.
|
||||||
|
const liveOcc = useLiveStore((s) => s.occupancy);
|
||||||
|
const liveFeed = useLiveStore((s) => s.feed);
|
||||||
|
|
||||||
|
// Prefer the live-pushed occupancy; fall back to the query.
|
||||||
|
const occ = liveOcc ?? occQuery.data ?? null;
|
||||||
|
|
||||||
|
// Merge: live events first (newest), then the queried history, de-duped by id.
|
||||||
|
const seen = new Set(liveFeed.map((e) => e.id));
|
||||||
|
const history = (eventsQuery.data?.events ?? []).filter((e) => !seen.has(e.id));
|
||||||
|
const events = [...liveFeed, ...history].slice(0, 200);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid h-full grid-cols-1 gap-3 lg:grid-cols-[minmax(320px,1fr)_2fr] lg:grid-rows-[auto_1fr]">
|
||||||
|
{/* Ticket input spans both columns at the top — the operator's primary action. */}
|
||||||
|
<div className="lg:col-span-2">
|
||||||
|
<Panel title={t("booth.processTicket")}>
|
||||||
|
<TicketInput onSubmit={setActiveTicket} />
|
||||||
|
</Panel>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Left column: occupancy gauge above the active-sessions list. */}
|
||||||
|
<div className="flex min-h-0 flex-col gap-3">
|
||||||
|
<Panel title={t("booth.occupancy")} right={<StatusDot />}>
|
||||||
|
{occ ? (
|
||||||
|
<OccupancyGauge occ={occ} />
|
||||||
|
) : (
|
||||||
|
<div className="text-term-muted">{occQuery.isError ? t("booth.occUnavailable") : t("common.loading")}</div>
|
||||||
|
)}
|
||||||
|
</Panel>
|
||||||
|
<div className="min-h-0 flex-1">
|
||||||
|
<ActiveSessions onPick={setActiveTicket} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Panel
|
||||||
|
title={t("booth.liveFeed")}
|
||||||
|
right={
|
||||||
|
<span className="text-[10px] uppercase tracking-wider text-term-muted">
|
||||||
|
{events.length} {t("booth.events")}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
className="min-h-0"
|
||||||
|
>
|
||||||
|
<div className="h-full overflow-y-auto pr-1">
|
||||||
|
{events.length === 0 ? (
|
||||||
|
<div className="text-term-muted">{eventsQuery.isLoading ? t("common.loading") : t("booth.noEventsYet")}</div>
|
||||||
|
) : (
|
||||||
|
events.map((e) => <EventRow key={e.id} e={e} />)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
{activeTicket && <BoothPayModal identity={activeTicket} onClose={() => setActiveTicket(null)} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { login, type SessionUser } from "./api.js";
|
import { login, type SessionUser } from "./api.js";
|
||||||
|
|
||||||
export function Login({ onLoggedIn }: { onLoggedIn: (u: SessionUser) => void }) {
|
export function Login({ onLoggedIn }: { onLoggedIn: (u: SessionUser) => void }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [username, setUsername] = useState("");
|
const [username, setUsername] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -22,11 +24,11 @@ export function Login({ onLoggedIn }: { onLoggedIn: (u: SessionUser) => void })
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<main style={{ fontFamily: "system-ui", maxWidth: 320, margin: "4rem auto" }}>
|
<main style={{ fontFamily: "system-ui", maxWidth: 320, margin: "4rem auto" }}>
|
||||||
<h1>Parking System</h1>
|
<h1>{t("auth.title")}</h1>
|
||||||
<form onSubmit={submit}>
|
<form onSubmit={submit}>
|
||||||
<div style={{ margin: "0.5rem 0" }}>
|
<div style={{ margin: "0.5rem 0" }}>
|
||||||
<label>
|
<label>
|
||||||
Username
|
{t("auth.username")}
|
||||||
<br />
|
<br />
|
||||||
<input
|
<input
|
||||||
value={username}
|
value={username}
|
||||||
@@ -39,7 +41,7 @@ export function Login({ onLoggedIn }: { onLoggedIn: (u: SessionUser) => void })
|
|||||||
</div>
|
</div>
|
||||||
<div style={{ margin: "0.5rem 0" }}>
|
<div style={{ margin: "0.5rem 0" }}>
|
||||||
<label>
|
<label>
|
||||||
Password
|
{t("auth.password")}
|
||||||
<br />
|
<br />
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
@@ -52,7 +54,7 @@ export function Login({ onLoggedIn }: { onLoggedIn: (u: SessionUser) => void })
|
|||||||
</div>
|
</div>
|
||||||
{error && <p style={{ color: "crimson" }}>{error}</p>}
|
{error && <p style={{ color: "crimson" }}>{error}</p>}
|
||||||
<button type="submit" disabled={busy || !username || !password}>
|
<button type="submit" disabled={busy || !username || !password}>
|
||||||
{busy ? "Signing in…" : "Sign in"}
|
{busy ? t("auth.signingIn") : t("auth.signIn")}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
ApiError,
|
ApiError,
|
||||||
createPermit,
|
createPermit,
|
||||||
@@ -41,6 +42,12 @@ function formFrom(p: Permit): FormState {
|
|||||||
platesText: p.plates.join(", "),
|
platesText: p.plates.join(", "),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
const STATUS_KEY: Record<Permit["status"], string> = {
|
||||||
|
active: "permits.statusActive",
|
||||||
|
suspended: "permits.statusSuspended",
|
||||||
|
revoked: "permits.statusRevoked",
|
||||||
|
};
|
||||||
|
|
||||||
function toInput(f: FormState): PermitInput {
|
function toInput(f: FormState): PermitInput {
|
||||||
return {
|
return {
|
||||||
holderName: f.holderName.trim() || null,
|
holderName: f.holderName.trim() || null,
|
||||||
@@ -54,6 +61,7 @@ function toInput(f: FormState): PermitInput {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function PermitManager() {
|
export function PermitManager() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [permits, setPermits] = useState<Permit[] | null>(null);
|
const [permits, setPermits] = useState<Permit[] | null>(null);
|
||||||
const [editing, setEditing] = useState<string | "new" | null>(null);
|
const [editing, setEditing] = useState<string | "new" | null>(null);
|
||||||
const [form, setForm] = useState<FormState>(emptyForm);
|
const [form, setForm] = useState<FormState>(emptyForm);
|
||||||
@@ -84,19 +92,19 @@ export function PermitManager() {
|
|||||||
else if (editing) await updatePermit(editing, toInput(form));
|
else if (editing) await updatePermit(editing, toInput(form));
|
||||||
setEditing(null);
|
setEditing(null);
|
||||||
reload();
|
reload();
|
||||||
setMsg({ kind: "ok", text: "Permit saved." });
|
setMsg({ kind: "ok", text: t("permits.permitSaved") });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const problems = e instanceof ApiError ? (e as ApiError & { problems?: string[] }).problems : undefined;
|
const problems = e instanceof ApiError ? (e as ApiError & { problems?: string[] }).problems : undefined;
|
||||||
setMsg({ kind: "err", text: problems?.length ? `${(e as Error).message}: ${problems.join("; ")}` : (e as Error).message });
|
setMsg({ kind: "err", text: problems?.length ? `${(e as Error).message}: ${problems.join("; ")}` : (e as Error).message });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
async function doRevoke(p: Permit) {
|
async function doRevoke(p: Permit) {
|
||||||
if (!confirm(`Revoke permit for ${p.holderName ?? p.id}? It will be refused at the barrier.`)) return;
|
if (!confirm(t("permits.confirmRevoke", { name: p.holderName ?? p.id }))) return;
|
||||||
await revokePermit(p.id).catch((e) => setMsg({ kind: "err", text: (e as Error).message }));
|
await revokePermit(p.id).catch((e) => setMsg({ kind: "err", text: (e as Error).message }));
|
||||||
reload();
|
reload();
|
||||||
}
|
}
|
||||||
async function doDelete(p: Permit) {
|
async function doDelete(p: Permit) {
|
||||||
if (!confirm(`Delete permit for ${p.holderName ?? p.id}? (Past events are kept.)`)) return;
|
if (!confirm(t("permits.confirmDelete", { name: p.holderName ?? p.id }))) return;
|
||||||
await deletePermit(p.id).catch((e) => setMsg({ kind: "err", text: (e as Error).message }));
|
await deletePermit(p.id).catch((e) => setMsg({ kind: "err", text: (e as Error).message }));
|
||||||
reload();
|
reload();
|
||||||
}
|
}
|
||||||
@@ -109,71 +117,71 @@ export function PermitManager() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section style={{ marginTop: "2rem" }}>
|
<section style={{ marginTop: "2rem" }}>
|
||||||
<h2>Permits</h2>
|
<h2>{t("permits.title")}</h2>
|
||||||
<ul style={{ listStyle: "none", padding: 0 }}>
|
<ul style={{ listStyle: "none", padding: 0 }}>
|
||||||
{permits.map((p) => (
|
{permits.map((p) => (
|
||||||
<li key={p.id} style={{ display: "flex", gap: "0.5rem", alignItems: "center", padding: "0.4rem 0", borderBottom: "1px solid #eee" }}>
|
<li key={p.id} style={{ display: "flex", gap: "0.5rem", alignItems: "center", padding: "0.4rem 0", borderBottom: "1px solid #eee" }}>
|
||||||
<strong>{p.holderName ?? "(unnamed)"}</strong>
|
<strong>{p.holderName ?? t("permits.unnamed")}</strong>
|
||||||
<span style={{ color: p.status === "active" ? "#16a34a" : "#b45309" }}>{p.status}</span>
|
<span style={{ color: p.status === "active" ? "#16a34a" : "#b45309" }}>{t(STATUS_KEY[p.status])}</span>
|
||||||
<span style={{ color: "#666" }}>
|
<span style={{ color: "#666" }}>
|
||||||
{p.maxConcurrent == null ? "unbound" : `${p.maxConcurrent} car${p.maxConcurrent > 1 ? "s" : ""}`} ·{" "}
|
{p.maxConcurrent == null ? t("permits.unbound") : t("permits.car", { count: p.maxConcurrent })} ·{" "}
|
||||||
{p.credentials.length} cred · {p.plates.length} plate(s)
|
{p.credentials.length} {t("permits.cred")} · {t("permits.plates", { count: p.plates.length })}
|
||||||
</span>
|
</span>
|
||||||
<span style={{ flex: 1 }} />
|
<span style={{ flex: 1 }} />
|
||||||
<button type="button" onClick={() => startEdit(p)}>Edit</button>
|
<button type="button" onClick={() => startEdit(p)}>{t("permits.edit")}</button>
|
||||||
{p.status !== "revoked" && <button type="button" onClick={() => doRevoke(p)}>Revoke</button>}
|
{p.status !== "revoked" && <button type="button" onClick={() => doRevoke(p)}>{t("permits.revoke")}</button>}
|
||||||
<button type="button" onClick={() => doDelete(p)}>Delete</button>
|
<button type="button" onClick={() => doDelete(p)}>{t("permits.delete")}</button>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
{permits.length === 0 && <li style={{ color: "#777" }}>No permits yet.</li>}
|
{permits.length === 0 && <li style={{ color: "#777" }}>{t("permits.noPermitsYet")}</li>}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
{editing == null ? (
|
{editing == null ? (
|
||||||
<button type="button" onClick={startNew}>+ Add permit</button>
|
<button type="button" onClick={startNew}>{t("permits.addPermit")}</button>
|
||||||
) : (
|
) : (
|
||||||
<div style={{ border: "1px solid #ddd", padding: "1rem", marginTop: "0.5rem", maxWidth: 460 }}>
|
<div style={{ border: "1px solid #ddd", padding: "1rem", marginTop: "0.5rem", maxWidth: 460 }}>
|
||||||
<h3 style={{ marginTop: 0 }}>{editing === "new" ? "New permit" : "Edit permit"}</h3>
|
<h3 style={{ marginTop: 0 }}>{editing === "new" ? t("permits.newPermit") : t("permits.editPermit")}</h3>
|
||||||
<div style={{ display: "grid", gridTemplateColumns: "max-content 1fr", gap: "0.4rem 0.75rem", alignItems: "center" }}>
|
<div style={{ display: "grid", gridTemplateColumns: "max-content 1fr", gap: "0.4rem 0.75rem", alignItems: "center" }}>
|
||||||
<label>Holder name</label>
|
<label>{t("permits.holderName")}</label>
|
||||||
<input value={form.holderName} onChange={(e) => setForm((f) => ({ ...f, holderName: e.target.value }))} />
|
<input value={form.holderName} onChange={(e) => setForm((f) => ({ ...f, holderName: e.target.value }))} />
|
||||||
<label>Contact</label>
|
<label>{t("permits.contact")}</label>
|
||||||
<input value={form.contact} onChange={(e) => setForm((f) => ({ ...f, contact: e.target.value }))} />
|
<input value={form.contact} onChange={(e) => setForm((f) => ({ ...f, contact: e.target.value }))} />
|
||||||
<label>Car limit</label>
|
<label>{t("permits.carLimit")}</label>
|
||||||
<span>
|
<span>
|
||||||
<label style={{ marginRight: "0.5rem" }}>
|
<label style={{ marginRight: "0.5rem" }}>
|
||||||
<input type="checkbox" checked={form.carBound} onChange={(e) => setForm((f) => ({ ...f, carBound: e.target.checked }))} /> limit cars in at once
|
<input type="checkbox" checked={form.carBound} onChange={(e) => setForm((f) => ({ ...f, carBound: e.target.checked }))} /> {t("permits.limitCarsInAtOnce")}
|
||||||
</label>
|
</label>
|
||||||
{form.carBound && (
|
{form.carBound && (
|
||||||
<input value={form.maxConcurrent} onChange={(e) => setForm((f) => ({ ...f, maxConcurrent: e.target.value }))} style={{ width: 50 }} />
|
<input value={form.maxConcurrent} onChange={(e) => setForm((f) => ({ ...f, maxConcurrent: e.target.value }))} style={{ width: 50 }} />
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
<label>Valid from</label>
|
<label>{t("permits.validFrom")}</label>
|
||||||
<input value={form.validFrom} onChange={(e) => setForm((f) => ({ ...f, validFrom: e.target.value }))} placeholder="ISO date (optional)" />
|
<input value={form.validFrom} onChange={(e) => setForm((f) => ({ ...f, validFrom: e.target.value }))} placeholder={t("permits.isoDateOptional")} />
|
||||||
<label>Valid to</label>
|
<label>{t("permits.validTo")}</label>
|
||||||
<input value={form.validTo} onChange={(e) => setForm((f) => ({ ...f, validTo: e.target.value }))} placeholder="ISO date (optional)" />
|
<input value={form.validTo} onChange={(e) => setForm((f) => ({ ...f, validTo: e.target.value }))} placeholder={t("permits.isoDateOptional")} />
|
||||||
<label>Bound plates</label>
|
<label>{t("permits.boundPlates")}</label>
|
||||||
<input value={form.platesText} onChange={(e) => setForm((f) => ({ ...f, platesText: e.target.value }))} placeholder="comma-separated (optional)" />
|
<input value={form.platesText} onChange={(e) => setForm((f) => ({ ...f, platesText: e.target.value }))} placeholder={t("permits.commaSeparatedOptional")} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h4 style={{ marginBottom: "0.25rem" }}>Credentials (card / QR)</h4>
|
<h4 style={{ marginBottom: "0.25rem" }}>{t("permits.credentialsCardQr")}</h4>
|
||||||
{form.credentials.map((c, i) => (
|
{form.credentials.map((c, i) => (
|
||||||
<div key={i} style={{ display: "flex", gap: "0.4rem", marginBottom: "0.3rem" }}>
|
<div key={i} style={{ display: "flex", gap: "0.4rem", marginBottom: "0.3rem" }}>
|
||||||
<select value={c.kind} onChange={(e) => setCred(i, { kind: e.target.value as "rf" | "qr" })}>
|
<select value={c.kind} onChange={(e) => setCred(i, { kind: e.target.value as "rf" | "qr" })}>
|
||||||
<option value="rf">RF card/tag</option>
|
<option value="rf">{t("permits.rfCardTag")}</option>
|
||||||
<option value="qr">QR</option>
|
<option value="qr">{t("permits.qr")}</option>
|
||||||
</select>
|
</select>
|
||||||
<input value={c.value} onChange={(e) => setCred(i, { value: e.target.value })} placeholder="credential value" style={{ flex: 1 }} />
|
<input value={c.value} onChange={(e) => setCred(i, { value: e.target.value })} placeholder={t("permits.credentialValue")} style={{ flex: 1 }} />
|
||||||
<button type="button" onClick={() => setForm((f) => ({ ...f, credentials: f.credentials.filter((_, j) => j !== i) }))}>×</button>
|
<button type="button" onClick={() => setForm((f) => ({ ...f, credentials: f.credentials.filter((_, j) => j !== i) }))}>×</button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
<button type="button" onClick={() => setForm((f) => ({ ...f, credentials: [...f.credentials, { kind: "rf", value: "" }] }))}>+ credential</button>
|
<button type="button" onClick={() => setForm((f) => ({ ...f, credentials: [...f.credentials, { kind: "rf", value: "" }] }))}>{t("permits.addCredential")}</button>
|
||||||
<p style={{ color: "#777", fontSize: "0.85em", margin: "0.5rem 0 0" }}>
|
<p style={{ color: "#777", fontSize: "0.85em", margin: "0.5rem 0 0" }}>
|
||||||
A permit needs at least one credential OR one bound plate.
|
{t("permits.needCredentialOrPlate")}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div style={{ marginTop: "1rem", display: "flex", gap: "0.5rem" }}>
|
<div style={{ marginTop: "1rem", display: "flex", gap: "0.5rem" }}>
|
||||||
<button type="button" onClick={save}>Save</button>
|
<button type="button" onClick={save}>{t("permits.save")}</button>
|
||||||
<button type="button" onClick={() => setEditing(null)}>Cancel</button>
|
<button type="button" onClick={() => setEditing(null)}>{t("permits.cancel")}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,25 +1,41 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { closeShift, fetchShift, openShift, type ShiftReport } from "./api.js";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { closeShift, fetchShift, openShift, recordCashMovement, type ShiftReport } from "./api.js";
|
||||||
|
|
||||||
// Manned-mode shift control. Start/End are explicit (not time-based — see
|
// Manned-mode shift control. Start/End are explicit (not time-based — see
|
||||||
// wiki/concepts/shift.md). End Shift signs + prints a Z-report and shows the
|
// wiki/concepts/shift.md). End Shift signs + prints a Z-report and shows the
|
||||||
// totals. Available to cashier/operator/admin (readonly has no shift).
|
// totals + the DRAWER picture (opening float carried from the prior shift, cash
|
||||||
|
// taken/added/removed, expected drawer). Admins can load/remove drawer cash.
|
||||||
|
// Available to cashier/operator/admin (readonly has no shift).
|
||||||
|
|
||||||
const money = (m: number, cur: string | null) => `${(m / 100).toFixed(2)} ${cur ?? ""}`.trim();
|
const money = (m: number, cur: string | null) => `${(m / 100).toFixed(2)} ${cur ?? ""}`.trim();
|
||||||
|
|
||||||
export function ShiftControl() {
|
export function ShiftControl({ isAdmin = false }: { isAdmin?: boolean }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [startedAt, setStartedAt] = useState<string | null>(null);
|
const [startedAt, setStartedAt] = useState<string | null>(null);
|
||||||
|
const [drawerMinor, setDrawerMinor] = useState<number | null>(null);
|
||||||
|
const [currency, setCurrency] = useState<string | null>(null);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [report, setReport] = useState<ShiftReport | null>(null);
|
const [report, setReport] = useState<ShiftReport | null>(null);
|
||||||
const [err, setErr] = useState<string | null>(null);
|
const [err, setErr] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
// Cash-movement form (admin only).
|
||||||
|
const [moveAmount, setMoveAmount] = useState("");
|
||||||
|
const [moveReason, setMoveReason] = useState("");
|
||||||
|
const [moveMsg, setMoveMsg] = useState<string | null>(null);
|
||||||
|
|
||||||
|
function refresh() {
|
||||||
fetchShift()
|
fetchShift()
|
||||||
.then((s) => setStartedAt(s.open?.startedAt ?? null))
|
.then((s) => {
|
||||||
|
setStartedAt(s.open?.startedAt ?? null);
|
||||||
|
setDrawerMinor(s.drawerMinor);
|
||||||
|
setCurrency(s.currency);
|
||||||
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
/* readonly / not permitted — hide control */
|
/* readonly / not permitted — hide control */
|
||||||
});
|
});
|
||||||
}, []);
|
}
|
||||||
|
useEffect(refresh, []);
|
||||||
|
|
||||||
async function start() {
|
async function start() {
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
@@ -28,6 +44,7 @@ export function ShiftControl() {
|
|||||||
try {
|
try {
|
||||||
const { startedAt } = await openShift();
|
const { startedAt } = await openShift();
|
||||||
setStartedAt(startedAt);
|
setStartedAt(startedAt);
|
||||||
|
refresh();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setErr((e as Error).message);
|
setErr((e as Error).message);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -41,6 +58,7 @@ export function ShiftControl() {
|
|||||||
const z = await closeShift();
|
const z = await closeShift();
|
||||||
setReport(z);
|
setReport(z);
|
||||||
setStartedAt(null);
|
setStartedAt(null);
|
||||||
|
refresh();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setErr((e as Error).message);
|
setErr((e as Error).message);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -48,33 +66,96 @@ export function ShiftControl() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function move(sign: 1 | -1) {
|
||||||
|
setMoveMsg(null);
|
||||||
|
const major = Number(moveAmount);
|
||||||
|
if (!Number.isFinite(major) || major <= 0) {
|
||||||
|
setMoveMsg(t("shift.enterPositive"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const r = await recordCashMovement(sign * Math.round(major * 100), moveReason.trim());
|
||||||
|
setMoveAmount("");
|
||||||
|
setMoveReason("");
|
||||||
|
setMoveMsg(t("shift.drawerNow", { amount: money(r.balanceMinor, currency) }));
|
||||||
|
refresh();
|
||||||
|
} catch (e) {
|
||||||
|
setMoveMsg((e as Error).message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section style={{ marginTop: "1.5rem", padding: "0.75rem 1rem", border: "1px solid #ddd", borderRadius: 6, maxWidth: 460 }}>
|
<section style={{ marginTop: "1.5rem", padding: "0.75rem 1rem", border: "1px solid #ddd", borderRadius: 6, maxWidth: 460 }}>
|
||||||
<strong>Shift:</strong>{" "}
|
<strong>{t("shift.label")}</strong>{" "}
|
||||||
{startedAt ? (
|
{startedAt ? (
|
||||||
<>
|
<>
|
||||||
<span style={{ color: "#16a34a" }}>open</span> since {new Date(startedAt).toLocaleString()}{" "}
|
<span style={{ color: "#16a34a" }}>{t("shift.open")}</span> {t("shift.since")}{" "}
|
||||||
|
{new Date(startedAt).toLocaleString()}{" "}
|
||||||
<button type="button" onClick={end} disabled={busy}>
|
<button type="button" onClick={end} disabled={busy}>
|
||||||
{busy ? "Ending…" : "End shift"}
|
{busy ? t("shift.ending") : t("shift.endShift")}
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<span style={{ color: "#777" }}>not started</span>{" "}
|
<span style={{ color: "#777" }}>{t("shift.notStarted")}</span>{" "}
|
||||||
<button type="button" onClick={start} disabled={busy}>
|
<button type="button" onClick={start} disabled={busy}>
|
||||||
{busy ? "Starting…" : "Start shift"}
|
{busy ? t("shift.starting") : t("shift.startShift")}
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
{/* Live drawer balance (what's in the till right now / inherited). */}
|
||||||
|
{drawerMinor != null && (
|
||||||
|
<div style={{ marginTop: "0.5rem", color: "#555" }}>
|
||||||
|
{t("shift.drawer")} <strong>{money(drawerMinor, currency)}</strong>
|
||||||
|
{startedAt && <span style={{ color: "#888" }}> {t("shift.openingFloatInherited")}</span>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{err && <p style={{ color: "crimson", margin: "0.5rem 0 0" }}>{err}</p>}
|
{err && <p style={{ color: "crimson", margin: "0.5rem 0 0" }}>{err}</p>}
|
||||||
|
|
||||||
|
{/* Admin: load / remove physical drawer cash (signed cash_movement). */}
|
||||||
|
{isAdmin && (
|
||||||
|
<div style={{ marginTop: "0.6rem", paddingTop: "0.5rem", borderTop: "1px solid #eee" }}>
|
||||||
|
<div style={{ color: "#666", fontSize: "0.85rem", marginBottom: "0.35rem" }}>
|
||||||
|
{t("shift.drawerCashAdmin")}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", gap: "0.4rem", alignItems: "center", flexWrap: "wrap" }}>
|
||||||
|
<input
|
||||||
|
value={moveAmount}
|
||||||
|
onChange={(e) => setMoveAmount(e.target.value)}
|
||||||
|
placeholder={t("shift.amount")}
|
||||||
|
inputMode="decimal"
|
||||||
|
style={{ width: 90 }}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
value={moveReason}
|
||||||
|
onChange={(e) => setMoveReason(e.target.value)}
|
||||||
|
placeholder={t("shift.reasonPlaceholder")}
|
||||||
|
style={{ flex: 1, minWidth: 140 }}
|
||||||
|
/>
|
||||||
|
<button type="button" onClick={() => move(1)}>{t("shift.load")}</button>
|
||||||
|
<button type="button" onClick={() => move(-1)}>{t("shift.remove")}</button>
|
||||||
|
</div>
|
||||||
|
{moveMsg && <div style={{ marginTop: "0.35rem", color: "#555", fontSize: "0.85rem" }}>{moveMsg}</div>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{report && (
|
{report && (
|
||||||
<div style={{ marginTop: "0.75rem", fontFamily: "ui-monospace, monospace", fontSize: "0.9em" }}>
|
<div style={{ marginTop: "0.75rem", fontFamily: "ui-monospace, monospace", fontSize: "0.9em" }}>
|
||||||
<div style={{ fontWeight: 600 }}>Z-REPORT — {report.operator}</div>
|
<div style={{ fontWeight: 600 }}>{t("shift.zReport")} — {report.operator}</div>
|
||||||
<div>Payments: {report.paymentCount}</div>
|
<div>{t("shift.payments")} {report.paymentCount}</div>
|
||||||
<div>Cash: {money(report.cashTotalMinor, report.currency)}</div>
|
<div>{t("shift.cash")} {money(report.cashTotalMinor, report.currency)}</div>
|
||||||
<div>Card: {money(report.cardTotalMinor, report.currency)}</div>
|
<div>{t("shift.card")} {money(report.cardTotalMinor, report.currency)}</div>
|
||||||
<div style={{ color: report.printed ? "#16a34a" : "#b45309" }}>
|
<div style={{ marginTop: "0.4rem", color: "#666" }}>{t("shift.drawerSection")}</div>
|
||||||
{report.printed ? "Printed to booth receipt." : "Recorded (no printer to print to)."}
|
<div>{t("shift.openingFloat")} {money(report.openingFloatMinor, report.currency)}</div>
|
||||||
|
<div>{t("shift.cashTaken")} {money(report.cashTotalMinor, report.currency)}</div>
|
||||||
|
<div>{t("shift.cashAdded")} {money(report.cashAddedMinor, report.currency)}</div>
|
||||||
|
<div>{t("shift.cashRemoved")} {money(report.cashRemovedMinor, report.currency)}</div>
|
||||||
|
<div style={{ fontWeight: 600 }}>
|
||||||
|
{t("shift.expectedDrawer")} {money(report.expectedDrawerMinor, report.currency)}
|
||||||
|
</div>
|
||||||
|
<div style={{ color: report.printed ? "#16a34a" : "#b45309", marginTop: "0.3rem" }}>
|
||||||
|
{report.printed ? t("shift.printedToReceipt") : t("shift.recordedNoPrinter")}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { fetchOccupancy, fetchSiteConfig, saveSiteConfig, type Occupancy, type SiteConfig } from "./api.js";
|
import { fetchOccupancy, fetchSiteConfig, saveSiteConfig, type Occupancy, type SiteConfig } from "./api.js";
|
||||||
|
|
||||||
// Live occupancy + capacity + park metadata. Occupancy is shown to everyone (it's a
|
// Live occupancy + capacity + park metadata. Occupancy is shown to everyone (it's a
|
||||||
@@ -7,20 +8,23 @@ import { fetchOccupancy, fetchSiteConfig, saveSiteConfig, type Occupancy, type S
|
|||||||
// entry flow. Metadata (name, NIUS, address, contact) feeds the ticket header.
|
// entry flow. Metadata (name, NIUS, address, contact) feeds the ticket header.
|
||||||
// See wiki/concepts/capacity-occupancy.md and wiki/concepts/site-metadata.md.
|
// See wiki/concepts/capacity-occupancy.md and wiki/concepts/site-metadata.md.
|
||||||
|
|
||||||
// The optional text fields, in display order, with labels + placeholders.
|
// The optional text fields, in display order. `labelKey`/`phKey` are i18n keys
|
||||||
const META_FIELDS: ReadonlyArray<{ key: keyof SiteConfig; label: string; placeholder?: string; multiline?: boolean }> = [
|
// (resolved at render); only `address` is multiline.
|
||||||
{ key: "parkName", label: "Park name", placeholder: "e.g. Acme Parking" },
|
const META_FIELDS: ReadonlyArray<{ key: keyof SiteConfig; labelKey: string; phKey?: string; multiline?: boolean }> = [
|
||||||
{ key: "operatorName", label: "Operator (legal name)", placeholder: "operating company" },
|
{ key: "parkName", labelKey: "site.fieldParkName", phKey: "site.fieldParkNamePh" },
|
||||||
{ key: "nius", label: "NIUS", placeholder: "e.g. L01234567A" },
|
{ key: "operatorName", labelKey: "site.fieldOperator", phKey: "site.fieldOperatorPh" },
|
||||||
{ key: "address", label: "Address", multiline: true },
|
{ key: "nius", labelKey: "site.fieldNius", phKey: "site.fieldNiusPh" },
|
||||||
{ key: "phone", label: "Phone" },
|
{ key: "address", labelKey: "site.fieldAddress", multiline: true },
|
||||||
{ key: "email", label: "Email" },
|
{ key: "phone", labelKey: "site.fieldPhone" },
|
||||||
|
{ key: "email", labelKey: "site.fieldEmail" },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function SiteSettings({ canEdit }: { canEdit: boolean }) {
|
export function SiteSettings({ canEdit }: { canEdit: boolean }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [occ, setOcc] = useState<Occupancy | null>(null);
|
const [occ, setOcc] = useState<Occupancy | null>(null);
|
||||||
const [capInput, setCapInput] = useState("");
|
const [capInput, setCapInput] = useState("");
|
||||||
const [meta, setMeta] = useState<Record<string, string>>({});
|
const [meta, setMeta] = useState<Record<string, string>>({});
|
||||||
|
const [exitVoucherDefault, setExitVoucherDefault] = useState(false);
|
||||||
const [msg, setMsg] = useState<string | null>(null);
|
const [msg, setMsg] = useState<string | null>(null);
|
||||||
|
|
||||||
function reload() {
|
function reload() {
|
||||||
@@ -31,6 +35,7 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
|
|||||||
fetchSiteConfig()
|
fetchSiteConfig()
|
||||||
.then((c) => {
|
.then((c) => {
|
||||||
setCapInput(c.capacity == null ? "" : String(c.capacity));
|
setCapInput(c.capacity == null ? "" : String(c.capacity));
|
||||||
|
setExitVoucherDefault(c.exitVoucherDefault);
|
||||||
const m: Record<string, string> = {};
|
const m: Record<string, string> = {};
|
||||||
for (const { key } of META_FIELDS) m[key] = c[key] == null ? "" : String(c[key]);
|
for (const { key } of META_FIELDS) m[key] = c[key] == null ? "" : String(c[key]);
|
||||||
setMeta(m);
|
setMeta(m);
|
||||||
@@ -41,13 +46,16 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
|
|||||||
async function save() {
|
async function save() {
|
||||||
setMsg(null);
|
setMsg(null);
|
||||||
const raw = capInput.trim();
|
const raw = capInput.trim();
|
||||||
const patch: Partial<SiteConfig> = { capacity: raw === "" ? null : Math.round(Number(raw)) };
|
const patch: Partial<SiteConfig> = {
|
||||||
|
capacity: raw === "" ? null : Math.round(Number(raw)),
|
||||||
|
exitVoucherDefault,
|
||||||
|
};
|
||||||
// Send each metadata field; "" → null is applied server-side.
|
// Send each metadata field; "" → null is applied server-side.
|
||||||
for (const { key } of META_FIELDS) (patch as Record<string, string | null>)[key] = meta[key] ?? "";
|
for (const { key } of META_FIELDS) (patch as Record<string, string | null>)[key] = meta[key] ?? "";
|
||||||
try {
|
try {
|
||||||
await saveSiteConfig(patch);
|
await saveSiteConfig(patch);
|
||||||
reload();
|
reload();
|
||||||
setMsg("Saved.");
|
setMsg(t("site.saved"));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setMsg((e as Error).message);
|
setMsg((e as Error).message);
|
||||||
}
|
}
|
||||||
@@ -55,50 +63,59 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section style={{ marginTop: "1.5rem", padding: "0.75rem 1rem", border: "1px solid #ddd", borderRadius: 6, maxWidth: 460 }}>
|
<section style={{ marginTop: "1.5rem", padding: "0.75rem 1rem", border: "1px solid #ddd", borderRadius: 6, maxWidth: 460 }}>
|
||||||
<strong>Occupancy:</strong>{" "}
|
<strong>{t("site.occupancy")}</strong>{" "}
|
||||||
{occ == null ? (
|
{occ == null ? (
|
||||||
"…"
|
"…"
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<span style={{ fontWeight: 600 }}>{occ.count}</span>
|
<span style={{ fontWeight: 600 }}>{occ.count}</span>
|
||||||
{occ.capacity != null ? ` / ${occ.capacity}` : " (no capacity set)"}
|
{occ.capacity != null ? ` / ${occ.capacity}` : ` ${t("site.noCapacitySet")}`}
|
||||||
{occ.capacity != null && (
|
{occ.capacity != null && (
|
||||||
<span style={{ color: "#666" }}> · {occ.free} free</span>
|
<span style={{ color: "#666" }}> · {occ.free} {t("site.free")}</span>
|
||||||
)}
|
)}
|
||||||
{occ.full && <span style={{ color: "crimson", marginLeft: "0.5rem", fontWeight: 600 }}>FULL</span>}{" "}
|
{occ.full && <span style={{ color: "crimson", marginLeft: "0.5rem", fontWeight: 600 }}>{t("site.full")}</span>}{" "}
|
||||||
<button type="button" onClick={reload} style={{ marginLeft: "0.5rem" }}>↻</button>
|
<button type="button" onClick={reload} style={{ marginLeft: "0.5rem" }}>↻</button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{canEdit && (
|
{canEdit && (
|
||||||
<div style={{ marginTop: "0.6rem", display: "grid", gap: "0.5rem" }}>
|
<div style={{ marginTop: "0.6rem", display: "grid", gap: "0.5rem" }}>
|
||||||
<label>
|
<label>
|
||||||
Capacity (blank = no limit):{" "}
|
{t("site.capacityLabel")}{" "}
|
||||||
<input value={capInput} onChange={(e) => setCapInput(e.target.value)} style={{ width: 80 }} placeholder="e.g. 120" />
|
<input value={capInput} onChange={(e) => setCapInput(e.target.value)} style={{ width: 80 }} placeholder={t("site.capacityPlaceholder")} />
|
||||||
|
</label>
|
||||||
|
<label style={{ display: "flex", alignItems: "center", gap: "0.4rem" }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={exitVoucherDefault}
|
||||||
|
onChange={(e) => setExitVoucherDefault(e.target.checked)}
|
||||||
|
/>
|
||||||
|
{t("site.printExitDefault")}
|
||||||
|
<span style={{ color: "#888", fontSize: "0.8rem" }}>{t("site.printExitHint")}</span>
|
||||||
</label>
|
</label>
|
||||||
<div style={{ borderTop: "1px solid #eee", paddingTop: "0.5rem", color: "#666", fontSize: "0.85rem" }}>
|
<div style={{ borderTop: "1px solid #eee", paddingTop: "0.5rem", color: "#666", fontSize: "0.85rem" }}>
|
||||||
Park details (optional — shown on tickets/receipts)
|
{t("site.parkDetails")}
|
||||||
</div>
|
</div>
|
||||||
{META_FIELDS.map(({ key, label, placeholder, multiline }) => (
|
{META_FIELDS.map(({ key, labelKey, phKey, multiline }) => (
|
||||||
<label key={key} style={{ display: "flex", flexDirection: "column", fontSize: "0.85rem" }}>
|
<label key={key} style={{ display: "flex", flexDirection: "column", fontSize: "0.85rem" }}>
|
||||||
{label}
|
{t(labelKey)}
|
||||||
{multiline ? (
|
{multiline ? (
|
||||||
<textarea
|
<textarea
|
||||||
value={meta[key] ?? ""}
|
value={meta[key] ?? ""}
|
||||||
onChange={(e) => setMeta((m) => ({ ...m, [key]: e.target.value }))}
|
onChange={(e) => setMeta((m) => ({ ...m, [key]: e.target.value }))}
|
||||||
rows={2}
|
rows={2}
|
||||||
placeholder={placeholder}
|
placeholder={phKey ? t(phKey) : undefined}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<input
|
<input
|
||||||
value={meta[key] ?? ""}
|
value={meta[key] ?? ""}
|
||||||
onChange={(e) => setMeta((m) => ({ ...m, [key]: e.target.value }))}
|
onChange={(e) => setMeta((m) => ({ ...m, [key]: e.target.value }))}
|
||||||
placeholder={placeholder}
|
placeholder={phKey ? t(phKey) : undefined}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</label>
|
</label>
|
||||||
))}
|
))}
|
||||||
<div>
|
<div>
|
||||||
<button type="button" onClick={save}>Save</button>
|
<button type="button" onClick={save}>{t("site.save")}</button>
|
||||||
{msg && <span style={{ marginLeft: "0.5rem", color: "#555" }}>{msg}</span>}
|
{msg && <span style={{ marginLeft: "0.5rem", color: "#555" }}>{msg}</span>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
ApiError,
|
ApiError,
|
||||||
fetchTariff,
|
fetchTariff,
|
||||||
@@ -78,6 +79,7 @@ function toStructure(f: FormState): TariffStructure {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function TariffComposer() {
|
export function TariffComposer() {
|
||||||
|
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);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
@@ -112,7 +114,7 @@ export function TariffComposer() {
|
|||||||
await publishTariffVersion({ currency: form.currency.trim().toUpperCase(), structure: toStructure(form) });
|
await publishTariffVersion({ currency: form.currency.trim().toUpperCase(), structure: toStructure(form) });
|
||||||
const fresh = await fetchTariff();
|
const fresh = await fetchTariff();
|
||||||
setState(fresh);
|
setState(fresh);
|
||||||
setMsg({ kind: "ok", text: "New tariff version published — it's now the active rate card." });
|
setMsg({ kind: "ok", text: t("tariff.publishedOk") });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const text =
|
const text =
|
||||||
e instanceof ApiError && (e as ApiError & { problems?: string[] }).problems
|
e instanceof ApiError && (e as ApiError & { problems?: string[] }).problems
|
||||||
@@ -126,44 +128,40 @@ export function TariffComposer() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section style={{ marginTop: "2rem" }}>
|
<section style={{ marginTop: "2rem" }}>
|
||||||
<h2>Tariff</h2>
|
<h2>{t("tariff.title")}</h2>
|
||||||
{!state?.active ? (
|
{!state?.active ? (
|
||||||
<p style={{ color: "#b45309" }}>
|
<p style={{ color: "#b45309" }}>{t("tariff.noRateCard")}</p>
|
||||||
No rate card published yet — the pay station can't charge until you publish one.
|
|
||||||
</p>
|
|
||||||
) : (
|
) : (
|
||||||
<p style={{ color: "#555" }}>
|
<p style={{ color: "#555" }}>
|
||||||
Active since {new Date(state.active.effectiveFrom).toLocaleString()} ·{" "}
|
{t("tariff.activeSince", {
|
||||||
{state.versions.length} version(s) in history. Publishing creates a new version; past
|
date: new Date(state.active.effectiveFrom).toLocaleString(),
|
||||||
sessions keep their original pricing.
|
count: state.versions.length,
|
||||||
|
})}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div style={{ display: "grid", gridTemplateColumns: "max-content 1fr", gap: "0.4rem 0.75rem", alignItems: "center", maxWidth: 460 }}>
|
<div style={{ display: "grid", gridTemplateColumns: "max-content 1fr", gap: "0.4rem 0.75rem", alignItems: "center", maxWidth: 460 }}>
|
||||||
<label>Currency</label>
|
<label>{t("tariff.currency")}</label>
|
||||||
<input value={form.currency} onChange={(e) => set("currency", e.target.value)} maxLength={3} style={{ width: 80 }} />
|
<input value={form.currency} onChange={(e) => set("currency", e.target.value)} maxLength={3} style={{ width: 80 }} />
|
||||||
<label>Free entry grace (min)</label>
|
<label>{t("tariff.freeEntryGrace")}</label>
|
||||||
<input value={form.gracePeriodEntryMin} onChange={(e) => set("gracePeriodEntryMin", e.target.value)} />
|
<input value={form.gracePeriodEntryMin} onChange={(e) => set("gracePeriodEntryMin", e.target.value)} />
|
||||||
<label>Billing increment (min)</label>
|
<label>{t("tariff.billingIncrement")}</label>
|
||||||
<input value={form.incrementMin} onChange={(e) => set("incrementMin", e.target.value)} />
|
<input value={form.incrementMin} onChange={(e) => set("incrementMin", e.target.value)} />
|
||||||
<label>Daily cap (blank = none)</label>
|
<label>{t("tariff.dailyCap")}</label>
|
||||||
<input value={form.dailyCap} onChange={(e) => set("dailyCap", e.target.value)} placeholder="e.g. 12.00" />
|
<input value={form.dailyCap} onChange={(e) => set("dailyCap", e.target.value)} placeholder={t("tariff.dailyCapPh")} />
|
||||||
<label>Lost-ticket fee</label>
|
<label>{t("tariff.lostTicketFee")}</label>
|
||||||
<input value={form.lostTicket} onChange={(e) => set("lostTicket", e.target.value)} />
|
<input value={form.lostTicket} onChange={(e) => set("lostTicket", e.target.value)} />
|
||||||
<label>Exit walk-back grace (min)</label>
|
<label>{t("tariff.exitGrace")}</label>
|
||||||
<input value={form.gracePeriodExitMin} onChange={(e) => set("gracePeriodExitMin", e.target.value)} />
|
<input value={form.gracePeriodExitMin} onChange={(e) => set("gracePeriodExitMin", e.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h3 style={{ marginBottom: "0.25rem" }}>Rate blocks</h3>
|
<h3 style={{ marginBottom: "0.25rem" }}>{t("tariff.rateBlocks")}</h3>
|
||||||
<p style={{ color: "#777", margin: "0 0 0.5rem", fontSize: "0.9em" }}>
|
<p style={{ color: "#777", margin: "0 0 0.5rem", fontSize: "0.9em" }}>{t("tariff.rateBlocksHint")}</p>
|
||||||
Consumed in order as time accrues. "Up to (min)" is the block's upper bound; leave the last
|
|
||||||
block's bound blank for "thereafter". Price is per billing increment.
|
|
||||||
</p>
|
|
||||||
<table style={{ borderCollapse: "collapse" }}>
|
<table style={{ borderCollapse: "collapse" }}>
|
||||||
<thead>
|
<thead>
|
||||||
<tr style={{ textAlign: "left", color: "#555" }}>
|
<tr style={{ textAlign: "left", color: "#555" }}>
|
||||||
<th style={{ padding: "0 0.5rem" }}>Up to (min)</th>
|
<th style={{ padding: "0 0.5rem" }}>{t("tariff.upToMin")}</th>
|
||||||
<th style={{ padding: "0 0.5rem" }}>Price / increment</th>
|
<th style={{ padding: "0 0.5rem" }}>{t("tariff.pricePerIncrement")}</th>
|
||||||
<th />
|
<th />
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -174,7 +172,7 @@ export function TariffComposer() {
|
|||||||
<input
|
<input
|
||||||
value={b.uptoMin}
|
value={b.uptoMin}
|
||||||
onChange={(e) => setBlock(i, { uptoMin: e.target.value })}
|
onChange={(e) => setBlock(i, { uptoMin: e.target.value })}
|
||||||
placeholder={i === form.blocks.length - 1 ? "thereafter" : "e.g. 60"}
|
placeholder={i === form.blocks.length - 1 ? t("tariff.thereafter") : t("tariff.egExample")}
|
||||||
style={{ width: 110 }}
|
style={{ width: 110 }}
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
@@ -183,7 +181,7 @@ export function TariffComposer() {
|
|||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<button type="button" onClick={() => removeBlock(i)} disabled={form.blocks.length <= 1}>
|
<button type="button" onClick={() => removeBlock(i)} disabled={form.blocks.length <= 1}>
|
||||||
Remove
|
{t("tariff.remove")}
|
||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -191,12 +189,12 @@ export function TariffComposer() {
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
<button type="button" onClick={addBlock} style={{ marginTop: "0.4rem" }}>
|
<button type="button" onClick={addBlock} style={{ marginTop: "0.4rem" }}>
|
||||||
+ Add block
|
{t("tariff.addBlock")}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div style={{ marginTop: "1rem" }}>
|
<div style={{ marginTop: "1rem" }}>
|
||||||
<button type="button" onClick={publish} disabled={saving}>
|
<button type="button" onClick={publish} disabled={saving}>
|
||||||
{saving ? "Publishing…" : "Publish new version"}
|
{saving ? t("tariff.publishing") : t("tariff.publishNewVersion")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{msg && (
|
{msg && (
|
||||||
|
|||||||
+138
-1
@@ -45,10 +45,13 @@ export class ApiError extends Error {
|
|||||||
// --- Auth -----------------------------------------------------------------
|
// --- Auth -----------------------------------------------------------------
|
||||||
|
|
||||||
export type Role = "admin" | "operator" | "cashier" | "readonly";
|
export type Role = "admin" | "operator" | "cashier" | "readonly";
|
||||||
|
export type Lang = "sq" | "en";
|
||||||
export interface SessionUser {
|
export interface SessionUser {
|
||||||
id: string;
|
id: string;
|
||||||
username: string;
|
username: string;
|
||||||
role: Role;
|
role: Role;
|
||||||
|
/** Preferred UI language (loaded from the server on login). */
|
||||||
|
language: Lang;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function login(username: string, password: string): Promise<SessionUser> {
|
export function login(username: string, password: string): Promise<SessionUser> {
|
||||||
@@ -62,6 +65,11 @@ export function logout(): Promise<{ ok: boolean }> {
|
|||||||
return apiFetch("/api/auth/logout", { method: "POST" });
|
return apiFetch("/api/auth/logout", { method: "POST" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Persist the current user's UI language preference (restored on next login). */
|
||||||
|
export function setLanguagePref(language: Lang): Promise<{ language: Lang }> {
|
||||||
|
return apiFetch("/api/auth/language", { method: "PUT", body: JSON.stringify({ language }) });
|
||||||
|
}
|
||||||
|
|
||||||
/** Returns the current user, or null if not authenticated. */
|
/** Returns the current user, or null if not authenticated. */
|
||||||
export async function fetchMe(): Promise<SessionUser | null> {
|
export async function fetchMe(): Promise<SessionUser | null> {
|
||||||
try {
|
try {
|
||||||
@@ -311,6 +319,9 @@ export function deletePermit(id: string): Promise<void> {
|
|||||||
export interface ShiftStatus {
|
export interface ShiftStatus {
|
||||||
operator: string;
|
operator: string;
|
||||||
open: { startedAt: string } | null;
|
open: { startedAt: string } | null;
|
||||||
|
/** Live physical drawer balance (cash payments + cash movements). */
|
||||||
|
drawerMinor: number;
|
||||||
|
currency: string | null;
|
||||||
}
|
}
|
||||||
export interface ShiftReport {
|
export interface ShiftReport {
|
||||||
operator: string;
|
operator: string;
|
||||||
@@ -320,19 +331,35 @@ export interface ShiftReport {
|
|||||||
cardTotalMinor: number;
|
cardTotalMinor: number;
|
||||||
currency: string | null;
|
currency: string | null;
|
||||||
paymentCount: number;
|
paymentCount: number;
|
||||||
|
// Drawer (carries across shifts).
|
||||||
|
openingFloatMinor: number;
|
||||||
|
cashAddedMinor: number;
|
||||||
|
cashRemovedMinor: number;
|
||||||
|
expectedDrawerMinor: number;
|
||||||
printed: boolean;
|
printed: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fetchShift(): Promise<ShiftStatus> {
|
export function fetchShift(): Promise<ShiftStatus> {
|
||||||
return apiFetch("/api/shift/current");
|
return apiFetch("/api/shift/current");
|
||||||
}
|
}
|
||||||
export function openShift(): Promise<{ startedAt: string }> {
|
export function openShift(): Promise<{ startedAt: string; openingFloatMinor: number }> {
|
||||||
return apiFetch("/api/shift/open", { method: "POST" });
|
return apiFetch("/api/shift/open", { method: "POST" });
|
||||||
}
|
}
|
||||||
export function closeShift(): Promise<ShiftReport> {
|
export function closeShift(): Promise<ShiftReport> {
|
||||||
return apiFetch("/api/shift/close", { method: "POST" });
|
return apiFetch("/api/shift/close", { method: "POST" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Admin loads/removes physical drawer cash. amountMinor signed: + load, − remove. */
|
||||||
|
export function recordCashMovement(
|
||||||
|
amountMinor: number,
|
||||||
|
reason: string,
|
||||||
|
): Promise<{ amountMinor: number; balanceMinor: number }> {
|
||||||
|
return apiFetch("/api/cash-movement", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ amountMinor, reason }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// --- Site config / occupancy ----------------------------------------------
|
// --- Site config / occupancy ----------------------------------------------
|
||||||
|
|
||||||
export interface Occupancy {
|
export interface Occupancy {
|
||||||
@@ -345,6 +372,8 @@ export interface Occupancy {
|
|||||||
/** Capacity + optional park metadata (all nullable). Mirrors site_config. */
|
/** Capacity + optional park metadata (all nullable). Mirrors site_config. */
|
||||||
export interface SiteConfig {
|
export interface SiteConfig {
|
||||||
capacity: number | null;
|
capacity: number | null;
|
||||||
|
/** Default for the booth "print exit ticket" checkbox (booth-geography knob). */
|
||||||
|
exitVoucherDefault: boolean;
|
||||||
parkName: string | null;
|
parkName: string | null;
|
||||||
operatorName: string | null;
|
operatorName: string | null;
|
||||||
/** NIUS — Albanian tax/identification number. */
|
/** NIUS — Albanian tax/identification number. */
|
||||||
@@ -357,6 +386,114 @@ export interface SiteConfig {
|
|||||||
export function fetchOccupancy(): Promise<Occupancy> {
|
export function fetchOccupancy(): Promise<Occupancy> {
|
||||||
return apiFetch("/api/occupancy");
|
return apiFetch("/api/occupancy");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Ledger events (the signed audit trail; read-only) --------------------
|
||||||
|
|
||||||
|
/** A persisted ledger row. Re-exported from shared so UI code has one source of
|
||||||
|
* truth for the event shape (the same type the WS pushes). */
|
||||||
|
export type { LedgerEvent } from "@parking/shared";
|
||||||
|
|
||||||
|
/** Recent ledger events, newest first (default 100, max 1000). Used for the
|
||||||
|
* booth feed's initial load; live updates then arrive over the WS. */
|
||||||
|
export function fetchEvents(limit = 100): Promise<{ events: import("@parking/shared").LedgerEvent[] }> {
|
||||||
|
return apiFetch(`/api/events?limit=${limit}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Booth: session lookup, payment, exit ---------------------------------
|
||||||
|
|
||||||
|
/** One-read session view for the booth pay/exit modal (mirrors server SessionLookup). */
|
||||||
|
export interface SessionLookup {
|
||||||
|
identity: string;
|
||||||
|
found: boolean;
|
||||||
|
open: boolean;
|
||||||
|
enteredAt: string | null;
|
||||||
|
exitedAt: string | null;
|
||||||
|
paidAt: string | null;
|
||||||
|
amountMinor: number | null;
|
||||||
|
currency: string | null;
|
||||||
|
withinGrace: boolean;
|
||||||
|
graceExpiresAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Look up a ticket/session for the booth modal (entry/exit, paid, amount owed). */
|
||||||
|
export function lookupSession(identity: string): Promise<SessionLookup> {
|
||||||
|
return apiFetch(`/api/session/${encodeURIComponent(identity)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One row in the booth Active Sessions list (mirrors server ActiveSession). */
|
||||||
|
export interface ActiveSession {
|
||||||
|
identity: string;
|
||||||
|
source: string | null;
|
||||||
|
enteredAt: string;
|
||||||
|
exitedAt: string | null;
|
||||||
|
open: boolean;
|
||||||
|
paidAt: string | null;
|
||||||
|
amountMinor: number | null;
|
||||||
|
currency: string | null;
|
||||||
|
withinGrace: boolean;
|
||||||
|
graceExpiresAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Active sessions: still-inside OR exited-but-within-grace (barrier unconfirmed). */
|
||||||
|
export function fetchActiveSessions(): Promise<{ sessions: ActiveSession[] }> {
|
||||||
|
return apiFetch("/api/sessions/active");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Human-intervention barrier re-open for a paid active session (damaged ticket /
|
||||||
|
* phantom re-close). Signs an audited anomaly; never a 2nd exit. */
|
||||||
|
export function reopenBarrier(identity: string): Promise<{ ok: true; opened: boolean; reason?: string }> {
|
||||||
|
return apiFetch("/api/barrier/reopen", { method: "POST", body: JSON.stringify({ identity }) });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Take payment for a session → signed payment event. `overrideMinor` sets an
|
||||||
|
* operator amount (lost ticket / dispute). */
|
||||||
|
export function paySession(
|
||||||
|
identity: string,
|
||||||
|
tender: "cash" | "card",
|
||||||
|
overrideMinor?: number,
|
||||||
|
): Promise<{ amountMinor: number; currency: string }> {
|
||||||
|
return apiFetch("/api/pay", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ identity, tender, ...(overrideMinor != null ? { overrideMinor } : {}) }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Booth-driven exit result. `opened:false` = exit recorded but barrier didn't
|
||||||
|
* open (payment stands; operator opens manually). */
|
||||||
|
export type BoothExitResult = { ok: true; opened: boolean; reason?: string };
|
||||||
|
|
||||||
|
/** Validate + open the barrier for a session from the booth (when near the exit). */
|
||||||
|
export function boothExit(identity: string): Promise<BoothExitResult> {
|
||||||
|
return apiFetch("/api/exit", { method: "POST", body: JSON.stringify({ identity }) });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Print an exit voucher (paid ticket id reprinted) for self-exit at a distant
|
||||||
|
* exit. Requires the session to be paid. */
|
||||||
|
export function printVoucher(identity: string): Promise<{ ok: boolean; printedBy: string }> {
|
||||||
|
return apiFetch("/api/voucher", { method: "POST", body: JSON.stringify({ identity }) });
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Snapshots (entry/exit evidence images) -------------------------------
|
||||||
|
|
||||||
|
export interface SnapshotMeta {
|
||||||
|
id: string;
|
||||||
|
direction: "entry" | "exit" | null;
|
||||||
|
deviceId: string;
|
||||||
|
identity: string;
|
||||||
|
contentType: string;
|
||||||
|
capturedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Snapshot metadata for a session identity (newest first). Image bytes are at
|
||||||
|
* `/api/snapshots/:id` — use that URL directly as an <img src>. */
|
||||||
|
export function fetchSnapshots(identity: string): Promise<{ snapshots: SnapshotMeta[] }> {
|
||||||
|
return apiFetch(`/api/snapshots/by-identity/${encodeURIComponent(identity)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** URL for a snapshot's image bytes (cookie-authed; usable as <img src>). */
|
||||||
|
export function snapshotImageUrl(id: string): string {
|
||||||
|
return `/api/snapshots/${encodeURIComponent(id)}`;
|
||||||
|
}
|
||||||
export function fetchSiteConfig(): Promise<SiteConfig> {
|
export function fetchSiteConfig(): Promise<SiteConfig> {
|
||||||
return apiFetch("/api/site-config");
|
return apiFetch("/api/site-config");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
/* Bloomberg-terminal aesthetic: dense, dark, monospace, keyboard-first.
|
||||||
|
Tailwind v4 — design tokens live here in @theme (no tailwind.config.js).
|
||||||
|
The booth runs on a fixed appliance display; we optimise for a dark room,
|
||||||
|
glanceable status colour, and high information density over whitespace. */
|
||||||
|
@theme {
|
||||||
|
/* Surfaces — near-black, layered greys for panels/borders. */
|
||||||
|
--color-term-bg: #0a0e12;
|
||||||
|
--color-term-panel: #11161c;
|
||||||
|
--color-term-panel-2: #161d25;
|
||||||
|
--color-term-border: #232c37;
|
||||||
|
--color-term-muted: #6b7785;
|
||||||
|
--color-term-text: #c9d3de;
|
||||||
|
|
||||||
|
/* Status accents — the terminal's signal colours. */
|
||||||
|
--color-term-amber: #f5a623; /* primary accent / headings / focus */
|
||||||
|
--color-term-green: #2ecc71; /* entry / ok / free */
|
||||||
|
--color-term-red: #ff4d4f; /* exit / fault / full */
|
||||||
|
--color-term-cyan: #38bdf8; /* payment / info */
|
||||||
|
|
||||||
|
/* Monospace stack — IBM Plex Mono / JetBrains first, system mono fallback. */
|
||||||
|
--font-mono: "JetBrains Mono", "IBM Plex Mono", ui-monospace, "SFMono-Regular",
|
||||||
|
"Menlo", "Consolas", monospace;
|
||||||
|
|
||||||
|
/* Tight radius — terminals are square. */
|
||||||
|
--radius-term: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body,
|
||||||
|
#root {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
background: var(--color-term-bg);
|
||||||
|
color: var(--color-term-text);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.4;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
/* Crisp text and no rubber-banding on the fixed appliance display. */
|
||||||
|
overscroll-behavior: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Terminal scrollbars — thin, dark, unobtrusive. */
|
||||||
|
* {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: var(--color-term-border) transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A visible keyboard-focus ring in the amber accent (keyboard-first UI). */
|
||||||
|
:focus-visible {
|
||||||
|
outline: 1px solid var(--color-term-amber);
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
// Small formatting helpers for the booth. Money is integer MINOR units (never a
|
||||||
|
// float — matches the tariff/ledger model); duration is whole minutes.
|
||||||
|
|
||||||
|
/** Format integer minor units + ISO-4217 currency as a major-unit string. */
|
||||||
|
export function formatMoney(amountMinor: number, currency: string): string {
|
||||||
|
const major = amountMinor / 100;
|
||||||
|
try {
|
||||||
|
return new Intl.NumberFormat(undefined, { style: "currency", currency }).format(major);
|
||||||
|
} catch {
|
||||||
|
// Unknown/garbled currency code — fall back to a plain number + the code.
|
||||||
|
return `${major.toFixed(2)} ${currency}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Human duration between two ISO times, e.g. "2h 14m" / "47m" / "0m". */
|
||||||
|
export function formatDuration(fromIso: string, toIso: string): string {
|
||||||
|
const ms = Date.parse(toIso) - Date.parse(fromIso);
|
||||||
|
if (!Number.isFinite(ms) || ms < 0) return "—";
|
||||||
|
const mins = Math.floor(ms / 60_000);
|
||||||
|
const h = Math.floor(mins / 60);
|
||||||
|
const m = mins % 60;
|
||||||
|
return h > 0 ? `${h}h ${m}m` : `${m}m`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Local time-of-day HH:MM:SS from an ISO string. */
|
||||||
|
export function formatTime(iso: string | null): string {
|
||||||
|
if (!iso) return "—";
|
||||||
|
const d = new Date(iso);
|
||||||
|
return Number.isNaN(d.getTime()) ? "—" : d.toTimeString().slice(0, 8);
|
||||||
|
}
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
// English (en). Mirrors the key structure of sq.ts (the default/fallback). Any key
|
||||||
|
// missing here falls back to Albanian. See wiki/concepts/i18n.md.
|
||||||
|
|
||||||
|
import type { Catalog } from "./sq.js";
|
||||||
|
|
||||||
|
export const en: Catalog = {
|
||||||
|
common: {
|
||||||
|
loading: "Loading…",
|
||||||
|
logout: "Log out",
|
||||||
|
cancel: "Cancel",
|
||||||
|
close: "Close",
|
||||||
|
save: "Save",
|
||||||
|
none: "—",
|
||||||
|
},
|
||||||
|
auth: {
|
||||||
|
title: "Parking System",
|
||||||
|
username: "Username",
|
||||||
|
password: "Password",
|
||||||
|
signIn: "Sign in",
|
||||||
|
signingIn: "Signing in…",
|
||||||
|
},
|
||||||
|
nav: {
|
||||||
|
booth: "Booth",
|
||||||
|
shift: "Shift",
|
||||||
|
setup: "Setup",
|
||||||
|
tariff: "Tariff",
|
||||||
|
permits: "Permits",
|
||||||
|
site: "Site",
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
live: "LIVE",
|
||||||
|
connecting: "CONNECTING",
|
||||||
|
offline: "OFFLINE",
|
||||||
|
},
|
||||||
|
booth: {
|
||||||
|
processTicket: "Process ticket",
|
||||||
|
scanPlaceholder: "Scan or type ticket number…",
|
||||||
|
open: "Open",
|
||||||
|
occupancy: "Occupancy",
|
||||||
|
occUnavailable: "occupancy unavailable",
|
||||||
|
inside: "inside",
|
||||||
|
of: "of",
|
||||||
|
uncapped: "uncapped",
|
||||||
|
free: "free",
|
||||||
|
lotFull: "● lot full",
|
||||||
|
liveFeed: "Live feed",
|
||||||
|
events: "events",
|
||||||
|
noEventsYet: "No events yet — entries and exits will stream here.",
|
||||||
|
activeSessions: "Active sessions",
|
||||||
|
insideCount: "inside",
|
||||||
|
noActiveSessions: "No active sessions.",
|
||||||
|
inAt: "in",
|
||||||
|
openPayExit: "Open pay / exit",
|
||||||
|
openBarrier: "Open barrier",
|
||||||
|
openBarrierTitle: "Human-intervention barrier open (audited)",
|
||||||
|
barrierOpened: "barrier opened",
|
||||||
|
openManually: "open manually",
|
||||||
|
badgeExiting: "exiting",
|
||||||
|
badgePaid: "paid",
|
||||||
|
badgeUnpaid: "unpaid",
|
||||||
|
evtEntry: "ENTRY",
|
||||||
|
evtExit: "EXIT",
|
||||||
|
evtPay: "PAY",
|
||||||
|
evtVoid: "VOID",
|
||||||
|
evtOpenCmd: "OPEN→",
|
||||||
|
evtOpenObserved: "OPEN✓",
|
||||||
|
evtShiftOpen: "SHIFT+",
|
||||||
|
evtShiftZ: "SHIFT Z",
|
||||||
|
evtCashMovement: "CASH",
|
||||||
|
evtAnomaly: "ANOMALY",
|
||||||
|
},
|
||||||
|
tariff: {
|
||||||
|
title: "Tariff",
|
||||||
|
noRateCard: "No rate card published yet — the pay station can't charge until you publish one.",
|
||||||
|
activeSince: "Active since {{date}} · {{count}} version(s) in history. Publishing creates a new version; past sessions keep their original pricing.",
|
||||||
|
currency: "Currency",
|
||||||
|
freeEntryGrace: "Free entry grace (min)",
|
||||||
|
billingIncrement: "Billing increment (min)",
|
||||||
|
dailyCap: "Daily cap (blank = none)",
|
||||||
|
dailyCapPh: "e.g. 12.00",
|
||||||
|
lostTicketFee: "Lost-ticket fee",
|
||||||
|
exitGrace: "Exit walk-back grace (min)",
|
||||||
|
rateBlocks: "Rate blocks",
|
||||||
|
rateBlocksHint: "Consumed in order as time accrues. \"Up to (min)\" is the block's upper bound; leave the last block's bound blank for \"thereafter\". Price is per billing increment.",
|
||||||
|
upToMin: "Up to (min)",
|
||||||
|
pricePerIncrement: "Price / increment",
|
||||||
|
thereafter: "thereafter",
|
||||||
|
egExample: "e.g. 60",
|
||||||
|
remove: "Remove",
|
||||||
|
addBlock: "+ Add block",
|
||||||
|
publishNewVersion: "Publish new version",
|
||||||
|
publishing: "Publishing…",
|
||||||
|
publishedOk: "New tariff version published — it's now the active rate card.",
|
||||||
|
},
|
||||||
|
permits: {
|
||||||
|
title: "Permits",
|
||||||
|
unnamed: "(unnamed)",
|
||||||
|
unbound: "unbound",
|
||||||
|
car_one: "{{count}} car",
|
||||||
|
car_other: "{{count}} cars",
|
||||||
|
cred: "cred",
|
||||||
|
plates: "{{count}} plate(s)",
|
||||||
|
edit: "Edit",
|
||||||
|
revoke: "Revoke",
|
||||||
|
delete: "Delete",
|
||||||
|
noPermitsYet: "No permits yet.",
|
||||||
|
addPermit: "+ Add permit",
|
||||||
|
newPermit: "New permit",
|
||||||
|
editPermit: "Edit permit",
|
||||||
|
holderName: "Holder name",
|
||||||
|
contact: "Contact",
|
||||||
|
carLimit: "Car limit",
|
||||||
|
limitCarsInAtOnce: "limit cars in at once",
|
||||||
|
validFrom: "Valid from",
|
||||||
|
validTo: "Valid to",
|
||||||
|
isoDateOptional: "ISO date (optional)",
|
||||||
|
boundPlates: "Bound plates",
|
||||||
|
commaSeparatedOptional: "comma-separated (optional)",
|
||||||
|
credentialsCardQr: "Credentials (card / QR)",
|
||||||
|
rfCardTag: "RF card/tag",
|
||||||
|
qr: "QR",
|
||||||
|
credentialValue: "credential value",
|
||||||
|
addCredential: "+ credential",
|
||||||
|
needCredentialOrPlate: "A permit needs at least one credential OR one bound plate.",
|
||||||
|
save: "Save",
|
||||||
|
cancel: "Cancel",
|
||||||
|
permitSaved: "Permit saved.",
|
||||||
|
confirmRevoke: "Revoke permit for {{name}}? It will be refused at the barrier.",
|
||||||
|
confirmDelete: "Delete permit for {{name}}? (Past events are kept.)",
|
||||||
|
statusActive: "active",
|
||||||
|
statusSuspended: "suspended",
|
||||||
|
statusRevoked: "revoked",
|
||||||
|
},
|
||||||
|
site: {
|
||||||
|
occupancy: "Occupancy:",
|
||||||
|
noCapacitySet: "(no capacity set)",
|
||||||
|
free: "free",
|
||||||
|
full: "FULL",
|
||||||
|
capacityLabel: "Capacity (blank = no limit):",
|
||||||
|
capacityPlaceholder: "e.g. 120",
|
||||||
|
printExitDefault: "Print exit ticket by default",
|
||||||
|
printExitHint: "(booth far from exit → customer self-exits with a voucher)",
|
||||||
|
parkDetails: "Park details (optional — shown on tickets/receipts)",
|
||||||
|
save: "Save",
|
||||||
|
saved: "Saved.",
|
||||||
|
fieldParkName: "Park name",
|
||||||
|
fieldParkNamePh: "e.g. Acme Parking",
|
||||||
|
fieldOperator: "Operator (legal name)",
|
||||||
|
fieldOperatorPh: "operating company",
|
||||||
|
fieldNius: "NIUS",
|
||||||
|
fieldNiusPh: "e.g. L01234567A",
|
||||||
|
fieldAddress: "Address",
|
||||||
|
fieldPhone: "Phone",
|
||||||
|
fieldEmail: "Email",
|
||||||
|
},
|
||||||
|
shift: {
|
||||||
|
label: "Shift:",
|
||||||
|
open: "open",
|
||||||
|
notStarted: "not started",
|
||||||
|
since: "since",
|
||||||
|
startShift: "Start shift",
|
||||||
|
starting: "Starting…",
|
||||||
|
endShift: "End shift",
|
||||||
|
ending: "Ending…",
|
||||||
|
drawer: "Drawer:",
|
||||||
|
openingFloatInherited: "(opening float inherited from the prior shift)",
|
||||||
|
drawerCashAdmin: "Drawer cash (admin) — load or remove the float",
|
||||||
|
amount: "amount",
|
||||||
|
reasonPlaceholder: "reason (e.g. opening float)",
|
||||||
|
load: "Load +",
|
||||||
|
remove: "Remove −",
|
||||||
|
enterPositive: "Enter a positive amount.",
|
||||||
|
drawerNow: "Drawer now {{amount}}.",
|
||||||
|
zReport: "Z-REPORT",
|
||||||
|
payments: "Payments:",
|
||||||
|
cash: "Cash:",
|
||||||
|
card: "Card:",
|
||||||
|
drawerSection: "— Drawer —",
|
||||||
|
openingFloat: "Opening float:",
|
||||||
|
cashTaken: "Cash taken:",
|
||||||
|
cashAdded: "Cash added:",
|
||||||
|
cashRemoved: "Cash removed:",
|
||||||
|
expectedDrawer: "Expected drawer:",
|
||||||
|
printedToReceipt: "Printed to booth receipt.",
|
||||||
|
recordedNoPrinter: "Recorded (no printer to print to).",
|
||||||
|
},
|
||||||
|
pay: {
|
||||||
|
ticket: "Ticket",
|
||||||
|
entry: "Entry",
|
||||||
|
now: "Now",
|
||||||
|
duration: "Duration",
|
||||||
|
statusLabel: "Status",
|
||||||
|
paid: "PAID",
|
||||||
|
unpaid: "UNPAID",
|
||||||
|
total: "Total",
|
||||||
|
noTariff: "no tariff",
|
||||||
|
tender: "Tender",
|
||||||
|
cash: "Cash",
|
||||||
|
card: "Card",
|
||||||
|
printExitVoucher: "Print exit ticket",
|
||||||
|
selfExitHint: "(customer self-exits at the exit)",
|
||||||
|
payAndOpen: "Pay + open barrier",
|
||||||
|
payAndVoucher: "Pay + print voucher",
|
||||||
|
openBarrier: "Open barrier",
|
||||||
|
printVoucher: "Print voucher",
|
||||||
|
takingPayment: "taking payment…",
|
||||||
|
printingVoucher: "printing voucher…",
|
||||||
|
opening: "opening…",
|
||||||
|
noSessionFound: "No session found for this ticket.",
|
||||||
|
alreadyClosed: "This session is already closed (exited {{time}}).",
|
||||||
|
lookingUp: "looking up…",
|
||||||
|
paidBarrierOpened: "Paid — barrier opened. Car may exit.",
|
||||||
|
paidExitRecorded: "Paid and exit recorded, but the barrier did not open: {{reason}}.",
|
||||||
|
voucherPrinted: "Exit voucher printed on {{printer}}. Customer self-exits at the exit.",
|
||||||
|
noSnapshots: "no snapshots",
|
||||||
|
loadingSnapshots: "loading snapshots…",
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import i18n from "i18next";
|
||||||
|
import { initReactI18next } from "react-i18next";
|
||||||
|
import { sq } from "./sq.js";
|
||||||
|
import { en } from "./en.js";
|
||||||
|
|
||||||
|
// i18next setup for the operator UI. Albanian (sq) is the DEFAULT and the fallback;
|
||||||
|
// English (en) is the second language. The active language is the LOGGED-IN USER's
|
||||||
|
// stored preference (users.language), applied via setLanguage() after auth resolves
|
||||||
|
// — not localStorage, not the browser. Printed tickets are NOT governed by this
|
||||||
|
// (always Albanian, customer-facing). See wiki/concepts/i18n.md.
|
||||||
|
|
||||||
|
export type Lang = "sq" | "en";
|
||||||
|
|
||||||
|
// Single flat namespace; keys are dot-paths (e.g. "booth.processTicket"). Nested
|
||||||
|
// objects in the catalogs are walked by i18next's keySeparator.
|
||||||
|
void i18n.use(initReactI18next).init({
|
||||||
|
resources: {
|
||||||
|
sq: { translation: sq },
|
||||||
|
en: { translation: en },
|
||||||
|
},
|
||||||
|
lng: "sq",
|
||||||
|
fallbackLng: "sq",
|
||||||
|
interpolation: { escapeValue: false }, // React already escapes
|
||||||
|
returnNull: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Apply a language (e.g. after login resolves the user's preference). No-op if
|
||||||
|
* already active. */
|
||||||
|
export function setLanguage(lang: Lang): void {
|
||||||
|
if (i18n.language !== lang) void i18n.changeLanguage(lang);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default i18n;
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
// Albanian (sq) — the DEFAULT and fallback language. Customer/operator-facing copy.
|
||||||
|
// Keys are dot-namespaced by area (common, nav, booth, …). When adding a string,
|
||||||
|
// add it here first (the fallback), then mirror the key in en.ts.
|
||||||
|
// See wiki/concepts/i18n.md.
|
||||||
|
|
||||||
|
export const sq = {
|
||||||
|
common: {
|
||||||
|
loading: "Duke u ngarkuar…",
|
||||||
|
logout: "Dil",
|
||||||
|
cancel: "Anulo",
|
||||||
|
close: "Mbyll",
|
||||||
|
save: "Ruaj",
|
||||||
|
none: "—",
|
||||||
|
},
|
||||||
|
auth: {
|
||||||
|
title: "Sistemi i Parkimit",
|
||||||
|
username: "Përdoruesi",
|
||||||
|
password: "Fjalëkalimi",
|
||||||
|
signIn: "Hyr",
|
||||||
|
signingIn: "Duke hyrë…",
|
||||||
|
},
|
||||||
|
nav: {
|
||||||
|
booth: "Kabina",
|
||||||
|
shift: "Turni",
|
||||||
|
setup: "Konfigurimi",
|
||||||
|
tariff: "Tarifa",
|
||||||
|
permits: "Lejet",
|
||||||
|
site: "Vendi",
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
live: "DREJTPËRDREJT",
|
||||||
|
connecting: "DUKE U LIDHUR",
|
||||||
|
offline: "JASHTË LINJE",
|
||||||
|
},
|
||||||
|
booth: {
|
||||||
|
processTicket: "Proceso biletën",
|
||||||
|
scanPlaceholder: "Skano ose shkruaj numrin e biletës…",
|
||||||
|
open: "Hap",
|
||||||
|
occupancy: "Zënia",
|
||||||
|
occUnavailable: "zënia e padisponueshme",
|
||||||
|
inside: "brenda",
|
||||||
|
of: "nga",
|
||||||
|
uncapped: "pa kufi",
|
||||||
|
free: "lirë",
|
||||||
|
lotFull: "● parkimi plot",
|
||||||
|
liveFeed: "Aktiviteti i drejtpërdrejtë",
|
||||||
|
events: "ngjarje",
|
||||||
|
noEventsYet: "Asnjë ngjarje ende — hyrjet dhe daljet do të shfaqen këtu.",
|
||||||
|
activeSessions: "Sesionet aktive",
|
||||||
|
insideCount: "brenda",
|
||||||
|
noActiveSessions: "Asnjë sesion aktiv.",
|
||||||
|
inAt: "në",
|
||||||
|
openPayExit: "Hap pagesën / daljen",
|
||||||
|
openBarrier: "Hap barrierën",
|
||||||
|
openBarrierTitle: "Hapje barriere me ndërhyrje njerëzore (e regjistruar)",
|
||||||
|
barrierOpened: "barriera u hap",
|
||||||
|
openManually: "hape me dorë",
|
||||||
|
// session row badges
|
||||||
|
badgeExiting: "duke dalë",
|
||||||
|
badgePaid: "paguar",
|
||||||
|
badgeUnpaid: "papaguar",
|
||||||
|
// event types (live feed labels)
|
||||||
|
evtEntry: "HYRJE",
|
||||||
|
evtExit: "DALJE",
|
||||||
|
evtPay: "PAGESË",
|
||||||
|
evtVoid: "ANULIM",
|
||||||
|
evtOpenCmd: "HAP→",
|
||||||
|
evtOpenObserved: "HAP✓",
|
||||||
|
evtShiftOpen: "TURN+",
|
||||||
|
evtShiftZ: "TURN Z",
|
||||||
|
evtCashMovement: "ARKË",
|
||||||
|
evtAnomaly: "ANOMALI",
|
||||||
|
},
|
||||||
|
tariff: {
|
||||||
|
title: "Tarifa",
|
||||||
|
noRateCard: "Asnjë kartë tarifore e publikuar — arka nuk mund të faturojë derisa të publikoni një.",
|
||||||
|
activeSince: "Aktive që nga {{date}} · {{count}} version(e) në histori. Publikimi krijon një version të ri; sesionet e kaluara ruajnë çmimin origjinal.",
|
||||||
|
currency: "Monedha",
|
||||||
|
freeEntryGrace: "Periudha pa pagesë në hyrje (min)",
|
||||||
|
billingIncrement: "Intervali i faturimit (min)",
|
||||||
|
dailyCap: "Kufiri ditor (bosh = pa kufi)",
|
||||||
|
dailyCapPh: "p.sh. 12.00",
|
||||||
|
lostTicketFee: "Tarifa për biletë të humbur",
|
||||||
|
exitGrace: "Periudha e kthimit në dalje (min)",
|
||||||
|
rateBlocks: "Blloqet tarifore",
|
||||||
|
rateBlocksHint: "Konsumohen me radhë me kalimin e kohës. \"Deri në (min)\" është kufiri i sipërm i bllokut; lëre bosh kufirin e bllokut të fundit për \"më pas\". Çmimi është për interval faturimi.",
|
||||||
|
upToMin: "Deri në (min)",
|
||||||
|
pricePerIncrement: "Çmimi / interval",
|
||||||
|
thereafter: "më pas",
|
||||||
|
egExample: "p.sh. 60",
|
||||||
|
remove: "Hiq",
|
||||||
|
addBlock: "+ Shto bllok",
|
||||||
|
publishNewVersion: "Publiko version të ri",
|
||||||
|
publishing: "Duke publikuar…",
|
||||||
|
publishedOk: "U publikua versioni i ri i tarifës — tani është karta tarifore aktive.",
|
||||||
|
},
|
||||||
|
permits: {
|
||||||
|
title: "Lejet",
|
||||||
|
unnamed: "(pa emër)",
|
||||||
|
unbound: "pa kufizim",
|
||||||
|
car_one: "{{count}} makinë",
|
||||||
|
car_other: "{{count}} makina",
|
||||||
|
cred: "kredencial",
|
||||||
|
plates: "{{count}} targë(a)",
|
||||||
|
edit: "Ndrysho",
|
||||||
|
revoke: "Anulo",
|
||||||
|
delete: "Fshij",
|
||||||
|
noPermitsYet: "Asnjë leje ende.",
|
||||||
|
addPermit: "+ Shto leje",
|
||||||
|
newPermit: "Leje e re",
|
||||||
|
editPermit: "Ndrysho lejen",
|
||||||
|
holderName: "Emri i mbajtësit",
|
||||||
|
contact: "Kontakti",
|
||||||
|
carLimit: "Kufiri i makinave",
|
||||||
|
limitCarsInAtOnce: "kufizo makinat brenda njëkohësisht",
|
||||||
|
validFrom: "Vlen nga",
|
||||||
|
validTo: "Vlen deri",
|
||||||
|
isoDateOptional: "Datë ISO (opsionale)",
|
||||||
|
boundPlates: "Targat e lidhura",
|
||||||
|
commaSeparatedOptional: "të ndara me presje (opsionale)",
|
||||||
|
credentialsCardQr: "Kredencialet (kartë / QR)",
|
||||||
|
rfCardTag: "Kartë/etiketë RF",
|
||||||
|
qr: "QR",
|
||||||
|
credentialValue: "vlera e kredencialit",
|
||||||
|
addCredential: "+ kredencial",
|
||||||
|
needCredentialOrPlate: "Një leje kërkon të paktën një kredencial OSE një targë të lidhur.",
|
||||||
|
save: "Ruaj",
|
||||||
|
cancel: "Anulo",
|
||||||
|
permitSaved: "Leja u ruajt.",
|
||||||
|
confirmRevoke: "Të anulohet leja për {{name}}? Do të refuzohet te barriera.",
|
||||||
|
confirmDelete: "Të fshihet leja për {{name}}? (Ngjarjet e kaluara ruhen.)",
|
||||||
|
statusActive: "aktive",
|
||||||
|
statusSuspended: "pezulluar",
|
||||||
|
statusRevoked: "anuluar",
|
||||||
|
},
|
||||||
|
site: {
|
||||||
|
occupancy: "Zënia:",
|
||||||
|
noCapacitySet: "(pa kapacitet të caktuar)",
|
||||||
|
free: "lirë",
|
||||||
|
full: "PLOT",
|
||||||
|
capacityLabel: "Kapaciteti (bosh = pa kufi):",
|
||||||
|
capacityPlaceholder: "p.sh. 120",
|
||||||
|
printExitDefault: "Printo biletën e daljes si parazgjedhje",
|
||||||
|
printExitHint: "(kabina larg daljes → klienti del vetë me biletë)",
|
||||||
|
parkDetails: "Të dhënat e parkimit (opsionale — shfaqen në bileta/fatura)",
|
||||||
|
save: "Ruaj",
|
||||||
|
saved: "U ruajt.",
|
||||||
|
fieldParkName: "Emri i parkimit",
|
||||||
|
fieldParkNamePh: "p.sh. Acme Parking",
|
||||||
|
fieldOperator: "Operatori (emri ligjor)",
|
||||||
|
fieldOperatorPh: "kompania operuese",
|
||||||
|
fieldNius: "NIUS",
|
||||||
|
fieldNiusPh: "p.sh. L01234567A",
|
||||||
|
fieldAddress: "Adresa",
|
||||||
|
fieldPhone: "Telefoni",
|
||||||
|
fieldEmail: "Email",
|
||||||
|
},
|
||||||
|
shift: {
|
||||||
|
label: "Turni:",
|
||||||
|
open: "hapur",
|
||||||
|
notStarted: "i panisur",
|
||||||
|
since: "që nga",
|
||||||
|
startShift: "Fillo turnin",
|
||||||
|
starting: "Duke filluar…",
|
||||||
|
endShift: "Mbyll turnin",
|
||||||
|
ending: "Duke mbyllur…",
|
||||||
|
drawer: "Arka:",
|
||||||
|
openingFloatInherited: "(bilanci fillestar i trashëguar nga turni i mëparshëm)",
|
||||||
|
drawerCashAdmin: "Para në arkë (admin) — shto ose hiq bilancin",
|
||||||
|
amount: "shuma",
|
||||||
|
reasonPlaceholder: "arsyeja (p.sh. bilanci fillestar)",
|
||||||
|
load: "Shto +",
|
||||||
|
remove: "Hiq −",
|
||||||
|
enterPositive: "Shkruaj një shumë pozitive.",
|
||||||
|
drawerNow: "Arka tani {{amount}}.",
|
||||||
|
zReport: "RAPORT Z",
|
||||||
|
payments: "Pagesa:",
|
||||||
|
cash: "Para:",
|
||||||
|
card: "Kartë:",
|
||||||
|
drawerSection: "— Arka —",
|
||||||
|
openingFloat: "Bilanci fillestar:",
|
||||||
|
cashTaken: "Para të marra:",
|
||||||
|
cashAdded: "Para të shtuara:",
|
||||||
|
cashRemoved: "Para të hequra:",
|
||||||
|
expectedDrawer: "Arka e pritshme:",
|
||||||
|
printedToReceipt: "Printuar te printeri i kabinës.",
|
||||||
|
recordedNoPrinter: "Regjistruar (pa printer për të printuar).",
|
||||||
|
},
|
||||||
|
pay: {
|
||||||
|
ticket: "Bileta",
|
||||||
|
entry: "Hyrja",
|
||||||
|
now: "Tani",
|
||||||
|
duration: "Kohëzgjatja",
|
||||||
|
statusLabel: "Statusi",
|
||||||
|
paid: "PAGUAR",
|
||||||
|
unpaid: "PAPAGUAR",
|
||||||
|
total: "Totali",
|
||||||
|
noTariff: "pa tarifë",
|
||||||
|
tender: "Mënyra",
|
||||||
|
cash: "Para",
|
||||||
|
card: "Kartë",
|
||||||
|
printExitVoucher: "Printo biletë dalje",
|
||||||
|
selfExitHint: "(klienti del vetë te dalja)",
|
||||||
|
payAndOpen: "Paguaj + hap barrierën",
|
||||||
|
payAndVoucher: "Paguaj + printo biletën",
|
||||||
|
openBarrier: "Hap barrierën",
|
||||||
|
printVoucher: "Printo biletën",
|
||||||
|
takingPayment: "Duke marrë pagesën…",
|
||||||
|
printingVoucher: "Duke printuar biletën…",
|
||||||
|
opening: "Duke hapur…",
|
||||||
|
noSessionFound: "Nuk u gjet asnjë sesion për këtë biletë.",
|
||||||
|
alreadyClosed: "Ky sesion është mbyllur tashmë (doli {{time}}).",
|
||||||
|
lookingUp: "Duke kërkuar…",
|
||||||
|
paidBarrierOpened: "Paguar — barriera u hap. Automjeti mund të dalë.",
|
||||||
|
paidExitRecorded: "Paguar dhe dalja u regjistrua, por barriera nuk u hap: {{reason}}.",
|
||||||
|
voucherPrinted: "Bileta e daljes u printua në {{printer}}. Klienti del vetë te dalja.",
|
||||||
|
// snapshots
|
||||||
|
noSnapshots: "asnjë foto",
|
||||||
|
loadingSnapshots: "duke ngarkuar fotot…",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// The catalog SHAPE (keys + nesting), with string-typed values — so en.ts must
|
||||||
|
// supply every key but may differ in value. (Not `typeof sq` with `as const`, which
|
||||||
|
// would pin en.ts to the Albanian literals.)
|
||||||
|
type Stringify<T> = { [K in keyof T]: T[K] extends object ? Stringify<T[K]> : string };
|
||||||
|
export type Catalog = Stringify<typeof sq>;
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { create } from "zustand";
|
||||||
|
import type { LedgerEvent, Occupancy } from "../api.js";
|
||||||
|
|
||||||
|
// CLIENT state for the live booth feed — deliberately small. Server data (the
|
||||||
|
// authoritative event list, occupancy totals) is owned by TanStack Query; this
|
||||||
|
// store holds only what Query shouldn't: the WS connection status, the latest
|
||||||
|
// pushed occupancy snapshot, and a rolling in-memory tail of recent events for the
|
||||||
|
// live ticker. Anything durable is re-fetched via Query. See lib/query.ts.
|
||||||
|
|
||||||
|
/** Connection state of the booth WebSocket, for a status indicator in the UI. */
|
||||||
|
export type WsStatus = "connecting" | "open" | "closed";
|
||||||
|
|
||||||
|
/** Cap the in-memory live feed so a long-running booth session can't grow it
|
||||||
|
* unbounded — the full history is always available via the /api/events query. */
|
||||||
|
const MAX_FEED = 200;
|
||||||
|
|
||||||
|
interface LiveState {
|
||||||
|
status: WsStatus;
|
||||||
|
/** Most recent occupancy pushed by the server (rides on every ledger event). */
|
||||||
|
occupancy: Occupancy | null;
|
||||||
|
/** Newest-first tail of recently pushed ledger events (for the live ticker). */
|
||||||
|
feed: LedgerEvent[];
|
||||||
|
setStatus: (s: WsStatus) => void;
|
||||||
|
setOccupancy: (o: Occupancy) => void;
|
||||||
|
pushEvent: (e: LedgerEvent) => void;
|
||||||
|
reset: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useLiveStore = create<LiveState>((set) => ({
|
||||||
|
status: "connecting",
|
||||||
|
occupancy: null,
|
||||||
|
feed: [],
|
||||||
|
setStatus: (status) => set({ status }),
|
||||||
|
setOccupancy: (occupancy) => set({ occupancy }),
|
||||||
|
pushEvent: (e) =>
|
||||||
|
set((s) => ({
|
||||||
|
// Newest first; de-dupe by id (a reconnect can replay) and cap the length.
|
||||||
|
feed: s.feed.some((x) => x.id === e.id) ? s.feed : [e, ...s.feed].slice(0, MAX_FEED),
|
||||||
|
})),
|
||||||
|
reset: () => set({ status: "connecting", occupancy: null, feed: [] }),
|
||||||
|
}));
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { QueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
// Single QueryClient for the app. TanStack Query owns SERVER state (fetch, cache,
|
||||||
|
// refetch, loading/error) — wrapping the existing thin api.ts fetchers. Client/UI
|
||||||
|
// state (live feed, WS status) lives in Zustand, not here. The WS layer invalidates
|
||||||
|
// these caches on live events so Query stays the source of truth for server data.
|
||||||
|
//
|
||||||
|
// Defaults tuned for a single-appliance booth: no window-focus refetch (it's a
|
||||||
|
// kiosk, not a tab someone switches to), and a short staleTime since the WS is the
|
||||||
|
// real freshness mechanism — queries are the fallback/initial load.
|
||||||
|
export const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
staleTime: 5_000,
|
||||||
|
retry: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Stable query keys — referenced by both the screens and the WS invalidator. */
|
||||||
|
export const qk = {
|
||||||
|
me: ["me"] as const,
|
||||||
|
occupancy: ["occupancy"] as const,
|
||||||
|
events: ["events"] as const,
|
||||||
|
activeSessions: ["active-sessions"] as const,
|
||||||
|
siteConfig: ["site-config"] as const,
|
||||||
|
} as const;
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
import type { LedgerEvent, Occupancy } from "../api.js";
|
||||||
|
import { qk } from "./query.js";
|
||||||
|
import { useLiveStore } from "./live-store.js";
|
||||||
|
|
||||||
|
// Booth WebSocket client. Opens ONE socket to /api/ws and turns server pushes into
|
||||||
|
// (a) live-store updates for the ticker/occupancy and (b) Query cache invalidations
|
||||||
|
// so TanStack Query remains the source of truth for durable server data. The browser
|
||||||
|
// attaches the auth cookie automatically; the backend gates by cookie + Origin
|
||||||
|
// (see routes/ws.ts). Auto-reconnects with capped backoff so a booth left running
|
||||||
|
// recovers from a server restart without a manual refresh.
|
||||||
|
|
||||||
|
/** Server → client message shapes (mirror routes/ws.ts OutMsg). */
|
||||||
|
type WsMessage =
|
||||||
|
| { kind: "hello"; occupancy: Occupancy }
|
||||||
|
| { kind: "ledger"; event: LedgerEvent; occupancy: Occupancy }
|
||||||
|
| { kind: "printer-status"; event: unknown };
|
||||||
|
|
||||||
|
/** Build the ws:// or wss:// URL for the same origin the SPA is served from. */
|
||||||
|
function wsUrl(): string {
|
||||||
|
const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||||
|
return `${proto}//${window.location.host}/api/ws`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useLiveFeed(): void {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const { setStatus, setOccupancy, pushEvent } = useLiveStore();
|
||||||
|
// Hold the socket + reconnect timer across renders; guard against StrictMode
|
||||||
|
// double-invoke and unmount.
|
||||||
|
const sockRef = useRef<WebSocket | null>(null);
|
||||||
|
const retryRef = useRef(0);
|
||||||
|
const closedRef = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
closedRef.current = false;
|
||||||
|
|
||||||
|
const connect = () => {
|
||||||
|
if (closedRef.current) return;
|
||||||
|
setStatus(retryRef.current === 0 ? "connecting" : "connecting");
|
||||||
|
const sock = new WebSocket(wsUrl());
|
||||||
|
sockRef.current = sock;
|
||||||
|
|
||||||
|
sock.onopen = () => {
|
||||||
|
retryRef.current = 0;
|
||||||
|
setStatus("open");
|
||||||
|
};
|
||||||
|
|
||||||
|
sock.onmessage = (ev) => {
|
||||||
|
let msg: WsMessage;
|
||||||
|
try {
|
||||||
|
msg = JSON.parse(ev.data as string) as WsMessage;
|
||||||
|
} catch {
|
||||||
|
return; // ignore malformed frames
|
||||||
|
}
|
||||||
|
if (msg.kind === "hello") {
|
||||||
|
setOccupancy(msg.occupancy);
|
||||||
|
} else if (msg.kind === "ledger") {
|
||||||
|
setOccupancy(msg.occupancy);
|
||||||
|
pushEvent(msg.event);
|
||||||
|
// Keep Query authoritative: the durable event list, occupancy totals,
|
||||||
|
// and active-sessions list refetch on the next read instead of trusting
|
||||||
|
// the pushed copy alone.
|
||||||
|
void qc.invalidateQueries({ queryKey: qk.events });
|
||||||
|
void qc.invalidateQueries({ queryKey: qk.occupancy });
|
||||||
|
void qc.invalidateQueries({ queryKey: qk.activeSessions });
|
||||||
|
} else if (msg.kind === "printer-status") {
|
||||||
|
void qc.invalidateQueries({ queryKey: ["printers"] });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const scheduleReconnect = () => {
|
||||||
|
if (closedRef.current) return;
|
||||||
|
setStatus("closed");
|
||||||
|
// Capped exponential backoff: 0.5s, 1s, 2s, … up to 10s.
|
||||||
|
const delay = Math.min(500 * 2 ** retryRef.current, 10_000);
|
||||||
|
retryRef.current += 1;
|
||||||
|
window.setTimeout(connect, delay);
|
||||||
|
};
|
||||||
|
|
||||||
|
sock.onclose = scheduleReconnect;
|
||||||
|
// onerror fires before onclose; let onclose own the reconnect to avoid double.
|
||||||
|
sock.onerror = () => sock.close();
|
||||||
|
};
|
||||||
|
|
||||||
|
connect();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
closedRef.current = true;
|
||||||
|
sockRef.current?.close();
|
||||||
|
sockRef.current = null;
|
||||||
|
};
|
||||||
|
// qc / store setters are stable; run once on mount.
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import { StrictMode } from "react";
|
import { StrictMode } from "react";
|
||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
|
import "./index.css";
|
||||||
|
import "./lib/i18n/index.js"; // initialize i18next before the app renders
|
||||||
import { App } from "./App.js";
|
import { App } from "./App.js";
|
||||||
|
|
||||||
const rootEl = document.getElementById("root");
|
const rootEl = document.getElementById("root");
|
||||||
|
|||||||
@@ -0,0 +1,202 @@
|
|||||||
|
import {
|
||||||
|
createRootRouteWithContext,
|
||||||
|
createRoute,
|
||||||
|
createRouter,
|
||||||
|
Link,
|
||||||
|
Outlet,
|
||||||
|
redirect,
|
||||||
|
} from "@tanstack/react-router";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import type { Lang, SessionUser } from "./api.js";
|
||||||
|
import { logout, setLanguagePref } from "./api.js";
|
||||||
|
import { queryClient } from "./lib/query.js";
|
||||||
|
import { setLanguage } from "./lib/i18n/index.js";
|
||||||
|
import { useLiveFeed } from "./lib/use-live-feed.js";
|
||||||
|
import { StatusDot } from "./ui/StatusDot.js";
|
||||||
|
import { BoothScreen } from "./BoothScreen.js";
|
||||||
|
import { SetupWizard } from "./SetupWizard.js";
|
||||||
|
import { TariffComposer } from "./TariffComposer.js";
|
||||||
|
import { PermitManager } from "./PermitManager.js";
|
||||||
|
import { ShiftControl } from "./ShiftControl.js";
|
||||||
|
import { SiteSettings } from "./SiteSettings.js";
|
||||||
|
|
||||||
|
// Code-based TanStack Router (no file-based codegen — the app is small enough that
|
||||||
|
// an explicit tree is clearer). The router context carries the signed-in user and
|
||||||
|
// a setter so route guards can redirect by role. The root renders the terminal
|
||||||
|
// chrome (nav + user + live status) and opens the booth WebSocket once, app-wide.
|
||||||
|
|
||||||
|
export interface RouterContext {
|
||||||
|
user: SessionUser | null;
|
||||||
|
setUser: (u: SessionUser | null) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rootRoute = createRootRouteWithContext<RouterContext>()({
|
||||||
|
component: RootLayout,
|
||||||
|
});
|
||||||
|
|
||||||
|
function NavLink({ to, label }: { to: string; label: string }) {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
to={to}
|
||||||
|
className="px-2 py-1 text-[11px] uppercase tracking-wider text-term-muted rounded-term hover:text-term-text [&.active]:text-term-amber [&.active]:bg-term-panel-2"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** SQ/EN toggle. Persists the choice to the user's profile (restored on next login)
|
||||||
|
* and applies it immediately. Updates the router-context user so App re-syncs. */
|
||||||
|
function LanguageToggle({
|
||||||
|
user,
|
||||||
|
setUser,
|
||||||
|
}: {
|
||||||
|
user: SessionUser;
|
||||||
|
setUser: (u: SessionUser | null) => void;
|
||||||
|
}) {
|
||||||
|
async function pick(lang: Lang) {
|
||||||
|
if (lang === user.language) return;
|
||||||
|
setLanguage(lang); // instant UI
|
||||||
|
setUser({ ...user, language: lang });
|
||||||
|
try {
|
||||||
|
await setLanguagePref(lang); // persist
|
||||||
|
} catch {
|
||||||
|
/* non-fatal — the choice still applies this session */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-0.5 text-[10px] uppercase tracking-wider">
|
||||||
|
{(["sq", "en"] as const).map((l) => (
|
||||||
|
<button
|
||||||
|
key={l}
|
||||||
|
type="button"
|
||||||
|
onClick={() => pick(l)}
|
||||||
|
className={`rounded-term px-1.5 py-0.5 ${
|
||||||
|
user.language === l ? "bg-term-panel-2 text-term-amber" : "text-term-muted hover:text-term-text"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{l}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RootLayout() {
|
||||||
|
const { user, setUser } = rootRoute.useRouteContext();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
// One app-wide WebSocket for the live feed (booth + any live widget).
|
||||||
|
useLiveFeed();
|
||||||
|
const isAdmin = user?.role === "admin";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen flex-col bg-term-bg text-term-text">
|
||||||
|
<header className="flex items-center gap-4 border-b border-term-border bg-term-panel px-4 py-2">
|
||||||
|
<span className="text-sm font-bold uppercase tracking-widest text-term-amber">▮ Parking</span>
|
||||||
|
<nav className="flex items-center gap-1">
|
||||||
|
<NavLink to="/booth" label={t("nav.booth")} />
|
||||||
|
<NavLink to="/shift" label={t("nav.shift")} />
|
||||||
|
{isAdmin && <NavLink to="/setup" label={t("nav.setup")} />}
|
||||||
|
{isAdmin && <NavLink to="/tariff" label={t("nav.tariff")} />}
|
||||||
|
{isAdmin && <NavLink to="/permits" label={t("nav.permits")} />}
|
||||||
|
{isAdmin && <NavLink to="/site" label={t("nav.site")} />}
|
||||||
|
</nav>
|
||||||
|
<div className="ml-auto flex items-center gap-3">
|
||||||
|
{user && <LanguageToggle user={user} setUser={setUser} />}
|
||||||
|
<StatusDot />
|
||||||
|
<span className="text-[11px] text-term-muted">
|
||||||
|
{user?.username} · {user?.role}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rounded-term border border-term-border px-2 py-0.5 text-[11px] uppercase tracking-wider text-term-muted hover:text-term-text"
|
||||||
|
onClick={async () => {
|
||||||
|
await logout();
|
||||||
|
setUser(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("common.logout")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<main className="min-h-0 flex-1 overflow-auto p-3">
|
||||||
|
<Outlet />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const indexRoute = createRoute({
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
path: "/",
|
||||||
|
beforeLoad: () => {
|
||||||
|
throw redirect({ to: "/booth" });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const boothRoute = createRoute({
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
path: "/booth",
|
||||||
|
component: BoothScreen,
|
||||||
|
});
|
||||||
|
|
||||||
|
const shiftRoute = createRoute({
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
path: "/shift",
|
||||||
|
component: function ShiftRoute() {
|
||||||
|
const { user } = rootRoute.useRouteContext();
|
||||||
|
return <ShiftControl isAdmin={user?.role === "admin"} />;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Guard: admin-only routes redirect non-admins back to the booth. */
|
||||||
|
function adminOnly(ctx: RouterContext) {
|
||||||
|
if (ctx.user?.role !== "admin") throw redirect({ to: "/booth" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const setupRoute = createRoute({
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
path: "/setup",
|
||||||
|
beforeLoad: ({ context }) => adminOnly(context),
|
||||||
|
component: () => <SetupWizard />,
|
||||||
|
});
|
||||||
|
const tariffRoute = createRoute({
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
path: "/tariff",
|
||||||
|
beforeLoad: ({ context }) => adminOnly(context),
|
||||||
|
component: () => <TariffComposer />,
|
||||||
|
});
|
||||||
|
const permitsRoute = createRoute({
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
path: "/permits",
|
||||||
|
beforeLoad: ({ context }) => adminOnly(context),
|
||||||
|
component: () => <PermitManager />,
|
||||||
|
});
|
||||||
|
const siteRoute = createRoute({
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
path: "/site",
|
||||||
|
beforeLoad: ({ context }) => adminOnly(context),
|
||||||
|
component: () => <SiteSettings canEdit={true} />,
|
||||||
|
});
|
||||||
|
|
||||||
|
const routeTree = rootRoute.addChildren([
|
||||||
|
indexRoute,
|
||||||
|
boothRoute,
|
||||||
|
shiftRoute,
|
||||||
|
setupRoute,
|
||||||
|
tariffRoute,
|
||||||
|
permitsRoute,
|
||||||
|
siteRoute,
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const router = createRouter({
|
||||||
|
routeTree,
|
||||||
|
context: { user: null, setUser: () => {} },
|
||||||
|
defaultPreload: "intent",
|
||||||
|
});
|
||||||
|
|
||||||
|
declare module "@tanstack/react-router" {
|
||||||
|
interface Register {
|
||||||
|
router: typeof router;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
// Terminal panel: a bordered, titled box — the basic building block of the dense
|
||||||
|
// booth layout. Title bar in amber, square corners, subtle layered surfaces.
|
||||||
|
|
||||||
|
export function Panel({
|
||||||
|
title,
|
||||||
|
right,
|
||||||
|
children,
|
||||||
|
className = "",
|
||||||
|
}: {
|
||||||
|
title?: string;
|
||||||
|
/** Optional right-aligned content in the title bar (e.g. a status dot). */
|
||||||
|
right?: ReactNode;
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
className={`flex flex-col border border-term-border bg-term-panel rounded-term overflow-hidden ${className}`}
|
||||||
|
>
|
||||||
|
{title && (
|
||||||
|
<header className="flex items-center justify-between px-3 py-1.5 bg-term-panel-2 border-b border-term-border">
|
||||||
|
<h2 className="m-0 text-[11px] font-semibold uppercase tracking-wider text-term-amber">
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
{right}
|
||||||
|
</header>
|
||||||
|
)}
|
||||||
|
<div className="flex-1 min-h-0 p-3">{children}</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { fetchSnapshots, snapshotImageUrl } from "../api.js";
|
||||||
|
|
||||||
|
// Entry/exit evidence images for a session. Lets the operator verify the car at the
|
||||||
|
// booth against the ticket. Thumbnails load from /api/snapshots/:id (cookie-authed,
|
||||||
|
// served with a long immutable cache); clicking one enlarges it. Read-only.
|
||||||
|
|
||||||
|
export function SnapshotStrip({ identity }: { identity: string }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ["snapshots", identity],
|
||||||
|
queryFn: () => fetchSnapshots(identity),
|
||||||
|
enabled: !!identity,
|
||||||
|
});
|
||||||
|
const [zoom, setZoom] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const shots = data?.snapshots ?? [];
|
||||||
|
|
||||||
|
if (isLoading) return <div className="text-[11px] text-term-muted">{t("pay.loadingSnapshots")}</div>;
|
||||||
|
if (shots.length === 0) return <div className="text-[11px] text-term-muted">{t("pay.noSnapshots")}</div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{shots.map((s) => (
|
||||||
|
<button
|
||||||
|
key={s.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setZoom(s.id)}
|
||||||
|
className="group flex flex-col items-center gap-1 rounded-term border border-term-border bg-term-panel-2 p-1 hover:border-term-amber"
|
||||||
|
title={`${s.direction ?? "snapshot"} · ${new Date(s.capturedAt).toLocaleString()}`}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={snapshotImageUrl(s.id)}
|
||||||
|
alt={s.direction ?? "snapshot"}
|
||||||
|
className="h-20 w-28 object-cover"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className={`text-[9px] uppercase tracking-wider ${
|
||||||
|
s.direction === "entry" ? "text-term-green" : s.direction === "exit" ? "text-term-red" : "text-term-muted"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{s.direction ?? "—"}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{zoom && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 p-6"
|
||||||
|
onClick={() => setZoom(null)}
|
||||||
|
>
|
||||||
|
<img src={snapshotImageUrl(zoom)} alt="snapshot" className="max-h-full max-w-full object-contain" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useLiveStore, type WsStatus } from "../lib/live-store.js";
|
||||||
|
|
||||||
|
// Small live-connection indicator for the booth chrome: a coloured dot + label
|
||||||
|
// reflecting the WebSocket status. Green = live, amber = connecting, red = down.
|
||||||
|
|
||||||
|
const COLOR: Record<WsStatus, string> = {
|
||||||
|
open: "bg-term-green",
|
||||||
|
connecting: "bg-term-amber",
|
||||||
|
closed: "bg-term-red",
|
||||||
|
};
|
||||||
|
const LABEL_KEY: Record<WsStatus, string> = {
|
||||||
|
open: "status.live",
|
||||||
|
connecting: "status.connecting",
|
||||||
|
closed: "status.offline",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function StatusDot() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const status = useLiveStore((s) => s.status);
|
||||||
|
return (
|
||||||
|
<span className="flex items-center gap-1.5 text-[10px] uppercase tracking-wider text-term-muted">
|
||||||
|
<span
|
||||||
|
className={`inline-block h-2 w-2 rounded-full ${COLOR[status]} ${status === "open" ? "" : "animate-pulse"}`}
|
||||||
|
/>
|
||||||
|
{t(LABEL_KEY[status])}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,18 +1,25 @@
|
|||||||
import react from "@vitejs/plugin-react";
|
import react from "@vitejs/plugin-react";
|
||||||
|
import tailwindcss from "@tailwindcss/vite";
|
||||||
import { defineConfig } from "vite";
|
import { defineConfig } from "vite";
|
||||||
|
|
||||||
// Operator SPA. Built by Vite and served by Fastify in production
|
// Operator SPA. Built by Vite and served by Fastify in production
|
||||||
// (see wiki/entities/react-vite-spa.md). The dev proxy points the API at the
|
// (see wiki/entities/react-vite-spa.md). The dev proxy points the API at the
|
||||||
// local Fastify server.
|
// local Fastify server.
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react(), tailwindcss()],
|
||||||
server: {
|
server: {
|
||||||
port: 5173,
|
port: 5173,
|
||||||
proxy: {
|
proxy: {
|
||||||
// Use 127.0.0.1 (not "localhost") so the proxy never tries IPv6 ::1
|
// Use 127.0.0.1 (not "localhost") so the proxy never tries IPv6 ::1
|
||||||
// first and stall — the backend binds IPv4. Avoids slow/hung requests,
|
// first and stall — the backend binds IPv4. Avoids slow/hung requests,
|
||||||
// notably under WSL2 mirrored networking.
|
// notably under WSL2 mirrored networking.
|
||||||
"/api": "http://127.0.0.1:3000",
|
"/api": {
|
||||||
|
target: "http://127.0.0.1:3000",
|
||||||
|
// The live booth feed (/api/ws) is a WebSocket — without `ws: true` the
|
||||||
|
// proxy would not forward the upgrade. The backend's Origin allowlist must
|
||||||
|
// include the dev origin (http://localhost:5173) via WS_ALLOWED_ORIGINS.
|
||||||
|
ws: true,
|
||||||
|
},
|
||||||
"/health": "http://127.0.0.1:3000",
|
"/health": "http://127.0.0.1:3000",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE `site_config` ADD `exit_voucher_default` integer DEFAULT false NOT NULL;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE `users` ADD `language` text DEFAULT 'sq' NOT NULL;
|
||||||
@@ -0,0 +1,805 @@
|
|||||||
|
{
|
||||||
|
"version": "6",
|
||||||
|
"dialect": "sqlite",
|
||||||
|
"id": "dbee8e05-0b49-4af7-962c-9aab53b36eb7",
|
||||||
|
"prevId": "2cfc13fa-43fc-4f89-8438-7b9bcaf7ea3b",
|
||||||
|
"tables": {
|
||||||
|
"blocklist": {
|
||||||
|
"name": "blocklist",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"kind": {
|
||||||
|
"name": "kind",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"value": {
|
||||||
|
"name": "value",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"reason": {
|
||||||
|
"name": "reason",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"active": {
|
||||||
|
"name": "active",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
"added_by": {
|
||||||
|
"name": "added_by",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"added_at": {
|
||||||
|
"name": "added_at",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(current_timestamp)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"device_events": {
|
||||||
|
"name": "device_events",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"device_id": {
|
||||||
|
"name": "device_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"category": {
|
||||||
|
"name": "category",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"kind": {
|
||||||
|
"name": "kind",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"detail": {
|
||||||
|
"name": "detail",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"occurred_at": {
|
||||||
|
"name": "occurred_at",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(current_timestamp)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"devices": {
|
||||||
|
"name": "devices",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"category": {
|
||||||
|
"name": "category",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"driver_id": {
|
||||||
|
"name": "driver_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"config": {
|
||||||
|
"name": "config",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"enabled": {
|
||||||
|
"name": "enabled",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(current_timestamp)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"ledger_events": {
|
||||||
|
"name": "ledger_events",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"index": {
|
||||||
|
"name": "index",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"name": "type",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"direction": {
|
||||||
|
"name": "direction",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"source": {
|
||||||
|
"name": "source",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"identity": {
|
||||||
|
"name": "identity",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"payload": {
|
||||||
|
"name": "payload",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"occurred_at": {
|
||||||
|
"name": "occurred_at",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"prev_hash": {
|
||||||
|
"name": "prev_hash",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"signature": {
|
||||||
|
"name": "signature",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"key_id": {
|
||||||
|
"name": "key_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"ledger_events_index_unique": {
|
||||||
|
"name": "ledger_events_index_unique",
|
||||||
|
"columns": [
|
||||||
|
"index"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"permit_credentials": {
|
||||||
|
"name": "permit_credentials",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"permit_id": {
|
||||||
|
"name": "permit_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"kind": {
|
||||||
|
"name": "kind",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"value": {
|
||||||
|
"name": "value",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"permit_plates": {
|
||||||
|
"name": "permit_plates",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"permit_id": {
|
||||||
|
"name": "permit_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"plate": {
|
||||||
|
"name": "plate",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"permits": {
|
||||||
|
"name": "permits",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"holder_name": {
|
||||||
|
"name": "holder_name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"contact": {
|
||||||
|
"name": "contact",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"max_concurrent": {
|
||||||
|
"name": "max_concurrent",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 1
|
||||||
|
},
|
||||||
|
"valid_from": {
|
||||||
|
"name": "valid_from",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"valid_to": {
|
||||||
|
"name": "valid_to",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"name": "status",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'active'"
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(current_timestamp)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"sessions": {
|
||||||
|
"name": "sessions",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"identity": {
|
||||||
|
"name": "identity",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"source": {
|
||||||
|
"name": "source",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"permit_id": {
|
||||||
|
"name": "permit_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"entered_at": {
|
||||||
|
"name": "entered_at",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"exited_at": {
|
||||||
|
"name": "exited_at",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"state": {
|
||||||
|
"name": "state",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'open'"
|
||||||
|
},
|
||||||
|
"last_event_index": {
|
||||||
|
"name": "last_event_index",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"setup_state": {
|
||||||
|
"name": "setup_state",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"completed_at": {
|
||||||
|
"name": "completed_at",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"site_config": {
|
||||||
|
"name": "site_config",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"capacity": {
|
||||||
|
"name": "capacity",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"park_name": {
|
||||||
|
"name": "park_name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"operator_name": {
|
||||||
|
"name": "operator_name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"nius": {
|
||||||
|
"name": "nius",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"address": {
|
||||||
|
"name": "address",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"phone": {
|
||||||
|
"name": "phone",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"email": {
|
||||||
|
"name": "email",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"exit_voucher_default": {
|
||||||
|
"name": "exit_voucher_default",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(current_timestamp)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"snapshots": {
|
||||||
|
"name": "snapshots",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"direction": {
|
||||||
|
"name": "direction",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"device_id": {
|
||||||
|
"name": "device_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"identity": {
|
||||||
|
"name": "identity",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"content_type": {
|
||||||
|
"name": "content_type",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"bytes": {
|
||||||
|
"name": "bytes",
|
||||||
|
"type": "blob",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"captured_at": {
|
||||||
|
"name": "captured_at",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"tariff_versions": {
|
||||||
|
"name": "tariff_versions",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"tariff_id": {
|
||||||
|
"name": "tariff_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"effective_from": {
|
||||||
|
"name": "effective_from",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"currency": {
|
||||||
|
"name": "currency",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"structure": {
|
||||||
|
"name": "structure",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_by": {
|
||||||
|
"name": "created_by",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(current_timestamp)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"tariffs": {
|
||||||
|
"name": "tariffs",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"scope": {
|
||||||
|
"name": "scope",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'site'"
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(current_timestamp)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"users": {
|
||||||
|
"name": "users",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"username": {
|
||||||
|
"name": "username",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"password_hash": {
|
||||||
|
"name": "password_hash",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"role": {
|
||||||
|
"name": "role",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(current_timestamp)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"users_username_unique": {
|
||||||
|
"name": "users_username_unique",
|
||||||
|
"columns": [
|
||||||
|
"username"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"views": {},
|
||||||
|
"enums": {},
|
||||||
|
"_meta": {
|
||||||
|
"schemas": {},
|
||||||
|
"tables": {},
|
||||||
|
"columns": {}
|
||||||
|
},
|
||||||
|
"internal": {
|
||||||
|
"indexes": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,813 @@
|
|||||||
|
{
|
||||||
|
"version": "6",
|
||||||
|
"dialect": "sqlite",
|
||||||
|
"id": "620eba1b-2c7e-4bd4-8b3e-c779a69e87b9",
|
||||||
|
"prevId": "dbee8e05-0b49-4af7-962c-9aab53b36eb7",
|
||||||
|
"tables": {
|
||||||
|
"blocklist": {
|
||||||
|
"name": "blocklist",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"kind": {
|
||||||
|
"name": "kind",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"value": {
|
||||||
|
"name": "value",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"reason": {
|
||||||
|
"name": "reason",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"active": {
|
||||||
|
"name": "active",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
"added_by": {
|
||||||
|
"name": "added_by",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"added_at": {
|
||||||
|
"name": "added_at",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(current_timestamp)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"device_events": {
|
||||||
|
"name": "device_events",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"device_id": {
|
||||||
|
"name": "device_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"category": {
|
||||||
|
"name": "category",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"kind": {
|
||||||
|
"name": "kind",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"detail": {
|
||||||
|
"name": "detail",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"occurred_at": {
|
||||||
|
"name": "occurred_at",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(current_timestamp)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"devices": {
|
||||||
|
"name": "devices",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"category": {
|
||||||
|
"name": "category",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"driver_id": {
|
||||||
|
"name": "driver_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"config": {
|
||||||
|
"name": "config",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"enabled": {
|
||||||
|
"name": "enabled",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(current_timestamp)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"ledger_events": {
|
||||||
|
"name": "ledger_events",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"index": {
|
||||||
|
"name": "index",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"name": "type",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"direction": {
|
||||||
|
"name": "direction",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"source": {
|
||||||
|
"name": "source",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"identity": {
|
||||||
|
"name": "identity",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"payload": {
|
||||||
|
"name": "payload",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"occurred_at": {
|
||||||
|
"name": "occurred_at",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"prev_hash": {
|
||||||
|
"name": "prev_hash",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"signature": {
|
||||||
|
"name": "signature",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"key_id": {
|
||||||
|
"name": "key_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"ledger_events_index_unique": {
|
||||||
|
"name": "ledger_events_index_unique",
|
||||||
|
"columns": [
|
||||||
|
"index"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"permit_credentials": {
|
||||||
|
"name": "permit_credentials",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"permit_id": {
|
||||||
|
"name": "permit_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"kind": {
|
||||||
|
"name": "kind",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"value": {
|
||||||
|
"name": "value",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"permit_plates": {
|
||||||
|
"name": "permit_plates",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"permit_id": {
|
||||||
|
"name": "permit_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"plate": {
|
||||||
|
"name": "plate",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"permits": {
|
||||||
|
"name": "permits",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"holder_name": {
|
||||||
|
"name": "holder_name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"contact": {
|
||||||
|
"name": "contact",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"max_concurrent": {
|
||||||
|
"name": "max_concurrent",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 1
|
||||||
|
},
|
||||||
|
"valid_from": {
|
||||||
|
"name": "valid_from",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"valid_to": {
|
||||||
|
"name": "valid_to",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"name": "status",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'active'"
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(current_timestamp)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"sessions": {
|
||||||
|
"name": "sessions",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"identity": {
|
||||||
|
"name": "identity",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"source": {
|
||||||
|
"name": "source",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"permit_id": {
|
||||||
|
"name": "permit_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"entered_at": {
|
||||||
|
"name": "entered_at",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"exited_at": {
|
||||||
|
"name": "exited_at",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"state": {
|
||||||
|
"name": "state",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'open'"
|
||||||
|
},
|
||||||
|
"last_event_index": {
|
||||||
|
"name": "last_event_index",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"setup_state": {
|
||||||
|
"name": "setup_state",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"completed_at": {
|
||||||
|
"name": "completed_at",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"site_config": {
|
||||||
|
"name": "site_config",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"capacity": {
|
||||||
|
"name": "capacity",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"park_name": {
|
||||||
|
"name": "park_name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"operator_name": {
|
||||||
|
"name": "operator_name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"nius": {
|
||||||
|
"name": "nius",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"address": {
|
||||||
|
"name": "address",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"phone": {
|
||||||
|
"name": "phone",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"email": {
|
||||||
|
"name": "email",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"exit_voucher_default": {
|
||||||
|
"name": "exit_voucher_default",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(current_timestamp)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"snapshots": {
|
||||||
|
"name": "snapshots",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"direction": {
|
||||||
|
"name": "direction",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"device_id": {
|
||||||
|
"name": "device_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"identity": {
|
||||||
|
"name": "identity",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"content_type": {
|
||||||
|
"name": "content_type",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"bytes": {
|
||||||
|
"name": "bytes",
|
||||||
|
"type": "blob",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"captured_at": {
|
||||||
|
"name": "captured_at",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"tariff_versions": {
|
||||||
|
"name": "tariff_versions",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"tariff_id": {
|
||||||
|
"name": "tariff_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"effective_from": {
|
||||||
|
"name": "effective_from",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"currency": {
|
||||||
|
"name": "currency",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"structure": {
|
||||||
|
"name": "structure",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_by": {
|
||||||
|
"name": "created_by",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(current_timestamp)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"tariffs": {
|
||||||
|
"name": "tariffs",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"scope": {
|
||||||
|
"name": "scope",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'site'"
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(current_timestamp)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"users": {
|
||||||
|
"name": "users",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"username": {
|
||||||
|
"name": "username",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"password_hash": {
|
||||||
|
"name": "password_hash",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"role": {
|
||||||
|
"name": "role",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"language": {
|
||||||
|
"name": "language",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'sq'"
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(current_timestamp)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"users_username_unique": {
|
||||||
|
"name": "users_username_unique",
|
||||||
|
"columns": [
|
||||||
|
"username"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"views": {},
|
||||||
|
"enums": {},
|
||||||
|
"_meta": {
|
||||||
|
"schemas": {},
|
||||||
|
"tables": {},
|
||||||
|
"columns": {}
|
||||||
|
},
|
||||||
|
"internal": {
|
||||||
|
"indexes": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,20 @@
|
|||||||
"when": 1781682176094,
|
"when": 1781682176094,
|
||||||
"tag": "0001_neat_slipstream",
|
"tag": "0001_neat_slipstream",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 2,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1781713560438,
|
||||||
|
"tag": "0002_panoramic_tiger_shark",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 3,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1781774228086,
|
||||||
|
"tag": "0003_early_hawkeye",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -22,6 +22,12 @@ export const users = sqliteTable("users", {
|
|||||||
role: text("role", {
|
role: text("role", {
|
||||||
enum: ["admin", "operator", "cashier", "readonly"],
|
enum: ["admin", "operator", "cashier", "readonly"],
|
||||||
}).notNull(),
|
}).notNull(),
|
||||||
|
// Preferred UI language for this user (operator-facing). Loaded on login and
|
||||||
|
// restored from any booth. Albanian is the default. Printed tickets are NOT
|
||||||
|
// governed by this — they're always Albanian (customer-facing). See i18n.md.
|
||||||
|
language: text("language", { enum: ["sq", "en"] })
|
||||||
|
.notNull()
|
||||||
|
.default("sq"),
|
||||||
createdAt: text("created_at")
|
createdAt: text("created_at")
|
||||||
.notNull()
|
.notNull()
|
||||||
.default(sql`(current_timestamp)`),
|
.default(sql`(current_timestamp)`),
|
||||||
@@ -151,6 +157,15 @@ export const siteConfig = sqliteTable("site_config", {
|
|||||||
phone: text("phone"),
|
phone: text("phone"),
|
||||||
/** Contact email. */
|
/** Contact email. */
|
||||||
email: text("email"),
|
email: text("email"),
|
||||||
|
/** Default for the booth pay modal's "print exit ticket" checkbox. Site-wide
|
||||||
|
* because it's booth GEOGRAPHY: when the booth is far from the exit, the
|
||||||
|
* customer pays at the booth and self-exits later by scanning a printed exit
|
||||||
|
* voucher (= the ticket id reprinted, now paid). When near the exit, the booth
|
||||||
|
* opens the barrier directly. The operator may still override per transaction.
|
||||||
|
* Stored 0/1 (SQLite has no bool). See wiki/concepts/booth-exit-flow.md. */
|
||||||
|
exitVoucherDefault: integer("exit_voucher_default", { mode: "boolean" })
|
||||||
|
.notNull()
|
||||||
|
.default(false),
|
||||||
updatedAt: text("updated_at")
|
updatedAt: text("updated_at")
|
||||||
.notNull()
|
.notNull()
|
||||||
.default(sql`(current_timestamp)`),
|
.default(sql`(current_timestamp)`),
|
||||||
|
|||||||
@@ -52,6 +52,10 @@ export type LedgerEventType =
|
|||||||
// with a takings summary (shift_z_report). See wiki/concepts/shift.md.
|
// with a takings summary (shift_z_report). See wiki/concepts/shift.md.
|
||||||
| "shift_open"
|
| "shift_open"
|
||||||
| "shift_z_report"
|
| "shift_z_report"
|
||||||
|
// Admin loads/removes physical drawer cash (the float). Signed payload:
|
||||||
|
// { amountMinor (signed: + load, − removal), reason, currency, operator }.
|
||||||
|
// Folds into the drawer balance carried across shifts. See wiki/concepts/shift.md.
|
||||||
|
| "cash_movement"
|
||||||
| "anomaly";
|
| "anomaly";
|
||||||
|
|
||||||
/** How money was tendered (for payment events + the shift Z-report). */
|
/** How money was tendered (for payment events + the shift Z-report). */
|
||||||
|
|||||||
Generated
+1267
-7
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,131 @@
|
|||||||
|
---
|
||||||
|
type: concept
|
||||||
|
tags: [parking, domain, booth, exit, payment, threat-model]
|
||||||
|
sources: []
|
||||||
|
updated: 2026-06-17
|
||||||
|
status: open
|
||||||
|
---
|
||||||
|
|
||||||
|
# Booth Exit Flow — pay-at-booth, voucher vs. immediate exit
|
||||||
|
|
||||||
|
How the **manned booth** takes payment for a transient ticket and lets the car out. Complements the
|
||||||
|
unattended reader path in [[parking-session]] / the exit flow: same signed events, a booth-driven
|
||||||
|
trigger. Decided 2026-06-17.
|
||||||
|
|
||||||
|
## Operator flow
|
||||||
|
|
||||||
|
1. **Ticket input** on the booth screen. The operator scans (HID scanner types the id + Enter) or
|
||||||
|
keys the ticket number.
|
||||||
|
2. On submit, the booth **looks up the session** and opens a **modal**: entry time, exit time (now),
|
||||||
|
**duration**, **total owed** (the [[tariff]] quote), tender (cash/card), and a checkbox
|
||||||
|
**"Printo biletë dalje"** (print exit ticket).
|
||||||
|
3. The operator takes payment → a signed `payment` event ([[parking-session]]). What happens next
|
||||||
|
depends on the checkbox:
|
||||||
|
- **Checked → print an exit voucher.** The customer carries it to a (distant) exit and
|
||||||
|
**self-exits by scanning it** there; that scan runs the normal reader exit flow. The booth does
|
||||||
|
NOT open the barrier.
|
||||||
|
- **Unchecked → immediate exit.** When the modal closes after a successful payment, the booth
|
||||||
|
**signs `vehicle_exit`, pulses the exit relay, and fires the exit snapshot** right away (booth is
|
||||||
|
at/near the exit).
|
||||||
|
|
||||||
|
## Settled decisions (2026-06-17)
|
||||||
|
|
||||||
|
- **Voucher carries the SAME ticket id** (reprinted as the Code128 barcode). At the exit reader it
|
||||||
|
runs the existing exit validation — which now finds the session **paid + within walk-back grace**,
|
||||||
|
so it opens. No new identity or code type; the "biletë dalje" is a *paid reprint* of the entry
|
||||||
|
ticket id. Reuses [[tariff|walk-back grace]] exactly.
|
||||||
|
- **The checkbox default lives in `site_config`** (`exit_voucher_default`, a site-wide boolean edited
|
||||||
|
in Site settings) — because it's booth geography, not per-ticket. The operator may override per
|
||||||
|
transaction. (Per-exit-point config deferred until a site has both a near and a far exit.)
|
||||||
|
- **Payment is never rolled back.** If the checkbox is OFF and `pulseOpen` fails (offline
|
||||||
|
controller), the signed `payment` + `vehicle_exit` already stand (money was taken, the car is
|
||||||
|
owed an exit). The booth surfaces a clear error and an **audited `anomaly`** so the operator opens
|
||||||
|
manually — we never silently drop the payment, and never leave a paid car without an exit event.
|
||||||
|
|
||||||
|
## Threat-model notes ([[threat-model|operator as adversary]])
|
||||||
|
|
||||||
|
- The booth exit reuses the **same validation as the reader path** (paid + within grace, or free
|
||||||
|
entry-grace) — there is no booth-only bypass that admits an unpaid car. An unpaid ticket sends the
|
||||||
|
operator to take payment first.
|
||||||
|
- Every booth action is a **signed ledger event attributed to the operator's session**: the payment,
|
||||||
|
the exit, and any `anomaly` (failed open / override). A colluding operator can't wave a car out
|
||||||
|
without leaving a signed, attributed trail visible to [[reconciliation]].
|
||||||
|
- The voucher path keeps the **camera snapshot at the physical exit** (the self-scan fires it), so
|
||||||
|
the evidence is captured where the car actually leaves, not where it paid.
|
||||||
|
|
||||||
|
## Active sessions & human-intervention barrier open
|
||||||
|
|
||||||
|
**The barrier state is ASSUMED, never confirmed.** We send "open" intent and never truly know the car
|
||||||
|
cleared ([[barrier-not-a-door]], no wired loop/sensor feedback). So a signed `vehicle_exit` does NOT
|
||||||
|
mean the car is gone — it may be stuck (damaged ticket / dead scanner, or the barrier re-closed on a
|
||||||
|
phantom obstacle: an animal, a person, a cardboard box or bag in the wind). These edge cases need a
|
||||||
|
**human in the booth** to open the barrier, leaving a signed trace.
|
||||||
|
|
||||||
|
**A session is "active" (shown in the booth Active Sessions list) while it is EITHER:**
|
||||||
|
- **open** — entered, no `vehicle_exit` yet (still inside), OR
|
||||||
|
- **exited but `now ≤ graceExpiresAt`** — paid and/or the voucher scanned, but still within the
|
||||||
|
walk-back grace window. Because the barrier is unconfirmed, the car is presumed *possibly still
|
||||||
|
present* until grace expires. **Payment and a successful voucher scan do NOT remove it from the
|
||||||
|
list** — only grace expiry does.
|
||||||
|
|
||||||
|
A session drops off the list once it is exited **and** past grace (presumed truly gone).
|
||||||
|
|
||||||
|
### The one operator action — "Open barrier" (audited re-pulse)
|
||||||
|
|
||||||
|
For an active session, the operator can open the barrier as a **human intervention**. This:
|
||||||
|
- **re-pulses an exit relay** (resolved site-wide, as the booth exit does), and
|
||||||
|
- signs an **`anomaly`** (`source: booth`, attributed to the operator, reason "manual barrier open")
|
||||||
|
— **NEVER a second `vehicle_exit`** (a second exit would double-count occupancy and corrupt the
|
||||||
|
ledger's meaning). It is an audited *re-open*, not a new exit.
|
||||||
|
|
||||||
|
**Guard — no payment, no button.** The "Open barrier" action is shown/active **only for sessions that
|
||||||
|
have a payment** (paid, or paid-and-exited-in-grace). An **unpaid** open session has **no barrier-open
|
||||||
|
affordance at all** — the row routes to the [[#operator-flow|pay/exit modal]] instead. The
|
||||||
|
no-unpaid-bypass rule is enforced structurally: the button simply does not exist for an unpaid car.
|
||||||
|
(A future reason-required *force exit* for genuine disputes would be a separately-audited path — see
|
||||||
|
Open.)
|
||||||
|
|
||||||
|
This single mechanism covers both edge cases: a **damaged ticket / dead scanner** (find the still-open
|
||||||
|
session in the list → pay/exit modal, or if already paid → Open barrier, no scan needed), and a
|
||||||
|
**phantom-obstacle re-close** (the just-exited car is still in the list within grace → Open barrier).
|
||||||
|
|
||||||
|
## ⚠ Open question — walk-back grace renews on every payment (voucher overstay)
|
||||||
|
|
||||||
|
**Found 2026-06-17. Not yet fixed.** Scenario: customer pays at the booth, takes an exit voucher,
|
||||||
|
then dawdles past the walk-back grace before reaching the exit.
|
||||||
|
|
||||||
|
What the code does today (`exit-flow.ts`, `pay-station.ts`):
|
||||||
|
- The exit reader's grace check is `now − paidAt ≤ graceExitMin`, reading **the latest payment's**
|
||||||
|
`graceExitMin`. Over the window → exit **refuses** ("top-up required"). ✓ *Correct — no free exit.*
|
||||||
|
- The re-quote (`computeFee(enteredAt, now, …)`) always prices from **entry**, never from the last
|
||||||
|
payment. So a top-up charges the **full** entry→now fee (minus what's paid is implicit via the
|
||||||
|
ledger). ✓ *Correct — the timer does NOT restart; the customer pays the true total.*
|
||||||
|
- BUT every `payment` writes its own `graceExitMin`, and the exit flow reads the **latest** one — so
|
||||||
|
**each top-up grants a fresh, full grace window.** ✗ *This is the bug.*
|
||||||
|
|
||||||
|
**The leak is time, not money.** It is not a free-exit hole (the fee always catches up from entry).
|
||||||
|
But the grace window — meant as a one-time walk-from-pay-to-gate allowance — is re-granted in full on
|
||||||
|
every payment, so a customer could pay → wait → pay a tiny delta → get another full window → repeat,
|
||||||
|
riding the gap between "paid" and "next increment accrues." With coarse [[tariff]] increments the
|
||||||
|
abuse is bounded but real.
|
||||||
|
|
||||||
|
**Candidate fixes (business call — fairness vs. anti-abuse):**
|
||||||
|
1. **Grace on top-up only when the top-up charged new money** (recommended). Kills the "tiny delta
|
||||||
|
forever" loop while staying fair to a genuine overstay; re-price stays from entry.
|
||||||
|
2. **Single non-renewing window** anchored to the FIRST payment — cleanest anti-abuse, but can unfairly
|
||||||
|
trap someone who legitimately paid, walked, then hit a slow elevator after a top-up.
|
||||||
|
3. **Cap total grace** granted per session regardless of payment count.
|
||||||
|
|
||||||
|
Decided halves: **refuse-on-expiry** and **reprice-from-entry** are deliberate and correct. The
|
||||||
|
**grace-renews-fully-per-payment** consequence was an unintended side effect of reading `graceExitMin`
|
||||||
|
off the latest payment. See [[tariff]] (walk-back grace) for the pricing side of the same question.
|
||||||
|
|
||||||
|
## As-built / open
|
||||||
|
- Backend: `GET /api/session/:identity` (lookup + quote), `POST /api/exit { identity }` (validated
|
||||||
|
booth exit), `site_config.exit_voucher_default`. Exit validation shared between the booth and the
|
||||||
|
reader path (one code path, two triggers).
|
||||||
|
- **Open: walk-back grace renews on every payment** — see the flagged section above (voucher overstay
|
||||||
|
re-grants a full grace window; pick a fix before production).
|
||||||
|
- Voucher print = reprint the ticket id barcode on the booth printer ([[ticket-encoding]]).
|
||||||
|
- Open: a force-open **override** (lost ticket / equipment fault) — deferred; would be a separately
|
||||||
|
audited signed event, not folded into the validated path.
|
||||||
@@ -70,6 +70,54 @@ no variance gate, no manager override.
|
|||||||
close totals correct + signed + printed → close-again 409 → re-open works; readonly 403;
|
close totals correct + signed + printed → close-again 409 → re-open works; readonly 403;
|
||||||
verifyChain ok.
|
verifyChain ok.
|
||||||
|
|
||||||
|
## Drawer balance — opening float, cash movements, carry-over (decided 2026-06-18)
|
||||||
|
|
||||||
|
The Z-report's payment totals answer "how much did this shift *take*?" — but a manned booth also has a
|
||||||
|
**physical cash drawer** that carries across shifts. The drawer is tracked as a running balance over
|
||||||
|
the signed chain, so each shift knows what it **inherited** and what it should **hand over**.
|
||||||
|
|
||||||
|
**The events:**
|
||||||
|
- A new signed **`cash_movement`** event: the admin loads or removes drawer cash, `{ amountMinor
|
||||||
|
(signed: + load, − removal), reason, operator }`. **Admin-only** (an operator takes payments but
|
||||||
|
cannot move the float in/out). The opening-day load (+5000 ALL) and a mid-shift withdrawal (−5000)
|
||||||
|
are both `cash_movement` events.
|
||||||
|
- The existing `payment` events already add cash to the drawer (cash tender only; card never touches
|
||||||
|
the drawer).
|
||||||
|
|
||||||
|
**The math — drawer is a fold over the chain BY TIME, not by operator** (a `cash_movement` is the
|
||||||
|
admin's, not the shift operator's, so it can't key off `identity`):
|
||||||
|
|
||||||
|
```
|
||||||
|
expectedDrawer(at) = Σ cash payments (tender=cash) up to `at`
|
||||||
|
+ Σ cash_movement amounts up to `at`
|
||||||
|
```
|
||||||
|
|
||||||
|
A shift's **opening float = expectedDrawer(shiftStart)** — i.e. everything that happened to the drawer
|
||||||
|
before this shift's start mark. It is **auto-inherited from the chain** (no operator entry). The
|
||||||
|
first shift ever opens at **0**; the admin's load makes it 5000.
|
||||||
|
|
||||||
|
**The Z-report at close** reports the full drawer picture for the shift window `[start, end]`:
|
||||||
|
`openingFloat`, `cashTakenMinor` (cash payments in-window), `cashAddedMinor` / `cashRemovedMinor`
|
||||||
|
(movements in-window), and `expectedDrawerMinor = openingFloat + cashTaken + cashAdded − cashRemoved`.
|
||||||
|
That `expectedDrawer` is exactly the **next** shift's opening float — the carry-over.
|
||||||
|
|
||||||
|
**Worked example (the canonical scenario):**
|
||||||
|
|
||||||
|
| Step | Event | Drawer |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Opening day | admin `cash_movement` +5000 | 5000 |
|
||||||
|
| Shift 1 takes 6500 cash | payments | 11500 |
|
||||||
|
| Shift 1 closes | Z: open 5000, took 6500, expected **11500** | 11500 |
|
||||||
|
| Shift 2 opens | opening float = **11500** (inherited) | 11500 |
|
||||||
|
| admin `cash_movement` −5000 | withdrawal | 6500 |
|
||||||
|
| Shift 2 takes 4500 cash | payments | 11000 |
|
||||||
|
| Shift 2 closes | Z: open 11500, took 4500, removed 5000, expected **11000** | 11000 |
|
||||||
|
| Shift 3 opens | opening float = **11000** | … |
|
||||||
|
|
||||||
|
Card payments are excluded from the drawer (they settle to the bank, not the till). The drawer figure
|
||||||
|
is **expected**, not counted — the optional blind-count enhancement below would record the *variance*
|
||||||
|
against it.
|
||||||
|
|
||||||
## Where the fraud control actually lives
|
## Where the fraud control actually lives
|
||||||
|
|
||||||
Deliberately **not** in a shift-close ceremony. Because every payment is a **signed event in the
|
Deliberately **not** in a shift-close ceremony. Because every payment is a **signed event in the
|
||||||
@@ -86,6 +134,9 @@ reconciles the signed Z-report against the actual drawer and the bank/POS batch
|
|||||||
|
|
||||||
## Open
|
## Open
|
||||||
|
|
||||||
|
- **Drawer carry-over (decided 2026-06-18, building):** opening float auto-inherits the prior shift's
|
||||||
|
expected drawer; admin-only `cash_movement` events; Z-report reports the full drawer picture. See
|
||||||
|
the Drawer balance section above.
|
||||||
- **Shift ↔ session boundary:** a vehicle may enter under one shift and pay under another — the
|
- **Shift ↔ session boundary:** a vehicle may enter under one shift and pay under another — the
|
||||||
Z-report sums by **payment time** (when cash/card was taken), which is the operator who handled
|
Z-report sums by **payment time** (when cash/card was taken), which is the operator who handled
|
||||||
the money. Confirm that's the intended accountability (vs. by entry).
|
the money. Confirm that's the intended accountability (vs. by entry).
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
---
|
||||||
|
type: concept
|
||||||
|
tags: [parking, domain, business, pricing, design]
|
||||||
|
sources: [parksql2017-legacy-schema]
|
||||||
|
updated: 2026-06-17
|
||||||
|
status: open
|
||||||
|
---
|
||||||
|
|
||||||
|
# Tariff Time Tiers — happy hour, off-peak, weekend, seasonal
|
||||||
|
|
||||||
|
Design for **time-of-day / day-of-week / seasonal pricing** on top of the existing [[tariff]] engine.
|
||||||
|
Resolves the `tariff.md` open question *"Time-of-day / weekday tiers — not in the block model yet."*
|
||||||
|
Driven by two concrete operator asks: a **happy-hour** rate, and (from [[parksql2017-legacy-schema|the
|
||||||
|
legacy schema]]) **vehicle/customer categories**.
|
||||||
|
|
||||||
|
> Status: **design, not built.** No schema/code committed yet — this records the chosen shape and
|
||||||
|
> the rejected alternatives so implementation is a transcription.
|
||||||
|
|
||||||
|
## The two real-world models we looked at
|
||||||
|
|
||||||
|
1. **Legacy `BA_TicketPrice`** ([[parksql2017-legacy-schema]]): each rate-card row is scoped by
|
||||||
|
`ValidFrom`/`ValidTo` (date window) **and** `ValidFromHour`/`ValidToHour` (daily hour window) **and**
|
||||||
|
`TicketCategoryID`. Happy hour = a second price row valid 14:00–16:00. Off-peak/season = a row
|
||||||
|
with a date or hour window. The active rate is selected by **(category, now-or-entry, date)**.
|
||||||
|
2. **Research (verified):** rates modelled as **time segments nested inside recurring time frames**,
|
||||||
|
where time frames = days-of-week / holidays / special-event days (US patent 10,762,723, 3-0
|
||||||
|
verified). Industry APIs (INRIX `structured_rate`) carry `time_in`/`time_out` + `dow` per rate.
|
||||||
|
Both point at the **same primitive**: a rate that is *active for a wall-clock window*.
|
||||||
|
|
||||||
|
Both converge: **happy hour is not a discount flag — it is a selector over which rate card is active
|
||||||
|
for a given slice of wall-clock time.**
|
||||||
|
|
||||||
|
## The decision to make: which-rate selector vs. discount modifier
|
||||||
|
|
||||||
|
| Option | Shape | Verdict |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| **A. Time-windowed rate cards** (recommended) | A stay is sliced at wall-clock boundaries; each slice priced by the rate card whose window covers it. Happy hour = a card with `window: {dow, fromHour, toHour}`. | Most general: one mechanism covers happy hour, early-bird, night flat, weekend, season. Matches both references. |
|
||||||
|
| **B. Discount modifier on one ladder** | Keep one ladder; apply `−X%`/`−N min` when the clock is inside a window. | Simpler, but can't express "different ladder at night," daily caps interact badly, and it's a second pricing path. Rejected as the primary model. |
|
||||||
|
|
||||||
|
**Recommendation: A.** A discount-style happy hour (B) is then expressible *as* a windowed card (a
|
||||||
|
cheaper ladder), so we don't lose it.
|
||||||
|
|
||||||
|
## The wall-clock slicing consequence (the hard part)
|
||||||
|
|
||||||
|
The current `computeFee(enteredAt, asOf, structure)` walks **elapsed** minutes through `blocks`. Time
|
||||||
|
tiers add a **second clock**: the *wall-clock* time-of-day, which the elapsed walk doesn't track. A
|
||||||
|
stay 13:30→15:30 that has happy hour 14:00–16:00 must be **split at 14:00**: 30 min normal + 90 min
|
||||||
|
happy. So the fee function must:
|
||||||
|
|
||||||
|
1. Resolve the **applicable rate set** for the stay (all cards matching the category, ordered by
|
||||||
|
precedence — see below).
|
||||||
|
2. Walk the stay in wall-clock order, **switching the active card at each window boundary**, while
|
||||||
|
keeping the **elapsed-duration position** in the block ladder continuous (so block steps and the
|
||||||
|
daily cap still accrue across a window switch — a happy hour mid-stay must not reset the ladder).
|
||||||
|
3. Keep it **pure, integer, offline, deterministic** — the same invariants the current engine and the
|
||||||
|
[[append-only-event-chain|signed chain]] depend on. The `payment` event still records the
|
||||||
|
`tariffVersionId`; the version now contains the windowed card set, so a past session reprices
|
||||||
|
identically.
|
||||||
|
|
||||||
|
> Open edge: does the block ladder accrue by **elapsed time** (a 2h stay is in the 2nd block
|
||||||
|
> regardless of windows) or **reset per window**? Legacy `IntervalChange` hints some sites reset.
|
||||||
|
> **Lean: elapsed-continuous** (predictable, no double-charging), revisit if a site needs otherwise.
|
||||||
|
|
||||||
|
## Precedence (when windows overlap)
|
||||||
|
|
||||||
|
Multiple cards can match one instant (a weekday-evening card + a holiday card). Need a deterministic
|
||||||
|
winner. Proposal, most-specific-wins, matching the research's "event rates override":
|
||||||
|
`special-event/holiday > specific date range > day-of-week + hour > hour-only > default`. Ties broken
|
||||||
|
by an explicit integer `priority`. This must be **total and pure** — no ambiguity the operator can't
|
||||||
|
predict, no "depends on row order."
|
||||||
|
|
||||||
|
## Vehicle / customer category (the second new axis)
|
||||||
|
|
||||||
|
Legacy `BA_TicketCategory` prices by **category** (car/bus/VIP/…), orthogonal to time. Two ways:
|
||||||
|
- **Multiple tariffs scoped by category** — the schema already reserves `tariffs.scope`
|
||||||
|
(`site`/`zone`); add `category` cleanly, no migration. The session records which category it was
|
||||||
|
priced under.
|
||||||
|
- **Category as another window dimension** on the card. Simpler table, busier card.
|
||||||
|
|
||||||
|
**Lean: category as a tariff scope** (a category is a different rate *card*, not a different *window*
|
||||||
|
of one). Deferred until a site actually needs non-car pricing, but the `scope` hook means **no
|
||||||
|
migration when it lands**.
|
||||||
|
|
||||||
|
## Proposed data shape (illustrative)
|
||||||
|
|
||||||
|
Extend the `TariffStructure` JSON (still one immutable [[tariff]] version) with an optional ordered
|
||||||
|
card list; absence = today's single-ladder behaviour (back-compatible):
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"currency": "ALL",
|
||||||
|
"defaultCard": { /* the existing blocks/cap/grace structure */ },
|
||||||
|
"windowedCards": [
|
||||||
|
{
|
||||||
|
"name": "Happy hour",
|
||||||
|
"priority": 10,
|
||||||
|
"window": { "dow": [1,2,3,4,5], "fromHour": "14:00", "toHour": "16:00" },
|
||||||
|
"blocks": [ /* cheaper ladder */ ],
|
||||||
|
"dailyCapMinor": null
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
A bare `defaultCard` (no `windowedCards`) is exactly today's tariff — so this ships additively and a
|
||||||
|
site that never wants tiers never sees them. Keeps the **intuitive-for-operators** goal: the common
|
||||||
|
case stays one rate card; tiers are opt-in.
|
||||||
|
|
||||||
|
## Open
|
||||||
|
- Elapsed-continuous vs. per-window ladder reset (lean: elapsed-continuous).
|
||||||
|
- Holiday/special-event calendar: a date list per version, or a separate editable calendar table?
|
||||||
|
- Precedence model — confirm most-specific + explicit `priority` tiebreak.
|
||||||
|
- Category axis — confirm "category = tariff scope" vs. window dimension (deferred).
|
||||||
|
- UI: how to author windows without confusing operators (the notoriously-hard part — keep default
|
||||||
|
card front-and-center, tiers as an "advanced" add).
|
||||||
+32
-2
@@ -124,6 +124,24 @@ time references**, not one:
|
|||||||
`gracePeriodExit` is therefore a real revenue/UX parameter, not a nicety: too short traps people
|
`gracePeriodExit` is therefore a real revenue/UX parameter, not a nicety: too short traps people
|
||||||
who paid; too long gives free parking between pay and exit.
|
who paid; too long gives free parking between pay and exit.
|
||||||
|
|
||||||
|
> **As-built correction (2026-06-17):** the overstay top-up reprices from **entry**, not `paidAt` —
|
||||||
|
> `computeFee(enteredAt, now, …)` (so the timer never restarts; the customer pays the true entry→now
|
||||||
|
> total). The line above (`f(paidAt, now, …)`) was the original sketch; the implementation uses entry.
|
||||||
|
|
||||||
|
### ⚠ Open question — walk-back grace renews on every payment
|
||||||
|
|
||||||
|
A consequence of the two-time-reference model, surfaced via the [[booth-exit-flow|booth exit /
|
||||||
|
voucher]] path: every `payment` event stores its own `gracePeriodExit`, and the exit check reads the
|
||||||
|
**latest** payment's value. So an **overstay top-up re-grants a full, fresh grace window** each time.
|
||||||
|
The fee is correct (always recomputed from entry — no free exit), but the **walk-back grace doubles**
|
||||||
|
(or repeats) on every top-up — a customer could pay → wait → pay a tiny delta → earn another window →
|
||||||
|
repeat. The leak is **time, not money**, bounded by increment coarseness but real.
|
||||||
|
|
||||||
|
Candidate policies (business call): grant grace on a top-up **only when it charged new money**
|
||||||
|
(recommended), a **single non-renewing window** from the first payment, or a **per-session grace
|
||||||
|
cap**. Full analysis + the decided/undecided halves live in [[booth-exit-flow]]. Pick a policy before
|
||||||
|
production.
|
||||||
|
|
||||||
## Permit holders
|
## Permit holders
|
||||||
|
|
||||||
A valid [[permit]] bypasses tariff computation entirely for the covered period (subscription
|
A valid [[permit]] bypasses tariff computation entirely for the covered period (subscription
|
||||||
@@ -169,12 +187,24 @@ Unlike the event log, tariff data is **mutable master data** in the sense that n
|
|||||||
on the network — [[offline-first]]), a base currency, and a rounding policy. Deferred to
|
on the network — [[offline-first]]), a base currency, and a rounding policy. Deferred to
|
||||||
[[open-questions]].
|
[[open-questions]].
|
||||||
|
|
||||||
|
## Extensions under design
|
||||||
|
|
||||||
|
Two operator asks extend this engine; both have design pages (not yet built), grounded in
|
||||||
|
[[parksql2017-legacy-schema|the legacy schema]] + external research:
|
||||||
|
|
||||||
|
- **Time-of-day / weekday / seasonal tiers** (happy hour, off-peak, weekend, vehicle category) —
|
||||||
|
see [[tariff-time-tiers]]. Chosen shape: **time-windowed rate cards** selected by wall-clock window,
|
||||||
|
layered additively on this structure (a bare default card = today's behaviour). The hard part is
|
||||||
|
slicing a stay at window boundaries while keeping the block ladder + daily cap continuous.
|
||||||
|
- **Validation & sponsorship** (merchant comps, coupons, **postpaid B2B** "enter/exit free, bill the
|
||||||
|
business monthly") — see [[validation-sponsorship]]. A validation is a **typed modifier applied as a
|
||||||
|
signed event** on a transient session, distinct from a [[permit]]; postpaid sponsors accrue a
|
||||||
|
monthly-invoiced liability derivable from the chain.
|
||||||
|
|
||||||
## Open
|
## Open
|
||||||
|
|
||||||
- The **actual rate cards** are owner-authored at runtime — nothing to confirm at build time; the
|
- The **actual rate cards** are owner-authored at runtime — nothing to confirm at build time; the
|
||||||
composer UI + validation (sane blocks, non-negative, ordered `uptoMin`) is the work.
|
composer UI + validation (sane blocks, non-negative, ordered `uptoMin`) is the work.
|
||||||
- **Time-of-day / weekday tiers** — not in the block model yet; add as a tier wrapper if a site
|
|
||||||
needs day/night/weekend cards (deferred until asked).
|
|
||||||
- **Blank-tariff policy** — free vs. gated until a rate card is published (operator policy).
|
- **Blank-tariff policy** — free vs. gated until a rate card is published (operator policy).
|
||||||
- **In-progress version-boundary** — entry-version (decided) vs. pro-rate (revisit if needed).
|
- **In-progress version-boundary** — entry-version (decided) vs. pro-rate (revisit if needed).
|
||||||
- **FX** — exchange-rate system, offline rate source, base currency ([[open-questions]]).
|
- **FX** — exchange-rate system, offline rate source, base currency ([[open-questions]]).
|
||||||
|
|||||||
@@ -39,8 +39,17 @@ Because each validation is signed and attributed (`issuedBy`), over-validation b
|
|||||||
merchant is **visible to [[reconciliation]]** (a merchant validating far more than their footfall is
|
merchant is **visible to [[reconciliation]]** (a merchant validating far more than their footfall is
|
||||||
an anomaly), rather than invisible free parking.
|
an anomaly), rather than invisible free parking.
|
||||||
|
|
||||||
|
## Postpaid sponsors
|
||||||
|
|
||||||
|
When the validating party is a **business with a postpaid agreement** (its customers park free, it's
|
||||||
|
billed monthly) — not just a one-off discount — the **sponsor account + settlement** layer is in
|
||||||
|
[[validation-sponsorship]]. That's the distinction between a discount (this page) and a *sponsored*
|
||||||
|
session that accrues a receivable.
|
||||||
|
|
||||||
## Open
|
## Open
|
||||||
|
|
||||||
- Validation types the site needs (free hours / fixed amount / percentage / flat rate).
|
- Validation types the site needs (free hours / fixed amount / percentage / flat rate) — superset in
|
||||||
|
[[validation-sponsorship]] (`comp`/`percent`/`fixed`/`time-credit`/`rate-switch`).
|
||||||
- Whether merchants self-serve (portal/terminal) or the operator applies it.
|
- Whether merchants self-serve (portal/terminal) or the operator applies it.
|
||||||
- Caps (max discount, max per merchant/day).
|
- Caps (max discount, max per merchant/day).
|
||||||
|
- Prepaid coupon pool vs. postpaid accrual — see [[validation-sponsorship]].
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
---
|
||||||
|
type: concept
|
||||||
|
tags: [parking, domain, business, pricing, validation, design]
|
||||||
|
sources: [parksql2017-legacy-schema]
|
||||||
|
updated: 2026-06-17
|
||||||
|
status: open
|
||||||
|
---
|
||||||
|
|
||||||
|
# Validation & Sponsorship — merchant comps, coupons, postpaid B2B
|
||||||
|
|
||||||
|
Builds on [[validation-discounts]] (the signed-event discount mechanism) to add the layer it leaves
|
||||||
|
open: **a sponsor account and postpaid B2B billing.** The driving case — **a nearby business with a
|
||||||
|
postpaid agreement whose customers enter and exit freely, billed to the business monthly.**
|
||||||
|
|
||||||
|
> This page owns the **sponsor/account/settlement** model and the **permit-vs-validation
|
||||||
|
> distinction**. The *how a discount is applied* mechanics (signed event, `due = max(0, fee −
|
||||||
|
> discounts)`, attribution, anti-abuse) live in [[validation-discounts]] — not duplicated here.
|
||||||
|
> Status: **design, not built.**
|
||||||
|
|
||||||
|
## Why this is NOT a permit (the key distinction)
|
||||||
|
|
||||||
|
| | [[permit]] | Validation / sponsorship |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Subject | Known in advance; carries a credential (card/QR/plate) | Anonymous walk-in; identified only by the **ticket they were issued** |
|
||||||
|
| When applied | At entry (credential opens the lane) | **After entry**, against an existing session — at a pay station, by a code, or by a sponsor rule |
|
||||||
|
| Who pays | The subscriber, out-of-band | A **third party** (merchant/sponsor), or nobody (comp) |
|
||||||
|
| Model fit | `permits` + credentials | New: a **validation event** on a session + a **sponsor account** |
|
||||||
|
|
||||||
|
A permit bypasses tariff computation; a validation **adjusts the computed fee** (or zeroes it). They
|
||||||
|
compose — but they are different primitives.
|
||||||
|
|
||||||
|
## Two economic models (both real)
|
||||||
|
|
||||||
|
- **Prepaid** — merchant buys a pool of value up front (legacy `BA_Cupons`: printed single-use codes
|
||||||
|
worth `DiscMinutes`; City Center research: merchant pre-buys time tickets 15 min→all-day).
|
||||||
|
Reconciliation = count used codes against the pool.
|
||||||
|
- **Postpaid** (the asked-for case) — merchant signs an agreement; their customers park free or
|
||||||
|
discounted; the system **accrues each validation against a sponsor balance** and **invoices monthly**
|
||||||
|
(City Center: "billed for the number of tickets validated each month," verified 3-0). No money moves
|
||||||
|
at the lane.
|
||||||
|
|
||||||
|
The legacy system did **only prepaid coupons** — **the postpaid sponsor account is net-new** for this
|
||||||
|
project.
|
||||||
|
|
||||||
|
## Modifier types (extends [[validation-discounts]])
|
||||||
|
|
||||||
|
The discount-type enum lives in [[validation-discounts]]; legacy `DiscType` (smallint) and research
|
||||||
|
(Amano McGann / HUB J4M, abstained-not-refuted) confirm the set: `comp` / `percent` / `fixed` /
|
||||||
|
`time-credit` (legacy `DiscMinutes`) / `rate-switch`. **Sponsorship adds one field** to a validation:
|
||||||
|
a `sponsorId`. Full-comp + a sponsor = the "free entry/exit, bill the business" case.
|
||||||
|
|
||||||
|
## The sponsor-liability consequence (anti-fraud)
|
||||||
|
|
||||||
|
The validation is a signed event ([[validation-discounts]], [[append-only-event-chain]]); what
|
||||||
|
**sponsorship** adds is that **free-to-the-parker is not free-to-the-ledger** — it is a *receivable
|
||||||
|
from the sponsor*. Under the [[threat-model|operator-as-adversary]] model:
|
||||||
|
|
||||||
|
- A postpaid sponsor's "enter/exit freely" still **mints signed entry + exit events** (and snapshots)
|
||||||
|
— the audit trail is identical to a paying car; only the **settlement target** differs.
|
||||||
|
- The **sponsor's period liability = the sum of `sponsorId`-tagged validation events** over the
|
||||||
|
period — derivable from the chain, reconcilable like a [[shift|shift Z-report]] and visible to
|
||||||
|
[[reconciliation]] (a sponsor comping far more than plausible footfall is an anomaly).
|
||||||
|
|
||||||
|
## Proposed data shape (illustrative — design only)
|
||||||
|
|
||||||
|
```
|
||||||
|
sponsors id, name, contact, mode {prepaid|postpaid},
|
||||||
|
balance_minor (prepaid pool / postpaid accrual), billing_period, active
|
||||||
|
validations id, session_id, sponsor_id?, type, amount_minor|minutes,
|
||||||
|
code?, operator_id, created_at // append-only; one row per application
|
||||||
|
(coupons) code, value_minutes|minor, single_use, used_at? // prepaid pool, optional
|
||||||
|
```
|
||||||
|
|
||||||
|
- A **postpaid** sponsor: each full-comp validation appends a row and accrues `amount` to the
|
||||||
|
sponsor; monthly invoice = sum over the period; exit is free at the lane.
|
||||||
|
- **Free entry/exit "freely"**: either the sponsor issues credentials (then it's closer to a
|
||||||
|
[[permit]] — pick that path), or customers take a normal ticket and a sponsor rule / merchant code
|
||||||
|
comps it at exit. The agreement wording decides which; **both are expressible.**
|
||||||
|
|
||||||
|
## Reconciliation & settlement
|
||||||
|
- **Prepaid**: pool decrements; alert at low balance; no invoice.
|
||||||
|
- **Postpaid**: accrue; **monthly statement** per sponsor (legacy/City Center cadence ~the 10th).
|
||||||
|
Statement lines trace to signed validation events → disputes resolvable against the chain.
|
||||||
|
|
||||||
|
## Open
|
||||||
|
- **"Enter/exit freely" mechanism**: sponsor-issued credentials ([[permit]]-like) vs. ticket +
|
||||||
|
comp-at-exit. Likely offer both; confirm the operator's actual deal shape.
|
||||||
|
- Prepaid coupon format: printed codes (legacy) vs. QR vs. merchant web-validation portal.
|
||||||
|
- Who may apply a validation, and the **per-operator cap** (a comp is a fraud vector — bound it and
|
||||||
|
always sign it).
|
||||||
|
- Invoicing: in-app statement only, or export for external billing? FX if sponsor bills in another
|
||||||
|
currency (defer to [[tariff]] FX).
|
||||||
|
- Partial-stay sponsorship (merchant covers first 2h, parker pays the rest) — `time-credit` or
|
||||||
|
`rate-switch` covers it; confirm.
|
||||||
+4
-1
@@ -7,7 +7,7 @@ updated: 2026-06-14
|
|||||||
# Index
|
# Index
|
||||||
|
|
||||||
Content catalog for the wiki. Start at [[overview]]. Maintained on every ingest.
|
Content catalog for the wiki. Start at [[overview]]. Maintained on every ingest.
|
||||||
Counts: 3 sources · 19 entities · 25 concepts · 5 decision records.
|
Counts: 4 sources · 19 entities · 27 concepts · 5 decision records.
|
||||||
|
|
||||||
## Overview & navigation
|
## Overview & navigation
|
||||||
- [[overview]] — the top-level synthesis and entry point.
|
- [[overview]] — the top-level synthesis and entry point.
|
||||||
@@ -18,6 +18,7 @@ Counts: 3 sources · 19 entities · 25 concepts · 5 decision records.
|
|||||||
- [[parking-system-architecture]] — design notes: stack, threat model, devices, UHPPOTE, ESP32, readers, BOM, open decisions.
|
- [[parking-system-architecture]] — design notes: stack, threat model, devices, UHPPOTE, ESP32, readers, BOM, open decisions.
|
||||||
- [[gee-qr-er80]] — datasheet: GEE QR access reader (QR/DM/1D; Wiegand/RS-232/485/USB/TCP; Linux).
|
- [[gee-qr-er80]] — datasheet: GEE QR access reader (QR/DM/1D; Wiegand/RS-232/485/USB/TCP; Linux).
|
||||||
- [[qrcode-sdk]] — QRCode SDK v1.6.5: the reader's HTTP-GET-poll protocol + JSON verdict (beep/output).
|
- [[qrcode-sdk]] — QRCode SDK v1.6.5: the reader's HTTP-GET-poll protocol + JSON verdict (beep/output).
|
||||||
|
- [[parksql2017-legacy-schema]] — predecessor SQL Server schema (Albanian market): legacy tariff/discount/membership/shift/fiscal model; confirms blocks, adds time-windows + categories, lacks postpaid sponsors.
|
||||||
|
|
||||||
## Entities — technology stack
|
## Entities — technology stack
|
||||||
- [[technology-stack]] — the full stack table; all MIT/Apache/BSD, chosen to avoid lock-in.
|
- [[technology-stack]] — the full stack table; all MIT/Apache/BSD, chosen to avoid lock-in.
|
||||||
@@ -78,11 +79,13 @@ Counts: 3 sources · 19 entities · 25 concepts · 5 decision records.
|
|||||||
## Concepts — business domain
|
## Concepts — business domain
|
||||||
- [[parking-session]] — the core domain entity; a projection over the signed log, never a mutable table.
|
- [[parking-session]] — the core domain entity; a projection over the signed log, never a mutable table.
|
||||||
- [[tariff]] — fee model; pure, data-driven, offline; pay-on-foot adds a walk-back grace window.
|
- [[tariff]] — fee model; pure, data-driven, offline; pay-on-foot adds a walk-back grace window.
|
||||||
|
- [[tariff-time-tiers]] — design: happy-hour/off-peak/weekend/seasonal + vehicle categories via time-windowed rate cards.
|
||||||
- [[shift]] — manned-only accountability period; explicit Start/End (not time-based); End → signed + printed Z-report (cash + POS).
|
- [[shift]] — manned-only accountability period; explicit Start/End (not time-based); End → signed + printed Z-report (cash + POS).
|
||||||
- [[capacity-occupancy]] — live count = open sessions; refuse entry + FULL sign when full (soft policy); exit never blocked.
|
- [[capacity-occupancy]] — live count = open sessions; refuse entry + FULL sign when full (soft policy); exit never blocked.
|
||||||
- [[site-metadata]] — optional park identity (name, operator, VAT, address, contact) in site_config; feeds the ticket header.
|
- [[site-metadata]] — optional park identity (name, operator, VAT, address, contact) in site_config; feeds the ticket header.
|
||||||
- [[valet-overcapacity]] — "full" is soft: operator may valet-accept over capacity (keys handed over, custody). Manned, deferred.
|
- [[valet-overcapacity]] — "full" is soft: operator may valet-accept over capacity (keys handed over, custody). Manned, deferred.
|
||||||
- [[validation-discounts]] — merchant validates a ticket → signed discount event applied at fee time.
|
- [[validation-discounts]] — merchant validates a ticket → signed discount event applied at fee time.
|
||||||
|
- [[validation-sponsorship]] — design: sponsor accounts + postpaid B2B (customers park free, business billed monthly); not a permit.
|
||||||
- [[reporting-analytics]] — revenue/occupancy/stay reports + plate-search, all projections over the signed log.
|
- [[reporting-analytics]] — revenue/occupancy/stay reports + plate-search, all projections over the signed log.
|
||||||
- [[clock-integrity]] — fees depend on the host clock; detect/flag backdating on an offline box.
|
- [[clock-integrity]] — fees depend on the host clock; detect/flag backdating on an offline box.
|
||||||
- [[ticket-encoding]] — transient ticket id as QR; printed at entry, scanned at pay station + exit; plate-as-ticket alt.
|
- [[ticket-encoding]] — transient ticket id as QR; printed at entry, scanned at pay station + exit; plate-as-ticket alt.
|
||||||
|
|||||||
+20
@@ -760,3 +760,23 @@ Extended `site_config` (single-row) with optional park identity: `park_name`, `o
|
|||||||
## [2026-06-17] build | Ticket in Albanian; VAT->NIUS, drop registration; CP852 codepage
|
## [2026-06-17] build | Ticket in Albanian; VAT->NIUS, drop registration; CP852 codepage
|
||||||
|
|
||||||
Ticket header now prints in Albanian and uses NIUS instead of VAT. Renamed `site_config.vat_number` -> `nius` and DROPPED `registration_number` (regenerated migration 0001 in place; only the dev DB had it, so no migration debt; dev DB reset + re-migrated). `renderTicket()`: NIUS line (only if set), "Printuar më:" before the timestamp, "Keni humbur biletën? <phone>" footer; strings centralised in a `STR` table for future i18n. Added CP852 (Latin-2) codepage support (`ESC t 18` + a Unicode->CP852 `line()` encoder with ASCII fallback) so `ë`/`ç` render. Touched: schema, migration, routes/site.ts, web api.ts + SiteSettings.tsx, devices interfaces + printer-rongta.ts, entry-flow.ts. Byte-verified ë->0x89. See [[site-metadata]], [[ticket-encoding]].
|
Ticket header now prints in Albanian and uses NIUS instead of VAT. Renamed `site_config.vat_number` -> `nius` and DROPPED `registration_number` (regenerated migration 0001 in place; only the dev DB had it, so no migration debt; dev DB reset + re-migrated). `renderTicket()`: NIUS line (only if set), "Printuar më:" before the timestamp, "Keni humbur biletën? <phone>" footer; strings centralised in a `STR` table for future i18n. Added CP852 (Latin-2) codepage support (`ESC t 18` + a Unicode->CP852 `line()` encoder with ASCII fallback) so `ë`/`ç` render. Touched: schema, migration, routes/site.ts, web api.ts + SiteSettings.tsx, devices interfaces + printer-rongta.ts, entry-flow.ts. Byte-verified ë->0x89. See [[site-metadata]], [[ticket-encoding]].
|
||||||
|
|
||||||
|
## [2026-06-17] ingest | ParkSQL2017 legacy schema + tariff research
|
||||||
|
|
||||||
|
Ingested `raw/parksql2017-legacy-schema.sql` (predecessor SQL Server 2017 schema, decoded from UTF-16; Albanian market — NIVF fiscal codes, Cupons, LostPrice1..4). Source summary in [[parksql2017-legacy-schema]]. Combined with a deep-research run (5 claims verified 3-0/2-0; synthesis + 20 claims aborted on a session limit — treat those as unverified, not refuted). Filed two design pages: [[tariff-time-tiers]] (happy-hour/off-peak/weekend/seasonal + vehicle categories via time-windowed rate cards; the hard part is wall-clock stay-slicing with a continuous block ladder) and [[validation-sponsorship]] (sponsor accounts + postpaid B2B billing; distinct from [[permit]]). Reconciled with the existing [[validation-discounts]] (cross-linked, no duplication — that page owns the signed-event discount mechanism, the new one owns sponsor/settlement). Updated [[tariff]] (new "Extensions under design" section; removed the now-addressed time-tier open item). Legacy confirms our stepped ladder + per-rate lost penalty + typed session discount; adds time-of-day windows + category axis; lacks any postpaid sponsor model (net-new). Flagged legacy anti-patterns we deliberately reject: float money, mutable rate rows, in-row image BLOBs.
|
||||||
|
|
||||||
|
## [2026-06-17] build | Booth actions — ticket input, pay/exit modal, voucher, snapshots
|
||||||
|
|
||||||
|
Made the booth screen operational (was a passive monitor). Backend: `site_config.exit_voucher_default` (additive migration 0002); `GET /api/session/:identity` (lookup + quote in one read), `POST /api/exit` (booth-driven, VALIDATED exit — reuses ExitFlow's paid+grace checks, no booth bypass; signs vehicle_exit + pulses an exit relay resolved via firstRelayByDirection; payment never rolled back, relay-open failure → signed anomaly + opened:false), `POST /api/voucher` (reprint paid ticket id barcode on the booth printer). Refactored ExitFlow into shared #signExit/#fireExitSnapshot/#closeSessionCache so the reader and booth paths are one validated code path. Frontend: ticket input on /booth (HID-scanner-friendly), Radix pay/exit modal (entry/now/duration/total + tender + 'Printo biletë dalje' checkbox defaulting from site_config), entry/exit SnapshotStrip (thumbnails → zoom). Verified end-to-end in-browser: scan → modal shows ALL 200 + real entry photo → pay → "barrier opened"; ledger recorded entry→payment→vehicle_exit in order; chain verify ok after. Decision recorded in [[booth-exit-flow]]. Unpaid exit correctly 409-refused (threat-model). NOT yet covered: voucher print success path (dev printers physically offline), automated tests.
|
||||||
|
|
||||||
|
## [2026-06-17] query | Walk-back grace renews on every payment (voucher overstay)
|
||||||
|
|
||||||
|
User flagged: customer pays, takes an exit voucher, dawdles past grace. Traced exit-flow.ts + pay-station.ts. Findings: refuse-on-expiry ✓ (no free exit) and reprice-from-entry ✓ (timer never restarts — `computeFee(enteredAt, now)`, NOT from paidAt) are both correct and deliberate. BUG: each `payment` writes its own `graceExitMin` and the exit flow reads the LATEST one, so every top-up re-grants a full walk-back window → grace doubles/repeats. Leak is TIME not money (fee always catches up from entry), bounded by increment coarseness but real. Flagged as an open question in [[booth-exit-flow]] (full analysis + 3 candidate fixes) and [[tariff]] (cross-ref + corrected the original `f(paidAt,now)` sketch to the as-built entry-based reprice). Decision deferred — fairness vs. anti-abuse business call. Recommended fix: grant grace on top-up only when it charged new money.
|
||||||
|
|
||||||
|
## [2026-06-18] build | Active sessions panel + audited barrier re-open
|
||||||
|
|
||||||
|
Operator escape hatch for stuck cars (damaged ticket / dead scanner / phantom barrier re-close). Key model from operator: the barrier state is ASSUMED not confirmed, so a session is "active" while OPEN or exited-but-within-grace — payment and a successful voucher scan do NOT remove it; only grace expiry does. Backend: PayStation.activeSessions() (one ledger fold, open OR within-grace, newest first), GET /api/sessions/active; ExitFlow.reopenBarrier() + POST /api/barrier/reopen — re-pulses an exit relay and signs an attributed `anomaly` (barrierReopen, operator), NEVER a 2nd vehicle_exit. Guard: no payment → 409 refuse (no-unpaid-bypass), enforced server-side AND the UI hides the button. Frontend: ActiveSessions panel on /booth (live via WS invalidation + 15s poll for grace expiry), row click → pay/exit modal, "Open barrier" only on paid rows. Verified in-browser: in-grace session stayed listed as "exiting" with the button; unpaid rows had none; click → audited anomaly #53 [op=boothtest], vehicle_exit count stayed 1 (no double-count); chain verify ok. Design recorded in [[booth-exit-flow]] (Active sessions & human-intervention barrier open). Standing gap: still no automated tests.
|
||||||
|
|
||||||
|
## [2026-06-18] build | Drawer balance — opening float carry-over + admin cash movements
|
||||||
|
|
||||||
|
Cash drawer that carries across shifts. New signed `cash_movement` ledger event type (shared); admin-only POST /api/cash-movement {amountMinor signed +load/-remove, reason}. ShiftService: #drawerBalanceAt(time) folds cash payments + cash_movements BY TIME (not operator — the movement is the admin's); open() auto-inherits openingFloat = drawerBalanceAt(start) and records it on shift_open; close() Z-report adds openingFloat/cashAdded/cashRemoved/expectedDrawer (= opening + taken + added − removed = next shift's opening float). Card payments excluded (settle to bank). GET /api/shift/current returns live drawerMinor. Frontend: ShiftControl shows live drawer + admin Load/Remove form + full Z-report drawer block (admin gate via router context). Verified the canonical scenario on a FRESH DB: load 5000 → shift1 takes 6500 → expected 11500 → shift2 inherits 11500, admin removes 5000, takes 4500 → expected 11000 → shift3 inherits 11000; chain ok. Also verified through the real UI (load/remove → Z-report opening 11200 removed 5000 expected 6200; both cash_movements signed+attributed; chain ok). Decision + worked example in [[shift]] (Drawer balance section). Standing gap: still no automated tests.
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,87 @@
|
|||||||
|
---
|
||||||
|
type: source
|
||||||
|
tags: [parking, legacy, pricing, schema, fiscalization]
|
||||||
|
sources: [parksql2017-legacy-schema]
|
||||||
|
updated: 2026-06-17
|
||||||
|
---
|
||||||
|
|
||||||
|
# ParkSQL2017 — Legacy Parking System Schema (source summary)
|
||||||
|
|
||||||
|
A SQL Server 2017 schema dump (`raw/parksql2017-legacy-schema.sql`, scripted 2024-10-07) of an
|
||||||
|
**existing/predecessor parking system** in the same Albanian market this project targets. It is the
|
||||||
|
single most concrete reference we have for how the prior generation modelled **tariffs, discounts,
|
||||||
|
memberships, sessions, shifts, and fiscalization** — a real, deployed data model rather than vendor
|
||||||
|
marketing. Treat it as evidence of what worked and what to improve, not as a spec to copy (it has
|
||||||
|
clear anti-patterns, e.g. money as `float`).
|
||||||
|
|
||||||
|
> Albanian-context tells: `BA_TicketFisc.nivf` (NIVF fiscalization code), the `Cupons` spelling,
|
||||||
|
> `LostPrice1..4` tiers. `BA_` = business-app table prefix; `SYS_` = system/auth tables.
|
||||||
|
|
||||||
|
## Table map (24 tables)
|
||||||
|
|
||||||
|
**Pricing / tariff**
|
||||||
|
- **`BA_TicketPrice`** — the rate-card *header*. Key fields: `Code`, `Name`, `TicketCategoryID`,
|
||||||
|
`ParkID`, **`ValidFrom`/`ValidTo`** (date window), **`ValidFromHour`/`ValidToHour`** (time-of-day
|
||||||
|
window), `FixedPrice` (flat option), `IntervalType` (2-char unit, e.g. MI/HR/DY), `Interval`
|
||||||
|
(increment size), **`LostPenalty`**, `IsDefault`, `IsActive`. → A rate card is scoped by
|
||||||
|
**(category × date-range × hour-range)**. This is the **happy-hour / time-of-day mechanism**.
|
||||||
|
- **`BA_TicketPriceHours`** — the *stepped ladder* (child of TicketPrice): rows of
|
||||||
|
`HourFrom`, `HourTo`, `Price`. → equivalent to this project's tariff `blocks[]`.
|
||||||
|
- **`BA_TicketCategory`** — vehicle/customer category (`Code`, `Name`, `IsDefault`,
|
||||||
|
`IntervalChange`). → a **pricing axis by category** the current model lacks.
|
||||||
|
- **`BA_TicketFisc`** — `TicketID` → `nivf` (Albanian fiscalization code per ticket).
|
||||||
|
|
||||||
|
**Discounts / validation**
|
||||||
|
- **`BA_Cupons`** — `CODE`, **`DiscMinutes`** (discount as *free minutes*), `IsUsed`, `LastUsed`,
|
||||||
|
`IsPrinted`. → validation = a **single-use coupon code worth N free minutes**; reconciled by
|
||||||
|
counting used codes (**prepaid** model, no merchant account/ledger).
|
||||||
|
|
||||||
|
**Sessions**
|
||||||
|
- **`BA_ParkRecords`** — the transient parking session (ticket cars). Carries lifecycle
|
||||||
|
(`InTime`/`OutTime`/`ExitTime`, `In/Out Mode/Addr/OperatorID`, `In/Out ShiftID`), money
|
||||||
|
(`OrgCharge`, `Charge`, `Discount`, `FreeMin`, `IsPaid`), **discount detail** (`DiscMinutes`,
|
||||||
|
`DiscType` smallint, `DisTicketSerial`), entry/exit plate+image columns, and
|
||||||
|
`ManualOpenReason` / `ExpiredTime/DateApproval` audit fields.
|
||||||
|
- **`BA_MembersCheckINOUT`** — per-event check-in/out log for *members* (cards), separate from
|
||||||
|
ticket sessions.
|
||||||
|
- **`BA_ManualCheckINOUT`** — every manual barrier open, with `Reason` + `OperatorID` + image.
|
||||||
|
|
||||||
|
**Memberships (≈ this project's permits)**
|
||||||
|
- **`BA_Members`** — the member (card+plate identity, contact, `isVIP`).
|
||||||
|
- **`BA_Memberships`** — an issued subscription: `PlanID`, `StartDate`/`EndDate`, `Price`,
|
||||||
|
`CalculatedPrice`, `Paid`, `AllowedDays`.
|
||||||
|
- **`BA_MembershipPlans`** — plan template: `Type`, `Duration`, `Price`, **`ActiveDays`**.
|
||||||
|
- **`BA_MembershipPlansTime`** — **`StartTime`/`EndTime`** windows per plan → memberships valid only
|
||||||
|
in specific **hours** (commuter/day-shift permits).
|
||||||
|
|
||||||
|
**Site / ops / auth**
|
||||||
|
- **`BA_Park`** — a lot: capacity (`ParkingPlaces`/`FreePlaces`), LED sign addr, default ticket/lost
|
||||||
|
category codes, **`FreeMinutes`**, `DiscMinutesTicket`, `DiscMinutesApp`, **`LostPrice1..4`**.
|
||||||
|
- **`BA_Shifts`** — cashier shift / Z-report: open/close, `Charged`, `TicketCharges`,
|
||||||
|
`CardCharges`, entry/exit + manual counts, **`Reconciled`**, `UserID`, `MachineID`.
|
||||||
|
- **`BA_CashRegister`**, `SYS_Configs` (company/fiscal/ticket header+footer, `AllowTimeExeed`,
|
||||||
|
`ImageDays`), `SYS_User`/`SYS_Role`/`SYS_Rights`/`SYS_UserRights`, `SYS_Controls`,
|
||||||
|
`SYS_Language` (DB-driven i18n).
|
||||||
|
|
||||||
|
## What it confirms for our design
|
||||||
|
1. **Stepped ladder** — `BA_TicketPriceHours` (HourFrom/HourTo/Price) ≈ our `blocks[]`. Good signal.
|
||||||
|
2. **Per-rate lost penalty** + site-level lost tiers (`LostPrice1..4`) ≈ our `lostTicketMinor` (+ the
|
||||||
|
admin-override idea).
|
||||||
|
3. **Typed discount on the session record** (`DiscType`) ≈ the research's "typed validation modifier."
|
||||||
|
4. **Manual-open + reason logging at the schema level** ≈ our [[threat-model|operator-as-adversary]] audit need.
|
||||||
|
|
||||||
|
## What it adds (genuinely new vs. our current model)
|
||||||
|
1. **Time-of-day + date windows on the rate card** (`ValidFromHour`/`ValidToHour`,
|
||||||
|
`ValidFrom`/`ValidTo`) — the shipped way to do **happy hour / seasonal**. See [[tariff-time-tiers]].
|
||||||
|
2. **Vehicle/customer category as a pricing axis** (`BA_TicketCategory`). See [[tariff-time-tiers]].
|
||||||
|
3. **Time-/day-restricted memberships** (`MembershipPlansTime`, `ActiveDays`) — a [[permit]] gap.
|
||||||
|
|
||||||
|
## Anti-patterns to NOT copy
|
||||||
|
- **Money as `float`** everywhere (`Charge`, `Price`, `LostPenalty`) — drifts across a revenue
|
||||||
|
ledger. Our integer-minor-units rule is the deliberate fix. (rejected alternative)
|
||||||
|
- **Mutable rate rows** (`Updated`/`UpdatedBy` in place) — a past session can't reliably reprice
|
||||||
|
against the rate then in force. Our immutable effective-dated [[tariff]] versions fix this.
|
||||||
|
- **No merchant/sponsor account or postpaid ledger** — only prepaid printed coupons. The B2B
|
||||||
|
postpaid case is net-new; see [[validation-sponsorship]].
|
||||||
|
- **Images stored as `image` BLOBs in-row** — we keep snapshot *bytes* out of the event row and
|
||||||
|
store a reference instead ([[append-only-event-chain]]).
|
||||||
Reference in New Issue
Block a user