import { useState, useEffect, useCallback } from "react"; import { useTranslation } from "react-i18next"; import { assignDevice, editDevice, discoverDevices, fetchBackendIps, fetchCatalog, fetchState, testDevice, unassignDevice, 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 = { entry: "setup.dirEntry", exit: "setup.dirExit", both: "setup.dirBoth", }; export function SetupWizard() { const { t } = useTranslation(); const [catalog, setCatalog] = useState(null); const [assignments, setAssignments] = useState(null); const [error, setError] = useState(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

{t("setup.failedToLoad", { error })}

; if (!catalog || !assignments) return

{t("setup.loadingCatalog")}

; // Controllers are needed before binding readers/cameras (they pick a controller relay). const controllers = assignments.filter((a) => a.category === "access"); return (

{t("setup.title")}

{t("setup.intro")}

{BOUND.map(({ key, titleKey, nounKey }) => ( a.category === key)} onChanged={reloadState} /> ))}
); } 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; }) { 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(null); const [warnings, setWarnings] = useState([]); // 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 (
{title} {warnings.length > 0 && (
{t("setup.warnTitle")}
    {warnings.map((w, i) => (
  • {w}
  • ))}
)} {assignments.length > 0 && (
    {assignments.map((a) => ( setFormFor(a)} /> ))}
)} {blockedNoController ? (

{t("setup.needControllerFirst", { noun })}

) : ( )} {/* Add/edit form — popped out. One modal per category; the device list stays in the page behind it. */} setFormFor(null)} title={editing ? t("setup.editTitle", { noun }) : t("setup.addTitle", { noun })} width="max-w-2xl" > {formFor != null && ( { setWarnings(w); await onChanged(); setFormFor(null); }} onCancel={() => setFormFor(null)} /> )}
); } function AssignmentRow({ assignment, controllers, onChanged, onEdit, }: { assignment: Assignment; controllers: Assignment[]; onChanged: () => Promise | void; onEdit: () => void; }) { const { t } = useTranslation(); const [removing, setRemoving] = useState(false); const [error, setError] = useState(null); const cfg = assignment.config as Record; 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 (
  • {assignment.driverId} {host && {host}} {!assignment.enabled && {t("setup.disabled")}} {error && {error}}
  • ); } /** 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; if (assignment.category === "access") { const relays = Array.isArray(cfg.relays) ? (cfg.relays as RelaySpec[]) : []; if (relays.length === 0) return {t("setup.noRelaysSet")}; return ( {relays.map((r) => ( ))} ); } // 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 {t("setup.unbound")}; const controller = controllers.find((c) => c.id === controllerId); const spec = controller ? (((controller.config as Record).relays as RelaySpec[]) ?? []).find((r) => r.relay === relay) : undefined; return ( ); } 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; 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 | undefined; const [selectedId, setSelectedId] = useState(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"; // Pre-fill scalar config fields from the existing assignment when editing. // (relays/controllerId/relay are model fields handled by their own state below.) const [config, setConfig] = useState>(() => { if (!editCfg) return {}; const out: Record = {}; for (const [k, v] of Object.entries(editCfg)) { if (typeof v === "string" || typeof v === "number") out[k] = v; } return out; }); // Controllers: the relay map (which relay = entry/exit/both, + entry button terminal). const [relays, setRelays] = useState(() => 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( typeof editCfg?.controllerId === "string" ? editCfg.controllerId : "", ); const [boundRelay, setBoundRelay] = useState( typeof editCfg?.relay === "number" ? editCfg.relay : "", ); const [tested, setTested] = useState(null); const [testing, setTesting] = useState(false); const [testError, setTestError] = useState(null); const [saving, setSaving] = useState(false); const [saveError, setSaveError] = useState(null); const [found, setFound] = useState(null); const [scanning, setScanning] = useState(false); const [scanError, setScanError] = useState(null); const [backendIps, setBackendIps] = useState(null); const [backendIp, setBackendIp] = useState(""); 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) })); resetStatus(); } /** Scalar config the user entered, merged over driver defaults (for test/push-IP). */ function mergedScalarConfig(): Record { const out: Record = {}; for (const f of selected?.configFields ?? []) { 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 } : {}), })); } else if (controllerId && boundRelay !== "") { out.controllerId = controllerId; out.relay = boundRelay; } return out; } function resetStatus() { setTested(null); setTestError(null); setSaveError(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); } } 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 (
    {entries.length === 0 ? ( {t("setup.noDrivers")} ) : ( // Driver is locked when editing — changing the kind of device is a // remove + re-add, not an in-place edit. )} {selected && (

    {selected.description}

    {canDiscover && (
    {scanError && {scanError}} {found && found.length === 0 &&

    {t("setup.noControllersFound")}

    } {found && found.length > 0 && (
      {found.map((d) => (
    • {d.label} {d.info?.firmware && · fw {d.info.firmware}}
    • ))}
    )}
    )} {selected.configFields.map((f) => (
    {f.type === "select" ? ( ) : ( { const v = e.target.value; setConfig((c) => ({ ...c, [f.key]: v })); resetStatus(); }} /> )}
    ))} {/* CONTROLLER: the relay map — which relay opens which direction + entry button. */} {isController && } {/* BOUND device: which controller + relay it sits at. */} {!isController && ( { setControllerId(id); setBoundRelay(""); }} onRelayChange={setBoundRelay} /> )} {/* Test (no save/no device change) then Save (configures + persists). */}
    {onCancel && ( )}
    {testError &&

    {t("setup.testFailed", { error: testError })}

    } {tested && (
    {t("setup.deviceLabel")} {tested.health.detail && — {tested.health.detail}}
    {tested.preconditions.ok ? (
    {t("setup.preconditionsOk")}
    ) : ( tested.preconditions.issues.map((i) => (
    ⚠ {i.message} {i.fixable && {t("setup.autoFixedOnSave")}}
    )) )}
    )} {backendIps && backendIps.length > 0 && (
    {!backendIps.some((c) => c.onDeviceSubnet) && ( {t("setup.noNicOnSubnet")} )}

    {t("setup.backendIpHint")}

    )} {saveError &&

    {t("setup.saveFailed", { error: saveError })}

    }
    )}
    ); } /** 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) { 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 (
    {t("setup.relaysTitle")}

    {t("setup.relaysHint")}

    {relays.map((r, i) => (
    {(r.direction === "entry" || r.direction === "both") && ( )} {relays.length > 1 && ( )}
    ))}
    ); } /** 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).relays as RelaySpec[]) ?? []) : []; const chosen = relays.find((r) => r.relay === relay); return (
    {t("setup.whichBarrier")}
    {chosen && }
    {controller && relays.length === 0 && (

    {t("setup.noRelaysConfigured")}

    )}
    ); } 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 ( {label ?? direction} ); } function HealthBadge({ status }: { status: string }) { const cls = status === "ready" ? "text-term-green" : status === "degraded" ? "text-term-amber" : "text-term-red"; return ● {status}; }