add function create dashboard
Some checks failed
Test / test (push) Has been cancelled

This commit is contained in:
2026-06-17 10:19:14 +07:00
parent a2e87aeb29
commit 4054d81aaf
7 changed files with 1112 additions and 125 deletions

View File

@@ -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 = `<td colspan="4">${escapeHtml(t("dashboard.list.empty"))}</td>`;
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 = `
<td class="dashboardTableStatusCol">
${isActive ? `<span class="dashboardActiveMark" aria-label="${escapeHtml(t("dashboard.list.active"))}">✓</span>` : `<span class="dashboardActiveMark dashboardActiveMark--placeholder" aria-hidden="true"></span>`}
</td>
<td class="dashboardTableNameCell">${escapeHtml(dashboard.name)}</td>
<td class="dashboardTableCreatedCell">${escapeHtml(dashboard.createdBy || "—")}</td>
<td class="dashboardTableFuncCol">
<div class="dashboardFuncBtns">
<button type="button" class="dashboardFuncBtn" data-action="design" data-id="${escapeHtml(dashboard.id)}" title="${escapeHtml(t("dashboard.list.design"))}" aria-label="${escapeHtml(t("dashboard.list.design"))}">
<span class="dashboardFuncIcon dashboardFuncIcon--design" aria-hidden="true"></span>
</button>
<button type="button" class="dashboardFuncBtn" data-action="edit" data-id="${escapeHtml(dashboard.id)}" title="${escapeHtml(t("dashboard.list.edit"))}" aria-label="${escapeHtml(t("dashboard.list.edit"))}" ${canEdit ? "" : "disabled"}>
<span class="dashboardFuncIcon dashboardFuncIcon--edit" aria-hidden="true"></span>
</button>
<button type="button" class="dashboardFuncBtn dashboardFuncBtn--delete" data-action="delete" data-id="${escapeHtml(dashboard.id)}" title="${escapeHtml(t("dashboard.list.delete"))}" aria-label="${escapeHtml(t("dashboard.list.delete"))}" ${canEdit && !dashboard.isDefault ? "" : "disabled"}>
<span class="dashboardFuncIcon dashboardFuncIcon--delete" aria-hidden="true"></span>
</button>
</div>
</td>`;
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 @@
</div>
<div class="row rowWide">
<label>${t("dashboard.widget.field.title")}</label>
<input data-field="title" type="text" value="${escapeHtml(widget.title || "")}" placeholder="${t(\"dashboard.widget.titlePlaceholder\")}" />
<input data-field="title" type="text" value="${escapeHtml(widget.title || "")}" placeholder="${escapeHtml(t("dashboard.widget.titlePlaceholder"))}" />
</div>`;
} else if (type === "mission_group") {
container.innerHTML = `
@@ -129,7 +425,7 @@
<label>${t("dashboard.widget.field.title")}</label>
<input data-field="title" type="text" value="${escapeHtml(widget.title || "")}" />
</div>
<p class="mutedNote">Tạm dừng / tiếp tục / hủy mission đang chạy trên robot.</p>`;
<p class="mutedNote">${escapeHtml(t("dashboard.widget.pauseHint"))}</p>`;
}
}
@@ -240,8 +536,8 @@
<div class="dashboardWidgetHeader">
<div class="dashboardWidgetTitle">${escapeHtml(widgetTitle(widget))}</div>
<div class="dashboardWidgetChrome" hidden>
<button type="button" class="iconBtn" data-widget-config title="${t(\"common.configure\")}"></button>
<button type="button" class="iconBtn danger" data-widget-delete title="${t(\"common.delete\")}">×</button>
<button type="button" class="iconBtn" data-widget-config title="${escapeHtml(t("common.configure"))}">⚙</button>
<button type="button" class="iconBtn danger" data-widget-delete title="${escapeHtml(t("common.delete"))}">×</button>
</div>
</div>
<div class="dashboardWidgetBody"></div>`;
@@ -261,7 +557,7 @@
renderPauseContinueWidget(widget, bodyEl);
break;
default:
bodyEl.innerHTML = `<p class="mutedNote">Widget không hỗ trợ.</p>`;
bodyEl.innerHTML = `<p class="mutedNote">${t("dashboard.widget.unsupported")}</p>`;
}
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();