(() => { 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 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 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: [], }; 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 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; } 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; } 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; 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.widget.pauseHint"))}
`; } } 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 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; bodyEl.innerHTML = `${running ? (paused ? t("dashboard.widget.runner.paused") : t("dashboard.widget.runner.running")) : t("dashboard.widget.runner.idle")}
`; 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) { const card = document.createElement("article"); card.className = `dashboardWidget dashboardWidget--${widget.type}`; card.dataset.widgetId = widget.id; card.innerHTML = `${t("dashboard.widget.unsupported")}
`; } card.querySelector("[data-widget-config]")?.addEventListener("click", () => openEditDialog(widget.id)); card.querySelector("[data-widget-delete]")?.addEventListener("click", () => deleteWidget(widget.id)); return card; } function renderDashboard() { if (!gridEl) return; const widgets = activeWidgets(); gridEl.innerHTML = ""; if (designerEmptyEl) designerEmptyEl.hidden = widgets.length > 0; gridEl.classList.toggle("dashboardGrid--edit", store.editMode); widgets.forEach((w) => gridEl.appendChild(renderWidget(w))); gridEl.querySelectorAll(".dashboardWidgetChrome").forEach((n) => { n.hidden = !store.editMode; }); } 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); }); } function openEditDialog(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); editWidgetDialogEl.showModal(); } 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); 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); 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); db.widgets.push({ id: newId("w"), type, ...fields }); 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)); } 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; } function stopDashboardPoll() { if (store.pollActive) { missions()?.stopQueuePoll?.(); store.pollActive = false; } if (store.queueUnsub) { store.queueUnsub(); store.queueUnsub = null; } } async function init() { await loadStoreFromBackend(); await loadUserGroups(); bindEvents(); setView("list"); } window.DashboardApp = { init, getNavItems, handleNav, onPageShow() { if (store.view === "designer") { 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); })();