(() => { const STORAGE_KEY_V3 = "phenikaax_dashboard_v3"; const STORAGE_KEY_V2 = "phenikaax_dashboard_v2"; const PAGE_SIZE = 10; const DEFAULT_ID = "dashboard_default"; const GRID_COLS = 12; const GRID_ROW_PX = 52; const GRID_GAP = 10; const DEFAULT_W = 4; const DEFAULT_H = 3; const MIN_W = 2; const MIN_H = 2; const MAX_W = 12; const MAX_H = 8; let dragSession = null; let resizeSession = null; 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 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 designerToolbarEl = el("dashboardDesignerToolbar"); const editModeBtnEl = el("dashboardEditModeBtn"); const saveBtnEl = el("dashboardSaveBtn"); let activeWidgetTab = "missions"; const editDialogEl = el("dashboardEditDialog"); const permissionsDialogEl = el("dashboardPermissionsDialog"); const addDialogEl = el("dashboardAddWidgetDialog"); const editWidgetDialogEl = el("dashboardEditWidgetDialog"); const addTypeEl = el("dashboardWidgetType"); const addFieldsEl = el("dashboardAddWidgetFields"); const editFieldsEl = el("dashboardEditWidgetFields"); const editWidgetIdEl = el("dashboardEditWidgetId"); const editWidgetTypeEl = el("dashboardEditWidgetType"); const store = { dashboards: [], activeDashboardId: null, view: "list", filter: "", page: 1, editMode: false, pollActive: false, queueUnsub: null, userGroups: [], maps: [], mapsLoaded: false, robotPose: null, robotPoseAt: 0, mapPollTimer: null, }; function hasMapWidget(widgets = activeWidgets()) { return widgets.some((w) => w.type === "map" || w.type === "map_locked"); } async function ensureMapsLoaded(force = false) { if (store.mapsLoaded && !force) return store.maps; try { const res = await fetch("/api/maps", { credentials: "include" }); if (res.ok) { const data = await res.json(); store.maps = Array.isArray(data.maps) ? data.maps : []; } else { store.maps = []; } } catch { store.maps = []; } store.mapsLoaded = true; return store.maps; } async function fetchRobotPose(force = false) { const now = Date.now(); if (!force && store.robotPoseAt && now - store.robotPoseAt < 1200) return store.robotPose; try { const res = await fetch("/api/robot/status", { credentials: "include" }); if (res.ok) { const data = await res.json(); const pose = data.pose && typeof data.pose === "object" ? data.pose : {}; store.robotPose = { x: Number(pose.x) || 0, y: Number(pose.y) || 0, yaw: Number(pose.yaw) || 0, mapId: data.active_map_id || null, }; } } catch { /* keep last pose */ } store.robotPoseAt = Date.now(); return store.robotPose; } function mapMeta(map) { return { resolution: Number(map?.resolution) || 0.05, originX: Number(map?.origin_x) || 0, originY: Number(map?.origin_y) || 0, }; } function worldToPixel(map, imgW, imgH, wx, wy) { const { resolution, originX, originY } = mapMeta(map); return { x: (wx - originX) / resolution, y: imgH - (wy - originY) / resolution, }; } function defaultMapPose(map, imgW, imgH) { const { resolution, originX, originY } = mapMeta(map); return { x: originX + (imgW * resolution) / 2, y: originY + (imgH * resolution) / 2, yaw: 0, }; } function resolveWidgetMap(widget) { const preferred = normalizeStr(widget.map_id) || store.robotPose?.mapId || ""; if (preferred) { const hit = store.maps.find((m) => m.id === preferred); if (hit) return hit; } return store.maps[0] || null; } function poseForWidget(map, imgW, imgH) { const pose = store.robotPose; if (pose && (!pose.mapId || pose.mapId === map.id)) { return { x: pose.x, y: pose.y, yaw: pose.yaw }; } return defaultMapPose(map, imgW, imgH); } function mapOptions(selected = "", includeActive = true) { const opts = []; if (includeActive) { opts.push( `` ); } store.maps.forEach((map) => { opts.push( `` ); }); return opts.join(""); } function mapImageUrl(map) { if (!map?.id || !map.image_file) return null; return `/api/maps/${encodeURIComponent(map.id)}/image`; } function applyMapLayout(viewportEl, map, locked, pose, imgW, imgH) { if (!viewportEl || !map) return; const width = imgW || 800; const height = imgH || 600; const px = worldToPixel(map, width, height, pose.x, pose.y); const vw = viewportEl.clientWidth || 1; const vh = viewportEl.clientHeight || 1; const layer = viewportEl.querySelector("[data-map-layer]"); const marker = viewportEl.querySelector("[data-map-marker]"); if (!layer || !marker) return; if (locked) { const scale = Math.max(vw / width, vh / height) * 1.35; const tx = vw / 2 - px.x * scale; const ty = vh / 2 - px.y * scale; layer.style.width = `${width}px`; layer.style.height = `${height}px`; layer.style.transform = `translate(${tx}px, ${ty}px) scale(${scale})`; marker.style.left = `${vw / 2}px`; marker.style.top = `${vh / 2}px`; } else { const scale = Math.min(vw / width, vh / height); const offsetX = (vw - width * scale) / 2; const offsetY = (vh - height * scale) / 2; layer.style.width = `${width}px`; layer.style.height = `${height}px`; layer.style.transform = `translate(${offsetX}px, ${offsetY}px) scale(${scale})`; marker.style.left = `${offsetX + px.x * scale}px`; marker.style.top = `${offsetY + px.y * scale}px`; } marker.style.transform = `translate(-50%, -50%) rotate(${pose.yaw}rad)`; viewportEl.dataset.mapId = map.id; } function mountMapViewport(viewportEl, map, locked) { const imgUrl = mapImageUrl(map); const label = escapeHtml(map.name || map.id); viewportEl.classList.toggle("dashboardMapViewport--locked", locked); viewportEl.innerHTML = imgUrl ? `
${label}
` : `
${label} ${escapeHtml(t("dashboard.widget.mapNoImage"))}
`; const imgEl = viewportEl.querySelector("[data-map-image]"); const layout = () => { const w = imgEl?.naturalWidth || 800; const h = imgEl?.naturalHeight || 600; const pose = poseForWidget(map, w, h); applyMapLayout(viewportEl, map, locked, pose, w, h); }; if (imgEl) { if (imgEl.complete) layout(); else { imgEl.addEventListener("load", layout); imgEl.addEventListener("error", () => { viewportEl.innerHTML = `
${escapeHtml(t("dashboard.widget.mapImageError"))}
`; }); } } else { layout(); } if (!viewportEl.dataset.roObserved) { viewportEl.dataset.roObserved = "1"; if (typeof ResizeObserver !== "undefined") { const ro = new ResizeObserver(() => layout()); ro.observe(viewportEl); viewportEl._mapRo = ro; } } } async function layoutMapWidget(widget, bodyEl, locked) { let viewportEl = bodyEl.querySelector("[data-map-viewport]"); if (!viewportEl) { bodyEl.innerHTML = `
`; viewportEl = bodyEl.querySelector("[data-map-viewport]"); } await ensureMapsLoaded(); await fetchRobotPose(); const map = resolveWidgetMap(widget); if (!map) { bodyEl.innerHTML = `
${escapeHtml(t("dashboard.widget.mapEmpty"))}
`; return; } mountMapViewport(viewportEl, map, locked); } function refreshMapWidgets() { activeWidgets().forEach((widget) => { if (widget.type !== "map" && widget.type !== "map_locked") return; const bodyEl = gridEl?.querySelector(`[data-widget-id="${widget.id}"] .dashboardWidgetBody`); if (!bodyEl) return; void layoutMapWidget(widget, bodyEl, widget.type === "map_locked"); }); } 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 currentUser() { return window.AuthApp?.getUser?.() || null; } function canEditDashboardsModule() { const user = currentUser(); if (!user?.permissions) return true; const level = user.permissions.dashboard; return level === "write" || level === undefined; } 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) { return String(str) .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """); } function activeWidgets() { return activeDashboard()?.widgets || []; } function setActiveWidgets(widgets) { const db = activeDashboard(); if (db) db.widgets = widgets; } function clamp(n, min, max) { return Math.min(max, Math.max(min, n)); } function hasGridPos(widget) { return Number.isFinite(widget?.col) && Number.isFinite(widget?.row); } function normalizeWidget(widget) { if (!widget || typeof widget !== "object") return widget; widget.w = clamp(Number(widget.w) || DEFAULT_W, MIN_W, MAX_W); widget.h = clamp(Number(widget.h) || DEFAULT_H, MIN_H, MAX_H); if (widget.col != null) widget.col = clamp(Math.round(Number(widget.col)), 1, GRID_COLS); if (widget.row != null) widget.row = Math.max(1, Math.round(Number(widget.row))); return widget; } function gridMetrics() { const rect = gridEl.getBoundingClientRect(); const colW = (rect.width - GRID_GAP * (GRID_COLS - 1)) / GRID_COLS; return { rect, colW, rowH: GRID_ROW_PX, gap: GRID_GAP }; } function pointerToGrid(clientX, clientY) { const { rect, colW, rowH, gap } = gridMetrics(); const x = Math.max(0, clientX - rect.left); const y = Math.max(0, clientY - rect.top); const col = clamp(Math.floor(x / (colW + gap)) + 1, 1, GRID_COLS); const row = Math.max(1, Math.floor(y / (rowH + gap)) + 1); return { col, row }; } function rectsOverlap(c1, r1, w1, h1, c2, r2, w2, h2) { return c1 < c2 + w2 && c1 + w1 > c2 && r1 < r2 + h2 && r1 + h1 > r2; } function widgetFits(widgets, ignoreId, col, row, w, h) { if (col < 1 || row < 1 || col + w - 1 > GRID_COLS) return false; return !widgets.some((other) => { if (other.id === ignoreId || !hasGridPos(other)) return false; return rectsOverlap(col, row, w, h, other.col, other.row, other.w, other.h); }); } function findFreeGridSpot(widgets, w, h) { const maxRow = widgets.reduce((max, item) => { if (!hasGridPos(item)) return max; return Math.max(max, item.row + item.h); }, 4); for (let row = 1; row <= maxRow + 6; row += 1) { for (let col = 1; col <= GRID_COLS - w + 1; col += 1) { if (widgetFits(widgets, null, col, row, w, h)) return { col, row }; } } return { col: 1, row: maxRow + 1 }; } function ensureWidgetPositions(widgets) { let cursorCol = 1; let cursorRow = 1; let rowHeight = 0; widgets.forEach((widget) => { normalizeWidget(widget); if (hasGridPos(widget)) return; if (cursorCol + widget.w - 1 > GRID_COLS) { cursorRow += rowHeight || widget.h; cursorCol = 1; rowHeight = 0; } widget.col = cursorCol; widget.row = cursorRow; cursorCol += widget.w; rowHeight = Math.max(rowHeight, widget.h); if (cursorCol > GRID_COLS) { cursorRow += rowHeight; cursorCol = 1; rowHeight = 0; } }); } function updateGridCanvasHeight(widgets) { if (!gridEl) return; let maxRowEnd = 4; widgets.forEach((widget) => { if (hasGridPos(widget)) maxRowEnd = Math.max(maxRowEnd, widget.row + widget.h - 1); }); const h = maxRowEnd * GRID_ROW_PX + Math.max(0, maxRowEnd - 1) * GRID_GAP; gridEl.style.minHeight = `${h}px`; } function normalizeDashboards() { store.dashboards.forEach((db) => { if (!Array.isArray(db.widgets)) db.widgets = []; ensureWidgetPositions(db.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 loadStoreLocal() { 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; normalizeDashboards(); } catch { bootstrapDefaultDashboard(); } } let persistTimer = null; async function loadStoreFromBackend() { try { const res = await fetch("/api/dashboards", { credentials: "include" }); if (!res.ok) { loadStoreLocal(); return; } const data = await res.json(); 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; normalizeDashboards(); } catch { loadStoreLocal(); } } function persistStore() { clearTimeout(persistTimer); persistTimer = setTimeout(syncStoreToBackend, 400); } async function syncStoreToBackend() { try { await fetch("/api/dashboards", { credentials: "include", method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ dashboards: store.dashboards, activeDashboardId: store.activeDashboardId, }), }); } catch { /* keep in-memory state; retry on next persist */ } } 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; document.querySelector(".dashboardShell")?.classList.toggle("dashboardShell--designer", view === "designer"); 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 { syncDesignerEditMode(); 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 setWidgetTab(tab) { activeWidgetTab = tab; designerToolbarEl?.querySelectorAll("[data-widget-tab]").forEach((btn) => { const active = btn.dataset.widgetTab === tab; btn.classList.toggle("is-active", active); btn.setAttribute("aria-selected", active ? "true" : "false"); }); designerToolbarEl?.querySelectorAll("[data-panel]").forEach((panel) => { panel.hidden = panel.dataset.panel !== tab; }); } function renderDesignerChrome() { const db = activeDashboard(); if (designerTitleEl) designerTitleEl.textContent = db?.name || "—"; const canEdit = dashboardCanEdit(db); if (designerToolbarEl) designerToolbarEl.hidden = !canEdit || !store.editMode; if (saveBtnEl) saveBtnEl.hidden = !canEdit || !store.editMode; if (editModeBtnEl) { editModeBtnEl.hidden = !canEdit; editModeBtnEl.textContent = store.editMode ? t("dashboard.editDone") : t("dashboard.editLayout"); editModeBtnEl.setAttribute("aria-pressed", store.editMode ? "true" : "false"); } if (!designerToolbarEl?.hidden) setWidgetTab(activeWidgetTab); } function syncDesignerEditMode() { /* operate mode by default — edit toggled explicitly */ } function addWidget(type) { const db = activeDashboard(); if (!db || !store.editMode || !dashboardCanEdit(db)) return; const isMap = type === "map_locked" || type === "map"; const widget = normalizeWidget({ id: newId("w"), type, title: type === "mission_queue" ? "Mission queue" : "", w: isMap ? 6 : DEFAULT_W, h: isMap ? 6 : DEFAULT_H, }); const spot = findFreeGridSpot(db.widgets, widget.w, widget.h); widget.col = spot.col; widget.row = spot.row; db.widgets.push(widget); persistStore(); renderDashboard(); if (type === "mission_button" || type === "mission_group") openEditDialog(widget.id); else if (type === "map_locked" || type === "map") openEditDialog(widget.id); } function applyWidgetGridStyle(card, widget) { normalizeWidget(widget); card.style.setProperty("--dw", String(widget.w)); card.style.setProperty("--dh", String(widget.h)); if (hasGridPos(widget)) { card.style.gridColumn = `${widget.col} / span ${widget.w}`; card.style.gridRow = `${widget.row} / span ${widget.h}`; } else { card.style.gridColumn = `span ${widget.w}`; card.style.gridRow = `span ${widget.h}`; } } function startWidgetMove(evt, widget, card) { if (!store.editMode || resizeSession || dragSession) return; if (evt.button !== 0) return; if (!hasGridPos(widget)) return; evt.preventDefault(); const startCol = widget.col; const startRow = widget.row; const grab = pointerToGrid(evt.clientX, evt.clientY); const grabColOff = grab.col - widget.col; const grabRowOff = grab.row - widget.row; dragSession = { widgetId: widget.id }; card.classList.add("is-dragging"); document.body.classList.add("dashboard-widget-dragging"); const onMove = (e) => { const pt = pointerToGrid(e.clientX, e.clientY); let col = pt.col - grabColOff; let row = pt.row - grabRowOff; col = clamp(col, 1, GRID_COLS - widget.w + 1); row = Math.max(1, row); widget.col = col; widget.row = row; applyWidgetGridStyle(card, widget); updateGridCanvasHeight(activeWidgets()); }; const onUp = () => { document.removeEventListener("mousemove", onMove); document.removeEventListener("mouseup", onUp); card.classList.remove("is-dragging"); document.body.classList.remove("dashboard-widget-dragging"); dragSession = null; const widgets = activeWidgets(); if (!widgetFits(widgets, widget.id, widget.col, widget.row, widget.w, widget.h)) { widget.col = startCol; widget.row = startRow; applyWidgetGridStyle(card, widget); } else { persistStore(); } updateGridCanvasHeight(widgets); }; document.addEventListener("mousemove", onMove); document.addEventListener("mouseup", onUp); } function startWidgetResize(evt, widget, card) { if (!store.editMode) return; evt.preventDefault(); evt.stopPropagation(); const startX = evt.clientX; const startY = evt.clientY; const startW = widget.w; const startH = widget.h; const colW = gridEl.clientWidth / GRID_COLS; const onMove = (e) => { const dw = Math.round((e.clientX - startX) / colW); const dh = Math.round((e.clientY - startY) / GRID_ROW_PX); widget.w = clamp(startW + dw, MIN_W, MAX_W); widget.h = clamp(startH + dh, MIN_H, MAX_H); applyWidgetGridStyle(card, widget); }; const onUp = () => { document.removeEventListener("mousemove", onMove); document.removeEventListener("mouseup", onUp); persistStore(); resizeSession = null; }; resizeSession = { widgetId: widget.id }; document.addEventListener("mousemove", onMove); document.addEventListener("mouseup", onUp); } function attachWidgetInteractions(card, widget) { applyWidgetGridStyle(card, widget); const header = card.querySelector(".dashboardWidgetHeader"); const pen = card.querySelector("[data-widget-config]"); const resize = card.querySelector("[data-widget-resize]"); pen?.addEventListener("click", (evt) => { evt.stopPropagation(); openEditDialog(widget.id); }); resize?.addEventListener("mousedown", (evt) => startWidgetResize(evt, widget, card)); if (!store.editMode) return; header?.addEventListener("mousedown", (evt) => startWidgetMove(evt, widget, card)); } 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, { edit = false } = {}) { setActiveDashboard(id); const db = activeDashboard(); store.editMode = edit && dashboardCanEdit(db); if (store.editMode) setWidgetTab("missions"); 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); } function missionOptions(selected) { const list = missions()?.getMissions?.() || []; return list .map( (m) => `` ) .join(""); } function groupOptions(selected) { const groups = missions()?.getGroups?.() || ["Missions"]; return groups .map((g) => ``) .join(""); } function fillTypeFields(container, type, widget = {}) { if (!container) return; container.innerHTML = ""; if (type === "mission_button") { container.innerHTML = `
`; } else if (type === "mission_group") { container.innerHTML = `
`; } else if (type === "mission_queue") { container.innerHTML = `
`; } else if (type === "pause_continue") { container.innerHTML = `

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

`; } else if (type === "mission_action_log") { container.innerHTML = `
`; } else if (type === "logout_button") { container.innerHTML = `
`; } else if (type === "map_locked" || type === "map") { container.innerHTML = `

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

`; } else if (type === "robot_summary") { container.innerHTML = `
`; } } function readFields(container) { const out = {}; container?.querySelectorAll("[data-field]").forEach((node) => { out[node.dataset.field] = node.value; }); return out; } function renderMissionButtonWidget(widget, bodyEl) { const m = missions()?.getMissionById?.(widget.mission_id); const label = m?.name || t("dashboard.widget.selectMission"); bodyEl.innerHTML = ` ${!m ? `

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

` : ""}`; bodyEl.querySelector("[data-run-mission]")?.addEventListener("click", () => { if (!widget.mission_id) return; missions()?.queueMission?.(widget.mission_id); }); } function renderMissionGroupWidget(widget, bodyEl) { const group = widget.group || "Missions"; const list = (missions()?.getMissions?.() || []).filter((m) => m.group === group); if (!list.length) { bodyEl.innerHTML = `

${t("dashboard.widget.emptyGroup", { group })}

`; return; } bodyEl.innerHTML = `
`; const listEl = bodyEl.querySelector(".dashboardMissionGroupList"); list.forEach((m) => { const btn = document.createElement("button"); btn.type = "button"; btn.className = "dashboardMissionGroupBtn"; btn.innerHTML = `${escapeHtml(m.name)}`; btn.addEventListener("click", () => missions()?.queueMission?.(m.id)); listEl.appendChild(btn); }); } function renderMissionQueueWidget(widget, bodyEl) { bodyEl.innerHTML = `

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

`; bodyEl.querySelector(".dashboardQueueClear")?.addEventListener("click", () => missions()?.clearQueue?.()); refreshQueueWidget(bodyEl); } function refreshQueueWidget(bodyEl) { const snap = missions()?.getQueueSnapshot?.(); if (!snap) return; missions()?.renderQueueInto?.( { listEl: bodyEl.querySelector('[data-role="list"]'), runnerEl: bodyEl.querySelector('[data-role="runner"]'), emptyEl: bodyEl.querySelector('[data-role="empty"]'), }, { compact: true } ); } function renderMissionActionLogWidget(widget, bodyEl) { const snap = missions()?.getQueueSnapshot?.(); const runner = snap?.runner || {}; const executing = (snap?.queue || []).find((e) => e.status === "executing"); const lines = []; if (runner.current_action) { lines.push({ message: runner.current_action, current: true }); } else if (runner.message) { lines.push({ message: runner.message, current: true }); } if (executing?.log && Array.isArray(executing.log)) { executing.log .slice(-10) .reverse() .forEach((entry) => { if (entry?.message) lines.push({ message: entry.message, level: entry.level || "info" }); }); } if (!lines.length) { bodyEl.innerHTML = `

${escapeHtml(t("dashboard.widget.actionLog.empty"))}

`; return; } bodyEl.innerHTML = ``; const listEl = bodyEl.querySelector(".dashboardActionLogList"); lines.forEach((line) => { const li = document.createElement("li"); li.className = `dashboardActionLogItem${line.current ? " is-current" : ""}${line.level ? ` level-${line.level}` : ""}`; li.textContent = line.message; listEl.appendChild(li); }); } function renderLogoutButtonWidget(widget, bodyEl) { const label = widget.title || t("dashboard.widget.logout_button"); bodyEl.innerHTML = ``; bodyEl.querySelector(".dashboardLogoutBtn")?.addEventListener("click", () => window.AuthApp?.logout?.()); } function renderMapWidget(widget, bodyEl, locked = false) { bodyEl.innerHTML = `
${escapeHtml(t("dashboard.widget.mapLoading"))}
`; void layoutMapWidget(widget, bodyEl, locked); } function renderRobotSummaryWidget(widget, bodyEl) { const title = widget.title || t("dashboard.widget.robot_summary"); bodyEl.innerHTML = `
${escapeHtml(t("app.robotName"))}
${escapeHtml(title)}
`; } function renderPauseContinueWidget(widget, bodyEl) { const snap = missions()?.getQueueSnapshot?.(); const state = snap?.runner?.state || "idle"; const paused = state === "paused" || snap?.runner?.paused; const running = state === "running" || paused; if (!store.editMode) { bodyEl.innerHTML = ` `; bodyEl.querySelector("[data-pause-action]")?.addEventListener("click", async (evt) => { const action = evt.currentTarget.dataset.pauseAction; try { if (action === "pause") await missions()?.pauseRunner?.(); else await missions()?.continueRunner?.(); } catch (e) { alert(e.message); } }); return; } bodyEl.innerHTML = `

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

`; bodyEl.querySelector("[data-pause-action]")?.addEventListener("click", async (evt) => { const action = evt.currentTarget.dataset.pauseAction; try { if (action === "pause") await missions()?.pauseRunner?.(); else await missions()?.continueRunner?.(); } catch (e) { alert(e.message); } }); bodyEl.querySelector("[data-cancel-mission]")?.addEventListener("click", async () => { try { await missions()?.cancelRunner?.(); } catch (e) { alert(e.message); } }); } function renderWidget(widget) { normalizeWidget(widget); const card = document.createElement("article"); card.className = `dashboardWidget dashboardWidget--${widget.type}`; card.classList.toggle("dashboardWidget--operate", !store.editMode); card.classList.toggle("dashboardWidget--edit", store.editMode); card.dataset.widgetId = widget.id; card.innerHTML = ` ${store.editMode ? `
${escapeHtml(widgetTitle(widget))}
` : ""}
`; const bodyEl = card.querySelector(".dashboardWidgetBody"); switch (widget.type) { case "mission_button": renderMissionButtonWidget(widget, bodyEl); break; case "mission_group": renderMissionGroupWidget(widget, bodyEl); break; case "mission_queue": renderMissionQueueWidget(widget, bodyEl); break; case "pause_continue": renderPauseContinueWidget(widget, bodyEl); break; case "mission_action_log": renderMissionActionLogWidget(widget, bodyEl); break; case "logout_button": renderLogoutButtonWidget(widget, bodyEl); break; case "map_locked": renderMapWidget(widget, bodyEl, true); break; case "map": renderMapWidget(widget, bodyEl, false); break; case "robot_summary": renderRobotSummaryWidget(widget, bodyEl); break; default: bodyEl.innerHTML = `

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

`; } attachWidgetInteractions(card, widget); return card; } function renderDashboard() { if (!gridEl) return; const widgets = activeWidgets(); gridEl.innerHTML = ""; if (designerEmptyEl) { designerEmptyEl.hidden = widgets.length > 0; designerEmptyEl.textContent = store.editMode ? t("dashboard.designer.emptyEdit") : t("dashboard.designer.empty"); } gridEl.classList.toggle("dashboardGrid--edit", store.editMode); ensureWidgetPositions(widgets); widgets.forEach((w) => gridEl.appendChild(renderWidget(w))); updateGridCanvasHeight(widgets); renderDesignerChrome(); if (hasMapWidget(widgets)) { void ensureMapsLoaded().then(() => refreshMapWidgets()); } } function refreshDynamicWidgets() { activeWidgets().forEach((widget) => { const card = gridEl?.querySelector(`[data-widget-id="${widget.id}"]`); if (!card) return; const bodyEl = card.querySelector(".dashboardWidgetBody"); 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 (hasMapWidget()) refreshMapWidgets(); } function openEditDialog(widgetId) { const widget = activeWidgets().find((w) => w.id === widgetId); if (!widget) return; editWidgetIdEl.value = widget.id; editWidgetTypeEl.value = widgetTypeLabel(widget.type); const open = () => { fillTypeFields(editFieldsEl, widget.type, widget); editWidgetDialogEl.showModal(); }; if (widget.type === "map" || widget.type === "map_locked") { void ensureMapsLoaded().then(open); } else { open(); } } function deleteWidget(widgetId) { if (!confirm(t("dashboard.widget.deleteConfirm"))) return; const db = activeDashboard(); if (!db) return; db.widgets = db.widgets.filter((w) => w.id !== widgetId); persistStore(); renderDashboard(); 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); store.editMode = false; 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("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, { edit: true }); 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); const widget = normalizeWidget({ id: newId("w"), type, ...fields, w: DEFAULT_W, h: DEFAULT_H }); const spot = findFreeGridSpot(db.widgets, widget.w, widget.h); widget.col = spot.col; widget.row = spot.row; db.widgets.push(widget); persistStore(); addDialogEl.close(); renderDashboard(); }); el("dashboardEditWidgetForm")?.addEventListener("submit", (evt) => { evt.preventDefault(); const id = editWidgetIdEl.value; const widget = activeWidgets().find((w) => w.id === id); if (!widget) return; Object.assign(widget, readFields(editFieldsEl)); persistStore(); editWidgetDialogEl.close(); renderDashboard(); }); el("dashboardDeleteWidgetBtn")?.addEventListener("click", () => deleteWidget(editWidgetIdEl.value)); editModeBtnEl?.addEventListener("click", async () => { if (!dashboardCanEdit(activeDashboard())) return; if (store.editMode) { clearTimeout(persistTimer); await syncStoreToBackend(); } store.editMode = !store.editMode; if (store.editMode) setWidgetTab("missions"); renderDashboard(); }); saveBtnEl?.addEventListener("click", async () => { clearTimeout(persistTimer); await syncStoreToBackend(); saveBtnEl.classList.add("is-saved"); saveBtnEl.textContent = t("dashboard.designer.saved"); setTimeout(() => { saveBtnEl.classList.remove("is-saved"); saveBtnEl.textContent = t("dashboard.designer.save"); }, 1600); }); designerToolbarEl?.addEventListener("click", (evt) => { const tabBtn = evt.target.closest("[data-widget-tab]"); if (tabBtn) { setWidgetTab(tabBtn.dataset.widgetTab); return; } const btn = evt.target.closest("[data-add-widget]"); if (!btn) return; addWidget(btn.dataset.addWidget); }); } function startDashboardPoll() { if (window.AuthApp && !window.AuthApp.isReady()) return; if (store.view !== "designer") return; stopDashboardPoll(); missions()?.refreshQueue?.(); store.queueUnsub = missions()?.onQueueUpdate?.(() => refreshDynamicWidgets()); missions()?.startQueuePoll?.(); store.pollActive = true; if (hasMapWidget()) { void fetchRobotPose(true); store.mapPollTimer = setInterval(() => { void fetchRobotPose(true).then(() => refreshMapWidgets()); }, 2000); } } function stopDashboardPoll() { if (store.pollActive) { missions()?.stopQueuePoll?.(); store.pollActive = false; } if (store.queueUnsub) { store.queueUnsub(); store.queueUnsub = null; } if (store.mapPollTimer) { clearInterval(store.mapPollTimer); store.mapPollTimer = null; } } async function init() { await loadStoreFromBackend(); await loadUserGroups(); bindEvents(); setView("list"); } window.DashboardApp = { init, getNavItems, handleNav, onPageShow() { if (store.view === "designer") { syncDesignerEditMode(); renderDesignerChrome(); renderDashboard(); startDashboardPoll(); } else { renderListUI(); } }, onPageHide() { stopDashboardPoll(); }, refresh() { if (store.view === "list") renderListUI(); else renderDashboard(); }, }; async function boot() { await init(); refreshNav(); } window.addEventListener("lm:locale-change", () => { if (store.view === "list") renderListUI(); else { renderDesignerChrome(); renderDashboard(); } refreshNav(); }); if (window.AuthApp?.isReady()) boot(); else window.addEventListener("lm:auth-ready", boot, { once: true }); window.addEventListener("lm:auth-logout", stopDashboardPoll); })();