diff --git a/src/lib/admin/dashboard.ts b/src/lib/admin/dashboard.ts new file mode 100644 index 0000000..0e5badf --- /dev/null +++ b/src/lib/admin/dashboard.ts @@ -0,0 +1,185 @@ +export type DashboardWidgetSize = 'S' | 'M' | 'L'; + +export type WidgetDataState = 'loading' | 'success' | 'empty' | 'error' | 'unavailable'; + +export type WidgetDataResult = { + state: WidgetDataState; + payload?: T; + message?: string; +}; + +export type KpiPayload = { + value: string; + delta: string; + note: string; + tone: 'up' | 'down'; + icon: 'users' | 'building' | 'trend' | 'card'; +}; + +export type LineChartPayload = { + labels: string[]; + series: number[]; + maxY: number; +}; + +export type BarChartPayload = { + labels: string[]; + series: number[]; + maxY: number; +}; + +export type AdminWidgetPayload = + | { kind: 'kpi'; data: KpiPayload } + | { kind: 'line_chart'; data: LineChartPayload } + | { kind: 'bar_chart'; data: BarChartPayload }; + +export type DashboardWidgetDefinition = { + widgetKey: string; + title: string; + moduleKey: string; + defaultSize: DashboardWidgetSize; + defaultVisible: boolean; + order: number; + dataAdapterKey: string; + readiness: 'ready' | 'planned'; + description?: string; +}; + +const kpiData: Record = { + totalUsers: { + value: '12,458', + delta: '+12.5%', + note: '+1,245 this month', + tone: 'up', + icon: 'users', + }, + activeCompanies: { + value: '1,234', + delta: '+8.2%', + note: '+94 this month', + tone: 'up', + icon: 'building', + }, + openLeads: { + value: '847', + delta: '-3.1%', + note: '27 fewer than last month', + tone: 'down', + icon: 'trend', + }, + creditsPurchased: { + value: '₹45,890', + delta: '+18.7%', + note: '₹7,234 more this month', + tone: 'up', + icon: 'card', + }, +}; + +const lineChartData: LineChartPayload = { + labels: ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun'], + series: [62, 70, 81, 75, 88, 102], + maxY: 120, +}; + +const barChartData: BarChartPayload = { + labels: ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun'], + series: [42000, 48000, 55000, 51000, 62000, 69000], + maxY: 80000, +}; + +const adapterMap: Record Promise>> = { + kpi_total_users: async () => ({ state: 'success', payload: { kind: 'kpi', data: kpiData.totalUsers } }), + kpi_active_companies: async () => ({ state: 'success', payload: { kind: 'kpi', data: kpiData.activeCompanies } }), + kpi_open_leads: async () => ({ state: 'success', payload: { kind: 'kpi', data: kpiData.openLeads } }), + kpi_credits_purchased: async () => ({ state: 'success', payload: { kind: 'kpi', data: kpiData.creditsPurchased } }), + chart_leads_trend: async () => ({ state: 'success', payload: { kind: 'line_chart', data: lineChartData } }), + chart_revenue_overview: async () => ({ state: 'success', payload: { kind: 'bar_chart', data: barChartData } }), +}; + +export const ADMIN_DASHBOARD_WIDGETS: DashboardWidgetDefinition[] = [ + { + widgetKey: 'kpi_total_users', + title: 'Total Users', + moduleKey: 'USER_MANAGEMENT', + defaultSize: 'S', + defaultVisible: true, + order: 1, + dataAdapterKey: 'kpi_total_users', + readiness: 'ready', + }, + { + widgetKey: 'kpi_active_companies', + title: 'Active Companies', + moduleKey: 'COMPANY_MANAGEMENT', + defaultSize: 'S', + defaultVisible: true, + order: 2, + dataAdapterKey: 'kpi_active_companies', + readiness: 'ready', + }, + { + widgetKey: 'kpi_open_leads', + title: 'Open Leads', + moduleKey: 'REQUIREMENTS_MANAGEMENT', + defaultSize: 'S', + defaultVisible: true, + order: 3, + dataAdapterKey: 'kpi_open_leads', + readiness: 'ready', + }, + { + widgetKey: 'kpi_credits_purchased', + title: 'Credits Purchased', + moduleKey: 'CREDIT_MANAGEMENT', + defaultSize: 'S', + defaultVisible: true, + order: 4, + dataAdapterKey: 'kpi_credits_purchased', + readiness: 'ready', + }, + { + widgetKey: 'chart_leads_trend', + title: 'Leads Trend', + moduleKey: 'REPORTS', + defaultSize: 'M', + defaultVisible: true, + order: 5, + dataAdapterKey: 'chart_leads_trend', + readiness: 'planned', + description: 'Monthly leads performance overview', + }, + { + widgetKey: 'chart_revenue_overview', + title: 'Revenue Overview', + moduleKey: 'REVENUE_LEDGER', + defaultSize: 'M', + defaultVisible: true, + order: 6, + dataAdapterKey: 'chart_revenue_overview', + readiness: 'planned', + description: 'Monthly revenue vs expenses comparison', + }, +]; + +export function loadWidgetData(definition: DashboardWidgetDefinition): Promise> { + if (definition.readiness !== 'ready') { + return Promise.resolve({ + state: 'unavailable', + message: 'This widget will switch to module-backed data when that module is completed.', + }); + } + + const loader = adapterMap[definition.dataAdapterKey]; + if (!loader) { + return Promise.resolve({ + state: 'error', + message: `Missing data adapter: ${definition.dataAdapterKey}`, + }); + } + + return loader().catch(() => ({ + state: 'error', + message: 'Failed to load widget data.', + })); +} diff --git a/src/lib/runtime/storage.ts b/src/lib/runtime/storage.ts index a52cedf..73480e1 100644 --- a/src/lib/runtime/storage.ts +++ b/src/lib/runtime/storage.ts @@ -1,3 +1,5 @@ +import type { RuntimeDashboardLayout } from './types'; + // All API calls go through the server route gateway proxy const API_GATEWAY = '/api/gateway'; @@ -22,6 +24,15 @@ type RoleRecord = { audience: string; }; +type DashboardConfigResponse = { + id?: string; + role_id?: string; + config_json?: Record; + is_active?: boolean; + updated_at?: string; + created_at?: string; +}; + export async function saveRuntimeConfig(type: RuntimeRecordType, key: string, payload: unknown, status: RuntimeRecordStatus) { const normalizedKey = key.trim().toUpperCase(); if (!normalizedKey) return; @@ -29,7 +40,7 @@ export async function saveRuntimeConfig(type: RuntimeRecordType, key: string, pa try { // 1. Ensure the Role exists first. We lookup by key. // If it doesn't exist, we create it generically. - let roleRes = await fetch(`${API_GATEWAY}/api/admin/roles/${normalizedKey}`); + const roleRes = await fetch(`${API_GATEWAY}/api/admin/roles/${normalizedKey}`); let role: RoleRecord; if (!roleRes.ok && roleRes.status === 404) { @@ -43,7 +54,7 @@ export async function saveRuntimeConfig(type: RuntimeRecordType, key: string, pa audience: 'EXTERNAL', }) }); - if (!createRes.ok) throw new Error("Failed to create role"); + if (!createRes.ok) throw new Error('Failed to create role'); role = await createRes.json(); } else { role = await roleRes.json(); @@ -56,7 +67,7 @@ export async function saveRuntimeConfig(type: RuntimeRecordType, key: string, pa onboarding: '/api/admin/onboarding-config', }; - let bodyPayload: any = { + const bodyPayload: Record = { role_id: role.id, }; @@ -66,7 +77,7 @@ export async function saveRuntimeConfig(type: RuntimeRecordType, key: string, pa } else if (type === 'onboarding') { bodyPayload.schema_json = payload; } else if (type === 'role') { - bodyPayload.config_json = payload; + bodyPayload.config_json = payload; } const saveRes = await fetch(`${API_GATEWAY}${endpointMap[type]}`, { @@ -76,7 +87,7 @@ export async function saveRuntimeConfig(type: RuntimeRecordType, key: string, pa }); if (!saveRes.ok) { - throw new Error(`Failed to save ${type} config`); + throw new Error(`Failed to save ${type} config`); } console.log(`Saved ${type} mapping for role ${normalizedKey} successfully to Rust backend`); @@ -87,53 +98,167 @@ export async function saveRuntimeConfig(type: RuntimeRecordType, key: string, pa } export async function listRuntimeConfigs(type: RuntimeRecordType): Promise>> { - const endpointMap: Record = { - role: '/api/admin/roles', - dashboard: '/api/admin/dashboard-config', - onboarding: '/api/admin/onboarding-config', - }; - - try { - const res = await fetch(`${API_GATEWAY}${endpointMap[type]}`); - if (!res.ok) throw new Error(`Failed to list ${type} configs`); - const data = await res.json(); - - // Map Rust backend models to UI expected format - return data.map((item: any) => ({ - key: item.role_key || item.key, - status: item.is_active ? 'published' : 'draft', - updatedAt: item.updated_at || item.created_at, - payload: item.config_json || item.schema_json || item, - // include raw id for routing convenience - id: item.id, - role_id: item.role_id - })); - } catch (err) { - console.error(`Error listing runtime config [${type}]:`, err); - return []; - } + const endpointMap: Record = { + role: '/api/admin/roles', + dashboard: '/api/admin/dashboard-config', + onboarding: '/api/admin/onboarding-config', + }; + + try { + const res = await fetch(`${API_GATEWAY}${endpointMap[type]}`); + if (!res.ok) throw new Error(`Failed to list ${type} configs`); + const data = await res.json(); + + // Map Rust backend models to UI expected format + return data.map((item: any) => ({ + key: item.role_key || item.key, + status: item.is_active ? 'published' : 'draft', + updatedAt: item.updated_at || item.created_at, + payload: item.config_json || item.schema_json || item, + // include raw id for routing convenience + id: item.id, + role_id: item.role_id + })); + } catch (err) { + console.error(`Error listing runtime config [${type}]:`, err); + return []; + } } export async function getRuntimeConfig(type: RuntimeRecordType, roleId: string): Promise | null> { - const endpointMap: Record = { - role: `/api/admin/roles/${roleId}`, // Assume roleId is key if testing - dashboard: `/api/admin/dashboard-config/${roleId}?audience=EXTERNAL`, - onboarding: `/api/admin/onboarding-config/${roleId}`, + const endpointMap: Record = { + role: `/api/admin/roles/${roleId}`, // Assume roleId is key if testing + dashboard: `/api/admin/dashboard-config/${roleId}?audience=EXTERNAL`, + onboarding: `/api/admin/onboarding-config/${roleId}`, + }; + + try { + const res = await fetch(`${API_GATEWAY}${endpointMap[type]}`); + if (!res.ok) return null; + const data = await res.json(); + + return { + status: data.is_active ? 'published' : 'draft', + updatedAt: data.updated_at || data.created_at, + payload: data.config_json || data.schema_json || data, }; - - try { - const res = await fetch(`${API_GATEWAY}${endpointMap[type]}`); - if (!res.ok) return null; - const data = await res.json(); - - return { - status: data.is_active ? 'published' : 'draft', - updatedAt: data.updated_at || data.created_at, - payload: data.config_json || data.schema_json || data, - }; - } catch (err) { - return null; - } + } catch (err) { + return null; + } +} + +async function resolveInternalRoleId(preferredKeys: string[] = ['ADMIN', 'SUPER_ADMIN']): Promise { + try { + const res = await fetch(`${API_GATEWAY}/api/admin/roles?audience=INTERNAL`); + if (!res.ok) return null; + + const data = await res.json(); + const rows = Array.isArray(data) ? data : (data.roles || []); + if (!Array.isArray(rows) || rows.length === 0) return null; + + const normalized = rows.map((row: any) => ({ + id: String(row?.id || ''), + key: String(row?.key || row?.role_key || '').toUpperCase(), + audience: String(row?.audience || '').toUpperCase(), + isActive: row?.is_active !== false, + })).filter((row: any) => row.id); + + for (const pref of preferredKeys) { + const hit = normalized.find((row: any) => row.key === pref && row.isActive); + if (hit) return hit.id; + } + + const firstInternalActive = normalized.find((row: any) => row.audience === 'INTERNAL' && row.isActive); + if (firstInternalActive) return firstInternalActive.id; + + return normalized[0]?.id || null; + } catch { + return null; + } +} + +function normalizeDashboardLayout(input: any): RuntimeDashboardLayout | null { + if (!input || typeof input !== 'object') return null; + + const rawOrder = Array.isArray(input.order) + ? input.order.map((value: unknown) => String(value)).filter(Boolean) + : []; + + const rawVisibility = (input.visibility && typeof input.visibility === 'object') ? input.visibility : {}; + const rawSize = (input.size && typeof input.size === 'object') ? input.size : {}; + + const visibility: Record = {}; + for (const [key, value] of Object.entries(rawVisibility)) { + visibility[String(key)] = Boolean(value); + } + + const size: Record = {}; + for (const [key, value] of Object.entries(rawSize)) { + const normalized = String(value || '').toUpperCase(); + if (normalized === 'S' || normalized === 'M' || normalized === 'L') { + size[String(key)] = normalized; + } + } + + return { + order: rawOrder, + visibility, + size, + }; +} + +async function readDashboardConfig(roleId: string, audience: 'INTERNAL' | 'EXTERNAL'): Promise { + try { + const res = await fetch(`${API_GATEWAY}/api/admin/dashboard-config/${roleId}?audience=${audience}`); + if (!res.ok) return null; + return await res.json(); + } catch { + return null; + } +} + +export async function loadAdminDashboardLayout(): Promise { + const roleId = await resolveInternalRoleId(); + if (!roleId) return null; + + const data = await readDashboardConfig(roleId, 'INTERNAL'); + const config = data?.config_json; + return normalizeDashboardLayout((config as any)?.dashboard_layout); +} + +export async function saveAdminDashboardLayout(layout: RuntimeDashboardLayout): Promise { + const roleId = await resolveInternalRoleId(); + if (!roleId) return false; + + const existing = await readDashboardConfig(roleId, 'INTERNAL'); + const existingJson = (existing?.config_json && typeof existing.config_json === 'object') + ? existing.config_json + : {}; + + const config_json = { + ...existingJson, + dashboard_layout: { + order: Array.from(new Set(layout.order.map((item) => String(item)).filter(Boolean))), + visibility: layout.visibility, + size: layout.size, + }, + }; + + try { + const res = await fetch(`${API_GATEWAY}/api/admin/dashboard-config`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + role_id: roleId, + audience: 'INTERNAL', + config_json, + }), + }); + + return res.ok; + } catch { + return false; + } } export async function deleteRuntimeConfig(type: RuntimeRecordType, key: string): Promise { diff --git a/src/lib/runtime/types.ts b/src/lib/runtime/types.ts index 22dc25d..c202fd2 100644 --- a/src/lib/runtime/types.ts +++ b/src/lib/runtime/types.ts @@ -1,3 +1,11 @@ +export type DashboardWidgetSize = 'S' | 'M' | 'L'; + +export type RuntimeDashboardLayout = { + order: string[]; + visibility: Record; + size: Record; +}; + export type RuntimeRoleConfig = { roleKey: string; displayName: string; @@ -11,6 +19,7 @@ export type RuntimeDashboardConfig = { roleKey: string; sidebar: Array<{ key: string; label: string; route: string }>; widgets: Array<{ key: string; title: string; enabled: boolean }>; + dashboard_layout?: RuntimeDashboardLayout; }; export type RuntimeOnboardingFieldOption = { diff --git a/src/routes/admin/index.tsx b/src/routes/admin/index.tsx index f227622..3a642d2 100644 --- a/src/routes/admin/index.tsx +++ b/src/routes/admin/index.tsx @@ -1,208 +1,524 @@ -import { For } from 'solid-js'; +import { For, Show, createEffect, createMemo, createSignal, onCleanup, onMount } from 'solid-js'; +import { + BarChart3, + Building2, + CircleDashed, + Coins, + EllipsisVertical, + Eye, + EyeOff, + GripVertical, + LineChart, + RotateCcw, + Search, + Settings2, + TrendingUp, + Users, +} from 'lucide-solid'; import AdminShell from '~/components/AdminShell'; -import { GripVertical } from 'lucide-solid'; +import { + ADMIN_DASHBOARD_WIDGETS, + type DashboardWidgetDefinition, + type DashboardWidgetSize, +} from '~/lib/admin/dashboard'; +import type { RuntimeDashboardLayout } from '~/lib/runtime/types'; +import { loadAdminDashboardLayout, saveAdminDashboardLayout } from '~/lib/runtime/storage'; -const kpis = [ - { title: 'Total Users', value: '12,458', delta: '+12.5%', note: '+1,245 this month', tone: 'up' as const, icon: 'users' as const }, - { title: 'Active Companies', value: '1,234', delta: '+8.2%', note: '+94 this month', tone: 'up' as const, icon: 'building' as const }, - { title: 'Open Leads', value: '847', delta: '-3.1%', note: '27 fewer than last month', tone: 'down' as const, icon: 'trend' as const }, - { title: 'Credits Purchased', value: '₹45,890', delta: '+18.7%', note: '₹7,234 more this month', tone: 'up' as const, icon: 'card' as const }, -]; +type WidgetStateKind = 'live' | 'empty' | 'pending'; +type WidgetType = 'summary' | 'analytics'; +type SortMode = 'layout' | 'name' | 'status'; +type FilterMode = 'all' | WidgetType; +type GridLayoutMode = '3x4' | '3x3'; -const trendSeries = [62, 70, 81, 75, 88, 102]; -const revSeries = [42000, 48000, 55000, 51000, 62000, 69000]; -const maxAmount = 80000; +type WidgetMeta = { + state: WidgetStateKind; + type: WidgetType; + statusLabel: string; + subtitle: string; + emptyMessage?: string; + pendingMessage?: string; +}; -function KpiIcon(props: { kind: 'users' | 'building' | 'trend' | 'card' }) { - if (props.kind === 'users') { - return ( - - ); +const WIDGET_DEFINITION_MAP = Object.fromEntries( + ADMIN_DASHBOARD_WIDGETS.map((definition) => [definition.widgetKey, definition]) +); + +const DEFAULT_LAYOUT: RuntimeDashboardLayout = { + order: ADMIN_DASHBOARD_WIDGETS.slice() + .sort((a, b) => a.order - b.order) + .map((definition) => definition.widgetKey), + visibility: Object.fromEntries(ADMIN_DASHBOARD_WIDGETS.map((definition) => [definition.widgetKey, definition.defaultVisible])), + size: Object.fromEntries(ADMIN_DASHBOARD_WIDGETS.map((definition) => [definition.widgetKey, definition.defaultSize])), +}; + +const WIDGET_META: Record = { + kpi_total_users: { + state: 'empty', + type: 'summary', + statusLabel: 'No Data', + subtitle: 'Powered by USER_MANAGEMENT', + emptyMessage: 'No user data available yet', + }, + kpi_active_companies: { + state: 'empty', + type: 'summary', + statusLabel: 'No Data', + subtitle: 'Powered by COMPANY_MANAGEMENT', + emptyMessage: 'No company data available yet', + }, + kpi_open_leads: { + state: 'empty', + type: 'summary', + statusLabel: 'No Data', + subtitle: 'Powered by REQUIREMENTS_MANAGEMENT', + emptyMessage: 'No lead data available yet', + }, + kpi_credits_purchased: { + state: 'empty', + type: 'summary', + statusLabel: 'No Data', + subtitle: 'Powered by CREDIT_MANAGEMENT', + emptyMessage: 'No credit activity available yet', + }, + chart_leads_trend: { + state: 'pending', + type: 'analytics', + statusLabel: 'Module Pending', + subtitle: 'Monthly leads performance overview • Powered by REPORTS', + pendingMessage: 'This widget will connect to live reporting data once the Reports module is completed', + }, + chart_revenue_overview: { + state: 'pending', + type: 'analytics', + statusLabel: 'Module Pending', + subtitle: 'Monthly revenue vs expenses comparison • Powered by REVENUE_LEDGER', + pendingMessage: 'This widget will connect to ledger-based analytics once the Revenue Ledger module is completed', + }, +}; + +function sanitizeLayout(layout: RuntimeDashboardLayout | null | undefined): RuntimeDashboardLayout { + const knownKeys = new Set(ADMIN_DASHBOARD_WIDGETS.map((definition) => definition.widgetKey)); + const fallbackOrder = DEFAULT_LAYOUT.order; + const incomingOrder = Array.isArray(layout?.order) ? layout!.order : []; + + const seen = new Set(); + const normalizedOrder: string[] = []; + + for (const key of incomingOrder) { + const normalizedKey = String(key || ''); + if (!knownKeys.has(normalizedKey) || seen.has(normalizedKey)) continue; + seen.add(normalizedKey); + normalizedOrder.push(normalizedKey); } - if (props.kind === 'building') { - return ( - - ); + + for (const key of fallbackOrder) { + if (!seen.has(key)) normalizedOrder.push(key); } - if (props.kind === 'trend') { - return ( - - ); + + const visibility: Record = {}; + const size: Record = {}; + + for (const definition of ADMIN_DASHBOARD_WIDGETS) { + const key = definition.widgetKey; + visibility[key] = typeof layout?.visibility?.[key] === 'boolean' + ? Boolean(layout?.visibility?.[key]) + : definition.defaultVisible; + + const rawSize = String(layout?.size?.[key] || '').toUpperCase(); + size[key] = rawSize === 'S' || rawSize === 'M' || rawSize === 'L' + ? (rawSize as DashboardWidgetSize) + : definition.defaultSize; } + + return { + order: normalizedOrder, + visibility, + size, + }; +} + +function reorderKeys(order: string[], draggedKey: string, targetKey: string): string[] { + if (draggedKey === targetKey) return order; + const next = order.slice(); + const fromIndex = next.indexOf(draggedKey); + const toIndex = next.indexOf(targetKey); + if (fromIndex === -1 || toIndex === -1) return order; + next.splice(fromIndex, 1); + next.splice(toIndex, 0, draggedKey); + return next; +} + +function iconForWidget(widgetKey: string) { + const cls = 'text-[#FA5014]'; + if (widgetKey.includes('users')) return ; + if (widgetKey.includes('companies')) return ; + if (widgetKey.includes('leads')) return ; + if (widgetKey.includes('credits')) return ; + if (widgetKey.includes('revenue')) return ; + return ; +} + +function badgeClass(state: WidgetStateKind): string { + if (state === 'live') return 'border-[#FDBA8C] bg-[#FFF1EB] text-[#FA5014]'; + if (state === 'pending') return 'border-[#D1D5DB] bg-[#F3F4F6] text-[#6B7280]'; + return 'border-[#E5E7EB] bg-white text-[#6B7280]'; +} + +function widgetSpan(mode: GridLayoutMode): string { + return mode === '3x3' ? 'xl:col-span-4' : 'xl:col-span-3'; +} + +function EmptyPreview() { return ( - +
+ +
--
+
); } -function DragHandle() { +function PendingPreview() { return ( - +
+ +
+
+
+
+
+
+ ); +} + +function LivePreview() { + return ( +
+

12,458

+

↗ +12.5%

+
); } export default function AdminHomePage() { + const [layout, setLayout] = createSignal(sanitizeLayout(DEFAULT_LAYOUT)); + const [settingsOpen, setSettingsOpen] = createSignal(false); + const [isHydrating, setIsHydrating] = createSignal(true); + const [isAutoSaving, setIsAutoSaving] = createSignal(false); + const [autoSaveNotice, setAutoSaveNotice] = createSignal(''); + const [lastSavedSnapshot, setLastSavedSnapshot] = createSignal(''); + const [draggingKey, setDraggingKey] = createSignal(null); + const [openMenuId, setOpenMenuId] = createSignal(null); + + const [search, setSearch] = createSignal(''); + const [filterMode, setFilterMode] = createSignal('all'); + const [sortMode, setSortMode] = createSignal('layout'); + const [gridLayout, setGridLayout] = createSignal('3x4'); + + const orderedWidgets = createMemo(() => { + const current = layout(); + const rows = current.order + .map((key) => WIDGET_DEFINITION_MAP[key]) + .filter((definition): definition is DashboardWidgetDefinition => Boolean(definition)) + .filter((definition) => current.visibility[definition.widgetKey] !== false) + .filter((definition) => { + const query = search().trim().toLowerCase(); + if (!query) return true; + const meta = WIDGET_META[definition.widgetKey]; + return definition.title.toLowerCase().includes(query) + || definition.moduleKey.toLowerCase().includes(query) + || (meta?.subtitle || '').toLowerCase().includes(query); + }) + .filter((definition) => { + const mode = filterMode(); + if (mode === 'all') return true; + return (WIDGET_META[definition.widgetKey]?.type || 'summary') === mode; + }); + + const mode = sortMode(); + if (mode === 'layout') return rows; + + const next = rows.slice(); + next.sort((a, b) => { + if (mode === 'name') return a.title.localeCompare(b.title); + const rank = (key: string) => { + const state = WIDGET_META[key]?.state || 'empty'; + if (state === 'live') return 1; + if (state === 'empty') return 2; + return 3; + }; + return rank(a.widgetKey) - rank(b.widgetKey); + }); + + return next; + }); + + onMount(async () => { + try { + const persistedLayout = await loadAdminDashboardLayout(); + const normalized = sanitizeLayout(persistedLayout || DEFAULT_LAYOUT); + setLayout(normalized); + setLastSavedSnapshot(JSON.stringify(normalized)); + } finally { + setIsHydrating(false); + } + }); + + createEffect(() => { + if (isHydrating()) return; + const nextLayout = layout(); + const nextSnapshot = JSON.stringify(nextLayout); + if (nextSnapshot === lastSavedSnapshot()) return; + + setIsAutoSaving(true); + setAutoSaveNotice(''); + + const timer = setTimeout(async () => { + const ok = await saveAdminDashboardLayout(nextLayout); + setIsAutoSaving(false); + if (ok) { + setLastSavedSnapshot(nextSnapshot); + setAutoSaveNotice('Layout saved automatically.'); + } else { + setAutoSaveNotice('Auto-save failed. Please try again.'); + } + }, 450); + + onCleanup(() => clearTimeout(timer)); + }); + + const setWidgetVisibility = (widgetKey: string, visible: boolean) => { + setLayout((current) => ({ + ...current, + visibility: { + ...current.visibility, + [widgetKey]: visible, + }, + })); + }; + + const resetLayout = () => { + const normalized = sanitizeLayout(DEFAULT_LAYOUT); + setLayout(normalized); + setAutoSaveNotice('Layout reset to default.'); + }; + + const handleDragStart = (event: DragEvent, widgetKey: string) => { + event.dataTransfer?.setData('text/plain', widgetKey); + if (event.dataTransfer) event.dataTransfer.effectAllowed = 'move'; + setDraggingKey(widgetKey); + }; + + const handleDrop = (event: DragEvent, targetKey: string) => { + event.preventDefault(); + const dragged = event.dataTransfer?.getData('text/plain') || draggingKey(); + if (!dragged) return; + + setLayout((current) => ({ + ...current, + order: reorderKeys(current.order, dragged, targetKey), + })); + setDraggingKey(null); + }; + return ( -
+
+
+
+
+

Dashboard Overview

+

Manage widget layout, visibility, sizing, and dashboard presentation

+
- {/* Page header */} -
-
-

Dashboard Overview

-

Welcome back! Here's what's happening with your platform today.

+
+ +
- {/* KPI cards */} -
- - {(item) => ( -
-
-
- -
- + + +
+
+
+

Widget Settings

+

Choose visible widgets and select a grid layout.

+
+ +
+
+ + + + + + + +
+
+ + {(definition) => { + const visible = () => layout().visibility[definition.widgetKey] !== false; + + return ( +
+
+
+

{definition.title}

+

{definition.moduleKey}

+
+ +
+
+ ); + }} +
+
+ +

{isAutoSaving() ? 'Saving layout...' : autoSaveNotice()}

+
+
+
+ +
+ +
+ + {(definition) => { + const meta = WIDGET_META[definition.widgetKey]; + const state = meta?.state || 'empty'; + const isOpenMenu = () => openMenuId() === definition.widgetKey; + + return ( +
handleDragStart(event, definition.widgetKey)} + onDragOver={(event) => event.preventDefault()} + onDrop={(event) => handleDrop(event, definition.widgetKey)} + onDragEnd={() => setDraggingKey(null)} + class={`relative aspect-square min-h-[235px] rounded-2xl border border-[#E5E7EB] bg-white p-5 shadow-sm md:min-h-[260px] md:p-6 ${widgetSpan(gridLayout())} ${ + state === 'pending' ? 'opacity-95' : '' + } ${draggingKey() === definition.widgetKey ? 'opacity-60' : ''}`} > - {item.tone === 'up' ? '↗' : '↘'} {item.delta} - -
+ + <> + +
+ -
-

{item.title}

-

{item.value}

-
-

{item.note}

-
-
-
- )} - -
+ +
+ {['Edit Widget', 'Duplicate Widget', 'Hide Widget', 'Remove Widget'].map((item) => ( + + ))} +
+
+
+ + - {/* Chart widgets */} -
- - {/* Leads Trend */} -
-
-
-

Leads Trend

-

Monthly leads performance overview

-
- -
-
-
-
- 120 - 90 - 60 - 30 - 0 -
-
-
-
- {() =>
} +
+
+ {iconForWidget(definition.widgetKey)} +
+

{definition.title}

+
+ + + + + + + + + +
+ + {meta?.statusLabel || 'No Data'} +
- -
-
- {(m) => {m}} -
-
-
-
-
- - {/* Revenue Overview */} -
-
-
-

Revenue Overview

-

Monthly revenue vs expenses comparison

-
- -
-
-
-
- 80k - 60k - 40k - 20k - 0 -
-
-
-
- {() =>
} -
-
- - {(value) => ( -
-
-
- )} - -
-
-
- {(m) => {m}} -
-
-
-
-
+ + ); + }} +