e579fe5b6e
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.
66 lines
2.3 KiB
TypeScript
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>
|
|
);
|
|
}
|