c5ed3f1308
/drawer was record + review only: no current balance, no sight of the open shift's incomings, no daily activity, no shift history. Rebuilt as a hub: - Drawer now: the till's running balance (new GET /api/drawer/balance, shift:read — exposes the service's existing drawerBalance(); the drawer is one site-wide till, same exposure the X-report already had) with the open shift's X-report breakdown alongside (float + takings + vouchers = expected = balance) and a "This shift: ±X" figure (expected − opening float — the shift's own contribution vs what it inherited). - Today's cash activity: every cash payment + voucher since local midnight from the signed chain, live, with day totals (card never enters the till). - Record + movements/review: the 2026-07-01 flow, unchanged. - Closed shifts: drawer-focused history via the scope-aware /api/shifts (float → takings ± vouchers → expected per shift). Also: every shift open/close button (header, /shifts, pay modal, end- shift confirm) now shows an animated spinner + dims while busy — the old label-swap-only feedback read as a dead click when a shift open ran slow. The slowness itself (drawer/shift reads fold the WHOLE chain, O(chain)) is recorded as an open item in wiki/concepts/shift.md with the fix sketch: fold from the last z-report's signed expectedDrawerMinor forward. No new ledger surface — one read-only endpoint; RBAC test added. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
1458 lines
53 KiB
TypeScript
1458 lines
53 KiB
TypeScript
// Thin API client for the operator/admin UI.
|
||
//
|
||
// Auth is cookie-based: the JWT lives in an HttpOnly cookie the browser sends
|
||
// automatically (credentials: 'include'). For mutations we echo the readable
|
||
// CSRF cookie back in the X-CSRF-Token header (double-submit). See
|
||
// wiki/entities/local-jwt-auth.md.
|
||
|
||
import { logFailedRequest } from "./lib/logger.js";
|
||
import { apiUrl } from "./lib/origin.js";
|
||
import type { AppLogRecord } from "@parking/shared";
|
||
|
||
const CSRF_COOKIE = "parking_csrf";
|
||
const CSRF_HEADER = "X-CSRF-Token";
|
||
|
||
function readCookie(name: string): string | null {
|
||
const m = document.cookie.match(new RegExp(`(?:^|; )${name}=([^;]*)`));
|
||
return m ? decodeURIComponent(m[1]!) : null;
|
||
}
|
||
|
||
/** fetch wrapper: sends cookies, adds CSRF header on mutations, parses errors. */
|
||
export async function apiFetch<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||
const method = (init.method ?? "GET").toUpperCase();
|
||
const headers = new Headers(init.headers);
|
||
if (init.body && !headers.has("content-type")) {
|
||
headers.set("content-type", "application/json");
|
||
}
|
||
if (method !== "GET" && method !== "HEAD") {
|
||
const csrf = readCookie(CSRF_COOKIE);
|
||
if (csrf) headers.set(CSRF_HEADER, csrf);
|
||
}
|
||
const res = await fetch(apiUrl(path), { ...init, headers, credentials: "include" });
|
||
if (!res.ok) {
|
||
const msg = (await res.json().catch(() => ({}))) as { error?: string; problems?: string[]; [k: string]: unknown };
|
||
const error = msg.error ?? `${path}: ${res.status}`;
|
||
// Ship the failed request to the backend log store (best-effort, loop-safe — the
|
||
// logger itself never logs the /api/logs call). 401s are normal pre-login churn,
|
||
// so we don't report them as errors. See lib/logger.ts.
|
||
if (res.status !== 401) {
|
||
logFailedRequest({ path, method, status: res.status, error });
|
||
}
|
||
throw new ApiError(error, res.status, msg.problems, msg);
|
||
}
|
||
if (res.status === 204) return undefined as T;
|
||
return res.json() as Promise<T>;
|
||
}
|
||
|
||
export class ApiError extends Error {
|
||
constructor(
|
||
message: string,
|
||
readonly status: number,
|
||
/** Field-level problems from a validation error (e.g. tariff publish), if any. */
|
||
readonly problems?: string[],
|
||
/** The full parsed error body, for callers that need extra fields (e.g. a booth
|
||
* exit's plate-swap detail: { status, plate, otherIdentity, otherEnteredAt }). */
|
||
readonly body?: Record<string, unknown>,
|
||
) {
|
||
super(message);
|
||
}
|
||
}
|
||
|
||
// --- Auth -----------------------------------------------------------------
|
||
|
||
export type Lang = "sq" | "en";
|
||
export type Theme = "dark" | "light";
|
||
/** A `resource:action` permission string (the server is the source of truth for
|
||
* the full grid; the role composer fetches it via /api/roles). */
|
||
export type Permission = string;
|
||
export interface SessionUser {
|
||
id: string;
|
||
username: string;
|
||
roleId: string;
|
||
roleName: string;
|
||
/** The permissions this user's role grants — the UI gates nav/routes on these. */
|
||
permissions: Permission[];
|
||
/** Preferred UI language (loaded from the server on login). */
|
||
language: Lang;
|
||
/** Preferred UI theme (loaded from the server on login). */
|
||
theme: Theme;
|
||
/** Preferred UI font scale, percent of base (100 = base; clamped 80–160). */
|
||
fontScale: number;
|
||
/** Optional display name (profile metadata); null if unset. */
|
||
fullName: string | null;
|
||
/** Optional contact email (profile metadata); null if unset. */
|
||
email: string | null;
|
||
}
|
||
|
||
/** Does this session grant the permission? Central authz check for the SPA. */
|
||
export function can(user: SessionUser | null, perm: Permission): boolean {
|
||
return !!user && user.permissions.includes(perm);
|
||
}
|
||
|
||
export function login(username: string, password: string): Promise<SessionUser> {
|
||
return apiFetch<SessionUser>("/api/auth/login", {
|
||
method: "POST",
|
||
body: JSON.stringify({ username, password }),
|
||
});
|
||
}
|
||
|
||
export function logout(): Promise<{ ok: boolean }> {
|
||
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 }) });
|
||
}
|
||
|
||
/** Persist the current user's UI theme preference (restored on next login). */
|
||
export function setThemePref(theme: Theme): Promise<{ theme: Theme }> {
|
||
return apiFetch("/api/auth/theme", { method: "PUT", body: JSON.stringify({ theme }) });
|
||
}
|
||
|
||
/** Allowed font-scale band (percent of base) + step. The header control clamps to these. */
|
||
export const FONT_SCALE_MIN = 80;
|
||
export const FONT_SCALE_MAX = 160;
|
||
export const FONT_SCALE_STEP = 10;
|
||
|
||
/** Persist the current user's UI font scale (percent; restored on next login). */
|
||
export function setFontScalePref(fontScale: number): Promise<{ fontScale: number }> {
|
||
return apiFetch("/api/auth/font-scale", { method: "PUT", body: JSON.stringify({ fontScale }) });
|
||
}
|
||
|
||
/** Edit MY own profile (display name / email). Returns the refreshed session.
|
||
* Self-service — touches only the signed-in user; no `user:*` permission needed. */
|
||
export function updateMyProfile(patch: {
|
||
fullName?: string | null;
|
||
email?: string | null;
|
||
}): Promise<SessionUser> {
|
||
return apiFetch<SessionUser>("/api/auth/profile", {
|
||
method: "PUT",
|
||
body: JSON.stringify(patch),
|
||
});
|
||
}
|
||
|
||
/** Change MY own password — proves the current one first (server enforces). */
|
||
export function changeMyPassword(
|
||
currentPassword: string,
|
||
newPassword: string,
|
||
): Promise<{ ok: boolean }> {
|
||
return apiFetch("/api/auth/password", {
|
||
method: "PUT",
|
||
body: JSON.stringify({ currentPassword, newPassword }),
|
||
});
|
||
}
|
||
|
||
/** Returns the current user, or null if not authenticated. */
|
||
export async function fetchMe(): Promise<SessionUser | null> {
|
||
try {
|
||
return await apiFetch<SessionUser>("/api/auth/me");
|
||
} catch (e) {
|
||
if (e instanceof ApiError && (e.status === 401 || e.status === 403)) return null;
|
||
throw e;
|
||
}
|
||
}
|
||
|
||
// --- User & role management (RBAC) ----------------------------------------
|
||
|
||
/** Optional profile metadata on a managed user (all nullable). */
|
||
export interface UserProfile {
|
||
fullName: string | null;
|
||
phone: string | null;
|
||
email: string | null;
|
||
address: string | null;
|
||
}
|
||
export interface ManagedUser extends UserProfile {
|
||
id: string;
|
||
username: string;
|
||
roleId: string;
|
||
roleName: string;
|
||
language: Lang;
|
||
createdAt: string;
|
||
}
|
||
export interface ManagedRole {
|
||
id: string;
|
||
name: string;
|
||
builtin: boolean;
|
||
permissions: Permission[];
|
||
userCount: number;
|
||
}
|
||
|
||
export function fetchUsers(): Promise<{ users: ManagedUser[] }> {
|
||
return apiFetch("/api/users");
|
||
}
|
||
export function createUser(
|
||
body: { username: string; password: string; roleId: string } & Partial<UserProfile>,
|
||
): Promise<ManagedUser> {
|
||
return apiFetch("/api/users", { method: "POST", body: JSON.stringify(body) });
|
||
}
|
||
export function updateUser(
|
||
id: string,
|
||
body: { username?: string; roleId?: string } & Partial<UserProfile>,
|
||
): Promise<ManagedUser> {
|
||
return apiFetch(`/api/users/${id}`, { method: "PUT", body: JSON.stringify(body) });
|
||
}
|
||
export function resetUserPassword(id: string, password: string): Promise<{ ok: boolean }> {
|
||
return apiFetch(`/api/users/${id}/password`, { method: "PUT", body: JSON.stringify({ password }) });
|
||
}
|
||
export function deleteUser(id: string): Promise<{ ok: boolean }> {
|
||
return apiFetch(`/api/users/${id}`, { method: "DELETE" });
|
||
}
|
||
|
||
/** Roles + the full permission catalog (for the composer checkbox grid). */
|
||
export function fetchRoles(): Promise<{ catalog: Permission[]; roles: ManagedRole[] }> {
|
||
return apiFetch("/api/roles");
|
||
}
|
||
export function createRole(body: { name: string; permissions: Permission[] }): Promise<ManagedRole> {
|
||
return apiFetch("/api/roles", { method: "POST", body: JSON.stringify(body) });
|
||
}
|
||
export function updateRole(id: string, body: { name?: string; permissions?: Permission[] }): Promise<ManagedRole> {
|
||
return apiFetch(`/api/roles/${id}`, { method: "PUT", body: JSON.stringify(body) });
|
||
}
|
||
export function deleteRole(id: string): Promise<{ ok: boolean }> {
|
||
return apiFetch(`/api/roles/${id}`, { method: "DELETE" });
|
||
}
|
||
|
||
// --- Application logs (app_logs) ------------------------------------------
|
||
/** Read recent diagnostic logs (gated server-side by log:read). */
|
||
export function fetchLogs(params: {
|
||
limit?: number;
|
||
level?: string;
|
||
source?: string;
|
||
since?: string;
|
||
} = {}): Promise<{ logs: AppLogRecord[] }> {
|
||
const q = new URLSearchParams();
|
||
if (params.limit) q.set("limit", String(params.limit));
|
||
if (params.level) q.set("level", params.level);
|
||
if (params.source) q.set("source", params.source);
|
||
if (params.since) q.set("since", params.since);
|
||
const qs = q.toString();
|
||
return apiFetch(`/api/logs${qs ? `?${qs}` : ""}`);
|
||
}
|
||
|
||
// --- Backup ---------------------------------------------------------------
|
||
// On-site encrypted DB backup. See wiki/concepts/backup-recovery.md.
|
||
|
||
export interface BackupStatus {
|
||
configured: boolean;
|
||
/** Admin-chosen target directory (null = not set). */
|
||
targetDir: string | null;
|
||
/** Admin-tuned retention (resolved value: DB or code default). */
|
||
keepLast: number;
|
||
keepDailyDays: number;
|
||
/** Whether the env encryption key is present (a missing key is flagged distinctly). */
|
||
keyPresent: boolean;
|
||
running: boolean;
|
||
lastSuccessAt: string | null;
|
||
lastResult: { path: string; bytes: number; prunedFiles: number } | null;
|
||
lastErrorAt: string | null;
|
||
lastError: string | null;
|
||
}
|
||
|
||
export async function fetchBackupStatus(): Promise<BackupStatus> {
|
||
return apiFetch("/api/backup/status");
|
||
}
|
||
|
||
export interface BackupConfigPatch {
|
||
/** "" clears the target. Omit a field to leave it unchanged; null resets retention to default. */
|
||
targetDir?: string | null;
|
||
keepLast?: number | null;
|
||
keepDailyDays?: number | null;
|
||
}
|
||
|
||
/** Update backup config (target dir and/or retention). Returns the new status. */
|
||
export async function setBackupConfig(patch: BackupConfigPatch): Promise<BackupStatus> {
|
||
return apiFetch("/api/backup/config", { method: "PUT", body: JSON.stringify(patch) });
|
||
}
|
||
|
||
export interface TargetCheck {
|
||
ok: boolean;
|
||
/** "empty" | "missing" | "not_a_dir" | "not_writable" when !ok. */
|
||
reason?: string;
|
||
}
|
||
|
||
/** Probe a candidate target path server-side (exists / is a dir / is writable). */
|
||
export async function testBackupTarget(targetDir: string): Promise<TargetCheck> {
|
||
return apiFetch("/api/backup/test", { method: "POST", body: JSON.stringify({ targetDir }) });
|
||
}
|
||
|
||
export interface BackupRunResult {
|
||
ok: true;
|
||
path: string;
|
||
bytes: number;
|
||
prunedFiles: number;
|
||
}
|
||
|
||
/** Trigger a manual "back up now". Throws on 409 (not configured) / 500 (run failed). */
|
||
export async function runBackup(): Promise<BackupRunResult> {
|
||
return apiFetch("/api/backup/run", { method: "POST" });
|
||
}
|
||
|
||
// --- Device setup ---------------------------------------------------------
|
||
|
||
export interface ConfigField {
|
||
key: string;
|
||
label: string;
|
||
type: "string" | "number" | "boolean" | "host" | "port" | "secret" | "select";
|
||
required: boolean;
|
||
default?: string | number | boolean;
|
||
options?: { value: string; label: string }[];
|
||
help?: string;
|
||
}
|
||
|
||
export interface CatalogEntry {
|
||
id: string;
|
||
label: string;
|
||
description: string;
|
||
transports: string[];
|
||
configFields: ConfigField[];
|
||
}
|
||
|
||
export type DeviceCategory = "access" | "reader" | "camera" | "printer";
|
||
export type Catalog = Record<DeviceCategory, CatalogEntry[]> & {
|
||
/** Driver ids that support LAN discovery. */
|
||
discoverable: string[];
|
||
/** Driver ids that push to the backend (need a backend IP at assign time). */
|
||
pushCapable: string[];
|
||
};
|
||
|
||
export function fetchCatalog(): Promise<Catalog> {
|
||
return apiFetch<Catalog>("/api/setup/catalog");
|
||
}
|
||
|
||
export interface DiscoveredDevice {
|
||
id: string;
|
||
label: string;
|
||
config: Record<string, string | number | boolean>;
|
||
info?: Record<string, string>;
|
||
health: { status: string; detail?: string };
|
||
}
|
||
|
||
/** Scan the LAN for devices a driver can discover. Admin-only. */
|
||
export async function discoverDevices(driverId: string): Promise<DiscoveredDevice[]> {
|
||
const body = await apiFetch<{ devices: DiscoveredDevice[] }>(
|
||
`/api/setup/discover/${driverId}`,
|
||
);
|
||
return body.devices;
|
||
}
|
||
|
||
export type ConfigValue =
|
||
| string
|
||
| number
|
||
| boolean
|
||
| null
|
||
| ConfigValue[]
|
||
| { [k: string]: ConfigValue };
|
||
export type DeviceConfig = Record<string, ConfigValue>;
|
||
|
||
/** Direction a barrier/relay (or a device bound to it) serves. */
|
||
export type Direction = "entry" | "exit" | "both";
|
||
|
||
/** The EVENT a relay reacts to. entry/exit/both → pulse a barrier; `radarAlert` → drive a
|
||
* non-barrier alert lamp (blink while its trigger input is active, SOLID once the camera
|
||
* confirms a car). The action is implied by the event. */
|
||
export type RelayEvent = Direction | "radarAlert";
|
||
|
||
/** What a controller input terminal means: a transient-entry `button`, a one-car-one-ticket
|
||
* `presence` sensor (loop/radar), or an `alertTrigger` for a radarAlert lamp. */
|
||
export type InputRole = "button" | "presence" | "alertTrigger";
|
||
|
||
/** One INPUT terminal the host reads (the twin of RelaySpec). An exit radar is just another
|
||
* `presence` row serving the exit relay. */
|
||
export interface InputSpec {
|
||
input: number;
|
||
role: InputRole;
|
||
/** The barrier relay this input serves (required for button/presence; optional for
|
||
* alertTrigger). */
|
||
relay?: number;
|
||
/** presence only — induction LOOP or RADAR (label only). */
|
||
kind?: "loop" | "radar";
|
||
/** This terminal idles HIGH / is active-LOW (e.g. a radar wired opposite the button). */
|
||
activeLow?: boolean;
|
||
/** button only — presence-less fallback cooldown (seconds). */
|
||
cooldownSec?: number;
|
||
}
|
||
|
||
/** One relay on an access controller: the event it reacts to. Input wiring lives in
|
||
* `config.inputs[]`; the legacy per-relay button/presence fields are still read for
|
||
* back-compat but no longer written. */
|
||
export interface RelaySpec {
|
||
relay: number;
|
||
/** The event this relay reacts to (UI label: "Event"). */
|
||
direction: RelayEvent;
|
||
// ── legacy input fields (read-only back-compat; superseded by config.inputs[]) ──
|
||
button?: number;
|
||
presenceInput?: number;
|
||
presenceKind?: "loop" | "radar";
|
||
presenceActiveLow?: boolean;
|
||
entryCooldownSec?: number;
|
||
// ── radarAlert-only ──
|
||
/** Input terminal whose active edge starts the blink (the radar). */
|
||
triggerInput?: number;
|
||
/** Which lane's camera locks this lamp SOLID (default entry). An exit radar locks on exit. */
|
||
lockLane?: "entry" | "exit";
|
||
/** Blink cadence (ms on / ms off) for the radar-only state. Default 500/500. */
|
||
blinkOnMs?: number;
|
||
blinkOffMs?: number;
|
||
}
|
||
|
||
export interface TestResult {
|
||
health: { status: string; detail?: string };
|
||
preconditions: {
|
||
ok: boolean;
|
||
issues: { key: string; message: string; fixable: boolean }[];
|
||
};
|
||
}
|
||
|
||
/** Test a device config (reachability + preconditions) without saving. Pass the
|
||
* device `id` when editing an existing one so the server re-merges its stored
|
||
* machine secrets (e.g. the relay password redacted from the client). */
|
||
export function testDevice(driverId: string, config: DeviceConfig, id?: string): Promise<TestResult> {
|
||
return apiFetch<TestResult>("/api/setup/test", {
|
||
method: "POST",
|
||
body: JSON.stringify({ driverId, config, ...(id ? { id } : {}) }),
|
||
});
|
||
}
|
||
|
||
/** Result of an end-to-end ANPR probe on a camera: snapshot → vision analyze. */
|
||
export type AnprTestResult =
|
||
| {
|
||
ok: true;
|
||
plate: string;
|
||
confidence: number;
|
||
region: string | null;
|
||
lowConfidence: boolean;
|
||
modelVersion: string;
|
||
tookMs: number;
|
||
}
|
||
| {
|
||
ok: false;
|
||
/** vision-disabled | snapshot-failed | no-plate */
|
||
reason: string;
|
||
detail?: string;
|
||
tookMs?: number;
|
||
};
|
||
|
||
/** Take a live snapshot off the camera and run ANPR on it — without saving. Reports
|
||
* whether a plate was extracted, the read, and how long it took. */
|
||
export function testAnpr(driverId: string, config: DeviceConfig): Promise<AnprTestResult> {
|
||
return apiFetch<AnprTestResult>("/api/setup/test-anpr", {
|
||
method: "POST",
|
||
body: JSON.stringify({ driverId, config }),
|
||
});
|
||
}
|
||
|
||
/** Result of a physical print test: a real test slip is pushed to the printer. */
|
||
export type PrintTestResult =
|
||
| { ok: true; tookMs: number }
|
||
| { ok: false; reason: string; detail?: string; tookMs?: number };
|
||
|
||
/** Print a real test slip on the printer — without saving. Confirms the printer
|
||
* actually feeds paper + fires the head (healthCheck only opens the transport). */
|
||
export function testPrint(
|
||
driverId: string,
|
||
config: DeviceConfig,
|
||
id?: string,
|
||
): Promise<PrintTestResult> {
|
||
return apiFetch<PrintTestResult>("/api/setup/test-print", {
|
||
method: "POST",
|
||
body: JSON.stringify({ driverId, config, id }),
|
||
});
|
||
}
|
||
|
||
export type RelayTestResult =
|
||
| { ok: true; firedAt: string; tookMs: number }
|
||
| { ok: false; reason: string; detail?: string; tookMs?: number };
|
||
|
||
/** Pulse a SAVED controller's barrier relay to test the wiring — physically opens the
|
||
* barrier. The server signs a `barrier_open_command` (reason setup.relayTest) before
|
||
* firing, so the open is explained, not a reconciliation anomaly. Saved controller only
|
||
* (needs a persisted id for attribution). */
|
||
export function testRelay(id: string, relay: number): Promise<RelayTestResult> {
|
||
return apiFetch<RelayTestResult>("/api/setup/test-relay", {
|
||
method: "POST",
|
||
body: JSON.stringify({ id, relay }),
|
||
});
|
||
}
|
||
|
||
// --- Admin reports -------------------------------------------------------
|
||
export type ReportBucket = "hour" | "day" | "month";
|
||
|
||
export interface ReportSeriesPoint {
|
||
bucket: string;
|
||
entries: number;
|
||
exits: number;
|
||
revenueMinor: number;
|
||
payments: number;
|
||
}
|
||
|
||
export interface ReportTotals {
|
||
entries: number;
|
||
exits: number;
|
||
payments: number;
|
||
revenueMinor: number;
|
||
cashMinor: number;
|
||
cardMinor: number;
|
||
ticketMinor: number;
|
||
subscriptionSalesMinor: number;
|
||
subscriptionWindowMinor: number;
|
||
closedSessions: number;
|
||
totalParkedMinutes: number;
|
||
avgParkedMinutes: number;
|
||
medianParkedMinutes: number;
|
||
}
|
||
|
||
export interface ReportSubscriptionStats {
|
||
active: number;
|
||
suspended: number;
|
||
revoked: number;
|
||
currentlyValid: number;
|
||
coveredCars: number;
|
||
}
|
||
|
||
export interface ReportSummary {
|
||
from: string;
|
||
to: string;
|
||
bucket: ReportBucket;
|
||
tz: string;
|
||
currency: string | null;
|
||
totals: ReportTotals;
|
||
series: ReportSeriesPoint[];
|
||
entriesByHour: number[];
|
||
subscriptions: ReportSubscriptionStats;
|
||
}
|
||
|
||
/** The whole admin dashboard (totals + series + peak-hours + subscriptions) for a range. */
|
||
export function fetchReport(from: string, to: string, bucket: ReportBucket): Promise<ReportSummary> {
|
||
const qs = new URLSearchParams({ from, to, bucket }).toString();
|
||
return apiFetch<ReportSummary>(`/api/reports/summary?${qs}`);
|
||
}
|
||
|
||
/** URL for the CSV export of the per-bucket series (opened/downloaded directly; the
|
||
* auth cookie rides along same-origin). */
|
||
export function reportCsvUrl(from: string, to: string, bucket: ReportBucket): string {
|
||
const qs = new URLSearchParams({ from, to, bucket }).toString();
|
||
return apiUrl(`/api/reports/summary.csv?${qs}`);
|
||
}
|
||
|
||
// --- Recycle bin (soft-deleted master data) ------------------------------
|
||
export type RecycleKind = "user" | "role" | "subscription" | "plan" | "tariff";
|
||
|
||
export interface RecycleBinItem {
|
||
kind: RecycleKind;
|
||
id: string;
|
||
label: string;
|
||
deletedAt: string;
|
||
deletedBy: string | null;
|
||
}
|
||
|
||
export interface RecycleBin {
|
||
items: RecycleBinItem[];
|
||
retentionDays: number;
|
||
}
|
||
|
||
/** Everything currently in the recycle bin + the retention window (days). */
|
||
export function fetchRecycleBin(): Promise<RecycleBin> {
|
||
return apiFetch<RecycleBin>("/api/recycle-bin");
|
||
}
|
||
|
||
/** Restore a soft-deleted item (back to its catalog). 409 if a live row would collide. */
|
||
export function restoreRecycleItem(kind: RecycleKind, id: string): Promise<{ restored: boolean }> {
|
||
return apiFetch<{ restored: boolean }>(`/api/recycle-bin/${kind}/${encodeURIComponent(id)}/restore`, {
|
||
method: "POST",
|
||
});
|
||
}
|
||
|
||
/** Permanently purge a soft-deleted item. Irreversible. */
|
||
export function purgeRecycleItem(kind: RecycleKind, id: string): Promise<void> {
|
||
return apiFetch<void>(`/api/recycle-bin/${kind}/${encodeURIComponent(id)}`, { method: "DELETE" });
|
||
}
|
||
|
||
export interface BackendIpCandidate {
|
||
ip: string;
|
||
iface: string;
|
||
onDeviceSubnet: boolean;
|
||
}
|
||
|
||
/** Local IPs the device could push to (on-subnet first), for the wizard to
|
||
* pre-fill/override. Matters on multi-NIC hosts. */
|
||
export function fetchBackendIps(
|
||
host: string,
|
||
): Promise<{ candidates: BackendIpCandidate[]; port: number }> {
|
||
return apiFetch(`/api/setup/backend-ips?host=${encodeURIComponent(host)}`);
|
||
}
|
||
|
||
export interface AssignBody {
|
||
category: DeviceCategory;
|
||
driverId: string;
|
||
// Direction/binding lives in config: access → config.relays=[{relay,direction,button?}];
|
||
// reader/camera → config.controllerId + config.relay.
|
||
config: DeviceConfig;
|
||
/** Backend IP the device should push to (overrides auto-pick). */
|
||
backendIp?: string;
|
||
}
|
||
|
||
/** Save + configure the device (preconditions, push setup), then persist. */
|
||
export function assignDevice(body: AssignBody): Promise<AssignResult> {
|
||
return apiFetch("/api/setup/assign", { method: "POST", body: JSON.stringify(body) });
|
||
}
|
||
|
||
/** Re-configure an existing device in place, keeping its id (and so its push
|
||
* URL). Category/driver are fixed at create time, so only config changes. */
|
||
export function editDevice(
|
||
id: string,
|
||
body: Omit<AssignBody, "category" | "driverId">,
|
||
): Promise<AssignResult> {
|
||
return apiFetch(`/api/setup/assign/${id}`, { method: "PATCH", body: JSON.stringify(body) });
|
||
}
|
||
|
||
/** A persisted device assignment (one per instance; machine-only secrets stripped). */
|
||
export interface Assignment {
|
||
id: string;
|
||
category: DeviceCategory;
|
||
driverId: string;
|
||
config: DeviceConfig;
|
||
enabled: boolean;
|
||
createdAt?: string;
|
||
}
|
||
|
||
/** Assign response = the saved assignment plus any residual-risk warnings
|
||
* (e.g. "string protocol could not be disabled — finish in the device web UI"). */
|
||
export interface AssignResult extends Assignment {
|
||
warnings?: string[];
|
||
}
|
||
|
||
export interface SetupState {
|
||
completedAt: string | null;
|
||
assignments: Assignment[];
|
||
}
|
||
|
||
/** Current setup status + all assigned device instances. */
|
||
export function fetchState(): Promise<SetupState> {
|
||
return apiFetch<SetupState>("/api/setup/state");
|
||
}
|
||
|
||
/** Remove one assigned device instance by id. */
|
||
export function unassignDevice(id: string): Promise<void> {
|
||
return apiFetch(`/api/setup/assign/${id}`, { method: "DELETE" });
|
||
}
|
||
|
||
// --- Tariff composer ------------------------------------------------------
|
||
|
||
export interface TariffBlock {
|
||
uptoMin: number | null;
|
||
priceMinorPerIncrement: number;
|
||
}
|
||
// Mirrors @parking/shared. Two shapes: V1 (bare ladder) and V2 (default + windowed
|
||
// cards by time-of-day / dow / date / category, flat or laddered). The discriminant
|
||
// is the presence of `defaultCard`. See wiki/concepts/tariff-time-tiers.md.
|
||
export interface TariffStructureV1 {
|
||
gracePeriodEntryMin: number;
|
||
incrementMin: number;
|
||
blocks: TariffBlock[];
|
||
/** STEPPED ("up-to") total-by-duration table; when non-empty it replaces `blocks`. */
|
||
steps?: TariffStep[];
|
||
dailyCapMinor: number | null;
|
||
lostTicketMinor: number;
|
||
gracePeriodExitMin: number;
|
||
overstay: "reprice";
|
||
}
|
||
export interface TariffWindow {
|
||
dow?: number[];
|
||
dateFrom?: string;
|
||
dateTo?: string;
|
||
fromHour?: string;
|
||
toHour?: string;
|
||
}
|
||
export interface TariffCard {
|
||
name: string;
|
||
priority: number;
|
||
category?: string;
|
||
window?: TariffWindow;
|
||
/** Flat price PER INCREMENT (an hourly flat rate) — not a whole-stay price. */
|
||
flatMinor?: number;
|
||
blocks?: TariffBlock[];
|
||
/** STEPPED ("up-to") table (defaultCard only); mutually exclusive with flat/blocks. */
|
||
steps?: TariffStep[];
|
||
/** WINDOW PACKAGE (windowed cards only): ONE total per contiguous window occurrence
|
||
* ("any presence in the window = this price"). Mirrors @parking/shared. */
|
||
packageMinor?: number;
|
||
dailyCapMinor?: number | null;
|
||
}
|
||
/** One row of a STEPPED ("up-to") tariff: a TOTAL price for a stay up to and including
|
||
* `uptoMin` minutes (cumulative, not marginal). Mirrors @parking/shared TariffStep. */
|
||
export interface TariffStep {
|
||
uptoMin: number;
|
||
totalMinor: number;
|
||
}
|
||
|
||
export interface TariffStructureV2 {
|
||
version: 2;
|
||
tz: string;
|
||
gracePeriodEntryMin: number;
|
||
incrementMin: number;
|
||
lostTicketMinor: number;
|
||
gracePeriodExitMin: number;
|
||
overstay: "reprice";
|
||
defaultCard: TariffCard;
|
||
windowedCards?: TariffCard[];
|
||
}
|
||
export type TariffStructure = TariffStructureV1 | TariffStructureV2;
|
||
|
||
/** True when a structure is the windowed V2 shape (mirrors @parking/shared isTariffV2). */
|
||
export function isTariffV2(t: TariffStructure): t is TariffStructureV2 {
|
||
return (t as TariffStructureV2).defaultCard != null;
|
||
}
|
||
export interface TariffVersion {
|
||
id: string;
|
||
tariffId: string;
|
||
/** Optional human label, stamped at publish (e.g. carried from a lab draft). */
|
||
name?: string | null;
|
||
effectiveFrom: string;
|
||
currency: string;
|
||
structure: TariffStructure;
|
||
createdBy?: string | null;
|
||
createdAt?: string;
|
||
}
|
||
export interface TariffState {
|
||
tariffId: string;
|
||
active: TariffVersion | null;
|
||
versions: TariffVersion[];
|
||
}
|
||
|
||
export function fetchTariff(): Promise<TariffState> {
|
||
return apiFetch<TariffState>("/api/tariff");
|
||
}
|
||
|
||
/** Publish a new immutable tariff version (becomes the active rate card). */
|
||
export function publishTariffVersion(body: {
|
||
currency: string;
|
||
structure: TariffStructure;
|
||
effectiveFrom?: string;
|
||
name?: string;
|
||
}): Promise<TariffVersion> {
|
||
return apiFetch("/api/tariff/versions", { method: "POST", body: JSON.stringify(body) });
|
||
}
|
||
|
||
// --- Tariff Lab (simulator) -----------------------------------------------
|
||
|
||
export interface SimPayment {
|
||
paidAt: string;
|
||
graceExitMin: number | null;
|
||
}
|
||
export interface SimSessionPricing {
|
||
periodStart: string;
|
||
amountMinor: number;
|
||
overstay: boolean;
|
||
withinGrace: boolean;
|
||
graceExpiresAt: string | null;
|
||
}
|
||
export interface SimulateResult {
|
||
currency: string | null;
|
||
pricing: SimSessionPricing;
|
||
curve: { minutes: number; amountMinor: number }[];
|
||
gracePeriodExitMin: number;
|
||
}
|
||
export interface SimulateBody {
|
||
enteredAt: string;
|
||
asOf: string;
|
||
payments?: SimPayment[];
|
||
category?: string;
|
||
tariffVersionId?: string;
|
||
structure?: TariffStructure;
|
||
currency?: string;
|
||
}
|
||
|
||
/** Price a hypothetical session — pure, no ledger write. See Tariff Lab. */
|
||
export function simulateTariff(body: SimulateBody): Promise<SimulateResult> {
|
||
return apiFetch("/api/tariff/simulate", { method: "POST", body: JSON.stringify(body) });
|
||
}
|
||
|
||
// --- Tariff Lab drafts ------------------------------------------------------
|
||
// Mutable experimental rate cards — the lab composes + simulates these, and
|
||
// publishing one goes through the normal immutable-version path above.
|
||
|
||
export interface TariffDraft {
|
||
id: string;
|
||
name: string;
|
||
currency: string;
|
||
structure: TariffStructure;
|
||
createdBy: string | null;
|
||
createdAt: string;
|
||
updatedAt: string;
|
||
}
|
||
|
||
export function fetchTariffDrafts(): Promise<{ drafts: TariffDraft[] }> {
|
||
return apiFetch("/api/tariff/drafts");
|
||
}
|
||
|
||
export interface TariffDraftBody {
|
||
name: string;
|
||
currency: string;
|
||
structure: TariffStructure;
|
||
}
|
||
|
||
export function createTariffDraft(body: TariffDraftBody): Promise<TariffDraft> {
|
||
return apiFetch("/api/tariff/drafts", { method: "POST", body: JSON.stringify(body) });
|
||
}
|
||
|
||
export function updateTariffDraft(id: string, body: TariffDraftBody): Promise<TariffDraft> {
|
||
return apiFetch(`/api/tariff/drafts/${encodeURIComponent(id)}`, { method: "PUT", body: JSON.stringify(body) });
|
||
}
|
||
|
||
export function deleteTariffDraft(id: string): Promise<void> {
|
||
return apiFetch(`/api/tariff/drafts/${encodeURIComponent(id)}`, { method: "DELETE" });
|
||
}
|
||
|
||
// --- Subscriptions --------------------------------------------------------
|
||
|
||
export interface SubscriptionCredential {
|
||
kind: "rf" | "qr";
|
||
value: string;
|
||
}
|
||
export type SubscriptionPeriod = "day" | "week" | "month";
|
||
|
||
/** A subscriber's allowed parking window (minutes-from-local-midnight) on selected days.
|
||
* A scan outside the window is charged the transient tariff for the gap. days: 0=Sun..6=Sat
|
||
* (empty = every day); the window [fromMin,toMin) wraps past midnight when toMin ≤ fromMin. */
|
||
export interface PlanTimeframes {
|
||
days?: number[];
|
||
fromMin: number;
|
||
toMin: number;
|
||
graceMin?: number;
|
||
tz?: string;
|
||
}
|
||
|
||
/** A subscription PLAN version — admin-composed, versioned config the operator sells
|
||
* from (so they never type a price). */
|
||
export interface SubscriptionPlan {
|
||
id: string;
|
||
planId: string;
|
||
name: string;
|
||
period: SubscriptionPeriod;
|
||
pricePerPeriodMinor: number;
|
||
currency: string;
|
||
effectiveFrom: string;
|
||
active: boolean;
|
||
/** Allowed-time windows (tariff bridge); null/absent = 24/7, no time charge. */
|
||
timeframes?: PlanTimeframes | null;
|
||
}
|
||
|
||
export interface Subscription {
|
||
id: string;
|
||
holderName: string | null;
|
||
contact: string | null;
|
||
/** Price billed for the window in minor units — DERIVED from the plan. null = comp. */
|
||
priceMinor: number | null;
|
||
period: SubscriptionPeriod;
|
||
currency: string | null;
|
||
/** Which plan + immutable version priced this sale (null for legacy/comp). */
|
||
planId: string | null;
|
||
planVersionId: string | null;
|
||
/** Cars covered by this one subscription (price was ×N). Default 1. */
|
||
quantity: number;
|
||
maxConcurrent: number | null;
|
||
validFrom: string | null;
|
||
validTo: string | null;
|
||
status: "active" | "suspended" | "revoked";
|
||
credentials: SubscriptionCredential[];
|
||
plates: string[];
|
||
}
|
||
/** A credential as SENT to the server: a QR value may be omitted/blank → the server
|
||
* auto-generates an unguessable code. RF must carry the card id. */
|
||
export interface SubscriptionCredentialInput {
|
||
kind: "rf" | "qr";
|
||
value?: string;
|
||
}
|
||
export type SubscriptionInput = {
|
||
holderName: string | null;
|
||
contact: string | null;
|
||
/** PRICED SALE: the plan selected. Price is looked up server-side (never typed).
|
||
* Omit for a comp subscription. */
|
||
planId?: string | null;
|
||
/** Coverage window. Priced sale: validFrom defaults to now, validTo required. */
|
||
validFrom: string | null;
|
||
validTo: string | null;
|
||
/** Cars covered (price ×N). Default 1. */
|
||
quantity?: number;
|
||
maxConcurrent: number | null;
|
||
status?: Subscription["status"];
|
||
/** How the sale fee was tendered (cash → drawer, card → bank). Used at CREATE when a
|
||
* plan is sold (the sale appends a signed payment); ignored on update. */
|
||
tender?: "cash" | "card";
|
||
credentials: SubscriptionCredentialInput[];
|
||
plates: string[];
|
||
/** UPDATE-only correction: move the sub to a different VERSION of its SAME plan. Price
|
||
* stays frozen; only the access rules change going forward. Requires subscription:plan. */
|
||
planVersionId?: string;
|
||
};
|
||
|
||
/** A server-computed quote: periods (ceil) × per-period price × quantity for a span. */
|
||
export interface SubscriptionQuote {
|
||
periods: number;
|
||
amountMinor: number;
|
||
currency: string;
|
||
period: SubscriptionPeriod;
|
||
quantity?: number;
|
||
plan: SubscriptionPlan;
|
||
}
|
||
|
||
/** The create response = the saved subscription + the auto-print outcome, plus the
|
||
* recorded SALE (the signed payment) when a price was collected. */
|
||
export type SubscriptionCreated = Subscription & {
|
||
printed: boolean;
|
||
printedBy?: string;
|
||
printError?: string;
|
||
/** Present when a priced subscription was sold: the signed payment just appended. */
|
||
sale?: {
|
||
amountMinor: number;
|
||
currency: string | null;
|
||
tender: "cash" | "card";
|
||
periods: number;
|
||
inShift: boolean;
|
||
};
|
||
};
|
||
|
||
export function fetchSubscriptions(): Promise<{ subscriptions: Subscription[] }> {
|
||
return apiFetch("/api/subscriptions");
|
||
}
|
||
|
||
// --- Subscription plan catalog (admin-composed; operator sells from it) ------
|
||
export function fetchSubscriptionPlans(all = false): Promise<{ plans: SubscriptionPlan[] }> {
|
||
return apiFetch(`/api/subscription-plans${all ? "?all=1" : ""}`);
|
||
}
|
||
export function createSubscriptionPlan(body: {
|
||
planId?: string;
|
||
name: string;
|
||
period: SubscriptionPeriod;
|
||
pricePerPeriodMinor: number;
|
||
currency: string;
|
||
effectiveFrom?: string;
|
||
timeframes?: PlanTimeframes | null;
|
||
}): Promise<SubscriptionPlan> {
|
||
return apiFetch("/api/subscription-plans", { method: "POST", body: JSON.stringify(body) });
|
||
}
|
||
export function retireSubscriptionPlan(planId: string): Promise<{ planId: string; retired: boolean }> {
|
||
return apiFetch(`/api/subscription-plans/${encodeURIComponent(planId)}/retire`, { method: "POST" });
|
||
}
|
||
export function reactivateSubscriptionPlan(planId: string): Promise<{ planId: string; reactivated: boolean }> {
|
||
return apiFetch(`/api/subscription-plans/${encodeURIComponent(planId)}/reactivate`, { method: "POST" });
|
||
}
|
||
/** Delete a plan (all versions). Rejects (409 plan_in_use) if any subscription uses it. */
|
||
export function deleteSubscriptionPlan(planId: string): Promise<{ planId: string; deleted: boolean }> {
|
||
return apiFetch(`/api/subscription-plans/${encodeURIComponent(planId)}`, { method: "DELETE" });
|
||
}
|
||
/** Live quote for the sell form (server-computed; the operator can't override it). */
|
||
export function quoteSubscription(body: {
|
||
planId: string;
|
||
validFrom: string | null;
|
||
validTo: string;
|
||
quantity?: number;
|
||
}): Promise<SubscriptionQuote> {
|
||
return apiFetch("/api/subscriptions/quote", { method: "POST", body: JSON.stringify(body) });
|
||
}
|
||
export function createSubscription(body: SubscriptionInput): Promise<SubscriptionCreated> {
|
||
return apiFetch("/api/subscriptions", { method: "POST", body: JSON.stringify(body) });
|
||
}
|
||
/** Re-print a subscription's QR card (failed auto-print / lost card). */
|
||
export function printSubscription(id: string): Promise<{ ok: boolean; printedBy: string }> {
|
||
return apiFetch(`/api/subscriptions/${id}/print`, { method: "POST" });
|
||
}
|
||
|
||
// --- Credential capture ("enroll a card" on a chosen reader) ---------------
|
||
|
||
export interface ReaderInfo {
|
||
id: string;
|
||
driverId: string;
|
||
direction: "entry" | "exit" | "both";
|
||
}
|
||
export type CaptureState =
|
||
| { status: "idle" }
|
||
| { status: "armed"; deviceId: string; armedAt: number; expiresAt: number }
|
||
| { status: "captured"; deviceId: string; value: string; capturedAt: number }
|
||
| { status: "expired"; deviceId: string };
|
||
|
||
export function fetchReaders(): Promise<{ readers: ReaderInfo[] }> {
|
||
return apiFetch("/api/subscriptions/readers");
|
||
}
|
||
export function armCapture(deviceId: string): Promise<{ expiresAt: number }> {
|
||
return apiFetch("/api/subscriptions/capture/arm", { method: "POST", body: JSON.stringify({ deviceId }) });
|
||
}
|
||
export function pollCapture(): Promise<CaptureState> {
|
||
return apiFetch("/api/subscriptions/capture");
|
||
}
|
||
export function cancelCapture(): Promise<{ ok: boolean }> {
|
||
return apiFetch("/api/subscriptions/capture/cancel", { method: "POST" });
|
||
}
|
||
export function updateSubscription(id: string, body: SubscriptionInput): Promise<Subscription> {
|
||
return apiFetch(`/api/subscriptions/${id}`, { method: "PUT", body: JSON.stringify(body) });
|
||
}
|
||
export function revokeSubscription(id: string): Promise<Subscription> {
|
||
return apiFetch(`/api/subscriptions/${id}/revoke`, { method: "POST" });
|
||
}
|
||
export function deleteSubscription(id: string): Promise<void> {
|
||
return apiFetch(`/api/subscriptions/${id}`, { method: "DELETE" });
|
||
}
|
||
|
||
// --- Shifts ---------------------------------------------------------------
|
||
|
||
export interface ShiftStatus {
|
||
/** The requesting (logged-in) operator. */
|
||
operator: string;
|
||
/** The SINGLE site-wide open shift (startedAt + whose), or null if none open. */
|
||
open: { startedAt: string; operator: string | null } | null;
|
||
/** True iff the open shift belongs to the requesting operator (can close it). */
|
||
isMine: boolean;
|
||
/** Live physical drawer balance (cash payments + cash movements). */
|
||
drawerMinor: number;
|
||
currency: string | null;
|
||
}
|
||
/** Takings split by SOURCE — transient tickets vs subscriber money (monthly sales +
|
||
* out-of-window charges). Cash+card combined; the per-tender totals stay separate for
|
||
* the drawer. Shared by the X-report, the close Z-report, and the history summary. */
|
||
export interface ShiftSourceSplit {
|
||
ticketTotalMinor: number;
|
||
subscriptionTotalMinor: number;
|
||
subscriptionSalesMinor: number;
|
||
subscriptionWindowMinor: number;
|
||
}
|
||
|
||
export interface ShiftReport extends ShiftSourceSplit {
|
||
operator: string;
|
||
startedAt: string;
|
||
endedAt: string;
|
||
cashTotalMinor: number;
|
||
cardTotalMinor: number;
|
||
currency: string | null;
|
||
paymentCount: number;
|
||
// Drawer (carries across shifts).
|
||
openingFloatMinor: number;
|
||
cashAddedMinor: number;
|
||
cashRemovedMinor: number;
|
||
expectedDrawerMinor: number;
|
||
printed: boolean;
|
||
}
|
||
|
||
export function fetchShift(): Promise<ShiftStatus> {
|
||
return apiFetch("/api/shift/current");
|
||
}
|
||
export function openShift(): Promise<{ startedAt: string; openingFloatMinor: number }> {
|
||
return apiFetch("/api/shift/open", { method: "POST" });
|
||
}
|
||
export function closeShift(): Promise<ShiftReport> {
|
||
return apiFetch("/api/shift/close", { method: "POST" });
|
||
}
|
||
|
||
/** Mid-shift X-report: the open shift's takings + drawer "so far" (read-only — no
|
||
* event is appended). Same figures the Z-report will print at close. `asOf` is the
|
||
* snapshot instant. */
|
||
export interface XReport extends ShiftSourceSplit {
|
||
operator: string;
|
||
startedAt: string;
|
||
endedAt: string; // = asOf
|
||
asOf: string;
|
||
cashTotalMinor: number;
|
||
cardTotalMinor: number;
|
||
currency: string | null;
|
||
paymentCount: number;
|
||
openingFloatMinor: number;
|
||
cashAddedMinor: number;
|
||
cashRemovedMinor: number;
|
||
expectedDrawerMinor: number;
|
||
}
|
||
|
||
/** Fetch the mid-shift X-report; resolves to null when no shift is open (204). */
|
||
export async function fetchShiftReport(): Promise<XReport | null> {
|
||
return (await apiFetch<XReport | undefined>("/api/shift/report")) ?? null;
|
||
}
|
||
|
||
// --- Drawer cash movements (operator records, admin reviews) ---------------------
|
||
// Redesigned 2026-07-01: an operator RECORDS a receipt/disbursement freely; an admin
|
||
// REVIEWS it after the fact (authorize/deny — a flag, never a cash reversal). See
|
||
// wiki/concepts/shift.md.
|
||
|
||
export type MovementStatus = "pending" | "authorized" | "denied";
|
||
|
||
/** A drawer movement with its admin-review status. */
|
||
export interface DrawerMovement {
|
||
id: string;
|
||
type: "cash_in" | "cash_out";
|
||
/** Positive magnitude; direction is the type. */
|
||
amountMinor: number;
|
||
currency: string | null;
|
||
reason: string | null;
|
||
operator: string;
|
||
voucherNo: string | null;
|
||
at: string;
|
||
status: MovementStatus;
|
||
reviewedBy: string | null;
|
||
reviewNote: string | null;
|
||
reviewedAt: string | null;
|
||
}
|
||
|
||
/** Operator RECORDS a drawer movement — cash_in (Mandat Arkëtimi / pay-IN) or cash_out
|
||
* (Mandat Pagese / pay-OUT). Direction is the TYPE; amountMinor a positive magnitude.
|
||
* No admin sign-off at creation — it's reviewed afterward. */
|
||
export function recordDrawerMovement(args: {
|
||
type: "cash_in" | "cash_out";
|
||
amountMinor: number;
|
||
reason: string;
|
||
currency?: string;
|
||
}): Promise<{
|
||
type: "cash_in" | "cash_out";
|
||
amountMinor: number;
|
||
voucherNo: string;
|
||
balanceMinor: number;
|
||
printed: boolean;
|
||
}> {
|
||
return apiFetch("/api/drawer/movement", { method: "POST", body: JSON.stringify(args) });
|
||
}
|
||
|
||
/** List drawer movements + review status. Operators get their OWN; a reviewer gets all
|
||
* and may filter by status (the pending review queue). */
|
||
export function fetchDrawerMovements(status?: MovementStatus): Promise<{
|
||
movements: DrawerMovement[];
|
||
scope: "all" | "self";
|
||
}> {
|
||
const qs = status ? `?status=${encodeURIComponent(status)}` : "";
|
||
return apiFetch(`/api/drawer/movements${qs}`);
|
||
}
|
||
|
||
/** The physical drawer balance NOW (cash payments + vouchers over the whole chain —
|
||
* the amount that carries across shifts). */
|
||
export function fetchDrawerBalance(): Promise<{ balanceMinor: number; currency: string | null }> {
|
||
return apiFetch("/api/drawer/balance");
|
||
}
|
||
|
||
/** Admin AUTHORIZES or DENIES a recorded movement (a flag — never a cash reversal). */
|
||
export function reviewDrawerMovement(args: {
|
||
refId: string;
|
||
decision: "authorize" | "deny";
|
||
note?: string;
|
||
}): Promise<{ refId: string; decision: "authorize" | "deny"; reviewedBy: string; at: string }> {
|
||
return apiFetch("/api/drawer/review", { method: "POST", body: JSON.stringify(args) });
|
||
}
|
||
|
||
/** A completed shift (reconstructed from its signed Z-report). */
|
||
export interface ShiftSummary extends ShiftSourceSplit {
|
||
id: string;
|
||
index: number;
|
||
operator: string;
|
||
startedAt: string;
|
||
endedAt: string;
|
||
cashTotalMinor: number;
|
||
cardTotalMinor: number;
|
||
currency: string | null;
|
||
paymentCount: number;
|
||
openingFloatMinor: number;
|
||
cashAddedMinor: number;
|
||
cashRemovedMinor: number;
|
||
expectedDrawerMinor: number;
|
||
}
|
||
|
||
/** Completed shift history. The server scopes by permission: operators get their
|
||
* own shifts only (filter args ignored); admins (shift:cash) get all, optionally
|
||
* filtered by operator + a from/to window over the shift start. `scope` echoes
|
||
* which the server applied, so the UI can show/hide the filter. */
|
||
export function fetchShifts(params: { operator?: string; from?: string; to?: string } = {}): Promise<{
|
||
shifts: ShiftSummary[];
|
||
scope: "all" | "self";
|
||
/** Admin scope only: every operator that has a shift — feeds the filter dropdown. */
|
||
operators?: string[];
|
||
}> {
|
||
const qs = new URLSearchParams();
|
||
if (params.operator) qs.set("operator", params.operator);
|
||
if (params.from) qs.set("from", params.from);
|
||
if (params.to) qs.set("to", params.to);
|
||
const q = qs.toString();
|
||
return apiFetch(`/api/shifts${q ? `?${q}` : ""}`);
|
||
}
|
||
|
||
// --- Site config / occupancy ----------------------------------------------
|
||
|
||
export interface Occupancy {
|
||
count: number;
|
||
capacity: number | null;
|
||
free: number | null;
|
||
full: boolean;
|
||
}
|
||
|
||
/** Capacity + optional park metadata (all nullable). Mirrors site_config. */
|
||
export interface SiteConfig {
|
||
capacity: number | null;
|
||
/** Default for the booth "print exit ticket" checkbox (booth-geography knob). */
|
||
exitVoucherDefault: boolean;
|
||
/** Site default monthly subscription price (minor units); pre-fills the form. */
|
||
subscriptionMonthlyPriceMinor: number | null;
|
||
/** Reserve a spot for each active subscriber's car(s) in the occupancy/full gate. */
|
||
reserveSubscriberSpots: boolean;
|
||
/** Master switch for the ANPR subscriber-entry bridge (auto-open on a plate read). */
|
||
anprEntryEnabled: boolean;
|
||
/** Entry presence-gate bypass: drop radar/loop as an entry-button requirement (faulty
|
||
* device). Set only via the dedicated signed endpoint, not saveSiteConfig. */
|
||
bypassPresenceRadar: boolean;
|
||
/** Entry presence-gate bypass: drop camera detection as an entry-button requirement. */
|
||
bypassPresenceCamera: boolean;
|
||
parkName: string | null;
|
||
operatorName: string | null;
|
||
/** NIUS — Albanian tax/identification number. */
|
||
nius: string | null;
|
||
address: string | null;
|
||
phone: string | null;
|
||
email: string | null;
|
||
/** IANA timezone for tariff wall-clock windows (e.g. "Europe/Tirane"). Copied into
|
||
* each published tariff version so its windows are frozen. */
|
||
timezone: string | null;
|
||
/** Default vehicle/customer category frozen onto each transient entry (V2 pricing). */
|
||
defaultVehicleCategory: string | null;
|
||
}
|
||
|
||
export function fetchOccupancy(): Promise<Occupancy> {
|
||
return apiFetch("/api/occupancy");
|
||
}
|
||
|
||
// --- Device status (the booth footer) -------------------------------------
|
||
|
||
/** Live status of one configured device — mirrors the server's DeviceStatusEvent.
|
||
* Every enabled device is polled (printers via rich readStatus, the rest via
|
||
* healthCheck) and flattened to one traffic-light. Pushed over the WS; the REST
|
||
* snapshot below is the initial load / fallback. */
|
||
export interface DeviceStatus {
|
||
deviceId: string;
|
||
driverId: string;
|
||
category: "access" | "reader" | "camera" | "printer" | "vision";
|
||
/** Role/direction token for the footer label (NOT the vendor) — the client
|
||
* localises it next to the category, e.g. "Lexuesi hyrje", "Printer kabina". */
|
||
roleKind: "entry" | "exit" | "both" | "mixed" | "lane" | "booth" | null;
|
||
state: "ready" | "degraded" | "offline";
|
||
detail?: string;
|
||
checkedAt: string;
|
||
}
|
||
|
||
export function fetchDeviceStatus(): Promise<{ devices: DeviceStatus[] }> {
|
||
return apiFetch("/api/devices/status");
|
||
}
|
||
|
||
// --- 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, LogLevel, LogSource } from "@parking/shared";
|
||
export type { AppLogRecord };
|
||
|
||
/** Recent ledger events, newest first (default 100, max 1000). Used for the
|
||
* booth feed's initial load; live updates then arrive over the WS. `since` (ISO)
|
||
* scopes to events at/after that instant — the booth passes the current shift's
|
||
* start so the feed shows ONLY this shift's activity. */
|
||
export function fetchEvents(
|
||
limit = 100,
|
||
since?: string,
|
||
until?: string,
|
||
): Promise<{ events: import("@parking/shared").LedgerEvent[] }> {
|
||
const qs = new URLSearchParams({ limit: String(limit) });
|
||
if (since) qs.set("since", since);
|
||
if (until) qs.set("until", until);
|
||
return apiFetch(`/api/events?${qs.toString()}`);
|
||
}
|
||
|
||
// --- 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;
|
||
/** Amount actually PAID (sum of payment events), independent of what's owed now. */
|
||
paidMinor: number | null;
|
||
paidCurrency: string | null;
|
||
withinGrace: boolean;
|
||
graceExpiresAt: string | null;
|
||
/** OVERSTAY: paid transient, walk-back grace expired, no exit — a new period began;
|
||
* owes a fresh top-up (amountMinor); cannot exit for free. */
|
||
overstay: boolean;
|
||
/** A subscription occurrence (prepaid — no pay flow; barrier-open assist only). */
|
||
subscription: boolean;
|
||
subscriptionId: string | null;
|
||
subscriptionHolder: string | null;
|
||
/** Advisory licence plate recognized for this session (ANPR). Null when none. */
|
||
plate: 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;
|
||
/** OVERSTAY: paid transient whose walk-back grace lapsed with no signed exit — a new
|
||
* period began (re-parked) or the car is faulty/abandoned. Owes a fresh top-up;
|
||
* flagged so the operator reconciles, never a free exit. */
|
||
overstay: boolean;
|
||
/** A subscription occurrence (prepaid — no pay flow; barrier-open assist only). */
|
||
subscription: boolean;
|
||
subscriptionId: string | null;
|
||
subscriptionHolder: string | null;
|
||
/** Advisory licence plate recognized for this session (ANPR). Null when none. */
|
||
plate: 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 } : {}) }),
|
||
});
|
||
}
|
||
|
||
/** Cancel (void) a wrongly-printed transient ticket. Appends a SIGNED `void` event with
|
||
* the operator + a required reason; the entry itself is never edited (append-only).
|
||
* Refuses a subscription / already-exited / already-voided / paid ticket (409). */
|
||
export function voidTicket(identity: string, reason: string): Promise<{ ok: boolean; identity?: string }> {
|
||
return apiFetch("/api/tickets/void", { method: "POST", body: JSON.stringify({ identity, reason }) });
|
||
}
|
||
|
||
/** Booth-driven exit result. `opened:false` = exit recorded but barrier didn't
|
||
* open (payment stands; operator opens manually). `swapSuspected` = the exiting car's
|
||
* plate is already inside under a DIFFERENT ticket (possible ticket-swap); the operator
|
||
* must review and re-call with override:true to release. See plate-reconciliation.md. */
|
||
export type BoothExitResult =
|
||
| { ok: true; opened: boolean; reason?: string }
|
||
| { ok: false; swapSuspected: true; reason: string; plate: string; otherIdentity: string; otherEnteredAt: string | null };
|
||
|
||
/** Validate + open the barrier for a session from the booth (when near the exit).
|
||
* Pass override:true to consciously release a suspected plate-swap exit. */
|
||
export async function boothExit(identity: string, override = false): Promise<BoothExitResult> {
|
||
try {
|
||
return await apiFetch<{ ok: true; opened: boolean; reason?: string }>("/api/exit", {
|
||
method: "POST",
|
||
body: JSON.stringify({ identity, ...(override ? { override: true } : {}) }),
|
||
});
|
||
} catch (e) {
|
||
// A suspected plate-swap comes back 409 with status:"swap_suspected" + detail — surface
|
||
// it as a structured result (not a thrown error) so the modal can warn + offer override.
|
||
if (e instanceof ApiError && e.body?.status === "swap_suspected") {
|
||
const b = e.body;
|
||
return {
|
||
ok: false,
|
||
swapSuspected: true,
|
||
reason: String(b.error ?? ""),
|
||
plate: String(b.plate ?? ""),
|
||
otherIdentity: String(b.otherIdentity ?? ""),
|
||
otherEnteredAt: (b.otherEnteredAt as string | null) ?? null,
|
||
};
|
||
}
|
||
throw e;
|
||
}
|
||
}
|
||
|
||
/** Operator issues an entry ticket when the physical button is broken. A FLAGGED mint,
|
||
* server-gated on real vehicle presence (radar + camera). Returns the new ticket id. */
|
||
export function issueEntryTicket(): Promise<{ ok: true; ticketId: string; opened: boolean; overCapacity: boolean }> {
|
||
return apiFetch("/api/entry/issue", { method: "POST", body: JSON.stringify({}) });
|
||
}
|
||
|
||
/** Print an exit voucher (paid ticket id reprinted as a barcode) + payment detail,
|
||
* 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 }) });
|
||
}
|
||
|
||
/** Print a standalone PAYMENT RECEIPT (entry/paid/duration/amount, no barcode).
|
||
* Auto-printed after a payment when no voucher is issued; also the "reprint"
|
||
* action. Requires the session to be paid. */
|
||
export function printReceipt(identity: string): Promise<{ ok: boolean; printedBy: string }> {
|
||
return apiFetch("/api/receipt", { 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;
|
||
}
|
||
|
||
/** A capture that was ATTEMPTED but failed (camera offline, config) — surfaced so a
|
||
* missing image isn't a silent gap. From snapshot telemetry, not the image store. */
|
||
export interface SnapshotFailure {
|
||
direction: "entry" | "exit" | null;
|
||
deviceId: string;
|
||
error: string;
|
||
occurredAt: string;
|
||
}
|
||
|
||
/** A licence plate recognized for this session by the ANPR-on-snapshot path (advisory
|
||
* record — see opencv-anpr-service.md). `snapshotId` links to the image it was read from. */
|
||
export interface PlateRead {
|
||
plate: string;
|
||
confidence: number | null;
|
||
region: string | null;
|
||
direction: "entry" | "exit" | null;
|
||
snapshotId: string | null;
|
||
at: string;
|
||
}
|
||
|
||
/** Snapshot metadata for a session identity (newest first) PLUS failed capture
|
||
* attempts PLUS any recognized plates. Image bytes are at `/api/snapshots/:id`. */
|
||
export function fetchSnapshots(
|
||
identity: string,
|
||
): Promise<{ snapshots: SnapshotMeta[]; failures?: SnapshotFailure[]; plates?: PlateRead[] }> {
|
||
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> {
|
||
return apiFetch("/api/site-config");
|
||
}
|
||
/** PUT a partial config — only the fields supplied are changed. */
|
||
export function saveSiteConfig(patch: Partial<SiteConfig>): Promise<SiteConfig> {
|
||
return apiFetch("/api/site-config", { method: "PUT", body: JSON.stringify(patch) });
|
||
}
|
||
|
||
/** Toggle the entry presence-gate bypass (radar/camera). Dedicated signed endpoint —
|
||
* each changed signal appends a config_change to the ledger. See entry-presence-bypass. */
|
||
export function updatePresenceBypass(patch: { radar?: boolean; camera?: boolean }): Promise<SiteConfig> {
|
||
return apiFetch("/api/site-config/presence-bypass", { method: "PUT", body: JSON.stringify(patch) });
|
||
}
|
||
export function setCapacity(capacity: number | null): Promise<SiteConfig> {
|
||
return saveSiteConfig({ capacity });
|
||
}
|