diff --git a/README.md b/README.md index 978da9d..cd015ae 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# LiDAR Manager Web (Test3) +# Robot App Web (Test3) Chức năng: - Đăng ký danh sách cảm biến LiDAR (tên, ip, port) diff --git a/data/robot_runtime.json b/data/robot_runtime.json index f694bbd..9e8b8ec 100644 --- a/data/robot_runtime.json +++ b/data/robot_runtime.json @@ -7,7 +7,7 @@ "health": "ok", "joystick_engaged": false, "joystick_speed": "fast", - "message": "Robot paused", - "motion": "paused", - "updated_at": "2026-06-16T03:40:34Z" + "message": "Waiting for new missions...", + "motion": "running", + "updated_at": "2026-06-16T10:33:19Z" } \ No newline at end of file diff --git a/www/dashboard.js b/www/dashboard.js index a91937c..f4ddae1 100644 --- a/www/dashboard.js +++ b/www/dashboard.js @@ -1,16 +1,27 @@ (() => { - const STORAGE_KEY = "phenikaax_dashboard_v1"; - - function widgetTypeLabel(type) { - return t(`dashboard.widget.${type}`) || type; - } + const STORAGE_KEY_V3 = "phenikaax_dashboard_v3"; + const STORAGE_KEY_V2 = "phenikaax_dashboard_v2"; + const PAGE_SIZE = 10; + const DEFAULT_ID = "dashboard_default"; const el = (id) => document.getElementById(id); const t = (key, vars) => window.I18n?.t(key, vars) ?? key; + + const listViewEl = el("dashboardListView"); + const createViewEl = el("dashboardCreateView"); + const designerViewEl = el("dashboardDesignerView"); const gridEl = el("dashboardGrid"); - const emptyEl = el("dashboardEmpty"); + const designerEmptyEl = el("dashboardDesignerEmpty"); + const tableBodyEl = el("dashboardTableBody"); + const tableWrapEl = el("dashboardTableWrap"); + const filterInputEl = el("dashboardFilterInput"); + const listCountEl = el("dashboardListCount"); + const pageLabelEl = el("dashboardPageLabel"); + const designerTitleEl = el("dashboardDesignerTitle"); + const editDialogEl = el("dashboardEditDialog"); + const permissionsDialogEl = el("dashboardPermissionsDialog"); const addDialogEl = el("dashboardAddWidgetDialog"); - const editDialogEl = el("dashboardEditWidgetDialog"); + const editWidgetDialogEl = el("dashboardEditWidgetDialog"); const addTypeEl = el("dashboardWidgetType"); const addFieldsEl = el("dashboardAddWidgetFields"); const editFieldsEl = el("dashboardEditWidgetFields"); @@ -18,50 +29,57 @@ const editWidgetTypeEl = el("dashboardEditWidgetType"); const store = { - widgets: [], + dashboards: [], + activeDashboardId: null, + view: "list", + filter: "", + page: 1, editMode: false, pollActive: false, queueUnsub: null, + userGroups: [], }; - function newId() { - if (typeof crypto !== "undefined" && crypto.randomUUID) return crypto.randomUUID(); - return `w_${Date.now().toString(36)}`; + function widgetTypeLabel(type) { + return t(`dashboard.widget.${type}`) || type; + } + + function newId(prefix = "d") { + if (typeof crypto !== "undefined" && crypto.randomUUID) return `${prefix}_${crypto.randomUUID()}`; + return `${prefix}_${Date.now().toString(36)}`; } function missions() { return window.MissionsApp || null; } - function loadStore() { - try { - const raw = localStorage.getItem(STORAGE_KEY); - if (!raw) { - bootstrapDefaults(); - return; - } - const data = JSON.parse(raw); - store.widgets = Array.isArray(data.widgets) ? data.widgets : []; - if (!store.widgets.length) bootstrapDefaults(); - } catch { - bootstrapDefaults(); - } + function currentUser() { + return window.AuthApp?.getUser?.() || null; } - function persistStore() { - localStorage.setItem(STORAGE_KEY, JSON.stringify({ widgets: store.widgets })); + function canEditDashboardsModule() { + const user = currentUser(); + if (!user?.permissions) return true; + const level = user.permissions.dashboard; + return level === "write" || level === undefined; } - function bootstrapDefaults() { - const m = missions()?.getMissions?.() || []; - const firstId = m[0]?.id || ""; - store.widgets = [ - { id: newId(), type: "mission_button", mission_id: firstId, title: "" }, - { id: newId(), type: "mission_group", group: "Missions", title: "" }, - { id: newId(), type: "mission_queue", title: "" }, - { id: newId(), type: "pause_continue", title: "" }, - ]; - persistStore(); + function activeDashboard() { + return store.dashboards.find((d) => d.id === store.activeDashboardId) || null; + } + + function dashboardCanEdit(dashboard) { + if (!dashboard) return false; + if (!canEditDashboardsModule()) return false; + const user = currentUser(); + if (!user) return true; + const groups = Array.isArray(dashboard.editGroups) ? dashboard.editGroups : []; + if (!groups.length) return true; + return groups.includes(user.group_id); + } + + function normalizeStr(value) { + return String(value || "").trim(); } function escapeHtml(str) { @@ -72,6 +90,284 @@ .replace(/"/g, """); } + function activeWidgets() { + return activeDashboard()?.widgets || []; + } + + function setActiveWidgets(widgets) { + const db = activeDashboard(); + if (db) db.widgets = widgets; + } + + function bootstrapDefaultDashboard(widgets = []) { + store.dashboards = [ + { + id: DEFAULT_ID, + name: "Default Dashboard", + createdBy: t("dashboard.createdBy.system"), + createdByUser: null, + isDefault: true, + editGroups: ["group_administrators", "group_distributors", "group_users"], + widgets: Array.isArray(widgets) ? widgets : [], + }, + ]; + store.activeDashboardId = DEFAULT_ID; + persistStore(); + } + + function migrateFromV2() { + try { + const raw = localStorage.getItem(STORAGE_KEY_V2); + if (!raw) return false; + const data = JSON.parse(raw); + const widgets = Array.isArray(data.widgets) ? data.widgets : []; + bootstrapDefaultDashboard(widgets); + return true; + } catch { + return false; + } + } + + function loadStore() { + try { + const raw = localStorage.getItem(STORAGE_KEY_V3); + if (!raw) { + if (!migrateFromV2()) bootstrapDefaultDashboard(); + return; + } + const data = JSON.parse(raw); + store.dashboards = Array.isArray(data.dashboards) ? data.dashboards : []; + store.activeDashboardId = data.activeDashboardId || store.dashboards[0]?.id || null; + if (!store.dashboards.length) bootstrapDefaultDashboard(); + else if (!store.activeDashboardId) store.activeDashboardId = store.dashboards[0].id; + } catch { + bootstrapDefaultDashboard(); + } + } + + function persistStore() { + localStorage.setItem( + STORAGE_KEY_V3, + JSON.stringify({ + dashboards: store.dashboards, + activeDashboardId: store.activeDashboardId, + }) + ); + } + + async function loadUserGroups() { + try { + const res = await fetch("/api/user_groups", { credentials: "include" }); + if (!res.ok) return; + const data = await res.json(); + store.userGroups = Array.isArray(data.groups) ? data.groups : []; + } catch { + store.userGroups = []; + } + } + + function filteredDashboards() { + const q = normalizeStr(store.filter).toLowerCase(); + const list = [...store.dashboards]; + list.sort((a, b) => { + if (a.isDefault && !b.isDefault) return -1; + if (!a.isDefault && b.isDefault) return 1; + return a.name.localeCompare(b.name); + }); + if (!q) return list; + return list.filter((d) => d.name.toLowerCase().includes(q)); + } + + function paginatedDashboards() { + const all = filteredDashboards(); + const totalPages = Math.max(1, Math.ceil(all.length / PAGE_SIZE)); + if (store.page > totalPages) store.page = totalPages; + if (store.page < 1) store.page = 1; + const start = (store.page - 1) * PAGE_SIZE; + return { all, pageItems: all.slice(start, start + PAGE_SIZE), totalPages }; + } + + function renderPermissionsChecklist(container, selected = []) { + if (!container) return; + container.replaceChildren(); + const selectedSet = new Set(selected); + store.userGroups.forEach((group) => { + const label = document.createElement("label"); + const input = document.createElement("input"); + input.type = "checkbox"; + input.value = group.id; + input.checked = selectedSet.has(group.id); + label.appendChild(input); + label.appendChild(document.createTextNode(group.name || group.id)); + container.appendChild(label); + }); + if (!store.userGroups.length) { + const note = document.createElement("p"); + note.className = "mutedNote"; + note.textContent = "—"; + container.appendChild(note); + } + } + + function readPermissionsChecklist(container) { + const ids = []; + container?.querySelectorAll('input[type="checkbox"]:checked').forEach((node) => { + ids.push(node.value); + }); + return ids; + } + + function refreshNav() { + window.NavApp?.refreshFlyout?.(); + } + + function setView(view) { + store.view = view; + if (listViewEl) listViewEl.hidden = view !== "list"; + if (createViewEl) createViewEl.hidden = view !== "create"; + if (designerViewEl) designerViewEl.hidden = view !== "designer"; + if (view === "list") { + stopDashboardPoll(); + renderListUI(); + } else if (view === "create") { + stopDashboardPoll(); + } else { + renderDesignerChrome(); + renderDashboard(); + startDashboardPoll(); + } + } + + function setActiveDashboard(id) { + if (!store.dashboards.some((d) => d.id === id)) return; + store.activeDashboardId = id; + persistStore(); + refreshNav(); + } + + function renderListUI() { + const { all, pageItems, totalPages } = paginatedDashboards(); + + if (listCountEl) listCountEl.textContent = t("dashboard.list.itemsFound", { count: all.length }); + if (pageLabelEl) pageLabelEl.textContent = t("dashboard.list.pageOf", { page: store.page, total: totalPages }); + + document.querySelectorAll(".dashboardPageBtn").forEach((btn) => { + const action = btn.dataset.pageAction; + if (action === "first" || action === "prev") btn.disabled = store.page <= 1; + else if (action === "next" || action === "last") btn.disabled = store.page >= totalPages; + }); + + if (tableBodyEl) { + tableBodyEl.replaceChildren(); + if (!pageItems.length) { + const tr = document.createElement("tr"); + tr.className = "dashboardTableEmptyRow"; + tr.innerHTML = `${escapeHtml(t("dashboard.list.empty"))}`; + tableBodyEl.appendChild(tr); + } else { + pageItems.forEach((dashboard) => { + const tr = document.createElement("tr"); + tr.className = "dashboardTableRow"; + if (dashboard.isDefault) tr.classList.add("is-default"); + if (dashboard.id === store.activeDashboardId) tr.classList.add("is-active"); + const canEdit = dashboardCanEdit(dashboard); + const isActive = dashboard.id === store.activeDashboardId; + tr.innerHTML = ` + + ${isActive ? `` : ``} + + ${escapeHtml(dashboard.name)} + ${escapeHtml(dashboard.createdBy || "—")} + +
+ + + +
+ `; + tableBodyEl.appendChild(tr); + }); + } + } + + if (tableWrapEl) tableWrapEl.classList.toggle("is-empty", all.length === 0); + + const createBtn = el("dashboardCreateBtn"); + if (createBtn) createBtn.disabled = !canEditDashboardsModule(); + } + + function renderDesignerChrome() { + const db = activeDashboard(); + if (designerTitleEl) designerTitleEl.textContent = db?.name || "—"; + } + + function openCreateView() { + if (!canEditDashboardsModule()) return; + const nameInput = el("dashboardCreateName"); + if (nameInput) nameInput.value = ""; + renderPermissionsChecklist(el("dashboardCreatePermissions"), currentUser()?.group_id ? [currentUser().group_id] : []); + setView("create"); + requestAnimationFrame(() => nameInput?.focus()); + } + + function openCreatePermissionsDialog() { + const container = el("dashboardCreatePermissions"); + const current = readPermissionsChecklist(container); + const defaults = current.length ? current : currentUser()?.group_id ? [currentUser().group_id] : []; + renderPermissionsChecklist(container, defaults); + permissionsDialogEl?.showModal(); + } + + function closeCreateView() { + setView("list"); + window.NavApp?.syncDashboardSection?.("dashboard-list"); + } + + function openEditDashboardDialog(id) { + const dashboard = store.dashboards.find((d) => d.id === id); + if (!dashboard || !dashboardCanEdit(dashboard)) { + alert(t("dashboard.list.noEditPermission")); + return; + } + el("dashboardEditId").value = dashboard.id; + el("dashboardEditName").value = dashboard.name; + renderPermissionsChecklist(el("dashboardEditPermissions"), dashboard.editGroups || []); + editDialogEl?.showModal(); + } + + function openDesignerFor(id) { + setActiveDashboard(id); + setView("designer"); + window.NavApp?.syncDashboardSection?.(`dashboard-${id}`); + } + + function deleteDashboard(id) { + const dashboard = store.dashboards.find((d) => d.id === id); + if (!dashboard) return; + if (dashboard.isDefault) { + alert(t("dashboard.list.cannotDeleteDefault")); + return; + } + if (!dashboardCanEdit(dashboard)) { + alert(t("dashboard.list.noEditPermission")); + return; + } + if (!confirm(t("dashboard.list.deleteConfirm", { name: dashboard.name }))) return; + store.dashboards = store.dashboards.filter((d) => d.id !== id); + if (store.activeDashboardId === id) { + store.activeDashboardId = store.dashboards[0]?.id || null; + } + persistStore(); + refreshNav(); + renderListUI(); + } + function widgetTitle(widget) { if (widget.title) return widget.title; return widgetTypeLabel(widget.type); @@ -105,7 +401,7 @@
- +
`; } else if (type === "mission_group") { container.innerHTML = ` @@ -129,7 +425,7 @@ -

Tạm dừng / tiếp tục / hủy mission đang chạy trên robot.

`; +

${escapeHtml(t("dashboard.widget.pauseHint"))}

`; } } @@ -240,8 +536,8 @@
${escapeHtml(widgetTitle(widget))}
`; @@ -261,7 +557,7 @@ renderPauseContinueWidget(widget, bodyEl); break; default: - bodyEl.innerHTML = `

Widget không hỗ trợ.

`; + bodyEl.innerHTML = `

${t("dashboard.widget.unsupported")}

`; } card.querySelector("[data-widget-config]")?.addEventListener("click", () => openEditDialog(widget.id)); @@ -271,17 +567,18 @@ function renderDashboard() { if (!gridEl) return; + const widgets = activeWidgets(); gridEl.innerHTML = ""; - if (emptyEl) emptyEl.hidden = store.widgets.length > 0; + if (designerEmptyEl) designerEmptyEl.hidden = widgets.length > 0; gridEl.classList.toggle("dashboardGrid--edit", store.editMode); - store.widgets.forEach((w) => gridEl.appendChild(renderWidget(w))); + widgets.forEach((w) => gridEl.appendChild(renderWidget(w))); gridEl.querySelectorAll(".dashboardWidgetChrome").forEach((n) => { n.hidden = !store.editMode; }); } function refreshDynamicWidgets() { - store.widgets.forEach((widget) => { + activeWidgets().forEach((widget) => { const card = gridEl?.querySelector(`[data-widget-id="${widget.id}"]`); if (!card) return; const bodyEl = card.querySelector(".dashboardWidgetBody"); @@ -290,43 +587,143 @@ }); } - function openAddDialog() { - fillTypeFields(addFieldsEl, addTypeEl.value); - addDialogEl.showModal(); - } - function openEditDialog(widgetId) { - const widget = store.widgets.find((w) => w.id === widgetId); + const widget = activeWidgets().find((w) => w.id === widgetId); if (!widget) return; editWidgetIdEl.value = widget.id; editWidgetTypeEl.value = widgetTypeLabel(widget.type); fillTypeFields(editFieldsEl, widget.type, widget); - editDialogEl.showModal(); + editWidgetDialogEl.showModal(); } function deleteWidget(widgetId) { if (!confirm(t("dashboard.widget.deleteConfirm"))) return; - store.widgets = store.widgets.filter((w) => w.id !== widgetId); + const db = activeDashboard(); + if (!db) return; + db.widgets = db.widgets.filter((w) => w.id !== widgetId); persistStore(); renderDashboard(); - editDialogEl.close(); + editWidgetDialogEl.close(); + } + + function handleNav(section) { + if (section === "dashboard-list") { + setView("list"); + return; + } + if (section.startsWith("dashboard-")) { + const id = section.slice("dashboard-".length); + if (store.dashboards.some((d) => d.id === id)) { + setActiveDashboard(id); + setView("designer"); + } + } + } + + function getNavItems() { + const items = [{ section: "dashboard-list", page: "dashboard", label: t("nav.dashboardsList") }]; + store.dashboards.forEach((dashboard) => { + items.push({ + section: `dashboard-${dashboard.id}`, + page: "dashboard", + label: dashboard.name, + }); + }); + return items; } function bindEvents() { - el("dashboardAddWidgetBtn")?.addEventListener("click", openAddDialog); - el("dashboardEditBtn")?.addEventListener("click", () => { - store.editMode = !store.editMode; - el("dashboardEditBtn").textContent = store.editMode ? t("dashboard.editDone") : t("dashboard.editLayout"); - renderDashboard(); + el("dashboardCreateBtn")?.addEventListener("click", openCreateView); + el("dashboardCreateBackBtn")?.addEventListener("click", closeCreateView); + el("dashboardCreateCancelBtn")?.addEventListener("click", closeCreateView); + el("dashboardCreatePermissionsBtn")?.addEventListener("click", openCreatePermissionsDialog); + el("dashboardPermissionsForm")?.addEventListener("submit", (evt) => { + evt.preventDefault(); + permissionsDialogEl?.close(); + }); + el("dashboardClearFiltersBtn")?.addEventListener("click", () => { + store.filter = ""; + store.page = 1; + if (filterInputEl) filterInputEl.value = ""; + renderListUI(); + }); + filterInputEl?.addEventListener("input", () => { + store.filter = filterInputEl.value; + store.page = 1; + renderListUI(); + }); + + document.getElementById("dashboardPagination")?.addEventListener("click", (evt) => { + const btn = evt.target.closest("[data-page-action]"); + if (!btn || btn.disabled) return; + const { totalPages } = paginatedDashboards(); + const action = btn.dataset.pageAction; + if (action === "first") store.page = 1; + else if (action === "prev") store.page = Math.max(1, store.page - 1); + else if (action === "next") store.page = Math.min(totalPages, store.page + 1); + else if (action === "last") store.page = totalPages; + renderListUI(); + }); + + tableBodyEl?.addEventListener("click", (evt) => { + const btn = evt.target.closest("[data-action]"); + if (!btn) return; + const id = btn.dataset.id; + const action = btn.dataset.action; + if (action === "design") openDesignerFor(id); + else if (action === "edit") openEditDashboardDialog(id); + else if (action === "delete") deleteDashboard(id); + }); + + el("dashboardBackToListBtn")?.addEventListener("click", () => { + setView("list"); + window.NavApp?.syncDashboardSection?.("dashboard-list"); + }); + + el("dashboardCreateForm")?.addEventListener("submit", (evt) => { + evt.preventDefault(); + const name = normalizeStr(el("dashboardCreateName").value); + if (!name) return; + const user = currentUser(); + const dashboard = { + id: newId("dashboard"), + name, + createdBy: user?.group_name || user?.display_name || user?.username || "—", + createdByUser: user?.username || null, + isDefault: false, + editGroups: readPermissionsChecklist(el("dashboardCreatePermissions")), + widgets: [], + }; + store.dashboards.push(dashboard); + store.activeDashboardId = dashboard.id; + persistStore(); + refreshNav(); + openDesignerFor(dashboard.id); + }); + + el("dashboardEditForm")?.addEventListener("submit", (evt) => { + evt.preventDefault(); + const id = el("dashboardEditId").value; + const dashboard = store.dashboards.find((d) => d.id === id); + if (!dashboard || !dashboardCanEdit(dashboard)) return; + dashboard.name = normalizeStr(el("dashboardEditName").value); + dashboard.editGroups = readPermissionsChecklist(el("dashboardEditPermissions")); + persistStore(); + editDialogEl?.close(); + refreshNav(); + if (store.view === "list") renderListUI(); + else renderDesignerChrome(); }); addTypeEl?.addEventListener("change", () => fillTypeFields(addFieldsEl, addTypeEl.value)); el("dashboardAddWidgetForm")?.addEventListener("submit", (evt) => { evt.preventDefault(); + const db = activeDashboard(); + if (!db) return; const type = addTypeEl.value; const fields = readFields(addFieldsEl); - store.widgets.push({ id: newId(), type, ...fields }); + db.widgets.push({ id: newId("w"), type, ...fields }); persistStore(); addDialogEl.close(); renderDashboard(); @@ -335,11 +732,11 @@ el("dashboardEditWidgetForm")?.addEventListener("submit", (evt) => { evt.preventDefault(); const id = editWidgetIdEl.value; - const widget = store.widgets.find((w) => w.id === id); + const widget = activeWidgets().find((w) => w.id === id); if (!widget) return; Object.assign(widget, readFields(editFieldsEl)); persistStore(); - editDialogEl.close(); + editWidgetDialogEl.close(); renderDashboard(); }); @@ -348,6 +745,7 @@ function startDashboardPoll() { if (window.AuthApp && !window.AuthApp.isReady()) return; + if (store.view !== "designer") return; stopDashboardPoll(); missions()?.refreshQueue?.(); store.queueUnsub = missions()?.onQueueUpdate?.(() => refreshDynamicWidgets()); @@ -366,33 +764,47 @@ } } - function init() { + async function init() { loadStore(); + await loadUserGroups(); bindEvents(); - renderDashboard(); + setView("list"); } window.DashboardApp = { init, + getNavItems, + handleNav, onPageShow() { - renderDashboard(); - startDashboardPoll(); + if (store.view === "designer") { + renderDesignerChrome(); + renderDashboard(); + startDashboardPoll(); + } else { + renderListUI(); + } }, onPageHide() { stopDashboardPoll(); }, refresh() { - renderDashboard(); + if (store.view === "list") renderListUI(); + else renderDashboard(); }, }; - function boot() { - init(); + async function boot() { + await init(); + refreshNav(); } + window.addEventListener("lm:locale-change", () => { - renderDashboard(); - const editBtn = el("dashboardEditBtn"); - if (editBtn) editBtn.textContent = store.editMode ? t("dashboard.editDone") : t("dashboard.editLayout"); + if (store.view === "list") renderListUI(); + else { + renderDesignerChrome(); + renderDashboard(); + } + refreshNav(); }); if (window.AuthApp?.isReady()) boot(); diff --git a/www/i18n.js b/www/i18n.js index c5e94d9..c7d6a58 100644 --- a/www/i18n.js +++ b/www/i18n.js @@ -1,12 +1,12 @@ /** - * Central i18n for LiDAR Manager — vi / en. + * Central i18n for Robot App — vi / en. * Static DOM: data-i18n, data-i18n-placeholder, data-i18n-title, data-i18n-aria * Dynamic JS: I18n.t("key") or I18n.t("key", { name: "..." }) */ (() => { const MESSAGES = { vi: { - "app.title": "LiDAR Manager", + "app.title": "Robot App", "app.robotName": "RobotApp", "app.status.ready": "Sẵn sàng", "app.status.reloaded": "Đã tải lại", @@ -66,6 +66,7 @@ "nav.help": "Help", "nav.logout": "Log out", "nav.dashboard": "Dashboard", + "nav.dashboardsList": "Dashboards", "nav.missions": "Missions", "nav.maps": "Maps & layout", "nav.monitoring-log": "System log", @@ -116,6 +117,40 @@ "dashboard.title": "Dashboard", "dashboard.subtitle": "Widget mission — chạy, xếp hàng và tạm dừng giống MiR Fleet.", + "dashboard.list.title": "Dashboards", + "dashboard.list.subtitle": "Tạo và chỉnh sửa dashboard cho robot.", + "dashboard.list.create": "+ Tạo dashboard", + "dashboard.list.clearFilters": "Xóa bộ lọc", + "dashboard.list.filterLabel": "Lọc:", + "dashboard.list.filterPlaceholder": "Nhập tên để lọc…", + "dashboard.list.itemsFound": "{count} mục", + "dashboard.list.pageOf": "Trang {page} / {total}", + "dashboard.list.col.name": "Tên", + "dashboard.list.col.createdBy": "Tạo bởi", + "dashboard.list.col.functions": "Chức năng", + "dashboard.list.empty": "Không có dashboard nào.", + "dashboard.list.back": "← Danh sách", + "dashboard.list.design": "Thiết kế", + "dashboard.list.active": "Dashboard đang active", + "dashboard.list.edit": "Sửa", + "dashboard.list.delete": "Xóa", + "dashboard.list.deleteConfirm": "Xóa dashboard «{name}»?", + "dashboard.list.cannotDeleteDefault": "Không thể xóa Default Dashboard.", + "dashboard.list.noEditPermission": "Bạn không có quyền chỉnh sửa dashboard này.", + "dashboard.dialog.create.title": "Tạo dashboard", + "dashboard.create.title": "Tạo dashboard", + "dashboard.create.subtitle": "Tạo dashboard mới trên robot.", + "dashboard.create.backToList": "← Quay lại danh sách", + "dashboard.create.name": "Tên", + "dashboard.create.namePlaceholder": "VD: John's Dashboard", + "dashboard.create.permissions": "Chọn user groups được phép chỉnh sửa dashboard này.", + "dashboard.create.permissionsBtn": "Quyền", + "dashboard.create.permissionsTitle": "Quyền chỉnh sửa", + "dashboard.create.submit": "Tạo dashboard", + "dashboard.create.cancel": "Hủy", + "dashboard.dialog.editDashboard.title": "Sửa dashboard", + "dashboard.designer.empty": "Chưa có widget. Phase B sẽ thêm designer đầy đủ.", + "dashboard.createdBy.system": "MiR", "dashboard.addWidget": "Thêm widget", "dashboard.editLayout": "Sửa layout", "dashboard.editDone": "Xong", @@ -341,7 +376,7 @@ "help.api.body2": "Reference Guide MiR rev 1.9: docs/Reference guide.pdf", }, en: { - "app.title": "LiDAR Manager", + "app.title": "Robot App", "app.robotName": "RobotApp", "app.status.ready": "Ready", "app.status.reloaded": "Reloaded", @@ -401,6 +436,7 @@ "nav.help": "Help", "nav.logout": "Log out", "nav.dashboard": "Dashboard", + "nav.dashboardsList": "Dashboards", "nav.missions": "Missions", "nav.maps": "Maps & layout", "nav.monitoring-log": "System log", @@ -451,6 +487,40 @@ "dashboard.title": "Dashboard", "dashboard.subtitle": "Mission widgets — run, queue and pause like MiR Fleet.", + "dashboard.list.title": "Dashboards", + "dashboard.list.subtitle": "Create and edit dashboards for the robot.", + "dashboard.list.create": "+ Create dashboard", + "dashboard.list.clearFilters": "Clear filters", + "dashboard.list.filterLabel": "Filter:", + "dashboard.list.filterPlaceholder": "Write name to filter by…", + "dashboard.list.itemsFound": "{count} item(s) found", + "dashboard.list.pageOf": "Page {page} of {total}", + "dashboard.list.col.name": "Name", + "dashboard.list.col.createdBy": "Created by", + "dashboard.list.col.functions": "Functions", + "dashboard.list.empty": "No dashboards found.", + "dashboard.list.back": "← Back to list", + "dashboard.list.design": "Design", + "dashboard.list.active": "Active dashboard", + "dashboard.list.edit": "Edit", + "dashboard.list.delete": "Delete", + "dashboard.list.deleteConfirm": "Delete dashboard «{name}»?", + "dashboard.list.cannotDeleteDefault": "Cannot delete Default Dashboard.", + "dashboard.list.noEditPermission": "You do not have permission to edit this dashboard.", + "dashboard.dialog.create.title": "Create dashboard", + "dashboard.create.title": "Create dashboard", + "dashboard.create.subtitle": "Create a new dashboard in the robot.", + "dashboard.create.backToList": "← Back to the list", + "dashboard.create.name": "Name", + "dashboard.create.namePlaceholder": "John's Dashboard", + "dashboard.create.permissions": "Select user groups allowed to edit this dashboard.", + "dashboard.create.permissionsBtn": "Permissions", + "dashboard.create.permissionsTitle": "Permissions", + "dashboard.create.submit": "Create dashboard", + "dashboard.create.cancel": "Cancel", + "dashboard.dialog.editDashboard.title": "Edit dashboard", + "dashboard.designer.empty": "No widgets yet. Full designer coming in Phase B.", + "dashboard.createdBy.system": "MiR", "dashboard.addWidget": "Add widget", "dashboard.editLayout": "Edit layout", "dashboard.editDone": "Done", diff --git a/www/index.html b/www/index.html index e41284c..ba121df 100644 --- a/www/index.html +++ b/www/index.html @@ -3,7 +3,7 @@ - LiDAR Manager + Robot App @@ -248,50 +248,94 @@
@@ -1024,6 +1068,47 @@ GET /api/v2.0.0/status + +
+
+

Quyền chỉnh sửa

+ +
+
+

Chọn user groups được phép chỉnh sửa dashboard này.

+
+
+
+ + +
+
+
+ + +
+
+

Sửa dashboard

+ +
+
+ +
+ + +
+
+ +
+
+
+
+ + +
+
+
+
diff --git a/www/nav.js b/www/nav.js index 46da719..a7c4d72 100644 --- a/www/nav.js +++ b/www/nav.js @@ -8,7 +8,8 @@ const MODULES = { dashboards: { - items: [{ section: "dashboard", page: "dashboard" }], + items: [{ section: "dashboard-list", page: "dashboard" }], + dynamic: true, }, setup: { items: [ @@ -28,7 +29,7 @@ }; const PAGE_NAV = { - dashboard: { module: "dashboards", section: "dashboard" }, + dashboard: { module: "dashboards", section: "dashboard-list" }, config: { module: "setup", section: "maps" }, missions: { module: "setup", section: "missions" }, integrations: { module: "system", section: "integrations" }, @@ -54,10 +55,24 @@ return true; } + function moduleItems(moduleId) { + const mod = MODULES[moduleId]; + if (!mod) return []; + if (mod.dynamic && moduleId === "dashboards" && window.DashboardApp?.getNavItems) { + return window.DashboardApp.getNavItems(); + } + return mod.items; + } + function visibleItems(moduleId) { const mod = MODULES[moduleId]; if (!mod) return []; - return mod.items.filter((item) => canAccessPage(item.page)); + return moduleItems(moduleId).filter((item) => canAccessPage(item.page)); + } + + function itemLabel(item) { + if (item.label) return item.label; + return t(item.section); } function moduleHasAccess(moduleId) { @@ -89,7 +104,7 @@ btn.className = "mirNavFlyoutItem"; btn.dataset.section = item.section; btn.dataset.page = item.page; - btn.textContent = t(item.section); + btn.textContent = itemLabel(item); if (item.section === activeSection) { btn.classList.add("is-active"); btn.setAttribute("aria-current", "page"); @@ -159,6 +174,14 @@ saveState(); updateRailUI(); navigateToPage(page); + if (page === "dashboard") window.DashboardApp?.handleNav?.(section); + } + + function syncDashboardSection(section) { + activeModule = "dashboards"; + activeSection = section.startsWith("dashboard-") || section === "dashboard-list" ? section : `dashboard-${section}`; + saveState(); + updateRailUI(); } function navigateToPage(page) { @@ -172,6 +195,7 @@ activeSection = nav.section; saveState(); updateRailUI(); + if (page === "dashboard") window.DashboardApp?.handleNav?.(activeSection); } function toggleFlyout() { @@ -231,6 +255,10 @@ navigateToPage(page); } + function refreshFlyout() { + updateRailUI(); + } + function refreshLabels() { window.I18n?.applyDOM?.(); updateRailUI(); @@ -260,9 +288,11 @@ window.NavApp = { init, syncFromPage, + syncDashboardSection, applyPermissions, selectModule, selectSection, toggleFlyout, + refreshFlyout, }; })(); diff --git a/www/style.css b/www/style.css index 636ccf1..ec7f46c 100644 --- a/www/style.css +++ b/www/style.css @@ -26,6 +26,8 @@ body { display: grid; grid-template-columns: auto 1fr; min-height: 100vh; + height: 100vh; + overflow: hidden; } /* —— MiR 3-column navigation (rail + flyout + content) —— */ @@ -200,6 +202,8 @@ body { display: grid; grid-template-rows: auto 1fr; min-width: 0; + min-height: 0; + overflow: hidden; background: var(--bg); box-shadow: -2px 0 12px rgba(15, 23, 42, 0.06); } @@ -1002,7 +1006,18 @@ canvas { } .content.content--dashboard { grid-template-columns: minmax(0, 1fr); - max-width: 1100px; + max-width: none; + align-items: stretch; + align-content: stretch; + padding: 16px; + overflow: hidden; +} +.content.content--dashboard > #pageOverview { + display: flex; + flex-direction: column; + min-height: 0; + height: 100%; + width: 100%; } .content.content--config { grid-template-columns: var(--leftPaneW, 460px) 10px 1fr; @@ -1339,8 +1354,384 @@ canvas { .missionConfigGrid { display: grid; gap: 12px; } .missionConfigGrid .rowWide { grid-template-columns: 1fr; gap: 6px; } -.dashboardPage { display: grid; gap: 16px; min-width: 0; width: 100%; } -.dashboardToolbar { display: flex; gap: 8px; flex-wrap: wrap; } +.dashboardShell { + display: flex; + flex-direction: column; + flex: 1; + min-width: 0; + min-height: 0; + width: 100%; + height: 100%; + background: #fff; + border: 1px solid var(--border); + border-radius: 14px; + box-shadow: var(--shadow); + overflow: hidden; +} +.dashboardContent { + display: flex; + flex-direction: column; + flex: 1; + min-width: 0; + min-height: 0; + height: 100%; + padding: 20px 24px 24px; +} +.dashboardListView, +.dashboardCreateView, +.dashboardDesignerView { + display: flex; + flex-direction: column; + flex: 1; + min-width: 0; + min-height: 0; + height: 100%; + overflow-y: auto; +} +.dashboardListView[hidden], +.dashboardCreateView[hidden], +.dashboardDesignerView[hidden] { + display: none !important; +} +.dashboardListHeader { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + flex-wrap: wrap; + margin-bottom: 16px; + flex-shrink: 0; +} +.dashboardListTitle { + margin: 0; + font-size: 22px; + font-weight: 800; + color: var(--text); + letter-spacing: -0.02em; +} +.dashboardListSub { + margin: 6px 0 0; + font-size: 13px; + color: var(--muted); + display: flex; + align-items: center; + gap: 6px; +} +.dashboardListHelp { + display: inline-grid; + place-items: center; + width: 16px; + height: 16px; + border-radius: 999px; + border: 1px solid var(--border); + font-size: 10px; + font-weight: 700; + color: var(--muted); +} +.dashboardListHeaderActions { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; } +.dashboardCreateBtn { font-weight: 700; } + +.dashboardCreateHeader { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + flex-wrap: wrap; + margin-bottom: 20px; + flex-shrink: 0; +} +.dashboardCreateTitle { + margin: 0; + font-size: 22px; + font-weight: 800; + color: var(--text); + letter-spacing: -0.02em; +} +.dashboardCreateSub { + margin: 6px 0 0; + font-size: 13px; + color: var(--muted); + display: flex; + align-items: center; + gap: 6px; +} +.dashboardCreateBackBtn { white-space: nowrap; } +.dashboardCreateForm { + flex: 0 0 auto; + display: flex; + flex-direction: column; + align-self: stretch; +} +.dashboardCreatePanel { + border: 1px solid #c8d4e0; + background: #fff; + padding: 20px 24px 24px; + display: flex; + flex-direction: column; + gap: 24px; +} +.dashboardCreateField { + display: grid; + gap: 8px; +} +.dashboardCreateField label { + font-size: 11px; + font-weight: 800; + text-transform: uppercase; + letter-spacing: 0.06em; + color: #334155; +} +.dashboardCreateField input { + width: 100%; + padding: 9px 12px; + border: 1px solid #c8d4e0; + border-radius: 4px; + font: inherit; + font-size: 14px; + background: #fff; + box-sizing: border-box; + color: #64748b; +} +.dashboardCreateField input:focus { + outline: none; + border-color: #64748b; + color: #0f172a; +} +.dashboardCreateActions { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} +.dashboardMirBtn { + border-radius: 6px; + padding: 8px 14px; + font-size: 13px; + font-weight: 600; + border: 1px solid #c8d4e0; + background: #f8fafc; + color: #334155; +} +.dashboardMirBtn:hover { + background: #f1f5f9; + border-color: #94a3b8; +} +.dashboardCreateSubmitBtn, +.dashboardPermissionsApplyBtn { + display: inline-flex; + align-items: center; + gap: 8px; + font-weight: 700; + background: #22a06b !important; + border-color: #1a8f5c !important; + color: #fff !important; +} +.dashboardCreateSubmitBtn:hover, +.dashboardPermissionsApplyBtn:hover { + background: #1a8f5c !important; + border-color: #178052 !important; +} +.dashboardCreateSubmitIcon { + display: inline-grid; + place-items: center; + width: 16px; + height: 16px; + font-size: 11px; + font-weight: 800; + line-height: 1; +} +.dashboardCreateCancelBtn { + display: inline-flex; + align-items: center; + gap: 6px; +} +.dashboardCreateCancelIcon { font-size: 13px; line-height: 1; } + +.dashboardPermissionsDialog .missionDialogBody { display: grid; gap: 14px; } +.dashboardPermissionsIntro { + margin: 0; + font-size: 13px; + font-weight: 600; + color: #334155; + line-height: 1.45; +} +.dashboardPermissionsList { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 10px; +} +.dashboardPermissionsList label { + display: inline-flex; + align-items: center; + gap: 10px; + font-size: 14px; + font-weight: 500; + color: #0f172a; + cursor: pointer; + width: auto; +} +.dashboardPermissionsList input[type="checkbox"] { + width: 16px !important; + height: 16px; + min-width: 16px; + padding: 0; + margin: 0; + flex: 0 0 auto; + border-radius: 3px; + accent-color: #2563eb; +} + +.dashboardListBar { + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; + margin-bottom: 12px; + padding-bottom: 12px; + border-bottom: 1px solid var(--border); + flex-shrink: 0; +} +.dashboardFilter { display: flex; align-items: center; gap: 8px; flex: 1; min-width: 240px; } +.dashboardFilterLabel { font-size: 13px; color: var(--muted); white-space: nowrap; } +.dashboardFilterInput { + flex: 1; + min-width: 200px; + max-width: none; + padding: 8px 12px; + border: 1px solid var(--border); + border-radius: 8px; + font: inherit; + background: #fff; +} +.dashboardListCount { font-size: 13px; color: var(--muted); white-space: nowrap; } +.dashboardPagination { display: flex; align-items: center; gap: 4px; margin-left: auto; } +.dashboardPageLabel { font-size: 13px; color: var(--muted); padding: 0 8px; white-space: nowrap; } +.dashboardPageBtn:disabled { opacity: 0.35; cursor: not-allowed; } +.dashboardTableWrap { + flex: 0 0 auto; + width: 100%; + align-self: stretch; + overflow: visible; + border: 1px solid #c8d4e0; + border-radius: 0; + background: #fff; +} +.dashboardTable { + width: 100%; + border-collapse: collapse; + font-size: 14px; + table-layout: fixed; +} +.dashboardTable thead { + background: #eef2f6; +} +.dashboardTable th, +.dashboardTable td { + padding: 0 16px; + height: 52px; + text-align: left; + vertical-align: middle; + border-bottom: 1px solid #d8e0e8; + border-right: 1px solid #d8e0e8; +} +.dashboardTable th:last-child, +.dashboardTable td:last-child { border-right: none; } +.dashboardTable th { + font-size: 12px; + font-weight: 800; + text-transform: uppercase; + letter-spacing: 0.04em; + color: #334155; +} +.dashboardTableStatusCol { width: 56px; text-align: center; padding: 0 8px; } +.dashboardTable th.dashboardTableStatusCol { width: 56px; } +.dashboardTableNameCell { width: auto; font-weight: 600; color: #0f172a; } +.dashboardTableCreatedCell { width: 24%; color: #334155; } +.dashboardTableFuncCol { width: 168px; } +.dashboardTable tbody tr:last-child td { border-bottom: none; } +.dashboardTable tbody tr:hover:not(.dashboardTableEmptyRow) { background: #f8fafc; } +.dashboardTableRow.is-default .dashboardTableNameCell, +.dashboardTableRow.is-default .dashboardTableCreatedCell { color: #94a3b8; } +.dashboardTableRow.is-active .dashboardTableNameCell { color: #0f172a; } +.dashboardTableEmptyRow td { + height: 120px; + text-align: center; + color: var(--muted); + font-size: 13px; +} +.dashboardActiveMark { + width: 28px; + height: 28px; + border-radius: 999px; + display: inline-grid; + place-items: center; + background: #22a06b; + color: #fff; + font-size: 14px; + font-weight: 800; + line-height: 1; + box-shadow: inset 0 -2px 0 rgba(0, 0, 0, 0.12); +} +.dashboardActiveMark--placeholder { + visibility: hidden; + background: transparent; + box-shadow: none; +} +.dashboardFuncBtns { display: flex; gap: 8px; align-items: center; justify-content: flex-start; } +.dashboardFuncBtn { + appearance: none; + width: 34px; + height: 34px; + border: 1px solid #c8d4e0; + border-radius: 6px; + background: #fff; + cursor: pointer; + display: grid; + place-items: center; + padding: 0; +} +.dashboardFuncBtn:hover:not(:disabled) { border-color: #94a3b8; background: #f8fafc; } +.dashboardFuncBtn:disabled { opacity: 0.28; cursor: not-allowed; } +.dashboardFuncIcon { + width: 18px; + height: 18px; + display: block; + background: currentColor; + color: #64748b; +} +.dashboardFuncBtn:hover:not(:disabled) .dashboardFuncIcon { color: #334155; } +.dashboardFuncIcon--design { + mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='black' d='M12 3c-1.5 2.4-3.6 4.5-6 6 1.5 1.5 3.6 3.6 6 6 1.5-2.4 3.6-4.5 6-6-2.4-1.5-4.5-3.6-6-6zm0 3.8c.9 1.1 2 2.2 3.2 3.2-1.2 1-2.3 2.1-3.2 3.2-.9-1.1-2-2.2-3.2-3.2 1.2-1 2.3-2.1 3.2-3.2z'/%3E%3C/svg%3E") center / contain no-repeat; + -webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='black' d='M12 3c-1.5 2.4-3.6 4.5-6 6 1.5 1.5 3.6 3.6 6 6 1.5-2.4 3.6-4.5 6-6-2.4-1.5-4.5-3.6-6-6zm0 3.8c.9 1.1 2 2.2 3.2 3.2-1.2 1-2.3 2.1-3.2 3.2-.9-1.1-2-2.2-3.2-3.2 1.2-1 2.3-2.1 3.2-3.2z'/%3E%3C/svg%3E") center / contain no-repeat; +} +.dashboardFuncIcon--edit { + mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='black' d='M4 17.5V20h2.5L17 9.5 14.5 7 4 17.5zm14.7-9.8a1 1 0 0 0 0-1.4l-1.8-1.8a1 1 0 0 0-1.4 0l-1.3 1.3 3.2 3.2 1.3-1.3z'/%3E%3C/svg%3E") center / contain no-repeat; + -webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='black' d='M4 17.5V20h2.5L17 9.5 14.5 7 4 17.5zm14.7-9.8a1 1 0 0 0 0-1.4l-1.8-1.8a1 1 0 0 0-1.4 0l-1.3 1.3 3.2 3.2 1.3-1.3z'/%3E%3C/svg%3E") center / contain no-repeat; +} +.dashboardFuncIcon--delete { + mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='black' d='M6 6l12 12M18 6L6 18' stroke='black' stroke-width='2' stroke-linecap='round'/%3E%3C/svg%3E") center / contain no-repeat; + -webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='black' d='M6 6l12 12M18 6L6 18' stroke='black' stroke-width='2' stroke-linecap='round'/%3E%3C/svg%3E") center / contain no-repeat; +} +.dashboardTableWrap.is-empty .dashboardTable thead { display: none; } +.dashboardDesignerHeader { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 16px; + padding-bottom: 12px; + border-bottom: 1px solid var(--border); + flex-shrink: 0; +} +.dashboardDesignerTitle { margin: 0; font-size: 18px; font-weight: 800; } +.dashboardDesignerBody { + flex: 1; + min-height: 0; + overflow: auto; +} +.dashboardDesignerEmpty { text-align: center; padding: 24px 0; } +.dashboardPage { display: grid; gap: 0; min-width: 0; width: 100%; } +.dashboardMain { min-width: 0; } .dashboardGrid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); @@ -1441,7 +1832,6 @@ canvas { } .dashboardCancelBtn:hover:not(:disabled) { background: #fee2e2; } .dashboardCancelBtn:disabled { opacity: 0.45; cursor: not-allowed; } -.dashboardInfoCard .dashboardInfoGrid { display: grid; gap: 8px; } .dashboardEmpty { text-align: center; padding: 12px 0 0; } @media (max-width: 1100px) {