feat(web): pop-out modal forms for setup/subscriptions/roles

Add a reusable ui/Modal (Radix Dialog + terminal chrome) and move the
add/edit forms in the Devices setup, Subscriptions and Roles screens into it,
leaving each list in the page behind the modal. The Devices wizard's per-
category device form is also fully translated (setup.* i18n keys).

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
2026-06-19 10:08:33 +02:00
parent 808fb26ab6
commit 8444bf34c3
4 changed files with 367 additions and 348 deletions
+35 -36
View File
@@ -12,6 +12,7 @@ import {
type Permission, type Permission,
type SessionUser, type SessionUser,
} from "./api.js"; } from "./api.js";
import { Modal } from "./ui/Modal.js";
// Role management (admin). Compose a role from the permission grid (a checkbox // 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 // 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 }) {
<div className="mb-3 flex items-center justify-between"> <div className="mb-3 flex items-center justify-between">
<h1 className="text-sm font-bold uppercase tracking-widest text-term-amber">{t("roles.title")}</h1> <h1 className="text-sm font-bold uppercase tracking-widest text-term-amber">{t("roles.title")}</h1>
{canCreate && ( {canCreate && (
<button type="button" onClick={() => { setEditing("new"); setError(null); }} <button type="button" className="btn btn-go btn-sm" onClick={() => { setEditing("new"); setError(null); }}>
className="rounded-term border border-term-green bg-term-green/10 px-3 py-1 text-[12px] uppercase tracking-wider text-term-green">
{t("roles.add")} {t("roles.add")}
</button> </button>
)} )}
@@ -65,21 +65,28 @@ export function RolesManager({ user }: { user: SessionUser | null }) {
{error && <div className="mb-2 rounded-term border border-term-red px-3 py-2 text-[12px] text-term-red">{error}</div>} {error && <div className="mb-2 rounded-term border border-term-red px-3 py-2 text-[12px] text-term-red">{error}</div>}
{editing && ( <Modal
<RoleEditor open={editing != null}
role={editing === "new" ? null : editing} onClose={() => setEditing(null)}
grouped={grouped} title={editing && editing !== "new" ? t("roles.editTitle") : t("roles.new")}
onCancel={() => setEditing(null)} width="max-w-2xl"
onSubmit={async (v) => { >
try { {editing && (
if (editing === "new") await createRole(v); <RoleEditor
else await updateRole(editing.id, v); role={editing === "new" ? null : editing}
setEditing(null); grouped={grouped}
invalidate(); onCancel={() => setEditing(null)}
} catch (e) { onError(e); } onSubmit={async (v) => {
}} try {
/> if (editing === "new") await createRole(v);
)} else await updateRole(editing.id, v);
setEditing(null);
invalidate();
} catch (e) { onError(e); }
}}
/>
)}
</Modal>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
{roles.map((r) => ( {roles.map((r) => (
@@ -98,13 +105,11 @@ export function RolesManager({ user }: { user: SessionUser | null }) {
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
{canUpdate && !r.builtin && ( {canUpdate && !r.builtin && (
<button type="button" onClick={() => { setEditing(r); setError(null); }} <button type="button" className="btn btn-ghost btn-sm" onClick={() => { setEditing(r); setError(null); }}>{t("roles.edit")}</button>
className="text-[11px] uppercase tracking-wider text-term-muted hover:text-term-text">{t("roles.edit")}</button>
)} )}
{canDelete && !r.builtin && ( {canDelete && !r.builtin && (
<button type="button" <button type="button" className="btn btn-danger btn-sm"
onClick={() => { if (confirm(t("roles.confirmDelete", { name: r.name }))) deleteRoleSafe(r.id, invalidate, onError); }} onClick={() => { if (confirm(t("roles.confirmDelete", { name: r.name }))) deleteRoleSafe(r.id, invalidate, onError); }}>{t("roles.delete")}</button>
className="text-[11px] uppercase tracking-wider text-term-red hover:text-term-text">{t("roles.delete")}</button>
)} )}
</div> </div>
</div> </div>
@@ -140,17 +145,13 @@ function RoleEditor({
const valid = name.trim().length > 0; const valid = name.trim().length > 0;
return ( return (
<div className="mb-3 rounded-term border border-term-border bg-term-panel p-3"> <div>
<div className="mb-2 text-[12px] font-semibold uppercase tracking-wider text-term-amber"> <div className="field mb-3 w-64">
{role ? t("roles.editTitle") : t("roles.new")} <span className="label">{t("roles.name")}</span>
<input className="input" value={name} onChange={(e) => setName(e.target.value)} />
</div> </div>
<label className="mb-3 block text-[11px] text-term-muted">
{t("roles.name")}
<input value={name} onChange={(e) => setName(e.target.value)}
className="mt-1 w-64 rounded-term border border-term-border bg-term-panel-2 px-2 py-1 text-[12px] text-term-text" />
</label>
<div className="text-[11px] uppercase tracking-wider text-term-muted">{t("roles.permissions")}</div> <div className="label">{t("roles.permissions")}</div>
<div className="mt-1 grid grid-cols-1 gap-1"> <div className="mt-1 grid grid-cols-1 gap-1">
{Object.entries(grouped).map(([resource, list]) => ( {Object.entries(grouped).map(([resource, list]) => (
<div key={resource} className="flex flex-wrap items-center gap-x-4 gap-y-1 border-t border-term-border py-1.5"> <div key={resource} className="flex flex-wrap items-center gap-x-4 gap-y-1 border-t border-term-border py-1.5">
@@ -159,7 +160,7 @@ function RoleEditor({
const action = p.split(":")[1]!; const action = p.split(":")[1]!;
return ( return (
<label key={p} className="flex items-center gap-1 text-[12px] text-term-text"> <label key={p} className="flex items-center gap-1 text-[12px] text-term-text">
<input type="checkbox" checked={perms.has(p)} onChange={() => toggle(p)} /> <input type="checkbox" className="accent-term-amber" checked={perms.has(p)} onChange={() => toggle(p)} />
{action} {action}
</label> </label>
); );
@@ -169,10 +170,8 @@ function RoleEditor({
</div> </div>
<div className="mt-3 flex justify-end gap-2"> <div className="mt-3 flex justify-end gap-2">
<button type="button" onClick={onCancel} <button type="button" className="btn btn-sm" onClick={onCancel}>{t("common.cancel")}</button>
className="rounded-term border border-term-border px-3 py-1 text-[12px] uppercase tracking-wider text-term-muted">{t("common.cancel")}</button> <button type="button" className="btn btn-primary btn-sm" disabled={!valid} onClick={() => onSubmit({ name: name.trim(), permissions: [...perms] })}>{t("common.save")}</button>
<button type="button" disabled={!valid} onClick={() => onSubmit({ name: name.trim(), permissions: [...perms] })}
className="rounded-term border border-term-green bg-term-green/10 px-3 py-1 text-[12px] uppercase tracking-wider text-term-green disabled:opacity-40">{t("common.save")}</button>
</div> </div>
</div> </div>
); );
+214 -240
View File
@@ -1,4 +1,5 @@
import { useState, useEffect, useCallback } from "react"; import { useState, useEffect, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { import {
assignDevice, assignDevice,
editDevice, editDevice,
@@ -19,6 +20,7 @@ import {
type RelaySpec, type RelaySpec,
type TestResult, type TestResult,
} from "./api.js"; } 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 // 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 // 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 // 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. // 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", key: "access",
title: "Controllers (barriers + entry button)", titleKey: "setup.catControllers",
noun: "controller", nounKey: "setup.nounController",
}; };
// Categories that BIND to a controller relay (direction inherited from the relay). // Categories that BIND to a controller relay (direction inherited from the relay).
const BOUND: { key: DeviceCategory; title: string; noun: string }[] = [ const BOUND: { key: DeviceCategory; titleKey: string; nounKey: string }[] = [
{ key: "reader", title: "Readers (QR / RFID)", noun: "reader" }, { key: "reader", titleKey: "setup.catReaders", nounKey: "setup.nounReader" },
{ key: "camera", title: "Cameras (snapshot + plate)", noun: "camera" }, { key: "camera", titleKey: "setup.catCameras", nounKey: "setup.nounCamera" },
{ key: "printer", title: "Printers (tickets / vouchers)", noun: "printer" }, { key: "printer", titleKey: "setup.catPrinters", nounKey: "setup.nounPrinter" },
]; ];
const DIRECTION_LABELS: Record<Direction, string> = { // Translated direction label (relay direction / inherited binding).
entry: "Entry", const DIRECTION_KEYS: Record<Direction, string> = {
exit: "Exit", entry: "setup.dirEntry",
both: "Both (entry + exit)", exit: "setup.dirExit",
both: "setup.dirBoth",
}; };
export function SetupWizard() { export function SetupWizard() {
const { t } = useTranslation();
const [catalog, setCatalog] = useState<Catalog | null>(null); const [catalog, setCatalog] = useState<Catalog | null>(null);
const [assignments, setAssignments] = useState<Assignment[] | null>(null); const [assignments, setAssignments] = useState<Assignment[] | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -61,25 +68,21 @@ export function SetupWizard() {
reloadState(); reloadState();
}, [reloadState]); }, [reloadState]);
if (error) return <p style={{ color: "crimson" }}>Failed to load setup: {error}</p>; if (error) return <p className="px-4 py-6 text-term-red">{t("setup.failedToLoad", { error })}</p>;
if (!catalog || !assignments) return <p>Loading device catalog…</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). // Controllers are needed before binding readers/cameras (they pick a controller relay).
const controllers = assignments.filter((a) => a.category === "access"); const controllers = assignments.filter((a) => a.category === "access");
return ( return (
<section> <section className="mx-auto max-w-3xl px-4 py-6">
<h2>First-run setup</h2> <h2 className="mb-1 text-h4 font-semibold text-term-text">{t("setup.title")}</h2>
<p style={{ color: "#666", fontSize: "0.9em" }}> <p className="hint mb-4 max-w-prose">{t("setup.intro")}</p>
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.
</p>
<CategorySection <CategorySection
category={CONTROLLER.key} category={CONTROLLER.key}
title={CONTROLLER.title} title={t(CONTROLLER.titleKey)}
noun={CONTROLLER.noun} noun={t(CONTROLLER.nounKey)}
entries={catalog[CONTROLLER.key]} entries={catalog[CONTROLLER.key]}
discoverableIds={catalog.discoverable} discoverableIds={catalog.discoverable}
pushCapableIds={catalog.pushCapable} pushCapableIds={catalog.pushCapable}
@@ -88,12 +91,12 @@ export function SetupWizard() {
onChanged={reloadState} onChanged={reloadState}
/> />
{BOUND.map(({ key, title, noun }) => ( {BOUND.map(({ key, titleKey, nounKey }) => (
<CategorySection <CategorySection
key={key} key={key}
category={key} category={key}
title={title} title={t(titleKey)}
noun={noun} noun={t(nounKey)}
entries={catalog[key]} entries={catalog[key]}
discoverableIds={catalog.discoverable} discoverableIds={catalog.discoverable}
pushCapableIds={catalog.pushCapable} pushCapableIds={catalog.pushCapable}
@@ -127,101 +130,84 @@ function CategorySection({
assignments: Assignment[]; assignments: Assignment[];
onChanged: () => Promise<void> | void; onChanged: () => Promise<void> | void;
}) { }) {
const [adding, setAdding] = useState(false); const { t } = useTranslation();
const [editingId, setEditingId] = useState<string | null>(null); // 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[]>([]); const [warnings, setWarnings] = useState<string[]>([]);
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. // Binding categories need a controller to point at first.
const isBound = category !== "access"; const isBound = category !== "access";
const blockedNoController = isBound && controllers.length === 0; const blockedNoController = isBound && controllers.length === 0;
const editing = formFor && formFor !== "new" ? formFor : undefined;
return ( return (
<fieldset style={{ marginTop: "1rem" }}> <fieldset className="card mt-4 p-4">
<legend>{title}</legend> <legend className="px-1 text-h6 font-semibold uppercase tracking-wider text-term-text">{title}</legend>
{warnings.length > 0 && ( {warnings.length > 0 && (
<div <div className="mb-3 rounded-term border border-term-amber/60 bg-term-amber/10 px-3 py-2">
style={{ <strong className="text-[12px] text-term-amber">{t("setup.warnTitle")}</strong>
margin: "0 0 0.75rem", <ul className="mt-1 list-disc pl-5 text-[12px] text-term-amber">
padding: "0.5rem 0.75rem",
background: "#fef3c7",
border: "1px solid #f59e0b",
borderRadius: 6,
}}
>
<strong style={{ color: "#92400e" }}>⚠ Saved, but action needed:</strong>
<ul style={{ margin: "0.25rem 0 0", paddingLeft: "1.25rem", color: "#92400e" }}>
{warnings.map((w, i) => ( {warnings.map((w, i) => (
<li key={i}>{w}</li> <li key={i}>{w}</li>
))} ))}
</ul> </ul>
<button type="button" onClick={() => setWarnings([])} style={{ marginTop: "0.5rem" }}> <button type="button" className="btn btn-sm mt-2" onClick={() => setWarnings([])}>
Dismiss {t("setup.dismiss")}
</button> </button>
</div> </div>
)} )}
{assignments.length > 0 && ( {assignments.length > 0 && (
<ul style={{ listStyle: "none", padding: 0, margin: "0 0 0.75rem" }}> <ul className="mb-3 list-none p-0">
{assignments.map((a) => {assignments.map((a) => (
editingId === a.id ? ( <AssignmentRow
<li key={a.id} style={{ listStyle: "none", padding: 0 }}> key={a.id}
<DeviceForm assignment={a}
category={category} controllers={controllers}
entries={entries} onChanged={onChanged}
discoverableIds={discoverableIds} onEdit={() => setFormFor(a)}
pushCapableIds={pushCapableIds} />
controllers={controllers} ))}
editing={a}
onSaved={async (w) => {
setWarnings(w);
await onChanged();
setEditingId(null);
}}
onCancel={() => setEditingId(null)}
/>
</li>
) : (
<AssignmentRow
key={a.id}
assignment={a}
controllers={controllers}
onChanged={onChanged}
onEdit={() => {
setAdding(false);
setEditingId(a.id);
}}
/>
),
)}
</ul> </ul>
)} )}
{blockedNoController ? ( {blockedNoController ? (
<p style={{ color: "#b45309", margin: 0 }}>Add a controller first — a {noun} points at one of its relays.</p> <p className="m-0 text-[12px] text-term-amber">{t("setup.needControllerFirst", { noun })}</p>
) : editing ? null : showForm ? (
<DeviceForm
category={category}
entries={entries}
discoverableIds={discoverableIds}
pushCapableIds={pushCapableIds}
controllers={controllers}
onSaved={async (w) => {
setWarnings(w);
await onChanged();
setAdding(false);
}}
onCancel={assignments.length > 0 ? () => setAdding(false) : undefined}
/>
) : ( ) : (
<button type="button" onClick={() => setAdding(true)}> <button type="button" className="btn btn-sm" onClick={() => setFormFor("new")}>
+ Add another {noun} {assignments.length === 0 ? t("setup.add", { noun }) : t("setup.addAnother", { noun })}
</button> </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> </fieldset>
); );
} }
@@ -237,6 +223,7 @@ function AssignmentRow({
onChanged: () => Promise<void> | void; onChanged: () => Promise<void> | void;
onEdit: () => void; onEdit: () => void;
}) { }) {
const { t } = useTranslation();
const [removing, setRemoving] = useState(false); const [removing, setRemoving] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -244,7 +231,7 @@ function AssignmentRow({
const host = typeof cfg.host === "string" ? cfg.host : null; const host = typeof cfg.host === "string" ? cfg.host : null;
async function remove() { async function remove() {
if (!confirm(`Remove this ${assignment.driverId} device?`)) return; if (!confirm(t("setup.confirmRemove", { driver: assignment.driverId }))) return;
setRemoving(true); setRemoving(true);
setError(null); setError(null);
try { try {
@@ -257,26 +244,18 @@ function AssignmentRow({
} }
return ( return (
<li <li className="flex items-center gap-2 border-b border-term-border/60 px-1 py-2 text-[12px]">
style={{ <strong className="text-term-text">{assignment.driverId}</strong>
display: "flex", {host && <span className="tabular-nums text-term-muted">{host}</span>}
alignItems: "center",
gap: "0.5rem",
padding: "0.4rem 0.5rem",
borderBottom: "1px solid #eee",
}}
>
<strong>{assignment.driverId}</strong>
{host && <span style={{ color: "#666" }}>{host}</span>}
<DeviceSummary assignment={assignment} controllers={controllers} /> <DeviceSummary assignment={assignment} controllers={controllers} />
{!assignment.enabled && <span style={{ color: "#b45309" }}>(disabled)</span>} {!assignment.enabled && <span className="text-term-amber">{t("setup.disabled")}</span>}
<span style={{ flex: 1 }} /> <span className="flex-1" />
{error && <span style={{ color: "crimson" }}>{error}</span>} {error && <span className="text-term-red">{error}</span>}
<button type="button" onClick={onEdit} disabled={removing}> <button type="button" className="btn btn-ghost btn-sm" onClick={onEdit} disabled={removing}>
Edit {t("setup.edit")}
</button> </button>
<button type="button" onClick={remove} disabled={removing}> <button type="button" className="btn btn-danger btn-sm" onClick={remove} disabled={removing}>
{removing ? "Removing…" : "Remove"} {removing ? t("setup.removing") : t("setup.remove")}
</button> </button>
</li> </li>
); );
@@ -284,12 +263,13 @@ function AssignmentRow({
/** Inline summary of an assignment's direction/binding for the list. */ /** Inline summary of an assignment's direction/binding for the list. */
function DeviceSummary({ assignment, controllers }: { assignment: Assignment; controllers: Assignment[] }) { function DeviceSummary({ assignment, controllers }: { assignment: Assignment; controllers: Assignment[] }) {
const { t } = useTranslation();
const cfg = assignment.config as Record<string, unknown>; const cfg = assignment.config as Record<string, unknown>;
if (assignment.category === "access") { if (assignment.category === "access") {
const relays = Array.isArray(cfg.relays) ? (cfg.relays as RelaySpec[]) : []; const relays = Array.isArray(cfg.relays) ? (cfg.relays as RelaySpec[]) : [];
if (relays.length === 0) return <em style={{ color: "#b45309" }}>no relays set</em>; if (relays.length === 0) return <em className="text-term-amber">{t("setup.noRelaysSet")}</em>;
return ( return (
<span style={{ display: "flex", gap: "0.35rem" }}> <span className="flex gap-1.5">
{relays.map((r) => ( {relays.map((r) => (
<DirectionBadge key={r.relay} direction={r.direction} label={`R${r.relay}${r.button ? `·btn${r.button}` : ""}`} /> <DirectionBadge key={r.relay} direction={r.direction} label={`R${r.relay}${r.button ? `·btn${r.button}` : ""}`} />
))} ))}
@@ -299,7 +279,7 @@ function DeviceSummary({ assignment, controllers }: { assignment: Assignment; co
// Bound device: show controller + relay it points at, with inherited direction. // Bound device: show controller + relay it points at, with inherited direction.
const controllerId = typeof cfg.controllerId === "string" ? cfg.controllerId : null; const controllerId = typeof cfg.controllerId === "string" ? cfg.controllerId : null;
const relay = typeof cfg.relay === "number" ? cfg.relay : null; const relay = typeof cfg.relay === "number" ? cfg.relay : null;
if (!controllerId || relay == null) return <em style={{ color: "#b45309" }}>unbound</em>; if (!controllerId || relay == null) return <em className="text-term-amber">{t("setup.unbound")}</em>;
const controller = controllers.find((c) => c.id === controllerId); const controller = controllers.find((c) => c.id === controllerId);
const spec = controller const spec = controller
? (((controller.config as Record<string, unknown>).relays as RelaySpec[]) ?? []).find((r) => r.relay === relay) ? (((controller.config as Record<string, unknown>).relays as RelaySpec[]) ?? []).find((r) => r.relay === relay)
@@ -333,6 +313,7 @@ function DeviceForm({
onSaved: (warnings: string[]) => Promise<void> | void; onSaved: (warnings: string[]) => Promise<void> | void;
onCancel?: () => void; onCancel?: () => void;
}) { }) {
const { t } = useTranslation();
// On edit the driver is fixed (you can't change what KIND of device a slot is — // 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. // that's a remove + re-add); pre-select it and lock the picker.
const editCfg = editing?.config as Record<string, unknown> | undefined; const editCfg = editing?.config as Record<string, unknown> | undefined;
@@ -500,15 +481,15 @@ function DeviceForm({
} }
return ( return (
<div style={{ padding: "0.5rem", background: "#fafafa", borderRadius: 6 }}> <div>
{entries.length === 0 ? ( {entries.length === 0 ? (
<em>No drivers registered.</em> <em className="text-term-muted">{t("setup.noDrivers")}</em>
) : ( ) : (
// Driver is locked when editing — changing the kind of device is a // Driver is locked when editing — changing the kind of device is a
// remove + re-add, not an in-place edit. // remove + re-add, not an in-place edit.
<select value={selectedId} onChange={(e) => selectDriver(e.target.value)} disabled={!!editing}> <select className="select w-auto min-w-64" value={selectedId} onChange={(e) => selectDriver(e.target.value)} disabled={!!editing}>
<option value="" disabled> <option value="" disabled>
Choose a device… {t("setup.chooseDevice")}
</option> </option>
{entries.map((e) => ( {entries.map((e) => (
<option key={e.id} value={e.id}> <option key={e.id} value={e.id}>
@@ -519,26 +500,26 @@ function DeviceForm({
)} )}
{selected && ( {selected && (
<div style={{ marginTop: "0.5rem" }}> <div className="mt-3">
<p style={{ margin: "0.25rem 0", color: "#555" }}>{selected.description}</p> <p className="mb-2 text-[12px] text-term-muted">{selected.description}</p>
{canDiscover && ( {canDiscover && (
<div style={{ margin: "0.5rem 0", padding: "0.5rem", background: "#f3f4f6", borderRadius: 6 }}> <div className="my-2 rounded-term border border-term-border bg-term-bg p-2">
<button type="button" onClick={scan} disabled={scanning}> <button type="button" className="btn btn-sm" onClick={scan} disabled={scanning}>
{scanning ? "Scanning…" : "Scan for controllers"} {scanning ? t("setup.scanning") : t("setup.scan")}
</button> </button>
{scanError && <span style={{ marginLeft: 8, color: "crimson" }}>{scanError}</span>} {scanError && <span className="ml-2 text-[12px] text-term-red">{scanError}</span>}
{found && found.length === 0 && <p style={{ margin: "0.5rem 0 0" }}>No controllers found on the LAN.</p>} {found && found.length === 0 && <p className="mt-2 text-[12px] text-term-muted">{t("setup.noControllersFound")}</p>}
{found && found.length > 0 && ( {found && found.length > 0 && (
<ul style={{ margin: "0.5rem 0 0", paddingLeft: "1rem" }}> <ul className="mt-2 list-none p-0">
{found.map((d) => ( {found.map((d) => (
<li key={d.id} style={{ margin: "0.25rem 0" }}> <li key={d.id} className="my-1 flex items-center gap-2 text-[12px]">
<button type="button" onClick={() => applyDiscovered(d)}> <button type="button" className="btn btn-sm" onClick={() => applyDiscovered(d)}>
Use {t("setup.use")}
</button>{" "} </button>
<strong>{d.label}</strong>{" "} <strong className="text-term-text">{d.label}</strong>
<HealthBadge status={d.health.status} /> <HealthBadge status={d.health.status} />
{d.info?.firmware && <span style={{ color: "#666" }}> · fw {d.info.firmware}</span>} {d.info?.firmware && <span className="text-term-muted"> · fw {d.info.firmware}</span>}
</li> </li>
))} ))}
</ul> </ul>
@@ -547,38 +528,40 @@ function DeviceForm({
)} )}
{selected.configFields.map((f) => ( {selected.configFields.map((f) => (
<div key={f.key} style={{ margin: "0.25rem 0" }}> <div key={f.key} className="field my-2 max-w-sm">
<label> <label className="label">
{f.label} {f.label}
{f.required ? " *" : ""}{" "} {f.required ? " *" : ""}
{f.type === "select" ? (
<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
type={f.type === "secret" ? "password" : f.type === "number" || f.type === "port" ? "number" : "text"}
value={config[f.key] ?? (f.default as string | number | undefined) ?? ""}
placeholder={f.help}
onChange={(e) => {
const v = e.target.value;
setConfig((c) => ({ ...c, [f.key]: v }));
resetStatus();
}}
/>
)}
</label> </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) ?? ""}
placeholder={f.help}
onChange={(e) => {
const v = e.target.value;
setConfig((c) => ({ ...c, [f.key]: v }));
resetStatus();
}}
/>
)}
</div> </div>
))} ))}
@@ -600,34 +583,34 @@ function DeviceForm({
)} )}
{/* Test (no save/no device change) then Save (configures + persists). */} {/* Test (no save/no device change) then Save (configures + persists). */}
<div style={{ marginTop: "0.75rem", display: "flex", gap: "0.5rem", alignItems: "center" }}> <div className="mt-3 flex items-center gap-2">
<button type="button" onClick={test} disabled={testing}> <button type="button" className="btn btn-sm" onClick={test} disabled={testing}>
{testing ? "Testing…" : "Test connection"} {testing ? t("setup.testing") : t("setup.test")}
</button> </button>
<button type="button" onClick={save} disabled={saving}> <button type="button" className="btn btn-primary btn-sm" onClick={save} disabled={saving}>
{saving ? "Saving…" : editing ? "Save changes" : "Save & configure"} {saving ? t("setup.saving") : editing ? t("setup.saveChanges") : t("setup.saveConfigure")}
</button> </button>
{onCancel && ( {onCancel && (
<button type="button" onClick={onCancel} disabled={saving}> <button type="button" className="btn btn-ghost btn-sm" onClick={onCancel} disabled={saving}>
Cancel {t("setup.cancel")}
</button> </button>
)} )}
</div> </div>
{testError && <p style={{ color: "crimson", margin: "0.5rem 0 0" }}>Test failed: {testError}</p>} {testError && <p className="mt-2 text-[12px] text-term-red">{t("setup.testFailed", { error: testError })}</p>}
{tested && ( {tested && (
<div style={{ margin: "0.5rem 0 0" }}> <div className="mt-2 text-[12px]">
<div> <div className="text-term-text">
Device: <HealthBadge status={tested.health.status} /> {t("setup.deviceLabel")} <HealthBadge status={tested.health.status} />
{tested.health.detail && <span style={{ color: "#666" }}> — {tested.health.detail}</span>} {tested.health.detail && <span className="text-term-muted"> — {tested.health.detail}</span>}
</div> </div>
{tested.preconditions.ok ? ( {tested.preconditions.ok ? (
<div style={{ color: "#16a34a" }}>● preconditions OK</div> <div className="text-term-green">{t("setup.preconditionsOk")}</div>
) : ( ) : (
tested.preconditions.issues.map((i) => ( tested.preconditions.issues.map((i) => (
<div key={i.key} style={{ color: "#d97706" }}> <div key={i.key} className="text-term-amber">
⚠ {i.message} ⚠ {i.message}
{i.fixable && <span style={{ color: "#666" }}> (auto-fixed on save)</span>} {i.fixable && <span className="text-term-muted"> {t("setup.autoFixedOnSave")}</span>}
</div> </div>
)) ))
)} )}
@@ -635,33 +618,29 @@ function DeviceForm({
)} )}
{backendIps && backendIps.length > 0 && ( {backendIps && backendIps.length > 0 && (
<div style={{ margin: "0.5rem 0 0" }}> <div className="mt-3">
<label> <div className="field max-w-md">
Backend push IP{" "} <label className="label">{t("setup.backendPushIp")}</label>
<select value={backendIp} onChange={(e) => setBackendIp(e.target.value)}> <select className="select" value={backendIp} onChange={(e) => setBackendIp(e.target.value)}>
{!backendIps.some((c) => c.onDeviceSubnet) && ( {!backendIps.some((c) => c.onDeviceSubnet) && (
<option value="" disabled> <option value="" disabled>
Choose an address… {t("setup.chooseAddress")}
</option> </option>
)} )}
{backendIps.map((c) => ( {backendIps.map((c) => (
<option key={c.ip} value={c.ip}> <option key={c.ip} value={c.ip}>
{c.ip} ({c.iface}){c.onDeviceSubnet ? " — on device subnet" : ""} {c.ip} ({c.iface}){c.onDeviceSubnet ? ` ${t("setup.onDeviceSubnet")}` : ""}
</option> </option>
))} ))}
</select> </select>
</label> </div>
{!backendIps.some((c) => c.onDeviceSubnet) && ( {!backendIps.some((c) => c.onDeviceSubnet) && (
<span style={{ marginLeft: 8, color: "#d97706" }}> <span className="text-[12px] text-term-amber">{t("setup.noNicOnSubnet")}</span>
⚠ no NIC on the device's subnet — the device may not reach the backend
</span>
)} )}
<p style={{ margin: "0.25rem 0 0", color: "#666", fontSize: "0.85em" }}> <p className="hint mt-1">{t("setup.backendIpHint")}</p>
The address this device will POST input events to.
</p>
</div> </div>
)} )}
{saveError && <p style={{ color: "crimson", margin: "0.5rem 0 0" }}>Save failed: {saveError}</p>} {saveError && <p className="mt-2 text-[12px] text-term-red">{t("setup.saveFailed", { error: saveError })}</p>}
</div> </div>
)} )}
</div> </div>
@@ -671,6 +650,7 @@ function DeviceForm({
/** Controller relay map editor: each row = a relay + its direction + (optional) /** Controller relay map editor: each row = a relay + its direction + (optional)
* the input terminal its entry button is wired to. */ * the input terminal its entry button is wired to. */
function RelayEditor({ relays, onChange }: { relays: RelaySpec[]; onChange: (r: RelaySpec[]) => void }) { function RelayEditor({ relays, onChange }: { relays: RelaySpec[]; onChange: (r: RelaySpec[]) => void }) {
const { t } = useTranslation();
function update(i: number, patch: Partial<RelaySpec>) { function update(i: number, patch: Partial<RelaySpec>) {
onChange(relays.map((r, idx) => (idx === i ? { ...r, ...patch } : r))); onChange(relays.map((r, idx) => (idx === i ? { ...r, ...patch } : r)));
} }
@@ -683,53 +663,50 @@ function RelayEditor({ relays, onChange }: { relays: RelaySpec[]; onChange: (r:
} }
return ( return (
<div style={{ margin: "0.5rem 0", padding: "0.5rem", background: "#f3f4f6", borderRadius: 6 }}> <div className="my-2 rounded-term border border-term-border bg-term-bg p-2">
<strong style={{ fontSize: "0.9em" }}>Relays on this controller</strong> <strong className="text-[12px] uppercase tracking-wider text-term-text">{t("setup.relaysTitle")}</strong>
<p style={{ margin: "0.15rem 0 0.5rem", color: "#666", fontSize: "0.8em" }}> <p className="hint mt-0.5 mb-2">{t("setup.relaysHint")}</p>
Each relay opens one barrier. Set its direction; for transient entry, set which input
terminal the entry button is wired to.
</p>
{relays.map((r, i) => ( {relays.map((r, i) => (
<div key={i} style={{ display: "flex", gap: "0.5rem", alignItems: "center", margin: "0.25rem 0" }}> <div key={i} className="my-1 flex flex-wrap items-center gap-2">
<label> <label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted">
Relay{" "} {t("setup.relay")}
<input <input
type="number" type="number"
min={1} min={1}
value={r.relay} value={r.relay}
style={{ width: "3.5rem" }} className="input input-sm w-16"
onChange={(e) => update(i, { relay: Number(e.target.value) })} onChange={(e) => update(i, { relay: Number(e.target.value) })}
/> />
</label> </label>
<select value={r.direction} onChange={(e) => update(i, { direction: e.target.value as Direction })}> <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) => ( {(["entry", "exit", "both"] as Direction[]).map((d) => (
<option key={d} value={d}> <option key={d} value={d}>
{DIRECTION_LABELS[d]} {t(DIRECTION_KEYS[d])}
</option> </option>
))} ))}
</select> </select>
{(r.direction === "entry" || r.direction === "both") && ( {(r.direction === "entry" || r.direction === "both") && (
<label> <label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted">
Entry button on terminal{" "} {t("setup.entryButtonTerminal")}
<input <input
type="number" type="number"
min={1} min={1}
value={r.button ?? ""} value={r.button ?? ""}
placeholder="—" placeholder="—"
style={{ width: "3.5rem" }} className="input input-sm w-16"
onChange={(e) => update(i, { button: e.target.value === "" ? undefined : Number(e.target.value) })} onChange={(e) => update(i, { button: e.target.value === "" ? undefined : Number(e.target.value) })}
/> />
</label> </label>
)} )}
{relays.length > 1 && ( {relays.length > 1 && (
<button type="button" onClick={() => remove(i)}> <button type="button" className="btn btn-ghost btn-sm" onClick={() => remove(i)}>
✕ ✕
</button> </button>
)} )}
</div> </div>
))} ))}
<button type="button" onClick={add} style={{ marginTop: "0.25rem" }}> <button type="button" className="btn btn-sm mt-1" onClick={add}>
+ Add relay {t("setup.addRelay")}
</button> </button>
</div> </div>
); );
@@ -750,6 +727,7 @@ function BindingPicker({
onControllerChange: (id: string) => void; onControllerChange: (id: string) => void;
onRelayChange: (relay: number) => void; onRelayChange: (relay: number) => void;
}) { }) {
const { t } = useTranslation();
const controller = controllers.find((c) => c.id === controllerId); const controller = controllers.find((c) => c.id === controllerId);
const relays: RelaySpec[] = controller const relays: RelaySpec[] = controller
? (((controller.config as Record<string, unknown>).relays as RelaySpec[]) ?? []) ? (((controller.config as Record<string, unknown>).relays as RelaySpec[]) ?? [])
@@ -757,14 +735,14 @@ function BindingPicker({
const chosen = relays.find((r) => r.relay === relay); const chosen = relays.find((r) => r.relay === relay);
return ( return (
<div style={{ margin: "0.5rem 0", padding: "0.5rem", background: "#f3f4f6", borderRadius: 6 }}> <div className="my-2 rounded-term border border-term-border bg-term-bg p-2">
<strong style={{ fontSize: "0.9em" }}>Which barrier does this device serve?</strong> <strong className="text-[12px] uppercase tracking-wider text-term-text">{t("setup.whichBarrier")}</strong>
<div style={{ display: "flex", gap: "0.5rem", alignItems: "center", marginTop: "0.35rem", flexWrap: "wrap" }}> <div className="mt-1.5 flex flex-wrap items-center gap-2">
<label> <label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted">
Controller{" "} {t("setup.controller")}
<select value={controllerId} onChange={(e) => onControllerChange(e.target.value)}> <select className="select input-sm w-auto" value={controllerId} onChange={(e) => onControllerChange(e.target.value)}>
<option value="" disabled> <option value="" disabled>
Choose… {t("setup.choose")}
</option> </option>
{controllers.map((c) => { {controllers.map((c) => {
const host = (c.config as Record<string, unknown>).host; const host = (c.config as Record<string, unknown>).host;
@@ -777,53 +755,49 @@ function BindingPicker({
})} })}
</select> </select>
</label> </label>
<label> <label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted">
Relay{" "} {t("setup.relay")}
<select <select
className="select input-sm w-auto"
value={relay === "" ? "" : String(relay)} value={relay === "" ? "" : String(relay)}
disabled={!controller} disabled={!controller}
onChange={(e) => onRelayChange(Number(e.target.value))} onChange={(e) => onRelayChange(Number(e.target.value))}
> >
<option value="" disabled> <option value="" disabled>
Choose… {t("setup.choose")}
</option> </option>
{relays.map((r) => ( {relays.map((r) => (
<option key={r.relay} value={r.relay}> <option key={r.relay} value={r.relay}>
Relay {r.relay} ({DIRECTION_LABELS[r.direction]}) {t("setup.relayLabel", { relay: r.relay, direction: t(DIRECTION_KEYS[r.direction]) })}
</option> </option>
))} ))}
</select> </select>
</label> </label>
{chosen && <DirectionBadge direction={chosen.direction} label={`inherits ${chosen.direction}`} />} {chosen && <DirectionBadge direction={chosen.direction} label={t("setup.inherits", { direction: t(DIRECTION_KEYS[chosen.direction]) })} />}
</div> </div>
{controller && relays.length === 0 && ( {controller && relays.length === 0 && (
<p style={{ margin: "0.35rem 0 0", color: "#b45309", fontSize: "0.85em" }}> <p className="mt-1.5 text-[12px] text-term-amber">{t("setup.noRelaysConfigured")}</p>
This controller has no relays configured.
</p>
)} )}
</div> </div>
); );
} }
function DirectionBadge({ direction, label }: { direction: Direction; label?: string }) { function DirectionBadge({ direction, label }: { direction: Direction; label?: string }) {
const color = direction === "entry" ? "#15803d" : direction === "exit" ? "#b45309" : "#6b7280"; // 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 ( return (
<span <span className={`rounded-term border px-1.5 text-[10px] font-semibold uppercase tracking-wider ${cls}`}>
style={{
color,
border: `1px solid ${color}`,
borderRadius: 4,
padding: "0 0.35rem",
fontSize: "0.75em",
fontWeight: 600,
}}
>
{label ?? direction} {label ?? direction}
</span> </span>
); );
} }
function HealthBadge({ status }: { status: string }) { function HealthBadge({ status }: { status: string }) {
const color = status === "ready" ? "#16a34a" : status === "degraded" ? "#d97706" : "#dc2626"; const cls = status === "ready" ? "text-term-green" : status === "degraded" ? "text-term-amber" : "text-term-red";
return <span style={{ color, fontWeight: 600 }}>● {status}</span>; return <span className={`font-semibold ${cls}`}>● {status}</span>;
} }
+73 -72
View File
@@ -18,6 +18,7 @@ import {
type SubscriptionCredential, type SubscriptionCredential,
type SubscriptionInput, type SubscriptionInput,
} from "./api.js"; } from "./api.js";
import { Modal } from "./ui/Modal.js";
// Subscription admin. Create/edit/revoke/delete subscriptions + their credentials // Subscription admin. Create/edit/revoke/delete subscriptions + their credentials
// (card/QR) and bound plates, and the recurring monthly price (e.g. 10,000 ALL). A // (card/QR) and bound plates, and the recurring monthly price (e.g. 10,000 ALL). A
@@ -285,89 +286,92 @@ export function SubscriptionManager() {
if (!subs) return null; if (!subs) return null;
return ( return (
<section style={{ marginTop: "2rem" }}> <section className="mx-auto max-w-3xl px-4 py-6">
<h2>{t("subs.title")}</h2> <h2 className="mb-3 text-h4 font-semibold text-term-text">{t("subs.title")}</h2>
<ul style={{ listStyle: "none", padding: 0 }}> <ul className="mb-3 list-none p-0">
{subs.map((s) => ( {subs.map((s) => (
<li key={s.id} style={{ display: "flex", gap: "0.5rem", alignItems: "center", padding: "0.4rem 0", borderBottom: "1px solid #eee" }}> <li key={s.id} className="flex flex-wrap items-center gap-2 border-b border-term-border/60 py-2 text-[12px]">
<strong>{s.holderName ?? t("subs.unnamed")}</strong> <strong className="text-term-text">{s.holderName ?? t("subs.unnamed")}</strong>
<span style={{ color: s.status === "active" ? "#16a34a" : "#b45309" }}>{t(STATUS_KEY[s.status])}</span> <span className={s.status === "active" ? "text-term-green" : "text-term-amber"}>{t(STATUS_KEY[s.status])}</span>
<span style={{ color: "#0a7", fontVariantNumeric: "tabular-nums" }}>{priceLabel(s, t)}</span> <span className="tabular-nums text-term-cyan">{priceLabel(s, t)}</span>
<span style={{ color: "#666" }}> <span className="text-term-muted">
{s.maxConcurrent == null ? t("subs.unbound") : t("subs.car", { count: s.maxConcurrent })} ·{" "} {s.maxConcurrent == null ? t("subs.unbound") : t("subs.car", { count: s.maxConcurrent })} ·{" "}
{s.credentials.length} {t("subs.cred")} · {t("subs.plates", { count: s.plates.length })} {s.credentials.length} {t("subs.cred")} · {t("subs.plates", { count: s.plates.length })}
</span> </span>
<span style={{ flex: 1 }} /> <span className="flex-1" />
{/* Print code — only when the subscription has a QR credential to encode. */} {/* Print code — only when the subscription has a QR credential to encode. */}
{s.credentials.some((c) => c.kind === "qr") && ( {s.credentials.some((c) => c.kind === "qr") && (
<button type="button" onClick={() => doPrint(s)}>{t("subs.printCode")}</button> <button type="button" className="btn btn-ghost btn-sm" onClick={() => doPrint(s)}>{t("subs.printCode")}</button>
)} )}
<button type="button" onClick={() => startEdit(s)}>{t("subs.edit")}</button> <button type="button" className="btn btn-ghost btn-sm" onClick={() => startEdit(s)}>{t("subs.edit")}</button>
{s.status !== "revoked" && <button type="button" onClick={() => doRevoke(s)}>{t("subs.revoke")}</button>} {s.status !== "revoked" && <button type="button" className="btn btn-ghost btn-sm" onClick={() => doRevoke(s)}>{t("subs.revoke")}</button>}
<button type="button" onClick={() => doDelete(s)}>{t("subs.delete")}</button> <button type="button" className="btn btn-danger btn-sm" onClick={() => doDelete(s)}>{t("subs.delete")}</button>
</li> </li>
))} ))}
{subs.length === 0 && <li style={{ color: "#777" }}>{t("subs.noneYet")}</li>} {subs.length === 0 && <li className="py-2 text-term-muted">{t("subs.noneYet")}</li>}
</ul> </ul>
{editing == null ? ( <button type="button" className="btn btn-go btn-sm" onClick={startNew}>{t("subs.add")}</button>
<button type="button" onClick={startNew}>{t("subs.add")}</button>
) : ( <Modal
<div style={{ border: "1px solid #ddd", padding: "1rem", marginTop: "0.5rem", maxWidth: 460 }}> open={editing != null}
<h3 style={{ marginTop: 0 }}>{editing === "new" ? t("subs.new") : t("subs.editTitle")}</h3> onClose={() => setEditing(null)}
<div style={{ display: "grid", gridTemplateColumns: "max-content 1fr", gap: "0.4rem 0.75rem", alignItems: "center" }}> title={editing === "new" ? t("subs.new") : t("subs.editTitle")}
<label>{t("subs.holderName")}</label> width="max-w-2xl"
<input value={form.holderName} onChange={(e) => setForm((f) => ({ ...f, holderName: e.target.value }))} /> >
<label>{t("subs.contact")}</label> <div className="grid grid-cols-[max-content_1fr] items-center gap-x-4 gap-y-2">
<input value={form.contact} onChange={(e) => setForm((f) => ({ ...f, contact: e.target.value }))} /> <label className="label">{t("subs.holderName")}</label>
<label>{t("subs.monthlyPrice")}</label> <input className="input" value={form.holderName} onChange={(e) => setForm((f) => ({ ...f, holderName: e.target.value }))} />
<span style={{ display: "flex", gap: "0.4rem", alignItems: "center" }}> <label className="label">{t("subs.contact")}</label>
<input className="input" value={form.contact} onChange={(e) => setForm((f) => ({ ...f, contact: e.target.value }))} />
<label className="label">{t("subs.monthlyPrice")}</label>
<span className="flex items-center gap-2">
<input <input
className="input w-28"
value={form.priceMajor} value={form.priceMajor}
onChange={(e) => setForm((f) => ({ ...f, priceMajor: e.target.value }))} onChange={(e) => setForm((f) => ({ ...f, priceMajor: e.target.value }))}
inputMode="decimal" inputMode="decimal"
placeholder={t("subs.pricePlaceholder")} placeholder={t("subs.pricePlaceholder")}
style={{ width: 110 }}
/> />
<input value={form.currency} onChange={(e) => setForm((f) => ({ ...f, currency: e.target.value }))} style={{ width: 60 }} /> <input className="input w-16" value={form.currency} onChange={(e) => setForm((f) => ({ ...f, currency: e.target.value }))} />
<span style={{ color: "#888" }}>/ {t("subs.perMonth")}</span> <span className="text-[12px] text-term-muted">/ {t("subs.perMonth")}</span>
</span> </span>
<label>{t("subs.carLimit")}</label> <label className="label">{t("subs.carLimit")}</label>
<span> <span className="flex items-center gap-3">
<label style={{ marginRight: "0.5rem" }}> <label className="inline-flex items-center gap-1.5 text-[12px] text-term-text">
<input type="checkbox" checked={form.carBound} onChange={(e) => setForm((f) => ({ ...f, carBound: e.target.checked }))} /> {t("subs.limitCarsInAtOnce")} <input type="checkbox" className="accent-term-amber" checked={form.carBound} onChange={(e) => setForm((f) => ({ ...f, carBound: e.target.checked }))} /> {t("subs.limitCarsInAtOnce")}
</label> </label>
{form.carBound && ( {form.carBound && (
<input value={form.maxConcurrent} onChange={(e) => setForm((f) => ({ ...f, maxConcurrent: e.target.value }))} style={{ width: 50 }} /> <input className="input w-16" value={form.maxConcurrent} onChange={(e) => setForm((f) => ({ ...f, maxConcurrent: e.target.value }))} />
)} )}
</span> </span>
<label>{t("subs.validFrom")}</label> <label className="label">{t("subs.validFrom")}</label>
<input type="date" value={form.validFrom} onChange={(e) => setForm((f) => ({ ...f, validFrom: e.target.value }))} /> <input type="date" className="input w-44" value={form.validFrom} onChange={(e) => setForm((f) => ({ ...f, validFrom: e.target.value }))} />
<label>{t("subs.months")}</label> <label className="label">{t("subs.months")}</label>
<span style={{ display: "flex", gap: "0.4rem", alignItems: "center", flexWrap: "wrap" }}> <span className="flex flex-wrap items-center gap-2">
<input <input
className="input w-16"
value={form.months} value={form.months}
onChange={(e) => setForm((f) => ({ ...f, months: e.target.value }))} onChange={(e) => setForm((f) => ({ ...f, months: e.target.value }))}
inputMode="numeric" inputMode="numeric"
placeholder="1" placeholder="1"
style={{ width: 50 }}
/> />
<span style={{ color: "#888" }}>{t("subs.monthsHint")}</span> <span className="text-[12px] text-term-muted">{t("subs.monthsHint")}</span>
{/* Live preview of the coverage end + the N×price total. */} {/* Live preview of the coverage end + the N×price total. */}
{coverageHint && <span style={{ color: "#0a7" }}>{coverageHint}</span>} {coverageHint && <span className="text-[12px] text-term-cyan">{coverageHint}</span>}
</span> </span>
<label>{t("subs.validToOverride")}</label> <label className="label">{t("subs.validToOverride")}</label>
<input type="date" value={form.validTo} onChange={(e) => setForm((f) => ({ ...f, validTo: e.target.value }))} /> <input type="date" className="input w-44" value={form.validTo} onChange={(e) => setForm((f) => ({ ...f, validTo: e.target.value }))} />
<label>{t("subs.boundPlates")}</label> <label className="label">{t("subs.boundPlates")}</label>
<input value={form.platesText} onChange={(e) => setForm((f) => ({ ...f, platesText: e.target.value }))} placeholder={t("subs.commaSeparatedOptional")} /> <input className="input" value={form.platesText} onChange={(e) => setForm((f) => ({ ...f, platesText: e.target.value }))} placeholder={t("subs.commaSeparatedOptional")} />
</div> </div>
<h4 style={{ marginBottom: "0.25rem" }}>{t("subs.credentials")}</h4> <h4 className="mt-4 mb-1 text-[12px] font-semibold uppercase tracking-wider text-term-muted">{t("subs.credentials")}</h4>
{form.credentials.map((c, i) => ( {form.credentials.map((c, i) => (
<div key={i} style={{ display: "flex", gap: "0.4rem", marginBottom: "0.3rem" }}> <div key={i} className="mb-1.5 flex items-center gap-2">
{/* Operator chooses the credential type: QR (auto-generated) or RFID {/* Operator chooses the credential type: QR (auto-generated) or RFID
(read off a card via "Read card"). */} (read off a card via "Read card"). */}
<select value={c.kind} onChange={(e) => setCred(i, { kind: e.target.value as "rf" | "qr" })}> <select className="select input-sm w-auto" value={c.kind} onChange={(e) => setCred(i, { kind: e.target.value as "rf" | "qr" })}>
<option value="qr">{t("subs.qr")}</option> <option value="qr">{t("subs.qr")}</option>
<option value="rf">{t("subs.rfCardTag")}</option> <option value="rf">{t("subs.rfCardTag")}</option>
</select> </select>
@@ -375,60 +379,57 @@ export function SubscriptionManager() {
// QR codes are server-generated. Blank → "will be generated"; an // QR codes are server-generated. Blank → "will be generated"; an
// existing code is shown read-only (it can be printed; never typed). // existing code is shown read-only (it can be printed; never typed).
c.value.trim() ? ( c.value.trim() ? (
<input value={c.value} readOnly style={{ flex: 1, fontFamily: "ui-monospace, monospace", background: "#f6f6f6" }} /> <input className="input input-sm flex-1 opacity-70" value={c.value} readOnly />
) : ( ) : (
<span style={{ flex: 1, color: "#888", fontStyle: "italic", alignSelf: "center" }}>{t("subs.qrAutoGen")}</span> <span className="flex-1 self-center text-[12px] italic text-term-muted">{t("subs.qrAutoGen")}</span>
) )
) : ( ) : (
// RFID: the value is read off a physical card (or typed). "Read card" // RFID: the value is read off a physical card (or typed). "Read card"
// arms a chosen reader and fills the captured value. // arms a chosen reader and fills the captured value.
<input value={c.value} onChange={(e) => setCred(i, { value: e.target.value })} placeholder={t("subs.rfPlaceholder")} style={{ flex: 1, fontFamily: "ui-monospace, monospace" }} /> <input className="input input-sm flex-1" value={c.value} onChange={(e) => setCred(i, { value: e.target.value })} placeholder={t("subs.rfPlaceholder")} />
)} )}
{c.kind === "rf" && ( {c.kind === "rf" && (
<button type="button" onClick={() => startCapture(i)} disabled={capture != null}>{t("subs.readCard")}</button> <button type="button" className="btn btn-sm" onClick={() => startCapture(i)} disabled={capture != null}>{t("subs.readCard")}</button>
)} )}
<button type="button" onClick={() => setForm((f) => ({ ...f, credentials: f.credentials.filter((_, j) => j !== i) }))}>×</button> <button type="button" className="btn btn-ghost btn-sm" onClick={() => setForm((f) => ({ ...f, credentials: f.credentials.filter((_, j) => j !== i) }))}>×</button>
</div> </div>
))} ))}
<button type="button" onClick={() => setForm((f) => ({ ...f, credentials: [...f.credentials, { kind: "qr", value: "" }] }))}>{t("subs.addCredential")}</button> <button type="button" className="btn btn-sm" onClick={() => setForm((f) => ({ ...f, credentials: [...f.credentials, { kind: "qr", value: "" }] }))}>{t("subs.addCredential")}</button>
{/* Capture panel: pick a reader, present the card; the captured value fills {/* Capture panel: pick a reader, present the card; the captured value fills
the credential. The OTHER reader keeps serving the live flow. */} the credential. The OTHER reader keeps serving the live flow. */}
{capture && ( {capture && (
<div style={{ marginTop: "0.5rem", padding: "0.6rem 0.75rem", border: "1px solid #0a7", borderRadius: 6, background: "#f0fbf6" }}> <div className="mt-3 rounded-term border border-term-cyan/50 bg-term-cyan/5 p-3 text-[12px]">
{capture.phase === "pick" ? ( {capture.phase === "pick" ? (
<> <>
<div style={{ marginBottom: "0.35rem" }}>{t("subs.captureChooseReader")}</div> <div className="mb-1.5 text-term-text">{t("subs.captureChooseReader")}</div>
<div style={{ display: "flex", gap: "0.4rem", flexWrap: "wrap" }}> <div className="flex flex-wrap gap-2">
{readers.length === 0 && <span style={{ color: "#a00" }}>{t("subs.captureNoReaders")}</span>} {readers.length === 0 && <span className="text-term-red">{t("subs.captureNoReaders")}</span>}
{readers.map((r) => ( {readers.map((r) => (
<button key={r.id} type="button" onClick={() => pickReader(r.id)}> <button key={r.id} type="button" className="btn btn-pay btn-sm" onClick={() => pickReader(r.id)}>
{t(`devices.role.${r.direction}`)} ({r.driverId}) {t(`devices.role.${r.direction}`)} ({r.driverId})
</button> </button>
))} ))}
<button type="button" onClick={stopCapture}>{t("subs.cancel")}</button> <button type="button" className="btn btn-ghost btn-sm" onClick={stopCapture}>{t("subs.cancel")}</button>
</div> </div>
</> </>
) : ( ) : (
<div style={{ display: "flex", gap: "0.6rem", alignItems: "center" }}> <div className="flex items-center gap-3">
<span>{capture.status ?? t("subs.captureWaiting")}</span> <span className="text-term-text">{capture.status ?? t("subs.captureWaiting")}</span>
<button type="button" onClick={stopCapture}>{t("subs.cancel")}</button> <button type="button" className="btn btn-ghost btn-sm" onClick={stopCapture}>{t("subs.cancel")}</button>
</div> </div>
)} )}
</div> </div>
)} )}
<p style={{ color: "#777", fontSize: "0.85em", margin: "0.5rem 0 0" }}> <p className="hint mt-3">{t("subs.needCredentialOrPlate")}</p>
{t("subs.needCredentialOrPlate")}
</p>
<div style={{ marginTop: "1rem", display: "flex", gap: "0.5rem" }}> <div className="mt-4 flex items-center gap-2">
<button type="button" onClick={save}>{t("subs.save")}</button> <button type="button" className="btn btn-primary btn-sm" onClick={save}>{t("subs.save")}</button>
<button type="button" onClick={() => setEditing(null)}>{t("subs.cancel")}</button> <button type="button" className="btn btn-sm" onClick={() => setEditing(null)}>{t("subs.cancel")}</button>
</div> </div>
</div> </Modal>
)} {msg && <p className={msg.kind === "ok" ? "mt-3 text-[12px] text-term-green" : "mt-3 text-[12px] text-term-red"}>{msg.text}</p>}
{msg && <p style={{ color: msg.kind === "ok" ? "#16a34a" : "crimson" }}>{msg.text}</p>}
</section> </section>
); );
} }
+45
View File
@@ -0,0 +1,45 @@
import * as Dialog from "@radix-ui/react-dialog";
import type { ReactNode } from "react";
// Reusable modal shell — a thin wrapper over Radix Dialog matching the terminal
// chrome (title bar + ✕, dark overlay, square panel). The same styling BoothPayModal
// uses inline, factored out so every popped-out form looks identical. Radix handles
// focus trap, Escape, and outside-click → onClose. `width` is a Tailwind max-width
// class (the panel is responsive: w-full up to that cap).
export function Modal({
open,
onClose,
title,
children,
width = "max-w-xl",
}: {
open: boolean;
onClose: () => void;
title: ReactNode;
children: ReactNode;
/** Tailwind max-width class for the panel (default max-w-xl). */
width?: string;
}) {
return (
<Dialog.Root open={open} onOpenChange={(o) => !o && onClose()}>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 z-40 bg-black/70" />
<Dialog.Content
className={`fixed left-1/2 top-1/2 z-50 max-h-[90vh] w-[95vw] ${width} -translate-x-1/2 -translate-y-1/2 overflow-y-auto rounded-term border border-term-border bg-term-panel font-mono text-term-text shadow-2xl`}
aria-describedby={undefined}
>
<div className="sticky top-0 flex items-center justify-between border-b border-term-border bg-term-panel-2 px-4 py-2">
<Dialog.Title className="m-0 text-[12px] font-semibold uppercase tracking-wider text-term-amber">
{title}
</Dialog.Title>
<Dialog.Close className="text-term-muted hover:text-term-text" aria-label="Close">
✕
</Dialog.Close>
</div>
<div className="p-4">{children}</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}