diff --git a/apps/web/src/RolesManager.tsx b/apps/web/src/RolesManager.tsx index 12b76e6..63fda3f 100644 --- a/apps/web/src/RolesManager.tsx +++ b/apps/web/src/RolesManager.tsx @@ -12,6 +12,7 @@ import { type Permission, type SessionUser, } from "./api.js"; +import { Modal } from "./ui/Modal.js"; // Role management (admin). Compose a role from the permission grid (a checkbox // matrix of resource × action) and name it; users are then assigned a role. The @@ -56,8 +57,7 @@ export function RolesManager({ user }: { user: SessionUser | null }) {

{t("roles.title")}

{canCreate && ( - )} @@ -65,21 +65,28 @@ export function RolesManager({ user }: { user: SessionUser | null }) { {error &&
{error}
} - {editing && ( - setEditing(null)} - onSubmit={async (v) => { - try { - if (editing === "new") await createRole(v); - else await updateRole(editing.id, v); - setEditing(null); - invalidate(); - } catch (e) { onError(e); } - }} - /> - )} + setEditing(null)} + title={editing && editing !== "new" ? t("roles.editTitle") : t("roles.new")} + width="max-w-2xl" + > + {editing && ( + setEditing(null)} + onSubmit={async (v) => { + try { + if (editing === "new") await createRole(v); + else await updateRole(editing.id, v); + setEditing(null); + invalidate(); + } catch (e) { onError(e); } + }} + /> + )} +
{roles.map((r) => ( @@ -98,13 +105,11 @@ export function RolesManager({ user }: { user: SessionUser | null }) {
{canUpdate && !r.builtin && ( - + )} {canDelete && !r.builtin && ( - + )}
@@ -140,17 +145,13 @@ function RoleEditor({ const valid = name.trim().length > 0; return ( -
-
- {role ? t("roles.editTitle") : t("roles.new")} +
+
+ {t("roles.name")} + setName(e.target.value)} />
- -
{t("roles.permissions")}
+
{t("roles.permissions")}
{Object.entries(grouped).map(([resource, list]) => (
@@ -159,7 +160,7 @@ function RoleEditor({ const action = p.split(":")[1]!; return ( ); @@ -169,10 +170,8 @@ function RoleEditor({
- - + +
); diff --git a/apps/web/src/SetupWizard.tsx b/apps/web/src/SetupWizard.tsx index b4d8513..310eb48 100644 --- a/apps/web/src/SetupWizard.tsx +++ b/apps/web/src/SetupWizard.tsx @@ -1,4 +1,5 @@ import { useState, useEffect, useCallback } from "react"; +import { useTranslation } from "react-i18next"; import { assignDevice, editDevice, @@ -19,6 +20,7 @@ import { 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 @@ -27,25 +29,30 @@ import { // 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. -const CONTROLLER: { key: DeviceCategory; title: string; noun: string } = { +// 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", - title: "Controllers (barriers + entry button)", - noun: "controller", + titleKey: "setup.catControllers", + nounKey: "setup.nounController", }; // Categories that BIND to a controller relay (direction inherited from the relay). -const BOUND: { key: DeviceCategory; title: string; noun: string }[] = [ - { key: "reader", title: "Readers (QR / RFID)", noun: "reader" }, - { key: "camera", title: "Cameras (snapshot + plate)", noun: "camera" }, - { key: "printer", title: "Printers (tickets / vouchers)", noun: "printer" }, +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" }, ]; -const DIRECTION_LABELS: Record = { - entry: "Entry", - exit: "Exit", - both: "Both (entry + exit)", +// 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); @@ -61,25 +68,21 @@ export function SetupWizard() { reloadState(); }, [reloadState]); - if (error) return

Failed to load setup: {error}

; - if (!catalog || !assignments) return

Loading device catalog…

; + 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 ( -
-

First-run setup

-

- Add your barrier controllers first — set which relay is entry/exit and which - terminal the entry button is wired to. Then add readers, cameras and printers - and point each at the barrier it serves. -

+
+

{t("setup.title")}

+

{t("setup.intro")}

- {BOUND.map(({ key, title, noun }) => ( + {BOUND.map(({ key, titleKey, nounKey }) => ( Promise | void; }) { - const [adding, setAdding] = useState(false); - const [editingId, setEditingId] = useState(null); + 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([]); - const editing = editingId ? assignments.find((a) => a.id === editingId) : undefined; - // Show the add form for an empty category or an explicit "+ Add", but not while - // editing an existing row (that row renders its own inline form). - const showForm = !editing && (adding || assignments.length === 0); // 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} +
+ {title} {warnings.length > 0 && ( -
- ⚠ Saved, but action needed: -
    +
    + {t("setup.warnTitle")} +
      {warnings.map((w, i) => (
    • {w}
    • ))}
    -
    )} {assignments.length > 0 && ( -
      - {assignments.map((a) => - editingId === a.id ? ( -
    • - { - setWarnings(w); - await onChanged(); - setEditingId(null); - }} - onCancel={() => setEditingId(null)} - /> -
    • - ) : ( - { - setAdding(false); - setEditingId(a.id); - }} - /> - ), - )} +
        + {assignments.map((a) => ( + setFormFor(a)} + /> + ))}
      )} {blockedNoController ? ( -

      Add a controller first — a {noun} points at one of its relays.

      - ) : editing ? null : showForm ? ( - { - setWarnings(w); - await onChanged(); - setAdding(false); - }} - onCancel={assignments.length > 0 ? () => setAdding(false) : undefined} - /> +

      {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)} + /> + )} +
); } @@ -237,6 +223,7 @@ function AssignmentRow({ onChanged: () => Promise | void; onEdit: () => void; }) { + const { t } = useTranslation(); const [removing, setRemoving] = useState(false); const [error, setError] = useState(null); @@ -244,7 +231,7 @@ function AssignmentRow({ const host = typeof cfg.host === "string" ? cfg.host : null; async function remove() { - if (!confirm(`Remove this ${assignment.driverId} device?`)) return; + if (!confirm(t("setup.confirmRemove", { driver: assignment.driverId }))) return; setRemoving(true); setError(null); try { @@ -257,26 +244,18 @@ function AssignmentRow({ } return ( -
  • - {assignment.driverId} - {host && {host}} +
  • + {assignment.driverId} + {host && {host}} - {!assignment.enabled && (disabled)} - - {error && {error}} - -
  • ); @@ -284,12 +263,13 @@ function AssignmentRow({ /** 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 no relays set; + if (relays.length === 0) return {t("setup.noRelaysSet")}; return ( - + {relays.map((r) => ( ))} @@ -299,7 +279,7 @@ function DeviceSummary({ assignment, controllers }: { assignment: Assignment; co // 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 unbound; + 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) @@ -333,6 +313,7 @@ function DeviceForm({ 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; @@ -500,15 +481,15 @@ function DeviceForm({ } return ( -
    +
    {entries.length === 0 ? ( - No drivers registered. + {t("setup.noDrivers")} ) : ( // Driver is locked when editing — changing the kind of device is a // remove + re-add, not an in-place edit. - selectDriver(e.target.value)} disabled={!!editing}> {entries.map((e) => (