import { createMemo, createSignal, For, onMount, Show } from 'solid-js';
import AdminShell from '~/components/AdminShell';
const API = '/api/gateway';
const LEGACY_INTERNAL_PREVIEW_BASE = String(import.meta.env.VITE_LEGACY_ADMIN_PREVIEW_URL || 'http://localhost:3002').replace(/\/+$/, '');
// ---------- Types ----------
type SidebarItem = { key: string; label: string; visible: boolean; order: number };
type Field = { id: string; label: string; type: 'text' | 'number' | 'select' | 'date'; required: boolean; placeholder?: string };
type Tab = { id: string; title: string; fields: Field[] };
type Widget = { id: string; title: string; metric: string; description?: string };
type Section = { id: string; title: string; tabs: Tab[]; widgets: Widget[] };
type Dashboard = { id: string; roleId: string; roleName: string; title: string; description?: string; status: string; version: number; sidebar: SidebarItem[]; sections: Section[] };
type InternalRole = { id: string; key: string; name: string };
const FIELD_TYPES: Field['type'][] = ['text', 'number', 'select', 'date'];
const makeId = (prefix: string) => `${prefix}_${Math.random().toString(36).slice(2, 10)}`;
function normalizeInternalDashboardFromConfig(base: Dashboard, configJson: any): Dashboard {
const sidebarRaw = Array.isArray(configJson?.sidebar) ? configJson.sidebar : [];
const sectionsRaw = Array.isArray(configJson?.sections) ? configJson.sections : [];
const legacyNav = Array.isArray(configJson?.nav) ? configJson.nav : [];
const sidebar: SidebarItem[] = sidebarRaw.length > 0
? sidebarRaw.map((item: any, index: number) => ({
key: String(item?.key || makeId('sb')),
label: String(item?.label || `Menu ${index + 1}`),
visible: item?.visible !== false,
order: Number(item?.order) || index + 1,
}))
: legacyNav.map((item: any, index: number) => ({
key: String(item?.key || makeId('sb')),
label: String(item?.label || `Menu ${index + 1}`),
visible: true,
order: index + 1,
}));
const sections: Section[] = sectionsRaw.map((section: any, sectionIndex: number) => ({
id: String(section?.id || `section_${sectionIndex + 1}`),
title: String(section?.title || `Section ${sectionIndex + 1}`),
tabs: Array.isArray(section?.tabs)
? section.tabs.map((tab: any, tabIndex: number) => ({
id: String(tab?.id || `tab_${tabIndex + 1}`),
title: String(tab?.title || `Tab ${tabIndex + 1}`),
fields: Array.isArray(tab?.fields)
? tab.fields.map((field: any, fieldIndex: number) => ({
id: String(field?.id || `field_${fieldIndex + 1}`),
label: String(field?.label || `Field ${fieldIndex + 1}`),
type: field?.type === 'number' || field?.type === 'select' || field?.type === 'date' ? field.type : 'text',
required: Boolean(field?.required),
placeholder: String(field?.placeholder || ''),
}))
: [],
}))
: [],
widgets: Array.isArray(section?.widgets)
? section.widgets.map((widget: any, widgetIndex: number) => ({
id: String(widget?.id || `widget_${widgetIndex + 1}`),
title: String(widget?.title || `Widget ${widgetIndex + 1}`),
metric: String(widget?.metric || '0'),
description: String(widget?.description || ''),
}))
: [],
}));
return {
...base,
title: String(configJson?.title || base.title || 'Internal Dashboard'),
description: String(configJson?.description || ''),
roleName: String(configJson?.roleName || base.roleName || ''),
sidebar,
sections,
};
}
// ---------- Preview ----------
function PreviewSection(props: { section: Section }) {
const [activeTabId, setActiveTabId] = createSignal(props.section.tabs[0]?.id || '');
const activeTab = createMemo(() => props.section.tabs.find((t) => t.id === activeTabId()) || props.section.tabs[0] || null);
return (
{props.section.title}
Preview tabs, fields, and widgets.
0}>
{props.section.tabs.map((tab) => (
))}
0}>
{activeTab()!.fields.map((field) => (
{field.type === 'select'
?
: }
))}
Add fields to this tab to preview.
0}>
);
}
function DashboardPreview(props: { dashboard: Dashboard }) {
const visibleSidebar = createMemo(() =>
[...props.dashboard.sidebar].filter((i) => i.visible).sort((a, b) => a.order - b.order)
);
const [activeSidebarKey, setActiveSidebarKey] = createSignal('');
const selectedLabel = createMemo(() => visibleSidebar().find((i) => i.key === activeSidebarKey())?.label || 'Overview');
return (
Dashboard Preview
{selectedLabel()}
{props.dashboard.description || 'Preview of the internal dashboard layout.'}
{(section) => }
Add sections, tabs, fields, and widgets to preview here.
);
}
// ---------- Main Page ----------
export default function InternalDashboardManagementPage() {
const [dashboards, setDashboards] = createSignal([]);
const [roles, setRoles] = createSignal([]);
const [selectedId, setSelectedId] = createSignal('');
const [activeTab, setActiveTab] = createSignal<'overview' | 'sidebar' | 'sections' | 'preview'>('overview');
const [previewMode, setPreviewMode] = createSignal<'configured' | 'live'>('configured');
const [loading, setLoading] = createSignal(true);
const [saving, setSaving] = createSignal(false);
const [creating, setCreating] = createSignal(false);
const [error, setError] = createSignal('');
onMount(async () => {
await Promise.all([loadDashboards(), loadRoles()]);
});
const loadDashboards = async () => {
try {
setLoading(true);
setError('');
const res = await fetch(`${API}/api/admin/dashboard-config?audience=INTERNAL`);
if (!res.ok) throw new Error('Failed to load internal dashboards');
const data = await res.json();
const rows = (Array.isArray(data) ? data : (data.dashboards || []))
.filter((item: any) => String(item?.audience || '').toUpperCase() === 'INTERNAL')
.map((item: any) => ({
id: String(item.id || ''),
roleId: String(item.role_id || ''),
roleName: '',
title: 'Internal Dashboard',
description: '',
status: item.is_active ? 'published' : 'draft',
version: Number(item.version) || 1,
sidebar: [],
sections: [],
}));
setDashboards(rows);
} catch (err: any) {
setError(err.message || 'Failed to load dashboards');
} finally {
setLoading(false);
}
};
const loadRoles = async () => {
try {
const res = await fetch(`${API}/api/admin/roles?audience=INTERNAL`);
if (!res.ok) return;
const data = await res.json();
setRoles(
(Array.isArray(data) ? data : (data.roles || []))
.filter((r: any) => String(r?.audience || '').toUpperCase() === 'INTERNAL')
.map((r: any) => ({ id: String(r.id || ''), key: String(r.key || ''), name: String(r.name || 'Internal Role') })),
);
} catch { setRoles([]); }
};
const hydrateDashboard = async (configId: string) => {
const base = dashboards().find((item) => item.id === configId);
if (!base || !base.roleId) return;
try {
const res = await fetch(`${API}/api/admin/dashboard-config/${base.roleId}?audience=INTERNAL`);
if (!res.ok) return;
const detail = await res.json();
const role = roles().find((item) => item.id === base.roleId);
const hydrated = normalizeInternalDashboardFromConfig(
{
...base,
roleName: role?.name || base.roleName,
status: detail?.is_active ? 'published' : base.status,
version: Number(detail?.version) || base.version,
},
detail?.config_json || {},
);
setDashboards((prev) => prev.map((item) => (item.id === configId ? hydrated : item)));
} catch {
// Keep list-summary data if detail fetch fails.
}
};
const openDashboard = async (configId: string) => {
setSelectedId(configId);
setActiveTab('overview');
await hydrateDashboard(configId);
};
const selected = () => dashboards().find((d) => d.id === selectedId()) || null;
const livePreviewUrl = createMemo(() => {
const roleId = String(selected()?.roleId || '').trim();
if (!roleId) return `${LEGACY_INTERNAL_PREVIEW_BASE}/internal-dashboard-management`;
return `${LEGACY_INTERNAL_PREVIEW_BASE}/internal-dashboard-management?roleId=${encodeURIComponent(roleId)}`;
});
const livePreviewRoleLabel = createMemo(() => String(selected()?.roleName || '').trim() || 'Unlinked Role');
const update = (patch: Partial) =>
setDashboards((prev) => prev.map((d) => d.id === selectedId() ? { ...d, ...patch } : d));
const updateSection = (sectionId: string, patch: Partial) =>
update({ sections: selected()!.sections.map((s) => s.id === sectionId ? { ...s, ...patch } : s) });
const updateTab = (sectionId: string, tabId: string, patch: Partial) => {
const section = selected()!.sections.find((s) => s.id === sectionId)!;
updateSection(sectionId, { tabs: section.tabs.map((t) => t.id === tabId ? { ...t, ...patch } : t) });
};
const updateField = (sId: string, tId: string, fId: string, patch: Partial) => {
const tab = selected()!.sections.find((s) => s.id === sId)!.tabs.find((t) => t.id === tId)!;
updateTab(sId, tId, { fields: tab.fields.map((f) => f.id === fId ? { ...f, ...patch } : f) });
};
const updateWidget = (sId: string, wId: string, patch: Partial) => {
const section = selected()!.sections.find((s) => s.id === sId)!;
updateSection(sId, { widgets: section.widgets.map((w) => w.id === wId ? { ...w, ...patch } : w) });
};
const addSidebarItem = () => {
const items = selected()!.sidebar;
update({ sidebar: [...items, { key: makeId('sb'), label: 'New Sidebar Item', visible: true, order: items.length + 1 }] });
};
const removeSidebarItem = (key: string) =>
update({ sidebar: selected()!.sidebar.filter((i) => i.key !== key).map((i, idx) => ({ ...i, order: idx + 1 })) });
const addSection = () =>
update({ sections: [...selected()!.sections, { id: makeId('sec'), title: 'New Section', tabs: [], widgets: [] }] });
const removeSection = (id: string) =>
update({ sections: selected()!.sections.filter((s) => s.id !== id) });
const addTab = (sId: string) => {
const section = selected()!.sections.find((s) => s.id === sId)!;
updateSection(sId, { tabs: [...section.tabs, { id: makeId('tab'), title: 'New Tab', fields: [] }] });
};
const removeTab = (sId: string, tId: string) => {
const section = selected()!.sections.find((s) => s.id === sId)!;
updateSection(sId, { tabs: section.tabs.filter((t) => t.id !== tId) });
};
const addField = (sId: string, tId: string) => {
const tab = selected()!.sections.find((s) => s.id === sId)!.tabs.find((t) => t.id === tId)!;
updateTab(sId, tId, { fields: [...tab.fields, { id: makeId('fld'), label: 'New Field', type: 'text', required: false, placeholder: 'Enter value' }] });
};
const removeField = (sId: string, tId: string, fId: string) => {
const tab = selected()!.sections.find((s) => s.id === sId)!.tabs.find((t) => t.id === tId)!;
updateTab(sId, tId, { fields: tab.fields.filter((f) => f.id !== fId) });
};
const addWidget = (sId: string) => {
const section = selected()!.sections.find((s) => s.id === sId)!;
updateSection(sId, { widgets: [...section.widgets, { id: makeId('wgt'), title: 'New Widget', metric: '0', description: 'Describe this widget.' }] });
};
const removeWidget = (sId: string, wId: string) => {
const section = selected()!.sections.find((s) => s.id === sId)!;
updateSection(sId, { widgets: section.widgets.filter((w) => w.id !== wId) });
};
const createDashboard = async () => {
try {
setCreating(true);
setError('');
let newId = makeId('local');
const defaultRole = roles()[0];
try {
if (defaultRole?.id) {
const res = await fetch(`${API}/api/admin/dashboard-config`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
role_id: defaultRole.id,
audience: 'INTERNAL',
config_json: { title: `${defaultRole.name} Dashboard`, description: '', roleName: defaultRole.name, version: 1, sidebar: [], sections: [], nav: [] },
}),
});
if (res.ok) {
const data = await res.json();
newId = data.id;
}
}
} catch { /* backend unavailable — use local draft */ }
const nd: Dashboard = {
id: newId,
roleId: defaultRole?.id || '',
roleName: defaultRole?.name || '',
title: `${defaultRole?.name || 'New Internal'} Dashboard`,
status: 'draft',
version: 1,
sidebar: [],
sections: [],
};
setDashboards((prev) => [nd, ...prev]);
setSelectedId(newId);
setActiveTab('overview');
} finally {
setCreating(false);
}
};
const saveSelected = async () => {
const d = selected();
if (!d) return;
if (!d.roleId) {
setError('Please select an internal role before saving.');
return;
}
try {
setSaving(true);
setError('');
const nav = d.sidebar
.filter((item) => item.visible)
.sort((a, b) => a.order - b.order)
.map((item) => ({
key: item.key,
label: item.label,
path: '/internal-dashboard-management',
}));
const res = await fetch(`${API}/api/admin/dashboard-config`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
role_id: d.roleId,
audience: 'INTERNAL',
config_json: { title: d.title, description: d.description, roleName: d.roleName, version: d.version, sidebar: d.sidebar, sections: d.sections, nav },
}),
});
if (!res.ok) throw new Error('Failed to save dashboard');
await loadDashboards();
const next = dashboards().find((item) => item.roleId === d.roleId);
if (next) await openDashboard(next.id);
} catch (err: any) {
setError(err.message || 'Failed to save dashboard');
} finally {
setSaving(false);
}
};
// ---------- List view ----------
return (
Internal Dashboard Management
Open one internal dashboard at a time from the list below and edit it using simple tabs.
Back to Dashboard
Internal Dashboard List
Choose one internal dashboard to open in the builder, or create a new dashboard for an internal role.
{error()}
| Role |
Role ID |
Dashboard |
Status |
Version |
Action |
| Loading internal dashboards... |
| No internal dashboards found. Create the first one. |
{(d) => (
| {d.roleName || 'Not linked'} |
{d.roleId || 'Not linked'} |
{d.title} |
{d.status} |
v{d.version} |
|
)}
{/* ---------- Builder view ---------- */}
{error()}
{/* Tab bar */}
{(['overview', 'sidebar', 'sections', 'preview'] as const).map((t) => (
))}
{/* Overview */}
update({ title: e.currentTarget.value })} />
update({ description: e.currentTarget.value })} />
Linked Role
{selected()!.roleName || 'No role selected yet'}
{selected()!.roleId || 'Select a team role to connect this dashboard.'}
{/* Sidebar */}
{/* Sections, Tabs & Fields */}
Sections
{(section) => (
{/* Tabs */}
{(tab) => (
{(field) => (
updateField(section.id, tab.id, field.id, { label: e.currentTarget.value })}
placeholder="Field label"
style="width:100%;margin-bottom:4px"
/>
updateField(section.id, tab.id, field.id, { placeholder: e.currentTarget.value })}
placeholder="Help text inside the input"
style="width:100%"
/>
)}
No fields in this tab yet.
)}
No tabs yet. Add a tab above.
{/* Widgets */}
{(widget) => (
)}
No widgets yet.
)}
No sections yet. Add the first section above.
{/* Preview */}
Preview this dashboard as a sample view or open the live version.
);
}