Compare commits
102 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a2e102f3dd | |||
| 14638c2e13 | |||
| 4f902d869e | |||
| a5e54a8b93 | |||
| 0180394c45 | |||
| d905dd19b4 | |||
| c5ed3f1308 | |||
| 0b7eb28dfa | |||
| d5ff2097bd | |||
| 1de209be48 | |||
| dc2cdc0a91 | |||
| fd9885e9ec | |||
| 52a89bfa56 | |||
| d9e6c13831 | |||
| 493210bbb0 | |||
| a9f18be700 | |||
| 72ad504b8d | |||
| 5cdf8f227b | |||
| 365b648282 | |||
| c03ef2a34b | |||
| d9829eb61f | |||
| bafa3282c7 | |||
| 93f9ebea05 | |||
| c21babf293 | |||
| 44f34d68c4 | |||
| 43c1f45e29 | |||
| 35e593ab63 | |||
| b4f1418858 | |||
| 9d73561855 | |||
| 094e963e5e | |||
| 6505a4a73b | |||
| 8b65e199a3 | |||
| f486dcbbfc | |||
| c142166972 | |||
| 306d136a08 | |||
| 61b9955160 | |||
| b7e4037fbe | |||
| d2ab2e022e | |||
| 33c4ea1e91 | |||
| 114a32e6f2 | |||
| 018328a877 | |||
| 266e9b0027 | |||
| d92b8d1e6a | |||
| 61de1fe772 | |||
| cfac14e09e | |||
| 1b86750b0d | |||
| 9c6741a485 | |||
| d0b609e375 | |||
| 8f32d90d28 | |||
| 07295f8063 | |||
| 652d6599d3 | |||
| 39c778fbac | |||
| e16bccc2f5 | |||
| 2ab001054d | |||
| 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 });
|
||||||
@@ -136,6 +256,22 @@ describe("AnprBridge", () => {
|
|||||||
expect((skips[0].detail as { plate?: string }).plate).toBe("ZZ999ZZ");
|
expect((skips[0].detail as { plate?: string }).plate).toBe("ZZ999ZZ");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("analyzes AT LEAST ONE frame even if the poll window already elapsed (loaded host)", async () => {
|
||||||
|
// Regression for a CI flake (2026-07-04): with a plain `while`, a window that lapsed
|
||||||
|
// between deadline-set and loop-entry (slow runner; here forced with a 0ms window)
|
||||||
|
// meant ZERO analyze attempts — the detection was silently dropped ("gave up") and no
|
||||||
|
// skip was recorded. The do-while guarantees one frame per detection regardless of load.
|
||||||
|
process.env.ANPR_POLL_WINDOW_MS = "0";
|
||||||
|
const cam = seedCamera({ anpr: true });
|
||||||
|
const vision = fakeVision({ plate: "ZZ999ZZ", confidence: 0.97 });
|
||||||
|
const bridge = new AnprBridge(db, vision, fakeSubFlow(null), silentLogger());
|
||||||
|
|
||||||
|
await captureReads(() => bridge.onVehicleDetected(cam));
|
||||||
|
expect(captureSnapshot).toHaveBeenCalledTimes(1); // the guaranteed first attempt
|
||||||
|
const skips = db.select().from(deviceEventsTable).where(eq(deviceEventsTable.kind, "anpr-skip")).all();
|
||||||
|
expect(skips).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
it("debounces: two vehicle events within the window analyze/emit at most once", async () => {
|
it("debounces: two vehicle events within the window analyze/emit at most once", 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 });
|
||||||
|
|||||||
+156
-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,79 @@ 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 {
|
||||||
|
// DO-while: a detection always analyzes AT LEAST ONE frame, however loaded the
|
||||||
|
// host — a plain while could zero-iterate if the window elapsed between setting
|
||||||
|
// the deadline and reaching the loop (seen as a CI flake with the tests' 5ms
|
||||||
|
// window; on a busy booth it would silently drop a real car's detection). Exit
|
||||||
|
// is via the breaks below (confident read, or next tick would pass the deadline).
|
||||||
|
do {
|
||||||
|
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);
|
||||||
|
} while (true);
|
||||||
|
} 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 +267,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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,13 +24,20 @@ export interface DeviceReadEvent {
|
|||||||
readonly deviceId: string; // devices id of the reader/scanner/camera
|
readonly deviceId: string; // devices id of the reader/scanner/camera
|
||||||
readonly value: string; // the ticket id / plate / card number
|
readonly value: string; // the ticket id / plate / card number
|
||||||
readonly kind: "ticket" | "plate" | "qr" | "card";
|
readonly kind: "ticket" | "plate" | "qr" | "card";
|
||||||
|
/** The CONFIRMED physical channel the value arrived on, when the reader tags it
|
||||||
|
* (the DT-008 output prefixes — see routes/qr-reader.ts). `optical` = decoded by
|
||||||
|
* the barcode/QR engine; `rf` = read from a card/chip. Undefined = legacy reader
|
||||||
|
* with no prefixes configured (channel unknown — flows must not assume). Lets the
|
||||||
|
* subscription match refuse an OPTICAL decode claiming an RF credential (a printed
|
||||||
|
* copy of a card's UID must not clone the card). */
|
||||||
|
readonly channel?: "optical" | "rf";
|
||||||
readonly at: string; // ISO-8601
|
readonly at: string; // ISO-8601
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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 +93,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 +177,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,9 +286,22 @@ 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) {
|
||||||
|
// Attach the presence sensor (if any) serving the SAME relay, so callers that gate on
|
||||||
|
// presence (the operator-issued entry) see it. Without this the ResolvedRelay carried
|
||||||
|
// no presenceInput and the presence gate read as "unavailable". Mirrors relayForButton.
|
||||||
|
const presence = inputsOf(controller).find((i) => i.role === "presence" && i.relay === spec.relay);
|
||||||
|
return {
|
||||||
|
controller,
|
||||||
|
relay: spec.relay,
|
||||||
|
direction: spec.direction,
|
||||||
|
presenceInput: presence?.input,
|
||||||
|
presenceKind: presence?.kind ?? "loop",
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { deviceEvents as deviceEventsTable, ledgerEvents, sessions, type Db } from "@parking/db";
|
||||||
|
import { createTestDb } from "@parking/db/testing";
|
||||||
|
import { flagDuplicateEntryPlate } from "./snapshot.js";
|
||||||
|
import { makeLog, silentLogger } from "./test-helpers.js";
|
||||||
|
import type { EventLog } from "./event-log.js";
|
||||||
|
|
||||||
|
// Entry-side duplicate-plate reconciliation (2026-07-04): when ANPR recognizes a plate on
|
||||||
|
// a fresh transient entry and that plate is already OPEN under another RECENT session,
|
||||||
|
// the same car most likely minted a second ticket (a motion radar dropped the stationary
|
||||||
|
// car → the button re-armed). We sign ONE entry.duplicatePlate anomaly for the operator
|
||||||
|
// to void. Post-hoc + advisory: recognition never gates the (already-open) barrier —
|
||||||
|
// exactly the non-blocking role the plate can play here.
|
||||||
|
|
||||||
|
let db: Db;
|
||||||
|
let log: EventLog;
|
||||||
|
|
||||||
|
const PLATE = "AA111BB";
|
||||||
|
const OLD = "11111111111";
|
||||||
|
const NEW = "22222222222";
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
({ db } = createTestDb());
|
||||||
|
log = makeLog(db);
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Seed the prior entry's unsigned plate-read telemetry (what recognizePlate records). */
|
||||||
|
function seedPriorRead(opts: { identity?: string; plate?: string; direction?: string; agoMs?: number } = {}) {
|
||||||
|
db.insert(deviceEventsTable).values({
|
||||||
|
id: randomUUID(),
|
||||||
|
deviceId: "cam-entry",
|
||||||
|
category: "camera",
|
||||||
|
kind: "read",
|
||||||
|
detail: {
|
||||||
|
identity: opts.identity ?? OLD,
|
||||||
|
direction: opts.direction ?? "entry",
|
||||||
|
plate: opts.plate ?? PLATE,
|
||||||
|
snapshotId: "snap-old",
|
||||||
|
source: "entry-exit-snapshot",
|
||||||
|
},
|
||||||
|
occurredAt: new Date(Date.now() - (opts.agoMs ?? 60_000)).toISOString(),
|
||||||
|
}).run();
|
||||||
|
}
|
||||||
|
|
||||||
|
function seedSession(id: string, state: "open" | "closed") {
|
||||||
|
db.insert(sessions).values({
|
||||||
|
id,
|
||||||
|
identity: id,
|
||||||
|
source: "ticket",
|
||||||
|
enteredAt: new Date(Date.now() - 60_000).toISOString(),
|
||||||
|
state,
|
||||||
|
}).run();
|
||||||
|
}
|
||||||
|
|
||||||
|
const flag = () =>
|
||||||
|
flagDuplicateEntryPlate({ db, log, identity: NEW, plate: PLATE, snapshotId: "snap-new", logger: silentLogger() });
|
||||||
|
|
||||||
|
const anomalies = () =>
|
||||||
|
db.select().from(ledgerEvents).all().filter((r) => r.type === "anomaly");
|
||||||
|
|
||||||
|
describe("flagDuplicateEntryPlate", () => {
|
||||||
|
it("same plate OPEN under another recent session → signs ONE entry.duplicatePlate anomaly", async () => {
|
||||||
|
seedPriorRead();
|
||||||
|
seedSession(OLD, "open");
|
||||||
|
await flag();
|
||||||
|
expect(anomalies()).toHaveLength(1);
|
||||||
|
const a = anomalies()[0];
|
||||||
|
expect(a.identity).toBe(NEW); // keyed to the NEW (suspect) ticket
|
||||||
|
expect(a.payload).toMatchObject({
|
||||||
|
reasonCode: "entry.duplicatePlate",
|
||||||
|
duplicateEntrySuspected: true,
|
||||||
|
plate: PLATE,
|
||||||
|
otherIdentity: OLD,
|
||||||
|
snapshotId: "snap-new",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prior session already CLOSED → no anomaly (that car drove off; a re-visit is legit)", async () => {
|
||||||
|
seedPriorRead();
|
||||||
|
seedSession(OLD, "closed");
|
||||||
|
await flag();
|
||||||
|
expect(anomalies()).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prior read outside the window → no anomaly (stale coincidence, not a double press)", async () => {
|
||||||
|
seedPriorRead({ agoMs: 30 * 60_000 }); // beyond the 15-min default window
|
||||||
|
seedSession(OLD, "open");
|
||||||
|
await flag();
|
||||||
|
expect(anomalies()).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("own read (same identity) never flags itself", async () => {
|
||||||
|
seedPriorRead({ identity: NEW });
|
||||||
|
seedSession(NEW, "open");
|
||||||
|
await flag();
|
||||||
|
expect(anomalies()).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("different plate / exit-side reads are ignored", async () => {
|
||||||
|
seedPriorRead({ plate: "ZZ999ZZ" });
|
||||||
|
seedPriorRead({ direction: "exit" });
|
||||||
|
seedSession(OLD, "open");
|
||||||
|
await flag();
|
||||||
|
expect(anomalies()).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
+210
-23
@@ -12,10 +12,10 @@ import {
|
|||||||
} from "@parking/devices";
|
} from "@parking/devices";
|
||||||
import { DEFAULT_VEHICLE_CATEGORY, reasonPayload } from "@parking/shared";
|
import { DEFAULT_VEHICLE_CATEGORY, reasonPayload } from "@parking/shared";
|
||||||
import type { FastifyBaseLogger } from "fastify";
|
import type { FastifyBaseLogger } from "fastify";
|
||||||
import type { DeviceInputEvent } from "./device-events.js";
|
import type { DeviceInputEvent, LaneStatusEvent } 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";
|
||||||
|
|
||||||
@@ -48,9 +48,21 @@ import type { VisionClient } from "./vision-client.js";
|
|||||||
// input edges to track presence + "armed" per relay.
|
// input edges to track presence + "armed" per relay.
|
||||||
// - COOLDOWN (fallback, no feedback): `entryCooldownSec` suppresses repeat presses on
|
// - COOLDOWN (fallback, no feedback): `entryCooldownSec` suppresses repeat presses on
|
||||||
// the relay for N seconds after a ticket. A timer — mitigation, not a guarantee.
|
// the relay for N seconds after a ticket. A timer — mitigation, not a guarantee.
|
||||||
|
// When a loop IS wired the cooldown still runs as a BACKSTOP behind it: a motion
|
||||||
|
// radar can drop a STATIONARY car (no doppler return) and spuriously re-arm, and the
|
||||||
|
// cooldown bounds how fast that re-armed press can mint a second ticket.
|
||||||
|
// - CAMERA (when an entry camera is configured): a press is live only while the entry
|
||||||
|
// lane camera confirms a vehicle — the button lamp's SOLID state (button-light.ts).
|
||||||
|
// A radar false-positive (rain, a pedestrian) blinks the lamp but prints nothing.
|
||||||
|
// Camera-less sites keep the radar-only gate; a faulty camera is dropped via the
|
||||||
|
// admin bypass (wiki/concepts/entry-presence-bypass.md).
|
||||||
// A suppressed press is recorded as UNSIGNED telemetry (a no-op, not a fraud anomaly).
|
// A suppressed press is recorded as UNSIGNED telemetry (a no-op, not a fraud anomaly).
|
||||||
// See wiki/concepts/entry-double-press.md.
|
// See wiki/concepts/entry-double-press.md.
|
||||||
|
|
||||||
|
/** A presence signal the entry gate can require (or, when a device is faulty, the admin
|
||||||
|
* can bypass): the radar/loop presence input, or the camera vehicle-detection. */
|
||||||
|
export type PresenceSignal = "radar" | "camera";
|
||||||
|
|
||||||
/** Per-relay anti-double-press state, keyed `controllerId:relay`. */
|
/** Per-relay anti-double-press state, keyed `controllerId:relay`. */
|
||||||
interface RelayGuardState {
|
interface RelayGuardState {
|
||||||
/** Last successful ticket time (ms epoch) — drives the cooldown check. */
|
/** Last successful ticket time (ms epoch) — drives the cooldown check. */
|
||||||
@@ -72,6 +84,10 @@ export class EntryFlow {
|
|||||||
readonly #guard = new Map<string, RelayGuardState>();
|
readonly #guard = new Map<string, RelayGuardState>();
|
||||||
/** Optional vision client — passed to snapshotAsync so ANPR runs on the entry image. */
|
/** Optional vision client — passed to snapshotAsync so ANPR runs on the entry image. */
|
||||||
readonly #vision: VisionClient | null;
|
readonly #vision: VisionClient | null;
|
||||||
|
/** Live entry-lane camera state (LaneStatus mirror, fed by onLaneStatus). Gates the
|
||||||
|
* physical press when an entry camera is configured — advisory sensor, but here it
|
||||||
|
* only ever SUPPRESSES a reprint; it never opens a barrier or traps a car. */
|
||||||
|
#entryBusy = false;
|
||||||
|
|
||||||
constructor(db: Db, log: EventLog, logger: FastifyBaseLogger, vision: VisionClient | null = null) {
|
constructor(db: Db, log: EventLog, logger: FastifyBaseLogger, vision: VisionClient | null = null) {
|
||||||
this.#db = db;
|
this.#db = db;
|
||||||
@@ -121,6 +137,12 @@ export class EntryFlow {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Track the entry lane's camera state (wired to deviceEvents.onLaneStatus in
|
||||||
|
* server.ts). LaneStatus emits on every flip, so this mirror stays current. */
|
||||||
|
onLaneStatus(s: LaneStatusEvent): void {
|
||||||
|
this.#entryBusy = s.entry;
|
||||||
|
}
|
||||||
|
|
||||||
/** Stable per-relay key for the guard map. */
|
/** Stable per-relay key for the guard map. */
|
||||||
#relayKey(r: ResolvedRelay): string {
|
#relayKey(r: ResolvedRelay): string {
|
||||||
return `${r.controller.id}:${r.relay}`;
|
return `${r.controller.id}:${r.relay}`;
|
||||||
@@ -153,17 +175,35 @@ export class EntryFlow {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Why a press should be SUPPRESSED (no ticket), or null if it may proceed.
|
/** Why a press should be SUPPRESSED (no ticket), or null if it may proceed.
|
||||||
* PRESENCE mode is authoritative when a loop is wired; otherwise COOLDOWN; else no
|
* Three layered gates: CAMERA (when an entry camera is configured), PRESENCE
|
||||||
* guard (legacy). The two can coexist — presence first, cooldown as a backstop. */
|
* (when a loop is wired), and COOLDOWN — no longer alternatives: the cooldown
|
||||||
|
* runs as a backstop BEHIND presence, because a motion radar can drop a
|
||||||
|
* stationary car and spuriously re-arm one-car-one-ticket. */
|
||||||
#suppressReason(r: ResolvedRelay): string | null {
|
#suppressReason(r: ResolvedRelay): string | null {
|
||||||
const s = this.#guardState(r);
|
const s = this.#guardState(r);
|
||||||
|
const bypass = this.#presenceBypass();
|
||||||
|
|
||||||
if (typeof r.presenceInput === "number") {
|
// CAMERA GATE — the lamp's blink-vs-solid rule, enforced at the press: with an entry
|
||||||
|
// camera configured, a press is live only once the camera confirms a vehicle in the
|
||||||
|
// entry zone (SOLID). Blink (radar-only — rain, a pedestrian, a reflection) prints
|
||||||
|
// nothing. Only ever suppresses a ticket; never opens or traps (advisory rule kept).
|
||||||
|
// A camera-less site skips this; a faulty camera is dropped via the admin bypass.
|
||||||
|
if (!bypass.camera && !this.#entryBusy && this.#entryCameraConfigured()) {
|
||||||
|
return "no camera-confirmed vehicle in the entry zone";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Admin bypass for a FAULTY radar/loop: skip the presence-loop check so a press prints.
|
||||||
|
// A dead loop can't re-arm one-car-one-ticket, so the cooldown below is what stops a
|
||||||
|
// held button minting a burst. If no cooldown is configured there's no anti-double-press
|
||||||
|
// left — that's the admin's accepted tradeoff while bypassed. See
|
||||||
|
// wiki/concepts/entry-presence-bypass.md.
|
||||||
|
if (typeof r.presenceInput === "number" && !bypass.radar) {
|
||||||
// Physical one-car-one-ticket: a car must be present AND we must be armed (no
|
// Physical one-car-one-ticket: a car must be present AND we must be armed (no
|
||||||
// ticket already issued for this still-present car).
|
// ticket already issued for this still-present car).
|
||||||
if (!s.present) return "no vehicle at the barrier (presence loop clear)";
|
if (!s.present) return "no vehicle at the barrier (presence loop clear)";
|
||||||
if (!s.armed) return "ticket already issued for the car at the barrier";
|
if (!s.armed) return "ticket already issued for the car at the barrier";
|
||||||
return null;
|
// Fall THROUGH to the cooldown backstop: a presence-approved press can still be the
|
||||||
|
// SAME stationary car after a radar dropout re-armed the guard.
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof r.entryCooldownSec === "number" && r.entryCooldownSec > 0) {
|
if (typeof r.entryCooldownSec === "number" && r.entryCooldownSec > 0) {
|
||||||
@@ -176,6 +216,13 @@ export class EntryFlow {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Is at least one enabled camera bound to the entry lane? The camera gate applies only
|
||||||
|
* then — a site with no entry camera keeps the radar-only press gate. Read live (like
|
||||||
|
* the bypass flags) so adding/removing a camera needs no restart. */
|
||||||
|
#entryCameraConfigured(): boolean {
|
||||||
|
return devicesByDirection(this.#db, "camera", "entry").length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
/** Record a suppressed (repeat/no-car) entry press as UNSIGNED telemetry — a no-op,
|
/** Record a suppressed (repeat/no-car) entry press as UNSIGNED telemetry — a no-op,
|
||||||
* not a fraud anomaly, so the signed ledger stays clean (the operator's choice). */
|
* not a fraud anomaly, so the signed ledger stays clean (the operator's choice). */
|
||||||
#recordSuppressedPress(e: DeviceInputEvent, r: ResolvedRelay, reason: string): void {
|
#recordSuppressedPress(e: DeviceInputEvent, r: ResolvedRelay, reason: string): void {
|
||||||
@@ -229,9 +276,36 @@ 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 };
|
||||||
|
/** Presence signals that were BYPASSED (admin dropped them due to faulty hardware).
|
||||||
|
* Recorded on the signed entry so a ticket issued under a weakened gate is auditable. */
|
||||||
|
presenceBypassed?: PresenceSignal[];
|
||||||
|
},
|
||||||
|
): 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 +334,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,44 +350,160 @@ 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 ?? "∞"}` } : {}),
|
||||||
|
...(opts.presenceBypassed && opts.presenceBypassed.length > 0
|
||||||
|
? { presenceBypassed: opts.presenceBypassed }
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
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 — normally require BOTH radar/loop presence AND camera detection. An
|
||||||
|
// admin may BYPASS a signal when its device is faulty (site_config, signed config_change);
|
||||||
|
// the bypassed signal is dropped as a requirement and RECORDED on the issued ticket.
|
||||||
|
const bypass = this.#presenceBypass();
|
||||||
|
const bypassed: PresenceSignal[] = [];
|
||||||
|
|
||||||
|
// Radar/loop side. A configured loop is only mandatory while radar is still REQUIRED;
|
||||||
|
// if radar is bypassed we skip the loop entirely (a dead loop is exactly why they bypass).
|
||||||
|
const radarRequired = !bypass.radar;
|
||||||
|
let radarPresent: boolean | null = null;
|
||||||
|
if (radarRequired) {
|
||||||
|
if (typeof resolved.presenceInput !== "number") {
|
||||||
|
return { ok: false, reason: "no presence loop on the entry barrier — operator issue unavailable (or bypass radar)" };
|
||||||
|
}
|
||||||
|
radarPresent = this.#guardState(resolved).present;
|
||||||
|
} else {
|
||||||
|
bypassed.push("radar");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Camera side.
|
||||||
|
const cameraRequired = !bypass.camera;
|
||||||
|
if (!cameraRequired) bypassed.push("camera");
|
||||||
|
|
||||||
|
// Refuse only when a STILL-REQUIRED signal fails to confirm a vehicle.
|
||||||
|
const radarOk = !radarRequired || radarPresent === true;
|
||||||
|
const cameraOk = !cameraRequired || cameraBusy;
|
||||||
|
if (!radarOk || !cameraOk) {
|
||||||
|
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,
|
||||||
|
cameraBusy,
|
||||||
|
...(bypassed.length > 0 ? { presenceBypassed: bypassed } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
this.#logger.warn(
|
||||||
|
`operator entry refused by ${operator}: no vehicle present (radar=${radarPresent}, camera=${cameraBusy}, bypassed=[${bypassed.join(",")}])`,
|
||||||
|
);
|
||||||
|
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 } } : {}),
|
||||||
|
...(bypassed.length > 0 ? { presenceBypassed: bypassed } : {}),
|
||||||
|
});
|
||||||
|
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).
|
||||||
* Used on both the OPEN path and the refused/held anomaly paths — a turned-away or
|
* Used on both the OPEN path and the refused/held anomaly paths — a turned-away or
|
||||||
* held car is exactly when the operator wants the photo. */
|
* held car is exactly when the operator wants the photo. */
|
||||||
#fireSnapshot(direction: "entry", identity: string): void {
|
#fireSnapshot(direction: "entry", identity: string): void {
|
||||||
void snapshotAsync({ db: this.#db, direction, identity, logger: this.#logger, vision: this.#vision }).catch(
|
// `log` lets the ANPR ride-along flag a duplicate-plate entry (a signed anomaly) —
|
||||||
|
// still fire-and-forget; recognition never gates the open. See snapshot.ts.
|
||||||
|
void snapshotAsync({ db: this.#db, direction, identity, logger: this.#logger, vision: this.#vision, log: this.#log }).catch(
|
||||||
(err) => this.#logger.error(`entry snapshot error: ${(err as Error).message}`),
|
(err) => this.#logger.error(`entry snapshot error: ${(err as Error).message}`),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Current admin presence-gate bypass (site_config), read LIVE so a toggle takes effect
|
||||||
|
* with no restart. Default: nothing bypassed (the normal both-required gate). */
|
||||||
|
#presenceBypass(): { radar: boolean; camera: boolean } {
|
||||||
|
const cfg = this.#db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
|
||||||
|
return { radar: cfg?.bypassPresenceRadar ?? false, camera: cfg?.bypassPresenceCamera ?? false };
|
||||||
|
}
|
||||||
|
|
||||||
/** Build a live access adapter from a resolved controller row, or null. */
|
/** Build a live access adapter from a resolved controller row, or null. */
|
||||||
#buildAccess(row: DeviceRow): AccessControlDevice | null {
|
#buildAccess(row: DeviceRow): AccessControlDevice | null {
|
||||||
const driver = registry.get(row.driverId);
|
const driver = registry.get(row.driverId);
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import { beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { devices, siteConfig, ledgerEvents, type Db } from "@parking/db";
|
||||||
|
import { createTestDb } from "@parking/db/testing";
|
||||||
|
import { EntryFlow } from "./entry-flow.js";
|
||||||
|
import { makeLog, silentLogger } from "./test-helpers.js";
|
||||||
|
|
||||||
|
// The entry presence gate normally requires BOTH radar/loop presence AND camera detection.
|
||||||
|
// An admin may BYPASS a signal when its device is faulty (site_config, set via a signed
|
||||||
|
// endpoint). These tests pin the GATE decision in EntryFlow.issueForOperator under each
|
||||||
|
// bypass combination: a still-required-but-absent signal refuses (+ signs an anomaly); a
|
||||||
|
// bypassed signal is dropped and recorded. We assert the gate outcome via the refuse path
|
||||||
|
// (deterministic, no printer needed); the allow path is proven by getting PAST the gate
|
||||||
|
// (it then fails at printing — a different reason — which is exactly "the gate opened").
|
||||||
|
|
||||||
|
let db: Db;
|
||||||
|
let flow: EntryFlow;
|
||||||
|
|
||||||
|
const CTL = "ctl-entry";
|
||||||
|
const PRESENCE_INPUT = 2;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
({ db } = createTestDb());
|
||||||
|
// A controller with an entry barrier (R1), a presence loop on input 2, and an entry button
|
||||||
|
// on input 1 — the shape device-resolve expects (relays[] + inputs[]).
|
||||||
|
db.insert(devices).values({
|
||||||
|
id: CTL,
|
||||||
|
category: "access",
|
||||||
|
driverId: "stub-access",
|
||||||
|
config: {
|
||||||
|
relays: [{ relay: 1, direction: "entry" }],
|
||||||
|
inputs: [
|
||||||
|
{ input: 1, role: "button", relay: 1 },
|
||||||
|
{ input: PRESENCE_INPUT, role: "presence", relay: 1, kind: "loop" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
enabled: true,
|
||||||
|
}).run();
|
||||||
|
flow = new EntryFlow(db, makeLog(db), silentLogger());
|
||||||
|
});
|
||||||
|
|
||||||
|
function setBypass(patch: { radar?: boolean; camera?: boolean }) {
|
||||||
|
db.insert(siteConfig)
|
||||||
|
.values({ id: 1, bypassPresenceRadar: patch.radar ?? false, bypassPresenceCamera: patch.camera ?? false })
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: siteConfig.id,
|
||||||
|
set: { bypassPresenceRadar: patch.radar ?? false, bypassPresenceCamera: patch.camera ?? false },
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drive a presence loop edge so the flow's per-relay guard marks a car present/clear. */
|
||||||
|
async function setRadarPresent(present: boolean) {
|
||||||
|
await flow.onInput({
|
||||||
|
driverId: "stub-access",
|
||||||
|
deviceId: CTL,
|
||||||
|
input: PRESENCE_INPUT,
|
||||||
|
edge: present ? "on" : "off",
|
||||||
|
at: new Date().toISOString(),
|
||||||
|
source: "poll",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const anomalies = () =>
|
||||||
|
db.select().from(ledgerEvents).all().filter((r) => r.type === "anomaly");
|
||||||
|
|
||||||
|
describe("entry presence-gate bypass", () => {
|
||||||
|
it("no bypass + no vehicle → refuses and signs a noPresence anomaly", async () => {
|
||||||
|
const res = await flow.issueForOperator("admin", /*cameraBusy*/ false);
|
||||||
|
expect(res.ok).toBe(false);
|
||||||
|
expect(anomalies()).toHaveLength(1);
|
||||||
|
expect(anomalies()[0].payload).toMatchObject({ reasonCode: "entry.issue.noPresence" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("camera bypassed + radar present → gate OPENS (no refuse anomaly)", async () => {
|
||||||
|
setBypass({ camera: true });
|
||||||
|
await setRadarPresent(true);
|
||||||
|
const res = await flow.issueForOperator("admin", /*cameraBusy*/ false); // camera absent but bypassed
|
||||||
|
// Gate passed: no noPresence refusal. (It then proceeds to print — no printer configured,
|
||||||
|
// so it HOLDS with a print reason, not a presence reason. Either way the gate opened.)
|
||||||
|
const refusals = anomalies().filter((a) => (a.payload as { reasonCode?: string }).reasonCode === "entry.issue.noPresence");
|
||||||
|
expect(refusals).toHaveLength(0);
|
||||||
|
if (!res.ok) expect(res.reason).not.toMatch(/no vehicle detected/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("radar bypassed + camera busy → gate OPENS even with NO presence loop reading", async () => {
|
||||||
|
setBypass({ radar: true });
|
||||||
|
// radar NOT set present; camera busy=true → radar dropped, camera satisfies.
|
||||||
|
const res = await flow.issueForOperator("admin", /*cameraBusy*/ true);
|
||||||
|
const refusals = anomalies().filter((a) => (a.payload as { reasonCode?: string }).reasonCode === "entry.issue.noPresence");
|
||||||
|
expect(refusals).toHaveLength(0);
|
||||||
|
if (!res.ok) expect(res.reason).not.toMatch(/no vehicle detected/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("camera bypassed but radar STILL required and absent → refuses (only the faulty signal is dropped)", async () => {
|
||||||
|
setBypass({ camera: true });
|
||||||
|
await setRadarPresent(false); // radar required (not bypassed) and clear
|
||||||
|
const res = await flow.issueForOperator("admin", /*cameraBusy*/ true);
|
||||||
|
expect(res.ok).toBe(false);
|
||||||
|
const refusal = anomalies().find((a) => (a.payload as { reasonCode?: string }).reasonCode === "entry.issue.noPresence");
|
||||||
|
expect(refusal, "the still-required radar gates the button").toBeTruthy();
|
||||||
|
// The refusal records which signal was bypassed (audit).
|
||||||
|
expect(refusal!.payload).toMatchObject({ presenceBypassed: ["camera"] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("both bypassed → gate OPENS with no radar and no camera (press-to-print)", async () => {
|
||||||
|
setBypass({ radar: true, camera: true });
|
||||||
|
const res = await flow.issueForOperator("admin", /*cameraBusy*/ false);
|
||||||
|
const refusals = anomalies().filter((a) => (a.payload as { reasonCode?: string }).reasonCode === "entry.issue.noPresence");
|
||||||
|
expect(refusals).toHaveLength(0);
|
||||||
|
if (!res.ok) expect(res.reason).not.toMatch(/no vehicle detected/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
import { beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { devices, siteConfig, ledgerEvents, deviceEvents as deviceEventsTable, type Db } from "@parking/db";
|
||||||
|
import { createTestDb } from "@parking/db/testing";
|
||||||
|
import { registry, type PrinterDevice } from "@parking/devices";
|
||||||
|
import { EntryFlow } from "./entry-flow.js";
|
||||||
|
import { makeLog, silentLogger } from "./test-helpers.js";
|
||||||
|
|
||||||
|
// The PHYSICAL entry button's press gate (#suppressReason), layered (2026-07-04):
|
||||||
|
// CAMERA — with an entry camera configured, a press is live only while the entry lane
|
||||||
|
// camera confirms a vehicle (the button lamp's SOLID state). Blink (radar-only) prints
|
||||||
|
// nothing. Camera-less sites skip this; the admin camera bypass drops it.
|
||||||
|
// PRESENCE — one-car-one-ticket off the loop (unchanged).
|
||||||
|
// COOLDOWN — now a BACKSTOP behind presence, not an alternative: a motion radar drops a
|
||||||
|
// stationary car (no doppler return), spuriously re-arming the guard; the cooldown bounds
|
||||||
|
// how fast that re-armed press can mint a second ticket for the same car.
|
||||||
|
// A suppressed press is unsigned telemetry (entrySuppressed), never a ledger anomaly.
|
||||||
|
|
||||||
|
let db: Db;
|
||||||
|
let flow: EntryFlow;
|
||||||
|
|
||||||
|
const CTL = "ctl-entry";
|
||||||
|
const BUTTON_INPUT = 1;
|
||||||
|
const PRESENCE_INPUT = 2;
|
||||||
|
|
||||||
|
// A no-op printer that always succeeds, so the happy path reaches the signed
|
||||||
|
// vehicle_entry (the real drivers need hardware). Registered once (registry is global).
|
||||||
|
const noopPrinter: PrinterDevice = {
|
||||||
|
driverId: "test-printer-ok",
|
||||||
|
connect: async () => {},
|
||||||
|
disconnect: async () => {},
|
||||||
|
healthCheck: async () => ({ status: "ready" as const }),
|
||||||
|
printTicket: async () => {},
|
||||||
|
printReport: async () => {},
|
||||||
|
printSubscriptionCard: async () => {},
|
||||||
|
printReceipt: async () => {},
|
||||||
|
printWindowChargeNotice: async () => {},
|
||||||
|
};
|
||||||
|
if (!registry.get("test-printer-ok")) {
|
||||||
|
registry.register({
|
||||||
|
id: "test-printer-ok",
|
||||||
|
category: "printer",
|
||||||
|
label: "Test printer",
|
||||||
|
description: "always-succeeds stub for tests",
|
||||||
|
transports: [],
|
||||||
|
configFields: [],
|
||||||
|
create: () => noopPrinter,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
({ db } = createTestDb());
|
||||||
|
db.insert(devices).values({
|
||||||
|
id: CTL,
|
||||||
|
category: "access",
|
||||||
|
driverId: "stub-access",
|
||||||
|
config: {
|
||||||
|
relays: [{ relay: 1, direction: "entry" }],
|
||||||
|
inputs: [
|
||||||
|
{ input: BUTTON_INPUT, role: "button", relay: 1 },
|
||||||
|
{ input: PRESENCE_INPUT, role: "presence", relay: 1, kind: "radar" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
enabled: true,
|
||||||
|
}).run();
|
||||||
|
db.insert(devices).values({
|
||||||
|
id: "printer-entry",
|
||||||
|
category: "printer",
|
||||||
|
driverId: "test-printer-ok",
|
||||||
|
config: { direction: "entry" },
|
||||||
|
enabled: true,
|
||||||
|
}).run();
|
||||||
|
flow = new EntryFlow(db, makeLog(db), silentLogger());
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Add an entry camera row. The driver never builds (unknown id) — only its EXISTENCE
|
||||||
|
* matters to the press gate; snapshot capture failing is the normal fire-and-forget path. */
|
||||||
|
function addEntryCamera() {
|
||||||
|
db.insert(devices).values({
|
||||||
|
id: "cam-entry",
|
||||||
|
category: "camera",
|
||||||
|
driverId: "no-such-camera-driver",
|
||||||
|
config: { direction: "entry" },
|
||||||
|
enabled: true,
|
||||||
|
}).run();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setCameraBypass(on: boolean) {
|
||||||
|
db.insert(siteConfig)
|
||||||
|
.values({ id: 1, bypassPresenceCamera: on })
|
||||||
|
.onConflictDoUpdate({ target: siteConfig.id, set: { bypassPresenceCamera: on } })
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function edge(input: number, edge: "on" | "off") {
|
||||||
|
await flow.onInput({
|
||||||
|
driverId: "stub-access",
|
||||||
|
deviceId: CTL,
|
||||||
|
input,
|
||||||
|
edge,
|
||||||
|
at: new Date().toISOString(),
|
||||||
|
source: "poll",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const press = () => edge(BUTTON_INPUT, "on");
|
||||||
|
const radar = (present: boolean) => edge(PRESENCE_INPUT, present ? "on" : "off");
|
||||||
|
|
||||||
|
const entries = () =>
|
||||||
|
db.select().from(ledgerEvents).all().filter((r) => r.type === "vehicle_entry");
|
||||||
|
const suppressed = () =>
|
||||||
|
db.select().from(deviceEventsTable).all()
|
||||||
|
.map((r) => r.detail as { entrySuppressed?: boolean; reason?: string })
|
||||||
|
.filter((d) => d.entrySuppressed === true);
|
||||||
|
|
||||||
|
describe("entry press gate — camera (blink vs solid)", () => {
|
||||||
|
it("BLINK state (radar present, no camera confirmation) → press suppressed, nothing signed", async () => {
|
||||||
|
addEntryCamera();
|
||||||
|
await radar(true); // lamp would blink: radar sees something, camera does not
|
||||||
|
await press();
|
||||||
|
expect(entries()).toHaveLength(0);
|
||||||
|
expect(db.select().from(ledgerEvents).all()).toHaveLength(0); // no anomaly either — telemetry only
|
||||||
|
expect(suppressed()).toHaveLength(1);
|
||||||
|
expect(suppressed()[0].reason).toMatch(/camera/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("SOLID state (radar present + camera busy) → press prints and signs a vehicle_entry", async () => {
|
||||||
|
addEntryCamera();
|
||||||
|
await radar(true);
|
||||||
|
flow.onLaneStatus({ entry: true, exit: false }); // camera confirms → SOLID
|
||||||
|
await press();
|
||||||
|
expect(entries()).toHaveLength(1);
|
||||||
|
expect(suppressed()).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("camera-less site → the camera gate does not apply (radar-only, as before)", async () => {
|
||||||
|
await radar(true); // no camera row; lane state irrelevant
|
||||||
|
await press();
|
||||||
|
expect(entries()).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("camera bypassed (faulty camera) → press prints without camera confirmation", async () => {
|
||||||
|
addEntryCamera();
|
||||||
|
setCameraBypass(true);
|
||||||
|
await radar(true);
|
||||||
|
await press();
|
||||||
|
expect(entries()).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("no car at all (radar clear too) → suppressed even with the camera bypassed", async () => {
|
||||||
|
addEntryCamera();
|
||||||
|
setCameraBypass(true);
|
||||||
|
await press(); // radar never went on
|
||||||
|
expect(entries()).toHaveLength(0);
|
||||||
|
expect(suppressed()[0].reason).toMatch(/presence loop clear/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("entry press gate — cooldown backstop behind presence", () => {
|
||||||
|
/** Same lane but the button carries a cooldown, making it a backstop behind the loop. */
|
||||||
|
function setButtonCooldown(sec: number) {
|
||||||
|
db.delete(devices).run();
|
||||||
|
db.insert(devices).values({
|
||||||
|
id: CTL,
|
||||||
|
category: "access",
|
||||||
|
driverId: "stub-access",
|
||||||
|
config: {
|
||||||
|
relays: [{ relay: 1, direction: "entry" }],
|
||||||
|
inputs: [
|
||||||
|
{ input: BUTTON_INPUT, role: "button", relay: 1, cooldownSec: sec },
|
||||||
|
{ input: PRESENCE_INPUT, role: "presence", relay: 1, kind: "radar" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
enabled: true,
|
||||||
|
}).run();
|
||||||
|
db.insert(devices).values({
|
||||||
|
id: "printer-entry",
|
||||||
|
category: "printer",
|
||||||
|
driverId: "test-printer-ok",
|
||||||
|
config: { direction: "entry" },
|
||||||
|
enabled: true,
|
||||||
|
}).run();
|
||||||
|
}
|
||||||
|
|
||||||
|
it("radar dropout re-arm + quick re-press → caught by the cooldown (one ticket)", async () => {
|
||||||
|
setButtonCooldown(60);
|
||||||
|
await radar(true);
|
||||||
|
await press(); // ticket 1 (no camera configured — radar-only site)
|
||||||
|
expect(entries()).toHaveLength(1);
|
||||||
|
// The motion radar loses the STATIONARY car and re-fires: off (re-arms!) then on.
|
||||||
|
await radar(false);
|
||||||
|
await radar(true);
|
||||||
|
await press(); // presence gate says yes (present + re-armed) — the backstop must catch it
|
||||||
|
expect(entries()).toHaveLength(1);
|
||||||
|
expect(suppressed().some((d) => /cooldown/.test(d.reason ?? ""))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("without a cooldown the dropout re-press mints a second ticket (the documented residual risk)", async () => {
|
||||||
|
await radar(true);
|
||||||
|
await press();
|
||||||
|
await radar(false);
|
||||||
|
await radar(true);
|
||||||
|
await press();
|
||||||
|
expect(entries()).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still-present car re-pressing (no dropout) stays suppressed by one-car-one-ticket", async () => {
|
||||||
|
await radar(true);
|
||||||
|
await press();
|
||||||
|
await press(); // car never left the loop → not re-armed
|
||||||
|
expect(entries()).toHaveLength(1);
|
||||||
|
expect(suppressed().some((d) => /already issued/.test(d.reason ?? ""))).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { appLogs, type Db } from "@parking/db";
|
||||||
|
import { createTestDb } from "@parking/db/testing";
|
||||||
|
import { LogService, pinoDbStream } from "./log-service.js";
|
||||||
|
|
||||||
|
// pinoDbStream feeds backend warn+ lines into app_logs. Since 2026-07-04 the logger
|
||||||
|
// emits level NAMES ("warn") instead of pino's numeric codes (40) — for human-readable
|
||||||
|
// container logs — and the stream must accept BOTH encodings (numeric covers any
|
||||||
|
// default-configured pino). A level the tee can't resolve falls back to info → not
|
||||||
|
// persisted, never a crash.
|
||||||
|
|
||||||
|
let db: Db;
|
||||||
|
let stream: { write: (line: string) => void };
|
||||||
|
let teed: string[];
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
({ db } = createTestDb());
|
||||||
|
teed = [];
|
||||||
|
stream = pinoDbStream(new LogService(db), {
|
||||||
|
write: (line: string) => {
|
||||||
|
teed.push(line);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
} as unknown as NodeJS.WritableStream);
|
||||||
|
});
|
||||||
|
|
||||||
|
const rows = () => db.select().from(appLogs).all();
|
||||||
|
|
||||||
|
describe("pinoDbStream level encodings", () => {
|
||||||
|
it("persists a LABEL-level warn line (the current logger format)", () => {
|
||||||
|
stream.write(`{"level":"warn","time":"2026-07-04T18:14:11.453Z","msg":"label warn"}\n`);
|
||||||
|
expect(rows()).toHaveLength(1);
|
||||||
|
expect(rows()[0]).toMatchObject({ level: "warn", source: "backend", message: "label warn" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still persists a NUMERIC-level error line (legacy/default pino)", () => {
|
||||||
|
stream.write(`{"level":50,"time":1783179038453,"msg":"numeric error"}\n`);
|
||||||
|
expect(rows()[0]).toMatchObject({ level: "error", message: "numeric error" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("info stays stdout-only in both encodings (teed, not persisted)", () => {
|
||||||
|
stream.write(`{"level":"info","msg":"label info"}\n`);
|
||||||
|
stream.write(`{"level":30,"msg":"numeric info"}\n`);
|
||||||
|
expect(rows()).toHaveLength(0);
|
||||||
|
expect(teed).toHaveLength(2); // stdout tee always happens
|
||||||
|
});
|
||||||
|
|
||||||
|
it("an unresolvable level falls back to info (dropped), never throws", () => {
|
||||||
|
stream.write(`{"level":"loud","msg":"weird"}\n`);
|
||||||
|
stream.write(`not json at all\n`);
|
||||||
|
expect(rows()).toHaveLength(0);
|
||||||
|
expect(teed).toHaveLength(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -33,7 +33,10 @@ export interface LogRetention {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const DEFAULT_RETENTION: LogRetention = {
|
export const DEFAULT_RETENTION: LogRetention = {
|
||||||
maxAgeDays: Number(process.env.LOG_RETENTION_DAYS ?? 30),
|
// 60 days (~2 months) — the operator's chosen diagnostic window (2026-07-04),
|
||||||
|
// matched by the container-log rotation caps in docker-compose.prod.yml. The row
|
||||||
|
// cap below still bounds a burst regardless of age.
|
||||||
|
maxAgeDays: Number(process.env.LOG_RETENTION_DAYS ?? 60),
|
||||||
maxRows: Number(process.env.LOG_RETENTION_MAX_ROWS ?? 50_000),
|
maxRows: Number(process.env.LOG_RETENTION_MAX_ROWS ?? 50_000),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -192,9 +195,10 @@ export class LogService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* A pino-compatible write stream that forwards BACKEND warn+ lines into the LogService.
|
* A pino-compatible write stream that forwards BACKEND warn+ lines into the LogService.
|
||||||
* Pino writes one JSON object per line to this stream; we parse, map the numeric level
|
* Pino writes one JSON object per line to this stream; we parse, resolve the level
|
||||||
* to a name, and persist. Returned as `{ write }` so it can be passed as pino's stream.
|
* (name or numeric encoding), and persist. Returned as `{ write }` so it can be passed
|
||||||
* stdout still receives the same line (we tee), so console logging is unchanged.
|
* as pino's stream. stdout still receives the same line (we tee), so console logging is
|
||||||
|
* unchanged.
|
||||||
*/
|
*/
|
||||||
export function pinoDbStream(
|
export function pinoDbStream(
|
||||||
service: LogService,
|
service: LogService,
|
||||||
@@ -218,12 +222,17 @@ export function pinoDbStream(
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const obj = JSON.parse(line) as {
|
const obj = JSON.parse(line) as {
|
||||||
level?: number;
|
level?: number | string;
|
||||||
msg?: string;
|
msg?: string;
|
||||||
err?: { stack?: string; message?: string };
|
err?: { stack?: string; message?: string };
|
||||||
[k: string]: unknown;
|
[k: string]: unknown;
|
||||||
};
|
};
|
||||||
const level = NUM_TO_LEVEL[obj.level ?? 30] ?? "info";
|
// The logger emits level NAMES (formatters.level in server.ts, for human-
|
||||||
|
// readable container logs); a default pino config emits numbers. Accept both.
|
||||||
|
const level: LogLevel =
|
||||||
|
typeof obj.level === "string" && obj.level in LOG_LEVEL_ORDER
|
||||||
|
? (obj.level as LogLevel)
|
||||||
|
: NUM_TO_LEVEL[typeof obj.level === "number" ? obj.level : 30] ?? "info";
|
||||||
if (LOG_LEVEL_ORDER[level] < LOG_LEVEL_ORDER[BACKEND_PERSIST_MIN]) return;
|
if (LOG_LEVEL_ORDER[level] < LOG_LEVEL_ORDER[BACKEND_PERSIST_MIN]) return;
|
||||||
// Strip pino's noisy standard fields from the persisted context.
|
// Strip pino's noisy standard fields from the persisted context.
|
||||||
const { level: _l, time: _t, pid: _p, hostname: _h, msg, ...rest } = obj;
|
const { level: _l, time: _t, pid: _p, hostname: _h, msg, ...rest } = obj;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { devices, deviceEvents as deviceEventsTable, ledgerEvents, subscriptionCredentials, type Db } from "@parking/db";
|
||||||
|
import { createTestDb } from "@parking/db/testing";
|
||||||
|
import { ReadDispatcher } from "./read-dispatch.js";
|
||||||
|
import { ExitFlow } from "./exit-flow.js";
|
||||||
|
import { SubscriptionFlow } from "./subscription-flow.js";
|
||||||
|
import type { DeviceReadEvent } from "./device-events.js";
|
||||||
|
import { makeLog, silentLogger } from "./test-helpers.js";
|
||||||
|
|
||||||
|
// STRUCTURAL FILTER at the dispatcher (2026-07-04): a reader value that matched
|
||||||
|
// nothing AND can't possibly be a credential we issued (no ticket Luhn shape, no
|
||||||
|
// SUB-/SUBSESS- prefix, not a confirmed-RF read) is refused with UNSIGNED telemetry
|
||||||
|
// instead of reaching the exit flow and signing a noSession anomaly. Born from the
|
||||||
|
// park-buzi phantom optical decodes: red "who is exiting?" rows for NOBODY train the
|
||||||
|
// operator to ignore the signed feed. Anything plausibly ours STILL signs normally.
|
||||||
|
|
||||||
|
let db: Db;
|
||||||
|
let dispatcher: ReadDispatcher;
|
||||||
|
|
||||||
|
const READER = "reader-exit";
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
({ db } = createTestDb());
|
||||||
|
db.insert(devices).values({
|
||||||
|
id: "ctl-exit",
|
||||||
|
category: "access",
|
||||||
|
driverId: "stub-access",
|
||||||
|
config: { relays: [{ relay: 1, direction: "exit" }] },
|
||||||
|
enabled: true,
|
||||||
|
}).run();
|
||||||
|
db.insert(devices).values({
|
||||||
|
id: READER,
|
||||||
|
category: "reader",
|
||||||
|
driverId: "dingtian-qr-reader",
|
||||||
|
config: { serial: "H05MA5B0", direction: "exit" },
|
||||||
|
enabled: true,
|
||||||
|
}).run();
|
||||||
|
const log = makeLog(db);
|
||||||
|
dispatcher = new ReadDispatcher(db, new ExitFlow(db, log, silentLogger()), new SubscriptionFlow(db, log, silentLogger()), silentLogger());
|
||||||
|
});
|
||||||
|
|
||||||
|
function read(value: string, opts: { kind?: DeviceReadEvent["kind"]; channel?: DeviceReadEvent["channel"] } = {}): DeviceReadEvent {
|
||||||
|
return {
|
||||||
|
driverId: "dingtian-qr-reader",
|
||||||
|
deviceId: READER,
|
||||||
|
value,
|
||||||
|
kind: opts.kind ?? "qr",
|
||||||
|
...(opts.channel ? { channel: opts.channel } : {}),
|
||||||
|
at: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const ledger = () => db.select().from(ledgerEvents).all();
|
||||||
|
const unrecognized = () =>
|
||||||
|
db.select().from(deviceEventsTable).all()
|
||||||
|
.map((r) => r.detail as { unrecognizedRead?: boolean; value?: string })
|
||||||
|
.filter((d) => d.unrecognizedRead === true);
|
||||||
|
|
||||||
|
describe("read-dispatch structural filter", () => {
|
||||||
|
it("phantom 6-digit optical decode → refused, telemetry only, NOTHING signed", async () => {
|
||||||
|
const out = await dispatcher.dispatch(read("999459", { channel: "optical" }));
|
||||||
|
expect(out.accepted).toBe(false);
|
||||||
|
expect(out.reason).toMatch(/unrecognized/);
|
||||||
|
expect(ledger()).toHaveLength(0); // the whole point: no red row in the feed
|
||||||
|
expect(unrecognized()).toHaveLength(1);
|
||||||
|
expect(unrecognized()[0].value).toBe("999459");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("legacy untagged garbage ('C') → filtered too (works before prefixes are deployed)", async () => {
|
||||||
|
const out = await dispatcher.dispatch(read("C"));
|
||||||
|
expect(out.accepted).toBe(false);
|
||||||
|
expect(ledger()).toHaveLength(0);
|
||||||
|
expect(unrecognized()).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Luhn-valid unknown ticket → NOT filtered: the exit flow signs the noSession anomaly", async () => {
|
||||||
|
const out = await dispatcher.dispatch(read("00000000000")); // valid shape, no session
|
||||||
|
expect(out.accepted).toBe(false);
|
||||||
|
expect(unrecognized()).toHaveLength(0);
|
||||||
|
const anomalies = ledger().filter((r) => r.type === "anomaly");
|
||||||
|
expect(anomalies.length).toBeGreaterThan(0); // a real probe stays in the signed feed
|
||||||
|
});
|
||||||
|
|
||||||
|
it("unknown card on a CONFIRMED RF channel → NOT filtered (a physical card is a real event)", async () => {
|
||||||
|
await dispatcher.dispatch(read("1A86A158", { kind: "card", channel: "rf" }));
|
||||||
|
expect(unrecognized()).toHaveLength(0);
|
||||||
|
expect(ledger().filter((r) => r.type === "anomaly").length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("unknown SUB- code → NOT filtered (our own prefix = an interesting probe)", async () => {
|
||||||
|
await dispatcher.dispatch(read("SUB-DOESNOTEXIST", { channel: "optical" }));
|
||||||
|
expect(unrecognized()).toHaveLength(0);
|
||||||
|
expect(ledger().filter((r) => r.type === "anomaly").length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("an ENROLLED credential is matched BEFORE the filter (never hidden by it)", async () => {
|
||||||
|
// A card UID that would fail every shape test — enrolled, so it must still match.
|
||||||
|
db.insert(subscriptionCredentials).values({ id: randomUUID(), subscriptionId: "sub-1", kind: "rf", value: "999459" }).run();
|
||||||
|
await dispatcher.dispatch(read("999459")); // legacy untagged read of it
|
||||||
|
expect(unrecognized()).toHaveLength(0); // reached the subscription flow, not the filter
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
import { devices, eq, type Db } from "@parking/db";
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { devices, deviceEvents as deviceEventsTable, eq, type Db } from "@parking/db";
|
||||||
import type { FastifyBaseLogger } from "fastify";
|
import type { FastifyBaseLogger } from "fastify";
|
||||||
import type { DeviceReadEvent, ReadOutcome } from "./device-events.js";
|
import type { DeviceReadEvent, ReadOutcome } from "./device-events.js";
|
||||||
import type { ExitFlow } from "./exit-flow.js";
|
import type { ExitFlow } from "./exit-flow.js";
|
||||||
|
import { validateTicketCode } from "./entry-flow.js";
|
||||||
import type { SubscriptionFlow } from "./subscription-flow.js";
|
import type { SubscriptionFlow } from "./subscription-flow.js";
|
||||||
import { relayForDevice } from "./device-resolve.js";
|
import { relayForDevice } from "./device-resolve.js";
|
||||||
|
|
||||||
@@ -17,6 +19,22 @@ import { relayForDevice } from "./device-resolve.js";
|
|||||||
// it opens that exact barrier. An "entry" reader drives the entry side, an "exit"
|
// it opens that exact barrier. An "entry" reader drives the entry side, an "exit"
|
||||||
// reader the exit side; "both" defers to the flow's own inference (subscription:
|
// reader the exit side; "both" defers to the flow's own inference (subscription:
|
||||||
// session state; transient: exit).
|
// session state; transient: exit).
|
||||||
|
//
|
||||||
|
// STRUCTURAL FILTER (2026-07-04, operator-requested). The DT-008's scan engine
|
||||||
|
// false-decodes sunlight stripe patterns into short garbage codes (phantom reads —
|
||||||
|
// see wiki/entities/dingtian-dt008-reader.md), and each one was reaching the exit
|
||||||
|
// flow and signing an exit.refused.noSession anomaly: red "who is trying to exit?"
|
||||||
|
// rows for NOBODY, training the operator to ignore the feed (alarm fatigue is the
|
||||||
|
// adversary's friend). So a reader value that matched nothing AND cannot possibly be
|
||||||
|
// a credential we issued is dropped to UNSIGNED telemetry (device_events, still
|
||||||
|
// auditable) instead of the signed ledger. "Possibly ours" stays deliberately wide —
|
||||||
|
// any of these still reaches the flows and signs the normal refusal anomaly:
|
||||||
|
// - a Luhn-valid ticket shape (validateTicketCode — a forged/expired ticket is a
|
||||||
|
// real probe),
|
||||||
|
// - our issued-code prefixes (SUB- / SUBSESS-),
|
||||||
|
// - ANY read on a CONFIRMED RF channel (a physically present card, enrolled or
|
||||||
|
// not, is a real event — RF is never sun noise),
|
||||||
|
// - plates (different population; never shape-filtered here).
|
||||||
|
|
||||||
export class ReadDispatcher {
|
export class ReadDispatcher {
|
||||||
readonly #db: Db;
|
readonly #db: Db;
|
||||||
@@ -45,6 +63,20 @@ export class ReadDispatcher {
|
|||||||
if (sub) {
|
if (sub) {
|
||||||
return this.#subscription.run(resolved, e, sub);
|
return this.#subscription.run(resolved, e, sub);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Matched nothing — if the value can't even BE one of ours, it's scanner noise
|
||||||
|
// (phantom optical decode): refuse with unsigned telemetry, keep the signed feed
|
||||||
|
// for events that involve an actual credential or an actual card.
|
||||||
|
if ((e.kind === "qr" || e.kind === "card" || e.kind === "ticket") && !plausibleCredential(e)) {
|
||||||
|
this.#recordUnrecognized(e);
|
||||||
|
this.#logger.info(`read filtered (not a credential shape): '${e.value}' from ${e.deviceId}${e.channel ? ` ch=${e.channel}` : ""}`);
|
||||||
|
return {
|
||||||
|
accepted: false,
|
||||||
|
direction: resolved.direction === "entry" ? "entry" : "exit",
|
||||||
|
reason: "unrecognized code (no credential shape — telemetry only)",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// Not a subscription → transient ticket exit. An ENTRY reader can't produce a
|
// Not a subscription → transient ticket exit. An ENTRY reader can't produce a
|
||||||
// transient exit (transient entry is the button flow, not a reader), so reject+log
|
// transient exit (transient entry is the button flow, not a reader), so reject+log
|
||||||
// rather than treat an entry scan as an exit.
|
// rather than treat an entry scan as an exit.
|
||||||
@@ -53,4 +85,39 @@ export class ReadDispatcher {
|
|||||||
}
|
}
|
||||||
return this.#exit.handleAt(resolved, e);
|
return this.#exit.handleAt(resolved, e);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Unsigned telemetry for a filtered read — auditable in device_events, out of the
|
||||||
|
* signed feed. Mirrors the entry flow's suppressed-press pattern. */
|
||||||
|
#recordUnrecognized(e: DeviceReadEvent): void {
|
||||||
|
try {
|
||||||
|
this.#db
|
||||||
|
.insert(deviceEventsTable)
|
||||||
|
.values({
|
||||||
|
id: randomUUID(),
|
||||||
|
deviceId: e.deviceId,
|
||||||
|
category: "reader",
|
||||||
|
kind: "read",
|
||||||
|
detail: {
|
||||||
|
unrecognizedRead: true,
|
||||||
|
value: e.value,
|
||||||
|
readKind: e.kind,
|
||||||
|
...(e.channel ? { channel: e.channel } : {}),
|
||||||
|
reason: "no credential shape (phantom decode / garbage scan)",
|
||||||
|
},
|
||||||
|
occurredAt: e.at,
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
} catch (err) {
|
||||||
|
this.#logger.error(`unrecognized-read telemetry insert failed: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Could this reader value possibly be a credential WE issued (or a real card)?
|
||||||
|
* Deliberately WIDE — only shapes that can't be anything of ours are filtered. */
|
||||||
|
function plausibleCredential(e: DeviceReadEvent): boolean {
|
||||||
|
if (e.channel === "rf") return true; // a physically present card — never sun noise
|
||||||
|
if (validateTicketCode(e.value)) return true; // ticket shape (10–14 digits + Luhn)
|
||||||
|
if (/^SUB(SESS)?-/.test(e.value)) return true; // our subscription QR / window-slip ids
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,101 @@
|
|||||||
|
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)
|
||||||
|
// - GET /api/drawer/balance : the physical drawer balance NOW (cash (shift:read)
|
||||||
|
// payments + vouchers over the whole chain — the
|
||||||
|
// amount that carries across shifts).
|
||||||
|
// 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" };
|
||||||
|
});
|
||||||
|
|
||||||
|
// The physical drawer balance now. Same visibility as the open shift's X-report
|
||||||
|
// (shift:read) — the drawer is a single site-wide till, not per-operator data.
|
||||||
|
app.get("/api/drawer/balance", { preHandler: readGuard }, async () => shift.drawerBalance());
|
||||||
|
|
||||||
|
// 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);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { ledgerEvents, type Db } from "@parking/db";
|
||||||
|
import { createTestDb } from "@parking/db/testing";
|
||||||
|
import type { FastifyInstance } from "fastify";
|
||||||
|
import { buildServer } from "../server.js";
|
||||||
|
import { seedUser, login } from "../test-helpers.js";
|
||||||
|
|
||||||
|
// PUT /api/site-config/presence-bypass toggles the entry presence-gate bypass. It's a
|
||||||
|
// DEDICATED, SIGNED endpoint: each signal that actually changes appends a config_change to
|
||||||
|
// the ledger (attributed), and it persists to site_config. Admin-only.
|
||||||
|
|
||||||
|
let db: Db;
|
||||||
|
let close: () => void;
|
||||||
|
let app: FastifyInstance;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const t = createTestDb();
|
||||||
|
db = t.db;
|
||||||
|
close = t.close;
|
||||||
|
app = await buildServer({ db });
|
||||||
|
await app.ready();
|
||||||
|
});
|
||||||
|
afterEach(async () => {
|
||||||
|
await app.close();
|
||||||
|
close();
|
||||||
|
});
|
||||||
|
|
||||||
|
const configChanges = () => db.select().from(ledgerEvents).all().filter((r) => r.type === "config_change");
|
||||||
|
|
||||||
|
async function put(body: unknown, auth: { cookie: string; csrf: string }) {
|
||||||
|
return app.inject({
|
||||||
|
method: "PUT",
|
||||||
|
url: "/api/site-config/presence-bypass",
|
||||||
|
headers: { cookie: auth.cookie, "x-csrf-token": auth.csrf },
|
||||||
|
payload: body as Record<string, unknown>,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("PUT /api/site-config/presence-bypass", () => {
|
||||||
|
it("is admin-only: a non-site:update user is 403", async () => {
|
||||||
|
await seedUser(db, { username: "op", password: "pw", roleId: "operator", permissions: ["shift:read"] });
|
||||||
|
const auth = await login(app, "op", "pw");
|
||||||
|
const res = await put({ camera: true }, auth);
|
||||||
|
expect(res.statusCode).toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("enabling a signal persists it AND signs an attributed config_change", async () => {
|
||||||
|
await seedUser(db, { username: "admin", password: "pw" });
|
||||||
|
const auth = await login(app, "admin", "pw");
|
||||||
|
|
||||||
|
const res = await put({ camera: true }, auth);
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(res.json()).toMatchObject({ bypassPresenceCamera: true, bypassPresenceRadar: false });
|
||||||
|
|
||||||
|
const changes = configChanges();
|
||||||
|
expect(changes).toHaveLength(1);
|
||||||
|
expect(changes[0].source).toBe("manual");
|
||||||
|
expect(changes[0].signature.length).toBeGreaterThan(0);
|
||||||
|
expect(changes[0].payload).toMatchObject({
|
||||||
|
setting: "entryPresenceBypass.camera",
|
||||||
|
value: true,
|
||||||
|
prev: false,
|
||||||
|
operator: "admin",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a no-op toggle (already in that state) signs nothing", async () => {
|
||||||
|
await seedUser(db, { username: "admin", password: "pw" });
|
||||||
|
const auth = await login(app, "admin", "pw");
|
||||||
|
await put({ camera: true }, auth); // 1st: on → 1 event
|
||||||
|
await put({ camera: true }, auth); // 2nd: still on → no new event
|
||||||
|
expect(configChanges()).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("disabling signs the off transition too (auditable both ways)", async () => {
|
||||||
|
await seedUser(db, { username: "admin", password: "pw" });
|
||||||
|
const auth = await login(app, "admin", "pw");
|
||||||
|
await put({ radar: true }, auth);
|
||||||
|
await put({ radar: false }, auth);
|
||||||
|
const changes = configChanges();
|
||||||
|
expect(changes).toHaveLength(2);
|
||||||
|
expect(changes[1].payload).toMatchObject({ setting: "entryPresenceBypass.radar", value: false, prev: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a non-boolean and an empty body", async () => {
|
||||||
|
await seedUser(db, { username: "admin", password: "pw" });
|
||||||
|
const auth = await login(app, "admin", "pw");
|
||||||
|
expect((await put({ camera: "yes" }, auth)).statusCode).toBe(400);
|
||||||
|
expect((await put({}, auth)).statusCode).toBe(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import Fastify from "fastify";
|
||||||
|
import { beforeEach, afterEach, describe, expect, it } from "vitest";
|
||||||
|
import { devices, type Db } from "@parking/db";
|
||||||
|
import { createTestDb } from "@parking/db/testing";
|
||||||
|
import { qrReaderRoutes, splitChannel } from "./qr-reader.js";
|
||||||
|
import { CredentialCapture } from "../credential-capture.js";
|
||||||
|
import type { DeviceReadEvent, ReadOutcome } from "../device-events.js";
|
||||||
|
import type { ReadDispatcher } from "../read-dispatch.js";
|
||||||
|
|
||||||
|
// CHANNEL TAGGING (2026-07-04): the DT-008's "QRCode Output Prefix" / "Card Output
|
||||||
|
// Prefix" (vendor tool) mark which engine produced a push — Q: = optical, K: = RF.
|
||||||
|
// The route strips the prefix, tags the read's confirmed channel, and enrollment
|
||||||
|
// capture stores the BARE value. Unprefixed reads stay the legacy untagged shape so
|
||||||
|
// an unconfigured reader keeps working. These tests pin the route-side contract;
|
||||||
|
// the match-side enforcement is pinned in ../subscription-channel.test.ts.
|
||||||
|
|
||||||
|
const SERIAL = "H05MA5B0";
|
||||||
|
const READER_ID = "reader-exit";
|
||||||
|
|
||||||
|
let db: Db;
|
||||||
|
let app: ReturnType<typeof Fastify>;
|
||||||
|
let capture: CredentialCapture;
|
||||||
|
let seen: DeviceReadEvent[];
|
||||||
|
|
||||||
|
/** Dispatcher stub: records the event the route built, always rejects. */
|
||||||
|
const fakeDispatcher = {
|
||||||
|
dispatch: async (e: DeviceReadEvent): Promise<ReadOutcome> => {
|
||||||
|
seen.push(e);
|
||||||
|
return { accepted: false, reason: "test" };
|
||||||
|
},
|
||||||
|
} as unknown as ReadDispatcher;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
({ db } = createTestDb());
|
||||||
|
db.insert(devices).values({
|
||||||
|
id: READER_ID,
|
||||||
|
category: "reader",
|
||||||
|
driverId: "dingtian-qr-reader",
|
||||||
|
config: { serial: SERIAL },
|
||||||
|
enabled: true,
|
||||||
|
}).run();
|
||||||
|
seen = [];
|
||||||
|
capture = new CredentialCapture();
|
||||||
|
app = Fastify({ logger: false });
|
||||||
|
await qrReaderRoutes(app as never, db, fakeDispatcher, capture);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
const scan = (cardid: string) =>
|
||||||
|
app.inject({ method: "GET", url: `/qa/mcardsea.php?cardid=${encodeURIComponent(cardid)}&cjihao=${SERIAL}&mjihao=1&status=10` });
|
||||||
|
|
||||||
|
describe("splitChannel", () => {
|
||||||
|
it("K: prefix → bare value, kind card, channel rf", () => {
|
||||||
|
expect(splitChannel("K:86A158")).toEqual({ value: "86A158", kind: "card", channel: "rf" });
|
||||||
|
});
|
||||||
|
it("Q: prefix → bare value, kind qr, channel optical", () => {
|
||||||
|
expect(splitChannel("Q:12345678901")).toEqual({ value: "12345678901", kind: "qr", channel: "optical" });
|
||||||
|
});
|
||||||
|
it("no prefix → value untouched, legacy untagged qr", () => {
|
||||||
|
expect(splitChannel("86A158")).toEqual({ value: "86A158", kind: "qr" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("qr-reader route channel tagging", () => {
|
||||||
|
it("card-prefixed push dispatches a stripped, rf-tagged read", async () => {
|
||||||
|
const res = await scan("K:86A158");
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(seen).toHaveLength(1);
|
||||||
|
expect(seen[0]).toMatchObject({ value: "86A158", kind: "card", channel: "rf", deviceId: READER_ID });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("qr-prefixed push dispatches a stripped, optical-tagged read", async () => {
|
||||||
|
await scan("Q:00000000000");
|
||||||
|
expect(seen[0]).toMatchObject({ value: "00000000000", kind: "qr", channel: "optical" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("unprefixed push stays legacy: kind qr, no channel", async () => {
|
||||||
|
await scan("86A158");
|
||||||
|
expect(seen[0]).toMatchObject({ value: "86A158", kind: "qr" });
|
||||||
|
expect(seen[0].channel).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a bare prefix (empty value after strip) dispatches nothing", async () => {
|
||||||
|
await scan("K:");
|
||||||
|
expect(seen).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("enrollment capture stores the BARE value, not the prefixed one", async () => {
|
||||||
|
capture.arm(READER_ID);
|
||||||
|
const res = await scan("K:86A158");
|
||||||
|
expect(seen).toHaveLength(0); // intercepted — never dispatched to the access flow
|
||||||
|
const state = capture.state();
|
||||||
|
expect(state.status).toBe("captured");
|
||||||
|
if (state.status === "captured") expect(state.value).toBe("86A158");
|
||||||
|
// Beeps "ok" so the operator knows the card was read.
|
||||||
|
expect(res.json().data[0].status).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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":""}
|
||||||
@@ -27,6 +27,36 @@ interface ReaderQuery {
|
|||||||
time?: string;
|
time?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── CHANNEL TAGGING (2026-07-04) ────────────────────────────────────────────────
|
||||||
|
// The DT-008 push carries one opaque `cardid` whether its OPTICAL engine decoded a
|
||||||
|
// QR/barcode or its RF engine read a card — the server can't tell them apart. That
|
||||||
|
// enabled a cheap clone: print a card's UID (often written on the card face) as a
|
||||||
|
// barcode and the optical decode matches the RF credential. Fix: the vendor tool's
|
||||||
|
// "QRCode Output Prefix" / "Card Output Prefix" are set to the markers below on every
|
||||||
|
// reader; the route strips the prefix and tags the read's confirmed channel, and the
|
||||||
|
// subscription match refuses a channel-mismatched credential. A read with NO prefix
|
||||||
|
// stays the legacy untagged shape (kind "qr", channel undefined) so an unconfigured
|
||||||
|
// reader keeps working — the enforcement only bites where prefixes are deployed.
|
||||||
|
// ⚠️ Prefixes must MATCH the vendor tool; also FREEZE "Card Input format" (6H) — that
|
||||||
|
// setting defines the UID shape we enroll. See wiki/entities/dingtian-dt008-reader.md.
|
||||||
|
const QR_CHANNEL_PREFIX = process.env.READER_QR_PREFIX ?? "Q:";
|
||||||
|
const CARD_CHANNEL_PREFIX = process.env.READER_CARD_PREFIX ?? "K:";
|
||||||
|
|
||||||
|
/** Split a raw pushed `cardid` into its bare value + confirmed channel (if prefixed). */
|
||||||
|
export function splitChannel(raw: string): {
|
||||||
|
value: string;
|
||||||
|
kind: "qr" | "card";
|
||||||
|
channel?: "optical" | "rf";
|
||||||
|
} {
|
||||||
|
if (CARD_CHANNEL_PREFIX.length > 0 && raw.startsWith(CARD_CHANNEL_PREFIX)) {
|
||||||
|
return { value: raw.slice(CARD_CHANNEL_PREFIX.length), kind: "card", channel: "rf" };
|
||||||
|
}
|
||||||
|
if (QR_CHANNEL_PREFIX.length > 0 && raw.startsWith(QR_CHANNEL_PREFIX)) {
|
||||||
|
return { value: raw.slice(QR_CHANNEL_PREFIX.length), kind: "qr", channel: "optical" };
|
||||||
|
}
|
||||||
|
return { value: raw, kind: "qr" }; // legacy: unprefixed reader, channel unknown
|
||||||
|
}
|
||||||
|
|
||||||
export async function qrReaderRoutes(
|
export async function qrReaderRoutes(
|
||||||
app: FastifyInstance,
|
app: FastifyInstance,
|
||||||
db: Db,
|
db: Db,
|
||||||
@@ -35,7 +65,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,9 +82,10 @@ 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 scan = splitChannel(cardid); // bare value + confirmed channel (if prefixed)
|
||||||
const mjihao = q.mjihao != null ? Number(q.mjihao) : 0;
|
const mjihao = q.mjihao != null ? Number(q.mjihao) : 0;
|
||||||
const serial = (q.cjihao ?? "").trim();
|
const serial = (q.cjihao ?? "").trim();
|
||||||
|
|
||||||
@@ -65,35 +96,37 @@ export async function qrReaderRoutes(
|
|||||||
const deviceId = matchedRowId ?? serial;
|
const deviceId = matchedRowId ?? serial;
|
||||||
|
|
||||||
let accepted = false;
|
let accepted = false;
|
||||||
if (cardid) {
|
if (scan.value) {
|
||||||
// ENROLLMENT INTERCEPT: if THIS reader is armed for credential capture, grab the
|
// ENROLLMENT INTERCEPT: if THIS reader is armed for credential capture, grab the
|
||||||
// value for the subscription form and do NOT run the access flow (we must not
|
// value for the subscription form and do NOT run the access flow (we must not
|
||||||
// open a barrier for a card being enrolled). Single-shot — capture auto-disarms.
|
// open a barrier for a card being enrolled). Single-shot — capture auto-disarms.
|
||||||
// Reads from the OTHER reader are untouched and dispatch normally below.
|
// Reads from the OTHER reader are untouched and dispatch normally below.
|
||||||
if (capture.tryConsume(deviceId, cardid)) {
|
// Captured BARE (prefix stripped) so enrolled values match future stripped reads.
|
||||||
app.log.info(`CAPTURE serial=${serial || "?"} device=${matchedRowId ? matchedRowId.slice(0, 8) : "?"} value=${cardid}`);
|
if (capture.tryConsume(deviceId, scan.value)) {
|
||||||
|
app.log.info(`CAPTURE serial=${serial || "?"} device=${matchedRowId ? matchedRowId.slice(0, 8) : "?"} value=${scan.value}${scan.channel ? ` ch=${scan.channel}` : ""}`);
|
||||||
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: scan.value,
|
||||||
kind: "qr",
|
kind: scan.kind,
|
||||||
|
...(scan.channel ? { channel: scan.channel } : {}),
|
||||||
at: new Date().toISOString(),
|
at: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
const outcome = await dispatcher.dispatch(read);
|
const outcome = await dispatcher.dispatch(read);
|
||||||
accepted = outcome.accepted;
|
accepted = outcome.accepted;
|
||||||
// Per-read diagnostic: which reader (serial) sent it, which configured device
|
// Per-read diagnostic: which reader (serial) sent it, which configured device
|
||||||
// it mapped to, and the verdict — so a barrier/serial mismatch is visible in
|
// it mapped to, the confirmed channel (if prefixed), and the verdict — so a
|
||||||
// the logs (e.g. an entry-side scan resolving to the exit relay).
|
// barrier/serial mismatch or a channel anomaly is visible in the logs.
|
||||||
app.log.info(
|
app.log.info(
|
||||||
`READ serial=${serial || "?"} → device=${matchedRowId ? matchedRowId.slice(0, 8) : "UNASSIGNED"} ` +
|
`READ serial=${serial || "?"} → device=${matchedRowId ? matchedRowId.slice(0, 8) : "UNASSIGNED"} ` +
|
||||||
`card=${cardid} verdict=${accepted ? "ACCEPT" : "REJECT"}${outcome.direction ? ` dir=${outcome.direction}` : ""}` +
|
`card=${scan.value}${scan.channel ? ` ch=${scan.channel}` : ""} verdict=${accepted ? "ACCEPT" : "REJECT"}${outcome.direction ? ` dir=${outcome.direction}` : ""}` +
|
||||||
`${accepted ? "" : ` reason="${outcome.reason ?? "?"}"`}`,
|
`${accepted ? "" : ` reason="${outcome.reason ?? "?"}"`}`,
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
app.log.error(`QR dispatch failed for ${cardid}: ${(err as Error).message}`);
|
app.log.error(`QR dispatch failed for ${scan.value}: ${(err as Error).message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -101,3 +101,21 @@ describe("CSRF double-submit on mutations", () => {
|
|||||||
expect(put.statusCode).toBe(403);
|
expect(put.statusCode).toBe(403);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("drawer balance (the till NOW)", () => {
|
||||||
|
it("shift:read gets the balance; a role without it is 403; no auth 401", async () => {
|
||||||
|
const anon = await app.inject({ method: "GET", url: "/api/drawer/balance" });
|
||||||
|
expect(anon.statusCode).toBe(401);
|
||||||
|
|
||||||
|
const viewer = await seedUser(db, { username: "till", roleId: "till", permissions: ["shift:read"] });
|
||||||
|
const { cookie } = await login(app, viewer.username, viewer.password);
|
||||||
|
const ok = await app.inject({ method: "GET", url: "/api/drawer/balance", headers: { cookie } });
|
||||||
|
expect(ok.statusCode).toBe(200);
|
||||||
|
expect(ok.json()).toEqual({ balanceMinor: 0, currency: null });
|
||||||
|
|
||||||
|
const outsider = await seedUser(db, { username: "noshift", roleId: "noshift", permissions: ["site:read"] });
|
||||||
|
const other = await login(app, outsider.username, outsider.password);
|
||||||
|
const denied = await app.inject({ method: "GET", url: "/api/drawer/balance", headers: { cookie: other.cookie } });
|
||||||
|
expect(denied.statusCode).toBe(403);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { devices, ledgerEvents, type Db } from "@parking/db";
|
||||||
|
import { createTestDb } from "@parking/db/testing";
|
||||||
|
import type { FastifyInstance } from "fastify";
|
||||||
|
import { buildServer } from "../server.js";
|
||||||
|
import { seedUser, login } from "../test-helpers.js";
|
||||||
|
|
||||||
|
// POST /api/setup/test-relay pulses a SAVED controller's barrier relay to prove the
|
||||||
|
// wiring — it physically opens the barrier. Because "a physical open with no matching
|
||||||
|
// signed command is the fraud signal" (append-only-event-chain / reconciliation), the
|
||||||
|
// route must SIGN a barrier_open_command (reason setup.relayTest) BEFORE it fires, and it
|
||||||
|
// must be admin-only. These tests use the `stub-access` controller (pulseOpen only logs —
|
||||||
|
// no real hardware) so they exercise the validate → sign → pulse path safely.
|
||||||
|
|
||||||
|
let db: Db;
|
||||||
|
let close: () => void;
|
||||||
|
let app: FastifyInstance;
|
||||||
|
|
||||||
|
const CTL = "ctl-stub";
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const t = createTestDb();
|
||||||
|
db = t.db;
|
||||||
|
close = t.close;
|
||||||
|
db.insert(devices).values({
|
||||||
|
id: CTL,
|
||||||
|
category: "access",
|
||||||
|
driverId: "stub-access",
|
||||||
|
config: { relays: [{ relay: 1, direction: "entry" }, { relay: 2, direction: "exit" }] },
|
||||||
|
enabled: true,
|
||||||
|
}).run();
|
||||||
|
app = await buildServer({ db });
|
||||||
|
await app.ready();
|
||||||
|
});
|
||||||
|
afterEach(async () => {
|
||||||
|
await app.close();
|
||||||
|
close();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function pulse(
|
||||||
|
body: unknown,
|
||||||
|
auth?: { cookie: string; csrf: string },
|
||||||
|
) {
|
||||||
|
return app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/setup/test-relay",
|
||||||
|
headers: auth ? { cookie: auth.cookie, "x-csrf-token": auth.csrf } : {},
|
||||||
|
payload: body as Record<string, unknown>,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("POST /api/setup/test-relay", () => {
|
||||||
|
it("is admin-only: a non-site:update user is 403", async () => {
|
||||||
|
await seedUser(db, { username: "op", password: "pw", roleId: "operator", permissions: ["shift:read"] });
|
||||||
|
const auth = await login(app, "op", "pw");
|
||||||
|
const res = await pulse({ id: CTL, relay: 1 }, auth);
|
||||||
|
expect(res.statusCode).toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires CSRF on the mutation", async () => {
|
||||||
|
await seedUser(db, { username: "admin", password: "pw" });
|
||||||
|
const { cookie } = await login(app, "admin", "pw");
|
||||||
|
const res = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/setup/test-relay",
|
||||||
|
headers: { cookie }, // no x-csrf-token
|
||||||
|
payload: { id: CTL, relay: 1 },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("signs a barrier_open_command (reason setup.relayTest) BEFORE firing, then reports ok", async () => {
|
||||||
|
await seedUser(db, { username: "admin", password: "pw" });
|
||||||
|
const auth = await login(app, "admin", "pw");
|
||||||
|
|
||||||
|
const res = await pulse({ id: CTL, relay: 2 }, auth);
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(res.json()).toMatchObject({ ok: true });
|
||||||
|
|
||||||
|
// The deliberate open is EXPLAINED in the signed ledger — not an anomaly.
|
||||||
|
const rows = db.select().from(ledgerEvents).all();
|
||||||
|
const testOpen = rows.find((r) => r.type === "barrier_open_command");
|
||||||
|
expect(testOpen, "a barrier_open_command must be signed").toBeTruthy();
|
||||||
|
expect(testOpen!.source).toBe("manual"); // deliberate human action
|
||||||
|
expect(testOpen!.signature.length).toBeGreaterThan(0);
|
||||||
|
const payload = testOpen!.payload as Record<string, unknown>;
|
||||||
|
expect(payload.relayTest).toBe(true);
|
||||||
|
expect(payload.reasonCode).toBe("setup.relayTest");
|
||||||
|
expect(payload.relay).toBe(2);
|
||||||
|
expect(payload.controllerId).toBe(CTL);
|
||||||
|
expect(payload.operator).toBe("admin"); // attributed to the acting admin
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a relay the controller does not declare (400, no ledger row)", async () => {
|
||||||
|
await seedUser(db, { username: "admin", password: "pw" });
|
||||||
|
const auth = await login(app, "admin", "pw");
|
||||||
|
const res = await pulse({ id: CTL, relay: 9 }, auth);
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
expect(db.select().from(ledgerEvents).all()).toHaveLength(0); // nothing signed
|
||||||
|
});
|
||||||
|
|
||||||
|
it("404s an unknown controller id", async () => {
|
||||||
|
await seedUser(db, { username: "admin", password: "pw" });
|
||||||
|
const auth = await login(app, "admin", "pw");
|
||||||
|
const res = await pulse({ id: "nope", relay: 1 }, auth);
|
||||||
|
expect(res.statusCode).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a bad relay value (non-positive-integer)", async () => {
|
||||||
|
await seedUser(db, { username: "admin", password: "pw" });
|
||||||
|
const auth = await login(app, "admin", "pw");
|
||||||
|
expect((await pulse({ id: CTL, relay: 0 }, auth)).statusCode).toBe(400);
|
||||||
|
expect((await pulse({ id: CTL, relay: -1 }, auth)).statusCode).toBe(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
@@ -14,7 +15,9 @@ import {
|
|||||||
type DeviceCategory,
|
type DeviceCategory,
|
||||||
type DeviceConfig,
|
type DeviceConfig,
|
||||||
} from "@parking/devices";
|
} from "@parking/devices";
|
||||||
|
import { reasonPayload } from "@parking/shared";
|
||||||
import { requirePermission } from "../auth.js";
|
import { requirePermission } from "../auth.js";
|
||||||
|
import type { EventLog } from "../event-log.js";
|
||||||
import { backendIpCandidates, backendIpForDevice, backendPort } from "../net.js";
|
import { backendIpCandidates, backendIpForDevice, backendPort } from "../net.js";
|
||||||
import type { VisionClient } from "../vision-client.js";
|
import type { VisionClient } from "../vision-client.js";
|
||||||
|
|
||||||
@@ -36,6 +39,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 +62,47 @@ function redactSecrets(config: Record<string, unknown>): Record<string, unknown>
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Feature-detect the barrier-pulse capability on a built device adapter (the Setup
|
||||||
|
* relay test needs it; a stub/reader/camera won't have it). */
|
||||||
|
function hasPulseOpen(d: unknown): d is { pulseOpen(doorId: number): Promise<void> } {
|
||||||
|
return typeof (d as { pulseOpen?: unknown } | null)?.pulseOpen === "function";
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 =
|
||||||
@@ -179,6 +228,7 @@ export async function setupRoutes(
|
|||||||
app: FastifyInstance,
|
app: FastifyInstance,
|
||||||
db: Db,
|
db: Db,
|
||||||
vision?: VisionClient | null,
|
vision?: VisionClient | null,
|
||||||
|
eventLog?: EventLog | null,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
registerBuiltinDrivers();
|
registerBuiltinDrivers();
|
||||||
setDeviceLogSink((line) => app.log.info(line));
|
setDeviceLogSink((line) => app.log.info(line));
|
||||||
@@ -249,13 +299,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 +401,153 @@ 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 });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// PULSE a controller's barrier relay from Setup, to test the wiring — WITHOUT any
|
||||||
|
// vehicle/session. This physically opens the barrier, so unlike the other tests it
|
||||||
|
// runs only against a SAVED controller (real id → clean attribution) and it SIGNS a
|
||||||
|
// `barrier_open_command` into the ledger FIRST, with reason `setup.relayTest` + the
|
||||||
|
// admin's identity. That is the whole point of doing it this way: a physical open with
|
||||||
|
// no matching signed command is the fraud signal ([[append-only-event-chain]],
|
||||||
|
// [[reconciliation]]) — a deliberate test must therefore be an EXPLAINED open, not a
|
||||||
|
// silent one. Sign-before-fire mirrors exit-flow's manual re-open: the intervention is
|
||||||
|
// recorded whether or not the physical pulse then succeeds. Admin-only (site:update).
|
||||||
|
app.post<{ Body: { id: string; relay: number } }>(
|
||||||
|
"/api/setup/test-relay",
|
||||||
|
{ preHandler: adminGuard },
|
||||||
|
async (req, reply) => {
|
||||||
|
const { id, relay } = req.body;
|
||||||
|
if (typeof id !== "string" || !id) return reply.code(400).send({ error: "missing controller id" });
|
||||||
|
if (!Number.isInteger(relay) || relay < 1) {
|
||||||
|
return reply.code(400).send({ error: "relay must be a 1-based channel number" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// A relay test fires REAL hardware, so it must target a persisted controller — no
|
||||||
|
// firing an unsaved/redirected config (that would let a probe open an arbitrary host's
|
||||||
|
// barrier). Load the saved row and build straight from its stored config (relayPassword
|
||||||
|
// included — it's on the row, never in the request).
|
||||||
|
const row = db.select().from(devices).where(eq(devices.id, id)).get();
|
||||||
|
if (!row) return reply.code(404).send({ error: "controller not found" });
|
||||||
|
if (row.category !== "access") {
|
||||||
|
return reply.code(400).send({ error: `device ${id} is not a controller` });
|
||||||
|
}
|
||||||
|
const cfg = (row.config ?? {}) as Record<string, unknown>;
|
||||||
|
const relays = Array.isArray(cfg.relays) ? (cfg.relays as { relay?: number }[]) : [];
|
||||||
|
if (!relays.some((r) => r.relay === relay)) {
|
||||||
|
return reply.code(400).send({ error: `controller ${id} has no relay ${relay}` });
|
||||||
|
}
|
||||||
|
|
||||||
|
let device;
|
||||||
|
try {
|
||||||
|
device = registry.create(row.driverId, cfg as Record<string, string | number | boolean>);
|
||||||
|
} catch (err) {
|
||||||
|
return reply.code(400).send({ error: (err as Error).message });
|
||||||
|
}
|
||||||
|
if (!hasPulseOpen(device)) {
|
||||||
|
return reply.code(400).send({ error: `driver ${row.driverId} cannot pulse a relay` });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sign the deliberate open FIRST — recorded whether or not the physical pulse then
|
||||||
|
// succeeds. Skip only if no ledger is wired (test/degraded), in which case we still
|
||||||
|
// refuse rather than fire an unrecorded open.
|
||||||
|
const operator = req.user?.username ?? "unknown";
|
||||||
|
if (!eventLog) {
|
||||||
|
return reply.code(503).send({ error: "ledger unavailable — refusing an unrecorded relay open" });
|
||||||
|
}
|
||||||
|
await eventLog.append({
|
||||||
|
type: "barrier_open_command",
|
||||||
|
// A deliberate human action from the admin console → "manual" (the top-level
|
||||||
|
// IdentitySource). The relayTest marker + reason distinguish it in the payload.
|
||||||
|
source: "manual",
|
||||||
|
identity: `relay-test:${id}:${relay}`,
|
||||||
|
payload: {
|
||||||
|
...reasonPayload("setup.relayTest", { operator, relay, controller: row.driverId }),
|
||||||
|
relayTest: true,
|
||||||
|
controllerId: id,
|
||||||
|
relay,
|
||||||
|
operator,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const startedAt = Date.now();
|
||||||
|
try {
|
||||||
|
await device.pulseOpen(relay);
|
||||||
|
} catch (err) {
|
||||||
|
// The failure we're testing for (relay unreachable, wrong password). The open is
|
||||||
|
// already signed; report the pulse failure, don't 500.
|
||||||
|
return reply.send({
|
||||||
|
ok: false,
|
||||||
|
reason: "pulse-failed",
|
||||||
|
detail: (err as Error).message,
|
||||||
|
tookMs: Date.now() - startedAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return reply.send({ ok: true, firedAt: new Date().toISOString(), 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");
|
||||||
@@ -84,52 +63,14 @@ export async function shiftRoutes(app: FastifyInstance, shift: ShiftService, db:
|
|||||||
const from = canSeeAll ? q.from?.trim() || undefined : undefined;
|
const from = canSeeAll ? q.from?.trim() || undefined : undefined;
|
||||||
const to = canSeeAll ? q.to?.trim() || undefined : undefined;
|
const to = canSeeAll ? q.to?.trim() || undefined : undefined;
|
||||||
const shifts = shift.listShifts({ operator, from, to });
|
const shifts = shift.listShifts({ operator, from, to });
|
||||||
return { shifts, scope: canSeeAll ? "all" : "self" };
|
// Admins also get the distinct operator list (unfiltered) for the filter
|
||||||
|
// dropdown — operators don't see other names, so it's scope-gated.
|
||||||
|
if (canSeeAll) return { shifts, scope: "all", operators: shift.listOperators() };
|
||||||
|
return { shifts, scope: "self" };
|
||||||
});
|
});
|
||||||
|
|
||||||
// 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 { eq, siteConfig, type Db } from "@parking/db";
|
import { eq, siteConfig, type Db } from "@parking/db";
|
||||||
import { requirePermission } from "../auth.js";
|
import { requirePermission } from "../auth.js";
|
||||||
|
import type { EventLog } from "../event-log.js";
|
||||||
import { getOccupancy } from "../occupancy.js";
|
import { getOccupancy } from "../occupancy.js";
|
||||||
|
|
||||||
// Site config (capacity) + live occupancy. Occupancy is a fold over the signed
|
// Site config (capacity) + live occupancy. Occupancy is a fold over the signed
|
||||||
@@ -38,13 +39,15 @@ interface SiteConfigBody extends Partial<Record<TextField, string | null>> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Shape returned by GET/PUT: capacity + the booth flag + the subscription default
|
/** Shape returned by GET/PUT: capacity + the booth flag + the subscription default
|
||||||
* + every metadata field. */
|
* + the entry presence-bypass flags + every metadata field. */
|
||||||
type SiteConfig = {
|
type SiteConfig = {
|
||||||
capacity: number | null;
|
capacity: number | null;
|
||||||
exitVoucherDefault: boolean;
|
exitVoucherDefault: boolean;
|
||||||
subscriptionMonthlyPriceMinor: number | null;
|
subscriptionMonthlyPriceMinor: number | null;
|
||||||
reserveSubscriberSpots: boolean;
|
reserveSubscriberSpots: boolean;
|
||||||
anprEntryEnabled: boolean;
|
anprEntryEnabled: boolean;
|
||||||
|
bypassPresenceRadar: boolean;
|
||||||
|
bypassPresenceCamera: boolean;
|
||||||
} & Record<TextField, string | null>;
|
} & Record<TextField, string | null>;
|
||||||
|
|
||||||
function toSiteConfig(row: typeof siteConfig.$inferSelect | undefined): SiteConfig {
|
function toSiteConfig(row: typeof siteConfig.$inferSelect | undefined): SiteConfig {
|
||||||
@@ -54,6 +57,8 @@ function toSiteConfig(row: typeof siteConfig.$inferSelect | undefined): SiteConf
|
|||||||
subscriptionMonthlyPriceMinor: row?.subscriptionMonthlyPriceMinor ?? null,
|
subscriptionMonthlyPriceMinor: row?.subscriptionMonthlyPriceMinor ?? null,
|
||||||
reserveSubscriberSpots: row?.reserveSubscriberSpots ?? false,
|
reserveSubscriberSpots: row?.reserveSubscriberSpots ?? false,
|
||||||
anprEntryEnabled: row?.anprEntryEnabled ?? true,
|
anprEntryEnabled: row?.anprEntryEnabled ?? true,
|
||||||
|
bypassPresenceRadar: row?.bypassPresenceRadar ?? false,
|
||||||
|
bypassPresenceCamera: row?.bypassPresenceCamera ?? false,
|
||||||
} as SiteConfig;
|
} as SiteConfig;
|
||||||
for (const f of TEXT_FIELDS) out[f] = row?.[f] ?? null;
|
for (const f of TEXT_FIELDS) out[f] = row?.[f] ?? null;
|
||||||
return out;
|
return out;
|
||||||
@@ -66,7 +71,7 @@ function normText(v: unknown): string | null {
|
|||||||
return s === "" ? null : s;
|
return s === "" ? null : s;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function siteRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
export async function siteRoutes(app: FastifyInstance, db: Db, eventLog?: EventLog | null): Promise<void> {
|
||||||
const readGuard = requirePermission("site:read");
|
const readGuard = requirePermission("site:read");
|
||||||
const writeGuard = requirePermission("site:update");
|
const writeGuard = requirePermission("site:update");
|
||||||
|
|
||||||
@@ -131,4 +136,64 @@ export async function siteRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
|||||||
const row = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
|
const row = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
|
||||||
return toSiteConfig(row);
|
return toSiteConfig(row);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Entry presence-gate BYPASS — a DEDICATED, SIGNED endpoint (not the generic PUT above),
|
||||||
|
// because dropping a radar/camera requirement weakens an anti-fraud gate. The admin is not
|
||||||
|
// the adversary (a faulty device blocks legit entry until support fixes it), but the change
|
||||||
|
// must be attributed + auditable: each toggled signal appends a signed `config_change`
|
||||||
|
// {setting, value, prev, operator}. Granular per signal. See wiki/concepts/entry-presence-bypass.md.
|
||||||
|
app.put<{ Body: { radar?: boolean; camera?: boolean } }>(
|
||||||
|
"/api/site-config/presence-bypass",
|
||||||
|
{ preHandler: writeGuard },
|
||||||
|
async (req, reply) => {
|
||||||
|
const body = req.body ?? {};
|
||||||
|
for (const k of ["radar", "camera"] as const) {
|
||||||
|
if (k in body && typeof body[k] !== "boolean") {
|
||||||
|
return reply.code(400).send({ error: `${k} must be a boolean` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!("radar" in body) && !("camera" in body)) {
|
||||||
|
return reply.code(400).send({ error: "nothing to change (send radar and/or camera)" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
|
||||||
|
const prev = {
|
||||||
|
radar: existing?.bypassPresenceRadar ?? false,
|
||||||
|
camera: existing?.bypassPresenceCamera ?? false,
|
||||||
|
};
|
||||||
|
const next = {
|
||||||
|
radar: "radar" in body ? (body.radar as boolean) : prev.radar,
|
||||||
|
camera: "camera" in body ? (body.camera as boolean) : prev.camera,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Sign a config_change for each signal that ACTUALLY changed (before persisting, so the
|
||||||
|
// audit record exists whether or not a later write hiccups). No-op toggles sign nothing.
|
||||||
|
const operator = req.user?.username ?? "unknown";
|
||||||
|
for (const signal of ["radar", "camera"] as const) {
|
||||||
|
if (next[signal] !== prev[signal]) {
|
||||||
|
await eventLog?.append({
|
||||||
|
type: "config_change",
|
||||||
|
source: "manual",
|
||||||
|
identity: `presence-bypass:${signal}`,
|
||||||
|
payload: {
|
||||||
|
setting: `entryPresenceBypass.${signal}`,
|
||||||
|
value: next[signal],
|
||||||
|
prev: prev[signal],
|
||||||
|
operator,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedAt = new Date().toISOString();
|
||||||
|
const patch = { bypassPresenceRadar: next.radar, bypassPresenceCamera: next.camera, updatedAt };
|
||||||
|
if (existing) {
|
||||||
|
db.update(siteConfig).set(patch).where(eq(siteConfig.id, 1)).run();
|
||||||
|
} else {
|
||||||
|
db.insert(siteConfig).values({ id: 1, ...patch }).run();
|
||||||
|
}
|
||||||
|
const row = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
|
||||||
|
return toSiteConfig(row);
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,177 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { createTestDb } from "@parking/db/testing";
|
||||||
|
import { type Db } from "@parking/db";
|
||||||
|
import type { FastifyInstance } from "fastify";
|
||||||
|
import { buildServer } from "../server.js";
|
||||||
|
import { seedUser, login } from "../test-helpers.js";
|
||||||
|
|
||||||
|
// Tariff-lab drafts: the MUTABLE experiment scratchpad next to the immutable
|
||||||
|
// published versions. The contract under test: drafts are validated + tz-stamped on
|
||||||
|
// save exactly like a publish (so "publish this draft" can never fail on a card that
|
||||||
|
// saved fine), mutations need tariff:update, and publishing a draft goes through the
|
||||||
|
// normal immutable-version path untouched.
|
||||||
|
|
||||||
|
let db: Db;
|
||||||
|
let close: () => void;
|
||||||
|
let app: FastifyInstance;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const t = createTestDb();
|
||||||
|
db = t.db;
|
||||||
|
close = t.close;
|
||||||
|
app = await buildServer({ db });
|
||||||
|
await app.ready();
|
||||||
|
});
|
||||||
|
afterEach(async () => {
|
||||||
|
await app.close();
|
||||||
|
close();
|
||||||
|
});
|
||||||
|
|
||||||
|
const V1_STRUCTURE = {
|
||||||
|
gracePeriodEntryMin: 5,
|
||||||
|
incrementMin: 60,
|
||||||
|
lostTicketMinor: 2000,
|
||||||
|
gracePeriodExitMin: 10,
|
||||||
|
overstay: "reprice",
|
||||||
|
blocks: [{ uptoMin: null, priceMinorPerIncrement: 200 }],
|
||||||
|
dailyCapMinor: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
// A V2 card with a night package — tz left blank on purpose: the server must stamp it.
|
||||||
|
const V2_STRUCTURE = {
|
||||||
|
version: 2,
|
||||||
|
tz: "",
|
||||||
|
gracePeriodEntryMin: 5,
|
||||||
|
incrementMin: 60,
|
||||||
|
lostTicketMinor: 2000,
|
||||||
|
gracePeriodExitMin: 10,
|
||||||
|
overstay: "reprice",
|
||||||
|
defaultCard: { name: "default", priority: 0, blocks: [{ uptoMin: null, priceMinorPerIncrement: 200 }], dailyCapMinor: null },
|
||||||
|
windowedCards: [{ name: "night", priority: 10, window: { fromHour: "20:00", toHour: "07:00" }, packageMinor: 40000 }],
|
||||||
|
};
|
||||||
|
|
||||||
|
async function editor() {
|
||||||
|
const { username, password } = await seedUser(db, {
|
||||||
|
username: "editor",
|
||||||
|
roleId: "editor",
|
||||||
|
permissions: ["tariff:read", "tariff:update"],
|
||||||
|
});
|
||||||
|
return login(app, username, password);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("tariff drafts", () => {
|
||||||
|
it("requires auth", async () => {
|
||||||
|
const res = await app.inject({ method: "GET", url: "/api/tariff/drafts" });
|
||||||
|
expect(res.statusCode).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a tariff:read-only user can list but not create", async () => {
|
||||||
|
const { username, password } = await seedUser(db, {
|
||||||
|
username: "viewer",
|
||||||
|
roleId: "viewer",
|
||||||
|
permissions: ["tariff:read"],
|
||||||
|
});
|
||||||
|
const { cookie, csrf } = await login(app, username, password);
|
||||||
|
|
||||||
|
const list = await app.inject({ method: "GET", url: "/api/tariff/drafts", headers: { cookie } });
|
||||||
|
expect(list.statusCode).toBe(200);
|
||||||
|
expect(list.json().drafts).toEqual([]);
|
||||||
|
|
||||||
|
const create = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/tariff/drafts",
|
||||||
|
headers: { cookie, "x-csrf-token": csrf },
|
||||||
|
payload: { name: "x", currency: "ALL", structure: V1_STRUCTURE },
|
||||||
|
});
|
||||||
|
expect(create.statusCode).toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("create → list → update → delete roundtrip", async () => {
|
||||||
|
const { cookie, csrf } = await editor();
|
||||||
|
const headers = { cookie, "x-csrf-token": csrf };
|
||||||
|
|
||||||
|
const create = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/tariff/drafts",
|
||||||
|
headers,
|
||||||
|
payload: { name: "Winter proposal", currency: "all", structure: V1_STRUCTURE },
|
||||||
|
});
|
||||||
|
expect(create.statusCode).toBe(201);
|
||||||
|
const draft = create.json();
|
||||||
|
expect(draft.name).toBe("Winter proposal");
|
||||||
|
expect(draft.currency).toBe("ALL"); // normalised to upper case
|
||||||
|
expect(draft.createdBy).toBe("editor");
|
||||||
|
|
||||||
|
const list = await app.inject({ method: "GET", url: "/api/tariff/drafts", headers: { cookie } });
|
||||||
|
expect(list.json().drafts).toHaveLength(1);
|
||||||
|
|
||||||
|
const update = await app.inject({
|
||||||
|
method: "PUT",
|
||||||
|
url: `/api/tariff/drafts/${draft.id}`,
|
||||||
|
headers,
|
||||||
|
payload: { name: "Winter v2", currency: "ALL", structure: V1_STRUCTURE },
|
||||||
|
});
|
||||||
|
expect(update.statusCode).toBe(200);
|
||||||
|
expect(update.json().name).toBe("Winter v2");
|
||||||
|
|
||||||
|
const del = await app.inject({ method: "DELETE", url: `/api/tariff/drafts/${draft.id}`, headers });
|
||||||
|
expect(del.statusCode).toBe(204);
|
||||||
|
const after = await app.inject({ method: "GET", url: "/api/tariff/drafts", headers: { cookie } });
|
||||||
|
expect(after.json().drafts).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an invalid structure with problems (validated like a publish)", async () => {
|
||||||
|
const { cookie, csrf } = await editor();
|
||||||
|
const res = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/tariff/drafts",
|
||||||
|
headers: { cookie, "x-csrf-token": csrf },
|
||||||
|
payload: { name: "broken", currency: "ALL", structure: { ...V1_STRUCTURE, blocks: [] } },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
expect(res.json().problems?.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stamps the site timezone on a V2 draft, and the draft simulates + publishes as-is", async () => {
|
||||||
|
const { cookie, csrf } = await editor();
|
||||||
|
const headers = { cookie, "x-csrf-token": csrf };
|
||||||
|
|
||||||
|
const create = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/tariff/drafts",
|
||||||
|
headers,
|
||||||
|
payload: { name: "Night package", currency: "ALL", structure: V2_STRUCTURE },
|
||||||
|
});
|
||||||
|
expect(create.statusCode).toBe(201);
|
||||||
|
const draft = create.json();
|
||||||
|
expect(draft.structure.tz).toBe("Europe/Tirane");
|
||||||
|
|
||||||
|
// The lab prices the draft by sending its stored structure inline.
|
||||||
|
const sim = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/tariff/simulate",
|
||||||
|
headers,
|
||||||
|
payload: {
|
||||||
|
enteredAt: "2026-07-03T21:00:00.000+02:00",
|
||||||
|
asOf: "2026-07-03T23:00:00.000+02:00",
|
||||||
|
structure: draft.structure,
|
||||||
|
currency: draft.currency,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(sim.statusCode).toBe(200);
|
||||||
|
expect(sim.json().pricing.amountMinor).toBe(40000); // one night package
|
||||||
|
|
||||||
|
// "Publish this draft" = the normal immutable-version path with the draft's card;
|
||||||
|
// the draft's name rides along as the version's optional label.
|
||||||
|
const publish = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/tariff/versions",
|
||||||
|
headers,
|
||||||
|
payload: { currency: draft.currency, structure: draft.structure, name: draft.name },
|
||||||
|
});
|
||||||
|
expect(publish.statusCode).toBe(201);
|
||||||
|
const state = await app.inject({ method: "GET", url: "/api/tariff", headers: { cookie } });
|
||||||
|
expect(state.json().active?.name).toBe("Night package");
|
||||||
|
expect(state.json().active?.structure?.windowedCards?.[0]?.packageMinor).toBe(40000);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import type { FastifyInstance } from "fastify";
|
import type { FastifyInstance } from "fastify";
|
||||||
import { and, desc, eq, isNull, ledgerEvents, siteConfig, tariffVersions, tariffs, type Db } from "@parking/db";
|
import { and, desc, eq, isNull, ledgerEvents, siteConfig, tariffDrafts, tariffVersions, tariffs, type Db } from "@parking/db";
|
||||||
import {
|
import {
|
||||||
computeFee,
|
computeFee,
|
||||||
isTariffV2,
|
isTariffV2,
|
||||||
@@ -25,10 +25,19 @@ interface PublishBody {
|
|||||||
structure: TariffStructure;
|
structure: TariffStructure;
|
||||||
/** When this version takes effect (ISO-8601). Defaults to now. */
|
/** When this version takes effect (ISO-8601). Defaults to now. */
|
||||||
effectiveFrom?: string;
|
effectiveFrom?: string;
|
||||||
|
/** Optional human label (e.g. carried from the lab draft being published). */
|
||||||
|
name?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SITE_TARIFF_NAME = "Site tariff";
|
const SITE_TARIFF_NAME = "Site tariff";
|
||||||
|
|
||||||
|
/** Body for saving a lab draft (create + update share the shape). */
|
||||||
|
interface DraftBody {
|
||||||
|
name: string;
|
||||||
|
currency: string;
|
||||||
|
structure: TariffStructure;
|
||||||
|
}
|
||||||
|
|
||||||
/** Body for POST /api/tariff/simulate — price a hypothetical session, no ledger write.
|
/** Body for POST /api/tariff/simulate — price a hypothetical session, no ledger write.
|
||||||
* Provide a structure source (one of): `tariffVersionId`, inline `structure`, or
|
* Provide a structure source (one of): `tariffVersionId`, inline `structure`, or
|
||||||
* neither (uses the active version). */
|
* neither (uses the active version). */
|
||||||
@@ -80,19 +89,14 @@ export async function tariffRoutes(app: FastifyInstance, db: Db): Promise<void>
|
|||||||
"/api/tariff/versions",
|
"/api/tariff/versions",
|
||||||
{ preHandler: writeGuard },
|
{ preHandler: writeGuard },
|
||||||
async (req, reply) => {
|
async (req, reply) => {
|
||||||
const { currency, structure, effectiveFrom } = req.body ?? ({} as PublishBody);
|
const { currency, structure, effectiveFrom, name } = req.body ?? ({} as PublishBody);
|
||||||
if (!currency || typeof currency !== "string" || currency.length < 3) {
|
if (!currency || typeof currency !== "string" || currency.length < 3) {
|
||||||
return reply.code(400).send({ error: "currency (ISO 4217) required" });
|
return reply.code(400).send({ error: "currency (ISO 4217) required" });
|
||||||
}
|
}
|
||||||
// For a windowed (V2) structure, stamp the wall-clock timezone from SITE config
|
// For a windowed (V2) structure, stamp the wall-clock timezone from SITE config
|
||||||
// (not the client) BEFORE validating — so the frozen tz is authoritative and the
|
// (not the client) BEFORE validating — so the frozen tz is authoritative and the
|
||||||
// validation that requires tz passes. A V1 (bare) structure is left untouched.
|
// validation that requires tz passes. A V1 (bare) structure is left untouched.
|
||||||
let toStore: TariffStructure = structure;
|
const toStore = stampSiteTz(structure);
|
||||||
if (structure && isTariffV2(structure)) {
|
|
||||||
const cfg = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
|
|
||||||
const tz = cfg?.timezone && cfg.timezone.length > 0 ? cfg.timezone : DEFAULT_TZ;
|
|
||||||
toStore = { ...structure, tz };
|
|
||||||
}
|
|
||||||
|
|
||||||
const problems = validateTariffStructure(toStore);
|
const problems = validateTariffStructure(toStore);
|
||||||
if (problems.length) {
|
if (problems.length) {
|
||||||
@@ -128,6 +132,7 @@ export async function tariffRoutes(app: FastifyInstance, db: Db): Promise<void>
|
|||||||
const row = {
|
const row = {
|
||||||
id,
|
id,
|
||||||
tariffId,
|
tariffId,
|
||||||
|
name: typeof name === "string" && name.trim() ? name.trim() : null,
|
||||||
effectiveFrom: effective,
|
effectiveFrom: effective,
|
||||||
currency,
|
currency,
|
||||||
structure: toStore as unknown as Record<string, unknown>,
|
structure: toStore as unknown as Record<string, unknown>,
|
||||||
@@ -230,6 +235,92 @@ export async function tariffRoutes(app: FastifyInstance, db: Db): Promise<void>
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// --- Lab drafts ---------------------------------------------------------------
|
||||||
|
// The lab's scratchpad: MUTABLE experimental rate cards (see tariff_drafts in the
|
||||||
|
// schema for why mutability is safe here — a draft prices nothing and signs
|
||||||
|
// nothing). Saved drafts are validated + tz-stamped exactly like a publish, so the
|
||||||
|
// simulator can always price them and "publish this draft" can never surprise the
|
||||||
|
// admin with a card that saved fine but won't go live. Publishing a draft is just
|
||||||
|
// POST /api/tariff/versions with the draft's structure — same guard, same
|
||||||
|
// validation, same immutability.
|
||||||
|
app.get("/api/tariff/drafts", { preHandler: readGuard }, async () => {
|
||||||
|
const drafts = db.select().from(tariffDrafts).orderBy(desc(tariffDrafts.updatedAt)).all();
|
||||||
|
return { drafts };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post<{ Body: DraftBody }>("/api/tariff/drafts", { preHandler: writeGuard }, async (req, reply) => {
|
||||||
|
const parsed = parseDraftBody(req.body);
|
||||||
|
if ("error" in parsed) return reply.code(400).send(parsed);
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const row = {
|
||||||
|
id: randomUUID(),
|
||||||
|
name: parsed.name,
|
||||||
|
currency: parsed.currency,
|
||||||
|
structure: parsed.structure as unknown as Record<string, unknown>,
|
||||||
|
createdBy: req.user?.username ?? null,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
db.insert(tariffDrafts).values(row).run();
|
||||||
|
return reply.code(201).send(row);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put<{ Params: { id: string }; Body: DraftBody }>(
|
||||||
|
"/api/tariff/drafts/:id",
|
||||||
|
{ preHandler: writeGuard },
|
||||||
|
async (req, reply) => {
|
||||||
|
const existing = db.select().from(tariffDrafts).where(eq(tariffDrafts.id, req.params.id)).get();
|
||||||
|
if (!existing) return reply.code(404).send({ error: "draft not found" });
|
||||||
|
const parsed = parseDraftBody(req.body);
|
||||||
|
if ("error" in parsed) return reply.code(400).send(parsed);
|
||||||
|
const patch = {
|
||||||
|
name: parsed.name,
|
||||||
|
currency: parsed.currency,
|
||||||
|
structure: parsed.structure as unknown as Record<string, unknown>,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
db.update(tariffDrafts).set(patch).where(eq(tariffDrafts.id, existing.id)).run();
|
||||||
|
return { ...existing, ...patch };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
app.delete<{ Params: { id: string } }>(
|
||||||
|
"/api/tariff/drafts/:id",
|
||||||
|
{ preHandler: writeGuard },
|
||||||
|
async (req, reply) => {
|
||||||
|
const existing = db.select().from(tariffDrafts).where(eq(tariffDrafts.id, req.params.id)).get();
|
||||||
|
if (!existing) return reply.code(404).send({ error: "draft not found" });
|
||||||
|
db.delete(tariffDrafts).where(eq(tariffDrafts.id, existing.id)).run();
|
||||||
|
return reply.code(204).send();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Validate + normalise a draft save body; tz-stamps V2 structures like a publish. */
|
||||||
|
function parseDraftBody(
|
||||||
|
body: DraftBody | undefined,
|
||||||
|
): { name: string; currency: string; structure: TariffStructure } | { error: string; problems?: string[] } {
|
||||||
|
const b = body ?? ({} as DraftBody);
|
||||||
|
const name = (b.name ?? "").trim();
|
||||||
|
if (!name) return { error: "name required" };
|
||||||
|
const currency = (b.currency ?? "").trim().toUpperCase();
|
||||||
|
if (currency.length < 3) return { error: "currency (ISO 4217) required" };
|
||||||
|
const structure = stampSiteTz(b.structure);
|
||||||
|
const problems = validateTariffStructure(structure);
|
||||||
|
if (problems.length) return { error: "invalid tariff structure", problems };
|
||||||
|
return { name, currency, structure };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stamp a V2 structure's frozen wall-clock timezone from SITE config (never the
|
||||||
|
* client); a V1 (bare) structure passes through untouched. */
|
||||||
|
function stampSiteTz(structure: TariffStructure): TariffStructure {
|
||||||
|
if (structure && isTariffV2(structure)) {
|
||||||
|
const cfg = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
|
||||||
|
const tz = cfg?.timezone && cfg.timezone.length > 0 ? cfg.timezone : DEFAULT_TZ;
|
||||||
|
return { ...structure, tz };
|
||||||
|
}
|
||||||
|
return structure;
|
||||||
|
}
|
||||||
|
|
||||||
/** The tariff version in force at a given instant (latest effectiveFrom ≤ when). */
|
/** The tariff version in force at a given instant (latest effectiveFrom ≤ when). */
|
||||||
function tariffVersionIdFor(whenIso: string): string | null {
|
function tariffVersionIdFor(whenIso: string): string | null {
|
||||||
const tariffId = ensureSiteTariff();
|
const tariffId = ensureSiteTariff();
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
+92
-18
@@ -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";
|
||||||
@@ -64,7 +71,14 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
|
|||||||
const logService = new LogService(db);
|
const logService = new LogService(db);
|
||||||
const app = Fastify({
|
const app = Fastify({
|
||||||
logger: {
|
logger: {
|
||||||
|
// Level knob: trace|debug|info|warn|error|fatal (pino). Default info; a booth
|
||||||
|
// being diagnosed can run LOG_LEVEL=debug without a code change.
|
||||||
level: process.env.LOG_LEVEL ?? "info",
|
level: process.env.LOG_LEVEL ?? "info",
|
||||||
|
// Container logs are read by humans (`docker logs` / Komodo), so stamp
|
||||||
|
// ISO-8601 UTC instead of pino's epoch-ms, and level NAMES instead of the
|
||||||
|
// numeric codes (30/40/50). pinoDbStream accepts both encodings.
|
||||||
|
timestamp: () => `,"time":"${new Date().toISOString()}"`,
|
||||||
|
formatters: { level: (label) => ({ level: label }) },
|
||||||
stream: pinoDbStream(logService, process.stdout),
|
stream: pinoDbStream(logService, process.stdout),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -108,11 +122,24 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
|
|||||||
const visionClient = new VisionClient(app.log);
|
const visionClient = new VisionClient(app.log);
|
||||||
if (visionClient.enabled) app.log.info("vision client enabled");
|
if (visionClient.enabled) app.log.info("vision client enabled");
|
||||||
|
|
||||||
|
// Append-only signed business LEDGER (ledger_events). Holds only business facts
|
||||||
|
// (vehicle_entry/exit, payment, void, …) — the anti-fraud audit trail. A raw
|
||||||
|
// button press is NOT a business fact: it's device telemetry, recorded UNSIGNED
|
||||||
|
// in device_events. The entry flow turns an input into a signed vehicle_entry once
|
||||||
|
// a ticket prints + the barrier is commanded. See event-streams-split.md.
|
||||||
|
// Constructed HERE (before setupRoutes) so the Setup relay-test can sign its
|
||||||
|
// deliberate barrier open into the ledger; the read routes are wired further down.
|
||||||
|
// The 4th arg is a read-side fan-out fired AFTER each durable append — used to
|
||||||
|
// push the event to live booth clients (WS). It cannot affect the sign/chain path.
|
||||||
|
const eventLog = new EventLog(db, buildSigner(app.log), buildVerifier, (row) =>
|
||||||
|
deviceEvents.emitLedger(row),
|
||||||
|
);
|
||||||
|
|
||||||
// Device-agnostic setup: the admin adds controllers (with their relays + entry
|
// Device-agnostic setup: the admin adds controllers (with their relays + entry
|
||||||
// button) and binds readers/cameras to a controller relay at first-run. There is
|
// button) and binds readers/cameras to a controller relay at first-run. There is
|
||||||
// no lane — a parking lot is one pool with a flexible set of entry/exit points.
|
// no lane — a parking lot is one pool with a flexible set of entry/exit points.
|
||||||
// See wiki/concepts/first-run-setup.md, entry-exit-points.md.
|
// See wiki/concepts/first-run-setup.md, entry-exit-points.md.
|
||||||
await setupRoutes(app, db, visionClient);
|
await setupRoutes(app, db, visionClient, eventLog);
|
||||||
|
|
||||||
// Inbound device pushes (e.g. Dingtian Input Link URL → button events),
|
// Inbound device pushes (e.g. Dingtian Input Link URL → button events),
|
||||||
// guarded by source-IP allowlist + a shared-secret path token, both read from
|
// guarded by source-IP allowlist + a shared-secret path token, both read from
|
||||||
@@ -124,6 +151,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.
|
||||||
@@ -145,17 +178,7 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
|
|||||||
app.addHook("onReady", async () => deviceMonitor.start());
|
app.addHook("onReady", async () => deviceMonitor.start());
|
||||||
app.addHook("onClose", async () => deviceMonitor.stop());
|
app.addHook("onClose", async () => deviceMonitor.stop());
|
||||||
|
|
||||||
// Append-only signed business LEDGER (ledger_events). Holds only business facts
|
// Read routes for the signed ledger (constructed above, before setupRoutes).
|
||||||
// (vehicle_entry/exit, payment, void, …) — the anti-fraud audit trail. A raw
|
|
||||||
// button press is NOT a business fact: it's device telemetry, recorded UNSIGNED
|
|
||||||
// in device_events. The entry flow (TODO) turns an input into a signed
|
|
||||||
// vehicle_entry once a ticket prints + the barrier is commanded.
|
|
||||||
// See wiki/decisions/event-streams-split.md.
|
|
||||||
// The 4th arg is a read-side fan-out fired AFTER each durable append — used to
|
|
||||||
// push the event to live booth clients (WS). It cannot affect the sign/chain path.
|
|
||||||
const eventLog = new EventLog(db, buildSigner(app.log), buildVerifier, (row) =>
|
|
||||||
deviceEvents.emitLedger(row),
|
|
||||||
);
|
|
||||||
await eventRoutes(app, db, eventLog);
|
await eventRoutes(app, db, eventLog);
|
||||||
|
|
||||||
// Admin reporting: read-only charts/totals aggregated from the signed ledger
|
// Admin reporting: read-only charts/totals aggregated from the signed ledger
|
||||||
@@ -168,7 +191,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);
|
||||||
@@ -187,6 +210,17 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
|
|||||||
void entryFlow.onInput(e);
|
void entryFlow.onInput(e);
|
||||||
});
|
});
|
||||||
app.addHook("onClose", async () => unsubscribeEntry());
|
app.addHook("onClose", async () => unsubscribeEntry());
|
||||||
|
// The camera press-gate: the entry flow mirrors the entry lane's camera state so a
|
||||||
|
// physical press is live only in the lamp's SOLID state (see entry-flow.ts).
|
||||||
|
const unsubscribeEntryLane = deviceEvents.onLaneStatus((s) => entryFlow.onLaneStatus(s));
|
||||||
|
app.addHook("onClose", async () => unsubscribeEntryLane());
|
||||||
|
|
||||||
|
// 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
|
||||||
@@ -218,10 +252,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,17 +281,28 @@ 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.
|
||||||
await siteRoutes(app, db);
|
await siteRoutes(app, db, eventLog);
|
||||||
|
|
||||||
// Application logs: ingest frontend errors (POST /api/logs, any signed-in user) +
|
// Application logs: ingest frontend errors (POST /api/logs, any signed-in user) +
|
||||||
// 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 +313,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");
|
||||||
@@ -161,4 +234,11 @@ describe("close signs a Z-report; listShifts reads it back", () => {
|
|||||||
await shift.open("bob"); await shift.close("bob");
|
await shift.open("bob"); await shift.close("bob");
|
||||||
expect(shift.listShifts({ operator: "alice" }).map((s) => s.operator)).toEqual(["alice"]);
|
expect(shift.listShifts({ operator: "alice" }).map((s) => s.operator)).toEqual(["alice"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("listOperators: distinct + sorted, includes the OPEN shift's operator", async () => {
|
||||||
|
await shift.open("bob"); await shift.close("bob");
|
||||||
|
await shift.open("bob"); await shift.close("bob"); // twice — must stay distinct
|
||||||
|
await shift.open("alice"); // open, no z-report yet
|
||||||
|
expect(shift.listOperators()).toEqual(["alice", "bob"]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -156,6 +177,28 @@ export class ShiftService {
|
|||||||
* The open shift (no z_report yet) is intentionally excluded — it's not a
|
* The open shift (no z_report yet) is intentionally excluded — it's not a
|
||||||
* completed accountability period. Use `currentOpenShift()` for the live one.
|
* completed accountability period. Use `currentOpenShift()` for the live one.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* Every operator that HAS a shift (closed z_reports + the open one, if any),
|
||||||
|
* distinct + sorted — feeds the admin filter dropdown so it can only ever ask
|
||||||
|
* for an operator that exists (the filter is an exact username match).
|
||||||
|
*/
|
||||||
|
listOperators(): string[] {
|
||||||
|
const rows = this.#db
|
||||||
|
.select()
|
||||||
|
.from(ledgerEvents)
|
||||||
|
.where(eq(ledgerEvents.type, "shift_z_report"))
|
||||||
|
.all();
|
||||||
|
const names = new Set<string>();
|
||||||
|
for (const r of rows) {
|
||||||
|
const op = ((r.payload ?? {}) as { operator?: string }).operator ?? r.identity;
|
||||||
|
if (op) names.add(op);
|
||||||
|
}
|
||||||
|
const open = this.currentOpenShift();
|
||||||
|
const openOp = open ? (((open.payload ?? {}) as { operator?: string }).operator ?? open.identity) : null;
|
||||||
|
if (openOp) names.add(openOp);
|
||||||
|
return [...names].sort((a, b) => a.localeCompare(b));
|
||||||
|
}
|
||||||
|
|
||||||
listShifts(opts: { operator?: string; from?: string; to?: string } = {}): ShiftSummary[] {
|
listShifts(opts: { operator?: string; from?: string; to?: string } = {}): ShiftSummary[] {
|
||||||
const rows = this.#db
|
const rows = this.#db
|
||||||
.select()
|
.select()
|
||||||
@@ -281,24 +324,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 +351,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,12 +688,13 @@ 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 --",
|
||||||
`Fillimi (kusur): ${money(r.openingFloatMinor)} ${cur}`,
|
`Fillimi: ${money(r.openingFloatMinor)} ${cur}`,
|
||||||
`Para të marra: ${money(r.cashTotalMinor)} ${cur}`,
|
`Para të marra: ${money(r.cashTotalMinor)} ${cur}`,
|
||||||
`Para të shtuara: ${money(r.cashAddedMinor)} ${cur}`,
|
`Para të shtuara: ${money(r.cashAddedMinor)} ${cur}`,
|
||||||
`Para të hequra: ${money(r.cashRemovedMinor)} ${cur}`,
|
`Para të hequra: ${money(r.cashRemovedMinor)} ${cur}`,
|
||||||
@@ -577,7 +718,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 +736,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");
|
||||||
|
});
|
||||||
|
});
|
||||||
+199
-7
@@ -1,8 +1,12 @@
|
|||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import { deviceEvents as deviceEventsTable, snapshots, type Db } from "@parking/db";
|
import sharp from "sharp";
|
||||||
import { registry, type CameraDevice } from "@parking/devices";
|
import { and, eq, gte, sessions, deviceEvents as deviceEventsTable, snapshots, type Db } from "@parking/db";
|
||||||
|
import { registry, type CameraDevice, type Snapshot } from "@parking/devices";
|
||||||
|
import { reasonPayload } from "@parking/shared";
|
||||||
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 { EventLog } from "./event-log.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 +30,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;
|
||||||
@@ -36,6 +81,10 @@ interface SnapshotJob {
|
|||||||
/** Optional vision client — when present, ANPR runs on each captured image from an
|
/** Optional vision client — when present, ANPR runs on each captured image from an
|
||||||
* `anpr`-enabled camera and records the plate against `identity`. Advisory only. */
|
* `anpr`-enabled camera and records the plate against `identity`. Advisory only. */
|
||||||
readonly vision?: VisionClient | null;
|
readonly vision?: VisionClient | null;
|
||||||
|
/** Optional signed ledger — when present (the transient ENTRY path passes it), a
|
||||||
|
* recognized entry plate that is already OPEN under another recent session signs an
|
||||||
|
* `entry.duplicatePlate` anomaly (same car, second ticket). Post-hoc; never a gate. */
|
||||||
|
readonly log?: EventLog | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Camera config flag opting it into snapshot-triggered ANPR. */
|
/** Camera config flag opting it into snapshot-triggered ANPR. */
|
||||||
@@ -50,7 +99,7 @@ interface CameraConfig {
|
|||||||
* The caller must NOT block its open path on this.
|
* The caller must NOT block its open path on this.
|
||||||
*/
|
*/
|
||||||
export function snapshotAsync(job: SnapshotJob): Promise<string[]> {
|
export function snapshotAsync(job: SnapshotJob): Promise<string[]> {
|
||||||
const { db, direction, identity, logger, vision } = job;
|
const { db, direction, identity, logger, vision, log } = job;
|
||||||
const rows = devicesByDirection(db, "camera", direction);
|
const rows = devicesByDirection(db, "camera", direction);
|
||||||
if (rows.length === 0) return Promise.resolve([]);
|
if (rows.length === 0) return Promise.resolve([]);
|
||||||
|
|
||||||
@@ -62,16 +111,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();
|
||||||
@@ -81,7 +135,7 @@ export function snapshotAsync(job: SnapshotJob): Promise<string[]> {
|
|||||||
// ANPR off the SAME image, tied to the SAME session — when vision is enabled
|
// ANPR off the SAME image, tied to the SAME session — when vision is enabled
|
||||||
// and this camera opts in. Fire-and-forget: never delays the open path.
|
// and this camera opts in. Fire-and-forget: never delays the open path.
|
||||||
if (vision?.enabled && (row.config as CameraConfig)?.anpr === true) {
|
if (vision?.enabled && (row.config as CameraConfig)?.anpr === true) {
|
||||||
void recognizePlate(db, vision, row.id, direction, identity, id, shot, logger);
|
void recognizePlate(db, vision, row.id, direction, identity, id, shot, logger, log);
|
||||||
}
|
}
|
||||||
return id;
|
return id;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -109,6 +163,7 @@ async function recognizePlate(
|
|||||||
snapshotId: string,
|
snapshotId: string,
|
||||||
shot: { bytes: Buffer; contentType: string },
|
shot: { bytes: Buffer; contentType: string },
|
||||||
logger: FastifyBaseLogger,
|
logger: FastifyBaseLogger,
|
||||||
|
log?: EventLog | null,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const result = await vision.analyze(shot.bytes, shot.contentType);
|
const result = await vision.analyze(shot.bytes, shot.contentType);
|
||||||
@@ -136,11 +191,84 @@ 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 });
|
||||||
|
|
||||||
|
// ENTRY-SIDE duplicate check: this plate already OPEN under another recent session is
|
||||||
|
// most likely the SAME car that minted a second ticket (a motion radar drops a
|
||||||
|
// stationary car → the button re-arms). Signed anomaly for the operator to void.
|
||||||
|
if (direction === "entry" && log) {
|
||||||
|
await flagDuplicateEntryPlate({ db, log, identity, plate, snapshotId, logger });
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.warn(`anpr recognize failed (${identity}): ${(err as Error).message}`);
|
logger.warn(`anpr recognize failed (${identity}): ${(err as Error).message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** How far back a recognized entry plate is compared against other OPEN sessions'
|
||||||
|
* entry plates. Short on purpose: the duplicate-ticket scenario is the same car
|
||||||
|
* re-pressing within minutes; a long window would flag legit re-visits. */
|
||||||
|
function dupPlateWindowMs(): number {
|
||||||
|
const raw = Number(process.env.ENTRY_DUP_PLATE_WINDOW_MIN ?? 15);
|
||||||
|
return (Number.isFinite(raw) && raw > 0 ? raw : 15) * 60_000;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flag a freshly-recognized ENTRY plate that is already open under a DIFFERENT recent
|
||||||
|
* session: sign ONE `entry.duplicatePlate` anomaly keyed to the new session, pointing at
|
||||||
|
* the prior one. Mirrors the exit-side plateSwapSuspected pattern (advisory, post-hoc —
|
||||||
|
* the barrier already opened; the operator voids the duplicate ticket). Exported for tests.
|
||||||
|
*/
|
||||||
|
export async function flagDuplicateEntryPlate(opts: {
|
||||||
|
db: Db;
|
||||||
|
log: EventLog;
|
||||||
|
/** The session the plate was just recognized for (the NEW ticket). */
|
||||||
|
identity: string;
|
||||||
|
plate: string;
|
||||||
|
snapshotId: string;
|
||||||
|
logger: FastifyBaseLogger;
|
||||||
|
}): Promise<void> {
|
||||||
|
const { db, log, identity, plate, snapshotId, logger } = opts;
|
||||||
|
try {
|
||||||
|
const cutoff = new Date(Date.now() - dupPlateWindowMs()).toISOString();
|
||||||
|
// Recent entry-plate reads (unsigned `kind:"read"` telemetry, written above) for the
|
||||||
|
// same plate under a different identity. detail is JSON — filter in JS; read volume
|
||||||
|
// inside the window is tiny (one row per entry).
|
||||||
|
const reads = db
|
||||||
|
.select()
|
||||||
|
.from(deviceEventsTable)
|
||||||
|
.where(and(eq(deviceEventsTable.kind, "read"), gte(deviceEventsTable.occurredAt, cutoff)))
|
||||||
|
.all();
|
||||||
|
const prior = reads
|
||||||
|
.map((r) => r.detail as { identity?: string; direction?: string; plate?: string })
|
||||||
|
.find((d) => d.direction === "entry" && d.plate === plate && d.identity && d.identity !== identity);
|
||||||
|
if (!prior?.identity) return;
|
||||||
|
// Only a still-OPEN prior session is a duplicate suspect (a closed one drove off).
|
||||||
|
const open = db
|
||||||
|
.select()
|
||||||
|
.from(sessions)
|
||||||
|
.where(and(eq(sessions.id, prior.identity), eq(sessions.state, "open")))
|
||||||
|
.get();
|
||||||
|
if (!open) return;
|
||||||
|
await log.append({
|
||||||
|
type: "anomaly",
|
||||||
|
identity,
|
||||||
|
payload: {
|
||||||
|
...reasonPayload("entry.duplicatePlate", { plate, otherIdentity: prior.identity }),
|
||||||
|
duplicateEntrySuspected: true,
|
||||||
|
plate,
|
||||||
|
otherIdentity: prior.identity,
|
||||||
|
snapshotId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
logger.warn(`duplicate entry suspected: plate ${plate} on ${identity} already open under ${prior.identity}`);
|
||||||
|
} catch (err) {
|
||||||
|
// Best-effort, post-hoc — never let the duplicate check surface on the open path.
|
||||||
|
logger.error(`duplicate-plate check failed (${identity}): ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Build a live camera adapter from a resolved devices row, or null. Exported so the
|
/** Build a live camera adapter from a resolved devices row, or null. Exported so the
|
||||||
* ANPR bridge (anpr-entry.ts) reuses the identical registry-build-or-null logic. */
|
* ANPR bridge (anpr-entry.ts) reuses the identical registry-build-or-null logic. */
|
||||||
export function buildCamera(row: { driverId: string; config: unknown }): CameraDevice | null {
|
export function buildCamera(row: { driverId: string; config: unknown }): CameraDevice | null {
|
||||||
@@ -153,6 +281,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,
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { ledgerEvents, subscriptionCredentials, type Db } from "@parking/db";
|
||||||
|
import { createTestDb } from "@parking/db/testing";
|
||||||
|
import { SubscriptionFlow } from "./subscription-flow.js";
|
||||||
|
import type { DeviceReadEvent } from "./device-events.js";
|
||||||
|
import { makeLog, silentLogger } from "./test-helpers.js";
|
||||||
|
|
||||||
|
// CHANNEL AGREEMENT in SubscriptionFlow.match (2026-07-04): when the reader CONFIRMED
|
||||||
|
// the physical channel (DT-008 output prefixes → DeviceReadEvent.channel), the
|
||||||
|
// credential kind must agree. An OPTICAL decode claiming an RF credential is the
|
||||||
|
// cheap clone (print the card's UID as a barcode) — refused + ONE signed anomaly.
|
||||||
|
// Legacy untagged reads (channel undefined) match as before, so readers without
|
||||||
|
// prefixes keep working.
|
||||||
|
|
||||||
|
let db: Db;
|
||||||
|
let flow: SubscriptionFlow;
|
||||||
|
|
||||||
|
const SUB = "sub-1";
|
||||||
|
const CARD_UID = "86A158";
|
||||||
|
const QR_CODE = "SUB-TESTQR";
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
({ db } = createTestDb());
|
||||||
|
db.insert(subscriptionCredentials).values({ id: randomUUID(), subscriptionId: SUB, kind: "rf", value: CARD_UID }).run();
|
||||||
|
db.insert(subscriptionCredentials).values({ id: randomUUID(), subscriptionId: SUB, kind: "qr", value: QR_CODE }).run();
|
||||||
|
flow = new SubscriptionFlow(db, makeLog(db), silentLogger());
|
||||||
|
});
|
||||||
|
|
||||||
|
function read(value: string, opts: { kind?: DeviceReadEvent["kind"]; channel?: DeviceReadEvent["channel"] } = {}): DeviceReadEvent {
|
||||||
|
return {
|
||||||
|
driverId: "dingtian-qr-reader",
|
||||||
|
deviceId: "reader-1",
|
||||||
|
value,
|
||||||
|
kind: opts.kind ?? "qr",
|
||||||
|
...(opts.channel ? { channel: opts.channel } : {}),
|
||||||
|
at: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const anomalies = () =>
|
||||||
|
db.select().from(ledgerEvents).all().filter((r) => r.type === "anomaly");
|
||||||
|
|
||||||
|
describe("subscription match — credential channel agreement", () => {
|
||||||
|
it("OPTICAL read of an RF card's UID → no match + signed channelMismatch anomaly (the clone)", async () => {
|
||||||
|
const m = flow.match(read(CARD_UID, { kind: "qr", channel: "optical" }));
|
||||||
|
expect(m).toBeNull();
|
||||||
|
await vi.waitFor(() => expect(anomalies()).toHaveLength(1)); // append is fire-and-forget
|
||||||
|
expect(anomalies()[0].identity).toBe(SUB);
|
||||||
|
expect(anomalies()[0].payload).toMatchObject({
|
||||||
|
reasonCode: "sub.refused.channelMismatch",
|
||||||
|
channelMismatch: true,
|
||||||
|
credentialKind: "rf",
|
||||||
|
channel: "optical",
|
||||||
|
value: CARD_UID,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("RF read of the same card → matches (via card), nothing signed", () => {
|
||||||
|
const m = flow.match(read(CARD_UID, { kind: "card", channel: "rf" }));
|
||||||
|
expect(m).toMatchObject({ subscriptionId: SUB, via: "card" });
|
||||||
|
expect(anomalies()).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("legacy untagged read of the card → still matches (unprefixed readers keep working)", () => {
|
||||||
|
const m = flow.match(read(CARD_UID)); // kind qr, channel undefined — today's shape
|
||||||
|
expect(m).toMatchObject({ subscriptionId: SUB, via: "card" });
|
||||||
|
expect(anomalies()).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("OPTICAL read of a QR credential → matches (the legit path)", () => {
|
||||||
|
const m = flow.match(read(QR_CODE, { kind: "qr", channel: "optical" }));
|
||||||
|
expect(m).toMatchObject({ subscriptionId: SUB, via: "qr" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("RF read claiming a QR credential → refused symmetrically (mis-encoded clone tag)", async () => {
|
||||||
|
const m = flow.match(read(QR_CODE, { kind: "card", channel: "rf" }));
|
||||||
|
expect(m).toBeNull();
|
||||||
|
await vi.waitFor(() => expect(anomalies()).toHaveLength(1));
|
||||||
|
expect(anomalies()[0].payload).toMatchObject({ credentialKind: "qr", channel: "rf" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("unknown value → plain no-match, no anomaly (a phantom/typo is not a clone attempt)", () => {
|
||||||
|
const m = flow.match(read("999459", { kind: "qr", channel: "optical" }));
|
||||||
|
expect(m).toBeNull();
|
||||||
|
expect(anomalies()).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -77,6 +77,40 @@ export class SubscriptionFlow {
|
|||||||
.where(eq(subscriptionCredentials.value, e.value))
|
.where(eq(subscriptionCredentials.value, e.value))
|
||||||
.get();
|
.get();
|
||||||
if (cred) {
|
if (cred) {
|
||||||
|
// CHANNEL AGREEMENT (clone defense, 2026-07-04). When the reader CONFIRMED the
|
||||||
|
// physical channel (DT-008 output prefixes), the credential kind must agree: an
|
||||||
|
// OPTICAL decode may not claim an RF credential — otherwise printing a card's
|
||||||
|
// UID (often written on the card face) as a barcode clones the card. Symmetric
|
||||||
|
// for an RF read claiming a QR credential (a mis-encoded clone tag). A legacy
|
||||||
|
// untagged read (channel undefined) matches as before — enforcement only bites
|
||||||
|
// where prefixes are deployed. The attempt itself is a fraud signal → signed
|
||||||
|
// anomaly, then treated as no-match (the flows refuse it as unknown).
|
||||||
|
const mismatch =
|
||||||
|
(e.channel === "optical" && cred.kind === "rf") ||
|
||||||
|
(e.channel === "rf" && cred.kind === "qr");
|
||||||
|
if (mismatch) {
|
||||||
|
this.#logger.warn(
|
||||||
|
`credential channel mismatch: ${cred.kind} credential '${e.value}' presented via ${e.channel} (sub ${cred.subscriptionId}) — possible clone`,
|
||||||
|
);
|
||||||
|
void this.#log
|
||||||
|
.append({
|
||||||
|
type: "anomaly",
|
||||||
|
identity: cred.subscriptionId,
|
||||||
|
payload: {
|
||||||
|
...reasonPayload("sub.refused.channelMismatch", {
|
||||||
|
credentialKind: cred.kind,
|
||||||
|
channel: e.channel === "optical" ? "optical" : "rf",
|
||||||
|
}),
|
||||||
|
channelMismatch: true,
|
||||||
|
credentialKind: cred.kind,
|
||||||
|
channel: e.channel,
|
||||||
|
value: e.value,
|
||||||
|
deviceId: e.deviceId,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.catch((err) => this.#logger.error(`channel-mismatch anomaly append failed: ${(err as Error).message}`));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
return { subscriptionId: cred.subscriptionId, carKey: e.value, via: cred.kind === "qr" ? "qr" : "card" };
|
return { subscriptionId: cred.subscriptionId, carKey: e.value, via: cred.kind === "qr" ? "qr" : "card" };
|
||||||
}
|
}
|
||||||
// Plate binding: a read plate that matches a subscription's bound plate is an identity.
|
// Plate binding: a read plate that matches a subscription's bound plate is an identity.
|
||||||
@@ -310,6 +344,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=
|
||||||
|
|||||||
@@ -4,6 +4,10 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<link rel="icon" href="data:," />
|
<link rel="icon" href="data:," />
|
||||||
|
<!-- Self-hosted primary face (offline appliance — no webfont CDN). Preload the
|
||||||
|
two weights on every screen so first paint doesn't flash the fallback. -->
|
||||||
|
<link rel="preload" href="/fonts/chakra-petch/chakra-petch-latin-400.woff2" as="font" type="font/woff2" crossorigin />
|
||||||
|
<link rel="preload" href="/fonts/chakra-petch/chakra-petch-latin-600.woff2" as="font" type="font/woff2" crossorigin />
|
||||||
<title>Parking System</title>
|
<title>Parking System</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
Copyright 2018 The Chakra Petch Project Authors (https://github.com/m4rc1e/Chakra-Petch.git)
|
||||||
|
|
||||||
|
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||||
|
This license is copied below, and is also available with a FAQ at:
|
||||||
|
http://scripts.sil.org/OFL
|
||||||
|
|
||||||
|
|
||||||
|
-----------------------------------------------------------
|
||||||
|
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||||
|
-----------------------------------------------------------
|
||||||
|
|
||||||
|
PREAMBLE
|
||||||
|
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||||
|
development of collaborative font projects, to support the font creation
|
||||||
|
efforts of academic and linguistic communities, and to provide a free and
|
||||||
|
open framework in which fonts may be shared and improved in partnership
|
||||||
|
with others.
|
||||||
|
|
||||||
|
The OFL allows the licensed fonts to be used, studied, modified and
|
||||||
|
redistributed freely as long as they are not sold by themselves. The
|
||||||
|
fonts, including any derivative works, can be bundled, embedded,
|
||||||
|
redistributed and/or sold with any software provided that any reserved
|
||||||
|
names are not used by derivative works. The fonts and derivatives,
|
||||||
|
however, cannot be released under any other type of license. The
|
||||||
|
requirement for fonts to remain under this license does not apply
|
||||||
|
to any document created using the fonts or their derivatives.
|
||||||
|
|
||||||
|
DEFINITIONS
|
||||||
|
"Font Software" refers to the set of files released by the Copyright
|
||||||
|
Holder(s) under this license and clearly marked as such. This may
|
||||||
|
include source files, build scripts and documentation.
|
||||||
|
|
||||||
|
"Reserved Font Name" refers to any names specified as such after the
|
||||||
|
copyright statement(s).
|
||||||
|
|
||||||
|
"Original Version" refers to the collection of Font Software components as
|
||||||
|
distributed by the Copyright Holder(s).
|
||||||
|
|
||||||
|
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||||
|
or substituting -- in part or in whole -- any of the components of the
|
||||||
|
Original Version, by changing formats or by porting the Font Software to a
|
||||||
|
new environment.
|
||||||
|
|
||||||
|
"Author" refers to any designer, engineer, programmer, technical
|
||||||
|
writer or other person who contributed to the Font Software.
|
||||||
|
|
||||||
|
PERMISSION & CONDITIONS
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining
|
||||||
|
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||||
|
redistribute, and sell modified and unmodified copies of the Font
|
||||||
|
Software, subject to the following conditions:
|
||||||
|
|
||||||
|
1) Neither the Font Software nor any of its individual components,
|
||||||
|
in Original or Modified Versions, may be sold by itself.
|
||||||
|
|
||||||
|
2) Original or Modified Versions of the Font Software may be bundled,
|
||||||
|
redistributed and/or sold with any software, provided that each copy
|
||||||
|
contains the above copyright notice and this license. These can be
|
||||||
|
included either as stand-alone text files, human-readable headers or
|
||||||
|
in the appropriate machine-readable metadata fields within text or
|
||||||
|
binary files as long as those fields can be easily viewed by the user.
|
||||||
|
|
||||||
|
3) No Modified Version of the Font Software may use the Reserved Font
|
||||||
|
Name(s) unless explicit written permission is granted by the corresponding
|
||||||
|
Copyright Holder. This restriction only applies to the primary font name as
|
||||||
|
presented to the users.
|
||||||
|
|
||||||
|
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||||
|
Software shall not be used to promote, endorse or advertise any
|
||||||
|
Modified Version, except to acknowledge the contribution(s) of the
|
||||||
|
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||||
|
permission.
|
||||||
|
|
||||||
|
5) The Font Software, modified or unmodified, in part or in whole,
|
||||||
|
must be distributed entirely under this license, and must not be
|
||||||
|
distributed under any other license. The requirement for fonts to
|
||||||
|
remain under this license does not apply to any document created
|
||||||
|
using the Font Software.
|
||||||
|
|
||||||
|
TERMINATION
|
||||||
|
This license becomes null and void if any of the above conditions are
|
||||||
|
not met.
|
||||||
|
|
||||||
|
DISCLAIMER
|
||||||
|
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||||
|
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||||
|
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||||
|
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||||
|
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||||
|
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||||
|
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+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>
|
||||||
|
);
|
||||||
|
}
|
||||||
+159
-30
@@ -19,7 +19,9 @@ 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";
|
||||||
|
import { Spinner } from "./ui/Spinner.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
|
||||||
// session (entry, exit=now, duration, total owed) + entry/exit snapshots, takes
|
// session (entry, exit=now, duration, total owed) + entry/exit snapshots, takes
|
||||||
@@ -59,6 +61,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 +78,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 +182,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 +190,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 +202,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 +245,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,26 +263,32 @@ 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}
|
||||||
disabled={openingShift}
|
disabled={openingShift}
|
||||||
className="btn btn-go btn-sm mt-2"
|
className="btn btn-go btn-sm mt-2"
|
||||||
>
|
>
|
||||||
{openingShift ? t("shift.opening") : t("shift.openNow")}
|
{openingShift ? (
|
||||||
|
<span className="inline-flex items-center gap-1.5">
|
||||||
|
<Spinner /> {t("shift.opening")}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
t("shift.openNow")
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -278,21 +303,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 +359,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 +370,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 +383,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 +402,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 +415,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 +466,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 +486,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 +511,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 +568,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 +639,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 +671,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 +689,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>
|
||||||
);
|
);
|
||||||
|
|||||||
+110
-31
@@ -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, fetchSiteConfig, 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,129 @@ 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,
|
||||||
|
bypassRadar,
|
||||||
|
bypassCamera,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
busy: boolean;
|
||||||
|
radar: boolean;
|
||||||
|
/** When set (entry light + permission), clicking issues an entry ticket — enabled when
|
||||||
|
* both presence conditions are satisfied, treating a BYPASSED signal as satisfied. */
|
||||||
|
onIssue?: () => void;
|
||||||
|
issuing?: boolean;
|
||||||
|
/** Admin bypass of a faulty device: a bypassed signal counts as present (server re-checks). */
|
||||||
|
bypassRadar?: boolean;
|
||||||
|
bypassCamera?: 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";
|
||||||
|
// A bypassed signal counts as satisfied (its device is faulty). The SERVER re-checks the
|
||||||
|
// effective gate authoritatively; this only governs button affordance.
|
||||||
|
const radarOk = radar || !!bypassRadar;
|
||||||
|
const cameraOk = busy || !!bypassCamera;
|
||||||
|
const canIssue = !!onIssue && radarOk && cameraOk && !issuing;
|
||||||
|
const clickable = !!onIssue && radarOk && cameraOk;
|
||||||
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;
|
||||||
|
// Presence-gate bypass flags (admin, for faulty radar/camera). Refetched on interval so a
|
||||||
|
// toggle reaches the booth without a reload; the server still re-checks authoritatively.
|
||||||
|
const { data: site } = useQuery({
|
||||||
|
queryKey: qk.siteConfig,
|
||||||
|
queryFn: fetchSiteConfig,
|
||||||
|
staleTime: 30_000,
|
||||||
|
refetchInterval: 60_000,
|
||||||
|
});
|
||||||
|
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}
|
||||||
|
bypassRadar={site?.bypassPresenceRadar ?? false}
|
||||||
|
bypassCamera={site?.bypassPresenceCamera ?? false}
|
||||||
|
/>
|
||||||
|
<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 +260,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 +286,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 +309,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 +346,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 +362,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,508 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
fetchDrawerBalance,
|
||||||
|
fetchDrawerMovements,
|
||||||
|
fetchEvents,
|
||||||
|
fetchShift,
|
||||||
|
fetchShiftReport,
|
||||||
|
fetchShifts,
|
||||||
|
recordDrawerMovement,
|
||||||
|
reviewDrawerMovement,
|
||||||
|
type DrawerMovement,
|
||||||
|
type MovementStatus,
|
||||||
|
type ShiftSummary,
|
||||||
|
} from "./api.js";
|
||||||
|
import { formatMoney, formatRelativeDateTime } from "./lib/format.js";
|
||||||
|
import { Panel } from "./ui/Panel.js";
|
||||||
|
import type { LedgerEvent } from "@parking/shared";
|
||||||
|
|
||||||
|
// The DRAWER HUB (redesigned 2026-07-05 — was only record + review). One screen
|
||||||
|
// answers "what's in the till and why": the CURRENT drawer balance with the open
|
||||||
|
// shift's running breakdown (float + takings + vouchers = expected), TODAY's cash
|
||||||
|
// activity (every cash payment and voucher, live), the movement record/review flow
|
||||||
|
// (unchanged), and the closed-shift drawer history. All figures come from the signed
|
||||||
|
// chain — the drawer is a single site-wide till that carries across shifts. See
|
||||||
|
// wiki/concepts/shift.md.
|
||||||
|
|
||||||
|
const money = (m: number, cur: string | null) => formatMoney(m, cur ?? "");
|
||||||
|
|
||||||
|
/** Local midnight, ISO — the "today" window for the activity feed. */
|
||||||
|
function startOfToday(): string {
|
||||||
|
const d = new Date();
|
||||||
|
d.setHours(0, 0, 0, 0);
|
||||||
|
return d.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
const refresh = () => {
|
||||||
|
void qc.invalidateQueries({ queryKey: ["drawer"] });
|
||||||
|
// A voucher moves the open shift's added/removed figures too (the X-report).
|
||||||
|
void qc.invalidateQueries({ queryKey: ["shift"] });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full min-h-0 flex-col gap-3 overflow-y-auto p-3 lg:overflow-hidden">
|
||||||
|
{/* Row 1: the till NOW + the record form. */}
|
||||||
|
<div className="grid shrink-0 gap-3 lg:grid-cols-[1.3fr_1fr]">
|
||||||
|
<StatePanel />
|
||||||
|
{canCreate && <RecordPanel onDone={refresh} />}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Row 2: today's cash feed · the movement review queue · closed shifts. */}
|
||||||
|
<div className="grid min-h-0 flex-1 gap-3 lg:grid-cols-3">
|
||||||
|
<TodayPanel />
|
||||||
|
<MovementsPanel canReview={canReview} onChanged={refresh} />
|
||||||
|
<ShiftHistoryPanel />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- The drawer NOW ---------------------------------------------------------
|
||||||
|
// Balance from the chain + the open shift's running X-report breakdown, so the big
|
||||||
|
// number is always explainable: float + cash takings + in − out = expected = balance.
|
||||||
|
|
||||||
|
function StatePanel() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const balance = useQuery({ queryKey: ["drawer", "balance"], queryFn: fetchDrawerBalance, refetchInterval: 10_000 });
|
||||||
|
const status = useQuery({ queryKey: ["shift", "current"], queryFn: fetchShift });
|
||||||
|
const report = useQuery({
|
||||||
|
queryKey: ["shift", "xreport"],
|
||||||
|
queryFn: fetchShiftReport,
|
||||||
|
enabled: status.data?.open != null,
|
||||||
|
refetchInterval: 10_000,
|
||||||
|
});
|
||||||
|
const x = status.data?.open ? report.data : null;
|
||||||
|
const cur = balance.data?.currency ?? x?.currency ?? null;
|
||||||
|
// The current SHIFT's own balance: what this shift changed in the till
|
||||||
|
// (takings + vouchers), i.e. everything above the inherited opening float.
|
||||||
|
const shiftDelta = x ? x.expectedDrawerMinor - x.openingFloatMinor : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Panel title={t("drawer.stateTitle")}>
|
||||||
|
<div className="flex flex-wrap items-end justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<div className="text-3xl font-bold text-term-cyan tabular-nums">
|
||||||
|
{balance.data ? money(balance.data.balanceMinor, cur) : "…"}
|
||||||
|
</div>
|
||||||
|
{shiftDelta != null && (
|
||||||
|
<div className="mt-0.5 text-[0.8125rem] tabular-nums">
|
||||||
|
<span className="text-term-muted">{t("drawer.thisShift")} </span>
|
||||||
|
<span className={shiftDelta < 0 ? "font-semibold text-term-red" : "font-semibold text-term-green"}>
|
||||||
|
{shiftDelta >= 0 ? "+" : ""}
|
||||||
|
{money(shiftDelta, cur)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="mt-0.5 text-[0.6875rem] text-term-muted">
|
||||||
|
{status.data?.open
|
||||||
|
? t("drawer.openShift", { operator: status.data.open.operator }) +
|
||||||
|
" · " +
|
||||||
|
formatRelativeDateTime(status.data.open.startedAt, t)
|
||||||
|
: t("drawer.noShiftOpen")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* The running breakdown, only while a shift is open (it's the X-report). */}
|
||||||
|
{x && (
|
||||||
|
<dl className="grid grid-cols-[max-content_max-content] gap-x-4 gap-y-0.5 text-[0.75rem] tabular-nums">
|
||||||
|
<dt className="text-term-muted">{t("shifts.openingFloat")}</dt>
|
||||||
|
<dd className="text-right text-term-text">{money(x.openingFloatMinor, cur)}</dd>
|
||||||
|
<dt className="text-term-muted">
|
||||||
|
{t("shifts.cashTaken")} · {t("shifts.payments")} {x.paymentCount}
|
||||||
|
</dt>
|
||||||
|
<dd className="text-right text-term-green">{money(x.cashTotalMinor, cur)}</dd>
|
||||||
|
<dt className="text-term-muted">{t("shifts.cashAdded")}</dt>
|
||||||
|
<dd className="text-right text-term-text">{money(x.cashAddedMinor, cur)}</dd>
|
||||||
|
<dt className="text-term-muted">{t("shifts.cashRemoved")}</dt>
|
||||||
|
<dd className="text-right text-term-red">{money(-x.cashRemovedMinor, cur)}</dd>
|
||||||
|
<dt className="border-t border-term-border pt-0.5 font-semibold text-term-muted">{t("shifts.expectedDrawer")}</dt>
|
||||||
|
<dd className="border-t border-term-border pt-0.5 text-right font-semibold text-term-text">
|
||||||
|
{money(x.expectedDrawerMinor, cur)}
|
||||||
|
</dd>
|
||||||
|
</dl>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Today's cash activity ---------------------------------------------------
|
||||||
|
// Every drawer-touching event since local midnight: cash payments (the current
|
||||||
|
// shift's incomings, live) + vouchers. Card payments never enter the till.
|
||||||
|
|
||||||
|
function TodayPanel() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const q = useQuery({
|
||||||
|
queryKey: ["drawer", "today"],
|
||||||
|
queryFn: () => fetchEvents(1000, startOfToday()),
|
||||||
|
refetchInterval: 15_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const rows = (q.data?.events ?? []).filter((e) => {
|
||||||
|
if (e.type === "cash_in" || e.type === "cash_out") return true;
|
||||||
|
if (e.type !== "payment") return false;
|
||||||
|
return (e.payload as { tender?: string } | null)?.tender !== "card";
|
||||||
|
});
|
||||||
|
|
||||||
|
let cashIn = 0;
|
||||||
|
let vouchersNet = 0;
|
||||||
|
let payments = 0;
|
||||||
|
let cur: string | null = null;
|
||||||
|
for (const e of rows) {
|
||||||
|
const pl = (e.payload ?? {}) as { amountMinor?: number; currency?: string };
|
||||||
|
const amt = pl.amountMinor ?? 0;
|
||||||
|
if (pl.currency) cur = pl.currency;
|
||||||
|
if (e.type === "payment") {
|
||||||
|
cashIn += amt;
|
||||||
|
payments++;
|
||||||
|
} else {
|
||||||
|
vouchersNet += e.type === "cash_in" ? Math.abs(amt) : -Math.abs(amt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Panel
|
||||||
|
title={t("drawer.todayTitle")}
|
||||||
|
right={
|
||||||
|
rows.length > 0 ? (
|
||||||
|
<span className="text-[0.6875rem] tabular-nums text-term-muted">
|
||||||
|
{t("drawer.todayPayments", { count: payments })} · <span className="text-term-green">{money(cashIn, cur)}</span>
|
||||||
|
{vouchersNet !== 0 && (
|
||||||
|
<>
|
||||||
|
{" "}
|
||||||
|
· <span className={vouchersNet < 0 ? "text-term-red" : "text-term-green"}>{money(vouchersNet, cur)}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
className="min-h-0"
|
||||||
|
>
|
||||||
|
<div className="h-full min-h-0 overflow-y-auto pr-1">
|
||||||
|
{q.isError ? (
|
||||||
|
<div className="text-[0.75rem] text-term-red">{(q.error as Error).message}</div>
|
||||||
|
) : q.isLoading ? (
|
||||||
|
<div className="text-term-muted">{t("common.loading")}</div>
|
||||||
|
) : rows.length === 0 ? (
|
||||||
|
<div className="text-term-muted">{t("drawer.noActivity")}</div>
|
||||||
|
) : (
|
||||||
|
<table className="w-full text-[0.75rem] tabular-nums">
|
||||||
|
<tbody>
|
||||||
|
{rows.map((e) => (
|
||||||
|
<TodayRow key={e.id} e={e} />
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TodayRow({ e }: { e: LedgerEvent }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const pl = (e.payload ?? {}) as { amountMinor?: number; currency?: string; voucherNo?: string; reason?: string };
|
||||||
|
const amt = pl.amountMinor ?? 0;
|
||||||
|
const signed = e.type === "cash_out" ? -Math.abs(amt) : Math.abs(amt);
|
||||||
|
const time = new Date(e.occurredAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
||||||
|
const label =
|
||||||
|
e.type === "payment"
|
||||||
|
? `${t("drawer.payment")}${e.identity ? ` · ${e.identity}` : ""}`
|
||||||
|
: `${e.type === "cash_in" ? t("drawer.mandatArketimi") : t("drawer.mandatPagese")}${pl.voucherNo ? ` ${pl.voucherNo}` : ""}`;
|
||||||
|
return (
|
||||||
|
<tr className="border-t border-term-border/40">
|
||||||
|
<td className="whitespace-nowrap py-1 pr-2 text-term-muted">{time}</td>
|
||||||
|
<td className="max-w-0 truncate py-1 pr-2 text-term-text" title={pl.reason || undefined}>
|
||||||
|
{label}
|
||||||
|
</td>
|
||||||
|
<td className={`whitespace-nowrap py-1 text-right ${signed < 0 ? "text-term-red" : "text-term-green"}`}>
|
||||||
|
{money(signed, pl.currency ?? null)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Movements (record + review) — the pre-redesign feature, unchanged ------
|
||||||
|
|
||||||
|
function MovementsPanel({ canReview, onChanged }: { canReview: boolean; onChanged: () => void }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
// 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 (
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
<div className="flex h-full min-h-0 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={onChanged} />
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Closed shifts, drawer-focused -------------------------------------------
|
||||||
|
// Scope follows /api/shifts: operators see their own, admins all.
|
||||||
|
|
||||||
|
function ShiftHistoryPanel() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const q = useQuery({ queryKey: ["shifts", "drawer-history"], queryFn: () => fetchShifts() });
|
||||||
|
const shifts = (q.data?.shifts ?? []).slice(0, 50);
|
||||||
|
const showOperator = q.data?.scope === "all";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Panel title={t("drawer.historyTitle")} className="min-h-0">
|
||||||
|
<div className="h-full min-h-0 overflow-y-auto pr-1">
|
||||||
|
{q.isLoading ? (
|
||||||
|
<div className="text-term-muted">{t("common.loading")}</div>
|
||||||
|
) : shifts.length === 0 ? (
|
||||||
|
<div className="text-term-muted">{t("drawer.noShifts")}</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
{shifts.map((s) => (
|
||||||
|
<ShiftDrawerCard key={s.id} s={s} showOperator={showOperator} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ShiftDrawerCard({ s, showOperator }: { s: ShiftSummary; showOperator: boolean }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const cur = s.currency;
|
||||||
|
return (
|
||||||
|
<div className="card p-2.5 text-[0.75rem]">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<span className="font-semibold text-term-text">
|
||||||
|
{showOperator ? `${s.operator} · ` : ""}
|
||||||
|
{formatRelativeDateTime(s.startedAt, t)}
|
||||||
|
</span>
|
||||||
|
<span className="font-semibold text-term-text tabular-nums" title={t("shifts.expectedDrawer")}>
|
||||||
|
{money(s.expectedDrawerMinor, cur)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-0.5 flex flex-wrap gap-x-3 text-term-muted tabular-nums">
|
||||||
|
<span title={t("shifts.openingFloat")}>{money(s.openingFloatMinor, cur)} →</span>
|
||||||
|
<span className="text-term-green" title={t("shifts.cashTaken")}>
|
||||||
|
+{money(s.cashTotalMinor, cur)}
|
||||||
|
</span>
|
||||||
|
{s.cashAddedMinor > 0 && (
|
||||||
|
<span className="text-term-green" title={t("shifts.cashAdded")}>
|
||||||
|
+{money(s.cashAddedMinor, cur)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{s.cashRemovedMinor > 0 && (
|
||||||
|
<span className="text-term-red" title={t("shifts.cashRemoved")}>
|
||||||
|
−{money(s.cashRemovedMinor, cur)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Record form (unchanged from the pre-redesign feature) ------------------
|
||||||
|
|
||||||
|
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>
|
||||||
|
|||||||
+705
-82
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
closeShift,
|
closeShift,
|
||||||
fetchEvents,
|
fetchEvents,
|
||||||
@@ -8,13 +8,14 @@ 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 { Spinner } from "./ui/Spinner.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 +89,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("");
|
||||||
@@ -105,9 +106,17 @@ export function ShiftsHistory({ user, canManage = false, canVoucher = false }: {
|
|||||||
to: range?.to ? new Date(`${range.to}T23:59:59`).toISOString() : undefined,
|
to: range?.to ? new Date(`${range.to}T23:59:59`).toISOString() : undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
const q = useQuery({ queryKey: ["shifts", applied], queryFn: () => fetchShifts(applied) });
|
// keepPreviousData: every filter change makes a NEW query key; without it the
|
||||||
|
// data (and with it `scope`) goes undefined for the fetch round-trip, which
|
||||||
|
// unmounted the admin filter controls mid-interaction and blanked the list.
|
||||||
|
const q = useQuery({
|
||||||
|
queryKey: ["shifts", applied],
|
||||||
|
queryFn: () => fetchShifts(applied),
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
|
});
|
||||||
const isAdmin = q.data?.scope === "all";
|
const isAdmin = q.data?.scope === "all";
|
||||||
const closed = q.data?.shifts ?? [];
|
const closed = q.data?.shifts ?? [];
|
||||||
|
const operators = q.data?.operators ?? [];
|
||||||
|
|
||||||
// The current/open shift sits at the TOP of the list (when present + visible to me).
|
// The current/open shift sits at the TOP of the list (when present + visible to me).
|
||||||
const list: (ShiftSummary & { open?: boolean })[] = current && (isMine || isAdmin) ? [current, ...closed] : closed;
|
const list: (ShiftSummary & { open?: boolean })[] = current && (isMine || isAdmin) ? [current, ...closed] : closed;
|
||||||
@@ -168,14 +177,21 @@ export function ShiftsHistory({ user, canManage = false, canVoucher = false }: {
|
|||||||
)}
|
)}
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<span className="label">{t("shifts.operator")}</span>
|
{/* <span className="label">{t("shifts.operator")}</span> */}
|
||||||
<input className="input w-44" value={operator} onChange={(e) => setOperator(e.target.value)} placeholder={t("shifts.allOperators")} />
|
{/* A select over operators that HAVE shifts — the server filter is an
|
||||||
|
exact username match, so free text could only miss. */}
|
||||||
|
<select className="input w-44" value={operator} onChange={(e) => setOperator(e.target.value)}>
|
||||||
|
<option value="">{t("shifts.allOperators")}</option>
|
||||||
|
{operators.map((op) => (
|
||||||
|
<option key={op} value={op}>{op}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</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 +199,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 +214,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,9 +243,15 @@ 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 ? (
|
||||||
|
<span className="inline-flex items-center gap-1.5">
|
||||||
|
<Spinner /> {t("shift.starting")}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
t("shift.startShift")
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
@@ -244,11 +265,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 +278,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 +291,6 @@ function ShiftActivityLog({
|
|||||||
isMine,
|
isMine,
|
||||||
showOperator,
|
showOperator,
|
||||||
canManage,
|
canManage,
|
||||||
canVoucher,
|
|
||||||
onChanged,
|
onChanged,
|
||||||
}: {
|
}: {
|
||||||
shift: ShiftSummary;
|
shift: ShiftSummary;
|
||||||
@@ -278,11 +298,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 +319,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 +330,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 +358,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 +389,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 +417,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 +447,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 +454,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 +488,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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
type SubscriptionPlan,
|
type SubscriptionPlan,
|
||||||
} from "./api.js";
|
} from "./api.js";
|
||||||
import { Modal } from "./ui/Modal.js";
|
import { Modal } from "./ui/Modal.js";
|
||||||
|
import { currencyOptions } from "./lib/currencies.js";
|
||||||
|
|
||||||
// Admin-only subscription PLAN catalog. Plans are admin-composed, versioned config the
|
// Admin-only subscription PLAN catalog. Plans are admin-composed, versioned config the
|
||||||
// operator sells from (so the operator never types a price). Editing a plan PUBLISHES A
|
// operator sells from (so the operator never types a price). Editing a plan PUBLISHES A
|
||||||
@@ -237,19 +238,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 +264,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 +281,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 +296,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>
|
||||||
@@ -348,15 +349,19 @@ export function SubscriptionPlansManager() {
|
|||||||
<label className="label">{t("plans.pricePer")}</label>
|
<label className="label">{t("plans.pricePer")}</label>
|
||||||
<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 })} />
|
<select className="input w-auto" 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>
|
{currencyOptions(form.currency).map((c) => (
|
||||||
|
<option key={c} value={c}>{c}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<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 +375,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 +391,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 +400,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>
|
||||||
|
|||||||
+83
-555
@@ -1,267 +1,27 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import { ApiError, fetchTariff, publishTariffVersion, type TariffState, type TariffVersion } from "./api.js";
|
||||||
ApiError,
|
import { TariffEditorForm, emptyForm, formFromActive, formFromVersion, toStructure, type FormState } from "./TariffEditorForm.js";
|
||||||
fetchTariff,
|
|
||||||
isTariffV2,
|
|
||||||
publishTariffVersion,
|
|
||||||
type TariffBlock,
|
|
||||||
type TariffCard,
|
|
||||||
type TariffStep,
|
|
||||||
type TariffStructure,
|
|
||||||
type TariffState,
|
|
||||||
} from "./api.js";
|
|
||||||
|
|
||||||
// Tariff composer — the admin builds + edits the rate card at runtime. Publishing
|
// Tariff composer — the admin edits + publishes the LIVE rate card. Publishing
|
||||||
// creates a new IMMUTABLE version (the active card); old versions are kept so past
|
// creates a new IMMUTABLE version (the active card); old versions are kept so past
|
||||||
// sessions reprice correctly. Amounts are entered in major units (e.g. euros) for
|
// sessions reprice correctly. A right sidebar lists the published history (named
|
||||||
// usability and converted to integer minor units on submit. See wiki/concepts/tariff.md.
|
// since 2026-07-05); clicking a version loads it into the editor as the STARTING
|
||||||
|
// POINT — publishing always creates a new version effective now, it never edits the
|
||||||
// Editable form mirror of TariffStructure, but money in major-unit strings.
|
// clicked one. The form machinery is shared with the Tariff Lab's draft modal — see
|
||||||
// Blocks are edited as a DURATION in hours ("this band lasts N hours") — the
|
// TariffEditorForm.tsx. To experiment without publishing, use the lab. See
|
||||||
// owner thinks "first 2 hours, then next 3 hours", not in cumulative minutes.
|
// wiki/concepts/tariff.md.
|
||||||
// The LAST block is always open-ended ("thereafter"): its hours field is unused
|
|
||||||
// and it has no bound. On submit, per-block hours accumulate into the engine's
|
|
||||||
// cumulative `uptoMin` (minutes), and the last block emits uptoMin: null.
|
|
||||||
interface BlockForm {
|
|
||||||
hours: string; // duration of THIS band, in hours (ignored for the last block)
|
|
||||||
price: string; // major units, e.g. "2.00"
|
|
||||||
}
|
|
||||||
// One STEPPED ("up-to") row: "a stay up to N hours costs TOTAL". The owner enters the
|
|
||||||
// matrix verbatim (totals, not marginal rates). See wiki/concepts/tariff.md.
|
|
||||||
interface StepForm {
|
|
||||||
hours: string; // inclusive upper bound of this tier, in hours (e.g. "3")
|
|
||||||
total: string; // TOTAL major units for a stay within this tier (e.g. "5.00")
|
|
||||||
}
|
|
||||||
// A pricing body the form edits: a flat rate, a marginal block ladder, or a stepped
|
|
||||||
// (up-to) total-by-duration table.
|
|
||||||
interface PricingForm {
|
|
||||||
mode: "ladder" | "flat" | "stepped";
|
|
||||||
flat: string; // major units (used when mode==="flat")
|
|
||||||
blocks: BlockForm[]; // hours-based ladder (used when mode==="ladder")
|
|
||||||
steps: StepForm[]; // up-to tiers (used when mode==="stepped")
|
|
||||||
dailyCap: string; // "" = no cap (ladder only)
|
|
||||||
}
|
|
||||||
// An optional time/category TIER (a V2 windowed card). Absent windows = unconstrained.
|
|
||||||
interface TierForm {
|
|
||||||
name: string;
|
|
||||||
priority: string;
|
|
||||||
category: string; // "" = applies to all categories
|
|
||||||
dow: number[]; // selected days 0..6; empty = every day
|
|
||||||
fromHour: string; // "" = all day
|
|
||||||
toHour: string;
|
|
||||||
dateFrom: string; // "" = unbounded
|
|
||||||
dateTo: string;
|
|
||||||
pricing: PricingForm;
|
|
||||||
}
|
|
||||||
interface FormState {
|
|
||||||
currency: string;
|
|
||||||
gracePeriodEntryMin: string;
|
|
||||||
incrementMin: string;
|
|
||||||
lostTicket: string;
|
|
||||||
gracePeriodExitMin: string;
|
|
||||||
// The default (always-active) card — its own flat/ladder body + daily cap.
|
|
||||||
base: PricingForm;
|
|
||||||
// Optional time/category tiers. Empty ⇒ a bare V1 structure is published.
|
|
||||||
tiers: TierForm[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const toMinor = (major: string): number => Math.round(parseFloat(major || "0") * 100);
|
|
||||||
const toMajor = (minor: number): string => (minor / 100).toFixed(2);
|
|
||||||
|
|
||||||
function emptySteps(): StepForm[] {
|
|
||||||
return [
|
|
||||||
{ hours: "1", total: "2.00" },
|
|
||||||
{ hours: "3", total: "5.00" },
|
|
||||||
];
|
|
||||||
}
|
|
||||||
function emptyLadder(): PricingForm {
|
|
||||||
return {
|
|
||||||
mode: "ladder",
|
|
||||||
flat: "0.00",
|
|
||||||
dailyCap: "",
|
|
||||||
blocks: [{ hours: "1", price: "2.00" }, { hours: "", price: "1.00" }],
|
|
||||||
steps: emptySteps(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
function emptyTier(): TierForm {
|
|
||||||
return {
|
|
||||||
name: "",
|
|
||||||
priority: "10",
|
|
||||||
category: "",
|
|
||||||
dow: [],
|
|
||||||
fromHour: "",
|
|
||||||
toHour: "",
|
|
||||||
dateFrom: "",
|
|
||||||
dateTo: "",
|
|
||||||
pricing: { ...emptyLadder(), blocks: [{ hours: "", price: "1.00" }] },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function emptyForm(): FormState {
|
|
||||||
return {
|
|
||||||
currency: "EUR",
|
|
||||||
gracePeriodEntryMin: "15",
|
|
||||||
incrementMin: "60",
|
|
||||||
lostTicket: "20.00",
|
|
||||||
gracePeriodExitMin: "15",
|
|
||||||
base: emptyLadder(),
|
|
||||||
tiers: [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert a stored block ladder's cumulative `uptoMin` (minutes) into the per-band
|
|
||||||
// hours the form edits. Open-ended last band has no hours. Legacy bounded tails still
|
|
||||||
// load (shown as their own band).
|
|
||||||
function blocksToForm(blocks: TariffBlock[]): BlockForm[] {
|
|
||||||
let prev = 0;
|
|
||||||
return blocks.map((b) => {
|
|
||||||
if (b.uptoMin == null) return { hours: "", price: toMajor(b.priceMinorPerIncrement) };
|
|
||||||
const hours = (b.uptoMin - prev) / 60;
|
|
||||||
prev = b.uptoMin;
|
|
||||||
return { hours: String(hours), price: toMajor(b.priceMinorPerIncrement) };
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// A stored stepped table's `uptoMin` (minutes) → the per-tier hours the form edits.
|
|
||||||
function stepsToForm(steps: TariffStep[]): StepForm[] {
|
|
||||||
return steps.map((s) => ({ hours: String(s.uptoMin / 60), total: toMajor(s.totalMinor) }));
|
|
||||||
}
|
|
||||||
|
|
||||||
// A stored card (V2) or bare-V1 body → the form's PricingForm (flat, ladder, or stepped).
|
|
||||||
function pricingFromCard(c: {
|
|
||||||
flatMinor?: number;
|
|
||||||
blocks?: TariffBlock[];
|
|
||||||
steps?: TariffStep[];
|
|
||||||
dailyCapMinor?: number | null;
|
|
||||||
}): PricingForm {
|
|
||||||
if (c.steps != null && c.steps.length > 0) {
|
|
||||||
return { mode: "stepped", flat: "0.00", dailyCap: "", blocks: emptyLadder().blocks, steps: stepsToForm(c.steps) };
|
|
||||||
}
|
|
||||||
if (c.flatMinor != null) {
|
|
||||||
return { mode: "flat", flat: toMajor(c.flatMinor), dailyCap: "", blocks: emptyLadder().blocks, steps: emptySteps() };
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
mode: "ladder",
|
|
||||||
flat: "0.00",
|
|
||||||
dailyCap: c.dailyCapMinor == null ? "" : toMajor(c.dailyCapMinor),
|
|
||||||
blocks: blocksToForm(c.blocks ?? []),
|
|
||||||
steps: emptySteps(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function tierFromCard(c: TariffCard): TierForm {
|
|
||||||
const w = c.window ?? {};
|
|
||||||
return {
|
|
||||||
name: c.name,
|
|
||||||
priority: String(c.priority),
|
|
||||||
category: c.category ?? "",
|
|
||||||
dow: w.dow ? [...w.dow] : [],
|
|
||||||
fromHour: w.fromHour ?? "",
|
|
||||||
toHour: w.toHour ?? "",
|
|
||||||
dateFrom: w.dateFrom ?? "",
|
|
||||||
dateTo: w.dateTo ?? "",
|
|
||||||
pricing: pricingFromCard(c),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function formFromActive(s: TariffState): FormState {
|
|
||||||
const v = s.active;
|
|
||||||
if (!v) return emptyForm();
|
|
||||||
const st = v.structure;
|
|
||||||
const common = {
|
|
||||||
currency: v.currency,
|
|
||||||
gracePeriodEntryMin: String(st.gracePeriodEntryMin),
|
|
||||||
incrementMin: String(st.incrementMin),
|
|
||||||
lostTicket: toMajor(st.lostTicketMinor),
|
|
||||||
gracePeriodExitMin: String(st.gracePeriodExitMin),
|
|
||||||
};
|
|
||||||
if (isTariffV2(st)) {
|
|
||||||
return { ...common, base: pricingFromCard(st.defaultCard), tiers: (st.windowedCards ?? []).map(tierFromCard) };
|
|
||||||
}
|
|
||||||
// V1: the bare ladder becomes the default card body; no tiers.
|
|
||||||
return { ...common, base: pricingFromCard(st), tiers: [] };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build a tariff card's pricing body (flat XOR ladder XOR stepped) from a PricingForm.
|
|
||||||
function pricingToCardBody(p: PricingForm): Pick<TariffCard, "flatMinor" | "blocks" | "steps" | "dailyCapMinor"> {
|
|
||||||
if (p.mode === "flat") return { flatMinor: toMinor(p.flat) };
|
|
||||||
if (p.mode === "stepped") {
|
|
||||||
// Each row's `hours` IS the inclusive threshold (the matrix "up to N hours").
|
|
||||||
const steps: TariffStep[] = p.steps.map((s) => ({
|
|
||||||
uptoMin: Math.round(Number(s.hours || "0") * 60),
|
|
||||||
totalMinor: toMinor(s.total),
|
|
||||||
}));
|
|
||||||
return { steps };
|
|
||||||
}
|
|
||||||
// Accumulate each band's hours into cumulative uptoMin (min); last band open-ended.
|
|
||||||
const last = p.blocks.length - 1;
|
|
||||||
let cum = 0;
|
|
||||||
const blocks: TariffBlock[] = p.blocks.map((b, i) => {
|
|
||||||
if (i === last) return { uptoMin: null, priceMinorPerIncrement: toMinor(b.price) };
|
|
||||||
cum += Math.round(Number(b.hours || "0") * 60);
|
|
||||||
return { uptoMin: cum, priceMinorPerIncrement: toMinor(b.price) };
|
|
||||||
});
|
|
||||||
return { blocks, dailyCapMinor: p.dailyCap.trim() === "" ? null : toMinor(p.dailyCap) };
|
|
||||||
}
|
|
||||||
|
|
||||||
function tierToCard(tr: TierForm): TariffCard {
|
|
||||||
const window: TariffCard["window"] = {};
|
|
||||||
if (tr.dow.length > 0) window.dow = [...tr.dow].sort((a, b) => a - b);
|
|
||||||
if (tr.fromHour && tr.toHour) {
|
|
||||||
window.fromHour = tr.fromHour;
|
|
||||||
window.toHour = tr.toHour;
|
|
||||||
}
|
|
||||||
if (tr.dateFrom) window.dateFrom = tr.dateFrom;
|
|
||||||
if (tr.dateTo) window.dateTo = tr.dateTo;
|
|
||||||
const card: TariffCard = {
|
|
||||||
name: tr.name.trim() || "tier",
|
|
||||||
priority: Math.round(Number(tr.priority || "0")),
|
|
||||||
...pricingToCardBody(tr.pricing),
|
|
||||||
};
|
|
||||||
if (tr.category.trim()) card.category = tr.category.trim();
|
|
||||||
if (Object.keys(window).length > 0) card.window = window;
|
|
||||||
return card;
|
|
||||||
}
|
|
||||||
|
|
||||||
function toStructure(f: FormState): TariffStructure {
|
|
||||||
const common = {
|
|
||||||
gracePeriodEntryMin: Math.round(Number(f.gracePeriodEntryMin)),
|
|
||||||
incrementMin: Math.round(Number(f.incrementMin)),
|
|
||||||
lostTicketMinor: toMinor(f.lostTicket),
|
|
||||||
gracePeriodExitMin: Math.round(Number(f.gracePeriodExitMin)),
|
|
||||||
overstay: "reprice" as const,
|
|
||||||
};
|
|
||||||
const baseBody = pricingToCardBody(f.base);
|
|
||||||
|
|
||||||
// NO tiers ⇒ publish a BARE V1 structure (back-compat: a site that never wants
|
|
||||||
// tiers gets exactly today's shape; the server leaves it untouched).
|
|
||||||
if (f.tiers.length === 0) {
|
|
||||||
if (f.base.mode === "stepped") {
|
|
||||||
// A stepped V1: the up-to table replaces the ladder (blocks empty, no cap).
|
|
||||||
return { ...common, blocks: [], steps: baseBody.steps ?? [], dailyCapMinor: null };
|
|
||||||
}
|
|
||||||
if (f.base.mode === "flat") {
|
|
||||||
// A flat V1: a single open-ended block at the flat rate (V1 has no flat field).
|
|
||||||
return { ...common, blocks: [{ uptoMin: null, priceMinorPerIncrement: toMinor(f.base.flat) }], dailyCapMinor: null };
|
|
||||||
}
|
|
||||||
return { ...common, blocks: baseBody.blocks ?? [], dailyCapMinor: baseBody.dailyCapMinor ?? null };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tiers present ⇒ V2. tz is stamped server-side from site config (left blank here).
|
|
||||||
return {
|
|
||||||
...common,
|
|
||||||
version: 2,
|
|
||||||
tz: "",
|
|
||||||
defaultCard: { name: "default", priority: 0, ...baseBody },
|
|
||||||
windowedCards: f.tiers.map(tierToCard),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TariffComposer() {
|
export function TariffComposer() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [state, setState] = useState<TariffState | null>(null);
|
const [state, setState] = useState<TariffState | null>(null);
|
||||||
const [form, setForm] = useState<FormState>(emptyForm);
|
const [form, setForm] = useState<FormState>(emptyForm);
|
||||||
|
// Which published version the editor was last loaded from (sidebar highlight).
|
||||||
|
const [loadedId, setLoadedId] = useState<string | null>(null);
|
||||||
|
// Optional label for the version about to be published. Deliberately NOT prefilled
|
||||||
|
// from the active version — a tweaked card republished under last season's name
|
||||||
|
// would mislabel the history.
|
||||||
|
const [versionName, setVersionName] = useState("");
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [msg, setMsg] = useState<{ kind: "ok" | "err"; text: string } | null>(null);
|
const [msg, setMsg] = useState<{ kind: "ok" | "err"; text: string } | null>(null);
|
||||||
|
|
||||||
@@ -270,74 +30,30 @@ export function TariffComposer() {
|
|||||||
.then((s) => {
|
.then((s) => {
|
||||||
setState(s);
|
setState(s);
|
||||||
setForm(formFromActive(s));
|
setForm(formFromActive(s));
|
||||||
|
setLoadedId(s.active?.id ?? null);
|
||||||
})
|
})
|
||||||
.catch((e) => setMsg({ kind: "err", text: (e as Error).message }));
|
.catch((e) => setMsg({ kind: "err", text: (e as Error).message }));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
function set<K extends keyof FormState>(key: K, value: FormState[K]) {
|
function loadVersion(v: TariffVersion) {
|
||||||
setForm((f) => ({ ...f, [key]: value }));
|
setForm(formFromVersion(v.currency, v.structure));
|
||||||
}
|
setLoadedId(v.id);
|
||||||
|
setMsg(null);
|
||||||
// --- pricing-body editing (used by the default card AND each tier) ---
|
|
||||||
// `update` maps the old PricingForm to a new one; `target` selects which body:
|
|
||||||
// the base card, or tier index N.
|
|
||||||
function updatePricing(target: "base" | number, update: (p: PricingForm) => PricingForm) {
|
|
||||||
setForm((f) => {
|
|
||||||
if (target === "base") return { ...f, base: update(f.base) };
|
|
||||||
return { ...f, tiers: f.tiers.map((tr, j) => (j === target ? { ...tr, pricing: update(tr.pricing) } : tr)) };
|
|
||||||
});
|
|
||||||
}
|
|
||||||
function setBlock(target: "base" | number, i: number, patch: Partial<BlockForm>) {
|
|
||||||
updatePricing(target, (p) => ({ ...p, blocks: p.blocks.map((b, j) => (j === i ? { ...b, ...patch } : b)) }));
|
|
||||||
}
|
|
||||||
// Insert a bounded band just BEFORE the open-ended tail, so the last block stays open-ended.
|
|
||||||
function addBlock(target: "base" | number) {
|
|
||||||
updatePricing(target, (p) => {
|
|
||||||
const next = [...p.blocks];
|
|
||||||
next.splice(p.blocks.length - 1, 0, { hours: "1", price: "0.00" });
|
|
||||||
return { ...p, blocks: next };
|
|
||||||
});
|
|
||||||
}
|
|
||||||
function removeBlock(target: "base" | number, i: number) {
|
|
||||||
updatePricing(target, (p) => (i === p.blocks.length - 1 || p.blocks.length <= 1 ? p : { ...p, blocks: p.blocks.filter((_, j) => j !== i) }));
|
|
||||||
}
|
|
||||||
// --- stepped (up-to) editing (base card only) ---
|
|
||||||
function setStep(i: number, patch: Partial<StepForm>) {
|
|
||||||
updatePricing("base", (p) => ({ ...p, steps: p.steps.map((s, j) => (j === i ? { ...s, ...patch } : s)) }));
|
|
||||||
}
|
|
||||||
function addStep() {
|
|
||||||
updatePricing("base", (p) => ({ ...p, steps: [...p.steps, { hours: "", total: "0.00" }] }));
|
|
||||||
}
|
|
||||||
function removeStep(i: number) {
|
|
||||||
updatePricing("base", (p) => (p.steps.length <= 1 ? p : { ...p, steps: p.steps.filter((_, j) => j !== i) }));
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- tier editing ---
|
|
||||||
function setTier(i: number, patch: Partial<TierForm>) {
|
|
||||||
setForm((f) => ({ ...f, tiers: f.tiers.map((tr, j) => (j === i ? { ...tr, ...patch } : tr)) }));
|
|
||||||
}
|
|
||||||
function addTier() {
|
|
||||||
setForm((f) => ({ ...f, tiers: [...f.tiers, emptyTier()] }));
|
|
||||||
}
|
|
||||||
function removeTier(i: number) {
|
|
||||||
setForm((f) => ({ ...f, tiers: f.tiers.filter((_, j) => j !== i) }));
|
|
||||||
}
|
|
||||||
function toggleDow(i: number, d: number) {
|
|
||||||
setForm((f) => ({
|
|
||||||
...f,
|
|
||||||
tiers: f.tiers.map((tr, j) =>
|
|
||||||
j === i ? { ...tr, dow: tr.dow.includes(d) ? tr.dow.filter((x) => x !== d) : [...tr.dow, d] } : tr,
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function publish() {
|
async function publish() {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
setMsg(null);
|
setMsg(null);
|
||||||
try {
|
try {
|
||||||
await publishTariffVersion({ currency: form.currency.trim().toUpperCase(), structure: toStructure(form) });
|
await publishTariffVersion({
|
||||||
|
currency: form.currency.trim().toUpperCase(),
|
||||||
|
structure: toStructure(form),
|
||||||
|
...(versionName.trim() ? { name: versionName.trim() } : {}),
|
||||||
|
});
|
||||||
const fresh = await fetchTariff();
|
const fresh = await fetchTariff();
|
||||||
setState(fresh);
|
setState(fresh);
|
||||||
|
setLoadedId(fresh.active?.id ?? null);
|
||||||
|
setVersionName("");
|
||||||
setMsg({ kind: "ok", text: t("tariff.publishedOk") });
|
setMsg({ kind: "ok", text: t("tariff.publishedOk") });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const text =
|
const text =
|
||||||
@@ -354,11 +70,12 @@ 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">
|
||||||
|
{state.active.name ? `${state.active.name} — ` : ""}
|
||||||
{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,
|
||||||
@@ -366,257 +83,68 @@ export function TariffComposer() {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="card card-body grid grid-cols-[max-content_1fr] items-center gap-x-4 gap-y-2">
|
<div className="flex flex-col gap-4 lg:flex-row">
|
||||||
<label className="label">{t("tariff.currency")}</label>
|
<div className="min-w-0 flex-1">
|
||||||
<input className="input w-24" value={form.currency} onChange={(e) => set("currency", e.target.value)} maxLength={3} />
|
<TariffEditorForm form={form} onChange={setForm} />
|
||||||
<label className="label">{t("tariff.freeEntryGrace")}</label>
|
|
||||||
<input className="input w-32" value={form.gracePeriodEntryMin} onChange={(e) => set("gracePeriodEntryMin", e.target.value)} />
|
|
||||||
<label className="label">{t("tariff.billingIncrement")}</label>
|
|
||||||
<input className="input w-32" value={form.incrementMin} onChange={(e) => set("incrementMin", e.target.value)} />
|
|
||||||
<label className="label">{t("tariff.lostTicketFee")}</label>
|
|
||||||
<input className="input w-32" value={form.lostTicket} onChange={(e) => set("lostTicket", e.target.value)} />
|
|
||||||
<label className="label">{t("tariff.exitGrace")}</label>
|
|
||||||
<input className="input w-32" value={form.gracePeriodExitMin} onChange={(e) => set("gracePeriodExitMin", e.target.value)} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* The DEFAULT card — always-active rate. Front-and-centre; a site that never
|
<div className="mt-6 flex flex-wrap items-center gap-3">
|
||||||
wants tiers just edits this and publishes a bare V1 structure. */}
|
|
||||||
<h3 className="mt-6 mb-0.5 text-h6 font-semibold uppercase tracking-wider text-term-text">{t("tariff.defaultCard")}</h3>
|
|
||||||
<p className="hint mb-2">{t("tariff.defaultCardHint")}</p>
|
|
||||||
<div className="card card-body">
|
|
||||||
<PricingEditor
|
|
||||||
t={t}
|
|
||||||
pricing={form.base}
|
|
||||||
allowStepped
|
|
||||||
onMode={(mode) => updatePricing("base", (p) => ({ ...p, mode }))}
|
|
||||||
onFlat={(flat) => updatePricing("base", (p) => ({ ...p, flat }))}
|
|
||||||
onCap={(dailyCap) => updatePricing("base", (p) => ({ ...p, dailyCap }))}
|
|
||||||
onBlock={(i, patch) => setBlock("base", i, patch)}
|
|
||||||
onAddBlock={() => addBlock("base")}
|
|
||||||
onRemoveBlock={(i) => removeBlock("base", i)}
|
|
||||||
onStep={setStep}
|
|
||||||
onAddStep={addStep}
|
|
||||||
onRemoveStep={removeStep}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Advanced: time & seasonal/category TIERS (opt-in). Empty ⇒ V1 is published. */}
|
|
||||||
<details className="mt-6" open={form.tiers.length > 0}>
|
|
||||||
<summary className="cursor-pointer text-h6 font-semibold uppercase tracking-wider text-term-text">{t("tariff.tiersAdvanced")}</summary>
|
|
||||||
<p className="hint mt-1.5 mb-2">{t("tariff.tiersHint")}</p>
|
|
||||||
{/* A stepped ("up-to") base rate cannot be combined with time tiers — the
|
|
||||||
engine would ignore them. Warn up-front; publishing is also blocked server-side. */}
|
|
||||||
{form.base.mode === "stepped" && form.tiers.length > 0 && (
|
|
||||||
<p className="mb-3 rounded-term border border-term-red/50 bg-term-red/10 px-3 py-2 text-[12px] text-term-red">
|
|
||||||
{t("tariff.steppedTiersConflict")}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{form.tiers.map((tr, i) => (
|
|
||||||
<fieldset key={i} className="card mb-3 p-4">
|
|
||||||
<legend className="flex items-center gap-2 px-1">
|
|
||||||
<input
|
<input
|
||||||
className="input w-40"
|
className="input w-64"
|
||||||
value={tr.name}
|
value={versionName}
|
||||||
onChange={(e) => setTier(i, { name: e.target.value })}
|
onChange={(e) => setVersionName(e.target.value)}
|
||||||
placeholder={t("tariff.tierName")}
|
placeholder={t("tariff.versionNamePh")}
|
||||||
/>
|
/>
|
||||||
<button type="button" className="btn btn-danger btn-sm" onClick={() => removeTier(i)}>
|
|
||||||
{t("tariff.remove")}
|
|
||||||
</button>
|
|
||||||
</legend>
|
|
||||||
<div className="grid grid-cols-[max-content_1fr] items-center gap-x-4 gap-y-2">
|
|
||||||
<label className="label">{t("tariff.tierPriority")}</label>
|
|
||||||
<input className="input w-20" value={tr.priority} onChange={(e) => setTier(i, { priority: e.target.value })} />
|
|
||||||
<label className="label">{t("tariff.tierCategory")}</label>
|
|
||||||
<input className="input w-40" value={tr.category} onChange={(e) => setTier(i, { category: e.target.value })} placeholder={t("tariff.tierCategoryPh")} />
|
|
||||||
<label className="label">{t("tariff.tierDays")}</label>
|
|
||||||
<span className="flex flex-wrap gap-2">
|
|
||||||
{[1, 2, 3, 4, 5, 6, 0].map((d) => (
|
|
||||||
<label key={d} className="inline-flex items-center gap-1 text-[12px] text-term-text">
|
|
||||||
<input type="checkbox" className="accent-term-amber" checked={tr.dow.includes(d)} onChange={() => toggleDow(i, d)} />
|
|
||||||
{t(`tariff.dow${d}`)}
|
|
||||||
</label>
|
|
||||||
))}
|
|
||||||
</span>
|
|
||||||
<label className="label">{t("tariff.tierHours")}</label>
|
|
||||||
<span className="inline-flex items-center gap-2">
|
|
||||||
<input className="input w-20" value={tr.fromHour} onChange={(e) => setTier(i, { fromHour: e.target.value })} placeholder="22:00" />
|
|
||||||
<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" />
|
|
||||||
{tr.fromHour && tr.toHour && tr.toHour <= tr.fromHour && (
|
|
||||||
<span className="text-[11px] text-term-muted">{t("tariff.tierOvernight")}</span>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
<label className="label">{t("tariff.tierDates")}</label>
|
|
||||||
<span className="inline-flex items-center gap-2">
|
|
||||||
<input type="date" className="input w-40" value={tr.dateFrom} onChange={(e) => setTier(i, { dateFrom: e.target.value })} />
|
|
||||||
<span className="text-term-muted">–</span>
|
|
||||||
<input type="date" className="input w-40" value={tr.dateTo} onChange={(e) => setTier(i, { dateTo: e.target.value })} />
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="mt-3 border-t border-term-border pt-3">
|
|
||||||
<PricingEditor
|
|
||||||
t={t}
|
|
||||||
pricing={tr.pricing}
|
|
||||||
onMode={(mode) => updatePricing(i, (p) => ({ ...p, mode }))}
|
|
||||||
onFlat={(flat) => updatePricing(i, (p) => ({ ...p, flat }))}
|
|
||||||
onCap={(dailyCap) => updatePricing(i, (p) => ({ ...p, dailyCap }))}
|
|
||||||
onBlock={(bi, patch) => setBlock(i, bi, patch)}
|
|
||||||
onAddBlock={() => addBlock(i)}
|
|
||||||
onRemoveBlock={(bi) => removeBlock(i, bi)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</fieldset>
|
|
||||||
))}
|
|
||||||
<button type="button" className="btn btn-sm" onClick={addTier}>
|
|
||||||
{t("tariff.addTier")}
|
|
||||||
</button>
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<div className="mt-6 flex items-center gap-3">
|
|
||||||
<button type="button" className="btn btn-primary btn-lg" onClick={publish} disabled={saving}>
|
<button type="button" className="btn btn-primary btn-lg" onClick={publish} disabled={saving}>
|
||||||
{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>
|
||||||
|
|
||||||
|
{/* Published history — click a version to load it into the editor. Same list
|
||||||
|
the lab's sidebar shows; here it seeds the next publish. */}
|
||||||
|
{state && state.versions.length > 0 && (
|
||||||
|
<aside className="w-full shrink-0 lg:w-72">
|
||||||
|
<h3 className="mb-1 text-h6 font-semibold uppercase tracking-wider text-term-text">
|
||||||
|
{t("tariff.versionsTitle")}
|
||||||
|
</h3>
|
||||||
|
<p className="hint mb-2">{t("tariff.versionsHint")}</p>
|
||||||
|
<ul className="flex flex-col gap-1">
|
||||||
|
{state.versions.map((v) => {
|
||||||
|
const isActive = v.id === state.active?.id;
|
||||||
|
return (
|
||||||
|
<li key={v.id}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => loadVersion(v)}
|
||||||
|
className={`w-full rounded-term border px-3 py-2 text-left text-[0.8125rem] ${
|
||||||
|
loadedId === v.id
|
||||||
|
? "border-term-amber bg-term-amber/10 text-term-text"
|
||||||
|
: "border-term-border text-term-muted hover:text-term-text"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-2 font-semibold">
|
||||||
|
{v.name ?? new Date(v.effectiveFrom).toLocaleString()}
|
||||||
|
{isActive && (
|
||||||
|
<span className="rounded border border-term-green px-1 text-[0.625rem] uppercase text-term-green">
|
||||||
|
{t("tariff.activeBadge")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span className="block text-[0.6875rem] text-term-muted">
|
||||||
|
{v.name ? `${new Date(v.effectiveFrom).toLocaleString()} · ` : ""}
|
||||||
|
{v.currency}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</aside>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// A reusable pricing-body editor — flat / marginal ladder / stepped (up-to). The
|
|
||||||
// stepped mode is offered only where `allowStepped` (the default card, not tiers).
|
|
||||||
function PricingEditor(props: {
|
|
||||||
t: (k: string) => string;
|
|
||||||
pricing: PricingForm;
|
|
||||||
allowStepped?: boolean;
|
|
||||||
onMode: (m: "ladder" | "flat" | "stepped") => void;
|
|
||||||
onFlat: (v: string) => void;
|
|
||||||
onCap: (v: string) => void;
|
|
||||||
onBlock: (i: number, patch: Partial<BlockForm>) => void;
|
|
||||||
onAddBlock: () => void;
|
|
||||||
onRemoveBlock: (i: number) => void;
|
|
||||||
onStep?: (i: number, patch: Partial<StepForm>) => void;
|
|
||||||
onAddStep?: () => void;
|
|
||||||
onRemoveStep?: (i: number) => void;
|
|
||||||
}) {
|
|
||||||
const { t, pricing: p } = props;
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<div className="mb-3 flex gap-4 text-[12px]">
|
|
||||||
<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")} />
|
|
||||||
{t("tariff.modeLadder")}
|
|
||||||
</label>
|
|
||||||
<label className="inline-flex items-center gap-1.5 text-term-text">
|
|
||||||
<input type="radio" className="accent-term-amber" checked={p.mode === "flat"} onChange={() => props.onMode("flat")} />
|
|
||||||
{t("tariff.modeFlat")}
|
|
||||||
</label>
|
|
||||||
{props.allowStepped && (
|
|
||||||
<label className="inline-flex items-center gap-1.5 text-term-text">
|
|
||||||
<input type="radio" className="accent-term-amber" checked={p.mode === "stepped"} onChange={() => props.onMode("stepped")} />
|
|
||||||
{t("tariff.modeStepped")}
|
|
||||||
</label>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{p.mode === "stepped" ? (
|
|
||||||
<>
|
|
||||||
<p className="hint mb-2">{t("tariff.steppedHint")}</p>
|
|
||||||
<table className="w-full border-collapse">
|
|
||||||
<thead>
|
|
||||||
<tr className="text-left">
|
|
||||||
<th className="label px-2 pb-1 font-normal">{t("tariff.stepUpTo")}</th>
|
|
||||||
<th className="label px-2 pb-1 font-normal">{t("tariff.stepTotal")}</th>
|
|
||||||
<th />
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{p.steps.map((s, i) => (
|
|
||||||
<tr key={i}>
|
|
||||||
<td className="px-2 py-1">
|
|
||||||
<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")} />
|
|
||||||
<span className="text-[11px] text-term-muted">{t("tariff.hoursUnit")}</span>
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="px-2 py-1">
|
|
||||||
<input className="input w-28" value={s.total} onChange={(e) => props.onStep?.(i, { total: e.target.value })} />
|
|
||||||
</td>
|
|
||||||
<td className="px-2">
|
|
||||||
{p.steps.length > 1 && (
|
|
||||||
<button type="button" className="btn btn-ghost btn-sm" onClick={() => props.onRemoveStep?.(i)}>
|
|
||||||
{t("tariff.remove")}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
<div className="mt-3">
|
|
||||||
<button type="button" className="btn btn-sm" onClick={props.onAddStep}>
|
|
||||||
{t("tariff.addStep")}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : p.mode === "flat" ? (
|
|
||||||
<div className="inline-flex items-center gap-2">
|
|
||||||
<span className="label">{t("tariff.pricePerIncrement")}</span>
|
|
||||||
<input className="input w-28" value={p.flat} onChange={(e) => props.onFlat(e.target.value)} />
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<table className="w-full border-collapse">
|
|
||||||
<thead>
|
|
||||||
<tr className="text-left">
|
|
||||||
<th className="label px-2 pb-1 font-normal">{t("tariff.bandDuration")}</th>
|
|
||||||
<th className="label px-2 pb-1 font-normal">{t("tariff.pricePerIncrement")}</th>
|
|
||||||
<th />
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{p.blocks.map((b, i) => {
|
|
||||||
const isTail = i === p.blocks.length - 1;
|
|
||||||
return (
|
|
||||||
<tr key={i}>
|
|
||||||
<td className="px-2 py-1">
|
|
||||||
{isTail ? (
|
|
||||||
<span className="italic text-term-muted">{t("tariff.thereafter")}</span>
|
|
||||||
) : (
|
|
||||||
<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")} />
|
|
||||||
<span className="text-[11px] text-term-muted">{t("tariff.hoursUnit")}</span>
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td className="px-2 py-1">
|
|
||||||
<input className="input w-28" value={b.price} onChange={(e) => props.onBlock(i, { price: e.target.value })} />
|
|
||||||
</td>
|
|
||||||
<td className="px-2">
|
|
||||||
{!isTail && (
|
|
||||||
<button type="button" className="btn btn-ghost btn-sm" onClick={() => props.onRemoveBlock(i)}>
|
|
||||||
{t("tariff.remove")}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
<div className="mt-3 flex items-center gap-4">
|
|
||||||
<button type="button" className="btn btn-sm" onClick={props.onAddBlock}>
|
|
||||||
{t("tariff.addBlock")}
|
|
||||||
</button>
|
|
||||||
<span className="inline-flex items-center gap-2">
|
|
||||||
<span className="label">{t("tariff.dailyCap")}</span>
|
|
||||||
<input className="input w-28" value={p.dailyCap} onChange={(e) => props.onCap(e.target.value)} placeholder={t("tariff.dailyCapPh")} />
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,609 @@
|
|||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { currencyOptions } from "./lib/currencies.js";
|
||||||
|
import {
|
||||||
|
isTariffV2,
|
||||||
|
type TariffBlock,
|
||||||
|
type TariffCard,
|
||||||
|
type TariffStep,
|
||||||
|
type TariffStructure,
|
||||||
|
type TariffState,
|
||||||
|
} from "./api.js";
|
||||||
|
|
||||||
|
// The tariff EDITOR FORM — the rate-card composer's form machinery (state shape,
|
||||||
|
// structure↔form converters, and the editing UI), extracted so two hosts can share
|
||||||
|
// it: the /setup/tariff page (edits + publishes the live card) and the Tariff Lab's
|
||||||
|
// draft modal (edits an experimental card). The host owns the FormState and the
|
||||||
|
// submit action; this module owns everything between. Amounts are entered in major
|
||||||
|
// units (e.g. euros) and converted to integer minor units on submit.
|
||||||
|
// See wiki/concepts/tariff.md.
|
||||||
|
|
||||||
|
// Editable form mirror of TariffStructure, but money in major-unit strings.
|
||||||
|
// Blocks are edited as a DURATION in hours ("this band lasts N hours") — the
|
||||||
|
// owner thinks "first 2 hours, then next 3 hours", not in cumulative minutes.
|
||||||
|
// The LAST block is always open-ended ("thereafter"): its hours field is unused
|
||||||
|
// and it has no bound. On submit, per-block hours accumulate into the engine's
|
||||||
|
// cumulative `uptoMin` (minutes), and the last block emits uptoMin: null.
|
||||||
|
export interface BlockForm {
|
||||||
|
hours: string; // duration of THIS band, in hours (ignored for the last block)
|
||||||
|
price: string; // major units, e.g. "2.00"
|
||||||
|
}
|
||||||
|
// One STEPPED ("up-to") row: "a stay up to N hours costs TOTAL". The owner enters the
|
||||||
|
// matrix verbatim (totals, not marginal rates). See wiki/concepts/tariff.md.
|
||||||
|
export interface StepForm {
|
||||||
|
hours: string; // inclusive upper bound of this tier, in hours (e.g. "3")
|
||||||
|
total: string; // TOTAL major units for a stay within this tier (e.g. "5.00")
|
||||||
|
}
|
||||||
|
// A pricing body the form edits: a per-increment flat rate, a marginal block ladder,
|
||||||
|
// a stepped (up-to) total-by-duration table, or a whole-window package (tiers only).
|
||||||
|
export interface PricingForm {
|
||||||
|
mode: "ladder" | "flat" | "stepped" | "package";
|
||||||
|
flat: string; // major units PER INCREMENT (used when mode==="flat")
|
||||||
|
packageTotal: string; // major units for the WHOLE window occurrence (mode==="package")
|
||||||
|
blocks: BlockForm[]; // hours-based ladder (used when mode==="ladder")
|
||||||
|
steps: StepForm[]; // up-to tiers (used when mode==="stepped")
|
||||||
|
dailyCap: string; // "" = no cap (ladder only)
|
||||||
|
}
|
||||||
|
// An optional time/category TIER (a V2 windowed card). Absent windows = unconstrained.
|
||||||
|
export interface TierForm {
|
||||||
|
name: string;
|
||||||
|
priority: string;
|
||||||
|
category: string; // "" = applies to all categories
|
||||||
|
dow: number[]; // selected days 0..6; empty = every day
|
||||||
|
fromHour: string; // "" = all day
|
||||||
|
toHour: string;
|
||||||
|
dateFrom: string; // "" = unbounded
|
||||||
|
dateTo: string;
|
||||||
|
pricing: PricingForm;
|
||||||
|
}
|
||||||
|
export interface FormState {
|
||||||
|
currency: string;
|
||||||
|
gracePeriodEntryMin: string;
|
||||||
|
incrementMin: string;
|
||||||
|
lostTicket: string;
|
||||||
|
gracePeriodExitMin: string;
|
||||||
|
// The default (always-active) card — its own flat/ladder body + daily cap.
|
||||||
|
base: PricingForm;
|
||||||
|
// Optional time/category tiers. Empty ⇒ a bare V1 structure is published.
|
||||||
|
tiers: TierForm[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const toMinor = (major: string): number => Math.round(parseFloat(major || "0") * 100);
|
||||||
|
const toMajor = (minor: number): string => (minor / 100).toFixed(2);
|
||||||
|
|
||||||
|
function emptySteps(): StepForm[] {
|
||||||
|
return [
|
||||||
|
{ hours: "1", total: "2.00" },
|
||||||
|
{ hours: "3", total: "5.00" },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
function emptyLadder(): PricingForm {
|
||||||
|
return {
|
||||||
|
mode: "ladder",
|
||||||
|
flat: "0.00",
|
||||||
|
packageTotal: "0.00",
|
||||||
|
dailyCap: "",
|
||||||
|
blocks: [{ hours: "1", price: "2.00" }, { hours: "", price: "1.00" }],
|
||||||
|
steps: emptySteps(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function emptyTier(): TierForm {
|
||||||
|
return {
|
||||||
|
name: "",
|
||||||
|
priority: "10",
|
||||||
|
category: "",
|
||||||
|
dow: [],
|
||||||
|
fromHour: "",
|
||||||
|
toHour: "",
|
||||||
|
dateFrom: "",
|
||||||
|
dateTo: "",
|
||||||
|
pricing: { ...emptyLadder(), blocks: [{ hours: "", price: "1.00" }] },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function emptyForm(): FormState {
|
||||||
|
return {
|
||||||
|
currency: "ALL",
|
||||||
|
gracePeriodEntryMin: "15",
|
||||||
|
incrementMin: "60",
|
||||||
|
lostTicket: "20.00",
|
||||||
|
gracePeriodExitMin: "15",
|
||||||
|
base: emptyLadder(),
|
||||||
|
tiers: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert a stored block ladder's cumulative `uptoMin` (minutes) into the per-band
|
||||||
|
// hours the form edits. Open-ended last band has no hours. Legacy bounded tails still
|
||||||
|
// load (shown as their own band).
|
||||||
|
function blocksToForm(blocks: TariffBlock[]): BlockForm[] {
|
||||||
|
let prev = 0;
|
||||||
|
return blocks.map((b) => {
|
||||||
|
if (b.uptoMin == null) return { hours: "", price: toMajor(b.priceMinorPerIncrement) };
|
||||||
|
const hours = (b.uptoMin - prev) / 60;
|
||||||
|
prev = b.uptoMin;
|
||||||
|
return { hours: String(hours), price: toMajor(b.priceMinorPerIncrement) };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// A stored stepped table's `uptoMin` (minutes) → the per-tier hours the form edits.
|
||||||
|
function stepsToForm(steps: TariffStep[]): StepForm[] {
|
||||||
|
return steps.map((s) => ({ hours: String(s.uptoMin / 60), total: toMajor(s.totalMinor) }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// A stored card (V2) or bare-V1 body → the form's PricingForm (flat, ladder, stepped,
|
||||||
|
// or window package).
|
||||||
|
function pricingFromCard(c: {
|
||||||
|
flatMinor?: number;
|
||||||
|
blocks?: TariffBlock[];
|
||||||
|
steps?: TariffStep[];
|
||||||
|
packageMinor?: number;
|
||||||
|
dailyCapMinor?: number | null;
|
||||||
|
}): PricingForm {
|
||||||
|
if (c.steps != null && c.steps.length > 0) {
|
||||||
|
return { ...emptyLadder(), mode: "stepped", steps: stepsToForm(c.steps) };
|
||||||
|
}
|
||||||
|
if (c.packageMinor != null) {
|
||||||
|
return { ...emptyLadder(), mode: "package", packageTotal: toMajor(c.packageMinor) };
|
||||||
|
}
|
||||||
|
if (c.flatMinor != null) {
|
||||||
|
return { ...emptyLadder(), mode: "flat", flat: toMajor(c.flatMinor) };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...emptyLadder(),
|
||||||
|
mode: "ladder",
|
||||||
|
dailyCap: c.dailyCapMinor == null ? "" : toMajor(c.dailyCapMinor),
|
||||||
|
blocks: blocksToForm(c.blocks ?? []),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function tierFromCard(c: TariffCard): TierForm {
|
||||||
|
const w = c.window ?? {};
|
||||||
|
return {
|
||||||
|
name: c.name,
|
||||||
|
priority: String(c.priority),
|
||||||
|
category: c.category ?? "",
|
||||||
|
dow: w.dow ? [...w.dow] : [],
|
||||||
|
fromHour: w.fromHour ?? "",
|
||||||
|
toHour: w.toHour ?? "",
|
||||||
|
dateFrom: w.dateFrom ?? "",
|
||||||
|
dateTo: w.dateTo ?? "",
|
||||||
|
pricing: pricingFromCard(c),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A stored (currency, structure) pair → the editable form. Used to load the active
|
||||||
|
* version into the composer page and a saved draft into the lab modal. */
|
||||||
|
export function formFromVersion(currency: string, st: TariffStructure): FormState {
|
||||||
|
const common = {
|
||||||
|
currency,
|
||||||
|
gracePeriodEntryMin: String(st.gracePeriodEntryMin),
|
||||||
|
incrementMin: String(st.incrementMin),
|
||||||
|
lostTicket: toMajor(st.lostTicketMinor),
|
||||||
|
gracePeriodExitMin: String(st.gracePeriodExitMin),
|
||||||
|
};
|
||||||
|
if (isTariffV2(st)) {
|
||||||
|
return { ...common, base: pricingFromCard(st.defaultCard), tiers: (st.windowedCards ?? []).map(tierFromCard) };
|
||||||
|
}
|
||||||
|
// V1: the bare ladder becomes the default card body; no tiers.
|
||||||
|
return { ...common, base: pricingFromCard(st), tiers: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formFromActive(s: TariffState): FormState {
|
||||||
|
return s.active ? formFromVersion(s.active.currency, s.active.structure) : emptyForm();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build a tariff card's pricing body (flat XOR ladder XOR stepped XOR package) from a PricingForm.
|
||||||
|
function pricingToCardBody(p: PricingForm): Pick<TariffCard, "flatMinor" | "blocks" | "steps" | "packageMinor" | "dailyCapMinor"> {
|
||||||
|
if (p.mode === "flat") return { flatMinor: toMinor(p.flat) };
|
||||||
|
if (p.mode === "package") return { packageMinor: toMinor(p.packageTotal) };
|
||||||
|
if (p.mode === "stepped") {
|
||||||
|
// Each row's `hours` IS the inclusive threshold (the matrix "up to N hours").
|
||||||
|
const steps: TariffStep[] = p.steps.map((s) => ({
|
||||||
|
uptoMin: Math.round(Number(s.hours || "0") * 60),
|
||||||
|
totalMinor: toMinor(s.total),
|
||||||
|
}));
|
||||||
|
return { steps };
|
||||||
|
}
|
||||||
|
// Accumulate each band's hours into cumulative uptoMin (min); last band open-ended.
|
||||||
|
const last = p.blocks.length - 1;
|
||||||
|
let cum = 0;
|
||||||
|
const blocks: TariffBlock[] = p.blocks.map((b, i) => {
|
||||||
|
if (i === last) return { uptoMin: null, priceMinorPerIncrement: toMinor(b.price) };
|
||||||
|
cum += Math.round(Number(b.hours || "0") * 60);
|
||||||
|
return { uptoMin: cum, priceMinorPerIncrement: toMinor(b.price) };
|
||||||
|
});
|
||||||
|
return { blocks, dailyCapMinor: p.dailyCap.trim() === "" ? null : toMinor(p.dailyCap) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function tierToCard(tr: TierForm): TariffCard {
|
||||||
|
const window: TariffCard["window"] = {};
|
||||||
|
if (tr.dow.length > 0) window.dow = [...tr.dow].sort((a, b) => a - b);
|
||||||
|
if (tr.fromHour && tr.toHour) {
|
||||||
|
window.fromHour = tr.fromHour;
|
||||||
|
window.toHour = tr.toHour;
|
||||||
|
}
|
||||||
|
if (tr.dateFrom) window.dateFrom = tr.dateFrom;
|
||||||
|
if (tr.dateTo) window.dateTo = tr.dateTo;
|
||||||
|
const card: TariffCard = {
|
||||||
|
name: tr.name.trim() || "tier",
|
||||||
|
priority: Math.round(Number(tr.priority || "0")),
|
||||||
|
...pricingToCardBody(tr.pricing),
|
||||||
|
};
|
||||||
|
if (tr.category.trim()) card.category = tr.category.trim();
|
||||||
|
if (Object.keys(window).length > 0) card.window = window;
|
||||||
|
return card;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toStructure(f: FormState): TariffStructure {
|
||||||
|
const common = {
|
||||||
|
gracePeriodEntryMin: Math.round(Number(f.gracePeriodEntryMin)),
|
||||||
|
incrementMin: Math.round(Number(f.incrementMin)),
|
||||||
|
lostTicketMinor: toMinor(f.lostTicket),
|
||||||
|
gracePeriodExitMin: Math.round(Number(f.gracePeriodExitMin)),
|
||||||
|
overstay: "reprice" as const,
|
||||||
|
};
|
||||||
|
const baseBody = pricingToCardBody(f.base);
|
||||||
|
|
||||||
|
// NO tiers ⇒ publish a BARE V1 structure (back-compat: a site that never wants
|
||||||
|
// tiers gets exactly today's shape; the server leaves it untouched).
|
||||||
|
if (f.tiers.length === 0) {
|
||||||
|
if (f.base.mode === "stepped") {
|
||||||
|
// A stepped V1: the up-to table replaces the ladder (blocks empty, no cap).
|
||||||
|
return { ...common, blocks: [], steps: baseBody.steps ?? [], dailyCapMinor: null };
|
||||||
|
}
|
||||||
|
if (f.base.mode === "flat") {
|
||||||
|
// A flat V1: a single open-ended block at the flat rate (V1 has no flat field).
|
||||||
|
return { ...common, blocks: [{ uptoMin: null, priceMinorPerIncrement: toMinor(f.base.flat) }], dailyCapMinor: null };
|
||||||
|
}
|
||||||
|
return { ...common, blocks: baseBody.blocks ?? [], dailyCapMinor: baseBody.dailyCapMinor ?? null };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tiers present ⇒ V2. tz is stamped server-side from site config (left blank here).
|
||||||
|
return {
|
||||||
|
...common,
|
||||||
|
version: 2,
|
||||||
|
tz: "",
|
||||||
|
defaultCard: { name: "default", priority: 0, ...baseBody },
|
||||||
|
windowedCards: f.tiers.map(tierToCard),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The full rate-card editing UI (shared settings + default card + tiers). The host
|
||||||
|
* owns the FormState; every edit flows through `onChange` as a functional update. */
|
||||||
|
export function TariffEditorForm({
|
||||||
|
form,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
form: FormState;
|
||||||
|
onChange: (update: (f: FormState) => FormState) => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
function set<K extends keyof FormState>(key: K, value: FormState[K]) {
|
||||||
|
onChange((f) => ({ ...f, [key]: value }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- pricing-body editing (used by the default card AND each tier) ---
|
||||||
|
// `update` maps the old PricingForm to a new one; `target` selects which body:
|
||||||
|
// the base card, or tier index N.
|
||||||
|
function updatePricing(target: "base" | number, update: (p: PricingForm) => PricingForm) {
|
||||||
|
onChange((f) => {
|
||||||
|
if (target === "base") return { ...f, base: update(f.base) };
|
||||||
|
return { ...f, tiers: f.tiers.map((tr, j) => (j === target ? { ...tr, pricing: update(tr.pricing) } : tr)) };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function setBlock(target: "base" | number, i: number, patch: Partial<BlockForm>) {
|
||||||
|
updatePricing(target, (p) => ({ ...p, blocks: p.blocks.map((b, j) => (j === i ? { ...b, ...patch } : b)) }));
|
||||||
|
}
|
||||||
|
// Insert a bounded band just BEFORE the open-ended tail, so the last block stays open-ended.
|
||||||
|
function addBlock(target: "base" | number) {
|
||||||
|
updatePricing(target, (p) => {
|
||||||
|
const next = [...p.blocks];
|
||||||
|
next.splice(p.blocks.length - 1, 0, { hours: "1", price: "0.00" });
|
||||||
|
return { ...p, blocks: next };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function removeBlock(target: "base" | number, i: number) {
|
||||||
|
updatePricing(target, (p) => (i === p.blocks.length - 1 || p.blocks.length <= 1 ? p : { ...p, blocks: p.blocks.filter((_, j) => j !== i) }));
|
||||||
|
}
|
||||||
|
// --- stepped (up-to) editing (base card only) ---
|
||||||
|
function setStep(i: number, patch: Partial<StepForm>) {
|
||||||
|
updatePricing("base", (p) => ({ ...p, steps: p.steps.map((s, j) => (j === i ? { ...s, ...patch } : s)) }));
|
||||||
|
}
|
||||||
|
function addStep() {
|
||||||
|
updatePricing("base", (p) => ({ ...p, steps: [...p.steps, { hours: "", total: "0.00" }] }));
|
||||||
|
}
|
||||||
|
function removeStep(i: number) {
|
||||||
|
updatePricing("base", (p) => (p.steps.length <= 1 ? p : { ...p, steps: p.steps.filter((_, j) => j !== i) }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- tier editing ---
|
||||||
|
function setTier(i: number, patch: Partial<TierForm>) {
|
||||||
|
onChange((f) => ({ ...f, tiers: f.tiers.map((tr, j) => (j === i ? { ...tr, ...patch } : tr)) }));
|
||||||
|
}
|
||||||
|
function addTier() {
|
||||||
|
onChange((f) => ({ ...f, tiers: [...f.tiers, emptyTier()] }));
|
||||||
|
}
|
||||||
|
function removeTier(i: number) {
|
||||||
|
onChange((f) => ({ ...f, tiers: f.tiers.filter((_, j) => j !== i) }));
|
||||||
|
}
|
||||||
|
function toggleDow(i: number, d: number) {
|
||||||
|
onChange((f) => ({
|
||||||
|
...f,
|
||||||
|
tiers: f.tiers.map((tr, j) =>
|
||||||
|
j === i ? { ...tr, dow: tr.dow.includes(d) ? tr.dow.filter((x) => x !== d) : [...tr.dow, d] } : tr,
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="card card-body grid grid-cols-[max-content_1fr] items-center gap-x-4 gap-y-2">
|
||||||
|
<label className="label">{t("tariff.currency")}</label>
|
||||||
|
<select className="input w-24" value={form.currency} onChange={(e) => set("currency", e.target.value)}>
|
||||||
|
{currencyOptions(form.currency).map((c) => (
|
||||||
|
<option key={c} value={c}>{c}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<label className="label">{t("tariff.freeEntryGrace")}</label>
|
||||||
|
<input className="input w-32" value={form.gracePeriodEntryMin} onChange={(e) => set("gracePeriodEntryMin", e.target.value)} />
|
||||||
|
<label className="label">{t("tariff.billingIncrement")}</label>
|
||||||
|
<input className="input w-32" value={form.incrementMin} onChange={(e) => set("incrementMin", e.target.value)} />
|
||||||
|
<label className="label">{t("tariff.lostTicketFee")}</label>
|
||||||
|
<input className="input w-32" value={form.lostTicket} onChange={(e) => set("lostTicket", e.target.value)} />
|
||||||
|
<label className="label">{t("tariff.exitGrace")}</label>
|
||||||
|
<input className="input w-32" value={form.gracePeriodExitMin} onChange={(e) => set("gracePeriodExitMin", e.target.value)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* The DEFAULT card — always-active rate. Front-and-centre; a site that never
|
||||||
|
wants tiers just edits this and publishes a bare V1 structure. */}
|
||||||
|
<h3 className="mt-6 mb-0.5 text-h6 font-semibold uppercase tracking-wider text-term-text">{t("tariff.defaultCard")}</h3>
|
||||||
|
<p className="hint mb-2">{t("tariff.defaultCardHint")}</p>
|
||||||
|
<div className="card card-body">
|
||||||
|
<PricingEditor
|
||||||
|
t={t}
|
||||||
|
pricing={form.base}
|
||||||
|
allowStepped
|
||||||
|
onMode={(mode) => updatePricing("base", (p) => ({ ...p, mode }))}
|
||||||
|
onFlat={(flat) => updatePricing("base", (p) => ({ ...p, flat }))}
|
||||||
|
onCap={(dailyCap) => updatePricing("base", (p) => ({ ...p, dailyCap }))}
|
||||||
|
onBlock={(i, patch) => setBlock("base", i, patch)}
|
||||||
|
onAddBlock={() => addBlock("base")}
|
||||||
|
onRemoveBlock={(i) => removeBlock("base", i)}
|
||||||
|
onStep={setStep}
|
||||||
|
onAddStep={addStep}
|
||||||
|
onRemoveStep={removeStep}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Advanced: time & seasonal/category TIERS (opt-in). Empty ⇒ V1 is published. */}
|
||||||
|
<details className="mt-6" open={form.tiers.length > 0}>
|
||||||
|
<summary className="cursor-pointer text-h6 font-semibold uppercase tracking-wider text-term-text">{t("tariff.tiersAdvanced")}</summary>
|
||||||
|
<p className="hint mt-1.5 mb-2">{t("tariff.tiersHint")}</p>
|
||||||
|
{/* A stepped ("up-to") base rate cannot be combined with time tiers — the
|
||||||
|
engine would ignore them. Warn up-front; publishing is also blocked server-side. */}
|
||||||
|
{form.base.mode === "stepped" && form.tiers.length > 0 && (
|
||||||
|
<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")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{form.tiers.map((tr, i) => (
|
||||||
|
<fieldset key={i} className="card mb-3 p-4">
|
||||||
|
<legend className="flex items-center gap-2 px-1">
|
||||||
|
<input
|
||||||
|
className="input w-40"
|
||||||
|
value={tr.name}
|
||||||
|
onChange={(e) => setTier(i, { name: e.target.value })}
|
||||||
|
placeholder={t("tariff.tierName")}
|
||||||
|
/>
|
||||||
|
<button type="button" className="btn btn-danger btn-sm" onClick={() => removeTier(i)}>
|
||||||
|
{t("tariff.remove")}
|
||||||
|
</button>
|
||||||
|
</legend>
|
||||||
|
<div className="grid grid-cols-[max-content_1fr] items-center gap-x-4 gap-y-2">
|
||||||
|
<label className="label">{t("tariff.tierPriority")}</label>
|
||||||
|
<input className="input w-20" value={tr.priority} onChange={(e) => setTier(i, { priority: e.target.value })} />
|
||||||
|
<label className="label">{t("tariff.tierCategory")}</label>
|
||||||
|
<input className="input w-40" value={tr.category} onChange={(e) => setTier(i, { category: e.target.value })} placeholder={t("tariff.tierCategoryPh")} />
|
||||||
|
<label className="label">{t("tariff.tierDays")}</label>
|
||||||
|
<span className="flex flex-wrap gap-2">
|
||||||
|
{[1, 2, 3, 4, 5, 6, 0].map((d) => (
|
||||||
|
<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)} />
|
||||||
|
{t(`tariff.dow${d}`)}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
<label className="label">{t("tariff.tierHours")}</label>
|
||||||
|
<span className="inline-flex items-center gap-2">
|
||||||
|
<input className="input w-20" value={tr.fromHour} onChange={(e) => setTier(i, { fromHour: e.target.value })} placeholder="22:00" />
|
||||||
|
<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" />
|
||||||
|
{tr.fromHour && tr.toHour && tr.toHour <= tr.fromHour && (
|
||||||
|
<span className="text-[0.6875rem] text-term-muted">{t("tariff.tierOvernight")}</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<label className="label">{t("tariff.tierDates")}</label>
|
||||||
|
<span className="inline-flex items-center gap-2">
|
||||||
|
<input type="date" className="input w-40" value={tr.dateFrom} onChange={(e) => setTier(i, { dateFrom: e.target.value })} />
|
||||||
|
<span className="text-term-muted">–</span>
|
||||||
|
<input type="date" className="input w-40" value={tr.dateTo} onChange={(e) => setTier(i, { dateTo: e.target.value })} />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 border-t border-term-border pt-3">
|
||||||
|
<PricingEditor
|
||||||
|
t={t}
|
||||||
|
pricing={tr.pricing}
|
||||||
|
allowPackage
|
||||||
|
onMode={(mode) => updatePricing(i, (p) => ({ ...p, mode }))}
|
||||||
|
onFlat={(flat) => updatePricing(i, (p) => ({ ...p, flat }))}
|
||||||
|
onPackage={(packageTotal) => updatePricing(i, (p) => ({ ...p, packageTotal }))}
|
||||||
|
onCap={(dailyCap) => updatePricing(i, (p) => ({ ...p, dailyCap }))}
|
||||||
|
onBlock={(bi, patch) => setBlock(i, bi, patch)}
|
||||||
|
onAddBlock={() => addBlock(i)}
|
||||||
|
onRemoveBlock={(bi) => removeBlock(i, bi)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
))}
|
||||||
|
<button type="button" className="btn btn-sm" onClick={addTier}>
|
||||||
|
{t("tariff.addTier")}
|
||||||
|
</button>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A reusable pricing-body editor — flat (per increment) / marginal ladder / stepped
|
||||||
|
// (up-to) / window package. The stepped mode is offered only where `allowStepped`
|
||||||
|
// (the default card); the package mode only where `allowPackage` (tier cards — the
|
||||||
|
// engine needs a window to be an occurrence of).
|
||||||
|
function PricingEditor(props: {
|
||||||
|
t: (k: string) => string;
|
||||||
|
pricing: PricingForm;
|
||||||
|
allowStepped?: boolean;
|
||||||
|
allowPackage?: boolean;
|
||||||
|
onMode: (m: "ladder" | "flat" | "stepped" | "package") => void;
|
||||||
|
onFlat: (v: string) => void;
|
||||||
|
onPackage?: (v: string) => void;
|
||||||
|
onCap: (v: string) => void;
|
||||||
|
onBlock: (i: number, patch: Partial<BlockForm>) => void;
|
||||||
|
onAddBlock: () => void;
|
||||||
|
onRemoveBlock: (i: number) => void;
|
||||||
|
onStep?: (i: number, patch: Partial<StepForm>) => void;
|
||||||
|
onAddStep?: () => void;
|
||||||
|
onRemoveStep?: (i: number) => void;
|
||||||
|
}) {
|
||||||
|
const { t, pricing: p } = props;
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="mb-3 flex flex-wrap gap-4 text-[0.75rem]">
|
||||||
|
<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")} />
|
||||||
|
{t("tariff.modeLadder")}
|
||||||
|
</label>
|
||||||
|
<label className="inline-flex items-center gap-1.5 text-term-text">
|
||||||
|
<input type="radio" className="accent-term-amber" checked={p.mode === "flat"} onChange={() => props.onMode("flat")} />
|
||||||
|
{t("tariff.modeFlat")}
|
||||||
|
</label>
|
||||||
|
{props.allowStepped && (
|
||||||
|
<label className="inline-flex items-center gap-1.5 text-term-text">
|
||||||
|
<input type="radio" className="accent-term-amber" checked={p.mode === "stepped"} onChange={() => props.onMode("stepped")} />
|
||||||
|
{t("tariff.modeStepped")}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
{props.allowPackage && (
|
||||||
|
<label className="inline-flex items-center gap-1.5 text-term-text">
|
||||||
|
<input type="radio" className="accent-term-amber" checked={p.mode === "package"} onChange={() => props.onMode("package")} />
|
||||||
|
{t("tariff.modePackage")}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{p.mode === "package" ? (
|
||||||
|
<div>
|
||||||
|
<p className="hint mb-2">{t("tariff.packageHint")}</p>
|
||||||
|
<div className="inline-flex items-center gap-2">
|
||||||
|
<span className="label">{t("tariff.packageTotal")}</span>
|
||||||
|
<input className="input w-28" value={p.packageTotal} onChange={(e) => props.onPackage?.(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : p.mode === "stepped" ? (
|
||||||
|
<>
|
||||||
|
<p className="hint mb-2">{t("tariff.steppedHint")}</p>
|
||||||
|
<table className="w-full border-collapse">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-left">
|
||||||
|
<th className="label px-2 pb-1 font-normal">{t("tariff.stepUpTo")}</th>
|
||||||
|
<th className="label px-2 pb-1 font-normal">{t("tariff.stepTotal")}</th>
|
||||||
|
<th />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{p.steps.map((s, i) => (
|
||||||
|
<tr key={i}>
|
||||||
|
<td className="px-2 py-1">
|
||||||
|
<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")} />
|
||||||
|
<span className="text-[0.6875rem] text-term-muted">{t("tariff.hoursUnit")}</span>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-1">
|
||||||
|
<input className="input w-28" value={s.total} onChange={(e) => props.onStep?.(i, { total: e.target.value })} />
|
||||||
|
</td>
|
||||||
|
<td className="px-2">
|
||||||
|
{p.steps.length > 1 && (
|
||||||
|
<button type="button" className="btn btn-ghost btn-sm" onClick={() => props.onRemoveStep?.(i)}>
|
||||||
|
{t("tariff.remove")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div className="mt-3">
|
||||||
|
<button type="button" className="btn btn-sm" onClick={props.onAddStep}>
|
||||||
|
{t("tariff.addStep")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : p.mode === "flat" ? (
|
||||||
|
<div className="inline-flex items-center gap-2">
|
||||||
|
<span className="label">{t("tariff.pricePerIncrement")}</span>
|
||||||
|
<input className="input w-28" value={p.flat} onChange={(e) => props.onFlat(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<table className="w-full border-collapse">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-left">
|
||||||
|
<th className="label px-2 pb-1 font-normal">{t("tariff.bandDuration")}</th>
|
||||||
|
<th className="label px-2 pb-1 font-normal">{t("tariff.pricePerIncrement")}</th>
|
||||||
|
<th />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{p.blocks.map((b, i) => {
|
||||||
|
const isTail = i === p.blocks.length - 1;
|
||||||
|
return (
|
||||||
|
<tr key={i}>
|
||||||
|
<td className="px-2 py-1">
|
||||||
|
{isTail ? (
|
||||||
|
<span className="italic text-term-muted">{t("tariff.thereafter")}</span>
|
||||||
|
) : (
|
||||||
|
<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")} />
|
||||||
|
<span className="text-[0.6875rem] text-term-muted">{t("tariff.hoursUnit")}</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-1">
|
||||||
|
<input className="input w-28" value={b.price} onChange={(e) => props.onBlock(i, { price: e.target.value })} />
|
||||||
|
</td>
|
||||||
|
<td className="px-2">
|
||||||
|
{!isTail && (
|
||||||
|
<button type="button" className="btn btn-ghost btn-sm" onClick={() => props.onRemoveBlock(i)}>
|
||||||
|
{t("tariff.remove")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div className="mt-3 flex items-center gap-4">
|
||||||
|
<button type="button" className="btn btn-sm" onClick={props.onAddBlock}>
|
||||||
|
{t("tariff.addBlock")}
|
||||||
|
</button>
|
||||||
|
<span className="inline-flex items-center gap-2">
|
||||||
|
<span className="label">{t("tariff.dailyCap")}</span>
|
||||||
|
<input className="input w-28" value={p.dailyCap} onChange={(e) => props.onCap(e.target.value)} placeholder={t("tariff.dailyCapPh")} />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+272
-108
@@ -1,21 +1,30 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
|
ApiError,
|
||||||
|
createTariffDraft,
|
||||||
|
deleteTariffDraft,
|
||||||
fetchTariff,
|
fetchTariff,
|
||||||
loadSimSession,
|
fetchTariffDrafts,
|
||||||
|
publishTariffVersion,
|
||||||
simulateTariff,
|
simulateTariff,
|
||||||
|
updateTariffDraft,
|
||||||
type SimulateResult,
|
type SimulateResult,
|
||||||
type SimPayment,
|
type TariffDraft,
|
||||||
type TariffState,
|
type TariffState,
|
||||||
} from "./api.js";
|
} from "./api.js";
|
||||||
|
import { TariffEditorForm, emptyForm, formFromActive, formFromVersion, toStructure, type FormState } from "./TariffEditorForm.js";
|
||||||
|
import { Modal } from "./ui/Modal.js";
|
||||||
import { formatMoney, formatDuration } from "./lib/format.js";
|
import { formatMoney, formatDuration } from "./lib/format.js";
|
||||||
|
|
||||||
// The TARIFF LAB — a pure session-pricing simulator. Test rates "in time" (overnight
|
// The TARIFF LAB — a sandbox for composing + pricing EXPERIMENTAL rate cards. Drafts
|
||||||
// windows, daily caps, overstay) in seconds instead of waiting hours, against ANY
|
// live in their own mutable table (tariff_drafts), so experimenting never churns the
|
||||||
// published tariff version, with no real ledger writes. Build a hypothetical session
|
// immutable published versions or risks a half-baked card going live: the admin
|
||||||
// (entry, optional payment, "now") OR load a real ticket and re-evaluate it at any
|
// composes a draft in the modal (the same form the composer page uses), simulates
|
||||||
// instant. Prices via the SAME `priceSession` the booth uses (server), so the lab and
|
// hypothetical stays against it (entry + exit, nothing else), and only when satisfied
|
||||||
// the live booth can never diverge. See wiki/concepts/tariff.md, booth-exit-flow.md.
|
// PUBLISHES it through the normal immutable-version path. Pricing uses the SAME
|
||||||
|
// `priceSession` the booth uses (server-side), so the lab and the live booth can
|
||||||
|
// never diverge. No ledger writes. See wiki/concepts/tariff.md.
|
||||||
|
|
||||||
/** <input type="datetime-local"> wants "YYYY-MM-DDTHH:mm" in LOCAL time. */
|
/** <input type="datetime-local"> wants "YYYY-MM-DDTHH:mm" in LOCAL time. */
|
||||||
function toLocalInput(iso: string): string {
|
function toLocalInput(iso: string): string {
|
||||||
@@ -33,50 +42,76 @@ function nowLocal(): string {
|
|||||||
return toLocalInput(new Date().toISOString());
|
return toLocalInput(new Date().toISOString());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** What the simulation runs against: the live card, a historical published
|
||||||
|
* version, or one lab draft. */
|
||||||
|
type Selection = { kind: "active" } | { kind: "version"; id: string } | { kind: "draft"; id: string };
|
||||||
|
|
||||||
|
/** Modal state: a draft being composed (id null = not yet saved). */
|
||||||
|
interface DraftEdit {
|
||||||
|
id: string | null;
|
||||||
|
name: string;
|
||||||
|
form: FormState;
|
||||||
|
}
|
||||||
|
|
||||||
export function TariffLab() {
|
export function TariffLab() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [state, setState] = useState<TariffState | null>(null);
|
const [state, setState] = useState<TariffState | null>(null);
|
||||||
|
const [drafts, setDrafts] = useState<TariffDraft[]>([]);
|
||||||
|
const [selected, setSelected] = useState<Selection>({ kind: "active" });
|
||||||
const [err, setErr] = useState<string | null>(null);
|
const [err, setErr] = useState<string | null>(null);
|
||||||
|
const [notice, setNotice] = useState<string | null>(null);
|
||||||
|
|
||||||
// Inputs (datetime-local strings, local wall-clock).
|
// The hypothetical stay: entry + exit, nothing else.
|
||||||
const [entered, setEntered] = useState<string>(() => {
|
const [entered, setEntered] = useState<string>(() => {
|
||||||
const d = new Date();
|
const d = new Date();
|
||||||
d.setHours(d.getHours() - 3); // default: a 3h-ago entry
|
d.setHours(d.getHours() - 3); // default: a 3h-ago entry
|
||||||
return toLocalInput(d.toISOString());
|
return toLocalInput(d.toISOString());
|
||||||
});
|
});
|
||||||
const [asOf, setAsOf] = useState<string>(nowLocal);
|
const [exit, setExit] = useState<string>(nowLocal);
|
||||||
const [category, setCategory] = useState("");
|
|
||||||
const [versionId, setVersionId] = useState<string>(""); // "" = active
|
|
||||||
// Optional single hypothetical payment (the latest grants the walk-back grace).
|
|
||||||
const [paid, setPaid] = useState(false);
|
|
||||||
const [paidAt, setPaidAt] = useState<string>(nowLocal);
|
|
||||||
const [graceMin, setGraceMin] = useState<string>("5");
|
|
||||||
// Load-a-real-ticket.
|
|
||||||
const [ticket, setTicket] = useState("");
|
|
||||||
const [loadMsg, setLoadMsg] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const [result, setResult] = useState<SimulateResult | null>(null);
|
const [result, setResult] = useState<SimulateResult | null>(null);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
// The draft-composer modal.
|
||||||
|
const [edit, setEdit] = useState<DraftEdit | null>(null);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [editErr, setEditErr] = useState<string | null>(null);
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
const [s, d] = await Promise.all([fetchTariff(), fetchTariffDrafts()]);
|
||||||
|
setState(s);
|
||||||
|
setDrafts(d.drafts);
|
||||||
|
return d.drafts;
|
||||||
|
}
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchTariff()
|
refresh().catch((e) => setErr((e as Error).message));
|
||||||
.then(setState)
|
|
||||||
.catch((e) => setErr((e as Error).message));
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const selectedDraft = selected.kind === "draft" ? drafts.find((d) => d.id === selected.id) ?? null : null;
|
||||||
|
const selectedVersion =
|
||||||
|
selected.kind === "version" ? state?.versions.find((v) => v.id === selected.id) ?? null : null;
|
||||||
|
|
||||||
|
function select(sel: Selection) {
|
||||||
|
setSelected(sel);
|
||||||
|
setResult(null); // a stale price against another card would mislead
|
||||||
|
setErr(null);
|
||||||
|
setNotice(null);
|
||||||
|
}
|
||||||
|
|
||||||
async function run() {
|
async function run() {
|
||||||
setErr(null);
|
setErr(null);
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
const payments: SimPayment[] = paid
|
|
||||||
? [{ paidAt: fromLocalInput(paidAt), graceExitMin: graceMin.trim() === "" ? null : Number(graceMin) }]
|
|
||||||
: [];
|
|
||||||
const r = await simulateTariff({
|
const r = await simulateTariff({
|
||||||
enteredAt: fromLocalInput(entered),
|
enteredAt: fromLocalInput(entered),
|
||||||
asOf: fromLocalInput(asOf),
|
asOf: fromLocalInput(exit),
|
||||||
payments,
|
// A draft carries its own structure+currency; a historical version is
|
||||||
category: category.trim() || undefined,
|
// referenced by id; otherwise the ACTIVE version.
|
||||||
tariffVersionId: versionId || undefined,
|
...(selectedDraft
|
||||||
|
? { structure: selectedDraft.structure, currency: selectedDraft.currency }
|
||||||
|
: selectedVersion
|
||||||
|
? { tariffVersionId: selectedVersion.id }
|
||||||
|
: {}),
|
||||||
});
|
});
|
||||||
setResult(r);
|
setResult(r);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -87,110 +122,123 @@ export function TariffLab() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadTicket() {
|
// --- draft actions ---
|
||||||
setLoadMsg(null);
|
function newDraft() {
|
||||||
|
// Start from the live card when there is one — the admin usually experiments
|
||||||
|
// with a variation of today's prices, not from a blank slate.
|
||||||
|
const form = state?.active ? formFromActive(state) : emptyForm();
|
||||||
|
setEditErr(null);
|
||||||
|
setEdit({ id: null, name: "", form });
|
||||||
|
}
|
||||||
|
function editDraft(d: TariffDraft) {
|
||||||
|
setEditErr(null);
|
||||||
|
setEdit({ id: d.id, name: d.name, form: formFromVersion(d.currency, d.structure) });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveDraft() {
|
||||||
|
if (!edit) return;
|
||||||
|
setSaving(true);
|
||||||
|
setEditErr(null);
|
||||||
|
try {
|
||||||
|
const body = {
|
||||||
|
name: edit.name.trim(),
|
||||||
|
currency: edit.form.currency.trim().toUpperCase(),
|
||||||
|
structure: toStructure(edit.form),
|
||||||
|
};
|
||||||
|
const saved = edit.id ? await updateTariffDraft(edit.id, body) : await createTariffDraft(body);
|
||||||
|
await refresh();
|
||||||
|
setEdit(null);
|
||||||
|
select({ kind: "draft", id: saved.id });
|
||||||
|
} catch (e) {
|
||||||
|
const text =
|
||||||
|
e instanceof ApiError && e.problems?.length ? `${e.message}: ${e.problems.join("; ")}` : (e as Error).message;
|
||||||
|
setEditErr(text);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeDraft(d: TariffDraft) {
|
||||||
|
if (!confirm(t("lab.confirmDelete", { name: d.name }))) return;
|
||||||
setErr(null);
|
setErr(null);
|
||||||
try {
|
try {
|
||||||
const s = await loadSimSession(ticket.trim());
|
await deleteTariffDraft(d.id);
|
||||||
setEntered(toLocalInput(s.enteredAt));
|
await refresh();
|
||||||
setAsOf(s.exitedAt ? toLocalInput(s.exitedAt) : nowLocal());
|
select({ kind: "active" });
|
||||||
setCategory(s.category ?? "");
|
|
||||||
setVersionId(s.tariffVersionId ?? "");
|
|
||||||
const last = s.payments.at(-1);
|
|
||||||
if (last) {
|
|
||||||
setPaid(true);
|
|
||||||
setPaidAt(toLocalInput(last.paidAt));
|
|
||||||
setGraceMin(last.graceExitMin != null ? String(last.graceExitMin) : "");
|
|
||||||
} else {
|
|
||||||
setPaid(false);
|
|
||||||
}
|
|
||||||
setLoadMsg(t("lab.loaded", { id: s.identity }));
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setErr((e as Error).message);
|
setErr((e as Error).message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const currency = result?.currency ?? state?.active?.currency ?? "ALL";
|
async function publishDraft(d: TariffDraft) {
|
||||||
|
if (!confirm(t("lab.confirmPublish", { name: d.name }))) return;
|
||||||
|
setErr(null);
|
||||||
|
setNotice(null);
|
||||||
|
try {
|
||||||
|
// The draft's name rides along onto the immutable version.
|
||||||
|
await publishTariffVersion({ currency: d.currency, structure: d.structure, name: d.name });
|
||||||
|
await refresh();
|
||||||
|
setNotice(t("tariff.publishedOk"));
|
||||||
|
} catch (e) {
|
||||||
|
const text =
|
||||||
|
e instanceof ApiError && e.problems?.length ? `${e.message}: ${e.problems.join("; ")}` : (e as Error).message;
|
||||||
|
setErr(text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const currency = result?.currency ?? selectedDraft?.currency ?? selectedVersion?.currency ?? state?.active?.currency ?? "ALL";
|
||||||
|
|
||||||
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("lab.title")}</h2>
|
<h2 className="mb-1 text-h4 font-semibold text-term-text">{t("lab.title")}</h2>
|
||||||
<p className="hint mb-4">{t("lab.intro")}</p>
|
<p className="hint mb-4">{t("lab.intro")}</p>
|
||||||
|
|
||||||
{/* Load a real ticket */}
|
<div className="flex flex-col gap-4 lg:flex-row">
|
||||||
<div className="card card-body mb-4 flex flex-wrap items-end gap-2">
|
{/* Main: the hypothetical stay + result, priced against the selection. */}
|
||||||
<div className="flex flex-col gap-1">
|
<div className="min-w-0 flex-1">
|
||||||
<label className="label">{t("lab.loadTicket")}</label>
|
{/* What we're pricing against + draft actions. */}
|
||||||
<input
|
<div className="mb-3 flex flex-wrap items-center gap-2">
|
||||||
className="input w-56"
|
<span className="rounded bg-term-panel-2 px-2 py-1 text-[0.75rem] text-term-cyan">
|
||||||
value={ticket}
|
{selectedDraft
|
||||||
onChange={(e) => setTicket(e.target.value)}
|
? selectedDraft.name
|
||||||
placeholder={t("lab.loadTicketPh")}
|
: selectedVersion
|
||||||
/>
|
? selectedVersion.name ?? new Date(selectedVersion.effectiveFrom).toLocaleString()
|
||||||
</div>
|
: t("lab.activeTariff")}
|
||||||
<button type="button" className="btn btn-sm" onClick={loadTicket} disabled={!ticket.trim()}>
|
</span>
|
||||||
{t("lab.load")}
|
{selectedDraft && (
|
||||||
|
<>
|
||||||
|
<button type="button" className="btn btn-sm" onClick={() => editDraft(selectedDraft)}>
|
||||||
|
{t("lab.edit")}
|
||||||
</button>
|
</button>
|
||||||
{loadMsg && <span className="text-[12px] text-term-green">{loadMsg}</span>}
|
<button type="button" className="btn btn-sm" onClick={() => publishDraft(selectedDraft)}>
|
||||||
|
{t("lab.publish")}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn btn-danger btn-sm" onClick={() => removeDraft(selectedDraft)}>
|
||||||
|
{t("lab.delete")}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{notice && <span className="text-[0.75rem] text-term-green">{notice}</span>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Hypothetical session inputs */}
|
|
||||||
<div className="card card-body grid grid-cols-[max-content_1fr] items-center gap-x-4 gap-y-2">
|
<div className="card card-body grid grid-cols-[max-content_1fr] items-center gap-x-4 gap-y-2">
|
||||||
<label className="label">{t("lab.tariffVersion")}</label>
|
|
||||||
<select className="input w-full max-w-md" value={versionId} onChange={(e) => setVersionId(e.target.value)}>
|
|
||||||
<option value="">{t("lab.activeVersion")}</option>
|
|
||||||
{state?.versions.map((v) => (
|
|
||||||
<option key={v.id} value={v.id}>
|
|
||||||
{new Date(v.effectiveFrom).toLocaleString()} · {v.currency} · {v.id.slice(0, 8)}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
|
|
||||||
<label className="label">{t("lab.entered")}</label>
|
<label className="label">{t("lab.entered")}</label>
|
||||||
<input type="datetime-local" className="input w-64" value={entered} onChange={(e) => setEntered(e.target.value)} />
|
<input type="datetime-local" className="input w-64" value={entered} onChange={(e) => setEntered(e.target.value)} />
|
||||||
|
|
||||||
<label className="label">{t("lab.asOf")}</label>
|
<label className="label">{t("lab.exit")}</label>
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
<input type="datetime-local" className="input w-64" value={asOf} onChange={(e) => setAsOf(e.target.value)} />
|
<input type="datetime-local" className="input w-64" value={exit} onChange={(e) => setExit(e.target.value)} />
|
||||||
<button type="button" className="btn btn-sm" onClick={() => setAsOf(nowLocal())}>
|
<button type="button" className="btn btn-sm" onClick={() => setExit(nowLocal())}>
|
||||||
{t("lab.now")}
|
{t("lab.now")}
|
||||||
</button>
|
</button>
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<label className="label">{t("lab.category")}</label>
|
|
||||||
<input
|
|
||||||
className="input w-40"
|
|
||||||
value={category}
|
|
||||||
onChange={(e) => setCategory(e.target.value)}
|
|
||||||
placeholder={t("lab.categoryPh")}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<label className="label">{t("lab.payment")}</label>
|
|
||||||
<span className="flex flex-wrap items-center gap-2">
|
|
||||||
<label className="inline-flex items-center gap-1 text-[12px] text-term-text">
|
|
||||||
<input type="checkbox" className="accent-term-amber" checked={paid} onChange={(e) => setPaid(e.target.checked)} />
|
|
||||||
{t("lab.paid")}
|
|
||||||
</label>
|
|
||||||
{paid && (
|
|
||||||
<>
|
|
||||||
<input
|
|
||||||
type="datetime-local"
|
|
||||||
className="input w-64"
|
|
||||||
value={paidAt}
|
|
||||||
onChange={(e) => setPaidAt(e.target.value)}
|
|
||||||
/>
|
|
||||||
<span className="text-term-muted">{t("lab.graceMin")}</span>
|
|
||||||
<input className="input w-20" value={graceMin} onChange={(e) => setGraceMin(e.target.value)} />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-4 flex items-center gap-3">
|
<div className="mt-4 flex items-center gap-3">
|
||||||
<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 +246,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(exit))}
|
||||||
{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 +278,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">
|
||||||
@@ -243,6 +291,122 @@ export function TariffLab() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sidebar: lab drafts + the full published history; click any to price
|
||||||
|
against it. */}
|
||||||
|
<aside className="w-full shrink-0 lg:w-72">
|
||||||
|
<div className="mb-2 flex items-center justify-between">
|
||||||
|
<h3 className="text-h6 font-semibold uppercase tracking-wider text-term-text">{t("lab.drafts")}</h3>
|
||||||
|
<button type="button" className="btn btn-sm" onClick={newDraft}>
|
||||||
|
{t("lab.newDraft")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<ul className="flex flex-col gap-1">
|
||||||
|
{drafts.map((d) => (
|
||||||
|
<li key={d.id}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => select({ kind: "draft", id: d.id })}
|
||||||
|
className={`w-full rounded-term border px-3 py-2 text-left text-[0.8125rem] ${
|
||||||
|
selected.kind === "draft" && selected.id === d.id
|
||||||
|
? "border-term-amber bg-term-amber/10 text-term-text"
|
||||||
|
: "border-term-border text-term-muted hover:text-term-text"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="block font-semibold">{d.name}</span>
|
||||||
|
<span className="block text-[0.6875rem] text-term-muted">
|
||||||
|
{d.currency} · {new Date(d.updatedAt).toLocaleString()}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
{drafts.length === 0 && <li className="hint px-1 py-2">{t("lab.noDrafts")}</li>}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{/* Published versions: the active card first, then the immutable history
|
||||||
|
(older versions still price past sessions — see wiki/concepts/tariff.md). */}
|
||||||
|
<h3 className="mb-2 mt-5 text-h6 font-semibold uppercase tracking-wider text-term-text">
|
||||||
|
{t("lab.published")}
|
||||||
|
</h3>
|
||||||
|
<ul className="flex flex-col gap-1">
|
||||||
|
<li>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => select({ kind: "active" })}
|
||||||
|
className={`w-full rounded-term border px-3 py-2 text-left text-[0.8125rem] ${
|
||||||
|
selected.kind === "active"
|
||||||
|
? "border-term-amber bg-term-amber/10 text-term-text"
|
||||||
|
: "border-term-border text-term-muted hover:text-term-text"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="block font-semibold">
|
||||||
|
{t("lab.activeTariff")}
|
||||||
|
{state?.active?.name ? ` — ${state.active.name}` : ""}
|
||||||
|
</span>
|
||||||
|
<span className="block text-[0.6875rem] text-term-muted">
|
||||||
|
{state?.active ? new Date(state.active.effectiveFrom).toLocaleString() : t("tariff.noRateCard")}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
{state?.versions
|
||||||
|
.filter((v) => v.id !== state.active?.id)
|
||||||
|
.map((v) => (
|
||||||
|
<li key={v.id}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => select({ kind: "version", id: v.id })}
|
||||||
|
className={`w-full rounded-term border px-3 py-2 text-left text-[0.8125rem] ${
|
||||||
|
selected.kind === "version" && selected.id === v.id
|
||||||
|
? "border-term-amber bg-term-amber/10 text-term-text"
|
||||||
|
: "border-term-border text-term-muted hover:text-term-text"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="block font-semibold">
|
||||||
|
{v.name ?? new Date(v.effectiveFrom).toLocaleString()}
|
||||||
|
</span>
|
||||||
|
<span className="block text-[0.6875rem] text-term-muted">
|
||||||
|
{v.name ? `${new Date(v.effectiveFrom).toLocaleString()} · ` : ""}
|
||||||
|
{v.currency}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* The draft composer — the SAME form the /setup/tariff page uses, in a modal. */}
|
||||||
|
<Modal
|
||||||
|
open={edit != null}
|
||||||
|
onClose={() => setEdit(null)}
|
||||||
|
title={edit?.id ? t("lab.editDraftTitle") : t("lab.newDraftTitle")}
|
||||||
|
width="max-w-3xl"
|
||||||
|
>
|
||||||
|
{edit && (
|
||||||
|
<div>
|
||||||
|
<div className="mb-4 flex items-center gap-2">
|
||||||
|
<label className="label">{t("lab.draftName")}</label>
|
||||||
|
<input
|
||||||
|
className="input w-72"
|
||||||
|
value={edit.name}
|
||||||
|
onChange={(e) => setEdit((d) => (d ? { ...d, name: e.target.value } : d))}
|
||||||
|
placeholder={t("lab.draftNamePh")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<TariffEditorForm
|
||||||
|
form={edit.form}
|
||||||
|
onChange={(update) => setEdit((d) => (d ? { ...d, form: update(d.form) } : d))}
|
||||||
|
/>
|
||||||
|
<div className="mt-6 flex items-center gap-3">
|
||||||
|
<button type="button" className="btn btn-primary" onClick={saveDraft} disabled={saving || !edit.name.trim()}>
|
||||||
|
{saving ? t("lab.savingDraft") : t("lab.saveDraft")}
|
||||||
|
</button>
|
||||||
|
{editErr && <span className="text-[0.75rem] text-term-red">{editErr}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
+300
-37
@@ -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,39 @@ 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 }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RelayTestResult =
|
||||||
|
| { ok: true; firedAt: string; tookMs: number }
|
||||||
|
| { ok: false; reason: string; detail?: string; tookMs?: number };
|
||||||
|
|
||||||
|
/** Pulse a SAVED controller's barrier relay to test the wiring — physically opens the
|
||||||
|
* barrier. The server signs a `barrier_open_command` (reason setup.relayTest) before
|
||||||
|
* firing, so the open is explained, not a reconciliation anomaly. Saved controller only
|
||||||
|
* (needs a persisted id for attribution). */
|
||||||
|
export function testRelay(id: string, relay: number): Promise<RelayTestResult> {
|
||||||
|
return apiFetch<RelayTestResult>("/api/setup/test-relay", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ id, relay }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// --- Admin reports -------------------------------------------------------
|
// --- Admin reports -------------------------------------------------------
|
||||||
export type ReportBucket = "hour" | "day" | "month";
|
export type ReportBucket = "hour" | "day" | "month";
|
||||||
|
|
||||||
@@ -527,10 +669,14 @@ export interface TariffCard {
|
|||||||
priority: number;
|
priority: number;
|
||||||
category?: string;
|
category?: string;
|
||||||
window?: TariffWindow;
|
window?: TariffWindow;
|
||||||
|
/** Flat price PER INCREMENT (an hourly flat rate) — not a whole-stay price. */
|
||||||
flatMinor?: number;
|
flatMinor?: number;
|
||||||
blocks?: TariffBlock[];
|
blocks?: TariffBlock[];
|
||||||
/** STEPPED ("up-to") table (defaultCard only); mutually exclusive with flat/blocks. */
|
/** STEPPED ("up-to") table (defaultCard only); mutually exclusive with flat/blocks. */
|
||||||
steps?: TariffStep[];
|
steps?: TariffStep[];
|
||||||
|
/** WINDOW PACKAGE (windowed cards only): ONE total per contiguous window occurrence
|
||||||
|
* ("any presence in the window = this price"). Mirrors @parking/shared. */
|
||||||
|
packageMinor?: number;
|
||||||
dailyCapMinor?: number | null;
|
dailyCapMinor?: number | null;
|
||||||
}
|
}
|
||||||
/** One row of a STEPPED ("up-to") tariff: a TOTAL price for a stay up to and including
|
/** One row of a STEPPED ("up-to") tariff: a TOTAL price for a stay up to and including
|
||||||
@@ -560,6 +706,8 @@ export function isTariffV2(t: TariffStructure): t is TariffStructureV2 {
|
|||||||
export interface TariffVersion {
|
export interface TariffVersion {
|
||||||
id: string;
|
id: string;
|
||||||
tariffId: string;
|
tariffId: string;
|
||||||
|
/** Optional human label, stamped at publish (e.g. carried from a lab draft). */
|
||||||
|
name?: string | null;
|
||||||
effectiveFrom: string;
|
effectiveFrom: string;
|
||||||
currency: string;
|
currency: string;
|
||||||
structure: TariffStructure;
|
structure: TariffStructure;
|
||||||
@@ -581,6 +729,7 @@ export function publishTariffVersion(body: {
|
|||||||
currency: string;
|
currency: string;
|
||||||
structure: TariffStructure;
|
structure: TariffStructure;
|
||||||
effectiveFrom?: string;
|
effectiveFrom?: string;
|
||||||
|
name?: string;
|
||||||
}): Promise<TariffVersion> {
|
}): Promise<TariffVersion> {
|
||||||
return apiFetch("/api/tariff/versions", { method: "POST", body: JSON.stringify(body) });
|
return apiFetch("/api/tariff/versions", { method: "POST", body: JSON.stringify(body) });
|
||||||
}
|
}
|
||||||
@@ -619,18 +768,40 @@ export function simulateTariff(body: SimulateBody): Promise<SimulateResult> {
|
|||||||
return apiFetch("/api/tariff/simulate", { method: "POST", body: JSON.stringify(body) });
|
return apiFetch("/api/tariff/simulate", { method: "POST", body: JSON.stringify(body) });
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SimSessionLoad {
|
// --- Tariff Lab drafts ------------------------------------------------------
|
||||||
identity: string;
|
// Mutable experimental rate cards — the lab composes + simulates these, and
|
||||||
enteredAt: string;
|
// publishing one goes through the normal immutable-version path above.
|
||||||
exitedAt: string | null;
|
|
||||||
payments: SimPayment[];
|
export interface TariffDraft {
|
||||||
category: string | null;
|
id: string;
|
||||||
tariffVersionId: string | null;
|
name: string;
|
||||||
|
currency: string;
|
||||||
|
structure: TariffStructure;
|
||||||
|
createdBy: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Prefill the lab from a real ledger session. */
|
export function fetchTariffDrafts(): Promise<{ drafts: TariffDraft[] }> {
|
||||||
export function loadSimSession(identity: string): Promise<SimSessionLoad> {
|
return apiFetch("/api/tariff/drafts");
|
||||||
return apiFetch(`/api/tariff/simulate/session/${encodeURIComponent(identity)}`);
|
}
|
||||||
|
|
||||||
|
export interface TariffDraftBody {
|
||||||
|
name: string;
|
||||||
|
currency: string;
|
||||||
|
structure: TariffStructure;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createTariffDraft(body: TariffDraftBody): Promise<TariffDraft> {
|
||||||
|
return apiFetch("/api/tariff/drafts", { method: "POST", body: JSON.stringify(body) });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateTariffDraft(id: string, body: TariffDraftBody): Promise<TariffDraft> {
|
||||||
|
return apiFetch(`/api/tariff/drafts/${encodeURIComponent(id)}`, { method: "PUT", body: JSON.stringify(body) });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteTariffDraft(id: string): Promise<void> {
|
||||||
|
return apiFetch(`/api/tariff/drafts/${encodeURIComponent(id)}`, { method: "DELETE" });
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Subscriptions --------------------------------------------------------
|
// --- Subscriptions --------------------------------------------------------
|
||||||
@@ -895,15 +1066,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 +1105,32 @@ 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}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The physical drawer balance NOW (cash payments + vouchers over the whole chain —
|
||||||
|
* the amount that carries across shifts). */
|
||||||
|
export function fetchDrawerBalance(): Promise<{ balanceMinor: number; currency: string | null }> {
|
||||||
|
return apiFetch("/api/drawer/balance");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Admin AUTHORIZES or DENIES a recorded movement (a flag — never a cash reversal). */
|
||||||
|
export function reviewDrawerMovement(args: {
|
||||||
|
refId: string;
|
||||||
|
decision: "authorize" | "deny";
|
||||||
|
note?: string;
|
||||||
|
}): Promise<{ refId: string; decision: "authorize" | "deny"; reviewedBy: string; at: string }> {
|
||||||
|
return apiFetch("/api/drawer/review", { method: "POST", body: JSON.stringify(args) });
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A completed shift (reconstructed from its signed Z-report). */
|
/** A completed shift (reconstructed from its signed Z-report). */
|
||||||
@@ -941,6 +1157,8 @@ export interface ShiftSummary extends ShiftSourceSplit {
|
|||||||
export function fetchShifts(params: { operator?: string; from?: string; to?: string } = {}): Promise<{
|
export function fetchShifts(params: { operator?: string; from?: string; to?: string } = {}): Promise<{
|
||||||
shifts: ShiftSummary[];
|
shifts: ShiftSummary[];
|
||||||
scope: "all" | "self";
|
scope: "all" | "self";
|
||||||
|
/** Admin scope only: every operator that has a shift — feeds the filter dropdown. */
|
||||||
|
operators?: string[];
|
||||||
}> {
|
}> {
|
||||||
const qs = new URLSearchParams();
|
const qs = new URLSearchParams();
|
||||||
if (params.operator) qs.set("operator", params.operator);
|
if (params.operator) qs.set("operator", params.operator);
|
||||||
@@ -970,6 +1188,11 @@ export interface SiteConfig {
|
|||||||
reserveSubscriberSpots: boolean;
|
reserveSubscriberSpots: boolean;
|
||||||
/** Master switch for the ANPR subscriber-entry bridge (auto-open on a plate read). */
|
/** Master switch for the ANPR subscriber-entry bridge (auto-open on a plate read). */
|
||||||
anprEntryEnabled: boolean;
|
anprEntryEnabled: boolean;
|
||||||
|
/** Entry presence-gate bypass: drop radar/loop as an entry-button requirement (faulty
|
||||||
|
* device). Set only via the dedicated signed endpoint, not saveSiteConfig. */
|
||||||
|
bypassPresenceRadar: boolean;
|
||||||
|
/** Entry presence-gate bypass: drop camera detection as an entry-button requirement. */
|
||||||
|
bypassPresenceCamera: boolean;
|
||||||
parkName: string | null;
|
parkName: string | null;
|
||||||
operatorName: string | null;
|
operatorName: string | null;
|
||||||
/** NIUS — Albanian tax/identification number. */
|
/** NIUS — Albanian tax/identification number. */
|
||||||
@@ -1044,6 +1267,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 +1344,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,
|
||||||
@@ -1189,6 +1446,12 @@ export function fetchSiteConfig(): Promise<SiteConfig> {
|
|||||||
export function saveSiteConfig(patch: Partial<SiteConfig>): Promise<SiteConfig> {
|
export function saveSiteConfig(patch: Partial<SiteConfig>): Promise<SiteConfig> {
|
||||||
return apiFetch("/api/site-config", { method: "PUT", body: JSON.stringify(patch) });
|
return apiFetch("/api/site-config", { method: "PUT", body: JSON.stringify(patch) });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Toggle the entry presence-gate bypass (radar/camera). Dedicated signed endpoint —
|
||||||
|
* each changed signal appends a config_change to the ledger. See entry-presence-bypass. */
|
||||||
|
export function updatePresenceBypass(patch: { radar?: boolean; camera?: boolean }): Promise<SiteConfig> {
|
||||||
|
return apiFetch("/api/site-config/presence-bypass", { method: "PUT", body: JSON.stringify(patch) });
|
||||||
|
}
|
||||||
export function setCapacity(capacity: number | null): Promise<SiteConfig> {
|
export function setCapacity(capacity: number | null): Promise<SiteConfig> {
|
||||||
return saveSiteConfig({ capacity });
|
return saveSiteConfig({ capacity });
|
||||||
}
|
}
|
||||||
|
|||||||
+80
-18
@@ -13,9 +13,42 @@
|
|||||||
exposed as utilities (night-*, ink-*, paper-*, flag/amber/green/blue, the
|
exposed as utilities (night-*, ink-*, paper-*, flag/amber/green/blue, the
|
||||||
spacing/type/shadow scales) for new work.
|
spacing/type/shadow scales) for new work.
|
||||||
|
|
||||||
Offline appliance: NO webfont @import (no network at runtime). Goldplay (the
|
Offline appliance: NO webfont @import (no network at runtime). The primary
|
||||||
TRM display face) is not self-hosted yet — display/heading text falls back to
|
face is Chakra Petch, SELF-HOSTED from public/fonts/chakra-petch (SIL OFL,
|
||||||
a clean sans stack; wire local Goldplay @font-face here if it's wanted. */
|
license alongside the files) — latin subset only (covers en + sq ë/ç), the
|
||||||
|
weights the UI actually uses (400/600/700 + 400 italic). Not a true
|
||||||
|
monospace: it stays FIRST in --font-mono for the look, with the real mono
|
||||||
|
stack behind it as fallback; .num/.tabular still request tabular figures. */
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "Chakra Petch";
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
font-display: swap;
|
||||||
|
src: url("/fonts/chakra-petch/chakra-petch-latin-400.woff2") format("woff2");
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: "Chakra Petch";
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 600;
|
||||||
|
font-display: swap;
|
||||||
|
src: url("/fonts/chakra-petch/chakra-petch-latin-600.woff2") format("woff2");
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: "Chakra Petch";
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 700;
|
||||||
|
font-display: swap;
|
||||||
|
src: url("/fonts/chakra-petch/chakra-petch-latin-700.woff2") format("woff2");
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: "Chakra Petch";
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: 400;
|
||||||
|
font-display: swap;
|
||||||
|
src: url("/fonts/chakra-petch/chakra-petch-latin-400-italic.woff2") format("woff2");
|
||||||
|
}
|
||||||
|
|
||||||
@theme {
|
@theme {
|
||||||
/* ============================================================
|
/* ============================================================
|
||||||
TERMINAL ACCENTS — aligned onto TRM's exact values.
|
TERMINAL ACCENTS — aligned onto TRM's exact values.
|
||||||
@@ -92,13 +125,15 @@
|
|||||||
--color-viz-8: #5a5a53;
|
--color-viz-8: #5a5a53;
|
||||||
|
|
||||||
/* ---------- TYPE — families ---------- */
|
/* ---------- TYPE — families ---------- */
|
||||||
/* Mono is the booth's primary face (data-dense, tabular). Display/UI fall
|
/* Chakra Petch (self-hosted, see @font-face above) is the booth's primary
|
||||||
back to a clean sans (Goldplay not self-hosted — see header note). */
|
face everywhere — it leads every stack so headings, body, and the
|
||||||
--font-mono: "JetBrains Mono", "IBM Plex Mono", ui-monospace, "SFMono-Regular",
|
`font-mono` chrome all render with it; the stacks behind it are the
|
||||||
"Menlo", "Consolas", monospace;
|
pre-2026-07-05 fallbacks for glyphs outside the latin subset. */
|
||||||
--font-display: "Goldplay", "Helvetica Neue", Arial, sans-serif;
|
--font-mono: "Chakra Petch", "JetBrains Mono", "IBM Plex Mono", ui-monospace,
|
||||||
--font-ui: "Goldplay", "Helvetica Neue", Arial, sans-serif;
|
"SFMono-Regular", "Menlo", "Consolas", monospace;
|
||||||
--font-body: "Inter", "Helvetica Neue", Arial, sans-serif;
|
--font-display: "Chakra Petch", "Helvetica Neue", Arial, sans-serif;
|
||||||
|
--font-ui: "Chakra Petch", "Helvetica Neue", Arial, sans-serif;
|
||||||
|
--font-body: "Chakra Petch", "Inter", "Helvetica Neue", Arial, sans-serif;
|
||||||
|
|
||||||
/* ---------- TYPE — scale (TRM, optimised for data density) ---------- */
|
/* ---------- TYPE — scale (TRM, optimised for data density) ---------- */
|
||||||
--text-overline: 11px;
|
--text-overline: 11px;
|
||||||
@@ -180,7 +215,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 +301,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 +337,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 +401,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 +463,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,11 @@
|
|||||||
|
// The currencies the booth can price in (ISO 4217). Money is always stored as
|
||||||
|
// integer minor units + one of these codes; the UI offers a closed select rather
|
||||||
|
// than free text so a typo can never publish an unknown currency.
|
||||||
|
export const CURRENCIES = ["ALL", "EUR", "USD"] as const;
|
||||||
|
|
||||||
|
/** The select options: the known set, plus the current value when it's some
|
||||||
|
* historical code outside it (so an old record still displays + round-trips). */
|
||||||
|
export function currencyOptions(current: string): string[] {
|
||||||
|
const cur = current.trim().toUpperCase();
|
||||||
|
return cur && !CURRENCIES.includes(cur as (typeof CURRENCIES)[number]) ? [...CURRENCIES, cur] : [...CURRENCIES];
|
||||||
|
}
|
||||||
@@ -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 "—";
|
||||||
|
|||||||
+232
-41
@@ -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,52 @@ 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: {
|
||||||
|
stateTitle: "Drawer now",
|
||||||
|
openShift: "Open shift: {{operator}}",
|
||||||
|
noShiftOpen: "No shift open — the drawer carries the last shift's closing balance.",
|
||||||
|
thisShift: "This shift:",
|
||||||
|
todayTitle: "Today's cash activity",
|
||||||
|
todayPayments: "{{count}} payments",
|
||||||
|
payment: "Payment",
|
||||||
|
noActivity: "No cash activity today.",
|
||||||
|
historyTitle: "Closed shifts",
|
||||||
|
noShifts: "No closed shifts yet.",
|
||||||
|
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 +162,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 +177,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 +190,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 +200,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 +227,9 @@ export const en: Catalog = {
|
|||||||
evtCashMovement: "CASH",
|
evtCashMovement: "CASH",
|
||||||
evtCashIn: "PAY-IN",
|
evtCashIn: "PAY-IN",
|
||||||
evtCashOut: "PAY-OUT",
|
evtCashOut: "PAY-OUT",
|
||||||
|
evtCashReview: "REVIEW",
|
||||||
|
evtConfigChange: "CONFIG",
|
||||||
|
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 +271,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 +290,9 @@ 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",
|
||||||
|
"entry.duplicatePlate": "Possible duplicate entry — plate {{plate}} is already inside under ticket {{otherIdentity}}",
|
||||||
"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,12 +302,16 @@ 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)",
|
||||||
"sub.refused.atCapacity": "Subscription refused — at capacity ({{inUse}}/{{max}} cars in)",
|
"sub.refused.atCapacity": "Subscription refused — at capacity ({{inUse}}/{{max}} cars in)",
|
||||||
"sub.refused.unpaidWindow": "Exit refused — out-of-window charge unpaid ({{amount}} {{currency}}); pay at the booth",
|
"sub.refused.unpaidWindow": "Exit refused — out-of-window charge unpaid ({{amount}} {{currency}}); pay at the booth",
|
||||||
|
"sub.refused.channelMismatch": "Credential refused — a {{credentialKind}} credential arrived via the {{channel}} channel (possible cloned credential)",
|
||||||
"void.ticketCancelled": "Ticket cancelled — {{reason}}",
|
"void.ticketCancelled": "Ticket cancelled — {{reason}}",
|
||||||
|
"setup.relayTest": "Relay test — admin {{operator}} pulsed relay {{relay}} on controller {{controller}} from Setup",
|
||||||
},
|
},
|
||||||
tariff: {
|
tariff: {
|
||||||
title: "Tariff",
|
title: "Tariff",
|
||||||
@@ -264,25 +329,33 @@ export const en: Catalog = {
|
|||||||
bandDuration: "Band duration",
|
bandDuration: "Band duration",
|
||||||
hoursUnit: "hours",
|
hoursUnit: "hours",
|
||||||
egHours: "e.g. 2",
|
egHours: "e.g. 2",
|
||||||
pricePerIncrement: "Price / increment",
|
pricePerIncrement: "Price / increment (per hour)",
|
||||||
thereafter: "thereafter (open-ended)",
|
thereafter: "thereafter (open-ended)",
|
||||||
remove: "Remove",
|
remove: "Remove",
|
||||||
addBlock: "+ Add block",
|
addBlock: "+ Add block",
|
||||||
publishNewVersion: "Publish new version",
|
publishNewVersion: "Publish new version",
|
||||||
publishing: "Publishing…",
|
publishing: "Publishing…",
|
||||||
|
versionNamePh: "Version name (optional), e.g. Summer 2026",
|
||||||
|
versionsTitle: "Published versions",
|
||||||
|
versionsHint: "Click one to load it into the editor. Publishing always creates a new version — past versions never change.",
|
||||||
|
activeBadge: "active",
|
||||||
publishedOk: "New tariff version published — it's now the active rate.",
|
publishedOk: "New tariff version published — it's now the active rate.",
|
||||||
defaultCard: "Base rate (always active)",
|
defaultCard: "Base rate (always active)",
|
||||||
defaultCardHint: "The base rate applied when no time/seasonal tier matches. This alone is enough for most car parks.",
|
defaultCardHint: "The base rate applied when no time/seasonal tier matches. This alone is enough for most car parks.",
|
||||||
modeLadder: "Hourly ladder",
|
modeLadder: "Hourly ladder",
|
||||||
modeFlat: "Flat price",
|
modeFlat: "Flat price / hour",
|
||||||
modeStepped: "By duration (up-to)",
|
modeStepped: "By duration (up-to)",
|
||||||
|
modePackage: "Window package (one total)",
|
||||||
|
packageHint:
|
||||||
|
"ONE total for any presence inside this tier's window — leaving earlier costs the same. Touching the window on two different nights charges the package twice (once per night). Hours outside the window are priced by the base rate.",
|
||||||
|
packageTotal: "Package total",
|
||||||
steppedHint:
|
steppedHint:
|
||||||
"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.",
|
"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.",
|
||||||
stepUpTo: "Up to",
|
stepUpTo: "Up to",
|
||||||
stepTotal: "Total price",
|
stepTotal: "Total price",
|
||||||
addStep: "+ Add row",
|
addStep: "+ Add row",
|
||||||
steppedTiersConflict:
|
steppedTiersConflict:
|
||||||
"⚠ 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.",
|
"⚠ 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 / hour'. Publishing is blocked until this is fixed.",
|
||||||
tiersAdvanced: "Advanced: time & seasonal tiers",
|
tiersAdvanced: "Advanced: time & seasonal tiers",
|
||||||
tiersHint: "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.",
|
tiersHint: "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.",
|
||||||
tierName: "Name",
|
tierName: "Name",
|
||||||
@@ -305,7 +378,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 +432,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 +485,43 @@ 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).",
|
||||||
|
// Relay wiring test — physically opens the barrier (the open is signed into the ledger).
|
||||||
|
testRelay: "Test R{{relay}}",
|
||||||
|
relayTesting: "Opening…",
|
||||||
|
testRelayTitle: "Pulse relay {{relay}} — opens the barrier",
|
||||||
|
confirmRelayTest: "Pulse relay {{relay}} now? This physically opens the barrier and is recorded in the ledger as a test.",
|
||||||
|
relayTestOk: "✓ R{{relay}} pulsed — barrier opened",
|
||||||
|
relayTestFailed: "✗ R{{relay}} failed: {{detail}}",
|
||||||
|
// Entry presence-gate bypass (faulty radar/camera) — admin drops a signal as a requirement.
|
||||||
|
presenceGateTitle: "Entry presence gate",
|
||||||
|
presenceGateHint:
|
||||||
|
"The entry button normally needs both a radar/loop and a camera detection to confirm a real vehicle. If a device is faulty, bypass it so transients can enter until support fixes it. Each change is signed to the ledger, and tickets issued while bypassed are flagged.",
|
||||||
|
presenceBypassRadar: "Bypass radar / loop (faulty presence sensor)",
|
||||||
|
presenceBypassCamera: "Bypass camera (faulty vehicle detection)",
|
||||||
|
presenceBypassActive: "Presence bypass active — the entry gate is weakened. Turn off once the device is repaired.",
|
||||||
|
// 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…",
|
||||||
@@ -390,21 +531,26 @@ export const en: Catalog = {
|
|||||||
lab: {
|
lab: {
|
||||||
title: "Tariff Lab",
|
title: "Tariff Lab",
|
||||||
intro:
|
intro:
|
||||||
"Test rates in time (day/night windows, daily caps, overstay) in seconds, with no waiting. Pricing uses the same logic as the booth; nothing is written to the ledger.",
|
"Compose experimental rate cards and price hypothetical stays against them — nothing goes live until you publish. Pricing uses the same logic as the booth; nothing is written to the ledger.",
|
||||||
loadTicket: "Load from a real ticket",
|
drafts: "Lab tariffs",
|
||||||
loadTicketPh: "Ticket number / identity",
|
newDraft: "New draft",
|
||||||
load: "Load",
|
activeTariff: "Active tariff",
|
||||||
loaded: "Loaded session {{id}}",
|
published: "Published versions",
|
||||||
tariffVersion: "Tariff version",
|
noDrafts: "No lab tariffs yet — create a draft to experiment.",
|
||||||
activeVersion: "Active version (current)",
|
edit: "Edit",
|
||||||
|
publish: "Publish",
|
||||||
|
delete: "Delete",
|
||||||
|
draftName: "Name",
|
||||||
|
draftNamePh: "e.g. Winter proposal",
|
||||||
|
saveDraft: "Save draft",
|
||||||
|
savingDraft: "Saving…",
|
||||||
|
newDraftTitle: "New lab tariff",
|
||||||
|
editDraftTitle: "Edit lab tariff",
|
||||||
|
confirmPublish: 'Publish "{{name}}" as the new live rate card? It takes effect immediately.',
|
||||||
|
confirmDelete: 'Delete lab tariff "{{name}}"?',
|
||||||
entered: "Entered",
|
entered: "Entered",
|
||||||
asOf: "As of (now/exit)",
|
exit: "Exit",
|
||||||
now: "Now",
|
now: "Now",
|
||||||
category: "Category",
|
|
||||||
categoryPh: "e.g. bus (blank = car)",
|
|
||||||
payment: "Payment",
|
|
||||||
paid: "paid",
|
|
||||||
graceMin: "grace (min)",
|
|
||||||
price: "Compute price",
|
price: "Compute price",
|
||||||
pricing: "Pricing…",
|
pricing: "Pricing…",
|
||||||
outcome: "Outcome",
|
outcome: "Outcome",
|
||||||
@@ -561,7 +707,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 +791,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 +827,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 +844,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 +920,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 +993,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",
|
||||||
|
|||||||
+249
-56
@@ -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,52 @@ 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: {
|
||||||
|
stateTitle: "Arka tani",
|
||||||
|
openShift: "Turn i hapur: {{operator}}",
|
||||||
|
noShiftOpen: "Asnjë turn i hapur — arka mban gjendjen e mbylljes së turnit të fundit.",
|
||||||
|
thisShift: "Ky turn:",
|
||||||
|
todayTitle: "Aktiviteti i arkës sot",
|
||||||
|
todayPayments: "{{count}} pagesa",
|
||||||
|
payment: "Pagesë",
|
||||||
|
noActivity: "Pa lëvizje arke sot.",
|
||||||
|
historyTitle: "Turne të mbyllura",
|
||||||
|
noShifts: "Ende pa turne të mbyllura.",
|
||||||
|
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 +164,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 +179,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 +192,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 +202,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 +231,9 @@ export const sq = {
|
|||||||
evtCashMovement: "ARKË",
|
evtCashMovement: "ARKË",
|
||||||
evtCashIn: "ARKËTIM",
|
evtCashIn: "ARKËTIM",
|
||||||
evtCashOut: "PAGESË",
|
evtCashOut: "PAGESË",
|
||||||
|
evtCashReview: "SHQYRTIM",
|
||||||
|
evtConfigChange: "KONFIG",
|
||||||
|
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 +275,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,21 +293,28 @@ 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",
|
||||||
|
"entry.duplicatePlate": "Hyrje e dyfishtë e mundshme — targa {{plate}} është tashmë brenda me biletën {{otherIdentity}}",
|
||||||
"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ë)",
|
||||||
"sub.refused.atCapacity": "Abonimi u refuzua — në kapacitet ({{inUse}}/{{max}} makina brenda)",
|
"sub.refused.atCapacity": "Abonimi u refuzua — në kapacitet ({{inUse}}/{{max}} makina brenda)",
|
||||||
"sub.refused.unpaidWindow": "Dalja u refuzua — detyrim jashtë orarit i papaguar ({{amount}} {{currency}}); paguaje në kabinë",
|
"sub.refused.unpaidWindow": "Dalja u refuzua — detyrim jashtë orarit i papaguar ({{amount}} {{currency}}); paguaje në kabinë",
|
||||||
|
"sub.refused.channelMismatch": "Kredenciali u refuzua — kredencial {{credentialKind}} i paraqitur në kanalin {{channel}} (kredencial i mundshëm i klonuar)",
|
||||||
"void.ticketCancelled": "Bileta u anulua — {{reason}}",
|
"void.ticketCancelled": "Bileta u anulua — {{reason}}",
|
||||||
|
"setup.relayTest": "Test releje — admini {{operator}} aktivizoi relenë {{relay}} te kontrolluesi {{controller}} nga Konfigurimi",
|
||||||
},
|
},
|
||||||
tariff: {
|
tariff: {
|
||||||
title: "Tarifa",
|
title: "Tarifa",
|
||||||
@@ -267,25 +332,33 @@ export const sq = {
|
|||||||
bandDuration: "Kohëzgjatja e brezit",
|
bandDuration: "Kohëzgjatja e brezit",
|
||||||
hoursUnit: "orë",
|
hoursUnit: "orë",
|
||||||
egHours: "p.sh. 2",
|
egHours: "p.sh. 2",
|
||||||
pricePerIncrement: "Çmimi / interval",
|
pricePerIncrement: "Çmimi / interval (orë)",
|
||||||
thereafter: "më pas (i hapur)",
|
thereafter: "më pas (i hapur)",
|
||||||
remove: "Hiq",
|
remove: "Hiq",
|
||||||
addBlock: "+ Shto bllok",
|
addBlock: "+ Shto bllok",
|
||||||
publishNewVersion: "Publiko version të ri",
|
publishNewVersion: "Publiko version të ri",
|
||||||
publishing: "Duke publikuar…",
|
publishing: "Duke publikuar…",
|
||||||
|
versionNamePh: "Emri i versionit (opsional), p.sh. Vera 2026",
|
||||||
|
versionsTitle: "Versione të publikuara",
|
||||||
|
versionsHint: "Kliko një për ta ngarkuar në editor. Publikimi krijon gjithmonë version të ri — versionet e kaluara nuk ndryshojnë kurrë.",
|
||||||
|
activeBadge: "aktive",
|
||||||
publishedOk: "U publikua versioni i ri i tarifës — tani është tarifa aktive.",
|
publishedOk: "U publikua versioni i ri i tarifës — tani është tarifa aktive.",
|
||||||
defaultCard: "Tarifa bazë (gjithmonë aktive)",
|
defaultCard: "Tarifa bazë (gjithmonë aktive)",
|
||||||
defaultCardHint: "Çmimi bazë i zbatuar kur asnjë nivel kohor/sezonal nuk vlen. Kjo e vetme është mjaftueshëm për shumicën e parkimeve.",
|
defaultCardHint: "Çmimi bazë i zbatuar kur asnjë nivel kohor/sezonal nuk vlen. Kjo e vetme është mjaftueshëm për shumicën e parkimeve.",
|
||||||
modeLadder: "Shkallë orësh",
|
modeLadder: "Shkallë orësh",
|
||||||
modeFlat: "Çmim fiks",
|
modeFlat: "Çmim fiks / orë",
|
||||||
modeStepped: "Sipas kohëzgjatjes (deri-në)",
|
modeStepped: "Sipas kohëzgjatjes (deri-në)",
|
||||||
|
modePackage: "Paketë dritareje (një total)",
|
||||||
|
packageHint:
|
||||||
|
"NJË çmim total për çdo prani brenda dritares së këtij niveli — largimi më herët kushton njësoj. Prekja e dritares në dy net të ndryshme e faturon paketën dy herë (një herë për natë). Orët jashtë dritares vlerësohen me tarifën bazë.",
|
||||||
|
packageTotal: "Çmimi i paketës",
|
||||||
steppedHint:
|
steppedHint:
|
||||||
"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.",
|
"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.",
|
||||||
stepUpTo: "Deri në",
|
stepUpTo: "Deri në",
|
||||||
stepTotal: "Çmimi total",
|
stepTotal: "Çmimi total",
|
||||||
addStep: "+ Shto rresht",
|
addStep: "+ Shto rresht",
|
||||||
steppedTiersConflict:
|
steppedTiersConflict:
|
||||||
"⚠ 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.",
|
"⚠ 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 / orë'. Publikimi bllokohet derisa kjo të rregullohet.",
|
||||||
tiersAdvanced: "Të avancuara: nivele kohore & sezonale",
|
tiersAdvanced: "Të avancuara: nivele kohore & sezonale",
|
||||||
tiersHint: "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ë.",
|
tiersHint: "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ë.",
|
||||||
tierName: "Emri",
|
tierName: "Emri",
|
||||||
@@ -308,21 +381,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 +417,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 +438,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,31 +495,74 @@ 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).",
|
||||||
|
// Provë e releut — hap fizikisht barrierën (hapja regjistrohet në ledger).
|
||||||
|
testRelay: "Provo R{{relay}}",
|
||||||
|
relayTesting: "Duke hapur…",
|
||||||
|
testRelayTitle: "Puls releu {{relay}} — hap barrierën",
|
||||||
|
confirmRelayTest: "Ky veprim hap fizikisht barrierën dhe regjistrohet në ledger si provë.",
|
||||||
|
relayTestOk: "✓ R{{relay}} u pulsua — barriera u hap",
|
||||||
|
relayTestFailed: "✗ R{{relay}} dështoi: {{detail}}",
|
||||||
|
// Anashkalimi i portës së pranisë (radar/kamera me defekt) — admini heq një sinjal si kusht.
|
||||||
|
presenceGateTitle: "Porta e pranisë në hyrje",
|
||||||
|
presenceGateHint:
|
||||||
|
"Butoni i hyrjes normalisht kërkon edhe radarin edhe një event nga kamera për të konfirmuar një automjet në hyrje. Nëse një pajisje ka defekt, anashkaloje që kalimtarët të mund të hyjnë derisa ta rregullohet/ndërrohet. Çdo ndryshim regjistrohet në ledger, dhe biletat e lëshuara gjatë anashkalimit shënohen.",
|
||||||
|
presenceBypassRadar: "Anashkalo radarin (radari me defekt)",
|
||||||
|
presenceBypassCamera: "Anashkalo kamerën (kamera me defekt)",
|
||||||
|
presenceBypassActive: "Anashkalimi i pranisë aktiv — siguria e hyrjes është dobësuar.",
|
||||||
|
// 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",
|
||||||
intro:
|
intro:
|
||||||
"Testo tarifat në kohë (dritare ditë/natë, kufi ditor, qëndrim tej afatit) në sekonda, pa pritur orë. Çmimi llogaritet me të njëjtën logjikë si kabina; nuk shkruhet asgjë në ledger.",
|
"Kompozo tarifa eksperimentale dhe llogarit qëndrime hipotetike kundrejt tyre — asgjë nuk hyn në fuqi pa u publikuar. Çmimi llogaritet me të njëjtën logjikë si kabina; nuk shkruhet asgjë në ledger.",
|
||||||
loadTicket: "Ngarko nga një biletë reale",
|
drafts: "Tarifa laboratori",
|
||||||
loadTicketPh: "Numri i biletës / identiteti",
|
newDraft: "Draft i ri",
|
||||||
load: "Ngarko",
|
activeTariff: "Tarifa aktive",
|
||||||
loaded: "U ngarkua sesioni {{id}}",
|
published: "Versione të publikuara",
|
||||||
tariffVersion: "Versioni i tarifës",
|
noDrafts: "Ende pa tarifa laboratori — krijo një draft për të eksperimentuar.",
|
||||||
activeVersion: "Versioni aktiv (i tanishëm)",
|
edit: "Ndrysho",
|
||||||
|
publish: "Publiko",
|
||||||
|
delete: "Fshi",
|
||||||
|
draftName: "Emri",
|
||||||
|
draftNamePh: "p.sh. Propozimi i dimrit",
|
||||||
|
saveDraft: "Ruaj draftin",
|
||||||
|
savingDraft: "Duke ruajtur…",
|
||||||
|
newDraftTitle: "Tarifë e re laboratori",
|
||||||
|
editDraftTitle: "Ndrysho tarifën e laboratorit",
|
||||||
|
confirmPublish: 'Të publikohet "{{name}}" si karta e re aktive e çmimeve? Hyn në fuqi menjëherë.',
|
||||||
|
confirmDelete: 'Të fshihet tarifa e laboratorit "{{name}}"?',
|
||||||
entered: "Hyrja",
|
entered: "Hyrja",
|
||||||
asOf: "Deri më (tani/dalja)",
|
exit: "Dalja",
|
||||||
now: "Tani",
|
now: "Tani",
|
||||||
category: "Kategoria",
|
|
||||||
categoryPh: "p.sh. bus (bosh = makinë)",
|
|
||||||
payment: "Pagesa",
|
|
||||||
paid: "u pagua",
|
|
||||||
graceMin: "afati (min)",
|
|
||||||
price: "Llogarit çmimin",
|
price: "Llogarit çmimin",
|
||||||
pricing: "Duke llogaritur…",
|
pricing: "Duke llogaritur…",
|
||||||
outcome: "Rezultati",
|
outcome: "Rezultati",
|
||||||
@@ -572,7 +719,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 +776,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 +804,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 +840,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 +859,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 +935,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 +1009,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 +1023,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);
|
||||||
|
|||||||
+161
-38
@@ -10,11 +10,24 @@ 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 { Spinner } from "./ui/Spinner.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 +42,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 +72,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 +86,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 +110,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>
|
||||||
@@ -101,9 +118,10 @@ function SetupLayout() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Subscriptions layout — a standalone top-level section (its own header nav entry),
|
/** Subscriptions layout — a standalone top-level section (its own header nav entry),
|
||||||
* with tabs for the subscriber catalog, the plan catalog, and the tariff lab. Each
|
* with tabs for the subscriber catalog and the plan catalog. Each tab is a gated
|
||||||
* tab is a gated child route; an operator with only subscription:read sees just the
|
* child route; an operator with only subscription:read sees just the first tab.
|
||||||
* first tab. */
|
* (The tariff lab moved to /setup/tariff/lab, 2026-07-05 — it tests the tariff, so
|
||||||
|
* it lives with the tariff.) */
|
||||||
function SubscriptionsLayout() {
|
function SubscriptionsLayout() {
|
||||||
const { user } = rootRoute.useRouteContext();
|
const { user } = rootRoute.useRouteContext();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -113,7 +131,21 @@ function SubscriptionsLayout() {
|
|||||||
<nav className="mb-4 flex flex-wrap items-center gap-1 border-b border-term-border">
|
<nav className="mb-4 flex flex-wrap items-center gap-1 border-b border-term-border">
|
||||||
{show("subscription:read") && <SetupTab to="/subscriptions" label={t("nav.subscriptions")} exact />}
|
{show("subscription:read") && <SetupTab to="/subscriptions" label={t("nav.subscriptions")} exact />}
|
||||||
{show("subscription:plan") && <SetupTab to="/subscriptions/plans" label={t("nav.plans")} />}
|
{show("subscription:plan") && <SetupTab to="/subscriptions/plans" label={t("nav.plans")} />}
|
||||||
{show("tariff:read") && <SetupTab to="/subscriptions/tariff-lab" label={t("nav.tariffLab")} />}
|
</nav>
|
||||||
|
<Outlet />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tariff layout — the rate-card hub under Setup: the composer (index) and the
|
||||||
|
* pricing LAB as sub-tabs. One tariff:read gate on the parent covers both. */
|
||||||
|
function TariffLayout() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<nav className="mb-2 flex flex-wrap items-center gap-1 border-b border-term-border">
|
||||||
|
<SetupTab to="/setup/tariff" label={t("nav.tariff")} exact />
|
||||||
|
<SetupTab to="/setup/tariff/lab" label={t("nav.tariffLab")} />
|
||||||
</nav>
|
</nav>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</div>
|
</div>
|
||||||
@@ -147,7 +179,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 +225,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 +242,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 +336,20 @@ 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 disabled:opacity-60 ${tone}`}
|
||||||
>
|
>
|
||||||
{busy ? t("shift.opening") : label}
|
{busy ? (
|
||||||
|
<span className="inline-flex items-center gap-1.5">
|
||||||
|
<Spinner /> {isMine ? t("shift.ending") : t("shift.opening")}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
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 +384,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 +396,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>
|
||||||
</>
|
</>
|
||||||
@@ -342,7 +416,13 @@ function CloseShiftConfirm({
|
|||||||
{t("subs.cancel")}
|
{t("subs.cancel")}
|
||||||
</button>
|
</button>
|
||||||
<button type="button" className="btn btn-sm btn-danger" onClick={onConfirm} disabled={busy || !x}>
|
<button type="button" className="btn btn-sm btn-danger" onClick={onConfirm} disabled={busy || !x}>
|
||||||
{busy ? t("shift.ending") : t("shift.endShift")}
|
{busy ? (
|
||||||
|
<span className="inline-flex items-center gap-1.5">
|
||||||
|
<Spinner /> {t("shift.ending")}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
t("shift.endShift")
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -353,10 +433,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 +460,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 +481,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>
|
||||||
@@ -457,7 +547,10 @@ const legacyRedirects = (
|
|||||||
["/shift", "/shifts"],
|
["/shift", "/shifts"],
|
||||||
["/setup/subscriptions", "/subscriptions"],
|
["/setup/subscriptions", "/subscriptions"],
|
||||||
["/setup/plans", "/subscriptions/plans"],
|
["/setup/plans", "/subscriptions/plans"],
|
||||||
["/setup/tariff-lab", "/subscriptions/tariff-lab"],
|
// The tariff lab bounced twice: /setup/tariff-lab → /subscriptions/tariff-lab
|
||||||
|
// (2026-06-21) → /setup/tariff/lab (2026-07-05, back with the tariff it tests).
|
||||||
|
["/setup/tariff-lab", "/setup/tariff/lab"],
|
||||||
|
["/subscriptions/tariff-lab", "/setup/tariff/lab"],
|
||||||
["/setup/shifts", "/shifts"],
|
["/setup/shifts", "/shifts"],
|
||||||
["/setup/reports", "/reports"],
|
["/setup/reports", "/reports"],
|
||||||
] as const
|
] as const
|
||||||
@@ -493,14 +586,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")}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -555,8 +664,20 @@ const tariffRoute = createRoute({
|
|||||||
getParentRoute: () => setupRoute,
|
getParentRoute: () => setupRoute,
|
||||||
path: "tariff",
|
path: "tariff",
|
||||||
beforeLoad: ({ context }) => requirePerm("tariff:read")(context),
|
beforeLoad: ({ context }) => requirePerm("tariff:read")(context),
|
||||||
|
component: TariffLayout,
|
||||||
|
});
|
||||||
|
const tariffComposerRoute = createRoute({
|
||||||
|
getParentRoute: () => tariffRoute,
|
||||||
|
path: "/",
|
||||||
component: () => <TariffComposer />,
|
component: () => <TariffComposer />,
|
||||||
});
|
});
|
||||||
|
// The tariff LAB — lives with the tariff it tests (moved from /subscriptions,
|
||||||
|
// 2026-07-05). The parent's tariff:read gate covers it.
|
||||||
|
const tariffLabRoute = createRoute({
|
||||||
|
getParentRoute: () => tariffRoute,
|
||||||
|
path: "lab",
|
||||||
|
component: () => <TariffLab />,
|
||||||
|
});
|
||||||
|
|
||||||
// --- /subscriptions — a standalone top-level section with its own tabs. The catalog
|
// --- /subscriptions — a standalone top-level section with its own tabs. The catalog
|
||||||
// (index), the plan catalog, and the tariff lab live here, not under /setup. ---
|
// (index), the plan catalog, and the tariff lab live here, not under /setup. ---
|
||||||
@@ -573,7 +694,6 @@ const subscriptionsIndexRoute = createRoute({
|
|||||||
beforeLoad: ({ context }) => {
|
beforeLoad: ({ context }) => {
|
||||||
if (can(context.user, "subscription:read")) return;
|
if (can(context.user, "subscription:read")) return;
|
||||||
if (can(context.user, "subscription:plan")) throw redirect({ to: "/subscriptions/plans" });
|
if (can(context.user, "subscription:plan")) throw redirect({ to: "/subscriptions/plans" });
|
||||||
if (can(context.user, "tariff:read")) throw redirect({ to: "/subscriptions/tariff-lab" });
|
|
||||||
throw redirect({ to: "/booth" });
|
throw redirect({ to: "/booth" });
|
||||||
},
|
},
|
||||||
component: function SubscriptionsRoute() {
|
component: function SubscriptionsRoute() {
|
||||||
@@ -587,12 +707,6 @@ const subscriptionPlansRoute = createRoute({
|
|||||||
beforeLoad: ({ context }) => requirePerm("subscription:plan")(context),
|
beforeLoad: ({ context }) => requirePerm("subscription:plan")(context),
|
||||||
component: () => <SubscriptionPlansManager />,
|
component: () => <SubscriptionPlansManager />,
|
||||||
});
|
});
|
||||||
const tariffLabRoute = createRoute({
|
|
||||||
getParentRoute: () => subscriptionsRoute,
|
|
||||||
path: "tariff-lab",
|
|
||||||
beforeLoad: ({ context }) => requirePerm("tariff:read")(context),
|
|
||||||
component: () => <TariffLab />,
|
|
||||||
});
|
|
||||||
const siteRoute = createRoute({
|
const siteRoute = createRoute({
|
||||||
getParentRoute: () => setupRoute,
|
getParentRoute: () => setupRoute,
|
||||||
path: "site",
|
path: "site",
|
||||||
@@ -642,6 +756,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,20 +782,21 @@ const routeTree = rootRoute.addChildren([
|
|||||||
...legacyRedirects,
|
...legacyRedirects,
|
||||||
profileRoute,
|
profileRoute,
|
||||||
shiftRoute,
|
shiftRoute,
|
||||||
|
drawerRoute,
|
||||||
reportsRoute,
|
reportsRoute,
|
||||||
subscriptionsRoute.addChildren([
|
subscriptionsRoute.addChildren([
|
||||||
subscriptionsIndexRoute,
|
subscriptionsIndexRoute,
|
||||||
subscriptionPlansRoute,
|
subscriptionPlansRoute,
|
||||||
tariffLabRoute,
|
|
||||||
]),
|
]),
|
||||||
setupRoute.addChildren([
|
setupRoute.addChildren([
|
||||||
setupDevicesRoute,
|
setupDevicesRoute,
|
||||||
tariffRoute,
|
tariffRoute.addChildren([tariffComposerRoute, tariffLabRoute]),
|
||||||
siteRoute,
|
siteRoute,
|
||||||
usersRoute,
|
usersRoute,
|
||||||
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>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user