65328b8c11
CI / check (push) Failing after 15s
Wire the lane camera's vehicle event into the gated subscription flow: on a vehicle/active push from an opt-in (config.anpr) camera, AnprBridge pulls a fresh snapshot, runs ANPR, applies a stricter entry confidence floor, debounces, and — matching the plate to a subscription BEFORE emitting — emits a kind:"plate" read. The existing ReadDispatcher -> SubscriptionFlow then signs the entry/exit and opens the barrier. A plate is never the sole authority: it routes through the same gate (active/window/blocklist/car-count) as any credential. Fail-soft, fire-and-forget, subscriber-only by construction. Field-verified end to end (plate AA504LX opened the entry barrier and appended a signed vehicle_entry). Add an admin master switch (site_config.anpr_entry_enabled, default ON) in Site Settings that disables ONLY the barrier-driving bridge; advisory snapshot-ANPR and lane busy/free are unaffected. Read live per event, so toggling takes effect with no restart. Migration 0013 (additive ALTER ADD COLUMN, default 1). - New: apps/server/src/anpr-entry.ts (AnprBridge) + tests (9) - hikvision-alarm.ts hands vehicle detections to the bridge (fire-and-forget) + wiring tests (3) - server.ts reorders the read flows above the hik-alarm registration - snapshot.ts exports buildCamera for reuse - env: VISION_ENTRY_MIN_CONFIDENCE (0.85), ANPR_DEBOUNCE_MS (12000) - site route + SiteSettings checkbox + i18n (sq/en parity) - wiki: lane-presence-and-anpr-entry / lpr-camera / index / log -> BUILT Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
163 lines
6.8 KiB
TypeScript
163 lines
6.8 KiB
TypeScript
import { useEffect, useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { fetchOccupancy, fetchSiteConfig, saveSiteConfig, type Occupancy, type SiteConfig } from "./api.js";
|
|
|
|
// Live occupancy + capacity + park metadata. Occupancy is shown to everyone (it's a
|
|
// fold over the signed ledger); capacity and the metadata fields are admin-editable.
|
|
// The FULL gate (refuse transient entry at capacity) is enforced server-side in the
|
|
// entry flow. Metadata (name, NIUS, address, contact) feeds the ticket header.
|
|
// See wiki/concepts/capacity-occupancy.md and wiki/concepts/site-metadata.md.
|
|
|
|
// The optional text fields, in display order. `labelKey`/`phKey` are i18n keys
|
|
// (resolved at render); only `address` is multiline.
|
|
const META_FIELDS: ReadonlyArray<{ key: keyof SiteConfig; labelKey: string; phKey?: string; multiline?: boolean }> = [
|
|
{ key: "parkName", labelKey: "site.fieldParkName", phKey: "site.fieldParkNamePh" },
|
|
{ key: "operatorName", labelKey: "site.fieldOperator", phKey: "site.fieldOperatorPh" },
|
|
{ key: "nius", labelKey: "site.fieldNius", phKey: "site.fieldNiusPh" },
|
|
{ key: "address", labelKey: "site.fieldAddress", multiline: true },
|
|
{ key: "phone", labelKey: "site.fieldPhone" },
|
|
{ key: "email", labelKey: "site.fieldEmail" },
|
|
];
|
|
|
|
export function SiteSettings({ canEdit }: { canEdit: boolean }) {
|
|
const { t } = useTranslation();
|
|
const [occ, setOcc] = useState<Occupancy | null>(null);
|
|
const [capInput, setCapInput] = useState("");
|
|
const [meta, setMeta] = useState<Record<string, string>>({});
|
|
const [exitVoucherDefault, setExitVoucherDefault] = useState(false);
|
|
const [reserveSubs, setReserveSubs] = useState(false);
|
|
const [anprEntry, setAnprEntry] = useState(true);
|
|
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));
|
|
setExitVoucherDefault(c.exitVoucherDefault);
|
|
setReserveSubs(c.reserveSubscriberSpots);
|
|
setAnprEntry(c.anprEntryEnabled);
|
|
const m: Record<string, string> = {};
|
|
for (const { key } of META_FIELDS) m[key] = c[key] == null ? "" : String(c[key]);
|
|
setMeta(m);
|
|
})
|
|
.catch(() => {});
|
|
}, []);
|
|
|
|
async function save() {
|
|
setMsg(null);
|
|
const raw = capInput.trim();
|
|
const patch: Partial<SiteConfig> = {
|
|
capacity: raw === "" ? null : Math.round(Number(raw)),
|
|
exitVoucherDefault,
|
|
reserveSubscriberSpots: reserveSubs,
|
|
anprEntryEnabled: anprEntry,
|
|
};
|
|
// Send each metadata field; "" → null is applied server-side.
|
|
for (const { key } of META_FIELDS) (patch as Record<string, string | null>)[key] = meta[key] ?? "";
|
|
try {
|
|
await saveSiteConfig(patch);
|
|
reload();
|
|
setMsg(t("site.saved"));
|
|
} catch (e) {
|
|
setMsg((e as Error).message);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<section className="card mt-6 max-w-md p-4">
|
|
<div className="flex flex-wrap items-center gap-1.5 text-[13px]">
|
|
<strong className="uppercase tracking-wider text-term-muted">{t("site.occupancy")}</strong>
|
|
{occ == null ? (
|
|
<span className="text-term-muted">…</span>
|
|
) : (
|
|
<>
|
|
<span className="text-h5 font-semibold tabular-nums text-term-text">{occ.count}</span>
|
|
<span className="tabular-nums text-term-muted">
|
|
{occ.capacity != null ? `/ ${occ.capacity}` : t("site.noCapacitySet")}
|
|
</span>
|
|
{occ.capacity != null && (
|
|
<span className="tabular-nums text-term-muted">· {occ.free} {t("site.free")}</span>
|
|
)}
|
|
{occ.full && <span className="font-semibold text-term-red">{t("site.full")}</span>}
|
|
<button type="button" className="btn btn-ghost btn-sm" onClick={reload}>↻</button>
|
|
</>
|
|
)}
|
|
</div>
|
|
{canEdit && (
|
|
<div className="mt-4 grid gap-3">
|
|
<div className="field">
|
|
<span className="label">{t("site.capacityLabel")}</span>
|
|
<input className="input w-32" value={capInput} onChange={(e) => setCapInput(e.target.value)} placeholder={t("site.capacityPlaceholder")} />
|
|
</div>
|
|
<label className="flex items-center gap-2 text-[12px] text-term-text">
|
|
<input
|
|
type="checkbox"
|
|
className="accent-term-amber"
|
|
checked={exitVoucherDefault}
|
|
onChange={(e) => setExitVoucherDefault(e.target.checked)}
|
|
/>
|
|
{t("site.printExitDefault")}
|
|
<span className="hint">{t("site.printExitHint")}</span>
|
|
</label>
|
|
<label className="flex items-start gap-2 text-[12px] text-term-text">
|
|
<input
|
|
type="checkbox"
|
|
className="mt-0.5 accent-term-amber"
|
|
checked={reserveSubs}
|
|
onChange={(e) => setReserveSubs(e.target.checked)}
|
|
/>
|
|
<span>
|
|
{t("site.reserveSubs")}
|
|
<span className="hint block">{t("site.reserveSubsHint")}</span>
|
|
</span>
|
|
</label>
|
|
<label className="flex items-start gap-2 text-[12px] text-term-text">
|
|
<input
|
|
type="checkbox"
|
|
className="mt-0.5 accent-term-amber"
|
|
checked={anprEntry}
|
|
onChange={(e) => setAnprEntry(e.target.checked)}
|
|
/>
|
|
<span>
|
|
{t("site.anprEntry")}
|
|
<span className="hint block">{t("site.anprEntryHint")}</span>
|
|
</span>
|
|
</label>
|
|
<div className="border-t border-term-border pt-3 text-[11px] uppercase tracking-wider text-term-muted">
|
|
{t("site.parkDetails")}
|
|
</div>
|
|
{META_FIELDS.map(({ key, labelKey, phKey, multiline }) => (
|
|
<div key={key} className="field">
|
|
<span className="label">{t(labelKey)}</span>
|
|
{multiline ? (
|
|
<textarea
|
|
className="textarea"
|
|
value={meta[key] ?? ""}
|
|
onChange={(e) => setMeta((m) => ({ ...m, [key]: e.target.value }))}
|
|
rows={2}
|
|
placeholder={phKey ? t(phKey) : undefined}
|
|
/>
|
|
) : (
|
|
<input
|
|
className="input"
|
|
value={meta[key] ?? ""}
|
|
onChange={(e) => setMeta((m) => ({ ...m, [key]: e.target.value }))}
|
|
placeholder={phKey ? t(phKey) : undefined}
|
|
/>
|
|
)}
|
|
</div>
|
|
))}
|
|
<div className="flex items-center gap-3">
|
|
<button type="button" className="btn btn-primary btn-sm" onClick={save}>{t("site.save")}</button>
|
|
{msg && <span className="text-[12px] text-term-muted">{msg}</span>}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</section>
|
|
);
|
|
}
|