- Replace text-2xl font-bold with text-xl font-semibold in all page headers - Replace bg-[#fd6216] orange buttons with bg-[#0a1d37] navy buttons - Wrap all pages in -mx-6 -mt-6 flex flex-col layout for edge-to-edge headers - Replace .field/.actions CSS classes with explicit Tailwind utility classes - Apply data-table/table-card shared CSS classes to remaining list pages - Remove duplicate tab bar from roles/index.tsx (AdminShell TAB_SETS handles it) - Move Create Internal Role button to page header in roles/index.tsx Pages updated: applications, modules, responses, verification-status, company/create, company/[id], employees/[id]/edit, users/[id]/edit, users/details/[id], roles/index, roles/[id]/index, roles/[id]/edit, role-ui-configs, runtime-roles/[roleKey], onboarding-schemas/*, external/internal-dashboard-management, approval/[id], approval, jobs/[id], leads/[id], photographer/[id], requirements/[id], kb/articles/[id], kb/articles/[id]/edit, verification/[id], verification-status/[id], help/[id], help/support-bridge Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
747 lines
36 KiB
TypeScript
747 lines
36 KiB
TypeScript
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 (
|
|
<div class="preview-section">
|
|
<h5 style="margin:0 0 4px;font-size:17px;font-weight:700;color:#050026">{props.section.title}</h5>
|
|
<p style="margin:0;font-size:13px;color:#64748b">Preview tabs, fields, and widgets.</p>
|
|
<Show when={props.section.tabs.length > 0}>
|
|
<div class="preview-tabs">
|
|
{props.section.tabs.map((tab) => (
|
|
<button type="button" class={`preview-tab-btn ${activeTabId() === tab.id ? 'active' : ''}`} onClick={() => setActiveTabId(tab.id)}>
|
|
{tab.title}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</Show>
|
|
<Show when={activeTab()}>
|
|
<Show when={activeTab()!.fields.length > 0}>
|
|
<div class="preview-fields-grid">
|
|
{activeTab()!.fields.map((field) => (
|
|
<div class="preview-field">
|
|
<label>{field.label}{field.required ? <span style="color:#fd6216"> *</span> : null}</label>
|
|
{field.type === 'select'
|
|
? <select><option>{field.placeholder || `Select ${field.label}`}</option><option>Option A</option><option>Option B</option></select>
|
|
: <input type={field.type === 'number' ? 'number' : field.type === 'date' ? 'date' : 'text'} placeholder={field.placeholder || `Enter ${field.label}`} />}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</Show>
|
|
<Show when={activeTab()!.fields.length === 0}>
|
|
<div style="margin-top:16px;border:1px dashed #cbd5e1;border-radius:12px;padding:24px;text-align:center;font-size:13px;color:#94a3b8">Add fields to this tab to preview.</div>
|
|
</Show>
|
|
</Show>
|
|
<Show when={props.section.widgets.length > 0}>
|
|
<div class="preview-widget-grid">
|
|
{props.section.widgets.map((w) => (
|
|
<div class="preview-widget">
|
|
<div class="w-label">{w.title}</div>
|
|
<div class="w-metric">{w.metric}</div>
|
|
<div class="w-desc">{w.description || 'Widget description'}</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</Show>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<div class="preview-shell">
|
|
<div class="preview-header">
|
|
<div>
|
|
<h3 style="margin:0;font-size:17px;font-weight:700;color:#050026">{props.dashboard.title}</h3>
|
|
<p style="margin:2px 0 0;font-size:13px;color:#64748b">{props.dashboard.roleName}</p>
|
|
</div>
|
|
<div style="display:flex;align-items:center;gap:12px">
|
|
<div style="width:36px;height:36px;border-radius:999px;background:#fff1e8;display:flex;align-items:center;justify-content:center;color:#c2410c;font-weight:700;font-size:14px">A</div>
|
|
</div>
|
|
</div>
|
|
<div class="preview-layout">
|
|
<aside class="preview-sidebar">
|
|
{visibleSidebar().map((item) => (
|
|
<button
|
|
type="button"
|
|
class={`preview-sidebar-item ${activeSidebarKey() === item.key ? 'active' : ''}`}
|
|
onClick={() => setActiveSidebarKey(item.key)}
|
|
>
|
|
<span style="width:16px;height:16px;border-radius:4px;background:#cbd5e1;flex-shrink:0;display:inline-block" />
|
|
{item.label}
|
|
</button>
|
|
))}
|
|
<Show when={visibleSidebar().length === 0}>
|
|
<p style="font-size:12px;color:#94a3b8;padding:8px">No sidebar items added yet.</p>
|
|
</Show>
|
|
</aside>
|
|
<div class="preview-content">
|
|
<p style="margin:0;font-size:11px;font-weight:700;letter-spacing:.18em;text-transform:uppercase;color:#fd6216">Dashboard Preview</p>
|
|
<h4 style="margin:4px 0 4px;font-size:22px;font-weight:700;color:#050026">{selectedLabel()}</h4>
|
|
<p style="margin:0 0 16px;font-size:13px;color:#64748b">{props.dashboard.description || 'Preview of the internal dashboard layout.'}</p>
|
|
<For each={props.dashboard.sections}>
|
|
{(section) => <PreviewSection section={section} />}
|
|
</For>
|
|
<Show when={props.dashboard.sections.length === 0}>
|
|
<div style="border:1px dashed #cbd5e1;border-radius:12px;padding:32px;text-align:center;font-size:13px;color:#94a3b8">Add sections, tabs, fields, and widgets to preview here.</div>
|
|
</Show>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ---------- Main Page ----------
|
|
export default function InternalDashboardManagementPage() {
|
|
const [dashboards, setDashboards] = createSignal<Dashboard[]>([]);
|
|
const [roles, setRoles] = createSignal<InternalRole[]>([]);
|
|
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<Dashboard>) =>
|
|
setDashboards((prev) => prev.map((d) => d.id === selectedId() ? { ...d, ...patch } : d));
|
|
|
|
const updateSection = (sectionId: string, patch: Partial<Section>) =>
|
|
update({ sections: selected()!.sections.map((s) => s.id === sectionId ? { ...s, ...patch } : s) });
|
|
|
|
const updateTab = (sectionId: string, tabId: string, patch: Partial<Tab>) => {
|
|
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<Field>) => {
|
|
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<Widget>) => {
|
|
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 (
|
|
<AdminShell>
|
|
<div class="flex flex-col -mx-6 -mt-6 min-h-full">
|
|
<div class="bg-white border-b border-gray-200 px-6 py-4 flex items-center justify-between">
|
|
<div>
|
|
<h1 class="text-xl font-semibold text-gray-900">Internal Dashboard Management</h1>
|
|
<p class="text-sm text-gray-500 mt-0.5">Open one internal dashboard at a time from the list below and edit it using simple tabs.</p>
|
|
</div>
|
|
<a class="rounded-lg border border-gray-200 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors" href="/admin">Back to Dashboard</a>
|
|
</div>
|
|
<div class="p-6 flex-1">
|
|
|
|
<div class="hidden" style="margin-bottom:14px">
|
|
<a class="hidden" href="#builder">View Dashboards</a>
|
|
<a class="hidden" href="#builder">Open Builder</a>
|
|
</div>
|
|
|
|
<div id="builder" />
|
|
|
|
<Show when={!selected()}>
|
|
<div class="mb-6 flex items-start justify-between gap-4">
|
|
<div>
|
|
<h2 class="text-lg font-semibold text-gray-900">Internal Dashboard List</h2>
|
|
<p class="mt-1 text-sm text-gray-500">Choose one internal dashboard to open in the builder, or create a new dashboard for an internal role.</p>
|
|
</div>
|
|
<button class="inline-flex items-center rounded-lg bg-[#0a1d37] px-4 py-2 text-sm font-medium text-white hover:bg-[#0f2a4e] transition-colors" onClick={createDashboard} disabled={creating()}>
|
|
{creating() ? 'Creating...' : 'Create Internal Dashboard'}
|
|
</button>
|
|
</div>
|
|
<Show when={error()}><div class="mb-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{error()}</div></Show>
|
|
<section class="rounded-xl border border-gray-200 bg-white shadow-sm" style="padding: 0; overflow: hidden;">
|
|
<div class="overflow-x-auto">
|
|
<table class="w-full text-sm list-table-soft-head">
|
|
<thead>
|
|
<tr>
|
|
<th>Role</th>
|
|
<th>Role ID</th>
|
|
<th>Dashboard</th>
|
|
<th>Status</th>
|
|
<th>Version</th>
|
|
<th class="text-right">Action</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<Show when={loading()}>
|
|
<tr><td colspan="6" style="text-align:center;padding:32px;color:#64748b">Loading internal dashboards...</td></tr>
|
|
</Show>
|
|
<Show when={!loading() && dashboards().length === 0}>
|
|
<tr><td colspan="6" style="text-align:center;padding:32px;color:#94a3b8">No internal dashboards found. Create the first one.</td></tr>
|
|
</Show>
|
|
<For each={dashboards()}>
|
|
{(d) => (
|
|
<tr>
|
|
<td style="color:#475569">{d.roleName || 'Not linked'}</td>
|
|
<td style="color:#475569">{d.roleId || 'Not linked'}</td>
|
|
<td style="font-weight:600;color:#0f172a">{d.title}</td>
|
|
<td><span class={`inline-flex items-center rounded-full bg-gray-100 px-2.5 py-0.5 text-xs font-medium text-gray-600 ${d.status === 'published' ? 'active' : ''}`}>{d.status}</span></td>
|
|
<td style="color:#64748b">v{d.version}</td>
|
|
<td>
|
|
<div class="flex items-center justify-end gap-1">
|
|
<button class="inline-flex items-center rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors" onClick={() => void openDashboard(d.id)}>View Builder</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
)}
|
|
</For>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</section>
|
|
</Show>
|
|
|
|
{/* ---------- Builder view ---------- */}
|
|
<Show when={selected()}>
|
|
<div class="builder-header">
|
|
<div>
|
|
<h2>Internal Dashboard Builder</h2>
|
|
<p>Manage menu items, sections, tabs, form fields, and summary cards from one place.</p>
|
|
</div>
|
|
<div class="builder-header-actions">
|
|
<button class="inline-flex items-center rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors" onClick={() => setSelectedId('')}>Back to List</button>
|
|
<button class="inline-flex items-center rounded-lg bg-[#0a1d37] px-4 py-2 text-sm font-medium text-white hover:bg-[#0f2a4e] transition-colors" onClick={saveSelected} disabled={saving()}>
|
|
{saving() ? 'Saving...' : 'Save Dashboard'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<Show when={error()}><div class="mb-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{error()}</div></Show>
|
|
|
|
{/* Tab bar */}
|
|
<div class="builder-tab-bar">
|
|
{(['overview', 'sidebar', 'sections', 'preview'] as const).map((t) => (
|
|
<button
|
|
type="button"
|
|
class={`builder-tab-btn ${activeTab() === t ? 'active' : ''}`}
|
|
onClick={() => setActiveTab(t)}
|
|
>
|
|
{t === 'sections' ? 'Sections, Tabs & Fields' : t.charAt(0).toUpperCase() + t.slice(1)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{/* Overview */}
|
|
<Show when={activeTab() === 'overview'}>
|
|
<div class="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-2">
|
|
<div class="field">
|
|
<label>Team Role</label>
|
|
<select
|
|
value={selected()!.roleId}
|
|
onChange={(e) => {
|
|
const role = roles().find((r) => r.id === e.currentTarget.value);
|
|
update({ roleId: e.currentTarget.value, roleName: role?.name || '', title: role ? `${role.name} Dashboard` : selected()!.title });
|
|
}}
|
|
>
|
|
<option value="">Select team role</option>
|
|
{roles().map((r) => <option value={r.id}>{r.name}</option>)}
|
|
</select>
|
|
</div>
|
|
<div class="field">
|
|
<label>Dashboard Title</label>
|
|
<input value={selected()!.title} onInput={(e) => update({ title: e.currentTarget.value })} />
|
|
</div>
|
|
<div class="field">
|
|
<label>Short description</label>
|
|
<input value={selected()!.description || ''} onInput={(e) => update({ description: e.currentTarget.value })} />
|
|
</div>
|
|
<div class="info-box">
|
|
<p style="margin:0 0 4px;font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.1em;color:#64748b">Linked Role</p>
|
|
<p style="margin:0;font-weight:600;color:#0f172a">{selected()!.roleName || 'No role selected yet'}</p>
|
|
<p style="margin:2px 0 0;font-size:12px;color:#64748b">{selected()!.roleId || 'Select a team role to connect this dashboard.'}</p>
|
|
</div>
|
|
</div>
|
|
</Show>
|
|
|
|
{/* Sidebar */}
|
|
<Show when={activeTab() === 'sidebar'}>
|
|
<div class="builder-section">
|
|
<div class="sub-card-header">
|
|
<h4>Menu</h4>
|
|
<button class="inline-flex items-center rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors orange" onClick={addSidebarItem}>Add Menu Item</button>
|
|
</div>
|
|
<For each={selected()!.sidebar}>
|
|
{(item, idx) => (
|
|
<div class="builder-item builder-item-row-4">
|
|
<input
|
|
value={item.label}
|
|
placeholder="Menu label"
|
|
onInput={(e) => update({ sidebar: selected()!.sidebar.map((i) => i.key === item.key ? { ...i, label: e.currentTarget.value } : i) })}
|
|
/>
|
|
<input
|
|
type="number"
|
|
value={item.order}
|
|
onInput={(e) => update({ sidebar: selected()!.sidebar.map((i) => i.key === item.key ? { ...i, order: Number(e.currentTarget.value) || idx() + 1 } : i) })}
|
|
style="width:80px"
|
|
/>
|
|
<label class="checkbox-label" style="justify-content:center">
|
|
<input
|
|
type="checkbox"
|
|
checked={item.visible}
|
|
onChange={(e) => update({ sidebar: selected()!.sidebar.map((i) => i.key === item.key ? { ...i, visible: e.currentTarget.checked } : i) })}
|
|
/>
|
|
Show
|
|
</label>
|
|
<button class="inline-flex items-center rounded-lg border border-red-200 bg-red-50 px-4 py-2 text-sm font-medium text-red-600 hover:bg-red-100 transition-colors" onClick={() => removeSidebarItem(item.key)}>Remove</button>
|
|
</div>
|
|
)}
|
|
</For>
|
|
<Show when={selected()!.sidebar.length === 0}>
|
|
<p class="notice">No menu items yet. Add your first menu item.</p>
|
|
</Show>
|
|
</div>
|
|
</Show>
|
|
|
|
{/* Sections, Tabs & Fields */}
|
|
<Show when={activeTab() === 'sections'}>
|
|
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:12px">
|
|
<h3 style="margin:0;font-size:15px;font-weight:700">Sections</h3>
|
|
<button class="inline-flex items-center rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors orange" onClick={addSection}>Add Section</button>
|
|
</div>
|
|
<For each={selected()!.sections}>
|
|
{(section) => (
|
|
<div class="builder-section">
|
|
<div class="builder-section-header">
|
|
<input
|
|
value={section.title}
|
|
onInput={(e) => updateSection(section.id, { title: e.currentTarget.value })}
|
|
placeholder="Section title"
|
|
/>
|
|
<button class="inline-flex items-center rounded-lg border border-red-200 bg-red-50 px-4 py-2 text-sm font-medium text-red-600 hover:bg-red-100 transition-colors" onClick={() => removeSection(section.id)}>Remove</button>
|
|
</div>
|
|
|
|
{/* Tabs */}
|
|
<div class="sub-card">
|
|
<div class="sub-card-header">
|
|
<h4>Tabs and Form Fields</h4>
|
|
<button class="inline-flex items-center rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors orange" onClick={() => addTab(section.id)}>Add Tab</button>
|
|
</div>
|
|
<For each={section.tabs}>
|
|
{(tab) => (
|
|
<div class="nested-card">
|
|
<div class="nested-card-header">
|
|
<input
|
|
value={tab.title}
|
|
onInput={(e) => updateTab(section.id, tab.id, { title: e.currentTarget.value })}
|
|
placeholder="Tab title"
|
|
/>
|
|
<button class="inline-flex items-center rounded-lg border border-red-200 bg-red-50 px-4 py-2 text-sm font-medium text-red-600 hover:bg-red-100 transition-colors" onClick={() => removeTab(section.id, tab.id)}>Remove</button>
|
|
</div>
|
|
<div style="display:flex;justify-content:flex-end;margin-bottom:8px">
|
|
<button class="inline-flex items-center rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors orange" onClick={() => addField(section.id, tab.id)}>Add Field</button>
|
|
</div>
|
|
<For each={tab.fields}>
|
|
{(field) => (
|
|
<div class="field-row">
|
|
<div>
|
|
<input
|
|
value={field.label}
|
|
onInput={(e) => updateField(section.id, tab.id, field.id, { label: e.currentTarget.value })}
|
|
placeholder="Field label"
|
|
style="width:100%;margin-bottom:4px"
|
|
/>
|
|
<input
|
|
value={field.placeholder || ''}
|
|
onInput={(e) => updateField(section.id, tab.id, field.id, { placeholder: e.currentTarget.value })}
|
|
placeholder="Help text inside the input"
|
|
style="width:100%"
|
|
/>
|
|
</div>
|
|
<select
|
|
value={field.type}
|
|
onChange={(e) => updateField(section.id, tab.id, field.id, { type: e.currentTarget.value as Field['type'] })}
|
|
>
|
|
{FIELD_TYPES.map((t) => <option value={t}>{t}</option>)}
|
|
</select>
|
|
<label class="checkbox-label" style="justify-content:center">
|
|
<input
|
|
type="checkbox"
|
|
checked={field.required}
|
|
onChange={(e) => updateField(section.id, tab.id, field.id, { required: e.currentTarget.checked })}
|
|
/>
|
|
Required
|
|
</label>
|
|
<button class="inline-flex items-center rounded-lg border border-red-200 bg-red-50 px-4 py-2 text-sm font-medium text-red-600 hover:bg-red-100 transition-colors" onClick={() => removeField(section.id, tab.id, field.id)}>Remove</button>
|
|
</div>
|
|
)}
|
|
</For>
|
|
<Show when={tab.fields.length === 0}>
|
|
<p class="notice" style="text-align:center">No fields in this tab yet.</p>
|
|
</Show>
|
|
</div>
|
|
)}
|
|
</For>
|
|
<Show when={section.tabs.length === 0}>
|
|
<p class="notice">No tabs yet. Add a tab above.</p>
|
|
</Show>
|
|
</div>
|
|
|
|
{/* Widgets */}
|
|
<div class="sub-card" style="margin-top:8px">
|
|
<div class="sub-card-header">
|
|
<h4>Widgets</h4>
|
|
<button class="inline-flex items-center rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors orange" onClick={() => addWidget(section.id)}>Add Widget</button>
|
|
</div>
|
|
<For each={section.widgets}>
|
|
{(widget) => (
|
|
<div class="widget-item">
|
|
<input value={widget.title} onInput={(e) => updateWidget(section.id, widget.id, { title: e.currentTarget.value })} placeholder="Widget title" />
|
|
<input value={widget.metric} onInput={(e) => updateWidget(section.id, widget.id, { metric: e.currentTarget.value })} placeholder="Metric value e.g. 42" />
|
|
<textarea rows={2} value={widget.description || ''} onInput={(e) => updateWidget(section.id, widget.id, { description: e.currentTarget.value })} placeholder="Widget description" />
|
|
<div style="display:flex;justify-content:flex-end">
|
|
<button class="inline-flex items-center rounded-lg border border-red-200 bg-red-50 px-4 py-2 text-sm font-medium text-red-600 hover:bg-red-100 transition-colors" style="font-size:12px;padding:5px 10px" onClick={() => removeWidget(section.id, widget.id)}>Remove Widget</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</For>
|
|
<Show when={section.widgets.length === 0}>
|
|
<p class="notice">No widgets yet.</p>
|
|
</Show>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</For>
|
|
<Show when={selected()!.sections.length === 0}>
|
|
<div style="text-align:center;padding:32px;border:1px dashed #cbd5e1;border-radius:12px;color:#94a3b8;font-size:13px">No sections yet. Add the first section above.</div>
|
|
</Show>
|
|
</Show>
|
|
|
|
{/* Preview */}
|
|
<Show when={activeTab() === 'preview'}>
|
|
<div style="display:flex;align-items:center;justify-content:space-between;gap:10px;flex-wrap:wrap;margin-bottom:12px">
|
|
<p class="notice" style="margin:0">Preview this dashboard as a sample view or open the live version.</p>
|
|
<div class="admin-segmented" style="margin:0">
|
|
<button
|
|
type="button"
|
|
class={`admin-segment ${previewMode() === 'configured' ? 'active' : ''}`}
|
|
onClick={() => setPreviewMode('configured')}
|
|
>
|
|
Sample Preview
|
|
</button>
|
|
<button
|
|
type="button"
|
|
class={`admin-segment ${previewMode() === 'live' ? 'active' : ''}`}
|
|
onClick={() => setPreviewMode('live')}
|
|
>
|
|
Live Preview
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<Show when={previewMode() === 'configured'}>
|
|
<DashboardPreview dashboard={selected()!} />
|
|
</Show>
|
|
|
|
<Show when={previewMode() === 'live'}>
|
|
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:10px">
|
|
<span class="meta-chip">Role: {livePreviewRoleLabel()}</span>
|
|
<Show when={selected()!.roleId}>
|
|
<span class="meta-chip">Role ID: {selected()!.roleId}</span>
|
|
</Show>
|
|
<a class="inline-flex items-center rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors" href={livePreviewUrl()} target="_blank" rel="noreferrer">Open Full Page Preview</a>
|
|
</div>
|
|
<iframe
|
|
src={livePreviewUrl()}
|
|
title="Live Internal Dashboard Preview"
|
|
style="width:100%;height:760px;border:1px solid #e2e8f0;border-radius:14px;background:#fff"
|
|
/>
|
|
</Show>
|
|
</Show>
|
|
</Show>
|
|
</div>
|
|
</div>
|
|
</AdminShell>
|
|
);
|
|
}
|