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.
59 lines
2.0 KiB
TypeScript
59 lines
2.0 KiB
TypeScript
import { useEffect, useState } from "react";
|
|
import { fetchMe, logout, type SessionUser } from "./api.js";
|
|
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
|
|
// simple enough that a framework's abstractions cost more than they save.
|
|
// Auth is cookie-based; the SPA bootstraps the session from /api/auth/me.
|
|
// See wiki/entities/react-vite-spa.md and local-jwt-auth.md.
|
|
|
|
export function App() {
|
|
const [user, setUser] = useState<SessionUser | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
useEffect(() => {
|
|
fetchMe()
|
|
.then(setUser)
|
|
.finally(() => setLoading(false));
|
|
}, []);
|
|
|
|
if (loading) return <p style={{ fontFamily: "system-ui", padding: "2rem" }}>Loading…</p>;
|
|
if (!user) return <Login onLoggedIn={setUser} />;
|
|
|
|
return (
|
|
<main style={{ fontFamily: "system-ui", padding: "2rem", maxWidth: 720 }}>
|
|
<header style={{ display: "flex", justifyContent: "space-between", alignItems: "baseline" }}>
|
|
<h1 style={{ margin: 0 }}>Parking System</h1>
|
|
<span style={{ color: "#555" }}>
|
|
{user.username} ({user.role}){" "}
|
|
<button
|
|
type="button"
|
|
onClick={async () => {
|
|
await logout();
|
|
setUser(null);
|
|
}}
|
|
>
|
|
Log out
|
|
</button>
|
|
</span>
|
|
</header>
|
|
<SiteSettings canEdit={user.role === "admin"} />
|
|
{user.role !== "readonly" && <ShiftControl />}
|
|
{user.role === "admin" ? (
|
|
<>
|
|
<SetupWizard />
|
|
<TariffComposer />
|
|
<PermitManager />
|
|
</>
|
|
) : (
|
|
<p style={{ marginTop: "1rem" }}>Signed in. (Operator console coming soon.)</p>
|
|
)}
|
|
</main>
|
|
);
|
|
}
|