feat(booth): pay-on-foot at the booth — ticket lookup, pay, exit, voucher, snapshots
Backend: PayStation.lookup (session view + quote in one read); ExitFlow.exitForBooth reuses the reader path's paid+grace validation (no booth-only unpaid bypass) and signs vehicle_exit + pulses an exit relay; printExitVoucher reprints the paid ticket id barcode; site_config.exit_voucher_default (migration 0002) drives the default. Routes: GET /api/session/:id, POST /api/exit, POST /api/voucher. Web: BoothPayModal (entry/now/duration/total, tender, 'Printo biletë dalje'), SnapshotStrip (entry/exit evidence), api.ts client fns, SiteSettings toggle.
This commit is contained in:
@@ -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;
|
||||||
|
}
|
||||||
+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
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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,234 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
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 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(`Exit voucher printed on ${r.printedBy}. Customer self-exits at the exit.`);
|
||||||
|
} else {
|
||||||
|
const r = await boothExit(identity);
|
||||||
|
setResult(
|
||||||
|
r.opened
|
||||||
|
? "Paid — barrier opened. Car may exit."
|
||||||
|
: `Paid and exit recorded, but the barrier did not open: ${r.reason ?? "open manually"}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// 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">
|
||||||
|
Ticket {identity}
|
||||||
|
</Dialog.Title>
|
||||||
|
<Dialog.Close className="text-term-muted hover:text-term-text" aria-label="Close">
|
||||||
|
✕
|
||||||
|
</Dialog.Close>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-3 p-4">
|
||||||
|
{session.isLoading && <div className="text-term-muted">looking up…</div>}
|
||||||
|
|
||||||
|
{s && !s.found && (
|
||||||
|
<div className="rounded-term border border-term-red px-3 py-2 text-term-red">
|
||||||
|
No session found for this ticket.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{s && s.found && !s.open && (
|
||||||
|
<div className="rounded-term border border-term-amber px-3 py-2 text-term-amber">
|
||||||
|
This session is already closed (exited {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="Entry" value={formatTime(s.enteredAt)} />
|
||||||
|
<Row label="Now" value={formatTime(new Date().toISOString())} />
|
||||||
|
<Row label="Duration" value={s.enteredAt ? formatDuration(s.enteredAt, new Date().toISOString()) : "—"} />
|
||||||
|
<Row
|
||||||
|
label="Status"
|
||||||
|
value={alreadyPaid ? "PAID" : "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">Total</span>
|
||||||
|
<span className="text-3xl font-bold text-term-cyan">
|
||||||
|
{s.amountMinor != null && s.currency
|
||||||
|
? formatMoney(s.amountMinor, s.currency)
|
||||||
|
: alreadyPaid
|
||||||
|
? "paid"
|
||||||
|
: "no tariff"}
|
||||||
|
</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">Tender</span>
|
||||||
|
{(["cash", "card"] as const).map((t) => (
|
||||||
|
<button
|
||||||
|
key={t}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setTender(t)}
|
||||||
|
className={`rounded-term border px-3 py-1 text-[12px] uppercase tracking-wider ${
|
||||||
|
tender === t
|
||||||
|
? "border-term-amber text-term-amber"
|
||||||
|
: "border-term-border text-term-muted hover:text-term-text"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t}
|
||||||
|
</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)}
|
||||||
|
/>
|
||||||
|
Printo biletë dalje
|
||||||
|
<span className="text-term-muted">(customer self-exits at the exit)</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"
|
||||||
|
>
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
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"
|
||||||
|
? "taking payment…"
|
||||||
|
: phase === "finishing"
|
||||||
|
? voucher
|
||||||
|
? "printing voucher…"
|
||||||
|
: "opening…"
|
||||||
|
: alreadyPaid
|
||||||
|
? voucher
|
||||||
|
? "Print voucher"
|
||||||
|
: "Open barrier"
|
||||||
|
: voucher
|
||||||
|
? "Pay + print voucher"
|
||||||
|
: "Pay + open barrier"}
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
|
|||||||
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 +32,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,7 +43,10 @@ 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 {
|
||||||
@@ -75,6 +80,17 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
|
|||||||
Capacity (blank = no limit):{" "}
|
Capacity (blank = no limit):{" "}
|
||||||
<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="e.g. 120" />
|
||||||
</label>
|
</label>
|
||||||
|
<label style={{ display: "flex", alignItems: "center", gap: "0.4rem" }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={exitVoucherDefault}
|
||||||
|
onChange={(e) => setExitVoucherDefault(e.target.checked)}
|
||||||
|
/>
|
||||||
|
Print exit ticket by default
|
||||||
|
<span style={{ color: "#888", fontSize: "0.8rem" }}>
|
||||||
|
(booth far from exit → customer self-exits with a voucher)
|
||||||
|
</span>
|
||||||
|
</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)
|
Park details (optional — shown on tickets/receipts)
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+130
-1
@@ -311,6 +311,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 +323,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 +364,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 +378,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,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,60 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
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 { 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">loading snapshots…</div>;
|
||||||
|
if (shots.length === 0) return <div className="text-[11px] text-term-muted">no snapshots</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 @@
|
|||||||
|
ALTER TABLE `site_config` ADD `exit_voucher_default` integer DEFAULT false 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": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,13 @@
|
|||||||
"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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -151,6 +151,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)`),
|
||||||
|
|||||||
Reference in New Issue
Block a user