update User, User group, IO modules
Some checks failed
Test / test (push) Has been cancelled

This commit is contained in:
2026-06-23 11:06:26 +07:00
parent 523f98b74c
commit 50a2587cef
32 changed files with 4728 additions and 52 deletions

View File

@@ -12,6 +12,9 @@ const pageMissionsEl = el("pageMissions");
const pageIntegrationsEl = el("pageIntegrations");
const pageSoundsEl = el("pageSounds");
const pageTransitionsEl = el("pageTransitions");
const pageUsersEl = el("pageUsers");
const pageUserGroupsEl = el("pageUserGroups");
const pageIoModulesEl = el("pageIoModules");
const pageMonitoringEl = el("pageMonitoring");
const pageHelpEl = el("pageHelp");
const contentEl = document.querySelector(".content");
@@ -126,7 +129,7 @@ const state = {
};
function setActivePage(page) {
const valid = ["dashboard", "config", "maps", "missions", "sounds", "transitions", "integrations", "monitoring", "help"];
const valid = ["dashboard", "config", "maps", "missions", "sounds", "transitions", "user-groups", "io-modules", "users", "integrations", "monitoring", "help"];
let p = valid.includes(page) ? page : "missions";
if (window.AuthApp && !window.AuthApp.canAccessPage(p)) {
const fallback = valid.find((v) => window.AuthApp.canAccessPage(v));
@@ -139,6 +142,9 @@ function setActivePage(page) {
if (pageMissionsEl) pageMissionsEl.hidden = p !== "missions";
if (pageSoundsEl) pageSoundsEl.hidden = p !== "sounds";
if (pageTransitionsEl) pageTransitionsEl.hidden = p !== "transitions";
if (pageUserGroupsEl) pageUserGroupsEl.hidden = p !== "user-groups";
if (pageIoModulesEl) pageIoModulesEl.hidden = p !== "io-modules";
if (pageUsersEl) pageUsersEl.hidden = p !== "users";
if (pageIntegrationsEl) pageIntegrationsEl.hidden = p !== "integrations";
if (pageMonitoringEl) pageMonitoringEl.hidden = p !== "monitoring";
if (pageHelpEl) pageHelpEl.hidden = p !== "help";
@@ -151,6 +157,9 @@ function setActivePage(page) {
contentEl.classList.toggle("content--missions", p === "missions");
contentEl.classList.toggle("content--sounds", p === "sounds");
contentEl.classList.toggle("content--transitions", p === "transitions");
contentEl.classList.toggle("content--user-groups", p === "user-groups");
contentEl.classList.toggle("content--io-modules", p === "io-modules");
contentEl.classList.toggle("content--users", p === "users");
contentEl.classList.toggle("content--integrations", p === "integrations");
contentEl.classList.toggle("content--monitoring", p === "monitoring");
contentEl.classList.toggle("content--help", p === "help");
@@ -162,6 +171,12 @@ function setActivePage(page) {
else if (window.SoundsApp?.onPageHide) window.SoundsApp.onPageHide();
if (p === "transitions" && window.TransitionsApp) window.TransitionsApp.onPageShow();
else if (window.TransitionsApp?.onPageHide) window.TransitionsApp.onPageHide();
if (p === "user-groups" && window.UserGroupsApp) window.UserGroupsApp.onPageShow();
else if (window.UserGroupsApp?.onPageHide) window.UserGroupsApp.onPageHide();
if (p === "io-modules" && window.IoModulesApp) window.IoModulesApp.onPageShow();
else if (window.IoModulesApp?.onPageHide) window.IoModulesApp.onPageHide();
if (p === "users" && window.UsersApp) window.UsersApp.onPageShow();
else if (window.UsersApp?.onPageHide) window.UsersApp.onPageHide();
if (p === "dashboard" && window.DashboardApp) window.DashboardApp.onPageShow();
else if (window.DashboardApp?.onPageHide) window.DashboardApp.onPageHide();
if (p === "integrations" && window.IntegrationsApp) window.IntegrationsApp.onPageShow();

View File

@@ -151,6 +151,9 @@
missions: "missions",
sounds: "sounds",
transitions: "maps",
"user-groups": "users",
"io-modules": "integrations",
users: "users",
integrations: "integrations",
};
const resource = map[page];
@@ -171,6 +174,9 @@
document.body.classList.toggle("auth-readonly-missions", !canWrite("missions"));
document.body.classList.toggle("auth-readonly-sounds", !canWrite("sounds") && !canWrite("integrations"));
document.body.classList.toggle("auth-readonly-integrations", !canWrite("integrations"));
document.body.classList.toggle("auth-readonly-users", !canWrite("users"));
document.body.classList.toggle("auth-readonly-user-groups", !canWrite("users"));
document.body.classList.toggle("auth-readonly-io-modules", !canWrite("integrations"));
}
function updateUserMenu() {

View File

@@ -56,6 +56,7 @@
userGroups: [],
maps: [],
mapsLoaded: false,
ioModules: [],
robotPose: null,
robotPoseAt: 0,
mapPollTimer: null,
@@ -277,6 +278,167 @@
});
}
async function ensureIoModulesLoaded() {
if (store.ioModules.length) return store.ioModules;
try {
if (window.IoModulesCatalog?.refresh) {
store.ioModules = await window.IoModulesCatalog.refresh();
} else {
const res = await fetch("/api/io_modules", { credentials: "include" });
if (res.ok) {
const data = await res.json();
store.ioModules = Array.isArray(data.io_modules) ? data.io_modules : [];
}
}
} catch {
store.ioModules = [];
}
return store.ioModules;
}
function ioModuleOptions(selectedId) {
const list = store.ioModules || [];
if (!list.length) return `<option value="">${escapeHtml(t("ioModules.empty"))}</option>`;
return list
.map(
(m) =>
`<option value="${escapeHtml(m.id)}" ${m.id === selectedId ? "selected" : ""}>${escapeHtml(m.name || m.id)}</option>`
)
.join("");
}
function ioModuleById(id) {
return (store.ioModules || []).find((m) => m.id === id) || null;
}
async function setIoModuleConnected(id, connect) {
const path = connect ? "connect" : "disconnect";
const res = await fetch(`/api/io_modules/${encodeURIComponent(id)}/${path}`, {
method: "POST",
credentials: "include",
});
if (!res.ok) {
const text = await res.text();
throw new Error(text || res.statusText);
}
const data = await res.json();
const idx = store.ioModules.findIndex((m) => m.id === id);
if (idx >= 0) store.ioModules[idx] = data;
return data;
}
function renderIoConnectWidget(widget, bodyEl) {
if (!bodyEl) return;
const mod = ioModuleById(widget.io_module_id);
const connected = !!mod?.connected;
const label = widget.title || mod?.name || t("dashboard.widget.io_connect");
bodyEl.innerHTML = `
<button type="button" class="dashboardMirMissionBtn dashboardIoConnectBtn" data-io-connect="${escapeHtml(widget.io_module_id || "")}">
${escapeHtml(connected ? t("dashboard.widget.io_disconnect") : t("dashboard.widget.io_connectAction"))}
</button>
<p class="mutedNote dashboardIoWidgetMeta">${escapeHtml(label)} · ${escapeHtml(mod ? (mod.connected ? t("ioModules.status.connected") : t("ioModules.status.disconnected")) : "—")}</p>`;
const btn = bodyEl.querySelector("[data-io-connect]");
btn?.addEventListener("click", async () => {
if (!widget.io_module_id) return;
try {
await setIoModuleConnected(widget.io_module_id, !connected);
renderIoConnectWidget(widget, bodyEl);
} catch (e) {
alert(e.message);
}
});
}
function renderIoStatusWidget(widget, bodyEl) {
if (!bodyEl) return;
const mod = ioModuleById(widget.io_module_id);
const label = widget.title || mod?.name || t("dashboard.widget.io_status");
bodyEl.innerHTML = `
<div class="dashboardIoStatusCard">
<div class="dashboardIoStatusName">${escapeHtml(label)}</div>
<div class="dashboardIoStatusLine">${escapeHtml(mod?.module_type ? t(`ioModules.type.${mod.module_type}`) : "—")} · ${escapeHtml(mod?.ip_address || "—")}</div>
<div class="dashboardIoStatusBadge ${mod?.connected ? "is-on" : "is-off"}">${escapeHtml(mod ? (mod.connected ? t("ioModules.status.connected") : t("ioModules.status.disconnected")) : "—")}</div>
</div>`;
}
async function fetchIoRuntime(moduleId) {
const res = await fetch(`/api/io_modules/${encodeURIComponent(moduleId)}/runtime`, { credentials: "include" });
if (!res.ok) throw new Error(res.statusText);
return res.json();
}
async function setIoOutput(moduleId, port, value, timeoutMs = 0) {
const res = await fetch(`/api/io_modules/${encodeURIComponent(moduleId)}/output`, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ output: port, value, timeout_ms: timeoutMs }),
});
if (!res.ok) {
const text = await res.text();
throw new Error(text || res.statusText);
}
return res.json();
}
function ioPortRange(mod) {
return mod?.module_type === "wise" ? [0, 1, 2, 3] : [1, 2, 3, 4];
}
function renderIoConfigurationWidget(widget, bodyEl) {
if (!bodyEl) return;
const mod = ioModuleById(widget.io_module_id);
const label = widget.title || mod?.name || t("dashboard.widget.io_configuration");
if (!mod) {
bodyEl.innerHTML = `<p class="mutedNote">${escapeHtml(t("dashboard.widget.configHint"))}</p>`;
return;
}
const ports = ioPortRange(mod);
bodyEl.innerHTML = `
<div class="dashboardIoConfigHead">
<div class="dashboardIoStatusName">${escapeHtml(label)}</div>
<div class="dashboardIoStatusBadge ${mod.connected ? "is-on" : "is-off"}">${escapeHtml(mod.connected ? t("ioModules.status.connected") : t("ioModules.status.disconnected"))}</div>
</div>
<div class="dashboardIoConfigPorts" data-io-config-ports></div>`;
const portsEl = bodyEl.querySelector("[data-io-config-ports]");
ports.forEach((port) => {
const row = document.createElement("div");
row.className = "dashboardIoConfigRow";
row.innerHTML = `
<span class="dashboardIoConfigPortLabel">${escapeHtml(t("dashboard.widget.ioPort", { port }))}</span>
<button type="button" class="dashboardIoConfigToggle" data-port="${port}" data-value="off">${escapeHtml(t("dashboard.widget.ioOff"))}</button>
<button type="button" class="dashboardIoConfigToggle is-primary" data-port="${port}" data-value="on">${escapeHtml(t("dashboard.widget.ioOn"))}</button>`;
portsEl.appendChild(row);
});
const refresh = async () => {
try {
const rt = await fetchIoRuntime(mod.id);
portsEl.querySelectorAll("[data-port]").forEach((btn) => {
const port = Number(btn.dataset.port);
const outputs = Array.isArray(rt.outputs) ? rt.outputs : [];
const idx = mod.module_type === "wise" ? port : port;
const on = !!outputs[idx];
btn.classList.toggle("is-active", (btn.dataset.value === "on") === on);
});
} catch {
/* ignore */
}
};
portsEl.addEventListener("click", async (evt) => {
const btn = evt.target.closest("[data-port]");
if (!btn || !widget.io_module_id) return;
const port = Number(btn.dataset.port);
const on = btn.dataset.value === "on";
try {
await setIoOutput(widget.io_module_id, port, on);
await refresh();
} catch (e) {
alert(e.message);
}
});
void refresh();
}
function widgetTypeLabel(type) {
return t(`dashboard.widget.${type}`) || type;
}
@@ -986,6 +1148,16 @@
<label>${t("dashboard.widget.field.title")}</label>
<input data-field="title" type="text" value="${escapeHtml(widget.title || t("dashboard.widget.robot_summary"))}" />
</div>`;
} else if (type === "io_connect" || type === "io_status" || type === "io_configuration") {
container.innerHTML = `
<div class="row rowWide">
<label>${t("dashboard.widget.field.ioModule")}</label>
<select data-field="io_module_id">${ioModuleOptions(widget.io_module_id || "")}</select>
</div>
<div class="row rowWide">
<label>${t("dashboard.widget.field.title")}</label>
<input data-field="title" type="text" value="${escapeHtml(widget.title || "")}" placeholder="${escapeHtml(widgetTypeLabel(type))}" />
</div>`;
}
}
@@ -1209,6 +1381,15 @@
case "robot_summary":
renderRobotSummaryWidget(widget, bodyEl);
break;
case "io_connect":
renderIoConnectWidget(widget, bodyEl);
break;
case "io_status":
renderIoStatusWidget(widget, bodyEl);
break;
case "io_configuration":
renderIoConfigurationWidget(widget, bodyEl);
break;
default:
bodyEl.innerHTML = `<p class="mutedNote">${t("dashboard.widget.unsupported")}</p>`;
}
@@ -1245,6 +1426,9 @@
if (widget.type === "mission_queue") refreshQueueWidget(bodyEl);
if (widget.type === "pause_continue") renderPauseContinueWidget(widget, bodyEl);
if (widget.type === "mission_action_log") renderMissionActionLogWidget(widget, bodyEl);
if (widget.type === "io_connect") renderIoConnectWidget(widget, bodyEl);
if (widget.type === "io_status") renderIoStatusWidget(widget, bodyEl);
if (widget.type === "io_configuration") renderIoConfigurationWidget(widget, bodyEl);
});
if (hasMapWidget()) refreshMapWidgets();
}
@@ -1260,6 +1444,8 @@
};
if (widget.type === "map" || widget.type === "map_locked") {
void ensureMapsLoaded().then(open);
} else if (widget.type === "io_connect" || widget.type === "io_status" || widget.type === "io_configuration") {
void ensureIoModulesLoaded().then(open);
} else {
open();
}
@@ -1484,6 +1670,7 @@
async function init() {
await loadStoreFromBackend();
await loadUserGroups();
await ensureIoModulesLoaded();
bindEvents();
setView("list");
}
@@ -1493,6 +1680,7 @@
getNavItems,
handleNav,
onPageShow() {
void ensureIoModulesLoaded();
if (store.view === "designer") {
syncDesignerEditMode();
renderDesignerChrome();

View File

@@ -74,6 +74,9 @@
"nav.maps": "Maps",
"nav.sounds": "Sounds",
"nav.transitions": "Transitions",
"nav.user-groups": "User groups",
"nav.io-modules": "I/O modules",
"nav.users": "Users",
"nav.build-robot": "Build Robot",
"nav.monitoring-log": "System log",
"nav.integrations": "Tích hợp",
@@ -194,6 +197,15 @@
"dashboard.widget.map_locked": "Locked map",
"dashboard.widget.map": "Map",
"dashboard.widget.robot_summary": "Robot summary",
"dashboard.widget.io_connect": "Connect I/O module",
"dashboard.widget.io_status": "I/O status",
"dashboard.widget.io_connectAction": "Connect",
"dashboard.widget.io_disconnect": "Disconnect",
"dashboard.widget.io_configuration": "I/O configuration",
"dashboard.widget.ioPort": "Port {port}",
"dashboard.widget.ioOn": "ON",
"dashboard.widget.ioOff": "OFF",
"dashboard.widget.field.ioModule": "I/O module",
"dashboard.widget.field.map": "Map",
"dashboard.widget.mapActive": "Active map (robot)",
"dashboard.widget.mapHint": "Chọn map cố định hoặc để «Active map» dùng map đang gắn với robot.",
@@ -304,6 +316,8 @@
"maps.activeHint": "Map đang hoạt động: {name}",
"maps.view": "Xem",
"maps.importComingSoon": "Import site sẽ có trong phiên bản sau.",
"maps.importNoSite": "Chọn site trước khi import.",
"maps.importSuccess": "Import xong: {maps} map(s), {io} I/O module(s), {transitions} transition(s).",
"maps.helpTitle": "Trợ giúp Maps",
"maps.helpText": "Tạo map mới, upload ảnh PNG qua menu ⋮, sau đó kích hoạt map cho robot.",
"maps.createDialog.title": "Tạo map",
@@ -555,6 +569,129 @@
"transitions.deleteConfirmText": "Xóa transition {from} → {to}?",
"transitions.error.missing": "Thiếu trường bắt buộc.",
"users.title": "Users",
"users.subtitle": "Tạo và chỉnh sửa users.",
"users.helpTitle": "Trợ giúp Users",
"users.helpBody": "Mỗi người cần user profile. Name hiển thị góc phải khi đăng nhập; Username dùng để login. Quyền theo User group (§4.6). PIN 4 số chỉ cho nhóm cho phép. User tự đổi password qua menu góc phải.",
"users.create": "Tạo user",
"users.clearFilters": "Xóa bộ lọc",
"users.filterLabel": "Lọc:",
"users.filterPlaceholder": "Lọc theo tên hoặc username...",
"users.itemsFound": "{n} mục",
"users.pageOf": "Trang {page} / {total}",
"users.colName": "Name",
"users.colUsername": "Username",
"users.colGroup": "User group",
"users.colEmail": "Email",
"users.colPin": "PIN",
"users.colFunctions": "Functions",
"users.empty": "Chưa có user.",
"users.emptyFilter": "Không có user khớp bộ lọc.",
"users.createTitle": "Tạo user",
"users.editTitle": "Sửa user",
"users.fieldName": "Name",
"users.fieldUsername": "Username",
"users.fieldPassword": "Password",
"users.fieldEmail": "Email address",
"users.fieldGroup": "User group",
"users.fieldEnabled": "Enabled",
"users.fieldPinEnable": "Cho phép đăng nhập PIN",
"users.fieldPin": "Mã PIN (4 số)",
"users.passwordEditHint": "User tự đổi password qua menu góc phải trên.",
"users.pinGroupHint": "PIN chỉ dùng được với user group cho phép PIN.",
"users.pinYes": "Có",
"users.pinNo": "Không",
"users.save": "Lưu",
"users.deleteTitle": "Xóa user?",
"users.deleteConfirmText": "Xóa user \"{name}\"?",
"users.deleteHint": "Chỉ xóa hồ sơ user. Map, mission và dữ liệu khác do user tạo vẫn giữ nguyên.",
"users.error.missing": "Thiếu trường bắt buộc.",
"users.error.passwordRequired": "Nhập password khi tạo user.",
"users.error.pinInvalid": "PIN phải đúng 4 chữ số.",
"userGroups.title": "User groups",
"userGroups.subtitle": "Tạo và chỉnh sửa user groups.",
"userGroups.helpTitle": "Trợ giúp User groups",
"userGroups.helpBody": "Mỗi user thuộc một user group. Quyền (none/read/write) áp dụng cho từng module. Tạo group trước khi tạo user. PIN chỉ dùng khi bật Allow PIN sign-in.",
"userGroups.create": "Tạo user group",
"userGroups.clearFilters": "Xóa bộ lọc",
"userGroups.filterLabel": "Lọc:",
"userGroups.filterPlaceholder": "Lọc theo tên...",
"userGroups.itemsFound": "{n} mục",
"userGroups.pageOf": "Trang {page} / {total}",
"userGroups.colName": "Name",
"userGroups.colUsers": "Users",
"userGroups.colPin": "PIN sign-in",
"userGroups.colPermissions": "Permissions",
"userGroups.colFunctions": "Functions",
"userGroups.empty": "Chưa có user group.",
"userGroups.emptyFilter": "Không có user group khớp bộ lọc.",
"userGroups.createTitle": "Tạo user group",
"userGroups.editTitle": "Sửa user group",
"userGroups.fieldName": "Name",
"userGroups.fieldAllowPin": "Cho phép đăng nhập PIN",
"userGroups.permissionsTitle": "Permissions",
"userGroups.permissionsHint": "Gán quyền read hoặc write cho từng module. None ẩn module khỏi menu.",
"userGroups.permColModule": "Module",
"userGroups.permColAccess": "Access",
"userGroups.perm.dashboard": "Dashboards",
"userGroups.perm.config": "Build robot",
"userGroups.perm.maps": "Maps",
"userGroups.perm.missions": "Missions",
"userGroups.perm.sounds": "Sounds",
"userGroups.perm.integrations": "Integrations",
"userGroups.perm.users": "Users & User groups",
"userGroups.permLevel.none": "None",
"userGroups.permLevel.read": "Read",
"userGroups.permLevel.write": "Write",
"userGroups.permSummaryAllWrite": "Full write",
"userGroups.permSummaryNone": "No access",
"userGroups.permSummaryMixed": "{write} write, {read} read",
"userGroups.pinYes": "Có",
"userGroups.pinNo": "Không",
"userGroups.userCount": "{n} user(s)",
"userGroups.save": "Lưu",
"userGroups.deleteTitle": "Xóa user group?",
"userGroups.deleteConfirmText": "Xóa user group \"{name}\"?",
"userGroups.deleteHint": "Phải chuyển users sang group khác trước khi xóa.",
"userGroups.error.missing": "Thiếu trường bắt buộc.",
"ioModules.title": "I/O modules",
"ioModules.subtitle": "Tạo và quản lý kết nối I/O.",
"ioModules.helpTitle": "Trợ giúp I/O modules",
"ioModules.helpBody": "I/O module (Bluetooth hoặc WISE) dùng để giao tiếp cửa, thang nâng, băng tải… Nhập IP và chọn loại module. Dùng trong mission (Set output / Wait for input) và I/O zones trên map.",
"ioModules.create": "Tạo I/O connection",
"ioModules.clearFilters": "Xóa bộ lọc",
"ioModules.filterLabel": "Lọc:",
"ioModules.filterPlaceholder": "Lọc theo tên hoặc IP...",
"ioModules.itemsFound": "{n} mục",
"ioModules.pageOf": "Trang {page} / {total}",
"ioModules.colName": "Name",
"ioModules.colType": "Type",
"ioModules.colIp": "IP address",
"ioModules.colStatus": "Status",
"ioModules.colFunctions": "Functions",
"ioModules.empty": "Chưa có I/O module.",
"ioModules.emptyFilter": "Không có I/O module khớp bộ lọc.",
"ioModules.createTitle": "Tạo I/O connection",
"ioModules.editTitle": "Sửa I/O connection",
"ioModules.fieldSite": "Site",
"ioModules.fieldName": "Name",
"ioModules.fieldType": "I/O module type",
"ioModules.fieldIp": "IP address",
"ioModules.type.bluetooth": "Bluetooth",
"ioModules.type.wise": "WISE",
"ioModules.typeHint.bluetooth": "Output ports 14.",
"ioModules.typeHint.wise": "Output ports 03.",
"ioModules.status.connected": "Connected",
"ioModules.status.disconnected": "Disconnected",
"ioModules.save": "Lưu",
"ioModules.deleteTitle": "Xóa I/O connection?",
"ioModules.deleteConfirmText": "Xóa I/O module \"{name}\"?",
"ioModules.error.missing": "Thiếu trường bắt buộc.",
"ioModules.testConnection": "Kiểm tra kết nối",
"ioModules.testOk": "Kết nối TCP thành công.",
"missions.title": "Missions",
"missions.subtitle": "Setup → Missions — danh sách nhiệm vụ robot.",
"missions.create": "Tạo mission",
@@ -618,6 +755,9 @@
"missions.action.pause": "Pause",
"missions.action.set_digital_output": "Set digital output",
"missions.action.wait_digital_input": "Wait for digital input",
"missions.action.connect_bluetooth": "Connect Bluetooth module",
"missions.action.disconnect_bluetooth": "Disconnect Bluetooth module",
"missions.action.while": "While",
"missions.action.set_plc_register": "Set PLC register",
"missions.action.pick_cart": "Pick cart",
"missions.action.drop_cart": "Drop cart",
@@ -745,6 +885,9 @@
"nav.maps": "Maps",
"nav.sounds": "Sounds",
"nav.transitions": "Transitions",
"nav.user-groups": "User groups",
"nav.io-modules": "I/O modules",
"nav.users": "Users",
"nav.build-robot": "Build Robot",
"nav.monitoring-log": "System log",
"nav.integrations": "Integrations",
@@ -865,6 +1008,15 @@
"dashboard.widget.map_locked": "Locked map",
"dashboard.widget.map": "Map",
"dashboard.widget.robot_summary": "Robot summary",
"dashboard.widget.io_connect": "Connect I/O module",
"dashboard.widget.io_status": "I/O status",
"dashboard.widget.io_connectAction": "Connect",
"dashboard.widget.io_disconnect": "Disconnect",
"dashboard.widget.io_configuration": "I/O configuration",
"dashboard.widget.ioPort": "Port {port}",
"dashboard.widget.ioOn": "ON",
"dashboard.widget.ioOff": "OFF",
"dashboard.widget.field.ioModule": "I/O module",
"dashboard.widget.field.map": "Map",
"dashboard.widget.mapActive": "Active map (robot)",
"dashboard.widget.mapHint": "Pick a fixed map or leave «Active map» to follow the robot's current map.",
@@ -975,6 +1127,8 @@
"maps.activeHint": "Active map: {name}",
"maps.view": "View",
"maps.importComingSoon": "Import site will be available in a future release.",
"maps.importNoSite": "Select a site before importing.",
"maps.importSuccess": "Import complete: {maps} map(s), {io} I/O module(s), {transitions} transition(s).",
"maps.helpTitle": "Maps help",
"maps.helpText": "Create a new map, upload a PNG via the ⋮ menu, then activate the map for the robot.",
"maps.createDialog.title": "Create map",
@@ -1226,6 +1380,129 @@
"transitions.deleteConfirmText": "Delete transition {from} → {to}?",
"transitions.error.missing": "Missing required fields.",
"users.title": "Users",
"users.subtitle": "Create and edit users.",
"users.helpTitle": "Users help",
"users.helpBody": "Everyone needs a user profile. Name is shown in the upper right when signed in; Username is used to log in. Permissions come from the user group (§4.6). PIN is only for groups that allow PIN sign-in. Users change their own password from the menu in the upper right.",
"users.create": "Create user",
"users.clearFilters": "Clear filters",
"users.filterLabel": "Filter:",
"users.filterPlaceholder": "Filter by name or username...",
"users.itemsFound": "{n} item(s) found",
"users.pageOf": "Page {page} of {total}",
"users.colName": "Name",
"users.colUsername": "Username",
"users.colGroup": "User group",
"users.colEmail": "Email",
"users.colPin": "PIN",
"users.colFunctions": "Functions",
"users.empty": "No users yet.",
"users.emptyFilter": "No users match the filter.",
"users.createTitle": "Create user",
"users.editTitle": "Edit user",
"users.fieldName": "Name",
"users.fieldUsername": "Username",
"users.fieldPassword": "Password",
"users.fieldEmail": "Email address",
"users.fieldGroup": "User group",
"users.fieldEnabled": "Enabled",
"users.fieldPinEnable": "Allow PIN sign-in",
"users.fieldPin": "PIN code (4 digits)",
"users.passwordEditHint": "Users change their own password from the menu in the upper right corner.",
"users.pinGroupHint": "PIN is only available for user groups that allow PIN sign-in.",
"users.pinYes": "Yes",
"users.pinNo": "No",
"users.save": "Save",
"users.deleteTitle": "Delete user?",
"users.deleteConfirmText": "Delete user \"{name}\"?",
"users.deleteHint": "Only the user profile is removed. Maps, missions and other data created by this user remain unchanged.",
"users.error.missing": "Missing required fields.",
"users.error.passwordRequired": "Enter a password when creating a user.",
"users.error.pinInvalid": "PIN must be exactly 4 digits.",
"userGroups.title": "User groups",
"userGroups.subtitle": "Create and edit user groups.",
"userGroups.helpTitle": "User groups help",
"userGroups.helpBody": "Each user belongs to one user group. Permissions (none/read/write) apply per module. Create groups before users. PIN sign-in works only when Allow PIN sign-in is enabled.",
"userGroups.create": "Create user group",
"userGroups.clearFilters": "Clear filters",
"userGroups.filterLabel": "Filter:",
"userGroups.filterPlaceholder": "Filter by name...",
"userGroups.itemsFound": "{n} item(s) found",
"userGroups.pageOf": "Page {page} of {total}",
"userGroups.colName": "Name",
"userGroups.colUsers": "Users",
"userGroups.colPin": "PIN sign-in",
"userGroups.colPermissions": "Permissions",
"userGroups.colFunctions": "Functions",
"userGroups.empty": "No user groups yet.",
"userGroups.emptyFilter": "No user groups match the filter.",
"userGroups.createTitle": "Create user group",
"userGroups.editTitle": "Edit user group",
"userGroups.fieldName": "Name",
"userGroups.fieldAllowPin": "Allow PIN sign-in",
"userGroups.permissionsTitle": "Permissions",
"userGroups.permissionsHint": "Set read or write access per module. None hides the module from the menu.",
"userGroups.permColModule": "Module",
"userGroups.permColAccess": "Access",
"userGroups.perm.dashboard": "Dashboards",
"userGroups.perm.config": "Build robot",
"userGroups.perm.maps": "Maps",
"userGroups.perm.missions": "Missions",
"userGroups.perm.sounds": "Sounds",
"userGroups.perm.integrations": "Integrations",
"userGroups.perm.users": "Users & User groups",
"userGroups.permLevel.none": "None",
"userGroups.permLevel.read": "Read",
"userGroups.permLevel.write": "Write",
"userGroups.permSummaryAllWrite": "Full write",
"userGroups.permSummaryNone": "No access",
"userGroups.permSummaryMixed": "{write} write, {read} read",
"userGroups.pinYes": "Yes",
"userGroups.pinNo": "No",
"userGroups.userCount": "{n} user(s)",
"userGroups.save": "Save",
"userGroups.deleteTitle": "Delete user group?",
"userGroups.deleteConfirmText": "Delete user group \"{name}\"?",
"userGroups.deleteHint": "Users must be moved to another group before this group can be deleted.",
"userGroups.error.missing": "Missing required fields.",
"ioModules.title": "I/O modules",
"ioModules.subtitle": "Create and manage I/O connections.",
"ioModules.helpTitle": "I/O modules help",
"ioModules.helpBody": "I/O modules (Bluetooth or WISE) communicate with doors, lifts, conveyors, etc. Enter the IP and module type. Use in missions (Set output / Wait for input) and I/O zones on maps.",
"ioModules.create": "Create I/O connection",
"ioModules.clearFilters": "Clear filters",
"ioModules.filterLabel": "Filter:",
"ioModules.filterPlaceholder": "Filter by name or IP...",
"ioModules.itemsFound": "{n} item(s) found",
"ioModules.pageOf": "Page {page} of {total}",
"ioModules.colName": "Name",
"ioModules.colType": "Type",
"ioModules.colIp": "IP address",
"ioModules.colStatus": "Status",
"ioModules.colFunctions": "Functions",
"ioModules.empty": "No I/O modules yet.",
"ioModules.emptyFilter": "No I/O modules match the filter.",
"ioModules.createTitle": "Create I/O connection",
"ioModules.editTitle": "Edit I/O connection",
"ioModules.fieldSite": "Site",
"ioModules.fieldName": "Name",
"ioModules.fieldType": "I/O module type",
"ioModules.fieldIp": "IP address",
"ioModules.type.bluetooth": "Bluetooth",
"ioModules.type.wise": "WISE",
"ioModules.typeHint.bluetooth": "Output ports 14.",
"ioModules.typeHint.wise": "Output ports 03.",
"ioModules.status.connected": "Connected",
"ioModules.status.disconnected": "Disconnected",
"ioModules.save": "Save",
"ioModules.deleteTitle": "Delete I/O connection?",
"ioModules.deleteConfirmText": "Delete I/O module \"{name}\"?",
"ioModules.error.missing": "Missing required fields.",
"ioModules.testConnection": "Test connection",
"ioModules.testOk": "TCP connection successful.",
"missions.title": "Missions",
"missions.subtitle": "Setup → Missions — robot task list.",
"missions.create": "Create mission",
@@ -1289,6 +1566,9 @@
"missions.action.pause": "Pause",
"missions.action.set_digital_output": "Set digital output",
"missions.action.wait_digital_input": "Wait for digital input",
"missions.action.connect_bluetooth": "Connect Bluetooth module",
"missions.action.disconnect_bluetooth": "Disconnect Bluetooth module",
"missions.action.while": "While",
"missions.action.set_plc_register": "Set PLC register",
"missions.action.pick_cart": "Pick cart",
"missions.action.drop_cart": "Drop cart",

View File

@@ -358,7 +358,9 @@
<p class="dashboardMirWidgetPanelNote" data-i18n="dashboard.menu.comingSoon">Widget nhóm này sẽ có trong bản cập nhật sau.</p>
</div>
<div class="dashboardMirWidgetPanel" data-panel="io" hidden>
<p class="dashboardMirWidgetPanelNote" data-i18n="dashboard.menu.comingSoon">I/O widgets — sắp có.</p>
<button type="button" class="dashboardMirWidgetPick" data-add-widget="io_connect" data-i18n="dashboard.widget.io_connect">Connect I/O module</button>
<button type="button" class="dashboardMirWidgetPick" data-add-widget="io_status" data-i18n="dashboard.widget.io_status">I/O status</button>
<button type="button" class="dashboardMirWidgetPick" data-add-widget="io_configuration" data-i18n="dashboard.widget.io_configuration">I/O configuration</button>
</div>
<div class="dashboardMirWidgetPanel" data-panel="misc" hidden>
<button type="button" class="dashboardMirWidgetPick" data-add-widget="robot_summary" data-i18n="dashboard.widget.robot_summary">Robot summary</button>
@@ -1069,6 +1071,323 @@
</dialog>
</div>
<div class="page" id="pageUserGroups" data-page-content="user-groups" hidden>
<div id="userGroupsListView" class="mapsMirPage">
<header class="mapsMirHeader">
<div class="mapsMirHeaderText">
<h1 class="mapsMirTitle" data-i18n="userGroups.title">User groups</h1>
<p class="mapsMirSubtitle">
<span data-i18n="userGroups.subtitle">Create and edit user groups.</span>
<button type="button" class="mapsMirHelpBtn" id="userGroupsHelpBtn" data-i18n-title="userGroups.helpTitle" aria-label="Help">
<svg width="16" height="16" viewBox="0 0 16 16" aria-hidden="true"><circle cx="8" cy="8" r="7" fill="none" stroke="currentColor" stroke-width="1.5"/><text x="8" y="11.5" text-anchor="middle" font-size="10" font-weight="700" fill="currentColor">?</text></svg>
</button>
</p>
</div>
<div class="mapsMirHeaderActions">
<button type="button" class="mapsMirBtn mapsMirBtn--green" id="userGroupCreateBtn">
<svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true"><path d="M7 1v12M1 7h12" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
<span data-i18n="userGroups.create">Create user group</span>
</button>
<button type="button" class="mapsMirBtn mapsMirBtn--outline" id="userGroupsClearFiltersBtn">
<svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true"><circle cx="7" cy="7" r="5.5" fill="none" stroke="currentColor" stroke-width="1.3"/><path d="M4.5 4.5l5 5" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/></svg>
<span data-i18n="userGroups.clearFilters">Clear filters</span>
</button>
</div>
</header>
<div class="mapsMirFilterBar">
<label class="mapsMirFilterLabel" for="userGroupsFilterInput" data-i18n="userGroups.filterLabel">Filter:</label>
<input type="search" id="userGroupsFilterInput" class="mapsMirFilterInput" data-i18n-placeholder="userGroups.filterPlaceholder" placeholder="Filter by name..." autocomplete="off" />
<span id="userGroupsFilterCount" class="mapsMirFilterCount">0 item(s) found</span>
<div class="mapsMirPager">
<button type="button" class="mapsMirPageBtn" id="userGroupsPageFirst" aria-label="First page">&laquo;</button>
<button type="button" class="mapsMirPageBtn" id="userGroupsPagePrev" aria-label="Previous page">&lsaquo;</button>
<span id="userGroupsPageLabel" class="mapsMirPageLabel">Page 1 of 1</span>
<button type="button" class="mapsMirPageBtn" id="userGroupsPageNext" aria-label="Next page">&rsaquo;</button>
<button type="button" class="mapsMirPageBtn" id="userGroupsPageLast" aria-label="Last page">&raquo;</button>
</div>
</div>
<div class="mapsMirTableWrap">
<table class="mapsMirTable mapsMirTable--userGroups" id="userGroupsTable">
<thead>
<tr>
<th class="userGroupsMirThIcon" aria-hidden="true"></th>
<th data-i18n="userGroups.colName">Name</th>
<th data-i18n="userGroups.colUsers">Users</th>
<th data-i18n="userGroups.colPin">PIN sign-in</th>
<th data-i18n="userGroups.colPermissions">Permissions</th>
<th class="mapsMirThFunctions" data-i18n="userGroups.colFunctions">Functions</th>
</tr>
</thead>
<tbody id="userGroupList"></tbody>
</table>
<div id="userGroupListEmpty" class="mapsMirEmpty" hidden data-i18n="userGroups.empty">No user groups yet.</div>
</div>
</div>
<dialog id="userGroupEditDialog" class="mapsMirDialog mapsMirDialog--wide">
<form id="userGroupEditForm" method="dialog">
<h2 class="mapsMirDialogTitle" id="userGroupEditTitle" data-i18n="userGroups.createTitle">Create user group</h2>
<label class="mapsMirField">
<span class="mapsMirFieldLabel" data-i18n="userGroups.fieldName">Name</span>
<input type="text" id="userGroupEditName" required />
</label>
<label class="mapsMirField mapsMirField--checkbox">
<input type="checkbox" id="userGroupEditAllowPin" />
<span data-i18n="userGroups.fieldAllowPin">Allow PIN sign-in</span>
</label>
<div class="userGroupsPermSection">
<h3 class="userGroupsPermTitle" data-i18n="userGroups.permissionsTitle">Permissions</h3>
<p class="mapsMirFieldHint" data-i18n="userGroups.permissionsHint">Set read or write access per module. None hides the module from the menu.</p>
<table class="userGroupsPermTable">
<thead>
<tr>
<th data-i18n="userGroups.permColModule">Module</th>
<th data-i18n="userGroups.permColAccess">Access</th>
</tr>
</thead>
<tbody id="userGroupEditPermsBody"></tbody>
</table>
</div>
<div class="mapsMirDialogFooter">
<button type="button" class="mapsMirBtn mapsMirBtn--outline" id="userGroupEditCancelBtn" data-i18n="common.cancel">Cancel</button>
<button type="button" class="mapsMirBtn mapsMirBtn--danger" id="userGroupEditDeleteBtn" hidden data-i18n="common.delete">Delete</button>
<button type="submit" class="mapsMirBtn mapsMirBtn--green" id="userGroupEditSaveBtn" data-i18n="userGroups.save">Save</button>
</div>
</form>
</dialog>
<dialog id="userGroupDeleteConfirmDialog" class="mapsMirDialog">
<div class="mapsMirDialogPanel">
<h2 class="mapsMirDialogTitle" data-i18n="userGroups.deleteTitle">Delete user group?</h2>
<p id="userGroupDeleteConfirmText" class="mapsMirDialogText"></p>
<p class="mapsMirDialogHint" data-i18n="userGroups.deleteHint">Users must be moved to another group before this group can be deleted.</p>
<div class="mapsMirDialogFooter">
<button type="button" class="mapsMirBtn mapsMirBtn--outline" id="userGroupDeleteCancelBtn" data-i18n="common.no">No</button>
<button type="button" class="mapsMirBtn mapsMirBtn--danger" id="userGroupDeleteYesBtn" data-i18n="common.yes">Yes</button>
</div>
</div>
</dialog>
</div>
<div class="page" id="pageIoModules" data-page-content="io-modules" hidden>
<div id="ioModulesListView" class="mapsMirPage">
<header class="mapsMirHeader">
<div class="mapsMirHeaderText">
<h1 class="mapsMirTitle" data-i18n="ioModules.title">I/O modules</h1>
<p class="mapsMirSubtitle">
<span data-i18n="ioModules.subtitle">Create and manage I/O connections.</span>
<button type="button" class="mapsMirHelpBtn" id="ioModulesHelpBtn" data-i18n-title="ioModules.helpTitle" aria-label="Help">
<svg width="16" height="16" viewBox="0 0 16 16" aria-hidden="true"><circle cx="8" cy="8" r="7" fill="none" stroke="currentColor" stroke-width="1.5"/><text x="8" y="11.5" text-anchor="middle" font-size="10" font-weight="700" fill="currentColor">?</text></svg>
</button>
</p>
</div>
<div class="mapsMirHeaderActions">
<button type="button" class="mapsMirBtn mapsMirBtn--green" id="ioModuleCreateBtn">
<svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true"><path d="M7 1v12M1 7h12" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
<span data-i18n="ioModules.create">Create I/O connection</span>
</button>
<button type="button" class="mapsMirBtn mapsMirBtn--outline" id="ioModulesClearFiltersBtn">
<svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true"><circle cx="7" cy="7" r="5.5" fill="none" stroke="currentColor" stroke-width="1.3"/><path d="M4.5 4.5l5 5" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/></svg>
<span data-i18n="ioModules.clearFilters">Clear filters</span>
</button>
</div>
</header>
<div class="mapsMirFilterBar">
<label class="mapsMirFilterLabel" for="ioModulesFilterInput" data-i18n="ioModules.filterLabel">Filter:</label>
<input type="search" id="ioModulesFilterInput" class="mapsMirFilterInput" data-i18n-placeholder="ioModules.filterPlaceholder" placeholder="Filter by name or IP..." autocomplete="off" />
<span id="ioModulesFilterCount" class="mapsMirFilterCount">0 item(s) found</span>
<div class="mapsMirPager">
<button type="button" class="mapsMirPageBtn" id="ioModulesPageFirst" aria-label="First page">&laquo;</button>
<button type="button" class="mapsMirPageBtn" id="ioModulesPagePrev" aria-label="Previous page">&lsaquo;</button>
<span id="ioModulesPageLabel" class="mapsMirPageLabel">Page 1 of 1</span>
<button type="button" class="mapsMirPageBtn" id="ioModulesPageNext" aria-label="Next page">&rsaquo;</button>
<button type="button" class="mapsMirPageBtn" id="ioModulesPageLast" aria-label="Last page">&raquo;</button>
</div>
</div>
<div class="mapsMirTableWrap">
<table class="mapsMirTable mapsMirTable--ioModules" id="ioModulesTable">
<thead>
<tr>
<th class="ioModulesMirThIcon" aria-hidden="true"></th>
<th data-i18n="ioModules.colName">Name</th>
<th data-i18n="ioModules.colType">Type</th>
<th data-i18n="ioModules.colIp">IP address</th>
<th data-i18n="ioModules.colStatus">Status</th>
<th class="mapsMirThFunctions" data-i18n="ioModules.colFunctions">Functions</th>
</tr>
</thead>
<tbody id="ioModuleList"></tbody>
</table>
<div id="ioModuleListEmpty" class="mapsMirEmpty" hidden data-i18n="ioModules.empty">No I/O modules yet.</div>
</div>
</div>
<dialog id="ioModuleEditDialog" class="mapsMirDialog">
<form id="ioModuleEditForm" method="dialog">
<h2 class="mapsMirDialogTitle" id="ioModuleEditTitle" data-i18n="ioModules.createTitle">Create I/O connection</h2>
<label class="mapsMirField">
<span class="mapsMirFieldLabel" data-i18n="ioModules.fieldSite">Site</span>
<select id="ioModuleEditSite" required></select>
</label>
<label class="mapsMirField">
<span class="mapsMirFieldLabel" data-i18n="ioModules.fieldName">Name</span>
<input type="text" id="ioModuleEditName" required autocomplete="off" />
</label>
<label class="mapsMirField">
<span class="mapsMirFieldLabel" data-i18n="ioModules.fieldType">I/O module type</span>
<select id="ioModuleEditType" required>
<option value="bluetooth" data-i18n="ioModules.type.bluetooth">Bluetooth</option>
<option value="wise" data-i18n="ioModules.type.wise">WISE</option>
</select>
</label>
<p id="ioModuleEditTypeHint" class="mapsMirFieldHint" data-i18n="ioModules.typeHint.bluetooth">Output ports 14.</p>
<div class="mapsMirFieldRow mapsMirFieldRow--actions">
<button type="button" class="mapsMirBtn mapsMirBtn--outline" id="ioModuleTestBtn" data-i18n="ioModules.testConnection">Test connection</button>
</div>
<label class="mapsMirField">
<span class="mapsMirFieldLabel" data-i18n="ioModules.fieldIp">IP address</span>
<input type="text" id="ioModuleEditIp" required autocomplete="off" inputmode="decimal" />
</label>
<div class="mapsMirDialogFooter">
<button type="button" class="mapsMirBtn mapsMirBtn--outline" id="ioModuleEditCancelBtn" data-i18n="common.cancel">Cancel</button>
<button type="button" class="mapsMirBtn mapsMirBtn--danger" id="ioModuleEditDeleteBtn" hidden data-i18n="common.delete">Delete</button>
<button type="submit" class="mapsMirBtn mapsMirBtn--green" id="ioModuleEditSaveBtn" data-i18n="ioModules.save">Create</button>
</div>
</form>
</dialog>
<dialog id="ioModuleDeleteConfirmDialog" class="mapsMirDialog">
<div class="mapsMirDialogPanel">
<h2 class="mapsMirDialogTitle" data-i18n="ioModules.deleteTitle">Delete I/O connection?</h2>
<p id="ioModuleDeleteConfirmText" class="mapsMirDialogText"></p>
<div class="mapsMirDialogFooter">
<button type="button" class="mapsMirBtn mapsMirBtn--outline" id="ioModuleDeleteCancelBtn" data-i18n="common.no">No</button>
<button type="button" class="mapsMirBtn mapsMirBtn--danger" id="ioModuleDeleteYesBtn" data-i18n="common.yes">Yes</button>
</div>
</div>
</dialog>
</div>
<div class="page" id="pageUsers" data-page-content="users" hidden>
<div id="usersListView" class="mapsMirPage">
<header class="mapsMirHeader">
<div class="mapsMirHeaderText">
<h1 class="mapsMirTitle" data-i18n="users.title">Users</h1>
<p class="mapsMirSubtitle">
<span data-i18n="users.subtitle">Create and edit users.</span>
<button type="button" class="mapsMirHelpBtn" id="usersHelpBtn" data-i18n-title="users.helpTitle" aria-label="Help">
<svg width="16" height="16" viewBox="0 0 16 16" aria-hidden="true"><circle cx="8" cy="8" r="7" fill="none" stroke="currentColor" stroke-width="1.5"/><text x="8" y="11.5" text-anchor="middle" font-size="10" font-weight="700" fill="currentColor">?</text></svg>
</button>
</p>
</div>
<div class="mapsMirHeaderActions">
<button type="button" class="mapsMirBtn mapsMirBtn--green" id="userCreateBtn">
<svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true"><path d="M7 1v12M1 7h12" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
<span data-i18n="users.create">Create user</span>
</button>
<button type="button" class="mapsMirBtn mapsMirBtn--outline" id="usersClearFiltersBtn">
<svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true"><circle cx="7" cy="7" r="5.5" fill="none" stroke="currentColor" stroke-width="1.3"/><path d="M4.5 4.5l5 5" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/></svg>
<span data-i18n="users.clearFilters">Clear filters</span>
</button>
</div>
</header>
<div class="mapsMirFilterBar">
<label class="mapsMirFilterLabel" for="usersFilterInput" data-i18n="users.filterLabel">Filter:</label>
<input type="search" id="usersFilterInput" class="mapsMirFilterInput" data-i18n-placeholder="users.filterPlaceholder" placeholder="Filter by name or username..." autocomplete="off" />
<span id="usersFilterCount" class="mapsMirFilterCount">0 item(s) found</span>
<div class="mapsMirPager">
<button type="button" class="mapsMirPageBtn" id="usersPageFirst" aria-label="First page">&laquo;</button>
<button type="button" class="mapsMirPageBtn" id="usersPagePrev" aria-label="Previous page">&lsaquo;</button>
<span id="usersPageLabel" class="mapsMirPageLabel">Page 1 of 1</span>
<button type="button" class="mapsMirPageBtn" id="usersPageNext" aria-label="Next page">&rsaquo;</button>
<button type="button" class="mapsMirPageBtn" id="usersPageLast" aria-label="Last page">&raquo;</button>
</div>
</div>
<div class="mapsMirTableWrap">
<table class="mapsMirTable mapsMirTable--users" id="usersTable">
<thead>
<tr>
<th class="usersMirThIcon" aria-hidden="true"></th>
<th data-i18n="users.colName">Name</th>
<th data-i18n="users.colUsername">Username</th>
<th data-i18n="users.colGroup">User group</th>
<th data-i18n="users.colEmail">Email</th>
<th data-i18n="users.colPin">PIN</th>
<th class="mapsMirThFunctions" data-i18n="users.colFunctions">Functions</th>
</tr>
</thead>
<tbody id="userList"></tbody>
</table>
<div id="userListEmpty" class="mapsMirEmpty" hidden data-i18n="users.empty">No users yet.</div>
</div>
</div>
<dialog id="userEditDialog" class="mapsMirDialog">
<form id="userEditForm" method="dialog">
<h2 class="mapsMirDialogTitle" id="userEditTitle" data-i18n="users.createTitle">Create user</h2>
<label class="mapsMirField">
<span class="mapsMirFieldLabel" data-i18n="users.fieldName">Name</span>
<input type="text" id="userEditDisplayName" autocomplete="name" required />
</label>
<label class="mapsMirField">
<span class="mapsMirFieldLabel" data-i18n="users.fieldUsername">Username</span>
<input type="text" id="userEditUsername" autocomplete="username" required />
</label>
<label class="mapsMirField" id="userEditPasswordField">
<span class="mapsMirFieldLabel" data-i18n="users.fieldPassword">Password</span>
<input type="password" id="userEditPassword" autocomplete="new-password" minlength="4" />
</label>
<p id="userEditPasswordHint" class="mapsMirFieldHint" hidden data-i18n="users.passwordEditHint">Users change their own password from the menu in the upper right corner.</p>
<label class="mapsMirField">
<span class="mapsMirFieldLabel" data-i18n="users.fieldEmail">Email address</span>
<input type="email" id="userEditEmail" autocomplete="email" />
</label>
<label class="mapsMirField">
<span class="mapsMirFieldLabel" data-i18n="users.fieldGroup">User group</span>
<select id="userEditGroup" required></select>
</label>
<label class="mapsMirField mapsMirField--checkbox">
<input type="checkbox" id="userEditEnabled" checked />
<span data-i18n="users.fieldEnabled">Enabled</span>
</label>
<div id="userEditPinSection" class="mapsMirField">
<label class="mapsMirField mapsMirField--checkbox">
<input type="checkbox" id="userEditPinEnabled" />
<span data-i18n="users.fieldPinEnable">Allow PIN sign-in</span>
</label>
<label class="mapsMirField" id="userEditPinField" hidden>
<span class="mapsMirFieldLabel" data-i18n="users.fieldPin">PIN code (4 digits)</span>
<input type="text" id="userEditPin" inputmode="numeric" pattern="[0-9]{4}" maxlength="4" autocomplete="off" />
</label>
<span id="userEditPinHint" class="mapsMirFieldHint" hidden data-i18n="users.pinGroupHint">PIN is only available for user groups that allow PIN sign-in.</span>
</div>
<div class="mapsMirDialogFooter">
<button type="button" class="mapsMirBtn mapsMirBtn--outline" id="userEditCancelBtn" data-i18n="common.cancel">Cancel</button>
<button type="button" class="mapsMirBtn mapsMirBtn--danger" id="userEditDeleteBtn" hidden data-i18n="common.delete">Delete</button>
<button type="submit" class="mapsMirBtn mapsMirBtn--green" id="userEditSaveBtn" data-i18n="users.save">Save</button>
</div>
</form>
</dialog>
<dialog id="userDeleteConfirmDialog" class="mapsMirDialog">
<div class="mapsMirDialogPanel">
<h2 class="mapsMirDialogTitle" data-i18n="users.deleteTitle">Delete user?</h2>
<p id="userDeleteConfirmText" class="mapsMirDialogText"></p>
<p class="mapsMirDialogHint" data-i18n="users.deleteHint">Only the user profile is removed. Maps, missions and other data created by this user remain unchanged.</p>
<div class="mapsMirDialogFooter">
<button type="button" class="mapsMirBtn mapsMirBtn--outline" id="userDeleteCancelBtn" data-i18n="common.no">No</button>
<button type="button" class="mapsMirBtn mapsMirBtn--danger" id="userDeleteYesBtn" data-i18n="common.yes">Yes</button>
</div>
</div>
</dialog>
</div>
<div class="page" id="pageMaps" data-page-content="maps" hidden>
<div id="mapsListView" class="mapsMirPage">
<header class="mapsMirHeader">
@@ -1666,10 +1985,7 @@
<label class="mapsMirField">
<span class="mapsMirFieldLabel" data-i18n="maps.editor.io.module">I/O module</span>
<input type="text" id="mapIoModule" list="mapIoModuleList" autocomplete="off" required />
<datalist id="mapIoModuleList">
<option value="GPIO module 1"></option>
<option value="PLC I/O 1"></option>
</datalist>
<datalist id="mapIoModuleList"></datalist>
</label>
<div class="mapsMirFieldRow">
<label class="mapsMirField">
@@ -2238,6 +2554,9 @@ GET /api/v2.0.0/status</pre>
<script src="/maps.js"></script>
<script src="/sounds.js"></script>
<script src="/transitions.js"></script>
<script src="/users.js"></script>
<script src="/user-groups.js"></script>
<script src="/io-modules.js"></script>
<script src="/map-editor.js"></script>
<script src="/topbar.js"></script>
<script src="/dashboard.js"></script>

404
www/io-modules.js Normal file
View File

@@ -0,0 +1,404 @@
(() => {
const PAGE_SIZE = 10;
const ICONS = {
io: `<svg class="ioModulesMirIcon" width="20" height="20" viewBox="0 0 20 20" aria-hidden="true"><rect x="3" y="6" width="14" height="8" rx="1.5" fill="none" stroke="currentColor" stroke-width="1.4"/><circle cx="7" cy="10" r="1.2" fill="currentColor"/><circle cx="13" cy="10" r="1.2" fill="currentColor"/><path d="M10 3v3M10 14v3" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/></svg>`,
edit: `<svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true"><path d="M9.5 2.5l2 2L5 11H3v-2L9.5 2.5z" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round"/></svg>`,
delete: `<svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true"><path d="M4 4l6 6M10 4l-6 6" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg>`,
};
const el = (id) => document.getElementById(id);
const t = (key, vars) => window.I18n?.t(key, vars) ?? key;
const listEl = el("ioModuleList");
const emptyEl = el("ioModuleListEmpty");
const tableEl = el("ioModulesTable");
const filterInputEl = el("ioModulesFilterInput");
const filterCountEl = el("ioModulesFilterCount");
const pageLabelEl = el("ioModulesPageLabel");
const dialogEl = el("ioModuleEditDialog");
const formEl = el("ioModuleEditForm");
const titleEl = el("ioModuleEditTitle");
const deleteBtnEl = el("ioModuleEditDeleteBtn");
const typeHintEl = el("ioModuleEditTypeHint");
const deleteConfirmDialogEl = el("ioModuleDeleteConfirmDialog");
const deleteConfirmTextEl = el("ioModuleDeleteConfirmText");
const fields = {
site: el("ioModuleEditSite"),
name: el("ioModuleEditName"),
type: el("ioModuleEditType"),
ip: el("ioModuleEditIp"),
};
const store = {
modules: [],
sites: [],
editingId: null,
pendingDeleteId: null,
filter: "",
page: 1,
};
function canWrite() {
if (!window.AuthApp?.canWrite) return true;
return window.AuthApp.canWrite("integrations");
}
function escapeHtml(str) {
return String(str)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
async function apiJson(url, opts = {}) {
const res = await fetch(url, { credentials: "include", ...opts });
const text = await res.text();
let data = null;
try {
data = text ? JSON.parse(text) : null;
} catch {
data = null;
}
if (!res.ok) throw new Error((data && data.error) || text || res.statusText);
return data;
}
function defaultSiteId() {
return store.sites[0]?.id || "site_configuration";
}
function typeLabel(type) {
if (type === "bluetooth") return t("ioModules.type.bluetooth");
if (type === "wise") return t("ioModules.type.wise");
return type || "—";
}
function statusLabel(mod) {
return mod.connected ? t("ioModules.status.connected") : t("ioModules.status.disconnected");
}
function updateTypeHint() {
if (!typeHintEl || !fields.type) return;
const type = fields.type.value;
typeHintEl.textContent =
type === "wise" ? t("ioModules.typeHint.wise") : t("ioModules.typeHint.bluetooth");
}
async function refreshAll() {
const [sitesData, modulesData] = await Promise.all([
apiJson("/api/sites"),
apiJson("/api/io_modules"),
]);
store.sites = Array.isArray(sitesData.sites) ? sitesData.sites : [];
store.modules = Array.isArray(modulesData.io_modules) ? modulesData.io_modules : [];
return store.modules;
}
function moduleById(id) {
return store.modules.find((m) => m.id === id) || null;
}
function filteredModules() {
const q = store.filter.trim().toLowerCase();
let items = [...store.modules].sort((a, b) => (a.name || "").localeCompare(b.name || ""));
if (q) {
items = items.filter((m) => {
const name = (m.name || "").toLowerCase();
const ip = (m.ip_address || "").toLowerCase();
const type = (m.module_type || "").toLowerCase();
return name.includes(q) || ip.includes(q) || type.includes(q);
});
}
return items;
}
function pageCount(total) {
return Math.max(1, Math.ceil(total / PAGE_SIZE));
}
function pagedItems(items) {
const totalPages = pageCount(items.length);
if (store.page > totalPages) store.page = totalPages;
if (store.page < 1) store.page = 1;
const start = (store.page - 1) * PAGE_SIZE;
return items.slice(start, start + PAGE_SIZE);
}
function updatePagerUi(totalItems) {
const totalPages = pageCount(totalItems);
if (filterCountEl) filterCountEl.textContent = t("ioModules.itemsFound", { n: totalItems });
if (pageLabelEl) pageLabelEl.textContent = t("ioModules.pageOf", { page: store.page, total: totalPages });
const atStart = store.page <= 1;
const atEnd = store.page >= totalPages;
el("ioModulesPageFirst")?.toggleAttribute("disabled", atStart);
el("ioModulesPagePrev")?.toggleAttribute("disabled", atStart);
el("ioModulesPageNext")?.toggleAttribute("disabled", atEnd);
el("ioModulesPageLast")?.toggleAttribute("disabled", atEnd);
}
function fillSiteSelect(selectedId) {
if (!fields.site) return;
fields.site.innerHTML = "";
store.sites.forEach((site) => {
const opt = document.createElement("option");
opt.value = site.id;
opt.textContent = site.name || site.id;
if (site.id === selectedId) opt.selected = true;
fields.site.appendChild(opt);
});
if (!fields.site.value && store.sites[0]) fields.site.value = store.sites[0].id;
}
function renderList() {
if (!listEl) return;
const items = filteredModules();
const pageItems = pagedItems(items);
updatePagerUi(items.length);
listEl.innerHTML = "";
const showEmpty = items.length === 0;
if (tableEl) tableEl.hidden = showEmpty;
if (emptyEl) {
emptyEl.hidden = !showEmpty;
emptyEl.textContent = store.filter.trim() ? t("ioModules.emptyFilter") : t("ioModules.empty");
}
pageItems.forEach((mod) => {
const tr = document.createElement("tr");
tr.className = "mapsMirRow ioModulesMirRow";
tr.dataset.id = mod.id;
const actions = canWrite()
? `<div class="mapsMirRowActions">
<button type="button" class="mapsMirIconBtn ioModuleEditBtn" data-edit="${escapeHtml(mod.id)}" title="${escapeHtml(t("common.edit"))}">${ICONS.edit}</button>
<button type="button" class="mapsMirIconBtn mapsMirIconBtn--danger ioModuleDeleteBtn" data-delete="${escapeHtml(mod.id)}" title="${escapeHtml(t("common.delete"))}">${ICONS.delete}</button>
</div>`
: "";
const statusClass = mod.connected ? "ioModulesMirStatus--on" : "ioModulesMirStatus--off";
tr.innerHTML = `
<td class="ioModulesMirCellIcon">${ICONS.io}</td>
<td class="ioModulesMirCellName">
<button type="button" class="mapsMirNameLink ioModulesMirNameLink" data-edit="${escapeHtml(mod.id)}">${escapeHtml(mod.name || "—")}</button>
</td>
<td>${escapeHtml(typeLabel(mod.module_type))}</td>
<td>${escapeHtml(mod.ip_address || "—")}</td>
<td><span class="ioModulesMirStatus ${statusClass}">${escapeHtml(statusLabel(mod))}</span></td>
<td class="mapsMirCellFunctions">${actions}</td>
`;
listEl.appendChild(tr);
});
}
function openCreateDialog() {
store.editingId = null;
if (titleEl) titleEl.textContent = t("ioModules.createTitle");
fillSiteSelect(defaultSiteId());
if (fields.name) fields.name.value = "";
if (fields.type) fields.type.value = "bluetooth";
if (fields.ip) fields.ip.value = "";
updateTypeHint();
deleteBtnEl?.toggleAttribute("hidden", true);
dialogEl?.showModal();
}
function openEditDialog(id) {
const mod = moduleById(id);
if (!mod) return;
store.editingId = id;
if (titleEl) titleEl.textContent = t("ioModules.editTitle");
fillSiteSelect(mod.site_id || defaultSiteId());
if (fields.name) fields.name.value = mod.name || "";
if (fields.type) fields.type.value = mod.module_type || "bluetooth";
if (fields.ip) fields.ip.value = mod.ip_address || "";
updateTypeHint();
deleteBtnEl?.toggleAttribute("hidden", false);
dialogEl?.showModal();
}
async function saveModule(evt) {
evt?.preventDefault();
const payload = {
site_id: fields.site?.value || defaultSiteId(),
name: (fields.name?.value || "").trim(),
module_type: fields.type?.value || "bluetooth",
ip_address: (fields.ip?.value || "").trim(),
};
if (!payload.name || !payload.ip_address) {
alert(t("ioModules.error.missing"));
return;
}
try {
if (store.editingId) {
await apiJson(`/api/io_modules/${encodeURIComponent(store.editingId)}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
} else {
await apiJson("/api/io_modules", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
}
dialogEl?.close();
await refreshAll();
renderList();
window.dispatchEvent(new CustomEvent("lm:io-modules-changed"));
} catch (e) {
alert(e.message);
}
}
function openDeleteConfirm(id) {
const mod = moduleById(id);
if (!mod) return;
store.pendingDeleteId = id;
if (deleteConfirmTextEl) {
deleteConfirmTextEl.textContent = t("ioModules.deleteConfirmText", { name: mod.name || "" });
}
deleteConfirmDialogEl?.showModal();
}
async function confirmDelete() {
const id = store.pendingDeleteId;
if (!id) return;
try {
const res = await fetch(`/api/io_modules/${encodeURIComponent(id)}`, { method: "DELETE", credentials: "include" });
if (!res.ok) {
let msg = res.statusText;
try {
const err = await res.json();
if (err.error) msg = err.error;
if (err.usages) msg += "\n" + JSON.stringify(err.usages, null, 2);
} catch {
/* ignore */
}
throw new Error(msg);
}
store.pendingDeleteId = null;
deleteConfirmDialogEl?.close();
dialogEl?.close();
await refreshAll();
renderList();
window.dispatchEvent(new CustomEvent("lm:io-modules-changed"));
} catch (e) {
alert(e.message);
}
}
function clearFilters() {
store.filter = "";
store.page = 1;
if (filterInputEl) filterInputEl.value = "";
renderList();
}
function bindEvents() {
el("ioModuleCreateBtn")?.addEventListener("click", () => {
if (canWrite()) openCreateDialog();
});
formEl?.addEventListener("submit", saveModule);
el("ioModuleEditCancelBtn")?.addEventListener("click", () => dialogEl?.close());
deleteBtnEl?.addEventListener("click", () => {
if (store.editingId) openDeleteConfirm(store.editingId);
});
fields.type?.addEventListener("change", updateTypeHint);
el("ioModuleTestBtn")?.addEventListener("click", async () => {
const ip = (fields.ip?.value || "").trim();
if (!ip) {
alert(t("ioModules.error.missing"));
return;
}
try {
await apiJson("/api/io_modules/test", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ip_address: ip, port: 502 }),
});
alert(t("ioModules.testOk"));
} catch (e) {
alert(e.message);
}
});
listEl?.addEventListener("click", (evt) => {
const editBtn = evt.target.closest("[data-edit]");
const deleteBtn = evt.target.closest("[data-delete]");
if (editBtn?.dataset.edit && canWrite()) openEditDialog(editBtn.dataset.edit);
else if (deleteBtn?.dataset.delete && canWrite()) openDeleteConfirm(deleteBtn.dataset.delete);
});
deleteConfirmDialogEl?.addEventListener("cancel", (evt) => {
evt.preventDefault();
store.pendingDeleteId = null;
deleteConfirmDialogEl?.close();
});
el("ioModuleDeleteCancelBtn")?.addEventListener("click", () => {
store.pendingDeleteId = null;
deleteConfirmDialogEl?.close();
});
el("ioModuleDeleteYesBtn")?.addEventListener("click", confirmDelete);
filterInputEl?.addEventListener("input", () => {
store.filter = filterInputEl.value;
store.page = 1;
renderList();
});
el("ioModulesClearFiltersBtn")?.addEventListener("click", clearFilters);
el("ioModulesPageFirst")?.addEventListener("click", () => {
store.page = 1;
renderList();
});
el("ioModulesPagePrev")?.addEventListener("click", () => {
store.page = Math.max(1, store.page - 1);
renderList();
});
el("ioModulesPageNext")?.addEventListener("click", () => {
store.page += 1;
renderList();
});
el("ioModulesPageLast")?.addEventListener("click", () => {
store.page = pageCount(filteredModules().length);
renderList();
});
el("ioModulesHelpBtn")?.addEventListener("click", () => alert(t("ioModules.helpBody")));
window.addEventListener("lm:locale-change", () => renderList());
}
async function onPageShow() {
if (!window.AuthApp?.canAccessPage?.("io-modules")) return;
document.body.classList.toggle("auth-readonly-io-modules", !canWrite());
try {
await refreshAll();
renderList();
} catch (e) {
if (emptyEl) {
emptyEl.hidden = false;
emptyEl.textContent = e.message;
}
if (tableEl) tableEl.hidden = true;
}
}
function onPageHide() {
dialogEl?.close();
deleteConfirmDialogEl?.close();
}
bindEvents();
window.IoModulesCatalog = {
getModules: () => [...store.modules],
getNames: () => store.modules.map((m) => m.name).filter(Boolean),
refresh: refreshAll,
};
window.IoModulesApp = { onPageShow, onPageHide, refreshAll, getModules: () => [...store.modules] };
})();

View File

@@ -146,6 +146,28 @@
plcMode: el("mapIoPlcMode"),
};
async function refreshMapIoModuleList() {
const listEl = document.getElementById("mapIoModuleList");
if (!listEl) return;
let modules = [];
try {
if (window.IoModulesCatalog?.refresh) {
modules = await window.IoModulesCatalog.refresh();
} else {
const res = await fetch("/api/io_modules", { credentials: "include" });
if (res.ok) {
const data = await res.json();
modules = Array.isArray(data.io_modules) ? data.io_modules : [];
}
}
} catch {
modules = [];
}
listEl.innerHTML = modules
.map((m) => `<option value="${String(m.name || "").replace(/"/g, "&quot;")}"></option>`)
.join("");
}
/** Off-screen base scan layer (map_base.png — eraser edits this). */
let baseCanvasEl = null;
function getBaseCanvas() {
@@ -1177,13 +1199,15 @@
}
if (zone.type === obj.TYPES.io) {
const s = adv.normalizeIoSettings(zone);
if (ioFields.module) ioFields.module.value = s.io_module || "";
if (ioFields.plcRegister) {
ioFields.plcRegister.value = s.plc_register == null ? "" : String(s.plc_register);
}
if (ioFields.plcValue) ioFields.plcValue.value = s.plc_value == null ? "" : String(s.plc_value);
if (ioFields.plcMode) ioFields.plcMode.value = s.plc_mode || "set";
ioDialogEl?.showModal();
void refreshMapIoModuleList().then(() => {
if (ioFields.module) ioFields.module.value = s.io_module || "";
if (ioFields.plcRegister) {
ioFields.plcRegister.value = s.plc_register == null ? "" : String(s.plc_register);
}
if (ioFields.plcValue) ioFields.plcValue.value = s.plc_value == null ? "" : String(s.plc_value);
if (ioFields.plcMode) ioFields.plcMode.value = s.plc_mode || "set";
ioDialogEl?.showModal();
});
}
}

View File

@@ -530,12 +530,45 @@
renderList();
}
async function importSiteBundle() {
const site_id = createSiteSelectEl?.value || store.sites[0]?.id;
if (!site_id) {
alert(t("maps.importNoSite"));
return;
}
const input = document.createElement("input");
input.type = "file";
input.accept = ".json,application/json";
input.addEventListener("change", async () => {
const file = input.files?.[0];
if (!file) return;
try {
const text = await file.text();
const result = await api(`/api/sites/${encodeURIComponent(site_id)}/import`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: text,
});
await loadMaps();
const counts = [
Array.isArray(result.maps) ? result.maps.length : 0,
Array.isArray(result.io_modules) ? result.io_modules.length : 0,
Array.isArray(result.transitions) ? result.transitions.length : 0,
];
alert(t("maps.importSuccess", { maps: counts[0], io: counts[1], transitions: counts[2] }));
} catch (e) {
alert(e.message);
}
});
input.click();
}
function bindEvents() {
el("mapsCreateOpenBtn")?.addEventListener("click", openCreatePage);
el("mapsCreateGoBackBtn")?.addEventListener("click", showList);
el("mapsCreateCancelBtn")?.addEventListener("click", showList);
el("mapsCreateHelpBtn")?.addEventListener("click", () => alert(t("maps.createPage.helpText")));
el("mapsImportSiteBtn")?.addEventListener("click", () => alert(t("maps.importComingSoon")));
el("mapsImportSiteBtn")?.addEventListener("click", () => importSiteBundle().catch((e) => alert(e.message)));
el("mapsClearFiltersBtn")?.addEventListener("click", clearFilters);
el("mapsHelpBtn")?.addEventListener("click", () => alert(t("maps.helpText")));

View File

@@ -12,6 +12,7 @@
],
Logic: [
{ type: "if", label: "If" },
{ type: "while", label: "While", isLoop: true },
{ type: "loop", label: "Loop", isLoop: true },
{ type: "break", label: "Break" },
{ type: "continue", label: "Continue" },
@@ -20,6 +21,8 @@
"I/O": [
{ type: "set_digital_output", label: "Set digital output" },
{ type: "wait_digital_input", label: "Wait for digital input" },
{ type: "connect_bluetooth", label: "Connect Bluetooth module" },
{ type: "disconnect_bluetooth", label: "Disconnect Bluetooth module" },
{ type: "set_plc_register", label: "Set PLC register" },
],
Cart: [
@@ -37,6 +40,42 @@
const SAMPLE_POSITIONS = ["Charging station", "Warehouse", "Production line 1", "Dock A"];
const SAMPLE_MARKERS = ["Marker 1", "Marker 2", "Home"];
const SAMPLE_IO_MODULES = ["GPIO module 1", "PLC I/O 1"];
let ioModuleCatalog = [];
function ioModuleNames() {
const names = ioModuleCatalog.map((m) => m.name).filter(Boolean);
return names.length ? names : SAMPLE_IO_MODULES;
}
function ioModuleByName(name) {
return ioModuleCatalog.find((m) => m.name === name || m.id === name) || null;
}
function ioOutputPorts(moduleName) {
const mod = ioModuleByName(moduleName);
return mod?.module_type === "wise" ? [0, 1, 2, 3] : [1, 2, 3, 4];
}
function ioInputPorts(moduleName) {
return ioOutputPorts(moduleName);
}
async function loadIoModuleCatalog() {
try {
if (window.IoModulesCatalog?.refresh) {
ioModuleCatalog = await window.IoModulesCatalog.refresh();
} else {
const res = await fetch("/api/io_modules", { credentials: "include" });
if (res.ok) {
const data = await res.json();
ioModuleCatalog = Array.isArray(data.io_modules) ? data.io_modules : [];
}
}
} catch {
ioModuleCatalog = [];
}
return ioModuleCatalog;
}
const SAMPLE_CARTS = ["Any valid cart", "Cart A", "Cart B"];
let missionPositionCatalog = [];
@@ -170,13 +209,18 @@
case "switch_map":
return { map_id: "", entry_position_id: "" };
case "if":
return { condition: "position_free", position: positionIds()[0] || SAMPLE_POSITIONS[0] };
return { condition: "io_input", module: ioModuleNames()[0] || "", input: 1, expected: true };
case "while":
return { condition: "io_input", module: ioModuleNames()[0] || "", input: 1, expected: true };
case "loop":
return { count: 1, mode: "count" };
case "set_digital_output":
return { module: SAMPLE_IO_MODULES[0], pin: 1, value: true };
return { module: ioModuleNames()[0] || "", output: 1, value: true, operation: "set", timeout_ms: 0 };
case "wait_digital_input":
return { module: SAMPLE_IO_MODULES[0], pin: 1, expected: true, timeout_s: 30 };
return { module: ioModuleNames()[0] || "", input: 1, expected: true, timeout_s: 30 };
case "connect_bluetooth":
case "disconnect_bluetooth":
return { module: ioModuleNames()[0] || "" };
case "set_plc_register":
return { register: 1, action: "set", value: 0 };
case "pick_cart":
@@ -344,12 +388,28 @@
return `Speed: ${p.speed}`;
case "loop":
return p.mode === "endless" ? "Lặp vô hạn" : `Lặp ${p.count} lần • ${action.children?.length || 0} bước`;
case "if":
case "if": {
if (p.condition === "io_input") {
const port = p.input != null ? p.input : p.pin;
return `If I/O ${p.module} in ${port} = ${p.expected ? "ON" : "OFF"}`;
}
return `If ${p.condition} @ ${positionLabel(p.position)}`;
case "set_digital_output":
return `${p.module} pin ${p.pin}${p.value ? "ON" : "OFF"}`;
case "wait_digital_input":
return `${p.module} pin ${p.pin} = ${p.expected ? "ON" : "OFF"}`;
}
case "while":
return `While I/O ${p.module} in ${p.input != null ? p.input : p.pin} = ${p.expected ? "ON" : "OFF"}${action.children?.length || 0} bước`;
case "set_digital_output": {
const port = p.output != null ? p.output : p.pin;
const op = p.operation === "timed" ? ` (${p.timeout_ms || 0}ms)` : "";
return `${p.module} out ${port}${p.value ? "ON" : "OFF"}${op}`;
}
case "wait_digital_input": {
const port = p.input != null ? p.input : p.pin;
return `${p.module} in ${port} = ${p.expected ? "ON" : "OFF"} (${p.timeout_s}s)`;
}
case "connect_bluetooth":
return `Connect ${p.module || "?"}`;
case "disconnect_bluetooth":
return `Disconnect ${p.module || "?"}`;
case "set_plc_register":
return `Reg ${p.register}: ${p.action} ${p.value}`;
case "pick_cart":
@@ -374,10 +434,15 @@
function normalizeActionTree(actions) {
if (!Array.isArray(actions)) return;
actions.forEach((action) => {
if (action.type === "loop" && !Array.isArray(action.children)) {
if ((action.type === "loop" || action.type === "while") && !Array.isArray(action.children)) {
action.children = [];
}
if (action.type === "if") {
if (!Array.isArray(action.children)) action.children = [];
if (!Array.isArray(action.else_children)) action.else_children = [];
}
if (Array.isArray(action.children)) normalizeActionTree(action.children);
if (Array.isArray(action.else_children)) normalizeActionTree(action.else_children);
});
}
@@ -387,9 +452,24 @@
if (path === "root") return draft.actions;
const parts = path.split(".").filter((p) => p !== "root");
let list = draft.actions;
for (const part of parts) {
let i = 0;
while (i < parts.length) {
const part = parts[i];
if (part === "else") return null;
const node = list.find((a) => a.id === part);
if (!node || !Array.isArray(node.children)) return null;
if (!node) return null;
i += 1;
if (i < parts.length && parts[i] === "else") {
if (!Array.isArray(node.else_children)) return null;
list = node.else_children;
i += 1;
continue;
}
if (i >= parts.length) {
if (!Array.isArray(node.children)) return null;
return node.children;
}
if (!Array.isArray(node.children)) return null;
list = node.children;
}
return list;
@@ -402,7 +482,11 @@
if (action.id === actionId) return { action, list, index: i, path, parent };
if (Array.isArray(action.children)) {
const hit = findActionWithParent(actionId, action.children, `${path}.${action.id}`, action);
if (hit) return { ...hit, label: t(`missions.action.${type}`) || hit.label };
if (hit) return hit;
}
if (Array.isArray(action.else_children)) {
const hit = findActionWithParent(actionId, action.else_children, `${path}.${action.id}.else`, action);
if (hit) return hit;
}
}
return null;
@@ -492,6 +576,7 @@
actions.forEach((action) => {
visit(action, depth);
if (Array.isArray(action.children)) walkActions(action.children, visit, depth + 1);
if (Array.isArray(action.else_children)) walkActions(action.else_children, visit, depth + 1);
});
}
@@ -504,6 +589,9 @@
if (Array.isArray(copy.children)) {
copy.children = copy.children.map((child) => resolveActionSnapshot(child, depth));
}
if (Array.isArray(copy.else_children)) {
copy.else_children = copy.else_children.map((child) => resolveActionSnapshot(child, depth));
}
return copy;
}
@@ -949,8 +1037,8 @@
row.dataset.index = String(index);
const iconClass =
action.kind === "mission" ? "kind-mission" : action.type === "loop" ? "kind-loop" : "";
const iconChar = action.kind === "mission" ? "◎" : action.type === "loop" ? "↻" : "▶";
action.kind === "mission" ? "kind-mission" : action.type === "loop" || action.type === "while" ? "kind-loop" : action.type === "if" ? "kind-if" : "";
const iconChar = action.kind === "mission" ? "◎" : action.type === "loop" || action.type === "while" ? "↻" : action.type === "if" ? "?" : "▶";
row.innerHTML = `
<div class="missionDragHandle" draggable="true" title="Kéo để sắp xếp" aria-label="Kéo để sắp xếp">↕</div>
@@ -968,17 +1056,17 @@
</div>
</div>`;
if (action.type === "loop" && Array.isArray(action.children)) {
if ((action.type === "loop" || action.type === "while") && Array.isArray(action.children)) {
const loop = document.createElement("div");
loop.className = "missionLoopBlock";
loop.innerHTML = `<div class="missionLoopLabel">Loop body — kéo action vào đây</div>`;
loop.innerHTML = `<div class="missionLoopLabel">${action.type === "while" ? "While body" : "Loop body"} — kéo action vào đây</div>`;
const drop = document.createElement("div");
drop.className = "missionLoopDrop";
drop.dataset.loopPath = `${listPath}.${action.id}`;
if (!action.children.length) {
const empty = document.createElement("div");
empty.className = "missionLoopEmpty";
empty.textContent = "Kéo action hoặc mission vào loop";
empty.textContent = "Kéo action hoặc mission vào đây";
drop.appendChild(empty);
} else {
renderActionRows(action.children, `${listPath}.${action.id}`, drop);
@@ -987,6 +1075,31 @@
row.appendChild(loop);
}
if (action.type === "if" && Array.isArray(action.children)) {
const makeBranch = (label, branchPath, children) => {
const block = document.createElement("div");
block.className = "missionLoopBlock missionIfBlock";
block.innerHTML = `<div class="missionLoopLabel">${label}</div>`;
const drop = document.createElement("div");
drop.className = "missionLoopDrop";
drop.dataset.loopPath = branchPath;
if (!children.length) {
const empty = document.createElement("div");
empty.className = "missionLoopEmpty";
empty.textContent = "Kéo action vào nhánh này";
drop.appendChild(empty);
} else {
renderActionRows(children, branchPath, drop);
}
block.appendChild(drop);
return block;
};
row.appendChild(makeBranch("Then", `${listPath}.${action.id}`, action.children));
row.appendChild(
makeBranch("Else", `${listPath}.${action.id}.else`, Array.isArray(action.else_children) ? action.else_children : [])
);
}
row.querySelector("[data-config]").addEventListener("click", (evt) => {
evt.stopPropagation();
openActionConfig(action.id);
@@ -1282,13 +1395,35 @@
addField("Số lần lặp", textInput("count", p.count, "number"));
break;
case "if":
addField("Điều kiện", selectInput("condition", p.condition, ["position_free", "position_occupied", "register_equals"]));
addField("Position", selectInputLabeled("position", p.position, positionSelectOptions()));
addVariableToggle("position", "Position");
addField("Điều kiện", selectInput("condition", p.condition, ["io_input", "position_free", "position_occupied", "register_equals"]));
if (p.condition === "io_input") {
addField("Module", selectInput("module", p.module, ioModuleNames()));
addField("Input", selectInput("input", String(p.input != null ? p.input : p.pin || 1), ioInputPorts(p.module).map(String)));
{
const chk = document.createElement("label");
chk.innerHTML = `<input type="checkbox" data-param="expected" ${p.expected ? "checked" : ""} /> ${t("missions.action.waitOnLevel")}`;
addField("Kỳ vọng", chk);
}
} else {
addField("Position", selectInputLabeled("position", p.position, positionSelectOptions()));
addVariableToggle("position", "Position");
}
break;
case "while":
addField("Điều kiện", selectInput("condition", p.condition || "io_input", ["io_input"]));
addField("Module", selectInput("module", p.module, ioModuleNames()));
addField("Input", selectInput("input", String(p.input != null ? p.input : p.pin || 1), ioInputPorts(p.module).map(String)));
{
const chk = document.createElement("label");
chk.innerHTML = `<input type="checkbox" data-param="expected" ${p.expected ? "checked" : ""} /> ${t("missions.action.waitOnLevel")}`;
addField("Kỳ vọng", chk);
}
break;
case "set_digital_output":
addField("Module", selectInput("module", p.module, SAMPLE_IO_MODULES));
addField("Pin", textInput("pin", p.pin, "number"));
addField("Module", selectInput("module", p.module, ioModuleNames()));
addField("Output", selectInput("output", String(p.output != null ? p.output : p.pin || 1), ioOutputPorts(p.module).map(String)));
addField("Operation", selectInput("operation", p.operation || "set", ["set", "timed"]));
addField("Timeout (ms)", textInput("timeout_ms", p.timeout_ms != null ? p.timeout_ms : 0, "number"));
{
const chk = document.createElement("label");
chk.innerHTML = `<input type="checkbox" data-param="value" ${p.value ? "checked" : ""} /> Bật (ON)`;
@@ -1296,8 +1431,8 @@
}
break;
case "wait_digital_input":
addField("Module", selectInput("module", p.module, SAMPLE_IO_MODULES));
addField("Pin", textInput("pin", p.pin, "number"));
addField("Module", selectInput("module", p.module, ioModuleNames()));
addField("Input", selectInput("input", String(p.input != null ? p.input : p.pin || 1), ioInputPorts(p.module).map(String)));
addField("Timeout (s)", textInput("timeout_s", p.timeout_s, "number"));
{
const chk = document.createElement("label");
@@ -1305,6 +1440,10 @@
addField("Kỳ vọng", chk);
}
break;
case "connect_bluetooth":
case "disconnect_bluetooth":
addField("Module", selectInput("module", p.module, ioModuleNames()));
break;
case "set_plc_register":
addField("Register", textInput("register", p.register, "number"));
addField("Hành động", selectInput("action", p.action, ["set", "add", "subtract"]));
@@ -1364,6 +1503,8 @@
else params[key] = node.value;
});
hit.action.params = params;
if (params.input != null && params.input !== "") params.input = Number(params.input);
if (params.output != null && params.output !== "") params.output = Number(params.output);
setDirty(true);
renderMissionEditor();
}
@@ -1464,6 +1605,7 @@
/* ignore */
}
}
await loadIoModuleCatalog();
try {
const maps = await fetch("/api/maps", { credentials: "include" });
if (maps.ok) {
@@ -1503,11 +1645,13 @@
startQueuePoll,
stopQueuePoll,
onPageShow() {
if (!missionEditorViewEl?.hidden) renderMissionEditor();
else {
renderMissionList();
startQueuePoll();
}
void loadIoModuleCatalog().then(() => {
if (!missionEditorViewEl?.hidden) renderMissionEditor();
else {
renderMissionList();
startQueuePoll();
}
});
},
onPageHide() {
stopQueuePoll();
@@ -1527,6 +1671,12 @@
}
window.addEventListener("lm:locale-change", onLocaleChange);
window.addEventListener("lm:io-modules-changed", () => {
void loadIoModuleCatalog().then(() => {
if (!missionEditorViewEl?.hidden) renderMissionEditor();
});
});
if (window.AuthApp?.isReady()) boot();
else window.addEventListener("lm:auth-ready", boot, { once: true });
window.addEventListener("lm:auth-logout", stopQueuePollForce);

View File

@@ -17,6 +17,9 @@
{ section: "maps", page: "maps" },
{ section: "sounds", page: "sounds" },
{ section: "transitions", page: "transitions" },
{ section: "user-groups", page: "user-groups" },
{ section: "io-modules", page: "io-modules" },
{ section: "users", page: "users" },
{ section: "build-robot", page: "config" },
],
},
@@ -38,6 +41,9 @@
missions: { module: "setup", section: "missions" },
sounds: { module: "setup", section: "sounds" },
transitions: { module: "setup", section: "transitions" },
"user-groups": { module: "setup", section: "user-groups" },
"io-modules": { module: "setup", section: "io-modules" },
users: { module: "setup", section: "users" },
integrations: { module: "system", section: "integrations" },
monitoring: { module: "monitoring", section: "monitoring-log" },
help: { module: "help", section: "help-api" },

