permits: admin CRUD (route + UI)
A permit is an aggregate (row + credentials + bound plates); create/update treat it as one unit (child sets replaced on update). GET /api/permits (any signed-in role, for lookup); POST/PUT/DELETE + POST /:id/revoke (admin only). Validation: maxConcurrent positive-int-or-null (unbound); a permit must have at least one credential OR one bound plate. Revoke is the soft common case (keeps history, barred at the barrier); DELETE hard-removes — past ledger events that reference it are untouched (append-only audit trail, independent of this row). Web PermitManager in the admin shell: list + add/edit (holder, car-bound toggle, validity, credentials, plates), revoke, delete. Makes permits usable without hand-seeding (companion to the tariff composer). Verified via inject: validation (empty / maxConcurrent=0 -> 400), create -> 201, operator can LIST but not write (403), update replaces child rows, revoke -> revoked, delete -> 204 then 404 with children cleaned.
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
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 { TariffComposer } from "./TariffComposer.js";
|
||||
|
||||
@@ -43,6 +44,7 @@ export function App() {
|
||||
<>
|
||||
<SetupWizard />
|
||||
<TariffComposer />
|
||||
<PermitManager />
|
||||
</>
|
||||
) : (
|
||||
<p style={{ marginTop: "1rem" }}>Signed in. (Operator console coming soon.)</p>
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
ApiError,
|
||||
createPermit,
|
||||
deletePermit,
|
||||
fetchPermits,
|
||||
revokePermit,
|
||||
updatePermit,
|
||||
type Permit,
|
||||
type PermitCredential,
|
||||
type PermitInput,
|
||||
} from "./api.js";
|
||||
|
||||
// Permit (subscription) admin. Create/edit/revoke/delete permits + their
|
||||
// credentials (card/QR) and bound plates. A permit is mutable master data; every
|
||||
// USE of it is a signed ledger event elsewhere. See wiki/entities/permit.md.
|
||||
|
||||
interface FormState {
|
||||
holderName: string;
|
||||
contact: string;
|
||||
carBound: boolean; // false = unbound (maxConcurrent null)
|
||||
maxConcurrent: string;
|
||||
validFrom: string;
|
||||
validTo: string;
|
||||
credentials: PermitCredential[];
|
||||
platesText: string; // comma/space separated
|
||||
}
|
||||
|
||||
function emptyForm(): FormState {
|
||||
return { holderName: "", contact: "", carBound: true, maxConcurrent: "1", validFrom: "", validTo: "", credentials: [{ kind: "rf", value: "" }], platesText: "" };
|
||||
}
|
||||
function formFrom(p: Permit): FormState {
|
||||
return {
|
||||
holderName: p.holderName ?? "",
|
||||
contact: p.contact ?? "",
|
||||
carBound: p.maxConcurrent != null,
|
||||
maxConcurrent: p.maxConcurrent != null ? String(p.maxConcurrent) : "1",
|
||||
validFrom: p.validFrom ?? "",
|
||||
validTo: p.validTo ?? "",
|
||||
credentials: p.credentials.length ? p.credentials : [{ kind: "rf", value: "" }],
|
||||
platesText: p.plates.join(", "),
|
||||
};
|
||||
}
|
||||
function toInput(f: FormState): PermitInput {
|
||||
return {
|
||||
holderName: f.holderName.trim() || null,
|
||||
contact: f.contact.trim() || null,
|
||||
maxConcurrent: f.carBound ? Math.max(1, Math.round(Number(f.maxConcurrent) || 1)) : null,
|
||||
validFrom: f.validFrom.trim() || null,
|
||||
validTo: f.validTo.trim() || null,
|
||||
credentials: f.credentials.filter((c) => c.value.trim()).map((c) => ({ kind: c.kind, value: c.value.trim() })),
|
||||
plates: f.platesText.split(/[,\s]+/).map((s) => s.trim()).filter(Boolean),
|
||||
};
|
||||
}
|
||||
|
||||
export function PermitManager() {
|
||||
const [permits, setPermits] = useState<Permit[] | null>(null);
|
||||
const [editing, setEditing] = useState<string | "new" | null>(null);
|
||||
const [form, setForm] = useState<FormState>(emptyForm);
|
||||
const [msg, setMsg] = useState<{ kind: "ok" | "err"; text: string } | null>(null);
|
||||
|
||||
function reload() {
|
||||
fetchPermits()
|
||||
.then((r) => setPermits(r.permits))
|
||||
.catch((e) => setMsg({ kind: "err", text: (e as Error).message }));
|
||||
}
|
||||
useEffect(reload, []);
|
||||
|
||||
function startNew() {
|
||||
setForm(emptyForm());
|
||||
setEditing("new");
|
||||
setMsg(null);
|
||||
}
|
||||
function startEdit(p: Permit) {
|
||||
setForm(formFrom(p));
|
||||
setEditing(p.id);
|
||||
setMsg(null);
|
||||
}
|
||||
|
||||
async function save() {
|
||||
setMsg(null);
|
||||
try {
|
||||
if (editing === "new") await createPermit(toInput(form));
|
||||
else if (editing) await updatePermit(editing, toInput(form));
|
||||
setEditing(null);
|
||||
reload();
|
||||
setMsg({ kind: "ok", text: "Permit saved." });
|
||||
} catch (e) {
|
||||
const problems = e instanceof ApiError ? (e as ApiError & { problems?: string[] }).problems : undefined;
|
||||
setMsg({ kind: "err", text: problems?.length ? `${(e as Error).message}: ${problems.join("; ")}` : (e as Error).message });
|
||||
}
|
||||
}
|
||||
async function doRevoke(p: Permit) {
|
||||
if (!confirm(`Revoke permit for ${p.holderName ?? p.id}? It will be refused at the barrier.`)) return;
|
||||
await revokePermit(p.id).catch((e) => setMsg({ kind: "err", text: (e as Error).message }));
|
||||
reload();
|
||||
}
|
||||
async function doDelete(p: Permit) {
|
||||
if (!confirm(`Delete permit for ${p.holderName ?? p.id}? (Past events are kept.)`)) return;
|
||||
await deletePermit(p.id).catch((e) => setMsg({ kind: "err", text: (e as Error).message }));
|
||||
reload();
|
||||
}
|
||||
|
||||
function setCred(i: number, patch: Partial<PermitCredential>) {
|
||||
setForm((f) => ({ ...f, credentials: f.credentials.map((c, j) => (j === i ? { ...c, ...patch } : c)) }));
|
||||
}
|
||||
|
||||
if (!permits) return null;
|
||||
|
||||
return (
|
||||
<section style={{ marginTop: "2rem" }}>
|
||||
<h2>Permits</h2>
|
||||
<ul style={{ listStyle: "none", padding: 0 }}>
|
||||
{permits.map((p) => (
|
||||
<li key={p.id} style={{ display: "flex", gap: "0.5rem", alignItems: "center", padding: "0.4rem 0", borderBottom: "1px solid #eee" }}>
|
||||
<strong>{p.holderName ?? "(unnamed)"}</strong>
|
||||
<span style={{ color: p.status === "active" ? "#16a34a" : "#b45309" }}>{p.status}</span>
|
||||
<span style={{ color: "#666" }}>
|
||||
{p.maxConcurrent == null ? "unbound" : `${p.maxConcurrent} car${p.maxConcurrent > 1 ? "s" : ""}`} ·{" "}
|
||||
{p.credentials.length} cred · {p.plates.length} plate(s)
|
||||
</span>
|
||||
<span style={{ flex: 1 }} />
|
||||
<button type="button" onClick={() => startEdit(p)}>Edit</button>
|
||||
{p.status !== "revoked" && <button type="button" onClick={() => doRevoke(p)}>Revoke</button>}
|
||||
<button type="button" onClick={() => doDelete(p)}>Delete</button>
|
||||
</li>
|
||||
))}
|
||||
{permits.length === 0 && <li style={{ color: "#777" }}>No permits yet.</li>}
|
||||
</ul>
|
||||
|
||||
{editing == null ? (
|
||||
<button type="button" onClick={startNew}>+ Add permit</button>
|
||||
) : (
|
||||
<div style={{ border: "1px solid #ddd", padding: "1rem", marginTop: "0.5rem", maxWidth: 460 }}>
|
||||
<h3 style={{ marginTop: 0 }}>{editing === "new" ? "New permit" : "Edit permit"}</h3>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "max-content 1fr", gap: "0.4rem 0.75rem", alignItems: "center" }}>
|
||||
<label>Holder name</label>
|
||||
<input value={form.holderName} onChange={(e) => setForm((f) => ({ ...f, holderName: e.target.value }))} />
|
||||
<label>Contact</label>
|
||||
<input value={form.contact} onChange={(e) => setForm((f) => ({ ...f, contact: e.target.value }))} />
|
||||
<label>Car limit</label>
|
||||
<span>
|
||||
<label style={{ marginRight: "0.5rem" }}>
|
||||
<input type="checkbox" checked={form.carBound} onChange={(e) => setForm((f) => ({ ...f, carBound: e.target.checked }))} /> limit cars in at once
|
||||
</label>
|
||||
{form.carBound && (
|
||||
<input value={form.maxConcurrent} onChange={(e) => setForm((f) => ({ ...f, maxConcurrent: e.target.value }))} style={{ width: 50 }} />
|
||||
)}
|
||||
</span>
|
||||
<label>Valid from</label>
|
||||
<input value={form.validFrom} onChange={(e) => setForm((f) => ({ ...f, validFrom: e.target.value }))} placeholder="ISO date (optional)" />
|
||||
<label>Valid to</label>
|
||||
<input value={form.validTo} onChange={(e) => setForm((f) => ({ ...f, validTo: e.target.value }))} placeholder="ISO date (optional)" />
|
||||
<label>Bound plates</label>
|
||||
<input value={form.platesText} onChange={(e) => setForm((f) => ({ ...f, platesText: e.target.value }))} placeholder="comma-separated (optional)" />
|
||||
</div>
|
||||
|
||||
<h4 style={{ marginBottom: "0.25rem" }}>Credentials (card / QR)</h4>
|
||||
{form.credentials.map((c, i) => (
|
||||
<div key={i} style={{ display: "flex", gap: "0.4rem", marginBottom: "0.3rem" }}>
|
||||
<select value={c.kind} onChange={(e) => setCred(i, { kind: e.target.value as "rf" | "qr" })}>
|
||||
<option value="rf">RF card/tag</option>
|
||||
<option value="qr">QR</option>
|
||||
</select>
|
||||
<input value={c.value} onChange={(e) => setCred(i, { value: e.target.value })} placeholder="credential value" style={{ flex: 1 }} />
|
||||
<button type="button" onClick={() => setForm((f) => ({ ...f, credentials: f.credentials.filter((_, j) => j !== i) }))}>×</button>
|
||||
</div>
|
||||
))}
|
||||
<button type="button" onClick={() => setForm((f) => ({ ...f, credentials: [...f.credentials, { kind: "rf", value: "" }] }))}>+ credential</button>
|
||||
<p style={{ color: "#777", fontSize: "0.85em", margin: "0.5rem 0 0" }}>
|
||||
A permit needs at least one credential OR one bound plate.
|
||||
</p>
|
||||
|
||||
<div style={{ marginTop: "1rem", display: "flex", gap: "0.5rem" }}>
|
||||
<button type="button" onClick={save}>Save</button>
|
||||
<button type="button" onClick={() => setEditing(null)}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{msg && <p style={{ color: msg.kind === "ok" ? "#16a34a" : "crimson" }}>{msg.text}</p>}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -240,3 +240,40 @@ export function publishTariffVersion(body: {
|
||||
}): Promise<TariffVersion> {
|
||||
return apiFetch("/api/tariff/versions", { method: "POST", body: JSON.stringify(body) });
|
||||
}
|
||||
|
||||
// --- Permits --------------------------------------------------------------
|
||||
|
||||
export interface PermitCredential {
|
||||
kind: "rf" | "qr";
|
||||
value: string;
|
||||
}
|
||||
export interface Permit {
|
||||
id: string;
|
||||
holderName: string | null;
|
||||
contact: string | null;
|
||||
maxConcurrent: number | null;
|
||||
validFrom: string | null;
|
||||
validTo: string | null;
|
||||
status: "active" | "suspended" | "revoked";
|
||||
credentials: PermitCredential[];
|
||||
plates: string[];
|
||||
}
|
||||
export type PermitInput = Omit<Permit, "id" | "status"> & {
|
||||
status?: Permit["status"];
|
||||
};
|
||||
|
||||
export function fetchPermits(): Promise<{ permits: Permit[] }> {
|
||||
return apiFetch("/api/permits");
|
||||
}
|
||||
export function createPermit(body: PermitInput): Promise<Permit> {
|
||||
return apiFetch("/api/permits", { method: "POST", body: JSON.stringify(body) });
|
||||
}
|
||||
export function updatePermit(id: string, body: PermitInput): Promise<Permit> {
|
||||
return apiFetch(`/api/permits/${id}`, { method: "PUT", body: JSON.stringify(body) });
|
||||
}
|
||||
export function revokePermit(id: string): Promise<Permit> {
|
||||
return apiFetch(`/api/permits/${id}/revoke`, { method: "POST" });
|
||||
}
|
||||
export function deletePermit(id: string): Promise<void> {
|
||||
return apiFetch(`/api/permits/${id}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user