Files
parking_solution/apps/web/src/SetupWizard.tsx
T
julian 461275521d fix(setup): render boolean config fields as a checkbox (not a text box)
The generic config-field loop had no boolean branch, so a type:"boolean"
field (e.g. the camera's alarmPushEnabled) fell through to a TEXT input and
saved the STRING "true" instead of a real boolean. Downstream checks use
=== true, so the feature read as disabled even when the admin ticked it.

- Web: render type:"boolean" config fields as a real checkbox; store/merge
  a true/false boolean (and persist false on edit so toggling off sticks);
  normalize a legacy string "true"/"false" on load.
- Server: isOn() coerces the flag when reading config (accepts true/"true"/
  1/"yes"/"on") so an existing row saved as the string "true" still works
  without a re-save, and no other boolean field hits the same trap.

Tests: hik-alarm accepts string "true" for alarmPushEnabled. server
112/112; web typecheck + build green.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-22 10:39:10 +02:00

947 lines
36 KiB
TypeScript

import { useState, useEffect, useCallback } from "react";
import { useTranslation } from "react-i18next";
import {
assignDevice,
editDevice,
discoverDevices,
fetchBackendIps,
fetchCatalog,
fetchState,
testAnpr,
testDevice,
unassignDevice,
type AnprTestResult,
type Assignment,
type BackendIpCandidate,
type Catalog,
type CatalogEntry,
type DeviceCategory,
type DeviceConfig,
type Direction,
type DiscoveredDevice,
type RelaySpec,
type TestResult,
} from "./api.js";
import { Modal } from "./ui/Modal.js";
// First-run setup wizard. The pool-of-spaces model: a parking lot is one pool with
// a flexible set of entry/exit points — NO lane. The admin adds CONTROLLERS (each
// declares its relays = entry/exit/both + which input terminal the entry button is
// on), then binds READERS / CAMERAS to a controller relay (the barrier they sit at).
// Direction is a property of the relay, inherited by bound devices. The data model
// is multi-instance — one `devices` row per instance. See entry-exit-points.md.
// Categories carry i18n KEYS (resolved at render via t()), not literal copy.
// `titleKey` is the section heading; `nounKey` resolves to the singular noun used in
// the add/edit buttons, modal titles and confirm prompts.
const CONTROLLER: { key: DeviceCategory; titleKey: string; nounKey: string } = {
key: "access",
titleKey: "setup.catControllers",
nounKey: "setup.nounController",
};
// Categories that BIND to a controller relay (direction inherited from the relay).
const BOUND: { key: DeviceCategory; titleKey: string; nounKey: string }[] = [
{ key: "reader", titleKey: "setup.catReaders", nounKey: "setup.nounReader" },
{ key: "camera", titleKey: "setup.catCameras", nounKey: "setup.nounCamera" },
{ key: "printer", titleKey: "setup.catPrinters", nounKey: "setup.nounPrinter" },
];
// Translated direction label (relay direction / inherited binding).
const DIRECTION_KEYS: Record<Direction, string> = {
entry: "setup.dirEntry",
exit: "setup.dirExit",
both: "setup.dirBoth",
};
export function SetupWizard() {
const { t } = useTranslation();
const [catalog, setCatalog] = useState<Catalog | null>(null);
const [assignments, setAssignments] = useState<Assignment[] | null>(null);
const [error, setError] = useState<string | null>(null);
const reloadState = useCallback(() => {
return fetchState()
.then((s) => setAssignments(s.assignments))
.catch((e: Error) => setError(e.message));
}, []);
useEffect(() => {
fetchCatalog().then(setCatalog).catch((e: Error) => setError(e.message));
reloadState();
}, [reloadState]);
if (error) return <p className="px-4 py-6 text-term-red">{t("setup.failedToLoad", { error })}</p>;
if (!catalog || !assignments) return <p className="px-4 py-6 text-term-muted">{t("setup.loadingCatalog")}</p>;
// Controllers are needed before binding readers/cameras (they pick a controller relay).
const controllers = assignments.filter((a) => a.category === "access");
return (
<section className="px-4 py-6">
<h2 className="mb-1 text-h4 font-semibold text-term-text">{t("setup.title")}</h2>
<p className="hint mb-4 max-w-prose">{t("setup.intro")}</p>
<CategorySection
category={CONTROLLER.key}
title={t(CONTROLLER.titleKey)}
noun={t(CONTROLLER.nounKey)}
entries={catalog[CONTROLLER.key]}
discoverableIds={catalog.discoverable}
pushCapableIds={catalog.pushCapable}
controllers={controllers}
assignments={controllers}
onChanged={reloadState}
/>
{BOUND.map(({ key, titleKey, nounKey }) => (
<CategorySection
key={key}
category={key}
title={t(titleKey)}
noun={t(nounKey)}
entries={catalog[key]}
discoverableIds={catalog.discoverable}
pushCapableIds={catalog.pushCapable}
controllers={controllers}
assignments={assignments.filter((a) => a.category === key)}
onChanged={reloadState}
/>
))}
</section>
);
}
function CategorySection({
category,
title,
noun,
entries,
discoverableIds,
pushCapableIds,
controllers,
assignments,
onChanged,
}: {
category: DeviceCategory;
title: string;
noun: string;
entries: CatalogEntry[];
discoverableIds: string[];
pushCapableIds: string[];
controllers: Assignment[];
assignments: Assignment[];
onChanged: () => Promise<void> | void;
}) {
const { t } = useTranslation();
// The form is popped out in a Modal. `formFor` selects what it edits:
// - "new" → the add form
// - an Assignment → edit that device in place
// - null → closed.
const [formFor, setFormFor] = useState<Assignment | "new" | null>(null);
const [warnings, setWarnings] = useState<string[]>([]);
// Binding categories need a controller to point at first.
const isBound = category !== "access";
const blockedNoController = isBound && controllers.length === 0;
const editing = formFor && formFor !== "new" ? formFor : undefined;
return (
<fieldset className="card mt-4 p-4">
<legend className="px-1 text-h6 font-semibold uppercase tracking-wider text-term-text">{title}</legend>
{warnings.length > 0 && (
<div className="mb-3 rounded-term border border-term-amber/60 bg-term-amber/10 px-3 py-2">
<strong className="text-[12px] text-term-amber">{t("setup.warnTitle")}</strong>
<ul className="mt-1 list-disc pl-5 text-[12px] text-term-amber">
{warnings.map((w, i) => (
<li key={i}>{w}</li>
))}
</ul>
<button type="button" className="btn btn-sm mt-2" onClick={() => setWarnings([])}>
{t("setup.dismiss")}
</button>
</div>
)}
{assignments.length > 0 && (
<ul className="mb-3 list-none p-0">
{assignments.map((a) => (
<AssignmentRow
key={a.id}
assignment={a}
controllers={controllers}
onChanged={onChanged}
onEdit={() => setFormFor(a)}
/>
))}
</ul>
)}
{blockedNoController ? (
<p className="m-0 text-[12px] text-term-amber">{t("setup.needControllerFirst", { noun })}</p>
) : (
<button type="button" className="btn btn-sm" onClick={() => setFormFor("new")}>
{assignments.length === 0 ? t("setup.add", { noun }) : t("setup.addAnother", { noun })}
</button>
)}
{/* Add/edit form — popped out. One modal per category; the device list stays
in the page behind it. */}
<Modal
open={formFor != null}
onClose={() => setFormFor(null)}
title={editing ? t("setup.editTitle", { noun }) : t("setup.addTitle", { noun })}
width="max-w-2xl"
>
{formFor != null && (
<DeviceForm
category={category}
entries={entries}
discoverableIds={discoverableIds}
pushCapableIds={pushCapableIds}
controllers={controllers}
editing={editing}
onSaved={async (w) => {
setWarnings(w);
await onChanged();
setFormFor(null);
}}
onCancel={() => setFormFor(null)}
/>
)}
</Modal>
</fieldset>
);
}
function AssignmentRow({
assignment,
controllers,
onChanged,
onEdit,
}: {
assignment: Assignment;
controllers: Assignment[];
onChanged: () => Promise<void> | void;
onEdit: () => void;
}) {
const { t } = useTranslation();
const [removing, setRemoving] = useState(false);
const [error, setError] = useState<string | null>(null);
const cfg = assignment.config as Record<string, unknown>;
const host = typeof cfg.host === "string" ? cfg.host : null;
async function remove() {
if (!confirm(t("setup.confirmRemove", { driver: assignment.driverId }))) return;
setRemoving(true);
setError(null);
try {
await unassignDevice(assignment.id);
await onChanged();
} catch (e) {
setError((e as Error).message);
setRemoving(false);
}
}
return (
<li className="flex items-center gap-2 border-b border-term-border/60 px-1 py-2 text-[12px]">
<strong className="text-term-text">{assignment.driverId}</strong>
{host && <span className="tabular-nums text-term-muted">{host}</span>}
<DeviceSummary assignment={assignment} controllers={controllers} />
{!assignment.enabled && <span className="text-term-amber">{t("setup.disabled")}</span>}
<span className="flex-1" />
{error && <span className="text-term-red">{error}</span>}
<button type="button" className="btn btn-ghost btn-sm" onClick={onEdit} disabled={removing}>
{t("setup.edit")}
</button>
<button type="button" className="btn btn-danger btn-sm" onClick={remove} disabled={removing}>
{removing ? t("setup.removing") : t("setup.remove")}
</button>
</li>
);
}
/** Inline summary of an assignment's direction/binding for the list. */
function DeviceSummary({ assignment, controllers }: { assignment: Assignment; controllers: Assignment[] }) {
const { t } = useTranslation();
const cfg = assignment.config as Record<string, unknown>;
if (assignment.category === "access") {
const relays = Array.isArray(cfg.relays) ? (cfg.relays as RelaySpec[]) : [];
if (relays.length === 0) return <em className="text-term-amber">{t("setup.noRelaysSet")}</em>;
return (
<span className="flex gap-1.5">
{relays.map((r) => (
<DirectionBadge key={r.relay} direction={r.direction} label={`R${r.relay}${r.button ? `·btn${r.button}` : ""}`} />
))}
</span>
);
}
// Bound device: show controller + relay it points at, with inherited direction.
const controllerId = typeof cfg.controllerId === "string" ? cfg.controllerId : null;
const relay = typeof cfg.relay === "number" ? cfg.relay : null;
if (!controllerId || relay == null) return <em className="text-term-amber">{t("setup.unbound")}</em>;
const controller = controllers.find((c) => c.id === controllerId);
const spec = controller
? (((controller.config as Record<string, unknown>).relays as RelaySpec[]) ?? []).find((r) => r.relay === relay)
: undefined;
return (
<DirectionBadge
direction={spec?.direction ?? "both"}
label={`${controller ? controller.driverId : "?"} · R${relay}`}
/>
);
}
function DeviceForm({
category,
entries,
discoverableIds,
pushCapableIds,
controllers,
editing,
onSaved,
onCancel,
}: {
category: DeviceCategory;
entries: CatalogEntry[];
discoverableIds: string[];
pushCapableIds: string[];
controllers: Assignment[];
/** When set, the form edits this assignment in place (driver locked, config
* pre-filled) instead of adding a new device. */
editing?: Assignment;
onSaved: (warnings: string[]) => Promise<void> | void;
onCancel?: () => void;
}) {
const { t } = useTranslation();
// On edit the driver is fixed (you can't change what KIND of device a slot is —
// that's a remove + re-add); pre-select it and lock the picker.
const editCfg = editing?.config as Record<string, unknown> | undefined;
const [selectedId, setSelectedId] = useState<string>(editing?.driverId ?? "");
const selected = entries.find((e) => e.id === selectedId);
const canDiscover = !editing && selected != null && discoverableIds.includes(selected.id);
const pushesToBackend = selected != null && pushCapableIds.includes(selected.id);
const isController = category === "access";
const isCamera = category === "camera";
// ANPR opt-in for a camera: when true, the VisionReader polls this camera for plates
// (config.anpr). Off by default. See wiki/entities/opencv-anpr-service.md.
const [anpr, setAnpr] = useState<boolean>(editCfg?.anpr === true);
// Pre-fill scalar config fields from the existing assignment when editing.
// (relays/controllerId/relay are model fields handled by their own state below.)
// Booleans are kept as real booleans (a checkbox field) — older saved configs may
// have stored a boolean as the string "true"/"false"; normalize those on load.
const [config, setConfig] = useState<Record<string, string | number | boolean>>(() => {
if (!editCfg) return {};
const out: Record<string, string | number | boolean> = {};
for (const [k, v] of Object.entries(editCfg)) {
if (typeof v === "string" || typeof v === "number" || typeof v === "boolean") out[k] = v;
}
return out;
});
// Controllers: the relay map (which relay = entry/exit/both, + entry button terminal).
const [relays, setRelays] = useState<RelaySpec[]>(() =>
Array.isArray(editCfg?.relays) ? (editCfg!.relays as RelaySpec[]) : [{ relay: 1, direction: "both" }],
);
// Bound devices: which controller + relay this device sits at.
const [controllerId, setControllerId] = useState<string>(
typeof editCfg?.controllerId === "string" ? editCfg.controllerId : "",
);
const [boundRelay, setBoundRelay] = useState<number | "">(
typeof editCfg?.relay === "number" ? editCfg.relay : "",
);
const [tested, setTested] = useState<TestResult | null>(null);
const [testing, setTesting] = useState(false);
const [testError, setTestError] = useState<string | null>(null);
// ANPR probe (camera + anpr on): snapshot → vision analyze, reported below.
const [anprResult, setAnprResult] = useState<AnprTestResult | null>(null);
const [anprTesting, setAnprTesting] = useState(false);
const [anprError, setAnprError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const [found, setFound] = useState<DiscoveredDevice[] | null>(null);
const [scanning, setScanning] = useState(false);
const [scanError, setScanError] = useState<string | null>(null);
const [backendIps, setBackendIps] = useState<BackendIpCandidate[] | null>(null);
const [backendIp, setBackendIp] = useState<string>("");
const testedHost = tested ? String(mergedScalarConfig().host ?? "") : "";
useEffect(() => {
if (!testedHost || !pushesToBackend) {
setBackendIps(null);
return;
}
let live = true;
fetchBackendIps(testedHost)
.then(({ candidates }) => {
if (!live) return;
setBackendIps(candidates);
setBackendIp((cur) => cur || candidates.find((c) => c.onDeviceSubnet)?.ip || "");
})
.catch(() => {
if (live) setBackendIps(null);
});
return () => {
live = false;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [testedHost, pushesToBackend]);
function selectDriver(id: string) {
setSelectedId(id);
setConfig({});
setFound(null);
resetStatus();
}
async function scan() {
if (!selected) return;
setScanning(true);
setScanError(null);
try {
setFound(await discoverDevices(selected.id));
} catch (e) {
setScanError((e as Error).message);
} finally {
setScanning(false);
}
}
function applyDiscovered(d: DiscoveredDevice) {
setConfig((c) => ({ ...c, ...(d.config as Record<string, string | number>) }));
resetStatus();
}
/** Scalar config the user entered, merged over driver defaults (for test/push-IP). */
function mergedScalarConfig(): Record<string, string | number | boolean> {
const out: Record<string, string | number | boolean> = {};
for (const f of selected?.configFields ?? []) {
// Boolean (checkbox) fields persist a REAL boolean — always (so toggling one OFF
// on an edit actually writes false), defaulting to the field default or false.
if (f.type === "boolean") {
const cur = config[f.key];
out[f.key] = typeof cur === "boolean" ? cur : Boolean(cur ?? f.default ?? false);
continue;
}
const v = config[f.key] ?? (f.default as string | number | undefined);
if (v !== undefined && v !== "") out[f.key] = v;
}
return out;
}
/** Full config to persist: scalars + the model's direction/binding fields. */
function mergedConfig(): DeviceConfig {
const out: DeviceConfig = { ...mergedScalarConfig() };
if (isController) {
out.relays = relays.map((r) => ({
relay: r.relay,
direction: r.direction,
...(r.button ? { button: r.button } : {}),
...(r.presenceInput ? { presenceInput: r.presenceInput } : {}),
...(r.entryCooldownSec ? { entryCooldownSec: r.entryCooldownSec } : {}),
}));
} else if (controllerId && boundRelay !== "") {
out.controllerId = controllerId;
out.relay = boundRelay;
}
// Camera ANPR opt-in (only persisted when on, to keep configs minimal).
if (isCamera && anpr) out.anpr = true;
return out;
}
function resetStatus() {
setTested(null);
setTestError(null);
setSaveError(null);
setAnprResult(null);
setAnprError(null);
}
async function test() {
if (!selected) return;
setTesting(true);
setTestError(null);
setTested(null);
try {
setTested(await testDevice(selected.id, mergedScalarConfig()));
} catch (e) {
setTestError((e as Error).message);
} finally {
setTesting(false);
}
}
// End-to-end ANPR probe: capture a frame off this camera and run the vision service
// on it, reporting plate + time (or the failure stage). Only meaningful for an
// ANPR-enabled camera; never blocks save.
async function testAnprNow() {
if (!selected) return;
setAnprTesting(true);
setAnprError(null);
setAnprResult(null);
try {
setAnprResult(await testAnpr(selected.id, mergedScalarConfig()));
} catch (e) {
setAnprError((e as Error).message);
} finally {
setAnprTesting(false);
}
}
async function save() {
if (!selected) return;
// Bound devices must point at a controller relay (binding is optional in the
// model with a fallback, but the wizard guides the admin to bind explicitly).
if (!isController && (!controllerId || boundRelay === "")) {
setSaveError("Pick the controller and relay this device sits at.");
return;
}
setSaving(true);
setSaveError(null);
try {
const result = editing
? await editDevice(editing.id, {
config: mergedConfig(),
...(backendIp ? { backendIp } : {}),
})
: await assignDevice({
category,
driverId: selected.id,
config: mergedConfig(),
...(backendIp ? { backendIp } : {}),
});
await onSaved(result.warnings ?? []);
} catch (e) {
setSaveError((e as Error).message);
} finally {
setSaving(false);
}
}
return (
<div>
{entries.length === 0 ? (
<em className="text-term-muted">{t("setup.noDrivers")}</em>
) : (
// Driver is locked when editing — changing the kind of device is a
// remove + re-add, not an in-place edit.
<select className="select w-auto min-w-64" value={selectedId} onChange={(e) => selectDriver(e.target.value)} disabled={!!editing}>
<option value="" disabled>
{t("setup.chooseDevice")}
</option>
{entries.map((e) => (
<option key={e.id} value={e.id}>
{e.label} ({e.transports.join(", ")})
</option>
))}
</select>
)}
{selected && (
<div className="mt-3">
<p className="mb-2 text-[12px] text-term-muted">{selected.description}</p>
{canDiscover && (
<div className="my-2 rounded-term border border-term-border bg-term-bg p-2">
<button type="button" className="btn btn-sm" onClick={scan} disabled={scanning}>
{scanning ? t("setup.scanning") : t("setup.scan")}
</button>
{scanError && <span className="ml-2 text-[12px] text-term-red">{scanError}</span>}
{found && found.length === 0 && <p className="mt-2 text-[12px] text-term-muted">{t("setup.noControllersFound")}</p>}
{found && found.length > 0 && (
<ul className="mt-2 list-none p-0">
{found.map((d) => (
<li key={d.id} className="my-1 flex items-center gap-2 text-[12px]">
<button type="button" className="btn btn-sm" onClick={() => applyDiscovered(d)}>
{t("setup.use")}
</button>
<strong className="text-term-text">{d.label}</strong>
<HealthBadge status={d.health.status} />
{d.info?.firmware && <span className="text-term-muted"> · fw {d.info.firmware}</span>}
</li>
))}
</ul>
)}
</div>
)}
{selected.configFields.map((f) =>
f.type === "boolean" ? (
// Boolean config field → a real checkbox (stores a true/false boolean, not
// the string "true"). The label sits beside the box, with the help below.
<label key={f.key} className="my-2 flex max-w-sm items-start gap-2 rounded-term border border-term-border bg-term-bg p-2 text-[12px]">
<input
type="checkbox"
className="mt-0.5"
checked={Boolean(config[f.key] ?? f.default ?? false)}
onChange={(e) => {
const v = e.target.checked;
setConfig((c) => ({ ...c, [f.key]: v }));
resetStatus();
}}
/>
<span>
<span className="font-semibold text-term-text">{f.label}</span>
{f.help && <span className="hint mt-0.5 block">{f.help}</span>}
</span>
</label>
) : (
<div key={f.key} className="field my-2 max-w-sm">
<label className="label">
{f.label}
{f.required ? " *" : ""}
</label>
{f.type === "select" ? (
<select
className="select"
value={String(config[f.key] ?? (f.default as string | number | undefined) ?? "")}
onChange={(e) => {
const v = e.target.value;
setConfig((c) => ({ ...c, [f.key]: v }));
resetStatus();
}}
>
{f.options?.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
) : (
<input
className="input"
type={f.type === "secret" ? "password" : f.type === "number" || f.type === "port" ? "number" : "text"}
value={(config[f.key] ?? (f.default as string | number | undefined) ?? "") as string | number}
placeholder={f.help}
onChange={(e) => {
const v = e.target.value;
setConfig((c) => ({ ...c, [f.key]: v }));
resetStatus();
}}
/>
)}
</div>
),
)}
{/* CONTROLLER: the relay map — which relay opens which direction + entry button. */}
{isController && <RelayEditor relays={relays} onChange={setRelays} />}
{/* BOUND device: which controller + relay it sits at. */}
{!isController && (
<BindingPicker
controllers={controllers}
controllerId={controllerId}
relay={boundRelay}
onControllerChange={(id) => {
setControllerId(id);
setBoundRelay("");
}}
onRelayChange={setBoundRelay}
/>
)}
{/* CAMERA: opt this camera into ANPR (the VisionReader polls it for plates). */}
{isCamera && (
<label className="my-2 flex items-start gap-2 rounded-term border border-term-border bg-term-bg p-2 text-[12px]">
<input
type="checkbox"
className="mt-0.5"
checked={anpr}
onChange={(e) => setAnpr(e.target.checked)}
/>
<span>
<span className="font-semibold text-term-text">{t("setup.anpr")}</span>
<span className="hint mt-0.5 block">{t("setup.anprHint")}</span>
</span>
</label>
)}
{/* Test (no save/no device change) then Save (configures + persists). */}
<div className="mt-3 flex items-center gap-2">
<button type="button" className="btn btn-sm" onClick={test} disabled={testing}>
{testing ? t("setup.testing") : t("setup.test")}
</button>
<button type="button" className="btn btn-primary btn-sm" onClick={save} disabled={saving}>
{saving ? t("setup.saving") : editing ? t("setup.saveChanges") : t("setup.saveConfigure")}
</button>
{onCancel && (
<button type="button" className="btn btn-ghost btn-sm" onClick={onCancel} disabled={saving}>
{t("setup.cancel")}
</button>
)}
</div>
{testError && <p className="mt-2 text-[12px] text-term-red">{t("setup.testFailed", { error: testError })}</p>}
{tested && (
<div className="mt-2 text-[12px]">
<div className="text-term-text">
{t("setup.deviceLabel")} <HealthBadge status={tested.health.status} />
{tested.health.detail && <span className="text-term-muted"> — {tested.health.detail}</span>}
</div>
{tested.preconditions.ok ? (
<div className="text-term-green">{t("setup.preconditionsOk")}</div>
) : (
tested.preconditions.issues.map((i) => (
<div key={i.key} className="text-term-amber">
⚠ {i.message}
{i.fixable && <span className="text-term-muted"> {t("setup.autoFixedOnSave")}</span>}
</div>
))
)}
</div>
)}
{/* CAMERA + ANPR on: a bottom-of-modal end-to-end probe — capture a frame and
run the vision service on it, reporting the plate read + how long it took. */}
{isCamera && anpr && (
<div className="mt-3 rounded-term border border-term-border bg-term-bg p-2">
<button type="button" className="btn btn-sm" onClick={testAnprNow} disabled={anprTesting}>
{anprTesting ? t("setup.anprTesting") : t("setup.testAnpr")}
</button>
<p className="hint mt-1">{t("setup.testAnprHint")}</p>
{anprError && <p className="mt-2 text-[12px] text-term-red">{t("setup.testFailed", { error: anprError })}</p>}
{anprResult &&
(anprResult.ok ? (
<div className="mt-2 text-[12px] text-term-green">
{t("setup.anprOk", {
plate: anprResult.plate,
confidence: Math.round(anprResult.confidence * 100),
ms: anprResult.tookMs,
})}
{anprResult.lowConfidence && (
<span className="ml-1 text-term-amber">{t("setup.anprLowConfidence")}</span>
)}
</div>
) : (
<div className="mt-2 text-[12px] text-term-amber">
⚠ {t(`setup.anprFail.${anprResult.reason}`, { defaultValue: anprResult.reason })}
{anprResult.detail && <span className="text-term-muted"> — {anprResult.detail}</span>}
{anprResult.tookMs != null && (
<span className="text-term-muted"> ({t("setup.anprTookMs", { ms: anprResult.tookMs })})</span>
)}
</div>
))}
</div>
)}
{backendIps && backendIps.length > 0 && (
<div className="mt-3">
<div className="field max-w-md">
<label className="label">{t("setup.backendPushIp")}</label>
<select className="select" value={backendIp} onChange={(e) => setBackendIp(e.target.value)}>
{!backendIps.some((c) => c.onDeviceSubnet) && (
<option value="" disabled>
{t("setup.chooseAddress")}
</option>
)}
{backendIps.map((c) => (
<option key={c.ip} value={c.ip}>
{c.ip} ({c.iface}){c.onDeviceSubnet ? ` ${t("setup.onDeviceSubnet")}` : ""}
</option>
))}
</select>
</div>
{!backendIps.some((c) => c.onDeviceSubnet) && (
<span className="text-[12px] text-term-amber">{t("setup.noNicOnSubnet")}</span>
)}
<p className="hint mt-1">{t("setup.backendIpHint")}</p>
</div>
)}
{saveError && <p className="mt-2 text-[12px] text-term-red">{t("setup.saveFailed", { error: saveError })}</p>}
</div>
)}
</div>
);
}
/** Controller relay map editor: each row = a relay + its direction + (optional)
* the input terminal its entry button is wired to. */
function RelayEditor({ relays, onChange }: { relays: RelaySpec[]; onChange: (r: RelaySpec[]) => void }) {
const { t } = useTranslation();
function update(i: number, patch: Partial<RelaySpec>) {
onChange(relays.map((r, idx) => (idx === i ? { ...r, ...patch } : r)));
}
function add() {
const nextRelay = (relays.reduce((m, r) => Math.max(m, r.relay), 0) || 0) + 1;
onChange([...relays, { relay: nextRelay, direction: "both" }]);
}
function remove(i: number) {
onChange(relays.filter((_, idx) => idx !== i));
}
return (
<div className="my-2 rounded-term border border-term-border bg-term-bg p-2">
<strong className="text-[12px] uppercase tracking-wider text-term-text">{t("setup.relaysTitle")}</strong>
<p className="hint mt-0.5 mb-2">{t("setup.relaysHint")}</p>
{relays.map((r, i) => (
<div key={i} className="my-1 flex flex-wrap items-center gap-2">
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted">
{t("setup.relay")}
<input
type="number"
min={1}
value={r.relay}
className="input input-sm w-16"
onChange={(e) => update(i, { relay: Number(e.target.value) })}
/>
</label>
<select className="select input-sm w-auto" value={r.direction} onChange={(e) => update(i, { direction: e.target.value as Direction })}>
{(["entry", "exit", "both"] as Direction[]).map((d) => (
<option key={d} value={d}>
{t(DIRECTION_KEYS[d])}
</option>
))}
</select>
{(r.direction === "entry" || r.direction === "both") && (
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted">
{t("setup.entryButtonTerminal")}
<input
type="number"
min={1}
value={r.button ?? ""}
placeholder="—"
className="input input-sm w-16"
onChange={(e) => update(i, { button: e.target.value === "" ? undefined : Number(e.target.value) })}
/>
</label>
)}
{(r.direction === "entry" || r.direction === "both") && (
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted" title={t("setup.presenceInputHint")}>
{t("setup.presenceInput")}
<input
type="number"
min={1}
value={r.presenceInput ?? ""}
placeholder="—"
className="input input-sm w-16"
onChange={(e) =>
update(i, { presenceInput: e.target.value === "" ? undefined : Number(e.target.value) })
}
/>
</label>
)}
{(r.direction === "entry" || r.direction === "both") && !r.presenceInput && (
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted" title={t("setup.entryCooldownHint")}>
{t("setup.entryCooldown")}
<input
type="number"
min={0}
value={r.entryCooldownSec ?? ""}
placeholder="—"
className="input input-sm w-16"
onChange={(e) =>
update(i, { entryCooldownSec: e.target.value === "" ? undefined : Number(e.target.value) })
}
/>
</label>
)}
{relays.length > 1 && (
<button type="button" className="btn btn-ghost btn-sm" onClick={() => remove(i)}>
✕
</button>
)}
</div>
))}
<button type="button" className="btn btn-sm mt-1" onClick={add}>
{t("setup.addRelay")}
</button>
</div>
);
}
/** Binding picker for readers/cameras/printers: choose the controller + relay this
* device sits at. Direction is inherited from the chosen relay (shown). */
function BindingPicker({
controllers,
controllerId,
relay,
onControllerChange,
onRelayChange,
}: {
controllers: Assignment[];
controllerId: string;
relay: number | "";
onControllerChange: (id: string) => void;
onRelayChange: (relay: number) => void;
}) {
const { t } = useTranslation();
const controller = controllers.find((c) => c.id === controllerId);
const relays: RelaySpec[] = controller
? (((controller.config as Record<string, unknown>).relays as RelaySpec[]) ?? [])
: [];
const chosen = relays.find((r) => r.relay === relay);
return (
<div className="my-2 rounded-term border border-term-border bg-term-bg p-2">
<strong className="text-[12px] uppercase tracking-wider text-term-text">{t("setup.whichBarrier")}</strong>
<div className="mt-1.5 flex flex-wrap items-center gap-2">
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted">
{t("setup.controller")}
<select className="select input-sm w-auto" value={controllerId} onChange={(e) => onControllerChange(e.target.value)}>
<option value="" disabled>
{t("setup.choose")}
</option>
{controllers.map((c) => {
const host = (c.config as Record<string, unknown>).host;
return (
<option key={c.id} value={c.id}>
{c.driverId}
{typeof host === "string" ? ` (${host})` : ""}
</option>
);
})}
</select>
</label>
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted">
{t("setup.relay")}
<select
className="select input-sm w-auto"
value={relay === "" ? "" : String(relay)}
disabled={!controller}
onChange={(e) => onRelayChange(Number(e.target.value))}
>
<option value="" disabled>
{t("setup.choose")}
</option>
{relays.map((r) => (
<option key={r.relay} value={r.relay}>
{t("setup.relayLabel", { relay: r.relay, direction: t(DIRECTION_KEYS[r.direction]) })}
</option>
))}
</select>
</label>
{chosen && <DirectionBadge direction={chosen.direction} label={t("setup.inherits", { direction: t(DIRECTION_KEYS[chosen.direction]) })} />}
</div>
{controller && relays.length === 0 && (
<p className="mt-1.5 text-[12px] text-term-amber">{t("setup.noRelaysConfigured")}</p>
)}
</div>
);
}
function DirectionBadge({ direction, label }: { direction: Direction; label?: string }) {
// entry=green, exit=amber, both=muted — aligned to the terminal accent palette.
const cls =
direction === "entry"
? "border-term-green text-term-green"
: direction === "exit"
? "border-term-amber text-term-amber"
: "border-term-muted text-term-muted";
return (
<span className={`rounded-term border px-1.5 text-[10px] font-semibold uppercase tracking-wider ${cls}`}>
{label ?? direction}
</span>
);
}
function HealthBadge({ status }: { status: string }) {
const cls = status === "ready" ? "text-term-green" : status === "degraded" ? "text-term-amber" : "text-term-red";
return <span className={`font-semibold ${cls}`}>● {status}</span>;
}