View File

@@ -1103,6 +1103,57 @@ canvas {
height: 100%;
}
.content.content--users {
grid-template-columns: minmax(0, 1fr);
align-items: stretch;
align-content: stretch;
max-width: none;
width: 100%;
padding: 0;
overflow: hidden;
background: #fff;
}
.content.content--users > #pageUsers {
width: 100%;
min-width: 0;
min-height: 0;
height: 100%;
}
.content.content--user-groups {
grid-template-columns: minmax(0, 1fr);
align-items: stretch;
align-content: stretch;
max-width: none;
width: 100%;
padding: 0;
overflow: hidden;
background: #fff;
}
.content.content--user-groups > #pageUserGroups {
width: 100%;
min-width: 0;
min-height: 0;
height: 100%;
}
.content.content--io-modules {
grid-template-columns: minmax(0, 1fr);
align-items: stretch;
align-content: stretch;
max-width: none;
width: 100%;
padding: 0;
overflow: hidden;
background: #fff;
}
.content.content--io-modules > #pageIoModules {
width: 100%;
min-width: 0;
min-height: 0;
height: 100%;
}
.missionsPage { min-width: 0; width: 100%; }
.soundVolumeRow {
@@ -3141,6 +3192,45 @@ body.auth-readonly-integrations .integrationToolbar .btn.primary { pointer-event
min-height: 0;
}
#pageUsers {
position: relative;
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
}
#usersListView {
flex: 1;
min-height: 0;
}
#pageUserGroups {
position: relative;
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
}
#userGroupsListView {
flex: 1;
min-height: 0;
}
#pageIoModules {
position: relative;
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
}
#ioModulesListView {
flex: 1;
min-height: 0;
}
#mapsListView[hidden],
#mapsCreateView[hidden],
#mapEditorView[hidden] {
@@ -3590,6 +3680,261 @@ body.auth-readonly-integrations .integrationToolbar .btn.primary { pointer-event
.mapsMirTable--transitions thead th:nth-child(4) { width: 22%; }
.mapsMirTable--transitions thead th:nth-child(5) { width: 16%; }
.mapsMirTable--users thead th.usersMirThIcon { width: 52px; }
.mapsMirTable--users thead th:nth-child(2) { width: 18%; }
.mapsMirTable--users thead th:nth-child(3) { width: 14%; }
.mapsMirTable--users thead th:nth-child(4) { width: 16%; }
.mapsMirTable--users thead th:nth-child(5) { width: 22%; }
.mapsMirTable--users thead th:nth-child(6) { width: 72px; }
.usersMirIcon {
color: var(--mir-green, #5cb85c);
display: block;
}
.usersMirCellIcon {
width: 52px;
text-align: center;
padding-left: 12px !important;
padding-right: 8px !important;
}
.usersMirNameLink {
font-weight: 600;
}
.usersMirDisabledTag {
font-size: 12px;
font-weight: 600;
color: #a94442;
margin-left: 6px;
}
body.auth-readonly-users #userCreateBtn { display: none !important; }
body.auth-readonly-users .usersMirRow .userEditBtn,
body.auth-readonly-users .usersMirRow .userDeleteBtn { pointer-events: none; opacity: 0.55; }
.mapsMirTable--userGroups thead th.userGroupsMirThIcon { width: 52px; }
.mapsMirTable--userGroups thead th:nth-child(2) { width: 20%; }
.mapsMirTable--userGroups thead th:nth-child(3) { width: 12%; }
.mapsMirTable--userGroups thead th:nth-child(4) { width: 12%; }
.mapsMirTable--userGroups thead th:nth-child(5) { width: auto; }
.userGroupsMirIcon {
color: var(--mir-green, #5cb85c);
display: block;
}
.userGroupsMirCellIcon {
width: 52px;
text-align: center;
padding-left: 12px !important;
padding-right: 8px !important;
}
.userGroupsMirNameLink { font-weight: 600; }
.userGroupsMirCellPerms {
font-size: 13px;
color: #555;
}
body.auth-readonly-user-groups #userGroupCreateBtn { display: none !important; }
body.auth-readonly-user-groups .userGroupsMirRow .userGroupEditBtn,
body.auth-readonly-user-groups .userGroupsMirRow .userGroupDeleteBtn { pointer-events: none; opacity: 0.55; }
.mapsMirTable--ioModules thead th.ioModulesMirThIcon { width: 52px; }
.mapsMirTable--ioModules thead th:nth-child(2) { width: 20%; }
.mapsMirTable--ioModules thead th:nth-child(3) { width: 14%; }
.mapsMirTable--ioModules thead th:nth-child(4) { width: 18%; }
.mapsMirTable--ioModules thead th:nth-child(5) { width: 14%; }
.ioModulesMirIcon {
color: var(--mir-green, #5cb85c);
display: block;
}
.ioModulesMirCellIcon {
width: 52px;
text-align: center;
padding-left: 12px !important;
padding-right: 8px !important;
}
.ioModulesMirNameLink { font-weight: 600; }
.ioModulesMirStatus {
font-size: 12px;
font-weight: 600;
padding: 2px 8px;
border-radius: 10px;
}
.ioModulesMirStatus--on {
color: #2d6a2d;
background: #e8f5e8;
}
.ioModulesMirStatus--off {
color: #666;
background: #f0f0f0;
}
body.auth-readonly-io-modules #ioModuleCreateBtn { display: none !important; }
body.auth-readonly-io-modules .ioModulesMirRow .ioModuleEditBtn,
body.auth-readonly-io-modules .ioModulesMirRow .ioModuleDeleteBtn { pointer-events: none; opacity: 0.55; }
.dashboardIoConnectBtn { width: 100%; }
.dashboardIoWidgetMeta {
margin: 8px 0 0;
font-size: 12px;
}
.dashboardIoStatusCard {
display: flex;
flex-direction: column;
gap: 4px;
padding: 4px 0;
}
.dashboardIoStatusName {
font-weight: 700;
font-size: 15px;
}
.dashboardIoStatusLine {
font-size: 12px;
color: #666;
}
.dashboardIoStatusBadge {
display: inline-block;
margin-top: 4px;
font-size: 12px;
font-weight: 600;
padding: 3px 10px;
border-radius: 10px;
align-self: flex-start;
}
.dashboardIoStatusBadge.is-on {
color: #2d6a2d;
background: #e8f5e8;
}
.dashboardIoStatusBadge.is-off {
color: #666;
background: #f0f0f0;
}
.dashboardIoConfigHead {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
margin-bottom: 0.75rem;
}
.dashboardIoConfigPorts {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.dashboardIoConfigRow {
display: grid;
grid-template-columns: 1fr auto auto;
gap: 0.35rem;
align-items: center;
}
.dashboardIoConfigPortLabel {
font-size: 0.85rem;
font-weight: 600;
}
.dashboardIoConfigToggle {
padding: 0.25rem 0.55rem;
border-radius: 6px;
border: 1px solid var(--border, #d1d5db);
background: #fff;
font-size: 0.8rem;
cursor: pointer;
}
.dashboardIoConfigToggle.is-primary {
border-color: #16a34a;
color: #15803d;
}
.dashboardIoConfigToggle.is-active {
background: #16a34a;
color: #fff;
border-color: #16a34a;
}
.missionIfBlock {
margin-top: 0.35rem;
}
.mapsMirDialog--wide {
width: min(520px, calc(100vw - 32px));
max-width: none;
}
.userGroupsPermSection {
margin-top: 8px;
}
.userGroupsPermTitle {
margin: 0 0 4px;
font-size: 14px;
font-weight: 700;
color: #333;
}
.userGroupsPermTable {
width: 100%;
border-collapse: collapse;
margin-top: 8px;
font-size: 13px;
}
.userGroupsPermTable th,
.userGroupsPermTable td {
padding: 8px 10px;
border-top: 1px solid #eee;
text-align: left;
vertical-align: middle;
}
.userGroupsPermTable thead th {
font-weight: 700;
color: #444;
background: #fafafa;
border-top: none;
}
.userGroupsPermTable th[scope="row"] {
font-weight: 600;
color: #333;
width: 55%;
}
.userGroupsPermSelect {
width: 100%;
min-width: 120px;
padding: 6px 8px;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 13px;
background: #fff;
}
.transMirIcon {
color: var(--mir-green, #5cb85c);
display: block;

407
www/user-groups.js Normal file
View File

@@ -0,0 +1,407 @@
(() => {
const PAGE_SIZE = 10;
const PERM_RESOURCES = [
{ key: "dashboard", labelKey: "userGroups.perm.dashboard" },
{ key: "config", labelKey: "userGroups.perm.config" },
{ key: "maps", labelKey: "userGroups.perm.maps" },
{ key: "missions", labelKey: "userGroups.perm.missions" },
{ key: "sounds", labelKey: "userGroups.perm.sounds" },
{ key: "integrations", labelKey: "userGroups.perm.integrations" },
{ key: "users", labelKey: "userGroups.perm.users" },
];
const ICONS = {
group: `<svg class="userGroupsMirIcon" width="20" height="20" viewBox="0 0 20 20" aria-hidden="true"><circle cx="7" cy="7" r="2.8" fill="none" stroke="currentColor" stroke-width="1.3"/><circle cx="13" cy="7" r="2.8" fill="none" stroke="currentColor" stroke-width="1.3"/><path d="M3.5 16c0-2.5 1.6-4.5 3.5-4.5s3.5 2 3.5 4.5M10 16c0-2.5 1.6-4.5 3.5-4.5s3.5 2 3.5 4.5" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/></svg>`,
edit: `<svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true"><path d="M9.5 2.5l2 2L5 11H3v-2L9.5 2.5z" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round"/></svg>`,
delete: `<svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true"><path d="M4 4l6 6M10 4l-6 6" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg>`,
};
const el = (id) => document.getElementById(id);
const t = (key, vars) => window.I18n?.t(key, vars) ?? key;
const listEl = el("userGroupList");
const emptyEl = el("userGroupListEmpty");
const tableEl = el("userGroupsTable");
const filterInputEl = el("userGroupsFilterInput");
const filterCountEl = el("userGroupsFilterCount");
const pageLabelEl = el("userGroupsPageLabel");
const dialogEl = el("userGroupEditDialog");
const formEl = el("userGroupEditForm");
const titleEl = el("userGroupEditTitle");
const deleteBtnEl = el("userGroupEditDeleteBtn");
const permsBodyEl = el("userGroupEditPermsBody");
const deleteConfirmDialogEl = el("userGroupDeleteConfirmDialog");
const deleteConfirmTextEl = el("userGroupDeleteConfirmText");
const fields = {
name: el("userGroupEditName"),
allowPin: el("userGroupEditAllowPin"),
};
const store = {
groups: [],
editingId: null,
pendingDeleteId: null,
filter: "",
page: 1,
permSelects: {},
};
function canWrite() {
if (!window.AuthApp?.canWrite) return true;
return window.AuthApp.canWrite("users");
}
function isDistributor() {
return window.AuthApp?.getUser?.()?.group_id === "group_distributors";
}
function canManageGroup(group) {
if (!group) return canWrite();
if (isDistributor()) return canWrite();
return canWrite() && group.id !== "group_distributors" && group.id !== "group_administrators";
}
function escapeHtml(str) {
return String(str)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
async function apiJson(url, opts = {}) {
const res = await fetch(url, { credentials: "include", ...opts });
const text = await res.text();
let data = null;
try {
data = text ? JSON.parse(text) : null;
} catch {
data = null;
}
if (!res.ok) throw new Error((data && data.error) || text || res.statusText);
return data;
}
async function refreshAll() {
const data = await apiJson("/api/user_groups");
store.groups = Array.isArray(data.groups) ? data.groups : [];
}
function groupById(id) {
return store.groups.find((g) => g.id === id) || null;
}
function filteredGroups() {
const q = store.filter.trim().toLowerCase();
let items = [...store.groups].sort((a, b) => (a.name || "").localeCompare(b.name || ""));
if (q) {
items = items.filter((g) => (g.name || "").toLowerCase().includes(q));
}
return items;
}
function pageCount(total) {
return Math.max(1, Math.ceil(total / PAGE_SIZE));
}
function pagedItems(items) {
const totalPages = pageCount(items.length);
if (store.page > totalPages) store.page = totalPages;
if (store.page < 1) store.page = 1;
const start = (store.page - 1) * PAGE_SIZE;
return items.slice(start, start + PAGE_SIZE);
}
function updatePagerUi(totalItems) {
const totalPages = pageCount(totalItems);
if (filterCountEl) filterCountEl.textContent = t("userGroups.itemsFound", { n: totalItems });
if (pageLabelEl) pageLabelEl.textContent = t("userGroups.pageOf", { page: store.page, total: totalPages });
const atStart = store.page <= 1;
const atEnd = store.page >= totalPages;
el("userGroupsPageFirst")?.toggleAttribute("disabled", atStart);
el("userGroupsPagePrev")?.toggleAttribute("disabled", atStart);
el("userGroupsPageNext")?.toggleAttribute("disabled", atEnd);
el("userGroupsPageLast")?.toggleAttribute("disabled", atEnd);
}
function permSummary(group) {
const perms = group.permissions || {};
const writeCount = PERM_RESOURCES.filter((r) => perms[r.key] === "write").length;
const readCount = PERM_RESOURCES.filter((r) => perms[r.key] === "read").length;
if (writeCount === PERM_RESOURCES.length) return t("userGroups.permSummaryAllWrite");
if (writeCount === 0 && readCount === 0) return t("userGroups.permSummaryNone");
return t("userGroups.permSummaryMixed", { write: writeCount, read: readCount });
}
function renderList() {
if (!listEl) return;
const items = filteredGroups();
const pageItems = pagedItems(items);
updatePagerUi(items.length);
listEl.innerHTML = "";
const showEmpty = items.length === 0;
if (tableEl) tableEl.hidden = showEmpty;
if (emptyEl) {
emptyEl.hidden = !showEmpty;
emptyEl.textContent = store.filter.trim() ? t("userGroups.emptyFilter") : t("userGroups.empty");
}
pageItems.forEach((group) => {
const tr = document.createElement("tr");
tr.className = "mapsMirRow userGroupsMirRow";
tr.dataset.id = group.id;
const manageable = canManageGroup(group);
const canDelete = manageable && !group.builtin && (group.user_count || 0) === 0;
const actions = canWrite()
? `<div class="mapsMirRowActions">
<button type="button" class="mapsMirIconBtn userGroupEditBtn" data-edit="${escapeHtml(group.id)}" title="${escapeHtml(t("common.edit"))}" ${manageable ? "" : "disabled"}>${ICONS.edit}</button>
<button type="button" class="mapsMirIconBtn mapsMirIconBtn--danger userGroupDeleteBtn" data-delete="${escapeHtml(group.id)}" title="${escapeHtml(t("common.delete"))}" ${canDelete ? "" : "disabled"}>${ICONS.delete}</button>
</div>`
: "";
const pinLabel = group.allow_pin ? t("userGroups.pinYes") : t("userGroups.pinNo");
const usersLabel = t("userGroups.userCount", { n: group.user_count || 0 });
tr.innerHTML = `
<td class="userGroupsMirCellIcon">${ICONS.group}</td>
<td class="userGroupsMirCellName">
<button type="button" class="mapsMirNameLink userGroupsMirNameLink" data-edit="${escapeHtml(group.id)}" ${manageable ? "" : "disabled"}>${escapeHtml(group.name || "—")}</button>
</td>
<td>${escapeHtml(usersLabel)}</td>
<td>${escapeHtml(pinLabel)}</td>
<td class="userGroupsMirCellPerms">${escapeHtml(permSummary(group))}</td>
<td class="mapsMirCellFunctions">${actions}</td>
`;
listEl.appendChild(tr);
});
}
function buildPermRows(permissions = {}) {
if (!permsBodyEl) return;
permsBodyEl.innerHTML = "";
store.permSelects = {};
PERM_RESOURCES.forEach((res) => {
const tr = document.createElement("tr");
const select = document.createElement("select");
select.className = "userGroupsPermSelect";
select.dataset.resource = res.key;
["none", "read", "write"].forEach((level) => {
const opt = document.createElement("option");
opt.value = level;
opt.textContent = t(`userGroups.permLevel.${level}`);
select.appendChild(opt);
});
select.value = permissions[res.key] || "none";
store.permSelects[res.key] = select;
tr.innerHTML = `<th scope="row">${escapeHtml(t(res.labelKey))}</th>`;
const td = document.createElement("td");
td.appendChild(select);
tr.appendChild(td);
permsBodyEl.appendChild(tr);
});
}
function readPermissionsFromForm() {
const perms = {};
PERM_RESOURCES.forEach((res) => {
const select = store.permSelects[res.key];
perms[res.key] = select?.value || "none";
});
return perms;
}
function openCreateDialog() {
store.editingId = null;
if (titleEl) titleEl.textContent = t("userGroups.createTitle");
if (fields.name) {
fields.name.value = "";
fields.name.disabled = false;
}
if (fields.allowPin) fields.allowPin.checked = false;
buildPermRows(defaultPermissionsForNewGroup());
deleteBtnEl?.toggleAttribute("hidden", true);
dialogEl?.showModal();
}
function defaultPermissionsForNewGroup() {
return {
dashboard: "write",
config: "none",
maps: "none",
missions: "read",
sounds: "read",
integrations: "read",
users: "none",
};
}
function openEditDialog(id) {
const group = groupById(id);
if (!group || !canManageGroup(group)) return;
store.editingId = id;
if (titleEl) titleEl.textContent = t("userGroups.editTitle");
if (fields.name) fields.name.value = group.name || "";
if (fields.allowPin) fields.allowPin.checked = !!group.allow_pin;
buildPermRows(group.permissions || {});
const canDelete = !group.builtin && (group.user_count || 0) === 0;
deleteBtnEl?.toggleAttribute("hidden", !canDelete);
dialogEl?.showModal();
}
async function saveGroup(evt) {
evt?.preventDefault();
const name = (fields.name?.value || "").trim();
if (!name) {
alert(t("userGroups.error.missing"));
return;
}
const payload = {
name,
allow_pin: !!fields.allowPin?.checked,
permissions: readPermissionsFromForm(),
};
try {
if (store.editingId) {
await apiJson(`/api/user_groups/${encodeURIComponent(store.editingId)}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
} else {
await apiJson("/api/user_groups", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
}
dialogEl?.close();
await refreshAll();
renderList();
} catch (e) {
alert(e.message);
}
}
function openDeleteConfirm(id) {
const group = groupById(id);
if (!group || group.builtin || (group.user_count || 0) > 0) return;
store.pendingDeleteId = id;
if (deleteConfirmTextEl) {
deleteConfirmTextEl.textContent = t("userGroups.deleteConfirmText", { name: group.name || "" });
}
deleteConfirmDialogEl?.showModal();
}
function openDeleteConfirmFromDialog() {
if (store.editingId) openDeleteConfirm(store.editingId);
}
async function confirmDelete() {
const id = store.pendingDeleteId;
if (!id) return;
try {
await apiJson(`/api/user_groups/${encodeURIComponent(id)}`, { method: "DELETE" });
store.pendingDeleteId = null;
deleteConfirmDialogEl?.close();
dialogEl?.close();
await refreshAll();
renderList();
} catch (e) {
alert(e.message);
}
}
function clearFilters() {
store.filter = "";
store.page = 1;
if (filterInputEl) filterInputEl.value = "";
renderList();
}
function bindEvents() {
el("userGroupCreateBtn")?.addEventListener("click", () => {
if (canWrite()) openCreateDialog();
});
formEl?.addEventListener("submit", saveGroup);
el("userGroupEditCancelBtn")?.addEventListener("click", () => dialogEl?.close());
deleteBtnEl?.addEventListener("click", openDeleteConfirmFromDialog);
listEl?.addEventListener("click", (evt) => {
const editBtn = evt.target.closest("[data-edit]");
const deleteBtn = evt.target.closest("[data-delete]");
if (editBtn?.dataset.edit) openEditDialog(editBtn.dataset.edit);
else if (deleteBtn?.dataset.delete) openDeleteConfirm(deleteBtn.dataset.delete);
});
deleteConfirmDialogEl?.addEventListener("cancel", (evt) => {
evt.preventDefault();
store.pendingDeleteId = null;
deleteConfirmDialogEl?.close();
});
el("userGroupDeleteCancelBtn")?.addEventListener("click", () => {
store.pendingDeleteId = null;
deleteConfirmDialogEl?.close();
});
el("userGroupDeleteYesBtn")?.addEventListener("click", confirmDelete);
filterInputEl?.addEventListener("input", () => {
store.filter = filterInputEl.value;
store.page = 1;
renderList();
});
el("userGroupsClearFiltersBtn")?.addEventListener("click", clearFilters);
el("userGroupsPageFirst")?.addEventListener("click", () => {
store.page = 1;
renderList();
});
el("userGroupsPagePrev")?.addEventListener("click", () => {
store.page = Math.max(1, store.page - 1);
renderList();
});
el("userGroupsPageNext")?.addEventListener("click", () => {
store.page += 1;
renderList();
});
el("userGroupsPageLast")?.addEventListener("click", () => {
store.page = pageCount(filteredGroups().length);
renderList();
});
el("userGroupsHelpBtn")?.addEventListener("click", () => alert(t("userGroups.helpBody")));
window.addEventListener("lm:locale-change", () => {
if (store.editingId || dialogEl?.open) {
const perms = readPermissionsFromForm();
buildPermRows(perms);
}
renderList();
});
}
async function onPageShow() {
if (!window.AuthApp?.canAccessPage?.("user-groups")) return;
document.body.classList.toggle("auth-readonly-user-groups", !canWrite());
try {
await refreshAll();
renderList();
} catch (e) {
if (emptyEl) {
emptyEl.hidden = false;
emptyEl.textContent = e.message;
}
if (tableEl) tableEl.hidden = true;
}
}
function onPageHide() {
dialogEl?.close();
deleteConfirmDialogEl?.close();
}
bindEvents();
window.UserGroupsApp = { onPageShow, onPageHide };
})();

441
www/users.js Normal file
View File

@@ -0,0 +1,441 @@
(() => {
const PAGE_SIZE = 10;
const ICONS = {
user: `<svg class="usersMirIcon" width="20" height="20" viewBox="0 0 20 20" aria-hidden="true"><circle cx="10" cy="7" r="3.5" fill="none" stroke="currentColor" stroke-width="1.4"/><path d="M4 17c0-3.3 2.7-6 6-6s6 2.7 6 6" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/></svg>`,
edit: `<svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true"><path d="M9.5 2.5l2 2L5 11H3v-2L9.5 2.5z" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round"/></svg>`,
delete: `<svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true"><path d="M4 4l6 6M10 4l-6 6" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg>`,
};
const el = (id) => document.getElementById(id);
const t = (key, vars) => window.I18n?.t(key, vars) ?? key;
const listEl = el("userList");
const emptyEl = el("userListEmpty");
const tableEl = el("usersTable");
const createBtnEl = el("userCreateBtn");
const filterInputEl = el("usersFilterInput");
const filterCountEl = el("usersFilterCount");
const pageLabelEl = el("usersPageLabel");
const dialogEl = el("userEditDialog");
const formEl = el("userEditForm");
const titleEl = el("userEditTitle");
const deleteBtnEl = el("userEditDeleteBtn");
const passwordFieldEl = el("userEditPasswordField");
const passwordHintEl = el("userEditPasswordHint");
const pinSectionEl = el("userEditPinSection");
const pinFieldEl = el("userEditPinField");
const pinHintEl = el("userEditPinHint");
const deleteConfirmDialogEl = el("userDeleteConfirmDialog");
const deleteConfirmTextEl = el("userDeleteConfirmText");
const fields = {
displayName: el("userEditDisplayName"),
username: el("userEditUsername"),
password: el("userEditPassword"),
email: el("userEditEmail"),
group: el("userEditGroup"),
enabled: el("userEditEnabled"),
pinEnabled: el("userEditPinEnabled"),
pin: el("userEditPin"),
};
const store = {
users: [],
groups: [],
editingId: null,
pendingDeleteId: null,
filter: "",
page: 1,
};
function canWrite() {
if (!window.AuthApp?.canWrite) return true;
return window.AuthApp.canWrite("users");
}
function currentUserId() {
return window.AuthApp?.getUser?.()?.id || "";
}
function escapeHtml(str) {
return String(str)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
async function apiJson(url, opts = {}) {
const res = await fetch(url, { credentials: "include", ...opts });
const text = await res.text();
let data = null;
try {
data = text ? JSON.parse(text) : null;
} catch {
data = null;
}
if (!res.ok) throw new Error((data && data.error) || text || res.statusText);
return data;
}
async function refreshAll() {
const [usersData, groupsData] = await Promise.all([
apiJson("/api/users"),
apiJson("/api/user_groups"),
]);
store.users = Array.isArray(usersData.users) ? usersData.users : [];
store.groups = Array.isArray(groupsData.groups) ? groupsData.groups : [];
}
function groupById(id) {
return store.groups.find((g) => g.id === id) || null;
}
function groupAllowsPin(groupId) {
return !!groupById(groupId)?.allow_pin;
}
function filteredUsers() {
const q = store.filter.trim().toLowerCase();
let items = [...store.users].sort((a, b) =>
(a.display_name || a.username || "").localeCompare(b.display_name || b.username || ""),
);
if (q) {
items = items.filter((u) => {
const name = (u.display_name || "").toLowerCase();
const username = (u.username || "").toLowerCase();
const email = (u.email || "").toLowerCase();
const group = (u.group_name || "").toLowerCase();
return name.includes(q) || username.includes(q) || email.includes(q) || group.includes(q);
});
}
return items;
}
function pageCount(total) {
return Math.max(1, Math.ceil(total / PAGE_SIZE));
}
function pagedItems(items) {
const totalPages = pageCount(items.length);
if (store.page > totalPages) store.page = totalPages;
if (store.page < 1) store.page = 1;
const start = (store.page - 1) * PAGE_SIZE;
return items.slice(start, start + PAGE_SIZE);
}
function updatePagerUi(totalItems) {
const totalPages = pageCount(totalItems);
if (filterCountEl) filterCountEl.textContent = t("users.itemsFound", { n: totalItems });
if (pageLabelEl) pageLabelEl.textContent = t("users.pageOf", { page: store.page, total: totalPages });
const atStart = store.page <= 1;
const atEnd = store.page >= totalPages;
el("usersPageFirst")?.toggleAttribute("disabled", atStart);
el("usersPagePrev")?.toggleAttribute("disabled", atStart);
el("usersPageNext")?.toggleAttribute("disabled", atEnd);
el("usersPageLast")?.toggleAttribute("disabled", atEnd);
}
function pinLabel(user) {
if (!user.has_pin) return t("users.pinNo");
return t("users.pinYes");
}
function renderList() {
if (!listEl) return;
const items = filteredUsers();
const pageItems = pagedItems(items);
updatePagerUi(items.length);
listEl.innerHTML = "";
const showEmpty = items.length === 0;
if (tableEl) tableEl.hidden = showEmpty;
if (emptyEl) {
emptyEl.hidden = !showEmpty;
emptyEl.textContent = store.filter.trim() ? t("users.emptyFilter") : t("users.empty");
}
pageItems.forEach((user) => {
const tr = document.createElement("tr");
tr.className = "mapsMirRow usersMirRow";
tr.dataset.id = user.id;
const isSelf = user.id === currentUserId();
const actions = canWrite()
? `<div class="mapsMirRowActions">
<button type="button" class="mapsMirIconBtn" data-edit="${escapeHtml(user.id)}" title="${escapeHtml(t("common.edit"))}">${ICONS.edit}</button>
<button type="button" class="mapsMirIconBtn mapsMirIconBtn--danger" data-delete="${escapeHtml(user.id)}" title="${escapeHtml(t("common.delete"))}" ${isSelf ? "disabled" : ""}>${ICONS.delete}</button>
</div>`
: "";
const disabledTag = user.enabled === false ? ` <span class="usersMirDisabledTag">${escapeHtml(t("common.disabled"))}</span>` : "";
tr.innerHTML = `
<td class="usersMirCellIcon">${ICONS.user}</td>
<td class="usersMirCellName">
<button type="button" class="mapsMirNameLink usersMirNameLink" data-edit="${escapeHtml(user.id)}">${escapeHtml(user.display_name || user.username)}</button>${disabledTag}
</td>
<td>${escapeHtml(user.username || "—")}</td>
<td>${escapeHtml(user.group_name || user.group_id || "—")}</td>
<td>${escapeHtml(user.email || "—")}</td>
<td>${escapeHtml(pinLabel(user))}</td>
<td class="mapsMirCellActions">${actions}</td>`;
tr.querySelectorAll("[data-edit]").forEach((btn) => {
btn.addEventListener("click", () => openDialog(btn.dataset.edit));
});
tr.querySelector("[data-delete]")?.addEventListener("click", () => openDeleteConfirm(user.id));
tr.addEventListener("dblclick", () => {
if (canWrite()) openDialog(user.id);
});
listEl.appendChild(tr);
});
if (createBtnEl) createBtnEl.hidden = !canWrite();
}
function fillGroupSelect(value) {
if (!fields.group) return;
fields.group.innerHTML = "";
store.groups.forEach((g) => {
const o = document.createElement("option");
o.value = g.id;
o.textContent = g.name || g.id;
if (g.id === value) o.selected = true;
fields.group.appendChild(o);
});
}
function syncPinUi() {
const groupId = fields.group?.value || "";
const allowPin = groupAllowsPin(groupId);
if (pinSectionEl) pinSectionEl.hidden = !allowPin;
if (pinHintEl) pinHintEl.hidden = allowPin;
if (!allowPin) {
if (fields.pinEnabled) fields.pinEnabled.checked = false;
if (pinFieldEl) pinFieldEl.hidden = true;
if (fields.pin) fields.pin.value = "";
return;
}
const pinOn = fields.pinEnabled?.checked;
if (pinFieldEl) pinFieldEl.hidden = !pinOn;
if (!pinOn && fields.pin) fields.pin.value = "";
}
function openDialog(id = null) {
store.editingId = id;
const existing = id ? store.users.find((x) => x.id === id) : null;
const isEdit = !!existing;
if (titleEl) titleEl.textContent = isEdit ? t("users.editTitle") : t("users.createTitle");
if (fields.displayName) fields.displayName.value = existing?.display_name || "";
if (fields.username) fields.username.value = existing?.username || "";
if (fields.email) fields.email.value = existing?.email || "";
if (fields.enabled) fields.enabled.checked = existing?.enabled !== false;
if (fields.password) fields.password.value = "";
fillGroupSelect(existing?.group_id || store.groups[0]?.id || "");
if (fields.pinEnabled) fields.pinEnabled.checked = !!existing?.has_pin;
if (fields.pin) fields.pin.value = "";
if (passwordFieldEl) passwordFieldEl.hidden = isEdit;
if (passwordHintEl) passwordHintEl.hidden = !isEdit;
if (fields.password) fields.password.required = !isEdit;
const ro = !canWrite();
Object.values(fields).forEach((node) => {
if (!node) return;
node.disabled = ro;
});
if (deleteBtnEl) {
const isSelf = existing?.id === currentUserId();
deleteBtnEl.hidden = !isEdit || ro || isSelf;
}
syncPinUi();
dialogEl?.showModal();
}
function readPayload(isCreate) {
const payload = {
display_name: fields.displayName?.value.trim() || "",
username: fields.username?.value.trim() || "",
email: fields.email?.value.trim() || "",
group_id: fields.group?.value || "",
enabled: fields.enabled?.checked !== false,
};
if (isCreate) payload.password = fields.password?.value || "";
if (fields.pinEnabled?.checked && groupAllowsPin(payload.group_id)) {
const pin = (fields.pin?.value || "").trim();
if (pin) {
payload.pin = pin;
} else if (!store.editingId) {
payload.pin = null;
}
} else {
payload.pin = null;
}
return payload;
}
async function saveDialog() {
if (!canWrite()) return;
const isCreate = !store.editingId;
const payload = readPayload(isCreate);
if (!payload.display_name || !payload.username || !payload.group_id) {
alert(t("users.error.missing"));
return;
}
if (isCreate && !payload.password) {
alert(t("users.error.passwordRequired"));
return;
}
if (fields.pinEnabled?.checked && groupAllowsPin(payload.group_id)) {
const pin = (fields.pin?.value || "").trim();
const needPin = isCreate || !store.users.find((u) => u.id === store.editingId)?.has_pin;
if (needPin && pin.length !== 4) {
alert(t("users.error.pinInvalid"));
return;
}
}
try {
if (store.editingId) {
const updatePayload = { ...payload };
delete updatePayload.password;
await apiJson(`/api/users/${encodeURIComponent(store.editingId)}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(updatePayload),
});
} else {
await apiJson("/api/users", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
}
await refreshAll();
renderList();
dialogEl?.close();
} catch (e) {
alert(e.message);
}
}
function openDeleteConfirm(id) {
const user = store.users.find((x) => x.id === id);
if (!user || !canWrite() || user.id === currentUserId()) return;
store.pendingDeleteId = id;
if (deleteConfirmTextEl) {
deleteConfirmTextEl.textContent = t("users.deleteConfirmText", {
name: user.display_name || user.username,
});
}
deleteConfirmDialogEl?.showModal();
}
function openDeleteConfirmFromDialog() {
const id = store.editingId;
if (!id) return;
openDeleteConfirm(id);
}
async function confirmDelete() {
const id = store.pendingDeleteId || store.editingId;
if (!id || !canWrite()) return;
try {
await apiJson(`/api/users/${encodeURIComponent(id)}`, { method: "DELETE" });
deleteConfirmDialogEl?.close();
dialogEl?.close();
store.editingId = null;
store.pendingDeleteId = null;
await refreshAll();
renderList();
} catch (e) {
alert(e.message);
}
}
function clearFilters() {
store.filter = "";
store.page = 1;
if (filterInputEl) filterInputEl.value = "";
renderList();
}
function bindEvents() {
createBtnEl?.addEventListener("click", () => openDialog(null));
formEl?.addEventListener("submit", (evt) => {
evt.preventDefault();
saveDialog();
});
el("userEditCancelBtn")?.addEventListener("click", () => dialogEl?.close());
dialogEl?.addEventListener("cancel", (evt) => {
evt.preventDefault();
dialogEl?.close();
});
fields.group?.addEventListener("change", syncPinUi);
fields.pinEnabled?.addEventListener("change", syncPinUi);
deleteBtnEl?.addEventListener("click", openDeleteConfirmFromDialog);
el("userDeleteCancelBtn")?.addEventListener("click", () => {
store.pendingDeleteId = null;
deleteConfirmDialogEl?.close();
});
el("userDeleteYesBtn")?.addEventListener("click", confirmDelete);
deleteConfirmDialogEl?.addEventListener("cancel", (evt) => {
evt.preventDefault();
store.pendingDeleteId = null;
deleteConfirmDialogEl?.close();
});
filterInputEl?.addEventListener("input", () => {
store.filter = filterInputEl.value;
store.page = 1;
renderList();
});
el("usersClearFiltersBtn")?.addEventListener("click", clearFilters);
el("usersPageFirst")?.addEventListener("click", () => {
store.page = 1;
renderList();
});
el("usersPagePrev")?.addEventListener("click", () => {
store.page = Math.max(1, store.page - 1);
renderList();
});
el("usersPageNext")?.addEventListener("click", () => {
store.page += 1;
renderList();
});
el("usersPageLast")?.addEventListener("click", () => {
store.page = pageCount(filteredUsers().length);
renderList();
});
el("usersHelpBtn")?.addEventListener("click", () => alert(t("users.helpBody")));
window.addEventListener("lm:locale-change", () => renderList());
}
async function onPageShow() {
if (!window.AuthApp?.canAccessPage?.("users")) return;
document.body.classList.toggle("auth-readonly-users", !canWrite());
try {
await refreshAll();
renderList();
} catch (e) {
if (emptyEl) {
emptyEl.hidden = false;
emptyEl.textContent = e.message;
}
if (tableEl) tableEl.hidden = true;
}
}
function onPageHide() {
dialogEl?.close();
deleteConfirmDialogEl?.close();
}
bindEvents();
window.UsersApp = { onPageShow, onPageHide };
})();