Files
parking_solution/apps/web/src/SiteSettings.tsx
T
julian e579fe5b6e server+web: capacity / FULL gate (occupancy fold + transient refuse)
Occupancy is a fold over the signed ledger (entries minus exits per identity);
getOccupancy returns {count, capacity, free, full}. Capacity is a single-row
site_config table (admin-set; null = uncapped; migration 0001, additive).

FULL gate lives in the transient entry flow: when full, refuse (no ticket, no
vehicle_entry, no open) and sign an anomaly. Permit entry is NOT gated --
subscribers are admitted past transient-full (their own maxConcurrent still
applies), so occupancy can read over capacity by design (reserve-for-permits).

Routes: GET /api/occupancy + GET /api/site-config (any role), PUT
/api/site-config (admin; non-negative int or null). Web SiteSettings: live
occupancy + FULL badge (everyone), capacity editor (admin).

Verified: fill to cap -> 3rd transient refused; permit admitted past full; exit
frees a slot; RBAC (operator can't set, -5 -> 400); verifyChain ok. Physical
FULL-sign relay output deferred.
2026-06-16 08:13:06 +02:00

66 lines
2.3 KiB
TypeScript

import { useEffect, useState } from "react";
import { fetchOccupancy, fetchSiteConfig, setCapacity, type Occupancy } from "./api.js";
// Live occupancy + capacity. Occupancy is shown to everyone (it's a fold over the
// signed ledger); the capacity field is admin-editable. The FULL gate (refuse
// transient entry at capacity) is enforced server-side in the entry flow.
// See wiki/concepts/capacity-occupancy.md.
export function SiteSettings({ canEdit }: { canEdit: boolean }) {
const [occ, setOcc] = useState<Occupancy | null>(null);
const [capInput, setCapInput] = useState("");
const [msg, setMsg] = useState<string | null>(null);
function reload() {
fetchOccupancy().then(setOcc).catch(() => {});
}
useEffect(() => {
reload();
fetchSiteConfig()
.then((c) => setCapInput(c.capacity == null ? "" : String(c.capacity)))
.catch(() => {});
}, []);
async function save() {
setMsg(null);
const raw = capInput.trim();
const capacity = raw === "" ? null : Math.round(Number(raw));
try {
await setCapacity(capacity);
reload();
setMsg("Capacity saved.");
} catch (e) {
setMsg((e as Error).message);
}
}
return (
<section style={{ marginTop: "1.5rem", padding: "0.75rem 1rem", border: "1px solid #ddd", borderRadius: 6, maxWidth: 460 }}>
<strong>Occupancy:</strong>{" "}
{occ == null ? (
"…"
) : (
<>
<span style={{ fontWeight: 600 }}>{occ.count}</span>
{occ.capacity != null ? ` / ${occ.capacity}` : " (no capacity set)"}
{occ.capacity != null && (
<span style={{ color: "#666" }}> · {occ.free} free</span>
)}
{occ.full && <span style={{ color: "crimson", marginLeft: "0.5rem", fontWeight: 600 }}>FULL</span>}{" "}
<button type="button" onClick={reload} style={{ marginLeft: "0.5rem" }}>↻</button>
</>
)}
{canEdit && (
<div style={{ marginTop: "0.6rem" }}>
<label>
Capacity (blank = no limit):{" "}
<input value={capInput} onChange={(e) => setCapInput(e.target.value)} style={{ width: 80 }} placeholder="e.g. 120" />
</label>{" "}
<button type="button" onClick={save}>Save</button>
{msg && <span style={{ marginLeft: "0.5rem", color: "#555" }}>{msg}</span>}
</div>
)}
</section>
);
}