nxtgauge-admin-solid/src/routes/admin/internal-dashboard-management/index.tsx

572 lines
27 KiB
TypeScript
Raw Normal View History

import { createMemo, createSignal, For, onMount, Show } from 'solid-js';
import AdminShell from '~/components/AdminShell';
const API = '/api/gateway';
// ---------- 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; name: string };
const FIELD_TYPES: Field['type'][] = ['text', 'number', 'select', 'date'];
const makeId = (prefix: string) => `${prefix}_${Math.random().toString(36).slice(2, 10)}`;
// ---------- 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 [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 || [])).map((item: any) => ({
id: item.id,
roleId: item.role_id || '',
roleName: item.config_json?.roleName || '',
title: item.config_json?.title || 'Untitled Dashboard',
description: item.config_json?.description || '',
status: item.is_active ? 'published' : 'draft',
version: item.config_json?.version || 1,
sidebar: item.config_json?.sidebar || [],
sections: item.config_json?.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 || [])).map((r: any) => ({ id: r.id, name: r.name })));
} catch { setRoles([]); }
};
const selected = () => dashboards().find((d) => d.id === selectedId()) || null;
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');
try {
const res = await fetch(`${API}/api/admin/dashboard-config`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ audience: 'INTERNAL', config_json: { title: 'New Internal Dashboard', version: 1, sidebar: [], sections: [] } }),
});
if (res.ok) {
const data = await res.json();
newId = data.id;
}
// If POST fails, fall through to local draft mode silently
} catch { /* backend unavailable — use local draft */ }
const nd: Dashboard = { id: newId, roleId: '', roleName: '', title: '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;
try {
setSaving(true);
setError('');
const res = await fetch(`${API}/api/admin/dashboard-config/${d.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ role_id: d.roleId || null, config_json: { title: d.title, description: d.description, roleName: d.roleName, version: d.version, sidebar: d.sidebar, sections: d.sections } }),
});
if (!res.ok) throw new Error('Failed to save dashboard');
} catch (err: any) {
setError(err.message || 'Failed to save dashboard');
} finally {
setSaving(false);
}
};
// ---------- List view ----------
return (
<AdminShell>
<div class="page-hero-card page-actions">
<div>
<h1 class="page-title">Internal Dashboard Management</h1>
<p class="page-subtitle">Use the tabs and open one internal dashboard at a time from the list below.</p>
</div>
<a class="btn" href="/admin">Back to Dashboard</a>
</div>
<div class="admin-link-tabs" style="margin-bottom:14px">
<a class="admin-link-tab active" href="#builder">View Dashboards</a>
<a class="admin-link-tab" href="#builder">Open Builder</a>
</div>
<div id="builder" />
<Show when={!selected()}>
<div class="page-actions">
<div>
<h2 class="page-title" style="font-size:20px">Internal Dashboard List</h2>
<p class="page-subtitle">Choose one internal dashboard to open in the builder, or create a new dashboard for an internal role.</p>
</div>
<button class="btn navy" onClick={createDashboard} disabled={creating()}>
{creating() ? 'Creating...' : 'Create Internal Dashboard'}
</button>
</div>
<Show when={error()}><div class="error-box">{error()}</div></Show>
<section class="card" style="padding: 0; overflow: hidden;">
<div class="table-wrap">
<table class="list-table">
<thead>
<tr>
<th>Role</th>
<th>Dashboard</th>
<th>Status</th>
<th>Version</th>
<th class="align-right">Action</th>
</tr>
</thead>
<tbody>
<Show when={loading()}>
<tr><td colspan="5" style="text-align:center;padding:32px;color:#64748b">Loading internal dashboards...</td></tr>
</Show>
<Show when={!loading() && dashboards().length === 0}>
<tr><td colspan="5" 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="font-weight:600;color:#0f172a">{d.title}</td>
<td><span class={`status-chip ${d.status === 'published' ? 'active' : ''}`}>{d.status}</span></td>
<td style="color:#64748b">v{d.version}</td>
<td>
<div class="table-actions">
<button class="btn" onClick={() => { setSelectedId(d.id); setActiveTab('overview'); }}>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 sidebar, sections, tabs, fields, and widgets from one place.</p>
</div>
<div class="builder-header-actions">
<button class="btn" onClick={() => setSelectedId('')}>Back to List</button>
<button class="btn navy" onClick={saveSelected} disabled={saving()}>
{saving() ? 'Saving...' : 'Save Dashboard'}
</button>
</div>
</div>
<Show when={error()}><div class="error-box">{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="field-grid-2">
<div class="field">
<label>Internal 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 internal 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>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 an internal role to bind this dashboard.'}</p>
</div>
</div>
</Show>
{/* Sidebar */}
<Show when={activeTab() === 'sidebar'}>
<div class="builder-section">
<div class="sub-card-header">
<h4>Internal Sidebar</h4>
<button class="btn orange" onClick={addSidebarItem}>Add Sidebar Item</button>
</div>
<For each={selected()!.sidebar}>
{(item, idx) => (
<div class="builder-item builder-item-row-4">
<input
value={item.label}
placeholder="Sidebar 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="btn danger" onClick={() => removeSidebarItem(item.key)}>Remove</button>
</div>
)}
</For>
<Show when={selected()!.sidebar.length === 0}>
<p class="notice">No sidebar items yet. Add the first 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="btn 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="btn danger" onClick={() => removeSection(section.id)}>Remove</button>
</div>
{/* Tabs */}
<div class="sub-card">
<div class="sub-card-header">
<h4>Tabs and Fields</h4>
<button class="btn 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="btn danger" onClick={() => removeTab(section.id, tab.id)}>Remove</button>
</div>
<div style="display:flex;justify-content:flex-end;margin-bottom:8px">
<button class="btn 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="Placeholder text"
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="btn danger" 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="btn 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="btn danger" 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'}>
<p class="notice" style="margin-bottom:12px">Preview uses the actual internal dashboard layout so you can navigate the sidebar, tabs, fields, and widgets before saving.</p>
<DashboardPreview dashboard={selected()!} />
</Show>
</Show>
</AdminShell>
);
}