Compare commits
62 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 61b9955160 | |||
| 33c4ea1e91 | |||
| 114a32e6f2 | |||
| 018328a877 | |||
| 266e9b0027 | |||
| d92b8d1e6a | |||
| 61de1fe772 | |||
| cfac14e09e | |||
| 1b86750b0d | |||
| 9c6741a485 | |||
| 8f32d90d28 | |||
| 07295f8063 | |||
| 652d6599d3 | |||
| e16bccc2f5 | |||
| 381046190b | |||
| 84f00db48b | |||
| d5e41500a8 | |||
| 0c218179c4 | |||
| 9e442586af | |||
| 11567a417f | |||
| f6e35bbebf | |||
| 96acd6b662 | |||
| cce99aadfd | |||
| f706726eeb | |||
| 6734e9815e | |||
| 38481f105f | |||
| 4418594af0 | |||
| 25a72ff20a | |||
| c2a861208f | |||
| a888125eca | |||
| 96fd97efa9 | |||
| 2a13b95da6 | |||
| 513566c89e | |||
| f77ed11782 | |||
| e4a17efd97 | |||
| 6d32e0fc0f | |||
| 3a60367232 | |||
| 045892bc94 | |||
| 916c147b4d | |||
| 1ea1aa4189 | |||
| 9c20faf8de | |||
| a68dc23393 | |||
| c87dcb2253 | |||
| 7eadf71a0b | |||
| 9918f278b2 | |||
| 83298bc0c5 | |||
| 898cf1953a | |||
| dd0f6e483a | |||
| 40de8a7467 | |||
| f0fd15bb88 | |||
| 40ffa90dac | |||
| b3cb67188e | |||
| b1c4109045 | |||
| 50dd554b43 | |||
| 6d7682ab4a | |||
| 793b8d83ee | |||
| 7366ad19cb | |||
| 5a5fedf4f4 | |||
| 830993bcb8 | |||
| fd15988a73 | |||
| 420542ce10 | |||
| 2915d141aa |
@@ -0,0 +1,37 @@
|
|||||||
|
# Booth deploy env — copy to `.env` and fill in, then run ./scripts/booth.sh up
|
||||||
|
# (prod). Consumed by docker-compose.yml + the prod override via --env-file.
|
||||||
|
# See wiki/decisions/container-deployment.md. Do NOT commit the filled-in .env.
|
||||||
|
|
||||||
|
# --- image source (prod pulls from the house Gitea registry) ------------------
|
||||||
|
# The registry namespace; combined with the image name + TAG below.
|
||||||
|
REGISTRY=git.infra.msai.al/mca/parking_solution
|
||||||
|
# Image tag to deploy. CI publishes TWO tags per build: a MOVING branch tag
|
||||||
|
# (`dev`, and `main` once that branch is built) republished on every push, and an
|
||||||
|
# IMMUTABLE per-commit `dev-<sha>` (e.g. dev-830993b). Use the moving tag for a
|
||||||
|
# self-updating booth (`booth.sh update` pulls the latest); pin the `<branch>-<sha>`
|
||||||
|
# form for a reproducible, deterministic deploy. NOTE: `main` images only exist once
|
||||||
|
# something is built on main — until then deploy from `dev`.
|
||||||
|
TAG=dev
|
||||||
|
|
||||||
|
# --- secrets (NO safe defaults — the server refuses to boot without a real one) -
|
||||||
|
# JWT signing secret. Generate yourself, never share it: openssl rand -hex 32
|
||||||
|
# Must be 32+ chars and must NOT contain change-me / insecure / dev-only.
|
||||||
|
JWT_SECRET=
|
||||||
|
|
||||||
|
# Ledger-signing key for the append-only signed event chain. Set a DISTINCT value
|
||||||
|
# in prod (don't reuse JWT_SECRET). openssl rand -hex 32
|
||||||
|
EVENT_SIGNING_KEY=
|
||||||
|
|
||||||
|
# --- booth LAN specifics ------------------------------------------------------
|
||||||
|
# Auth cookie is HTTPS-only by default; the booth is plain HTTP behind Caddy on
|
||||||
|
# :80, so this MUST stay 0 or operators cannot log in. Set to 1 only behind TLS.
|
||||||
|
COOKIE_SECURE=0
|
||||||
|
|
||||||
|
# Remote origins the live WS feed must accept (same-origin always passes). Add any
|
||||||
|
# address admins hit the UI from beyond the booth itself, comma-separated, e.g.
|
||||||
|
# http://parksystems.msai.al (leave blank if only the local booth URL is used).
|
||||||
|
WS_ALLOWED_ORIGINS=
|
||||||
|
|
||||||
|
# Vision/ANPR. Prod override already forces the fast_alpr engine; leave VISION_ENABLED=1
|
||||||
|
# unless you are running without the camera. (Set 0 to disable the vision call entirely.)
|
||||||
|
VISION_ENABLED=1
|
||||||
@@ -1,13 +1,14 @@
|
|||||||
name: Build & push images
|
name: Build & push images
|
||||||
|
|
||||||
# Build the SERVER (API + SPA) and VISION (ANPR) container images and push them to the
|
# Build the SERVER (API + SPA) and VISION (ANPR) container images and push them to the
|
||||||
# house Gitea registry, tagged by BRANCH + short SHA (branch-aware: dev→:dev, main→:main).
|
# house Gitea registry, tagged by BRANCH + short SHA (branch-aware: dev→:dev, stage→:stage,
|
||||||
# Separate from ci.yml (checks-only) and release.yml (tag-only desktop bundle). Mirrors the
|
# main→:main). Separate from ci.yml (checks-only) and release.yml (tag-only desktop bundle).
|
||||||
# house pattern (cf. trm/processor build.yml). See wiki/decisions/container-deployment.md.
|
# Mirrors the house pattern (cf. trm/processor build.yml). See
|
||||||
|
# wiki/decisions/container-deployment.md and fleet-deployment-komodo.md (dev→stage→main tiers).
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [dev, main]
|
branches: [dev, stage, main]
|
||||||
paths:
|
paths:
|
||||||
- 'apps/server/**'
|
- 'apps/server/**'
|
||||||
- 'apps/web/**'
|
- 'apps/web/**'
|
||||||
@@ -20,6 +21,10 @@ on:
|
|||||||
- 'docker-compose*.yml'
|
- 'docker-compose*.yml'
|
||||||
- '.dockerignore'
|
- '.dockerignore'
|
||||||
- '.gitea/workflows/build-images.yml'
|
- '.gitea/workflows/build-images.yml'
|
||||||
|
# Deploy/IaC changes (compose above, plus the Komodo Stack defs) also rebuild — so a
|
||||||
|
# promotion or a Stack tweak gets the same build+checks sanity pass before it reaches a
|
||||||
|
# booth, and a komodo-only push to `stage` still produces a :stage image.
|
||||||
|
- 'komodo/**'
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
env:
|
env:
|
||||||
|
|||||||
@@ -9,5 +9,7 @@
|
|||||||
# CA / internal cert, use `tls /path/cert.pem /path/key.pem`.
|
# CA / internal cert, use `tls /path/cert.pem /path/key.pem`.
|
||||||
:80 {
|
:80 {
|
||||||
encode gzip
|
encode gzip
|
||||||
reverse_proxy server:3000
|
# Host network (prod): the server runs on the host's net namespace (to reach the booth LAN /
|
||||||
|
# device VLAN), so reach it over loopback, not the compose service name `server`.
|
||||||
|
reverse_proxy 127.0.0.1:3000
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,20 @@ JWT_SECRET=
|
|||||||
# them (keyId), so verifyChain still validates a chain that spans a key change.
|
# them (keyId), so verifyChain still validates a chain that spans a key change.
|
||||||
EVENT_SIGNING_KEY=
|
EVENT_SIGNING_KEY=
|
||||||
|
|
||||||
|
# On-site encrypted DB backup (durability for the signed ledger). A daily timer + an admin
|
||||||
|
# "back up now" button write a consistent, AES-256-GCM-encrypted copy to the target. The
|
||||||
|
# TARGET DIRECTORY is chosen by the admin in the UI (Setup → Backup) and stored in the DB —
|
||||||
|
# NOT here. Only the encryption KEY is an env secret. RESTORE is an out-of-band runbook action,
|
||||||
|
# not a console call. See wiki/concepts/backup-recovery.md.
|
||||||
|
#
|
||||||
|
# Dedicated backup-encryption key (>=16 chars), SEPARATE from EVENT_SIGNING_KEY so it can
|
||||||
|
# rotate without fracturing the signed chain. Generate with: openssl rand -hex 32
|
||||||
|
# Escrow it offsite (alongside EVENT_SIGNING_KEY) — recovery needs both, and neither is ever
|
||||||
|
# stored inside the backup it unlocks. Backups stay a no-op until BOTH this key and an in-UI
|
||||||
|
# target directory are set. The target directory AND retention (keep-last / keep-daily) are
|
||||||
|
# admin-chosen in the UI (Setup → Backup), NOT env — only this key is an env secret.
|
||||||
|
# BACKUP_KEY=
|
||||||
|
|
||||||
# Optional ----------------------------------------------------------------
|
# Optional ----------------------------------------------------------------
|
||||||
# PORT=3000
|
# PORT=3000
|
||||||
# HOST=0.0.0.0 # interface to bind. 127.0.0.1 = loopback only.
|
# HOST=0.0.0.0 # interface to bind. 127.0.0.1 = loopback only.
|
||||||
|
|||||||
@@ -23,7 +23,8 @@
|
|||||||
"@parking/shared": "workspace:*",
|
"@parking/shared": "workspace:*",
|
||||||
"bcrypt": "6.0.0",
|
"bcrypt": "6.0.0",
|
||||||
"fastify": "5.8.5",
|
"fastify": "5.8.5",
|
||||||
"fastify-plugin": "6.0.0"
|
"fastify-plugin": "6.0.0",
|
||||||
|
"sharp": "^0.35.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bcrypt": "6.0.0",
|
"@types/bcrypt": "6.0.0",
|
||||||
|
|||||||
@@ -15,8 +15,13 @@ import type { SubscriptionFlow, SubscriptionMatch } from "./subscription-flow.js
|
|||||||
// Mock buildCamera so the bridge gets a fake camera whose captureSnapshot is a stub
|
// Mock buildCamera so the bridge gets a fake camera whose captureSnapshot is a stub
|
||||||
// (no registry, no network). The factory returns a fresh shot each call.
|
// (no registry, no network). The factory returns a fresh shot each call.
|
||||||
const captureSnapshot = vi.fn(async () => ({ bytes: Buffer.from("jpg"), contentType: "image/jpeg" }));
|
const captureSnapshot = vi.fn(async () => ({ bytes: Buffer.from("jpg"), contentType: "image/jpeg" }));
|
||||||
|
// The bridge now goes through captureSnapshotShared (the dedup wrapper, exercised in
|
||||||
|
// snapshot.test.ts); here it just delegates to the fake camera's captureSnapshot so this
|
||||||
|
// suite stays focused on the bridge's own match/debounce/emit logic.
|
||||||
vi.mock("./snapshot.js", () => ({
|
vi.mock("./snapshot.js", () => ({
|
||||||
buildCamera: () => ({ captureSnapshot }),
|
buildCamera: () => ({ captureSnapshot }),
|
||||||
|
captureSnapshotShared: (_id: string, camera: { captureSnapshot: typeof captureSnapshot }, ctx: unknown) =>
|
||||||
|
camera.captureSnapshot(ctx as never),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Import AFTER the mock is registered.
|
// Import AFTER the mock is registered.
|
||||||
@@ -28,13 +33,22 @@ beforeEach(() => {
|
|||||||
captureSnapshot.mockClear();
|
captureSnapshot.mockClear();
|
||||||
delete process.env.VISION_ENTRY_MIN_CONFIDENCE;
|
delete process.env.VISION_ENTRY_MIN_CONFIDENCE;
|
||||||
delete process.env.ANPR_DEBOUNCE_MS;
|
delete process.env.ANPR_DEBOUNCE_MS;
|
||||||
|
// Poll-until-confident loop: keep the window + interval tiny so a below-floor / no-plate
|
||||||
|
// case gives up in ~one tick instead of the 8s production window (tests stay fast). Each
|
||||||
|
// bridge reads these in its constructor, so set them before `new AnprBridge`.
|
||||||
|
process.env.ANPR_POLL_MS = "1";
|
||||||
|
process.env.ANPR_POLL_WINDOW_MS = "5";
|
||||||
});
|
});
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
|
delete process.env.ANPR_POLL_MS;
|
||||||
|
delete process.env.ANPR_POLL_WINDOW_MS;
|
||||||
|
delete process.env.ANPR_POLL_MAX_MS;
|
||||||
});
|
});
|
||||||
|
|
||||||
/** A camera bound to an entry relay; `anpr` toggles the opt-in flag. */
|
/** A camera bound to an entry relay; `anpr` toggles recognition, `anprAutoTrigger` the
|
||||||
function seedCamera(opts: { anpr?: boolean } = {}): string {
|
* per-camera auto-open gate (absent ⇒ defaults on). */
|
||||||
|
function seedCamera(opts: { anpr?: boolean; anprAutoTrigger?: boolean } = {}): string {
|
||||||
const controllerId = randomUUID();
|
const controllerId = randomUUID();
|
||||||
db.insert(devices).values({
|
db.insert(devices).values({
|
||||||
id: controllerId,
|
id: controllerId,
|
||||||
@@ -48,7 +62,13 @@ function seedCamera(opts: { anpr?: boolean } = {}): string {
|
|||||||
id: camId,
|
id: camId,
|
||||||
category: "camera",
|
category: "camera",
|
||||||
driverId: "hikvision",
|
driverId: "hikvision",
|
||||||
config: { host: "10.0.0.9", controllerId, relay: 1, ...(opts.anpr ? { anpr: true } : {}) },
|
config: {
|
||||||
|
host: "10.0.0.9",
|
||||||
|
controllerId,
|
||||||
|
relay: 1,
|
||||||
|
...(opts.anpr ? { anpr: true } : {}),
|
||||||
|
...(opts.anprAutoTrigger === false ? { anprAutoTrigger: false } : {}),
|
||||||
|
},
|
||||||
enabled: true,
|
enabled: true,
|
||||||
}).run();
|
}).run();
|
||||||
return camId;
|
return camId;
|
||||||
@@ -74,8 +94,17 @@ function fakeVision(opts: { enabled?: boolean; plate?: string; confidence?: numb
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** A fake SubscriptionFlow: only `match()` is called by the bridge. */
|
/** A fake SubscriptionFlow: only `match()` is called by the bridge. */
|
||||||
function fakeSubFlow(match: SubscriptionMatch | null): SubscriptionFlow {
|
function fakeSubFlow(
|
||||||
return { match: vi.fn(() => match) } as unknown as SubscriptionFlow;
|
match: SubscriptionMatch | null,
|
||||||
|
// openOccurrenceCount: a constant, or a sequence consumed per call (to simulate a
|
||||||
|
// credential closing an occurrence mid-poll → count changes).
|
||||||
|
openCounts: number | number[] = 1,
|
||||||
|
): SubscriptionFlow {
|
||||||
|
const seq = Array.isArray(openCounts) ? [...openCounts] : null;
|
||||||
|
return {
|
||||||
|
match: vi.fn(() => match),
|
||||||
|
openOccurrenceCount: vi.fn(() => (seq ? (seq.length > 1 ? seq.shift()! : seq[0]) : (openCounts as number))),
|
||||||
|
} as unknown as SubscriptionFlow;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SUB_MATCH: SubscriptionMatch = { subscriptionId: "sub-1", carKey: "AA111BB", via: "plate" };
|
const SUB_MATCH: SubscriptionMatch = { subscriptionId: "sub-1", carKey: "AA111BB", via: "plate" };
|
||||||
@@ -104,6 +133,18 @@ describe("AnprBridge", () => {
|
|||||||
expect(captureSnapshot).not.toHaveBeenCalled();
|
expect(captureSnapshot).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("does NOT auto-trigger when anprAutoTrigger=false (recognition on, auto-open off)", async () => {
|
||||||
|
// Shared entry/exit lane: the exit cam keeps anpr (recognition) but auto-trigger off, so a
|
||||||
|
// car driving IN isn't phantom-EXITed by its back plate. The bridge bails before snapshot.
|
||||||
|
const cam = seedCamera({ anpr: true, anprAutoTrigger: false });
|
||||||
|
const vision = fakeVision({ plate: "AA111BB", confidence: 0.99 });
|
||||||
|
const bridge = new AnprBridge(db, vision, fakeSubFlow(SUB_MATCH), silentLogger());
|
||||||
|
|
||||||
|
const reads = await captureReads(() => bridge.onVehicleDetected(cam));
|
||||||
|
expect(reads).toEqual([]);
|
||||||
|
expect(captureSnapshot).not.toHaveBeenCalled(); // gated before the poll loop
|
||||||
|
});
|
||||||
|
|
||||||
it("emits a plate read (upper-cased) for a high-confidence SUBSCRIBER plate", async () => {
|
it("emits a plate read (upper-cased) for a high-confidence SUBSCRIBER plate", async () => {
|
||||||
const cam = seedCamera({ anpr: true });
|
const cam = seedCamera({ anpr: true });
|
||||||
const vision = fakeVision({ plate: " aa111bb ", confidence: 0.97 });
|
const vision = fakeVision({ plate: " aa111bb ", confidence: 0.97 });
|
||||||
@@ -123,6 +164,85 @@ describe("AnprBridge", () => {
|
|||||||
expect(reads).toEqual([]);
|
expect(reads).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("POLLS until confident: low-confidence approach frames, then a clean stop-at-barrier frame", async () => {
|
||||||
|
// The car APPROACHES (garbage reads) then STOPS at the barrier (clean read) — the bridge
|
||||||
|
// must re-pull until one frame clears the floor, not give up on the first bad frame.
|
||||||
|
const cam = seedCamera({ anpr: true });
|
||||||
|
// analyze escalates: 0.20, 0.20, then 0.97 on the 3rd pull → that one emits.
|
||||||
|
const confs = [0.2, 0.2, 0.97];
|
||||||
|
let i = 0;
|
||||||
|
const vision = {
|
||||||
|
enabled: true,
|
||||||
|
analyze: vi.fn(async () => ({
|
||||||
|
plate: { text: "AA111BB", confidence: confs[Math.min(i++, confs.length - 1)] },
|
||||||
|
plates: [],
|
||||||
|
lowConfidence: false,
|
||||||
|
modelVersion: "test",
|
||||||
|
tookMs: 1,
|
||||||
|
})),
|
||||||
|
} as unknown as VisionClient;
|
||||||
|
// Generous window so all 3 escalation attempts run deterministically under suite load
|
||||||
|
// (the global beforeEach sets a tiny 5ms window for the give-up cases).
|
||||||
|
process.env.ANPR_POLL_MS = "1";
|
||||||
|
process.env.ANPR_POLL_WINDOW_MS = "2000";
|
||||||
|
const bridge = new AnprBridge(db, vision, fakeSubFlow(SUB_MATCH), silentLogger());
|
||||||
|
|
||||||
|
const reads = await captureReads(() => bridge.onVehicleDetected(cam));
|
||||||
|
expect(reads).toHaveLength(1);
|
||||||
|
expect(reads[0]).toMatchObject({ value: "AA111BB", kind: "plate" });
|
||||||
|
expect(captureSnapshot.mock.calls.length).toBeGreaterThanOrEqual(3); // re-pulled fresh frames
|
||||||
|
});
|
||||||
|
|
||||||
|
it("SLIDES the window: a push mid-poll keeps the loop alive past the initial deadline", async () => {
|
||||||
|
// A loop started by an early/far car would expire — but a NEW push (another car arriving)
|
||||||
|
// extends the deadline, so the loop keeps polling and reads the car that settles at the
|
||||||
|
// barrier. Here: a SHORT base window, vision stays low until attempt 5; a second push at
|
||||||
|
// the start bumps the deadline so attempt 5's confident read still lands.
|
||||||
|
const cam = seedCamera({ anpr: true });
|
||||||
|
const confs = [0.2, 0.2, 0.2, 0.2, 0.97];
|
||||||
|
let i = 0;
|
||||||
|
const vision = {
|
||||||
|
enabled: true,
|
||||||
|
analyze: vi.fn(async () => ({
|
||||||
|
plate: { text: "AA111BB", confidence: confs[Math.min(i++, confs.length - 1)] },
|
||||||
|
plates: [],
|
||||||
|
lowConfidence: false,
|
||||||
|
modelVersion: "test",
|
||||||
|
tookMs: 1,
|
||||||
|
})),
|
||||||
|
} as unknown as VisionClient;
|
||||||
|
process.env.ANPR_POLL_MS = "5";
|
||||||
|
process.env.ANPR_POLL_WINDOW_MS = "12"; // tiny — would expire ~attempt 2 WITHOUT a slide
|
||||||
|
process.env.ANPR_POLL_MAX_MS = "5000"; // ceiling far above, so the slide is what matters
|
||||||
|
const bridge = new AnprBridge(db, vision, fakeSubFlow(SUB_MATCH), silentLogger());
|
||||||
|
|
||||||
|
const reads = await captureReads(async () => {
|
||||||
|
const loop = bridge.onVehicleDetected(cam); // starts the loop
|
||||||
|
// Joining pushes keep sliding the deadline forward so the slow-to-confident read lands.
|
||||||
|
for (let k = 0; k < 5; k++) {
|
||||||
|
await new Promise((r) => setTimeout(r, 5));
|
||||||
|
void bridge.onVehicleDetected(cam); // each bumps the deadline (loop already running)
|
||||||
|
}
|
||||||
|
await loop;
|
||||||
|
});
|
||||||
|
expect(reads).toHaveLength(1);
|
||||||
|
expect(reads[0]).toMatchObject({ value: "AA111BB" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ABORTS if the subscriber transacts by another credential mid-poll (no double-act)", async () => {
|
||||||
|
// The car's plate is read (identity known) but stays below the floor; meanwhile the
|
||||||
|
// subscriber scans their card → openOccurrenceCount drops. The bridge must abort and NOT
|
||||||
|
// emit (which would exit the NEXT open occurrence — a phantom double-exit, esp. fleet).
|
||||||
|
const cam = seedCamera({ anpr: true });
|
||||||
|
const vision = fakeVision({ plate: "AA111BB", confidence: 0.5 }); // never clears the floor
|
||||||
|
// openOccurrenceCount: 1 at baseline, then 0 (the card exit closed it) on the next check.
|
||||||
|
const sub = fakeSubFlow(SUB_MATCH, [1, 0]);
|
||||||
|
const bridge = new AnprBridge(db, vision, sub, silentLogger());
|
||||||
|
|
||||||
|
const reads = await captureReads(() => bridge.onVehicleDetected(cam));
|
||||||
|
expect(reads).toEqual([]); // aborted — the credential already handled it
|
||||||
|
});
|
||||||
|
|
||||||
it("does NOT emit for a plate matching no subscription — records an advisory anpr-skip", async () => {
|
it("does NOT emit for a plate matching no subscription — records an advisory anpr-skip", async () => {
|
||||||
const cam = seedCamera({ anpr: true });
|
const cam = seedCamera({ anpr: true });
|
||||||
const vision = fakeVision({ plate: "ZZ999ZZ", confidence: 0.97 });
|
const vision = fakeVision({ plate: "ZZ999ZZ", confidence: 0.97 });
|
||||||
|
|||||||
+151
-12
@@ -30,6 +30,10 @@ import type { VisionClient } from "./vision-client.js";
|
|||||||
/** Camera config flag opting it into the ANPR bridge (same flag advisory ANPR uses). */
|
/** Camera config flag opting it into the ANPR bridge (same flag advisory ANPR uses). */
|
||||||
interface CameraConfig {
|
interface CameraConfig {
|
||||||
readonly anpr?: boolean;
|
readonly anpr?: boolean;
|
||||||
|
/** Whether this camera may AUTO-OPEN the barrier (entry/exit). Absent ⇒ true (when anpr is
|
||||||
|
* on). Set false to keep recognition but suppress auto-trigger — e.g. the exit camera on a
|
||||||
|
* shared entry/exit lane. */
|
||||||
|
readonly anprAutoTrigger?: boolean;
|
||||||
readonly [k: string]: unknown;
|
readonly [k: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,6 +53,40 @@ function debounceMs(): number {
|
|||||||
return Number.isFinite(raw) && raw > 0 ? raw : 12_000;
|
return Number.isFinite(raw) && raw > 0 ? raw : 12_000;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A single alarm fires the INSTANT motion starts — the car is still approaching, so the
|
||||||
|
* first frame often has a small/blurry/absent plate (a low-confidence misread). But the car
|
||||||
|
* then STOPS at the barrier (waiting for it to open) — the same stationary, well-framed
|
||||||
|
* moment the manual test reads at ~100%. So instead of one shot, we POLL fresh frames and
|
||||||
|
* re-run ANPR until one clears the confidence floor, or the window elapses. Poll interval: */
|
||||||
|
function pollMs(): number {
|
||||||
|
const raw = Number(process.env.ANPR_POLL_MS ?? 1000);
|
||||||
|
return Number.isFinite(raw) && raw > 0 ? raw : 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How long to keep polling AFTER THE LAST vehicle push before giving up. SLIDING: each new
|
||||||
|
* push for the camera extends the deadline by this much from now — so a loop started by a
|
||||||
|
* far/early car keeps pulling fresh frames as the REAL car arrives and settles at the
|
||||||
|
* barrier (the loop tracks "whoever is here now", not the car that started it). */
|
||||||
|
function pollWindowMs(): number {
|
||||||
|
const raw = Number(process.env.ANPR_POLL_WINDOW_MS ?? 8000);
|
||||||
|
return Number.isFinite(raw) && raw > 0 ? raw : 8000;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hard ceiling on a single loop from its START, so a continuously-busy lane (pushes never
|
||||||
|
* stop) can't slide the window forever. The loop ends at min(lastPush + window, start + max). */
|
||||||
|
function pollMaxMs(): number {
|
||||||
|
const raw = Number(process.env.ANPR_POLL_MAX_MS ?? 30_000);
|
||||||
|
return Number.isFinite(raw) && raw > 0 ? raw : 30_000;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
|
||||||
|
|
||||||
|
/** A plate DeviceReadEvent skeleton (value filled by the caller) — for matching the
|
||||||
|
* subscriber by plate during the poll loop without re-building the whole event. */
|
||||||
|
function baseRead(row: { driverId: string }, deviceId: string): Omit<DeviceReadEvent, "value"> {
|
||||||
|
return { driverId: row.driverId, deviceId, kind: "plate", at: new Date().toISOString() };
|
||||||
|
}
|
||||||
|
|
||||||
export class AnprBridge {
|
export class AnprBridge {
|
||||||
readonly #db: Db;
|
readonly #db: Db;
|
||||||
readonly #vision: VisionClient | null;
|
readonly #vision: VisionClient | null;
|
||||||
@@ -56,9 +94,19 @@ export class AnprBridge {
|
|||||||
readonly #logger: FastifyBaseLogger;
|
readonly #logger: FastifyBaseLogger;
|
||||||
readonly #entryMinConfidence: number;
|
readonly #entryMinConfidence: number;
|
||||||
readonly #debounceMs: number;
|
readonly #debounceMs: number;
|
||||||
|
readonly #pollMs: number;
|
||||||
|
readonly #pollWindowMs: number;
|
||||||
|
readonly #pollMaxMs: number;
|
||||||
/** Last-fire timestamps, keyed by deviceId (camera-level, pre-snapshot) AND by
|
/** Last-fire timestamps, keyed by deviceId (camera-level, pre-snapshot) AND by
|
||||||
* `deviceId:plate` (post-match) — both gated against #debounceMs. */
|
* `deviceId:plate` (post-match) — both gated against #debounceMs. */
|
||||||
readonly #lastFire = new Map<string, number>();
|
readonly #lastFire = new Map<string, number>();
|
||||||
|
/** Cameras with a poll loop already in flight — a re-fired alarm (the camera pushes ~1Hz
|
||||||
|
* while the car sits) must NOT start a second concurrent loop on the same camera. */
|
||||||
|
readonly #polling = new Set<string>();
|
||||||
|
/** Per-camera SLIDING deadline for the running poll loop. A push that joins a running loop
|
||||||
|
* bumps this forward (lastPush + window, capped at start + max), so the loop keeps pulling
|
||||||
|
* fresh frames while cars keep arriving — tracking whoever settles at the barrier. */
|
||||||
|
readonly #pollDeadline = new Map<string, number>();
|
||||||
|
|
||||||
constructor(db: Db, vision: VisionClient | null, subscription: SubscriptionFlow, logger: FastifyBaseLogger) {
|
constructor(db: Db, vision: VisionClient | null, subscription: SubscriptionFlow, logger: FastifyBaseLogger) {
|
||||||
this.#db = db;
|
this.#db = db;
|
||||||
@@ -67,6 +115,9 @@ export class AnprBridge {
|
|||||||
this.#logger = logger;
|
this.#logger = logger;
|
||||||
this.#entryMinConfidence = entryMinConfidence();
|
this.#entryMinConfidence = entryMinConfidence();
|
||||||
this.#debounceMs = debounceMs();
|
this.#debounceMs = debounceMs();
|
||||||
|
this.#pollMs = pollMs();
|
||||||
|
this.#pollWindowMs = pollWindowMs();
|
||||||
|
this.#pollMaxMs = pollMaxMs();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -84,15 +135,35 @@ export class AnprBridge {
|
|||||||
if (site && site.anprEntryEnabled === false) return;
|
if (site && site.anprEntryEnabled === false) return;
|
||||||
const row = this.#db.select().from(devices).where(eq(devices.id, deviceId)).get();
|
const row = this.#db.select().from(devices).where(eq(devices.id, deviceId)).get();
|
||||||
if (!row || !row.enabled || row.category !== "camera") return;
|
if (!row || !row.enabled || row.category !== "camera") return;
|
||||||
if ((row.config as CameraConfig)?.anpr !== true) return; // opt-in only
|
const cfg = row.config as CameraConfig;
|
||||||
|
if (cfg?.anpr !== true) return; // recognition opt-in (also gates the evidence/advisory path)
|
||||||
|
// Per-camera AUTO-TRIGGER gate. `anpr` keeps recognition (snapshots + plate record) on;
|
||||||
|
// this controls whether THIS camera may auto-open the barrier. A shared entry/exit lane
|
||||||
|
// sets it false on (e.g.) the exit camera so its back-plate read doesn't phantom-exit the
|
||||||
|
// car that just entered. Absent ⇒ true (back-compat: existing anpr cameras still trigger).
|
||||||
|
if (cfg.anprAutoTrigger === false) return;
|
||||||
|
|
||||||
// Camera-level debounce (pre-snapshot): a car re-firing ~1Hz must not pull a
|
// Post-success debounce: once we've emitted a read for this camera, ignore the
|
||||||
// snapshot + analyze every second.
|
// ~1Hz re-fires for #debounceMs (set on success below). A fresh alarm AFTER the
|
||||||
|
// window is a new presentation and may start a new poll loop.
|
||||||
if (this.#debounced(deviceId)) return;
|
if (this.#debounced(deviceId)) return;
|
||||||
this.#stamp(deviceId);
|
// One poll loop per camera. A push that arrives while a loop runs JOINs it — and
|
||||||
|
// SLIDES the deadline forward (a different car arriving mid-loop keeps the loop alive
|
||||||
|
// so it tracks whoever's at the barrier now, instead of giving up on the early car).
|
||||||
|
const now = Date.now();
|
||||||
|
if (this.#polling.has(deviceId)) {
|
||||||
|
const cur = this.#pollDeadline.get(deviceId) ?? now;
|
||||||
|
// Slide to lastPush + window, but never past the per-loop hard ceiling (set at start).
|
||||||
|
this.#pollDeadline.set(deviceId, Math.max(cur, now + this.#pollWindowMs));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.#polling.add(deviceId);
|
||||||
|
// Initial deadline; the hard ceiling (start + max) is enforced in the loop below.
|
||||||
|
this.#pollDeadline.set(deviceId, now + this.#pollWindowMs);
|
||||||
|
|
||||||
const camera = buildCamera(row);
|
const camera = buildCamera(row);
|
||||||
if (!camera) {
|
if (!camera) {
|
||||||
|
this.#polling.delete(deviceId);
|
||||||
this.#logger.warn(`anpr-bridge: camera ${deviceId} config won't build`);
|
this.#logger.warn(`anpr-bridge: camera ${deviceId} config won't build`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -100,16 +171,74 @@ export class AnprBridge {
|
|||||||
// "both" collapses to entry purely for the capture hint (it doesn't pick the lane —
|
// "both" collapses to entry purely for the capture hint (it doesn't pick the lane —
|
||||||
// the gated flow infers the verb from the camera's bound relay direction).
|
// the gated flow infers the verb from the camera's bound relay direction).
|
||||||
const direction: FlowDirection = directionOf(this.#db, row) === "exit" ? "exit" : "entry";
|
const direction: FlowDirection = directionOf(this.#db, row) === "exit" ? "exit" : "entry";
|
||||||
const shot = await camera.captureSnapshot({ direction });
|
|
||||||
const result = await this.#vision.analyze(shot.bytes, shot.contentType);
|
|
||||||
if (!result || !result.plate) return; // nothing read
|
|
||||||
|
|
||||||
// Entry floor — stricter than the advisory floor (analyze() still returns the plate
|
// POLL-UNTIL-CONFIDENT. The alarm fires as the car APPROACHES (small/blurry/absent
|
||||||
// object with its confidence even when its own lowConfidence flag is set).
|
// plate → low-confidence misread, e.g. '111'@0.20). But the car then STOPS at the
|
||||||
if (result.plate.confidence < this.#entryMinConfidence) {
|
// barrier — the stationary, well-framed moment the manual test reads at ~100%. So we
|
||||||
|
// pull a FRESH frame every #pollMs and re-run ANPR until one clears the floor, or the
|
||||||
|
// #pollWindowMs window elapses (car drove off / non-subscriber). NB: a fresh pull each
|
||||||
|
// tick — NOT captureSnapshotShared, whose TTL would re-serve the same bad frame.
|
||||||
|
// While polling, watch whether THIS subscriber transacts by another credential
|
||||||
|
// (card/QR at the reader). If their open-occurrence count drops mid-poll, the
|
||||||
|
// subscriber already exited/entered — the bridge must NOT also emit (it would act on
|
||||||
|
// the NEXT open occurrence: a phantom double-exit, worst for a fleet sub). We learn the
|
||||||
|
// subscription as soon as a frame reads the bound plate (identity needs no confidence),
|
||||||
|
// snapshot the count, then keep polling for a CONFIDENT read; abort if the count moved.
|
||||||
|
let result: Awaited<ReturnType<VisionClient["analyze"]>> = null;
|
||||||
|
let watchedSubId: string | null = null;
|
||||||
|
let baselineOpen = 0;
|
||||||
|
// Hard ceiling for THIS loop (start + max); the sliding deadline (bumped by joining
|
||||||
|
// pushes) is read from #pollDeadline each tick but never allowed past this cap.
|
||||||
|
const hardCap = Date.now() + this.#pollMaxMs;
|
||||||
|
let attempts = 0;
|
||||||
|
try {
|
||||||
|
while (Date.now() < Math.min(this.#pollDeadline.get(deviceId) ?? 0, hardCap)) {
|
||||||
|
attempts++;
|
||||||
|
const shot = await camera.captureSnapshot({ direction });
|
||||||
|
const r = await this.#vision.analyze(shot.bytes, shot.contentType);
|
||||||
|
|
||||||
|
// Identify the subscriber from ANY readable plate (even below the barrier floor),
|
||||||
|
// and baseline their open count once — so we can detect a credential beating us.
|
||||||
|
if (r?.plate?.text) {
|
||||||
|
const m0 = this.#subscription.match({ ...baseRead(row, deviceId), value: r.plate.text.trim().toUpperCase() });
|
||||||
|
if (m0 && watchedSubId == null) {
|
||||||
|
watchedSubId = m0.subscriptionId;
|
||||||
|
baselineOpen = this.#subscription.openOccurrenceCount(watchedSubId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A credential (card/QR) closed/opened an occurrence for this subscriber mid-poll →
|
||||||
|
// they already transacted; stop polling and do NOT emit.
|
||||||
|
if (watchedSubId && this.#subscription.openOccurrenceCount(watchedSubId) !== baselineOpen) {
|
||||||
this.#logger.info(
|
this.#logger.info(
|
||||||
`anpr-bridge: plate '${result.plate.text}' below entry floor ` +
|
`anpr-bridge: subscriber ${watchedSubId} transacted by another credential mid-poll — aborting ANPR`,
|
||||||
`(${result.plate.confidence.toFixed(3)} < ${this.#entryMinConfidence}) — ignored`,
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (r?.plate && r.plate.confidence >= this.#entryMinConfidence) {
|
||||||
|
result = r;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (r?.plate) {
|
||||||
|
this.#logger.info(
|
||||||
|
`anpr-bridge: '${r.plate.text}' (${r.plate.confidence.toFixed(3)}) below floor ` +
|
||||||
|
`${this.#entryMinConfidence} — re-pulling (attempt ${attempts})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Stop if the next tick would land past the (possibly slid) deadline or the cap.
|
||||||
|
const effDeadline = Math.min(this.#pollDeadline.get(deviceId) ?? 0, hardCap);
|
||||||
|
if (Date.now() + this.#pollMs >= effDeadline) break;
|
||||||
|
await sleep(this.#pollMs);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
this.#polling.delete(deviceId);
|
||||||
|
this.#pollDeadline.delete(deviceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!result || !result.plate) {
|
||||||
|
this.#logger.info(
|
||||||
|
`anpr-bridge: no confident plate from ${deviceId} after ${attempts} attempt(s) ` +
|
||||||
|
`in ${this.#pollWindowMs}ms — gave up`,
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -133,11 +262,21 @@ export class AnprBridge {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Final guard against the credential-mid-poll race: if the subscriber transacted between
|
||||||
|
// our baseline and now (e.g. a card scan in the last tick), don't double-act.
|
||||||
|
if (watchedSubId === match.subscriptionId && this.#subscription.openOccurrenceCount(match.subscriptionId) !== baselineOpen) {
|
||||||
|
this.#logger.info(`anpr-bridge: ${match.subscriptionId} already transacted — skipping ANPR emit`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Plate-level debounce — belt-and-suspenders against a gap that slips the
|
// Plate-level debounce — belt-and-suspenders against a gap that slips the
|
||||||
// camera-level gate re-emitting the SAME plate.
|
// camera-level gate re-emitting the SAME plate.
|
||||||
const plateKey = `${deviceId}:${plate}`;
|
const plateKey = `${deviceId}:${plate}`;
|
||||||
if (this.#debounced(plateKey)) return;
|
if (this.#debounced(plateKey)) return;
|
||||||
this.#stamp(plateKey);
|
this.#stamp(plateKey);
|
||||||
|
// Camera-level debounce stamp — now that we've emitted, suppress the camera's ~1Hz
|
||||||
|
// re-fires (and any new poll loop) for #debounceMs.
|
||||||
|
this.#stamp(deviceId);
|
||||||
|
|
||||||
this.#logger.info(
|
this.#logger.info(
|
||||||
`anpr-bridge: subscriber plate '${plate}' (${result.plate.confidence.toFixed(3)}) → read bus`,
|
`anpr-bridge: subscriber plate '${plate}' (${result.plate.confidence.toFixed(3)}) → read bus`,
|
||||||
|
|||||||
@@ -0,0 +1,168 @@
|
|||||||
|
import { constants } from "node:fs";
|
||||||
|
import { access, stat } from "node:fs/promises";
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
import { eq, siteConfig, type Db } from "@parking/db";
|
||||||
|
import type { FastifyBaseLogger } from "fastify";
|
||||||
|
import { DEFAULT_BACKUP_RETENTION, runBackup, type BackupResult, type BackupRetention } from "./backup.js";
|
||||||
|
|
||||||
|
// Thin coordinator around the backup engine (backup.ts). The TARGET DIRECTORY is admin-chosen
|
||||||
|
// and stored in site_config.backup_target_dir (read fresh each run, so changing it in the UI
|
||||||
|
// takes effect with no restart). The ENCRYPTION KEY stays an env/Komodo secret (BACKUP_KEY) —
|
||||||
|
// a key must never live in the DB it backs up. Remembers the last outcome so the route + UI can
|
||||||
|
// show last-success / last-error, and serializes concurrent runs (manual + timer). See
|
||||||
|
// wiki/concepts/backup-recovery.md.
|
||||||
|
|
||||||
|
/** The dedicated backup-encryption key, from env (NOT the DB). Separate from EVENT_SIGNING_KEY. */
|
||||||
|
export function backupKeyFromEnv(): string {
|
||||||
|
return process.env.BACKUP_KEY ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TargetCheck {
|
||||||
|
readonly ok: boolean;
|
||||||
|
/** Machine-readable reason when !ok: "empty" | "missing" | "not_a_dir" | "not_writable". */
|
||||||
|
readonly reason?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BackupStatus {
|
||||||
|
/** True once a target dir is set AND a usable key is present (else backups are a no-op). */
|
||||||
|
readonly configured: boolean;
|
||||||
|
/** The admin-chosen target dir (null if unset) — surfaced so the UI can show/edit it. */
|
||||||
|
readonly targetDir: string | null;
|
||||||
|
/** Admin-tuned retention (resolved: DB value or code default) — surfaced for the UI form. */
|
||||||
|
readonly keepLast: number;
|
||||||
|
readonly keepDailyDays: number;
|
||||||
|
/** Whether the env key is present + long enough (the UI flags a missing key distinctly). */
|
||||||
|
readonly keyPresent: boolean;
|
||||||
|
readonly running: boolean;
|
||||||
|
readonly lastSuccessAt: string | null;
|
||||||
|
readonly lastResult: { path: string; bytes: number; prunedFiles: number } | null;
|
||||||
|
readonly lastErrorAt: string | null;
|
||||||
|
readonly lastError: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Probe a candidate target path server-side: exists, is a directory, is writable. */
|
||||||
|
export async function checkTargetDir(dir: string): Promise<TargetCheck> {
|
||||||
|
const trimmed = dir.trim();
|
||||||
|
if (!trimmed) return { ok: false, reason: "empty" };
|
||||||
|
const path = resolve(trimmed);
|
||||||
|
let st: Awaited<ReturnType<typeof stat>>;
|
||||||
|
try {
|
||||||
|
st = await stat(path);
|
||||||
|
} catch {
|
||||||
|
return { ok: false, reason: "missing" };
|
||||||
|
}
|
||||||
|
if (!st.isDirectory()) return { ok: false, reason: "not_a_dir" };
|
||||||
|
try {
|
||||||
|
await access(path, constants.W_OK);
|
||||||
|
} catch {
|
||||||
|
return { ok: false, reason: "not_writable" };
|
||||||
|
}
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BackupService {
|
||||||
|
readonly #db: Db;
|
||||||
|
readonly #logger?: FastifyBaseLogger;
|
||||||
|
|
||||||
|
#running = false;
|
||||||
|
#lastSuccessAt: string | null = null;
|
||||||
|
#lastResult: BackupResult | null = null;
|
||||||
|
#lastErrorAt: string | null = null;
|
||||||
|
#lastError: string | null = null;
|
||||||
|
|
||||||
|
constructor(db: Db, logger?: FastifyBaseLogger) {
|
||||||
|
this.#db = db;
|
||||||
|
this.#logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The admin-chosen target dir from site_config (null/empty = unset). Read fresh each call. */
|
||||||
|
targetDir(): string | null {
|
||||||
|
const row = this.#db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
|
||||||
|
const dir = row?.backupTargetDir?.trim();
|
||||||
|
return dir ? dir : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolved retention from site_config, falling back to the code default per field. Read fresh. */
|
||||||
|
retention(): BackupRetention {
|
||||||
|
const row = this.#db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
|
||||||
|
const keepLast = row?.backupKeepLast;
|
||||||
|
const keepDailyDays = row?.backupKeepDailyDays;
|
||||||
|
return {
|
||||||
|
keepLast: keepLast != null && keepLast >= 0 ? keepLast : DEFAULT_BACKUP_RETENTION.keepLast,
|
||||||
|
keepDailyDays:
|
||||||
|
keepDailyDays != null && keepDailyDays >= 0 ? keepDailyDays : DEFAULT_BACKUP_RETENTION.keepDailyDays,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
get keyPresent(): boolean {
|
||||||
|
return backupKeyFromEnv().length >= 16;
|
||||||
|
}
|
||||||
|
|
||||||
|
get configured(): boolean {
|
||||||
|
return this.targetDir() !== null && this.keyPresent;
|
||||||
|
}
|
||||||
|
|
||||||
|
status(): BackupStatus {
|
||||||
|
const r = this.retention();
|
||||||
|
return {
|
||||||
|
configured: this.configured,
|
||||||
|
targetDir: this.targetDir(),
|
||||||
|
keepLast: r.keepLast,
|
||||||
|
keepDailyDays: r.keepDailyDays,
|
||||||
|
keyPresent: this.keyPresent,
|
||||||
|
running: this.#running,
|
||||||
|
lastSuccessAt: this.#lastSuccessAt,
|
||||||
|
lastResult: this.#lastResult
|
||||||
|
? { path: this.#lastResult.path, bytes: this.#lastResult.bytes, prunedFiles: this.#lastResult.prunedFiles }
|
||||||
|
: null,
|
||||||
|
lastErrorAt: this.#lastErrorAt,
|
||||||
|
lastError: this.#lastError,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run one backup. `trigger` is just for the log line. Serialized: if one is already in
|
||||||
|
* flight, resolves to that same promise. Reads the target dir + key at run time. Records
|
||||||
|
* last-success/last-error. Re-throws on failure so a manual caller (the route) can surface
|
||||||
|
* it; the scheduled timer wraps + swallows.
|
||||||
|
*/
|
||||||
|
#inflight: Promise<BackupResult> | null = null;
|
||||||
|
async run(trigger: "manual" | "scheduled"): Promise<BackupResult> {
|
||||||
|
if (this.#inflight) return this.#inflight;
|
||||||
|
const targetDir = this.targetDir();
|
||||||
|
const key = backupKeyFromEnv();
|
||||||
|
if (!targetDir) throw new Error("backup: no target directory configured");
|
||||||
|
if (key.length < 16) throw new Error("backup: BACKUP_KEY missing or too short (need ≥16 chars)");
|
||||||
|
|
||||||
|
this.#running = true;
|
||||||
|
this.#inflight = (async () => {
|
||||||
|
try {
|
||||||
|
this.#logger?.info(`backup: starting (${trigger}) → ${targetDir}`);
|
||||||
|
const res = await runBackup(this.#db, { targetDir, key, retention: this.retention() }, this.#logger);
|
||||||
|
this.#lastResult = res;
|
||||||
|
this.#lastSuccessAt = new Date().toISOString();
|
||||||
|
this.#lastError = null;
|
||||||
|
return res;
|
||||||
|
} catch (err) {
|
||||||
|
this.#lastError = (err as Error).message;
|
||||||
|
this.#lastErrorAt = new Date().toISOString();
|
||||||
|
this.#logger?.error(`backup: failed (${trigger}): ${this.#lastError}`);
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
this.#running = false;
|
||||||
|
this.#inflight = null;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return this.#inflight;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Scheduled-run wrapper: never throws (a timer must not crash the process). */
|
||||||
|
async runScheduled(): Promise<void> {
|
||||||
|
if (!this.configured) return; // silent no-op when backups aren't set up
|
||||||
|
try {
|
||||||
|
await this.run("scheduled");
|
||||||
|
} catch {
|
||||||
|
/* recorded in last-error; already logged */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from "node:crypto";
|
||||||
|
import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { createTestDb, openRawDb } from "@parking/db/testing";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
DEFAULT_BACKUP_RETENTION,
|
||||||
|
parseBackupStamp,
|
||||||
|
pruneOldBackups,
|
||||||
|
runBackup,
|
||||||
|
} from "./backup.js";
|
||||||
|
|
||||||
|
// Mirror of the engine's header layout, so the test decrypts independently (a real restore
|
||||||
|
// tool would do exactly this) rather than trusting the engine to also decrypt.
|
||||||
|
const MAGIC = Buffer.from("PKBK", "ascii");
|
||||||
|
const SALT_LEN = 16;
|
||||||
|
const IV_LEN = 12;
|
||||||
|
const TAG_LEN = 16;
|
||||||
|
|
||||||
|
function decryptBackup(enc: Buffer, key: string): Buffer {
|
||||||
|
expect(enc.subarray(0, 4)).toEqual(MAGIC);
|
||||||
|
expect(enc[4]).toBe(1); // format version
|
||||||
|
let off = 5;
|
||||||
|
const salt = enc.subarray(off, (off += SALT_LEN));
|
||||||
|
const iv = enc.subarray(off, (off += IV_LEN));
|
||||||
|
const tag = enc.subarray(enc.length - TAG_LEN);
|
||||||
|
const ciphertext = enc.subarray(off, enc.length - TAG_LEN);
|
||||||
|
const derived = scryptSync(key, salt, 32);
|
||||||
|
const decipher = createDecipheriv("aes-256-gcm", derived, iv);
|
||||||
|
decipher.setAuthTag(tag);
|
||||||
|
return Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
let workDir: string;
|
||||||
|
const KEY = "a-test-backup-key-that-is-long-enough";
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
workDir = mkdtempSync(join(tmpdir(), "pk-backup-test-"));
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
rmSync(workDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("runBackup — round-trip", () => {
|
||||||
|
it("produces an encrypted backup that decrypts to a byte-identical, queryable DB", async () => {
|
||||||
|
// A real on-disk DB so the engine's better-sqlite3 .backup() runs for real.
|
||||||
|
const dbPath = join(workDir, "source.sqlite");
|
||||||
|
const t = createTestDb(dbPath);
|
||||||
|
// Put some recognizable data in.
|
||||||
|
t.sqlite.exec("CREATE TABLE marker (k TEXT PRIMARY KEY, v TEXT)");
|
||||||
|
t.sqlite.prepare("INSERT INTO marker (k, v) VALUES (?, ?)").run("hello", "world");
|
||||||
|
|
||||||
|
const targetDir = join(workDir, "target");
|
||||||
|
const res = await runBackup(t.db, { targetDir, key: KEY });
|
||||||
|
t.close();
|
||||||
|
|
||||||
|
expect(res.bytes).toBeGreaterThan(0);
|
||||||
|
expect(res.path).toMatch(/parking-backup-\d{8}T\d{6}Z\.sqlite\.enc$/);
|
||||||
|
|
||||||
|
// Decrypt independently and open the recovered DB raw (no migrations — verify as-written).
|
||||||
|
const plain = decryptBackup(readFileSync(res.path), KEY);
|
||||||
|
const restoredPath = join(workDir, "restored.sqlite");
|
||||||
|
writeFileSync(restoredPath, plain);
|
||||||
|
const restored = openRawDb(restoredPath);
|
||||||
|
const row = restored.prepare("SELECT v FROM marker WHERE k = ?").get("hello") as { v: string };
|
||||||
|
expect(row.v).toBe("world");
|
||||||
|
restored.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a missing/short key before touching the filesystem", async () => {
|
||||||
|
const t = createTestDb();
|
||||||
|
await expect(runBackup(t.db, { targetDir: join(workDir, "t"), key: "short" })).rejects.toThrow(
|
||||||
|
/BACKUP_KEY/,
|
||||||
|
);
|
||||||
|
t.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes the plaintext scratch copy after a successful run", async () => {
|
||||||
|
const scratchDir = join(workDir, "scratch");
|
||||||
|
const t = createTestDb();
|
||||||
|
await runBackup(t.db, {
|
||||||
|
targetDir: join(workDir, "target"),
|
||||||
|
key: KEY,
|
||||||
|
scratchDir,
|
||||||
|
// Stub the copy so we don't need a file-backed handle here.
|
||||||
|
makeConsistentCopy: async (_db, dest) => writeFileSync(dest, "PRAGMA;"),
|
||||||
|
});
|
||||||
|
t.close();
|
||||||
|
// The only thing left in scratch must NOT be a .sqlite plaintext.
|
||||||
|
const left = readdirSync(scratchDir).filter((n) => n.endsWith(".sqlite"));
|
||||||
|
expect(left).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("wipes the plaintext scratch copy even when the copy step fails", async () => {
|
||||||
|
const scratchDir = join(workDir, "scratch");
|
||||||
|
mkdirSync(scratchDir, { recursive: true });
|
||||||
|
const t = createTestDb();
|
||||||
|
// Force a failure: the copy step writes the plaintext, then throws (mid-pipeline). The
|
||||||
|
// finally{} must still remove the plaintext it left behind.
|
||||||
|
await expect(
|
||||||
|
runBackup(t.db, {
|
||||||
|
targetDir: join(workDir, "target"),
|
||||||
|
key: KEY,
|
||||||
|
scratchDir,
|
||||||
|
makeConsistentCopy: async (_db, dest) => {
|
||||||
|
writeFileSync(dest, "PRAGMA;"); // leave a plaintext intermediate…
|
||||||
|
throw new Error("simulated copy failure"); // …then fail
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(/simulated copy failure/);
|
||||||
|
t.close();
|
||||||
|
const left = readdirSync(scratchDir).filter((n) => n.endsWith(".sqlite"));
|
||||||
|
expect(left).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("backup encryption — tamper evidence (AES-256-GCM)", () => {
|
||||||
|
it("a flipped ciphertext byte fails authentication on decrypt", async () => {
|
||||||
|
const t = createTestDb();
|
||||||
|
const targetDir = join(workDir, "target");
|
||||||
|
const res = await runBackup(t.db, {
|
||||||
|
targetDir,
|
||||||
|
key: KEY,
|
||||||
|
makeConsistentCopy: async (_db, dest) => writeFileSync(dest, "the quick brown fox".repeat(100)),
|
||||||
|
});
|
||||||
|
t.close();
|
||||||
|
|
||||||
|
const enc = readFileSync(res.path);
|
||||||
|
// Flip a byte in the ciphertext region (after the header, before the tag).
|
||||||
|
enc[5 + SALT_LEN + IV_LEN + 3] ^= 0xff;
|
||||||
|
expect(() => decryptBackup(enc, KEY)).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the wrong key fails authentication", async () => {
|
||||||
|
const t = createTestDb();
|
||||||
|
const res = await runBackup(t.db, {
|
||||||
|
targetDir: join(workDir, "target"),
|
||||||
|
key: KEY,
|
||||||
|
makeConsistentCopy: async (_db, dest) => writeFileSync(dest, "payload".repeat(50)),
|
||||||
|
});
|
||||||
|
t.close();
|
||||||
|
expect(() => decryptBackup(readFileSync(res.path), "a-different-but-also-long-key-xx")).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("parseBackupStamp", () => {
|
||||||
|
it("round-trips a stamped name and rejects non-backups", () => {
|
||||||
|
const d = parseBackupStamp("parking-backup-20260629T141503Z.sqlite.enc");
|
||||||
|
expect(d?.toISOString()).toBe("2026-06-29T14:15:03.000Z");
|
||||||
|
expect(parseBackupStamp("random.txt")).toBeNull();
|
||||||
|
expect(parseBackupStamp("parking-backup-not-a-date.sqlite.enc")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("pruneOldBackups — keep-last-N + dailies", () => {
|
||||||
|
const day = 24 * 60 * 60 * 1000;
|
||||||
|
const now = new Date("2026-06-29T12:00:00Z");
|
||||||
|
|
||||||
|
function seed(stamps: string[]) {
|
||||||
|
const dir = join(workDir, "retain");
|
||||||
|
mkdirSync(dir, { recursive: true });
|
||||||
|
for (const s of stamps) writeFileSync(join(dir, `parking-backup-${s}.sqlite.enc`), "x");
|
||||||
|
return dir;
|
||||||
|
}
|
||||||
|
const stamp = (ms: number) =>
|
||||||
|
new Date(ms).toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z");
|
||||||
|
|
||||||
|
it("keeps the keepLast newest regardless of age", async () => {
|
||||||
|
// 5 backups within the last hour; keepLast=3 → 2 pruned, even though all are recent.
|
||||||
|
const t = now.getTime();
|
||||||
|
const dir = seed([0, 1, 2, 3, 4].map((i) => stamp(t - i * 60 * 1000)));
|
||||||
|
const pruned = await pruneOldBackups(dir, { keepLast: 3, keepDailyDays: 0 }, now);
|
||||||
|
expect(pruned).toBe(2);
|
||||||
|
expect(readdirSync(dir).length).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps one-per-day within the daily window and drops older", async () => {
|
||||||
|
const t = now.getTime();
|
||||||
|
// Two backups today, one 5 days ago, one 40 days ago. keepLast=1, keepDailyDays=30.
|
||||||
|
const dir = seed([
|
||||||
|
stamp(t), // today A (newest → kept by keepLast)
|
||||||
|
stamp(t - 60 * 1000), // today B (same day as the kept one → pruned)
|
||||||
|
stamp(t - 5 * day), // 5 days ago (kept: within window, unique day)
|
||||||
|
stamp(t - 40 * day), // 40 days ago (pruned: outside the window)
|
||||||
|
]);
|
||||||
|
const pruned = await pruneOldBackups(dir, { keepLast: 1, keepDailyDays: 30 }, now);
|
||||||
|
expect(pruned).toBe(2);
|
||||||
|
const left = readdirSync(dir);
|
||||||
|
expect(left.length).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is a no-op on a missing target dir", async () => {
|
||||||
|
const pruned = await pruneOldBackups(join(workDir, "does-not-exist"), DEFAULT_BACKUP_RETENTION, now);
|
||||||
|
expect(pruned).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
import { createCipheriv, randomBytes, scryptSync } from "node:crypto";
|
||||||
|
import { createReadStream, createWriteStream } from "node:fs";
|
||||||
|
import { mkdir, readdir, rm, stat } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { basename, join, resolve } from "node:path";
|
||||||
|
import { pipeline } from "node:stream/promises";
|
||||||
|
import type { Db } from "@parking/db";
|
||||||
|
import type { FastifyBaseLogger } from "fastify";
|
||||||
|
|
||||||
|
// On-site encrypted DB backup — the durability half of the anti-fraud design. The SQLite
|
||||||
|
// DB *is* the signed append-only ledger, so a disk failure / stolen-or-destroyed PC means
|
||||||
|
// total revenue-history loss. This produces a consistent, encrypted, restore-to-a-fresh-
|
||||||
|
// appliance copy. See wiki/concepts/backup-recovery.md.
|
||||||
|
//
|
||||||
|
// Two load-bearing properties:
|
||||||
|
// 1. CONSISTENT copy of a LIVE WAL-mode DB — via better-sqlite3's online .backup() (NOT a
|
||||||
|
// raw file copy, which can capture a torn WAL). The result must still verifyChain.
|
||||||
|
// 2. Encrypted with a DEDICATED key (BACKUP_KEY / park_buzi_backup_key), SEPARATE from
|
||||||
|
// EVENT_SIGNING_KEY — so the backup key can rotate without fracturing the signed chain,
|
||||||
|
// and a backup target never exposes the signing key. The key is NEVER written into the
|
||||||
|
// backup it unlocks.
|
||||||
|
//
|
||||||
|
// This module is the engine (consistent copy → encrypt → retention). Targets beyond a local/
|
||||||
|
// mounted path (SMB/NFS are just mount paths; SFTP) and the manual button/route are layered on
|
||||||
|
// top. RESTORE is intentionally NOT here — it's an out-of-band runbook action on a fresh box.
|
||||||
|
|
||||||
|
/** AES-256-GCM with a scrypt-derived key. Self-describing header so a restore tool needs only
|
||||||
|
* the key + the file. Layout: magic | version | salt(16) | iv(12) | ciphertext… | authTag(16). */
|
||||||
|
const MAGIC = Buffer.from("PKBK", "ascii"); // ParKing BacKup
|
||||||
|
const FORMAT_VERSION = 1;
|
||||||
|
const SALT_LEN = 16;
|
||||||
|
const IV_LEN = 12;
|
||||||
|
const TAG_LEN = 16;
|
||||||
|
const SCRYPT_KEYLEN = 32; // AES-256
|
||||||
|
|
||||||
|
export interface BackupRetention {
|
||||||
|
/** Keep at least this many most-recent backups regardless of age. */
|
||||||
|
readonly keepLast: number;
|
||||||
|
/** Beyond keepLast, keep one backup per day for this many days; older ones are pruned. */
|
||||||
|
readonly keepDailyDays: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Code defaults — the fallback when the admin hasn't set a value in site_config (the source of
|
||||||
|
// truth). NOT env-driven: retention is operational policy tuned from the Backup screen.
|
||||||
|
export const DEFAULT_BACKUP_RETENTION: BackupRetention = {
|
||||||
|
keepLast: 7,
|
||||||
|
keepDailyDays: 30,
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface BackupOptions {
|
||||||
|
/** Directory the encrypted backup is written to (a mounted local/USB/SATA/SMB/NFS path). */
|
||||||
|
readonly targetDir: string;
|
||||||
|
/** Encryption key (BACKUP_KEY / park_buzi_backup_key). ≥16 chars enforced. */
|
||||||
|
readonly key: string;
|
||||||
|
readonly retention?: BackupRetention;
|
||||||
|
/** Override the consistent-copy step (tests inject a fake to avoid a real sqlite handle). */
|
||||||
|
readonly makeConsistentCopy?: (db: Db, destPath: string) => Promise<void>;
|
||||||
|
/** Override "now" for deterministic filenames/retention in tests. */
|
||||||
|
readonly now?: () => Date;
|
||||||
|
/** Scratch dir for the intermediate plaintext copy (default os.tmpdir()). */
|
||||||
|
readonly scratchDir?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BackupResult {
|
||||||
|
/** Absolute path of the encrypted backup written. */
|
||||||
|
readonly path: string;
|
||||||
|
/** Size of the encrypted file in bytes. */
|
||||||
|
readonly bytes: number;
|
||||||
|
/** Backups pruned by the retention policy this run. */
|
||||||
|
readonly prunedFiles: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Filename convention: parking-backup-YYYYMMDDTHHMMSSZ.sqlite.enc — sortable, UTC, parseable. */
|
||||||
|
const FILE_PREFIX = "parking-backup-";
|
||||||
|
const FILE_SUFFIX = ".sqlite.enc";
|
||||||
|
|
||||||
|
function stampFor(d: Date): string {
|
||||||
|
return d.toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse the UTC instant back out of a backup filename, or null if it doesn't match. */
|
||||||
|
export function parseBackupStamp(name: string): Date | null {
|
||||||
|
const base = basename(name);
|
||||||
|
if (!base.startsWith(FILE_PREFIX) || !base.endsWith(FILE_SUFFIX)) return null;
|
||||||
|
const stamp = base.slice(FILE_PREFIX.length, -FILE_SUFFIX.length);
|
||||||
|
// 20260629T141503Z → 2026-06-29T14:15:03Z
|
||||||
|
const m = /^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z$/.exec(stamp);
|
||||||
|
if (!m) return null;
|
||||||
|
const iso = `${m[1]}-${m[2]}-${m[3]}T${m[4]}:${m[5]}:${m[6]}Z`;
|
||||||
|
const dt = new Date(iso);
|
||||||
|
return Number.isNaN(dt.getTime()) ? null : dt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Consistent online copy of the live WAL-mode DB via better-sqlite3's native backup(). */
|
||||||
|
async function defaultConsistentCopy(db: Db, destPath: string): Promise<void> {
|
||||||
|
// db.$client is the raw better-sqlite3 Database; .backup() returns a promise and copies a
|
||||||
|
// transactionally-consistent snapshot even while the source is being written.
|
||||||
|
const client = db.$client as { backup: (dest: string) => Promise<unknown> };
|
||||||
|
await client.backup(destPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Encrypt `srcPath` → `destPath` streaming, with the self-describing header. */
|
||||||
|
async function encryptFile(srcPath: string, destPath: string, key: string): Promise<void> {
|
||||||
|
const salt = randomBytes(SALT_LEN);
|
||||||
|
const iv = randomBytes(IV_LEN);
|
||||||
|
const derived = scryptSync(key, salt, SCRYPT_KEYLEN);
|
||||||
|
const cipher = createCipheriv("aes-256-gcm", derived, iv);
|
||||||
|
|
||||||
|
const out = createWriteStream(destPath);
|
||||||
|
const header = Buffer.concat([MAGIC, Buffer.from([FORMAT_VERSION]), salt, iv]);
|
||||||
|
out.write(header);
|
||||||
|
|
||||||
|
await pipeline(createReadStream(srcPath), cipher, out, { end: false });
|
||||||
|
// GCM auth tag is available only after the cipher has flushed; append it, then close.
|
||||||
|
const tag = cipher.getAuthTag();
|
||||||
|
await new Promise<void>((res, rej) => {
|
||||||
|
out.end(tag, () => res());
|
||||||
|
out.on("error", rej);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run one backup: consistent copy → encrypt → prune old backups by retention.
|
||||||
|
* Best-effort caller-facing: throws on real failure (so a manual run surfaces the error),
|
||||||
|
* but the scheduled timer wraps it and logs.
|
||||||
|
*/
|
||||||
|
export async function runBackup(
|
||||||
|
db: Db,
|
||||||
|
opts: BackupOptions,
|
||||||
|
logger?: FastifyBaseLogger,
|
||||||
|
): Promise<BackupResult> {
|
||||||
|
if (!opts.key || opts.key.length < 16) {
|
||||||
|
throw new Error("backup: BACKUP_KEY missing or too short (need ≥16 chars)");
|
||||||
|
}
|
||||||
|
const now = opts.now ?? (() => new Date());
|
||||||
|
const retention = opts.retention ?? DEFAULT_BACKUP_RETENTION;
|
||||||
|
const targetDir = resolve(opts.targetDir);
|
||||||
|
await mkdir(targetDir, { recursive: true });
|
||||||
|
|
||||||
|
const stamp = stampFor(now());
|
||||||
|
const finalPath = join(targetDir, `${FILE_PREFIX}${stamp}${FILE_SUFFIX}`);
|
||||||
|
|
||||||
|
// Intermediate plaintext copy in scratch (NOT the target dir — the target may be a network
|
||||||
|
// share / removable disk; keep the plaintext local and short-lived, then wipe it).
|
||||||
|
const scratch = opts.scratchDir ?? tmpdir();
|
||||||
|
await mkdir(scratch, { recursive: true });
|
||||||
|
const plainPath = join(scratch, `${FILE_PREFIX}${stamp}.sqlite`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const copy = opts.makeConsistentCopy ?? defaultConsistentCopy;
|
||||||
|
await copy(db, plainPath);
|
||||||
|
await encryptFile(plainPath, finalPath, opts.key);
|
||||||
|
} finally {
|
||||||
|
// Always wipe the plaintext intermediate, success or fail — it's the unencrypted ledger.
|
||||||
|
await rm(plainPath, { force: true }).catch((err) =>
|
||||||
|
logger?.warn(`backup: failed to remove plaintext scratch copy: ${(err as Error).message}`),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { size } = await stat(finalPath);
|
||||||
|
const prunedFiles = await pruneOldBackups(targetDir, retention, now());
|
||||||
|
logger?.info(
|
||||||
|
`backup: wrote ${basename(finalPath)} (${(size / 1048576).toFixed(1)} MB)` +
|
||||||
|
(prunedFiles > 0 ? `, pruned ${prunedFiles} old` : ""),
|
||||||
|
);
|
||||||
|
return { path: finalPath, bytes: size, prunedFiles };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retention: keep the `keepLast` most-recent backups always; beyond those, keep at most one
|
||||||
|
* backup per UTC day for `keepDailyDays` days; delete anything older or any extra same-day
|
||||||
|
* duplicates outside the keepLast window. Returns the count deleted.
|
||||||
|
*/
|
||||||
|
export async function pruneOldBackups(
|
||||||
|
targetDir: string,
|
||||||
|
retention: BackupRetention,
|
||||||
|
now: Date,
|
||||||
|
): Promise<number> {
|
||||||
|
let names: string[];
|
||||||
|
try {
|
||||||
|
names = await readdir(targetDir);
|
||||||
|
} catch {
|
||||||
|
return 0; // target gone/unmounted — nothing to prune (the write would have failed first)
|
||||||
|
}
|
||||||
|
|
||||||
|
const backups = names
|
||||||
|
.map((n) => ({ name: n, at: parseBackupStamp(n) }))
|
||||||
|
.filter((b): b is { name: string; at: Date } => b.at !== null)
|
||||||
|
.sort((a, b) => b.at.getTime() - a.at.getTime()); // newest first
|
||||||
|
|
||||||
|
const keep = new Set<string>();
|
||||||
|
// 1. Always keep the keepLast newest.
|
||||||
|
for (const b of backups.slice(0, Math.max(0, retention.keepLast))) keep.add(b.name);
|
||||||
|
|
||||||
|
// 2. Beyond that, keep the newest per UTC day within the keepDailyDays window.
|
||||||
|
const cutoff = now.getTime() - retention.keepDailyDays * 24 * 60 * 60 * 1000;
|
||||||
|
const seenDays = new Set<string>();
|
||||||
|
for (const b of backups) {
|
||||||
|
if (keep.has(b.name)) {
|
||||||
|
seenDays.add(b.at.toISOString().slice(0, 10));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (b.at.getTime() < cutoff) continue; // too old → not kept
|
||||||
|
const day = b.at.toISOString().slice(0, 10);
|
||||||
|
if (seenDays.has(day)) continue; // already have a backup for this day → prune the extra
|
||||||
|
seenDays.add(day);
|
||||||
|
keep.add(b.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
let pruned = 0;
|
||||||
|
for (const b of backups) {
|
||||||
|
if (keep.has(b.name)) continue;
|
||||||
|
await rm(join(targetDir, b.name), { force: true });
|
||||||
|
pruned += 1;
|
||||||
|
}
|
||||||
|
return pruned;
|
||||||
|
}
|
||||||
@@ -0,0 +1,357 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { eq, devices, type Db } from "@parking/db";
|
||||||
|
import { createTestDb } from "@parking/db/testing";
|
||||||
|
import type { AuxOutputDevice } from "@parking/devices";
|
||||||
|
import { ButtonLightController } from "./button-light.js";
|
||||||
|
import { deviceEvents } from "./device-events.js";
|
||||||
|
import { silentLogger } from "./test-helpers.js";
|
||||||
|
|
||||||
|
// ButtonLightController: alert (radarAlert) relays — the entry-button lamp on a spare
|
||||||
|
// relay, driven by the lamp's trigger input vs. the camera lane status. Truth table:
|
||||||
|
// trigger active + lane busy -> SOLID on
|
||||||
|
// trigger active + lane free -> BLINK (~1 Hz)
|
||||||
|
// otherwise -> OFF
|
||||||
|
// Lamp is a non-barrier aux output; fails OFF; de-dupes redundant writes. A controller may
|
||||||
|
// carry several alert relays (each its own row + trigger input), keyed independently.
|
||||||
|
|
||||||
|
let db: Db;
|
||||||
|
const CONTROLLER = "ctl-1";
|
||||||
|
const RADAR_INPUT = 2; // I2
|
||||||
|
const LAMP_RELAY = 3; // spare relay R3
|
||||||
|
|
||||||
|
/** A fake aux device recording setAux calls (channel,on). Optionally throws. */
|
||||||
|
function fakeAux(record: Array<{ ch: number; on: boolean }>, throwOnce = { v: false }): AuxOutputDevice {
|
||||||
|
return {
|
||||||
|
async setAux(channel: number, on: boolean): Promise<void> {
|
||||||
|
if (throwOnce.v) {
|
||||||
|
throwOnce.v = false;
|
||||||
|
throw new Error("UDP down");
|
||||||
|
}
|
||||||
|
record.push({ ch: channel, on });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
({ db } = createTestDb());
|
||||||
|
vi.useFakeTimers();
|
||||||
|
// One controller: entry relay 1 with radar on I2; lamp on spare relay 3.
|
||||||
|
db.insert(devices).values({
|
||||||
|
id: CONTROLLER,
|
||||||
|
category: "access",
|
||||||
|
driverId: "dingtian",
|
||||||
|
config: {
|
||||||
|
host: "10.0.0.5",
|
||||||
|
relays: [
|
||||||
|
{ relay: 1, direction: "entry", button: 1, presenceInput: RADAR_INPUT, presenceKind: "radar" },
|
||||||
|
{ relay: 2, direction: "exit" },
|
||||||
|
{ relay: LAMP_RELAY, direction: "radarAlert", triggerInput: RADAR_INPUT, blinkOnMs: 500, blinkOffMs: 500 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
enabled: true,
|
||||||
|
}).run();
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Emit a radar (presence input) edge for the controller. */
|
||||||
|
function radar(present: boolean): void {
|
||||||
|
deviceEvents.emitInput({
|
||||||
|
driverId: "dingtian",
|
||||||
|
deviceId: CONTROLLER,
|
||||||
|
input: RADAR_INPUT,
|
||||||
|
edge: present ? "on" : "off",
|
||||||
|
at: new Date().toISOString(),
|
||||||
|
source: "poll",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Emit a lane status (entry busy/free). */
|
||||||
|
function lane(entryBusy: boolean): void {
|
||||||
|
deviceEvents.emitLaneStatus({ entry: entryBusy, exit: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Flush the microtask queue so serialized setAux promises (and their re-pump on
|
||||||
|
* completion) settle. The lamp worker sends ONE UDP at a time and re-pumps on resolve;
|
||||||
|
* a few turns drain a burst. Needed because sends are now async (was synchronous). */
|
||||||
|
async function flush(): Promise<void> {
|
||||||
|
for (let i = 0; i < 6; i++) await Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("ButtonLightController truth table", () => {
|
||||||
|
it("OFF at start (no radar, no car)", async () => {
|
||||||
|
const calls: Array<{ ch: number; on: boolean }> = [];
|
||||||
|
const ctl = new ButtonLightController(db, silentLogger(), () => fakeAux(calls));
|
||||||
|
ctl.start();
|
||||||
|
await flush();
|
||||||
|
expect(ctl.stateOf(CONTROLLER)).toBe("off");
|
||||||
|
// confirmedOn starts null; OFF de-dupes (null !== false → one off write), so the
|
||||||
|
// device is confirmed OFF and at most one call was made.
|
||||||
|
expect(ctl.confirmedOf(CONTROLLER)).toBe(false);
|
||||||
|
ctl.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("radar present + lane busy -> SOLID on", async () => {
|
||||||
|
const calls: Array<{ ch: number; on: boolean }> = [];
|
||||||
|
const aux = fakeAux(calls);
|
||||||
|
const ctl = new ButtonLightController(db, silentLogger(), () => aux);
|
||||||
|
ctl.start();
|
||||||
|
await flush();
|
||||||
|
lane(true);
|
||||||
|
radar(true);
|
||||||
|
await flush();
|
||||||
|
expect(ctl.stateOf(CONTROLLER)).toBe("solid");
|
||||||
|
expect(ctl.confirmedOf(CONTROLLER)).toBe(true); // device latched ON
|
||||||
|
// Solid = no blinking: advancing time produces no further sends.
|
||||||
|
const n = calls.length;
|
||||||
|
vi.advanceTimersByTime(2000);
|
||||||
|
await flush();
|
||||||
|
expect(calls.length).toBe(n);
|
||||||
|
ctl.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("radar present + lane free -> BLINK (toggles the device over time)", async () => {
|
||||||
|
const calls: Array<{ ch: number; on: boolean }> = [];
|
||||||
|
const aux = fakeAux(calls);
|
||||||
|
const ctl = new ButtonLightController(db, silentLogger(), () => aux);
|
||||||
|
ctl.start();
|
||||||
|
await flush();
|
||||||
|
radar(true); // lane still free
|
||||||
|
await flush();
|
||||||
|
expect(ctl.stateOf(CONTROLLER)).toBe("blink");
|
||||||
|
expect(ctl.confirmedOf(CONTROLLER)).toBe(true); // on now
|
||||||
|
vi.advanceTimersByTime(500);
|
||||||
|
await flush();
|
||||||
|
expect(ctl.confirmedOf(CONTROLLER)).toBe(false); // toggled off
|
||||||
|
vi.advanceTimersByTime(500);
|
||||||
|
await flush();
|
||||||
|
expect(ctl.confirmedOf(CONTROLLER)).toBe(true); // toggled on
|
||||||
|
ctl.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blink -> solid when the camera confirms a car (lane busy)", async () => {
|
||||||
|
const calls: Array<{ ch: number; on: boolean }> = [];
|
||||||
|
const aux = fakeAux(calls);
|
||||||
|
const ctl = new ButtonLightController(db, silentLogger(), () => aux);
|
||||||
|
ctl.start();
|
||||||
|
await flush();
|
||||||
|
radar(true); // blink
|
||||||
|
await flush();
|
||||||
|
expect(ctl.stateOf(CONTROLLER)).toBe("blink");
|
||||||
|
lane(true); // camera confirms
|
||||||
|
await flush();
|
||||||
|
expect(ctl.stateOf(CONTROLLER)).toBe("solid");
|
||||||
|
expect(ctl.confirmedOf(CONTROLLER)).toBe(true);
|
||||||
|
// No more toggles (blink torn down) — the device stays ON over time.
|
||||||
|
vi.advanceTimersByTime(2000);
|
||||||
|
await flush();
|
||||||
|
expect(ctl.confirmedOf(CONTROLLER)).toBe(true);
|
||||||
|
ctl.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("radar clears -> OFF", async () => {
|
||||||
|
const calls: Array<{ ch: number; on: boolean }> = [];
|
||||||
|
const aux = fakeAux(calls);
|
||||||
|
const ctl = new ButtonLightController(db, silentLogger(), () => aux);
|
||||||
|
ctl.start();
|
||||||
|
await flush();
|
||||||
|
lane(true);
|
||||||
|
radar(true); // solid
|
||||||
|
await flush();
|
||||||
|
radar(false); // car gone
|
||||||
|
await flush();
|
||||||
|
expect(ctl.stateOf(CONTROLLER)).toBe("off");
|
||||||
|
expect(ctl.confirmedOf(CONTROLLER)).toBe(false); // device latched OFF
|
||||||
|
ctl.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("de-dupes redundant writes (no spam on repeat events)", async () => {
|
||||||
|
const calls: Array<{ ch: number; on: boolean }> = [];
|
||||||
|
const aux = fakeAux(calls);
|
||||||
|
const ctl = new ButtonLightController(db, silentLogger(), () => aux);
|
||||||
|
ctl.start();
|
||||||
|
await flush();
|
||||||
|
lane(true);
|
||||||
|
radar(true); // solid, on
|
||||||
|
await flush();
|
||||||
|
const n = calls.length;
|
||||||
|
radar(true); // same state — no new edge (present unchanged)
|
||||||
|
lane(true); // same lane — no change
|
||||||
|
await flush();
|
||||||
|
expect(calls.length).toBe(n);
|
||||||
|
ctl.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails OFF: a setAux error does not throw or escalate", async () => {
|
||||||
|
const calls: Array<{ ch: number; on: boolean }> = [];
|
||||||
|
const throwOnce = { v: true };
|
||||||
|
const aux = fakeAux(calls, throwOnce);
|
||||||
|
const ctl = new ButtonLightController(db, silentLogger(), () => aux);
|
||||||
|
// First write (initial off) throws — must be swallowed.
|
||||||
|
expect(() => ctl.start()).not.toThrow();
|
||||||
|
await flush();
|
||||||
|
// Subsequent writes work; driving to solid still converges to ON.
|
||||||
|
lane(true);
|
||||||
|
radar(true);
|
||||||
|
await flush();
|
||||||
|
expect(ctl.confirmedOf(CONTROLLER)).toBe(true);
|
||||||
|
ctl.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores controllers without an alert relay", () => {
|
||||||
|
// A second controller, no alert relay.
|
||||||
|
db.insert(devices).values({
|
||||||
|
id: "ctl-2",
|
||||||
|
category: "access",
|
||||||
|
driverId: "dingtian",
|
||||||
|
config: { host: "10.0.0.6", relays: [{ relay: 1, direction: "entry", presenceInput: 2 }] },
|
||||||
|
enabled: true,
|
||||||
|
}).run();
|
||||||
|
const calls: Array<{ ch: number; on: boolean }> = [];
|
||||||
|
const ctl = new ButtonLightController(db, silentLogger(), () => fakeAux(calls));
|
||||||
|
ctl.start();
|
||||||
|
expect(ctl.stateOf("ctl-2")).toBeNull();
|
||||||
|
ctl.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("picks up an alert relay ADDED after start() (no restart needed)", async () => {
|
||||||
|
// Fresh controller with a radar input but NO alert relay yet.
|
||||||
|
const calls: Array<{ ch: number; on: boolean }> = [];
|
||||||
|
const aux = fakeAux(calls);
|
||||||
|
const ctl = new ButtonLightController(db, silentLogger(), () => aux);
|
||||||
|
// Replace the seeded controller with one that has the radar but no lamp.
|
||||||
|
db.update(devices)
|
||||||
|
.set({
|
||||||
|
config: {
|
||||||
|
host: "10.0.0.5",
|
||||||
|
relays: [{ relay: 1, direction: "entry", presenceInput: RADAR_INPUT, presenceKind: "radar" }],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.where(eq(devices.id, CONTROLLER))
|
||||||
|
.run();
|
||||||
|
ctl.start();
|
||||||
|
await flush();
|
||||||
|
// No lamp configured → an input does nothing.
|
||||||
|
radar(true);
|
||||||
|
await flush();
|
||||||
|
expect(ctl.stateOf(CONTROLLER)).toBeNull();
|
||||||
|
expect(calls.length).toBe(0);
|
||||||
|
radar(false);
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
// Admin saves an alert relay (relay 3, trigger I2) — without restarting the server.
|
||||||
|
db.update(devices)
|
||||||
|
.set({
|
||||||
|
config: {
|
||||||
|
host: "10.0.0.5",
|
||||||
|
relays: [
|
||||||
|
{ relay: 1, direction: "entry", presenceInput: RADAR_INPUT, presenceKind: "radar" },
|
||||||
|
{ relay: LAMP_RELAY, direction: "radarAlert", triggerInput: RADAR_INPUT, blinkOnMs: 500, blinkOffMs: 500 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.where(eq(devices.id, CONTROLLER))
|
||||||
|
.run();
|
||||||
|
|
||||||
|
// The very next radar edge reconciles + blinks (lane still free).
|
||||||
|
radar(true);
|
||||||
|
await flush();
|
||||||
|
expect(ctl.stateOf(CONTROLLER)).toBe("blink");
|
||||||
|
expect(ctl.confirmedOf(CONTROLLER)).toBe(true);
|
||||||
|
ctl.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drives two alert relays on one controller independently", async () => {
|
||||||
|
const R3 = 3;
|
||||||
|
const R4 = 4;
|
||||||
|
const I2 = 2;
|
||||||
|
const I3 = 3;
|
||||||
|
// Controller with two alert lamps, each on its own trigger input.
|
||||||
|
db.update(devices)
|
||||||
|
.set({
|
||||||
|
config: {
|
||||||
|
host: "10.0.0.5",
|
||||||
|
relays: [
|
||||||
|
{ relay: 1, direction: "entry", presenceInput: I2, presenceKind: "radar" },
|
||||||
|
{ relay: R3, direction: "radarAlert", triggerInput: I2, blinkOnMs: 500, blinkOffMs: 500 },
|
||||||
|
{ relay: R4, direction: "radarAlert", triggerInput: I3, blinkOnMs: 500, blinkOffMs: 500 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.where(eq(devices.id, CONTROLLER))
|
||||||
|
.run();
|
||||||
|
const calls: Array<{ ch: number; on: boolean }> = [];
|
||||||
|
const aux = fakeAux(calls);
|
||||||
|
const ctl = new ButtonLightController(db, silentLogger(), () => aux);
|
||||||
|
ctl.start();
|
||||||
|
await flush();
|
||||||
|
expect(ctl.stateOf(CONTROLLER, R3)).toBe("off");
|
||||||
|
expect(ctl.stateOf(CONTROLLER, R4)).toBe("off");
|
||||||
|
|
||||||
|
// I2 active → only R3 blinks; R4 stays off (different trigger).
|
||||||
|
deviceEvents.emitInput({ driverId: "dingtian", deviceId: CONTROLLER, input: I2, edge: "on", at: new Date().toISOString(), source: "poll" });
|
||||||
|
await flush();
|
||||||
|
expect(ctl.stateOf(CONTROLLER, R3)).toBe("blink");
|
||||||
|
expect(ctl.stateOf(CONTROLLER, R4)).toBe("off");
|
||||||
|
|
||||||
|
// I3 active → R4 blinks too, independently.
|
||||||
|
deviceEvents.emitInput({ driverId: "dingtian", deviceId: CONTROLLER, input: I3, edge: "on", at: new Date().toISOString(), source: "poll" });
|
||||||
|
await flush();
|
||||||
|
expect(ctl.stateOf(CONTROLLER, R3)).toBe("blink");
|
||||||
|
expect(ctl.stateOf(CONTROLLER, R4)).toBe("blink");
|
||||||
|
|
||||||
|
// Camera confirms a car → BOTH lock solid (lane-busy is site-wide).
|
||||||
|
lane(true);
|
||||||
|
await flush();
|
||||||
|
expect(ctl.stateOf(CONTROLLER, R3)).toBe("solid");
|
||||||
|
expect(ctl.stateOf(CONTROLLER, R4)).toBe("solid");
|
||||||
|
|
||||||
|
// I2 clears → R3 off, R4 still solid (its trigger still active).
|
||||||
|
deviceEvents.emitInput({ driverId: "dingtian", deviceId: CONTROLLER, input: I2, edge: "off", at: new Date().toISOString(), source: "poll" });
|
||||||
|
await flush();
|
||||||
|
expect(ctl.stateOf(CONTROLLER, R3)).toBe("off");
|
||||||
|
expect(ctl.stateOf(CONTROLLER, R4)).toBe("solid");
|
||||||
|
ctl.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("an EXIT alert lamp locks on the EXIT camera, not entry", async () => {
|
||||||
|
const R4 = 4;
|
||||||
|
const I5 = 5; // exit radar
|
||||||
|
db.update(devices)
|
||||||
|
.set({
|
||||||
|
config: {
|
||||||
|
host: "10.0.0.5",
|
||||||
|
relays: [
|
||||||
|
{ relay: 1, direction: "entry" },
|
||||||
|
{ relay: 2, direction: "exit" },
|
||||||
|
// Exit alert lamp: triggers on the exit radar, locks on the EXIT camera.
|
||||||
|
{ relay: R4, direction: "radarAlert", triggerInput: I5, lockLane: "exit", blinkOnMs: 500, blinkOffMs: 500 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.where(eq(devices.id, CONTROLLER))
|
||||||
|
.run();
|
||||||
|
const aux = fakeAux([]);
|
||||||
|
const ctl = new ButtonLightController(db, silentLogger(), () => aux);
|
||||||
|
ctl.start();
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
// Exit radar active → blink.
|
||||||
|
deviceEvents.emitInput({ driverId: "dingtian", deviceId: CONTROLLER, input: I5, edge: "on", at: new Date().toISOString(), source: "poll" });
|
||||||
|
await flush();
|
||||||
|
expect(ctl.stateOf(CONTROLLER, R4)).toBe("blink");
|
||||||
|
|
||||||
|
// ENTRY camera busy must NOT lock this exit lamp — it still blinks.
|
||||||
|
deviceEvents.emitLaneStatus({ entry: true, exit: false });
|
||||||
|
await flush();
|
||||||
|
expect(ctl.stateOf(CONTROLLER, R4)).toBe("blink");
|
||||||
|
|
||||||
|
// EXIT camera busy → SOLID.
|
||||||
|
deviceEvents.emitLaneStatus({ entry: true, exit: true });
|
||||||
|
await flush();
|
||||||
|
expect(ctl.stateOf(CONTROLLER, R4)).toBe("solid");
|
||||||
|
ctl.stop();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,317 @@
|
|||||||
|
import { eq, devices, type Db, type DeviceRow } from "@parking/db";
|
||||||
|
import type { FastifyBaseLogger } from "fastify";
|
||||||
|
import { hasAuxOutput, registry, type AuxOutputDevice } from "@parking/devices";
|
||||||
|
import { deviceEvents, type DeviceInputEvent, type LaneStatusEvent } from "./device-events.js";
|
||||||
|
import { alertRelaysOf, relayForPresence, type RelaySpec } from "./device-resolve.js";
|
||||||
|
|
||||||
|
// Alert (radarAlert) relays — non-barrier indicator lamps, e.g. the entry button's 12 V
|
||||||
|
// light. Each lamp is a `relays[]` row with event `radarAlert`, driven by ITS trigger
|
||||||
|
// input vs. the camera "car in zone" signal (the advisory lane-status). A disagreement
|
||||||
|
// indicator:
|
||||||
|
// trigger active + lane busy (camera confirms a car) → SOLID on
|
||||||
|
// trigger active + lane free (radar sees something, no car) → BLINK (~1 Hz)
|
||||||
|
// otherwise → OFF
|
||||||
|
// The lamp is a NON-barrier aux output (setAux latch), so holding/blinking it is fine
|
||||||
|
// — barrier-not-a-door applies only to barriers, which still only pulseOpen. The lamp
|
||||||
|
// FAILS OFF: any error / shutdown leaves it off, so a dead lamp is "no hint", never a
|
||||||
|
// misleading solid "go". A controller may have several alert relays (each its own row +
|
||||||
|
// trigger input), keyed independently. See wiki/concepts/button-light-indicator.md.
|
||||||
|
|
||||||
|
type LightState = "off" | "solid" | "blink";
|
||||||
|
|
||||||
|
const DEFAULT_BLINK_MS = 500;
|
||||||
|
|
||||||
|
/** Per-lamp live state for the alert rule (one per radarAlert relay). */
|
||||||
|
interface LampState {
|
||||||
|
/** The controller this lamp lives on (its deviceId) — for resolving the aux adapter. */
|
||||||
|
readonly controllerId: string;
|
||||||
|
/** Alert relay row (relay #, triggerInput, blink ms). Mutable: #reconcile updates it in
|
||||||
|
* place when the admin changes the alert config without a restart. */
|
||||||
|
spec: RelaySpec;
|
||||||
|
/** Is the lamp's trigger input (the radar) currently active? */
|
||||||
|
present: boolean;
|
||||||
|
/** The high-level state we're rendering (to avoid restarting a running blink). */
|
||||||
|
rendered: LightState | null;
|
||||||
|
/** Active blink timer, if blinking. */
|
||||||
|
blink: ReturnType<typeof setInterval> | null;
|
||||||
|
/** Blink phase (true = currently on). */
|
||||||
|
blinkOn: boolean;
|
||||||
|
/** The output we WANT the relay to be in. The serialized worker drives the device
|
||||||
|
* toward this. The blink timer only flips this flag — it never sends directly. */
|
||||||
|
desiredOn: boolean;
|
||||||
|
/** The output we last CONFIRMED on the device (after a successful send). null = unknown. */
|
||||||
|
confirmedOn: boolean | null;
|
||||||
|
/** True while a send is in flight for this lamp — serializes UDP so on/off can't
|
||||||
|
* overlap or reorder (UDP is unordered; concurrent toggles left the relay stuck). */
|
||||||
|
sending: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolves a controller's live aux-output adapter. The default goes through the
|
||||||
|
* driver registry; tests inject a spy. Returns null when the controller has no
|
||||||
|
* aux-output capability (or won't build). */
|
||||||
|
export type AuxResolver = (controllerId: string) => AuxOutputDevice | null;
|
||||||
|
|
||||||
|
export class ButtonLightController {
|
||||||
|
readonly #db: Db;
|
||||||
|
readonly #logger: FastifyBaseLogger;
|
||||||
|
readonly #resolveAux: AuxResolver;
|
||||||
|
/** Per-lamp state, keyed by `${controllerId}:${relay}` (a controller may have several). */
|
||||||
|
readonly #lamps = new Map<string, LampState>();
|
||||||
|
/** Latest lane status — a camera-confirmed car in the entry / exit zone. A lamp locks
|
||||||
|
* SOLID off its OWN lane's camera (`spec.lockLane`), so an exit radar's lamp tracks the
|
||||||
|
* exit camera, not the entry one. */
|
||||||
|
#entryBusy = false;
|
||||||
|
#exitBusy = false;
|
||||||
|
/** Controllers we've already warned lack the aux-output capability (warn once). */
|
||||||
|
readonly #warned = new Set<string>();
|
||||||
|
#unsubInput: (() => void) | null = null;
|
||||||
|
#unsubLane: (() => void) | null = null;
|
||||||
|
|
||||||
|
constructor(db: Db, logger: FastifyBaseLogger, resolveAux?: AuxResolver) {
|
||||||
|
this.#db = db;
|
||||||
|
this.#logger = logger;
|
||||||
|
this.#resolveAux = resolveAux ?? ((id) => this.#auxFromRegistry(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Subscribe to radar input edges + lane status, and initialise every lamp OFF. */
|
||||||
|
start(): void {
|
||||||
|
this.#reconcile();
|
||||||
|
// All lamps start OFF (known-safe baseline) regardless of prior device state.
|
||||||
|
for (const lamp of this.#lamps.values()) this.#apply(lamp);
|
||||||
|
|
||||||
|
this.#unsubInput = deviceEvents.onInput((e) => this.#onInput(e));
|
||||||
|
this.#unsubLane = deviceEvents.onLaneStatus((s) => this.#onLane(s));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reconcile the lamp map with the CURRENT device config (the booth can add/change a
|
||||||
|
* button light without a server restart). Mirrors DeviceMonitor, which re-reads the
|
||||||
|
* device set each tick. Adds lamps for newly-configured controllers, updates the spec
|
||||||
|
* (relay #, blink ms) in place — preserving live `present`/blink state — and drops
|
||||||
|
* lamps whose controller lost its buttonLight or was disabled. Called at start() and
|
||||||
|
* before handling each event, so a just-saved lamp takes effect immediately. */
|
||||||
|
#reconcile(): void {
|
||||||
|
const rows = this.#db.select().from(devices).where(eq(devices.category, "access")).all();
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const row of rows) {
|
||||||
|
if (!row.enabled) continue;
|
||||||
|
for (const spec of alertRelaysOf(row)) {
|
||||||
|
const key = lampKey(row.id, spec.relay);
|
||||||
|
seen.add(key);
|
||||||
|
const existing = this.#lamps.get(key);
|
||||||
|
if (existing) {
|
||||||
|
existing.spec = spec; // pick up a changed trigger input / blink cadence
|
||||||
|
} else {
|
||||||
|
this.#lamps.set(key, {
|
||||||
|
controllerId: row.id,
|
||||||
|
spec,
|
||||||
|
present: false,
|
||||||
|
rendered: null,
|
||||||
|
blink: null,
|
||||||
|
blinkOn: false,
|
||||||
|
desiredOn: false,
|
||||||
|
confirmedOn: null,
|
||||||
|
sending: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Drop lamps whose controller no longer declares one (or was disabled/removed).
|
||||||
|
for (const [key, lamp] of this.#lamps) {
|
||||||
|
if (seen.has(key)) continue;
|
||||||
|
if (lamp.blink) {
|
||||||
|
clearInterval(lamp.blink);
|
||||||
|
lamp.blink = null;
|
||||||
|
}
|
||||||
|
this.#finalOff(lamp); // best-effort fail-OFF before forgetting it
|
||||||
|
this.#lamps.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A radar (presence) edge updates that controller's `present` flag. We resolve the
|
||||||
|
* edge the SAME way the entry flow does (relayForPresence on an entry/both relay),
|
||||||
|
* so the lamp and the one-car-one-ticket gate always agree on "a car is here". */
|
||||||
|
#onInput(e: DeviceInputEvent): void {
|
||||||
|
// Reconcile first so a lamp added/changed since boot (no restart) is picked up.
|
||||||
|
this.#reconcile();
|
||||||
|
const present = e.edge === "on";
|
||||||
|
for (const lamp of this.#lamps.values()) {
|
||||||
|
if (lamp.controllerId !== e.deviceId) continue;
|
||||||
|
// A lamp's trigger is its own `triggerInput`; if unset, fall back to the controller's
|
||||||
|
// entry-relay presence terminal (resolved the SAME way the entry flow does) so the
|
||||||
|
// lamp and the one-car-one-ticket gate always agree on "a car is here".
|
||||||
|
const trigger =
|
||||||
|
lamp.spec.triggerInput ?? relayForPresence(this.#db, e.deviceId, e.input)?.presenceInput;
|
||||||
|
if (trigger !== e.input) continue; // not this lamp's trigger terminal
|
||||||
|
if (present === lamp.present) continue;
|
||||||
|
lamp.present = present;
|
||||||
|
this.#apply(lamp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lane status changed: a camera-confirmed car in the entry and/or exit zone. */
|
||||||
|
#onLane(s: LaneStatusEvent): void {
|
||||||
|
if (s.entry === this.#entryBusy && s.exit === this.#exitBusy) return;
|
||||||
|
this.#entryBusy = s.entry;
|
||||||
|
this.#exitBusy = s.exit;
|
||||||
|
// Re-render every lamp (each picks its own lane's camera in #apply).
|
||||||
|
for (const lamp of this.#lamps.values()) this.#apply(lamp);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Compute + render the target state for one lamp. Drives are fire-and-forget (the
|
||||||
|
* timer/state machine is synchronous; the UDP write resolves on its own). */
|
||||||
|
#apply(lamp: LampState): void {
|
||||||
|
// SOLID only once THIS lamp's lane camera confirms a car (default entry).
|
||||||
|
const laneBusy = lamp.spec.lockLane === "exit" ? this.#exitBusy : this.#entryBusy;
|
||||||
|
const target: LightState = !lamp.present ? "off" : laneBusy ? "solid" : "blink";
|
||||||
|
if (target === lamp.rendered) return; // already rendering this state
|
||||||
|
|
||||||
|
// Tear down any running blink before switching states.
|
||||||
|
if (lamp.blink) {
|
||||||
|
clearInterval(lamp.blink);
|
||||||
|
lamp.blink = null;
|
||||||
|
}
|
||||||
|
lamp.rendered = target;
|
||||||
|
|
||||||
|
if (target === "off") {
|
||||||
|
lamp.desiredOn = false;
|
||||||
|
this.#pump(lamp);
|
||||||
|
} else if (target === "solid") {
|
||||||
|
lamp.desiredOn = true;
|
||||||
|
this.#pump(lamp);
|
||||||
|
} else {
|
||||||
|
// BLINK: a wall-clock timer flips ONLY the desired flag; #pump does the actual
|
||||||
|
// (serialized) UDP send. A symmetric cadence uses one interval; an asymmetric one
|
||||||
|
// re-arms each phase with its own duration. Sends never overlap or reorder, so the
|
||||||
|
// relay can't get stuck on a stale packet.
|
||||||
|
const onMs = lamp.spec.blinkOnMs && lamp.spec.blinkOnMs > 0 ? lamp.spec.blinkOnMs : DEFAULT_BLINK_MS;
|
||||||
|
const offMs = lamp.spec.blinkOffMs && lamp.spec.blinkOffMs > 0 ? lamp.spec.blinkOffMs : DEFAULT_BLINK_MS;
|
||||||
|
lamp.blinkOn = true;
|
||||||
|
lamp.desiredOn = true;
|
||||||
|
const tick = () => {
|
||||||
|
lamp.blinkOn = !lamp.blinkOn;
|
||||||
|
lamp.desiredOn = lamp.blinkOn;
|
||||||
|
this.#pump(lamp);
|
||||||
|
if (onMs !== offMs && lamp.blink) {
|
||||||
|
clearInterval(lamp.blink);
|
||||||
|
lamp.blink = setInterval(tick, lamp.blinkOn ? onMs : offMs);
|
||||||
|
lamp.blink.unref?.();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
lamp.blink = setInterval(tick, onMs);
|
||||||
|
lamp.blink.unref?.();
|
||||||
|
this.#pump(lamp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Serialized per-lamp worker: drive the relay toward `desiredOn`, one UDP send at a
|
||||||
|
* time. Because UDP is unordered, concurrent on/off sends previously raced and left
|
||||||
|
* the relay stuck on a stale packet. Here a single in-flight send is guaranteed
|
||||||
|
* (`sending` guard); when it resolves, if the desired state moved on we send again —
|
||||||
|
* so the LAST desired state is always the one finally asserted on the device. */
|
||||||
|
#pump(lamp: LampState): void {
|
||||||
|
if (lamp.sending) return; // a send is already in flight; it'll re-check on completion
|
||||||
|
if (lamp.confirmedOn === lamp.desiredOn) return; // already there — no redundant UDP
|
||||||
|
const aux = this.#resolveAux(lamp.controllerId);
|
||||||
|
if (!aux) return;
|
||||||
|
const target = lamp.desiredOn;
|
||||||
|
lamp.sending = true;
|
||||||
|
void aux
|
||||||
|
.setAux(lamp.spec.relay, target)
|
||||||
|
.then(() => {
|
||||||
|
lamp.confirmedOn = target;
|
||||||
|
})
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
// Leave confirmedOn unchanged so the next pump retries this state. Never escalates.
|
||||||
|
this.#logger.error(`button-light setAux failed (${lamp.controllerId} R${lamp.spec.relay}): ${(err as Error).message}`);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
lamp.sending = false;
|
||||||
|
// Desired state may have changed (or the send failed) while we were busy —
|
||||||
|
// re-pump to converge. This is what makes the final state authoritative.
|
||||||
|
if (lamp.confirmedOn !== lamp.desiredOn) this.#pump(lamp);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build the live aux-output adapter for a controller, or null (logged once). */
|
||||||
|
#auxFromRegistry(controllerId: string): AuxOutputDevice | null {
|
||||||
|
const row = this.#db.select().from(devices).where(eq(devices.id, controllerId)).get();
|
||||||
|
if (!row) return null;
|
||||||
|
const driver = registry.get(row.driverId);
|
||||||
|
if (!driver) return null;
|
||||||
|
let device: unknown;
|
||||||
|
try {
|
||||||
|
device = driver.create(row.config as never);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!hasAuxOutput(device)) {
|
||||||
|
if (!this.#warned.has(controllerId)) {
|
||||||
|
this.#warned.add(controllerId);
|
||||||
|
this.#logger.warn(`button-light: controller ${controllerId} (${row.driverId}) has no aux-output — lamp ignored`);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return device;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Unsubscribe, stop all blink timers, and best-effort drive every lamp OFF. */
|
||||||
|
stop(): void {
|
||||||
|
this.#unsubInput?.();
|
||||||
|
this.#unsubLane?.();
|
||||||
|
this.#unsubInput = null;
|
||||||
|
this.#unsubLane = null;
|
||||||
|
for (const lamp of this.#lamps.values()) {
|
||||||
|
if (lamp.blink) {
|
||||||
|
clearInterval(lamp.blink);
|
||||||
|
lamp.blink = null;
|
||||||
|
}
|
||||||
|
// Best-effort fail-OFF on shutdown.
|
||||||
|
this.#finalOff(lamp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drive a lamp OFF as a one-shot (used when dropping/stopping a lamp): set desired
|
||||||
|
* OFF and pump. The serialized worker still applies, so this can't collide with an
|
||||||
|
* in-flight send — it converges to OFF. */
|
||||||
|
#finalOff(lamp: LampState): void {
|
||||||
|
lamp.desiredOn = false;
|
||||||
|
this.#pump(lamp);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Test seam: current high-level state being rendered for a lamp (controller + relay).
|
||||||
|
* `relay` defaults to the controller's only/first alert relay for single-lamp tests. */
|
||||||
|
stateOf(controllerId: string, relay?: number): LightState | null {
|
||||||
|
return this.#lamp(controllerId, relay)?.rendered ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Test seam: the state last CONFIRMED on the device for a lamp (after a successful
|
||||||
|
* send). null = unknown / nothing sent yet. `relay` defaults to the only alert relay. */
|
||||||
|
confirmedOf(controllerId: string, relay?: number): boolean | null {
|
||||||
|
return this.#lamp(controllerId, relay)?.confirmedOn ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolve a lamp by controller + relay. When `relay` is omitted, returns the
|
||||||
|
* controller's single lamp (the common single-alert case); ambiguous if several. */
|
||||||
|
#lamp(controllerId: string, relay?: number): LampState | undefined {
|
||||||
|
if (relay != null) return this.#lamps.get(lampKey(controllerId, relay));
|
||||||
|
for (const lamp of this.#lamps.values()) if (lamp.controllerId === controllerId) return lamp;
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Composite key for the lamp map (a controller may carry several alert relays). */
|
||||||
|
function lampKey(controllerId: string, relay: number): string {
|
||||||
|
return `${controllerId}:${relay}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build a controller row's live aux device (exported for reuse/tests). */
|
||||||
|
export function buildAux(db: Db, row: DeviceRow): AuxOutputDevice | null {
|
||||||
|
const driver = registry.get(row.driverId);
|
||||||
|
if (!driver) return null;
|
||||||
|
try {
|
||||||
|
const device = driver.create(row.config as never);
|
||||||
|
return hasAuxOutput(device) ? device : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,7 +30,7 @@ export interface DeviceReadEvent {
|
|||||||
/**
|
/**
|
||||||
* The decision a read produced. Returned by the read flows so a SYNCHRONOUS reader
|
* The decision a read produced. Returned by the read flows so a SYNCHRONOUS reader
|
||||||
* (e.g. the QR reader, whose HTTP reply drives its beep + output) can answer the
|
* (e.g. the QR reader, whose HTTP reply drives its beep + output) can answer the
|
||||||
* device. A fire-and-forget reader simply ignores it. See wiki/entities/gee-qr-er80.md.
|
* device. A fire-and-forget reader simply ignores it. See wiki/entities/dingtian-dt008-reader.md.
|
||||||
*/
|
*/
|
||||||
export interface ReadOutcome {
|
export interface ReadOutcome {
|
||||||
/** Was the vehicle admitted/exited (barrier opened)? Drives the reader's beep. */
|
/** Was the vehicle admitted/exited (barrier opened)? Drives the reader's beep. */
|
||||||
@@ -86,6 +86,28 @@ export interface LaneStatusEvent {
|
|||||||
readonly exit: boolean; // true = busy (a vehicle is at the exit vicinity)
|
readonly exit: boolean; // true = busy (a vehicle is at the exit vicinity)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A plate was RECOGNIZED for a session AFTER its entry/exit event already shipped. Plate
|
||||||
|
* recognition is async/advisory (a vision round-trip off the snapshot), so it lands a
|
||||||
|
* moment after the signed event — too late for the event's own WS push to carry it. This
|
||||||
|
* notifies the booth so it can fill in the plate badge on the already-rendered feed row /
|
||||||
|
* active session in place, no refresh. Advisory; never touches the signed ledger. See
|
||||||
|
* snapshot.ts (recognizePlate) + event-enrich.ts. */
|
||||||
|
export interface PlateRecognizedEvent {
|
||||||
|
readonly identity: string; // the session identity the plate is tied to
|
||||||
|
readonly plate: string; // normalized plate text (trimmed, upper)
|
||||||
|
readonly direction: "entry" | "exit";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Per-lane RADAR presence — a vehicle-presence INPUT (loop/radar) is shorted at the
|
||||||
|
* entry/exit barrier, i.e. "something is in the lane vicinity" BEFORE the camera has
|
||||||
|
* confirmed a vehicle. Same signal that makes the physical button lamp (relay 3) blink:
|
||||||
|
* radar-present + camera-not-busy. Drives the booth's barrier light blink. Advisory only —
|
||||||
|
* it gates nothing. See wiki/concepts/button-light-indicator.md. */
|
||||||
|
export interface LanePresenceEvent {
|
||||||
|
readonly entry: boolean; // true = a presence input on an entry barrier is active
|
||||||
|
readonly exit: boolean; // true = a presence input on an exit barrier is active
|
||||||
|
}
|
||||||
|
|
||||||
class DeviceEventBus extends EventEmitter {
|
class DeviceEventBus extends EventEmitter {
|
||||||
emitInput(event: DeviceInputEvent): void {
|
emitInput(event: DeviceInputEvent): void {
|
||||||
this.emit("input", event);
|
this.emit("input", event);
|
||||||
@@ -148,6 +170,26 @@ class DeviceEventBus extends EventEmitter {
|
|||||||
this.on("lane-status", cb);
|
this.on("lane-status", cb);
|
||||||
return () => this.off("lane-status", cb);
|
return () => this.off("lane-status", cb);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Emitted whenever a lane's RADAR presence CHANGES (a presence input shorted/cleared
|
||||||
|
* at an entry/exit barrier). Drives the booth barrier light's blink. Advisory only. */
|
||||||
|
emitLanePresence(event: LanePresenceEvent): void {
|
||||||
|
this.emit("lane-presence", event);
|
||||||
|
}
|
||||||
|
onLanePresence(cb: (event: LanePresenceEvent) => void): () => void {
|
||||||
|
this.on("lane-presence", cb);
|
||||||
|
return () => this.off("lane-presence", cb);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Emitted when an async plate recognition completes for a session (after its event
|
||||||
|
* already shipped). Lets the booth backfill the plate badge in place. Advisory only. */
|
||||||
|
emitPlateRecognized(event: PlateRecognizedEvent): void {
|
||||||
|
this.emit("plate-recognized", event);
|
||||||
|
}
|
||||||
|
onPlateRecognized(cb: (event: PlateRecognizedEvent) => void): () => void {
|
||||||
|
this.on("plate-recognized", cb);
|
||||||
|
return () => this.off("plate-recognized", cb);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Process-wide device event bus. */
|
/** Process-wide device event bus. */
|
||||||
|
|||||||
@@ -39,7 +39,12 @@ function roleKindOf(db: Db, row: DeviceRow): DeviceStatusEvent["roleKind"] {
|
|||||||
return d;
|
return d;
|
||||||
}
|
}
|
||||||
case "access": {
|
case "access": {
|
||||||
const dirs = new Set(relaysOf(row).map((r) => r.direction));
|
// Only barrier relays carry a role direction; alert (radarAlert) relays don't.
|
||||||
|
const dirs = new Set(
|
||||||
|
relaysOf(row)
|
||||||
|
.map((r) => r.direction)
|
||||||
|
.filter((d): d is "entry" | "exit" | "both" => d !== "radarAlert"),
|
||||||
|
);
|
||||||
if (dirs.size === 0) return null;
|
if (dirs.size === 0) return null;
|
||||||
if (dirs.size > 1) return "mixed";
|
if (dirs.size > 1) return "mixed";
|
||||||
const only = [...dirs][0]; // entry | exit | both
|
const only = [...dirs][0]; // entry | exit | both
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { devices, type Db } from "@parking/db";
|
||||||
|
import { createTestDb } from "@parking/db/testing";
|
||||||
|
import { inputsOf, relayForButton, relayForPresence } from "./device-resolve.js";
|
||||||
|
|
||||||
|
// device-resolve: the input resolution layer. Inputs live in config.inputs[] (the first-class
|
||||||
|
// model); a pre-inputs[] controller is back-compat-synthesized from the legacy per-relay
|
||||||
|
// button/presenceInput fields. relayForButton/relayForPresence must resolve IDENTICALLY from
|
||||||
|
// either shape, so an exit radar = just another presence row.
|
||||||
|
|
||||||
|
let db: Db;
|
||||||
|
const CTL = "ctl-1";
|
||||||
|
|
||||||
|
function seed(config: Record<string, unknown>): void {
|
||||||
|
({ db } = createTestDb());
|
||||||
|
db.insert(devices).values({ id: CTL, category: "access", driverId: "dingtian", config, enabled: true }).run();
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("inputsOf back-compat synth", () => {
|
||||||
|
it("synthesizes inputs[] from legacy relay button/presence fields", () => {
|
||||||
|
seed({
|
||||||
|
relays: [
|
||||||
|
{ relay: 1, direction: "entry", button: 1, presenceInput: 2, presenceKind: "radar", presenceActiveLow: true },
|
||||||
|
{ relay: 2, direction: "exit" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const row = db.select().from(devices).get()!;
|
||||||
|
const inputs = inputsOf(row);
|
||||||
|
expect(inputs).toEqual([
|
||||||
|
{ input: 1, role: "button", relay: 1, cooldownSec: undefined },
|
||||||
|
{ input: 2, role: "presence", relay: 1, kind: "radar", activeLow: true },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefers an explicit inputs[] over the legacy fields", () => {
|
||||||
|
seed({
|
||||||
|
relays: [{ relay: 1, direction: "entry", button: 9 /* legacy ignored */ }],
|
||||||
|
inputs: [{ input: 1, role: "button", relay: 1 }],
|
||||||
|
});
|
||||||
|
const row = db.select().from(devices).get()!;
|
||||||
|
expect(inputsOf(row)).toEqual([{ input: 1, role: "button", relay: 1 }]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("relayForButton / relayForPresence", () => {
|
||||||
|
it("resolves a button + presence from inputs[]", () => {
|
||||||
|
seed({
|
||||||
|
relays: [{ relay: 1, direction: "entry" }],
|
||||||
|
inputs: [
|
||||||
|
{ input: 1, role: "button", relay: 1 },
|
||||||
|
{ input: 2, role: "presence", relay: 1, kind: "radar" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const byBtn = relayForButton(db, CTL, 1);
|
||||||
|
expect(byBtn).toMatchObject({ relay: 1, direction: "entry", presenceInput: 2, presenceKind: "radar" });
|
||||||
|
const byPres = relayForPresence(db, CTL, 2);
|
||||||
|
expect(byPres).toMatchObject({ relay: 1, direction: "entry", presenceInput: 2 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves IDENTICALLY from the legacy shape (no inputs[])", () => {
|
||||||
|
seed({ relays: [{ relay: 1, direction: "entry", button: 1, presenceInput: 2, presenceKind: "loop" }] });
|
||||||
|
expect(relayForButton(db, CTL, 1)).toMatchObject({ relay: 1, presenceInput: 2, presenceKind: "loop" });
|
||||||
|
expect(relayForPresence(db, CTL, 2)).toMatchObject({ relay: 1, presenceInput: 2 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves an EXIT presence row to the exit relay (the exit radar)", () => {
|
||||||
|
seed({
|
||||||
|
relays: [
|
||||||
|
{ relay: 1, direction: "entry" },
|
||||||
|
{ relay: 2, direction: "exit" },
|
||||||
|
],
|
||||||
|
inputs: [
|
||||||
|
{ input: 2, role: "presence", relay: 1, kind: "radar" }, // entry radar
|
||||||
|
{ input: 5, role: "presence", relay: 2, kind: "radar" }, // exit radar
|
||||||
|
],
|
||||||
|
});
|
||||||
|
// NOTE: relayForPresence only gates entry/both relays (transient entry). The exit radar
|
||||||
|
// resolves to null HERE (the exit barrier has no entry gate) — but it's still a valid
|
||||||
|
// inputs[] row the lamp can trigger on. The entry radar resolves to relay 1.
|
||||||
|
expect(relayForPresence(db, CTL, 2)).toMatchObject({ relay: 1 });
|
||||||
|
expect(relayForPresence(db, CTL, 5)).toBeNull(); // exit relay isn't a transient-entry gate
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a button on an exit-only relay is not a transient-entry trigger", () => {
|
||||||
|
seed({
|
||||||
|
relays: [{ relay: 2, direction: "exit" }],
|
||||||
|
inputs: [{ input: 1, role: "button", relay: 2 }],
|
||||||
|
});
|
||||||
|
expect(relayForButton(db, CTL, 1)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -10,35 +10,74 @@ export type Direction = "entry" | "exit" | "both";
|
|||||||
/** A concrete flow a credential/button drives (never "both"). */
|
/** A concrete flow a credential/button drives (never "both"). */
|
||||||
export type FlowDirection = "entry" | "exit";
|
export type FlowDirection = "entry" | "exit";
|
||||||
|
|
||||||
/** One relay on an access controller: which barrier it opens, in which direction,
|
/** The EVENT a relay reacts to. The barrier events (entry/exit/both) `pulseOpen`; the
|
||||||
* and (optionally) the input terminals its entry button + presence loop are wired to. */
|
* `radarAlert` event drives a non-barrier alert lamp (blink while the trigger input is
|
||||||
|
* active, locked SOLID by the camera). A relay is "when EVENT X happens, do its action" —
|
||||||
|
* the action is implied by the event. See wiki/concepts/button-light-indicator.md. */
|
||||||
|
export type RelayEvent = Direction | "radarAlert";
|
||||||
|
|
||||||
|
/** What a controller input terminal MEANS. `button` = a transient-entry button; `presence`
|
||||||
|
* = a one-car-one-ticket sensor (induction loop or radar); `alertTrigger` = the edge that
|
||||||
|
* starts a `radarAlert` lamp blinking. See wiki/concepts/entry-double-press.md. */
|
||||||
|
export type InputRole = "button" | "presence" | "alertTrigger";
|
||||||
|
|
||||||
|
/** One INPUT terminal the host reads, as a first-class citizen (the twin of RelaySpec).
|
||||||
|
* An exit radar is just another `presence` row serving the exit relay. */
|
||||||
|
export interface InputSpec {
|
||||||
|
/** 1-based input terminal the host reads. */
|
||||||
|
readonly input: number;
|
||||||
|
readonly role: InputRole;
|
||||||
|
/** The barrier relay this input serves. Required for `button`/`presence` (the gate is
|
||||||
|
* keyed per relay); optional for `alertTrigger` (a standalone lamp trigger). */
|
||||||
|
readonly relay?: number;
|
||||||
|
/** `presence` only — induction LOOP or RADAR. Label only (gate is identical). Default loop. */
|
||||||
|
readonly kind?: "loop" | "radar";
|
||||||
|
/** This terminal is ACTIVE-LOW (idles HIGH) — e.g. a radar wired opposite the button.
|
||||||
|
* Maps to the driver's per-input `inputActiveLow`. See wiki/entities/hikvision-radar.md. */
|
||||||
|
readonly activeLow?: boolean;
|
||||||
|
/** `button` only — presence-less fallback: suppress repeat presses for N seconds after a
|
||||||
|
* ticket. A timer (mitigation, not a guarantee); used when no `presence` row serves this relay. */
|
||||||
|
readonly cooldownSec?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One relay on an access controller: the event it reacts to. Input wiring (button,
|
||||||
|
* presence) lives in `config.inputs[]`; the LEGACY per-relay fields below are still read
|
||||||
|
* (back-compat) but no longer written by the UI. */
|
||||||
export interface RelaySpec {
|
export interface RelaySpec {
|
||||||
/** 1-based relay channel on the board (the driver's pulseOpen(doorId)). */
|
/** 1-based relay channel on the board (the driver's pulseOpen(doorId)). */
|
||||||
readonly relay: number;
|
readonly relay: number;
|
||||||
readonly direction: Direction;
|
/** The event this relay reacts to. entry/exit/both → pulse a barrier; `radarAlert` →
|
||||||
/** 1-based input terminal of the entry button that fires this relay (transient
|
* drive an alert lamp (blink + camera-lock) via `setAux`, NEVER pulseOpen. */
|
||||||
* entry). Absent = no button at this barrier (subscriber/reader-driven only). */
|
readonly direction: RelayEvent;
|
||||||
|
|
||||||
|
// ── LEGACY input fields (read-only back-compat; superseded by config.inputs[]) ──
|
||||||
|
// Pre-inputs[] configs wired the entry button + presence sensor here. `inputsOf()`
|
||||||
|
// synthesizes InputSpec rows from these when a controller has no `inputs[]` yet.
|
||||||
readonly button?: number;
|
readonly button?: number;
|
||||||
/**
|
|
||||||
* Anti-double-press for the transient entry button (one car must yield ONE ticket).
|
|
||||||
* Two modes, chosen by what barrier feedback exists at this lane:
|
|
||||||
* - PRESENCE (preferred, when a vehicle loop is wired): `presenceInput` = the
|
|
||||||
* 1-based input terminal of an induction loop / barrier presence signal on THIS
|
|
||||||
* controller. A press prints only while a car is present, and no second ticket
|
|
||||||
* issues until the loop CLEARS (car drove in) and a new car re-occupies it. This
|
|
||||||
* makes one-car-one-ticket physical.
|
|
||||||
* - COOLDOWN (fallback, no feedback): `entryCooldownSec` suppresses repeat presses
|
|
||||||
* on this relay for N seconds after a ticket prints. A pure timer — mitigation,
|
|
||||||
* not a guarantee. Used when `presenceInput` is unset (or as a secondary guard).
|
|
||||||
* Both absent = no guard (legacy behaviour). See wiki/concepts/entry-double-press.md.
|
|
||||||
*/
|
|
||||||
readonly presenceInput?: number;
|
readonly presenceInput?: number;
|
||||||
|
readonly presenceKind?: "loop" | "radar";
|
||||||
|
readonly presenceActiveLow?: boolean;
|
||||||
readonly entryCooldownSec?: number;
|
readonly entryCooldownSec?: number;
|
||||||
|
|
||||||
|
// ── radarAlert-only (direction === "radarAlert") ──
|
||||||
|
// A non-barrier indicator lamp wired to this (spare) relay — e.g. the entry button's
|
||||||
|
// 12 V light. Driven by the server ButtonLightController off its trigger input vs. the
|
||||||
|
// camera lane status: blink while the trigger is active + lane free, SOLID once the
|
||||||
|
// camera confirms a car, OFF otherwise. NOT a barrier (uses setAux, never pulseOpen).
|
||||||
|
/** 1-based input terminal whose active edge starts the blink (the radar). */
|
||||||
|
readonly triggerInput?: number;
|
||||||
|
/** Which lane's camera locks this lamp SOLID — the entry or the exit camera. Default
|
||||||
|
* "entry". An exit radar's lamp must lock on the EXIT camera. */
|
||||||
|
readonly lockLane?: FlowDirection;
|
||||||
|
/** Blink cadence (ms on / ms off) for the radar-only state. Default 500/500. */
|
||||||
|
readonly blinkOnMs?: number;
|
||||||
|
readonly blinkOffMs?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Access controller config (the `relays[]` map + connection fields). */
|
/** Access controller config (the `relays[]` + `inputs[]` maps + connection fields). */
|
||||||
interface AccessConfig {
|
interface AccessConfig {
|
||||||
readonly relays?: RelaySpec[];
|
readonly relays?: RelaySpec[];
|
||||||
|
readonly inputs?: InputSpec[];
|
||||||
readonly [k: string]: unknown;
|
readonly [k: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,9 +99,11 @@ export interface ResolvedRelay {
|
|||||||
readonly controller: DeviceRow;
|
readonly controller: DeviceRow;
|
||||||
readonly relay: number;
|
readonly relay: number;
|
||||||
readonly direction: Direction;
|
readonly direction: Direction;
|
||||||
/** 1-based presence-loop input gating this relay's entry (when wired). */
|
/** 1-based presence input gating this relay's entry (loop or radar, when wired). */
|
||||||
readonly presenceInput?: number;
|
readonly presenceInput?: number;
|
||||||
/** Cooldown seconds suppressing repeat presses (fallback when no presence loop). */
|
/** Sensor kind on the presence input (loop|radar) — telemetry/label only. */
|
||||||
|
readonly presenceKind?: "loop" | "radar";
|
||||||
|
/** Cooldown seconds suppressing repeat presses (fallback when no presence input). */
|
||||||
readonly entryCooldownSec?: number;
|
readonly entryCooldownSec?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,9 +124,49 @@ export function relaysOf(row: DeviceRow): RelaySpec[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve a button press to the relay it fires: the access controller with this
|
* The INPUT terminals declared on an access controller — the back-compat keystone. Returns
|
||||||
* deviceId, and the relay whose `button` terminal matches the pressed input. Only
|
* `config.inputs[]` when present; otherwise SYNTHESIZES InputSpec rows from the LEGACY
|
||||||
* an ENTRY (or both) relay is a transient-entry trigger. Returns null otherwise.
|
* per-relay fields (`relays[].button` → a `button` row; `relays[].presenceInput` → a
|
||||||
|
* `presence` row) so a pre-inputs[] controller resolves identically. Everything that reads
|
||||||
|
* inputs goes through here, so the legacy fold lives in exactly one place.
|
||||||
|
*/
|
||||||
|
export function inputsOf(row: DeviceRow): InputSpec[] {
|
||||||
|
const cfg = row.config as AccessConfig;
|
||||||
|
if (Array.isArray(cfg.inputs) && cfg.inputs.length > 0) return cfg.inputs;
|
||||||
|
const synth: InputSpec[] = [];
|
||||||
|
for (const r of relaysOf(row)) {
|
||||||
|
if (typeof r.button === "number") {
|
||||||
|
synth.push({ input: r.button, role: "button", relay: r.relay, cooldownSec: r.entryCooldownSec });
|
||||||
|
}
|
||||||
|
if (typeof r.presenceInput === "number") {
|
||||||
|
synth.push({
|
||||||
|
input: r.presenceInput,
|
||||||
|
role: "presence",
|
||||||
|
relay: r.relay,
|
||||||
|
kind: r.presenceKind ?? "loop",
|
||||||
|
activeLow: r.presenceActiveLow,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return synth;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The barrier RelaySpec a `button`/`presence` input row serves (its `relay`), or null —
|
||||||
|
* only entry/both relays gate transient entry. Narrows `direction` to a barrier Direction. */
|
||||||
|
function barrierForInput(row: DeviceRow, spec: InputSpec): (RelaySpec & { direction: Direction }) | null {
|
||||||
|
if (typeof spec.relay !== "number") return null;
|
||||||
|
const relay = relaysOf(row).find((r) => r.relay === spec.relay);
|
||||||
|
if (!relay) return null;
|
||||||
|
if (relay.direction !== "entry" && relay.direction !== "both") return null;
|
||||||
|
return { ...relay, direction: relay.direction };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a button press to the relay it fires: the access controller with this deviceId,
|
||||||
|
* and the relay served by the `button` input on this terminal (via inputsOf). Only an
|
||||||
|
* ENTRY (or both) relay is a transient-entry trigger. Carries the one-car-one-ticket
|
||||||
|
* config (presence input + cooldown) for that relay so the entry flow can enforce it.
|
||||||
|
* Returns null otherwise.
|
||||||
*/
|
*/
|
||||||
export function relayForButton(db: Db, controllerId: string, terminal: number): ResolvedRelay | null {
|
export function relayForButton(db: Db, controllerId: string, terminal: number): ResolvedRelay | null {
|
||||||
const row = db
|
const row = db
|
||||||
@@ -94,23 +175,28 @@ export function relayForButton(db: Db, controllerId: string, terminal: number):
|
|||||||
.where(and(eq(devices.id, controllerId), eq(devices.category, "access")))
|
.where(and(eq(devices.id, controllerId), eq(devices.category, "access")))
|
||||||
.get();
|
.get();
|
||||||
if (!row || !row.enabled) return null;
|
if (!row || !row.enabled) return null;
|
||||||
const spec = relaysOf(row).find((r) => r.button === terminal);
|
const inputs = inputsOf(row);
|
||||||
if (!spec) return null;
|
const btn = inputs.find((i) => i.role === "button" && i.input === terminal);
|
||||||
if (spec.direction !== "entry" && spec.direction !== "both") return null;
|
if (!btn) return null;
|
||||||
|
const relay = barrierForInput(row, btn);
|
||||||
|
if (!relay) return null;
|
||||||
|
// The presence sensor (if any) serving the SAME relay supplies the gate.
|
||||||
|
const presence = inputs.find((i) => i.role === "presence" && i.relay === relay.relay);
|
||||||
return {
|
return {
|
||||||
controller: row,
|
controller: row,
|
||||||
relay: spec.relay,
|
relay: relay.relay,
|
||||||
direction: spec.direction,
|
direction: relay.direction,
|
||||||
presenceInput: spec.presenceInput,
|
presenceInput: presence?.input,
|
||||||
entryCooldownSec: spec.entryCooldownSec,
|
presenceKind: presence?.kind ?? "loop",
|
||||||
|
entryCooldownSec: btn.cooldownSec,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve a PRESENCE-LOOP input edge to the entry relay it gates: the controller with
|
* Resolve a PRESENCE input edge to the entry relay it gates: the controller with this
|
||||||
* this deviceId, and the relay whose `presenceInput` terminal matches the fired input.
|
* deviceId, and the relay served by the `presence` input on this terminal. Lets the entry
|
||||||
* Lets the entry flow track "a car is physically at this entry barrier" so it issues
|
* flow track "a car is physically at this entry barrier" so it issues exactly one ticket
|
||||||
* exactly one ticket per car. Only entry/both relays gate transient entry. Null otherwise.
|
* per car. Only entry/both relays gate transient entry. Null otherwise.
|
||||||
*/
|
*/
|
||||||
export function relayForPresence(db: Db, controllerId: string, terminal: number): ResolvedRelay | null {
|
export function relayForPresence(db: Db, controllerId: string, terminal: number): ResolvedRelay | null {
|
||||||
const row = db
|
const row = db
|
||||||
@@ -119,10 +205,43 @@ export function relayForPresence(db: Db, controllerId: string, terminal: number)
|
|||||||
.where(and(eq(devices.id, controllerId), eq(devices.category, "access")))
|
.where(and(eq(devices.id, controllerId), eq(devices.category, "access")))
|
||||||
.get();
|
.get();
|
||||||
if (!row || !row.enabled) return null;
|
if (!row || !row.enabled) return null;
|
||||||
const spec = relaysOf(row).find((r) => r.presenceInput === terminal);
|
const presence = inputsOf(row).find((i) => i.role === "presence" && i.input === terminal);
|
||||||
if (!spec) return null;
|
if (!presence) return null;
|
||||||
if (spec.direction !== "entry" && spec.direction !== "both") return null;
|
const relay = barrierForInput(row, presence);
|
||||||
return { controller: row, relay: spec.relay, direction: spec.direction };
|
if (!relay) return null;
|
||||||
|
return {
|
||||||
|
controller: row,
|
||||||
|
relay: relay.relay,
|
||||||
|
direction: relay.direction,
|
||||||
|
presenceInput: presence.input,
|
||||||
|
presenceKind: presence.kind ?? "loop",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The alert (radarAlert) relay rows declared on an access controller — the lamps the
|
||||||
|
* ButtonLightController drives. Each is a `relays[]` row whose event is `radarAlert`. */
|
||||||
|
export function alertRelaysOf(row: DeviceRow): RelaySpec[] {
|
||||||
|
return relaysOf(row).filter((r) => r.direction === "radarAlert" && typeof r.relay === "number");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which LANE a presence input belongs to — for the booth's barrier-light blink (advisory).
|
||||||
|
* Unlike `relayForPresence` (entry-gated, for the one-car-one-ticket gate), this resolves a
|
||||||
|
* presence input on ANY barrier: entry/both → "entry", exit → "exit". Returns null if the
|
||||||
|
* terminal isn't a presence input on a barrier relay. See lane-presence.ts.
|
||||||
|
*/
|
||||||
|
export function presenceLaneOf(db: Db, controllerId: string, terminal: number): FlowDirection | null {
|
||||||
|
const row = db
|
||||||
|
.select()
|
||||||
|
.from(devices)
|
||||||
|
.where(and(eq(devices.id, controllerId), eq(devices.category, "access")))
|
||||||
|
.get();
|
||||||
|
if (!row || !row.enabled) return null;
|
||||||
|
const presence = inputsOf(row).find((i) => i.role === "presence" && i.input === terminal);
|
||||||
|
if (!presence || typeof presence.relay !== "number") return null;
|
||||||
|
const relay = relaysOf(row).find((r) => r.relay === presence.relay);
|
||||||
|
if (!relay) return null;
|
||||||
|
return relay.direction === "exit" ? "exit" : relay.direction === "radarAlert" ? null : "entry";
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -144,7 +263,10 @@ export function relayForDevice(db: Db, deviceRow: DeviceRow): ResolvedRelay | nu
|
|||||||
.get();
|
.get();
|
||||||
if (controller && controller.enabled) {
|
if (controller && controller.enabled) {
|
||||||
const spec = relaysOf(controller).find((r) => r.relay === cfg.relay);
|
const spec = relaysOf(controller).find((r) => r.relay === cfg.relay);
|
||||||
if (spec) return { controller, relay: spec.relay, direction: spec.direction };
|
// Only a barrier relay opens; an alert (radarAlert) relay is never a barrier.
|
||||||
|
if (spec && spec.direction !== "radarAlert") {
|
||||||
|
return { controller, relay: spec.relay, direction: spec.direction };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -164,7 +286,8 @@ export function relayForDevice(db: Db, deviceRow: DeviceRow): ResolvedRelay | nu
|
|||||||
export function firstRelayByDirection(db: Db, direction: FlowDirection): ResolvedRelay | null {
|
export function firstRelayByDirection(db: Db, direction: FlowDirection): ResolvedRelay | null {
|
||||||
for (const controller of accessRows(db)) {
|
for (const controller of accessRows(db)) {
|
||||||
const spec = relaysOf(controller).find(
|
const spec = relaysOf(controller).find(
|
||||||
(r) => r.direction === direction || r.direction === "both",
|
(r): r is RelaySpec & { direction: Direction } =>
|
||||||
|
r.direction === direction || r.direction === "both",
|
||||||
);
|
);
|
||||||
if (spec) return { controller, relay: spec.relay, direction: spec.direction };
|
if (spec) return { controller, relay: spec.relay, direction: spec.direction };
|
||||||
}
|
}
|
||||||
|
|||||||
+114
-17
@@ -15,7 +15,7 @@ import type { FastifyBaseLogger } from "fastify";
|
|||||||
import type { DeviceInputEvent } from "./device-events.js";
|
import type { DeviceInputEvent } from "./device-events.js";
|
||||||
import { getOccupancy } from "./occupancy.js";
|
import { getOccupancy } from "./occupancy.js";
|
||||||
import type { EventLog } from "./event-log.js";
|
import type { EventLog } from "./event-log.js";
|
||||||
import { devicesByDirection, relayForButton, relayForPresence, type ResolvedRelay } from "./device-resolve.js";
|
import { devicesByDirection, firstRelayByDirection, relayForButton, relayForPresence, type ResolvedRelay } from "./device-resolve.js";
|
||||||
import { snapshotAsync } from "./snapshot.js";
|
import { snapshotAsync } from "./snapshot.js";
|
||||||
import type { VisionClient } from "./vision-client.js";
|
import type { VisionClient } from "./vision-client.js";
|
||||||
|
|
||||||
@@ -229,9 +229,29 @@ export class EntryFlow {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await this.#issueTicket(resolved, { source: "ticket" });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The shared "issue a transient ticket" sequence used by BOTH the physical button
|
||||||
|
* (#runEntry) and the operator-initiated path (issueForOperator) — ONE copy of the
|
||||||
|
* fraud-critical ordering (print → sign vehicle_entry BEFORE open → open → snapshot →
|
||||||
|
* cache), never a divergent second copy. `opts.source` is "ticket" (button) or "booth"
|
||||||
|
* (operator). For an operator mint we stamp `operatorInitiated` + `operator` on the
|
||||||
|
* signed entry AND append a companion `anomaly` (the operator-adversary path always
|
||||||
|
* leaves a red-flag row); `overCapacity` records a full-lot override. Returns the
|
||||||
|
* outcome so the operator route can report it. See wiki/concepts/operator-issued-entry.md.
|
||||||
|
*/
|
||||||
|
async #issueTicket(
|
||||||
|
resolved: ResolvedRelay,
|
||||||
|
opts: { source: "ticket" | "manual"; operator?: string; overCapacity?: { count: number; capacity: number | null } },
|
||||||
|
): Promise<{ ok: true; ticketId: string; opened: boolean } | { ok: false; reason: string }> {
|
||||||
const ticketId = newTicketId();
|
const ticketId = newTicketId();
|
||||||
const issuedAt = new Date().toISOString();
|
const issuedAt = new Date().toISOString();
|
||||||
const printers = this.#loadPrinters();
|
const printers = this.#loadPrinters();
|
||||||
|
// Operator mint = ledger source "manual" (human intervention, like the barrier re-open)
|
||||||
|
// + operatorInitiated:true in the payload. The button path is source "ticket".
|
||||||
|
const operatorInitiated = opts.source === "manual";
|
||||||
|
|
||||||
// 1. PRINT FIRST. The ticket is the transient's session key — no ticket, no entry.
|
// 1. PRINT FIRST. The ticket is the transient's session key — no ticket, no entry.
|
||||||
const ticket: TicketData = { ticketId, issuedAt, header: this.#ticketHeader() };
|
const ticket: TicketData = { ticketId, issuedAt, header: this.#ticketHeader() };
|
||||||
@@ -260,17 +280,14 @@ export class EntryFlow {
|
|||||||
// Capture who is held at the barrier (evidence for the operator handling the car).
|
// Capture who is held at the barrier (evidence for the operator handling the car).
|
||||||
this.#fireSnapshot("entry", ticketId);
|
this.#fireSnapshot("entry", ticketId);
|
||||||
this.#logger.warn(`entry HELD: ${reason} (barrier NOT opened)`);
|
this.#logger.warn(`entry HELD: ${reason} (barrier NOT opened)`);
|
||||||
return;
|
return { ok: false, reason };
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. SIGN the vehicle_entry — BEFORE the relay fires (the core invariant).
|
// 2. SIGN the vehicle_entry — BEFORE the relay fires (the core invariant).
|
||||||
// `category` is FROZEN here (in the signed payload) so the tariff prices and
|
// `category` is FROZEN here (in the signed payload) so the tariff prices and
|
||||||
// later reprices the same way at exit. Today every transient takes the SITE
|
// later reprices the same way at exit. Today every transient takes the SITE
|
||||||
// default category (operator policy, site_config.default_vehicle_category;
|
// default category (operator policy, site_config.default_vehicle_category;
|
||||||
// falls back to the shared DEFAULT_VEHICLE_CATEGORY). Per-relay capture (a
|
// falls back to the shared DEFAULT_VEHICLE_CATEGORY).
|
||||||
// "bus lane" relay, mirroring how direction is per-relay in device-resolve.ts)
|
|
||||||
// is the future seam — source it from `resolved` then. A V1/no-category tariff
|
|
||||||
// ignores it; only V2 category cards consult it.
|
|
||||||
const cfg = this.#db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
|
const cfg = this.#db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
|
||||||
const category =
|
const category =
|
||||||
cfg?.defaultVehicleCategory && cfg.defaultVehicleCategory.length > 0
|
cfg?.defaultVehicleCategory && cfg.defaultVehicleCategory.length > 0
|
||||||
@@ -279,33 +296,113 @@ export class EntryFlow {
|
|||||||
await this.#log.append({
|
await this.#log.append({
|
||||||
type: "vehicle_entry",
|
type: "vehicle_entry",
|
||||||
direction: "entry",
|
direction: "entry",
|
||||||
source: "ticket",
|
source: opts.source,
|
||||||
identity: ticketId,
|
identity: ticketId,
|
||||||
payload: { sessionRef: ticketId, ticketPrinted: true, category },
|
payload: {
|
||||||
|
sessionRef: ticketId,
|
||||||
|
ticketPrinted: true,
|
||||||
|
category,
|
||||||
|
...(operatorInitiated ? { operatorInitiated: true, operator: opts.operator } : {}),
|
||||||
|
...(opts.overCapacity ? { lotFull: true, occupancy: `${opts.overCapacity.count}/${opts.overCapacity.capacity ?? "∞"}` } : {}),
|
||||||
|
},
|
||||||
occurredAt: issuedAt,
|
occurredAt: issuedAt,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 2b. For an operator mint, append a companion ANOMALY — the operator-adversary path
|
||||||
|
// always leaves a red-flag row in the tamper-evident record for reconciliation.
|
||||||
|
if (operatorInitiated) {
|
||||||
|
await this.#log.append({
|
||||||
|
type: "anomaly",
|
||||||
|
identity: ticketId,
|
||||||
|
payload: {
|
||||||
|
...reasonPayload("entry.operatorIssued", { operator: opts.operator ?? "?" }),
|
||||||
|
source: "booth",
|
||||||
|
operatorInitiated: true,
|
||||||
|
...(opts.operator ? { operator: opts.operator } : {}),
|
||||||
|
...(opts.overCapacity ? { lotFull: true } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// 3. OPEN the resolved entry barrier (intent only; the barrier owns the close).
|
// 3. OPEN the resolved entry barrier (intent only; the barrier owns the close).
|
||||||
const access = this.#buildAccess(resolved.controller);
|
const access = this.#buildAccess(resolved.controller);
|
||||||
if (access) await access.pulseOpen(resolved.relay);
|
let opened = false;
|
||||||
else this.#logger.warn(`entry signed for ${ticketId} but the entry relay won't build`);
|
if (access) {
|
||||||
|
await access.pulseOpen(resolved.relay);
|
||||||
|
opened = true;
|
||||||
|
} else this.#logger.warn(`entry signed for ${ticketId} but the entry relay won't build`);
|
||||||
|
|
||||||
// 3b. SNAPSHOT — fire the entry camera(s), never awaited (evidence, not a gate;
|
// 3b. SNAPSHOT — fire the entry camera(s), never awaited (evidence, not a gate; a
|
||||||
// a camera failure must not delay or block the already-open barrier).
|
// camera failure must not delay or block the already-open barrier). This is ALSO
|
||||||
|
// what records the plate that plate-reconciliation reads at exit.
|
||||||
this.#fireSnapshot("entry", ticketId);
|
this.#fireSnapshot("entry", ticketId);
|
||||||
|
|
||||||
// 4. Update the session projection cache (rebuildable from the ledger; this is
|
// 4. Update the session projection cache (rebuildable from the ledger; a read-model).
|
||||||
// just a fast read-model, never the source of truth).
|
|
||||||
try {
|
try {
|
||||||
this.#db
|
this.#db
|
||||||
.insert(sessions)
|
.insert(sessions)
|
||||||
.values({ id: ticketId, identity: ticketId, source: "ticket", enteredAt: issuedAt, state: "open" })
|
.values({ id: ticketId, identity: ticketId, source: opts.source, enteredAt: issuedAt, state: "open" })
|
||||||
.run();
|
.run();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Cache miss is non-fatal — the ledger is authoritative and the projection
|
|
||||||
// can be rebuilt. Log it; don't fail the (already-open) entry.
|
|
||||||
this.#logger.error(`session-cache insert failed for ${ticketId}: ${(err as Error).message}`);
|
this.#logger.error(`session-cache insert failed for ${ticketId}: ${(err as Error).message}`);
|
||||||
}
|
}
|
||||||
|
return { ok: true, ticketId, opened };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OPERATOR-ISSUED entry (physical entry button broken). Gated exactly like the button:
|
||||||
|
* a REAL vehicle must be present at the entry — BOTH radar/loop presence AND camera
|
||||||
|
* confirmation. `cameraBusy` is the current LaneStatus.entry (passed by the route); loop
|
||||||
|
* presence is this flow's own per-relay guard state. If a site has no presence loop the
|
||||||
|
* feature is unavailable (we require both — no weaker fallback). Refuses (+ signs an
|
||||||
|
* anomaly) when no vehicle is present, so probing the endpoint is itself recorded. Over
|
||||||
|
* capacity is ALLOWED but flagged (a broken button mustn't trap a legit car). The mint
|
||||||
|
* itself is flagged (source:"booth" + operatorInitiated + a companion anomaly).
|
||||||
|
* See wiki/concepts/operator-issued-entry.md.
|
||||||
|
*/
|
||||||
|
async issueForOperator(operator: string, cameraBusy: boolean): Promise<
|
||||||
|
{ ok: true; ticketId: string; opened: boolean; overCapacity: boolean } | { ok: false; reason: string }
|
||||||
|
> {
|
||||||
|
const resolved = firstRelayByDirection(this.#db, "entry");
|
||||||
|
if (!resolved) return { ok: false, reason: "no entry barrier configured" };
|
||||||
|
|
||||||
|
// PRESENCE GATE — require BOTH a presence loop (configured + currently occupied) AND
|
||||||
|
// the camera confirming a vehicle. No loop configured → feature unavailable here.
|
||||||
|
if (typeof resolved.presenceInput !== "number") {
|
||||||
|
return { ok: false, reason: "no presence loop on the entry barrier — operator issue unavailable" };
|
||||||
|
}
|
||||||
|
const present = this.#guardState(resolved).present;
|
||||||
|
if (!present || !cameraBusy) {
|
||||||
|
await this.#log.append({
|
||||||
|
type: "anomaly",
|
||||||
|
identity: `ENTRY-ATTEMPT-${randomUUID().replace(/-/g, "").slice(0, 12)}`,
|
||||||
|
payload: {
|
||||||
|
...reasonPayload("entry.issue.noPresence", { operator }),
|
||||||
|
source: "booth",
|
||||||
|
operator,
|
||||||
|
radarPresent: present,
|
||||||
|
cameraBusy,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
this.#logger.warn(`operator entry refused by ${operator}: no vehicle present (radar=${present}, camera=${cameraBusy})`);
|
||||||
|
return { ok: false, reason: "no vehicle detected at the entry" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = `operator-issue:${this.#relayKey(resolved)}`;
|
||||||
|
if (this.#inFlight.has(key)) return { ok: false, reason: "an entry is already in progress" };
|
||||||
|
this.#inFlight.add(key);
|
||||||
|
try {
|
||||||
|
const occ = getOccupancy(this.#db);
|
||||||
|
const res = await this.#issueTicket(resolved, {
|
||||||
|
source: "manual",
|
||||||
|
operator,
|
||||||
|
...(occ.full ? { overCapacity: { count: occ.count, capacity: occ.capacity ?? null } } : {}),
|
||||||
|
});
|
||||||
|
if (!res.ok) return res;
|
||||||
|
return { ok: true, ticketId: res.ticketId, opened: res.opened, overCapacity: occ.full };
|
||||||
|
} finally {
|
||||||
|
this.#inFlight.delete(key);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Fire the entry camera(s) for an identity; never awaited (evidence, not a gate).
|
/** Fire the entry camera(s) for an identity; never awaited (evidence, not a gate).
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
import { createTestDb } from "@parking/db/testing";
|
import { createTestDb } from "@parking/db/testing";
|
||||||
import { ledgerEvents, eq, type Db } from "@parking/db";
|
import { ledgerEvents, deviceEvents as deviceEventsTable, sessions as sessionsTable, eq, type Db } from "@parking/db";
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
import { ExitFlow } from "./exit-flow.js";
|
import { ExitFlow } from "./exit-flow.js";
|
||||||
import { PayStation } from "./pay-station.js";
|
import { PayStation } from "./pay-station.js";
|
||||||
import type { EventLog } from "./event-log.js";
|
import type { EventLog } from "./event-log.js";
|
||||||
@@ -33,6 +34,19 @@ async function enter(identity: string, enteredAt: string, payload?: Record<strin
|
|||||||
function exitsSigned(identity: string) {
|
function exitsSigned(identity: string) {
|
||||||
return db.select().from(ledgerEvents).where(eq(ledgerEvents.identity, identity)).all().filter((r) => r.type === "vehicle_exit");
|
return db.select().from(ledgerEvents).where(eq(ledgerEvents.identity, identity)).all().filter((r) => r.type === "vehicle_exit");
|
||||||
}
|
}
|
||||||
|
function anomalies(reason?: string) {
|
||||||
|
return db.select().from(ledgerEvents).where(eq(ledgerEvents.type, "anomaly")).all()
|
||||||
|
.filter((r) => !reason || (r.payload as { reason?: string } | null)?.reason?.includes(reason));
|
||||||
|
}
|
||||||
|
/** Seed the projection-cache open-session row + an ANPR plate read (device_events) so the
|
||||||
|
* plate-reconciliation check can see this identity's plate against open sessions. */
|
||||||
|
function seedOpenWithPlate(identity: string, plate: string, confidence: number, enteredAt: string) {
|
||||||
|
db.insert(sessionsTable).values({ id: identity, identity, source: "ticket", enteredAt, state: "open" }).run();
|
||||||
|
db.insert(deviceEventsTable).values({
|
||||||
|
id: randomUUID(), deviceId: "cam-entry", category: "camera", kind: "read", occurredAt: enteredAt,
|
||||||
|
detail: { identity, direction: "entry", plate, confidence },
|
||||||
|
}).run();
|
||||||
|
}
|
||||||
|
|
||||||
describe("exitForBooth — refusal gates", () => {
|
describe("exitForBooth — refusal gates", () => {
|
||||||
it("refuses an unknown ticket (no session) and signs an anomaly", async () => {
|
it("refuses an unknown ticket (no session) and signs an anomaly", async () => {
|
||||||
@@ -116,3 +130,63 @@ describe("reopenBarrier — no unpaid re-open", () => {
|
|||||||
expect(exitsSigned("T1")).toHaveLength(1);
|
expect(exitsSigned("T1")).toHaveLength(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("exitForBooth — plate-swap reconciliation (ticket-swap fraud)", () => {
|
||||||
|
// The fraud: a paid car is let out on a fresh $0 ticket while the original lingers "inside".
|
||||||
|
// The plate is the invariant — the exiting car's plate is already open under the old ticket.
|
||||||
|
it("HOLDS a paid exit when the plate is already open under a DIFFERENT ticket", async () => {
|
||||||
|
seedTariff(db, { pricePerIncrementMinor: 10000, gracePeriodExitMin: 15 });
|
||||||
|
// Original car entered on 1234, plate AA123BB, still open (never paid/exited).
|
||||||
|
await enter("1234", minutesAgo(120));
|
||||||
|
seedOpenWithPlate("1234", "AA123BB", 0.99, minutesAgo(120));
|
||||||
|
// A fresh ticket 1237 (same physical car, same plate) is paid and tries to exit.
|
||||||
|
await enter("1237", minutesAgo(1));
|
||||||
|
seedOpenWithPlate("1237", "AA123BB", 0.99, minutesAgo(1));
|
||||||
|
await pay.pay("1237", "cash");
|
||||||
|
|
||||||
|
const r = await exit.exitForBooth("1237");
|
||||||
|
expect(r).toMatchObject({ ok: false, status: "swap_suspected", plate: "AA123BB", otherIdentity: "1234" });
|
||||||
|
expect(exitsSigned("1237")).toHaveLength(0); // NOT let out
|
||||||
|
expect(anomalies("plate AA123BB is already inside").length).toBeGreaterThanOrEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("RELEASES on explicit operator override + signs an attributed override anomaly", async () => {
|
||||||
|
seedTariff(db, { pricePerIncrementMinor: 10000, gracePeriodExitMin: 15 });
|
||||||
|
await enter("1234", minutesAgo(120));
|
||||||
|
seedOpenWithPlate("1234", "AA123BB", 0.99, minutesAgo(120));
|
||||||
|
await enter("1237", minutesAgo(1));
|
||||||
|
seedOpenWithPlate("1237", "AA123BB", 0.99, minutesAgo(1));
|
||||||
|
await pay.pay("1237", "cash");
|
||||||
|
|
||||||
|
const r = await exit.exitForBooth("1237", { override: true, operator: "op1" });
|
||||||
|
expect(r.ok).toBe(true);
|
||||||
|
expect(exitsSigned("1237")).toHaveLength(1); // released
|
||||||
|
const ov = anomalies("released a suspected ticket-swap");
|
||||||
|
expect(ov.length).toBe(1);
|
||||||
|
expect((ov[0].payload as { operator?: string }).operator).toBe("op1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT warn on a LOW-confidence plate read (advisory, never a gate)", async () => {
|
||||||
|
seedTariff(db, { pricePerIncrementMinor: 10000, gracePeriodExitMin: 15 });
|
||||||
|
await enter("1234", minutesAgo(120));
|
||||||
|
seedOpenWithPlate("1234", "AA123BB", 0.5, minutesAgo(120)); // low conf
|
||||||
|
await enter("1237", minutesAgo(1));
|
||||||
|
seedOpenWithPlate("1237", "AA123BB", 0.5, minutesAgo(1)); // low conf
|
||||||
|
await pay.pay("1237", "cash");
|
||||||
|
|
||||||
|
const r = await exit.exitForBooth("1237");
|
||||||
|
expect(r.ok).toBe(true); // no warning — exits normally
|
||||||
|
expect(exitsSigned("1237")).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT warn a normal exit whose OWN plate is only open under its OWN ticket", async () => {
|
||||||
|
seedTariff(db, { pricePerIncrementMinor: 10000, gracePeriodExitMin: 15 });
|
||||||
|
await enter("1237", minutesAgo(90));
|
||||||
|
seedOpenWithPlate("1237", "AA999ZZ", 0.99, minutesAgo(90));
|
||||||
|
await pay.pay("1237", "cash");
|
||||||
|
|
||||||
|
const r = await exit.exitForBooth("1237");
|
||||||
|
expect(r.ok).toBe(true); // its own plate under its own ticket is not a swap
|
||||||
|
expect(exitsSigned("1237")).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
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 { firstRelayByDirection, type ResolvedRelay } from "./device-resolve.js";
|
import { firstRelayByDirection, type ResolvedRelay } from "./device-resolve.js";
|
||||||
|
import { plateForIdentity, platesForIdentities } from "./plate-lookup.js";
|
||||||
import { snapshotAsync } from "./snapshot.js";
|
import { snapshotAsync } from "./snapshot.js";
|
||||||
import type { VisionClient } from "./vision-client.js";
|
import type { VisionClient } from "./vision-client.js";
|
||||||
import { computeFee, reasonPayload, renderReasonEn, type LedgerPayload, type TariffStructure } from "@parking/shared";
|
import { computeFee, reasonPayload, renderReasonEn, type LedgerPayload, type TariffStructure } from "@parking/shared";
|
||||||
@@ -47,6 +48,11 @@ interface SessionView {
|
|||||||
* the barrier didn't open (payment stands; operator opens manually). */
|
* the barrier didn't open (payment stands; operator opens manually). */
|
||||||
export type BoothExitResult =
|
export type BoothExitResult =
|
||||||
| { ok: false; status: "invalid" | "no_session" | "closed" | "unpaid" | "grace_expired"; reason: string }
|
| { ok: false; status: "invalid" | "no_session" | "closed" | "unpaid" | "grace_expired"; reason: string }
|
||||||
|
// PLATE-SWAP suspected: the exiting car's plate is already OPEN under a DIFFERENT ticket
|
||||||
|
// (possible ticket-swap fraud / mixed-up tickets). Not opened — the operator must review
|
||||||
|
// and either resolve the tickets or consciously OVERRIDE (re-submit with override:true).
|
||||||
|
// See wiki/concepts/plate-reconciliation.md.
|
||||||
|
| { ok: false; status: "swap_suspected"; reason: string; plate: string; otherIdentity: string; otherEnteredAt: string | null }
|
||||||
| { ok: true; opened: true }
|
| { ok: true; opened: true }
|
||||||
| { ok: true; opened: false; reason: string };
|
| { ok: true; opened: false; reason: string };
|
||||||
|
|
||||||
@@ -57,6 +63,11 @@ export type BoothReopenResult =
|
|||||||
| { ok: false; reason: string }
|
| { ok: false; reason: string }
|
||||||
| { ok: true; opened: boolean; reason?: string };
|
| { ok: true; opened: boolean; reason?: string };
|
||||||
|
|
||||||
|
/** Minimum ANPR confidence for a plate to participate in swap reconciliation, both for the
|
||||||
|
* exiting read and the matched open session's entry read. Below this, the read is advisory-
|
||||||
|
* only and never triggers a swap warning (a fuzzy read must not block a legit car). */
|
||||||
|
const PLATE_MATCH_MIN_CONFIDENCE = 0.85;
|
||||||
|
|
||||||
export class ExitFlow {
|
export class ExitFlow {
|
||||||
readonly #db: Db;
|
readonly #db: Db;
|
||||||
readonly #log: EventLog;
|
readonly #log: EventLog;
|
||||||
@@ -88,7 +99,7 @@ export class ExitFlow {
|
|||||||
* (money was taken, the car is owed an exit) and an `anomaly` is appended so the
|
* (money was taken, the car is owed an exit) and an `anomaly` is appended so the
|
||||||
* operator opens manually. Payment is never rolled back.
|
* operator opens manually. Payment is never rolled back.
|
||||||
*/
|
*/
|
||||||
async exitForBooth(identity: string): Promise<BoothExitResult> {
|
async exitForBooth(identity: string, opts?: { override?: boolean; operator?: string }): Promise<BoothExitResult> {
|
||||||
const id = identity.trim();
|
const id = identity.trim();
|
||||||
if (!id) return { ok: false, status: "invalid", reason: "ticket id required" };
|
if (!id) return { ok: false, status: "invalid", reason: "ticket id required" };
|
||||||
|
|
||||||
@@ -122,6 +133,40 @@ export class ExitFlow {
|
|||||||
return { ok: false, status: paid ? "grace_expired" : "unpaid", reason: rp.reason };
|
return { ok: false, status: paid ? "grace_expired" : "unpaid", reason: rp.reason };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PLATE-SWAP CHECK — after the money/grace validation, before we sign the exit. If
|
||||||
|
// the plate is already open under a DIFFERENT ticket, HOLD for the operator to review
|
||||||
|
// (unless they consciously override). A denial here never traps the car — exit fails
|
||||||
|
// open and the operator can override; the anomaly is the control either way.
|
||||||
|
const swap = this.#reconcilePlateAtExit(id);
|
||||||
|
if (swap) {
|
||||||
|
if (!opts?.override) {
|
||||||
|
// Sign the SUSPICION even if the operator walks away (tamper-evident record).
|
||||||
|
const rp = reasonPayload("exit.plateSwapSuspected", { plate: swap.plate, otherIdentity: swap.otherIdentity });
|
||||||
|
await this.#log.append({
|
||||||
|
type: "anomaly",
|
||||||
|
identity: id,
|
||||||
|
payload: { ...rp, source: "booth", plateSwapSuspected: true, plate: swap.plate, otherIdentity: swap.otherIdentity },
|
||||||
|
});
|
||||||
|
this.#fireExitSnapshot(id);
|
||||||
|
this.#logger.warn(`booth exit HELD (${id}): plate ${swap.plate} already open under ${swap.otherIdentity}`);
|
||||||
|
return { ok: false, status: "swap_suspected", reason: rp.reason, plate: swap.plate, otherIdentity: swap.otherIdentity, otherEnteredAt: swap.otherEnteredAt };
|
||||||
|
}
|
||||||
|
// OVERRIDE: the operator consciously releases it. Sign the override (attributed).
|
||||||
|
await this.#log.append({
|
||||||
|
type: "anomaly",
|
||||||
|
identity: id,
|
||||||
|
payload: {
|
||||||
|
...reasonPayload("exit.plateSwapOverride", { operator: opts.operator ?? "?", plate: swap.plate, otherIdentity: swap.otherIdentity }),
|
||||||
|
source: "booth",
|
||||||
|
plateSwapOverride: true,
|
||||||
|
plate: swap.plate,
|
||||||
|
otherIdentity: swap.otherIdentity,
|
||||||
|
...(opts.operator ? { operator: opts.operator } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
this.#logger.warn(`booth exit OVERRIDE (${id}) by ${opts.operator ?? "?"}: plate-swap released (${swap.plate}, also open under ${swap.otherIdentity})`);
|
||||||
|
}
|
||||||
|
|
||||||
// Free entry-grace path: mint the $0 payment first (ledger invariant), as the
|
// Free entry-grace path: mint the $0 payment first (ledger invariant), as the
|
||||||
// reader path does.
|
// reader path does.
|
||||||
if (freeGrace && view.freeGrace) {
|
if (freeGrace && view.freeGrace) {
|
||||||
@@ -336,6 +381,25 @@ export class ExitFlow {
|
|||||||
return { accepted: false, direction: "exit", reason: rp.reason };
|
return { accepted: false, direction: "exit", reason: rp.reason };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PLATE-SWAP (reader path): detect + LOG, but FAIL OPEN. There's no operator at an
|
||||||
|
// automated lane to make the override decision, and exit fails open for safety, so we
|
||||||
|
// sign the suspicion anomaly (the control here) and still let the car out. The booth
|
||||||
|
// path (operator-mediated) is where the hold + override lives.
|
||||||
|
const swap = this.#reconcilePlateAtExit(e.value);
|
||||||
|
if (swap) {
|
||||||
|
await this.#log.append({
|
||||||
|
type: "anomaly",
|
||||||
|
identity: e.value,
|
||||||
|
payload: {
|
||||||
|
...reasonPayload("exit.plateSwapSuspected", { plate: swap.plate, otherIdentity: swap.otherIdentity }),
|
||||||
|
plateSwapSuspected: true,
|
||||||
|
plate: swap.plate,
|
||||||
|
otherIdentity: swap.otherIdentity,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
this.#logger.warn(`reader exit: plate ${swap.plate} already open under ${swap.otherIdentity} (${e.value}) — logged, fail-open`);
|
||||||
|
}
|
||||||
|
|
||||||
// Valid (a real payment within walk-back grace): sign + open.
|
// Valid (a real payment within walk-back grace): sign + open.
|
||||||
return this.#signExitAndOpen(resolved, e);
|
return this.#signExitAndOpen(resolved, e);
|
||||||
}
|
}
|
||||||
@@ -406,6 +470,43 @@ export class ExitFlow {
|
|||||||
this.#logger.error(`booth exit open failed (${identity}): ${detail}`);
|
this.#logger.error(`booth exit open failed (${identity}): ${detail}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PLATE-SWAP reconciliation. The car's PLATE is the invariant a ticket-swap can't hide:
|
||||||
|
* if this exiting ticket's plate is already OPEN under a DIFFERENT ticket, someone let a
|
||||||
|
* paid car out on a fresh $0 ticket while the original lingers "inside" (occupancy fraud),
|
||||||
|
* or two tickets were mixed up. We compare the EXITING plate against every open session's
|
||||||
|
* ENTRY plate, EXACT normalized match, HIGH-CONFIDENCE reads only (a fuzzy/absent read is
|
||||||
|
* advisory — never a gate, so it can't trap a legit car). Returns the matched open session
|
||||||
|
* or null. See wiki/concepts/plate-reconciliation.md.
|
||||||
|
*/
|
||||||
|
#reconcilePlateAtExit(exitingId: string): { plate: string; otherIdentity: string; otherEnteredAt: string | null } | null {
|
||||||
|
// The exiting car's plate: prefer its own exit read, else its entry read.
|
||||||
|
const mine = plateForIdentity(this.#db, exitingId);
|
||||||
|
if (!mine || !mine.plate || (mine.confidence ?? 0) < PLATE_MATCH_MIN_CONFIDENCE) return null;
|
||||||
|
const wanted = mine.plate.trim().toUpperCase();
|
||||||
|
|
||||||
|
// All currently-open sessions (from the projection cache — a fast read-model; the check
|
||||||
|
// is advisory so a slightly-stale cache is acceptable), excluding this ticket.
|
||||||
|
const openIds = this.#db
|
||||||
|
.select({ id: sessions.id })
|
||||||
|
.from(sessions)
|
||||||
|
.where(eq(sessions.state, "open"))
|
||||||
|
.all()
|
||||||
|
.map((r) => r.id)
|
||||||
|
.filter((id) => id !== exitingId);
|
||||||
|
if (openIds.length === 0) return null;
|
||||||
|
|
||||||
|
const plates = platesForIdentities(this.#db, openIds);
|
||||||
|
for (const [otherId, pv] of plates) {
|
||||||
|
if ((pv.confidence ?? 0) < PLATE_MATCH_MIN_CONFIDENCE) continue;
|
||||||
|
if (pv.plate.trim().toUpperCase() !== wanted) continue;
|
||||||
|
// A high-confidence exact match under a DIFFERENT open ticket → swap suspected.
|
||||||
|
const enteredAt = this.#db.select({ enteredAt: sessions.enteredAt }).from(sessions).where(eq(sessions.id, otherId)).get()?.enteredAt ?? null;
|
||||||
|
return { plate: wanted, otherIdentity: otherId, otherEnteredAt: enteredAt };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/** Fold the signed ledger into a session view for one identity (authoritative). */
|
/** Fold the signed ledger into a session view for one identity (authoritative). */
|
||||||
#sessionFor(identity: string): SessionView | null {
|
#sessionFor(identity: string): SessionView | null {
|
||||||
const rows = this.#db
|
const rows = this.#db
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import { beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { eq, devices, type Db } from "@parking/db";
|
||||||
|
import { createTestDb } from "@parking/db/testing";
|
||||||
|
import { LanePresence } from "./lane-presence.js";
|
||||||
|
import { deviceEvents, type DeviceInputEvent, type LanePresenceEvent } from "./device-events.js";
|
||||||
|
import { silentLogger } from "./test-helpers.js";
|
||||||
|
|
||||||
|
// LanePresence: a vehicle-presence INPUT edge (loop/radar) on an entry/exit barrier marks
|
||||||
|
// that lane "present" — the same signal that blinks the physical button lamp (relay 3). It
|
||||||
|
// resolves the edge via relayForPresence (the SAME path relay 3 + the entry gate use), and
|
||||||
|
// emits a lane-presence change only when a lane's present/clear state actually flips.
|
||||||
|
|
||||||
|
let db: Db;
|
||||||
|
const CTL = "ctl-1";
|
||||||
|
const ENTRY_RADAR = 2;
|
||||||
|
const EXIT_RADAR = 5;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
({ db } = createTestDb());
|
||||||
|
// Entry relay 1 with a radar on I2; exit relay 2 with a radar on I5.
|
||||||
|
db.insert(devices).values({
|
||||||
|
id: CTL,
|
||||||
|
category: "access",
|
||||||
|
driverId: "dingtian",
|
||||||
|
config: {
|
||||||
|
host: "10.0.0.5",
|
||||||
|
relays: [
|
||||||
|
{ relay: 1, direction: "entry" },
|
||||||
|
{ relay: 2, direction: "exit" },
|
||||||
|
],
|
||||||
|
inputs: [
|
||||||
|
{ input: ENTRY_RADAR, role: "presence", relay: 1, kind: "radar" },
|
||||||
|
{ input: EXIT_RADAR, role: "presence", relay: 2, kind: "radar" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
enabled: true,
|
||||||
|
}).run();
|
||||||
|
});
|
||||||
|
|
||||||
|
function edge(input: number, on: boolean): void {
|
||||||
|
const e: DeviceInputEvent = {
|
||||||
|
driverId: "dingtian",
|
||||||
|
deviceId: CTL,
|
||||||
|
input,
|
||||||
|
edge: on ? "on" : "off",
|
||||||
|
at: new Date().toISOString(),
|
||||||
|
source: "poll",
|
||||||
|
};
|
||||||
|
deviceEvents.emitInput(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Collect lane-presence emissions while running `fn`. */
|
||||||
|
function capture(fn: () => void): LanePresenceEvent[] {
|
||||||
|
const seen: LanePresenceEvent[] = [];
|
||||||
|
const off = deviceEvents.onLanePresence((p) => seen.push(p));
|
||||||
|
try {
|
||||||
|
fn();
|
||||||
|
} finally {
|
||||||
|
off();
|
||||||
|
}
|
||||||
|
return seen;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("LanePresence", () => {
|
||||||
|
it("starts clear and snapshots clear", () => {
|
||||||
|
const lp = new LanePresence(db, silentLogger());
|
||||||
|
lp.start();
|
||||||
|
expect(lp.snapshot()).toEqual({ entry: false, exit: false });
|
||||||
|
lp.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("an ENTRY radar edge marks the entry lane present, then clears", () => {
|
||||||
|
const lp = new LanePresence(db, silentLogger());
|
||||||
|
lp.start();
|
||||||
|
const events = capture(() => {
|
||||||
|
edge(ENTRY_RADAR, true);
|
||||||
|
edge(ENTRY_RADAR, false);
|
||||||
|
});
|
||||||
|
expect(events).toEqual([
|
||||||
|
{ entry: true, exit: false },
|
||||||
|
{ entry: false, exit: false },
|
||||||
|
]);
|
||||||
|
lp.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("an EXIT radar edge marks the exit lane independently", () => {
|
||||||
|
const lp = new LanePresence(db, silentLogger());
|
||||||
|
lp.start();
|
||||||
|
const events = capture(() => {
|
||||||
|
edge(EXIT_RADAR, true);
|
||||||
|
});
|
||||||
|
expect(events).toEqual([{ entry: false, exit: true }]);
|
||||||
|
expect(lp.snapshot()).toEqual({ entry: false, exit: true });
|
||||||
|
lp.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("de-dupes: a second 'on' from another presence input on the same lane emits once", () => {
|
||||||
|
// Two radars both serving the entry lane.
|
||||||
|
db.update(devices)
|
||||||
|
.set({
|
||||||
|
config: {
|
||||||
|
host: "10.0.0.5",
|
||||||
|
relays: [{ relay: 1, direction: "entry" }],
|
||||||
|
inputs: [
|
||||||
|
{ input: 2, role: "presence", relay: 1, kind: "radar" },
|
||||||
|
{ input: 3, role: "presence", relay: 1, kind: "radar" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.where(eq(devices.id, CTL))
|
||||||
|
.run();
|
||||||
|
const lp = new LanePresence(db, silentLogger());
|
||||||
|
lp.start();
|
||||||
|
const events = capture(() => {
|
||||||
|
edge(2, true); // entry → present (emit)
|
||||||
|
edge(3, true); // still present (no emit — same lane)
|
||||||
|
edge(2, false); // still present via I3 (no emit)
|
||||||
|
edge(3, false); // now clear (emit)
|
||||||
|
});
|
||||||
|
expect(events).toEqual([
|
||||||
|
{ entry: true, exit: false },
|
||||||
|
{ entry: false, exit: false },
|
||||||
|
]);
|
||||||
|
lp.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores a non-presence input (e.g. a button terminal)", () => {
|
||||||
|
db.update(devices)
|
||||||
|
.set({
|
||||||
|
config: {
|
||||||
|
host: "10.0.0.5",
|
||||||
|
relays: [{ relay: 1, direction: "entry" }],
|
||||||
|
inputs: [{ input: 1, role: "button", relay: 1 }],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.where(eq(devices.id, CTL))
|
||||||
|
.run();
|
||||||
|
const lp = new LanePresence(db, silentLogger());
|
||||||
|
lp.start();
|
||||||
|
const events = capture(() => edge(1, true));
|
||||||
|
expect(events).toEqual([]);
|
||||||
|
lp.stop();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import type { Db } from "@parking/db";
|
||||||
|
import type { FastifyBaseLogger } from "fastify";
|
||||||
|
import { deviceEvents, type DeviceInputEvent, type LanePresenceEvent } from "./device-events.js";
|
||||||
|
import { presenceLaneOf } from "./device-resolve.js";
|
||||||
|
|
||||||
|
// Per-lane RADAR presence for the booth's barrier lights. A vehicle-presence INPUT
|
||||||
|
// (loop/radar) shorted at an entry/exit barrier means "something is in the lane vicinity"
|
||||||
|
// BEFORE the camera confirms a vehicle. This is the SAME signal that makes the physical
|
||||||
|
// button lamp (relay 3) blink — see button-light.ts (#onInput) — so the on-screen light
|
||||||
|
// and the lamp stay in lockstep: both react to a presence edge resolved the SAME way
|
||||||
|
// (relayForPresence, on an entry/both relay). ADVISORY ONLY: it gates nothing.
|
||||||
|
//
|
||||||
|
// A radar serving an entry (or "both") barrier marks the ENTRY lane present; an exit radar
|
||||||
|
// marks EXIT. The lane is resolved via `presenceLaneOf` (direction-agnostic — unlike the
|
||||||
|
// entry-gated `relayForPresence` the one-car-one-ticket gate uses), so both lanes blink.
|
||||||
|
|
||||||
|
export class LanePresence {
|
||||||
|
readonly #db: Db;
|
||||||
|
readonly #logger: FastifyBaseLogger;
|
||||||
|
/** Active presence terminals per lane, keyed `${deviceId}:${input}` (several radars may
|
||||||
|
* serve one lane). A lane is "present" while its set is non-empty. */
|
||||||
|
readonly #entry = new Set<string>();
|
||||||
|
readonly #exit = new Set<string>();
|
||||||
|
#unsub: (() => void) | null = null;
|
||||||
|
|
||||||
|
constructor(db: Db, logger: FastifyBaseLogger) {
|
||||||
|
this.#db = db;
|
||||||
|
this.#logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Subscribe to presence input edges. */
|
||||||
|
start(): void {
|
||||||
|
this.#unsub = deviceEvents.onInput((e) => this.#onInput(e));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Current snapshot (for the WS hello). */
|
||||||
|
snapshot(): LanePresenceEvent {
|
||||||
|
return { entry: this.#entry.size > 0, exit: this.#exit.size > 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
#onInput(e: DeviceInputEvent): void {
|
||||||
|
const lane = presenceLaneOf(this.#db, e.deviceId, e.input);
|
||||||
|
if (!lane) return; // not a presence terminal on a barrier relay
|
||||||
|
const key = `${e.deviceId}:${e.input}`;
|
||||||
|
const set = lane === "entry" ? this.#entry : this.#exit;
|
||||||
|
const before = set.size > 0;
|
||||||
|
if (e.edge === "on") set.add(key);
|
||||||
|
else set.delete(key);
|
||||||
|
const after = set.size > 0;
|
||||||
|
if (before !== after) {
|
||||||
|
this.#logger.info(`lane-presence: ${lane} -> ${after ? "present" : "clear"}`);
|
||||||
|
deviceEvents.emitLanePresence(this.snapshot());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Unsubscribe on shutdown. */
|
||||||
|
stop(): void {
|
||||||
|
this.#unsub?.();
|
||||||
|
this.#unsub = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -98,6 +98,11 @@ export interface SessionLookup {
|
|||||||
/** Amount owed right now (the quote). Null when no session / no active tariff. */
|
/** Amount owed right now (the quote). Null when no session / no active tariff. */
|
||||||
readonly amountMinor: number | null;
|
readonly amountMinor: number | null;
|
||||||
readonly currency: string | null;
|
readonly currency: string | null;
|
||||||
|
/** Amount actually PAID (from the latest payment event), if any. Distinct from
|
||||||
|
* `amountMinor` (what's owed now): once a transient is settled `amountMinor` is null,
|
||||||
|
* but the operator still wants to see the sum that was collected. */
|
||||||
|
readonly paidMinor: number | null;
|
||||||
|
readonly paidCurrency: string | null;
|
||||||
/** True when paid AND still within the walk-back grace window. */
|
/** True when paid AND still within the walk-back grace window. */
|
||||||
readonly withinGrace: boolean;
|
readonly withinGrace: boolean;
|
||||||
/** ISO time the walk-back grace expires (paidAt + graceExitMin), if paid. */
|
/** ISO time the walk-back grace expires (paidAt + graceExitMin), if paid. */
|
||||||
@@ -274,7 +279,8 @@ export class PayStation {
|
|||||||
if (!entry) {
|
if (!entry) {
|
||||||
return {
|
return {
|
||||||
identity: id, found: false, open: false, enteredAt: null, exitedAt: null,
|
identity: id, found: false, open: false, enteredAt: null, exitedAt: null,
|
||||||
paidAt: null, amountMinor: null, currency: null, withinGrace: false, graceExpiresAt: null,
|
paidAt: null, amountMinor: null, currency: null, paidMinor: null, paidCurrency: null,
|
||||||
|
withinGrace: false, graceExpiresAt: null,
|
||||||
overstay: false, subscription: false, subscriptionId: null, subscriptionHolder: null, plate: null,
|
overstay: false, subscription: false, subscriptionId: null, subscriptionHolder: null, plate: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -289,11 +295,17 @@ export class PayStation {
|
|||||||
|
|
||||||
let paidAt: string | null = null;
|
let paidAt: string | null = null;
|
||||||
let graceExitMin: number | null = null;
|
let graceExitMin: number | null = null;
|
||||||
|
let paidMinor: number | null = null;
|
||||||
|
let paidCurrency: string | null = null;
|
||||||
for (const r of rows) {
|
for (const r of rows) {
|
||||||
if (r.type === "payment") {
|
if (r.type === "payment") {
|
||||||
paidAt = r.occurredAt;
|
paidAt = r.occurredAt;
|
||||||
const p = (r.payload ?? {}) as { graceExitMin?: number };
|
const p = (r.payload ?? {}) as { graceExitMin?: number; amountMinor?: number; currency?: string };
|
||||||
if (typeof p.graceExitMin === "number") graceExitMin = p.graceExitMin;
|
if (typeof p.graceExitMin === "number") graceExitMin = p.graceExitMin;
|
||||||
|
// Sum payments (overstay top-ups append a second one) so the displayed paid total
|
||||||
|
// reflects everything collected for the session, not just the last slip.
|
||||||
|
if (typeof p.amountMinor === "number") paidMinor = (paidMinor ?? 0) + p.amountMinor;
|
||||||
|
if (typeof p.currency === "string") paidCurrency = p.currency;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const graceExpiresAt =
|
const graceExpiresAt =
|
||||||
@@ -328,7 +340,7 @@ export class PayStation {
|
|||||||
return {
|
return {
|
||||||
identity: id, found: true, open,
|
identity: id, found: true, open,
|
||||||
enteredAt: entry.occurredAt, exitedAt: exitRow?.occurredAt ?? null,
|
enteredAt: entry.occurredAt, exitedAt: exitRow?.occurredAt ?? null,
|
||||||
paidAt, amountMinor, currency, withinGrace, graceExpiresAt, overstay,
|
paidAt, amountMinor, currency, paidMinor, paidCurrency, withinGrace, graceExpiresAt, overstay,
|
||||||
subscription: isSubscription, subscriptionId,
|
subscription: isSubscription, subscriptionId,
|
||||||
subscriptionHolder: this.#holderOf(subscriptionId),
|
subscriptionHolder: this.#holderOf(subscriptionId),
|
||||||
plate: plateForIdentity(this.#db, id)?.plate ?? null,
|
plate: plateForIdentity(this.#db, id)?.plate ?? null,
|
||||||
|
|||||||
@@ -29,6 +29,13 @@ interface ThemeBody {
|
|||||||
theme: Theme;
|
theme: Theme;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UI font scale: percent of base, clamped to [80, 160] in steps of 10. Integer percent.
|
||||||
|
const FONT_SCALE_MIN = 80;
|
||||||
|
const FONT_SCALE_MAX = 160;
|
||||||
|
interface FontScaleBody {
|
||||||
|
fontScale: number;
|
||||||
|
}
|
||||||
|
|
||||||
// Self-service profile: a signed-in user edits their OWN display name + email. This is
|
// Self-service profile: a signed-in user edits their OWN display name + email. This is
|
||||||
// NOT the admin user-management path (routes/users.ts) — it only ever touches the caller
|
// NOT the admin user-management path (routes/users.ts) — it only ever touches the caller
|
||||||
// (req.user.sub), needs no `user:*` permission, and can't change username, role, or any
|
// (req.user.sub), needs no `user:*` permission, and can't change username, role, or any
|
||||||
@@ -67,6 +74,7 @@ function sessionView(
|
|||||||
roleId: string;
|
roleId: string;
|
||||||
language: string;
|
language: string;
|
||||||
theme: string;
|
theme: string;
|
||||||
|
fontScale: number;
|
||||||
fullName?: string | null;
|
fullName?: string | null;
|
||||||
email?: string | null;
|
email?: string | null;
|
||||||
},
|
},
|
||||||
@@ -81,6 +89,7 @@ function sessionView(
|
|||||||
permissions,
|
permissions,
|
||||||
language: user.language,
|
language: user.language,
|
||||||
theme: user.theme,
|
theme: user.theme,
|
||||||
|
fontScale: user.fontScale,
|
||||||
fullName: user.fullName ?? null,
|
fullName: user.fullName ?? null,
|
||||||
email: user.email ?? null,
|
email: user.email ?? null,
|
||||||
};
|
};
|
||||||
@@ -171,6 +180,23 @@ export async function authRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Change MY own UI font scale (any signed-in user). Percent of base, clamped to
|
||||||
|
// [80, 160] in steps of 10. Persisted like `theme`, restored on the next login.
|
||||||
|
app.put<{ Body: FontScaleBody }>(
|
||||||
|
"/api/auth/font-scale",
|
||||||
|
{ preHandler: requireAuth },
|
||||||
|
async (req, reply) => {
|
||||||
|
const raw = req.body?.fontScale;
|
||||||
|
if (typeof raw !== "number" || !Number.isFinite(raw)) {
|
||||||
|
return reply.code(400).send({ error: "fontScale must be a number" });
|
||||||
|
}
|
||||||
|
// Snap to a 10-step and clamp to the allowed band (defensive — the UI already does).
|
||||||
|
const fontScale = Math.min(FONT_SCALE_MAX, Math.max(FONT_SCALE_MIN, Math.round(raw / 10) * 10));
|
||||||
|
await db.update(users).set({ fontScale }).where(eq(users.id, req.user.sub)).run();
|
||||||
|
return { fontScale };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// Edit MY own display name / email (any signed-in user; no permission needed — it only
|
// Edit MY own display name / email (any signed-in user; no permission needed — it only
|
||||||
// touches the caller). Cannot change username or role — those stay admin-only (users.ts).
|
// touches the caller). Cannot change username or role — those stay admin-only (users.ts).
|
||||||
app.put<{ Body: ProfileBody }>(
|
app.put<{ Body: ProfileBody }>(
|
||||||
|
|||||||
@@ -0,0 +1,191 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { createTestDb } from "@parking/db/testing";
|
||||||
|
import { type Db } from "@parking/db";
|
||||||
|
import type { FastifyInstance } from "fastify";
|
||||||
|
import { buildServer } from "../server.js";
|
||||||
|
import { seedUser, login } from "../test-helpers.js";
|
||||||
|
|
||||||
|
// HTTP integration for the backup routes — the security seam + the unconfigured-state
|
||||||
|
// behaviour. The booted test app has no BACKUP_TARGET_DIR/BACKUP_KEY, so the service is
|
||||||
|
// "not configured": status reports it, and a manual run is a clean 409 (not a 500).
|
||||||
|
// See wiki/concepts/backup-recovery.md.
|
||||||
|
|
||||||
|
let db: Db;
|
||||||
|
let close: () => void;
|
||||||
|
let app: FastifyInstance;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const t = createTestDb();
|
||||||
|
db = t.db;
|
||||||
|
close = t.close;
|
||||||
|
app = await buildServer({ db });
|
||||||
|
await app.ready();
|
||||||
|
});
|
||||||
|
afterEach(async () => {
|
||||||
|
await app.close();
|
||||||
|
close();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("GET /api/backup/status", () => {
|
||||||
|
it("401 without a session", async () => {
|
||||||
|
const res = await app.inject({ method: "GET", url: "/api/backup/status" });
|
||||||
|
expect(res.statusCode).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("403 for a user lacking backup:read", async () => {
|
||||||
|
const { username, password } = await seedUser(db, {
|
||||||
|
username: "viewer", roleId: "viewer", permissions: ["site:read"],
|
||||||
|
});
|
||||||
|
const { cookie } = await login(app, username, password);
|
||||||
|
const res = await app.inject({ method: "GET", url: "/api/backup/status", headers: { cookie } });
|
||||||
|
expect(res.statusCode).toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("an admin sees the (unconfigured) status shape", async () => {
|
||||||
|
const { username, password } = await seedUser(db, { username: "boss", roleId: "admin" });
|
||||||
|
const { cookie } = await login(app, username, password);
|
||||||
|
const res = await app.inject({ method: "GET", url: "/api/backup/status", headers: { cookie } });
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const body = res.json();
|
||||||
|
expect(body).toMatchObject({
|
||||||
|
configured: false,
|
||||||
|
targetDir: null,
|
||||||
|
keepLast: 7, // code defaults surfaced when unset
|
||||||
|
keepDailyDays: 30,
|
||||||
|
running: false,
|
||||||
|
lastSuccessAt: null,
|
||||||
|
lastError: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("PUT /api/backup/config — admin-chosen target", () => {
|
||||||
|
it("403 for a user lacking backup:update", async () => {
|
||||||
|
const { username, password } = await seedUser(db, {
|
||||||
|
username: "viewer", roleId: "viewer", permissions: ["backup:read"],
|
||||||
|
});
|
||||||
|
const { cookie, csrf } = await login(app, username, password);
|
||||||
|
const res = await app.inject({
|
||||||
|
method: "PUT", url: "/api/backup/config",
|
||||||
|
headers: { cookie, "x-csrf-token": csrf },
|
||||||
|
payload: { targetDir: "/tmp/x" },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("persists the target dir and reflects it in status", async () => {
|
||||||
|
const { username, password } = await seedUser(db, { username: "boss", roleId: "admin" });
|
||||||
|
const { cookie, csrf } = await login(app, username, password);
|
||||||
|
const put = await app.inject({
|
||||||
|
method: "PUT", url: "/api/backup/config",
|
||||||
|
headers: { cookie, "x-csrf-token": csrf },
|
||||||
|
payload: { targetDir: " /mnt/backup " }, // trimmed server-side
|
||||||
|
});
|
||||||
|
expect(put.statusCode).toBe(200);
|
||||||
|
expect(put.json()).toMatchObject({ targetDir: "/mnt/backup" });
|
||||||
|
|
||||||
|
const status = await app.inject({ method: "GET", url: "/api/backup/status", headers: { cookie } });
|
||||||
|
expect(status.json().targetDir).toBe("/mnt/backup");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears the target dir when given empty/null", async () => {
|
||||||
|
const { username, password } = await seedUser(db, { username: "boss", roleId: "admin" });
|
||||||
|
const { cookie, csrf } = await login(app, username, password);
|
||||||
|
await app.inject({
|
||||||
|
method: "PUT", url: "/api/backup/config",
|
||||||
|
headers: { cookie, "x-csrf-token": csrf }, payload: { targetDir: "/mnt/backup" },
|
||||||
|
});
|
||||||
|
const clear = await app.inject({
|
||||||
|
method: "PUT", url: "/api/backup/config",
|
||||||
|
headers: { cookie, "x-csrf-token": csrf }, payload: { targetDir: "" },
|
||||||
|
});
|
||||||
|
expect(clear.json().targetDir).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("persists retention and resets to defaults on null", async () => {
|
||||||
|
const { username, password } = await seedUser(db, { username: "boss", roleId: "admin" });
|
||||||
|
const { cookie, csrf } = await login(app, username, password);
|
||||||
|
|
||||||
|
const set = await app.inject({
|
||||||
|
method: "PUT", url: "/api/backup/config",
|
||||||
|
headers: { cookie, "x-csrf-token": csrf },
|
||||||
|
payload: { keepLast: 3, keepDailyDays: 14 },
|
||||||
|
});
|
||||||
|
expect(set.json()).toMatchObject({ keepLast: 3, keepDailyDays: 14 });
|
||||||
|
|
||||||
|
// null resets to the code default.
|
||||||
|
const reset = await app.inject({
|
||||||
|
method: "PUT", url: "/api/backup/config",
|
||||||
|
headers: { cookie, "x-csrf-token": csrf },
|
||||||
|
payload: { keepLast: null, keepDailyDays: null },
|
||||||
|
});
|
||||||
|
expect(reset.json()).toMatchObject({ keepLast: 7, keepDailyDays: 30 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a negative retention value (400)", async () => {
|
||||||
|
const { username, password } = await seedUser(db, { username: "boss", roleId: "admin" });
|
||||||
|
const { cookie, csrf } = await login(app, username, password);
|
||||||
|
const res = await app.inject({
|
||||||
|
method: "PUT", url: "/api/backup/config",
|
||||||
|
headers: { cookie, "x-csrf-token": csrf },
|
||||||
|
payload: { keepLast: -1 },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("POST /api/backup/test — path probe", () => {
|
||||||
|
it("reports ok for a writable directory and a reason for a missing one", async () => {
|
||||||
|
const { username, password } = await seedUser(db, { username: "boss", roleId: "admin" });
|
||||||
|
const { cookie, csrf } = await login(app, username, password);
|
||||||
|
|
||||||
|
const good = await app.inject({
|
||||||
|
method: "POST", url: "/api/backup/test",
|
||||||
|
headers: { cookie, "x-csrf-token": csrf },
|
||||||
|
payload: { targetDir: process.cwd() }, // an existing, writable dir
|
||||||
|
});
|
||||||
|
expect(good.json()).toMatchObject({ ok: true });
|
||||||
|
|
||||||
|
const bad = await app.inject({
|
||||||
|
method: "POST", url: "/api/backup/test",
|
||||||
|
headers: { cookie, "x-csrf-token": csrf },
|
||||||
|
payload: { targetDir: "/no/such/path/here-xyz" },
|
||||||
|
});
|
||||||
|
expect(bad.json()).toMatchObject({ ok: false, reason: "missing" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("POST /api/backup/run", () => {
|
||||||
|
it("403 for a user lacking backup:create", async () => {
|
||||||
|
const { username, password } = await seedUser(db, {
|
||||||
|
username: "viewer", roleId: "viewer", permissions: ["backup:read"], // read but not create
|
||||||
|
});
|
||||||
|
const { cookie, csrf } = await login(app, username, password);
|
||||||
|
const res = await app.inject({
|
||||||
|
method: "POST", url: "/api/backup/run",
|
||||||
|
headers: { cookie, "x-csrf-token": csrf },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires CSRF on the mutation", async () => {
|
||||||
|
const { username, password } = await seedUser(db, { username: "boss", roleId: "admin" });
|
||||||
|
const { cookie } = await login(app, username, password);
|
||||||
|
const res = await app.inject({
|
||||||
|
method: "POST", url: "/api/backup/run",
|
||||||
|
headers: { cookie }, // no csrf header
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 409 backup_not_configured when no target/key is set (not a 500)", async () => {
|
||||||
|
const { username, password } = await seedUser(db, { username: "boss", roleId: "admin" });
|
||||||
|
const { cookie, csrf } = await login(app, username, password);
|
||||||
|
const res = await app.inject({
|
||||||
|
method: "POST", url: "/api/backup/run",
|
||||||
|
headers: { cookie, "x-csrf-token": csrf },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(409);
|
||||||
|
expect(res.json()).toMatchObject({ error: "backup_not_configured" });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import type { FastifyInstance } from "fastify";
|
||||||
|
import { eq, siteConfig, type Db } from "@parking/db";
|
||||||
|
import { requirePermission } from "../auth.js";
|
||||||
|
import { checkTargetDir, type BackupService } from "../backup-service.js";
|
||||||
|
|
||||||
|
// On-site encrypted DB backup — admin-driven. See wiki/concepts/backup-recovery.md.
|
||||||
|
// - GET /api/backup/status : config + last-run success/error. (backup:read)
|
||||||
|
// - PUT /api/backup/config : set the admin-chosen target directory. (backup:update)
|
||||||
|
// - POST /api/backup/test : probe a candidate path (exists/dir/writable). (backup:update)
|
||||||
|
// - POST /api/backup/run : trigger a manual "back up now". (backup:create)
|
||||||
|
// The target dir lives in site_config (admin picks it from the UI); the encryption key stays an
|
||||||
|
// env secret. RESTORE is intentionally absent — out-of-band runbook on a fresh appliance.
|
||||||
|
|
||||||
|
interface ConfigBody {
|
||||||
|
targetDir?: string | null;
|
||||||
|
/** Retention: keep this many newest backups. null = reset to the code default. */
|
||||||
|
keepLast?: number | null;
|
||||||
|
/** Retention: keep one-per-day within this many days. null = reset to the code default. */
|
||||||
|
keepDailyDays?: number | null;
|
||||||
|
}
|
||||||
|
interface TestBody {
|
||||||
|
targetDir?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function backupRoutes(app: FastifyInstance, db: Db, backups: BackupService): Promise<void> {
|
||||||
|
app.get("/api/backup/status", { preHandler: requirePermission("backup:read") }, async () =>
|
||||||
|
backups.status(),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Set (or clear) the target directory. Empty/null clears it (backups become a no-op).
|
||||||
|
app.put<{ Body: ConfigBody }>(
|
||||||
|
"/api/backup/config",
|
||||||
|
{ preHandler: requirePermission("backup:update") },
|
||||||
|
async (req, reply) => {
|
||||||
|
const body = req.body ?? {};
|
||||||
|
const patch: { backupTargetDir?: string | null; backupKeepLast?: number | null; backupKeepDailyDays?: number | null } = {};
|
||||||
|
|
||||||
|
if ("targetDir" in body) {
|
||||||
|
const raw = body.targetDir;
|
||||||
|
if (raw != null && typeof raw !== "string") {
|
||||||
|
return reply.code(400).send({ error: "targetDir must be a string or null" });
|
||||||
|
}
|
||||||
|
patch.backupTargetDir = raw == null ? null : raw.trim() || null;
|
||||||
|
}
|
||||||
|
// Retention: a non-negative integer, or null to reset to the code default.
|
||||||
|
for (const [field, col] of [
|
||||||
|
["keepLast", "backupKeepLast"],
|
||||||
|
["keepDailyDays", "backupKeepDailyDays"],
|
||||||
|
] as const) {
|
||||||
|
if (field in body) {
|
||||||
|
const v = body[field];
|
||||||
|
if (v != null && (!Number.isInteger(v) || v < 0)) {
|
||||||
|
return reply.code(400).send({ error: `${field} must be a non-negative integer or null` });
|
||||||
|
}
|
||||||
|
patch[col] = v ?? null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedAt = new Date().toISOString();
|
||||||
|
// Single-row site_config (id=1): upsert, since a fresh install may not have it yet.
|
||||||
|
const existing = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
|
||||||
|
if (existing) {
|
||||||
|
db.update(siteConfig).set({ ...patch, updatedAt }).where(eq(siteConfig.id, 1)).run();
|
||||||
|
} else {
|
||||||
|
db.insert(siteConfig).values({ id: 1, ...patch, updatedAt }).run();
|
||||||
|
}
|
||||||
|
return backups.status();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Probe a candidate path before relying on it (the UI "Test target" button).
|
||||||
|
app.post<{ Body: TestBody }>(
|
||||||
|
"/api/backup/test",
|
||||||
|
{ preHandler: requirePermission("backup:update") },
|
||||||
|
async (req) => {
|
||||||
|
const dir = typeof req.body?.targetDir === "string" ? req.body.targetDir : "";
|
||||||
|
return checkTargetDir(dir);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
app.post("/api/backup/run", { preHandler: requirePermission("backup:create") }, async (_req, reply) => {
|
||||||
|
if (!backups.configured) {
|
||||||
|
return reply.code(409).send({ error: "backup_not_configured" });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await backups.run("manual");
|
||||||
|
return reply.send({ ok: true, path: res.path, bytes: res.bytes, prunedFiles: res.prunedFiles });
|
||||||
|
} catch (err) {
|
||||||
|
return reply.code(500).send({ error: "backup_failed", message: (err as Error).message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import type { FastifyInstance } from "fastify";
|
||||||
|
import { requirePermission, roleHasPermissions } from "../auth.js";
|
||||||
|
import { InvalidCashMovementError, type MovementStatus, type ShiftService } from "../shift-service.js";
|
||||||
|
|
||||||
|
// Drawer cash movements (manned mode). Redesigned 2026-07-01: an operator RECORDS a
|
||||||
|
// receipt/disbursement FREELY (no admin sign-off at creation); an admin REVIEWS it after
|
||||||
|
// the fact (authorize/deny — a flag that never moves cash). See wiki/concepts/shift.md.
|
||||||
|
// - POST /api/drawer/movement : operator records a cash_in/cash_out. (drawer:create)
|
||||||
|
// - GET /api/drawer/movements: list with review status. Operators see (shift:read)
|
||||||
|
// only their own; reviewers see all + can filter status.
|
||||||
|
// - POST /api/drawer/review : admin authorize/deny a movement. (drawer:review)
|
||||||
|
// The drawer BALANCE math is unchanged — a movement counts immediately; a denial is a
|
||||||
|
// judgment about the operator settled outside the app, never a cash reversal.
|
||||||
|
|
||||||
|
interface MovementBody {
|
||||||
|
/** Direction is the document TYPE, not a sign: cash_in = Mandat Arkëtimi (pay-IN),
|
||||||
|
* cash_out = Mandat Pagese (pay-OUT). */
|
||||||
|
type: "cash_in" | "cash_out";
|
||||||
|
/** POSITIVE minor units (magnitude). The direction comes from `type`. */
|
||||||
|
amountMinor: number;
|
||||||
|
reason?: string;
|
||||||
|
currency?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ReviewBody {
|
||||||
|
/** The cash_in/cash_out event id being decided on. */
|
||||||
|
refId: string;
|
||||||
|
decision: "authorize" | "deny";
|
||||||
|
/** Optional admin note (e.g. why denied). */
|
||||||
|
note?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MovementsQuery {
|
||||||
|
/** Reviewers only: filter to pending/authorized/denied. Ignored for non-reviewers. */
|
||||||
|
status?: MovementStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function drawerRoutes(app: FastifyInstance, shift: ShiftService): Promise<void> {
|
||||||
|
const createGuard = requirePermission("drawer:create");
|
||||||
|
const reviewGuard = requirePermission("drawer:review");
|
||||||
|
const readGuard = requirePermission("shift:read");
|
||||||
|
|
||||||
|
// Operator RECORDS a movement — freely, no authorizer. It counts in the drawer at once.
|
||||||
|
app.post<{ Body: MovementBody }>("/api/drawer/movement", { preHandler: createGuard }, async (req, reply) => {
|
||||||
|
const b = req.body ?? ({} as MovementBody);
|
||||||
|
if (b.type !== "cash_in" && b.type !== "cash_out") {
|
||||||
|
return reply.code(400).send({ error: "type must be cash_in or cash_out" });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return await shift.recordVoucher({
|
||||||
|
type: b.type,
|
||||||
|
operator: req.user.username,
|
||||||
|
amountMinor: b.amountMinor,
|
||||||
|
reason: b.reason ?? "",
|
||||||
|
currency: b.currency,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof InvalidCashMovementError) return reply.code(400).send({ error: err.message });
|
||||||
|
return reply.code(500).send({ error: (err as Error).message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// List movements + review status. Operators are hard-scoped to their OWN movements; a
|
||||||
|
// reviewer sees ALL and may filter by status (the pending review queue).
|
||||||
|
app.get<{ Querystring: MovementsQuery }>("/api/drawer/movements", { preHandler: readGuard }, async (req) => {
|
||||||
|
const canReview = roleHasPermissions(req.user.roleId, ["drawer:review"]);
|
||||||
|
const q = req.query ?? {};
|
||||||
|
const status = canReview && ["pending", "authorized", "denied"].includes(q.status ?? "") ? q.status : undefined;
|
||||||
|
const movements = shift.movementsWithStatus({
|
||||||
|
operator: canReview ? undefined : req.user.username,
|
||||||
|
status,
|
||||||
|
});
|
||||||
|
return { movements, scope: canReview ? "all" : "self" };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Admin AUTHORIZES or DENIES a recorded movement. A flag only — no cash reversal.
|
||||||
|
app.post<{ Body: ReviewBody }>("/api/drawer/review", { preHandler: reviewGuard }, async (req, reply) => {
|
||||||
|
const b = req.body ?? ({} as ReviewBody);
|
||||||
|
if (!b.refId || (b.decision !== "authorize" && b.decision !== "deny")) {
|
||||||
|
return reply.code(400).send({ error: "refId and decision (authorize|deny) are required" });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return await shift.reviewMovement({
|
||||||
|
refId: b.refId,
|
||||||
|
decision: b.decision,
|
||||||
|
reviewedBy: req.user.username,
|
||||||
|
note: b.note,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof InvalidCashMovementError) return reply.code(400).send({ error: err.message });
|
||||||
|
return reply.code(500).send({ error: (err as Error).message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import type { FastifyInstance } from "fastify";
|
||||||
|
import { requirePermission } from "../auth.js";
|
||||||
|
import type { EntryFlow } from "../entry-flow.js";
|
||||||
|
import type { LaneStatus } from "../lane-status.js";
|
||||||
|
import type { ShiftService } from "../shift-service.js";
|
||||||
|
import { NoShiftOpenError } from "../shift-service.js";
|
||||||
|
|
||||||
|
// Operator-issued entry (2026-07-01). When the physical entry button is broken, an operator
|
||||||
|
// may issue an entry ticket — a FLAGGED mint (vehicle_entry source=manual + operatorInitiated
|
||||||
|
// + a companion anomaly), gated EXACTLY like the physical button: a real vehicle must be
|
||||||
|
// present (radar/loop AND camera). The presence gate is enforced HERE (server-side), so a
|
||||||
|
// direct POST can't bypass a disabled UI button. Money-adjacent → requires an open shift.
|
||||||
|
// See wiki/concepts/operator-issued-entry.md.
|
||||||
|
|
||||||
|
export async function entryRoutes(
|
||||||
|
app: FastifyInstance,
|
||||||
|
entryFlow: EntryFlow,
|
||||||
|
laneStatus: LaneStatus,
|
||||||
|
shift: ShiftService,
|
||||||
|
): Promise<void> {
|
||||||
|
const guard = requirePermission("session:create");
|
||||||
|
|
||||||
|
app.post("/api/entry/issue", { preHandler: guard }, async (req, reply) => {
|
||||||
|
// Gate on an open shift (a minted entry belongs to an accountable operator).
|
||||||
|
if (!shift.currentOpenShift()) {
|
||||||
|
return reply.code(409).send({ error: new NoShiftOpenError().message });
|
||||||
|
}
|
||||||
|
// The camera side of the presence gate = the live entry lane-busy state; the radar/loop
|
||||||
|
// side is checked inside the flow (its per-relay presence guard).
|
||||||
|
const cameraBusy = laneStatus.snapshot().entry;
|
||||||
|
const res = await entryFlow.issueForOperator(req.user.username, cameraBusy);
|
||||||
|
if (!res.ok) return reply.code(409).send({ error: res.reason });
|
||||||
|
return res;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -30,6 +30,9 @@ interface PayBody {
|
|||||||
}
|
}
|
||||||
interface ExitBody {
|
interface ExitBody {
|
||||||
identity: string;
|
identity: string;
|
||||||
|
/** Operator consciously releases a suspected plate-swap exit (re-submit after the
|
||||||
|
* first call returned status "swap_suspected"). Signs an attributed override anomaly. */
|
||||||
|
override?: boolean;
|
||||||
}
|
}
|
||||||
interface VoucherBody {
|
interface VoucherBody {
|
||||||
identity: string;
|
identity: string;
|
||||||
@@ -109,8 +112,17 @@ export async function payRoutes(
|
|||||||
async (req, reply) => {
|
async (req, reply) => {
|
||||||
const identity = (req.body?.identity ?? "").trim();
|
const identity = (req.body?.identity ?? "").trim();
|
||||||
if (!identity) return reply.code(400).send({ error: "identity required" });
|
if (!identity) return reply.code(400).send({ error: "identity required" });
|
||||||
const res = await exitFlow.exitForBooth(identity);
|
const res = await exitFlow.exitForBooth(identity, {
|
||||||
if (!res.ok) return reply.code(409).send({ error: res.reason, status: res.status });
|
override: req.body?.override === true,
|
||||||
|
operator: req.user?.username,
|
||||||
|
});
|
||||||
|
// A suspected plate-swap returns the full detail so the modal can warn + offer override.
|
||||||
|
if (!res.ok) {
|
||||||
|
if (res.status === "swap_suspected") {
|
||||||
|
return reply.code(409).send({ error: res.reason, status: res.status, plate: res.plate, otherIdentity: res.otherIdentity, otherEnteredAt: res.otherEnteredAt });
|
||||||
|
}
|
||||||
|
return reply.code(409).send({ error: res.reason, status: res.status });
|
||||||
|
}
|
||||||
return reply.code(200).send(res);
|
return reply.code(200).send(res);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -128,3 +128,57 @@ describe("PUT /api/auth/password (self-service)", () => {
|
|||||||
expect(res.statusCode).toBe(400);
|
expect(res.statusCode).toBe(400);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("PUT /api/auth/font-scale (self-service)", () => {
|
||||||
|
it("persists a valid scale and returns it on the next session", async () => {
|
||||||
|
const { username, password } = await seedUser(db, { username: "f1", roleId: "viewer", permissions: [] });
|
||||||
|
const { cookie, csrf } = await login(app, username, password);
|
||||||
|
const res = await app.inject({
|
||||||
|
method: "PUT", url: "/api/auth/font-scale",
|
||||||
|
headers: { cookie, "x-csrf-token": csrf },
|
||||||
|
payload: { fontScale: 120 },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(res.json().fontScale).toBe(120);
|
||||||
|
// Persisted to the caller's row…
|
||||||
|
expect(db.select().from(users).where(eq(users.username, "f1")).get()?.fontScale).toBe(120);
|
||||||
|
// …and surfaced on /me (the session bootstrap).
|
||||||
|
const me = await app.inject({ method: "GET", url: "/api/auth/me", headers: { cookie } });
|
||||||
|
expect(me.json().fontScale).toBe(120);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clamps + snaps out-of-band / off-step values", async () => {
|
||||||
|
const { username, password } = await seedUser(db, { username: "f2", roleId: "viewer", permissions: [] });
|
||||||
|
const { cookie, csrf } = await login(app, username, password);
|
||||||
|
const tooBig = await app.inject({
|
||||||
|
method: "PUT", url: "/api/auth/font-scale",
|
||||||
|
headers: { cookie, "x-csrf-token": csrf },
|
||||||
|
payload: { fontScale: 999 },
|
||||||
|
});
|
||||||
|
expect(tooBig.json().fontScale).toBe(160); // clamped to max
|
||||||
|
const offStep = await app.inject({
|
||||||
|
method: "PUT", url: "/api/auth/font-scale",
|
||||||
|
headers: { cookie, "x-csrf-token": csrf },
|
||||||
|
payload: { fontScale: 113 },
|
||||||
|
});
|
||||||
|
expect(offStep.json().fontScale).toBe(110); // snapped to the 10-step
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a non-numeric scale (400)", async () => {
|
||||||
|
const { username, password } = await seedUser(db, { username: "f3", roleId: "viewer", permissions: [] });
|
||||||
|
const { cookie, csrf } = await login(app, username, password);
|
||||||
|
const res = await app.inject({
|
||||||
|
method: "PUT", url: "/api/auth/font-scale",
|
||||||
|
headers: { cookie, "x-csrf-token": csrf },
|
||||||
|
payload: { fontScale: "big" },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaults to 100 for a fresh user", async () => {
|
||||||
|
const { username, password } = await seedUser(db, { username: "f4", roleId: "viewer", permissions: [] });
|
||||||
|
const { cookie } = await login(app, username, password);
|
||||||
|
const me = await app.inject({ method: "GET", url: "/api/auth/me", headers: { cookie } });
|
||||||
|
expect(me.json().fontScale).toBe(100);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -4,10 +4,10 @@ import type { DeviceReadEvent } from "../device-events.js";
|
|||||||
import type { ReadDispatcher } from "../read-dispatch.js";
|
import type { ReadDispatcher } from "../read-dispatch.js";
|
||||||
import type { CredentialCapture } from "../credential-capture.js";
|
import type { CredentialCapture } from "../credential-capture.js";
|
||||||
|
|
||||||
// GEE/Dingtian QR reader endpoint. The reader is configured (vendor tool) with our
|
// Dingtian DT-008 QR/RFID reader endpoint. The reader is configured (vendor tool) with
|
||||||
// host as its "server"; on each scan it sends an HTTP GET and BEEPS/acts based on
|
// our host as its "server"; on each scan it sends an HTTP GET and BEEPS/acts based on
|
||||||
// our JSON reply — host-in-the-loop and synchronous. Protocol from the QRCode SDK
|
// our JSON reply — host-in-the-loop and synchronous. Protocol from the QRCode SDK
|
||||||
// v1.6.5; see wiki/sources/qrcode-sdk.md and wiki/entities/gee-qr-er80.md.
|
// v1.6.5; see wiki/sources/qrcode-sdk.md and wiki/entities/dingtian-dt008-reader.md.
|
||||||
//
|
//
|
||||||
// reader → GET /qa/mcardsea.php?cardid=<QR>&mjihao=<devId>&cjihao=<devSN>&status=<2ch>&time=<utc>
|
// reader → GET /qa/mcardsea.php?cardid=<QR>&mjihao=<devId>&cjihao=<devSN>&status=<2ch>&time=<utc>
|
||||||
// server → {"data":[{cardid,cjihao,mjihao,status,time,output}],"code":0,"message":""}
|
// server → {"data":[{cardid,cjihao,mjihao,status,time,output}],"code":0,"message":""}
|
||||||
@@ -35,7 +35,7 @@ export async function qrReaderRoutes(
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
// Resolve the lane_devices row whose config.serial matches the reader's reported
|
// Resolve the lane_devices row whose config.serial matches the reader's reported
|
||||||
// serial (cjihao). The row id is a normal UUID; the serial is config the admin
|
// serial (cjihao). The row id is a normal UUID; the serial is config the admin
|
||||||
// enters when assigning the gee-qr-reader. Returns the row id, or null if no
|
// enters when assigning the dingtian-qr-reader. Returns the row id, or null if no
|
||||||
// reader is assigned for that serial. (Small device set → scan in JS.)
|
// reader is assigned for that serial. (Small device set → scan in JS.)
|
||||||
const readerRowIdForSerial = (serial: string): string | null => {
|
const readerRowIdForSerial = (serial: string): string | null => {
|
||||||
if (!serial) return null;
|
if (!serial) return null;
|
||||||
@@ -52,7 +52,7 @@ export async function qrReaderRoutes(
|
|||||||
// drive output) once the socket CLOSES — every vendor demo replies
|
// drive output) once the socket CLOSES — every vendor demo replies
|
||||||
// `Connection: close` and shuts the socket. Without it the reader waits out a
|
// `Connection: close` and shuts the socket. Without it the reader waits out a
|
||||||
// ~10 s keep-alive timeout before beeping. So force-close the connection.
|
// ~10 s keep-alive timeout before beeping. So force-close the connection.
|
||||||
// See wiki/sources/qrcode-sdk.md, entities/gee-qr-er80.md.
|
// See wiki/sources/qrcode-sdk.md, entities/dingtian-dt008-reader.md.
|
||||||
reply.header("connection", "close");
|
reply.header("connection", "close");
|
||||||
const cardid = (q.cardid ?? "").trim();
|
const cardid = (q.cardid ?? "").trim();
|
||||||
const mjihao = q.mjihao != null ? Number(q.mjihao) : 0;
|
const mjihao = q.mjihao != null ? Number(q.mjihao) : 0;
|
||||||
@@ -75,7 +75,7 @@ export async function qrReaderRoutes(
|
|||||||
accepted = true; // beep "ok" so the operator knows the card was read
|
accepted = true; // beep "ok" so the operator knows the card was read
|
||||||
} else {
|
} else {
|
||||||
const read: DeviceReadEvent = {
|
const read: DeviceReadEvent = {
|
||||||
driverId: "gee-qr-reader",
|
driverId: "dingtian-qr-reader",
|
||||||
deviceId,
|
deviceId,
|
||||||
value: cardid,
|
value: cardid,
|
||||||
kind: "qr",
|
kind: "qr",
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { devices, type Db } from "@parking/db";
|
||||||
|
import { createTestDb } from "@parking/db/testing";
|
||||||
|
import { storedSecrets } from "./setup.js";
|
||||||
|
|
||||||
|
// storedSecrets re-merges a device's machine-only secrets (relayPassword/pushPassword)
|
||||||
|
// into a test/save — but ONLY when the submitted config addresses the SAME device at the
|
||||||
|
// SAME host/port. This guards against a redirected probe exfiltrating the secret to an
|
||||||
|
// attacker host (an admin keeps a real device id but swaps the host). The booth operator
|
||||||
|
// is the threat-model adversary, so an authenticated-admin redirect must NOT leak.
|
||||||
|
|
||||||
|
let db: Db;
|
||||||
|
const ID = "ctl-secret";
|
||||||
|
const HOST = "10.0.10.5";
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
({ db } = createTestDb());
|
||||||
|
db.insert(devices).values({
|
||||||
|
id: ID,
|
||||||
|
category: "access",
|
||||||
|
driverId: "dingtian",
|
||||||
|
config: { host: HOST, binaryPort: 60000, relayPassword: 1996, pushPassword: "p-secret" },
|
||||||
|
enabled: true,
|
||||||
|
}).run();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("storedSecrets identity guard", () => {
|
||||||
|
it("re-merges secrets when host/port/driver match the stored device", () => {
|
||||||
|
const out = storedSecrets(db, ID, "dingtian", { host: HOST, binaryPort: 60000 });
|
||||||
|
expect(out.relayPassword).toBe(1996);
|
||||||
|
expect(out.pushPassword).toBe("p-secret");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("re-merges when identity fields are OMITTED (fall back to the stored device)", () => {
|
||||||
|
const out = storedSecrets(db, ID, "dingtian", {});
|
||||||
|
expect(out.relayPassword).toBe(1996);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("REFUSES secrets when the host is redirected (exfiltration attempt)", () => {
|
||||||
|
const out = storedSecrets(db, ID, "dingtian", { host: "10.66.66.66", binaryPort: 60000 });
|
||||||
|
expect(out).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("REFUSES secrets when a control port is changed", () => {
|
||||||
|
const out = storedSecrets(db, ID, "dingtian", { host: HOST, binaryPort: 9999 });
|
||||||
|
expect(out).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("REFUSES secrets when the driver doesn't match the stored row", () => {
|
||||||
|
const out = storedSecrets(db, ID, "stub-access", { host: HOST });
|
||||||
|
expect(out).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns nothing for an unknown device id", () => {
|
||||||
|
expect(storedSecrets(db, randomUUID(), "dingtian", { host: HOST })).toEqual({});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
isCamera,
|
isCamera,
|
||||||
isDiscoverable,
|
isDiscoverable,
|
||||||
isHardenable,
|
isHardenable,
|
||||||
|
isPrinter,
|
||||||
registerBuiltinDrivers,
|
registerBuiltinDrivers,
|
||||||
registry,
|
registry,
|
||||||
setDeviceLogSink,
|
setDeviceLogSink,
|
||||||
@@ -36,6 +37,11 @@ interface AssignBody {
|
|||||||
interface TestBody {
|
interface TestBody {
|
||||||
driverId: string;
|
driverId: string;
|
||||||
config: Record<string, string | number | boolean>;
|
config: Record<string, string | number | boolean>;
|
||||||
|
/** When editing an EXISTING device, its id — so the test re-merges the stored
|
||||||
|
* machine secrets (relayPassword/pushPassword) the client never received. Without
|
||||||
|
* this, testing an edited device would send no relay password → the device ignores
|
||||||
|
* the probe → a false "offline". Omitted when testing a brand-new device. */
|
||||||
|
id?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Config keys that hold MACHINE-ONLY secrets — never sent back to the client.
|
// Config keys that hold MACHINE-ONLY secrets — never sent back to the client.
|
||||||
@@ -54,6 +60,41 @@ function redactSecrets(config: Record<string, unknown>): Record<string, unknown>
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Connection-identity keys: the fields that decide WHERE a probe is sent. A stored
|
||||||
|
// secret may only be re-merged when these match the stored row — otherwise an admin
|
||||||
|
// could point a test at an attacker host while keeping a real device id and have the
|
||||||
|
// secret sent there (exfiltration). host/port/binaryPort/httpPort cover the Dingtian's
|
||||||
|
// UDP + CGI targets; serial covers serial-bound readers.
|
||||||
|
const IDENTITY_KEYS = ["host", "port", "binaryPort", "httpPort", "serial"] as const;
|
||||||
|
|
||||||
|
/** Stored machine-only secrets (relayPassword/pushPassword) for a device `id`, but ONLY
|
||||||
|
* when the submitted config addresses the SAME device — same driver, and every
|
||||||
|
* connection-identity field (host/port/…) that the submitted config sets equals the
|
||||||
|
* stored value. If the admin redirected the probe (different host/port) or the driver
|
||||||
|
* doesn't match, NO secret is returned: they must re-enter it explicitly. This stops a
|
||||||
|
* redirected test from exfiltrating the secret to an attacker host. */
|
||||||
|
export function storedSecrets(
|
||||||
|
db: Db,
|
||||||
|
id: string,
|
||||||
|
driverId: string,
|
||||||
|
submitted: Record<string, unknown>,
|
||||||
|
): Record<string, unknown> {
|
||||||
|
const row = db.select().from(devices).where(eq(devices.id, id)).get();
|
||||||
|
if (!row || row.driverId !== driverId) return {};
|
||||||
|
const cfg = row.config as Record<string, unknown>;
|
||||||
|
// Any identity field the client SENT must equal the stored value. (A field the client
|
||||||
|
// omits falls back to the stored device, so it can't be used to redirect.)
|
||||||
|
for (const k of IDENTITY_KEYS) {
|
||||||
|
const sent = submitted[k];
|
||||||
|
if (sent !== undefined && sent !== "" && String(sent) !== String(cfg[k] ?? "")) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const out: Record<string, unknown> = {};
|
||||||
|
for (const k of SECRET_CONFIG_KEYS) if (cfg[k] !== undefined) out[k] = cfg[k];
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
/** Result of the device configure pipeline: a ready-to-persist config, or an
|
/** Result of the device configure pipeline: a ready-to-persist config, or an
|
||||||
* HTTP error to send back. Shared by assign (create) and patch (edit). */
|
* HTTP error to send back. Shared by assign (create) and patch (edit). */
|
||||||
type ConfigureOutcome =
|
type ConfigureOutcome =
|
||||||
@@ -249,13 +290,30 @@ export async function setupRoutes(
|
|||||||
"/api/setup/test",
|
"/api/setup/test",
|
||||||
{ preHandler: adminGuard },
|
{ preHandler: adminGuard },
|
||||||
async (req, reply) => {
|
async (req, reply) => {
|
||||||
const { driverId, config } = req.body;
|
const { driverId, config, id } = req.body;
|
||||||
const driver = registry.get(driverId);
|
const driver = registry.get(driverId);
|
||||||
if (!driver) return reply.code(400).send({ error: `unknown driver: ${driverId}` });
|
if (!driver) return reply.code(400).send({ error: `unknown driver: ${driverId}` });
|
||||||
|
|
||||||
|
// When editing an existing device, re-merge its stored machine secrets (e.g.
|
||||||
|
// relayPassword) — redacted from the client, so the submitted config omits them.
|
||||||
|
// Submitted values win (an admin can override), but a blank/0 field falls back to
|
||||||
|
// the stored secret so the probe authenticates. Without this, an edited Dingtian
|
||||||
|
// tests with no relay password → false "offline". The submitted-value-wins rule:
|
||||||
|
// only fill a secret from the store when the form didn't send a real one.
|
||||||
|
// Re-merge stored secrets ONLY when this addresses the same device at the same
|
||||||
|
// host/port (storedSecrets enforces identity) — so a redirected probe can't leak
|
||||||
|
// the secret to an attacker host. Submitted values still win.
|
||||||
|
const merged: Record<string, string | number | boolean | undefined> = { ...config };
|
||||||
|
if (id) {
|
||||||
|
for (const [k, v] of Object.entries(storedSecrets(db, id, driverId, config))) {
|
||||||
|
const sent = merged[k];
|
||||||
|
if (sent === undefined || sent === "" || sent === 0) merged[k] = v as string | number;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let device;
|
let device;
|
||||||
try {
|
try {
|
||||||
device = registry.create(driverId, config);
|
device = registry.create(driverId, merged as Record<string, string | number | boolean>);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return reply.code(400).send({ error: (err as Error).message });
|
return reply.code(400).send({ error: (err as Error).message });
|
||||||
}
|
}
|
||||||
@@ -334,6 +392,70 @@ export async function setupRoutes(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Print a TEST SLIP on a printer config WITHOUT saving. healthCheck only opens the
|
||||||
|
// transport (TCP connect / USB open) — it proves reachability, NOT that paper feeds
|
||||||
|
// and the head fires. This pushes a real short slip through the device-agnostic
|
||||||
|
// printReport(), so the admin can physically confirm the printer is live (the USB
|
||||||
|
// /dev/usb/lpN path or the network printer). Fail-soft like test-anpr: a print error
|
||||||
|
// is reported, never a 500. Mirrors /test's stored-secret re-merge so an edited
|
||||||
|
// network printer still authenticates.
|
||||||
|
app.post<{ Body: TestBody }>(
|
||||||
|
"/api/setup/test-print",
|
||||||
|
{ preHandler: adminGuard },
|
||||||
|
async (req, reply) => {
|
||||||
|
const { driverId, config, id } = req.body;
|
||||||
|
const driver = registry.get(driverId);
|
||||||
|
if (!driver) return reply.code(400).send({ error: `unknown driver: ${driverId}` });
|
||||||
|
if (driver.category !== "printer") {
|
||||||
|
return reply.code(400).send({ error: `driver ${driverId} is not a printer` });
|
||||||
|
}
|
||||||
|
|
||||||
|
const merged: Record<string, string | number | boolean | undefined> = { ...config };
|
||||||
|
if (id) {
|
||||||
|
for (const [k, v] of Object.entries(storedSecrets(db, id, driverId, config))) {
|
||||||
|
const sent = merged[k];
|
||||||
|
if (sent === undefined || sent === "" || sent === 0) merged[k] = v as string | number;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let device;
|
||||||
|
try {
|
||||||
|
device = registry.create(driverId, merged as Record<string, string | number | boolean>);
|
||||||
|
} catch (err) {
|
||||||
|
return reply.code(400).send({ error: (err as Error).message });
|
||||||
|
}
|
||||||
|
if (!isPrinter(device)) {
|
||||||
|
return reply.code(400).send({ error: `driver ${driverId} cannot print` });
|
||||||
|
}
|
||||||
|
|
||||||
|
const startedAt = Date.now();
|
||||||
|
try {
|
||||||
|
await device.printReport({
|
||||||
|
title: "TEST PRINT",
|
||||||
|
lines: [
|
||||||
|
"Parking System",
|
||||||
|
"Printer test slip",
|
||||||
|
new Date().toLocaleString("sv"), // YYYY-MM-DD HH:MM:SS, locale-stable
|
||||||
|
"",
|
||||||
|
"If you can read this, the",
|
||||||
|
"printer is connected and",
|
||||||
|
"printing correctly.",
|
||||||
|
],
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
// The failure we're testing for (paper out, head fault, transport drop) —
|
||||||
|
// report it, don't 500.
|
||||||
|
return reply.send({
|
||||||
|
ok: false,
|
||||||
|
reason: "print-failed",
|
||||||
|
detail: (err as Error).message,
|
||||||
|
tookMs: Date.now() - startedAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return reply.send({ ok: true, tookMs: Date.now() - startedAt });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// Candidate backend IPs the device can push to, for a given device host. The
|
// Candidate backend IPs the device can push to, for a given device host. The
|
||||||
// wizard pre-fills with the on-subnet one and lets the admin override (matters
|
// wizard pre-fills with the on-subnet one and lets the admin override (matters
|
||||||
// on multi-NIC hosts). See net.ts / wiki/concepts/device-input-flow.md.
|
// on multi-NIC hosts). See net.ts / wiki/concepts/device-input-flow.md.
|
||||||
|
|||||||
@@ -1,27 +1,6 @@
|
|||||||
import bcrypt from "bcrypt";
|
|
||||||
import { eq, users, type Db } from "@parking/db";
|
|
||||||
import type { FastifyInstance } from "fastify";
|
import type { FastifyInstance } from "fastify";
|
||||||
import { requirePermission, roleHasPermissions } from "../auth.js";
|
import { requirePermission, roleHasPermissions } from "../auth.js";
|
||||||
import {
|
import { NoOpenShiftError, ShiftAlreadyOpenError, type ShiftService } from "../shift-service.js";
|
||||||
InvalidCashMovementError,
|
|
||||||
NoOpenShiftError,
|
|
||||||
ShiftAlreadyOpenError,
|
|
||||||
type ShiftService,
|
|
||||||
} from "../shift-service.js";
|
|
||||||
|
|
||||||
interface CashVoucherBody {
|
|
||||||
/** Direction is the document TYPE, not a sign: cash_in = Mandat Arkëtimi (pay-IN),
|
|
||||||
* cash_out = Mandat Pagese (pay-OUT). */
|
|
||||||
type: "cash_in" | "cash_out";
|
|
||||||
/** POSITIVE minor units (magnitude). The direction comes from `type`. */
|
|
||||||
amountMinor: number;
|
|
||||||
reason?: string;
|
|
||||||
currency?: string;
|
|
||||||
/** The admin who authorizes this voucher (operator-raised / admin-authorized). */
|
|
||||||
authorizedBy: string;
|
|
||||||
/** That admin's password — re-entered to sign off on the drawer movement. */
|
|
||||||
authorizerPassword: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ShiftsQuery {
|
interface ShiftsQuery {
|
||||||
/** Filter to one operator (admin-only; non-admins are forced to themselves). */
|
/** Filter to one operator (admin-only; non-admins are forced to themselves). */
|
||||||
@@ -35,7 +14,7 @@ interface ShiftsQuery {
|
|||||||
// opened/closed explicitly (not time-based — see wiki/concepts/shift.md and
|
// opened/closed explicitly (not time-based — see wiki/concepts/shift.md and
|
||||||
// local-jwt-auth.md "until logout"). End Shift signs a shift_z_report + prints it.
|
// local-jwt-auth.md "until logout"). End Shift signs a shift_z_report + prints it.
|
||||||
|
|
||||||
export async function shiftRoutes(app: FastifyInstance, shift: ShiftService, db: Db): Promise<void> {
|
export async function shiftRoutes(app: FastifyInstance, shift: ShiftService): Promise<void> {
|
||||||
// Reading the shift state vs. opening/closing one's own shift.
|
// Reading the shift state vs. opening/closing one's own shift.
|
||||||
const readGuard = requirePermission("shift:read");
|
const readGuard = requirePermission("shift:read");
|
||||||
const guard = requirePermission("shift:create");
|
const guard = requirePermission("shift:create");
|
||||||
@@ -87,49 +66,8 @@ export async function shiftRoutes(app: FastifyInstance, shift: ShiftService, db:
|
|||||||
return { shifts, scope: canSeeAll ? "all" : "self" };
|
return { shifts, scope: canSeeAll ? "all" : "self" };
|
||||||
});
|
});
|
||||||
|
|
||||||
// Drawer cash VOUCHER — Mandat Arkëtimi (cash_in / pay-IN) or Mandat Pagese
|
// NB: drawer cash movements (record/review) moved to routes/drawer.ts (2026-07-01) — the
|
||||||
// (cash_out / pay-OUT). The direction is the document TYPE, not a signed amount.
|
// feature is no longer part of the shift route. See wiki/concepts/shift.md.
|
||||||
// OPERATOR-RAISED, ADMIN-AUTHORIZED: any holder of `shift:create` (operator-grade)
|
|
||||||
// may RAISE the voucher, but it only commits if `authorizedBy` is a real admin
|
|
||||||
// (`shift:cash`) who re-enters their password. This keeps the float control —
|
|
||||||
// an operator cannot move the float alone — while letting them raise the slip.
|
|
||||||
// See wiki/concepts/shift.md.
|
|
||||||
app.post<{ Body: CashVoucherBody }>(
|
|
||||||
"/api/cash-voucher",
|
|
||||||
{ preHandler: guard },
|
|
||||||
async (req, reply) => {
|
|
||||||
const b = req.body ?? ({} as CashVoucherBody);
|
|
||||||
if (b.type !== "cash_in" && b.type !== "cash_out") {
|
|
||||||
return reply.code(400).send({ error: "type must be cash_in or cash_out" });
|
|
||||||
}
|
|
||||||
const authName = (b.authorizedBy ?? "").trim();
|
|
||||||
if (!authName || !b.authorizerPassword) {
|
|
||||||
return reply.code(400).send({ error: "authorizedBy and authorizerPassword are required" });
|
|
||||||
}
|
|
||||||
// Verify the authorizer: a real user, admin-grade (shift:cash), correct password.
|
|
||||||
const authUser = await db.select().from(users).where(eq(users.username, authName)).get();
|
|
||||||
// Always run a bcrypt compare (constant-time wrt whether the user exists).
|
|
||||||
const hash = authUser?.passwordHash ?? "$2b$10$invalidinvalidinvalidinvalidinvalidinvalidinv";
|
|
||||||
const passwordOk = await bcrypt.compare(b.authorizerPassword, hash);
|
|
||||||
const isAdminGrade = authUser != null && roleHasPermissions(authUser.roleId, ["shift:cash"]);
|
|
||||||
if (!authUser || !passwordOk || !isAdminGrade) {
|
|
||||||
return reply.code(403).send({ error: "authorizer must be an admin with a correct password" });
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
return await shift.recordVoucher({
|
|
||||||
type: b.type,
|
|
||||||
operator: req.user.username, // who RAISED it
|
|
||||||
authorizedBy: authUser.username, // who signed off (canonical case)
|
|
||||||
amountMinor: b.amountMinor,
|
|
||||||
reason: b.reason ?? "",
|
|
||||||
currency: b.currency,
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
if (err instanceof InvalidCashMovementError) return reply.code(400).send({ error: err.message });
|
|
||||||
return reply.code(500).send({ error: (err as Error).message });
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
app.post("/api/shift/open", { preHandler: guard }, async (req, reply) => {
|
app.post("/api/shift/open", { preHandler: guard }, async (req, reply) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { FastifyInstance } from "fastify";
|
import type { FastifyInstance } from "fastify";
|
||||||
import { and, desc, eq, deviceEvents, snapshots, type Db } from "@parking/db";
|
import { and, desc, eq, deviceEvents, snapshots, type Db } from "@parking/db";
|
||||||
import { requirePermission } from "../auth.js";
|
import { requirePermission } from "../auth.js";
|
||||||
|
import { cleanType } from "../snapshot.js";
|
||||||
|
|
||||||
// Read access to captured entry/exit snapshots (the BLOB-in-DB image store, see
|
// Read access to captured entry/exit snapshots (the BLOB-in-DB image store, see
|
||||||
// packages/db schema + wiki/concepts/lane-direction.md). Snapshots are evidence
|
// packages/db schema + wiki/concepts/lane-direction.md). Snapshots are evidence
|
||||||
@@ -116,7 +117,10 @@ export async function snapshotRoutes(app: FastifyInstance, db: Db): Promise<void
|
|||||||
async (req, reply) => {
|
async (req, reply) => {
|
||||||
const row = db.select().from(snapshots).where(eq(snapshots.id, req.params.id)).get();
|
const row = db.select().from(snapshots).where(eq(snapshots.id, req.params.id)).get();
|
||||||
if (!row) return reply.code(404).send({ error: "no such snapshot" });
|
if (!row) return reply.code(404).send({ error: "no such snapshot" });
|
||||||
reply.header("content-type", row.contentType);
|
// Normalize on the way OUT too: legacy rows stored a camera's malformed
|
||||||
|
// `image/jpeg; charset="UTF-8"`, which browsers refuse to render. cleanType strips
|
||||||
|
// the bogus params back to a bare `image/jpeg` so every stored image displays.
|
||||||
|
reply.header("content-type", cleanType(row.contentType));
|
||||||
reply.header("cache-control", "private, max-age=31536000, immutable");
|
reply.header("cache-control", "private, max-age=31536000, immutable");
|
||||||
return reply.send(row.bytes);
|
return reply.send(row.bytes);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2,10 +2,16 @@ import type { FastifyInstance } from "fastify";
|
|||||||
import type { Db } from "@parking/db";
|
import type { Db } from "@parking/db";
|
||||||
import type { LedgerEvent } from "@parking/shared";
|
import type { LedgerEvent } from "@parking/shared";
|
||||||
import { roleHasPermissions } from "../auth.js";
|
import { roleHasPermissions } from "../auth.js";
|
||||||
import { deviceEvents, type LaneStatusEvent } from "../device-events.js";
|
import {
|
||||||
|
deviceEvents,
|
||||||
|
type LaneStatusEvent,
|
||||||
|
type LanePresenceEvent,
|
||||||
|
type PlateRecognizedEvent,
|
||||||
|
} from "../device-events.js";
|
||||||
import { enrichEvent } from "../event-enrich.js";
|
import { enrichEvent } from "../event-enrich.js";
|
||||||
import type { DeviceMonitor } from "../device-monitor.js";
|
import type { DeviceMonitor } from "../device-monitor.js";
|
||||||
import type { LaneStatus } from "../lane-status.js";
|
import type { LaneStatus } from "../lane-status.js";
|
||||||
|
import type { LanePresence } from "../lane-presence.js";
|
||||||
import { getOccupancy } from "../occupancy.js";
|
import { getOccupancy } from "../occupancy.js";
|
||||||
|
|
||||||
// Live booth feed over a WebSocket. The booth UI opens ONE socket and receives
|
// Live booth feed over a WebSocket. The booth UI opens ONE socket and receives
|
||||||
@@ -53,17 +59,26 @@ function isAllowedOrigin(origin: string | undefined, host: string | undefined):
|
|||||||
}
|
}
|
||||||
|
|
||||||
type OutMsg =
|
type OutMsg =
|
||||||
| { kind: "hello"; occupancy: ReturnType<typeof getOccupancy>; devices: unknown; lanes: LaneStatusEvent }
|
| {
|
||||||
|
kind: "hello";
|
||||||
|
occupancy: ReturnType<typeof getOccupancy>;
|
||||||
|
devices: unknown;
|
||||||
|
lanes: LaneStatusEvent;
|
||||||
|
radar: LanePresenceEvent;
|
||||||
|
}
|
||||||
| { kind: "ledger"; event: unknown; occupancy: ReturnType<typeof getOccupancy> }
|
| { kind: "ledger"; event: unknown; occupancy: ReturnType<typeof getOccupancy> }
|
||||||
| { kind: "printer-status"; event: unknown }
|
| { kind: "printer-status"; event: unknown }
|
||||||
| { kind: "device-status"; event: unknown }
|
| { kind: "device-status"; event: unknown }
|
||||||
| { kind: "lane-status"; lanes: LaneStatusEvent };
|
| { kind: "lane-status"; lanes: LaneStatusEvent }
|
||||||
|
| { kind: "lane-presence"; radar: LanePresenceEvent }
|
||||||
|
| { kind: "plate-recognized"; plate: PlateRecognizedEvent };
|
||||||
|
|
||||||
export async function wsRoutes(
|
export async function wsRoutes(
|
||||||
app: FastifyInstance,
|
app: FastifyInstance,
|
||||||
db: Db,
|
db: Db,
|
||||||
deviceMonitor: DeviceMonitor,
|
deviceMonitor: DeviceMonitor,
|
||||||
laneStatus: LaneStatus,
|
laneStatus: LaneStatus,
|
||||||
|
lanePresence: LanePresence,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
app.get(
|
app.get(
|
||||||
"/api/ws",
|
"/api/ws",
|
||||||
@@ -96,7 +111,13 @@ export async function wsRoutes(
|
|||||||
|
|
||||||
// Initial snapshot so the client renders immediately, before any event:
|
// Initial snapshot so the client renders immediately, before any event:
|
||||||
// occupancy AND the current device-status set (for the footer).
|
// occupancy AND the current device-status set (for the footer).
|
||||||
send({ kind: "hello", occupancy: getOccupancy(db), devices: deviceMonitor.snapshot(), lanes: laneStatus.snapshot() });
|
send({
|
||||||
|
kind: "hello",
|
||||||
|
occupancy: getOccupancy(db),
|
||||||
|
devices: deviceMonitor.snapshot(),
|
||||||
|
lanes: laneStatus.snapshot(),
|
||||||
|
radar: lanePresence.snapshot(),
|
||||||
|
});
|
||||||
|
|
||||||
// Subscribe to the live buses. Each handler recomputes occupancy from the
|
// Subscribe to the live buses. Each handler recomputes occupancy from the
|
||||||
// ledger (cheap fold) so the pushed count is always authoritative.
|
// ledger (cheap fold) so the pushed count is always authoritative.
|
||||||
@@ -117,12 +138,22 @@ export async function wsRoutes(
|
|||||||
const offLane = deviceEvents.onLaneStatus((lanes) => {
|
const offLane = deviceEvents.onLaneStatus((lanes) => {
|
||||||
send({ kind: "lane-status", lanes });
|
send({ kind: "lane-status", lanes });
|
||||||
});
|
});
|
||||||
|
// Lane RADAR presence (presence-input edge → barrier-light blink). Advisory.
|
||||||
|
const offPresence = deviceEvents.onLanePresence((radar) => {
|
||||||
|
send({ kind: "lane-presence", radar });
|
||||||
|
});
|
||||||
|
// A late async plate recognition → backfill the badge on the matching feed row. Advisory.
|
||||||
|
const offPlate = deviceEvents.onPlateRecognized((plate) => {
|
||||||
|
send({ kind: "plate-recognized", plate });
|
||||||
|
});
|
||||||
|
|
||||||
socket.on("close", () => {
|
socket.on("close", () => {
|
||||||
offLedger();
|
offLedger();
|
||||||
offPrinter();
|
offPrinter();
|
||||||
offDevice();
|
offDevice();
|
||||||
offLane();
|
offLane();
|
||||||
|
offPresence();
|
||||||
|
offPlate();
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { randomUUID } from "node:crypto";
|
|||||||
import { createDb, deviceEvents as deviceEventsTable, type Db } from "@parking/db";
|
import { createDb, deviceEvents as deviceEventsTable, type Db } from "@parking/db";
|
||||||
import { TOKEN_COOKIE, requireJwtSecret, initAuth } from "./auth.js";
|
import { TOKEN_COOKIE, requireJwtSecret, initAuth } from "./auth.js";
|
||||||
import { deviceEvents } from "./device-events.js";
|
import { deviceEvents } from "./device-events.js";
|
||||||
|
import { ButtonLightController } from "./button-light.js";
|
||||||
import { EntryFlow } from "./entry-flow.js";
|
import { EntryFlow } from "./entry-flow.js";
|
||||||
import { EventLog } from "./event-log.js";
|
import { EventLog } from "./event-log.js";
|
||||||
import { ExitFlow } from "./exit-flow.js";
|
import { ExitFlow } from "./exit-flow.js";
|
||||||
@@ -19,6 +20,9 @@ import { PrinterMonitor } from "./printer-monitor.js";
|
|||||||
import { DeviceMonitor } from "./device-monitor.js";
|
import { DeviceMonitor } from "./device-monitor.js";
|
||||||
import { buildSigner, buildVerifier } from "./signer.js";
|
import { buildSigner, buildVerifier } from "./signer.js";
|
||||||
import { LogService, pinoDbStream } from "./log-service.js";
|
import { LogService, pinoDbStream } from "./log-service.js";
|
||||||
|
import { pruneSnapshots } from "./snapshot-retention.js";
|
||||||
|
import { BackupService } from "./backup-service.js";
|
||||||
|
import { backupRoutes } from "./routes/backup.js";
|
||||||
import { logRoutes } from "./routes/logs.js";
|
import { logRoutes } from "./routes/logs.js";
|
||||||
import { VisionClient } from "./vision-client.js";
|
import { VisionClient } from "./vision-client.js";
|
||||||
import { authRoutes } from "./routes/auth.js";
|
import { authRoutes } from "./routes/auth.js";
|
||||||
@@ -27,6 +31,7 @@ import { roleRoutes } from "./routes/roles.js";
|
|||||||
import { deviceRoutes } from "./routes/devices.js";
|
import { deviceRoutes } from "./routes/devices.js";
|
||||||
import { hikvisionAlarmRoutes } from "./routes/hikvision-alarm.js";
|
import { hikvisionAlarmRoutes } from "./routes/hikvision-alarm.js";
|
||||||
import { LaneStatus } from "./lane-status.js";
|
import { LaneStatus } from "./lane-status.js";
|
||||||
|
import { LanePresence } from "./lane-presence.js";
|
||||||
import { AnprBridge } from "./anpr-entry.js";
|
import { AnprBridge } from "./anpr-entry.js";
|
||||||
import { eventRoutes } from "./routes/events.js";
|
import { eventRoutes } from "./routes/events.js";
|
||||||
import { reportRoutes } from "./routes/reports.js";
|
import { reportRoutes } from "./routes/reports.js";
|
||||||
@@ -37,6 +42,8 @@ import { subscriptionRoutes } from "./routes/subscriptions.js";
|
|||||||
import { subscriptionPlanRoutes } from "./routes/subscription-plans.js";
|
import { subscriptionPlanRoutes } from "./routes/subscription-plans.js";
|
||||||
import { qrReaderRoutes } from "./routes/qr-reader.js";
|
import { qrReaderRoutes } from "./routes/qr-reader.js";
|
||||||
import { shiftRoutes } from "./routes/shift.js";
|
import { shiftRoutes } from "./routes/shift.js";
|
||||||
|
import { drawerRoutes } from "./routes/drawer.js";
|
||||||
|
import { entryRoutes } from "./routes/entry.js";
|
||||||
import { siteRoutes } from "./routes/site.js";
|
import { siteRoutes } from "./routes/site.js";
|
||||||
import { snapshotRoutes } from "./routes/snapshots.js";
|
import { snapshotRoutes } from "./routes/snapshots.js";
|
||||||
import { tariffRoutes } from "./routes/tariffs.js";
|
import { tariffRoutes } from "./routes/tariffs.js";
|
||||||
@@ -124,6 +131,12 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
|
|||||||
const laneStatus = new LaneStatus(db, app.log);
|
const laneStatus = new LaneStatus(db, app.log);
|
||||||
app.addHook("onClose", async () => laneStatus.stop());
|
app.addHook("onClose", async () => laneStatus.stop());
|
||||||
|
|
||||||
|
// Per-lane RADAR presence (presence-input edges → barrier-light blink). Mirrors the
|
||||||
|
// physical button lamp (relay 3): the SAME presence signal, surfaced to the booth UI.
|
||||||
|
const lanePresence = new LanePresence(db, app.log);
|
||||||
|
lanePresence.start();
|
||||||
|
app.addHook("onClose", async () => lanePresence.stop());
|
||||||
|
|
||||||
// NB: the Hikvision Alarm Server routes are registered LOWER DOWN — after the read
|
// NB: the Hikvision Alarm Server routes are registered LOWER DOWN — after the read
|
||||||
// flows are constructed — because the ANPR bridge they carry depends on the
|
// flows are constructed — because the ANPR bridge they carry depends on the
|
||||||
// SubscriptionFlow. See the hikvisionAlarmRoutes() call below the read-flow wiring.
|
// SubscriptionFlow. See the hikvisionAlarmRoutes() call below the read-flow wiring.
|
||||||
@@ -168,7 +181,7 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
|
|||||||
|
|
||||||
// Live booth feed: server-pushed ledger + occupancy + printer-status over a
|
// Live booth feed: server-pushed ledger + occupancy + printer-status over a
|
||||||
// single authenticated WebSocket (/api/ws). See routes/ws.ts.
|
// single authenticated WebSocket (/api/ws). See routes/ws.ts.
|
||||||
await wsRoutes(app, db, deviceMonitor, laneStatus);
|
await wsRoutes(app, db, deviceMonitor, laneStatus, lanePresence);
|
||||||
|
|
||||||
// Entry/exit camera snapshots (BLOB-in-DB), read-only. See snapshot.ts.
|
// Entry/exit camera snapshots (BLOB-in-DB), read-only. See snapshot.ts.
|
||||||
await snapshotRoutes(app, db);
|
await snapshotRoutes(app, db);
|
||||||
@@ -188,6 +201,13 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
|
|||||||
});
|
});
|
||||||
app.addHook("onClose", async () => unsubscribeEntry());
|
app.addHook("onClose", async () => unsubscribeEntry());
|
||||||
|
|
||||||
|
// Button-light indicator: drives the entry button's lamp on a spare relay from the
|
||||||
|
// RADAR input vs. the camera lane status (blink = radar-only, solid = radar+camera,
|
||||||
|
// off otherwise). A non-barrier aux output; fails OFF. See button-light.ts.
|
||||||
|
const buttonLight = new ButtonLightController(db, app.log);
|
||||||
|
buttonLight.start();
|
||||||
|
app.addHook("onClose", async () => buttonLight.stop());
|
||||||
|
|
||||||
// Read-driven flows: a credential read (ticket scan / plate / card) routes via the
|
// Read-driven flows: a credential read (ticket scan / plate / card) routes via the
|
||||||
// dispatcher to either the SUBSCRIPTION flow (if it matches a subscription) or the
|
// dispatcher to either the SUBSCRIPTION flow (if it matches a subscription) or the
|
||||||
// transient EXIT flow. See read-dispatch.ts, exit-flow.ts, subscription-flow.ts,
|
// transient EXIT flow. See read-dispatch.ts, exit-flow.ts, subscription-flow.ts,
|
||||||
@@ -218,10 +238,10 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
|
|||||||
// reader's live flow. Single-shot + TTL. See credential-capture.ts.
|
// reader's live flow. Single-shot + TTL. See credential-capture.ts.
|
||||||
const credentialCapture = new CredentialCapture();
|
const credentialCapture = new CredentialCapture();
|
||||||
|
|
||||||
// GEE/Dingtian QR reader: it HTTP-GETs on each scan and beeps/acts on our JSON
|
// Dingtian DT-008 QR/RFID reader: it HTTP-GETs on each scan and beeps/acts on our JSON
|
||||||
// verdict (host-in-the-loop, synchronous). The capture service can intercept a read
|
// verdict (host-in-the-loop, synchronous). The capture service can intercept a read
|
||||||
// on an armed reader for enrollment; otherwise the read routes through the
|
// on an armed reader for enrollment; otherwise the read routes through the
|
||||||
// dispatcher. See wiki/entities/gee-qr-er80.md, qrcode-sdk.md.
|
// dispatcher. See wiki/entities/dingtian-dt008-reader.md, qrcode-sdk.md.
|
||||||
await qrReaderRoutes(app, db, readDispatcher, credentialCapture);
|
await qrReaderRoutes(app, db, readDispatcher, credentialCapture);
|
||||||
|
|
||||||
// Shifts (manned mode): explicit open/close → signed shift_open / shift_z_report
|
// Shifts (manned mode): explicit open/close → signed shift_open / shift_z_report
|
||||||
@@ -247,8 +267,12 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
|
|||||||
await subscriptionRoutes(app, db, credentialCapture, eventLog, shiftService);
|
await subscriptionRoutes(app, db, credentialCapture, eventLog, shiftService);
|
||||||
await subscriptionPlanRoutes(app, db);
|
await subscriptionPlanRoutes(app, db);
|
||||||
|
|
||||||
// Shift open/close + drawer endpoints (shiftService constructed above).
|
// Shift open/close (shiftService constructed above).
|
||||||
await shiftRoutes(app, shiftService, db);
|
await shiftRoutes(app, shiftService);
|
||||||
|
// Drawer cash movements — operator records, admin reviews (routes/drawer.ts).
|
||||||
|
await drawerRoutes(app, shiftService);
|
||||||
|
// Operator-issued entry (broken physical button) — flagged mint, presence-gated.
|
||||||
|
await entryRoutes(app, entryFlow, laneStatus, shiftService);
|
||||||
|
|
||||||
// Site config (capacity) + live occupancy. The FULL gate (refuse transient entry
|
// Site config (capacity) + live occupancy. The FULL gate (refuse transient entry
|
||||||
// at capacity) is in the entry flow. See wiki/concepts/capacity-occupancy.md.
|
// at capacity) is in the entry flow. See wiki/concepts/capacity-occupancy.md.
|
||||||
@@ -258,6 +282,13 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
|
|||||||
// read the store (GET /api/logs, log:read). See wiki/concepts/app-logs.md.
|
// read the store (GET /api/logs, log:read). See wiki/concepts/app-logs.md.
|
||||||
await logRoutes(app, logService);
|
await logRoutes(app, logService);
|
||||||
|
|
||||||
|
// On-site encrypted DB backup (durability for the signed ledger). Admin-driven: the target
|
||||||
|
// directory is admin-chosen (site_config), the key is an env secret; status + a manual "back
|
||||||
|
// up now"; the scheduled run is the daily timer below. A no-op until a target dir is set AND
|
||||||
|
// BACKUP_KEY is present. See wiki/concepts/backup-recovery.md.
|
||||||
|
const backupService = new BackupService(db, app.log);
|
||||||
|
await backupRoutes(app, db, backupService);
|
||||||
|
|
||||||
// Periodic retention prune (age + row cap) so the log table stays bounded on the
|
// Periodic retention prune (age + row cap) so the log table stays bounded on the
|
||||||
// offline appliance. Runs hourly; unref'd so it never holds the process open.
|
// offline appliance. Runs hourly; unref'd so it never holds the process open.
|
||||||
const pruneTimer = setInterval(() => {
|
const pruneTimer = setInterval(() => {
|
||||||
@@ -268,6 +299,35 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
|
|||||||
logService.prune(); // once at startup
|
logService.prune(); // once at startup
|
||||||
app.addHook("onClose", async () => clearInterval(pruneTimer));
|
app.addHook("onClose", async () => clearInterval(pruneTimer));
|
||||||
|
|
||||||
|
// Snapshot retention prune — DISK-PRESSURE safety valve: only when the DB's filesystem
|
||||||
|
// crosses the high-water mark do we delete the oldest snapshots + VACUUM. A no-op the rest
|
||||||
|
// of the time. Daily, unref'd, plus once at startup. See snapshot-retention.ts.
|
||||||
|
const runSnapPrune = async () => {
|
||||||
|
const res = await pruneSnapshots(db, {}, app.log);
|
||||||
|
if (res.deletedRows > 0) {
|
||||||
|
app.log.info(
|
||||||
|
`pruned ${res.deletedRows} snapshots, freed ~${(res.freedBytesEst / 1048576).toFixed(0)} MB ` +
|
||||||
|
`(disk was ${res.usedPctBefore.toFixed(0)}% used${res.vacuumed ? ", vacuumed" : ""})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const snapPruneTimer = setInterval(() => void runSnapPrune(), 24 * 60 * 60 * 1000);
|
||||||
|
snapPruneTimer.unref();
|
||||||
|
void runSnapPrune(); // once at startup
|
||||||
|
app.addHook("onClose", async () => clearInterval(snapPruneTimer));
|
||||||
|
|
||||||
|
// Scheduled encrypted backup — daily, unref'd. A no-op (silent) until BACKUP_TARGET_DIR +
|
||||||
|
// BACKUP_KEY are configured; tolerates an unreachable/unmounted target by recording the
|
||||||
|
// error and trying again next run. NOT run once at startup (a just-booted appliance after a
|
||||||
|
// power cut shouldn't immediately write to a possibly-not-yet-mounted disk; the daily cadence
|
||||||
|
// and the manual button cover it). See wiki/concepts/backup-recovery.md.
|
||||||
|
const backupTimer = setInterval(() => void backupService.runScheduled(), 24 * 60 * 60 * 1000);
|
||||||
|
backupTimer.unref();
|
||||||
|
app.addHook("onClose", async () => clearInterval(backupTimer));
|
||||||
|
if (backupService.configured) {
|
||||||
|
app.log.info("backup: scheduled daily encrypted backup enabled");
|
||||||
|
}
|
||||||
|
|
||||||
// Recycle-bin retention sweep: auto-purge master data soft-deleted longer than the
|
// Recycle-bin retention sweep: auto-purge master data soft-deleted longer than the
|
||||||
// retention window (RECYCLE_BIN_RETENTION_DAYS, default 30; 0 = keep forever). Runs
|
// retention window (RECYCLE_BIN_RETENTION_DAYS, default 30; 0 = keep forever). Runs
|
||||||
// every 6h, unref'd, plus once at startup. See recycle-bin.ts.
|
// every 6h, unref'd, plus once at startup. See recycle-bin.ts.
|
||||||
|
|||||||
@@ -117,27 +117,100 @@ describe("drawer carry-forward", () => {
|
|||||||
expect(next.openingFloatMinor).toBe(25000); // inherited
|
expect(next.openingFloatMinor).toBe(25000); // inherited
|
||||||
});
|
});
|
||||||
|
|
||||||
it("cash_in / cash_out vouchers adjust the drawer", async () => {
|
it("cash_in / cash_out movements adjust the drawer", async () => {
|
||||||
await shift.open("alice");
|
await shift.open("alice");
|
||||||
await shift.recordVoucher({ type: "cash_in", operator: "alice", authorizedBy: "admin", amountMinor: 100000, reason: "float load" });
|
await shift.recordVoucher({ type: "cash_in", operator: "alice", amountMinor: 100000, reason: "float load" });
|
||||||
await shift.recordVoucher({ type: "cash_out", operator: "alice", authorizedBy: "admin", amountMinor: 30000, reason: "bank drop" });
|
await shift.recordVoucher({ type: "cash_out", operator: "alice", amountMinor: 30000, reason: "bank drop" });
|
||||||
const r = shift.currentReport()!;
|
const r = shift.currentReport()!;
|
||||||
expect(r.cashAddedMinor).toBe(100000);
|
expect(r.cashAddedMinor).toBe(100000);
|
||||||
expect(r.cashRemovedMinor).toBe(30000);
|
expect(r.cashRemovedMinor).toBe(30000);
|
||||||
expect(r.expectedDrawerMinor).toBe(70000);
|
expect(r.expectedDrawerMinor).toBe(70000);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects a non-positive voucher amount", async () => {
|
it("rejects a non-positive movement amount", async () => {
|
||||||
await shift.open("alice");
|
await shift.open("alice");
|
||||||
await expect(
|
await expect(
|
||||||
shift.recordVoucher({ type: "cash_in", operator: "alice", authorizedBy: "admin", amountMinor: 0, reason: "x" }),
|
shift.recordVoucher({ type: "cash_in", operator: "alice", amountMinor: 0, reason: "x" }),
|
||||||
).rejects.toBeInstanceOf(InvalidCashMovementError);
|
).rejects.toBeInstanceOf(InvalidCashMovementError);
|
||||||
await expect(
|
await expect(
|
||||||
shift.recordVoucher({ type: "cash_out", operator: "alice", authorizedBy: "admin", amountMinor: -5, reason: "x" }),
|
shift.recordVoucher({ type: "cash_out", operator: "alice", amountMinor: -5, reason: "x" }),
|
||||||
).rejects.toBeInstanceOf(InvalidCashMovementError);
|
).rejects.toBeInstanceOf(InvalidCashMovementError);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("drawer review (operator records, admin reviews after)", () => {
|
||||||
|
it("a new movement starts pending; review sets authorized/denied", async () => {
|
||||||
|
await shift.open("alice");
|
||||||
|
const m = await shift.recordVoucher({ type: "cash_out", operator: "alice", amountMinor: 5000, reason: "supplies" });
|
||||||
|
// Find the movement's ledger id via the status list.
|
||||||
|
let list = shift.movementsWithStatus({ operator: "alice" });
|
||||||
|
expect(list).toHaveLength(1);
|
||||||
|
expect(list[0].status).toBe("pending");
|
||||||
|
expect(list[0].voucherNo).toBe(m.voucherNo);
|
||||||
|
|
||||||
|
await shift.reviewMovement({ refId: list[0].id, decision: "deny", reviewedBy: "admin", note: "not genuine" });
|
||||||
|
list = shift.movementsWithStatus({ operator: "alice" });
|
||||||
|
expect(list[0].status).toBe("denied");
|
||||||
|
expect(list[0].reviewedBy).toBe("admin");
|
||||||
|
expect(list[0].reviewNote).toBe("not genuine");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("DENY is a flag only — it does NOT reverse the movement or touch the drawer", async () => {
|
||||||
|
await shift.open("alice");
|
||||||
|
await shift.recordVoucher({ type: "cash_out", operator: "alice", amountMinor: 10000, reason: "x" });
|
||||||
|
const before = shift.drawerBalance().balanceMinor;
|
||||||
|
expect(before).toBe(-10000); // the disbursement counted immediately
|
||||||
|
const id = shift.movementsWithStatus({ operator: "alice" })[0].id;
|
||||||
|
await shift.reviewMovement({ refId: id, decision: "deny", reviewedBy: "admin" });
|
||||||
|
// Balance UNCHANGED by the denial — the correction is settled outside the app.
|
||||||
|
expect(shift.drawerBalance().balanceMinor).toBe(-10000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a denied movement in a CLOSED shift never leaks into the next operator's drawer", async () => {
|
||||||
|
// The regression that motivated the redesign: op1 disburses, shift closes, op2
|
||||||
|
// inherits; op1's disbursement is later DENIED. op2's drawer must be untouched.
|
||||||
|
await shift.open("op1");
|
||||||
|
await shift.recordVoucher({ type: "cash_out", operator: "op1", amountMinor: 10000, reason: "questionable" });
|
||||||
|
const closed = await shift.close("op1");
|
||||||
|
expect(closed.expectedDrawerMinor).toBe(-10000);
|
||||||
|
|
||||||
|
const next = await shift.open("op2");
|
||||||
|
expect(next.openingFloatMinor).toBe(-10000); // op2 inherits the real till balance
|
||||||
|
|
||||||
|
const id = shift.movementsWithStatus({ operator: "op1" })[0].id;
|
||||||
|
await shift.reviewMovement({ refId: id, decision: "deny", reviewedBy: "admin" });
|
||||||
|
|
||||||
|
// op2's drawer is STILL -10000 — the denial added no reversing cash.
|
||||||
|
expect(shift.drawerBalance().balanceMinor).toBe(-10000);
|
||||||
|
expect(shift.currentReport()!.openingFloatMinor).toBe(-10000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects reviewing a non-movement or an already-reviewed movement", async () => {
|
||||||
|
await shift.open("alice");
|
||||||
|
await shift.recordVoucher({ type: "cash_in", operator: "alice", amountMinor: 5000, reason: "x" });
|
||||||
|
const id = shift.movementsWithStatus({ operator: "alice" })[0].id;
|
||||||
|
await expect(
|
||||||
|
shift.reviewMovement({ refId: "not-a-real-id", decision: "authorize", reviewedBy: "admin" }),
|
||||||
|
).rejects.toBeInstanceOf(InvalidCashMovementError);
|
||||||
|
await shift.reviewMovement({ refId: id, decision: "authorize", reviewedBy: "admin" });
|
||||||
|
await expect(
|
||||||
|
shift.reviewMovement({ refId: id, decision: "deny", reviewedBy: "admin" }),
|
||||||
|
).rejects.toBeInstanceOf(InvalidCashMovementError); // already reviewed
|
||||||
|
});
|
||||||
|
|
||||||
|
it("scopes movements by operator", async () => {
|
||||||
|
await shift.open("alice");
|
||||||
|
await shift.recordVoucher({ type: "cash_in", operator: "alice", amountMinor: 1000, reason: "a" });
|
||||||
|
await shift.close("alice");
|
||||||
|
await shift.open("bob");
|
||||||
|
await shift.recordVoucher({ type: "cash_out", operator: "bob", amountMinor: 2000, reason: "b" });
|
||||||
|
expect(shift.movementsWithStatus({ operator: "alice" })).toHaveLength(1);
|
||||||
|
expect(shift.movementsWithStatus({ operator: "bob" })).toHaveLength(1);
|
||||||
|
expect(shift.movementsWithStatus()).toHaveLength(2); // reviewer sees all
|
||||||
|
expect(shift.movementsWithStatus({ status: "pending" })).toHaveLength(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("close signs a Z-report; listShifts reads it back", () => {
|
describe("close signs a Z-report; listShifts reads it back", () => {
|
||||||
it("a closed shift appears in history with its split figures", async () => {
|
it("a closed shift appears in history with its split figures", async () => {
|
||||||
await shift.open("alice");
|
await shift.open("alice");
|
||||||
|
|||||||
@@ -90,6 +90,27 @@ export interface ShiftReport {
|
|||||||
readonly printed: boolean;
|
readonly printed: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A drawer movement's admin-review status, derived from its latest `cash_review`. */
|
||||||
|
export type MovementStatus = "pending" | "authorized" | "denied";
|
||||||
|
|
||||||
|
/** One drawer cash movement (cash_in/cash_out) with its review status — the row shape for
|
||||||
|
* the operator's own list and the admin review queue. `status` is derived, not stored. */
|
||||||
|
export interface DrawerMovement {
|
||||||
|
readonly id: string;
|
||||||
|
readonly type: "cash_in" | "cash_out";
|
||||||
|
/** Positive magnitude; direction is the `type`. */
|
||||||
|
readonly amountMinor: number;
|
||||||
|
readonly currency: string | null;
|
||||||
|
readonly reason: string | null;
|
||||||
|
readonly operator: string;
|
||||||
|
readonly voucherNo: string | null;
|
||||||
|
readonly at: string;
|
||||||
|
readonly status: MovementStatus;
|
||||||
|
readonly reviewedBy: string | null;
|
||||||
|
readonly reviewNote: string | null;
|
||||||
|
readonly reviewedAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export class InvalidCashMovementError extends Error {
|
export class InvalidCashMovementError extends Error {
|
||||||
constructor(msg: string) {
|
constructor(msg: string) {
|
||||||
super(msg);
|
super(msg);
|
||||||
@@ -281,24 +302,24 @@ export class ShiftService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Record a drawer cash VOUCHER — the direction is the event TYPE, not the sign of
|
* Record a drawer cash MOVEMENT — the direction is the event TYPE, not the sign of an
|
||||||
* an amount (a receipt and a disbursement are different financial documents):
|
* amount (a receipt and a disbursement are different financial documents):
|
||||||
* - `cash_in` (Mandat Arkëtimi): cash entered the drawer (+).
|
* - `cash_in` (Mandat Arkëtimi): cash entered the drawer (+).
|
||||||
* - `cash_out` (Mandat Pagese): cash left the drawer (−).
|
* - `cash_out` (Mandat Pagese): cash left the drawer (−).
|
||||||
* `amountMinor` is always a POSITIVE magnitude. The voucher is OPERATOR-RAISED and
|
* `amountMinor` is always a POSITIVE magnitude. The movement is OPERATOR-RECORDED FREELY
|
||||||
* ADMIN-AUTHORIZED: `operator` raised it, `authorizedBy` signed off (verified at the
|
* (no admin sign-off at creation — 2026-07-01); an admin REVIEWS it after the fact via
|
||||||
* route). Returns the new drawer balance + the assigned voucher number, and prints
|
* `reviewMovement` (authorize/deny — a flag that never moves cash). It counts in the
|
||||||
* a slip best-effort (the signed event is the record). See wiki/concepts/shift.md.
|
* drawer immediately (the cash physically moved). Returns the new drawer balance + the
|
||||||
|
* assigned voucher number, and prints a slip best-effort. See wiki/concepts/shift.md.
|
||||||
*/
|
*/
|
||||||
async recordVoucher(args: {
|
async recordVoucher(args: {
|
||||||
type: "cash_in" | "cash_out";
|
type: "cash_in" | "cash_out";
|
||||||
operator: string;
|
operator: string;
|
||||||
authorizedBy: string;
|
|
||||||
amountMinor: number;
|
amountMinor: number;
|
||||||
reason: string;
|
reason: string;
|
||||||
currency?: string;
|
currency?: string;
|
||||||
}): Promise<{ type: "cash_in" | "cash_out"; amountMinor: number; voucherNo: string; balanceMinor: number; printed: boolean }> {
|
}): Promise<{ type: "cash_in" | "cash_out"; amountMinor: number; voucherNo: string; balanceMinor: number; printed: boolean }> {
|
||||||
const { type, operator, authorizedBy, reason } = args;
|
const { type, operator, reason } = args;
|
||||||
if (!Number.isInteger(args.amountMinor) || args.amountMinor <= 0) {
|
if (!Number.isInteger(args.amountMinor) || args.amountMinor <= 0) {
|
||||||
throw new InvalidCashMovementError("amountMinor must be a positive integer (minor units)");
|
throw new InvalidCashMovementError("amountMinor must be a positive integer (minor units)");
|
||||||
}
|
}
|
||||||
@@ -308,25 +329,122 @@ export class ShiftService {
|
|||||||
await this.#log.append({
|
await this.#log.append({
|
||||||
type,
|
type,
|
||||||
source: "manual",
|
source: "manual",
|
||||||
identity: operator, // who RAISED the voucher (the operator at the booth)
|
identity: operator, // who RECORDED the movement (the operator at the booth)
|
||||||
payload: {
|
payload: {
|
||||||
amountMinor, // positive magnitude — direction is the type
|
amountMinor, // positive magnitude — direction is the type
|
||||||
...(reason ? { reason } : {}),
|
...(reason ? { reason } : {}),
|
||||||
...(args.currency ? { currency: args.currency } : {}),
|
...(args.currency ? { currency: args.currency } : {}),
|
||||||
operator,
|
operator,
|
||||||
authorizedBy,
|
|
||||||
voucherNo,
|
voucherNo,
|
||||||
},
|
},
|
||||||
occurredAt: now,
|
occurredAt: now,
|
||||||
});
|
});
|
||||||
const { balanceMinor, currency } = this.#drawerBalanceAt(now);
|
const { balanceMinor, currency } = this.#drawerBalanceAt(now);
|
||||||
const printed = await this.#printVoucher({ type, voucherNo, amountMinor, reason, operator, authorizedBy, currency, at: now });
|
const printed = await this.#printVoucher({ type, voucherNo, amountMinor, reason, operator, currency, at: now });
|
||||||
this.#logger.info(
|
this.#logger.info(
|
||||||
`${type} ${voucherNo} ${amountMinor} by ${operator} authz ${authorizedBy} (${reason || "no reason"}) → drawer ${balanceMinor}`,
|
`${type} ${voucherNo} ${amountMinor} by ${operator} (${reason || "no reason"}) → drawer ${balanceMinor}`,
|
||||||
);
|
);
|
||||||
return { type, amountMinor, voucherNo, balanceMinor, printed };
|
return { type, amountMinor, voucherNo, balanceMinor, printed };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin's post-hoc REVIEW of a recorded cash_in/cash_out. Appends a signed `cash_review`
|
||||||
|
* referencing the movement. This is a FLAG ONLY — a `deny` does NOT reverse the movement
|
||||||
|
* and does NOT touch the drawer balance (a denial is a judgment about the operator,
|
||||||
|
* settled outside the app). Rejects an unknown/ non-movement refId, and a movement that
|
||||||
|
* was already decided (one decision per movement; a clean audit trail). Idempotent by
|
||||||
|
* design: the drawer fold never reads `cash_review`. See wiki/concepts/shift.md.
|
||||||
|
*/
|
||||||
|
async reviewMovement(args: {
|
||||||
|
refId: string;
|
||||||
|
decision: "authorize" | "deny";
|
||||||
|
reviewedBy: string;
|
||||||
|
note?: string;
|
||||||
|
}): Promise<{ refId: string; decision: "authorize" | "deny"; reviewedBy: string; at: string }> {
|
||||||
|
const { refId, decision, reviewedBy } = args;
|
||||||
|
if (decision !== "authorize" && decision !== "deny") {
|
||||||
|
throw new InvalidCashMovementError("decision must be authorize or deny");
|
||||||
|
}
|
||||||
|
const movement = this.#db.select().from(ledgerEvents).where(eq(ledgerEvents.id, refId)).get();
|
||||||
|
if (!movement || (movement.type !== "cash_in" && movement.type !== "cash_out")) {
|
||||||
|
throw new InvalidCashMovementError("refId is not a cash movement");
|
||||||
|
}
|
||||||
|
// One decision per movement — reject a re-review so the audit stays unambiguous.
|
||||||
|
const already = this.#db
|
||||||
|
.select()
|
||||||
|
.from(ledgerEvents)
|
||||||
|
.where(eq(ledgerEvents.type, "cash_review"))
|
||||||
|
.all()
|
||||||
|
.some((r) => (r.payload as LedgerPayload | null)?.refId === refId);
|
||||||
|
if (already) throw new InvalidCashMovementError("movement already reviewed");
|
||||||
|
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
await this.#log.append({
|
||||||
|
type: "cash_review",
|
||||||
|
source: "manual",
|
||||||
|
identity: reviewedBy, // the admin who decided
|
||||||
|
payload: {
|
||||||
|
refId,
|
||||||
|
decision,
|
||||||
|
reviewedBy,
|
||||||
|
...(args.note ? { note: args.note } : {}),
|
||||||
|
},
|
||||||
|
occurredAt: now,
|
||||||
|
});
|
||||||
|
this.#logger.info(`cash_review ${decision} of ${movement.type} ${refId} by ${reviewedBy}`);
|
||||||
|
return { refId, decision, reviewedBy, at: now };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* All drawer cash movements (cash_in/cash_out) with their review STATUS, newest first.
|
||||||
|
* Status is derived from the latest `cash_review` referencing each movement: none →
|
||||||
|
* `pending`, else `authorized`/`denied`. Powers the operator's own list and the admin
|
||||||
|
* review queue. `operator` (optional) scopes to one operator's movements (an operator
|
||||||
|
* sees only their own; a reviewer sees all). See wiki/concepts/shift.md.
|
||||||
|
*/
|
||||||
|
movementsWithStatus(filter?: { operator?: string; status?: MovementStatus }): DrawerMovement[] {
|
||||||
|
const rows = this.#db.select().from(ledgerEvents).orderBy(ledgerEvents.index).all();
|
||||||
|
// Latest review decision per movement id.
|
||||||
|
const reviewByRef = new Map<string, { decision: "authorize" | "deny"; reviewedBy: string; note?: string; at: string }>();
|
||||||
|
for (const r of rows) {
|
||||||
|
if (r.type !== "cash_review") continue;
|
||||||
|
const pl = (r.payload ?? {}) as LedgerPayload;
|
||||||
|
if (!pl.refId || (pl.decision !== "authorize" && pl.decision !== "deny")) continue;
|
||||||
|
reviewByRef.set(pl.refId, {
|
||||||
|
decision: pl.decision,
|
||||||
|
reviewedBy: pl.reviewedBy ?? "",
|
||||||
|
...(pl.note ? { note: pl.note } : {}),
|
||||||
|
at: r.occurredAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const out: DrawerMovement[] = [];
|
||||||
|
for (const r of rows) {
|
||||||
|
if (r.type !== "cash_in" && r.type !== "cash_out") continue;
|
||||||
|
const pl = (r.payload ?? {}) as LedgerPayload;
|
||||||
|
const operator = (typeof pl.operator === "string" ? pl.operator : null) ?? r.identity ?? "";
|
||||||
|
if (filter?.operator && operator !== filter.operator) continue;
|
||||||
|
const review = reviewByRef.get(r.id);
|
||||||
|
const status: MovementStatus = review ? (review.decision === "authorize" ? "authorized" : "denied") : "pending";
|
||||||
|
if (filter?.status && status !== filter.status) continue;
|
||||||
|
out.push({
|
||||||
|
id: r.id,
|
||||||
|
type: r.type,
|
||||||
|
amountMinor: typeof pl.amountMinor === "number" ? Math.abs(pl.amountMinor) : 0,
|
||||||
|
currency: pl.currency ?? null,
|
||||||
|
reason: pl.reason ?? null,
|
||||||
|
operator,
|
||||||
|
voucherNo: pl.voucherNo ?? null,
|
||||||
|
at: r.occurredAt,
|
||||||
|
status,
|
||||||
|
reviewedBy: review?.reviewedBy ?? null,
|
||||||
|
reviewNote: review?.note ?? null,
|
||||||
|
reviewedAt: review?.at ?? null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Newest first.
|
||||||
|
return out.sort((a, b) => (a.at < b.at ? 1 : a.at > b.at ? -1 : 0));
|
||||||
|
}
|
||||||
|
|
||||||
/** Open a shift for the operator (explicit start). The opening float is auto-
|
/** Open a shift for the operator (explicit start). The opening float is auto-
|
||||||
* inherited from the chain = the drawer balance at the start instant. */
|
* inherited from the chain = the drawer balance at the start instant. */
|
||||||
async open(operator: string): Promise<{ startedAt: string; openingFloatMinor: number }> {
|
async open(operator: string): Promise<{ startedAt: string; openingFloatMinor: number }> {
|
||||||
@@ -548,8 +666,9 @@ export class ShiftService {
|
|||||||
"",
|
"",
|
||||||
"-- Arkëtime sipas burimit --",
|
"-- Arkëtime sipas burimit --",
|
||||||
`Bileta: ${money(r.ticketTotalMinor)} ${cur}`,
|
`Bileta: ${money(r.ticketTotalMinor)} ${cur}`,
|
||||||
|
// Abonime is the subscription TOTAL; only the out-of-window part is broken out.
|
||||||
|
// (subscriptionSalesMinor stays in the signed payload — it's just not printed.)
|
||||||
`Abonime: ${money(r.subscriptionTotalMinor)} ${cur}`,
|
`Abonime: ${money(r.subscriptionTotalMinor)} ${cur}`,
|
||||||
` shitje: ${money(r.subscriptionSalesMinor)} ${cur}`,
|
|
||||||
` jashtë orarit: ${money(r.subscriptionWindowMinor)} ${cur}`,
|
` jashtë orarit: ${money(r.subscriptionWindowMinor)} ${cur}`,
|
||||||
"",
|
"",
|
||||||
"-- Arka --",
|
"-- Arka --",
|
||||||
@@ -577,7 +696,6 @@ export class ShiftService {
|
|||||||
amountMinor: number;
|
amountMinor: number;
|
||||||
reason: string;
|
reason: string;
|
||||||
operator: string;
|
operator: string;
|
||||||
authorizedBy: string;
|
|
||||||
currency: string | null;
|
currency: string | null;
|
||||||
at: string;
|
at: string;
|
||||||
}): Promise<boolean> {
|
}): Promise<boolean> {
|
||||||
@@ -596,8 +714,7 @@ export class ShiftService {
|
|||||||
`Shuma: ${money(v.amountMinor)} ${cur}`,
|
`Shuma: ${money(v.amountMinor)} ${cur}`,
|
||||||
`Arsyeja: ${v.reason || "-"}`,
|
`Arsyeja: ${v.reason || "-"}`,
|
||||||
"",
|
"",
|
||||||
`Hapur nga: ${v.operator}`,
|
`Regjistroi: ${v.operator}`,
|
||||||
`Autorizoi: ${v.authorizedBy}`,
|
|
||||||
];
|
];
|
||||||
try {
|
try {
|
||||||
await printer.printReport({ title, lines });
|
await printer.printReport({ title, lines });
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { snapshots, type Db } from "@parking/db";
|
||||||
|
import { createTestDb } from "@parking/db/testing";
|
||||||
|
import { pruneSnapshots, type DiskUsage, type SnapshotRetention } from "./snapshot-retention.js";
|
||||||
|
|
||||||
|
// Snapshot retention: DISK-PRESSURE prune. No-op unless the DB's filesystem is over the
|
||||||
|
// high-water mark; then delete the OLDEST until ~freeTargetPct of disk is freed (estimated from
|
||||||
|
// the deleted BLOB sizes), honoring a MIN_KEEP floor, then VACUUM once. Disk usage is injected
|
||||||
|
// so the test controls the trigger without touching the real filesystem.
|
||||||
|
|
||||||
|
let db: Db;
|
||||||
|
beforeEach(() => {
|
||||||
|
({ db } = createTestDb());
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Insert `n` snapshots, oldest first (s-0 is the oldest), each `bytes` long. */
|
||||||
|
function seed(n: number, bytes = 1000): void {
|
||||||
|
const t0 = Date.now() - n * 1000;
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
db.insert(snapshots)
|
||||||
|
.values({
|
||||||
|
id: `s-${i}`,
|
||||||
|
direction: "entry",
|
||||||
|
deviceId: "cam",
|
||||||
|
identity: `s-${i}`,
|
||||||
|
contentType: "image/jpeg",
|
||||||
|
bytes: Buffer.alloc(bytes, 1),
|
||||||
|
capturedAt: new Date(t0 + i * 1000).toISOString(), // s-0 oldest … s-(n-1) newest
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function count(): number {
|
||||||
|
return db.select().from(snapshots).all().length;
|
||||||
|
}
|
||||||
|
function ids(): string[] {
|
||||||
|
return db.select().from(snapshots).all().map((r) => r.id).sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A fake disk at a given used% on a 1 GB volume. */
|
||||||
|
const disk = (usedPct: number, totalBytes = 1_000_000_000): (() => Promise<DiskUsage>) =>
|
||||||
|
() => Promise.resolve({ usedPct, totalBytes });
|
||||||
|
|
||||||
|
const ret = (o: Partial<SnapshotRetention>): SnapshotRetention => ({
|
||||||
|
highPct: 70,
|
||||||
|
freeTargetPct: 10,
|
||||||
|
minKeep: 2,
|
||||||
|
batch: 5,
|
||||||
|
...o,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("pruneSnapshots (disk-pressure)", () => {
|
||||||
|
it("no-op when disk is below the high-water mark", async () => {
|
||||||
|
seed(10);
|
||||||
|
const res = await pruneSnapshots(db, { retention: ret({}), diskUsage: disk(50) });
|
||||||
|
expect(res.deletedRows).toBe(0);
|
||||||
|
expect(res.vacuumed).toBe(false);
|
||||||
|
expect(count()).toBe(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("over the mark: deletes the OLDEST until ~freeTargetPct is freed, then VACUUMs", async () => {
|
||||||
|
// 1 GB disk, target 10% = 100 MB. Each snapshot 20 MB → ~5 deletions reach the target.
|
||||||
|
seed(20, 20 * 1048576);
|
||||||
|
const vacuumSpy = vi.spyOn(db.$client as { exec: (s: string) => void }, "exec");
|
||||||
|
const res = await pruneSnapshots(db, { retention: ret({ minKeep: 2, batch: 100 }), diskUsage: disk(80) });
|
||||||
|
expect(res.deletedRows).toBeGreaterThanOrEqual(5);
|
||||||
|
expect(res.freedBytesEst).toBeGreaterThanOrEqual(0.1 * 1_000_000_000);
|
||||||
|
expect(res.vacuumed).toBe(true);
|
||||||
|
expect(vacuumSpy).toHaveBeenCalledWith("VACUUM");
|
||||||
|
// The survivors are the NEWEST (oldest went first).
|
||||||
|
const survivors = ids();
|
||||||
|
expect(survivors).toContain(`s-19`); // newest kept
|
||||||
|
expect(survivors).not.toContain(`s-0`); // oldest pruned
|
||||||
|
vacuumSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("honors the MIN_KEEP floor even when still over target", async () => {
|
||||||
|
// Target 10% of 1 GB = 100 MB, but only 3 tiny snapshots exist and minKeep=2 → at most 1 deleted.
|
||||||
|
seed(3, 1000);
|
||||||
|
const res = await pruneSnapshots(db, { retention: ret({ minKeep: 2, freeTargetPct: 90 }), diskUsage: disk(95) });
|
||||||
|
expect(res.deletedRows).toBe(1); // 3 − minKeep(2)
|
||||||
|
expect(count()).toBe(2);
|
||||||
|
expect(res.floorHitWhileOver).toBe(true); // couldn't reach target without crossing the floor
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips VACUUM when nothing was deleted", async () => {
|
||||||
|
seed(2); // == minKeep, so nothing to delete even over the mark
|
||||||
|
const vacuumSpy = vi.spyOn(db.$client as { exec: (s: string) => void }, "exec");
|
||||||
|
const res = await pruneSnapshots(db, { retention: ret({ minKeep: 2 }), diskUsage: disk(99) });
|
||||||
|
expect(res.deletedRows).toBe(0);
|
||||||
|
expect(res.vacuumed).toBe(false);
|
||||||
|
expect(vacuumSpy).not.toHaveBeenCalled();
|
||||||
|
vacuumSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
import { statfs } from "node:fs/promises";
|
||||||
|
import { dirname, resolve } from "node:path";
|
||||||
|
import { asc, snapshots, sql, type Db } from "@parking/db";
|
||||||
|
import type { FastifyBaseLogger } from "fastify";
|
||||||
|
|
||||||
|
// Snapshot retention — DISK-PRESSURE model. Camera snapshots are unsigned, advisory, prunable
|
||||||
|
// BLOBs (see snapshot.ts); they're referenced by the signed ledger only by id, so pruning an
|
||||||
|
// old image never affects the chain. They no longer dominate the DB day-to-day (captures are
|
||||||
|
// re-encoded small at SNAPSHOT_MAX_EDGE/JPEG_QUALITY), so this is a SAFETY VALVE: only when the
|
||||||
|
// filesystem holding the DB crosses a high-water mark do we delete the OLDEST snapshots and
|
||||||
|
// VACUUM to return disk to the OS.
|
||||||
|
//
|
||||||
|
// Why estimated-bytes, not live disk%: a DELETE only frees SQLite *pages* — the file (and thus
|
||||||
|
// OS disk usage) doesn't shrink until VACUUM. So the prune loop can't watch usedPct fall in real
|
||||||
|
// time. Instead it sums LENGTH(bytes) of the rows it deletes and stops when that estimate reaches
|
||||||
|
// the free-target, then VACUUMs ONCE at the end to realize the space. A MIN_KEEP floor always
|
||||||
|
// wins — we never delete evidence below it, even under pressure (if the disk is full of something
|
||||||
|
// else, that's not ours to fix).
|
||||||
|
|
||||||
|
export interface SnapshotRetention {
|
||||||
|
/** Prune when the DB's filesystem is at least this % used. */
|
||||||
|
readonly highPct: number;
|
||||||
|
/** Try to free roughly this % of the disk per run (the delete target). */
|
||||||
|
readonly freeTargetPct: number;
|
||||||
|
/** Never prune below this many snapshots (the floor). */
|
||||||
|
readonly minKeep: number;
|
||||||
|
/** Delete oldest in batches of this size (re-checks between batches). */
|
||||||
|
readonly batch: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_SNAPSHOT_RETENTION: SnapshotRetention = {
|
||||||
|
highPct: Number(process.env.SNAPSHOT_DISK_HIGH_PCT ?? 70),
|
||||||
|
freeTargetPct: Number(process.env.SNAPSHOT_DISK_FREE_TARGET_PCT ?? 10),
|
||||||
|
minKeep: Number(process.env.SNAPSHOT_MIN_KEEP ?? 500),
|
||||||
|
batch: Number(process.env.SNAPSHOT_PRUNE_BATCH ?? 200),
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Disk usage of the filesystem holding the DB. Injectable so tests don't touch the real FS. */
|
||||||
|
export interface DiskUsage {
|
||||||
|
readonly usedPct: number;
|
||||||
|
readonly totalBytes: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PruneOptions {
|
||||||
|
readonly retention?: SnapshotRetention;
|
||||||
|
/** Override how disk usage is read (tests inject a fake; default = statfs the DB's FS). */
|
||||||
|
readonly diskUsage?: () => Promise<DiskUsage>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PruneResult {
|
||||||
|
readonly deletedRows: number;
|
||||||
|
readonly freedBytesEst: number;
|
||||||
|
readonly vacuumed: boolean;
|
||||||
|
readonly usedPctBefore: number;
|
||||||
|
/** True if we hit the MIN_KEEP floor while the disk was still over the high-water mark. */
|
||||||
|
readonly floorHitWhileOver: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read the used% + total bytes of the filesystem holding the DB file. */
|
||||||
|
async function diskUsageForDb(db: Db): Promise<DiskUsage> {
|
||||||
|
const file = (db.$client as { name?: string }).name ?? process.env.DATABASE_URL ?? "./parking.sqlite";
|
||||||
|
const st = await statfs(dirname(resolve(file)));
|
||||||
|
const total = st.blocks * st.bsize;
|
||||||
|
const avail = st.bavail * st.bsize;
|
||||||
|
const usedPct = total > 0 ? (1 - avail / total) * 100 : 0;
|
||||||
|
return { usedPct, totalBytes: total };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prune snapshots under DISK PRESSURE. No-op unless the DB's filesystem is ≥ highPct used. When
|
||||||
|
* over, deletes the OLDEST snapshots until an estimated freeTargetPct of the disk is freed (or the
|
||||||
|
* minKeep floor is hit, or no rows remain), then VACUUMs once. Best-effort; safe on a timer.
|
||||||
|
*/
|
||||||
|
export async function pruneSnapshots(
|
||||||
|
db: Db,
|
||||||
|
opts: PruneOptions = {},
|
||||||
|
logger?: FastifyBaseLogger,
|
||||||
|
): Promise<PruneResult> {
|
||||||
|
const r = opts.retention ?? DEFAULT_SNAPSHOT_RETENTION;
|
||||||
|
const readDisk = opts.diskUsage ?? (() => diskUsageForDb(db));
|
||||||
|
|
||||||
|
let usedPctBefore = 0;
|
||||||
|
try {
|
||||||
|
const disk = await readDisk();
|
||||||
|
usedPctBefore = disk.usedPct;
|
||||||
|
|
||||||
|
// The overwhelmingly common case: plenty of headroom → do nothing.
|
||||||
|
if (disk.usedPct < r.highPct) {
|
||||||
|
return { deletedRows: 0, freedBytesEst: 0, vacuumed: false, usedPctBefore, floorHitWhileOver: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Target bytes to free this run (≈ freeTargetPct of the whole disk).
|
||||||
|
const targetBytes = (r.freeTargetPct / 100) * disk.totalBytes;
|
||||||
|
|
||||||
|
let freedBytesEst = 0;
|
||||||
|
let deletedRows = 0;
|
||||||
|
let floorHitWhileOver = false;
|
||||||
|
|
||||||
|
// Delete the oldest in batches, summing their BLOB sizes, until we've freed the target — or
|
||||||
|
// we'd cross the MIN_KEEP floor — or there are no more rows.
|
||||||
|
for (;;) {
|
||||||
|
const count = db.select({ c: sql<number>`count(*)` }).from(snapshots).get()?.c ?? 0;
|
||||||
|
if (count <= r.minKeep) {
|
||||||
|
floorHitWhileOver = true; // still over the high-water mark but can't delete below the floor
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (freedBytesEst >= targetBytes) break;
|
||||||
|
|
||||||
|
const room = count - r.minKeep; // how many we may still delete before the floor
|
||||||
|
const take = Math.min(r.batch, room);
|
||||||
|
const oldest = db
|
||||||
|
.select({ id: snapshots.id, len: sql<number>`length(${snapshots.bytes})` })
|
||||||
|
.from(snapshots)
|
||||||
|
.orderBy(asc(snapshots.capturedAt))
|
||||||
|
.limit(take)
|
||||||
|
.all();
|
||||||
|
if (oldest.length === 0) break;
|
||||||
|
|
||||||
|
const ids = oldest.map((o) => o.id);
|
||||||
|
db.delete(snapshots).where(sql`${snapshots.id} in (${sql.join(ids, sql`, `)})`).run();
|
||||||
|
deletedRows += oldest.length;
|
||||||
|
freedBytesEst += oldest.reduce((s, o) => s + (o.len ?? 0), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Realize the freed space: VACUUM returns pages to the OS (the file shrinks). Only if we
|
||||||
|
// actually deleted something. Non-fatal on failure — pages are still freed for reuse.
|
||||||
|
let vacuumed = false;
|
||||||
|
if (deletedRows > 0) {
|
||||||
|
try {
|
||||||
|
(db.$client as { exec: (sql: string) => void }).exec("VACUUM");
|
||||||
|
vacuumed = true;
|
||||||
|
} catch (err) {
|
||||||
|
logger?.warn(`snapshot prune: VACUUM failed (pages freed for reuse): ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (floorHitWhileOver) {
|
||||||
|
logger?.warn(
|
||||||
|
`snapshot prune: disk ${usedPctBefore.toFixed(0)}% used but hit MIN_KEEP floor (${r.minKeep}) ` +
|
||||||
|
`after deleting ${deletedRows} — disk pressure is not from snapshots`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return { deletedRows, freedBytesEst, vacuumed, usedPctBefore, floorHitWhileOver };
|
||||||
|
} catch (err) {
|
||||||
|
logger?.warn(`snapshot prune failed: ${(err as Error).message}`);
|
||||||
|
return { deletedRows: 0, freedBytesEst: 0, vacuumed: false, usedPctBefore, floorHitWhileOver: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import sharp from "sharp";
|
||||||
|
import type { CameraDevice, Snapshot } from "@parking/devices";
|
||||||
|
import { captureSnapshotShared, cleanType, encodeForStorage } from "./snapshot.js";
|
||||||
|
import { silentLogger } from "./test-helpers.js";
|
||||||
|
|
||||||
|
// captureSnapshotShared: one HTTP pull per camera per vehicle. A Hikvision unit serves
|
||||||
|
// snapshots SINGLE-THREADED (a 2nd concurrent GET → HTTP 503). On an entry the ANPR
|
||||||
|
// bridge AND the advisory snapshotAsync both capture the same camera within ~1s, each
|
||||||
|
// from a SEPARATE adapter instance — so this deviceId-keyed cache coalesces in-flight
|
||||||
|
// captures and serves a brief freshness window, collapsing the two into one real pull.
|
||||||
|
// (Root cause of the slow 2026-06-25 subscriber entry.)
|
||||||
|
|
||||||
|
/** A fake camera whose captureSnapshot is controllable (count calls, delay, fail). */
|
||||||
|
function fakeCamera(opts: { delayMs?: number; fail?: boolean; tag?: string } = {}): {
|
||||||
|
camera: CameraDevice;
|
||||||
|
calls: () => number;
|
||||||
|
} {
|
||||||
|
let calls = 0;
|
||||||
|
const tag = opts.tag ?? "x";
|
||||||
|
const camera = {
|
||||||
|
async captureSnapshot(): Promise<Snapshot> {
|
||||||
|
calls++;
|
||||||
|
if (opts.delayMs) await new Promise((r) => setTimeout(r, opts.delayMs));
|
||||||
|
if (opts.fail) throw new Error("HTTP 503");
|
||||||
|
// Tag distinguishes frames from different cameras (the per-camera keying test).
|
||||||
|
return { bytes: Buffer.from(`shot-${tag}-${calls}`), contentType: "image/jpeg", capturedAt: new Date().toISOString() };
|
||||||
|
},
|
||||||
|
} as unknown as CameraDevice;
|
||||||
|
return { camera, calls: () => calls };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A unique deviceId per test so the module-level cache never bleeds across cases. */
|
||||||
|
function id(): string {
|
||||||
|
return `cam-${Math.random().toString(36).slice(2)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("captureSnapshotShared", () => {
|
||||||
|
it("coalesces CONCURRENT captures into a single hardware pull (the 503 fix)", async () => {
|
||||||
|
const { camera, calls } = fakeCamera({ delayMs: 20 });
|
||||||
|
const dev = id();
|
||||||
|
// The bridge and the advisory path fire at nearly the same instant.
|
||||||
|
const [a, b] = await Promise.all([
|
||||||
|
captureSnapshotShared(dev, camera, { direction: "entry" }),
|
||||||
|
captureSnapshotShared(dev, camera, { direction: "entry" }),
|
||||||
|
]);
|
||||||
|
expect(calls()).toBe(1); // ONE GET, not two — no concurrent 503
|
||||||
|
expect(a.bytes.equals(b.bytes)).toBe(true); // both got the same frame
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reuses a fresh capture within the TTL (sequential, same vehicle)", async () => {
|
||||||
|
const { camera, calls } = fakeCamera();
|
||||||
|
const dev = id();
|
||||||
|
const a = await captureSnapshotShared(dev, camera, { direction: "entry" });
|
||||||
|
const b = await captureSnapshotShared(dev, camera, { direction: "entry" }); // ~0ms later
|
||||||
|
expect(calls()).toBe(1); // 2nd call served from the freshness cache
|
||||||
|
expect(a.bytes.equals(b.bytes)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("pulls AGAIN after the TTL lapses (a later, different vehicle)", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
try {
|
||||||
|
const { camera, calls } = fakeCamera();
|
||||||
|
const dev = id();
|
||||||
|
await captureSnapshotShared(dev, camera, { direction: "entry" });
|
||||||
|
expect(calls()).toBe(1);
|
||||||
|
await vi.advanceTimersByTimeAsync(2000); // past SNAPSHOT_TTL_MS (1500)
|
||||||
|
await captureSnapshotShared(dev, camera, { direction: "entry" });
|
||||||
|
expect(calls()).toBe(2); // stale → a real new pull (never a stale frame for a new car)
|
||||||
|
} finally {
|
||||||
|
vi.useRealTimers();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT cache a failure — the next caller retries", async () => {
|
||||||
|
const dev = id();
|
||||||
|
const failing = fakeCamera({ fail: true });
|
||||||
|
await expect(captureSnapshotShared(dev, failing.camera, { direction: "entry" })).rejects.toThrow("503");
|
||||||
|
// A subsequent capture (camera recovered) must actually pull, not inherit the error.
|
||||||
|
const ok = fakeCamera();
|
||||||
|
const shot = await captureSnapshotShared(dev, ok.camera, { direction: "entry" });
|
||||||
|
expect(shot.bytes.toString()).toBe("shot-x-1");
|
||||||
|
expect(ok.calls()).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keys by deviceId — different cameras never share a frame", async () => {
|
||||||
|
const c1 = fakeCamera({ tag: "A" });
|
||||||
|
const c2 = fakeCamera({ tag: "B" });
|
||||||
|
const s1 = await captureSnapshotShared("cam-A", c1.camera, { direction: "entry" });
|
||||||
|
const s2 = await captureSnapshotShared("cam-B", c2.camera, { direction: "entry" });
|
||||||
|
expect(c1.calls()).toBe(1);
|
||||||
|
expect(c2.calls()).toBe(1);
|
||||||
|
expect(s1.bytes.equals(s2.bytes)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// encodeForStorage: downscale + re-compress a captured frame for STORAGE (smaller, plate
|
||||||
|
// still readable). Recognition uses the original; this never runs on the OCR path. Fail-soft.
|
||||||
|
describe("encodeForStorage", () => {
|
||||||
|
/** A big synthetic JPEG (2688×1520, the Hikvision main-stream size) to downscale. */
|
||||||
|
async function bigJpeg(): Promise<Buffer> {
|
||||||
|
return sharp({
|
||||||
|
create: { width: 2688, height: 1520, channels: 3, background: { r: 120, g: 130, b: 140 } },
|
||||||
|
})
|
||||||
|
.jpeg({ quality: 95 })
|
||||||
|
.toBuffer();
|
||||||
|
}
|
||||||
|
|
||||||
|
it("downscales the long edge to ≤1280 and emits clean image/jpeg", async () => {
|
||||||
|
const bytes = await bigJpeg();
|
||||||
|
const shot: Snapshot = { bytes, contentType: 'image/jpeg; charset="UTF-8"', capturedAt: new Date().toISOString() };
|
||||||
|
const out = await encodeForStorage(shot, silentLogger());
|
||||||
|
expect(out.contentType).toBe("image/jpeg"); // charset cruft stripped
|
||||||
|
const meta = await sharp(out.bytes).metadata();
|
||||||
|
expect(Math.max(meta.width ?? 0, meta.height ?? 0)).toBeLessThanOrEqual(1280);
|
||||||
|
expect(out.bytes.length).toBeLessThan(bytes.length); // smaller than the original
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never enlarges an already-small image", async () => {
|
||||||
|
const small = await sharp({ create: { width: 640, height: 360, channels: 3, background: { r: 0, g: 0, b: 0 } } })
|
||||||
|
.jpeg()
|
||||||
|
.toBuffer();
|
||||||
|
const out = await encodeForStorage(
|
||||||
|
{ bytes: small, contentType: "image/jpeg", capturedAt: new Date().toISOString() },
|
||||||
|
silentLogger(),
|
||||||
|
);
|
||||||
|
const meta = await sharp(out.bytes).metadata();
|
||||||
|
expect(meta.width).toBe(640); // withoutEnlargement
|
||||||
|
expect(meta.height).toBe(360);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails soft: a non-image body is stored unchanged with a cleaned type", async () => {
|
||||||
|
const garbage = Buffer.from("this is not an image");
|
||||||
|
const out = await encodeForStorage(
|
||||||
|
{ bytes: garbage, contentType: 'text/plain; charset="UTF-8"', capturedAt: new Date().toISOString() },
|
||||||
|
silentLogger(),
|
||||||
|
);
|
||||||
|
expect(out.bytes.equals(garbage)).toBe(true); // original bytes, never dropped
|
||||||
|
expect(out.contentType).toBe("text/plain"); // charset stripped even on the fallback
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("cleanType", () => {
|
||||||
|
it("strips a camera's charset cruft so a binary JPEG renders", () => {
|
||||||
|
// The exact malformed value some cameras (Hikvision) return, which broke the
|
||||||
|
// snapshot strip for every legacy row until the serve route normalized it.
|
||||||
|
expect(cleanType('image/jpeg; charset="UTF-8"')).toBe("image/jpeg");
|
||||||
|
expect(cleanType("image/jpeg; charset=utf-8")).toBe("image/jpeg");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes a clean type through and defaults a missing one", () => {
|
||||||
|
expect(cleanType("image/jpeg")).toBe("image/jpeg");
|
||||||
|
expect(cleanType("image/png")).toBe("image/png");
|
||||||
|
expect(cleanType(null)).toBe("image/jpeg");
|
||||||
|
expect(cleanType(undefined)).toBe("image/jpeg");
|
||||||
|
expect(cleanType("")).toBe("image/jpeg");
|
||||||
|
});
|
||||||
|
});
|
||||||
+119
-4
@@ -1,8 +1,10 @@
|
|||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
|
import sharp from "sharp";
|
||||||
import { deviceEvents as deviceEventsTable, snapshots, type Db } from "@parking/db";
|
import { deviceEvents as deviceEventsTable, snapshots, type Db } from "@parking/db";
|
||||||
import { registry, type CameraDevice } from "@parking/devices";
|
import { registry, type CameraDevice, type Snapshot } from "@parking/devices";
|
||||||
import type { FastifyBaseLogger } from "fastify";
|
import type { FastifyBaseLogger } from "fastify";
|
||||||
import { devicesByDirection, type FlowDirection } from "./device-resolve.js";
|
import { devicesByDirection, type FlowDirection } from "./device-resolve.js";
|
||||||
|
import { deviceEvents } from "./device-events.js";
|
||||||
import type { VisionClient } from "./vision-client.js";
|
import type { VisionClient } from "./vision-client.js";
|
||||||
|
|
||||||
// Camera snapshot capture, fired AFTER the barrier opens and never awaited on the
|
// Camera snapshot capture, fired AFTER the barrier opens and never awaited on the
|
||||||
@@ -26,6 +28,47 @@ import type { VisionClient } from "./vision-client.js";
|
|||||||
// fire-and-forget: it never blocks the open and never changes the entry/exit decision —
|
// fire-and-forget: it never blocks the open and never changes the entry/exit decision —
|
||||||
// it's a record ("session X entered on plate AA558EE"). No polling; recognition only
|
// it's a record ("session X entered on plate AA558EE"). No polling; recognition only
|
||||||
// happens on a real entry/exit. See wiki/entities/opencv-anpr-service.md.
|
// happens on a real entry/exit. See wiki/entities/opencv-anpr-service.md.
|
||||||
|
//
|
||||||
|
// STORAGE RE-ENCODE (2026-06-28). Cameras serve full-res JPEGs (a Hikvision main stream is
|
||||||
|
// 2688×1520 / ~600 KB); stored raw, snapshots dominated the appliance DB (~72%). Each frame
|
||||||
|
// is now downscaled (long edge ≤ SNAPSHOT_MAX_EDGE) + re-compressed (q SNAPSHOT_JPEG_QUALITY)
|
||||||
|
// BEFORE storage — ~6–10× smaller, plate still clearly readable. RECOGNITION runs on the
|
||||||
|
// ORIGINAL full-res bytes (downscaling hurts OCR); the re-encode is storage-only. Fail-soft:
|
||||||
|
// a re-encode error stores the original, never drops the snapshot or blocks the open.
|
||||||
|
|
||||||
|
/** Long-edge cap (px) + JPEG quality for the STORED snapshot. Env-overridable per appliance. */
|
||||||
|
const SNAP_MAX_EDGE = Number(process.env.SNAPSHOT_MAX_EDGE ?? 1280);
|
||||||
|
const SNAP_QUALITY = Number(process.env.SNAPSHOT_JPEG_QUALITY ?? 80);
|
||||||
|
|
||||||
|
/** Strip a camera's `; charset=...` cruft from a content type (a JPEG is binary). A bare
|
||||||
|
* `image/jpeg` renders; `image/jpeg; charset="UTF-8"` (what some cameras return, e.g.
|
||||||
|
* Hikvision) is malformed for a binary body and browsers refuse to decode it. Applied
|
||||||
|
* both on capture AND when serving, so legacy rows stored before this normalization
|
||||||
|
* existed still serve a clean type. */
|
||||||
|
export function cleanType(ct: string | null | undefined): string {
|
||||||
|
const base = ct?.split(";")[0]?.trim();
|
||||||
|
return base || "image/jpeg";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Downscale + re-encode a captured frame for STORAGE (evidence, not OCR). Caps the long edge
|
||||||
|
* and re-compresses to JPEG. Fail-soft: any error (e.g. a non-image body) returns the original
|
||||||
|
* bytes with a cleaned content type, so a snapshot is never lost. */
|
||||||
|
export async function encodeForStorage(
|
||||||
|
shot: Snapshot,
|
||||||
|
logger: FastifyBaseLogger,
|
||||||
|
): Promise<{ bytes: Buffer; contentType: string }> {
|
||||||
|
try {
|
||||||
|
const out = await sharp(shot.bytes, { failOn: "none" })
|
||||||
|
.rotate() // honor EXIF orientation before we drop the metadata
|
||||||
|
.resize({ width: SNAP_MAX_EDGE, height: SNAP_MAX_EDGE, fit: "inside", withoutEnlargement: true })
|
||||||
|
.jpeg({ quality: SNAP_QUALITY, mozjpeg: true })
|
||||||
|
.toBuffer();
|
||||||
|
return { bytes: out, contentType: "image/jpeg" };
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn(`snapshot re-encode failed, storing original: ${(err as Error).message}`);
|
||||||
|
return { bytes: shot.bytes, contentType: cleanType(shot.contentType) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
interface SnapshotJob {
|
interface SnapshotJob {
|
||||||
readonly db: Db;
|
readonly db: Db;
|
||||||
@@ -62,16 +105,21 @@ export function snapshotAsync(job: SnapshotJob): Promise<string[]> {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const shot = await camera.captureSnapshot({ direction });
|
// Shared capture: if the ANPR bridge just pulled this camera's frame for the
|
||||||
|
// same vehicle, reuse it instead of a 2nd concurrent GET (which 503s).
|
||||||
|
const shot = await captureSnapshotShared(row.id, camera, { direction });
|
||||||
const id: string = randomUUID();
|
const id: string = randomUUID();
|
||||||
|
// Re-encode for STORAGE only (downscale + recompress). Recognition below still
|
||||||
|
// uses the original full-res `shot`.
|
||||||
|
const stored = await encodeForStorage(shot, logger);
|
||||||
db.insert(snapshots)
|
db.insert(snapshots)
|
||||||
.values({
|
.values({
|
||||||
id,
|
id,
|
||||||
direction,
|
direction,
|
||||||
deviceId: row.id,
|
deviceId: row.id,
|
||||||
identity,
|
identity,
|
||||||
contentType: shot.contentType,
|
contentType: stored.contentType,
|
||||||
bytes: shot.bytes,
|
bytes: stored.bytes,
|
||||||
capturedAt: shot.capturedAt,
|
capturedAt: shot.capturedAt,
|
||||||
})
|
})
|
||||||
.run();
|
.run();
|
||||||
@@ -136,6 +184,9 @@ async function recognizePlate(
|
|||||||
})
|
})
|
||||||
.run();
|
.run();
|
||||||
logger.info(`anpr plate '${plate}' (${result.plate.confidence.toFixed(3)}) for ${identity}`);
|
logger.info(`anpr plate '${plate}' (${result.plate.confidence.toFixed(3)}) for ${identity}`);
|
||||||
|
// The session's entry/exit event already shipped without this (async) plate — tell the
|
||||||
|
// booth so it backfills the plate badge in place (no refresh). Advisory; ledger untouched.
|
||||||
|
deviceEvents.emitPlateRecognized({ identity, plate, direction });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.warn(`anpr recognize failed (${identity}): ${(err as Error).message}`);
|
logger.warn(`anpr recognize failed (${identity}): ${(err as Error).message}`);
|
||||||
}
|
}
|
||||||
@@ -153,6 +204,70 @@ export function buildCamera(row: { driverId: string; config: unknown }): CameraD
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- shared snapshot capture (one HTTP pull per camera per vehicle) -----------
|
||||||
|
// A Hikvision camera serves /ISAPI/.../picture SINGLE-THREADED: two concurrent
|
||||||
|
// snapshot GETs to the same unit return HTTP 503 "service busy". On a vehicle entry
|
||||||
|
// TWO paths capture the SAME camera within ~1s — the ANPR bridge (barrier-driving,
|
||||||
|
// anpr-entry.ts) and the advisory snapshotAsync (evidence + telemetry, below). They
|
||||||
|
// each `buildCamera()` a SEPARATE adapter instance, so a per-instance cache can't
|
||||||
|
// dedupe them. This module-level, deviceId-keyed cache does: it coalesces in-flight
|
||||||
|
// captures (the 2nd caller awaits the 1st's pull) AND serves a result captured within
|
||||||
|
// SNAPSHOT_TTL_MS, so the bridge + advisory share ONE frame instead of colliding into
|
||||||
|
// a 503 (which then burned the bridge's 12s debounce → the slow entry observed
|
||||||
|
// 2026-06-25; see wiki/concepts/lane-presence-and-anpr-entry.md).
|
||||||
|
|
||||||
|
/** How long a fresh capture is reused for the same camera. A car is one event for a
|
||||||
|
* couple of seconds; 1.5s comfortably spans the bridge→advisory gap without ever
|
||||||
|
* serving a stale frame for a *different* vehicle (entries are seconds apart). */
|
||||||
|
const SNAPSHOT_TTL_MS = 1500;
|
||||||
|
|
||||||
|
interface CacheEntry {
|
||||||
|
/** A capture in flight — concurrent callers await this instead of issuing a 2nd GET. */
|
||||||
|
inflight?: Promise<Snapshot>;
|
||||||
|
/** The last SUCCESSFUL capture + when it resolved, for the freshness window. */
|
||||||
|
last?: { shot: Snapshot; at: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
const snapshotCache = new Map<string, CacheEntry>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Capture a snapshot for a camera, sharing ONE HTTP pull across concurrent/near-
|
||||||
|
* simultaneous callers (the ANPR bridge and the advisory snapshot). Same contract as
|
||||||
|
* `camera.captureSnapshot` (throws on failure) — a failed pull is NOT cached, so the
|
||||||
|
* next caller retries rather than inheriting the error. Key by the stable `deviceId`.
|
||||||
|
*/
|
||||||
|
export function captureSnapshotShared(
|
||||||
|
deviceId: string,
|
||||||
|
camera: CameraDevice,
|
||||||
|
ctx: { direction: FlowDirection },
|
||||||
|
): Promise<Snapshot> {
|
||||||
|
const now = Date.now();
|
||||||
|
let entry = snapshotCache.get(deviceId);
|
||||||
|
if (!entry) {
|
||||||
|
entry = {};
|
||||||
|
snapshotCache.set(deviceId, entry);
|
||||||
|
}
|
||||||
|
// Fresh enough → reuse the last frame (same vehicle, no second hardware hit).
|
||||||
|
if (entry.last && now - entry.last.at < SNAPSHOT_TTL_MS) {
|
||||||
|
return Promise.resolve(entry.last.shot);
|
||||||
|
}
|
||||||
|
// A capture is already running → join it (this is what prevents the 503 collision).
|
||||||
|
if (entry.inflight) return entry.inflight;
|
||||||
|
// Otherwise issue the single real pull; record it as the in-flight promise.
|
||||||
|
const pull = camera
|
||||||
|
.captureSnapshot(ctx)
|
||||||
|
.then((shot) => {
|
||||||
|
entry.last = { shot, at: Date.now() };
|
||||||
|
return shot;
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
// Clear the in-flight slot whether it resolved or threw; a failure is never cached.
|
||||||
|
if (entry.inflight === pull) entry.inflight = undefined;
|
||||||
|
});
|
||||||
|
entry.inflight = pull;
|
||||||
|
return pull;
|
||||||
|
}
|
||||||
|
|
||||||
function recordFailure(
|
function recordFailure(
|
||||||
db: Db,
|
db: Db,
|
||||||
direction: FlowDirection,
|
direction: FlowDirection,
|
||||||
|
|||||||
@@ -310,6 +310,15 @@ export class SubscriptionFlow {
|
|||||||
* subscription, (b) pick which occurrence a read closes, and (c) enforce
|
* subscription, (b) pick which occurrence a read closes, and (c) enforce
|
||||||
* `maxConcurrent`. The on-chain field is `permitId`, so we match against that.
|
* `maxConcurrent`. The on-chain field is `permitId`, so we match against that.
|
||||||
*/
|
*/
|
||||||
|
/** How many occurrences this subscription currently has OPEN (entries not yet exited).
|
||||||
|
* Public so the ANPR bridge can detect a credential (card/QR) exit landing mid-poll — if
|
||||||
|
* the count drops while it's polling, the subscriber already transacted and the bridge must
|
||||||
|
* NOT also emit (which would exit the NEXT open occurrence — a phantom double-exit, esp. for
|
||||||
|
* a fleet sub). See anpr-entry.ts. */
|
||||||
|
openOccurrenceCount(subscriptionId: string): number {
|
||||||
|
return this.#openOccurrences(subscriptionId).length;
|
||||||
|
}
|
||||||
|
|
||||||
#openOccurrences(subscriptionId: string): { identity: string; index: number }[] {
|
#openOccurrences(subscriptionId: string): { identity: string; index: number }[] {
|
||||||
const rows = this.#db.select().from(ledgerEvents).orderBy(ledgerEvents.index).all();
|
const rows = this.#db.select().from(ledgerEvents).orderBy(ledgerEvents.index).all();
|
||||||
// Net entries−exits per occurrence identity, keeping the entry order (oldest first).
|
// Net entries−exits per occurrence identity, keeping the entry order (oldest first).
|
||||||
|
|||||||
@@ -3,14 +3,16 @@
|
|||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"//": "Thin shim so this Python service is a first-class node in the Turbo task graph (it is NOT a JS package — deps are managed by uv/pyproject.toml). Each script shells to Python tooling. See wiki/decisions/vision-service-packaging.md.",
|
"//": "Thin shim so this Python service is a first-class node in the Turbo task graph (it is NOT a JS package — deps are managed by uv/pyproject.toml). Each script shells to Python tooling. See wiki/decisions/vision-service-packaging.md.",
|
||||||
|
"//alpr": "DEV self-heals real ANPR: `dev`/`start` run `uv sync --extra alpr` FIRST, because a plain `uv run` re-resolves the venv to the lockfile DEFAULTS and STRIPS fast-alpr (the cause of silent 'snapshot but no plate' after a prior pnpm dev). Syncing the extra here guarantees the recognizer survives every run. Use `dev:stub` for a lean, model-free local run. The BOOTH is unaffected — it runs the Docker image, which bakes `--extra alpr` at build (see Dockerfile + docker-compose.prod.yml).",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "uv run uvicorn vision_service.app:app --reload --host 0.0.0.0 --port 8089",
|
"dev": "uv sync --extra alpr && uv run uvicorn vision_service.app:app --reload --host 0.0.0.0 --port 8089",
|
||||||
"start": "uv run uvicorn vision_service.app:app --host 0.0.0.0 --port 8089",
|
"dev:stub": "uv run uvicorn vision_service.app:app --reload --host 0.0.0.0 --port 8089",
|
||||||
|
"start": "uv sync --extra alpr && uv run uvicorn vision_service.app:app --host 0.0.0.0 --port 8089",
|
||||||
"lint": "uv run ruff check .",
|
"lint": "uv run ruff check .",
|
||||||
"format": "uv run ruff format .",
|
"format": "uv run ruff format .",
|
||||||
"typecheck": "uv run mypy vision_service",
|
"typecheck": "uv run mypy vision_service",
|
||||||
"test": "uv run pytest -q",
|
"test": "uv run pytest -q",
|
||||||
"recognize": "uv run python -m vision_service.cli",
|
"recognize": "uv sync --extra alpr && uv run python -m vision_service.cli",
|
||||||
"build": "echo 'no build step (Python service; models fetched at deploy)'"
|
"build": "echo 'no build step (Python service; models fetched at deploy)'"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
# Production build env for the SPA (auto-loaded by `vite build`, which the Tauri
|
# Production build env for the SPA (auto-loaded by `vite build`). NOT loaded by `vite` dev.
|
||||||
# desktop bundle runs via beforeBuildCommand). NOT loaded by `vite` dev.
|
|
||||||
#
|
#
|
||||||
# The desktop shell serves the bundled SPA from tauri://localhost (no proxy, not
|
# RELATIVE /api base (empty value). The booth serves the SPA same-origin (Fastify serves
|
||||||
# same-origin), so the SPA must reach Fastify by absolute origin. This is the
|
# dist/, reached via Caddy on :80), so requests must stay relative — baking an absolute
|
||||||
# appliance's local Fastify address. Not a secret — committed for reproducible
|
# origin here would point the browser at the wrong host. This matches the deploy
|
||||||
# desktop builds. Override per-deployment if Fastify binds elsewhere.
|
# (wiki/decisions/container-deployment.md "Web access"; the 77b2acb fix).
|
||||||
#
|
#
|
||||||
# NOTE: a plain browser prod build (Fastify serving dist/ same-origin) does NOT
|
# DESKTOP (Tauri) NOTE: the desktop shell serves the SPA from tauri://localhost (no proxy,
|
||||||
# want this set. If you build the SPA for that, override VITE_API_BASE="" .
|
# not same-origin) and DOES need an absolute Fastify origin — but the desktop app is a
|
||||||
VITE_API_BASE=http://127.0.0.1:3000
|
# DEFERRED, separate task (it's currently hardcoded to localhost:3000; see apps/desktop +
|
||||||
|
# the desktop-app-hardcoded-localhost note). When that work resumes, set VITE_API_BASE to
|
||||||
|
# the appliance's Fastify origin for the desktop build only (e.g. via apps/desktop or an
|
||||||
|
# exported override), NOT here.
|
||||||
|
VITE_API_BASE=
|
||||||
|
|||||||
+78
-120
@@ -1,10 +1,9 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { fetchActiveSessions, reopenBarrier, type ActiveSession } from "./api.js";
|
import { fetchActiveSessions } from "./api.js";
|
||||||
import { qk } from "./lib/query.js";
|
import { qk } from "./lib/query.js";
|
||||||
import { useShift } from "./lib/use-shift.js";
|
import { formatCountdown, formatDuration, formatRelativeDateTime } from "./lib/format.js";
|
||||||
import { formatDuration, formatRelativeDateTime } from "./lib/format.js";
|
|
||||||
import { Panel } from "./ui/Panel.js";
|
import { Panel } from "./ui/Panel.js";
|
||||||
import { FilterBar, SegGroup, type SegOption } from "./ui/FilterBar.js";
|
import { FilterBar, SegGroup, type SegOption } from "./ui/FilterBar.js";
|
||||||
|
|
||||||
@@ -12,13 +11,8 @@ import { FilterBar, SegGroup, type SegOption } from "./ui/FilterBar.js";
|
|||||||
// within-grace (the barrier is UNCONFIRMED, so a paid/exited car is presumed
|
// within-grace (the barrier is UNCONFIRMED, so a paid/exited car is presumed
|
||||||
// possibly-present until grace runs out). Lets the operator find a stuck car —
|
// possibly-present until grace runs out). Lets the operator find a stuck car —
|
||||||
// damaged ticket, dead scanner, or a phantom barrier re-close — without a scan:
|
// damaged ticket, dead scanner, or a phantom barrier re-close — without a scan:
|
||||||
// - click a row → the pay/exit modal (pay an unpaid car, settle a subscriber's
|
// click a row → the pay/exit modal (pay an unpaid car, settle a subscriber's
|
||||||
// out-of-window charge, assist-open a prepaid subscriber, or review),
|
// out-of-window charge, assist-open a prepaid subscriber, or review).
|
||||||
// - "Open barrier" (PAID transient sessions only) → an audited human-intervention
|
|
||||||
// re-pulse for a car that paid but whose barrier didn't confirm.
|
|
||||||
// No payment → no Open barrier button (the no-unpaid-bypass rule). Subscriptions get
|
|
||||||
// NO inline open here — their assist-open / window-charge payment is modal-only, so
|
|
||||||
// the list can't one-click past an unpaid out-of-window charge.
|
|
||||||
//
|
//
|
||||||
// OVERSTAY sessions (paid, grace expired, no signed exit) are no longer aged out — they
|
// OVERSTAY sessions (paid, grace expired, no signed exit) are no longer aged out — they
|
||||||
// stay listed with a distinct badge. A new period has begun (the car re-parked or is
|
// stay listed with a distinct badge. A new period has begun (the car re-parked or is
|
||||||
@@ -26,33 +20,10 @@ import { FilterBar, SegGroup, type SegOption } from "./ui/FilterBar.js";
|
|||||||
// reconciles via the pay/exit modal — never a free barrier open.
|
// reconciles via the pay/exit modal — never a free barrier open.
|
||||||
// See wiki/concepts/booth-exit-flow.md.
|
// See wiki/concepts/booth-exit-flow.md.
|
||||||
|
|
||||||
type StatusFilter = "unpaid" | "paid" | "exiting" | "overstay";
|
|
||||||
type KindFilter = "transient" | "subscription";
|
type KindFilter = "transient" | "subscription";
|
||||||
|
|
||||||
function statusOf(s: ActiveSession): StatusFilter | "subscription" {
|
|
||||||
if (s.subscription) return "subscription";
|
|
||||||
if (s.overstay) return "overstay";
|
|
||||||
if (!s.open && s.withinGrace) return "exiting";
|
|
||||||
if (s.paidAt) return "paid";
|
|
||||||
return "unpaid";
|
|
||||||
}
|
|
||||||
|
|
||||||
function statusBadge(s: ActiveSession): { key: string; titleKey?: string; cls: string } {
|
|
||||||
if (s.subscription) return { key: "booth.badgeSubscription", cls: "text-term-cyan" };
|
|
||||||
if (s.overstay)
|
|
||||||
return { key: "booth.badgeOverstay", titleKey: "booth.badgeOverstayTitle", cls: "text-term-red" };
|
|
||||||
if (!s.open && s.withinGrace) return { key: "booth.badgeExiting", cls: "text-term-cyan" };
|
|
||||||
if (s.paidAt) return { key: "booth.badgePaid", cls: "text-term-green" };
|
|
||||||
return { key: "booth.badgeUnpaid", cls: "text-term-amber" };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ActiveSessions({ onPick }: { onPick: (identity: string) => void }) {
|
export function ActiveSessions({ onPick }: { onPick: (identity: string) => void }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const qc = useQueryClient();
|
|
||||||
// The audited barrier re-open is a money-path action (server-gated on an open
|
|
||||||
// shift); disable it unless this operator's shift is open.
|
|
||||||
const { isOpen: shiftOpen, isMine: shiftMine } = useShift();
|
|
||||||
const shiftReady = shiftOpen && shiftMine;
|
|
||||||
const { data, isLoading } = useQuery({
|
const { data, isLoading } = useQuery({
|
||||||
queryKey: qk.activeSessions,
|
queryKey: qk.activeSessions,
|
||||||
queryFn: fetchActiveSessions,
|
queryFn: fetchActiveSessions,
|
||||||
@@ -61,18 +32,17 @@ export function ActiveSessions({ onPick }: { onPick: (identity: string) => void
|
|||||||
refetchInterval: 15_000,
|
refetchInterval: 15_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
const reopen = useMutation({
|
// A 1-second clock so the within-grace countdown badge ticks live (the query only
|
||||||
mutationFn: (identity: string) => reopenBarrier(identity),
|
// refetches every 15s; the badge needs per-second resolution).
|
||||||
onSettled: () => {
|
const [nowMs, setNowMs] = useState(() => Date.now());
|
||||||
void qc.invalidateQueries({ queryKey: qk.activeSessions });
|
useEffect(() => {
|
||||||
void qc.invalidateQueries({ queryKey: qk.events });
|
const id = setInterval(() => setNowMs(Date.now()), 1000);
|
||||||
},
|
return () => clearInterval(id);
|
||||||
});
|
}, []);
|
||||||
const [reopenMsg, setReopenMsg] = useState<{ id: string; text: string; ok: boolean } | null>(null);
|
|
||||||
|
|
||||||
// Filters: free-text search, status, and transient-vs-subscriber.
|
// Filters: free-text search + transient-vs-subscriber. (No status filter — the status
|
||||||
|
// column was dropped; an unpaid transient is normal and a subscriber is marked ★.)
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [status, setStatus] = useState<StatusFilter | "">("");
|
|
||||||
const [kind, setKind] = useState<KindFilter | "">("");
|
const [kind, setKind] = useState<KindFilter | "">("");
|
||||||
|
|
||||||
const sessions = useMemo(() => data?.sessions ?? [], [data]);
|
const sessions = useMemo(() => data?.sessions ?? [], [data]);
|
||||||
@@ -81,45 +51,25 @@ export function ActiveSessions({ onPick }: { onPick: (identity: string) => void
|
|||||||
return sessions.filter((s) => {
|
return sessions.filter((s) => {
|
||||||
if (kind === "transient" && s.subscription) return false;
|
if (kind === "transient" && s.subscription) return false;
|
||||||
if (kind === "subscription" && !s.subscription) return false;
|
if (kind === "subscription" && !s.subscription) return false;
|
||||||
if (status && statusOf(s) !== status) return false;
|
|
||||||
if (q) {
|
if (q) {
|
||||||
const hay = `${s.identity} ${s.subscriptionHolder ?? ""}`.toLowerCase();
|
// Include the enriched plate (`s.plate`, the displayed badge) so a plate search hits.
|
||||||
|
const hay = `${s.identity} ${s.subscriptionHolder ?? ""} ${s.plate ?? ""}`.toLowerCase();
|
||||||
if (!hay.includes(q)) return false;
|
if (!hay.includes(q)) return false;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
}, [sessions, search, status, kind]);
|
}, [sessions, search, kind]);
|
||||||
|
|
||||||
const statusOpts: SegOption<StatusFilter>[] = [
|
|
||||||
{ value: "unpaid", label: t("booth.fStatusUnpaid") },
|
|
||||||
{ value: "paid", label: t("booth.fStatusPaid") },
|
|
||||||
{ value: "exiting", label: t("booth.fStatusExiting") },
|
|
||||||
{ value: "overstay", label: t("booth.fStatusOverstay") },
|
|
||||||
];
|
|
||||||
const kindOpts: SegOption<KindFilter>[] = [
|
const kindOpts: SegOption<KindFilter>[] = [
|
||||||
{ value: "transient", label: t("booth.fKindTransient") },
|
{ value: "transient", label: t("booth.fKindTransient") },
|
||||||
{ value: "subscription", label: t("booth.fKindSubscription") },
|
{ value: "subscription", label: t("booth.fKindSubscription") },
|
||||||
];
|
];
|
||||||
|
|
||||||
async function handleReopen(s: ActiveSession) {
|
|
||||||
setReopenMsg(null);
|
|
||||||
try {
|
|
||||||
const r = await reopen.mutateAsync(s.identity);
|
|
||||||
setReopenMsg({
|
|
||||||
id: s.identity,
|
|
||||||
ok: r.opened,
|
|
||||||
text: r.opened ? t("booth.barrierOpened") : r.reason ?? t("booth.openManually"),
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
setReopenMsg({ id: s.identity, ok: false, text: (e as Error).message });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Panel
|
<Panel
|
||||||
title={t("booth.activeSessions")}
|
title={t("booth.activeSessions")}
|
||||||
right={
|
right={
|
||||||
<span className="text-[10px] uppercase tracking-wider text-term-muted">
|
<span className="text-[0.625rem] uppercase tracking-wider text-term-muted">
|
||||||
{filtered.length}
|
{filtered.length}
|
||||||
{filtered.length !== sessions.length ? `/${sessions.length}` : ""} {t("booth.insideCount")}
|
{filtered.length !== sessions.length ? `/${sessions.length}` : ""} {t("booth.insideCount")}
|
||||||
</span>
|
</span>
|
||||||
@@ -128,7 +78,6 @@ export function ActiveSessions({ onPick }: { onPick: (identity: string) => void
|
|||||||
>
|
>
|
||||||
<div className="flex h-full flex-col">
|
<div className="flex h-full flex-col">
|
||||||
<FilterBar search={search} onSearch={setSearch} searchPlaceholder={t("booth.filterSearchSessions")}>
|
<FilterBar search={search} onSearch={setSearch} searchPlaceholder={t("booth.filterSearchSessions")}>
|
||||||
<SegGroup value={status} options={statusOpts} onChange={setStatus} allLabel={t("booth.filterAll")} />
|
|
||||||
<SegGroup value={kind} options={kindOpts} onChange={setKind} allLabel={t("booth.filterAll")} />
|
<SegGroup value={kind} options={kindOpts} onChange={setKind} allLabel={t("booth.filterAll")} />
|
||||||
</FilterBar>
|
</FilterBar>
|
||||||
|
|
||||||
@@ -142,23 +91,58 @@ export function ActiveSessions({ onPick }: { onPick: (identity: string) => void
|
|||||||
: t("booth.noMatch")}
|
: t("booth.noMatch")}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
filtered.map((s) => {
|
// A real table — aligned columns (who · plate · entry · elapsed). No status
|
||||||
const badge = statusBadge(s);
|
// column: an unpaid transient is the normal case, and a subscriber is already
|
||||||
const msg = reopenMsg?.id === s.identity ? reopenMsg : null;
|
// marked with ★ + holder name. Overstay (a top-up is owed) keeps a row tint so
|
||||||
|
// that fraud-relevant signal isn't lost. The whole row is clickable (→ pay/exit
|
||||||
|
// modal).
|
||||||
|
<table className="w-full text-[0.75rem] tabular-nums">
|
||||||
|
<thead className="sticky top-0 bg-term-panel-2 text-[0.6875rem] uppercase tracking-wider text-term-muted">
|
||||||
|
<tr>
|
||||||
|
<th className="px-2 py-1.5 text-left font-semibold">{t("booth.colWho")}</th>
|
||||||
|
<th className="px-2 py-1.5 text-left font-semibold">{t("booth.colPlate")}</th>
|
||||||
|
<th className="whitespace-nowrap px-2 py-1.5 text-left font-semibold">{t("booth.colEntry")}</th>
|
||||||
|
<th className="whitespace-nowrap px-2 py-1.5 text-left font-semibold">{t("booth.colElapsed")}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{filtered.map((s) => {
|
||||||
|
// EXITED-WITHIN-GRACE: a paid transient whose exit is recorded but the
|
||||||
|
// barrier didn't confirm — it lingers here until grace runs out. Mark it
|
||||||
|
// so the operator can tell it apart from a still-inside car (clicking it
|
||||||
|
// opens the modal's manual barrier re-open, not a pay flow).
|
||||||
|
const closedInGrace = !s.open && s.withinGrace && !s.subscription;
|
||||||
|
// Live grace-remaining for the badge (M:SS). Null once it lapses — the
|
||||||
|
// next refetch (≤15s) reclassifies the row (overstay / gone); until then
|
||||||
|
// we show a generic label so the badge doesn't flicker empty.
|
||||||
|
const graceLeft = closedInGrace ? formatCountdown(s.graceExpiresAt, nowMs) : null;
|
||||||
return (
|
return (
|
||||||
<div
|
<tr
|
||||||
key={s.identity}
|
key={s.identity}
|
||||||
className="flex items-center gap-3 border-b border-term-border/50 py-1.5 text-[12px] tabular-nums"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => onPick(s.identity)}
|
onClick={() => onPick(s.identity)}
|
||||||
className="flex flex-1 items-center gap-3 text-left hover:text-term-amber"
|
className={`cursor-pointer border-t border-term-border/50 hover:bg-term-panel-2 ${
|
||||||
title={t("booth.openPayExit")}
|
s.overstay ? "bg-term-red/5" : closedInGrace ? "bg-term-amber/5 text-term-muted" : ""
|
||||||
|
}`}
|
||||||
|
title={closedInGrace ? t("booth.openReopenBarrier") : t("booth.openPayExit")}
|
||||||
>
|
>
|
||||||
<span className="text-term-text">
|
<td className="px-2 py-1.5 text-term-text">
|
||||||
{s.subscription ? `★ ${s.subscriptionHolder ?? t("subs.unnamed")}` : s.identity}
|
{s.subscription ? (
|
||||||
|
<span className="text-term-cyan">★ {s.subscriptionHolder ?? t("subs.unnamed")}</span>
|
||||||
|
) : (
|
||||||
|
<span className="inline-flex items-center gap-1.5">
|
||||||
|
{s.identity}
|
||||||
|
{closedInGrace && (
|
||||||
|
<span
|
||||||
|
className="rounded border border-term-amber/60 px-1 text-[0.5625rem] uppercase tracking-wider tabular-nums text-term-amber"
|
||||||
|
title={t("booth.exitedGraceTitle")}
|
||||||
|
>
|
||||||
|
{graceLeft ? t("booth.exitedGraceLeft", { time: graceLeft }) : t("booth.exitedGrace")}
|
||||||
</span>
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-1.5">
|
||||||
{s.plate && (
|
{s.plate && (
|
||||||
<span
|
<span
|
||||||
className="rounded border border-term-border px-1 font-semibold tracking-wide text-term-amber"
|
className="rounded border border-term-border px-1 font-semibold tracking-wide text-term-amber"
|
||||||
@@ -167,45 +151,19 @@ export function ActiveSessions({ onPick }: { onPick: (identity: string) => void
|
|||||||
{s.plate}
|
{s.plate}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<span className="text-term-muted">{formatRelativeDateTime(s.enteredAt, t)}</span>
|
</td>
|
||||||
<span className="text-term-muted">{formatDuration(s.enteredAt, new Date().toISOString())}</span>
|
<td className="whitespace-nowrap px-2 py-1.5 text-term-muted">
|
||||||
<span
|
{formatRelativeDateTime(s.enteredAt, t)}
|
||||||
className={`ml-auto w-16 text-right font-semibold uppercase ${badge.cls}`}
|
</td>
|
||||||
title={badge.titleKey ? t(badge.titleKey) : undefined}
|
<td className="whitespace-nowrap px-2 py-1.5 text-term-muted">
|
||||||
>
|
{/* Freeze the elapsed at the recorded exit for a closed-in-grace row. */}
|
||||||
{t(badge.key)}
|
{formatDuration(s.enteredAt, (closedInGrace ? s.exitedAt : null) ?? new Date().toISOString())}
|
||||||
</span>
|
</td>
|
||||||
</button>
|
</tr>
|
||||||
|
|
||||||
{/* Open barrier — PAID-and-still-in-grace TRANSIENT only: an audited
|
|
||||||
re-pulse for a car that paid but the barrier didn't confirm. NOT an
|
|
||||||
OVERSTAY (grace expired → owes a top-up; routes to the pay/exit modal)
|
|
||||||
and NOT a SUBSCRIPTION (the assist-open, and any out-of-window payment,
|
|
||||||
live in the pay/exit modal — the list must not offer a one-click open,
|
|
||||||
which would bypass an unpaid window charge). An unpaid transient has no
|
|
||||||
button either (no-unpaid-bypass). Mirrors reopenBarrier's server guard. */}
|
|
||||||
{s.paidAt && !s.overstay && !s.subscription ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
disabled={reopen.isPending || !shiftReady}
|
|
||||||
onClick={() => handleReopen(s)}
|
|
||||||
className="btn btn-pay btn-sm shrink-0"
|
|
||||||
title={shiftReady ? t("booth.openBarrierTitle") : t("shift.gateTitle")}
|
|
||||||
>
|
|
||||||
{t("booth.openBarrier")}
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<span className="w-[88px] shrink-0" />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{msg && (
|
|
||||||
<span className={`shrink-0 text-[10px] ${msg.ok ? "text-term-green" : "text-term-red"}`}>
|
|
||||||
{msg.text}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
})
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { fetchMe, type SessionUser } from "./api.js";
|
|||||||
import { Login } from "./Login.js";
|
import { Login } from "./Login.js";
|
||||||
import { queryClient } from "./lib/query.js";
|
import { queryClient } from "./lib/query.js";
|
||||||
import { setLanguage } from "./lib/i18n/index.js";
|
import { setLanguage } from "./lib/i18n/index.js";
|
||||||
import { applyTheme } from "./lib/theme.js";
|
import { applyTheme, applyFontScale } from "./lib/theme.js";
|
||||||
import { router } from "./router.js";
|
import { router } from "./router.js";
|
||||||
|
|
||||||
// App root: bootstraps the session (cookie-based, from /api/auth/me), then hands
|
// App root: bootstraps the session (cookie-based, from /api/auth/me), then hands
|
||||||
@@ -24,15 +24,17 @@ export function App() {
|
|||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Apply the signed-in user's preferred language + theme whenever they resolve/
|
// Apply the signed-in user's preferred language + theme + font scale whenever they
|
||||||
// change (login, bootstrap, or a toggle). Albanian + dark are the defaults before
|
// resolve/change (login, bootstrap, or a toggle). Albanian + dark + 100% are the defaults
|
||||||
// auth resolves; on logout, fall back to dark so the Login screen is consistent.
|
// before auth resolves; on logout, fall back so the Login screen is consistent.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (user) {
|
if (user) {
|
||||||
setLanguage(user.language);
|
setLanguage(user.language);
|
||||||
applyTheme(user.theme);
|
applyTheme(user.theme);
|
||||||
|
applyFontScale(user.fontScale);
|
||||||
} else {
|
} else {
|
||||||
applyTheme("dark");
|
applyTheme("dark");
|
||||||
|
applyFontScale(100);
|
||||||
}
|
}
|
||||||
}, [user]);
|
}, [user]);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,280 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
ApiError,
|
||||||
|
fetchBackupStatus,
|
||||||
|
runBackup,
|
||||||
|
setBackupConfig,
|
||||||
|
testBackupTarget,
|
||||||
|
type BackupStatus,
|
||||||
|
type TargetCheck,
|
||||||
|
} from "./api.js";
|
||||||
|
import { formatRelativeDateTime } from "./lib/format.js";
|
||||||
|
|
||||||
|
// Admin screen for the on-site encrypted DB backup. The admin picks the TARGET DIRECTORY here
|
||||||
|
// (stored in site_config; a mounted USB/SATA/SMB/NFS path) — the encryption key stays a server
|
||||||
|
// secret. Shows status + last-run outcome, a "Test target" probe, and a manual "Back up now".
|
||||||
|
// Gated by backup:read (config/test by backup:update, run by backup:create). RESTORE is absent
|
||||||
|
// by design — out-of-band on a fresh appliance. See wiki/concepts/backup-recovery.md.
|
||||||
|
|
||||||
|
function formatBytes(n: number): string {
|
||||||
|
if (n < 1024) return `${n} B`;
|
||||||
|
const mb = n / 1048576;
|
||||||
|
if (mb < 1024) return `${mb.toFixed(1)} MB`;
|
||||||
|
return `${(mb / 1024).toFixed(2)} GB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Map a target-check result to a localized message. */
|
||||||
|
function checkMessage(c: TargetCheck, t: (k: string) => string): string {
|
||||||
|
if (c.ok) return t("backup.testOk");
|
||||||
|
switch (c.reason) {
|
||||||
|
case "empty":
|
||||||
|
return t("backup.testEmpty");
|
||||||
|
case "not_a_dir":
|
||||||
|
return t("backup.testNotDir");
|
||||||
|
case "not_writable":
|
||||||
|
return t("backup.testNotWritable");
|
||||||
|
default:
|
||||||
|
return t("backup.testMissing");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusBadge({ status }: { status: BackupStatus }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
if (!status.configured) {
|
||||||
|
return <span className="text-[0.75rem] font-semibold text-term-muted">{t("backup.notConfigured")}</span>;
|
||||||
|
}
|
||||||
|
if (status.running) {
|
||||||
|
return <span className="text-[0.75rem] font-semibold text-term-amber">{t("backup.running")}</span>;
|
||||||
|
}
|
||||||
|
return <span className="text-[0.75rem] font-semibold text-term-green">{t("backup.configured")}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BackupSettings() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const [toast, setToast] = useState<{ kind: "ok" | "err"; msg: string } | null>(null);
|
||||||
|
const [target, setTarget] = useState("");
|
||||||
|
const [keepLast, setKeepLast] = useState("");
|
||||||
|
const [keepDaily, setKeepDaily] = useState("");
|
||||||
|
const [check, setCheck] = useState<{ kind: "ok" | "err"; msg: string } | null>(null);
|
||||||
|
|
||||||
|
const q = useQuery({
|
||||||
|
queryKey: ["backup-status"],
|
||||||
|
queryFn: fetchBackupStatus,
|
||||||
|
refetchInterval: (query) => (query.state.data?.running ? 2000 : false),
|
||||||
|
});
|
||||||
|
const status = q.data;
|
||||||
|
|
||||||
|
// Seed the editable fields from the saved values once they load (and on server-side change).
|
||||||
|
useEffect(() => {
|
||||||
|
if (status) {
|
||||||
|
setTarget(status.targetDir ?? "");
|
||||||
|
setKeepLast(String(status.keepLast));
|
||||||
|
setKeepDaily(String(status.keepDailyDays));
|
||||||
|
}
|
||||||
|
}, [status?.targetDir, status?.keepLast, status?.keepDailyDays]);
|
||||||
|
|
||||||
|
const save = useMutation({
|
||||||
|
mutationFn: () =>
|
||||||
|
setBackupConfig({
|
||||||
|
targetDir: target.trim() || null,
|
||||||
|
keepLast: keepLast.trim() === "" ? null : Number(keepLast),
|
||||||
|
keepDailyDays: keepDaily.trim() === "" ? null : Number(keepDaily),
|
||||||
|
}),
|
||||||
|
onSuccess: (next) => {
|
||||||
|
setToast({ kind: "ok", msg: t("backup.saved") });
|
||||||
|
setCheck(null);
|
||||||
|
qc.setQueryData(["backup-status"], next);
|
||||||
|
},
|
||||||
|
onError: () => setToast({ kind: "err", msg: t("backup.runFailed") }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const test = useMutation({
|
||||||
|
mutationFn: () => testBackupTarget(target.trim()),
|
||||||
|
onSuccess: (res) => setCheck({ kind: res.ok ? "ok" : "err", msg: checkMessage(res, t) }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const run = useMutation({
|
||||||
|
mutationFn: runBackup,
|
||||||
|
onSuccess: () => {
|
||||||
|
setToast({ kind: "ok", msg: t("backup.runSuccess") });
|
||||||
|
void qc.invalidateQueries({ queryKey: ["backup-status"] });
|
||||||
|
},
|
||||||
|
onError: (err: unknown) => {
|
||||||
|
const code = err instanceof ApiError ? err.message : "";
|
||||||
|
setToast({
|
||||||
|
kind: "err",
|
||||||
|
msg: code === "backup_not_configured" ? t("backup.notConfiguredError") : t("backup.runFailed"),
|
||||||
|
});
|
||||||
|
void qc.invalidateQueries({ queryKey: ["backup-status"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const dirty =
|
||||||
|
(status?.targetDir ?? "") !== target.trim() ||
|
||||||
|
String(status?.keepLast ?? "") !== keepLast.trim() ||
|
||||||
|
String(status?.keepDailyDays ?? "") !== keepDaily.trim();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="">
|
||||||
|
<div className="mb-3 flex items-center justify-between">
|
||||||
|
<h1 className="text-sm font-bold uppercase tracking-widest text-term-amber">{t("backup.title")}</h1>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary btn-sm"
|
||||||
|
disabled={!status?.configured || status?.running || run.isPending || dirty}
|
||||||
|
onClick={() => {
|
||||||
|
setToast(null);
|
||||||
|
run.mutate();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{status?.running || run.isPending ? t("backup.running") : t("backup.runNow")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="mb-3 max-w-2xl text-[0.75rem] text-term-muted">{t("backup.intro")}</p>
|
||||||
|
|
||||||
|
{toast && (
|
||||||
|
<div
|
||||||
|
className={`mb-3 rounded-term border px-3 py-2 text-[0.75rem] ${
|
||||||
|
toast.kind === "ok"
|
||||||
|
? "border-term-green/40 bg-term-green/5 text-term-green"
|
||||||
|
: "border-term-red/40 bg-term-red/5 text-term-red"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{toast.msg}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Config — admin-chosen destination + retention policy. */}
|
||||||
|
<div className="card mb-3 p-4">
|
||||||
|
{/* Target directory + its Test probe. */}
|
||||||
|
<div className="field">
|
||||||
|
<span className="label">{t("backup.targetLabel")}</span>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<input
|
||||||
|
className="input w-96 max-w-full"
|
||||||
|
value={target}
|
||||||
|
placeholder={t("backup.targetPlaceholder")}
|
||||||
|
onChange={(e) => {
|
||||||
|
setTarget(e.target.value);
|
||||||
|
setCheck(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost btn-sm"
|
||||||
|
disabled={test.isPending || !target.trim()}
|
||||||
|
onClick={() => test.mutate()}
|
||||||
|
>
|
||||||
|
{t("backup.test")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<span className="mt-1 text-[0.6875rem] text-term-muted">{t("backup.targetHint")}</span>
|
||||||
|
{check && (
|
||||||
|
<span className={`mt-1 text-[0.75rem] ${check.kind === "ok" ? "text-term-green" : "text-term-red"}`}>
|
||||||
|
{check.msg}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Retention — admin-tuned policy (how many backups to keep at the target). */}
|
||||||
|
<div className="mt-4 flex flex-wrap items-start gap-6">
|
||||||
|
<div className="field">
|
||||||
|
<span className="label">{t("backup.keepLastLabel")}</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
className="input w-28"
|
||||||
|
value={keepLast}
|
||||||
|
onChange={(e) => setKeepLast(e.target.value)}
|
||||||
|
/>
|
||||||
|
<span className="mt-1 text-[0.6875rem] text-term-muted">{t("backup.keepLastHint")}</span>
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<span className="label">{t("backup.keepDailyLabel")}</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
className="input w-28"
|
||||||
|
value={keepDaily}
|
||||||
|
onChange={(e) => setKeepDaily(e.target.value)}
|
||||||
|
/>
|
||||||
|
<span className="mt-1 text-[0.6875rem] text-term-muted">{t("backup.keepDailyHint")}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary btn-sm"
|
||||||
|
disabled={save.isPending || !dirty}
|
||||||
|
onClick={() => {
|
||||||
|
setToast(null);
|
||||||
|
save.mutate();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("backup.save")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card p-4">
|
||||||
|
{q.isLoading || !status ? (
|
||||||
|
<div className="text-[0.75rem] text-term-muted">{t("common.loading")}</div>
|
||||||
|
) : (
|
||||||
|
<dl className="grid grid-cols-[10rem_1fr] gap-x-4 gap-y-2 text-[0.8125rem]">
|
||||||
|
<dt className="text-term-muted">{t("backup.statusTitle")}</dt>
|
||||||
|
<dd>
|
||||||
|
<StatusBadge status={status} />
|
||||||
|
</dd>
|
||||||
|
|
||||||
|
{!status.keyPresent && (
|
||||||
|
<>
|
||||||
|
<dt className="text-term-muted" />
|
||||||
|
<dd className="text-[0.75rem] text-term-amber">{t("backup.keyMissing")}</dd>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<dt className="text-term-muted">{t("backup.lastSuccess")}</dt>
|
||||||
|
<dd className="text-term-text">
|
||||||
|
{status.lastSuccessAt ? formatRelativeDateTime(status.lastSuccessAt, t) : t("backup.never")}
|
||||||
|
</dd>
|
||||||
|
|
||||||
|
{status.lastResult && (
|
||||||
|
<>
|
||||||
|
<dt className="text-term-muted">{t("backup.size")}</dt>
|
||||||
|
<dd className="text-term-text tabular-nums">
|
||||||
|
{formatBytes(status.lastResult.bytes)}
|
||||||
|
{status.lastResult.prunedFiles > 0 && (
|
||||||
|
<span className="ml-2 text-term-muted">
|
||||||
|
({t("backup.pruned")}: {status.lastResult.prunedFiles})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</dd>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status.lastError && (
|
||||||
|
<>
|
||||||
|
<dt className="text-term-muted">{t("backup.lastError")}</dt>
|
||||||
|
<dd className="text-term-red">
|
||||||
|
{status.lastError}
|
||||||
|
{status.lastErrorAt && (
|
||||||
|
<span className="ml-2 text-term-muted">
|
||||||
|
({formatRelativeDateTime(status.lastErrorAt, t)})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</dd>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</dl>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="mt-3 max-w-2xl text-[0.6875rem] text-term-muted">{t("backup.restoreNote")}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+151
-29
@@ -19,6 +19,7 @@ import { rootRoute } from "./router.js";
|
|||||||
import { qk } from "./lib/query.js";
|
import { qk } from "./lib/query.js";
|
||||||
import { useShift } from "./lib/use-shift.js";
|
import { useShift } from "./lib/use-shift.js";
|
||||||
import { formatDuration, formatMoney, formatTime, formatRelativeDateTime } from "./lib/format.js";
|
import { formatDuration, formatMoney, formatTime, formatRelativeDateTime } from "./lib/format.js";
|
||||||
|
import { CARD_PAYMENTS_ENABLED } from "./lib/features.js";
|
||||||
import { SnapshotStrip } from "./ui/SnapshotStrip.js";
|
import { SnapshotStrip } from "./ui/SnapshotStrip.js";
|
||||||
|
|
||||||
// The booth pay/exit modal. Opened when the operator submits a ticket id. Shows the
|
// The booth pay/exit modal. Opened when the operator submits a ticket id. Shows the
|
||||||
@@ -59,6 +60,9 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
|||||||
const { user } = rootRoute.useRouteContext();
|
const { user } = rootRoute.useRouteContext();
|
||||||
const canVoid = can(user, "event:void");
|
const canVoid = can(user, "event:void");
|
||||||
const [voiding, setVoiding] = useState(false); // reason prompt revealed
|
const [voiding, setVoiding] = useState(false); // reason prompt revealed
|
||||||
|
// Plate-swap: set when boothExit returns swap_suspected. Holds the detail for the warning
|
||||||
|
// panel; the operator must consciously "Override & release". See plate-reconciliation.md.
|
||||||
|
const [swap, setSwap] = useState<{ plate: string; otherIdentity: string; otherEnteredAt: string | null } | null>(null);
|
||||||
const [voidReason, setVoidReason] = useState("");
|
const [voidReason, setVoidReason] = useState("");
|
||||||
|
|
||||||
const s: SessionLookup | undefined = session.data;
|
const s: SessionLookup | undefined = session.data;
|
||||||
@@ -73,6 +77,12 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
|||||||
// exit. A normal within-grace paid session is NOT payable (it's settled). See
|
// exit. A normal within-grace paid session is NOT payable (it's settled). See
|
||||||
// booth-exit-flow.md / reopenBarrier server guard.
|
// booth-exit-flow.md / reopenBarrier server guard.
|
||||||
const isOverstay = s?.overstay === true;
|
const isOverstay = s?.overstay === true;
|
||||||
|
// CLOSED-WITHIN-GRACE: a paid transient whose exit was already signed but the barrier
|
||||||
|
// didn't confirm — it lingers in the active list until grace runs out (the "phantom
|
||||||
|
// re-close" / damaged-ticket case). `s.open` is false, so it's not payable and not the
|
||||||
|
// normal review flow; the only action is an audited manual re-pulse of the barrier.
|
||||||
|
// (A grace-EXPIRED closed session falls through to the plain "already closed" notice.)
|
||||||
|
const closedWithinGrace = !!(s?.found && !s.open && s.withinGrace && !isSubscription);
|
||||||
// A subscription is normally prepaid (never charged). EXCEPTION: a time-window plan can
|
// A subscription is normally prepaid (never charged). EXCEPTION: a time-window plan can
|
||||||
// owe an out-of-window TARIFF-BRIDGE charge (early entry / late exit) — lookup() returns
|
// owe an out-of-window TARIFF-BRIDGE charge (early entry / late exit) — lookup() returns
|
||||||
// it as s.amountMinor, and exit is GATED until it's paid. So a subscription IS payable
|
// it as s.amountMinor, and exit is GATED until it's paid. So a subscription IS payable
|
||||||
@@ -171,7 +181,7 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handlePayAndExit() {
|
async function handlePayAndExit(override = false) {
|
||||||
if (!s) return;
|
if (!s) return;
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
@@ -179,7 +189,8 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
|||||||
// session is "already paid" but a new period accrued — we still charge (canPay
|
// session is "already paid" but a new period accrued — we still charge (canPay
|
||||||
// is true). A settled within-grace session is not payable (canPay false) and is
|
// is true). A settled within-grace session is not payable (canPay false) and is
|
||||||
// skipped. The server re-quotes authoritatively (overstay → from grace-expiry).
|
// skipped. The server re-quotes authoritatively (overstay → from grace-expiry).
|
||||||
if (canPay) {
|
// On an OVERRIDE re-submit the payment already happened; don't double-charge.
|
||||||
|
if (canPay && !override) {
|
||||||
setPhase("paying");
|
setPhase("paying");
|
||||||
await paySession(identity, tender);
|
await paySession(identity, tender);
|
||||||
}
|
}
|
||||||
@@ -190,7 +201,14 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
|||||||
const r = await printVoucher(identity);
|
const r = await printVoucher(identity);
|
||||||
setResult(t("pay.voucherPrinted", { printer: r.printedBy }));
|
setResult(t("pay.voucherPrinted", { printer: r.printedBy }));
|
||||||
} else {
|
} else {
|
||||||
const r = await boothExit(identity);
|
const r = await boothExit(identity, override);
|
||||||
|
// PLATE-SWAP suspected → don't exit; surface the warning + offer an override.
|
||||||
|
if (!r.ok) {
|
||||||
|
setSwap({ plate: r.plate, otherIdentity: r.otherIdentity, otherEnteredAt: r.otherEnteredAt });
|
||||||
|
setPhase("review");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSwap(null);
|
||||||
// No voucher → auto-print a standalone payment receipt for transparency.
|
// No voucher → auto-print a standalone payment receipt for transparency.
|
||||||
// Best-effort: a printer fault must NOT block the exit that already happened;
|
// Best-effort: a printer fault must NOT block the exit that already happened;
|
||||||
// the operator can reprint from the done screen.
|
// the operator can reprint from the done screen.
|
||||||
@@ -226,7 +244,7 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
|||||||
aria-describedby={undefined}
|
aria-describedby={undefined}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between border-b border-term-border bg-term-panel-2 px-4 py-2">
|
<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">
|
<Dialog.Title className="m-0 text-[0.75rem] font-semibold uppercase tracking-wider text-term-amber">
|
||||||
{isSubscription
|
{isSubscription
|
||||||
? `${t("pay.subscription")} · ${s?.subscriptionHolder ?? t("subs.unnamed")}`
|
? `${t("pay.subscription")} · ${s?.subscriptionHolder ?? t("subs.unnamed")}`
|
||||||
: `${t("pay.ticket")} ${identity}`}
|
: `${t("pay.ticket")} ${identity}`}
|
||||||
@@ -244,19 +262,19 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
|||||||
<div className="rounded-term border border-term-amber bg-term-amber/5 px-3 py-2">
|
<div className="rounded-term border border-term-amber bg-term-amber/5 px-3 py-2">
|
||||||
{blockedByOther ? (
|
{blockedByOther ? (
|
||||||
<>
|
<>
|
||||||
<div className="text-[12px] font-semibold uppercase tracking-wider text-term-amber">
|
<div className="text-[0.75rem] font-semibold uppercase tracking-wider text-term-amber">
|
||||||
{t("shift.gateOtherTitle")}
|
{t("shift.gateOtherTitle")}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 text-[12px] text-term-text">
|
<div className="mt-1 text-[0.75rem] text-term-text">
|
||||||
{t("shift.gateOtherBody", { operator: heldBy ?? "?" })}
|
{t("shift.gateOtherBody", { operator: heldBy ?? "?" })}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="text-[12px] font-semibold uppercase tracking-wider text-term-amber">
|
<div className="text-[0.75rem] font-semibold uppercase tracking-wider text-term-amber">
|
||||||
{t("shift.gateTitle")}
|
{t("shift.gateTitle")}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 text-[12px] text-term-text">{t("shift.gateBody")}</div>
|
<div className="mt-1 text-[0.75rem] text-term-text">{t("shift.gateBody")}</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleOpenShift}
|
onClick={handleOpenShift}
|
||||||
@@ -278,21 +296,54 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{s && s.found && !s.open && (
|
{s && s.found && !s.open && !closedWithinGrace && (
|
||||||
|
// A fully-closed session (exited, grace expired): no action to take, but the
|
||||||
|
// operator may still need to REVIEW the evidence (entry/exit snapshots + plate)
|
||||||
|
// — e.g. a dispute about a car that just left. Show the closed notice, the
|
||||||
|
// figures, and the snapshot strip read-only. No tender / voucher / open here.
|
||||||
|
<>
|
||||||
<div className="rounded-term border border-term-amber px-3 py-2 text-term-amber">
|
<div className="rounded-term border border-term-amber px-3 py-2 text-term-amber">
|
||||||
{t("pay.alreadyClosed", { time: formatTime(s.exitedAt) })}
|
{t("pay.alreadyClosed", { time: formatTime(s.exitedAt) })}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-x-6 gap-y-1 tabular-nums">
|
||||||
|
<Row label={t("pay.entry")} value={formatRelativeDateTime(s.enteredAt, t)} />
|
||||||
|
<Row label={t("pay.exit")} value={formatTime(s.exitedAt)} />
|
||||||
|
<Row
|
||||||
|
label={t("pay.duration")}
|
||||||
|
value={
|
||||||
|
s.enteredAt ? formatDuration(s.enteredAt, s.exitedAt ?? new Date().toISOString()) : "—"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{alreadyPaid && s.paidMinor != null && s.paidCurrency && (
|
||||||
|
<Row label={t("pay.paidAmount")} value={formatMoney(s.paidMinor, s.paidCurrency)} valueClass="text-term-green" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SnapshotStrip identity={identity} />
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{s && s.found && s.open && (
|
{s && s.found && (s.open || closedWithinGrace) && (
|
||||||
<>
|
<>
|
||||||
{/* Session figures */}
|
{/* Session figures */}
|
||||||
<div className="grid grid-cols-2 gap-x-6 gap-y-1 tabular-nums">
|
<div className="grid grid-cols-2 gap-x-6 gap-y-1 tabular-nums">
|
||||||
<Row label={t("pay.entry")} value={formatRelativeDateTime(s.enteredAt, t)} />
|
<Row label={t("pay.entry")} value={formatRelativeDateTime(s.enteredAt, t)} />
|
||||||
<Row label={t("pay.now")} value={formatTime(new Date().toISOString())} />
|
{/* Closed-within-grace shows the recorded EXIT; an open session shows now. */}
|
||||||
|
<Row
|
||||||
|
label={closedWithinGrace ? t("pay.exit") : t("pay.now")}
|
||||||
|
value={closedWithinGrace ? formatTime(s.exitedAt) : formatTime(new Date().toISOString())}
|
||||||
|
/>
|
||||||
<Row
|
<Row
|
||||||
label={t("pay.duration")}
|
label={t("pay.duration")}
|
||||||
value={s.enteredAt ? formatDuration(s.enteredAt, new Date().toISOString()) : "—"}
|
value={
|
||||||
|
s.enteredAt
|
||||||
|
? formatDuration(
|
||||||
|
s.enteredAt,
|
||||||
|
(closedWithinGrace ? s.exitedAt : null) ?? new Date().toISOString(),
|
||||||
|
)
|
||||||
|
: "—"
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<Row
|
<Row
|
||||||
label={t("pay.statusLabel")}
|
label={t("pay.statusLabel")}
|
||||||
@@ -301,6 +352,8 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
|||||||
? t("pay.subscription")
|
? t("pay.subscription")
|
||||||
: isOverstay
|
: isOverstay
|
||||||
? t("pay.overstay")
|
? t("pay.overstay")
|
||||||
|
: closedWithinGrace
|
||||||
|
? t("pay.closedWithinGrace")
|
||||||
: alreadyPaid
|
: alreadyPaid
|
||||||
? t("pay.paid")
|
? t("pay.paid")
|
||||||
: t("pay.unpaid")
|
: t("pay.unpaid")
|
||||||
@@ -310,6 +363,8 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
|||||||
? "text-term-cyan"
|
? "text-term-cyan"
|
||||||
: isOverstay
|
: isOverstay
|
||||||
? "text-term-red"
|
? "text-term-red"
|
||||||
|
: closedWithinGrace
|
||||||
|
? "text-term-amber"
|
||||||
: alreadyPaid
|
: alreadyPaid
|
||||||
? "text-term-green"
|
? "text-term-green"
|
||||||
: "text-term-amber"
|
: "text-term-amber"
|
||||||
@@ -321,8 +376,17 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
|||||||
out-of-window window charge; then show that amount. For an overstay the
|
out-of-window window charge; then show that amount. For an overstay the
|
||||||
amount is the TOP-UP delta, not the whole stay. */}
|
amount is the TOP-UP delta, not the whole stay. */}
|
||||||
<div className="flex items-end justify-between rounded-term bg-term-panel-2 px-3 py-2">
|
<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">
|
<span className="text-[0.6875rem] uppercase tracking-wider text-term-muted">
|
||||||
{subWindowDue ? t("pay.windowCharge") : isSubscription ? t("pay.plan") : isOverstay ? t("pay.topUp") : t("pay.total")}
|
{subWindowDue
|
||||||
|
? t("pay.windowCharge")
|
||||||
|
: isSubscription
|
||||||
|
? t("pay.plan")
|
||||||
|
: isOverstay
|
||||||
|
? t("pay.topUp")
|
||||||
|
: alreadyPaid && s.paidMinor != null
|
||||||
|
? // Settled session — the figure is the sum collected, not a quote.
|
||||||
|
t("pay.paidAmount")
|
||||||
|
: t("pay.total")}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-3xl font-bold text-term-cyan">
|
<span className="text-3xl font-bold text-term-cyan">
|
||||||
{subWindowDue && s.amountMinor != null && s.currency
|
{subWindowDue && s.amountMinor != null && s.currency
|
||||||
@@ -331,6 +395,9 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
|||||||
? t("pay.prepaid")
|
? t("pay.prepaid")
|
||||||
: s.amountMinor != null && s.currency
|
: s.amountMinor != null && s.currency
|
||||||
? formatMoney(s.amountMinor, s.currency)
|
? formatMoney(s.amountMinor, s.currency)
|
||||||
|
: alreadyPaid && s.paidMinor != null && s.paidCurrency
|
||||||
|
? // Settled (within-grace / closed): show the sum actually collected.
|
||||||
|
formatMoney(s.paidMinor, s.paidCurrency)
|
||||||
: alreadyPaid
|
: alreadyPaid
|
||||||
? t("booth.badgePaid")
|
? t("booth.badgePaid")
|
||||||
: t("pay.noTariff")}
|
: t("pay.noTariff")}
|
||||||
@@ -341,34 +408,44 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
|||||||
gate; once paid, prompt the operator to open the barrier; a prepaid
|
gate; once paid, prompt the operator to open the barrier; a prepaid
|
||||||
subscriber sees the assist explanation only after revealing it. */}
|
subscriber sees the assist explanation only after revealing it. */}
|
||||||
{subWindowDue && !windowPaid ? (
|
{subWindowDue && !windowPaid ? (
|
||||||
<div className="rounded-term border border-term-amber/40 bg-term-amber/5 px-3 py-2 text-[12px] text-term-text">
|
<div className="rounded-term border border-term-amber/40 bg-term-amber/5 px-3 py-2 text-[0.75rem] text-term-text">
|
||||||
{t("pay.windowChargeHint")}
|
{t("pay.windowChargeHint")}
|
||||||
</div>
|
</div>
|
||||||
) : isSubscription && windowPaid ? (
|
) : isSubscription && windowPaid ? (
|
||||||
<div className="rounded-term border border-term-green/40 bg-term-green/5 px-3 py-2 text-[12px] text-term-text">
|
<div className="rounded-term border border-term-green/40 bg-term-green/5 px-3 py-2 text-[0.75rem] text-term-text">
|
||||||
{t("pay.windowPaidHint")}
|
{t("pay.windowPaidHint")}
|
||||||
</div>
|
</div>
|
||||||
) : isSubscription && assistRevealed ? (
|
) : isSubscription && assistRevealed ? (
|
||||||
<div className="rounded-term border border-term-cyan/40 bg-term-cyan/5 px-3 py-2 text-[12px] text-term-text">
|
<div className="rounded-term border border-term-cyan/40 bg-term-cyan/5 px-3 py-2 text-[0.75rem] text-term-text">
|
||||||
{t("pay.subAssistHint")}
|
{t("pay.subAssistHint")}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{/* For an overstay, explain why a top-up is required (no free exit). */}
|
{/* For an overstay, explain why a top-up is required (no free exit). */}
|
||||||
{isOverstay && (
|
{isOverstay && (
|
||||||
<div className="rounded-term border border-term-red/40 bg-term-red/5 px-3 py-2 text-[12px] text-term-text">
|
<div className="rounded-term border border-term-red/40 bg-term-red/5 px-3 py-2 text-[0.75rem] text-term-text">
|
||||||
{t("pay.overstayHint")}
|
{t("pay.overstayHint")}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Closed-within-grace: the exit is already paid + recorded; the barrier
|
||||||
|
just didn't confirm. Explain that the only action is a manual re-pulse. */}
|
||||||
|
{closedWithinGrace && (
|
||||||
|
<div className="rounded-term border border-term-amber/40 bg-term-amber/5 px-3 py-2 text-[0.75rem] text-term-text">
|
||||||
|
{t("pay.closedWithinGraceHint")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Snapshots */}
|
{/* Snapshots */}
|
||||||
<SnapshotStrip identity={identity} />
|
<SnapshotStrip identity={identity} />
|
||||||
|
|
||||||
{/* Tender — shown for any payable case (transient, overstay, OR a
|
{/* Tender — shown for any payable case (transient, overstay, OR a
|
||||||
subscriber window charge that's still unpaid). */}
|
subscriber window charge that's still unpaid). Card is hidden until a
|
||||||
{phase !== "done" && canPay && !(subWindowDue && windowPaid) && (
|
P2PE POS terminal is on-site (CARD_PAYMENTS_ENABLED) — see
|
||||||
|
lib/features.ts + wiki/concepts/card-payments.md. */}
|
||||||
|
{phase !== "done" && canPay && !(subWindowDue && windowPaid) && CARD_PAYMENTS_ENABLED && (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-[11px] uppercase tracking-wider text-term-muted">{t("pay.tender")}</span>
|
<span className="text-[0.6875rem] uppercase tracking-wider text-term-muted">{t("pay.tender")}</span>
|
||||||
{(["cash", "card"] as const).map((tn) => (
|
{(["cash", "card"] as const).map((tn) => (
|
||||||
<button
|
<button
|
||||||
key={tn}
|
key={tn}
|
||||||
@@ -382,9 +459,10 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Voucher checkbox (transient only; a subscriber doesn't self-exit). */}
|
{/* Voucher checkbox (transient only; a subscriber doesn't self-exit). Not
|
||||||
{phase !== "done" && !isSubscription && (
|
for a closed-within-grace session — its exit is already recorded. */}
|
||||||
<label className="flex items-center gap-2 text-[12px]">
|
{phase !== "done" && !isSubscription && !closedWithinGrace && (
|
||||||
|
<label className="flex items-center gap-2 text-[0.75rem]">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
className="accent-term-amber"
|
className="accent-term-amber"
|
||||||
@@ -401,10 +479,10 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
|||||||
signed `void` event — the entry is never edited. */}
|
signed `void` event — the entry is never edited. */}
|
||||||
{voiding && phase !== "done" && (
|
{voiding && phase !== "done" && (
|
||||||
<div className="rounded-term border border-term-amber/50 bg-term-amber/5 px-3 py-2">
|
<div className="rounded-term border border-term-amber/50 bg-term-amber/5 px-3 py-2">
|
||||||
<div className="text-[11px] font-semibold uppercase tracking-wider text-term-amber">
|
<div className="text-[0.6875rem] font-semibold uppercase tracking-wider text-term-amber">
|
||||||
{t("pay.cancelTicketTitle")}
|
{t("pay.cancelTicketTitle")}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 text-[12px] text-term-text">{t("pay.cancelTicketHint")}</div>
|
<div className="mt-1 text-[0.75rem] text-term-text">{t("pay.cancelTicketHint")}</div>
|
||||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||||
{(["misprint", "test", "wrongVehicle"] as const).map((k) => (
|
{(["misprint", "test", "wrongVehicle"] as const).map((k) => (
|
||||||
<button
|
<button
|
||||||
@@ -426,6 +504,25 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* PLATE-SWAP warning: the exiting plate is already inside under another
|
||||||
|
ticket. A prominent, deliberate hold — the operator must consciously
|
||||||
|
override to release. See wiki/concepts/plate-reconciliation.md. */}
|
||||||
|
{swap && (
|
||||||
|
<div className="rounded-term border border-term-red bg-term-red/10 px-3 py-2">
|
||||||
|
<div className="text-[0.75rem] font-semibold uppercase tracking-wider text-term-red">
|
||||||
|
{t("pay.swapTitle")}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-[0.75rem] text-term-text">
|
||||||
|
{t("pay.swapBody", {
|
||||||
|
plate: swap.plate,
|
||||||
|
other: swap.otherIdentity,
|
||||||
|
when: swap.otherEnteredAt ? formatRelativeDateTime(swap.otherEnteredAt, t) : "—",
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-[0.6875rem] text-term-muted">{t("pay.swapHint")}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{error && <div className="rounded-term border border-term-red px-3 py-2 text-term-red">{error}</div>}
|
{error && <div className="rounded-term border border-term-red px-3 py-2 text-term-red">{error}</div>}
|
||||||
{result && (
|
{result && (
|
||||||
<div className="rounded-term border border-term-green px-3 py-2 text-term-green">{result}</div>
|
<div className="rounded-term border border-term-green px-3 py-2 text-term-green">{result}</div>
|
||||||
@@ -464,7 +561,19 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
|||||||
>
|
>
|
||||||
{t("common.cancel")}
|
{t("common.cancel")}
|
||||||
</button>
|
</button>
|
||||||
{isSubscription ? (
|
{closedWithinGrace ? (
|
||||||
|
// Paid + exited but the barrier didn't confirm — the only action is
|
||||||
|
// an audited manual re-pulse (the server re-opens without signing a
|
||||||
|
// second exit). No payment, no voucher; mirrors reopenBarrier's guard.
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleOpenBarrier}
|
||||||
|
disabled={!shiftReady || phase === "finishing"}
|
||||||
|
className="btn btn-pay btn-lg"
|
||||||
|
>
|
||||||
|
{phase === "finishing" ? t("pay.opening") : t("booth.openBarrier")}
|
||||||
|
</button>
|
||||||
|
) : isSubscription ? (
|
||||||
subWindowDue && !windowPaid ? (
|
subWindowDue && !windowPaid ? (
|
||||||
// Step 1 — a window charge is owed: take payment first. The
|
// Step 1 — a window charge is owed: take payment first. The
|
||||||
// barrier open is the explicit next step (revealed once paid).
|
// barrier open is the explicit next step (revealed once paid).
|
||||||
@@ -523,9 +632,21 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
|||||||
{t("pay.cancelTicket")}
|
{t("pay.cancelTicket")}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{swap ? (
|
||||||
|
// Plate-swap held → the only forward action is a conscious
|
||||||
|
// override (re-submit with override:true; payment already taken).
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handlePayAndExit}
|
onClick={() => handlePayAndExit(true)}
|
||||||
|
disabled={!shiftReady || phase === "finishing"}
|
||||||
|
className="btn btn-danger btn-lg"
|
||||||
|
>
|
||||||
|
{phase === "finishing" ? t("pay.opening") : t("pay.swapOverride")}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handlePayAndExit()}
|
||||||
disabled={!shiftReady || phase === "paying" || phase === "finishing"}
|
disabled={!shiftReady || phase === "paying" || phase === "finishing"}
|
||||||
className="btn btn-go btn-lg"
|
className="btn btn-go btn-lg"
|
||||||
>
|
>
|
||||||
@@ -543,6 +664,7 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
|||||||
? t("pay.payAndVoucher")
|
? t("pay.payAndVoucher")
|
||||||
: t("pay.payAndOpen")}
|
: t("pay.payAndOpen")}
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
@@ -560,7 +682,7 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
|||||||
function Row({ label, value, valueClass = "" }: { label: string; value: string; valueClass?: string }) {
|
function Row({ label, value, valueClass = "" }: { label: string; value: string; valueClass?: string }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-baseline justify-between">
|
<div className="flex items-baseline justify-between">
|
||||||
<span className="text-[11px] uppercase tracking-wider text-term-muted">{label}</span>
|
<span className="text-[0.6875rem] uppercase tracking-wider text-term-muted">{label}</span>
|
||||||
<span className={`text-sm ${valueClass}`}>{value}</span>
|
<span className={`text-sm ${valueClass}`}>{value}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useRef, useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { fetchEvents, fetchOccupancy, type LedgerEvent, type Occupancy } from "./api.js";
|
import { can, fetchEvents, fetchOccupancy, issueEntryTicket, type LedgerEvent, type Occupancy } from "./api.js";
|
||||||
|
import { rootRoute } from "./router.js";
|
||||||
import { qk } from "./lib/query.js";
|
import { qk } from "./lib/query.js";
|
||||||
import { useLiveStore } from "./lib/live-store.js";
|
import { useLiveStore } from "./lib/live-store.js";
|
||||||
import { useShift } from "./lib/use-shift.js";
|
import { useShift } from "./lib/use-shift.js";
|
||||||
@@ -49,13 +50,13 @@ function OccupancyGauge({ occ }: { occ: Occupancy }) {
|
|||||||
<div className="flex items-end gap-4">
|
<div className="flex items-end gap-4">
|
||||||
<div className="text-6xl font-bold leading-none tabular-nums text-term-text">{occ.count}</div>
|
<div className="text-6xl font-bold leading-none tabular-nums text-term-text">{occ.count}</div>
|
||||||
<div className="pb-1 text-term-muted">
|
<div className="pb-1 text-term-muted">
|
||||||
<div className="text-[11px] uppercase tracking-wider">{t("booth.inside")}</div>
|
<div className="text-[0.6875rem] uppercase tracking-wider">{t("booth.inside")}</div>
|
||||||
<div className="text-sm tabular-nums">
|
<div className="text-sm tabular-nums">
|
||||||
{occ.capacity == null ? t("booth.uncapped") : `${t("booth.of")} ${occ.capacity}`}
|
{occ.capacity == null ? t("booth.uncapped") : `${t("booth.of")} ${occ.capacity}`}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="ml-auto text-right">
|
<div className="ml-auto text-right">
|
||||||
<div className="text-[11px] uppercase tracking-wider text-term-muted">{t("booth.free")}</div>
|
<div className="text-[0.6875rem] uppercase tracking-wider text-term-muted">{t("booth.free")}</div>
|
||||||
<div className={`text-3xl font-bold tabular-nums ${occ.full ? "text-term-red" : "text-term-green"}`}>
|
<div className={`text-3xl font-bold tabular-nums ${occ.full ? "text-term-red" : "text-term-green"}`}>
|
||||||
{occ.free == null ? "∞" : occ.free}
|
{occ.free == null ? "∞" : occ.free}
|
||||||
</div>
|
</div>
|
||||||
@@ -67,7 +68,7 @@ function OccupancyGauge({ occ }: { occ: Occupancy }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{occ.full && (
|
{occ.full && (
|
||||||
<div className="rounded-term border border-term-red px-2 py-1 text-center text-[11px] font-bold uppercase tracking-widest text-term-red">
|
<div className="rounded-term border border-term-red px-2 py-1 text-center text-[0.6875rem] font-bold uppercase tracking-widest text-term-red">
|
||||||
{t("booth.lotFull")}
|
{t("booth.lotFull")}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -106,46 +107,111 @@ function TicketInput({ onSubmit }: { onSubmit: (identity: string) => void }) {
|
|||||||
className="input h-11 flex-1 px-3 text-lg tabular-nums"
|
className="input h-11 flex-1 px-3 text-lg tabular-nums"
|
||||||
/>
|
/>
|
||||||
<button type="submit" className="btn btn-primary btn-lg">
|
<button type="submit" className="btn btn-primary btn-lg">
|
||||||
{t("booth.open")}
|
{t("booth.openTicket")}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** One barrier light — green = free, red = busy (a vehicle is at the lane vicinity,
|
/** One barrier light — a 3-state indicator mirroring the physical button lamp (relay 3):
|
||||||
* from camera detection). Advisory only; it gates nothing. */
|
* - radar present + camera NOT busy → BLINK green↔red (~1 Hz): "detected, not yet confirmed"
|
||||||
function BarrierLight({ label, busy }: { label: string; busy: boolean }) {
|
* - camera busy → SOLID red: a vehicle is confirmed at the lane vicinity
|
||||||
|
* - otherwise → SOLID green: free
|
||||||
|
* Advisory only; it gates nothing. On the ENTRY light, when the operator holds `session:create`
|
||||||
|
* and BOTH presence conditions meet (radar present AND camera busy = a real car at the entry),
|
||||||
|
* the light becomes a CLICKABLE issue-ticket control (broken physical button). Same presence
|
||||||
|
* rule as the physical button; the server re-checks it. See operator-issued-entry.md. */
|
||||||
|
function BarrierLight({
|
||||||
|
label,
|
||||||
|
busy,
|
||||||
|
radar,
|
||||||
|
onIssue,
|
||||||
|
issuing,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
busy: boolean;
|
||||||
|
radar: boolean;
|
||||||
|
/** When set (entry light + permission), clicking issues an entry ticket — only enabled
|
||||||
|
* when both presence conditions meet (radar && busy). */
|
||||||
|
onIssue?: () => void;
|
||||||
|
issuing?: boolean;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
// Blink only when the radar sees something the camera hasn't confirmed.
|
||||||
|
const blinking = radar && !busy;
|
||||||
|
const solid = busy ? "border-term-red bg-term-red/10 text-term-red" : "border-term-green bg-term-green/10 text-term-green";
|
||||||
|
// The issue control is active only with a REAL car present (radar AND camera).
|
||||||
|
const canIssue = !!onIssue && radar && busy && !issuing;
|
||||||
|
const clickable = !!onIssue && radar && busy;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`flex items-center gap-2 rounded-term border px-3 py-2 ${
|
className={`flex items-center gap-2 rounded-term border px-3 py-2 ${blinking ? "lane-blink" : solid} ${
|
||||||
busy ? "border-term-red bg-term-red/10" : "border-term-green bg-term-green/10"
|
clickable ? "cursor-pointer hover:brightness-125" : ""
|
||||||
}`}
|
}`}
|
||||||
title={label}
|
title={clickable ? t("booth.issueEntryTitle") : label}
|
||||||
|
onClick={canIssue ? onIssue : undefined}
|
||||||
|
role={clickable ? "button" : undefined}
|
||||||
>
|
>
|
||||||
{/* Barrier glyph: a post + an arm. Colour carries the state. */}
|
{/* Barrier glyph: a post + an arm. `currentColor` follows the (possibly blinking) state. */}
|
||||||
<svg viewBox="0 0 24 24" className={`h-5 w-5 ${busy ? "text-term-red" : "text-term-green"}`} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
<svg viewBox="0 0 24 24" className="h-5 w-5" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||||
<line x1="5" y1="21" x2="5" y2="9" />
|
<line x1="5" y1="21" x2="5" y2="9" />
|
||||||
<line x1="5" y1="10" x2="21" y2="6" />
|
<line x1="5" y1="10" x2="21" y2="6" />
|
||||||
<circle cx="5" cy="7" r="1.6" fill="currentColor" stroke="none" />
|
<circle cx="5" cy="7" r="1.6" fill="currentColor" stroke="none" />
|
||||||
</svg>
|
</svg>
|
||||||
<div className="leading-tight">
|
<div className="leading-tight">
|
||||||
<div className="text-[10px] uppercase tracking-wider text-term-muted">{label}</div>
|
<div className="text-[0.625rem] uppercase tracking-wider text-term-muted">{label}</div>
|
||||||
<div className={`text-xs font-bold ${busy ? "text-term-red" : "text-term-green"}`}>
|
<div className="text-xs font-bold">
|
||||||
{busy ? "●" : "○"}
|
{issuing ? "…" : clickable ? t("booth.issueEntry") : busy ? "●" : blinking ? "◐" : "○"}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The two lane barrier lights (entry / exit) fed by the live lane-status. */
|
/** The two lane barrier lights (entry / exit) fed by the live lane-status (camera busy/free)
|
||||||
|
* and lane-presence (radar). The ENTRY light doubles as an operator issue-ticket control when
|
||||||
|
* the physical button is broken (permission + presence gated). */
|
||||||
function LaneIndicators() {
|
function LaneIndicators() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const lanes = useLiveStore((s) => s.lanes);
|
const lanes = useLiveStore((s) => s.lanes);
|
||||||
|
const radar = useLiveStore((s) => s.radar);
|
||||||
|
const { user } = rootRoute.useRouteContext();
|
||||||
|
const { isOpen: shiftOpen, isMine } = useShift();
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const canIssue = can(user, "session:create") && shiftOpen && isMine;
|
||||||
|
const [msg, setMsg] = useState<{ text: string; ok: boolean } | null>(null);
|
||||||
|
|
||||||
|
const issue = useMutation({
|
||||||
|
mutationFn: issueEntryTicket,
|
||||||
|
onSuccess: (r) => {
|
||||||
|
setMsg({ ok: true, text: t("booth.issueEntryOk", { ticket: r.ticketId }) });
|
||||||
|
void qc.invalidateQueries({ queryKey: qk.events });
|
||||||
|
void qc.invalidateQueries({ queryKey: qk.occupancy });
|
||||||
|
setTimeout(() => setMsg(null), 4000);
|
||||||
|
},
|
||||||
|
onError: (e) => {
|
||||||
|
setMsg({ ok: false, text: (e as Error).message });
|
||||||
|
setTimeout(() => setMsg(null), 4000);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function onIssue() {
|
||||||
|
if (window.confirm(t("booth.issueEntryConfirm"))) issue.mutate();
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<BarrierLight label={t("booth.laneEntry")} busy={lanes?.entry ?? false} />
|
<BarrierLight
|
||||||
<BarrierLight label={t("booth.laneExit")} busy={lanes?.exit ?? false} />
|
label={t("booth.laneEntry")}
|
||||||
|
busy={lanes?.entry ?? false}
|
||||||
|
radar={radar?.entry ?? false}
|
||||||
|
onIssue={canIssue ? onIssue : undefined}
|
||||||
|
issuing={issue.isPending}
|
||||||
|
/>
|
||||||
|
<BarrierLight label={t("booth.laneExit")} busy={lanes?.exit ?? false} radar={radar?.exit ?? false} />
|
||||||
|
{msg && (
|
||||||
|
<span className={`text-[0.6875rem] ${msg.ok ? "text-term-green" : "text-term-red"}`}>{msg.text}</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -176,10 +242,10 @@ export function BoothScreen() {
|
|||||||
// abandon an in-progress payment (the operator finishes/closes, then scans the next).
|
// abandon an in-progress payment (the operator finishes/closes, then scans the next).
|
||||||
useScanner({ onScan: setActiveTicket, paused: activeTicket != null || detailEvent != null });
|
useScanner({ onScan: setActiveTicket, paused: activeTicket != null || detailEvent != null });
|
||||||
|
|
||||||
// Live-feed filters: free-text search, event category, and direction/source.
|
// Live-feed filters: free-text search, event type, and source. (No direction filter —
|
||||||
|
// HYRJE/DALJE there just duplicated the entry/exit options already in the Type filter.)
|
||||||
const [feedSearch, setFeedSearch] = useState("");
|
const [feedSearch, setFeedSearch] = useState("");
|
||||||
const [feedType, setFeedType] = useState<FeedCat | "">("");
|
const [feedType, setFeedType] = useState<FeedCat | "">("");
|
||||||
const [feedDir, setFeedDir] = useState<"entry" | "exit" | "">("");
|
|
||||||
const [feedSrc, setFeedSrc] = useState<"booth" | "reader" | "">("");
|
const [feedSrc, setFeedSrc] = useState<"booth" | "reader" | "">("");
|
||||||
|
|
||||||
// Live overlays from the WS store.
|
// Live overlays from the WS store.
|
||||||
@@ -202,17 +268,17 @@ export function BoothScreen() {
|
|||||||
|
|
||||||
// Apply the live-feed filters. Source maps to booth (operator-initiated `manual`)
|
// Apply the live-feed filters. Source maps to booth (operator-initiated `manual`)
|
||||||
// vs reader (device-initiated: wiegand/lpr/qr/ticket). Search spans identity,
|
// vs reader (device-initiated: wiegand/lpr/qr/ticket). Search spans identity,
|
||||||
// subscriber label, and any advisory plate on the payload.
|
// subscriber label, and the enriched advisory plate (`e.plate` — the displayed field;
|
||||||
|
// the plate is NOT in the signed payload, so `payload.plate` would never match).
|
||||||
const fq = feedSearch.trim().toLowerCase();
|
const fq = feedSearch.trim().toLowerCase();
|
||||||
const events = scoped.filter((e) => {
|
const events = scoped.filter((e) => {
|
||||||
if (feedType && feedCat(e.type) !== feedType) return false;
|
if (feedType && feedCat(e.type) !== feedType) return false;
|
||||||
if (feedDir && e.direction !== feedDir) return false;
|
|
||||||
if (feedSrc) {
|
if (feedSrc) {
|
||||||
const isBooth = e.source === "manual";
|
const isBooth = e.source === "manual";
|
||||||
if (feedSrc === "booth" ? !isBooth : isBooth) return false;
|
if (feedSrc === "booth" ? !isBooth : isBooth) return false;
|
||||||
}
|
}
|
||||||
if (fq) {
|
if (fq) {
|
||||||
const hay = `${e.identity ?? ""} ${e.subscriberLabel ?? ""} ${e.payload?.plate ?? ""}`.toLowerCase();
|
const hay = `${e.identity ?? ""} ${e.subscriberLabel ?? ""} ${e.plate ?? ""}`.toLowerCase();
|
||||||
if (!hay.includes(fq)) return false;
|
if (!hay.includes(fq)) return false;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
@@ -225,10 +291,6 @@ export function BoothScreen() {
|
|||||||
{ value: "void", label: t("booth.fEvtVoid") },
|
{ value: "void", label: t("booth.fEvtVoid") },
|
||||||
{ value: "anomaly", label: t("booth.fEvtAnomaly") },
|
{ value: "anomaly", label: t("booth.fEvtAnomaly") },
|
||||||
];
|
];
|
||||||
const feedDirOpts: SegOption<"entry" | "exit">[] = [
|
|
||||||
{ value: "entry", label: t("booth.fDirEntry") },
|
|
||||||
{ value: "exit", label: t("booth.fDirExit") },
|
|
||||||
];
|
|
||||||
const feedSrcOpts: SegOption<"booth" | "reader">[] = [
|
const feedSrcOpts: SegOption<"booth" | "reader">[] = [
|
||||||
{ value: "booth", label: t("booth.fSrcBooth") },
|
{ value: "booth", label: t("booth.fSrcBooth") },
|
||||||
{ value: "reader", label: t("booth.fSrcReader") },
|
{ value: "reader", label: t("booth.fSrcReader") },
|
||||||
@@ -266,7 +328,7 @@ export function BoothScreen() {
|
|||||||
<Panel
|
<Panel
|
||||||
title={t("booth.liveFeed")}
|
title={t("booth.liveFeed")}
|
||||||
right={
|
right={
|
||||||
<span className="text-[10px] uppercase tracking-wider text-term-muted">
|
<span className="text-[0.625rem] uppercase tracking-wider text-term-muted">
|
||||||
{events.length}
|
{events.length}
|
||||||
{events.length !== scoped.length ? `/${scoped.length}` : ""} {t("booth.events")}
|
{events.length !== scoped.length ? `/${scoped.length}` : ""} {t("booth.events")}
|
||||||
</span>
|
</span>
|
||||||
@@ -282,7 +344,6 @@ export function BoothScreen() {
|
|||||||
onChange={setFeedType}
|
onChange={setFeedType}
|
||||||
allLabel={t("booth.filterAll")}
|
allLabel={t("booth.filterAll")}
|
||||||
/>
|
/>
|
||||||
<SegGroup value={feedDir} options={feedDirOpts} onChange={setFeedDir} allLabel={t("booth.filterAll")} />
|
|
||||||
<SegGroup value={feedSrc} options={feedSrcOpts} onChange={setFeedSrc} allLabel={t("booth.filterAll")} />
|
<SegGroup value={feedSrc} options={feedSrcOpts} onChange={setFeedSrc} allLabel={t("booth.filterAll")} />
|
||||||
</FilterBar>
|
</FilterBar>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,240 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
fetchDrawerMovements,
|
||||||
|
recordDrawerMovement,
|
||||||
|
reviewDrawerMovement,
|
||||||
|
type DrawerMovement,
|
||||||
|
type MovementStatus,
|
||||||
|
} from "./api.js";
|
||||||
|
import { formatMoney, formatRelativeDateTime } from "./lib/format.js";
|
||||||
|
import { Panel } from "./ui/Panel.js";
|
||||||
|
|
||||||
|
// Drawer cash movements. Operators RECORD receipts (Mandat Arkëtimi / cash_in) and
|
||||||
|
// disbursements (Mandat Pagese / cash_out) freely; admins REVIEW them after the fact
|
||||||
|
// (authorize/deny — a flag, never a cash reversal). A denial is a judgment about the
|
||||||
|
// operator, settled outside the app: the drawer balance is untouched. See
|
||||||
|
// wiki/concepts/shift.md.
|
||||||
|
|
||||||
|
const money = (m: number, cur: string | null) => formatMoney(m, cur ?? "");
|
||||||
|
|
||||||
|
function StatusBadge({ status }: { status: MovementStatus }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const cls =
|
||||||
|
status === "authorized"
|
||||||
|
? "border-term-green/60 text-term-green"
|
||||||
|
: status === "denied"
|
||||||
|
? "border-term-red/60 text-term-red"
|
||||||
|
: "border-term-amber/60 text-term-amber";
|
||||||
|
return (
|
||||||
|
<span className={`rounded border px-1 text-[0.625rem] uppercase tracking-wider ${cls}`}>
|
||||||
|
{t(`drawer.status.${status}`)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DrawerManager({ canCreate, canReview }: { canCreate: boolean; canReview: boolean }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const qc = useQueryClient();
|
||||||
|
// Reviewers can filter the list (the pending queue); operators always see their own, all.
|
||||||
|
const [statusFilter, setStatusFilter] = useState<MovementStatus | "">("");
|
||||||
|
const q = useQuery({
|
||||||
|
queryKey: ["drawer", "movements", canReview ? statusFilter : ""],
|
||||||
|
queryFn: () => fetchDrawerMovements(canReview && statusFilter ? statusFilter : undefined),
|
||||||
|
});
|
||||||
|
const movements = q.data?.movements ?? [];
|
||||||
|
const pendingCount = movements.filter((m) => m.status === "pending").length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col gap-3 p-3">
|
||||||
|
{canCreate && <RecordPanel onDone={() => void qc.invalidateQueries({ queryKey: ["drawer"] })} />}
|
||||||
|
|
||||||
|
<Panel
|
||||||
|
title={canReview ? t("drawer.allTitle") : t("drawer.myTitle")}
|
||||||
|
right={
|
||||||
|
canReview && pendingCount > 0 ? (
|
||||||
|
<span className="rounded border border-term-amber/60 px-1.5 text-[0.625rem] uppercase tracking-wider text-term-amber">
|
||||||
|
{t("drawer.pendingCount", { count: pendingCount })}
|
||||||
|
</span>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
className="min-h-0 flex-1"
|
||||||
|
>
|
||||||
|
<div className="flex h-full flex-col">
|
||||||
|
{canReview && (
|
||||||
|
<div className="mb-2 flex items-center gap-1.5">
|
||||||
|
{(["", "pending", "authorized", "denied"] as const).map((s) => (
|
||||||
|
<button
|
||||||
|
key={s || "all"}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setStatusFilter(s)}
|
||||||
|
className={statusFilter === s ? "btn btn-primary btn-sm" : "btn btn-sm"}
|
||||||
|
>
|
||||||
|
{s === "" ? t("drawer.filterAll") : t(`drawer.status.${s}`)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="min-h-0 flex-1 overflow-y-auto pr-1">
|
||||||
|
{q.isLoading ? (
|
||||||
|
<div className="text-term-muted">{t("common.loading")}</div>
|
||||||
|
) : movements.length === 0 ? (
|
||||||
|
<div className="text-term-muted">{t("drawer.empty")}</div>
|
||||||
|
) : (
|
||||||
|
<table className="w-full text-[0.75rem] tabular-nums">
|
||||||
|
<thead className="sticky top-0 bg-term-panel-2 text-[0.6875rem] uppercase tracking-wider text-term-muted">
|
||||||
|
<tr>
|
||||||
|
<th className="px-2 py-1.5 text-left font-semibold">{t("drawer.colWhen")}</th>
|
||||||
|
<th className="px-2 py-1.5 text-left font-semibold">{t("drawer.colType")}</th>
|
||||||
|
<th className="px-2 py-1.5 text-right font-semibold">{t("drawer.colAmount")}</th>
|
||||||
|
<th className="px-2 py-1.5 text-left font-semibold">{t("drawer.colReason")}</th>
|
||||||
|
{canReview && <th className="px-2 py-1.5 text-left font-semibold">{t("drawer.colOperator")}</th>}
|
||||||
|
<th className="px-2 py-1.5 text-left font-semibold">{t("drawer.colStatus")}</th>
|
||||||
|
{canReview && <th className="px-2 py-1.5" />}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{movements.map((m) => (
|
||||||
|
<MovementRow key={m.id} m={m} canReview={canReview} onReviewed={() => void qc.invalidateQueries({ queryKey: ["drawer"] })} />
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RecordPanel({ onDone }: { onDone: () => void }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [amount, setAmount] = useState("");
|
||||||
|
const [reason, setReason] = useState("");
|
||||||
|
const [msg, setMsg] = useState<{ text: string; ok: boolean } | null>(null);
|
||||||
|
const record = useMutation({
|
||||||
|
mutationFn: (type: "cash_in" | "cash_out") =>
|
||||||
|
recordDrawerMovement({ type, amountMinor: Math.round(Number(amount) * 100), reason: reason.trim() }),
|
||||||
|
onSuccess: (r) => {
|
||||||
|
setMsg({ ok: true, text: t("drawer.recorded", { no: r.voucherNo, amount: money(r.balanceMinor, null) }) });
|
||||||
|
setAmount("");
|
||||||
|
setReason("");
|
||||||
|
onDone();
|
||||||
|
},
|
||||||
|
onError: (e) => setMsg({ ok: false, text: (e as Error).message }),
|
||||||
|
});
|
||||||
|
|
||||||
|
function submit(type: "cash_in" | "cash_out") {
|
||||||
|
setMsg(null);
|
||||||
|
const major = Number(amount);
|
||||||
|
if (!Number.isFinite(major) || major <= 0) {
|
||||||
|
setMsg({ ok: false, text: t("drawer.enterPositive") });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
record.mutate(type);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Panel title={t("drawer.recordTitle")}>
|
||||||
|
<div className="flex flex-col gap-2 text-[0.8125rem]">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<input
|
||||||
|
className="input w-28"
|
||||||
|
value={amount}
|
||||||
|
onChange={(e) => setAmount(e.target.value)}
|
||||||
|
placeholder={t("drawer.amount")}
|
||||||
|
inputMode="decimal"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
className="input min-w-40 flex-1"
|
||||||
|
value={reason}
|
||||||
|
onChange={(e) => setReason(e.target.value)}
|
||||||
|
placeholder={t("drawer.reasonPlaceholder")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="text-[0.6875rem] text-term-muted">{t("drawer.recordHint")}</div>
|
||||||
|
{msg && (
|
||||||
|
<div className={`text-[0.75rem] ${msg.ok ? "text-term-green" : "text-term-red"}`}>{msg.text}</div>
|
||||||
|
)}
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<button type="button" className="btn btn-go btn-sm" disabled={record.isPending} onClick={() => submit("cash_in")}>
|
||||||
|
{t("drawer.mandatArketimi")}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn btn-danger btn-sm" disabled={record.isPending} onClick={() => submit("cash_out")}>
|
||||||
|
{t("drawer.mandatPagese")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MovementRow({ m, canReview, onReviewed }: { m: DrawerMovement; canReview: boolean; onReviewed: () => void }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [note, setNote] = useState("");
|
||||||
|
const [noteOpen, setNoteOpen] = useState(false);
|
||||||
|
const review = useMutation({
|
||||||
|
mutationFn: (decision: "authorize" | "deny") =>
|
||||||
|
reviewDrawerMovement({ refId: m.id, decision, note: note.trim() || undefined }),
|
||||||
|
onSuccess: onReviewed,
|
||||||
|
});
|
||||||
|
// Direction sign for display: cash_in is +, cash_out is −.
|
||||||
|
const signed = m.type === "cash_in" ? m.amountMinor : -m.amountMinor;
|
||||||
|
return (
|
||||||
|
<tr className="border-t border-term-border/50 align-top">
|
||||||
|
<td className="whitespace-nowrap px-2 py-1.5 text-term-muted">{formatRelativeDateTime(m.at, t)}</td>
|
||||||
|
<td className="px-2 py-1.5">
|
||||||
|
<span className={m.type === "cash_in" ? "text-term-green" : "text-term-red"}>
|
||||||
|
{m.type === "cash_in" ? t("drawer.mandatArketimi") : t("drawer.mandatPagese")}
|
||||||
|
</span>
|
||||||
|
{m.voucherNo && <span className="ml-1 text-[0.625rem] text-term-muted">{m.voucherNo}</span>}
|
||||||
|
</td>
|
||||||
|
<td className={`whitespace-nowrap px-2 py-1.5 text-right ${signed < 0 ? "text-term-red" : "text-term-green"}`}>
|
||||||
|
{money(signed, m.currency)}
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-1.5 text-term-text">{m.reason || "—"}</td>
|
||||||
|
{canReview && <td className="px-2 py-1.5 text-term-muted">{m.operator}</td>}
|
||||||
|
<td className="px-2 py-1.5">
|
||||||
|
<StatusBadge status={m.status} />
|
||||||
|
{m.status !== "pending" && m.reviewedBy && (
|
||||||
|
<div className="mt-0.5 text-[0.5625rem] text-term-muted">
|
||||||
|
{m.reviewedBy}
|
||||||
|
{m.reviewNote ? ` · ${m.reviewNote}` : ""}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
{canReview && (
|
||||||
|
<td className="px-2 py-1.5 text-right">
|
||||||
|
{m.status === "pending" ? (
|
||||||
|
<div className="flex flex-col items-end gap-1">
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<button type="button" className="btn btn-go btn-sm" disabled={review.isPending} onClick={() => review.mutate("authorize")}>
|
||||||
|
{t("drawer.authorize")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-danger btn-sm"
|
||||||
|
disabled={review.isPending}
|
||||||
|
onClick={() => (noteOpen ? review.mutate("deny") : setNoteOpen(true))}
|
||||||
|
>
|
||||||
|
{t("drawer.deny")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{noteOpen && (
|
||||||
|
<input
|
||||||
|
className="input w-44 text-[0.6875rem]"
|
||||||
|
value={note}
|
||||||
|
onChange={(e) => setNote(e.target.value)}
|
||||||
|
placeholder={t("drawer.denyNotePlaceholder")}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{review.isError && <span className="text-[0.625rem] text-term-red">{(review.error as Error).message}</span>}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -46,7 +46,7 @@ export function Login({ onLoggedIn }: { onLoggedIn: (u: SessionUser) => void })
|
|||||||
autoComplete="current-password"
|
autoComplete="current-password"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{error && <p className="mb-3 text-[12px] text-term-red">{error}</p>}
|
{error && <p className="mb-3 text-[0.75rem] text-term-red">{error}</p>}
|
||||||
<button type="submit" className="btn btn-primary btn-lg w-full" disabled={busy || !username || !password}>
|
<button type="submit" className="btn btn-primary btn-lg w-full" disabled={busy || !username || !password}>
|
||||||
{busy ? t("auth.signingIn") : t("auth.signIn")}
|
{busy ? t("auth.signingIn") : t("auth.signIn")}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ function LogRow({ log }: { log: AppLogRecord }) {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => hasDetail && setOpen((v) => !v)}
|
onClick={() => hasDetail && setOpen((v) => !v)}
|
||||||
className={`grid w-full grid-cols-[auto_4rem_5rem_1fr_auto] items-center gap-x-3 px-1 py-1 text-left text-[12px] ${
|
className={`grid w-full grid-cols-[auto_4rem_5rem_1fr_auto] items-center gap-x-3 px-1 py-1 text-left text-[0.75rem] ${
|
||||||
hasDetail ? "hover:bg-term-panel-2" : "cursor-default"
|
hasDetail ? "hover:bg-term-panel-2" : "cursor-default"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -44,17 +44,17 @@ function LogRow({ log }: { log: AppLogRecord }) {
|
|||||||
{open && hasDetail && (
|
{open && hasDetail && (
|
||||||
<div className="border-t border-term-border/40 bg-term-bg px-3 py-2">
|
<div className="border-t border-term-border/40 bg-term-bg px-3 py-2">
|
||||||
{log.path && (
|
{log.path && (
|
||||||
<div className="mb-1 text-[11px] text-term-muted">
|
<div className="mb-1 text-[0.6875rem] text-term-muted">
|
||||||
{t("logs.path")}: <code className="text-term-text">{log.path}</code>
|
{t("logs.path")}: <code className="text-term-text">{log.path}</code>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{log.context && Object.keys(log.context).length > 0 && (
|
{log.context && Object.keys(log.context).length > 0 && (
|
||||||
<pre className="mb-2 overflow-x-auto rounded-term border border-term-border bg-term-panel-2 p-2 text-[11px] text-term-text">
|
<pre className="mb-2 overflow-x-auto rounded-term border border-term-border bg-term-panel-2 p-2 text-[0.6875rem] text-term-text">
|
||||||
{JSON.stringify(log.context, null, 2)}
|
{JSON.stringify(log.context, null, 2)}
|
||||||
</pre>
|
</pre>
|
||||||
)}
|
)}
|
||||||
{log.stack && (
|
{log.stack && (
|
||||||
<pre className="overflow-x-auto rounded-term border border-term-border bg-term-panel-2 p-2 text-[11px] text-term-red/90">
|
<pre className="overflow-x-auto rounded-term border border-term-border bg-term-panel-2 p-2 text-[0.6875rem] text-term-red/90">
|
||||||
{log.stack}
|
{log.stack}
|
||||||
</pre>
|
</pre>
|
||||||
)}
|
)}
|
||||||
@@ -136,12 +136,12 @@ export function LogsViewer() {
|
|||||||
|
|
||||||
<div className="card p-2">
|
<div className="card p-2">
|
||||||
{q.isLoading ? (
|
{q.isLoading ? (
|
||||||
<div className="p-3 text-[12px] text-term-muted">{t("common.loading")}</div>
|
<div className="p-3 text-[0.75rem] text-term-muted">{t("common.loading")}</div>
|
||||||
) : logs.length === 0 ? (
|
) : logs.length === 0 ? (
|
||||||
<div className="p-3 text-[12px] text-term-muted">{t("logs.empty")}</div>
|
<div className="p-3 text-[0.75rem] text-term-muted">{t("logs.empty")}</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="grid grid-cols-[auto_4rem_5rem_1fr_auto] gap-x-3 border-b border-term-border px-1 pb-1 text-[10px] uppercase tracking-wider text-term-muted">
|
<div className="grid grid-cols-[auto_4rem_5rem_1fr_auto] gap-x-3 border-b border-term-border px-1 pb-1 text-[0.625rem] uppercase tracking-wider text-term-muted">
|
||||||
<span>{t("logs.time")}</span>
|
<span>{t("logs.time")}</span>
|
||||||
<span>{t("logs.level")}</span>
|
<span>{t("logs.level")}</span>
|
||||||
<span>{t("logs.source")}</span>
|
<span>{t("logs.source")}</span>
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ export function Profile({
|
|||||||
<h2 className="text-sm uppercase tracking-wider text-term-muted">
|
<h2 className="text-sm uppercase tracking-wider text-term-muted">
|
||||||
{t("profile.accountSection")}
|
{t("profile.accountSection")}
|
||||||
</h2>
|
</h2>
|
||||||
<div className="grid grid-cols-2 gap-3 text-[11px] text-term-muted">
|
<div className="grid grid-cols-2 gap-3 text-[0.6875rem] text-term-muted">
|
||||||
<div>
|
<div>
|
||||||
<span className="block">{t("profile.username")}</span>
|
<span className="block">{t("profile.username")}</span>
|
||||||
<span className="text-sm text-term-text">{user.username}</span>
|
<span className="text-sm text-term-text">{user.username}</span>
|
||||||
@@ -92,7 +92,7 @@ export function Profile({
|
|||||||
<span className="text-sm text-term-text">{user.roleName}</span>
|
<span className="text-sm text-term-text">{user.roleName}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<label className="flex flex-col gap-1 text-[11px] text-term-muted">
|
<label className="flex flex-col gap-1 text-[0.6875rem] text-term-muted">
|
||||||
{t("profile.fullName")}
|
{t("profile.fullName")}
|
||||||
<input
|
<input
|
||||||
className="input"
|
className="input"
|
||||||
@@ -101,7 +101,7 @@ export function Profile({
|
|||||||
onChange={(e) => setFullName(e.target.value)}
|
onChange={(e) => setFullName(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label className="flex flex-col gap-1 text-[11px] text-term-muted">
|
<label className="flex flex-col gap-1 text-[0.6875rem] text-term-muted">
|
||||||
{t("profile.email")}
|
{t("profile.email")}
|
||||||
<input
|
<input
|
||||||
className="input"
|
className="input"
|
||||||
@@ -115,7 +115,7 @@ export function Profile({
|
|||||||
<button type="button" className="btn btn-primary btn-sm" onClick={saveAccount} disabled={savingAccount}>
|
<button type="button" className="btn btn-primary btn-sm" onClick={saveAccount} disabled={savingAccount}>
|
||||||
{t("profile.saveProfile")}
|
{t("profile.saveProfile")}
|
||||||
</button>
|
</button>
|
||||||
{accountMsg && <span className="text-[11px] text-term-muted">{accountMsg}</span>}
|
{accountMsg && <span className="text-[0.6875rem] text-term-muted">{accountMsg}</span>}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -124,7 +124,7 @@ export function Profile({
|
|||||||
<h2 className="text-sm uppercase tracking-wider text-term-muted">
|
<h2 className="text-sm uppercase tracking-wider text-term-muted">
|
||||||
{t("profile.passwordSection")}
|
{t("profile.passwordSection")}
|
||||||
</h2>
|
</h2>
|
||||||
<label className="flex flex-col gap-1 text-[11px] text-term-muted">
|
<label className="flex flex-col gap-1 text-[0.6875rem] text-term-muted">
|
||||||
{t("profile.currentPassword")}
|
{t("profile.currentPassword")}
|
||||||
<input
|
<input
|
||||||
className="input"
|
className="input"
|
||||||
@@ -134,7 +134,7 @@ export function Profile({
|
|||||||
onChange={(e) => setCurrent(e.target.value)}
|
onChange={(e) => setCurrent(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label className="flex flex-col gap-1 text-[11px] text-term-muted">
|
<label className="flex flex-col gap-1 text-[0.6875rem] text-term-muted">
|
||||||
{t("profile.newPassword")}
|
{t("profile.newPassword")}
|
||||||
<input
|
<input
|
||||||
className="input"
|
className="input"
|
||||||
@@ -144,7 +144,7 @@ export function Profile({
|
|||||||
onChange={(e) => setNext(e.target.value)}
|
onChange={(e) => setNext(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label className="flex flex-col gap-1 text-[11px] text-term-muted">
|
<label className="flex flex-col gap-1 text-[0.6875rem] text-term-muted">
|
||||||
{t("profile.confirmPassword")}
|
{t("profile.confirmPassword")}
|
||||||
<input
|
<input
|
||||||
className="input"
|
className="input"
|
||||||
@@ -163,7 +163,7 @@ export function Profile({
|
|||||||
>
|
>
|
||||||
{t("profile.changePassword")}
|
{t("profile.changePassword")}
|
||||||
</button>
|
</button>
|
||||||
{pwMsg && <span className="text-[11px] text-term-muted">{pwMsg}</span>}
|
{pwMsg && <span className="text-[0.6875rem] text-term-muted">{pwMsg}</span>}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -76,13 +76,13 @@ export function RecycleBin({ user }: { user: SessionUser | null }) {
|
|||||||
{t("recycleBin.title")}
|
{t("recycleBin.title")}
|
||||||
</h1>
|
</h1>
|
||||||
{retentionDays > 0 && (
|
{retentionDays > 0 && (
|
||||||
<span className="text-[12px] text-term-muted">
|
<span className="text-[0.75rem] text-term-muted">
|
||||||
{t("recycleBin.retentionNote", { days: retentionDays })}
|
{t("recycleBin.retentionNote", { days: retentionDays })}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && <p className="mb-2 text-[12px] text-term-red">{error}</p>}
|
{error && <p className="mb-2 text-[0.75rem] text-term-red">{error}</p>}
|
||||||
{binQ.isLoading && <p className="text-term-muted">{t("common.loading")}</p>}
|
{binQ.isLoading && <p className="text-term-muted">{t("common.loading")}</p>}
|
||||||
|
|
||||||
{!binQ.isLoading && items.length === 0 ? (
|
{!binQ.isLoading && items.length === 0 ? (
|
||||||
@@ -90,9 +90,9 @@ export function RecycleBin({ user }: { user: SessionUser | null }) {
|
|||||||
{t("recycleBin.empty")}
|
{t("recycleBin.empty")}
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<table className="w-full text-[13px]">
|
<table className="w-full text-[0.8125rem]">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-term-border text-left text-[11px] uppercase tracking-wider text-term-muted">
|
<tr className="border-b border-term-border text-left text-[0.6875rem] uppercase tracking-wider text-term-muted">
|
||||||
<th className="py-1.5 pr-3">{t("recycleBin.col.type")}</th>
|
<th className="py-1.5 pr-3">{t("recycleBin.col.type")}</th>
|
||||||
<th className="py-1.5 pr-3">{t("recycleBin.col.item")}</th>
|
<th className="py-1.5 pr-3">{t("recycleBin.col.item")}</th>
|
||||||
<th className="py-1.5 pr-3">{t("recycleBin.col.deleted")}</th>
|
<th className="py-1.5 pr-3">{t("recycleBin.col.deleted")}</th>
|
||||||
@@ -103,7 +103,7 @@ export function RecycleBin({ user }: { user: SessionUser | null }) {
|
|||||||
{items.map((it) => (
|
{items.map((it) => (
|
||||||
<tr key={`${it.kind}:${it.id}`} className="border-b border-term-border/50">
|
<tr key={`${it.kind}:${it.id}`} className="border-b border-term-border/50">
|
||||||
<td className="py-1.5 pr-3">
|
<td className="py-1.5 pr-3">
|
||||||
<span className="rounded-term border border-term-border px-1.5 py-0.5 text-[11px] text-term-muted">
|
<span className="rounded-term border border-term-border px-1.5 py-0.5 text-[0.6875rem] text-term-muted">
|
||||||
{t(KIND_KEY[it.kind])}
|
{t(KIND_KEY[it.kind])}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
@@ -146,10 +146,10 @@ export function RecycleBin({ user }: { user: SessionUser | null }) {
|
|||||||
|
|
||||||
{purging && (
|
{purging && (
|
||||||
<Modal open onClose={() => setPurging(null)} title={t("recycleBin.purgeConfirmTitle")}>
|
<Modal open onClose={() => setPurging(null)} title={t("recycleBin.purgeConfirmTitle")}>
|
||||||
<p className="text-[13px] text-term-text">
|
<p className="text-[0.8125rem] text-term-text">
|
||||||
{t("recycleBin.purgeConfirmBody", { label: purging.label })}
|
{t("recycleBin.purgeConfirmBody", { label: purging.label })}
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1 text-[12px] text-term-red">{t("recycleBin.purgeIrreversible")}</p>
|
<p className="mt-1 text-[0.75rem] text-term-red">{t("recycleBin.purgeIrreversible")}</p>
|
||||||
<div className="mt-3 flex justify-end gap-2">
|
<div className="mt-3 flex justify-end gap-2">
|
||||||
<button type="button" className="btn btn-sm btn-ghost" onClick={() => setPurging(null)}>
|
<button type="button" className="btn btn-sm btn-ghost" onClick={() => setPurging(null)}>
|
||||||
{t("common.cancel")}
|
{t("common.cancel")}
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ export function Reports() {
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="ml-2 flex items-center gap-1 text-[12px] text-term-muted">
|
<div className="ml-2 flex items-center gap-1 text-[0.75rem] text-term-muted">
|
||||||
<span>{t("reports.groupBy")}</span>
|
<span>{t("reports.groupBy")}</span>
|
||||||
<select
|
<select
|
||||||
className="select input-sm w-auto"
|
className="select input-sm w-auto"
|
||||||
@@ -247,7 +247,7 @@ function ReportBody({ data, t }: { data: ReportSummary; t: TFunction }) {
|
|||||||
|
|
||||||
{/* Cash / card + duration + subscription breakdown (numbers). */}
|
{/* Cash / card + duration + subscription breakdown (numbers). */}
|
||||||
<Panel title={t("reports.chart.breakdown")}>
|
<Panel title={t("reports.chart.breakdown")}>
|
||||||
<dl className="grid grid-cols-2 gap-x-6 gap-y-1.5 text-[13px]">
|
<dl className="grid grid-cols-2 gap-x-6 gap-y-1.5 text-[0.8125rem]">
|
||||||
<Row label={t("reports.row.cash")} value={money(tot.cashMinor)} />
|
<Row label={t("reports.row.cash")} value={money(tot.cashMinor)} />
|
||||||
<Row label={t("reports.row.card")} value={money(tot.cardMinor)} />
|
<Row label={t("reports.row.card")} value={money(tot.cardMinor)} />
|
||||||
<Row label={t("reports.mix.ticket")} value={money(tot.ticketMinor)} />
|
<Row label={t("reports.mix.ticket")} value={money(tot.ticketMinor)} />
|
||||||
@@ -262,7 +262,7 @@ function ReportBody({ data, t }: { data: ReportSummary; t: TFunction }) {
|
|||||||
</Panel>
|
</Panel>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-[11px] text-term-muted">
|
<p className="text-[0.6875rem] text-term-muted">
|
||||||
{t("reports.footnote", { tz: data.tz })}
|
{t("reports.footnote", { tz: data.tz })}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -290,7 +290,7 @@ function Kpi({ label, value, accent }: { label: string; value: string; accent?:
|
|||||||
: "text-term-text";
|
: "text-term-text";
|
||||||
return (
|
return (
|
||||||
<div className="rounded-term border border-term-border bg-term-panel p-2.5">
|
<div className="rounded-term border border-term-border bg-term-panel p-2.5">
|
||||||
<div className="text-[11px] uppercase tracking-wider text-term-muted">{label}</div>
|
<div className="text-[0.6875rem] uppercase tracking-wider text-term-muted">{label}</div>
|
||||||
<div className={`mt-0.5 text-lg font-bold tabular-nums ${color}`}>{value}</div>
|
<div className={`mt-0.5 text-lg font-bold tabular-nums ${color}`}>{value}</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -299,7 +299,7 @@ function Kpi({ label, value, accent }: { label: string; value: string; accent?:
|
|||||||
function Panel({ title, children }: { title: string; children: React.ReactNode }) {
|
function Panel({ title, children }: { title: string; children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-term border border-term-border bg-term-panel p-3">
|
<div className="rounded-term border border-term-border bg-term-panel p-3">
|
||||||
<h2 className="mb-2 text-[11px] uppercase tracking-wider text-term-muted">{title}</h2>
|
<h2 className="mb-2 text-[0.6875rem] uppercase tracking-wider text-term-muted">{title}</h2>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -315,5 +315,5 @@ function Row({ label, value }: { label: string; value: string }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function Empty({ t }: { t: TFunction }) {
|
function Empty({ t }: { t: TFunction }) {
|
||||||
return <p className="py-12 text-center text-[12px] text-term-muted">{t("reports.noData")}</p>;
|
return <p className="py-12 text-center text-[0.75rem] text-term-muted">{t("reports.noData")}</p>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ export function RolesManager({ user }: { user: SessionUser | null }) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && <div className="mb-2 rounded-term border border-term-red px-3 py-2 text-[12px] text-term-red">{error}</div>}
|
{error && <div className="mb-2 rounded-term border border-term-red px-3 py-2 text-[0.75rem] text-term-red">{error}</div>}
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
open={editing != null}
|
open={editing != null}
|
||||||
@@ -93,13 +93,13 @@ export function RolesManager({ user }: { user: SessionUser | null }) {
|
|||||||
<div key={r.id} className="rounded-term border border-term-border bg-term-panel p-3">
|
<div key={r.id} className="rounded-term border border-term-border bg-term-panel p-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-[13px] font-semibold text-term-text">{r.name}</span>
|
<span className="text-[0.8125rem] font-semibold text-term-text">{r.name}</span>
|
||||||
{r.builtin && (
|
{r.builtin && (
|
||||||
<span className="rounded-term border border-term-amber/50 px-1.5 py-0.5 text-[10px] uppercase tracking-wider text-term-amber">
|
<span className="rounded-term border border-term-amber/50 px-1.5 py-0.5 text-[0.625rem] uppercase tracking-wider text-term-amber">
|
||||||
{t("roles.builtin")}
|
{t("roles.builtin")}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<span className="text-[11px] text-term-muted">
|
<span className="text-[0.6875rem] text-term-muted">
|
||||||
{t("roles.permCount", { count: r.permissions.length })} · {t("roles.userCount", { count: r.userCount })}
|
{t("roles.permCount", { count: r.permissions.length })} · {t("roles.userCount", { count: r.userCount })}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -155,11 +155,11 @@ function RoleEditor({
|
|||||||
<div className="mt-1 grid grid-cols-1 gap-1">
|
<div className="mt-1 grid grid-cols-1 gap-1">
|
||||||
{Object.entries(grouped).map(([resource, list]) => (
|
{Object.entries(grouped).map(([resource, list]) => (
|
||||||
<div key={resource} className="flex flex-wrap items-center gap-x-4 gap-y-1 border-t border-term-border py-1.5">
|
<div key={resource} className="flex flex-wrap items-center gap-x-4 gap-y-1 border-t border-term-border py-1.5">
|
||||||
<span className="w-28 shrink-0 text-[12px] font-semibold text-term-text">{resource}</span>
|
<span className="w-28 shrink-0 text-[0.75rem] font-semibold text-term-text">{resource}</span>
|
||||||
{list.map((p) => {
|
{list.map((p) => {
|
||||||
const action = p.split(":")[1]!;
|
const action = p.split(":")[1]!;
|
||||||
return (
|
return (
|
||||||
<label key={p} className="flex items-center gap-1 text-[12px] text-term-text">
|
<label key={p} className="flex items-center gap-1 text-[0.75rem] text-term-text">
|
||||||
<input type="checkbox" className="accent-term-amber" checked={perms.has(p)} onChange={() => toggle(p)} />
|
<input type="checkbox" className="accent-term-amber" checked={perms.has(p)} onChange={() => toggle(p)} />
|
||||||
{action}
|
{action}
|
||||||
</label>
|
</label>
|
||||||
|
|||||||
+589
-82
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect, useCallback } from "react";
|
import { useState, useEffect, useCallback, Fragment } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
assignDevice,
|
assignDevice,
|
||||||
@@ -9,8 +9,10 @@ import {
|
|||||||
fetchState,
|
fetchState,
|
||||||
testAnpr,
|
testAnpr,
|
||||||
testDevice,
|
testDevice,
|
||||||
|
testPrint,
|
||||||
unassignDevice,
|
unassignDevice,
|
||||||
type AnprTestResult,
|
type AnprTestResult,
|
||||||
|
type PrintTestResult,
|
||||||
type Assignment,
|
type Assignment,
|
||||||
type BackendIpCandidate,
|
type BackendIpCandidate,
|
||||||
type Catalog,
|
type Catalog,
|
||||||
@@ -19,6 +21,9 @@ import {
|
|||||||
type DeviceConfig,
|
type DeviceConfig,
|
||||||
type Direction,
|
type Direction,
|
||||||
type DiscoveredDevice,
|
type DiscoveredDevice,
|
||||||
|
type InputRole,
|
||||||
|
type InputSpec,
|
||||||
|
type RelayEvent,
|
||||||
type RelaySpec,
|
type RelaySpec,
|
||||||
type TestResult,
|
type TestResult,
|
||||||
} from "./api.js";
|
} from "./api.js";
|
||||||
@@ -46,13 +51,63 @@ const BOUND: { key: DeviceCategory; titleKey: string; nounKey: string }[] = [
|
|||||||
{ key: "printer", titleKey: "setup.catPrinters", nounKey: "setup.nounPrinter" },
|
{ key: "printer", titleKey: "setup.catPrinters", nounKey: "setup.nounPrinter" },
|
||||||
];
|
];
|
||||||
|
|
||||||
// Translated direction label (relay direction / inherited binding).
|
// Translated relay-event label (barrier direction, inherited binding, or alert).
|
||||||
const DIRECTION_KEYS: Record<Direction, string> = {
|
const DIRECTION_KEYS: Record<RelayEvent, string> = {
|
||||||
entry: "setup.dirEntry",
|
entry: "setup.dirEntry",
|
||||||
exit: "setup.dirExit",
|
exit: "setup.dirExit",
|
||||||
both: "setup.dirBoth",
|
both: "setup.dirBoth",
|
||||||
|
radarAlert: "setup.eventRadarAlert",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// The input-role dropdown folds presence `kind` into the choice: one select offers Button,
|
||||||
|
// Presence (loop), Presence (radar), Alert trigger. Each maps to a {role, kind} pair.
|
||||||
|
type InputChoice = "button" | "presenceLoop" | "presenceRadar" | "alertTrigger";
|
||||||
|
const INPUT_CHOICE_KEYS: Record<InputChoice, string> = {
|
||||||
|
button: "setup.roleButton",
|
||||||
|
presenceLoop: "setup.rolePresenceLoop",
|
||||||
|
presenceRadar: "setup.rolePresenceRadar",
|
||||||
|
alertTrigger: "setup.roleAlertTrigger",
|
||||||
|
};
|
||||||
|
function choiceOf(i: InputSpec): InputChoice {
|
||||||
|
if (i.role === "button") return "button";
|
||||||
|
if (i.role === "alertTrigger") return "alertTrigger";
|
||||||
|
return i.kind === "radar" ? "presenceRadar" : "presenceLoop";
|
||||||
|
}
|
||||||
|
function applyChoice(choice: InputChoice): { role: InputRole; kind?: "loop" | "radar" } {
|
||||||
|
switch (choice) {
|
||||||
|
case "button":
|
||||||
|
return { role: "button" };
|
||||||
|
case "alertTrigger":
|
||||||
|
return { role: "alertTrigger" };
|
||||||
|
case "presenceLoop":
|
||||||
|
return { role: "presence", kind: "loop" };
|
||||||
|
case "presenceRadar":
|
||||||
|
return { role: "presence", kind: "radar" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Synthesize an inputs[] list from the LEGACY per-relay button/presence fields, so an
|
||||||
|
* existing controller (saved before inputs[]) opens with its inputs populated. Mirrors the
|
||||||
|
* server's `inputsOf()` back-compat fold. */
|
||||||
|
function synthInputsFromRelays(relays: RelaySpec[]): InputSpec[] {
|
||||||
|
const out: InputSpec[] = [];
|
||||||
|
for (const r of relays) {
|
||||||
|
if (typeof r.button === "number") {
|
||||||
|
out.push({ input: r.button, role: "button", relay: r.relay, cooldownSec: r.entryCooldownSec });
|
||||||
|
}
|
||||||
|
if (typeof r.presenceInput === "number") {
|
||||||
|
out.push({
|
||||||
|
input: r.presenceInput,
|
||||||
|
role: "presence",
|
||||||
|
relay: r.relay,
|
||||||
|
kind: r.presenceKind ?? "loop",
|
||||||
|
activeLow: r.presenceActiveLow,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
export function SetupWizard() {
|
export function SetupWizard() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [catalog, setCatalog] = useState<Catalog | null>(null);
|
const [catalog, setCatalog] = useState<Catalog | null>(null);
|
||||||
@@ -79,7 +134,7 @@ export function SetupWizard() {
|
|||||||
return (
|
return (
|
||||||
<section className="px-4 py-6">
|
<section className="px-4 py-6">
|
||||||
<h2 className="mb-1 text-h4 font-semibold text-term-text">{t("setup.title")}</h2>
|
<h2 className="mb-1 text-h4 font-semibold text-term-text">{t("setup.title")}</h2>
|
||||||
<p className="hint mb-4 max-w-prose">{t("setup.intro")}</p>
|
<p className="hint mb-4 max-w">{t("setup.intro")}</p>
|
||||||
|
|
||||||
<CategorySection
|
<CategorySection
|
||||||
category={CONTROLLER.key}
|
category={CONTROLLER.key}
|
||||||
@@ -151,8 +206,8 @@ function CategorySection({
|
|||||||
|
|
||||||
{warnings.length > 0 && (
|
{warnings.length > 0 && (
|
||||||
<div className="mb-3 rounded-term border border-term-amber/60 bg-term-amber/10 px-3 py-2">
|
<div className="mb-3 rounded-term border border-term-amber/60 bg-term-amber/10 px-3 py-2">
|
||||||
<strong className="text-[12px] text-term-amber">{t("setup.warnTitle")}</strong>
|
<strong className="text-[0.75rem] text-term-amber">{t("setup.warnTitle")}</strong>
|
||||||
<ul className="mt-1 list-disc pl-5 text-[12px] text-term-amber">
|
<ul className="mt-1 list-disc pl-5 text-[0.75rem] text-term-amber">
|
||||||
{warnings.map((w, i) => (
|
{warnings.map((w, i) => (
|
||||||
<li key={i}>{w}</li>
|
<li key={i}>{w}</li>
|
||||||
))}
|
))}
|
||||||
@@ -178,7 +233,7 @@ function CategorySection({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{blockedNoController ? (
|
{blockedNoController ? (
|
||||||
<p className="m-0 text-[12px] text-term-amber">{t("setup.needControllerFirst", { noun })}</p>
|
<p className="m-0 text-[0.75rem] text-term-amber">{t("setup.needControllerFirst", { noun })}</p>
|
||||||
) : (
|
) : (
|
||||||
<button type="button" className="btn btn-sm" onClick={() => setFormFor("new")}>
|
<button type="button" className="btn btn-sm" onClick={() => setFormFor("new")}>
|
||||||
{assignments.length === 0 ? t("setup.add", { noun }) : t("setup.addAnother", { noun })}
|
{assignments.length === 0 ? t("setup.add", { noun }) : t("setup.addAnother", { noun })}
|
||||||
@@ -246,7 +301,7 @@ function AssignmentRow({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li className="flex items-center gap-2 border-b border-term-border/60 px-1 py-2 text-[12px]">
|
<li className="flex items-center gap-2 border-b border-term-border/60 px-1 py-2 text-[0.75rem]">
|
||||||
<strong className="text-term-text">{assignment.driverId}</strong>
|
<strong className="text-term-text">{assignment.driverId}</strong>
|
||||||
{host && <span className="tabular-nums text-term-muted">{host}</span>}
|
{host && <span className="tabular-nums text-term-muted">{host}</span>}
|
||||||
<DeviceSummary assignment={assignment} controllers={controllers} />
|
<DeviceSummary assignment={assignment} controllers={controllers} />
|
||||||
@@ -270,11 +325,25 @@ function DeviceSummary({ assignment, controllers }: { assignment: Assignment; co
|
|||||||
if (assignment.category === "access") {
|
if (assignment.category === "access") {
|
||||||
const relays = Array.isArray(cfg.relays) ? (cfg.relays as RelaySpec[]) : [];
|
const relays = Array.isArray(cfg.relays) ? (cfg.relays as RelaySpec[]) : [];
|
||||||
if (relays.length === 0) return <em className="text-term-amber">{t("setup.noRelaysSet")}</em>;
|
if (relays.length === 0) return <em className="text-term-amber">{t("setup.noRelaysSet")}</em>;
|
||||||
|
// Effective inputs: config.inputs[] if present, else synthesized from legacy relay fields.
|
||||||
|
const inputs = Array.isArray(cfg.inputs) ? (cfg.inputs as InputSpec[]) : synthInputsFromRelays(relays);
|
||||||
return (
|
return (
|
||||||
<span className="flex gap-1.5">
|
<span className="flex flex-wrap gap-1.5">
|
||||||
{relays.map((r) => (
|
{relays.map((r) => {
|
||||||
<DirectionBadge key={r.relay} direction={r.direction} label={`R${r.relay}${r.button ? `·btn${r.button}` : ""}`} />
|
// Alert relay: trigger input + lock lane. Barrier: its button + presence inputs.
|
||||||
))}
|
let wiring = "";
|
||||||
|
if (r.direction === "radarAlert") {
|
||||||
|
if (r.triggerInput) wiring += `·trig${r.triggerInput}`;
|
||||||
|
if (r.lockLane === "exit") wiring += "·lockExit";
|
||||||
|
} else {
|
||||||
|
const served = inputs.filter((x) => x.relay === r.relay);
|
||||||
|
const btn = served.find((x) => x.role === "button");
|
||||||
|
const pres = served.find((x) => x.role === "presence");
|
||||||
|
if (btn) wiring += `·btn${btn.input}`;
|
||||||
|
if (pres) wiring += `·${pres.kind === "radar" ? "radar" : "loop"}${pres.input}`;
|
||||||
|
}
|
||||||
|
return <DirectionBadge key={r.relay} direction={r.direction} label={`R${r.relay}${wiring}`} />;
|
||||||
|
})}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -325,9 +394,17 @@ function DeviceForm({
|
|||||||
const pushesToBackend = selected != null && pushCapableIds.includes(selected.id);
|
const pushesToBackend = selected != null && pushCapableIds.includes(selected.id);
|
||||||
const isController = category === "access";
|
const isController = category === "access";
|
||||||
const isCamera = category === "camera";
|
const isCamera = category === "camera";
|
||||||
// ANPR opt-in for a camera: when true, the VisionReader polls this camera for plates
|
const isPrinter = category === "printer";
|
||||||
// (config.anpr). Off by default. See wiki/entities/opencv-anpr-service.md.
|
// ANPR opt-in for a camera: when true, this camera's snapshots are run through the
|
||||||
|
// recognizer (plate recorded as evidence, both directions). (config.anpr). Off by default.
|
||||||
|
// See wiki/entities/opencv-anpr-service.md.
|
||||||
const [anpr, setAnpr] = useState<boolean>(editCfg?.anpr === true);
|
const [anpr, setAnpr] = useState<boolean>(editCfg?.anpr === true);
|
||||||
|
// Auto-trigger: when true, THIS camera's vehicle detection may auto-open the barrier
|
||||||
|
// (subscriber entry/exit). Separate from `anpr` so a shared entry/exit lane can keep
|
||||||
|
// RECOGNITION on both cameras but disable auto-open on, e.g., the exit camera (whose
|
||||||
|
// back-plate read would otherwise phantom-exit the car that just entered). Defaults ON
|
||||||
|
// when anpr is on (back-compat). (config.anprAutoTrigger).
|
||||||
|
const [anprAuto, setAnprAuto] = useState<boolean>(editCfg?.anprAutoTrigger !== false);
|
||||||
|
|
||||||
// Pre-fill scalar config fields from the existing assignment when editing.
|
// Pre-fill scalar config fields from the existing assignment when editing.
|
||||||
// (relays/controllerId/relay are model fields handled by their own state below.)
|
// (relays/controllerId/relay are model fields handled by their own state below.)
|
||||||
@@ -341,10 +418,20 @@ function DeviceForm({
|
|||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
});
|
});
|
||||||
// Controllers: the relay map (which relay = entry/exit/both, + entry button terminal).
|
// Controllers: the unified relay map. Each relay reacts to an EVENT — entry/exit/both
|
||||||
|
// (pulse a barrier) or radarAlert (drive an alert lamp). Alert relays carry a trigger
|
||||||
|
// input + blink cadence; barriers carry no input wiring (that lives in `inputs` below).
|
||||||
const [relays, setRelays] = useState<RelaySpec[]>(() =>
|
const [relays, setRelays] = useState<RelaySpec[]>(() =>
|
||||||
Array.isArray(editCfg?.relays) ? (editCfg!.relays as RelaySpec[]) : [{ relay: 1, direction: "both" }],
|
Array.isArray(editCfg?.relays) ? (editCfg!.relays as RelaySpec[]) : [{ relay: 1, direction: "both" }],
|
||||||
);
|
);
|
||||||
|
// Controller INPUTS — a first-class list (button / presence / alertTrigger), each naming
|
||||||
|
// the relay it serves. Seed from config.inputs[] if present, else SYNTHESIZE from the
|
||||||
|
// legacy per-relay button/presence fields so an existing controller opens populated.
|
||||||
|
const [inputs, setInputs] = useState<InputSpec[]>(() => {
|
||||||
|
const stored = editCfg?.inputs;
|
||||||
|
if (Array.isArray(stored) && stored.length > 0) return stored as InputSpec[];
|
||||||
|
return synthInputsFromRelays(Array.isArray(editCfg?.relays) ? (editCfg!.relays as RelaySpec[]) : []);
|
||||||
|
});
|
||||||
// Bound devices: which controller + relay this device sits at.
|
// Bound devices: which controller + relay this device sits at.
|
||||||
const [controllerId, setControllerId] = useState<string>(
|
const [controllerId, setControllerId] = useState<string>(
|
||||||
typeof editCfg?.controllerId === "string" ? editCfg.controllerId : "",
|
typeof editCfg?.controllerId === "string" ? editCfg.controllerId : "",
|
||||||
@@ -357,9 +444,21 @@ function DeviceForm({
|
|||||||
const [testing, setTesting] = useState(false);
|
const [testing, setTesting] = useState(false);
|
||||||
const [testError, setTestError] = useState<string | null>(null);
|
const [testError, setTestError] = useState<string | null>(null);
|
||||||
// ANPR probe (camera + anpr on): snapshot → vision analyze, reported below.
|
// ANPR probe (camera + anpr on): snapshot → vision analyze, reported below.
|
||||||
|
const [alarmUrlCopied, setAlarmUrlCopied] = useState(false);
|
||||||
const [anprResult, setAnprResult] = useState<AnprTestResult | null>(null);
|
const [anprResult, setAnprResult] = useState<AnprTestResult | null>(null);
|
||||||
const [anprTesting, setAnprTesting] = useState(false);
|
const [anprTesting, setAnprTesting] = useState(false);
|
||||||
const [anprError, setAnprError] = useState<string | null>(null);
|
const [anprError, setAnprError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const [printResult, setPrintResult] = useState<PrintTestResult | null>(null);
|
||||||
|
const [printTesting, setPrintTesting] = useState(false);
|
||||||
|
const [printError, setPrintError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Which `secret` fields are currently unmasked. The device web password is an
|
||||||
|
// operational credential the admin legitimately needs (to reach the device's web
|
||||||
|
// UI) — it's stored + sent to this admin-only view; a per-field reveal toggle just
|
||||||
|
// makes the already-present value readable. (Machine secrets — relay/push pw — are
|
||||||
|
// redacted server-side and never reach here, so there's nothing to reveal.)
|
||||||
|
const [revealed, setRevealed] = useState<Record<string, boolean>>({});
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [saveError, setSaveError] = useState<string | null>(null);
|
const [saveError, setSaveError] = useState<string | null>(null);
|
||||||
const [found, setFound] = useState<DiscoveredDevice[] | null>(null);
|
const [found, setFound] = useState<DiscoveredDevice[] | null>(null);
|
||||||
@@ -368,22 +467,31 @@ function DeviceForm({
|
|||||||
|
|
||||||
const [backendIps, setBackendIps] = useState<BackendIpCandidate[] | null>(null);
|
const [backendIps, setBackendIps] = useState<BackendIpCandidate[] | null>(null);
|
||||||
const [backendIp, setBackendIp] = useState<string>("");
|
const [backendIp, setBackendIp] = useState<string>("");
|
||||||
|
// The server's listen port (e.g. 3000) the device must POST to — NOT the page's
|
||||||
|
// port (the SPA may be served by Vite on :5173 in dev, or behind a proxy on :80).
|
||||||
|
// Comes from the same /api/setup/backend-ips probe as the IPs.
|
||||||
|
const [backendPort, setBackendPort] = useState<number | null>(null);
|
||||||
|
|
||||||
const testedHost = tested ? String(mergedScalarConfig().host ?? "") : "";
|
const testedHost = tested ? String(mergedScalarConfig().host ?? "") : "";
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!testedHost || !pushesToBackend) {
|
if (!testedHost || !pushesToBackend) {
|
||||||
setBackendIps(null);
|
setBackendIps(null);
|
||||||
|
setBackendPort(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let live = true;
|
let live = true;
|
||||||
fetchBackendIps(testedHost)
|
fetchBackendIps(testedHost)
|
||||||
.then(({ candidates }) => {
|
.then(({ candidates, port }) => {
|
||||||
if (!live) return;
|
if (!live) return;
|
||||||
setBackendIps(candidates);
|
setBackendIps(candidates);
|
||||||
|
setBackendPort(port);
|
||||||
setBackendIp((cur) => cur || candidates.find((c) => c.onDeviceSubnet)?.ip || "");
|
setBackendIp((cur) => cur || candidates.find((c) => c.onDeviceSubnet)?.ip || "");
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
if (live) setBackendIps(null);
|
if (live) {
|
||||||
|
setBackendIps(null);
|
||||||
|
setBackendPort(null);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
return () => {
|
return () => {
|
||||||
live = false;
|
live = false;
|
||||||
@@ -437,12 +545,30 @@ function DeviceForm({
|
|||||||
function mergedConfig(): DeviceConfig {
|
function mergedConfig(): DeviceConfig {
|
||||||
const out: DeviceConfig = { ...mergedScalarConfig() };
|
const out: DeviceConfig = { ...mergedScalarConfig() };
|
||||||
if (isController) {
|
if (isController) {
|
||||||
out.relays = relays.map((r) => ({
|
// Relays carry ONLY the event (+ alert fields). Input wiring lives in out.inputs.
|
||||||
|
out.relays = relays.map((r) =>
|
||||||
|
r.direction === "radarAlert"
|
||||||
|
? {
|
||||||
|
// Alert lamp: trigger input + lock lane + blink cadence.
|
||||||
relay: r.relay,
|
relay: r.relay,
|
||||||
direction: r.direction,
|
direction: r.direction,
|
||||||
...(r.button ? { button: r.button } : {}),
|
...(r.triggerInput ? { triggerInput: r.triggerInput } : {}),
|
||||||
...(r.presenceInput ? { presenceInput: r.presenceInput } : {}),
|
...(r.lockLane && r.lockLane !== "entry" ? { lockLane: r.lockLane } : {}),
|
||||||
...(r.entryCooldownSec ? { entryCooldownSec: r.entryCooldownSec } : {}),
|
...(r.blinkOnMs ? { blinkOnMs: r.blinkOnMs } : {}),
|
||||||
|
...(r.blinkOffMs ? { blinkOffMs: r.blinkOffMs } : {}),
|
||||||
|
}
|
||||||
|
: { relay: r.relay, direction: r.direction },
|
||||||
|
);
|
||||||
|
// Inputs: a button/presence row needs its relay; alertTrigger may be standalone.
|
||||||
|
out.inputs = inputs
|
||||||
|
.filter((i) => typeof i.input === "number" && i.input > 0)
|
||||||
|
.map((i) => ({
|
||||||
|
input: i.input,
|
||||||
|
role: i.role,
|
||||||
|
...(typeof i.relay === "number" ? { relay: i.relay } : {}),
|
||||||
|
...(i.role === "presence" && i.kind ? { kind: i.kind } : {}),
|
||||||
|
...(i.role === "presence" && i.activeLow ? { activeLow: true } : {}),
|
||||||
|
...(i.role === "button" && i.cooldownSec ? { cooldownSec: i.cooldownSec } : {}),
|
||||||
}));
|
}));
|
||||||
} else if (controllerId && boundRelay !== "") {
|
} else if (controllerId && boundRelay !== "") {
|
||||||
out.controllerId = controllerId;
|
out.controllerId = controllerId;
|
||||||
@@ -450,6 +576,10 @@ function DeviceForm({
|
|||||||
}
|
}
|
||||||
// Camera ANPR opt-in (only persisted when on, to keep configs minimal).
|
// Camera ANPR opt-in (only persisted when on, to keep configs minimal).
|
||||||
if (isCamera && anpr) out.anpr = true;
|
if (isCamera && anpr) out.anpr = true;
|
||||||
|
// Auto-trigger flag — only meaningful when anpr is on. Persist it (true OR false) so a
|
||||||
|
// park can explicitly DISABLE auto-open on a camera (e.g. the exit cam of a shared lane)
|
||||||
|
// while keeping recognition. Absent ⇒ defaults ON (back-compat for existing cameras).
|
||||||
|
if (isCamera && anpr) out.anprAutoTrigger = anprAuto;
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -467,7 +597,7 @@ function DeviceForm({
|
|||||||
setTestError(null);
|
setTestError(null);
|
||||||
setTested(null);
|
setTested(null);
|
||||||
try {
|
try {
|
||||||
setTested(await testDevice(selected.id, mergedScalarConfig()));
|
setTested(await testDevice(selected.id, mergedScalarConfig(), editing?.id));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setTestError((e as Error).message);
|
setTestError((e as Error).message);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -492,6 +622,23 @@ function DeviceForm({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Push a real test slip to the printer — proves it physically prints (healthCheck
|
||||||
|
// only opens the transport). Passes editing?.id so an edited network printer's
|
||||||
|
// stored secrets re-merge. Never blocks save.
|
||||||
|
async function testPrintNow() {
|
||||||
|
if (!selected) return;
|
||||||
|
setPrintTesting(true);
|
||||||
|
setPrintError(null);
|
||||||
|
setPrintResult(null);
|
||||||
|
try {
|
||||||
|
setPrintResult(await testPrint(selected.id, mergedScalarConfig(), editing?.id));
|
||||||
|
} catch (e) {
|
||||||
|
setPrintError((e as Error).message);
|
||||||
|
} finally {
|
||||||
|
setPrintTesting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function save() {
|
async function save() {
|
||||||
if (!selected) return;
|
if (!selected) return;
|
||||||
// Bound devices must point at a controller relay (binding is optional in the
|
// Bound devices must point at a controller relay (binding is optional in the
|
||||||
@@ -543,19 +690,19 @@ function DeviceForm({
|
|||||||
|
|
||||||
{selected && (
|
{selected && (
|
||||||
<div className="mt-3">
|
<div className="mt-3">
|
||||||
<p className="mb-2 text-[12px] text-term-muted">{selected.description}</p>
|
<p className="mb-2 text-[0.75rem] text-term-muted">{selected.description}</p>
|
||||||
|
|
||||||
{canDiscover && (
|
{canDiscover && (
|
||||||
<div className="my-2 rounded-term border border-term-border bg-term-bg p-2">
|
<div className="my-2 rounded-term border border-term-border bg-term-bg p-2">
|
||||||
<button type="button" className="btn btn-sm" onClick={scan} disabled={scanning}>
|
<button type="button" className="btn btn-sm" onClick={scan} disabled={scanning}>
|
||||||
{scanning ? t("setup.scanning") : t("setup.scan")}
|
{scanning ? t("setup.scanning") : t("setup.scan")}
|
||||||
</button>
|
</button>
|
||||||
{scanError && <span className="ml-2 text-[12px] text-term-red">{scanError}</span>}
|
{scanError && <span className="ml-2 text-[0.75rem] text-term-red">{scanError}</span>}
|
||||||
{found && found.length === 0 && <p className="mt-2 text-[12px] text-term-muted">{t("setup.noControllersFound")}</p>}
|
{found && found.length === 0 && <p className="mt-2 text-[0.75rem] text-term-muted">{t("setup.noControllersFound")}</p>}
|
||||||
{found && found.length > 0 && (
|
{found && found.length > 0 && (
|
||||||
<ul className="mt-2 list-none p-0">
|
<ul className="mt-2 list-none p-0">
|
||||||
{found.map((d) => (
|
{found.map((d) => (
|
||||||
<li key={d.id} className="my-1 flex items-center gap-2 text-[12px]">
|
<li key={d.id} className="my-1 flex items-center gap-2 text-[0.75rem]">
|
||||||
<button type="button" className="btn btn-sm" onClick={() => applyDiscovered(d)}>
|
<button type="button" className="btn btn-sm" onClick={() => applyDiscovered(d)}>
|
||||||
{t("setup.use")}
|
{t("setup.use")}
|
||||||
</button>
|
</button>
|
||||||
@@ -569,11 +716,25 @@ function DeviceForm({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{selected.configFields.map((f) =>
|
{selected.configFields
|
||||||
|
// pulseMs + inputRestingHigh are surfaced in the Outputs / Inputs model
|
||||||
|
// sections below (a relay setting and an input setting, respectively), so
|
||||||
|
// skip them here to avoid rendering them twice. See OutputEditor/InputEditor.
|
||||||
|
.filter((f) => !(isController && (f.key === "pulseMs" || f.key === "inputRestingHigh")))
|
||||||
|
// Printer transport is exclusive: when Connection = USB the network fields
|
||||||
|
// (host/port/status-page) don't apply, and vice-versa the USB device path
|
||||||
|
// doesn't. Hide the irrelevant side so the form can't mislead (e.g. a USB
|
||||||
|
// path lingering under a Network printer). Driven by config.transport.
|
||||||
|
.filter((f) => {
|
||||||
|
const transport = String(config.transport ?? "tcp-ip");
|
||||||
|
if (transport === "usb") return !["host", "port", "httpPort"].includes(f.key);
|
||||||
|
return f.key !== "devicePath";
|
||||||
|
})
|
||||||
|
.map((f) =>
|
||||||
f.type === "boolean" ? (
|
f.type === "boolean" ? (
|
||||||
// Boolean config field → a real checkbox (stores a true/false boolean, not
|
// Boolean config field → a real checkbox (stores a true/false boolean, not
|
||||||
// the string "true"). The label sits beside the box, with the help below.
|
// the string "true"). The label sits beside the box, with the help below.
|
||||||
<label key={f.key} className="my-2 flex max-w-sm items-start gap-2 rounded-term border border-term-border bg-term-bg p-2 text-[12px]">
|
<label key={f.key} className="my-2 flex max-w-sm items-start gap-2 rounded-term border border-term-border bg-term-bg p-2 text-[0.75rem]">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
className="mt-0.5"
|
className="mt-0.5"
|
||||||
@@ -611,10 +772,36 @@ function DeviceForm({
|
|||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
) : f.type === "secret" ? (
|
||||||
|
// Secret field with a reveal toggle: the device web password is shown
|
||||||
|
// here (admin-only view) so an admin can read/copy it to reach the
|
||||||
|
// device's own web UI. Masked by default; click the eye to reveal.
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<input
|
||||||
|
className="input flex-1"
|
||||||
|
type={revealed[f.key] ? "text" : "password"}
|
||||||
|
value={(config[f.key] ?? (f.default as string | number | undefined) ?? "") as string | number}
|
||||||
|
placeholder={f.help}
|
||||||
|
onChange={(e) => {
|
||||||
|
const v = e.target.value;
|
||||||
|
setConfig((c) => ({ ...c, [f.key]: v }));
|
||||||
|
resetStatus();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-sm"
|
||||||
|
aria-label={revealed[f.key] ? t("setup.hideSecret") : t("setup.revealSecret")}
|
||||||
|
title={revealed[f.key] ? t("setup.hideSecret") : t("setup.revealSecret")}
|
||||||
|
onClick={() => setRevealed((r) => ({ ...r, [f.key]: !r[f.key] }))}
|
||||||
|
>
|
||||||
|
{revealed[f.key] ? "🙈" : "👁"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<input
|
<input
|
||||||
className="input"
|
className="input"
|
||||||
type={f.type === "secret" ? "password" : f.type === "number" || f.type === "port" ? "number" : "text"}
|
type={f.type === "number" || f.type === "port" ? "number" : "text"}
|
||||||
value={(config[f.key] ?? (f.default as string | number | undefined) ?? "") as string | number}
|
value={(config[f.key] ?? (f.default as string | number | undefined) ?? "") as string | number}
|
||||||
placeholder={f.help}
|
placeholder={f.help}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
@@ -628,8 +815,33 @@ function DeviceForm({
|
|||||||
),
|
),
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* CONTROLLER: the relay map — which relay opens which direction + entry button. */}
|
{/* CONTROLLER — OUTPUTS: the unified relays (barriers pulse, alert relays blink). */}
|
||||||
{isController && <RelayEditor relays={relays} onChange={setRelays} />}
|
{isController && (
|
||||||
|
<OutputEditor
|
||||||
|
relays={relays}
|
||||||
|
onChange={setRelays}
|
||||||
|
pulseMs={config.pulseMs as number | undefined}
|
||||||
|
onPulseMsChange={(v) => {
|
||||||
|
setConfig((c) => ({ ...c, pulseMs: v }));
|
||||||
|
resetStatus();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* CONTROLLER — INPUTS: a generic terminal list (button / presence / alert trigger),
|
||||||
|
each naming the relay it serves. Separated from the outputs above. */}
|
||||||
|
{isController && (
|
||||||
|
<InputEditor
|
||||||
|
inputs={inputs}
|
||||||
|
onChange={setInputs}
|
||||||
|
relays={relays}
|
||||||
|
inputsIdleHigh={config.inputRestingHigh as boolean | undefined}
|
||||||
|
onInputsIdleHighChange={(v) => {
|
||||||
|
setConfig((c) => ({ ...c, inputRestingHigh: v }));
|
||||||
|
resetStatus();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* BOUND device: which controller + relay it sits at. */}
|
{/* BOUND device: which controller + relay it sits at. */}
|
||||||
{!isController && (
|
{!isController && (
|
||||||
@@ -647,7 +859,7 @@ function DeviceForm({
|
|||||||
|
|
||||||
{/* CAMERA: opt this camera into ANPR (the VisionReader polls it for plates). */}
|
{/* CAMERA: opt this camera into ANPR (the VisionReader polls it for plates). */}
|
||||||
{isCamera && (
|
{isCamera && (
|
||||||
<label className="my-2 flex items-start gap-2 rounded-term border border-term-border bg-term-bg p-2 text-[12px]">
|
<label className="my-2 flex items-start gap-2 rounded-term border border-term-border bg-term-bg p-2 text-[0.75rem]">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
className="mt-0.5"
|
className="mt-0.5"
|
||||||
@@ -661,6 +873,82 @@ function DeviceForm({
|
|||||||
</label>
|
</label>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Auto-trigger is only meaningful with ANPR on. Off = this camera RECOGNISES plates
|
||||||
|
(evidence) but does NOT auto-open the barrier — for a shared entry/exit lane where
|
||||||
|
the exit cam's back-plate read would phantom-exit a car that just entered. */}
|
||||||
|
{isCamera && anpr && (
|
||||||
|
<label className="my-2 flex items-start gap-2 rounded-term border border-term-border bg-term-bg p-2 text-[0.75rem]">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="mt-0.5"
|
||||||
|
checked={anprAuto}
|
||||||
|
onChange={(e) => setAnprAuto(e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
<span className="font-semibold text-term-text">{t("setup.anprAuto")}</span>
|
||||||
|
<span className="hint mt-0.5 block">{t("setup.anprAutoHint")}</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* CAMERA + Alarm Server push ON: show the camera's Alarm Server settings,
|
||||||
|
ready to copy, so the operator never has to find the deviceId or memorise the
|
||||||
|
endpoint. The CAMERA reaches us over the device VLAN, NOT via the browser's
|
||||||
|
origin — so host/port are the BACKEND address (backendIp on the camera's
|
||||||
|
subnet + the server's listen port), resolved by the same probe the push-IP
|
||||||
|
picker uses, NOT window.location (which is the SPA's dev/proxy origin). The
|
||||||
|
URL embeds the deviceId, so it needs a SAVED camera; and the backend IP needs
|
||||||
|
a Test connection first. We surface each field separately, matching the
|
||||||
|
camera's Alarm Settings form (Destination IP / URL / Protocol / Port). */}
|
||||||
|
{isCamera && Boolean(config.alarmPushEnabled) && (
|
||||||
|
<div className="my-2 rounded-term border border-term-border bg-term-bg p-2 text-[0.75rem]">
|
||||||
|
<div className="font-semibold text-term-text">{t("setup.alarmUrlTitle")}</div>
|
||||||
|
{!editing?.id ? (
|
||||||
|
<p className="hint mt-1">{t("setup.alarmUrlSaveFirst")}</p>
|
||||||
|
) : !backendIp || backendPort == null ? (
|
||||||
|
<p className="hint mt-1">{t("setup.alarmUrlTestFirst")}</p>
|
||||||
|
) : (
|
||||||
|
(() => {
|
||||||
|
const path = `/api/devices/hikvision/${editing.id}/event`;
|
||||||
|
// What the operator pastes into the camera's Alarm Settings form.
|
||||||
|
const fields: [string, string][] = [
|
||||||
|
[t("setup.alarmFieldHost"), backendIp],
|
||||||
|
[t("setup.alarmFieldUrl"), path],
|
||||||
|
[t("setup.alarmFieldProtocol"), "HTTP"],
|
||||||
|
[t("setup.alarmFieldPort"), String(backendPort)],
|
||||||
|
];
|
||||||
|
const copyText = fields.map(([k, v]) => `${k}: ${v}`).join("\n");
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="mt-1 grid grid-cols-[auto_1fr] gap-x-3 gap-y-1">
|
||||||
|
{fields.map(([k, v]) => (
|
||||||
|
<Fragment key={k}>
|
||||||
|
<span className="text-term-muted">{k}</span>
|
||||||
|
<code className="break-all rounded bg-term-panel px-2 py-0.5 text-term-green">{v}</code>
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-sm"
|
||||||
|
onClick={() => {
|
||||||
|
void navigator.clipboard?.writeText(copyText);
|
||||||
|
setAlarmUrlCopied(true);
|
||||||
|
setTimeout(() => setAlarmUrlCopied(false), 2000);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{alarmUrlCopied ? t("setup.alarmUrlCopied") : t("setup.alarmUrlCopy")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="hint mt-1">{t("setup.alarmUrlHint")}</p>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
})()
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Test (no save/no device change) then Save (configures + persists). */}
|
{/* Test (no save/no device change) then Save (configures + persists). */}
|
||||||
<div className="mt-3 flex items-center gap-2">
|
<div className="mt-3 flex items-center gap-2">
|
||||||
<button type="button" className="btn btn-sm" onClick={test} disabled={testing}>
|
<button type="button" className="btn btn-sm" onClick={test} disabled={testing}>
|
||||||
@@ -676,9 +964,9 @@ function DeviceForm({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{testError && <p className="mt-2 text-[12px] text-term-red">{t("setup.testFailed", { error: testError })}</p>}
|
{testError && <p className="mt-2 text-[0.75rem] text-term-red">{t("setup.testFailed", { error: testError })}</p>}
|
||||||
{tested && (
|
{tested && (
|
||||||
<div className="mt-2 text-[12px]">
|
<div className="mt-2 text-[0.75rem]">
|
||||||
<div className="text-term-text">
|
<div className="text-term-text">
|
||||||
{t("setup.deviceLabel")} <HealthBadge status={tested.health.status} />
|
{t("setup.deviceLabel")} <HealthBadge status={tested.health.status} />
|
||||||
{tested.health.detail && <span className="text-term-muted"> — {tested.health.detail}</span>}
|
{tested.health.detail && <span className="text-term-muted"> — {tested.health.detail}</span>}
|
||||||
@@ -705,10 +993,10 @@ function DeviceForm({
|
|||||||
</button>
|
</button>
|
||||||
<p className="hint mt-1">{t("setup.testAnprHint")}</p>
|
<p className="hint mt-1">{t("setup.testAnprHint")}</p>
|
||||||
|
|
||||||
{anprError && <p className="mt-2 text-[12px] text-term-red">{t("setup.testFailed", { error: anprError })}</p>}
|
{anprError && <p className="mt-2 text-[0.75rem] text-term-red">{t("setup.testFailed", { error: anprError })}</p>}
|
||||||
{anprResult &&
|
{anprResult &&
|
||||||
(anprResult.ok ? (
|
(anprResult.ok ? (
|
||||||
<div className="mt-2 text-[12px] text-term-green">
|
<div className="mt-2 text-[0.75rem] text-term-green">
|
||||||
{t("setup.anprOk", {
|
{t("setup.anprOk", {
|
||||||
plate: anprResult.plate,
|
plate: anprResult.plate,
|
||||||
confidence: Math.round(anprResult.confidence * 100),
|
confidence: Math.round(anprResult.confidence * 100),
|
||||||
@@ -719,7 +1007,7 @@ function DeviceForm({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="mt-2 text-[12px] text-term-amber">
|
<div className="mt-2 text-[0.75rem] text-term-amber">
|
||||||
⚠ {t(`setup.anprFail.${anprResult.reason}`, { defaultValue: anprResult.reason })}
|
⚠ {t(`setup.anprFail.${anprResult.reason}`, { defaultValue: anprResult.reason })}
|
||||||
{anprResult.detail && <span className="text-term-muted"> — {anprResult.detail}</span>}
|
{anprResult.detail && <span className="text-term-muted"> — {anprResult.detail}</span>}
|
||||||
{anprResult.tookMs != null && (
|
{anprResult.tookMs != null && (
|
||||||
@@ -730,6 +1018,30 @@ function DeviceForm({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* PRINTER: push a real test slip so the admin can confirm it physically
|
||||||
|
prints (healthCheck only opens the transport / USB node). */}
|
||||||
|
{isPrinter && (
|
||||||
|
<div className="mt-3 rounded-term border border-term-border bg-term-bg p-2">
|
||||||
|
<button type="button" className="btn btn-sm" onClick={testPrintNow} disabled={printTesting}>
|
||||||
|
{printTesting ? t("setup.printTesting") : t("setup.testPrint")}
|
||||||
|
</button>
|
||||||
|
<p className="hint mt-1">{t("setup.testPrintHint")}</p>
|
||||||
|
|
||||||
|
{printError && <p className="mt-2 text-[0.75rem] text-term-red">{t("setup.testFailed", { error: printError })}</p>}
|
||||||
|
{printResult &&
|
||||||
|
(printResult.ok ? (
|
||||||
|
<div className="mt-2 text-[0.75rem] text-term-green">
|
||||||
|
{t("setup.printOk", { ms: printResult.tookMs })}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="mt-2 text-[0.75rem] text-term-amber">
|
||||||
|
⚠ {t(`setup.printFail.${printResult.reason}`, { defaultValue: printResult.reason })}
|
||||||
|
{printResult.detail && <span className="text-term-muted"> — {printResult.detail}</span>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{backendIps && backendIps.length > 0 && (
|
{backendIps && backendIps.length > 0 && (
|
||||||
<div className="mt-3">
|
<div className="mt-3">
|
||||||
<div className="field max-w-md">
|
<div className="field max-w-md">
|
||||||
@@ -748,21 +1060,37 @@ function DeviceForm({
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
{!backendIps.some((c) => c.onDeviceSubnet) && (
|
{!backendIps.some((c) => c.onDeviceSubnet) && (
|
||||||
<span className="text-[12px] text-term-amber">{t("setup.noNicOnSubnet")}</span>
|
<span className="text-[0.75rem] text-term-amber">{t("setup.noNicOnSubnet")}</span>
|
||||||
)}
|
)}
|
||||||
<p className="hint mt-1">{t("setup.backendIpHint")}</p>
|
<p className="hint mt-1">{t("setup.backendIpHint")}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{saveError && <p className="mt-2 text-[12px] text-term-red">{t("setup.saveFailed", { error: saveError })}</p>}
|
{saveError && <p className="mt-2 text-[0.75rem] text-term-red">{t("setup.saveFailed", { error: saveError })}</p>}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Controller relay map editor: each row = a relay + its direction + (optional)
|
// ── Controller OUTPUTS (relays) ────────────────────────────────────────────
|
||||||
* the input terminal its entry button is wired to. */
|
// A relay is an OUTPUT reacting to an EVENT: entry/exit/both PULSE a barrier; radarAlert
|
||||||
function RelayEditor({ relays, onChange }: { relays: RelaySpec[]; onChange: (r: RelaySpec[]) => void }) {
|
// BLINKS an indicator lamp (and a camera-confirmed car locks it solid). This section owns
|
||||||
|
// the relay number + event, the pulse-open hold time (barriers), and — for alert relays —
|
||||||
|
// the trigger input + blink cadence. The barrier INPUT terminals (entry button, presence)
|
||||||
|
// live in InputEditor below; the two are deliberately separated.
|
||||||
|
|
||||||
|
/** Relays = the unified event→action outputs + the pulse-open hold time. */
|
||||||
|
function OutputEditor({
|
||||||
|
relays,
|
||||||
|
onChange,
|
||||||
|
pulseMs,
|
||||||
|
onPulseMsChange,
|
||||||
|
}: {
|
||||||
|
relays: RelaySpec[];
|
||||||
|
onChange: (r: RelaySpec[]) => void;
|
||||||
|
pulseMs: number | undefined;
|
||||||
|
onPulseMsChange: (v: number) => void;
|
||||||
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
function update(i: number, patch: Partial<RelaySpec>) {
|
function update(i: number, patch: Partial<RelaySpec>) {
|
||||||
onChange(relays.map((r, idx) => (idx === i ? { ...r, ...patch } : r)));
|
onChange(relays.map((r, idx) => (idx === i ? { ...r, ...patch } : r)));
|
||||||
@@ -777,11 +1105,27 @@ function RelayEditor({ relays, onChange }: { relays: RelaySpec[]; onChange: (r:
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="my-2 rounded-term border border-term-border bg-term-bg p-2">
|
<div className="my-2 rounded-term border border-term-border bg-term-bg p-2">
|
||||||
<strong className="text-[12px] uppercase tracking-wider text-term-text">{t("setup.relaysTitle")}</strong>
|
<strong className="text-[0.75rem] uppercase tracking-wider text-term-text">{t("setup.outputsTitle")}</strong>
|
||||||
<p className="hint mt-0.5 mb-2">{t("setup.relaysHint")}</p>
|
<p className="hint mt-0.5 mb-2">{t("setup.outputsHint")}</p>
|
||||||
|
|
||||||
|
{/* Pulse-open time applies to every barrier relay (how long it's held open). */}
|
||||||
|
<label className="my-1 inline-flex items-center gap-1.5 text-[0.75rem] text-term-muted" title={t("setup.pulseOpenHint")}>
|
||||||
|
{t("setup.pulseOpenMs")}
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={100}
|
||||||
|
value={pulseMs ?? ""}
|
||||||
|
placeholder="500"
|
||||||
|
className="input input-sm w-20"
|
||||||
|
onChange={(e) => onPulseMsChange(Number(e.target.value))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{/* Each relay: number + event. radarAlert reveals its trigger input + blink cadence;
|
||||||
|
barriers pulse (their button/presence terminals are in the Inputs section). */}
|
||||||
{relays.map((r, i) => (
|
{relays.map((r, i) => (
|
||||||
<div key={i} className="my-1 flex flex-wrap items-center gap-2">
|
<div key={i} className="my-1 flex flex-wrap items-center gap-2">
|
||||||
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted">
|
<label className="inline-flex items-center gap-1.5 text-[0.75rem] text-term-muted">
|
||||||
{t("setup.relay")}
|
{t("setup.relay")}
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
@@ -791,56 +1135,68 @@ function RelayEditor({ relays, onChange }: { relays: RelaySpec[]; onChange: (r:
|
|||||||
onChange={(e) => update(i, { relay: Number(e.target.value) })}
|
onChange={(e) => update(i, { relay: Number(e.target.value) })}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<select className="select input-sm w-auto" value={r.direction} onChange={(e) => update(i, { direction: e.target.value as Direction })}>
|
<select
|
||||||
{(["entry", "exit", "both"] as Direction[]).map((d) => (
|
className="select input-sm w-auto"
|
||||||
|
value={r.direction}
|
||||||
|
onChange={(e) => update(i, { direction: e.target.value as RelayEvent })}
|
||||||
|
>
|
||||||
|
{(["entry", "exit", "both", "radarAlert"] as RelayEvent[]).map((d) => (
|
||||||
<option key={d} value={d}>
|
<option key={d} value={d}>
|
||||||
{t(DIRECTION_KEYS[d])}
|
{t(DIRECTION_KEYS[d])}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
{(r.direction === "entry" || r.direction === "both") && (
|
|
||||||
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted">
|
{/* Alert relay: which input fires the blink + the blink cadence. */}
|
||||||
{t("setup.entryButtonTerminal")}
|
{r.direction === "radarAlert" && (
|
||||||
|
<>
|
||||||
|
<label className="inline-flex items-center gap-1.5 text-[0.75rem] text-term-muted" title={t("setup.triggerInputHint")}>
|
||||||
|
{t("setup.triggerInput")}
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
min={1}
|
min={1}
|
||||||
value={r.button ?? ""}
|
value={r.triggerInput ?? ""}
|
||||||
placeholder="—"
|
placeholder="—"
|
||||||
className="input input-sm w-16"
|
className="input input-sm w-16"
|
||||||
onChange={(e) => update(i, { button: e.target.value === "" ? undefined : Number(e.target.value) })}
|
onChange={(e) => update(i, { triggerInput: e.target.value === "" ? undefined : Number(e.target.value) })}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
)}
|
<label className="inline-flex items-center gap-1.5 text-[0.75rem] text-term-muted" title={t("setup.lockLaneHint")}>
|
||||||
{(r.direction === "entry" || r.direction === "both") && (
|
{t("setup.lockLane")}
|
||||||
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted" title={t("setup.presenceInputHint")}>
|
<select
|
||||||
{t("setup.presenceInput")}
|
className="select input-sm w-auto"
|
||||||
|
value={r.lockLane ?? "entry"}
|
||||||
|
onChange={(e) => update(i, { lockLane: e.target.value as "entry" | "exit" })}
|
||||||
|
>
|
||||||
|
<option value="entry">{t("setup.lockLaneEntry")}</option>
|
||||||
|
<option value="exit">{t("setup.lockLaneExit")}</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="inline-flex items-center gap-1.5 text-[0.75rem] text-term-muted">
|
||||||
|
{t("setup.blinkOnMs")}
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
min={1}
|
min={50}
|
||||||
value={r.presenceInput ?? ""}
|
value={r.blinkOnMs ?? ""}
|
||||||
placeholder="—"
|
placeholder="500"
|
||||||
className="input input-sm w-16"
|
className="input input-sm w-20"
|
||||||
onChange={(e) =>
|
onChange={(e) => update(i, { blinkOnMs: e.target.value === "" ? undefined : Number(e.target.value) })}
|
||||||
update(i, { presenceInput: e.target.value === "" ? undefined : Number(e.target.value) })
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
)}
|
<label className="inline-flex items-center gap-1.5 text-[0.75rem] text-term-muted">
|
||||||
{(r.direction === "entry" || r.direction === "both") && !r.presenceInput && (
|
{t("setup.blinkOffMs")}
|
||||||
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted" title={t("setup.entryCooldownHint")}>
|
|
||||||
{t("setup.entryCooldown")}
|
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
min={0}
|
min={50}
|
||||||
value={r.entryCooldownSec ?? ""}
|
value={r.blinkOffMs ?? ""}
|
||||||
placeholder="—"
|
placeholder="500"
|
||||||
className="input input-sm w-16"
|
className="input input-sm w-20"
|
||||||
onChange={(e) =>
|
onChange={(e) => update(i, { blinkOffMs: e.target.value === "" ? undefined : Number(e.target.value) })}
|
||||||
update(i, { entryCooldownSec: e.target.value === "" ? undefined : Number(e.target.value) })
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{relays.length > 1 && (
|
{relays.length > 1 && (
|
||||||
<button type="button" className="btn btn-ghost btn-sm" onClick={() => remove(i)}>
|
<button type="button" className="btn btn-ghost btn-sm" onClick={() => remove(i)}>
|
||||||
✕
|
✕
|
||||||
@@ -855,6 +1211,152 @@ function RelayEditor({ relays, onChange }: { relays: RelaySpec[]; onChange: (r:
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Controller INPUTS (terminals) ──────────────────────────────────────────
|
||||||
|
// An input is a TERMINAL the host READS. It's a first-class list (the twin of the relays
|
||||||
|
// list above): each row is a terminal + a ROLE (entry button / presence loop / presence
|
||||||
|
// radar / alert trigger) + the relay it serves. Adding an exit radar = adding a row. The
|
||||||
|
// button never SETS a pulse — its electrical pulse is the device's to report — so no timing
|
||||||
|
// field lives here (pulse-open is an OUTPUT setting, in OutputEditor).
|
||||||
|
|
||||||
|
/** Generic controller-input list: terminal + role + the relay it serves. */
|
||||||
|
function InputEditor({
|
||||||
|
inputs,
|
||||||
|
onChange,
|
||||||
|
relays,
|
||||||
|
inputsIdleHigh,
|
||||||
|
onInputsIdleHighChange,
|
||||||
|
}: {
|
||||||
|
inputs: InputSpec[];
|
||||||
|
onChange: (v: InputSpec[]) => void;
|
||||||
|
relays: RelaySpec[];
|
||||||
|
inputsIdleHigh: boolean | undefined;
|
||||||
|
onInputsIdleHighChange: (v: boolean) => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
function update(i: number, patch: Partial<InputSpec>) {
|
||||||
|
onChange(inputs.map((row, idx) => (idx === i ? { ...row, ...patch } : row)));
|
||||||
|
}
|
||||||
|
function add() {
|
||||||
|
const firstEntry = relays.find((r) => r.direction === "entry" || r.direction === "both");
|
||||||
|
onChange([...inputs, { input: 1, role: "button", relay: firstEntry?.relay }]);
|
||||||
|
}
|
||||||
|
function remove(i: number) {
|
||||||
|
onChange(inputs.filter((_, idx) => idx !== i));
|
||||||
|
}
|
||||||
|
// Barrier relays an input can serve (button/presence gate a barrier; alert triggers don't).
|
||||||
|
const barrierRelays = relays.filter((r) => r.direction !== "radarAlert");
|
||||||
|
// A button row shows its cooldown fallback only if no presence row serves the same relay.
|
||||||
|
const hasPresenceFor = (relay?: number) =>
|
||||||
|
relay != null && inputs.some((x) => x.role === "presence" && x.relay === relay);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="my-2 rounded-term border border-term-border bg-term-bg p-2">
|
||||||
|
<strong className="text-[0.75rem] uppercase tracking-wider text-term-text">{t("setup.inputsTitle")}</strong>
|
||||||
|
<p className="hint mt-0.5 mb-2">{t("setup.inputsHint")}</p>
|
||||||
|
|
||||||
|
{/* Board-wide resting level (idle HIGH vs LOW) — an input property. */}
|
||||||
|
<label className="my-1 inline-flex items-start gap-2 text-[0.75rem] text-term-muted">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="mt-0.5"
|
||||||
|
checked={inputsIdleHigh ?? true}
|
||||||
|
onChange={(e) => onInputsIdleHighChange(e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
<span className="font-semibold text-term-text">{t("setup.inputsIdleHigh")}</span>
|
||||||
|
<span className="hint mt-0.5 block">{t("setup.inputsIdleHighHint")}</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{inputs.map((row, i) => {
|
||||||
|
const choice = choiceOf(row);
|
||||||
|
const isPresence = row.role === "presence";
|
||||||
|
const isButton = row.role === "button";
|
||||||
|
return (
|
||||||
|
<div key={i} className="my-1 flex flex-wrap items-center gap-2 border-t border-term-border pt-2">
|
||||||
|
<label className="inline-flex items-center gap-1.5 text-[0.75rem] text-term-muted">
|
||||||
|
{t("setup.inputTerminal")}
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
value={row.input}
|
||||||
|
className="input input-sm w-16"
|
||||||
|
onChange={(e) => update(i, { input: Number(e.target.value) })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
className="select input-sm w-auto"
|
||||||
|
value={choice}
|
||||||
|
onChange={(e) => update(i, applyChoice(e.target.value as InputChoice))}
|
||||||
|
>
|
||||||
|
{(["button", "presenceLoop", "presenceRadar", "alertTrigger"] as InputChoice[]).map((c) => (
|
||||||
|
<option key={c} value={c}>
|
||||||
|
{t(INPUT_CHOICE_KEYS[c])}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{/* Which barrier this input serves — button/presence only (alert triggers a lamp). */}
|
||||||
|
{row.role !== "alertTrigger" && (
|
||||||
|
<label className="inline-flex items-center gap-1.5 text-[0.75rem] text-term-muted">
|
||||||
|
{t("setup.inputServesRelay")}
|
||||||
|
<select
|
||||||
|
className="select input-sm w-auto"
|
||||||
|
value={row.relay ?? ""}
|
||||||
|
onChange={(e) => update(i, { relay: e.target.value === "" ? undefined : Number(e.target.value) })}
|
||||||
|
>
|
||||||
|
<option value="" disabled>
|
||||||
|
{t("setup.choose")}
|
||||||
|
</option>
|
||||||
|
{barrierRelays.map((r) => (
|
||||||
|
<option key={r.relay} value={r.relay}>
|
||||||
|
{t("setup.relayLabel", { relay: r.relay, direction: t(DIRECTION_KEYS[r.direction]) })}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Presence: active-low (a radar wired opposite the button). */}
|
||||||
|
{isPresence && (
|
||||||
|
<label className="inline-flex items-center gap-1.5 text-[0.75rem] text-term-muted" title={t("setup.activeLowHint")}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={!!row.activeLow}
|
||||||
|
onChange={(e) => update(i, { activeLow: e.target.checked || undefined })}
|
||||||
|
/>
|
||||||
|
{t("setup.activeLow")}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Button cooldown fallback — only when no presence sensor serves this relay. */}
|
||||||
|
{isButton && !hasPresenceFor(row.relay) && (
|
||||||
|
<label className="inline-flex items-center gap-1.5 text-[0.75rem] text-term-muted" title={t("setup.entryCooldownHint")}>
|
||||||
|
{t("setup.entryCooldown")}
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
value={row.cooldownSec ?? ""}
|
||||||
|
placeholder="—"
|
||||||
|
className="input input-sm w-16"
|
||||||
|
onChange={(e) => update(i, { cooldownSec: e.target.value === "" ? undefined : Number(e.target.value) })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button type="button" className="btn btn-ghost btn-sm" onClick={() => remove(i)}>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<button type="button" className="btn btn-sm mt-1" onClick={add}>
|
||||||
|
{t("setup.addInput")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** Binding picker for readers/cameras/printers: choose the controller + relay this
|
/** Binding picker for readers/cameras/printers: choose the controller + relay this
|
||||||
* device sits at. Direction is inherited from the chosen relay (shown). */
|
* device sits at. Direction is inherited from the chosen relay (shown). */
|
||||||
function BindingPicker({
|
function BindingPicker({
|
||||||
@@ -879,9 +1381,9 @@ function BindingPicker({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="my-2 rounded-term border border-term-border bg-term-bg p-2">
|
<div className="my-2 rounded-term border border-term-border bg-term-bg p-2">
|
||||||
<strong className="text-[12px] uppercase tracking-wider text-term-text">{t("setup.whichBarrier")}</strong>
|
<strong className="text-[0.75rem] uppercase tracking-wider text-term-text">{t("setup.whichBarrier")}</strong>
|
||||||
<div className="mt-1.5 flex flex-wrap items-center gap-2">
|
<div className="mt-1.5 flex flex-wrap items-center gap-2">
|
||||||
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted">
|
<label className="inline-flex items-center gap-1.5 text-[0.75rem] text-term-muted">
|
||||||
{t("setup.controller")}
|
{t("setup.controller")}
|
||||||
<select className="select input-sm w-auto" value={controllerId} onChange={(e) => onControllerChange(e.target.value)}>
|
<select className="select input-sm w-auto" value={controllerId} onChange={(e) => onControllerChange(e.target.value)}>
|
||||||
<option value="" disabled>
|
<option value="" disabled>
|
||||||
@@ -898,7 +1400,7 @@ function BindingPicker({
|
|||||||
})}
|
})}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted">
|
<label className="inline-flex items-center gap-1.5 text-[0.75rem] text-term-muted">
|
||||||
{t("setup.relay")}
|
{t("setup.relay")}
|
||||||
<select
|
<select
|
||||||
className="select input-sm w-auto"
|
className="select input-sm w-auto"
|
||||||
@@ -909,7 +1411,10 @@ function BindingPicker({
|
|||||||
<option value="" disabled>
|
<option value="" disabled>
|
||||||
{t("setup.choose")}
|
{t("setup.choose")}
|
||||||
</option>
|
</option>
|
||||||
{relays.map((r) => (
|
{/* Only barrier relays are bindable — an alert lamp opens nothing. */}
|
||||||
|
{relays
|
||||||
|
.filter((r) => r.direction !== "radarAlert")
|
||||||
|
.map((r) => (
|
||||||
<option key={r.relay} value={r.relay}>
|
<option key={r.relay} value={r.relay}>
|
||||||
{t("setup.relayLabel", { relay: r.relay, direction: t(DIRECTION_KEYS[r.direction]) })}
|
{t("setup.relayLabel", { relay: r.relay, direction: t(DIRECTION_KEYS[r.direction]) })}
|
||||||
</option>
|
</option>
|
||||||
@@ -919,22 +1424,24 @@ function BindingPicker({
|
|||||||
{chosen && <DirectionBadge direction={chosen.direction} label={t("setup.inherits", { direction: t(DIRECTION_KEYS[chosen.direction]) })} />}
|
{chosen && <DirectionBadge direction={chosen.direction} label={t("setup.inherits", { direction: t(DIRECTION_KEYS[chosen.direction]) })} />}
|
||||||
</div>
|
</div>
|
||||||
{controller && relays.length === 0 && (
|
{controller && relays.length === 0 && (
|
||||||
<p className="mt-1.5 text-[12px] text-term-amber">{t("setup.noRelaysConfigured")}</p>
|
<p className="mt-1.5 text-[0.75rem] text-term-amber">{t("setup.noRelaysConfigured")}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DirectionBadge({ direction, label }: { direction: Direction; label?: string }) {
|
function DirectionBadge({ direction, label }: { direction: RelayEvent; label?: string }) {
|
||||||
// entry=green, exit=amber, both=muted — aligned to the terminal accent palette.
|
// entry=green, exit=amber, radarAlert=red (an alert), both=muted — terminal accents.
|
||||||
const cls =
|
const cls =
|
||||||
direction === "entry"
|
direction === "entry"
|
||||||
? "border-term-green text-term-green"
|
? "border-term-green text-term-green"
|
||||||
: direction === "exit"
|
: direction === "exit"
|
||||||
? "border-term-amber text-term-amber"
|
? "border-term-amber text-term-amber"
|
||||||
|
: direction === "radarAlert"
|
||||||
|
? "border-term-red text-term-red"
|
||||||
: "border-term-muted text-term-muted";
|
: "border-term-muted text-term-muted";
|
||||||
return (
|
return (
|
||||||
<span className={`rounded-term border px-1.5 text-[10px] font-semibold uppercase tracking-wider ${cls}`}>
|
<span className={`rounded-term border px-1.5 text-[0.625rem] font-semibold uppercase tracking-wider ${cls}`}>
|
||||||
{label ?? direction}
|
{label ?? direction}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -8,12 +8,12 @@ import {
|
|||||||
fetchShiftReport,
|
fetchShiftReport,
|
||||||
fetchShifts,
|
fetchShifts,
|
||||||
openShift,
|
openShift,
|
||||||
recordCashVoucher,
|
|
||||||
type ShiftReport,
|
type ShiftReport,
|
||||||
type ShiftSummary,
|
type ShiftSummary,
|
||||||
type SessionUser,
|
type SessionUser,
|
||||||
} from "./api.js";
|
} from "./api.js";
|
||||||
import { formatMoney, formatDuration, formatRelativeDateTime } from "./lib/format.js";
|
import { formatMoney, formatDuration, formatRelativeDateTime } from "./lib/format.js";
|
||||||
|
import { CARD_PAYMENTS_ENABLED } from "./lib/features.js";
|
||||||
import { Modal } from "./ui/Modal.js";
|
import { Modal } from "./ui/Modal.js";
|
||||||
import { EventDetailModal, EventRow } from "./ui/event-detail.js";
|
import { EventDetailModal, EventRow } from "./ui/event-detail.js";
|
||||||
import type { LedgerEvent } from "@parking/shared";
|
import type { LedgerEvent } from "@parking/shared";
|
||||||
@@ -88,7 +88,7 @@ function useCurrentShift(): { current: (ShiftSummary & { open: true }) | null; i
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ShiftsHistory({ user, canManage = false, canVoucher = false }: { user: SessionUser | null; canManage?: boolean; canVoucher?: boolean }) {
|
export function ShiftsHistory({ user, canManage = false }: { user: SessionUser | null; canManage?: boolean }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [preset, setPreset] = useState<Preset>("week");
|
const [preset, setPreset] = useState<Preset>("week");
|
||||||
const [operator, setOperator] = useState("");
|
const [operator, setOperator] = useState("");
|
||||||
@@ -175,7 +175,7 @@ export function ShiftsHistory({ user, canManage = false, canVoucher = false }: {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{q.isError && (
|
{q.isError && (
|
||||||
<div className="mb-2 shrink-0 rounded-term border border-term-red px-3 py-2 text-[12px] text-term-red">{t("shifts.loadFailed")}</div>
|
<div className="mb-2 shrink-0 rounded-term border border-term-red px-3 py-2 text-[0.75rem] text-term-red">{t("shifts.loadFailed")}</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Two-pane: shift list (left) + selected shift's activity log (right). Both
|
{/* Two-pane: shift list (left) + selected shift's activity log (right). Both
|
||||||
@@ -183,7 +183,7 @@ export function ShiftsHistory({ user, canManage = false, canVoucher = false }: {
|
|||||||
<div className="grid min-h-0 flex-1 gap-3 md:grid-cols-[minmax(0,1fr)_minmax(0,1.6fr)]">
|
<div className="grid min-h-0 flex-1 gap-3 md:grid-cols-[minmax(0,1fr)_minmax(0,1.6fr)]">
|
||||||
<div className="flex min-h-0 flex-col gap-1.5 overflow-y-auto pr-1">
|
<div className="flex min-h-0 flex-col gap-1.5 overflow-y-auto pr-1">
|
||||||
{!q.isLoading && list.length === 0 && (
|
{!q.isLoading && list.length === 0 && (
|
||||||
<p className="rounded-term border border-term-border px-3 py-3 text-[12px] text-term-muted">{t("shifts.none")}</p>
|
<p className="rounded-term border border-term-border px-3 py-3 text-[0.75rem] text-term-muted">{t("shifts.none")}</p>
|
||||||
)}
|
)}
|
||||||
{list.map((s) => (
|
{list.map((s) => (
|
||||||
<ShiftCard key={s.id} s={s} showOperator={isAdmin} open={!!s.open} selected={selected?.id === s.id} onClick={() => setSelectedId(s.id)} />
|
<ShiftCard key={s.id} s={s} showOperator={isAdmin} open={!!s.open} selected={selected?.id === s.id} onClick={() => setSelectedId(s.id)} />
|
||||||
@@ -198,11 +198,10 @@ export function ShiftsHistory({ user, canManage = false, canVoucher = false }: {
|
|||||||
isMine={isMine}
|
isMine={isMine}
|
||||||
showOperator={isAdmin}
|
showOperator={isAdmin}
|
||||||
canManage={canManage}
|
canManage={canManage}
|
||||||
canVoucher={canVoucher}
|
|
||||||
onChanged={refreshAll}
|
onChanged={refreshAll}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<p className="px-3 py-6 text-center text-[12px] text-term-muted">{t("shifts.selectAShift")}</p>
|
<p className="px-3 py-6 text-center text-[0.75rem] text-term-muted">{t("shifts.selectAShift")}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -228,7 +227,7 @@ function StartShiftButton({ onDone }: { onDone: () => void }) {
|
|||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
{err && <span className="text-[12px] text-term-red">{err}</span>}
|
{err && <span className="text-[0.75rem] text-term-red">{err}</span>}
|
||||||
<button type="button" className="btn btn-go btn-sm" onClick={start} disabled={busy}>
|
<button type="button" className="btn btn-go btn-sm" onClick={start} disabled={busy}>
|
||||||
{busy ? t("shift.starting") : t("shift.startShift")}
|
{busy ? t("shift.starting") : t("shift.startShift")}
|
||||||
</button>
|
</button>
|
||||||
@@ -244,11 +243,11 @@ function ShiftCard({ s, showOperator, open, selected, onClick }: { s: ShiftSumma
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
className={`card w-full p-2.5 text-left text-[12px] transition-colors ${selected ? "border-term-amber bg-term-panel-2" : "hover:bg-term-panel-2"}`}
|
className={`card w-full p-2.5 text-left text-[0.75rem] transition-colors ${selected ? "border-term-amber bg-term-panel-2" : "hover:bg-term-panel-2"}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<span className="flex items-center gap-2 font-semibold text-term-text">
|
<span className="flex items-center gap-2 font-semibold text-term-text">
|
||||||
{open && <span className="rounded border border-term-green px-1 text-[10px] text-term-green">{t("shifts.current")}</span>}
|
{open && <span className="rounded border border-term-green px-1 text-[0.625rem] text-term-green">{t("shifts.current")}</span>}
|
||||||
{showOperator ? s.operator : when(s.startedAt)}
|
{showOperator ? s.operator : when(s.startedAt)}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-term-muted">{formatDuration(s.startedAt, s.endedAt)}</span>
|
<span className="text-term-muted">{formatDuration(s.startedAt, s.endedAt)}</span>
|
||||||
@@ -257,7 +256,7 @@ function ShiftCard({ s, showOperator, open, selected, onClick }: { s: ShiftSumma
|
|||||||
<div className="mt-1 flex flex-wrap gap-x-3 tabular-nums">
|
<div className="mt-1 flex flex-wrap gap-x-3 tabular-nums">
|
||||||
<span className="text-term-muted">{t("shifts.payments")} {s.paymentCount}</span>
|
<span className="text-term-muted">{t("shifts.payments")} {s.paymentCount}</span>
|
||||||
<span className="text-term-green">{money(s.cashTotalMinor, cur)}</span>
|
<span className="text-term-green">{money(s.cashTotalMinor, cur)}</span>
|
||||||
<span className="text-term-cyan">{money(s.cardTotalMinor, cur)}</span>
|
{CARD_PAYMENTS_ENABLED && <span className="text-term-cyan">{money(s.cardTotalMinor, cur)}</span>}
|
||||||
<span className="ml-auto font-semibold text-term-text" title={t("shifts.expectedDrawer")}>{money(s.expectedDrawerMinor, cur)}</span>
|
<span className="ml-auto font-semibold text-term-text" title={t("shifts.expectedDrawer")}>{money(s.expectedDrawerMinor, cur)}</span>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
@@ -270,7 +269,6 @@ function ShiftActivityLog({
|
|||||||
isMine,
|
isMine,
|
||||||
showOperator,
|
showOperator,
|
||||||
canManage,
|
canManage,
|
||||||
canVoucher,
|
|
||||||
onChanged,
|
onChanged,
|
||||||
}: {
|
}: {
|
||||||
shift: ShiftSummary;
|
shift: ShiftSummary;
|
||||||
@@ -278,11 +276,10 @@ function ShiftActivityLog({
|
|||||||
isMine: boolean;
|
isMine: boolean;
|
||||||
showOperator: boolean;
|
showOperator: boolean;
|
||||||
canManage: boolean;
|
canManage: boolean;
|
||||||
canVoucher: boolean;
|
|
||||||
onChanged: () => void;
|
onChanged: () => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [modal, setModal] = useState<null | "end" | "voucher" | "takings">(null);
|
const [modal, setModal] = useState<null | "end" | "takings">(null);
|
||||||
// Click an activity row → the SAME read-only event-detail modal the booth feed opens
|
// Click an activity row → the SAME read-only event-detail modal the booth feed opens
|
||||||
// (full signed payload + snapshots + chain provenance).
|
// (full signed payload + snapshots + chain provenance).
|
||||||
const [detailEvent, setDetailEvent] = useState<LedgerEvent | null>(null);
|
const [detailEvent, setDetailEvent] = useState<LedgerEvent | null>(null);
|
||||||
@@ -300,9 +297,9 @@ function ShiftActivityLog({
|
|||||||
return (
|
return (
|
||||||
<div className="flex h-full min-h-0 flex-col">
|
<div className="flex h-full min-h-0 flex-col">
|
||||||
<div className="shrink-0 border-b border-term-border bg-term-panel-2 px-3 py-2">
|
<div className="shrink-0 border-b border-term-border bg-term-panel-2 px-3 py-2">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-2 text-[12px]">
|
<div className="flex flex-wrap items-center justify-between gap-2 text-[0.75rem]">
|
||||||
<span className="flex items-center gap-2 font-semibold text-term-text">
|
<span className="flex items-center gap-2 font-semibold text-term-text">
|
||||||
{isCurrent && <span className="rounded border border-term-green px-1 text-[10px] text-term-green">{t("shifts.current")}</span>}
|
{isCurrent && <span className="rounded border border-term-green px-1 text-[0.625rem] text-term-green">{t("shifts.current")}</span>}
|
||||||
{showOperator && `${shift.operator} · `}
|
{showOperator && `${shift.operator} · `}
|
||||||
{formatRelativeDateTime(shift.startedAt, t)}
|
{formatRelativeDateTime(shift.startedAt, t)}
|
||||||
{!isCurrent && ` → ${formatRelativeDateTime(shift.endedAt, t)}`}
|
{!isCurrent && ` → ${formatRelativeDateTime(shift.endedAt, t)}`}
|
||||||
@@ -311,28 +308,27 @@ function ShiftActivityLog({
|
|||||||
{isCurrent && isMine && canManage && (
|
{isCurrent && isMine && canManage && (
|
||||||
<span className="flex flex-wrap gap-1.5">
|
<span className="flex flex-wrap gap-1.5">
|
||||||
<button type="button" className="btn btn-sm" onClick={() => setModal("takings")}>{t("shift.viewTakings")}</button>
|
<button type="button" className="btn btn-sm" onClick={() => setModal("takings")}>{t("shift.viewTakings")}</button>
|
||||||
{canVoucher && <button type="button" className="btn btn-sm" onClick={() => setModal("voucher")}>{t("shift.drawerVoucher")}</button>}
|
|
||||||
<button type="button" className="btn btn-sm btn-danger" onClick={() => setModal("end")}>{t("shift.endShift")}</button>
|
<button type="button" className="btn btn-sm btn-danger" onClick={() => setModal("end")}>{t("shift.endShift")}</button>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 grid grid-cols-2 gap-x-6 gap-y-0.5 text-[11px] tabular-nums sm:grid-cols-4">
|
<div className="mt-1 grid grid-cols-2 gap-x-6 gap-y-0.5 text-[0.6875rem] tabular-nums sm:grid-cols-4">
|
||||||
<Figure label={t("shifts.srcTickets")} value={money(shift.ticketTotalMinor, cur)} />
|
<Figure label={t("shifts.srcTickets")} value={money(shift.ticketTotalMinor, cur)} />
|
||||||
<Figure label={t("shifts.srcSubscriptions")} value={money(shift.subscriptionTotalMinor, cur)} />
|
<Figure label={t("shifts.srcSubscriptions")} value={money(shift.subscriptionTotalMinor, cur)} />
|
||||||
<Figure label={t("shifts.srcSubSales")} value={money(shift.subscriptionSalesMinor, cur)} sub />
|
{/* Abonime is the subscription TOTAL; only the out-of-window part is broken out. */}
|
||||||
<Figure label={t("shifts.srcSubWindow")} value={money(shift.subscriptionWindowMinor, cur)} sub />
|
<Figure label={t("shifts.srcSubWindow")} value={money(shift.subscriptionWindowMinor, cur)} sub />
|
||||||
<Figure label={t("shifts.openingFloat")} value={money(shift.openingFloatMinor, cur)} />
|
<Figure label={t("shifts.openingFloat")} value={money(shift.openingFloatMinor, cur)} />
|
||||||
<Figure label={t("shifts.cashTaken")} value={money(shift.cashTotalMinor, cur)} />
|
<Figure label={t("shifts.cashTaken")} value={money(shift.cashTotalMinor, cur)} />
|
||||||
<Figure label={t("shifts.cashAdded")} value={money(shift.cashAddedMinor, cur)} />
|
<Figure label={t("shifts.cashAdded")} value={money(shift.cashAddedMinor, cur)} />
|
||||||
<Figure label={t("shifts.cashRemoved")} value={money(shift.cashRemovedMinor, cur)} />
|
<Figure label={t("shifts.cashRemoved")} value={money(shift.cashRemovedMinor, cur)} />
|
||||||
<Figure label={t("shifts.card")} value={money(shift.cardTotalMinor, cur)} />
|
{CARD_PAYMENTS_ENABLED && <Figure label={t("shifts.card")} value={money(shift.cardTotalMinor, cur)} />}
|
||||||
<Figure label={t("shifts.expectedDrawer")} value={money(shift.expectedDrawerMinor, cur)} bold />
|
<Figure label={t("shifts.expectedDrawer")} value={money(shift.expectedDrawerMinor, cur)} bold />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="min-h-0 flex-1 overflow-y-auto px-1">
|
<div className="min-h-0 flex-1 overflow-y-auto px-1">
|
||||||
{q.isLoading && <p className="px-3 py-3 text-[12px] text-term-muted">{t("common.loading")}</p>}
|
{q.isLoading && <p className="px-3 py-3 text-[0.75rem] text-term-muted">{t("common.loading")}</p>}
|
||||||
{!q.isLoading && events.length === 0 && <p className="px-3 py-3 text-[12px] text-term-muted">{t("shifts.noActivity")}</p>}
|
{!q.isLoading && events.length === 0 && <p className="px-3 py-3 text-[0.75rem] text-term-muted">{t("shifts.noActivity")}</p>}
|
||||||
{events.map((e) => (
|
{events.map((e) => (
|
||||||
<EventRow key={e.id} e={e} onOpen={setDetailEvent} />
|
<EventRow key={e.id} e={e} onOpen={setDetailEvent} />
|
||||||
))}
|
))}
|
||||||
@@ -340,7 +336,6 @@ function ShiftActivityLog({
|
|||||||
|
|
||||||
{detailEvent && <EventDetailModal e={detailEvent} onClose={() => setDetailEvent(null)} />}
|
{detailEvent && <EventDetailModal e={detailEvent} onClose={() => setDetailEvent(null)} />}
|
||||||
{modal === "end" && <EndShiftModal shift={shift} onClose={() => setModal(null)} onDone={onChanged} />}
|
{modal === "end" && <EndShiftModal shift={shift} onClose={() => setModal(null)} onDone={onChanged} />}
|
||||||
{modal === "voucher" && <VoucherModal currency={cur} onClose={() => setModal(null)} onDone={onChanged} />}
|
|
||||||
{modal === "takings" && <TakingsModal onClose={() => setModal(null)} />}
|
{modal === "takings" && <TakingsModal onClose={() => setModal(null)} />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -372,19 +367,20 @@ function EndShiftModal({ shift, onClose, onDone }: { shift: ShiftSummary; onClos
|
|||||||
<Modal open onClose={onClose} title={t("shift.endShift")} width="max-w-md">
|
<Modal open onClose={onClose} title={t("shift.endShift")} width="max-w-md">
|
||||||
{report ? (
|
{report ? (
|
||||||
// Result — the signed Z-report.
|
// Result — the signed Z-report.
|
||||||
<div className="text-[13px] tabular-nums">
|
<div className="text-[0.8125rem] tabular-nums">
|
||||||
<div className="font-semibold text-term-text">{t("shift.zReport")} — {report.operator}</div>
|
<div className="font-semibold text-term-text">{t("shift.zReport")} — {report.operator}</div>
|
||||||
<div className="mt-1 grid grid-cols-2 gap-x-6 gap-y-0.5">
|
<div className="mt-1 grid grid-cols-2 gap-x-6 gap-y-0.5">
|
||||||
<Figure label={t("shift.payments")} value={String(report.paymentCount)} />
|
<Figure label={t("shift.payments")} value={String(report.paymentCount)} />
|
||||||
<span />
|
<span />
|
||||||
<Figure label={t("shift.srcTickets")} value={money(report.ticketTotalMinor, report.currency)} />
|
<Figure label={t("shift.srcTickets")} value={money(report.ticketTotalMinor, report.currency)} />
|
||||||
<Figure label={t("shift.srcSubscriptions")} value={money(report.subscriptionTotalMinor, report.currency)} />
|
<Figure label={t("shift.srcSubscriptions")} value={money(report.subscriptionTotalMinor, report.currency)} />
|
||||||
<Figure label={t("shift.srcSubSales")} value={money(report.subscriptionSalesMinor, report.currency)} sub />
|
{/* Abonime is the subscription TOTAL; only the out-of-window part is broken out. */}
|
||||||
|
<span />
|
||||||
<Figure label={t("shift.srcSubWindow")} value={money(report.subscriptionWindowMinor, report.currency)} sub />
|
<Figure label={t("shift.srcSubWindow")} value={money(report.subscriptionWindowMinor, report.currency)} sub />
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 grid grid-cols-2 gap-x-6 gap-y-0.5 border-t border-term-border pt-1">
|
<div className="mt-1 grid grid-cols-2 gap-x-6 gap-y-0.5 border-t border-term-border pt-1">
|
||||||
<Figure label={t("shift.cash")} value={money(report.cashTotalMinor, report.currency)} />
|
<Figure label={t("shift.cash")} value={money(report.cashTotalMinor, report.currency)} />
|
||||||
<Figure label={t("shift.card")} value={money(report.cardTotalMinor, report.currency)} />
|
{CARD_PAYMENTS_ENABLED && <Figure label={t("shift.card")} value={money(report.cardTotalMinor, report.currency)} />}
|
||||||
<Figure label={t("shift.openingFloat")} value={money(report.openingFloatMinor, report.currency)} />
|
<Figure label={t("shift.openingFloat")} value={money(report.openingFloatMinor, report.currency)} />
|
||||||
<Figure label={t("shift.cashAdded")} value={money(report.cashAddedMinor, report.currency)} />
|
<Figure label={t("shift.cashAdded")} value={money(report.cashAddedMinor, report.currency)} />
|
||||||
<Figure label={t("shift.cashRemoved")} value={money(report.cashRemovedMinor, report.currency)} />
|
<Figure label={t("shift.cashRemoved")} value={money(report.cashRemovedMinor, report.currency)} />
|
||||||
@@ -399,20 +395,24 @@ function EndShiftModal({ shift, onClose, onDone }: { shift: ShiftSummary; onClos
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
// Confirm — show the live takings (split by source) + drawer before closing.
|
// Confirm — show the live takings (split by source) + drawer before closing.
|
||||||
<div className="text-[13px] tabular-nums">
|
<div className="text-[0.8125rem] tabular-nums">
|
||||||
<p className="text-term-muted">{t("shift.endConfirm")}</p>
|
<p className="text-term-muted">{t("shift.endConfirm")}</p>
|
||||||
<div className="mt-2 grid grid-cols-2 gap-x-6 gap-y-0.5">
|
<div className="mt-2 grid grid-cols-2 gap-x-6 gap-y-0.5">
|
||||||
<Figure label={t("shift.srcTickets")} value={money(shift.ticketTotalMinor, cur)} />
|
<Figure label={t("shift.srcTickets")} value={money(shift.ticketTotalMinor, cur)} />
|
||||||
<Figure label={t("shift.srcSubscriptions")} value={money(shift.subscriptionTotalMinor, cur)} />
|
<Figure label={t("shift.srcSubscriptions")} value={money(shift.subscriptionTotalMinor, cur)} />
|
||||||
<Figure label={t("shift.srcSubSales")} value={money(shift.subscriptionSalesMinor, cur)} sub />
|
{/* Abonime is the subscription TOTAL; only the out-of-window part is broken out. */}
|
||||||
|
<span />
|
||||||
<Figure label={t("shift.srcSubWindow")} value={money(shift.subscriptionWindowMinor, cur)} sub />
|
<Figure label={t("shift.srcSubWindow")} value={money(shift.subscriptionWindowMinor, cur)} sub />
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2 grid grid-cols-2 gap-x-6 gap-y-0.5 border-t border-term-border pt-2">
|
<div className="mt-2 grid grid-cols-2 gap-x-6 gap-y-0.5 border-t border-term-border pt-2">
|
||||||
<Figure label={t("shift.cash")} value={money(shift.cashTotalMinor, cur)} />
|
<Figure label={t("shift.cash")} value={money(shift.cashTotalMinor, cur)} />
|
||||||
<Figure label={t("shift.card")} value={money(shift.cardTotalMinor, cur)} />
|
{CARD_PAYMENTS_ENABLED && <Figure label={t("shift.card")} value={money(shift.cardTotalMinor, cur)} />}
|
||||||
|
{/* Drawer math made explicit: opening cash + cash taken = expected drawer. */}
|
||||||
|
<Figure label={t("shift.openingFloat")} value={money(shift.openingFloatMinor, cur)} />
|
||||||
|
<span />
|
||||||
<Figure label={t("shift.expectedDrawer")} value={money(shift.expectedDrawerMinor, cur)} bold />
|
<Figure label={t("shift.expectedDrawer")} value={money(shift.expectedDrawerMinor, cur)} bold />
|
||||||
</div>
|
</div>
|
||||||
{err && <p className="mt-2 text-[12px] text-term-red">{err}</p>}
|
{err && <p className="mt-2 text-[0.75rem] text-term-red">{err}</p>}
|
||||||
<div className="mt-3 flex justify-end gap-2">
|
<div className="mt-3 flex justify-end gap-2">
|
||||||
<button type="button" className="btn btn-sm" onClick={onClose}>{t("subs.cancel")}</button>
|
<button type="button" className="btn btn-sm" onClick={onClose}>{t("subs.cancel")}</button>
|
||||||
<button type="button" className="btn btn-sm btn-danger" onClick={confirm} disabled={busy}>
|
<button type="button" className="btn btn-sm btn-danger" onClick={confirm} disabled={busy}>
|
||||||
@@ -425,54 +425,6 @@ function EndShiftModal({ shift, onClose, onDone }: { shift: ShiftSummary; onClos
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function VoucherModal({ currency, onClose, onDone }: { currency: string | null; onClose: () => void; onDone: () => void }) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [amount, setAmount] = useState("");
|
|
||||||
const [reason, setReason] = useState("");
|
|
||||||
const [authName, setAuthName] = useState("");
|
|
||||||
const [authPassword, setAuthPassword] = useState("");
|
|
||||||
const [msg, setMsg] = useState<string | null>(null);
|
|
||||||
|
|
||||||
async function submit(type: "cash_in" | "cash_out") {
|
|
||||||
setMsg(null);
|
|
||||||
const major = Number(amount);
|
|
||||||
if (!Number.isFinite(major) || major <= 0) return setMsg(t("shift.enterPositive"));
|
|
||||||
if (!authName.trim() || !authPassword) return setMsg(t("shift.authRequired"));
|
|
||||||
try {
|
|
||||||
const r = await recordCashVoucher({ type, amountMinor: Math.round(major * 100), reason: reason.trim(), authorizedBy: authName.trim(), authorizerPassword: authPassword });
|
|
||||||
setMsg(t("shift.voucherRecorded", { no: r.voucherNo, amount: money(r.balanceMinor, currency) }));
|
|
||||||
setAmount("");
|
|
||||||
setReason("");
|
|
||||||
setAuthPassword("");
|
|
||||||
onDone();
|
|
||||||
} catch (e) {
|
|
||||||
setMsg((e as Error).message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal open onClose={onClose} title={t("shift.drawerVoucher")} width="max-w-md">
|
|
||||||
<div className="flex flex-col gap-2 text-[13px]">
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
<input className="input w-28" value={amount} onChange={(e) => setAmount(e.target.value)} placeholder={t("shift.amount")} inputMode="decimal" />
|
|
||||||
<input className="input min-w-36 flex-1" value={reason} onChange={(e) => setReason(e.target.value)} placeholder={t("shift.reasonPlaceholder")} />
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
<input className="input w-36" value={authName} onChange={(e) => setAuthName(e.target.value)} placeholder={t("shift.authName")} autoComplete="off" />
|
|
||||||
<input className="input w-36" type="password" value={authPassword} onChange={(e) => setAuthPassword(e.target.value)} placeholder={t("shift.authPassword")} autoComplete="off" />
|
|
||||||
</div>
|
|
||||||
<div className="text-[11px] text-term-muted">{t("shift.voucherHint")}</div>
|
|
||||||
{msg && <div className="text-[12px] text-term-muted">{msg}</div>}
|
|
||||||
<div className="mt-1 flex justify-end gap-2">
|
|
||||||
<button type="button" className="btn btn-sm" onClick={onClose}>{t("common.close")}</button>
|
|
||||||
<button type="button" className="btn btn-go btn-sm" onClick={() => submit("cash_in")}>{t("shift.mandatArketimi")}</button>
|
|
||||||
<button type="button" className="btn btn-danger btn-sm" onClick={() => submit("cash_out")}>{t("shift.mandatPagese")}</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function TakingsModal({ onClose }: { onClose: () => void }) {
|
function TakingsModal({ onClose }: { onClose: () => void }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const q = useQuery({ queryKey: ["shift", "xreport", "modal"], queryFn: fetchShiftReport });
|
const q = useQuery({ queryKey: ["shift", "xreport", "modal"], queryFn: fetchShiftReport });
|
||||||
@@ -480,27 +432,28 @@ function TakingsModal({ onClose }: { onClose: () => void }) {
|
|||||||
return (
|
return (
|
||||||
<Modal open onClose={onClose} title={t("shift.xReport")} width="max-w-md">
|
<Modal open onClose={onClose} title={t("shift.xReport")} width="max-w-md">
|
||||||
{!x ? (
|
{!x ? (
|
||||||
<p className="text-[12px] text-term-muted">{t("common.loading")}</p>
|
<p className="text-[0.75rem] text-term-muted">{t("common.loading")}</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-[13px] tabular-nums">
|
<div className="text-[0.8125rem] tabular-nums">
|
||||||
<div className="text-term-muted">{t("shift.asOf")} {new Date(x.asOf).toLocaleString()}</div>
|
<div className="text-term-muted">{t("shift.asOf")} {new Date(x.asOf).toLocaleString()}</div>
|
||||||
<div className="mt-1 grid grid-cols-2 gap-x-6 gap-y-0.5">
|
<div className="mt-1 grid grid-cols-2 gap-x-6 gap-y-0.5">
|
||||||
<Figure label={t("shift.payments")} value={String(x.paymentCount)} />
|
<Figure label={t("shift.payments")} value={String(x.paymentCount)} />
|
||||||
<span />
|
<span />
|
||||||
<Figure label={t("shift.srcTickets")} value={money(x.ticketTotalMinor, x.currency)} />
|
<Figure label={t("shift.srcTickets")} value={money(x.ticketTotalMinor, x.currency)} />
|
||||||
<Figure label={t("shift.srcSubscriptions")} value={money(x.subscriptionTotalMinor, x.currency)} />
|
<Figure label={t("shift.srcSubscriptions")} value={money(x.subscriptionTotalMinor, x.currency)} />
|
||||||
<Figure label={t("shift.srcSubSales")} value={money(x.subscriptionSalesMinor, x.currency)} sub />
|
{/* Abonime is the subscription TOTAL; only the out-of-window part is broken out. */}
|
||||||
|
<span />
|
||||||
<Figure label={t("shift.srcSubWindow")} value={money(x.subscriptionWindowMinor, x.currency)} sub />
|
<Figure label={t("shift.srcSubWindow")} value={money(x.subscriptionWindowMinor, x.currency)} sub />
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 grid grid-cols-2 gap-x-6 gap-y-0.5 border-t border-term-border pt-1">
|
<div className="mt-1 grid grid-cols-2 gap-x-6 gap-y-0.5 border-t border-term-border pt-1">
|
||||||
<Figure label={t("shift.cash")} value={money(x.cashTotalMinor, x.currency)} />
|
<Figure label={t("shift.cash")} value={money(x.cashTotalMinor, x.currency)} />
|
||||||
<Figure label={t("shift.card")} value={money(x.cardTotalMinor, x.currency)} />
|
{CARD_PAYMENTS_ENABLED && <Figure label={t("shift.card")} value={money(x.cardTotalMinor, x.currency)} />}
|
||||||
<Figure label={t("shift.openingFloat")} value={money(x.openingFloatMinor, x.currency)} />
|
<Figure label={t("shift.openingFloat")} value={money(x.openingFloatMinor, x.currency)} />
|
||||||
<Figure label={t("shift.cashAdded")} value={money(x.cashAddedMinor, x.currency)} />
|
<Figure label={t("shift.cashAdded")} value={money(x.cashAddedMinor, x.currency)} />
|
||||||
<Figure label={t("shift.cashRemoved")} value={money(x.cashRemovedMinor, x.currency)} />
|
<Figure label={t("shift.cashRemoved")} value={money(x.cashRemovedMinor, x.currency)} />
|
||||||
<Figure label={t("shift.expectedDrawer")} value={money(x.expectedDrawerMinor, x.currency)} bold />
|
<Figure label={t("shift.expectedDrawer")} value={money(x.expectedDrawerMinor, x.currency)} bold />
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2 text-[11px] text-term-muted">{t("shift.xReportHint")}</div>
|
<div className="mt-2 text-[0.6875rem] text-term-muted">{t("shift.xReportHint")}</div>
|
||||||
<div className="mt-3 flex justify-end">
|
<div className="mt-3 flex justify-end">
|
||||||
<button type="button" className="btn btn-sm" onClick={onClose}>{t("common.close")}</button>
|
<button type="button" className="btn btn-sm" onClick={onClose}>{t("common.close")}</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -513,8 +466,9 @@ function TakingsModal({ onClose }: { onClose: () => void }) {
|
|||||||
function Figure({ label, value, bold, sub }: { label: string; value: string; bold?: boolean; sub?: boolean }) {
|
function Figure({ label, value, bold, sub }: { label: string; value: string; bold?: boolean; sub?: boolean }) {
|
||||||
return (
|
return (
|
||||||
<div className={`flex justify-between gap-2 ${sub ? "pl-3" : ""}`}>
|
<div className={`flex justify-between gap-2 ${sub ? "pl-3" : ""}`}>
|
||||||
<span className={sub ? "text-term-muted/70" : "text-term-muted"}>{label}</span>
|
<span className={`whitespace-nowrap ${sub ? "text-term-muted/70" : "text-term-muted"}`}>{label}</span>
|
||||||
<span className={bold ? "font-semibold text-term-text" : "text-term-text"}>{value}</span>
|
{/* The money/number never splits across lines (e.g. "89,650 ALL"). */}
|
||||||
|
<span className={`whitespace-nowrap ${bold ? "font-semibold text-term-text" : "text-term-text"}`}>{value}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="card mt-6 max-w-md p-4">
|
<section className="card mt-6 max-w-md p-4">
|
||||||
<div className="flex flex-wrap items-center gap-1.5 text-[13px]">
|
<div className="flex flex-wrap items-center gap-1.5 text-[0.8125rem]">
|
||||||
<strong className="uppercase tracking-wider text-term-muted">{t("site.occupancy")}</strong>
|
<strong className="uppercase tracking-wider text-term-muted">{t("site.occupancy")}</strong>
|
||||||
{occ == null ? (
|
{occ == null ? (
|
||||||
<span className="text-term-muted">…</span>
|
<span className="text-term-muted">…</span>
|
||||||
@@ -93,7 +93,7 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
|
|||||||
<span className="label">{t("site.capacityLabel")}</span>
|
<span className="label">{t("site.capacityLabel")}</span>
|
||||||
<input className="input w-32" value={capInput} onChange={(e) => setCapInput(e.target.value)} placeholder={t("site.capacityPlaceholder")} />
|
<input className="input w-32" value={capInput} onChange={(e) => setCapInput(e.target.value)} placeholder={t("site.capacityPlaceholder")} />
|
||||||
</div>
|
</div>
|
||||||
<label className="flex items-center gap-2 text-[12px] text-term-text">
|
<label className="flex items-center gap-2 text-[0.75rem] text-term-text">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
className="accent-term-amber"
|
className="accent-term-amber"
|
||||||
@@ -103,7 +103,7 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
|
|||||||
{t("site.printExitDefault")}
|
{t("site.printExitDefault")}
|
||||||
<span className="hint">{t("site.printExitHint")}</span>
|
<span className="hint">{t("site.printExitHint")}</span>
|
||||||
</label>
|
</label>
|
||||||
<label className="flex items-start gap-2 text-[12px] text-term-text">
|
<label className="flex items-start gap-2 text-[0.75rem] text-term-text">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
className="mt-0.5 accent-term-amber"
|
className="mt-0.5 accent-term-amber"
|
||||||
@@ -115,7 +115,7 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
|
|||||||
<span className="hint block">{t("site.reserveSubsHint")}</span>
|
<span className="hint block">{t("site.reserveSubsHint")}</span>
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
<label className="flex items-start gap-2 text-[12px] text-term-text">
|
<label className="flex items-start gap-2 text-[0.75rem] text-term-text">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
className="mt-0.5 accent-term-amber"
|
className="mt-0.5 accent-term-amber"
|
||||||
@@ -127,7 +127,7 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
|
|||||||
<span className="hint block">{t("site.anprEntryHint")}</span>
|
<span className="hint block">{t("site.anprEntryHint")}</span>
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
<div className="border-t border-term-border pt-3 text-[11px] uppercase tracking-wider text-term-muted">
|
<div className="border-t border-term-border pt-3 text-[0.6875rem] uppercase tracking-wider text-term-muted">
|
||||||
{t("site.parkDetails")}
|
{t("site.parkDetails")}
|
||||||
</div>
|
</div>
|
||||||
{META_FIELDS.map(({ key, labelKey, phKey, multiline }) => (
|
{META_FIELDS.map(({ key, labelKey, phKey, multiline }) => (
|
||||||
@@ -153,7 +153,7 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
|
|||||||
))}
|
))}
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<button type="button" className="btn btn-primary btn-sm" onClick={save}>{t("site.save")}</button>
|
<button type="button" className="btn btn-primary btn-sm" onClick={save}>{t("site.save")}</button>
|
||||||
{msg && <span className="text-[12px] text-term-muted">{msg}</span>}
|
{msg && <span className="text-[0.75rem] text-term-muted">{msg}</span>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
type SubscriptionPlan,
|
type SubscriptionPlan,
|
||||||
type SubscriptionQuote,
|
type SubscriptionQuote,
|
||||||
} from "./api.js";
|
} from "./api.js";
|
||||||
|
import { CARD_PAYMENTS_ENABLED } from "./lib/features.js";
|
||||||
import { Modal } from "./ui/Modal.js";
|
import { Modal } from "./ui/Modal.js";
|
||||||
|
|
||||||
// Subscription admin. Create/edit/revoke/delete subscriptions + their credentials
|
// Subscription admin. Create/edit/revoke/delete subscriptions + their credentials
|
||||||
@@ -412,7 +413,7 @@ export function SubscriptionManager({ user }: { user: SessionUser | null }) {
|
|||||||
<h2 className="mb-3 text-h4 font-semibold text-term-text">{t("subs.title")}</h2>
|
<h2 className="mb-3 text-h4 font-semibold text-term-text">{t("subs.title")}</h2>
|
||||||
<ul className="mb-3 list-none p-0">
|
<ul className="mb-3 list-none p-0">
|
||||||
{subs.map((s) => (
|
{subs.map((s) => (
|
||||||
<li key={s.id} className="flex flex-wrap items-center gap-2 border-b border-term-border/60 py-2 text-[12px]">
|
<li key={s.id} className="flex flex-wrap items-center gap-2 border-b border-term-border/60 py-2 text-[0.75rem]">
|
||||||
<strong className="text-term-text">{s.holderName ?? t("subs.unnamed")}</strong>
|
<strong className="text-term-text">{s.holderName ?? t("subs.unnamed")}</strong>
|
||||||
<span className={s.status === "active" ? "text-term-green" : "text-term-amber"}>{t(STATUS_KEY[s.status])}</span>
|
<span className={s.status === "active" ? "text-term-green" : "text-term-amber"}>{t(STATUS_KEY[s.status])}</span>
|
||||||
<span className="tabular-nums text-term-cyan">{priceLabel(s, t)}</span>
|
<span className="tabular-nums text-term-cyan">{priceLabel(s, t)}</span>
|
||||||
@@ -464,13 +465,13 @@ export function SubscriptionManager({ user }: { user: SessionUser | null }) {
|
|||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
{plans.length === 0 && <span className="text-[12px] text-term-amber">{t("subs.planNoneAvail")}</span>}
|
{plans.length === 0 && <span className="text-[0.75rem] text-term-amber">{t("subs.planNoneAvail")}</span>}
|
||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<label className="label">{t("subs.plan")}</label>
|
<label className="label">{t("subs.plan")}</label>
|
||||||
<span className="text-[13px] text-term-text">{form.planId || t("subs.noPrice")}</span>
|
<span className="text-[0.8125rem] text-term-text">{form.planId || t("subs.noPrice")}</span>
|
||||||
{/* VERSION CORRECTION (admins). The plan itself is frozen, but an admin may
|
{/* VERSION CORRECTION (admins). The plan itself is frozen, but an admin may
|
||||||
move the sub to a different VERSION of that same plan (e.g. one with
|
move the sub to a different VERSION of that same plan (e.g. one with
|
||||||
different timeframes). Price stays as billed. Only shown when the sub has
|
different timeframes). Price stays as billed. Only shown when the sub has
|
||||||
@@ -486,7 +487,15 @@ export function SubscriptionManager({ user }: { user: SessionUser | null }) {
|
|||||||
if (cur) versions.unshift(cur);
|
if (cur) versions.unshift(cur);
|
||||||
}
|
}
|
||||||
if (versions.length < 2 && versions.some((v) => v.id === form.planVersionId)) {
|
if (versions.length < 2 && versions.some((v) => v.id === form.planVersionId)) {
|
||||||
return <span className="text-[12px] text-term-muted">{t("subs.versionOnlyOne")}</span>;
|
// Keep the 2-col grid flow intact: a lone cell here would shift every
|
||||||
|
// following row by one column (label↔input swap). Emit a full row —
|
||||||
|
// the version label + the "only one version" hint as its control.
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<label className="label">{t("subs.version")}</label>
|
||||||
|
<span className="text-[0.75rem] text-term-muted">{t("subs.versionOnlyOne")}</span>
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -504,7 +513,7 @@ export function SubscriptionManager({ user }: { user: SessionUser | null }) {
|
|||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
<span className="text-[12px] text-term-muted">{t("subs.versionHint")}</span>
|
<span className="text-[0.75rem] text-term-muted">{t("subs.versionHint")}</span>
|
||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@@ -523,17 +532,21 @@ export function SubscriptionManager({ user }: { user: SessionUser | null }) {
|
|||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
onChange={(e) => setForm((f) => ({ ...f, quantity: e.target.value, maxConcurrent: e.target.value }))}
|
onChange={(e) => setForm((f) => ({ ...f, quantity: e.target.value, maxConcurrent: e.target.value }))}
|
||||||
/>
|
/>
|
||||||
<span className="text-[12px] text-term-muted">{t("subs.quantityHint")}</span>
|
<span className="text-[0.75rem] text-term-muted">{t("subs.quantityHint")}</span>
|
||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{/* Tender — only relevant when selling a plan (a SALE). The sale appends a
|
{/* Tender — only relevant when selling a plan (a SALE). The sale appends a
|
||||||
signed payment so the money shows in the feed/drawer/Z-report. */}
|
signed payment so the money shows in the feed/drawer/Z-report. */}
|
||||||
{form.planId.trim() !== "" && editing === "new" && (
|
{/* Tender picker — only meaningful when there's a choice. Card is hidden until a
|
||||||
|
P2PE POS terminal is on-site (CARD_PAYMENTS_ENABLED); with cash-only there's
|
||||||
|
nothing to pick, so the whole row is suppressed (form.tender stays "cash").
|
||||||
|
See lib/features.ts + wiki/concepts/card-payments.md. */}
|
||||||
|
{form.planId.trim() !== "" && editing === "new" && CARD_PAYMENTS_ENABLED && (
|
||||||
<>
|
<>
|
||||||
<label className="label">{t("subs.tender")}</label>
|
<label className="label">{t("subs.tender")}</label>
|
||||||
<span className="flex items-center gap-3">
|
<span className="flex items-center gap-3">
|
||||||
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-text">
|
<label className="inline-flex items-center gap-1.5 text-[0.75rem] text-term-text">
|
||||||
<input
|
<input
|
||||||
type="radio"
|
type="radio"
|
||||||
name="tender"
|
name="tender"
|
||||||
@@ -543,7 +556,7 @@ export function SubscriptionManager({ user }: { user: SessionUser | null }) {
|
|||||||
/>
|
/>
|
||||||
{t("subs.tenderCash")}
|
{t("subs.tenderCash")}
|
||||||
</label>
|
</label>
|
||||||
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-text">
|
<label className="inline-flex items-center gap-1.5 text-[0.75rem] text-term-text">
|
||||||
<input
|
<input
|
||||||
type="radio"
|
type="radio"
|
||||||
name="tender"
|
name="tender"
|
||||||
@@ -553,13 +566,13 @@ export function SubscriptionManager({ user }: { user: SessionUser | null }) {
|
|||||||
/>
|
/>
|
||||||
{t("subs.tenderCard")}
|
{t("subs.tenderCard")}
|
||||||
</label>
|
</label>
|
||||||
<span className="text-[12px] text-term-muted">{t("subs.tenderHint")}</span>
|
<span className="text-[0.75rem] text-term-muted">{t("subs.tenderHint")}</span>
|
||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<label className="label">{t("subs.carLimit")}</label>
|
<label className="label">{t("subs.carLimit")}</label>
|
||||||
<span className="flex items-center gap-3">
|
<span className="flex items-center gap-3">
|
||||||
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-text">
|
<label className="inline-flex items-center gap-1.5 text-[0.75rem] text-term-text">
|
||||||
<input type="checkbox" className="accent-term-amber" checked={form.carBound} onChange={(e) => setForm((f) => ({ ...f, carBound: e.target.checked }))} /> {t("subs.limitCarsInAtOnce")}
|
<input type="checkbox" className="accent-term-amber" checked={form.carBound} onChange={(e) => setForm((f) => ({ ...f, carBound: e.target.checked }))} /> {t("subs.limitCarsInAtOnce")}
|
||||||
</label>
|
</label>
|
||||||
{form.carBound && (
|
{form.carBound && (
|
||||||
@@ -580,7 +593,7 @@ export function SubscriptionManager({ user }: { user: SessionUser | null }) {
|
|||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
onChange={(e) => setForm((f) => ({ ...f, count: e.target.value }))}
|
onChange={(e) => setForm((f) => ({ ...f, count: e.target.value }))}
|
||||||
/>
|
/>
|
||||||
<span className="text-[12px] text-term-muted">
|
<span className="text-[0.75rem] text-term-muted">
|
||||||
× {t(PERIOD_KEY[selectedPlan.period])}
|
× {t(PERIOD_KEY[selectedPlan.period])}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
@@ -592,7 +605,7 @@ export function SubscriptionManager({ user }: { user: SessionUser | null }) {
|
|||||||
{/* Live SERVER quote: ceil(periods) × per-period price. The operator can't
|
{/* Live SERVER quote: ceil(periods) × per-period price. The operator can't
|
||||||
override it — this is exactly what will be charged + signed. */}
|
override it — this is exactly what will be charged + signed. */}
|
||||||
{editing === "new" && form.planId.trim() !== "" && (
|
{editing === "new" && form.planId.trim() !== "" && (
|
||||||
<span className="text-[12px] text-term-cyan">
|
<span className="text-[0.75rem] text-term-cyan">
|
||||||
{quoting
|
{quoting
|
||||||
? t("subs.quoting")
|
? t("subs.quoting")
|
||||||
: quote
|
: quote
|
||||||
@@ -610,7 +623,7 @@ export function SubscriptionManager({ user }: { user: SessionUser | null }) {
|
|||||||
<input className="input" value={form.platesText} onChange={(e) => setForm((f) => ({ ...f, platesText: e.target.value }))} placeholder={t("subs.commaSeparatedOptional")} />
|
<input className="input" value={form.platesText} onChange={(e) => setForm((f) => ({ ...f, platesText: e.target.value }))} placeholder={t("subs.commaSeparatedOptional")} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h4 className="mt-4 mb-1 text-[12px] font-semibold uppercase tracking-wider text-term-muted">{t("subs.credentials")}</h4>
|
<h4 className="mt-4 mb-1 text-[0.75rem] font-semibold uppercase tracking-wider text-term-muted">{t("subs.credentials")}</h4>
|
||||||
{form.credentials.map((c, i) => (
|
{form.credentials.map((c, i) => (
|
||||||
<div key={i} className="mb-1.5 flex items-center gap-2">
|
<div key={i} className="mb-1.5 flex items-center gap-2">
|
||||||
{/* Operator chooses the credential type: QR (auto-generated) or RFID
|
{/* Operator chooses the credential type: QR (auto-generated) or RFID
|
||||||
@@ -625,7 +638,7 @@ export function SubscriptionManager({ user }: { user: SessionUser | null }) {
|
|||||||
c.value.trim() ? (
|
c.value.trim() ? (
|
||||||
<input className="input input-sm flex-1 opacity-70" value={c.value} readOnly />
|
<input className="input input-sm flex-1 opacity-70" value={c.value} readOnly />
|
||||||
) : (
|
) : (
|
||||||
<span className="flex-1 self-center text-[12px] italic text-term-muted">{t("subs.qrAutoGen")}</span>
|
<span className="flex-1 self-center text-[0.75rem] italic text-term-muted">{t("subs.qrAutoGen")}</span>
|
||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
// RFID: the value is read off a physical card (or typed). "Read card"
|
// RFID: the value is read off a physical card (or typed). "Read card"
|
||||||
@@ -643,7 +656,7 @@ export function SubscriptionManager({ user }: { user: SessionUser | null }) {
|
|||||||
{/* Capture panel: pick a reader, present the card; the captured value fills
|
{/* Capture panel: pick a reader, present the card; the captured value fills
|
||||||
the credential. The OTHER reader keeps serving the live flow. */}
|
the credential. The OTHER reader keeps serving the live flow. */}
|
||||||
{capture && (
|
{capture && (
|
||||||
<div className="mt-3 rounded-term border border-term-cyan/50 bg-term-cyan/5 p-3 text-[12px]">
|
<div className="mt-3 rounded-term border border-term-cyan/50 bg-term-cyan/5 p-3 text-[0.75rem]">
|
||||||
{capture.phase === "pick" ? (
|
{capture.phase === "pick" ? (
|
||||||
<>
|
<>
|
||||||
<div className="mb-1.5 text-term-text">{t("subs.captureChooseReader")}</div>
|
<div className="mb-1.5 text-term-text">{t("subs.captureChooseReader")}</div>
|
||||||
@@ -673,7 +686,7 @@ export function SubscriptionManager({ user }: { user: SessionUser | null }) {
|
|||||||
<button type="button" className="btn btn-sm" onClick={() => setEditing(null)}>{t("subs.cancel")}</button>
|
<button type="button" className="btn btn-sm" onClick={() => setEditing(null)}>{t("subs.cancel")}</button>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
{msg && <p className={msg.kind === "ok" ? "mt-3 text-[12px] text-term-green" : "mt-3 text-[12px] text-term-red"}>{msg.text}</p>}
|
{msg && <p className={msg.kind === "ok" ? "mt-3 text-[0.75rem] text-term-green" : "mt-3 text-[0.75rem] text-term-red"}>{msg.text}</p>}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -237,19 +237,19 @@ export function SubscriptionPlansManager() {
|
|||||||
return (
|
return (
|
||||||
<section className="px-4 py-6">
|
<section className="px-4 py-6">
|
||||||
<div className="mb-3 flex items-center justify-between">
|
<div className="mb-3 flex items-center justify-between">
|
||||||
<h3 className="text-[13px] font-semibold uppercase tracking-wider text-term-muted">{t("plans.title")}</h3>
|
<h3 className="text-[0.8125rem] font-semibold uppercase tracking-wider text-term-muted">{t("plans.title")}</h3>
|
||||||
<button type="button" className="btn btn-go btn-sm" onClick={() => setForm(emptyForm())}>
|
<button type="button" className="btn btn-go btn-sm" onClick={() => setForm(emptyForm())}>
|
||||||
{t("plans.add")}
|
{t("plans.add")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p className="mb-3 text-[12px] text-term-muted">{t("plans.intro")}</p>
|
<p className="mb-3 text-[0.75rem] text-term-muted">{t("plans.intro")}</p>
|
||||||
|
|
||||||
{msg && (
|
{msg && (
|
||||||
<div className={`mb-3 text-[12px] ${msg.kind === "ok" ? "text-term-green" : "text-term-red"}`}>{msg.text}</div>
|
<div className={`mb-3 text-[0.75rem] ${msg.kind === "ok" ? "text-term-green" : "text-term-red"}`}>{msg.text}</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{groups.length === 0 ? (
|
{groups.length === 0 ? (
|
||||||
<p className="text-[13px] text-term-muted">{t("plans.noneYet")}</p>
|
<p className="text-[0.8125rem] text-term-muted">{t("plans.noneYet")}</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
{groups.map(({ planId, head: p, active, versions }) => {
|
{groups.map(({ planId, head: p, active, versions }) => {
|
||||||
@@ -263,15 +263,15 @@ export function SubscriptionPlansManager() {
|
|||||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||||
<span className="font-semibold text-term-text">{p.name}</span>
|
<span className="font-semibold text-term-text">{p.name}</span>
|
||||||
{active ? (
|
{active ? (
|
||||||
<span className="rounded border border-term-green px-1 text-[10px] text-term-green">{t("plans.inForce")}</span>
|
<span className="rounded border border-term-green px-1 text-[0.625rem] text-term-green">{t("plans.inForce")}</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="rounded border border-term-border px-1 text-[10px] text-term-muted">{t("plans.retired")}</span>
|
<span className="rounded border border-term-border px-1 text-[0.625rem] text-term-muted">{t("plans.retired")}</span>
|
||||||
)}
|
)}
|
||||||
{versions > 1 && <span className="text-[10px] text-term-muted">{t("plans.versionCount", { count: versions })}</span>}
|
{versions > 1 && <span className="text-[0.625rem] text-term-muted">{t("plans.versionCount", { count: versions })}</span>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Details: price · hours · effective */}
|
{/* Details: price · hours · effective */}
|
||||||
<div className="mt-1 flex flex-wrap gap-x-4 gap-y-0.5 text-[12px] text-term-muted">
|
<div className="mt-1 flex flex-wrap gap-x-4 gap-y-0.5 text-[0.75rem] text-term-muted">
|
||||||
<span className="tabular-nums text-term-text">
|
<span className="tabular-nums text-term-text">
|
||||||
{(p.pricePerPeriodMinor / 100).toLocaleString()} {p.currency} / {t(PERIOD_KEY[p.period])}
|
{(p.pricePerPeriodMinor / 100).toLocaleString()} {p.currency} / {t(PERIOD_KEY[p.period])}
|
||||||
</span>
|
</span>
|
||||||
@@ -280,7 +280,7 @@ export function SubscriptionPlansManager() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Used by */}
|
{/* Used by */}
|
||||||
<div className="mt-1 text-[12px]">
|
<div className="mt-1 text-[0.75rem]">
|
||||||
{users.length > 0 ? (
|
{users.length > 0 ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -295,12 +295,12 @@ export function SubscriptionPlansManager() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{isOpen && users.length > 0 && (
|
{isOpen && users.length > 0 && (
|
||||||
<ul className="mt-1 flex flex-wrap gap-x-4 gap-y-1 rounded-term bg-term-bg px-3 py-2 text-[12px]">
|
<ul className="mt-1 flex flex-wrap gap-x-4 gap-y-1 rounded-term bg-term-bg px-3 py-2 text-[0.75rem]">
|
||||||
{users.map((s) => (
|
{users.map((s) => (
|
||||||
<li key={s.id} className={s.status === "active" ? "text-term-text" : "text-term-muted"}>
|
<li key={s.id} className={s.status === "active" ? "text-term-text" : "text-term-muted"}>
|
||||||
{s.holderName || t("subs.unnamed")}
|
{s.holderName || t("subs.unnamed")}
|
||||||
{s.quantity > 1 && <span className="text-term-muted"> ×{s.quantity}</span>}
|
{s.quantity > 1 && <span className="text-term-muted"> ×{s.quantity}</span>}
|
||||||
{s.status !== "active" && <span className="ml-1 text-[10px]">({t(STATUS_KEY[s.status])})</span>}
|
{s.status !== "active" && <span className="ml-1 text-[0.625rem]">({t(STATUS_KEY[s.status])})</span>}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -349,14 +349,14 @@ export function SubscriptionPlansManager() {
|
|||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
<input className="input w-28" value={form.priceMajor} inputMode="decimal" onChange={(e) => setForm((f) => f && { ...f, priceMajor: e.target.value })} placeholder="e.g. 800" />
|
<input className="input w-28" value={form.priceMajor} inputMode="decimal" onChange={(e) => setForm((f) => f && { ...f, priceMajor: e.target.value })} placeholder="e.g. 800" />
|
||||||
<input className="input w-16" value={form.currency} onChange={(e) => setForm((f) => f && { ...f, currency: e.target.value })} />
|
<input className="input w-16" value={form.currency} onChange={(e) => setForm((f) => f && { ...f, currency: e.target.value })} />
|
||||||
<span className="text-[12px] text-term-muted">/ {t(PERIOD_KEY[form.period])}</span>
|
<span className="text-[0.75rem] text-term-muted">/ {t(PERIOD_KEY[form.period])}</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Timeframes (tariff bridge): restrict WHEN a subscriber may park. Outside the
|
{/* Timeframes (tariff bridge): restrict WHEN a subscriber may park. Outside the
|
||||||
window they're charged the transient tariff for the gap. Off = 24/7. */}
|
window they're charged the transient tariff for the gap. Off = 24/7. */}
|
||||||
<div className="mt-3 border-t border-term-border pt-3">
|
<div className="mt-3 border-t border-term-border pt-3">
|
||||||
<label className="flex items-center gap-2 text-[12px] text-term-text">
|
<label className="flex items-center gap-2 text-[0.75rem] text-term-text">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
className="accent-term-amber"
|
className="accent-term-amber"
|
||||||
@@ -370,7 +370,7 @@ export function SubscriptionPlansManager() {
|
|||||||
<label className="label">{t("plans.days")}</label>
|
<label className="label">{t("plans.days")}</label>
|
||||||
<span className="flex flex-wrap gap-2">
|
<span className="flex flex-wrap gap-2">
|
||||||
{DOW_ORDER.map((d) => (
|
{DOW_ORDER.map((d) => (
|
||||||
<label key={d} className="inline-flex items-center gap-1 text-[12px] text-term-text">
|
<label key={d} className="inline-flex items-center gap-1 text-[0.75rem] text-term-text">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
className="accent-term-amber"
|
className="accent-term-amber"
|
||||||
@@ -386,7 +386,7 @@ export function SubscriptionPlansManager() {
|
|||||||
))}
|
))}
|
||||||
</span>
|
</span>
|
||||||
<label className="label">{t("plans.window")}</label>
|
<label className="label">{t("plans.window")}</label>
|
||||||
<span className="flex flex-wrap items-center gap-2 text-[12px] text-term-muted">
|
<span className="flex flex-wrap items-center gap-2 text-[0.75rem] text-term-muted">
|
||||||
{t("plans.enterAfter")}
|
{t("plans.enterAfter")}
|
||||||
<input type="time" className="input w-28" value={form.winFrom} onChange={(e) => setForm((f) => f && { ...f, winFrom: e.target.value })} />
|
<input type="time" className="input w-28" value={form.winFrom} onChange={(e) => setForm((f) => f && { ...f, winFrom: e.target.value })} />
|
||||||
{t("plans.exitBefore")}
|
{t("plans.exitBefore")}
|
||||||
@@ -395,14 +395,14 @@ export function SubscriptionPlansManager() {
|
|||||||
<label className="label">{t("plans.grace")}</label>
|
<label className="label">{t("plans.grace")}</label>
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
<input className="input w-16" value={form.graceMin} inputMode="numeric" onChange={(e) => setForm((f) => f && { ...f, graceMin: e.target.value })} />
|
<input className="input w-16" value={form.graceMin} inputMode="numeric" onChange={(e) => setForm((f) => f && { ...f, graceMin: e.target.value })} />
|
||||||
<span className="text-[12px] text-term-muted">{t("plans.graceHint")}</span>
|
<span className="text-[0.75rem] text-term-muted">{t("plans.graceHint")}</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<p className="mt-1.5 text-[11px] text-term-muted">{t("plans.timeframesHint")}</p>
|
<p className="mt-1.5 text-[0.6875rem] text-term-muted">{t("plans.timeframesHint")}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{form.planId && <p className="mt-2 text-[11px] text-term-amber">{t("plans.newVersionHint")}</p>}
|
{form.planId && <p className="mt-2 text-[0.6875rem] text-term-amber">{t("plans.newVersionHint")}</p>}
|
||||||
<div className="mt-4 flex justify-end gap-2">
|
<div className="mt-4 flex justify-end gap-2">
|
||||||
<button type="button" className="btn btn-sm" onClick={() => setForm(null)}>{t("subs.cancel")}</button>
|
<button type="button" className="btn btn-sm" onClick={() => setForm(null)}>{t("subs.cancel")}</button>
|
||||||
<button type="button" className="btn btn-go btn-sm" onClick={save}>{t("subs.save")}</button>
|
<button type="button" className="btn btn-go btn-sm" onClick={save}>{t("subs.save")}</button>
|
||||||
|
|||||||
@@ -354,11 +354,11 @@ export function TariffComposer() {
|
|||||||
<section className="px-4 py-6">
|
<section className="px-4 py-6">
|
||||||
<h2 className="mb-1 text-h4 font-semibold text-term-text">{t("tariff.title")}</h2>
|
<h2 className="mb-1 text-h4 font-semibold text-term-text">{t("tariff.title")}</h2>
|
||||||
{!state?.active ? (
|
{!state?.active ? (
|
||||||
<p className="mb-4 rounded-term border border-term-amber/50 bg-term-amber/10 px-3 py-2 text-[12px] text-term-amber">
|
<p className="mb-4 rounded-term border border-term-amber/50 bg-term-amber/10 px-3 py-2 text-[0.75rem] text-term-amber">
|
||||||
{t("tariff.noRateCard")}
|
{t("tariff.noRateCard")}
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<p className="mb-4 text-[12px] text-term-muted">
|
<p className="mb-4 text-[0.75rem] text-term-muted">
|
||||||
{t("tariff.activeSince", {
|
{t("tariff.activeSince", {
|
||||||
date: new Date(state.active.effectiveFrom).toLocaleString(),
|
date: new Date(state.active.effectiveFrom).toLocaleString(),
|
||||||
count: state.versions.length,
|
count: state.versions.length,
|
||||||
@@ -407,7 +407,7 @@ export function TariffComposer() {
|
|||||||
{/* A stepped ("up-to") base rate cannot be combined with time tiers — the
|
{/* A stepped ("up-to") base rate cannot be combined with time tiers — the
|
||||||
engine would ignore them. Warn up-front; publishing is also blocked server-side. */}
|
engine would ignore them. Warn up-front; publishing is also blocked server-side. */}
|
||||||
{form.base.mode === "stepped" && form.tiers.length > 0 && (
|
{form.base.mode === "stepped" && form.tiers.length > 0 && (
|
||||||
<p className="mb-3 rounded-term border border-term-red/50 bg-term-red/10 px-3 py-2 text-[12px] text-term-red">
|
<p className="mb-3 rounded-term border border-term-red/50 bg-term-red/10 px-3 py-2 text-[0.75rem] text-term-red">
|
||||||
{t("tariff.steppedTiersConflict")}
|
{t("tariff.steppedTiersConflict")}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@@ -432,7 +432,7 @@ export function TariffComposer() {
|
|||||||
<label className="label">{t("tariff.tierDays")}</label>
|
<label className="label">{t("tariff.tierDays")}</label>
|
||||||
<span className="flex flex-wrap gap-2">
|
<span className="flex flex-wrap gap-2">
|
||||||
{[1, 2, 3, 4, 5, 6, 0].map((d) => (
|
{[1, 2, 3, 4, 5, 6, 0].map((d) => (
|
||||||
<label key={d} className="inline-flex items-center gap-1 text-[12px] text-term-text">
|
<label key={d} className="inline-flex items-center gap-1 text-[0.75rem] text-term-text">
|
||||||
<input type="checkbox" className="accent-term-amber" checked={tr.dow.includes(d)} onChange={() => toggleDow(i, d)} />
|
<input type="checkbox" className="accent-term-amber" checked={tr.dow.includes(d)} onChange={() => toggleDow(i, d)} />
|
||||||
{t(`tariff.dow${d}`)}
|
{t(`tariff.dow${d}`)}
|
||||||
</label>
|
</label>
|
||||||
@@ -444,7 +444,7 @@ export function TariffComposer() {
|
|||||||
<span className="text-term-muted">–</span>
|
<span className="text-term-muted">–</span>
|
||||||
<input className="input w-20" value={tr.toHour} onChange={(e) => setTier(i, { toHour: e.target.value })} placeholder="06:00" />
|
<input className="input w-20" value={tr.toHour} onChange={(e) => setTier(i, { toHour: e.target.value })} placeholder="06:00" />
|
||||||
{tr.fromHour && tr.toHour && tr.toHour <= tr.fromHour && (
|
{tr.fromHour && tr.toHour && tr.toHour <= tr.fromHour && (
|
||||||
<span className="text-[11px] text-term-muted">{t("tariff.tierOvernight")}</span>
|
<span className="text-[0.6875rem] text-term-muted">{t("tariff.tierOvernight")}</span>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
<label className="label">{t("tariff.tierDates")}</label>
|
<label className="label">{t("tariff.tierDates")}</label>
|
||||||
@@ -478,7 +478,7 @@ export function TariffComposer() {
|
|||||||
{saving ? t("tariff.publishing") : t("tariff.publishNewVersion")}
|
{saving ? t("tariff.publishing") : t("tariff.publishNewVersion")}
|
||||||
</button>
|
</button>
|
||||||
{msg && (
|
{msg && (
|
||||||
<span className={msg.kind === "ok" ? "text-[12px] text-term-green" : "text-[12px] text-term-red"}>{msg.text}</span>
|
<span className={msg.kind === "ok" ? "text-[0.75rem] text-term-green" : "text-[0.75rem] text-term-red"}>{msg.text}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -504,7 +504,7 @@ function PricingEditor(props: {
|
|||||||
const { t, pricing: p } = props;
|
const { t, pricing: p } = props;
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="mb-3 flex gap-4 text-[12px]">
|
<div className="mb-3 flex gap-4 text-[0.75rem]">
|
||||||
<label className="inline-flex items-center gap-1.5 text-term-text">
|
<label className="inline-flex items-center gap-1.5 text-term-text">
|
||||||
<input type="radio" className="accent-term-amber" checked={p.mode === "ladder"} onChange={() => props.onMode("ladder")} />
|
<input type="radio" className="accent-term-amber" checked={p.mode === "ladder"} onChange={() => props.onMode("ladder")} />
|
||||||
{t("tariff.modeLadder")}
|
{t("tariff.modeLadder")}
|
||||||
@@ -538,7 +538,7 @@ function PricingEditor(props: {
|
|||||||
<td className="px-2 py-1">
|
<td className="px-2 py-1">
|
||||||
<span className="inline-flex items-center gap-2">
|
<span className="inline-flex items-center gap-2">
|
||||||
<input className="input w-20" value={s.hours} onChange={(e) => props.onStep?.(i, { hours: e.target.value })} placeholder={t("tariff.egHours")} />
|
<input className="input w-20" value={s.hours} onChange={(e) => props.onStep?.(i, { hours: e.target.value })} placeholder={t("tariff.egHours")} />
|
||||||
<span className="text-[11px] text-term-muted">{t("tariff.hoursUnit")}</span>
|
<span className="text-[0.6875rem] text-term-muted">{t("tariff.hoursUnit")}</span>
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-2 py-1">
|
<td className="px-2 py-1">
|
||||||
@@ -587,7 +587,7 @@ function PricingEditor(props: {
|
|||||||
) : (
|
) : (
|
||||||
<span className="inline-flex items-center gap-2">
|
<span className="inline-flex items-center gap-2">
|
||||||
<input className="input w-20" value={b.hours} onChange={(e) => props.onBlock(i, { hours: e.target.value })} placeholder={t("tariff.egHours")} />
|
<input className="input w-20" value={b.hours} onChange={(e) => props.onBlock(i, { hours: e.target.value })} placeholder={t("tariff.egHours")} />
|
||||||
<span className="text-[11px] text-term-muted">{t("tariff.hoursUnit")}</span>
|
<span className="text-[0.6875rem] text-term-muted">{t("tariff.hoursUnit")}</span>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -131,7 +131,7 @@ export function TariffLab() {
|
|||||||
<button type="button" className="btn btn-sm" onClick={loadTicket} disabled={!ticket.trim()}>
|
<button type="button" className="btn btn-sm" onClick={loadTicket} disabled={!ticket.trim()}>
|
||||||
{t("lab.load")}
|
{t("lab.load")}
|
||||||
</button>
|
</button>
|
||||||
{loadMsg && <span className="text-[12px] text-term-green">{loadMsg}</span>}
|
{loadMsg && <span className="text-[0.75rem] text-term-green">{loadMsg}</span>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Hypothetical session inputs */}
|
{/* Hypothetical session inputs */}
|
||||||
@@ -167,7 +167,7 @@ export function TariffLab() {
|
|||||||
|
|
||||||
<label className="label">{t("lab.payment")}</label>
|
<label className="label">{t("lab.payment")}</label>
|
||||||
<span className="flex flex-wrap items-center gap-2">
|
<span className="flex flex-wrap items-center gap-2">
|
||||||
<label className="inline-flex items-center gap-1 text-[12px] text-term-text">
|
<label className="inline-flex items-center gap-1 text-[0.75rem] text-term-text">
|
||||||
<input type="checkbox" className="accent-term-amber" checked={paid} onChange={(e) => setPaid(e.target.checked)} />
|
<input type="checkbox" className="accent-term-amber" checked={paid} onChange={(e) => setPaid(e.target.checked)} />
|
||||||
{t("lab.paid")}
|
{t("lab.paid")}
|
||||||
</label>
|
</label>
|
||||||
@@ -190,7 +190,7 @@ export function TariffLab() {
|
|||||||
<button type="button" className="btn btn-primary btn-lg" onClick={run} disabled={busy}>
|
<button type="button" className="btn btn-primary btn-lg" onClick={run} disabled={busy}>
|
||||||
{busy ? t("lab.pricing") : t("lab.price")}
|
{busy ? t("lab.pricing") : t("lab.price")}
|
||||||
</button>
|
</button>
|
||||||
{err && <span className="text-[12px] text-term-red">{err}</span>}
|
{err && <span className="text-[0.75rem] text-term-red">{err}</span>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{result && (
|
{result && (
|
||||||
@@ -198,19 +198,19 @@ export function TariffLab() {
|
|||||||
{/* Outcome */}
|
{/* Outcome */}
|
||||||
<div className="card card-body">
|
<div className="card card-body">
|
||||||
<h3 className="mb-2 text-h6 font-semibold uppercase tracking-wider text-term-text">{t("lab.outcome")}</h3>
|
<h3 className="mb-2 text-h6 font-semibold uppercase tracking-wider text-term-text">{t("lab.outcome")}</h3>
|
||||||
<dl className="grid grid-cols-[max-content_1fr] gap-x-4 gap-y-1 text-[13px]">
|
<dl className="grid grid-cols-[max-content_1fr] gap-x-4 gap-y-1 text-[0.8125rem]">
|
||||||
<dt className="text-term-muted">{t("lab.amountDue")}</dt>
|
<dt className="text-term-muted">{t("lab.amountDue")}</dt>
|
||||||
<dd className="text-2xl font-bold text-term-cyan">{formatMoney(result.pricing.amountMinor, currency)}</dd>
|
<dd className="text-2xl font-bold text-term-cyan">{formatMoney(result.pricing.amountMinor, currency)}</dd>
|
||||||
<dt className="text-term-muted">{t("lab.billedPeriod")}</dt>
|
<dt className="text-term-muted">{t("lab.billedPeriod")}</dt>
|
||||||
<dd className="text-term-text">
|
<dd className="text-term-text">
|
||||||
{formatDuration(result.pricing.periodStart, fromLocalInput(asOf))}
|
{formatDuration(result.pricing.periodStart, fromLocalInput(asOf))}
|
||||||
{result.pricing.overstay && (
|
{result.pricing.overstay && (
|
||||||
<span className="ml-2 rounded bg-term-red/15 px-1.5 py-0.5 text-[10px] uppercase text-term-red">
|
<span className="ml-2 rounded bg-term-red/15 px-1.5 py-0.5 text-[0.625rem] uppercase text-term-red">
|
||||||
{t("lab.overstay")}
|
{t("lab.overstay")}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{result.pricing.withinGrace && (
|
{result.pricing.withinGrace && (
|
||||||
<span className="ml-2 rounded bg-term-green/15 px-1.5 py-0.5 text-[10px] uppercase text-term-green">
|
<span className="ml-2 rounded bg-term-green/15 px-1.5 py-0.5 text-[0.625rem] uppercase text-term-green">
|
||||||
{t("lab.settled")}
|
{t("lab.settled")}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -230,7 +230,7 @@ export function TariffLab() {
|
|||||||
<div className="card card-body">
|
<div className="card card-body">
|
||||||
<h3 className="mb-2 text-h6 font-semibold uppercase tracking-wider text-term-text">{t("lab.curve")}</h3>
|
<h3 className="mb-2 text-h6 font-semibold uppercase tracking-wider text-term-text">{t("lab.curve")}</h3>
|
||||||
<p className="hint mb-2">{t("lab.curveHint")}</p>
|
<p className="hint mb-2">{t("lab.curveHint")}</p>
|
||||||
<table className="w-full text-[12px] tabular-nums">
|
<table className="w-full text-[0.75rem] tabular-nums">
|
||||||
<tbody>
|
<tbody>
|
||||||
{result.curve.map((c) => (
|
{result.curve.map((c) => (
|
||||||
<tr key={c.minutes} className="border-b border-term-border/40">
|
<tr key={c.minutes} className="border-b border-term-border/40">
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ export function UsersManager({ user }: { user: SessionUser | null }) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && <div className="mb-2 rounded-term border border-term-red px-3 py-2 text-[12px] text-term-red">{error}</div>}
|
{error && <div className="mb-2 rounded-term border border-term-red px-3 py-2 text-[0.75rem] text-term-red">{error}</div>}
|
||||||
|
|
||||||
<Modal open={adding} onClose={() => setAdding(false)} title={t("users.new")} width="max-w-2xl">
|
<Modal open={adding} onClose={() => setAdding(false)} title={t("users.new")} width="max-w-2xl">
|
||||||
<UserForm
|
<UserForm
|
||||||
@@ -100,8 +100,8 @@ export function UsersManager({ user }: { user: SessionUser | null }) {
|
|||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
<div className="overflow-hidden rounded-term border border-term-border">
|
<div className="overflow-hidden rounded-term border border-term-border">
|
||||||
<table className="w-full text-[12px]">
|
<table className="w-full text-[0.75rem]">
|
||||||
<thead className="bg-term-panel-2 text-[11px] uppercase tracking-wider text-term-muted">
|
<thead className="bg-term-panel-2 text-[0.6875rem] uppercase tracking-wider text-term-muted">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-3 py-1.5 text-left">{t("users.username")}</th>
|
<th className="px-3 py-1.5 text-left">{t("users.username")}</th>
|
||||||
<th className="px-3 py-1.5 text-left">{t("users.role")}</th>
|
<th className="px-3 py-1.5 text-left">{t("users.role")}</th>
|
||||||
@@ -291,7 +291,7 @@ function UserForm({
|
|||||||
{!isEdit && <div className="hint mt-1">{t("users.passwordHint")}</div>}
|
{!isEdit && <div className="hint mt-1">{t("users.passwordHint")}</div>}
|
||||||
|
|
||||||
{/* Optional profile metadata. */}
|
{/* Optional profile metadata. */}
|
||||||
<div className="mt-4 mb-2 text-[11px] uppercase tracking-wider text-term-muted">{t("users.detailsSection")}</div>
|
<div className="mt-4 mb-2 text-[0.6875rem] uppercase tracking-wider text-term-muted">{t("users.detailsSection")}</div>
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<span className="label">{t("users.fullName")}</span>
|
<span className="label">{t("users.fullName")}</span>
|
||||||
|
|||||||
+227
-27
@@ -30,7 +30,7 @@ export async function apiFetch<T>(path: string, init: RequestInit = {}): Promise
|
|||||||
}
|
}
|
||||||
const res = await fetch(apiUrl(path), { ...init, headers, credentials: "include" });
|
const res = await fetch(apiUrl(path), { ...init, headers, credentials: "include" });
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const msg = (await res.json().catch(() => ({}))) as { error?: string; problems?: string[] };
|
const msg = (await res.json().catch(() => ({}))) as { error?: string; problems?: string[]; [k: string]: unknown };
|
||||||
const error = msg.error ?? `${path}: ${res.status}`;
|
const error = msg.error ?? `${path}: ${res.status}`;
|
||||||
// Ship the failed request to the backend log store (best-effort, loop-safe — the
|
// 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,
|
// logger itself never logs the /api/logs call). 401s are normal pre-login churn,
|
||||||
@@ -38,7 +38,7 @@ export async function apiFetch<T>(path: string, init: RequestInit = {}): Promise
|
|||||||
if (res.status !== 401) {
|
if (res.status !== 401) {
|
||||||
logFailedRequest({ path, method, status: res.status, error });
|
logFailedRequest({ path, method, status: res.status, error });
|
||||||
}
|
}
|
||||||
throw new ApiError(error, res.status, msg.problems);
|
throw new ApiError(error, res.status, msg.problems, msg);
|
||||||
}
|
}
|
||||||
if (res.status === 204) return undefined as T;
|
if (res.status === 204) return undefined as T;
|
||||||
return res.json() as Promise<T>;
|
return res.json() as Promise<T>;
|
||||||
@@ -50,6 +50,9 @@ export class ApiError extends Error {
|
|||||||
readonly status: number,
|
readonly status: number,
|
||||||
/** Field-level problems from a validation error (e.g. tariff publish), if any. */
|
/** Field-level problems from a validation error (e.g. tariff publish), if any. */
|
||||||
readonly problems?: string[],
|
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);
|
super(message);
|
||||||
}
|
}
|
||||||
@@ -73,6 +76,8 @@ export interface SessionUser {
|
|||||||
language: Lang;
|
language: Lang;
|
||||||
/** Preferred UI theme (loaded from the server on login). */
|
/** Preferred UI theme (loaded from the server on login). */
|
||||||
theme: Theme;
|
theme: Theme;
|
||||||
|
/** Preferred UI font scale, percent of base (100 = base; clamped 80–160). */
|
||||||
|
fontScale: number;
|
||||||
/** Optional display name (profile metadata); null if unset. */
|
/** Optional display name (profile metadata); null if unset. */
|
||||||
fullName: string | null;
|
fullName: string | null;
|
||||||
/** Optional contact email (profile metadata); null if unset. */
|
/** Optional contact email (profile metadata); null if unset. */
|
||||||
@@ -105,6 +110,16 @@ export function setThemePref(theme: Theme): Promise<{ theme: Theme }> {
|
|||||||
return apiFetch("/api/auth/theme", { method: "PUT", body: JSON.stringify({ 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.
|
/** Edit MY own profile (display name / email). Returns the refreshed session.
|
||||||
* Self-service — touches only the signed-in user; no `user:*` permission needed. */
|
* Self-service — touches only the signed-in user; no `user:*` permission needed. */
|
||||||
export function updateMyProfile(patch: {
|
export function updateMyProfile(patch: {
|
||||||
@@ -215,6 +230,64 @@ export function fetchLogs(params: {
|
|||||||
return apiFetch(`/api/logs${qs ? `?${qs}` : ""}`);
|
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 ---------------------------------------------------------
|
// --- Device setup ---------------------------------------------------------
|
||||||
|
|
||||||
export interface ConfigField {
|
export interface ConfigField {
|
||||||
@@ -275,18 +348,52 @@ export type DeviceConfig = Record<string, ConfigValue>;
|
|||||||
/** Direction a barrier/relay (or a device bound to it) serves. */
|
/** Direction a barrier/relay (or a device bound to it) serves. */
|
||||||
export type Direction = "entry" | "exit" | "both";
|
export type Direction = "entry" | "exit" | "both";
|
||||||
|
|
||||||
/** One relay on an access controller: which barrier it opens, in which direction,
|
/** The EVENT a relay reacts to. entry/exit/both → pulse a barrier; `radarAlert` → drive a
|
||||||
* and (optionally) the input terminal its entry button is wired to. */
|
* 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 {
|
export interface RelaySpec {
|
||||||
relay: number;
|
relay: number;
|
||||||
direction: Direction;
|
/** The event this relay reacts to (UI label: "Event"). */
|
||||||
/** Input terminal of the entry button that fires this relay (transient entry). */
|
direction: RelayEvent;
|
||||||
|
// ── legacy input fields (read-only back-compat; superseded by config.inputs[]) ──
|
||||||
button?: number;
|
button?: number;
|
||||||
/** Anti-double-press (one car = one ticket). PRESENCE: input terminal of a vehicle
|
|
||||||
* loop/barrier-feedback signal; a press prints only with a car present + re-arms when
|
|
||||||
* it clears. COOLDOWN (fallback, no feedback): suppress repeat presses for N seconds. */
|
|
||||||
presenceInput?: number;
|
presenceInput?: number;
|
||||||
|
presenceKind?: "loop" | "radar";
|
||||||
|
presenceActiveLow?: boolean;
|
||||||
entryCooldownSec?: number;
|
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 {
|
export interface TestResult {
|
||||||
@@ -297,11 +404,13 @@ export interface TestResult {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Test a device config (reachability + preconditions) without saving. */
|
/** Test a device config (reachability + preconditions) without saving. Pass the
|
||||||
export function testDevice(driverId: string, config: DeviceConfig): Promise<TestResult> {
|
* 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", {
|
return apiFetch<TestResult>("/api/setup/test", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ driverId, config }),
|
body: JSON.stringify({ driverId, config, ...(id ? { id } : {}) }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -333,6 +442,24 @@ export function testAnpr(driverId: string, config: DeviceConfig): Promise<AnprTe
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 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 }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// --- Admin reports -------------------------------------------------------
|
// --- Admin reports -------------------------------------------------------
|
||||||
export type ReportBucket = "hour" | "day" | "month";
|
export type ReportBucket = "hour" | "day" | "month";
|
||||||
|
|
||||||
@@ -895,15 +1022,38 @@ export async function fetchShiftReport(): Promise<XReport | null> {
|
|||||||
return (await apiFetch<XReport | undefined>("/api/shift/report")) ?? null;
|
return (await apiFetch<XReport | undefined>("/api/shift/report")) ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A drawer cash voucher: Mandat Arkëtimi (cash_in / pay-IN) or Mandat Pagese
|
// --- Drawer cash movements (operator records, admin reviews) ---------------------
|
||||||
* (cash_out / pay-OUT). Direction is the TYPE, amountMinor a positive magnitude.
|
// Redesigned 2026-07-01: an operator RECORDS a receipt/disbursement freely; an admin
|
||||||
* Operator-raised, admin-authorized (authorizedBy + their password). */
|
// REVIEWS it after the fact (authorize/deny — a flag, never a cash reversal). See
|
||||||
export function recordCashVoucher(args: {
|
// 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";
|
type: "cash_in" | "cash_out";
|
||||||
amountMinor: number;
|
amountMinor: number;
|
||||||
reason: string;
|
reason: string;
|
||||||
authorizedBy: string;
|
currency?: string;
|
||||||
authorizerPassword: string;
|
|
||||||
}): Promise<{
|
}): Promise<{
|
||||||
type: "cash_in" | "cash_out";
|
type: "cash_in" | "cash_out";
|
||||||
amountMinor: number;
|
amountMinor: number;
|
||||||
@@ -911,10 +1061,26 @@ export function recordCashVoucher(args: {
|
|||||||
balanceMinor: number;
|
balanceMinor: number;
|
||||||
printed: boolean;
|
printed: boolean;
|
||||||
}> {
|
}> {
|
||||||
return apiFetch("/api/cash-voucher", {
|
return apiFetch("/api/drawer/movement", { method: "POST", body: JSON.stringify(args) });
|
||||||
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}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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). */
|
/** A completed shift (reconstructed from its signed Z-report). */
|
||||||
@@ -1044,6 +1210,9 @@ export interface SessionLookup {
|
|||||||
paidAt: string | null;
|
paidAt: string | null;
|
||||||
amountMinor: number | null;
|
amountMinor: number | null;
|
||||||
currency: string | 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;
|
withinGrace: boolean;
|
||||||
graceExpiresAt: string | null;
|
graceExpiresAt: string | null;
|
||||||
/** OVERSTAY: paid transient, walk-back grace expired, no exit — a new period began;
|
/** OVERSTAY: paid transient, walk-back grace expired, no exit — a new period began;
|
||||||
@@ -1118,12 +1287,43 @@ export function voidTicket(identity: string, reason: string): Promise<{ ok: bool
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Booth-driven exit result. `opened:false` = exit recorded but barrier didn't
|
/** Booth-driven exit result. `opened:false` = exit recorded but barrier didn't
|
||||||
* open (payment stands; operator opens manually). */
|
* open (payment stands; operator opens manually). `swapSuspected` = the exiting car's
|
||||||
export type BoothExitResult = { ok: true; opened: boolean; reason?: string };
|
* 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). */
|
/** Validate + open the barrier for a session from the booth (when near the exit).
|
||||||
export function boothExit(identity: string): Promise<BoothExitResult> {
|
* Pass override:true to consciously release a suspected plate-swap exit. */
|
||||||
return apiFetch("/api/exit", { method: "POST", body: JSON.stringify({ identity }) });
|
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,
|
/** Print an exit voucher (paid ticket id reprinted as a barcode) + payment detail,
|
||||||
|
|||||||
+35
-8
@@ -180,7 +180,7 @@ body {
|
|||||||
background: var(--color-term-bg);
|
background: var(--color-term-bg);
|
||||||
color: var(--color-term-text);
|
color: var(--color-term-text);
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 13px;
|
font-size: 0.8125rem;
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
/* Crisp text and no rubber-banding on the fixed appliance display. */
|
/* Crisp text and no rubber-banding on the fixed appliance display. */
|
||||||
@@ -266,23 +266,23 @@ body {
|
|||||||
/* Small / dense variant for inline table cells */
|
/* Small / dense variant for inline table cells */
|
||||||
.input-sm {
|
.input-sm {
|
||||||
height: var(--control-h-sm);
|
height: var(--control-h-sm);
|
||||||
@apply px-2 text-[12px];
|
@apply px-2 text-[0.75rem];
|
||||||
}
|
}
|
||||||
|
|
||||||
.field {
|
.field {
|
||||||
@apply flex flex-col gap-1;
|
@apply flex flex-col gap-1;
|
||||||
}
|
}
|
||||||
.label {
|
.label {
|
||||||
@apply text-[11px] uppercase tracking-wider text-term-muted;
|
@apply text-[0.6875rem] uppercase tracking-wider text-term-muted;
|
||||||
}
|
}
|
||||||
.hint {
|
.hint {
|
||||||
@apply text-[11px] leading-snug text-term-muted;
|
@apply text-[0.6875rem] leading-snug text-term-muted;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- Buttons: a button must look pressable, never like a field ---- */
|
/* ---- Buttons: a button must look pressable, never like a field ---- */
|
||||||
.btn {
|
.btn {
|
||||||
@apply inline-flex items-center justify-center gap-1.5 rounded-term border
|
@apply inline-flex items-center justify-center gap-1.5 rounded-term border
|
||||||
px-3 text-[12px] font-semibold uppercase tracking-wider
|
px-3 text-[0.75rem] font-semibold uppercase tracking-wider
|
||||||
transition-colors select-none;
|
transition-colors select-none;
|
||||||
height: var(--control-h-md);
|
height: var(--control-h-md);
|
||||||
/* Neutral default: a filled grey body, not a bare outline. */
|
/* Neutral default: a filled grey body, not a bare outline. */
|
||||||
@@ -302,11 +302,11 @@ body {
|
|||||||
}
|
}
|
||||||
.btn-sm {
|
.btn-sm {
|
||||||
height: var(--control-h-sm);
|
height: var(--control-h-sm);
|
||||||
@apply px-2.5 text-[11px];
|
@apply px-2.5 text-[0.6875rem];
|
||||||
}
|
}
|
||||||
.btn-lg {
|
.btn-lg {
|
||||||
height: var(--control-h-lg);
|
height: var(--control-h-lg);
|
||||||
@apply px-5 text-[13px];
|
@apply px-5 text-[0.8125rem];
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Primary: FILLED amber, dark text — the unmistakable main action. */
|
/* Primary: FILLED amber, dark text — the unmistakable main action. */
|
||||||
@@ -366,7 +366,7 @@ body {
|
|||||||
}
|
}
|
||||||
.card-head {
|
.card-head {
|
||||||
@apply flex items-center justify-between border-b border-term-border
|
@apply flex items-center justify-between border-b border-term-border
|
||||||
bg-term-panel-2 px-4 py-2 text-[12px] uppercase tracking-wider text-term-muted;
|
bg-term-panel-2 px-4 py-2 text-[0.75rem] uppercase tracking-wider text-term-muted;
|
||||||
}
|
}
|
||||||
.card-body {
|
.card-body {
|
||||||
@apply p-4;
|
@apply p-4;
|
||||||
@@ -428,3 +428,30 @@ html.theme-light .btn:hover:not(:disabled) {
|
|||||||
html.theme-light .btn-primary {
|
html.theme-light .btn-primary {
|
||||||
color: #fafaf7;
|
color: #fafaf7;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Barrier-light blink ────────────────────────────────────────────────────
|
||||||
|
The booth Entry/Exit indicator blinks green↔red (~1 Hz) when the radar/presence
|
||||||
|
input is active but the camera hasn't confirmed a vehicle yet — mirroring the
|
||||||
|
physical button lamp (relay 3). Toggles a CSS var the component maps onto its
|
||||||
|
border / tint / glyph, so green and red alternate every 500 ms. */
|
||||||
|
@keyframes lane-blink {
|
||||||
|
0%, 49% { --lane-c: var(--color-term-green); --lane-tint: color-mix(in srgb, var(--color-term-green) 10%, transparent); }
|
||||||
|
50%, 100% { --lane-c: var(--color-term-red); --lane-tint: color-mix(in srgb, var(--color-term-red) 10%, transparent); }
|
||||||
|
}
|
||||||
|
.lane-blink {
|
||||||
|
animation: lane-blink 1s steps(1, end) infinite;
|
||||||
|
border-color: var(--lane-c);
|
||||||
|
background: var(--lane-tint);
|
||||||
|
color: var(--lane-c);
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
/* No flashing for motion-sensitive users — hold the "attention" (red) state. */
|
||||||
|
.lane-blink {
|
||||||
|
animation: none;
|
||||||
|
--lane-c: var(--color-term-red);
|
||||||
|
--lane-tint: color-mix(in srgb, var(--color-term-red) 10%, transparent);
|
||||||
|
border-color: var(--lane-c);
|
||||||
|
background: var(--lane-tint);
|
||||||
|
color: var(--lane-c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
// Client-side feature flags. Small, hand-flipped switches for capabilities the app
|
||||||
|
// SUPPORTS in code but that aren't provisioned on-site yet — so the UI doesn't offer an
|
||||||
|
// action the site can't fulfil.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CARD payments. The app models a `card` tender end-to-end (server, shift accounting,
|
||||||
|
* reports), but a card sale needs a bank-certified **P2PE POS terminal** on-site, and we
|
||||||
|
* have NONE yet (2026-07-01). Until one is procured + configured, the booth/subscription
|
||||||
|
* tender pickers show CASH only — offering "Card" would let an operator record a card
|
||||||
|
* payment that never actually cleared a terminal, corrupting the till reconciliation.
|
||||||
|
*
|
||||||
|
* Flip to `true` (and add the POS device config) once a terminal is on-site. Nothing about
|
||||||
|
* the `Tender` type or historical `card` events changes — this only gates the UI *offer*.
|
||||||
|
* See wiki/concepts/card-payments.md (future POS device requirements).
|
||||||
|
*/
|
||||||
|
export const CARD_PAYMENTS_ENABLED = false;
|
||||||
@@ -22,6 +22,22 @@ export function formatDuration(fromIso: string, toIso: string): string {
|
|||||||
return h > 0 ? `${h}h ${m}m` : `${m}m`;
|
return h > 0 ? `${h}h ${m}m` : `${m}m`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Remaining time until `untilIso`, as a live countdown: "M:SS" (or "H:MM:SS" past an
|
||||||
|
* hour). Returns null once expired (or for a bad/empty input) so callers can drop the
|
||||||
|
* badge. Pass `nowMs` (a ticking clock) to make it update each second. */
|
||||||
|
export function formatCountdown(untilIso: string | null, nowMs: number = Date.now()): string | null {
|
||||||
|
if (!untilIso) return null;
|
||||||
|
const ms = Date.parse(untilIso) - nowMs;
|
||||||
|
if (!Number.isFinite(ms) || ms <= 0) return null;
|
||||||
|
const total = Math.ceil(ms / 1000);
|
||||||
|
const h = Math.floor(total / 3600);
|
||||||
|
const m = Math.floor((total % 3600) / 60);
|
||||||
|
const s = total % 60;
|
||||||
|
const ss = String(s).padStart(2, "0");
|
||||||
|
if (h > 0) return `${h}:${String(m).padStart(2, "0")}:${ss}`;
|
||||||
|
return `${m}:${ss}`;
|
||||||
|
}
|
||||||
|
|
||||||
/** Human duration from whole minutes, e.g. 134 → "2h 14m", 47 → "47m", 0 → "0m". */
|
/** Human duration from whole minutes, e.g. 134 → "2h 14m", 47 → "47m", 0 → "0m". */
|
||||||
export function formatMinutes(mins: number): string {
|
export function formatMinutes(mins: number): string {
|
||||||
if (!Number.isFinite(mins) || mins < 0) return "—";
|
if (!Number.isFinite(mins) || mins < 0) return "—";
|
||||||
|
|||||||
+175
-25
@@ -14,6 +14,9 @@ export const en: Catalog = {
|
|||||||
themeDark: "dark",
|
themeDark: "dark",
|
||||||
themeLight: "light",
|
themeLight: "light",
|
||||||
theme: "Theme",
|
theme: "Theme",
|
||||||
|
fontSmaller: "Smaller text",
|
||||||
|
fontLarger: "Larger text",
|
||||||
|
fontSize: "Text size",
|
||||||
today: "Today",
|
today: "Today",
|
||||||
yesterday: "Yesterday",
|
yesterday: "Yesterday",
|
||||||
months: [
|
months: [
|
||||||
@@ -55,11 +58,42 @@ export const en: Catalog = {
|
|||||||
users: "Users",
|
users: "Users",
|
||||||
roles: "Roles",
|
roles: "Roles",
|
||||||
shifts: "Shifts",
|
shifts: "Shifts",
|
||||||
|
drawer: "Drawer",
|
||||||
reports: "Reports",
|
reports: "Reports",
|
||||||
recycleBin: "Recycle bin",
|
recycleBin: "Recycle bin",
|
||||||
logs: "Logs",
|
logs: "Logs",
|
||||||
|
backup: "Backup",
|
||||||
profile: "Profile",
|
profile: "Profile",
|
||||||
},
|
},
|
||||||
|
drawer: {
|
||||||
|
recordTitle: "Record a cash movement",
|
||||||
|
amount: "amount",
|
||||||
|
reasonPlaceholder: "reason (e.g. supplier payment, bank drop)",
|
||||||
|
recordHint: "Recorded to the drawer immediately. An admin reviews it afterward.",
|
||||||
|
mandatArketimi: "Receipt (in) +",
|
||||||
|
mandatPagese: "Disbursement (out) −",
|
||||||
|
enterPositive: "Enter a positive amount.",
|
||||||
|
recorded: "{{no}} recorded. Drawer now {{amount}}.",
|
||||||
|
myTitle: "My cash movements",
|
||||||
|
allTitle: "Cash movements",
|
||||||
|
pendingCount: "{{count}} pending",
|
||||||
|
filterAll: "All",
|
||||||
|
empty: "No cash movements yet.",
|
||||||
|
colWhen: "When",
|
||||||
|
colType: "Type",
|
||||||
|
colAmount: "Amount",
|
||||||
|
colReason: "Reason",
|
||||||
|
colOperator: "Operator",
|
||||||
|
colStatus: "Status",
|
||||||
|
status: {
|
||||||
|
pending: "pending",
|
||||||
|
authorized: "authorized",
|
||||||
|
denied: "denied",
|
||||||
|
},
|
||||||
|
authorize: "Authorize",
|
||||||
|
deny: "Deny",
|
||||||
|
denyNotePlaceholder: "reason for denial (optional)",
|
||||||
|
},
|
||||||
profile: {
|
profile: {
|
||||||
title: "My profile",
|
title: "My profile",
|
||||||
accountSection: "Account",
|
accountSection: "Account",
|
||||||
@@ -118,7 +152,7 @@ export const en: Catalog = {
|
|||||||
scanPlaceholder: "Scan or type ticket number…",
|
scanPlaceholder: "Scan or type ticket number…",
|
||||||
laneEntry: "Entry",
|
laneEntry: "Entry",
|
||||||
laneExit: "Exit",
|
laneExit: "Exit",
|
||||||
open: "Open",
|
openTicket: "Read",
|
||||||
occupancy: "Occupancy",
|
occupancy: "Occupancy",
|
||||||
occUnavailable: "occupancy unavailable",
|
occUnavailable: "occupancy unavailable",
|
||||||
inside: "inside",
|
inside: "inside",
|
||||||
@@ -133,6 +167,11 @@ export const en: Catalog = {
|
|||||||
insideCount: "inside",
|
insideCount: "inside",
|
||||||
noActiveSessions: "No active sessions.",
|
noActiveSessions: "No active sessions.",
|
||||||
noMatch: "No sessions match the filter.",
|
noMatch: "No sessions match the filter.",
|
||||||
|
// Active-sessions table column headers.
|
||||||
|
colWho: "Ticket / subscriber",
|
||||||
|
colPlate: "Plate",
|
||||||
|
colEntry: "Entry",
|
||||||
|
colElapsed: "Elapsed",
|
||||||
badgeOverstay: "overstay",
|
badgeOverstay: "overstay",
|
||||||
badgeOverstayTitle:
|
badgeOverstayTitle:
|
||||||
"Paid session. The customer failed to exit during the grace period. A new period began.",
|
"Paid session. The customer failed to exit during the grace period. A new period began.",
|
||||||
@@ -141,14 +180,8 @@ export const en: Catalog = {
|
|||||||
filterSearchSessions: "Search ticket / subscriber / plate…",
|
filterSearchSessions: "Search ticket / subscriber / plate…",
|
||||||
filterSearchFeed: "Search event / identity / plate…",
|
filterSearchFeed: "Search event / identity / plate…",
|
||||||
filterAll: "All",
|
filterAll: "All",
|
||||||
fStatusUnpaid: "Unpaid",
|
|
||||||
fStatusPaid: "Paid",
|
|
||||||
fStatusExiting: "Exiting",
|
|
||||||
fStatusOverstay: "Overstay",
|
|
||||||
fKindTransient: "Transient",
|
fKindTransient: "Transient",
|
||||||
fKindSubscription: "Subscribers",
|
fKindSubscription: "Subscribers",
|
||||||
fDirEntry: "Entry",
|
|
||||||
fDirExit: "Exit",
|
|
||||||
fSrcBooth: "Booth",
|
fSrcBooth: "Booth",
|
||||||
fSrcReader: "Reader",
|
fSrcReader: "Reader",
|
||||||
fEvtEntry: "Entry",
|
fEvtEntry: "Entry",
|
||||||
@@ -157,6 +190,14 @@ export const en: Catalog = {
|
|||||||
fEvtVoid: "Void",
|
fEvtVoid: "Void",
|
||||||
fEvtAnomaly: "Anomaly",
|
fEvtAnomaly: "Anomaly",
|
||||||
openPayExit: "Open pay / exit",
|
openPayExit: "Open pay / exit",
|
||||||
|
openReopenBarrier: "Open — paid, awaiting barrier",
|
||||||
|
issueEntry: "Issue ticket",
|
||||||
|
issueEntryTitle: "Issue an entry ticket & open the barrier (physical button broken)",
|
||||||
|
issueEntryConfirm: "A vehicle is at the entry. Issue an entry ticket and open the barrier?",
|
||||||
|
issueEntryOk: "Entry ticket {{ticket}} issued.",
|
||||||
|
exitedGrace: "exited · grace",
|
||||||
|
exitedGraceLeft: "exited · {{time}}",
|
||||||
|
exitedGraceTitle: "Paid and exited — barrier not confirmed; waiting out the grace period.",
|
||||||
openBarrier: "Open barrier",
|
openBarrier: "Open barrier",
|
||||||
openBarrierTitle: "Human-intervention barrier open (audited)",
|
openBarrierTitle: "Human-intervention barrier open (audited)",
|
||||||
barrierOpened: "barrier opened",
|
barrierOpened: "barrier opened",
|
||||||
@@ -176,6 +217,8 @@ export const en: Catalog = {
|
|||||||
evtCashMovement: "CASH",
|
evtCashMovement: "CASH",
|
||||||
evtCashIn: "PAY-IN",
|
evtCashIn: "PAY-IN",
|
||||||
evtCashOut: "PAY-OUT",
|
evtCashOut: "PAY-OUT",
|
||||||
|
evtCashReview: "REVIEW",
|
||||||
|
decision: { authorize: "authorized", deny: "denied" },
|
||||||
evtAnomaly: "ANOMALY",
|
evtAnomaly: "ANOMALY",
|
||||||
evtRefused: "REFUSED",
|
evtRefused: "REFUSED",
|
||||||
// live-feed event detail line + classification badges (computed from payload)
|
// live-feed event detail line + classification badges (computed from payload)
|
||||||
@@ -217,6 +260,10 @@ export const en: Catalog = {
|
|||||||
edPlate: "Plate",
|
edPlate: "Plate",
|
||||||
edCategory: "Category",
|
edCategory: "Category",
|
||||||
edOperator: "Operator",
|
edOperator: "Operator",
|
||||||
|
edDecision: "Review decision",
|
||||||
|
edReviewedBy: "Reviewed by",
|
||||||
|
edReviewNote: "Note",
|
||||||
|
edReviewRef: "Movement ref",
|
||||||
edTariffVersion: "Tariff version",
|
edTariffVersion: "Tariff version",
|
||||||
edRawPayload: "Raw signed payload",
|
edRawPayload: "Raw signed payload",
|
||||||
edOccurrence: "Occurrence id",
|
edOccurrence: "Occurrence id",
|
||||||
@@ -232,6 +279,8 @@ export const en: Catalog = {
|
|||||||
reason: {
|
reason: {
|
||||||
"entry.refused.full": "Entry refused — lot full ({{count}}/{{capacity}})",
|
"entry.refused.full": "Entry refused — lot full ({{count}}/{{capacity}})",
|
||||||
"entry.held.noTicket": "Entry held — ticket not printed: {{detail}}",
|
"entry.held.noTicket": "Entry held — ticket not printed: {{detail}}",
|
||||||
|
"entry.operatorIssued": "Entry ticket issued by operator {{operator}} (physical button broken)",
|
||||||
|
"entry.issue.noPresence": "Operator entry refused — no vehicle detected at the entry",
|
||||||
"exit.refused.closed": "Exit refused — session already closed",
|
"exit.refused.closed": "Exit refused — session already closed",
|
||||||
"exit.refused.noSession": "Exit refused — unknown ticket",
|
"exit.refused.noSession": "Exit refused — unknown ticket",
|
||||||
"exit.refused.unpaid": "Exit refused — not paid (take payment first)",
|
"exit.refused.unpaid": "Exit refused — not paid (take payment first)",
|
||||||
@@ -241,6 +290,8 @@ export const en: Catalog = {
|
|||||||
"exit.open.failed": "Exit recorded, but the barrier did not open — open manually",
|
"exit.open.failed": "Exit recorded, but the barrier did not open — open manually",
|
||||||
"exit.freeGrace": "Free entry-grace (no charge)",
|
"exit.freeGrace": "Free entry-grace (no charge)",
|
||||||
"exit.manualOpen": "Manual barrier open (human intervention)",
|
"exit.manualOpen": "Manual barrier open (human intervention)",
|
||||||
|
"exit.plateSwapSuspected": "Possible ticket swap — plate {{plate}} is already inside under ticket {{otherIdentity}}",
|
||||||
|
"exit.plateSwapOverride": "Operator {{operator}} released a suspected ticket-swap exit (plate {{plate}}, also open under {{otherIdentity}})",
|
||||||
"sub.refused.notFound": "Subscription refused — not found",
|
"sub.refused.notFound": "Subscription refused — not found",
|
||||||
"sub.refused.outOfWindow": "Subscription refused — {{status}}/out-of-window",
|
"sub.refused.outOfWindow": "Subscription refused — {{status}}/out-of-window",
|
||||||
"sub.refused.noSession": "Subscription exit with no open session (already out / never entered)",
|
"sub.refused.noSession": "Subscription exit with no open session (already out / never entered)",
|
||||||
@@ -305,7 +356,7 @@ export const en: Catalog = {
|
|||||||
setup: {
|
setup: {
|
||||||
title: "Setup",
|
title: "Setup",
|
||||||
intro:
|
intro:
|
||||||
"Add your barrier controllers first — set which relay is entry/exit and which terminal the entry button is wired to. Then add readers, cameras and printers and point each at the barrier it serves.",
|
"Add your barrier controllers first. Then add readers (QR/RF), cameras, printers",
|
||||||
catControllers: "Controllers (barriers + entry button)",
|
catControllers: "Controllers (barriers + entry button)",
|
||||||
catReaders: "Readers (QR / RFID)",
|
catReaders: "Readers (QR / RFID)",
|
||||||
catCameras: "Cameras (snapshot + plate)",
|
catCameras: "Cameras (snapshot + plate)",
|
||||||
@@ -359,18 +410,49 @@ export const en: Catalog = {
|
|||||||
relaysTitle: "Relays on this controller",
|
relaysTitle: "Relays on this controller",
|
||||||
relaysHint:
|
relaysHint:
|
||||||
"Each relay opens one barrier. Set its direction; for transient entry, set which input terminal the entry button is wired to.",
|
"Each relay opens one barrier. Set its direction; for transient entry, set which input terminal the entry button is wired to.",
|
||||||
|
outputsTitle: "Outputs — relays (barriers + lamp)",
|
||||||
|
outputsHint:
|
||||||
|
"Relays are OUTPUTS: each opens a barrier (or drives the button lamp). Set the relay number and direction. The input terminals (button, sensor) are in the Inputs section below.",
|
||||||
|
pulseOpenMs: "Pulse open (ms)",
|
||||||
|
pulseOpenHint: "How long a barrier relay is held open (jog). Applies to all barrier relays.",
|
||||||
|
inputsTitle: "Inputs — terminals (button, sensor)",
|
||||||
|
inputsHint:
|
||||||
|
"Inputs are TERMINALS the host READS: the entry button and the presence/radar sensor. Each belongs to an entry barrier — it triggers or gates that relay.",
|
||||||
|
inputsIdleHigh: "Inputs idle HIGH",
|
||||||
|
inputsIdleHighHint: "This board idles inputs HIGH (status 1111); a press pulls LOW.",
|
||||||
relay: "Relay",
|
relay: "Relay",
|
||||||
entryButtonTerminal: "Entry button on terminal",
|
// Generic input rows: terminal + role + the relay it serves.
|
||||||
presenceInput: "Presence loop (terminal)",
|
inputTerminal: "Terminal",
|
||||||
presenceInputHint:
|
inputServesRelay: "Serves relay",
|
||||||
"Input terminal the vehicle-presence loop / barrier feedback is wired to. When set, exactly ONE ticket issues per car: the button prints only while a car is present, and no second ticket issues until the loop clears (the car drove in) and a new car re-occupies it. Preferred mode.",
|
roleButton: "Entry button",
|
||||||
|
rolePresenceLoop: "Presence (loop)",
|
||||||
|
rolePresenceRadar: "Presence (radar)",
|
||||||
|
roleAlertTrigger: "Alert trigger",
|
||||||
|
addInput: "+ Add input",
|
||||||
entryCooldown: "Cooldown after ticket (s)",
|
entryCooldown: "Cooldown after ticket (s)",
|
||||||
entryCooldownHint:
|
entryCooldownHint:
|
||||||
"When there's no presence loop: repeat button presses are suppressed for this many seconds after a ticket. A fallback (not a guarantee) — a determined abuser can wait it out.",
|
"When there's no presence sensor: repeat button presses are suppressed for this many seconds after a ticket. A fallback (not a guarantee) — a determined abuser can wait it out.",
|
||||||
|
activeLow: "Active-low",
|
||||||
|
activeLowHint:
|
||||||
|
"Tick if the presence sensor (e.g. a radar) idles HIGH and goes LOW on detection — the opposite of the button. This inverts that terminal's reading so 'present' is read correctly.",
|
||||||
|
eventRadarAlert: "Radar alert (lamp)",
|
||||||
|
triggerInput: "Trigger input",
|
||||||
|
triggerInputHint:
|
||||||
|
"The input terminal (the radar) that starts this relay blinking. Blinks while the trigger is active but the camera doesn't confirm a car; solid on once the camera confirms; off otherwise.",
|
||||||
|
lockLane: "Lock from",
|
||||||
|
lockLaneHint:
|
||||||
|
"Which camera locks the lamp solid: the entry or the exit camera. An exit radar must lock on the EXIT camera.",
|
||||||
|
lockLaneEntry: "Entry camera",
|
||||||
|
lockLaneExit: "Exit camera",
|
||||||
|
blinkOnMs: "Blink on (ms)",
|
||||||
|
blinkOffMs: "Blink off (ms)",
|
||||||
addRelay: "+ Add relay",
|
addRelay: "+ Add relay",
|
||||||
anpr: "Plate recognition (ANPR)",
|
anpr: "Plate recognition (ANPR)",
|
||||||
anprHint:
|
anprHint:
|
||||||
"Enable to scan plates on this camera: the vision service reads the plate from a snapshot and feeds it as a read (advisory only — it never opens a barrier on its own). Requires the vision service running.",
|
"Read plates on this camera: the vision service reads the plate from each snapshot and records it (both entry and exit). Requires the vision service running.",
|
||||||
|
anprAuto: "Auto open/close on subscriber plate",
|
||||||
|
anprAutoHint:
|
||||||
|
"Let THIS camera auto-open the barrier when it recognises a subscriber's plate. Turn OFF on a shared entry/exit lane's exit camera, so a car driving IN isn't auto-EXITed by its back plate (recognition still runs — only the auto-trigger is off).",
|
||||||
testAnpr: "Test ANPR",
|
testAnpr: "Test ANPR",
|
||||||
anprTesting: "Testing ANPR…",
|
anprTesting: "Testing ANPR…",
|
||||||
testAnprHint:
|
testAnprHint:
|
||||||
@@ -381,6 +463,29 @@ export const en: Catalog = {
|
|||||||
"anprFail.vision-disabled": "Vision service is disabled — enable it (VISION_ENABLED) to test ANPR.",
|
"anprFail.vision-disabled": "Vision service is disabled — enable it (VISION_ENABLED) to test ANPR.",
|
||||||
"anprFail.snapshot-failed": "Couldn't take a snapshot from the camera (offline or unreachable).",
|
"anprFail.snapshot-failed": "Couldn't take a snapshot from the camera (offline or unreachable).",
|
||||||
"anprFail.no-plate": "No plate found in the snapshot.",
|
"anprFail.no-plate": "No plate found in the snapshot.",
|
||||||
|
// Printer test slip — pushes a real slip so the admin can confirm it physically prints.
|
||||||
|
testPrint: "Print test slip",
|
||||||
|
printTesting: "Printing…",
|
||||||
|
testPrintHint:
|
||||||
|
"Sends a test slip to the printer now. ‘Connected’ only opens the link — this confirms the printer actually feeds paper.",
|
||||||
|
printOk: "✓ Test slip sent ({{ms}} ms). Check the printer.",
|
||||||
|
"printFail.print-failed": "The printer rejected the job (out of paper, cover open, or the link dropped).",
|
||||||
|
// Reveal/hide toggle for a secret field (e.g. the device web password).
|
||||||
|
revealSecret: "Show password",
|
||||||
|
hideSecret: "Hide password",
|
||||||
|
alarmUrlTitle: "Alarm Server settings (enter these in the camera)",
|
||||||
|
alarmUrlHint:
|
||||||
|
"Enter these in the camera at Configuration → Event → … → Alarm Settings (or Notify Surveillance Center). The camera POSTs every event here — no polling.",
|
||||||
|
alarmUrlCopy: "Copy all",
|
||||||
|
alarmUrlCopied: "Copied ✓",
|
||||||
|
alarmUrlSaveFirst:
|
||||||
|
"Save the camera first — the address is generated once the device has an ID. Re-open it for editing to see it.",
|
||||||
|
alarmUrlTestFirst:
|
||||||
|
"Click “Test connection” first — that resolves this host's IP on the camera's network (so the camera can reach it).",
|
||||||
|
alarmFieldHost: "Destination IP / Host",
|
||||||
|
alarmFieldUrl: "URL",
|
||||||
|
alarmFieldProtocol: "Protocol",
|
||||||
|
alarmFieldPort: "Port",
|
||||||
whichBarrier: "Which barrier does this device serve?",
|
whichBarrier: "Which barrier does this device serve?",
|
||||||
controller: "Controller",
|
controller: "Controller",
|
||||||
choose: "Choose…",
|
choose: "Choose…",
|
||||||
@@ -561,7 +666,7 @@ export const en: Catalog = {
|
|||||||
save: "Save",
|
save: "Save",
|
||||||
saved: "Saved.",
|
saved: "Saved.",
|
||||||
fieldParkName: "Park name",
|
fieldParkName: "Park name",
|
||||||
fieldParkNamePh: "e.g. Acme Parking",
|
fieldParkNamePh: "e.g. Airport Parking",
|
||||||
fieldOperator: "Operator (legal name)",
|
fieldOperator: "Operator (legal name)",
|
||||||
fieldOperatorPh: "operating company",
|
fieldOperatorPh: "operating company",
|
||||||
fieldNius: "NIUS",
|
fieldNius: "NIUS",
|
||||||
@@ -645,13 +750,12 @@ export const en: Catalog = {
|
|||||||
card: "Card:",
|
card: "Card:",
|
||||||
srcTickets: "Tickets:",
|
srcTickets: "Tickets:",
|
||||||
srcSubscriptions: "Subscriptions:",
|
srcSubscriptions: "Subscriptions:",
|
||||||
srcSubSales: "sales",
|
|
||||||
srcSubWindow: "out-of-window",
|
srcSubWindow: "out-of-window",
|
||||||
drawerSection: "— Drawer —",
|
drawerSection: "— Drawer —",
|
||||||
openingFloat: "Opening float:",
|
openingFloat: "Opening cash:",
|
||||||
cashTaken: "Cash taken:",
|
cashTaken: "Daily takings:",
|
||||||
cashAdded: "Cash added:",
|
cashAdded: "Receipts:",
|
||||||
cashRemoved: "Cash removed:",
|
cashRemoved: "Disbursements:",
|
||||||
expectedDrawer: "Expected drawer:",
|
expectedDrawer: "Expected drawer:",
|
||||||
printedToReceipt: "Printed to booth receipt.",
|
printedToReceipt: "Printed to booth receipt.",
|
||||||
recordedNoPrinter: "Recorded (no printer to print to).",
|
recordedNoPrinter: "Recorded (no printer to print to).",
|
||||||
@@ -682,7 +786,6 @@ export const en: Catalog = {
|
|||||||
card: "Card",
|
card: "Card",
|
||||||
srcTickets: "Tickets",
|
srcTickets: "Tickets",
|
||||||
srcSubscriptions: "Subscriptions",
|
srcSubscriptions: "Subscriptions",
|
||||||
srcSubSales: "subs sales",
|
|
||||||
srcSubWindow: "out-of-window",
|
srcSubWindow: "out-of-window",
|
||||||
expectedDrawer: "Expected drawer",
|
expectedDrawer: "Expected drawer",
|
||||||
filterFrom: "From",
|
filterFrom: "From",
|
||||||
@@ -700,10 +803,10 @@ export const en: Catalog = {
|
|||||||
noActivity: "No activity in this shift.",
|
noActivity: "No activity in this shift.",
|
||||||
current: "current",
|
current: "current",
|
||||||
drawerSection: "Drawer",
|
drawerSection: "Drawer",
|
||||||
openingFloat: "Opening float",
|
openingFloat: "Opening cash",
|
||||||
cashTaken: "Cash taken",
|
cashTaken: "Daily takings",
|
||||||
cashAdded: "Cash added",
|
cashAdded: "Receipts",
|
||||||
cashRemoved: "Cash removed",
|
cashRemoved: "Disbursements",
|
||||||
loadFailed: "Failed to load shifts.",
|
loadFailed: "Failed to load shifts.",
|
||||||
},
|
},
|
||||||
reports: {
|
reports: {
|
||||||
@@ -776,18 +879,61 @@ export const en: Catalog = {
|
|||||||
path: "Path",
|
path: "Path",
|
||||||
empty: "No logs.",
|
empty: "No logs.",
|
||||||
},
|
},
|
||||||
|
backup: {
|
||||||
|
title: "Backup",
|
||||||
|
intro:
|
||||||
|
"An encrypted copy of the database (the signed ledger) to an external disk. Runs automatically every day and from the button below.",
|
||||||
|
statusTitle: "Status",
|
||||||
|
configured: "Enabled",
|
||||||
|
notConfigured: "Not configured",
|
||||||
|
notConfiguredHint: "Set BACKUP_TARGET_DIR and BACKUP_KEY on the server to enable backups.",
|
||||||
|
running: "Running…",
|
||||||
|
idle: "Idle",
|
||||||
|
lastSuccess: "Last successful backup",
|
||||||
|
lastError: "Last error",
|
||||||
|
never: "Never",
|
||||||
|
lastFile: "File",
|
||||||
|
size: "Size",
|
||||||
|
pruned: "Pruned",
|
||||||
|
runNow: "Back up now",
|
||||||
|
runSuccess: "Backup complete.",
|
||||||
|
runFailed: "Backup failed.",
|
||||||
|
notConfiguredError: "Backup is not configured.",
|
||||||
|
restoreNote:
|
||||||
|
"Restore is not done here — it's an out-of-band step when provisioning a fresh appliance (needs the backup file + the keys you escrowed offsite).",
|
||||||
|
targetLabel: "Backup location",
|
||||||
|
targetPlaceholder: "e.g. /mnt/backup or /media/usb",
|
||||||
|
targetHint: "An absolute path to a mounted disk (USB/SATA) or a network share (SMB/NFS).",
|
||||||
|
save: "Save",
|
||||||
|
saved: "Saved.",
|
||||||
|
test: "Test target",
|
||||||
|
testOk: "The location is writable.",
|
||||||
|
testEmpty: "Enter a path.",
|
||||||
|
testMissing: "The location does not exist.",
|
||||||
|
testNotDir: "The path is not a directory.",
|
||||||
|
testNotWritable: "The directory is not writable.",
|
||||||
|
keyMissing: "The encryption key (BACKUP_KEY) is missing on the server — set it to enable backups.",
|
||||||
|
keepLastLabel: "Keep last",
|
||||||
|
keepLastHint: "How many of the newest backups to always keep.",
|
||||||
|
keepDailyLabel: "Keep daily (days)",
|
||||||
|
keepDailyHint: "Beyond those, keep one backup per day for this many days.",
|
||||||
|
},
|
||||||
pay: {
|
pay: {
|
||||||
ticket: "Ticket",
|
ticket: "Ticket",
|
||||||
entry: "Entry",
|
entry: "Entry",
|
||||||
now: "Now",
|
now: "Now",
|
||||||
|
exit: "Exit",
|
||||||
duration: "Duration",
|
duration: "Duration",
|
||||||
statusLabel: "Status",
|
statusLabel: "Status",
|
||||||
paid: "PAID",
|
paid: "PAID",
|
||||||
unpaid: "UNPAID",
|
unpaid: "UNPAID",
|
||||||
overstay: "OVERSTAY",
|
overstay: "OVERSTAY",
|
||||||
overstayHint: "Earlier session paid. The customer failed to exit during the grace period. Payment for the new period is required. The total below is the new period's fee.",
|
overstayHint: "Earlier session paid. The customer failed to exit during the grace period. Payment for the new period is required. The total below is the new period's fee.",
|
||||||
|
closedWithinGrace: "EXITED · GRACE",
|
||||||
|
closedWithinGraceHint: "Paid and exit recorded — the barrier didn't confirm yet. The car stays listed until the grace period ends. Open the barrier manually if it's still waiting.",
|
||||||
topUp: "New period due",
|
topUp: "New period due",
|
||||||
total: "Total",
|
total: "Total",
|
||||||
|
paidAmount: "Paid",
|
||||||
noTariff: "no tariff",
|
noTariff: "no tariff",
|
||||||
tender: "Tender",
|
tender: "Tender",
|
||||||
cash: "Cash",
|
cash: "Cash",
|
||||||
@@ -806,6 +952,10 @@ export const en: Catalog = {
|
|||||||
lookingUp: "looking up…",
|
lookingUp: "looking up…",
|
||||||
paidBarrierOpened: "Paid — barrier opened. Car may exit.",
|
paidBarrierOpened: "Paid — barrier opened. Car may exit.",
|
||||||
paidExitRecorded: "Paid and exit recorded, but the barrier did not open: {{reason}}.",
|
paidExitRecorded: "Paid and exit recorded, but the barrier did not open: {{reason}}.",
|
||||||
|
swapTitle: "Possible ticket swap",
|
||||||
|
swapBody: "Plate {{plate}} is already inside under ticket {{other}} (entered {{when}}). This car may be exiting on a different ticket than it entered on.",
|
||||||
|
swapHint: "Verify the vehicle before releasing. Overriding is recorded against you.",
|
||||||
|
swapOverride: "Override & release",
|
||||||
subscription: "SUBSCRIPTION",
|
subscription: "SUBSCRIPTION",
|
||||||
plan: "Plan",
|
plan: "Plan",
|
||||||
prepaid: "PREPAID",
|
prepaid: "PREPAID",
|
||||||
|
|||||||
+192
-40
@@ -14,6 +14,9 @@ export const sq = {
|
|||||||
themeDark: "errët",
|
themeDark: "errët",
|
||||||
themeLight: "çelët",
|
themeLight: "çelët",
|
||||||
theme: "Tema",
|
theme: "Tema",
|
||||||
|
fontSmaller: "Zvogëlo tekstin",
|
||||||
|
fontLarger: "Rrit tekstin",
|
||||||
|
fontSize: "Madhësia e tekstit",
|
||||||
today: "Sot",
|
today: "Sot",
|
||||||
yesterday: "Dje",
|
yesterday: "Dje",
|
||||||
// Month names (index 0 = January) — kept in the catalog because the appliance's
|
// Month names (index 0 = January) — kept in the catalog because the appliance's
|
||||||
@@ -57,11 +60,42 @@ export const sq = {
|
|||||||
users: "Përdoruesit",
|
users: "Përdoruesit",
|
||||||
roles: "Rolet",
|
roles: "Rolet",
|
||||||
shifts: "Turnet",
|
shifts: "Turnet",
|
||||||
|
drawer: "Arka",
|
||||||
reports: "Raportet",
|
reports: "Raportet",
|
||||||
recycleBin: "Koshi",
|
recycleBin: "Koshi",
|
||||||
logs: "Loget",
|
logs: "Loget",
|
||||||
|
backup: "Kopje rezervë",
|
||||||
profile: "Profili",
|
profile: "Profili",
|
||||||
},
|
},
|
||||||
|
drawer: {
|
||||||
|
recordTitle: "Regjistro një lëvizje arke",
|
||||||
|
amount: "shuma",
|
||||||
|
reasonPlaceholder: "arsyeja (p.sh. pagesë furnitori, depozitë banke)",
|
||||||
|
recordHint: "Regjistrohet menjëherë në arkë. Një admin e shqyrton më pas.",
|
||||||
|
mandatArketimi: "Arkëtim (hyrje) +",
|
||||||
|
mandatPagese: "Pagesë (dalje) −",
|
||||||
|
enterPositive: "Fut një shumë pozitive.",
|
||||||
|
recorded: "{{no}} u regjistrua. Arka tani {{amount}}.",
|
||||||
|
myTitle: "Lëvizjet e mia të arkës",
|
||||||
|
allTitle: "Lëvizjet e arkës",
|
||||||
|
pendingCount: "{{count}} në pritje",
|
||||||
|
filterAll: "Të gjitha",
|
||||||
|
empty: "Asnjë lëvizje arke ende.",
|
||||||
|
colWhen: "Kur",
|
||||||
|
colType: "Lloji",
|
||||||
|
colAmount: "Shuma",
|
||||||
|
colReason: "Arsyeja",
|
||||||
|
colOperator: "Operatori",
|
||||||
|
colStatus: "Statusi",
|
||||||
|
status: {
|
||||||
|
pending: "në pritje",
|
||||||
|
authorized: "autorizuar",
|
||||||
|
denied: "refuzuar",
|
||||||
|
},
|
||||||
|
authorize: "Autorizo",
|
||||||
|
deny: "Refuzo",
|
||||||
|
denyNotePlaceholder: "arsyeja e refuzimit (opsionale)",
|
||||||
|
},
|
||||||
profile: {
|
profile: {
|
||||||
title: "Profili im",
|
title: "Profili im",
|
||||||
accountSection: "Llogaria",
|
accountSection: "Llogaria",
|
||||||
@@ -120,7 +154,7 @@ export const sq = {
|
|||||||
scanPlaceholder: "Skano ose shkruaj numrin e biletës…",
|
scanPlaceholder: "Skano ose shkruaj numrin e biletës…",
|
||||||
laneEntry: "Hyrje",
|
laneEntry: "Hyrje",
|
||||||
laneExit: "Dalje",
|
laneExit: "Dalje",
|
||||||
open: "Hap",
|
openTicket: "Lexo",
|
||||||
occupancy: "Prania",
|
occupancy: "Prania",
|
||||||
occUnavailable: "zënia e padisponueshme",
|
occUnavailable: "zënia e padisponueshme",
|
||||||
inside: "brenda",
|
inside: "brenda",
|
||||||
@@ -135,6 +169,11 @@ export const sq = {
|
|||||||
insideCount: "brenda",
|
insideCount: "brenda",
|
||||||
noActiveSessions: "Asnjë sesion aktiv.",
|
noActiveSessions: "Asnjë sesion aktiv.",
|
||||||
noMatch: "Asnjë rezultat për filtrin.",
|
noMatch: "Asnjë rezultat për filtrin.",
|
||||||
|
// Kokat e kolonave të tabelës së sesioneve aktive.
|
||||||
|
colWho: "Biletë / abonent",
|
||||||
|
colPlate: "Targa",
|
||||||
|
colEntry: "Hyrja",
|
||||||
|
colElapsed: "Koha brenda",
|
||||||
badgeOverstay: "tej afatit",
|
badgeOverstay: "tej afatit",
|
||||||
badgeOverstayTitle:
|
badgeOverstayTitle:
|
||||||
"Sesion i paguar. Klienti nuk doli brënda afatit kohor. Ka filluar një periudhë e re tarifimi.",
|
"Sesion i paguar. Klienti nuk doli brënda afatit kohor. Ka filluar një periudhë e re tarifimi.",
|
||||||
@@ -143,14 +182,8 @@ export const sq = {
|
|||||||
filterSearchSessions: "Kërko biletë / abonent / targë…",
|
filterSearchSessions: "Kërko biletë / abonent / targë…",
|
||||||
filterSearchFeed: "Kërko event / identitet / targë…",
|
filterSearchFeed: "Kërko event / identitet / targë…",
|
||||||
filterAll: "Të gjitha",
|
filterAll: "Të gjitha",
|
||||||
fStatusUnpaid: "Papaguar",
|
|
||||||
fStatusPaid: "Paguar",
|
|
||||||
fStatusExiting: "Duke dalë",
|
|
||||||
fStatusOverstay: "Tej afatit",
|
|
||||||
fKindTransient: "Kalimtarë",
|
fKindTransient: "Kalimtarë",
|
||||||
fKindSubscription: "Abonentë",
|
fKindSubscription: "Abonentë",
|
||||||
fDirEntry: "Hyrje",
|
|
||||||
fDirExit: "Dalje",
|
|
||||||
fSrcBooth: "Kabinë",
|
fSrcBooth: "Kabinë",
|
||||||
fSrcReader: "Lexues",
|
fSrcReader: "Lexues",
|
||||||
fEvtEntry: "Hyrje",
|
fEvtEntry: "Hyrje",
|
||||||
@@ -159,10 +192,18 @@ export const sq = {
|
|||||||
fEvtVoid: "Anulim",
|
fEvtVoid: "Anulim",
|
||||||
fEvtAnomaly: "Anomali",
|
fEvtAnomaly: "Anomali",
|
||||||
openPayExit: "Hap pagesën / daljen",
|
openPayExit: "Hap pagesën / daljen",
|
||||||
|
openReopenBarrier: "Hap — paguar, pret barrierën",
|
||||||
|
issueEntry: "Lësho biletë",
|
||||||
|
issueEntryTitle: "Lësho një biletë hyrjeje & hap barrierën (butoni fizik i prishur)",
|
||||||
|
issueEntryConfirm: "Një automjet është te hyrja. Të lëshohet një biletë hyrjeje dhe të hapet barriera?",
|
||||||
|
issueEntryOk: "Bileta e hyrjes {{ticket}} u lëshua.",
|
||||||
|
exitedGrace: "doli · në afat",
|
||||||
|
exitedGraceLeft: "doli · {{time}}",
|
||||||
|
exitedGraceTitle: "Paguar dhe dalur — barriera nuk u konfirmua; po pret afatin kohor.",
|
||||||
openBarrier: "Hap barrierën",
|
openBarrier: "Hap barrierën",
|
||||||
openBarrierTitle: "Hap barrierën manualisht",
|
openBarrierTitle: "Hap barrierën manualisht",
|
||||||
barrierOpened: "barriera u hap",
|
barrierOpened: "barriera u hap",
|
||||||
openManually: "hape me dorë",
|
openManually: "hape manualisht",
|
||||||
// session row badges
|
// session row badges
|
||||||
badgeExiting: "duke dalë",
|
badgeExiting: "duke dalë",
|
||||||
badgePaid: "paguar",
|
badgePaid: "paguar",
|
||||||
@@ -180,6 +221,8 @@ export const sq = {
|
|||||||
evtCashMovement: "ARKË",
|
evtCashMovement: "ARKË",
|
||||||
evtCashIn: "ARKËTIM",
|
evtCashIn: "ARKËTIM",
|
||||||
evtCashOut: "PAGESË",
|
evtCashOut: "PAGESË",
|
||||||
|
evtCashReview: "SHQYRTIM",
|
||||||
|
decision: { authorize: "autorizuar", deny: "refuzuar" },
|
||||||
evtAnomaly: "ANOMALI",
|
evtAnomaly: "ANOMALI",
|
||||||
evtRefused: "REFUZUAR",
|
evtRefused: "REFUZUAR",
|
||||||
// rreshti i detajeve të eventit live + etiketat e klasifikimit (nga payload)
|
// rreshti i detajeve të eventit live + etiketat e klasifikimit (nga payload)
|
||||||
@@ -221,6 +264,10 @@ export const sq = {
|
|||||||
edPlate: "Targa",
|
edPlate: "Targa",
|
||||||
edCategory: "Kategoria",
|
edCategory: "Kategoria",
|
||||||
edOperator: "Operatori",
|
edOperator: "Operatori",
|
||||||
|
edDecision: "Vendimi i shqyrtimit",
|
||||||
|
edReviewedBy: "Shqyrtuar nga",
|
||||||
|
edReviewNote: "Shënim",
|
||||||
|
edReviewRef: "Ref. lëvizjes",
|
||||||
edTariffVersion: "Versioni i tarifës",
|
edTariffVersion: "Versioni i tarifës",
|
||||||
edRawPayload: "Të dhënat e papërpunuara të nënshkruara",
|
edRawPayload: "Të dhënat e papërpunuara të nënshkruara",
|
||||||
edOccurrence: "ID e hyrjes",
|
edOccurrence: "ID e hyrjes",
|
||||||
@@ -235,15 +282,19 @@ export const sq = {
|
|||||||
reason: {
|
reason: {
|
||||||
"entry.refused.full": "Hyrja u refuzua — parkimi plot ({{count}}/{{capacity}})",
|
"entry.refused.full": "Hyrja u refuzua — parkimi plot ({{count}}/{{capacity}})",
|
||||||
"entry.held.noTicket": "Hyrja u mbajt — bileta nuk u printua: {{detail}}",
|
"entry.held.noTicket": "Hyrja u mbajt — bileta nuk u printua: {{detail}}",
|
||||||
|
"entry.operatorIssued": "Biletë hyrjeje e lëshuar nga operatori {{operator}} (butoni fizik i prishur)",
|
||||||
|
"entry.issue.noPresence": "Hyrja nga operatori u refuzua — asnjë automjet te hyrja",
|
||||||
"exit.refused.closed": "Dalja u refuzua — sesioni është mbyllur tashmë",
|
"exit.refused.closed": "Dalja u refuzua — sesioni është mbyllur tashmë",
|
||||||
"exit.refused.noSession": "Dalja u refuzua — biletë e panjohur",
|
"exit.refused.noSession": "Dalja u refuzua — biletë e panjohur",
|
||||||
"exit.refused.unpaid": "Dalja u refuzua — e papaguar (bëj pagesën në fillim)",
|
"exit.refused.unpaid": "Dalja u refuzua — e papaguar (bëj pagesën në fillim)",
|
||||||
"exit.refused.graceExpired": "Dalja u refuzua — afati i daljes skadoi (kërkohet pagesë shtesë)",
|
"exit.refused.graceExpired": "Dalja u refuzua — afati i daljes skadoi (kërkohet pagesë shtesë)",
|
||||||
"exit.open.noBarrier": "Dalja u regjistrua, por nuk ka barrierë daljeje të konfiguruar — hape me dorë",
|
"exit.open.noBarrier": "Dalja u regjistrua, por nuk ka barrierë daljeje të konfiguruar — hape manualisht",
|
||||||
"exit.open.unavailable": "Dalja u regjistrua, por barriera është e padisponueshme — hape me dorë",
|
"exit.open.unavailable": "Dalja u regjistrua, por barriera është e padisponueshme — hape manualisht",
|
||||||
"exit.open.failed": "Dalja u regjistrua, por barriera nuk u hap — hape me dorë",
|
"exit.open.failed": "Dalja u regjistrua, por barriera nuk u hap — hape manualisht",
|
||||||
"exit.freeGrace": "Periudhë pa pagesë në hyrje (pa tarifë)",
|
"exit.freeGrace": "Periudhë pa pagesë në hyrje (pa tarifë)",
|
||||||
"exit.manualOpen": "Hapje manuale e barrierës (ndërhyrje njerëzore)",
|
"exit.manualOpen": "Hapje manuale e barrierës (ndërhyrje njerëzore)",
|
||||||
|
"exit.plateSwapSuspected": "Mundësi ndërrimi biletash — targa {{plate}} është tashmë brenda me biletën {{otherIdentity}}",
|
||||||
|
"exit.plateSwapOverride": "Operatori {{operator}} lëshoi një dalje me dyshim ndërrimi biletash (targa {{plate}}, edhe e hapur me {{otherIdentity}})",
|
||||||
"sub.refused.notFound": "Abonimi u refuzua — nuk u gjet",
|
"sub.refused.notFound": "Abonimi u refuzua — nuk u gjet",
|
||||||
"sub.refused.outOfWindow": "Abonimi u refuzua — {{status}}/jashtë afatit",
|
"sub.refused.outOfWindow": "Abonimi u refuzua — {{status}}/jashtë afatit",
|
||||||
"sub.refused.noSession": "Dalje me abonim pa sesion të hapur (tashmë jashtë / nuk ka hyrë kurrë)",
|
"sub.refused.noSession": "Dalje me abonim pa sesion të hapur (tashmë jashtë / nuk ka hyrë kurrë)",
|
||||||
@@ -308,21 +359,21 @@ export const sq = {
|
|||||||
setup: {
|
setup: {
|
||||||
title: "Konfigurimi",
|
title: "Konfigurimi",
|
||||||
intro:
|
intro:
|
||||||
"Shto fillimisht kontrolluesit e barrierave — cakto cili rele është hyrje/dalje dhe në cilin terminal është lidhur butoni i hyrjes. Pastaj shto lexues, kamera dhe printera dhe drejto secilin te barriera që shërben.",
|
"Shto fillimisht kontrollerat e barrierave — Pastaj shto lexues (QR/RF), kamera, printera.",
|
||||||
// Category titles + the singular noun used in buttons/modal titles.
|
// Category titles + the singular noun used in buttons/modal titles.
|
||||||
catControllers: "Kontrolluesit (barrierat + butoni i hyrjes)",
|
catControllers: "Kontrollerat (barrierat + butoni i hyrjes)",
|
||||||
catReaders: "Lexuesit (QR / RFID)",
|
catReaders: "Lexuesit (QR / RFID)",
|
||||||
catCameras: "Kamerat (foto + targë)",
|
catCameras: "Kamerat (foto + targë)",
|
||||||
catPrinters: "Printerat (bileta / vouchera)",
|
catPrinters: "Printerat (bileta / vouchera)",
|
||||||
nounController: "kontrollues",
|
nounController: "kontroller",
|
||||||
nounReader: "lexues",
|
nounReader: "lexues",
|
||||||
nounCamera: "kamerë",
|
nounCamera: "kamerë",
|
||||||
nounPrinter: "printer",
|
nounPrinter: "printer",
|
||||||
add: "+ Shto {{noun}}",
|
add: "+ Shto {{noun}}",
|
||||||
addAnother: "+ Shto edhe një {{noun}}",
|
addAnother: "+ Shto {{noun}}",
|
||||||
addTitle: "Shto {{noun}}",
|
addTitle: "Shto {{noun}}",
|
||||||
editTitle: "Ndrysho {{noun}}",
|
editTitle: "Ndrysho {{noun}}",
|
||||||
needControllerFirst: "Shto fillimisht një kontrollues — {{noun}} drejtohet te një prej releve të tij.",
|
needControllerFirst: "Shto fillimisht një kontroller — {{noun}} drejtohet te një prej releve të tij.",
|
||||||
failedToLoad: "Ngarkimi i konfigurimit dështoi: {{error}}",
|
failedToLoad: "Ngarkimi i konfigurimit dështoi: {{error}}",
|
||||||
loadingCatalog: "Duke ngarkuar katalogun e pajisjeve…",
|
loadingCatalog: "Duke ngarkuar katalogun e pajisjeve…",
|
||||||
// Direction labels (relay direction + inherited binding).
|
// Direction labels (relay direction + inherited binding).
|
||||||
@@ -344,9 +395,9 @@ export const sq = {
|
|||||||
// Device form.
|
// Device form.
|
||||||
noDrivers: "Asnjë drejtues i regjistruar.",
|
noDrivers: "Asnjë drejtues i regjistruar.",
|
||||||
chooseDevice: "Zgjidh një pajisje…",
|
chooseDevice: "Zgjidh një pajisje…",
|
||||||
scan: "Skano për kontrollues",
|
scan: "Skano për kontroller",
|
||||||
scanning: "Duke skanuar…",
|
scanning: "Duke skanuar…",
|
||||||
noControllersFound: "Asnjë kontrollues në LAN.",
|
noControllersFound: "Asnjë kontroller në LAN.",
|
||||||
use: "Përdor",
|
use: "Përdor",
|
||||||
test: "Testo lidhjen",
|
test: "Testo lidhjen",
|
||||||
testing: "Duke testuar…",
|
testing: "Duke testuar…",
|
||||||
@@ -365,22 +416,53 @@ export const sq = {
|
|||||||
noNicOnSubnet: "⚠ asnjë NIC në subnetin e pajisjes — pajisja mund të mos arrijë backend-in",
|
noNicOnSubnet: "⚠ asnjë NIC në subnetin e pajisjes — pajisja mund të mos arrijë backend-in",
|
||||||
backendIpHint: "Adresa te e cila kjo pajisje do të dërgojë eventet e hyrjes.",
|
backendIpHint: "Adresa te e cila kjo pajisje do të dërgojë eventet e hyrjes.",
|
||||||
// Relay editor.
|
// Relay editor.
|
||||||
relaysTitle: "Relet në këtë kontrollues",
|
relaysTitle: "Relet në këtë kontroller",
|
||||||
relaysHint:
|
relaysHint:
|
||||||
"Çdo rele hap një barrierë. Cakto drejtimin e saj; për hyrje kalimtare, cakto në cilin terminal hyrës është lidhur butoni i hyrjes.",
|
"Çdo rele hap një barrierë. Cakto drejtimin e saj; për hyrje kalimtare, cakto në cilin terminal hyrës është lidhur butoni i hyrjes.",
|
||||||
|
outputsTitle: "Daljet — relet (barrierat + drita)",
|
||||||
|
outputsHint:
|
||||||
|
"Relet janë DALJE: secila hap një barrierë (ose ndez dritën e butonit). Cakto numrin e relesë dhe drejtimin. Terminalet hyrëse (butoni, sensori) janë te seksioni Hyrjet më poshtë.",
|
||||||
|
pulseOpenMs: "Kohëzgjatja e hapjes (ms)",
|
||||||
|
pulseOpenHint: "Sa kohë mbahet rele e barrierës e hapur (jog). Vlen për të gjitha relet e barrierave.",
|
||||||
|
inputsTitle: "Hyrjet — terminalet (buton, sensor)",
|
||||||
|
inputsHint:
|
||||||
|
"Hyrjet janë TERMINALE që hosti i LEXON: butoni i hyrjes dhe sensori i pranisë/radari. Secila i përket një barriere hyrëse — e gateron ose e nis atë rele.",
|
||||||
|
inputsIdleHigh: "Hyrjet në pushim HIGH",
|
||||||
|
inputsIdleHighHint: "Kjo pllakë i mban hyrjet HIGH në pushim (statusi 1111); një shtypje e ul në LOW.",
|
||||||
relay: "Rele",
|
relay: "Rele",
|
||||||
entryButtonTerminal: "Butoni i hyrjes në terminalin",
|
// Generic input rows: terminal + role + the relay it serves.
|
||||||
presenceInput: "Sensori i pranisë (terminali)",
|
inputTerminal: "Terminali",
|
||||||
presenceInputHint:
|
inputServesRelay: "I shërben reles",
|
||||||
"Terminali hyrës ku është lidhur sensori/laku i pranisë së automjetit. Kur vendoset, lëshohet vetëm NJË biletë për automjet: butoni printon vetëm kur ka makinë, dhe nuk lëshon biletë të dytë derisa laku të lirohet (makina hyri) dhe një makinë e re ta zërë. Mënyra e preferuar.",
|
roleButton: "Butoni i hyrjes",
|
||||||
|
rolePresenceLoop: "Prania (lak induktiv)",
|
||||||
|
rolePresenceRadar: "Prania (radar)",
|
||||||
|
roleAlertTrigger: "Trigger alarmi",
|
||||||
|
addInput: "+ Shto hyrje",
|
||||||
entryCooldown: "Pritje pas biletës (sek)",
|
entryCooldown: "Pritje pas biletës (sek)",
|
||||||
entryCooldownHint:
|
entryCooldownHint:
|
||||||
"Kur nuk ka sensor pranie: shtypjet e përsëritura të butonit shtypen për kaq sekonda pas një bilete. Zgjidhje rezervë (jo garanci) — një abuzues mund ta presë afatin.",
|
"Kur nuk ka sensor pranie: shtypjet e përsëritura të butonit shtypen për kaq sekonda pas një bilete. Zgjidhje rezervë (jo garanci) — një abuzues mund ta presë afatin.",
|
||||||
|
activeLow: "Aktiv-ulët",
|
||||||
|
activeLowHint:
|
||||||
|
"Shëno nëse sensori i pranisë (p.sh. radari) qëndron HIGH në pushim dhe shkon LOW kur detekton — e kundërta e butonit. Kjo përmbys leximin e atij terminali që 'prania' të lexohet saktë.",
|
||||||
|
eventRadarAlert: "Alarm radar (dritë)",
|
||||||
|
triggerInput: "Trigger input",
|
||||||
|
triggerInputHint:
|
||||||
|
"Terminali i hyrjes (radari) që nis pulsimin e kësaj rele. Pulson kur Trigger input është aktiv por kamera s'konfirmon makinë; ndizet fiks kur kamera konfirmon; përndryshe fiket.",
|
||||||
|
lockLane: "Bllokimi nga",
|
||||||
|
lockLaneHint:
|
||||||
|
"Cila kamerë e ndez dritën fiks: hyrja apo dalja. Një radar i daljes duhet të bllokohet nga kamera e DALJES.",
|
||||||
|
lockLaneEntry: "Kamera e hyrjes",
|
||||||
|
lockLaneExit: "Kamera e daljes",
|
||||||
|
blinkOnMs: "Pulsim ndezur (ms)",
|
||||||
|
blinkOffMs: "Pulsim fikur (ms)",
|
||||||
addRelay: "+ Shto rele",
|
addRelay: "+ Shto rele",
|
||||||
// Camera ANPR opt-in.
|
// Camera ANPR opt-in.
|
||||||
anpr: "Njohja e targave (ANPR)",
|
anpr: "Njohja e targave (ANPR)",
|
||||||
anprHint:
|
anprHint:
|
||||||
"Aktivizo që ky aparat të skanojë targat: shërbimi i vizionit lexon targën nga pamja dhe e dërgon si lexim (vetëm këshillues — nuk hap vetë barrierën). Kërkon shërbimin e vizionit aktiv.",
|
"Lexo targat në këtë aparat: shërbimi i vizionit lexon targën nga çdo pamje dhe e regjistron (hyrje dhe dalje). Kërkon shërbimin e vizionit aktiv.",
|
||||||
|
anprAuto: "Hapje/mbyllje automatike me targën e abonentit",
|
||||||
|
anprAutoHint:
|
||||||
|
"Lejo që KY aparat të hapë vetë barrierën kur njeh targën e një abonenti. ÇAKTIVIZOJE te aparati i daljes në një korsi të përbashkët hyrje/dalje, që një makinë që HYN të mos DALË automatikisht nga targa e pasme (njohja vazhdon — fiket vetëm hapja automatike).",
|
||||||
testAnpr: "Testo ANPR",
|
testAnpr: "Testo ANPR",
|
||||||
anprTesting: "Duke testuar ANPR…",
|
anprTesting: "Duke testuar ANPR…",
|
||||||
testAnprHint:
|
testAnprHint:
|
||||||
@@ -391,12 +473,36 @@ export const sq = {
|
|||||||
"anprFail.vision-disabled": "Shërbimi i vizionit është çaktivizuar — aktivizoje (VISION_ENABLED) për ta testuar ANPR.",
|
"anprFail.vision-disabled": "Shërbimi i vizionit është çaktivizuar — aktivizoje (VISION_ENABLED) për ta testuar ANPR.",
|
||||||
"anprFail.snapshot-failed": "Nuk u mor dot pamje nga kamera (jashtë linje ose e paarritshme).",
|
"anprFail.snapshot-failed": "Nuk u mor dot pamje nga kamera (jashtë linje ose e paarritshme).",
|
||||||
"anprFail.no-plate": "Nuk u gjet asnjë targë në pamje.",
|
"anprFail.no-plate": "Nuk u gjet asnjë targë në pamje.",
|
||||||
|
// Printer test slip — pushes a real slip so the admin can confirm it physically prints.
|
||||||
|
testPrint: "Printo provë",
|
||||||
|
printTesting: "Duke printuar…",
|
||||||
|
testPrintHint:
|
||||||
|
"Dërgon një fletë prove te printeri tani. ‘I lidhur’ vetëm hap lidhjen — kjo konfirmon se printeri vërtet nxjerr letër.",
|
||||||
|
printOk: "✓ Fleta e provës u dërgua ({{ms}} ms). Kontrollo printerin.",
|
||||||
|
"printFail.print-failed": "Printeri nuk pranoi punën (pa letër, kapaku hapur, ose lidhja ra).",
|
||||||
|
// Reveal/hide toggle for a secret field (e.g. the device web password).
|
||||||
|
revealSecret: "Shfaq fjalëkalimin",
|
||||||
|
hideSecret: "Fshih fjalëkalimin",
|
||||||
|
// Alarm Server push settings — generated for the camera's Event → Alarm Server form.
|
||||||
|
alarmUrlTitle: "Cilësimet e Alarm Server (vendosi te kamera)",
|
||||||
|
alarmUrlHint:
|
||||||
|
"Vendosi këto te kamera: Configuration → Event → … → Alarm Settings (ose Notify Surveillance Center). Kamera do të dërgojë çdo ngjarje këtu — pa polling.",
|
||||||
|
alarmUrlCopy: "Kopjo të gjitha",
|
||||||
|
alarmUrlCopied: "U kopjua ✓",
|
||||||
|
alarmUrlSaveFirst:
|
||||||
|
"Ruaje kamerën më parë — adresa gjenerohet pasi pajisja të marrë një ID. Hape sërish për editim që ta shohësh.",
|
||||||
|
alarmUrlTestFirst:
|
||||||
|
"Kliko “Testo lidhjen” më parë — kështu përcaktohet IP-ja e këtij hosti në rrjetin e kamerës (që kamera ta thërrasë).",
|
||||||
|
alarmFieldHost: "Destination IP / Host",
|
||||||
|
alarmFieldUrl: "URL",
|
||||||
|
alarmFieldProtocol: "Protokolli",
|
||||||
|
alarmFieldPort: "Porta",
|
||||||
// Binding picker.
|
// Binding picker.
|
||||||
whichBarrier: "Cilën barrierë shërben kjo pajisje?",
|
whichBarrier: "Cilën barrierë shërben kjo pajisje?",
|
||||||
controller: "Kontrolluesi",
|
controller: "Kontrolleri",
|
||||||
choose: "Zgjidh…",
|
choose: "Zgjidh…",
|
||||||
relayLabel: "Rele {{relay}} ({{direction}})",
|
relayLabel: "Rele {{relay}} ({{direction}})",
|
||||||
noRelaysConfigured: "Ky kontrollues nuk ka rele të konfiguruar.",
|
noRelaysConfigured: "Ky kontroller nuk ka rele të konfiguruar.",
|
||||||
},
|
},
|
||||||
lab: {
|
lab: {
|
||||||
title: "Lab Tarife",
|
title: "Lab Tarife",
|
||||||
@@ -572,7 +678,7 @@ export const sq = {
|
|||||||
save: "Ruaj",
|
save: "Ruaj",
|
||||||
saved: "U ruajt.",
|
saved: "U ruajt.",
|
||||||
fieldParkName: "Emri i parkimit",
|
fieldParkName: "Emri i parkimit",
|
||||||
fieldParkNamePh: "p.sh. Acme Parking",
|
fieldParkNamePh: "p.sh. Parking Aeroport",
|
||||||
fieldOperator: "Operatori (emri ligjor)",
|
fieldOperator: "Operatori (emri ligjor)",
|
||||||
fieldOperatorPh: "kompania operuese",
|
fieldOperatorPh: "kompania operuese",
|
||||||
fieldNius: "NIUS",
|
fieldNius: "NIUS",
|
||||||
@@ -629,7 +735,7 @@ export const sq = {
|
|||||||
starting: "Duke filluar…",
|
starting: "Duke filluar…",
|
||||||
endShift: "Mbyll turnin",
|
endShift: "Mbyll turnin",
|
||||||
ending: "Duke mbyllur…",
|
ending: "Duke mbyllur…",
|
||||||
endConfirm: "Të mbyllet ky turn? Regjistrohet dhe printohet një Raport Z i nënshkruar.",
|
endConfirm: "",
|
||||||
drawer: "Arka:",
|
drawer: "Arka:",
|
||||||
openingFloatInherited: "(bilanci fillestar i trashëguar nga turni i mëparshëm)",
|
openingFloatInherited: "(bilanci fillestar i trashëguar nga turni i mëparshëm)",
|
||||||
drawerCashAdmin: "Para në arkë (admin) — shto ose hiq bilancin",
|
drawerCashAdmin: "Para në arkë (admin) — shto ose hiq bilancin",
|
||||||
@@ -657,13 +763,12 @@ export const sq = {
|
|||||||
card: "Kartë:",
|
card: "Kartë:",
|
||||||
srcTickets: "Bileta:",
|
srcTickets: "Bileta:",
|
||||||
srcSubscriptions: "Abonime:",
|
srcSubscriptions: "Abonime:",
|
||||||
srcSubSales: "shitje",
|
|
||||||
srcSubWindow: "jashtë orarit",
|
srcSubWindow: "jashtë orarit",
|
||||||
drawerSection: "— Arka —",
|
drawerSection: "— Arka —",
|
||||||
openingFloat: "Bilanci fillestar:",
|
openingFloat: "Arka fillestare:",
|
||||||
cashTaken: "Para të marra:",
|
cashTaken: "Xhiro ditore:",
|
||||||
cashAdded: "Para të shtuara:",
|
cashAdded: "Arkëtime:",
|
||||||
cashRemoved: "Para të hequra:",
|
cashRemoved: "Pagesa:",
|
||||||
expectedDrawer: "Gjëndje Arke:",
|
expectedDrawer: "Gjëndje Arke:",
|
||||||
printedToReceipt: "Printuar te printeri i kabinës.",
|
printedToReceipt: "Printuar te printeri i kabinës.",
|
||||||
recordedNoPrinter: "Regjistruar (pa printer për të printuar).",
|
recordedNoPrinter: "Regjistruar (pa printer për të printuar).",
|
||||||
@@ -694,7 +799,6 @@ export const sq = {
|
|||||||
card: "Kartë",
|
card: "Kartë",
|
||||||
srcTickets: "Bileta",
|
srcTickets: "Bileta",
|
||||||
srcSubscriptions: "Abonime",
|
srcSubscriptions: "Abonime",
|
||||||
srcSubSales: "shitje abonimesh",
|
|
||||||
srcSubWindow: "jashtë orarit",
|
srcSubWindow: "jashtë orarit",
|
||||||
expectedDrawer: "Gjëndje arke",
|
expectedDrawer: "Gjëndje arke",
|
||||||
// Filter (admin only).
|
// Filter (admin only).
|
||||||
@@ -714,10 +818,10 @@ export const sq = {
|
|||||||
current: "aktual",
|
current: "aktual",
|
||||||
// Expanded drawer detail.
|
// Expanded drawer detail.
|
||||||
drawerSection: "Arka",
|
drawerSection: "Arka",
|
||||||
openingFloat: "Bilanci fillestar",
|
openingFloat: "Arka fillestare",
|
||||||
cashTaken: "Para të marra",
|
cashTaken: "Xhiro ditore",
|
||||||
cashAdded: "Para të shtuara",
|
cashAdded: "Arkëtime",
|
||||||
cashRemoved: "Para të hequra",
|
cashRemoved: "Pagesa",
|
||||||
loadFailed: "Ngarkimi i turneve dështoi.",
|
loadFailed: "Ngarkimi i turneve dështoi.",
|
||||||
},
|
},
|
||||||
reports: {
|
reports: {
|
||||||
@@ -790,24 +894,68 @@ export const sq = {
|
|||||||
path: "Rruga",
|
path: "Rruga",
|
||||||
empty: "Asnjë regjistër.",
|
empty: "Asnjë regjistër.",
|
||||||
},
|
},
|
||||||
|
backup: {
|
||||||
|
title: "Kopje rezervë",
|
||||||
|
intro:
|
||||||
|
"Kopje e enkriptuar e bazës së të dhënave (regjistri i nënshkruar) në një disk të jashtëm. Bëhet automatikisht çdo ditë dhe me butonin më poshtë.",
|
||||||
|
statusTitle: "Gjendja",
|
||||||
|
configured: "Aktive",
|
||||||
|
notConfigured: "E pakonfiguruar",
|
||||||
|
notConfiguredHint:
|
||||||
|
"Cakto BACKUP_TARGET_DIR dhe BACKUP_KEY në server që të aktivizohet kopja rezervë.",
|
||||||
|
running: "Duke u kryer…",
|
||||||
|
idle: "Në pritje",
|
||||||
|
lastSuccess: "Kopja e fundit e suksesshme",
|
||||||
|
lastError: "Gabimi i fundit",
|
||||||
|
never: "Asnjëherë",
|
||||||
|
lastFile: "Skedari",
|
||||||
|
size: "Madhësia",
|
||||||
|
pruned: "Të hequra",
|
||||||
|
runNow: "Bëj kopje tani",
|
||||||
|
runSuccess: "Kopja rezervë u krye.",
|
||||||
|
runFailed: "Kopja rezervë dështoi.",
|
||||||
|
notConfiguredError: "Kopja rezervë nuk është e konfiguruar.",
|
||||||
|
restoreNote:
|
||||||
|
"Rikthimi nuk bëhet nga këtu — është veprim i jashtëm gjatë instalimit të një aparati të ri (kërkon skedarin e kopjes + çelësat e ruajtur jashtë).",
|
||||||
|
targetLabel: "Vendndodhja e kopjes",
|
||||||
|
targetPlaceholder: "p.sh. /mnt/backup ose /media/usb",
|
||||||
|
targetHint: "Rrugë absolute drejt një disku të lidhur (USB/SATA) ose një ndarjeje rrjeti (SMB/NFS).",
|
||||||
|
save: "Ruaj",
|
||||||
|
saved: "U ruajt.",
|
||||||
|
test: "Testo vendndodhjen",
|
||||||
|
testOk: "Vendndodhja është e shkruajtshme.",
|
||||||
|
testEmpty: "Shkruaj një rrugë.",
|
||||||
|
testMissing: "Vendndodhja nuk ekziston.",
|
||||||
|
testNotDir: "Rruga nuk është një dosje.",
|
||||||
|
testNotWritable: "Dosja nuk është e shkruajtshme.",
|
||||||
|
keyMissing: "Çelësi i enkriptimit (BACKUP_KEY) mungon në server — caktoje që kopja të aktivizohet.",
|
||||||
|
keepLastLabel: "Mbaj kopjet e fundit",
|
||||||
|
keepLastHint: "Numri i kopjeve më të reja që mbahen gjithmonë.",
|
||||||
|
keepDailyLabel: "Mbaj ditore (ditë)",
|
||||||
|
keepDailyHint: "Përtej atyre, mbaj një kopje për ditë për kaq ditë.",
|
||||||
|
},
|
||||||
pay: {
|
pay: {
|
||||||
ticket: "Bileta",
|
ticket: "Bileta",
|
||||||
entry: "Hyrja",
|
entry: "Hyrja",
|
||||||
now: "Tani",
|
now: "Tani",
|
||||||
|
exit: "Dalja",
|
||||||
duration: "Kohëzgjatja",
|
duration: "Kohëzgjatja",
|
||||||
statusLabel: "Statusi",
|
statusLabel: "Statusi",
|
||||||
paid: "PAGUAR",
|
paid: "PAGUAR",
|
||||||
unpaid: "PAPAGUAR",
|
unpaid: "PAPAGUAR",
|
||||||
overstay: "TEJ AFATIT",
|
overstay: "TEJ AFATIT",
|
||||||
overstayHint: "Sesion i mëparshëm i paguar. Klienti nuk doli brënda afatit kohor. Kërkohet pagesë për periudhën e re. Totali më poshtë është tarifa e periudhës së re.",
|
overstayHint: "Sesion i mëparshëm i paguar. Klienti nuk doli brënda afatit kohor. Kërkohet pagesë për periudhën e re. Totali më poshtë është tarifa e periudhës së re.",
|
||||||
|
closedWithinGrace: "Paguar",
|
||||||
|
closedWithinGraceHint: "Pagesa dhe dalja u regjistruan — barriera nuk u konfirmua ende. Makina mbetet në listë derisa të mbarojë afati. Hapni barrierën manualisht nëse pret ende.",
|
||||||
topUp: "Periudha e re për pagesë",
|
topUp: "Periudha e re për pagesë",
|
||||||
total: "Totali",
|
total: "Totali",
|
||||||
|
paidAmount: "Paguar",
|
||||||
noTariff: "pa tarifë",
|
noTariff: "pa tarifë",
|
||||||
tender: "Mënyra",
|
tender: "Mënyra",
|
||||||
cash: "Para",
|
cash: "Para",
|
||||||
card: "Kartë",
|
card: "Kartë",
|
||||||
printExitVoucher: "Printo biletë dalje",
|
printExitVoucher: "Printo biletë dalje",
|
||||||
selfExitHint: "(klienti del vetë te dalja)",
|
selfExitHint: "(klienti del duke skanuar biletën)",
|
||||||
payAndOpen: "Paguaj + hap barrierën",
|
payAndOpen: "Paguaj + hap barrierën",
|
||||||
payAndVoucher: "Paguaj + printo biletën",
|
payAndVoucher: "Paguaj + printo biletën",
|
||||||
openBarrier: "Hap barrierën",
|
openBarrier: "Hap barrierën",
|
||||||
@@ -820,6 +968,10 @@ export const sq = {
|
|||||||
lookingUp: "Duke kërkuar…",
|
lookingUp: "Duke kërkuar…",
|
||||||
paidBarrierOpened: "Paguar — barriera u hap. Automjeti mund të dalë.",
|
paidBarrierOpened: "Paguar — barriera u hap. Automjeti mund të dalë.",
|
||||||
paidExitRecorded: "Paguar dhe dalja u regjistrua, por barriera nuk u hap: {{reason}}.",
|
paidExitRecorded: "Paguar dhe dalja u regjistrua, por barriera nuk u hap: {{reason}}.",
|
||||||
|
swapTitle: "Mundësi ndërrimi biletash",
|
||||||
|
swapBody: "Targa {{plate}} është tashmë brenda me biletën {{other}} (hyri {{when}}). Ky automjet mund të jetë duke dalë me një biletë tjetër nga ajo me të cilën hyri.",
|
||||||
|
swapHint: "Verifiko automjetin para se ta lëshosh. Anashkalimi regjistrohet në emrin tënd.",
|
||||||
|
swapOverride: "Anashkalo & lësho",
|
||||||
subscription: "ABONIM",
|
subscription: "ABONIM",
|
||||||
plan: "Plani",
|
plan: "Plani",
|
||||||
prepaid: "I PARAPAGUAR",
|
prepaid: "I PARAPAGUAR",
|
||||||
@@ -830,7 +982,7 @@ export const sq = {
|
|||||||
windowCharge: "JASHTË ORARIT",
|
windowCharge: "JASHTË ORARIT",
|
||||||
windowChargeHint: "Ky abonent parkoi jashtë orarit të lejuar të planit. Detyrohet të paguajë tarifën kalimtare për kohën jashtë orarit — merr pagesën, pastaj hap barrierën.",
|
windowChargeHint: "Ky abonent parkoi jashtë orarit të lejuar të planit. Detyrohet të paguajë tarifën kalimtare për kohën jashtë orarit — merr pagesën, pastaj hap barrierën.",
|
||||||
subBarrierOpened: "Barriera u hap për abonentin (ndërhyrje e regjistruar).",
|
subBarrierOpened: "Barriera u hap për abonentin (ndërhyrje e regjistruar).",
|
||||||
voucherPrinted: "Bileta e daljes u printua në {{printer}}. Klienti del vetë te dalja.",
|
voucherPrinted: "Bileta e daljes u printua në {{printer}}. Klienti del duke skanuar biletën.",
|
||||||
// payment receipt (transparency slip)
|
// payment receipt (transparency slip)
|
||||||
receiptPrintFailed: "(fatura nuk u printua — provoni \"Riprinto faturën\".)",
|
receiptPrintFailed: "(fatura nuk u printua — provoni \"Riprinto faturën\".)",
|
||||||
receiptReprinted: "Fatura u riprintua në {{printer}}.",
|
receiptReprinted: "Fatura u riprintua në {{printer}}.",
|
||||||
|
|||||||
@@ -16,6 +16,14 @@ export interface LaneStatus {
|
|||||||
exit: boolean; // true = busy
|
exit: boolean; // true = busy
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Per-lane RADAR presence — a presence input (loop/radar) is shorted at the barrier,
|
||||||
|
* i.e. "something is in the lane" BEFORE the camera confirms a vehicle. Drives the
|
||||||
|
* barrier light's BLINK (the same signal as the physical button lamp / relay 3). */
|
||||||
|
export interface LanePresence {
|
||||||
|
entry: boolean; // true = a radar/presence input on an entry barrier is active
|
||||||
|
exit: boolean; // true = … on an exit barrier
|
||||||
|
}
|
||||||
|
|
||||||
/** Cap the in-memory live feed so a long-running booth session can't grow it
|
/** Cap the in-memory live feed so a long-running booth session can't grow it
|
||||||
* unbounded — the full history is always available via the /api/events query. */
|
* unbounded — the full history is always available via the /api/events query. */
|
||||||
const MAX_FEED = 200;
|
const MAX_FEED = 200;
|
||||||
@@ -31,6 +39,8 @@ interface LiveState {
|
|||||||
devices: Record<string, DeviceStatus>;
|
devices: Record<string, DeviceStatus>;
|
||||||
/** Per-lane busy/free (camera vehicle detection). Null until the first WS hello. */
|
/** Per-lane busy/free (camera vehicle detection). Null until the first WS hello. */
|
||||||
lanes: LaneStatus | null;
|
lanes: LaneStatus | null;
|
||||||
|
/** Per-lane radar presence (advisory blink). Null until the first WS hello. */
|
||||||
|
radar: LanePresence | null;
|
||||||
setStatus: (s: WsStatus) => void;
|
setStatus: (s: WsStatus) => void;
|
||||||
setOccupancy: (o: Occupancy) => void;
|
setOccupancy: (o: Occupancy) => void;
|
||||||
pushEvent: (e: LedgerEvent) => void;
|
pushEvent: (e: LedgerEvent) => void;
|
||||||
@@ -40,6 +50,11 @@ interface LiveState {
|
|||||||
upsertDevice: (d: DeviceStatus) => void;
|
upsertDevice: (d: DeviceStatus) => void;
|
||||||
/** Set lane busy/free (WS hello + each lane-status push). */
|
/** Set lane busy/free (WS hello + each lane-status push). */
|
||||||
setLanes: (l: LaneStatus) => void;
|
setLanes: (l: LaneStatus) => void;
|
||||||
|
/** Set lane radar presence (WS hello + each lane-presence push). */
|
||||||
|
setRadar: (r: LanePresence) => void;
|
||||||
|
/** Backfill the enriched plate on every feed event matching `identity` (a late async
|
||||||
|
* recognition that landed after the event's own push). No-op if no row matches. */
|
||||||
|
patchPlate: (identity: string, plate: string) => void;
|
||||||
reset: () => void;
|
reset: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,6 +71,7 @@ export const useLiveStore = create<LiveState>((set) => ({
|
|||||||
feed: [],
|
feed: [],
|
||||||
devices: {},
|
devices: {},
|
||||||
lanes: null,
|
lanes: null,
|
||||||
|
radar: null,
|
||||||
setStatus: (status) => set({ status }),
|
setStatus: (status) => set({ status }),
|
||||||
setOccupancy: (occupancy) => set({ occupancy }),
|
setOccupancy: (occupancy) => set({ occupancy }),
|
||||||
pushEvent: (e) =>
|
pushEvent: (e) =>
|
||||||
@@ -66,5 +82,11 @@ export const useLiveStore = create<LiveState>((set) => ({
|
|||||||
setDevices: (list) => set({ devices: byId(list) }),
|
setDevices: (list) => set({ devices: byId(list) }),
|
||||||
upsertDevice: (d) => set((s) => ({ devices: { ...s.devices, [d.deviceId]: d } })),
|
upsertDevice: (d) => set((s) => ({ devices: { ...s.devices, [d.deviceId]: d } })),
|
||||||
setLanes: (lanes) => set({ lanes }),
|
setLanes: (lanes) => set({ lanes }),
|
||||||
reset: () => set({ status: "connecting", occupancy: null, feed: [], devices: {}, lanes: null }),
|
setRadar: (radar) => set({ radar }),
|
||||||
|
patchPlate: (identity, plate) =>
|
||||||
|
set((s) => {
|
||||||
|
if (!s.feed.some((e) => e.identity === identity && !e.plate)) return s; // nothing to fill
|
||||||
|
return { feed: s.feed.map((e) => (e.identity === identity && !e.plate ? { ...e, plate } : e)) };
|
||||||
|
}),
|
||||||
|
reset: () => set({ status: "connecting", occupancy: null, feed: [], devices: {}, lanes: null, radar: null }),
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -1,14 +1,26 @@
|
|||||||
import type { Theme } from "../api.js";
|
import type { Theme } from "../api.js";
|
||||||
|
import { FONT_SCALE_MAX, FONT_SCALE_MIN } from "../api.js";
|
||||||
|
|
||||||
// Theme application. The whole UI reads colour through the --color-term-* tokens;
|
// Theme + font-scale application. The whole UI reads colour through the --color-term-*
|
||||||
// the light palette lives in index.css under `html.theme-light`. Applying a theme is
|
// tokens; the light palette lives in index.css under `html.theme-light`. Applying a theme
|
||||||
// just toggling that class on <html>. The active theme is the LOGGED-IN USER's stored
|
// is just toggling that class on <html>. Both are the LOGGED-IN USER's stored preferences
|
||||||
// preference (users.theme), applied via applyTheme() after auth resolves — mirroring
|
// (users.theme / users.font_scale), applied after auth resolves — mirroring how language
|
||||||
// how language works. Dark is the default before auth resolves. Printed tickets are
|
// works. Defaults (dark, 100%) apply before auth resolves. Printed tickets are unaffected.
|
||||||
// unaffected (always Albanian, dark-agnostic).
|
|
||||||
|
|
||||||
/** Apply a theme by toggling `theme-light` on <html>. Dark is the absence of the
|
/** Apply a theme by toggling `theme-light` on <html>. Dark is the absence of the
|
||||||
* class (the base tokens). No-op-safe to call repeatedly. */
|
* class (the base tokens). No-op-safe to call repeatedly. */
|
||||||
export function applyTheme(theme: Theme): void {
|
export function applyTheme(theme: Theme): void {
|
||||||
document.documentElement.classList.toggle("theme-light", theme === "light");
|
document.documentElement.classList.toggle("theme-light", theme === "light");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Apply a font scale by setting the ROOT font-size (percent). The app's text is sized in
|
||||||
|
* rem (the `text-[…rem]` utilities + the .label/.input/.hint/.btn component classes all
|
||||||
|
* derive from the root), so only TEXT scales — viewport-locked layout (h-screen frame,
|
||||||
|
* max-h-[90vh] modals, vh units) is unaffected, so headers/footers never clip; taller
|
||||||
|
* content just scrolls its own container. NOT `zoom` (which scaled those vh boxes too and
|
||||||
|
* pushed modal chrome out of view). Clamped to the band; no-op-safe to call repeatedly. */
|
||||||
|
export function applyFontScale(pct: number): void {
|
||||||
|
const clamped = Math.min(FONT_SCALE_MAX, Math.max(FONT_SCALE_MIN, Math.round(pct)));
|
||||||
|
// 100% = the browser's 16px root. The app's rem units scale off this.
|
||||||
|
document.documentElement.style.fontSize = clamped === 100 ? "" : `${clamped}%`;
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useEffect, useRef } from "react";
|
|||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import type { DeviceStatus, LedgerEvent, Occupancy } from "../api.js";
|
import type { DeviceStatus, LedgerEvent, Occupancy } from "../api.js";
|
||||||
import { qk } from "./query.js";
|
import { qk } from "./query.js";
|
||||||
import { useLiveStore, type LaneStatus } from "./live-store.js";
|
import { useLiveStore, type LaneStatus, type LanePresence } from "./live-store.js";
|
||||||
import { wsUrl } from "./origin.js";
|
import { wsUrl } from "./origin.js";
|
||||||
|
|
||||||
// Booth WebSocket client. Opens ONE socket to /api/ws and turns server pushes into
|
// Booth WebSocket client. Opens ONE socket to /api/ws and turns server pushes into
|
||||||
@@ -14,16 +14,19 @@ import { wsUrl } from "./origin.js";
|
|||||||
|
|
||||||
/** Server → client message shapes (mirror routes/ws.ts OutMsg). */
|
/** Server → client message shapes (mirror routes/ws.ts OutMsg). */
|
||||||
type WsMessage =
|
type WsMessage =
|
||||||
| { kind: "hello"; occupancy: Occupancy; devices: DeviceStatus[]; lanes: LaneStatus }
|
| { kind: "hello"; occupancy: Occupancy; devices: DeviceStatus[]; lanes: LaneStatus; radar: LanePresence }
|
||||||
| { kind: "ledger"; event: LedgerEvent; occupancy: Occupancy }
|
| { kind: "ledger"; event: LedgerEvent; occupancy: Occupancy }
|
||||||
| { kind: "printer-status"; event: unknown }
|
| { kind: "printer-status"; event: unknown }
|
||||||
| { kind: "device-status"; event: DeviceStatus }
|
| { kind: "device-status"; event: DeviceStatus }
|
||||||
| { kind: "lane-status"; lanes: LaneStatus };
|
| { kind: "lane-status"; lanes: LaneStatus }
|
||||||
|
| { kind: "lane-presence"; radar: LanePresence }
|
||||||
|
| { kind: "plate-recognized"; plate: { identity: string; plate: string; direction: "entry" | "exit" } };
|
||||||
|
|
||||||
|
|
||||||
export function useLiveFeed(): void {
|
export function useLiveFeed(): void {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const { setStatus, setOccupancy, pushEvent, setDevices, upsertDevice, setLanes } = useLiveStore();
|
const { setStatus, setOccupancy, pushEvent, setDevices, upsertDevice, setLanes, setRadar, patchPlate } =
|
||||||
|
useLiveStore();
|
||||||
// Hold the socket + reconnect timer across renders; guard against StrictMode
|
// Hold the socket + reconnect timer across renders; guard against StrictMode
|
||||||
// double-invoke and unmount.
|
// double-invoke and unmount.
|
||||||
const sockRef = useRef<WebSocket | null>(null);
|
const sockRef = useRef<WebSocket | null>(null);
|
||||||
@@ -56,10 +59,18 @@ export function useLiveFeed(): void {
|
|||||||
// Initial device-status snapshot for the footer.
|
// Initial device-status snapshot for the footer.
|
||||||
if (Array.isArray(msg.devices)) setDevices(msg.devices);
|
if (Array.isArray(msg.devices)) setDevices(msg.devices);
|
||||||
if (msg.lanes) setLanes(msg.lanes);
|
if (msg.lanes) setLanes(msg.lanes);
|
||||||
|
if (msg.radar) setRadar(msg.radar);
|
||||||
} else if (msg.kind === "device-status") {
|
} else if (msg.kind === "device-status") {
|
||||||
upsertDevice(msg.event);
|
upsertDevice(msg.event);
|
||||||
} else if (msg.kind === "lane-status") {
|
} else if (msg.kind === "lane-status") {
|
||||||
setLanes(msg.lanes);
|
setLanes(msg.lanes);
|
||||||
|
} else if (msg.kind === "lane-presence") {
|
||||||
|
setRadar(msg.radar);
|
||||||
|
} else if (msg.kind === "plate-recognized") {
|
||||||
|
// Backfill the badge on the already-rendered feed row, and refetch the
|
||||||
|
// Query-owned active-sessions list (re-runs enrichEvents → the now-written plate).
|
||||||
|
patchPlate(msg.plate.identity, msg.plate.plate);
|
||||||
|
void qc.invalidateQueries({ queryKey: qk.activeSessions });
|
||||||
} else if (msg.kind === "ledger") {
|
} else if (msg.kind === "ledger") {
|
||||||
setOccupancy(msg.occupancy);
|
setOccupancy(msg.occupancy);
|
||||||
pushEvent(msg.event);
|
pushEvent(msg.event);
|
||||||
|
|||||||
+110
-22
@@ -10,11 +10,23 @@ import { lazy, Suspense, useState } from "react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import type { Lang, Permission, SessionUser, Theme } from "./api.js";
|
import type { Lang, Permission, SessionUser, Theme } from "./api.js";
|
||||||
import { can, closeShift, fetchShiftReport, logout, openShift, setLanguagePref, setThemePref } from "./api.js";
|
import {
|
||||||
|
can,
|
||||||
|
closeShift,
|
||||||
|
fetchShiftReport,
|
||||||
|
logout,
|
||||||
|
openShift,
|
||||||
|
setLanguagePref,
|
||||||
|
setThemePref,
|
||||||
|
setFontScalePref,
|
||||||
|
FONT_SCALE_MIN,
|
||||||
|
FONT_SCALE_MAX,
|
||||||
|
FONT_SCALE_STEP,
|
||||||
|
} from "./api.js";
|
||||||
import { qk, queryClient } from "./lib/query.js";
|
import { qk, queryClient } from "./lib/query.js";
|
||||||
import { Modal } from "./ui/Modal.js";
|
import { Modal } from "./ui/Modal.js";
|
||||||
import { setLanguage } from "./lib/i18n/index.js";
|
import { setLanguage } from "./lib/i18n/index.js";
|
||||||
import { applyTheme } from "./lib/theme.js";
|
import { applyTheme, applyFontScale } from "./lib/theme.js";
|
||||||
import { useLiveFeed } from "./lib/use-live-feed.js";
|
import { useLiveFeed } from "./lib/use-live-feed.js";
|
||||||
import { useShift } from "./lib/use-shift.js";
|
import { useShift } from "./lib/use-shift.js";
|
||||||
import { DeviceFooter } from "./ui/DeviceFooter.js";
|
import { DeviceFooter } from "./ui/DeviceFooter.js";
|
||||||
@@ -29,7 +41,10 @@ import { SiteSettings } from "./SiteSettings.js";
|
|||||||
import { UsersManager } from "./UsersManager.js";
|
import { UsersManager } from "./UsersManager.js";
|
||||||
import { RolesManager } from "./RolesManager.js";
|
import { RolesManager } from "./RolesManager.js";
|
||||||
import { ShiftsHistory } from "./ShiftsHistory.js";
|
import { ShiftsHistory } from "./ShiftsHistory.js";
|
||||||
|
import { DrawerManager } from "./DrawerManager.js";
|
||||||
|
import { CARD_PAYMENTS_ENABLED } from "./lib/features.js";
|
||||||
import { LogsViewer } from "./LogsViewer.js";
|
import { LogsViewer } from "./LogsViewer.js";
|
||||||
|
import { BackupSettings } from "./BackupSettings.js";
|
||||||
import { RecycleBin } from "./RecycleBin.js";
|
import { RecycleBin } from "./RecycleBin.js";
|
||||||
import { Profile } from "./Profile.js";
|
import { Profile } from "./Profile.js";
|
||||||
// Reports pulls in Recharts (~heavy) — lazy-loaded so it stays OUT of the booth's
|
// Reports pulls in Recharts (~heavy) — lazy-loaded so it stays OUT of the booth's
|
||||||
@@ -56,7 +71,7 @@ function NavLink({ to, label }: { to: string; label: string }) {
|
|||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
to={to}
|
to={to}
|
||||||
className="px-2 py-1 text-[11px] uppercase tracking-wider text-term-muted rounded-term hover:text-term-text [&.active]:text-term-amber [&.active]:bg-term-panel-2"
|
className="px-2 py-1 text-[0.6875rem] uppercase tracking-wider text-term-muted rounded-term hover:text-term-text [&.active]:text-term-amber [&.active]:bg-term-panel-2"
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
</Link>
|
</Link>
|
||||||
@@ -70,7 +85,7 @@ function SetupTab({ to, label, exact = false }: { to: string; label: string; exa
|
|||||||
<Link
|
<Link
|
||||||
to={to}
|
to={to}
|
||||||
activeOptions={{ exact }}
|
activeOptions={{ exact }}
|
||||||
className="border-b-2 border-transparent px-3 py-2 text-[12px] uppercase tracking-wider text-term-muted hover:text-term-text [&.active]:border-term-amber [&.active]:text-term-amber"
|
className="border-b-2 border-transparent px-3 py-2 text-[0.75rem] uppercase tracking-wider text-term-muted hover:text-term-text [&.active]:border-term-amber [&.active]:text-term-amber"
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
</Link>
|
</Link>
|
||||||
@@ -94,6 +109,7 @@ function SetupLayout() {
|
|||||||
{show("role:read") && <SetupTab to="/setup/roles" label={t("nav.roles")} />}
|
{show("role:read") && <SetupTab to="/setup/roles" label={t("nav.roles")} />}
|
||||||
{show("recyclebin:read") && <SetupTab to="/setup/recycle-bin" label={t("nav.recycleBin")} />}
|
{show("recyclebin:read") && <SetupTab to="/setup/recycle-bin" label={t("nav.recycleBin")} />}
|
||||||
{show("log:read") && <SetupTab to="/setup/logs" label={t("nav.logs")} />}
|
{show("log:read") && <SetupTab to="/setup/logs" label={t("nav.logs")} />}
|
||||||
|
{show("backup:read") && <SetupTab to="/setup/backup" label={t("nav.backup")} />}
|
||||||
</nav>
|
</nav>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</div>
|
</div>
|
||||||
@@ -147,7 +163,7 @@ function LanguageToggle({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-0.5 text-[10px] uppercase tracking-wider">
|
<div className="flex items-center gap-0.5 text-[0.625rem] uppercase tracking-wider">
|
||||||
{(["sq", "en"] as const).map((l) => (
|
{(["sq", "en"] as const).map((l) => (
|
||||||
<button
|
<button
|
||||||
key={l}
|
key={l}
|
||||||
@@ -193,7 +209,7 @@ function ThemeToggle({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-0.5 text-[10px] uppercase tracking-wider">
|
<div className="flex items-center gap-0.5 text-[0.625rem] uppercase tracking-wider">
|
||||||
{(["dark", "light"] as const).map((th) => (
|
{(["dark", "light"] as const).map((th) => (
|
||||||
<button
|
<button
|
||||||
key={th}
|
key={th}
|
||||||
@@ -210,6 +226,37 @@ function ThemeToggle({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Header font-size control: A−/value/A+ scaling the whole UI (root font-size). Persisted
|
||||||
|
* to the user profile like the theme, restored on next login. Local `active` state seeded
|
||||||
|
* from the prop (the router context doesn't re-render on setUser); App's effect keeps the
|
||||||
|
* DOM in sync with the persisted user on (re)login. */
|
||||||
|
function FontScaleToggle({ user, setUser }: { user: SessionUser; setUser: (u: SessionUser | null) => void }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [active, setActive] = useState<number>(user.fontScale);
|
||||||
|
function step(delta: number) {
|
||||||
|
const next = Math.min(FONT_SCALE_MAX, Math.max(FONT_SCALE_MIN, active + delta));
|
||||||
|
if (next === active) return;
|
||||||
|
setActive(next);
|
||||||
|
applyFontScale(next); // instant UI
|
||||||
|
setUser({ ...user, fontScale: next });
|
||||||
|
void setFontScalePref(next).catch(() => {
|
||||||
|
/* non-fatal — the choice still applies this session */
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const btn = "rounded-term px-1.5 py-0.5 text-term-muted hover:text-term-text disabled:opacity-40";
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-0.5 text-[0.625rem] uppercase tracking-wider">
|
||||||
|
<button type="button" className={btn} onClick={() => step(-FONT_SCALE_STEP)} disabled={active <= FONT_SCALE_MIN} title={t("common.fontSmaller")} aria-label={t("common.fontSmaller")}>
|
||||||
|
A−
|
||||||
|
</button>
|
||||||
|
<span className="min-w-[2.5rem] text-center text-term-muted" title={t("common.fontSize")}>{active}%</span>
|
||||||
|
<button type="button" className={btn} onClick={() => step(FONT_SCALE_STEP)} disabled={active >= FONT_SCALE_MAX} title={t("common.fontLarger")} aria-label={t("common.fontLarger")}>
|
||||||
|
A+
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Header shift control — the site-wide single-open shift expressed as one button:
|
* Header shift control — the site-wide single-open shift expressed as one button:
|
||||||
* - no shift open → "Open shift" (enabled; opens this operator's shift)
|
* - no shift open → "Open shift" (enabled; opens this operator's shift)
|
||||||
@@ -273,14 +320,14 @@ function ShiftButton() {
|
|||||||
disabled={busy || blockedByOther}
|
disabled={busy || blockedByOther}
|
||||||
title={blockedByOther ? t("shift.headerHeldBy", { operator: heldBy ?? "?" }) : undefined}
|
title={blockedByOther ? t("shift.headerHeldBy", { operator: heldBy ?? "?" }) : undefined}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
className={`rounded-term border px-2 py-0.5 text-[11px] font-semibold uppercase tracking-wider ${tone}`}
|
className={`rounded-term border px-2 py-0.5 text-[0.6875rem] font-semibold uppercase tracking-wider ${tone}`}
|
||||||
>
|
>
|
||||||
{busy ? t("shift.opening") : label}
|
{busy ? t("shift.opening") : label}
|
||||||
</button>
|
</button>
|
||||||
{!isOpen && (
|
{!isOpen && (
|
||||||
<span className="text-[10px] uppercase tracking-wider text-term-amber">{t("shift.headerNoShift")}</span>
|
<span className="text-[0.625rem] uppercase tracking-wider text-term-amber">{t("shift.headerNoShift")}</span>
|
||||||
)}
|
)}
|
||||||
{err && <span className="text-[10px] text-term-red">{err}</span>}
|
{err && <span className="text-[0.625rem] text-term-red">{err}</span>}
|
||||||
{confirmingClose && (
|
{confirmingClose && (
|
||||||
<CloseShiftConfirm
|
<CloseShiftConfirm
|
||||||
busy={busy}
|
busy={busy}
|
||||||
@@ -315,7 +362,7 @@ function CloseShiftConfirm({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal open onClose={onCancel} title={t("shift.endShift")} width="max-w-md">
|
<Modal open onClose={onCancel} title={t("shift.endShift")} width="max-w-md">
|
||||||
<div className="text-[13px] tabular-nums">
|
<div className="text-[0.8125rem] tabular-nums">
|
||||||
<p className="text-term-muted">{t("shift.endConfirm")}</p>
|
<p className="text-term-muted">{t("shift.endConfirm")}</p>
|
||||||
{!x ? (
|
{!x ? (
|
||||||
<p className="mt-2 text-term-muted">{t("common.loading")}</p>
|
<p className="mt-2 text-term-muted">{t("common.loading")}</p>
|
||||||
@@ -327,12 +374,17 @@ function CloseShiftConfirm({
|
|||||||
{/* Split by source — the operator's ask: subscription money apart from tickets. */}
|
{/* Split by source — the operator's ask: subscription money apart from tickets. */}
|
||||||
<ConfirmFigure label={t("shift.srcTickets")} value={fmt(x.ticketTotalMinor)} />
|
<ConfirmFigure label={t("shift.srcTickets")} value={fmt(x.ticketTotalMinor)} />
|
||||||
<ConfirmFigure label={t("shift.srcSubscriptions")} value={fmt(x.subscriptionTotalMinor)} />
|
<ConfirmFigure label={t("shift.srcSubscriptions")} value={fmt(x.subscriptionTotalMinor)} />
|
||||||
<ConfirmFigure label={t("shift.srcSubSales")} value={fmt(x.subscriptionSalesMinor)} sub />
|
{/* Abonime is the subscription TOTAL (sales + out-of-window). The 'jashtë orarit'
|
||||||
|
part is broken out below it; subscription SALES is not (it's the remainder). */}
|
||||||
|
<span />
|
||||||
<ConfirmFigure label={t("shift.srcSubWindow")} value={fmt(x.subscriptionWindowMinor)} sub />
|
<ConfirmFigure label={t("shift.srcSubWindow")} value={fmt(x.subscriptionWindowMinor)} sub />
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2 grid grid-cols-2 gap-x-6 gap-y-0.5 border-t border-term-border pt-2">
|
<div className="mt-2 grid grid-cols-2 gap-x-6 gap-y-0.5 border-t border-term-border pt-2">
|
||||||
<ConfirmFigure label={t("shift.cash")} value={fmt(x.cashTotalMinor)} />
|
<ConfirmFigure label={t("shift.cash")} value={fmt(x.cashTotalMinor)} />
|
||||||
<ConfirmFigure label={t("shift.card")} value={fmt(x.cardTotalMinor)} />
|
{CARD_PAYMENTS_ENABLED && <ConfirmFigure label={t("shift.card")} value={fmt(x.cardTotalMinor)} />}
|
||||||
|
{/* Drawer math made explicit: opening float + cash taken = expected drawer. */}
|
||||||
|
<ConfirmFigure label={t("shift.openingFloat")} value={fmt(x.openingFloatMinor)} />
|
||||||
|
<span />
|
||||||
<ConfirmFigure label={t("shift.expectedDrawer")} value={fmt(x.expectedDrawerMinor)} bold />
|
<ConfirmFigure label={t("shift.expectedDrawer")} value={fmt(x.expectedDrawerMinor)} bold />
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
@@ -353,10 +405,13 @@ function CloseShiftConfirm({
|
|||||||
function ConfirmFigure({ label, value, bold, sub }: { label: string; value: string; bold?: boolean; sub?: boolean }) {
|
function ConfirmFigure({ label, value, bold, sub }: { label: string; value: string; bold?: boolean; sub?: boolean }) {
|
||||||
return (
|
return (
|
||||||
<div className={`flex items-baseline justify-between gap-2 ${sub ? "pl-3" : ""}`}>
|
<div className={`flex items-baseline justify-between gap-2 ${sub ? "pl-3" : ""}`}>
|
||||||
<span className={`text-[11px] uppercase tracking-wider ${sub ? "text-term-muted/70" : "text-term-muted"}`}>
|
<span
|
||||||
|
className={`whitespace-nowrap text-[0.6875rem] uppercase tracking-wider ${sub ? "text-term-muted/70" : "text-term-muted"}`}
|
||||||
|
>
|
||||||
{label}
|
{label}
|
||||||
</span>
|
</span>
|
||||||
<span className={bold ? "font-semibold text-term-text" : "text-term-text"}>{value}</span>
|
{/* The money/number never splits across lines (e.g. "89,650 ALL"). */}
|
||||||
|
<span className={`whitespace-nowrap ${bold ? "font-semibold text-term-text" : "text-term-text"}`}>{value}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -377,6 +432,11 @@ function RootLayout() {
|
|||||||
<nav className="flex items-center gap-1">
|
<nav className="flex items-center gap-1">
|
||||||
<NavLink to="/booth" label={t("nav.booth")} />
|
<NavLink to="/booth" label={t("nav.booth")} />
|
||||||
<NavLink to="/shifts" label={t("nav.shifts")} />
|
<NavLink to="/shifts" label={t("nav.shifts")} />
|
||||||
|
{/* Drawer — record cash movements (operator) / review them (admin). Shown if the
|
||||||
|
user can do either. See wiki/concepts/shift.md. */}
|
||||||
|
{(show("drawer:create") || show("drawer:review")) && (
|
||||||
|
<NavLink to="/drawer" label={t("nav.drawer")} />
|
||||||
|
)}
|
||||||
{/* Subscriptions — a standalone section (Abonimet / Planet / Lab tarife).
|
{/* Subscriptions — a standalone section (Abonimet / Planet / Lab tarife).
|
||||||
Shown if the user can reach ANY of its tabs. */}
|
Shown if the user can reach ANY of its tabs. */}
|
||||||
{(show("subscription:read") || show("subscription:plan") || show("tariff:read")) && (
|
{(show("subscription:read") || show("subscription:plan") || show("tariff:read")) && (
|
||||||
@@ -393,18 +453,20 @@ function RootLayout() {
|
|||||||
show("user:read") ||
|
show("user:read") ||
|
||||||
show("role:read") ||
|
show("role:read") ||
|
||||||
show("recyclebin:read") ||
|
show("recyclebin:read") ||
|
||||||
|
show("backup:read") ||
|
||||||
show("shift:read")) && <NavLink to="/setup" label={t("nav.setup")} />}
|
show("shift:read")) && <NavLink to="/setup" label={t("nav.setup")} />}
|
||||||
</nav>
|
</nav>
|
||||||
<div className="ml-auto flex items-center gap-3">
|
<div className="ml-auto flex items-center gap-3">
|
||||||
{user && <ShiftButton />}
|
{user && <ShiftButton />}
|
||||||
{user && <LanguageToggle user={user} setUser={setUser} />}
|
{user && <LanguageToggle user={user} setUser={setUser} />}
|
||||||
{user && <ThemeToggle user={user} setUser={setUser} />}
|
{user && <ThemeToggle user={user} setUser={setUser} />}
|
||||||
|
{user && <FontScaleToggle user={user} setUser={setUser} />}
|
||||||
<StatusDot />
|
<StatusDot />
|
||||||
{user && (
|
{user && (
|
||||||
<Link
|
<Link
|
||||||
to="/profile"
|
to="/profile"
|
||||||
title={t("nav.profile")}
|
title={t("nav.profile")}
|
||||||
className="text-[11px] text-term-muted hover:text-term-text [&.active]:text-term-amber"
|
className="text-[0.6875rem] text-term-muted hover:text-term-text [&.active]:text-term-amber"
|
||||||
>
|
>
|
||||||
{user.username} · {user.roleName}
|
{user.username} · {user.roleName}
|
||||||
</Link>
|
</Link>
|
||||||
@@ -493,14 +555,30 @@ const shiftRoute = createRoute({
|
|||||||
component: function ShiftRoute() {
|
component: function ShiftRoute() {
|
||||||
const { user } = rootRoute.useRouteContext();
|
const { user } = rootRoute.useRouteContext();
|
||||||
// The shift hub: list (current/open shift on top + history) + per-shift activity log.
|
// The shift hub: list (current/open shift on top + history) + per-shift activity log.
|
||||||
// The CURRENT shift's pane carries the actions (open/close, drawer voucher, takings),
|
// The CURRENT shift's pane carries the actions (open/close, takings), each opening a
|
||||||
// each opening a modal. `canManage` = shift:create (start/end + raise vouchers); a
|
// modal. `canManage` = shift:create (start/end). Drawer cash movements moved to /drawer
|
||||||
// voucher additionally needs an admin's password sign-off server-side.
|
// (2026-07-01).
|
||||||
|
return <ShiftsHistory user={user} canManage={can(user, "shift:create")} />;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const drawerRoute = createRoute({
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
path: "/drawer",
|
||||||
|
// Reachable by anyone who can record OR review; the component shows the right view per
|
||||||
|
// permission. Guard on the broader of the two (create) so a review-only admin still gets
|
||||||
|
// in — the redirect only fires if the user has NEITHER, which the nav already hides.
|
||||||
|
beforeLoad: ({ context }) => {
|
||||||
|
if (!can(context.user, "drawer:create") && !can(context.user, "drawer:review")) {
|
||||||
|
throw redirect({ to: "/booth" });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
component: function DrawerRoute() {
|
||||||
|
const { user } = rootRoute.useRouteContext();
|
||||||
return (
|
return (
|
||||||
<ShiftsHistory
|
<DrawerManager
|
||||||
user={user}
|
canCreate={can(user, "drawer:create")}
|
||||||
canManage={can(user, "shift:create")}
|
canReview={can(user, "drawer:review")}
|
||||||
canVoucher={can(user, "shift:create")}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -642,6 +720,14 @@ const logsRoute = createRoute({
|
|||||||
component: LogsViewer,
|
component: LogsViewer,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Encrypted DB backup — status + manual run. Gated by backup:read (run by backup:create).
|
||||||
|
const backupRoute = createRoute({
|
||||||
|
getParentRoute: () => setupRoute,
|
||||||
|
path: "backup",
|
||||||
|
beforeLoad: ({ context }) => requirePerm("backup:read")(context),
|
||||||
|
component: BackupSettings,
|
||||||
|
});
|
||||||
|
|
||||||
// My profile — self-service for ANY signed-in user (no permission gate). Edits only
|
// My profile — self-service for ANY signed-in user (no permission gate). Edits only
|
||||||
// the caller's own name/email/password. See Profile.tsx and routes/auth.ts.
|
// the caller's own name/email/password. See Profile.tsx and routes/auth.ts.
|
||||||
const profileRoute = createRoute({
|
const profileRoute = createRoute({
|
||||||
@@ -660,6 +746,7 @@ const routeTree = rootRoute.addChildren([
|
|||||||
...legacyRedirects,
|
...legacyRedirects,
|
||||||
profileRoute,
|
profileRoute,
|
||||||
shiftRoute,
|
shiftRoute,
|
||||||
|
drawerRoute,
|
||||||
reportsRoute,
|
reportsRoute,
|
||||||
subscriptionsRoute.addChildren([
|
subscriptionsRoute.addChildren([
|
||||||
subscriptionsIndexRoute,
|
subscriptionsIndexRoute,
|
||||||
@@ -674,6 +761,7 @@ const routeTree = rootRoute.addChildren([
|
|||||||
rolesRoute,
|
rolesRoute,
|
||||||
recycleBinRoute,
|
recycleBinRoute,
|
||||||
logsRoute,
|
logsRoute,
|
||||||
|
backupRoute,
|
||||||
]),
|
]),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -98,7 +98,7 @@ export function DeviceFooter() {
|
|||||||
return (
|
return (
|
||||||
<footer
|
<footer
|
||||||
ref={rootRef}
|
ref={rootRef}
|
||||||
className="relative flex shrink-0 items-center gap-2 overflow-visible border-t border-term-border bg-term-panel px-3 py-1.5 text-[11px]"
|
className="relative flex shrink-0 items-center gap-2 overflow-visible border-t border-term-border bg-term-panel px-3 py-1.5 text-[0.6875rem]"
|
||||||
>
|
>
|
||||||
<span className="shrink-0 font-semibold uppercase tracking-wider text-term-muted">
|
<span className="shrink-0 font-semibold uppercase tracking-wider text-term-muted">
|
||||||
{t("devices.footerTitle")}
|
{t("devices.footerTitle")}
|
||||||
@@ -156,7 +156,7 @@ export function DeviceFooter() {
|
|||||||
{open && problems.length > 0 && (
|
{open && problems.length > 0 && (
|
||||||
<div className="absolute bottom-full right-2 z-50 mb-1 w-[360px] max-w-[95vw] rounded-term border border-term-border bg-term-panel shadow-2xl">
|
<div className="absolute bottom-full right-2 z-50 mb-1 w-[360px] max-w-[95vw] rounded-term border border-term-border bg-term-panel shadow-2xl">
|
||||||
<div className="flex items-center justify-between border-b border-term-border bg-term-panel-2 px-3 py-1.5">
|
<div className="flex items-center justify-between border-b border-term-border bg-term-panel-2 px-3 py-1.5">
|
||||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-term-amber">
|
<span className="text-[0.6875rem] font-semibold uppercase tracking-wider text-term-amber">
|
||||||
{t("devices.issuesTitle")}
|
{t("devices.issuesTitle")}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
@@ -177,13 +177,13 @@ export function DeviceFooter() {
|
|||||||
<span className={`mt-1 inline-block h-2 w-2 shrink-0 rounded-full ${DOT[d.state]}`} />
|
<span className={`mt-1 inline-block h-2 w-2 shrink-0 rounded-full ${DOT[d.state]}`} />
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex items-baseline justify-between gap-2">
|
<div className="flex items-baseline justify-between gap-2">
|
||||||
<span className={`text-[12px] font-semibold ${TEXT[d.state]}`}>{label(d)}</span>
|
<span className={`text-[0.75rem] font-semibold ${TEXT[d.state]}`}>{label(d)}</span>
|
||||||
<span className="shrink-0 text-[10px] uppercase tracking-wider text-term-muted">
|
<span className="shrink-0 text-[0.625rem] uppercase tracking-wider text-term-muted">
|
||||||
{t(`devices.state.${d.state}`)}
|
{t(`devices.state.${d.state}`)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{d.detail && <div className="mt-0.5 break-words text-[11px] text-term-muted">{d.detail}</div>}
|
{d.detail && <div className="mt-0.5 break-words text-[0.6875rem] text-term-muted">{d.detail}</div>}
|
||||||
<div className="mt-0.5 text-[10px] tabular-nums text-term-muted/70">
|
<div className="mt-0.5 text-[0.625rem] tabular-nums text-term-muted/70">
|
||||||
{t("devices.checkedAt", { time: new Date(d.checkedAt).toLocaleTimeString() })}
|
{t("devices.checkedAt", { time: new Date(d.checkedAt).toLocaleTimeString() })}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export function SegGroup<V extends string>({
|
|||||||
key={v || "all"}
|
key={v || "all"}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onChange(v)}
|
onClick={() => onChange(v)}
|
||||||
className={`px-2 py-0.5 text-[10px] uppercase tracking-wider transition-colors ${
|
className={`px-2 py-0.5 text-[0.625rem] uppercase tracking-wider transition-colors ${
|
||||||
value === v ? "bg-term-border text-term-text" : "text-term-muted hover:text-term-text"
|
value === v ? "bg-term-border text-term-text" : "text-term-muted hover:text-term-text"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -61,7 +61,7 @@ export function FilterBar({
|
|||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => onSearch(e.target.value)}
|
onChange={(e) => onSearch(e.target.value)}
|
||||||
placeholder={searchPlaceholder}
|
placeholder={searchPlaceholder}
|
||||||
className="min-w-[8rem] flex-1 rounded border border-term-border/60 bg-transparent px-2 py-0.5 text-[12px] text-term-text placeholder:text-term-muted focus:border-term-amber focus:outline-none"
|
className="min-w-[8rem] flex-1 rounded border border-term-border/60 bg-transparent px-2 py-0.5 text-[0.75rem] text-term-text placeholder:text-term-muted focus:border-term-amber focus:outline-none"
|
||||||
/>
|
/>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ export function Modal({
|
|||||||
aria-describedby={undefined}
|
aria-describedby={undefined}
|
||||||
>
|
>
|
||||||
<div className="sticky top-0 flex items-center justify-between border-b border-term-border bg-term-panel-2 px-4 py-2">
|
<div className="sticky top-0 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">
|
<Dialog.Title className="m-0 text-[0.75rem] font-semibold uppercase tracking-wider text-term-amber">
|
||||||
{title}
|
{title}
|
||||||
</Dialog.Title>
|
</Dialog.Title>
|
||||||
<Dialog.Close className="text-term-muted hover:text-term-text" aria-label="Close">
|
<Dialog.Close className="text-term-muted hover:text-term-text" aria-label="Close">
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export function Panel({
|
|||||||
>
|
>
|
||||||
{title && (
|
{title && (
|
||||||
<header className="flex items-center justify-between px-3 py-1.5 bg-term-panel-2 border-b border-term-border">
|
<header className="flex items-center justify-between px-3 py-1.5 bg-term-panel-2 border-b border-term-border">
|
||||||
<h2 className="m-0 text-[11px] font-semibold uppercase tracking-wider text-term-amber">
|
<h2 className="m-0 text-[0.6875rem] font-semibold uppercase tracking-wider text-term-amber">
|
||||||
{title}
|
{title}
|
||||||
</h2>
|
</h2>
|
||||||
{right}
|
{right}
|
||||||
|
|||||||
@@ -37,9 +37,9 @@ export function SnapshotStrip({ identity }: { identity: string }) {
|
|||||||
const dirLabel = (dir: "entry" | "exit" | null): string =>
|
const dirLabel = (dir: "entry" | "exit" | null): string =>
|
||||||
dir === "entry" ? t("pay.snapEntry") : dir === "exit" ? t("pay.snapExit") : "—";
|
dir === "entry" ? t("pay.snapEntry") : dir === "exit" ? t("pay.snapExit") : "—";
|
||||||
|
|
||||||
if (isLoading) return <div className="text-[11px] text-term-muted">{t("pay.loadingSnapshots")}</div>;
|
if (isLoading) return <div className="text-[0.6875rem] text-term-muted">{t("pay.loadingSnapshots")}</div>;
|
||||||
if (shots.length === 0 && failures.length === 0 && plates.length === 0)
|
if (shots.length === 0 && failures.length === 0 && plates.length === 0)
|
||||||
return <div className="text-[11px] text-term-muted">{t("pay.noSnapshots")}</div>;
|
return <div className="text-[0.6875rem] text-term-muted">{t("pay.noSnapshots")}</div>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -50,15 +50,15 @@ export function SnapshotStrip({ identity }: { identity: string }) {
|
|||||||
{dedupePlates(plates).map((p, i) => (
|
{dedupePlates(plates).map((p, i) => (
|
||||||
<span
|
<span
|
||||||
key={`${p.plate}-${p.direction}-${i}`}
|
key={`${p.plate}-${p.direction}-${i}`}
|
||||||
className="inline-flex items-center gap-1.5 rounded-term border border-term-cyan/40 bg-term-cyan/10 px-2 py-0.5 text-[11px]"
|
className="inline-flex items-center gap-1.5 rounded-term border border-term-cyan/40 bg-term-cyan/10 px-2 py-0.5 text-[0.6875rem]"
|
||||||
title={`${dirLabel(p.direction)}${p.region ? ` · ${p.region}` : ""}${
|
title={`${dirLabel(p.direction)}${p.region ? ` · ${p.region}` : ""}${
|
||||||
p.at ? ` · ${new Date(p.at).toLocaleString()}` : ""
|
p.at ? ` · ${new Date(p.at).toLocaleString()}` : ""
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span className="text-[9px] uppercase tracking-wider text-term-muted">{t("pay.plate")}</span>
|
<span className="text-[0.5625rem] uppercase tracking-wider text-term-muted">{t("pay.plate")}</span>
|
||||||
<span className="font-mono font-semibold text-term-cyan">{p.plate}</span>
|
<span className="font-mono font-semibold text-term-cyan">{p.plate}</span>
|
||||||
{typeof p.confidence === "number" && (
|
{typeof p.confidence === "number" && (
|
||||||
<span className="text-[10px] text-term-muted">{(p.confidence * 100).toFixed(0)}%</span>
|
<span className="text-[0.625rem] text-term-muted">{(p.confidence * 100).toFixed(0)}%</span>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
@@ -81,7 +81,7 @@ export function SnapshotStrip({ identity }: { identity: string }) {
|
|||||||
loading="lazy"
|
loading="lazy"
|
||||||
/>
|
/>
|
||||||
<span
|
<span
|
||||||
className={`text-[9px] uppercase tracking-wider ${
|
className={`text-[0.5625rem] uppercase tracking-wider ${
|
||||||
s.direction === "entry" ? "text-term-green" : s.direction === "exit" ? "text-term-red" : "text-term-muted"
|
s.direction === "entry" ? "text-term-green" : s.direction === "exit" ? "text-term-red" : "text-term-muted"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -100,8 +100,8 @@ export function SnapshotStrip({ identity }: { identity: string }) {
|
|||||||
title={`${dirLabel(f.direction)} · ${f.error}${f.occurredAt ? ` · ${new Date(f.occurredAt).toLocaleString()}` : ""}`}
|
title={`${dirLabel(f.direction)} · ${f.error}${f.occurredAt ? ` · ${new Date(f.occurredAt).toLocaleString()}` : ""}`}
|
||||||
>
|
>
|
||||||
<span className="text-lg leading-none text-term-amber">⚠</span>
|
<span className="text-lg leading-none text-term-amber">⚠</span>
|
||||||
<span className="text-[9px] uppercase tracking-wider text-term-amber">{dirLabel(f.direction)}</span>
|
<span className="text-[0.5625rem] uppercase tracking-wider text-term-amber">{dirLabel(f.direction)}</span>
|
||||||
<span className="px-1 text-[9px] leading-tight text-term-muted">{t("pay.snapFailed")}</span>
|
<span className="px-1 text-[0.5625rem] leading-tight text-term-muted">{t("pay.snapFailed")}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export function StatusDot() {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const status = useLiveStore((s) => s.status);
|
const status = useLiveStore((s) => s.status);
|
||||||
return (
|
return (
|
||||||
<span className="flex items-center gap-1.5 text-[10px] uppercase tracking-wider text-term-muted">
|
<span className="flex items-center gap-1.5 text-[0.625rem] uppercase tracking-wider text-term-muted">
|
||||||
<span
|
<span
|
||||||
className={`inline-block h-2 w-2 rounded-full ${COLOR[status]} ${status === "open" ? "" : "animate-pulse"}`}
|
className={`inline-block h-2 w-2 rounded-full ${COLOR[status]} ${status === "open" ? "" : "animate-pulse"}`}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export const EVENT_STYLE: Record<string, { labelKey: string; color: string }> =
|
|||||||
cash_movement: { labelKey: "booth.evtCashMovement", color: "text-term-cyan" },
|
cash_movement: { labelKey: "booth.evtCashMovement", color: "text-term-cyan" },
|
||||||
cash_in: { labelKey: "booth.evtCashIn", color: "text-term-green" },
|
cash_in: { labelKey: "booth.evtCashIn", color: "text-term-green" },
|
||||||
cash_out: { labelKey: "booth.evtCashOut", color: "text-term-amber" },
|
cash_out: { labelKey: "booth.evtCashOut", color: "text-term-amber" },
|
||||||
|
cash_review: { labelKey: "booth.evtCashReview", color: "text-term-cyan" },
|
||||||
anomaly: { labelKey: "booth.evtAnomaly", color: "text-term-red" },
|
anomaly: { labelKey: "booth.evtAnomaly", color: "text-term-red" },
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -95,9 +96,9 @@ export function displayIdentity(e: LedgerEvent): string {
|
|||||||
return e.subscriberLabel ?? e.identity ?? "—";
|
return e.subscriberLabel ?? e.identity ?? "—";
|
||||||
}
|
}
|
||||||
|
|
||||||
/** One clickable live-feed / activity row → opens the event-detail modal. A grid keeps
|
/** One clickable live-feed / activity row → opens the event-detail modal. A grid keeps the
|
||||||
* the time/label/identity/index columns aligned across rows; the detail line lives in
|
* time/label/#index columns aligned across rows; the identity, plate, badges and reason flow
|
||||||
* its own row, indented under the identity column. */
|
* inline in the middle column and wrap there only when they run out of width. */
|
||||||
export function EventRow({ e, onOpen }: { e: LedgerEvent; onOpen: (e: LedgerEvent) => void }) {
|
export function EventRow({ e, onOpen }: { e: LedgerEvent; onOpen: (e: LedgerEvent) => void }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const style = eventStyleFor(e);
|
const style = eventStyleFor(e);
|
||||||
@@ -110,52 +111,44 @@ export function EventRow({ e, onOpen }: { e: LedgerEvent; onOpen: (e: LedgerEven
|
|||||||
const reason = renderReason(p, t);
|
const reason = renderReason(p, t);
|
||||||
const amount = paymentSummary(p);
|
const amount = paymentSummary(p);
|
||||||
const badges = eventBadges(p);
|
const badges = eventBadges(p);
|
||||||
const via = viaKey(p);
|
|
||||||
const detail = reason ?? amount ?? (isAnomaly ? t("booth.evtNoReason") : null);
|
const detail = reason ?? amount ?? (isAnomaly ? t("booth.evtNoReason") : null);
|
||||||
const showDetail = detail != null || badges.length > 0 || via != null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onOpen(e)}
|
onClick={() => onOpen(e)}
|
||||||
className={`grid w-full grid-cols-[auto_5rem_1fr_auto] items-center gap-x-3 gap-y-0.5 border-b border-term-border/50 px-1 py-1 text-left text-[12px] tabular-nums hover:bg-term-panel-2 ${
|
className={`grid w-full grid-cols-[auto_5rem_1fr_auto] items-start gap-x-3 border-b border-term-border/50 px-1 py-1 text-left text-[0.75rem] tabular-nums hover:bg-term-panel-2 ${
|
||||||
isAnomaly ? "bg-term-red/5" : refusedWarning ? "bg-term-amber/5" : ""
|
isAnomaly ? "bg-term-red/5" : refusedWarning ? "bg-term-amber/5" : ""
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span className="text-term-muted">{hhmmss(e.occurredAt)}</span>
|
<span className="py-px text-term-muted">{hhmmss(e.occurredAt)}</span>
|
||||||
<span className={`shrink-0 font-semibold ${style.color}`}>{label}</span>
|
<span className={`py-px shrink-0 font-semibold ${style.color}`}>{label}</span>
|
||||||
<span className="flex min-w-0 items-center gap-2">
|
{/* Identity + plate + detail all flow in ONE wrapping line — they fill the available
|
||||||
<span className="truncate text-term-text">{displayIdentity(e)}</span>
|
width and only wrap to a second line when this cell actually runs out of room (no
|
||||||
|
forced second row). Keeps time/label/#index in their columns. */}
|
||||||
|
<span className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1 py-px">
|
||||||
|
<span className="break-all text-term-text">{displayIdentity(e)}</span>
|
||||||
{e.plate && (
|
{e.plate && (
|
||||||
<span
|
<span
|
||||||
className="shrink-0 rounded border border-term-border px-1 text-[11px] font-semibold tracking-wide text-term-amber"
|
className="shrink-0 rounded border border-term-border px-1 text-[0.6875rem] font-semibold tracking-wide text-term-amber"
|
||||||
title={t("booth.plateTitle")}
|
title={t("booth.plateTitle")}
|
||||||
>
|
>
|
||||||
{e.plate}
|
{e.plate}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</span>
|
|
||||||
<span className="text-term-muted">#{e.index}</span>
|
|
||||||
{showDetail && (
|
|
||||||
<div className="col-start-3 col-end-5 flex flex-wrap items-center gap-x-2 gap-y-1">
|
|
||||||
{badges.map((k) => (
|
{badges.map((k) => (
|
||||||
<span
|
<span
|
||||||
key={k}
|
key={k}
|
||||||
className="rounded-sm bg-term-red/15 px-1.5 py-px text-[10px] font-semibold uppercase tracking-wide text-term-red"
|
className="shrink-0 rounded-sm bg-term-red/15 px-1.5 py-px text-[0.625rem] font-semibold uppercase tracking-wide text-term-red"
|
||||||
>
|
>
|
||||||
{t(k)}
|
{t(k)}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
{via && (
|
|
||||||
<span className="rounded-sm bg-term-cyan/15 px-1.5 py-px text-[10px] font-semibold uppercase tracking-wide text-term-cyan">
|
|
||||||
{t(via)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{detail && (
|
{detail && (
|
||||||
<span className={`text-[11px] ${isAnomaly ? "text-term-red/90" : "text-term-muted"}`}>{detail}</span>
|
<span className={`${isAnomaly ? "text-term-red/90" : "text-term-muted"}`}>{detail}</span>
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
</span>
|
||||||
|
<span className="py-px text-term-muted">#{e.index}</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -163,8 +156,8 @@ export function EventRow({ e, onOpen }: { e: LedgerEvent; onOpen: (e: LedgerEven
|
|||||||
/** One label/value line in the event-detail modal. */
|
/** One label/value line in the event-detail modal. */
|
||||||
function DetailRow({ label, children }: { label: string; children: ReactNode }) {
|
function DetailRow({ label, children }: { label: string; children: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-[8rem_1fr] gap-3 border-b border-term-border/40 py-1.5 text-[12px]">
|
<div className="grid grid-cols-[8rem_1fr] gap-3 border-b border-term-border/40 py-1.5 text-[0.75rem]">
|
||||||
<span className="text-[11px] uppercase tracking-wider text-term-muted">{label}</span>
|
<span className="text-[0.6875rem] uppercase tracking-wider text-term-muted">{label}</span>
|
||||||
<span className="min-w-0 break-words text-term-text">{children}</span>
|
<span className="min-w-0 break-words text-term-text">{children}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -195,6 +188,12 @@ export function EventDetailModal({ e, onClose }: { e: LedgerEvent; onClose: () =
|
|||||||
const category = typeof p?.category === "string" ? p.category : null;
|
const category = typeof p?.category === "string" ? p.category : null;
|
||||||
const operator = typeof p?.operator === "string" ? p.operator : null;
|
const operator = typeof p?.operator === "string" ? p.operator : null;
|
||||||
const tariffVersionId = typeof p?.tariffVersionId === "string" ? p.tariffVersionId : null;
|
const tariffVersionId = typeof p?.tariffVersionId === "string" ? p.tariffVersionId : null;
|
||||||
|
// cash_review fields: the admin's decision on a drawer movement (+ who / note / the
|
||||||
|
// reviewed movement id). A flag only — it never moves cash. See wiki/concepts/shift.md.
|
||||||
|
const decision = p?.decision === "authorize" || p?.decision === "deny" ? p.decision : null;
|
||||||
|
const reviewedBy = typeof p?.reviewedBy === "string" ? p.reviewedBy : null;
|
||||||
|
const reviewNote = typeof p?.note === "string" ? p.note : null;
|
||||||
|
const refId = typeof p?.refId === "string" ? p.refId : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal open onClose={onClose} title={t("booth.eventDetail")} width="max-w-2xl">
|
<Modal open onClose={onClose} title={t("booth.eventDetail")} width="max-w-2xl">
|
||||||
@@ -203,19 +202,19 @@ export function EventDetailModal({ e, onClose }: { e: LedgerEvent; onClose: () =
|
|||||||
<div className={`rounded-term border p-3 ${isAnomaly ? "border-term-red/50 bg-term-red/5" : "border-term-border bg-term-panel-2"}`}>
|
<div className={`rounded-term border p-3 ${isAnomaly ? "border-term-red/50 bg-term-red/5" : "border-term-border bg-term-panel-2"}`}>
|
||||||
<div className={`text-sm font-bold uppercase tracking-widest ${style.color}`}>{label}</div>
|
<div className={`text-sm font-bold uppercase tracking-widest ${style.color}`}>{label}</div>
|
||||||
{(reason || money) && (
|
{(reason || money) && (
|
||||||
<div className={`mt-1 text-[13px] ${isAnomaly ? "text-term-red/90" : "text-term-text"}`}>
|
<div className={`mt-1 text-[0.8125rem] ${isAnomaly ? "text-term-red/90" : "text-term-text"}`}>
|
||||||
{reason ?? money}
|
{reason ?? money}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!reason && !money && isAnomaly && (
|
{!reason && !money && isAnomaly && (
|
||||||
<div className="mt-1 text-[13px] text-term-red/90">{t("booth.evtNoReason")}</div>
|
<div className="mt-1 text-[0.8125rem] text-term-red/90">{t("booth.evtNoReason")}</div>
|
||||||
)}
|
)}
|
||||||
{badges.length > 0 && (
|
{badges.length > 0 && (
|
||||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||||
{badges.map((k) => (
|
{badges.map((k) => (
|
||||||
<span
|
<span
|
||||||
key={k}
|
key={k}
|
||||||
className="rounded-sm bg-term-red/15 px-1.5 py-px text-[10px] font-semibold uppercase tracking-wide text-term-red"
|
className="rounded-sm bg-term-red/15 px-1.5 py-px text-[0.625rem] font-semibold uppercase tracking-wide text-term-red"
|
||||||
>
|
>
|
||||||
{t(k)}
|
{t(k)}
|
||||||
</span>
|
</span>
|
||||||
@@ -235,7 +234,7 @@ export function EventDetailModal({ e, onClose }: { e: LedgerEvent; onClose: () =
|
|||||||
(the SUBSESS-… session key) for traceability against the ledger. */}
|
(the SUBSESS-… session key) for traceability against the ledger. */}
|
||||||
{e.subscriberLabel && e.identity && (
|
{e.subscriberLabel && e.identity && (
|
||||||
<DetailRow label={t("booth.edOccurrence")}>
|
<DetailRow label={t("booth.edOccurrence")}>
|
||||||
<code className="text-[11px] text-term-muted">{e.identity}</code>
|
<code className="text-[0.6875rem] text-term-muted">{e.identity}</code>
|
||||||
</DetailRow>
|
</DetailRow>
|
||||||
)}
|
)}
|
||||||
{money && (
|
{money && (
|
||||||
@@ -252,12 +251,27 @@ export function EventDetailModal({ e, onClose }: { e: LedgerEvent; onClose: () =
|
|||||||
{category && <DetailRow label={t("booth.edCategory")}>{category}</DetailRow>}
|
{category && <DetailRow label={t("booth.edCategory")}>{category}</DetailRow>}
|
||||||
{plate && <DetailRow label={t("booth.edPlate")}>{plate}</DetailRow>}
|
{plate && <DetailRow label={t("booth.edPlate")}>{plate}</DetailRow>}
|
||||||
{operator && <DetailRow label={t("booth.edOperator")}>{operator}</DetailRow>}
|
{operator && <DetailRow label={t("booth.edOperator")}>{operator}</DetailRow>}
|
||||||
|
{/* cash_review: the admin's decision + who + why (for a denial). */}
|
||||||
|
{decision && (
|
||||||
|
<DetailRow label={t("booth.edDecision")}>
|
||||||
|
<span className={decision === "authorize" ? "text-term-green" : "text-term-red"}>
|
||||||
|
{t(`booth.decision.${decision}`)}
|
||||||
|
</span>
|
||||||
|
</DetailRow>
|
||||||
|
)}
|
||||||
|
{reviewedBy && <DetailRow label={t("booth.edReviewedBy")}>{reviewedBy}</DetailRow>}
|
||||||
|
{reviewNote && <DetailRow label={t("booth.edReviewNote")}>{reviewNote}</DetailRow>}
|
||||||
|
{refId && (
|
||||||
|
<DetailRow label={t("booth.edReviewRef")}>
|
||||||
|
<code className="text-[0.6875rem] text-term-muted">{refId}</code>
|
||||||
|
</DetailRow>
|
||||||
|
)}
|
||||||
{sessionRef && sessionRef !== e.identity && (
|
{sessionRef && sessionRef !== e.identity && (
|
||||||
<DetailRow label={t("booth.edSession")}>{sessionRef}</DetailRow>
|
<DetailRow label={t("booth.edSession")}>{sessionRef}</DetailRow>
|
||||||
)}
|
)}
|
||||||
{tariffVersionId && (
|
{tariffVersionId && (
|
||||||
<DetailRow label={t("booth.edTariffVersion")}>
|
<DetailRow label={t("booth.edTariffVersion")}>
|
||||||
<code className="text-[11px] text-term-muted">{tariffVersionId}</code>
|
<code className="text-[0.6875rem] text-term-muted">{tariffVersionId}</code>
|
||||||
</DetailRow>
|
</DetailRow>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -265,7 +279,7 @@ export function EventDetailModal({ e, onClose }: { e: LedgerEvent; onClose: () =
|
|||||||
{/* The entry/exit evidence images for this session's identity. */}
|
{/* The entry/exit evidence images for this session's identity. */}
|
||||||
{e.identity && (
|
{e.identity && (
|
||||||
<div>
|
<div>
|
||||||
<div className="mb-1.5 text-[11px] uppercase tracking-wider text-term-muted">{t("booth.edSnapshots")}</div>
|
<div className="mb-1.5 text-[0.6875rem] uppercase tracking-wider text-term-muted">{t("booth.edSnapshots")}</div>
|
||||||
<SnapshotStrip identity={e.identity} />
|
<SnapshotStrip identity={e.identity} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -275,28 +289,28 @@ export function EventDetailModal({ e, onClose }: { e: LedgerEvent; onClose: () =
|
|||||||
operator's; tucking them behind a disclosure keeps the common view clean
|
operator's; tucking them behind a disclosure keeps the common view clean
|
||||||
while preserving the tamper-evidence trail on demand. */}
|
while preserving the tamper-evidence trail on demand. */}
|
||||||
<details className="rounded-term border border-term-border bg-term-panel-2">
|
<details className="rounded-term border border-term-border bg-term-panel-2">
|
||||||
<summary className="cursor-pointer select-none px-3 py-2 text-[11px] uppercase tracking-wider text-term-muted hover:text-term-text">
|
<summary className="cursor-pointer select-none px-3 py-2 text-[0.6875rem] uppercase tracking-wider text-term-muted hover:text-term-text">
|
||||||
{t("booth.edAuditData")}
|
{t("booth.edAuditData")}
|
||||||
</summary>
|
</summary>
|
||||||
<div className="border-t border-term-border px-3 pb-3 pt-1">
|
<div className="border-t border-term-border px-3 pb-3 pt-1">
|
||||||
<DetailRow label={t("booth.edSignature")}>
|
<DetailRow label={t("booth.edSignature")}>
|
||||||
<code className="break-all text-[11px] text-term-muted">{e.signature}</code>
|
<code className="break-all text-[0.6875rem] text-term-muted">{e.signature}</code>
|
||||||
</DetailRow>
|
</DetailRow>
|
||||||
<DetailRow label={t("booth.edKeyId")}>
|
<DetailRow label={t("booth.edKeyId")}>
|
||||||
<code className="text-[11px] text-term-muted">{e.keyId}</code>
|
<code className="text-[0.6875rem] text-term-muted">{e.keyId}</code>
|
||||||
</DetailRow>
|
</DetailRow>
|
||||||
<DetailRow label={t("booth.edPrevHash")}>
|
<DetailRow label={t("booth.edPrevHash")}>
|
||||||
<code className="break-all text-[11px] text-term-muted">{e.prevHash ?? "—"}</code>
|
<code className="break-all text-[0.6875rem] text-term-muted">{e.prevHash ?? "—"}</code>
|
||||||
</DetailRow>
|
</DetailRow>
|
||||||
<div className="mb-1.5 mt-3 text-[11px] uppercase tracking-wider text-term-muted">
|
<div className="mb-1.5 mt-3 text-[0.6875rem] uppercase tracking-wider text-term-muted">
|
||||||
{t("booth.edRawPayload")}
|
{t("booth.edRawPayload")}
|
||||||
</div>
|
</div>
|
||||||
{p && Object.keys(p).length > 0 ? (
|
{p && Object.keys(p).length > 0 ? (
|
||||||
<pre className="overflow-x-auto rounded-term border border-term-border bg-term-bg p-2 text-[11px] text-term-text">
|
<pre className="overflow-x-auto rounded-term border border-term-border bg-term-bg p-2 text-[0.6875rem] text-term-text">
|
||||||
{JSON.stringify(p, null, 2)}
|
{JSON.stringify(p, null, 2)}
|
||||||
</pre>
|
</pre>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-[12px] text-term-muted">{t("booth.edNoPayload")}</div>
|
<div className="text-[0.75rem] text-term-muted">{t("booth.edNoPayload")}</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
|
|||||||
+54
-13
@@ -7,24 +7,25 @@
|
|||||||
# See wiki/decisions/container-deployment.md.
|
# See wiki/decisions/container-deployment.md.
|
||||||
|
|
||||||
services:
|
services:
|
||||||
# Reverse proxy: :80 → server:3000 (WebSocket /api/ws upgrades pass through natively).
|
# Reverse proxy: :80 → server (127.0.0.1:3000). On the HOST network (see the server note),
|
||||||
# Caddy is a single static binary with a one-line proxy config; swapping http:// for the
|
# so it reaches the host-net server over loopback and publishes :80 directly on the host.
|
||||||
# site's real hostname later enables automatic HTTPS. The booth is reached at
|
# WebSocket /api/ws upgrades pass through natively. Swapping http:// for the site's real
|
||||||
# http://<name-or-ip>/ (the name set via hosts/DNS on-site — NOT baked into any image).
|
# hostname later enables automatic HTTPS. Reached at http://<name-or-ip>/ (name via hosts/DNS
|
||||||
|
# on-site — NOT baked into any image).
|
||||||
proxy:
|
proxy:
|
||||||
image: caddy:2-alpine
|
image: caddy:2-alpine
|
||||||
restart: always
|
restart: always
|
||||||
ports:
|
# Host network: Caddy listens on the host's :80 and proxies the host-net server on
|
||||||
- "80:80"
|
# 127.0.0.1:3000. (No `ports:` mapping — host mode publishes directly.)
|
||||||
# - "443:443" # uncomment when moving to TLS (and set a real hostname in Caddyfile)
|
network_mode: host
|
||||||
|
# host mode is mutually exclusive with a named network; the base file doesn't attach proxy,
|
||||||
|
# so nothing to null here (server does — see below).
|
||||||
volumes:
|
volumes:
|
||||||
- ./Caddyfile:/etc/caddy/Caddyfile:ro
|
- ./Caddyfile:/etc/caddy/Caddyfile:ro
|
||||||
- caddy-data:/data
|
- caddy-data:/data
|
||||||
- caddy-config:/config
|
- caddy-config:/config
|
||||||
depends_on:
|
depends_on:
|
||||||
- server
|
- server
|
||||||
networks:
|
|
||||||
- parking
|
|
||||||
logging:
|
logging:
|
||||||
driver: json-file
|
driver: json-file
|
||||||
options:
|
options:
|
||||||
@@ -33,9 +34,45 @@ services:
|
|||||||
|
|
||||||
server:
|
server:
|
||||||
restart: always
|
restart: always
|
||||||
# No published port — only the proxy reaches the server, over the private network.
|
# HOST NETWORK — the crux of the appliance. The server is the ONLY container doing device
|
||||||
expose:
|
# I/O (camera ISAPI snapshots, relay control, receiving reader/alarm pushes), all on the
|
||||||
- "3000"
|
# booth's LAN / isolated device VLAN (10.0.10.x). On a bridge network it sees only the Docker
|
||||||
|
# subnet (172.18.0.x) — it can't reach the relay, can't be reached by push devices, and the
|
||||||
|
# backend-IP picker (net.ts networkInterfaces) only sees eth0. Host mode puts it on the real
|
||||||
|
# NICs. Vision stays bridged (it never touches a device — the server hands it JPEG bytes).
|
||||||
|
network_mode: host
|
||||||
|
# host mode is mutually exclusive with a named network — detach the base file's `parking`
|
||||||
|
# attachment (compose errors otherwise: "network_mode and networks cannot both be set").
|
||||||
|
networks: !reset []
|
||||||
|
# Listens on :3000 directly on the host (Caddy proxies it). Loopback to vision:
|
||||||
|
environment:
|
||||||
|
VISION_URL: http://127.0.0.1:8089
|
||||||
|
# NB: NO `sysctls:` here. net.ipv4.ping_group_range is a per-netns sysctl; under host net
|
||||||
|
# there is no separate namespace, and runc REFUSES it ("not allowed in host network
|
||||||
|
# namespace"). Reader liveness ping uses the HOST's setting instead — the booth host must
|
||||||
|
# set net.ipv4.ping_group_range (see appliance-provisioning §7 / disk-os-hardening).
|
||||||
|
#
|
||||||
|
# USB PRINTER PASSTHROUGH. A USB ESC/POS printer (Rongta/Cashino) is the kernel `usblp` char
|
||||||
|
# device /dev/usb/lpN on the HOST — the container has its own /dev and can't see it (probeUsb
|
||||||
|
# open() → ENOENT → printer always "offline"). Two parts, both needed:
|
||||||
|
# - bind-mount /dev/usb so the lpN NODES appear inside the container, and
|
||||||
|
# - a device-cgroup rule permitting the usblp char major (180) so the kernel allows the
|
||||||
|
# open(). `180:*` covers lp0/lp1/lp2… so a USB replug/boot-order renumber still works
|
||||||
|
# (the printer's path can move; set Connection=USB + the matching /dev/usb/lpN in setup).
|
||||||
|
# (Bind-mounting the dir, not a single `devices:` node, is what survives renumbering.)
|
||||||
|
#
|
||||||
|
# ...AND access: the lpN node is `crw-rw---- root:lp` (mode 660). The server runs as the
|
||||||
|
# non-root `app` user, which is NOT in `lp`, so open(O_WRONLY) → EACCES → still "offline".
|
||||||
|
# group_add the HOST's `lp` GID (numeric — `getent group lp`, typically 7 on Debian/Ubuntu)
|
||||||
|
# so the app process gains that supplementary group and can write the 660 node. Least-
|
||||||
|
# privilege (no world-writable device, no root, no rebuild). VERIFY the GID on the booth;
|
||||||
|
# if the host's lp GID differs, change the number here.
|
||||||
|
group_add:
|
||||||
|
- "7"
|
||||||
|
volumes:
|
||||||
|
- /dev/usb:/dev/usb
|
||||||
|
device_cgroup_rules:
|
||||||
|
- "c 180:* rmw"
|
||||||
logging:
|
logging:
|
||||||
driver: json-file
|
driver: json-file
|
||||||
options:
|
options:
|
||||||
@@ -45,9 +82,13 @@ services:
|
|||||||
vision:
|
vision:
|
||||||
restart: always
|
restart: always
|
||||||
# The real ANPR engine. The image baked the model weights at build (offline-first).
|
# The real ANPR engine. The image baked the model weights at build (offline-first).
|
||||||
|
# Stays on the bridge network (isolated — it makes NO outbound device calls), but PUBLISHES
|
||||||
|
# 8089 on the host LOOPBACK ONLY so the host-net server can reach it. 127.0.0.1 binding keeps
|
||||||
|
# it off the booth LAN — nothing on the network can hit the ANPR service.
|
||||||
environment:
|
environment:
|
||||||
VISION_RECOGNIZER: fast_alpr
|
VISION_RECOGNIZER: fast_alpr
|
||||||
# No published ports — vision is reached only by the server over the private network.
|
ports:
|
||||||
|
- "127.0.0.1:8089:8089"
|
||||||
logging:
|
logging:
|
||||||
driver: json-file
|
driver: json-file
|
||||||
options:
|
options:
|
||||||
|
|||||||
+9
-2
@@ -13,14 +13,21 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
DATABASE_URL: /data/parking.sqlite
|
DATABASE_URL: /data/parking.sqlite
|
||||||
# Reach the vision service over the private compose network by service name.
|
# Reach the vision service. DEV: the private compose-network service name (`vision`).
|
||||||
VISION_URL: http://vision:8089
|
# PROD: the server runs on the HOST network (to see the booth LAN / device VLAN — it's the
|
||||||
|
# only container doing device I/O), where compose DNS doesn't resolve, so the prod override
|
||||||
|
# sets VISION_URL=http://127.0.0.1:8089 and vision publishes 8089 on the host loopback.
|
||||||
|
VISION_URL: ${VISION_URL:-http://vision:8089}
|
||||||
VISION_ENABLED: ${VISION_ENABLED:-1}
|
VISION_ENABLED: ${VISION_ENABLED:-1}
|
||||||
# JWT signing secret MUST be provided at deploy (no insecure default — see auth.ts).
|
# JWT signing secret MUST be provided at deploy (no insecure default — see auth.ts).
|
||||||
JWT_SECRET: ${JWT_SECRET:?set JWT_SECRET in the env/.env}
|
JWT_SECRET: ${JWT_SECRET:?set JWT_SECRET in the env/.env}
|
||||||
# Dedicated ledger-signing key. Falls back to JWT_SECRET (with a warning) if empty;
|
# Dedicated ledger-signing key. Falls back to JWT_SECRET (with a warning) if empty;
|
||||||
# set a distinct one in prod. See apps/server/.env.example + local-jwt-auth.
|
# set a distinct one in prod. See apps/server/.env.example + local-jwt-auth.
|
||||||
EVENT_SIGNING_KEY: ${EVENT_SIGNING_KEY:-}
|
EVENT_SIGNING_KEY: ${EVENT_SIGNING_KEY:-}
|
||||||
|
# Dedicated backup-ENCRYPTION key (separate from the signing key). Empty = backups stay
|
||||||
|
# off (the in-UI target + retention do nothing without it). Per-booth + unique; escrow it
|
||||||
|
# offsite. See apps/server/.env.example + wiki/concepts/backup-recovery.md.
|
||||||
|
BACKUP_KEY: ${BACKUP_KEY:-}
|
||||||
# CRITICAL on the plain-HTTP booth LAN: cookies are Secure (HTTPS-only) by DEFAULT,
|
# CRITICAL on the plain-HTTP booth LAN: cookies are Secure (HTTPS-only) by DEFAULT,
|
||||||
# so without COOKIE_SECURE=0 the auth cookie is never sent over http and operators
|
# so without COOKIE_SECURE=0 the auth cookie is never sent over http and operators
|
||||||
# CANNOT LOG IN. Leave unset only behind TLS. See disk-os-hardening "deploy-time runbook".
|
# CANNOT LOG IN. Leave unset only behind TLS. See disk-os-hardening "deploy-time runbook".
|
||||||
|
|||||||
+238
@@ -0,0 +1,238 @@
|
|||||||
|
# i18n long-sentence review — EN vs SQ
|
||||||
|
|
||||||
|
Generated 2026-06-24. Source: `apps/web/src/lib/i18n/en.ts` + `sq.ts`. Threshold: strings ≥ 80 chars in either language (35 keys).
|
||||||
|
|
||||||
|
Review each pair for MEANING (does SQ say the same as EN?). Mark your decision in the **Verdict** line: `OK` / `FIX: <new text>` / `?`.
|
||||||
|
|
||||||
|
Keys with an automated note are tagged **⚑ NOTE**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. `badgeOverstayTitle` ⚑
|
||||||
|
|
||||||
|
- **EN** (en.ts:138, 86 ch): Paid session. The customer failed to exit during the grace period. A new period began.
|
||||||
|
- **SQ** (sq.ts:140, 93 ch): Sesion i paguar. Klienti nuk doli brenda afatit kohor. Ka filluar një periudhë e re tarifimi.
|
||||||
|
- **⚑ NOTE:** Spelling: "brënda" → standard is "brenda" (no ë).
|
||||||
|
- **Verdict:**
|
||||||
|
|
||||||
|
## 2. `activeSince`
|
||||||
|
|
||||||
|
- **EN** (en.ts:254, 133 ch): Active since {{date}} · {{count}} version(s) in history. Publishing creates a new version; past sessions keep their original pricing.
|
||||||
|
- **SQ** (sq.ts:257, 137 ch): Aktive që nga {{date}} · {{count}} version(e) në histori. Publikimi krijon një version të ri; sesionet e kaluara ruajnë çmimin origjinal.
|
||||||
|
- **Verdict:**
|
||||||
|
|
||||||
|
## 3. `rateBlocksHint`
|
||||||
|
|
||||||
|
- **EN** (en.ts:263, 256 ch): Each band lasts a number of hours and bills at its own price; bands are consumed in order (the first hours, then the next hours). The last band is \"thereafter\" (open-ended) — its price applies once the ladder is exhausted. Price is per billing increment.
|
||||||
|
- **SQ** (sq.ts:266, 251 ch): Çdo brez zgjat një numër orësh dhe faturohet me çmimin e tij; brezat konsumohen me radhë (orët e para, pastaj orët në vijim). Brezi i fundit është \"më pas\" (i hapur) — çmimi i tij zbatohet pas mbarimit të shkallës. Çmimi është për interval faturimi.
|
||||||
|
- **Verdict:**
|
||||||
|
|
||||||
|
## 4. `defaultCardHint`
|
||||||
|
|
||||||
|
- **EN** (en.ts:275, 98 ch): The base rate applied when no time/seasonal tier matches. This alone is enough for most car parks.
|
||||||
|
- **SQ** (sq.ts:278, 116 ch): Çmimi bazë i zbatuar kur asnjë nivel kohor/sezonal nuk vlen. Kjo e vetme është mjaftueshëm për shumicën e parkimeve.
|
||||||
|
- **Verdict:**
|
||||||
|
|
||||||
|
## 5. `steppedHint`
|
||||||
|
|
||||||
|
- **EN** (en.ts:280, 210 ch): Set the TOTAL price for a stay up to a given time (e.g. up to 3h = 500). The first row whose limit ≥ the duration wins (the limit is inclusive). The last row's total repeats as a per-day price for longer stays.
|
||||||
|
- **SQ** (sq.ts:283, 233 ch): Vendos çmimin TOTAL për një qëndrim deri në një kohë të caktuar (p.sh. deri 3 orë = 500). Fiton rreshti i parë me kufi ≥ kohëzgjatjes (kufiri përfshihet). Totali i rreshtit të fundit përsëritet si çmim ditor për qëndrime më të gjata.
|
||||||
|
- **Verdict:**
|
||||||
|
|
||||||
|
## 6. `steppedTiersConflict`
|
||||||
|
|
||||||
|
- **EN** (en.ts:285, 235 ch): ⚠ Time/seasonal tiers do NOT apply when the base rate is 'By duration (up-to)' — the engine ignores them entirely. Remove the tiers, or switch the base rate to 'Hourly ladder' or 'Flat price'. Publishing is blocked until this is fixed.
|
||||||
|
- **SQ** (sq.ts:288, 244 ch): ⚠ Nivelet kohore/sezonale NUK zbatohen kur tarifa bazë është 'Sipas kohëzgjatjes (deri-në)' — motori i shpërfill plotësisht. Hiqi nivelet, ose ndrysho tarifën bazë në 'Shkallë orësh' a 'Çmim fiks'. Publikimi bllokohet derisa kjo të rregullohet.
|
||||||
|
- **Verdict:**
|
||||||
|
|
||||||
|
## 7. `tiersHint`
|
||||||
|
|
||||||
|
- **EN** (en.ts:287, 174 ch): Optional. Add tiers that apply only at certain hours/days/dates or for a category (e.g. happy hour, night rate, weekend, bus). With no tiers, just the base rate is published.
|
||||||
|
- **SQ** (sq.ts:290, 182 ch): Opsionale. Shto nivele tarifore që vlejnë vetëm në orë/ditë/data ose kategori të caktuara (p.sh. orë e lirë, tarifë nate, fundjavë, autobus). Pa nivele, publikohet vetëm tarifa bazë.
|
||||||
|
- **Verdict:**
|
||||||
|
|
||||||
|
## 8. `intro`
|
||||||
|
|
||||||
|
- **EN** (en.ts:526, 160 ch): Admin-defined plans the operator sells from — the price is looked up, never typed. Editing a plan publishes a new version; past sales keep their recorded price.
|
||||||
|
- **SQ** (sq.ts:537, 193 ch): Planet i përcakton admini; operatori vetëm shet prej tyre — çmimi merret automatikisht, nuk shkruhet. Ndryshimi i një plani publikon një version të ri; shitjet e mëparshme ruajnë çmimin e tyre.
|
||||||
|
- **Verdict:**
|
||||||
|
|
||||||
|
## 9. `relaysHint`
|
||||||
|
|
||||||
|
- **EN** (en.ts:361, 124 ch): Each relay opens one barrier. Set its direction; for transient entry, set which input terminal the entry button is wired to.
|
||||||
|
- **SQ** (sq.ts:370, 130 ch): Çdo rele hap një barrierë. Cakto drejtimin e saj; për hyrje kalimtare, cakto në cilin terminal hyrës është lidhur butoni i hyrjes.
|
||||||
|
- **Verdict:**
|
||||||
|
|
||||||
|
## 10. `outputsHint`
|
||||||
|
|
||||||
|
- **EN** (en.ts:364, 175 ch): Relays are OUTPUTS: each opens a barrier (or drives the button lamp). Set the relay number and direction. The input terminals (button, sensor) are in the Inputs section below.
|
||||||
|
- **SQ** (sq.ts:373, 178 ch): Relet janë DALJE: secila hap një barrierë (ose ndez dritën e butonit). Cakto numrin e relesë dhe drejtimin. Terminalet hyrëse (butoni, sensori) janë te seksioni Hyrjet më poshtë.
|
||||||
|
- **Verdict:**
|
||||||
|
|
||||||
|
## 11. `pulseOpenHint`
|
||||||
|
|
||||||
|
- **EN** (en.ts:366, 75 ch): How long a barrier relay is held open (jog). Applies to all barrier relays.
|
||||||
|
- **SQ** (sq.ts:375, 85 ch): Sa kohë mbahet rele e barrierës e hapur (jog). Vlen për të gjitha relet e barrierave.
|
||||||
|
- **Verdict:**
|
||||||
|
|
||||||
|
## 12. `inputsHint` ⚑
|
||||||
|
|
||||||
|
- **EN** (en.ts:369, 152 ch): Inputs are TERMINALS the host READS: the entry button and the presence/radar sensor. Each belongs to an entry barrier — it triggers or gates that relay.
|
||||||
|
- **SQ** (sq.ts:378, 153 ch): Hyrjet janë TERMINALE që hosti i LEXON: butoni i hyrjes dhe sensori i pranisë/radari. Secila i përket një barriere hyrëse — e gateron ose e nis atë rele.
|
||||||
|
- **⚑ NOTE:** "e gateron" is an anglicism ("gates it"). Native: "e kushtëzon" / "e lejon".
|
||||||
|
- **Verdict:**
|
||||||
|
|
||||||
|
## 13. `inputsIdleHighHint`
|
||||||
|
|
||||||
|
- **EN** (en.ts:371, 62 ch): This board idles inputs HIGH (status 1111); a press pulls LOW.
|
||||||
|
- **SQ** (sq.ts:380, 80 ch): Kjo pllakë i mban hyrjet HIGH në pushim (statusi 1111); një shtypje e ul në LOW.
|
||||||
|
- **Verdict:**
|
||||||
|
|
||||||
|
## 14. `inputsNoEntryRelay`
|
||||||
|
|
||||||
|
- **EN** (en.ts:373, 87 ch): No entry relay — add an 'Entry' or 'Entry + exit' relay in Outputs to assign terminals.
|
||||||
|
- **SQ** (sq.ts:382, 97 ch): Asnjë rele hyrëse — shto një rele 'Hyrje' ose 'Hyrje + dalje' te Daljet që të caktosh terminalet.
|
||||||
|
- **Verdict:**
|
||||||
|
|
||||||
|
## 15. `presenceInputHint` ⚑
|
||||||
|
|
||||||
|
- **EN** (en.ts:378, 290 ch): Input terminal the vehicle-presence sensor (induction loop or radar) is wired to. When set, exactly ONE ticket issues per car: the button prints only while a car is present, and no second ticket issues until the sensor clears (the car drove in) and a new car re-occupies it. Preferred mode.
|
||||||
|
- **SQ** (sq.ts:387, 275 ch): Terminali hyrës ku është lidhur sensori/laku i pranisë së automjetit. Kur vendoset, lëshohet vetëm NJË biletë për automjet: butoni printon vetëm kur ka makinë, dhe nuk lëshon biletë të dytë derisa laku të lirohet (makina hyri) dhe një makinë e re ta zërë. Mënyra e preferuar.
|
||||||
|
- **⚑ NOTE:** EN names "induction loop OR radar"; SQ "sensori/laku i pranisë" omits radar explicitly.
|
||||||
|
- **Verdict:**
|
||||||
|
|
||||||
|
## 16. `entryCooldownHint`
|
||||||
|
|
||||||
|
- **EN** (en.ts:381, 175 ch): When there's no presence sensor: repeat button presses are suppressed for this many seconds after a ticket. A fallback (not a guarantee) — a determined abuser can wait it out.
|
||||||
|
- **SQ** (sq.ts:390, 165 ch): Kur nuk ka sensor pranie: shtypjet e përsëritura të butonit shtypen për kaq sekonda pas një bilete. Zgjidhje rezervë (jo garanci) — një abuzues mund ta presë afatin.
|
||||||
|
- **Verdict:**
|
||||||
|
|
||||||
|
## 17. `presenceActiveLowHint`
|
||||||
|
|
||||||
|
- **EN** (en.ts:387, 178 ch): Tick if the presence sensor (e.g. a radar) idles HIGH and goes LOW on detection — the opposite of the button. This inverts that terminal's reading so 'present' is read correctly.
|
||||||
|
- **SQ** (sq.ts:396, 184 ch): Shëno nëse sensori i pranisë (p.sh. radari) qëndron HIGH në pushim dhe shkon LOW kur detekton — e kundërta e butonit. Kjo përmbys leximin e atij terminali që 'prania' të lexohet saktë.
|
||||||
|
- **Verdict:**
|
||||||
|
|
||||||
|
## 18. `buttonLightHint`
|
||||||
|
|
||||||
|
- **EN** (en.ts:391, 152 ch): The button's 12 V light on a spare relay. Blinks when the radar detects but the camera doesn't confirm a car; solid on when both confirm; off otherwise.
|
||||||
|
- **SQ** (sq.ts:400, 160 ch): Drita 12V e butonit e lidhur në një rele rezervë. Pulson kur radari detekton por kamera s'konfirmon makinë; ndizet fiks kur të dy konfirmojnë; përndryshe fiket.
|
||||||
|
- **Verdict:**
|
||||||
|
|
||||||
|
## 19. `anprHint` ⚑
|
||||||
|
|
||||||
|
- **EN** (en.ts:398, 203 ch): Enable to scan plates on this camera: the vision service reads the plate from a snapshot and feeds it as a read (advisory only — it never opens a barrier on its own). Requires the vision service running.
|
||||||
|
- **SQ** (sq.ts:408, 185 ch): Aktivizo që ky aparat të skanojë targat: shërbimi i vizionit lexon targën nga pamja dhe e dërgon si lexim (vetëm këshillues — nuk hap vetë barrierën). Kërkon shërbimin e vizionit aktiv.
|
||||||
|
- **⚑ NOTE:** SQ uses "aparat" for camera; elsewhere camera = "kamerë" (see testAnprHint). Inconsistent.
|
||||||
|
- **Verdict:**
|
||||||
|
|
||||||
|
## 20. `testAnprHint`
|
||||||
|
|
||||||
|
- **EN** (en.ts:402, 143 ch): Takes a live snapshot from this camera and tries to read a plate, reporting the result and the time it took. Point a plate at the camera first.
|
||||||
|
- **SQ** (sq.ts:412, 169 ch): Merr një pamje të drejtpërdrejtë nga kjo kamerë dhe përpiqet të lexojë një targë, duke raportuar rezultatin dhe kohën e nevojshme. Vendos një targë para kamerës më parë.
|
||||||
|
- **Verdict:**
|
||||||
|
|
||||||
|
## 21. `curveHint`
|
||||||
|
|
||||||
|
- **EN** (en.ts:443, 88 ch): Fee from entry at several durations — see where the daily cap flattens or windows shift.
|
||||||
|
- **SQ** (sq.ts:454, 96 ch): Tarifa nga hyrja për disa kohëzgjatje — shih ku rrafshohet kufiri ditor ose ndryshojnë dritaret.
|
||||||
|
- **Verdict:**
|
||||||
|
|
||||||
|
## 22. `versionHint`
|
||||||
|
|
||||||
|
- **EN** (en.ts:465, 128 ch): Move this subscriber to another version of the same plan. The price stays as billed; only the access hours change going forward.
|
||||||
|
- **SQ** (sq.ts:476, 139 ch): Zhvendos këtë abonent në një version tjetër të të njëjtit plan. Çmimi mbetet siç u faturua; ndryshon vetëm orari i lejuar nga këtu e tutje.
|
||||||
|
- **Verdict:**
|
||||||
|
|
||||||
|
## 23. `deleteInUse`
|
||||||
|
|
||||||
|
- **EN** (en.ts:550, 68 ch): Can't delete — subscriptions still use this plan. Retire it instead.
|
||||||
|
- **SQ** (sq.ts:561, 80 ch): S'mund të fshihet — abonime ende e përdorin këtë plan. Tërhiqe në vend të kësaj.
|
||||||
|
- **Verdict:**
|
||||||
|
|
||||||
|
## 24. `newVersionHint`
|
||||||
|
|
||||||
|
- **EN** (en.ts:553, 84 ch): This publishes a NEW version of the plan — existing sales keep their original price.
|
||||||
|
- **SQ** (sq.ts:564, 86 ch): Kjo publikon një version TË RI të planit — shitjet ekzistuese ruajnë çmimin origjinal.
|
||||||
|
- **Verdict:**
|
||||||
|
|
||||||
|
## 25. `timeframesHint`
|
||||||
|
|
||||||
|
- **EN** (en.ts:570, 168 ch): A scan outside the allowed window is charged the normal transient tariff for the out-of-window minutes (early entry is deferred to exit; late exit is gated until paid).
|
||||||
|
- **SQ** (sq.ts:581, 184 ch): Një skanim jashtë intervalit të lejuar tarifohet me tarifën normale kalimtare për minutat jashtë intervalit (hyrja e hershme shtyhet në dalje; dalja e vonuar bllokohet derisa paguhet).
|
||||||
|
- **Verdict:**
|
||||||
|
|
||||||
|
## 26. `reserveSubsHint` ⚑
|
||||||
|
|
||||||
|
- **EN** (en.ts:582, 164 ch): Hold a spot for each active subscriber's car(s) even when they're not parked — transients see 'full' sooner. Off: only cars inside count (handle overflow by valet).
|
||||||
|
- **SQ** (sq.ts:593, 198 ch): Mban një vend për makinat e çdo abonenti aktiv edhe kur nuk janë të parkuar — kalimtarët e shohin 'plot' më shpejt. Joaktiv: numërohen vetëm makinat brenda (mbingarkesa menaxhohet me parkim manual).
|
||||||
|
- **⚑ NOTE:** EN "handle overflow by valet" → SQ "parkim manual" (manual parking) — meaning shift; valet ≠ manual parking.
|
||||||
|
- **Verdict:**
|
||||||
|
|
||||||
|
## 27. `anprEntryHint`
|
||||||
|
|
||||||
|
- **EN** (en.ts:584, 195 ch): When on, a subscriber's plate read by a lane camera opens the barrier through the normal subscription gate. Off: subscribers must use their card/QR. Plate snapshots are still recorded either way.
|
||||||
|
- **SQ** (sq.ts:595, 207 ch): Kur është aktiv, targa e një abonenti e lexuar nga kamera e korsisë hap barrierën përmes portës normale të abonimit. Joaktiv: abonentët duhet të përdorin kartën/QR-në. Fotot e targave regjistrohen gjithsesi.
|
||||||
|
- **Verdict:**
|
||||||
|
|
||||||
|
## 28. `voucherHint`
|
||||||
|
|
||||||
|
- **EN** (en.ts:659, 128 ch): A receipt (Mandat Arkëtimi) adds cash; a disbursement (Mandat Pagese) removes it. The float only moves with an admin's sign-off.
|
||||||
|
- **SQ** (sq.ts:671, 97 ch): Mandat Arkëtimi shton para; Mandat Pagese heq para. Arka lëviz vetëm me autorizimin e një admini.
|
||||||
|
- **Verdict:**
|
||||||
|
|
||||||
|
## 29. `xReportHint`
|
||||||
|
|
||||||
|
- **EN** (en.ts:667, 83 ch): View only — nothing is recorded. These figures are signed when the shift is closed.
|
||||||
|
- **SQ** (sq.ts:679, 85 ch): Vetëm për shikim — asgjë nuk regjistrohet. Këto shifra nënshkruhen kur mbyllet turni.
|
||||||
|
- **Verdict:**
|
||||||
|
|
||||||
|
## 30. `gateBody`
|
||||||
|
|
||||||
|
- **EN** (en.ts:691, 80 ch): No shift is open. Open your shift so payments and exits are recorded against it.
|
||||||
|
- **SQ** (sq.ts:703, 95 ch): Asnjë turn nuk është i hapur. Hap turnin tënd që pagesat dhe daljet të regjistrohen te ky turn.
|
||||||
|
- **Verdict:**
|
||||||
|
|
||||||
|
## 31. `gateOtherBody`
|
||||||
|
|
||||||
|
- **EN** (en.ts:694, 120 ch): {{operator}} has an open shift. Only one shift may be open at a time — they must close theirs before you can open yours.
|
||||||
|
- **SQ** (sq.ts:706, 140 ch): {{operator}} ka një turn të hapur. Vetëm një turn mund të jetë i hapur njëkohësisht — ai duhet të mbyllë turnin para se ti të hapësh tëndin.
|
||||||
|
- **Verdict:**
|
||||||
|
|
||||||
|
## 32. `overstayHint` ⚑
|
||||||
|
|
||||||
|
- **EN** (en.ts:813, 155 ch): Earlier session paid. The customer failed to exit during the grace period. Payment for the new period is required. The total below is the new period's fee.
|
||||||
|
- **SQ** (sq.ts:827, 151 ch): Sesion i mëparshëm i paguar. Klienti nuk doli brënda afatit kohor. Kërkohet pagesë për periudhën e re. Totali më poshtë është tarifa e periudhës së re.
|
||||||
|
- **⚑ NOTE:** Spelling: "brënda" → standard is "brenda" (no ë).
|
||||||
|
- **Verdict:**
|
||||||
|
|
||||||
|
## 33. `subAssistHint`
|
||||||
|
|
||||||
|
- **EN** (en.ts:837, 101 ch): Prepaid subscription. Open the barrier to assist the exit (faulty reader / missing card). No payment.
|
||||||
|
- **SQ** (sq.ts:851, 108 ch): Abonim i parapaguar. Hap barrierën për të ndihmuar daljen (lexues me defekt / kartë e munguar). S'ka pagesë.
|
||||||
|
- **Verdict:**
|
||||||
|
|
||||||
|
## 34. `windowChargeHint`
|
||||||
|
|
||||||
|
- **EN** (en.ts:842, 154 ch): This subscriber parked outside their plan's allowed hours. They owe the transient tariff for the out-of-window time — take payment, then open the barrier.
|
||||||
|
- **SQ** (sq.ts:856, 153 ch): Ky abonent parkoi jashtë orarit të lejuar të planit. Detyrohet të paguajë tarifën kalimtare për kohën jashtë orarit — merr pagesën, pastaj hap barrierën.
|
||||||
|
- **Verdict:**
|
||||||
|
|
||||||
|
## 35. `cancelTicketHint`
|
||||||
|
|
||||||
|
- **EN** (en.ts:852, 115 ch): Cancels a wrongly-printed ticket. A signed record is kept (operator + reason); the original entry is never deleted.
|
||||||
|
- **SQ** (sq.ts:866, 129 ch): Anulon një biletë të printuar gabimisht. Ruhet një gjurmë e nënshkruar (operatori + arsyeja); hyrja origjinale nuk fshihet kurrë.
|
||||||
|
- **Verdict:**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary of flagged items
|
||||||
|
|
||||||
|
- `badgeOverstayTitle` — Spelling: "brënda" → standard is "brenda" (no ë).
|
||||||
|
- `inputsHint` — "e gateron" is an anglicism ("gates it"). Native: "e kushtëzon" / "e lejon".
|
||||||
|
- `presenceInputHint` — EN names "induction loop OR radar"; SQ "sensori/laku i pranisë" omits radar explicitly.
|
||||||
|
- `anprHint` — SQ uses "aparat" for camera; elsewhere camera = "kamerë" (see testAnprHint). Inconsistent.
|
||||||
|
- `reserveSubsHint` — EN "handle overflow by valet" → SQ "parkim manual" (manual parking) — meaning shift; valet ≠ manual parking.
|
||||||
|
- `overstayHint` — Spelling: "brënda" → standard is "brenda" (no ë).
|
||||||
|
|
||||||
|
_All other pairs: meaning judged faithful in automated review; confirm during your read._
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
# Komodo Stack environment — the COMPLETE reference of every env the booth stack reads:
|
||||||
|
# required, image-selection, set-by-compose (don't override), and the optional tunables
|
||||||
|
# with their code defaults. Under Komodo, plain env lives in the Stack definition
|
||||||
|
# (komodo/resources.toml); the two SECRETS come from Core's secret store, PER BOOTH and
|
||||||
|
# UNIQUE. This file is DOCUMENTATION — never fill in real secrets here. See
|
||||||
|
# wiki/decisions/fleet-deployment-komodo.md.
|
||||||
|
#
|
||||||
|
# A minimal working Stack only needs: REGISTRY, TAG, the two secrets, COOKIE_SECURE=0,
|
||||||
|
# VISION_ENABLED=1, WS_ALLOWED_ORIGINS. Everything under "OPTIONAL TUNABLES" has a safe
|
||||||
|
# default in code — set one only to override it.
|
||||||
|
|
||||||
|
# ════════════════════════════════════════════════════════════════════════════
|
||||||
|
# IMAGE SELECTION (picks which container image to pull — not server runtime env)
|
||||||
|
# ════════════════════════════════════════════════════════════════════════════
|
||||||
|
REGISTRY=git.infra.msai.al/mca/parking_solution
|
||||||
|
# IMMUTABLE per-commit tag. Manual + pinned. Bump per deploy. Never the moving `dev`
|
||||||
|
# on a PRODUCTION booth (a staging booth may track `dev`).
|
||||||
|
TAG=dev-830993b
|
||||||
|
|
||||||
|
# ════════════════════════════════════════════════════════════════════════════
|
||||||
|
# REQUIRED (no safe default — the server refuses to boot / login breaks without)
|
||||||
|
# ════════════════════════════════════════════════════════════════════════════
|
||||||
|
# Login signing secret (>=32 chars, no change-me/insecure/dev-only). openssl rand -hex 32.
|
||||||
|
JWT_SECRET=[[booth_<name>_jwt_secret]]
|
||||||
|
# Ledger-signing HMAC key — the anti-fraud root. DISTINCT per booth; never reuse. If unset
|
||||||
|
# it falls back to JWT_SECRET (warned). openssl rand -hex 32.
|
||||||
|
EVENT_SIGNING_KEY=[[booth_<name>_event_signing_key]]
|
||||||
|
# CRITICAL on the plain-HTTP booth LAN: cookies are Secure (HTTPS-only) by DEFAULT, so
|
||||||
|
# without =0 the auth cookie never sends and operators CANNOT log in. Set 1 only behind TLS.
|
||||||
|
COOKIE_SECURE=0
|
||||||
|
|
||||||
|
# ════════════════════════════════════════════════════════════════════════════
|
||||||
|
# BACKUP (encrypted on-site DB backup — see wiki/concepts/backup-recovery.md)
|
||||||
|
# ════════════════════════════════════════════════════════════════════════════
|
||||||
|
# Dedicated backup-ENCRYPTION key. SEPARATE from EVENT_SIGNING_KEY (independent rotation;
|
||||||
|
# backups travel to the target, the signing key must not). Per booth + unique. openssl rand
|
||||||
|
# -hex 32. ESCROW it offsite alongside EVENT_SIGNING_KEY — disaster recovery needs BOTH, and
|
||||||
|
# neither is ever stored inside the backup it unlocks. Backups stay OFF until this key is set
|
||||||
|
# AND the admin picks a target directory in the UI. The key is the ONLY backup env var — the
|
||||||
|
# target directory and retention (keep-last / keep-daily) are admin-chosen in the UI (Setup →
|
||||||
|
# Backup) and stored in the DB, so changing them needs no redeploy.
|
||||||
|
BACKUP_KEY=[[booth_<name>_backup_key]]
|
||||||
|
|
||||||
|
# ════════════════════════════════════════════════════════════════════════════
|
||||||
|
# COMMONLY SET (have defaults, but you usually want these explicit on a booth)
|
||||||
|
# ════════════════════════════════════════════════════════════════════════════
|
||||||
|
# Turn the ANPR/vision call on. Default "" (off-ish). Set 1 to enable. (Prod compose also
|
||||||
|
# forces VISION_RECOGNIZER=fast_alpr on the vision container.)
|
||||||
|
VISION_ENABLED=1
|
||||||
|
# Extra origins the booth WebSocket (/api/ws) accepts beyond same-origin. Comma-separated,
|
||||||
|
# e.g. http://parksystems.msai.al. Blank = only the same-origin booth URL. Default "".
|
||||||
|
WS_ALLOWED_ORIGINS=
|
||||||
|
|
||||||
|
# ════════════════════════════════════════════════════════════════════════════
|
||||||
|
# SET BY COMPOSE — do NOT put these in the Stack (the compose files own them)
|
||||||
|
# ════════════════════════════════════════════════════════════════════════════
|
||||||
|
# VISION_URL=http://127.0.0.1:8089 # prod override (host-net server → loopback vision)
|
||||||
|
# DATABASE_URL=/data/parking.sqlite # the mounted volume (the signed ledger)
|
||||||
|
# VISION_RECOGNIZER=fast_alpr # prod override on the vision container
|
||||||
|
|
||||||
|
# ════════════════════════════════════════════════════════════════════════════
|
||||||
|
# OPTIONAL TUNABLES — all have code defaults; set only to override. (defaults shown)
|
||||||
|
# ════════════════════════════════════════════════════════════════════════════
|
||||||
|
# --- networking / process ---
|
||||||
|
# PORT=3000 # server listen port
|
||||||
|
# HOST=0.0.0.0 # bind interface (127.0.0.1 = loopback only)
|
||||||
|
# BACKEND_HOST_IP= # override the auto-picked IP devices push back to
|
||||||
|
# # (multi-NIC hosts; usually auto-detected fine)
|
||||||
|
# WEB_DIST_DIR= # where the built SPA lives (the image sets it)
|
||||||
|
# --- logging ---
|
||||||
|
# LOG_LEVEL=info # debug|info|warn|error
|
||||||
|
# LOG_RETENTION_DAYS=30 # app_logs auto-purge age
|
||||||
|
# LOG_RETENTION_MAX_ROWS=50000 # app_logs row cap
|
||||||
|
# RECYCLE_BIN_RETENTION_DAYS=30 # soft-deleted items auto-purge age (0 = keep forever)
|
||||||
|
# --- snapshots (camera evidence; stored re-encoded, then pruned under disk pressure) ---
|
||||||
|
# SNAPSHOT_MAX_EDGE=1280 # downscale long edge (px) before storing
|
||||||
|
# SNAPSHOT_JPEG_QUALITY=80 # stored JPEG quality (recognition uses full-res original)
|
||||||
|
# Disk-pressure prune (daily safety valve; deletes oldest + VACUUM only when the disk is tight):
|
||||||
|
# SNAPSHOT_DISK_HIGH_PCT=70 # prune only when the DB's filesystem is ≥ this % used
|
||||||
|
# SNAPSHOT_DISK_FREE_TARGET_PCT=10 # try to free ~this % of the disk per run
|
||||||
|
# SNAPSHOT_MIN_KEEP=500 # never prune below this many snapshots (floor)
|
||||||
|
# SNAPSHOT_PRUNE_BATCH=200 # delete oldest in batches of this many
|
||||||
|
# --- device monitor / lane ---
|
||||||
|
# DEVICE_POLL_MS=8000 # device health poll interval
|
||||||
|
# PRINTER_POLL_MS=5000 # printer status poll interval
|
||||||
|
# LANE_BUSY_TTL_MS=30000 # how long a lane stays "busy" after a vehicle push
|
||||||
|
# CAPTURE_TTL_MS=30000 # snapshot evidence cache TTL
|
||||||
|
# --- ANPR / vision (server side) ---
|
||||||
|
# VISION_URL is compose-set (above). These are the knobs you may tweak per booth:
|
||||||
|
# VISION_TIMEOUT_MS=1500 # per /analyze call timeout
|
||||||
|
# VISION_MIN_CONFIDENCE=0.5 # advisory floor (telemetry/lane); below = low_confidence
|
||||||
|
# VISION_ENTRY_MIN_CONFIDENCE=0.85 # STRICT barrier-driving floor (auto entry/exit). A
|
||||||
|
# # read below this is ignored (falls back to card/QR).
|
||||||
|
# ANPR_DEBOUNCE_MS=12000 # same plate/camera within this = one presentation
|
||||||
|
# ANPR_POLL_MS=1000 # poll-until-confident: re-pull a fresh frame every N ms
|
||||||
|
# ANPR_POLL_WINDOW_MS=8000 # ...for this long AFTER THE LAST vehicle push (sliding:
|
||||||
|
# # a car arriving mid-loop extends it). RAISE if a slow
|
||||||
|
# # barrier means the car waits >8s before reading clean.
|
||||||
|
# ANPR_POLL_MAX_MS=30000 # hard ceiling on one loop from start (so a continuously
|
||||||
|
# # busy lane can't slide the window forever)
|
||||||
|
|
||||||
|
# ════════════════════════════════════════════════════════════════════════════
|
||||||
|
# VISION CONTAINER env (the Python ANPR service — its OWN process, prefix VISION_)
|
||||||
|
# Mostly compose-set; documented here for completeness. (defaults shown)
|
||||||
|
# ════════════════════════════════════════════════════════════════════════════
|
||||||
|
# VISION_RECOGNIZER=fast_alpr # stub | fast_alpr (prod compose forces fast_alpr)
|
||||||
|
# VISION_HOST=0.0.0.0 # bind (prod publishes 127.0.0.1 only — see compose)
|
||||||
|
# VISION_PORT=8089
|
||||||
|
# VISION_DETECTOR_MODEL=yolo-v9-t-384-license-plate-end2end
|
||||||
|
# VISION_OCR_MODEL=cct-xs-v2-global-model
|
||||||
|
# VISION_MIN_CONFIDENCE=0.5 # the Python service's own floor (keep ~in sync w/ server)
|
||||||
|
|
||||||
|
# ════════════════════════════════════════════════════════════════════════════
|
||||||
|
# SECRETS — referenced by name in resources.toml, stored in Core (never inline here)
|
||||||
|
# ════════════════════════════════════════════════════════════════════════════
|
||||||
|
# JWT_SECRET -> [[booth_<name>_jwt_secret]] (login)
|
||||||
|
# EVENT_SIGNING_KEY -> [[booth_<name>_event_signing_key]] (ledger signing — fraud root)
|
||||||
|
# BACKUP_KEY -> [[booth_<name>_backup_key]] (backup encryption — escrow offsite)
|
||||||
|
# periphery passkey -> [[periphery_passkey_booth_<name>]] (agent onboarding)
|
||||||
|
# registry account -> [[gitea_registry_account]] (image pull)
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
# `komodo/` — fleet deployment as code
|
||||||
|
|
||||||
|
Infra-as-code for the **Komodo Core** control plane that deploys the parking appliance to the
|
||||||
|
booth fleet over the **NetBird** mesh. See `wiki/decisions/fleet-deployment-komodo.md` for the
|
||||||
|
rationale, threat-model analysis, and the three settled choices (many/growing fleet · deploys
|
||||||
|
are **manual + pinned** · secrets are **Komodo-managed, per-booth**).
|
||||||
|
|
||||||
|
This directory does **not** change how images are built or how the app runs — it's only the
|
||||||
|
control plane. The booth still runs the same `docker-compose.yml` + `docker-compose.prod.yml`
|
||||||
|
([[container-deployment]]); Komodo just drives them remotely instead of someone SSH-ing in to
|
||||||
|
run `booth.sh`.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
- **`resources.toml`** — the Komodo resource definitions (Servers, Stacks, optional Builders/
|
||||||
|
Procedures), synced into Core via a **ResourceSync**. This is the reviewable, version-
|
||||||
|
controlled source of truth for *which booth runs what*.
|
||||||
|
- **`.env.komodo.example`** — the variables a Stack expects, documenting what comes from Core's
|
||||||
|
**secret store** (per-booth `JWT_SECRET` / `EVENT_SIGNING_KEY` / `BACKUP_KEY`) vs. plain Stack env.
|
||||||
|
|
||||||
|
## Promotion: dev → stage → main
|
||||||
|
|
||||||
|
Three tiers (see `wiki/decisions/fleet-deployment-komodo.md`):
|
||||||
|
|
||||||
|
- **`dev`** — the working branch. CI builds `:dev` / `:dev-<sha>`. No booth deploys off it.
|
||||||
|
- **`stage`** — what the **staging booth (`park-buzi`)** runs, to test the app in real-world
|
||||||
|
conditions. When `dev` is confident-ready, **merge `dev → stage`**; CI builds `:stage` /
|
||||||
|
`:stage-<sha>`; then **bump `TAG=stage-<sha>` in `resources.toml`** to that build and deploy
|
||||||
|
from Core (manual, pinned — no webhook even on staging).
|
||||||
|
- **`main`** — vetted **production** booths: `:main-<sha>`, manual + pinned. `main` only gets what
|
||||||
|
survived staging.
|
||||||
|
|
||||||
|
> The `TAG=stage-<sha>` in `resources.toml` is a **pinned pointer**: the moving `:stage` tag exists
|
||||||
|
> but we deploy the immutable sha so a booth runs a known image. Re-pin on each promotion.
|
||||||
|
|
||||||
|
## How Core consumes this (one-time)
|
||||||
|
|
||||||
|
In Komodo Core, create a **ResourceSync** pointing at this repo + path (`komodo/resources.toml`),
|
||||||
|
on the branch you manage from (e.g. `main`). Core reads the file and reconciles Servers/Stacks to
|
||||||
|
match. Thereafter, a PR to this directory + a sync is how you change the fleet — no clicking.
|
||||||
|
|
||||||
|
> Komodo's TOML schema evolves across releases. Treat `resources.toml` as a **starting sketch**:
|
||||||
|
> `resources.toml` mirrors the **working `park-buzi` Stack** (built by hand in the Core UI, then
|
||||||
|
> exported to TOML — so field names match the running Komodo version, v2.2). Import it into the
|
||||||
|
> sync **Unmanaged** first and review the diff; it should be ~empty against the live Stack.
|
||||||
|
|
||||||
|
## How servers get created — NOT here
|
||||||
|
|
||||||
|
There is **no `[[server]]` block** in `resources.toml`. Servers are created by the **Periphery
|
||||||
|
agent onboarding outbound**: in Core, create a one-time **Onboarding Key** (Settings →
|
||||||
|
Onboarding), then install Periphery on the booth passing `--onboarding-key` + `--core-address`
|
||||||
|
(Core's reverse-proxy URL, reached over the NetBird mesh) + `--connect-as=<booth-name>`. The
|
||||||
|
agent self-registers, generates its own auto-rotating key pair (private key never leaves the
|
||||||
|
booth), and connects **outbound** — the booth opens **no inbound port**. The sync owns only the
|
||||||
|
**Stack**, which references the server by the name it onboarded as (`server = "park-buzi"`). See
|
||||||
|
`wiki/decisions/fleet-deployment-komodo.md`.
|
||||||
|
|
||||||
|
## Adding booth N
|
||||||
|
|
||||||
|
Copy the `[[stack]]` block, change `name`, `server` (its onboarded name), and the per-booth
|
||||||
|
secret references (`[[park_<site>_jwt_secret]]`, `[[park_<site>_event_signing_key]]`,
|
||||||
|
`[[park_<site>_backup_key]]`). Create those secrets in Core's store first. Set `branch` +
|
||||||
|
`TAG` for the tier the booth runs (staging → `stage` / `stage-<sha>`; production → `main` /
|
||||||
|
`main-<sha>`).
|
||||||
|
|
||||||
|
## Hard rules encoded here (do not relax without updating the decision page)
|
||||||
|
|
||||||
|
1. **No deploy webhook on a booth Stack.** Deploys are a human action; pin an immutable
|
||||||
|
`TAG=<branch>-<sha>` (staging → `stage-<sha>`, production → `main-<sha>`). A moving tag on a
|
||||||
|
booth is the non-determinism we rejected — and we hold that line even on the staging booth.
|
||||||
|
2. **Onboarding, outbound, mesh-only.** Servers self-register via an onboarding key; Periphery
|
||||||
|
connects outbound to Core's mesh URL and exposes no inbound port. Never a LAN/WAN address.
|
||||||
|
3. **Secrets are per-booth and unique.** `EVENT_SIGNING_KEY` signs the anti-fraud ledger — one
|
||||||
|
leak must taint one booth, never the fleet. Reference Core secrets by name; never inline a
|
||||||
|
real value in this file (it's in git).
|
||||||
|
4. **Volumes preserved.** The Stack must never run `compose down -v` — that would wipe the
|
||||||
|
`parking-data` volume (the signed ledger). Komodo's "destroy" is gated for the same reason.
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
# Komodo resources — parking appliance fleet (control plane as code)
|
||||||
|
#
|
||||||
|
# Synced into Komodo Core via a ResourceSync pointing at this file. Drives the SAME
|
||||||
|
# compose files the booth runs locally (docker-compose.yml + docker-compose.prod.yml);
|
||||||
|
# Komodo Periphery on each booth executes them. See:
|
||||||
|
# wiki/decisions/fleet-deployment-komodo.md (rationale + threat model)
|
||||||
|
# wiki/decisions/container-deployment.md (image build/tag/registry — unchanged)
|
||||||
|
#
|
||||||
|
# This file mirrors the WORKING park-buzi Stack (built by hand in the Core UI, then
|
||||||
|
# exported to TOML). Field names match the running Komodo version (v2.2).
|
||||||
|
#
|
||||||
|
# NO [[server]] block: servers are created by the AGENT onboarding outbound (a one-time
|
||||||
|
# onboarding key → Periphery self-registers with auto-rotating key pairs). The sync owns
|
||||||
|
# only the Stack; it references the server by the name it onboarded as (`connect_as`).
|
||||||
|
#
|
||||||
|
# Secrets ([[park_buzi_jwt_secret]] etc.) are REFERENCES to Komodo Core's secret store —
|
||||||
|
# per-booth + unique, never inlined here (this file is in git). JWT_SECRET gates login;
|
||||||
|
# EVENT_SIGNING_KEY signs the append-only anti-fraud ledger; BACKUP_KEY encrypts on-site DB
|
||||||
|
# backups (separate from the signing key; escrow it offsite — recovery needs both).
|
||||||
|
#
|
||||||
|
# Deploys are MANUAL + PINNED: park-buzi is the STAGING booth (real-world test of the app),
|
||||||
|
# so it tracks the `stage` branch + the `:stage` image, but is still deployed by hand with a
|
||||||
|
# PINNED immutable TAG=stage-<sha> (no webhook). Promotion: merge dev → stage when confident,
|
||||||
|
# CI builds :stage / :stage-<sha>, then bump TAG below to that sha and deploy from Komodo Core.
|
||||||
|
# A PRODUCTION booth tracks `main` + manual+pinned `:main-<sha>`. See
|
||||||
|
# wiki/decisions/fleet-deployment-komodo.md (dev → stage → main tiers).
|
||||||
|
|
||||||
|
##############################################################################
|
||||||
|
# Stack — the deployable unit for booth "park-buzi". One Stack per booth; add a
|
||||||
|
# new [[stack]] block per site (unique name, its own per-booth secret refs).
|
||||||
|
##############################################################################
|
||||||
|
|
||||||
|
[[stack]]
|
||||||
|
name = "park-buzi"
|
||||||
|
[stack.config]
|
||||||
|
server = "park-buzi"
|
||||||
|
git_provider = "git.infra.msai.al"
|
||||||
|
git_account = "komodo"
|
||||||
|
repo = "mca/parking_solution"
|
||||||
|
branch = "stage"
|
||||||
|
file_paths = [
|
||||||
|
"docker-compose.yml",
|
||||||
|
"docker-compose.prod.yml"
|
||||||
|
]
|
||||||
|
registry_provider = "git.infra.msai.al"
|
||||||
|
registry_account = "komodo"
|
||||||
|
environment = """
|
||||||
|
REGISTRY=git.infra.msai.al/mca/parking_solution
|
||||||
|
# Staging booth: pinned immutable stage-<sha>. After each promotion (merge dev → stage, CI builds
|
||||||
|
# :stage-<sha>), bump this to the new sha and re-sync/deploy from Core. The moving `:stage` tag
|
||||||
|
# exists as the pointer; we deploy the sha, not the mover.
|
||||||
|
TAG=stage-d2ab2e0
|
||||||
|
COOKIE_SECURE=0
|
||||||
|
VISION_ENABLED=1
|
||||||
|
WS_ALLOWED_ORIGINS=
|
||||||
|
JWT_SECRET=[[park_buzi_jwt_secret]]
|
||||||
|
EVENT_SIGNING_KEY=[[park_buzi_event_signing_key]]
|
||||||
|
BACKUP_KEY=[[park_buzi_backup_key]]
|
||||||
|
"""
|
||||||
+2
-1
@@ -12,7 +12,8 @@
|
|||||||
"lint": "turbo run lint",
|
"lint": "turbo run lint",
|
||||||
"typecheck": "turbo run typecheck",
|
"typecheck": "turbo run typecheck",
|
||||||
"test": "turbo run test",
|
"test": "turbo run test",
|
||||||
"seed:admin": "pnpm --filter @parking/server seed-admin"
|
"seed:admin": "pnpm --filter @parking/server seed-admin",
|
||||||
|
"db:reset": "pnpm --filter @parking/db db:reset"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"turbo": "2.9.18",
|
"turbo": "2.9.18",
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
-- Per-user UI font scale (PERCENT of base; 100 = base). Persisted like `theme`/`language`
|
||||||
|
-- so an operator's chosen text size is restored on the next login from any booth. Additive
|
||||||
|
-- ALTER ADD COLUMN — backward-compatible; existing users default to 100 (base). The client
|
||||||
|
-- clamps to 80–160 in steps of 10. Printed tickets are unaffected (server-rendered).
|
||||||
|
ALTER TABLE `users` ADD `font_scale` integer DEFAULT 100 NOT NULL;
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
-- The QR/RFID access reader was misnamed "GEE" on a wrong early assumption; it's actually a
|
||||||
|
-- Dingtian DT-008 (same vendor as the relay board). The driver was renamed
|
||||||
|
-- `gee-qr-reader` → `dingtian-qr-reader`. `devices.driver_id` is the persisted registry
|
||||||
|
-- lookup key, so rewrite existing reader rows or they'd fail to resolve a driver. Idempotent.
|
||||||
|
UPDATE `devices` SET `driver_id` = 'dingtian-qr-reader' WHERE `driver_id` = 'gee-qr-reader';
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
-- The on-site admin chooses where encrypted DB backups are written (a mounted USB/SATA/
|
||||||
|
-- SMB/NFS path), from the Backup screen — not a server env var. Additive, nullable ALTER:
|
||||||
|
-- null = not configured (backups stay a no-op). The BACKUP_KEY stays an env/Komodo secret
|
||||||
|
-- (a key must NEVER live in the DB it backs up). See wiki/concepts/backup-recovery.md.
|
||||||
|
ALTER TABLE `site_config` ADD `backup_target_dir` text;
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
-- Backup retention is OPERATIONAL POLICY the on-site admin tunes from the Backup screen, not a
|
||||||
|
-- server env var requiring a redeploy. Two additive, nullable columns on the single-row config;
|
||||||
|
-- null = fall back to the code default (keepLast 7, keepDailyDays 30). The backup ENGINE stays
|
||||||
|
-- parameterized; this just moves the source of truth env → DB. See wiki/concepts/backup-recovery.md.
|
||||||
|
ALTER TABLE `site_config` ADD `backup_keep_last` integer;--> statement-breakpoint
|
||||||
|
ALTER TABLE `site_config` ADD `backup_keep_daily_days` integer;
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
-- Drawer redesign (2026-07-01): operators RECORD cash movements freely; admins REVIEW them
|
||||||
|
-- after the fact (authorize/deny — a flag, not a reversal). New `drawer` resource with two
|
||||||
|
-- permissions in @parking/shared: drawer:create + drawer:review.
|
||||||
|
--
|
||||||
|
-- The built-in `admin` role gets ALL permissions in code (auth.ts ADMIN_PERMS = new
|
||||||
|
-- Set(PERMISSIONS)), so it needs NO seed row here. This grants the default `operator` role
|
||||||
|
-- the ability to record movements (drawer:create) — matching the prior behaviour where an
|
||||||
|
-- operator could raise a voucher. An admin can revoke it per-role in the Roles UI (it's just
|
||||||
|
-- data). drawer:review is admin-only, so it is NOT granted to operator.
|
||||||
|
--
|
||||||
|
-- Idempotent: role_permissions has a UNIQUE(role_id, permission) index, so re-running is a
|
||||||
|
-- no-op via OR IGNORE. See wiki/concepts/shift.md.
|
||||||
|
INSERT OR IGNORE INTO `role_permissions` (`role_id`, `permission`) VALUES
|
||||||
|
('operator','drawer:create');
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
-- Operator-issued entry (2026-07-01): when the physical entry button is broken, an operator
|
||||||
|
-- may ISSUE an entry ticket (a flagged mint, gated on real vehicle presence — radar + camera).
|
||||||
|
-- New permission `session:create` in @parking/shared. The built-in `admin` role gets ALL
|
||||||
|
-- permissions in code (auth.ts ADMIN_PERMS), so no seed row is needed for it. This grants the
|
||||||
|
-- default `operator` role the ability to issue — an admin can revoke it per-role in the Roles
|
||||||
|
-- UI (it's data). Idempotent via the UNIQUE(role_id, permission) index.
|
||||||
|
-- See wiki/concepts/operator-issued-entry.md.
|
||||||
|
INSERT OR IGNORE INTO `role_permissions` (`role_id`, `permission`) VALUES
|
||||||
|
('operator','session:create');
|
||||||
@@ -99,6 +99,48 @@
|
|||||||
"when": 1781885600000,
|
"when": 1781885600000,
|
||||||
"tag": "0013_anpr_entry_toggle",
|
"tag": "0013_anpr_entry_toggle",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 14,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1781885700000,
|
||||||
|
"tag": "0014_user_font_scale",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 15,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1781885800000,
|
||||||
|
"tag": "0015_dingtian_qr_reader_driverid",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 16,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1781885900000,
|
||||||
|
"tag": "0016_backup_target_dir",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 17,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1781886000000,
|
||||||
|
"tag": "0017_backup_retention",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 18,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1781886100000,
|
||||||
|
"tag": "0018_drawer_permissions",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 19,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1781886200000,
|
||||||
|
"tag": "0019_operator_session_create",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -26,7 +26,8 @@
|
|||||||
"lint": "tsc --noEmit",
|
"lint": "tsc --noEmit",
|
||||||
"db:generate": "drizzle-kit generate",
|
"db:generate": "drizzle-kit generate",
|
||||||
"db:migrate": "DATABASE_URL=\"${DATABASE_URL:-../../apps/server/parking.sqlite}\" drizzle-kit migrate",
|
"db:migrate": "DATABASE_URL=\"${DATABASE_URL:-../../apps/server/parking.sqlite}\" drizzle-kit migrate",
|
||||||
"db:migrate:runtime": "node scripts/migrate-runtime.mjs"
|
"db:migrate:runtime": "node scripts/migrate-runtime.mjs",
|
||||||
|
"db:reset": "DATABASE_URL=\"${DATABASE_URL:-../../apps/server/parking.sqlite}\" node scripts/reset-db.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@parking/shared": "workspace:*",
|
"@parking/shared": "workspace:*",
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// DESTRUCTIVE training/demo reset of the SQLite DB at DATABASE_URL. Deletes rows from
|
||||||
|
// whole CATEGORIES of tables so a site can be re-used to TRAIN operators/admins without
|
||||||
|
// leaving demo data behind. This is intentionally a CLI script (no UI button) so it
|
||||||
|
// cannot be triggered casually — and it is double-gated so it never runs on a real booth.
|
||||||
|
//
|
||||||
|
// ⚠ This TRUNCATES the append-only, hash-chained, SIGNED ledger (`ledger_events`).
|
||||||
|
// That is the anti-fraud record. A partial delete would break the chain, so a
|
||||||
|
// financial reset wipes the whole ledger back to empty (re-seeding starts a NEW
|
||||||
|
// chain under the same EVENT_SIGNING_KEY — the key is NOT touched here). Only ever
|
||||||
|
// do this on a TRAINING/DEMO box. See wiki/concepts/append-only-event-chain.md.
|
||||||
|
//
|
||||||
|
// Flags (combinable; at least one required):
|
||||||
|
// --all every category below (a blank-slate box)
|
||||||
|
// --financial transactional history: ledger (entry/exit/payment/void/shift/cash/
|
||||||
|
// anomaly), device telemetry, snapshots, subscription INSTANCES +
|
||||||
|
// their credentials/plates, blocklist. KEEPS users, devices, config,
|
||||||
|
// tariffs, subscription PLANS.
|
||||||
|
// --config site_config, devices, setup_state (re-runs first-run setup),
|
||||||
|
// tariffs + tariff_versions, subscription_plans.
|
||||||
|
// --users users, roles, role_permissions, auth sessions. (After this or --all,
|
||||||
|
// re-seed an admin: apps/server/scripts/seed-admin.mjs.)
|
||||||
|
//
|
||||||
|
// Safety gates (BOTH required):
|
||||||
|
// 1. env RESET_ALLOWED=1 — a real booth never sets this.
|
||||||
|
// 2. type the DB filename — interactive confirmation (skip with --yes ONLY in CI).
|
||||||
|
//
|
||||||
|
// Usage:
|
||||||
|
// RESET_ALLOWED=1 DATABASE_URL=apps/server/parking.sqlite \
|
||||||
|
// node packages/db/scripts/reset-db.mjs --financial
|
||||||
|
import { createInterface } from "node:readline";
|
||||||
|
import { basename, resolve } from "node:path";
|
||||||
|
import { existsSync } from "node:fs";
|
||||||
|
import Database from "better-sqlite3";
|
||||||
|
|
||||||
|
// --- Category → tables (child tables BEFORE parents; we also disable FKs for the txn). ---
|
||||||
|
const CATEGORIES = {
|
||||||
|
financial: [
|
||||||
|
"ledger_events",
|
||||||
|
"device_events",
|
||||||
|
"snapshots",
|
||||||
|
"subscription_plates",
|
||||||
|
"subscription_credentials",
|
||||||
|
"subscriptions",
|
||||||
|
"blocklist",
|
||||||
|
],
|
||||||
|
config: ["site_config", "devices", "setup_state", "tariff_versions", "tariffs", "subscription_plans"],
|
||||||
|
users: ["sessions", "role_permissions", "users", "roles"],
|
||||||
|
};
|
||||||
|
|
||||||
|
function parseArgs(argv) {
|
||||||
|
const flags = new Set(argv.filter((a) => a.startsWith("--")).map((a) => a.slice(2)));
|
||||||
|
const wantAll = flags.has("all");
|
||||||
|
const cats = wantAll ? Object.keys(CATEGORIES) : Object.keys(CATEGORIES).filter((c) => flags.has(c));
|
||||||
|
return { cats, autoYes: flags.has("yes"), wantAll };
|
||||||
|
}
|
||||||
|
|
||||||
|
function die(msg) {
|
||||||
|
console.error(`[reset] ${msg}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirm(promptText, expected) {
|
||||||
|
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
||||||
|
const answer = await new Promise((res) => rl.question(promptText, res));
|
||||||
|
rl.close();
|
||||||
|
return answer.trim() === expected;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const url = process.env.DATABASE_URL;
|
||||||
|
if (!url) die("DATABASE_URL is required");
|
||||||
|
const dbPath = resolve(url);
|
||||||
|
if (!existsSync(dbPath)) die(`no database at ${dbPath}`);
|
||||||
|
|
||||||
|
const { cats, autoYes, wantAll } = parseArgs(process.argv.slice(2));
|
||||||
|
if (cats.length === 0) {
|
||||||
|
die("nothing to do — pass --all, --financial, --config, and/or --users");
|
||||||
|
}
|
||||||
|
|
||||||
|
// GATE 1: env opt-in. A production booth never sets this.
|
||||||
|
if (process.env.RESET_ALLOWED !== "1") {
|
||||||
|
die(
|
||||||
|
`refusing to reset ${dbPath}\n` +
|
||||||
|
` set RESET_ALLOWED=1 to enable (real booths never set this).`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve the ordered, de-duplicated table list for the chosen categories.
|
||||||
|
const tables = [];
|
||||||
|
for (const c of cats) for (const t of CATEGORIES[c]) if (!tables.includes(t)) tables.push(t);
|
||||||
|
|
||||||
|
console.error(`\n⚠ DESTRUCTIVE RESET`);
|
||||||
|
console.error(` db : ${dbPath}`);
|
||||||
|
console.error(` categories: ${cats.join(", ")}${wantAll ? " (= everything)" : ""}`);
|
||||||
|
console.error(` tables : ${tables.join(", ")}`);
|
||||||
|
if (cats.includes("financial")) {
|
||||||
|
console.error(` NOTE: this TRUNCATES the signed append-only ledger. Training/demo only.`);
|
||||||
|
}
|
||||||
|
console.error("");
|
||||||
|
|
||||||
|
// GATE 2: typed confirmation of the DB filename (skippable only with --yes, for CI).
|
||||||
|
if (!autoYes) {
|
||||||
|
const fname = basename(dbPath);
|
||||||
|
const ok = await confirm(`Type the db filename to confirm (${fname}): `, fname);
|
||||||
|
if (!ok) die("confirmation did not match — aborted, nothing changed.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const sqlite = new Database(dbPath);
|
||||||
|
try {
|
||||||
|
// FKs OFF for the wipe so we can delete in any order without ordering hazards;
|
||||||
|
// a single transaction makes it all-or-nothing.
|
||||||
|
sqlite.pragma("foreign_keys = OFF");
|
||||||
|
const wipe = sqlite.transaction(() => {
|
||||||
|
const counts = {};
|
||||||
|
for (const t of tables) {
|
||||||
|
const before = sqlite.prepare(`SELECT COUNT(*) AS n FROM "${t}"`).get().n;
|
||||||
|
sqlite.prepare(`DELETE FROM "${t}"`).run();
|
||||||
|
counts[t] = before;
|
||||||
|
}
|
||||||
|
return counts;
|
||||||
|
});
|
||||||
|
const counts = wipe();
|
||||||
|
sqlite.pragma("foreign_keys = ON");
|
||||||
|
// Reclaim space + reset the WAL so the file shrinks (demo boxes get re-used a lot).
|
||||||
|
sqlite.exec("VACUUM");
|
||||||
|
|
||||||
|
console.error(`[reset] done. Rows deleted:`);
|
||||||
|
for (const t of tables) console.error(` ${String(counts[t]).padStart(7)} ${t}`);
|
||||||
|
if (cats.includes("users") || wantAll) {
|
||||||
|
console.error(
|
||||||
|
`\n[reset] users were cleared — re-seed an admin:\n` +
|
||||||
|
` ADMIN_USER=admin ADMIN_PASS='…' node apps/server/scripts/seed-admin.mjs`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
sqlite.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((e) => die(e.message));
|
||||||
@@ -73,6 +73,11 @@ export const users = sqliteTable("users", {
|
|||||||
theme: text("theme", { enum: ["dark", "light"] })
|
theme: text("theme", { enum: ["dark", "light"] })
|
||||||
.notNull()
|
.notNull()
|
||||||
.default("dark"),
|
.default("dark"),
|
||||||
|
// Preferred UI font scale (PERCENT of base, e.g. 100 = base, 120 = 20% larger).
|
||||||
|
// Persisted like `theme` (read on login, restored from any booth). Integer percent
|
||||||
|
// avoids float drift; the client clamps to 80–160 in steps of 10. Printed tickets are
|
||||||
|
// unaffected (server-rendered).
|
||||||
|
fontScale: integer("font_scale").notNull().default(100),
|
||||||
// Optional operator profile metadata — display name + contact details. All
|
// Optional operator profile metadata — display name + contact details. All
|
||||||
// nullable; only username/password/role are required to create a user. fullName
|
// nullable; only username/password/role are required to create a user. fullName
|
||||||
// (when set) is the human label for audit/Z-report display.
|
// (when set) is the human label for audit/Z-report display.
|
||||||
@@ -259,6 +264,16 @@ export const siteConfig = sqliteTable("site_config", {
|
|||||||
* payload so V2 category pricing reprices identically at exit. null ⇒ the shared
|
* payload so V2 category pricing reprices identically at exit. null ⇒ the shared
|
||||||
* DEFAULT_VEHICLE_CATEGORY fallback. See wiki/concepts/tariff-time-tiers.md. */
|
* DEFAULT_VEHICLE_CATEGORY fallback. See wiki/concepts/tariff-time-tiers.md. */
|
||||||
defaultVehicleCategory: text("default_vehicle_category"),
|
defaultVehicleCategory: text("default_vehicle_category"),
|
||||||
|
/** Admin-chosen directory encrypted DB backups are written to — a mounted local/USB/
|
||||||
|
* SATA/SMB/NFS path. null = not configured (backups are a no-op). Set from the Backup
|
||||||
|
* screen; the encryption key (BACKUP_KEY) stays an env/Komodo secret and is NEVER stored
|
||||||
|
* here (a key must not live in the DB it backs up). See wiki/concepts/backup-recovery.md. */
|
||||||
|
backupTargetDir: text("backup_target_dir"),
|
||||||
|
/** Backup retention (admin-tunable policy, not env). Keep this many newest backups always.
|
||||||
|
* null ⇒ code default (7). See wiki/concepts/backup-recovery.md. */
|
||||||
|
backupKeepLast: integer("backup_keep_last"),
|
||||||
|
/** Beyond keepLast, keep one backup per day for this many days. null ⇒ code default (30). */
|
||||||
|
backupKeepDailyDays: integer("backup_keep_daily_days"),
|
||||||
updatedAt: text("updated_at")
|
updatedAt: text("updated_at")
|
||||||
.notNull()
|
.notNull()
|
||||||
.default(sql`(current_timestamp)`),
|
.default(sql`(current_timestamp)`),
|
||||||
|
|||||||
@@ -30,3 +30,13 @@ export function createTestDb(url = ":memory:"): { db: Db; sqlite: Database.Datab
|
|||||||
migrate(db, { migrationsFolder: MIGRATIONS_DIR });
|
migrate(db, { migrationsFolder: MIGRATIONS_DIR });
|
||||||
return { db, sqlite, close: () => sqlite.close() };
|
return { db, sqlite, close: () => sqlite.close() };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open an EXISTING SQLite file raw, WITHOUT running migrations — for tests that need to
|
||||||
|
* inspect a file produced elsewhere (e.g. a restored backup) exactly as written, without
|
||||||
|
* mutating it. Returns the raw better-sqlite3 handle so the caller depends only on
|
||||||
|
* `@parking/db/testing`, never on `better-sqlite3` directly.
|
||||||
|
*/
|
||||||
|
export function openRawDb(url: string): Database.Database {
|
||||||
|
return new Database(url);
|
||||||
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user