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,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)

View File

@@ -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"
}

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();

View File

@@ -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",

View File

@@ -3,7 +3,7 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>LiDAR Manager</title>
<title>Robot App</title>
<link rel="stylesheet" href="/style.css" />
</head>
<body class="auth-logged-out">
@@ -248,50 +248,94 @@
<main class="content">
<div class="page" id="pageOverview" data-page-content="dashboard" hidden>
<div class="dashboardPage">
<section class="card">
<div class="cardHeader">
<div>
<div class="cardTitle" data-i18n="dashboard.title">Dashboard</div>
<div class="cardSub" data-i18n="dashboard.subtitle">Widget mission — chạy, xếp hàng và tạm dừng giống MiR Fleet.</div>
<div class="dashboardShell">
<div class="dashboardContent">
<div id="dashboardListView" class="dashboardListView">
<header class="dashboardListHeader">
<div class="dashboardListHeaderIntro">
<h2 class="dashboardListTitle" data-i18n="dashboard.list.title">Dashboards</h2>
<p class="dashboardListSub">
<span data-i18n="dashboard.list.subtitle">Tạo và chỉnh sửa dashboard cho robot.</span>
<span class="dashboardListHelp" title="?" aria-hidden="true">?</span>
</p>
</div>
<div class="dashboardListHeaderActions">
<button id="dashboardCreateBtn" type="button" class="btn primary dashboardCreateBtn" data-i18n="dashboard.list.create">+ Tạo dashboard</button>
<button id="dashboardClearFiltersBtn" type="button" class="btn subtle dashboardClearFiltersBtn" data-i18n="dashboard.list.clearFilters">Xóa bộ lọc</button>
</div>
</header>
<div class="dashboardListBar">
<label class="dashboardFilter">
<span class="dashboardFilterLabel" data-i18n="dashboard.list.filterLabel">Lọc:</span>
<input id="dashboardFilterInput" type="search" class="dashboardFilterInput" data-i18n-placeholder="dashboard.list.filterPlaceholder" placeholder="Nhập tên để lọc…" autocomplete="off" />
</label>
<span id="dashboardListCount" class="dashboardListCount"></span>
<div class="dashboardPagination" id="dashboardPagination">
<button type="button" class="iconBtn dashboardPageBtn" data-page-action="first" title="First">«</button>
<button type="button" class="iconBtn dashboardPageBtn" data-page-action="prev" title="Previous"></button>
<span id="dashboardPageLabel" class="dashboardPageLabel"></span>
<button type="button" class="iconBtn dashboardPageBtn" data-page-action="next" title="Next"></button>
<button type="button" class="iconBtn dashboardPageBtn" data-page-action="last" title="Last">»</button>
</div>
</div>
<div class="dashboardToolbar">
<button id="dashboardAddWidgetBtn" type="button" class="btn subtle" data-i18n="dashboard.addWidget">Thêm widget</button>
<button id="dashboardEditBtn" type="button" class="btn subtle" data-i18n="dashboard.editLayout">Sửa layout</button>
<div class="dashboardTableWrap" id="dashboardTableWrap">
<table class="dashboardTable">
<thead>
<tr>
<th scope="col" class="dashboardTableStatusCol" aria-hidden="true"></th>
<th scope="col" data-i18n="dashboard.list.col.name">Tên</th>
<th scope="col" data-i18n="dashboard.list.col.createdBy">Tạo bởi</th>
<th scope="col" class="dashboardTableFuncCol" data-i18n="dashboard.list.col.functions">Chức năng</th>
</tr>
</thead>
<tbody id="dashboardTableBody"></tbody>
</table>
</div>
</div>
<div class="cardBody">
<div id="dashboardGrid" class="dashboardGrid"></div>
<p id="dashboardEmpty" class="mutedNote dashboardEmpty" hidden data-i18n="dashboard.empty">Chưa có widget. Bấm «Thêm widget» để bắt đầu.</p>
</div>
</section>
<section class="card dashboardInfoCard">
<div class="cardHeader">
<div>
<div class="cardTitle" data-i18n="dashboard.system.title">Hệ thống</div>
<div class="cardSub" data-i18n="dashboard.system.subtitle">Trạng thái backend và layout đang active.</div>
<div id="dashboardCreateView" class="dashboardCreateView" hidden>
<header class="dashboardCreateHeader">
<div class="dashboardCreateHeaderIntro">
<h2 class="dashboardCreateTitle" data-i18n="dashboard.create.title">Tạo dashboard</h2>
<p class="dashboardCreateSub">
<span data-i18n="dashboard.create.subtitle">Tạo dashboard mới trên robot.</span>
<span class="dashboardListHelp" title="?" aria-hidden="true">?</span>
</p>
</div>
<button id="dashboardCreateBackBtn" type="button" class="btn subtle dashboardCreateBackBtn" data-i18n="dashboard.create.backToList">← Quay lại danh sách</button>
</header>
<form id="dashboardCreateForm" class="dashboardCreateForm">
<div class="dashboardCreatePanel">
<div class="dashboardCreateField">
<label for="dashboardCreateName" data-i18n="dashboard.create.name">Tên</label>
<input id="dashboardCreateName" type="text" required autocomplete="off" data-i18n-placeholder="dashboard.create.namePlaceholder" placeholder="VD: John's Dashboard" />
</div>
<div class="dashboardCreateActions">
<button type="button" id="dashboardCreatePermissionsBtn" class="btn subtle dashboardMirBtn dashboardCreatePermissionsBtn" data-i18n="dashboard.create.permissionsBtn">Quyền</button>
<button type="submit" class="btn dashboardMirBtn dashboardCreateSubmitBtn">
<span class="dashboardCreateSubmitIcon" aria-hidden="true"></span>
<span data-i18n="dashboard.create.submit">Tạo dashboard</span>
</button>
<button type="button" id="dashboardCreateCancelBtn" class="btn subtle dashboardMirBtn dashboardCreateCancelBtn">
<span class="dashboardCreateCancelIcon" aria-hidden="true"></span>
<span data-i18n="dashboard.create.cancel">Hủy</span>
</button>
</div>
</div>
</form>
</div>
<div id="dashboardDesignerView" class="dashboardDesignerView" hidden>
<header class="dashboardDesignerHeader">
<button id="dashboardBackToListBtn" type="button" class="btn subtle dashboardBackBtn" data-i18n="dashboard.list.back">← Danh sách</button>
<h2 id="dashboardDesignerTitle" class="dashboardDesignerTitle"></h2>
</header>
<div class="dashboardDesignerBody">
<div id="dashboardGrid" class="dashboardGrid"></div>
<p id="dashboardDesignerEmpty" class="mutedNote dashboardDesignerEmpty" data-i18n="dashboard.designer.empty">Chưa có widget. Phase B sẽ thêm designer đầy đủ.</p>
</div>
</div>
<div class="cardBody dashboardInfoGrid">
<div class="row rowWide">
<label data-i18n="dashboard.system.backend">Backend</label>
<div id="overviewBackend" class="mutedNote"></div>
</div>
<div class="row rowWide">
<label data-i18n="dashboard.system.layout">Layout</label>
<div id="overviewActiveLayout" class="mutedNote"></div>
</div>
<div class="row rowWide">
<label data-i18n="dashboard.system.model">Model robot</label>
<div id="overviewActiveModel" class="mutedNote"></div>
</div>
<div class="row rowWide">
<label data-i18n="dashboard.system.sensors">LiDAR / IMU</label>
<div id="overviewActiveSensors" class="mutedNote"></div>
</div>
</div>
</section>
</div>
</div>
</div>
@@ -1024,6 +1068,47 @@ GET /api/v2.0.0/status</pre>
</dialog>
<dialog id="dashboardPermissionsDialog" class="missionDialog dashboardPermissionsDialog">
<form id="dashboardPermissionsForm" method="dialog" class="missionDialogForm">
<div class="missionDialogHeader">
<h3 data-i18n="dashboard.create.permissionsTitle">Quyền chỉnh sửa</h3>
<button type="button" class="iconBtn missionDialogClose" data-close-dialog="dashboardPermissionsDialog" aria-label="Đóng" data-i18n-aria="common.close">×</button>
</div>
<div class="missionDialogBody">
<p class="dashboardPermissionsIntro" data-i18n="dashboard.create.permissions">Chọn user groups được phép chỉnh sửa dashboard này.</p>
<div id="dashboardCreatePermissions" class="dashboardPermissionsList"></div>
</div>
<div class="missionDialogFooter">
<button type="button" class="btn subtle" data-close-dialog="dashboardPermissionsDialog" data-i18n="common.cancel">Hủy</button>
<button type="submit" class="btn dashboardMirBtn dashboardPermissionsApplyBtn" data-i18n="common.apply">Áp dụng</button>
</div>
</form>
</dialog>
<dialog id="dashboardEditDialog" class="missionDialog">
<form id="dashboardEditForm" method="dialog" class="missionDialogForm">
<div class="missionDialogHeader">
<h3 data-i18n="dashboard.dialog.editDashboard.title">Sửa dashboard</h3>
<button type="button" class="iconBtn missionDialogClose" data-close-dialog="dashboardEditDialog" aria-label="Đóng" data-i18n-aria="common.close">×</button>
</div>
<div class="missionDialogBody">
<input type="hidden" id="dashboardEditId" />
<div class="row rowWide">
<label for="dashboardEditName" data-i18n="dashboard.create.name">Tên</label>
<input id="dashboardEditName" type="text" required />
</div>
<div class="row rowWide">
<label data-i18n="dashboard.create.permissions">Quyền chỉnh sửa</label>
<div id="dashboardEditPermissions" class="dashboardPermissionsList"></div>
</div>
</div>
<div class="missionDialogFooter">
<button type="button" class="btn subtle" data-close-dialog="dashboardEditDialog" data-i18n="common.cancel">Hủy</button>
<button type="submit" class="btn primary" data-i18n="common.save">Lưu</button>
</div>
</form>
</dialog>
<dialog id="dashboardAddWidgetDialog" class="missionDialog">
<form id="dashboardAddWidgetForm" method="dialog" class="missionDialogForm">
<div class="missionDialogHeader">

View File

@@ -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,
};
})();

View File

@@ -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) {