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.
This commit is contained in:
@@ -4,6 +4,7 @@ import { Login } from "./Login.js";
|
||||
import { PermitManager } from "./PermitManager.js";
|
||||
import { SetupWizard } from "./SetupWizard.js";
|
||||
import { ShiftControl } from "./ShiftControl.js";
|
||||
import { SiteSettings } from "./SiteSettings.js";
|
||||
import { TariffComposer } from "./TariffComposer.js";
|
||||
|
||||
// Operator UI shell. Plain React (no admin framework) — the operator UI is
|
||||
@@ -41,6 +42,7 @@ export function App() {
|
||||
</button>
|
||||
</span>
|
||||
</header>
|
||||
<SiteSettings canEdit={user.role === "admin"} />
|
||||
{user.role !== "readonly" && <ShiftControl />}
|
||||
{user.role === "admin" ? (
|
||||
<>
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -304,3 +304,22 @@ export function openShift(): Promise<{ startedAt: string }> {
|
||||
export function closeShift(): Promise<ShiftReport> {
|
||||
return apiFetch("/api/shift/close", { method: "POST" });
|
||||
}
|
||||
|
||||
// --- Site config / occupancy ----------------------------------------------
|
||||
|
||||
export interface Occupancy {
|
||||
count: number;
|
||||
capacity: number | null;
|
||||
free: number | null;
|
||||
full: boolean;
|
||||
}
|
||||
|
||||
export function fetchOccupancy(): Promise<Occupancy> {
|
||||
return apiFetch("/api/occupancy");
|
||||
}
|
||||
export function fetchSiteConfig(): Promise<{ capacity: number | null }> {
|
||||
return apiFetch("/api/site-config");
|
||||
}
|
||||
export function setCapacity(capacity: number | null): Promise<{ capacity: number | null }> {
|
||||
return apiFetch("/api/site-config", { method: "PUT", body: JSON.stringify({ capacity }) });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user