Refine admin dashboard layout spacing and widget settings UX

This commit is contained in:
Ashwin Kumar 2026-03-27 18:29:21 +01:00
parent 717a6c799e
commit 2a3a336114
4 changed files with 862 additions and 227 deletions

185
src/lib/admin/dashboard.ts Normal file
View file

@ -0,0 +1,185 @@
export type DashboardWidgetSize = 'S' | 'M' | 'L';
export type WidgetDataState = 'loading' | 'success' | 'empty' | 'error' | 'unavailable';
export type WidgetDataResult<T = unknown> = {
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<string, KpiPayload> = {
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<string, () => Promise<WidgetDataResult<AdminWidgetPayload>>> = {
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<WidgetDataResult<AdminWidgetPayload>> {
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.',
}));
}

View file

@ -1,3 +1,5 @@
import type { RuntimeDashboardLayout } from './types';
// All API calls go through the server route gateway proxy // All API calls go through the server route gateway proxy
const API_GATEWAY = '/api/gateway'; const API_GATEWAY = '/api/gateway';
@ -22,6 +24,15 @@ type RoleRecord = {
audience: string; audience: string;
}; };
type DashboardConfigResponse = {
id?: string;
role_id?: string;
config_json?: Record<string, unknown>;
is_active?: boolean;
updated_at?: string;
created_at?: string;
};
export async function saveRuntimeConfig(type: RuntimeRecordType, key: string, payload: unknown, status: RuntimeRecordStatus) { export async function saveRuntimeConfig(type: RuntimeRecordType, key: string, payload: unknown, status: RuntimeRecordStatus) {
const normalizedKey = key.trim().toUpperCase(); const normalizedKey = key.trim().toUpperCase();
if (!normalizedKey) return; if (!normalizedKey) return;
@ -29,7 +40,7 @@ export async function saveRuntimeConfig(type: RuntimeRecordType, key: string, pa
try { try {
// 1. Ensure the Role exists first. We lookup by key. // 1. Ensure the Role exists first. We lookup by key.
// If it doesn't exist, we create it generically. // 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; let role: RoleRecord;
if (!roleRes.ok && roleRes.status === 404) { if (!roleRes.ok && roleRes.status === 404) {
@ -43,7 +54,7 @@ export async function saveRuntimeConfig(type: RuntimeRecordType, key: string, pa
audience: 'EXTERNAL', 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(); role = await createRes.json();
} else { } else {
role = await roleRes.json(); role = await roleRes.json();
@ -56,7 +67,7 @@ export async function saveRuntimeConfig(type: RuntimeRecordType, key: string, pa
onboarding: '/api/admin/onboarding-config', onboarding: '/api/admin/onboarding-config',
}; };
let bodyPayload: any = { const bodyPayload: Record<string, unknown> = {
role_id: role.id, role_id: role.id,
}; };
@ -66,7 +77,7 @@ export async function saveRuntimeConfig(type: RuntimeRecordType, key: string, pa
} else if (type === 'onboarding') { } else if (type === 'onboarding') {
bodyPayload.schema_json = payload; bodyPayload.schema_json = payload;
} else if (type === 'role') { } else if (type === 'role') {
bodyPayload.config_json = payload; bodyPayload.config_json = payload;
} }
const saveRes = await fetch(`${API_GATEWAY}${endpointMap[type]}`, { const saveRes = await fetch(`${API_GATEWAY}${endpointMap[type]}`, {
@ -76,7 +87,7 @@ export async function saveRuntimeConfig(type: RuntimeRecordType, key: string, pa
}); });
if (!saveRes.ok) { 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`); 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<T>(type: RuntimeRecordType): Promise<Array<RuntimeListItem<T>>> { export async function listRuntimeConfigs<T>(type: RuntimeRecordType): Promise<Array<RuntimeListItem<T>>> {
const endpointMap: Record<RuntimeRecordType, string> = { const endpointMap: Record<RuntimeRecordType, string> = {
role: '/api/admin/roles', role: '/api/admin/roles',
dashboard: '/api/admin/dashboard-config', dashboard: '/api/admin/dashboard-config',
onboarding: '/api/admin/onboarding-config', onboarding: '/api/admin/onboarding-config',
}; };
try { try {
const res = await fetch(`${API_GATEWAY}${endpointMap[type]}`); const res = await fetch(`${API_GATEWAY}${endpointMap[type]}`);
if (!res.ok) throw new Error(`Failed to list ${type} configs`); if (!res.ok) throw new Error(`Failed to list ${type} configs`);
const data = await res.json(); const data = await res.json();
// Map Rust backend models to UI expected format // Map Rust backend models to UI expected format
return data.map((item: any) => ({ return data.map((item: any) => ({
key: item.role_key || item.key, key: item.role_key || item.key,
status: item.is_active ? 'published' : 'draft', status: item.is_active ? 'published' : 'draft',
updatedAt: item.updated_at || item.created_at, updatedAt: item.updated_at || item.created_at,
payload: item.config_json || item.schema_json || item, payload: item.config_json || item.schema_json || item,
// include raw id for routing convenience // include raw id for routing convenience
id: item.id, id: item.id,
role_id: item.role_id role_id: item.role_id
})); }));
} catch (err) { } catch (err) {
console.error(`Error listing runtime config [${type}]:`, err); console.error(`Error listing runtime config [${type}]:`, err);
return []; return [];
} }
} }
export async function getRuntimeConfig<T>(type: RuntimeRecordType, roleId: string): Promise<RuntimeStoredRecord<T> | null> { export async function getRuntimeConfig<T>(type: RuntimeRecordType, roleId: string): Promise<RuntimeStoredRecord<T> | null> {
const endpointMap: Record<RuntimeRecordType, string> = { const endpointMap: Record<RuntimeRecordType, string> = {
role: `/api/admin/roles/${roleId}`, // Assume roleId is key if testing role: `/api/admin/roles/${roleId}`, // Assume roleId is key if testing
dashboard: `/api/admin/dashboard-config/${roleId}?audience=EXTERNAL`, dashboard: `/api/admin/dashboard-config/${roleId}?audience=EXTERNAL`,
onboarding: `/api/admin/onboarding-config/${roleId}`, 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,
}; };
} catch (err) {
try { return null;
const res = await fetch(`${API_GATEWAY}${endpointMap[type]}`); }
if (!res.ok) return null; }
const data = await res.json();
async function resolveInternalRoleId(preferredKeys: string[] = ['ADMIN', 'SUPER_ADMIN']): Promise<string | null> {
return { try {
status: data.is_active ? 'published' : 'draft', const res = await fetch(`${API_GATEWAY}/api/admin/roles?audience=INTERNAL`);
updatedAt: data.updated_at || data.created_at, if (!res.ok) return null;
payload: data.config_json || data.schema_json || data,
}; const data = await res.json();
} catch (err) { const rows = Array.isArray(data) ? data : (data.roles || []);
return null; 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<string, boolean> = {};
for (const [key, value] of Object.entries(rawVisibility)) {
visibility[String(key)] = Boolean(value);
}
const size: Record<string, 'S' | 'M' | 'L'> = {};
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<DashboardConfigResponse | null> {
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<RuntimeDashboardLayout | null> {
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<boolean> {
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<boolean> { export async function deleteRuntimeConfig(type: RuntimeRecordType, key: string): Promise<boolean> {

View file

@ -1,3 +1,11 @@
export type DashboardWidgetSize = 'S' | 'M' | 'L';
export type RuntimeDashboardLayout = {
order: string[];
visibility: Record<string, boolean>;
size: Record<string, DashboardWidgetSize>;
};
export type RuntimeRoleConfig = { export type RuntimeRoleConfig = {
roleKey: string; roleKey: string;
displayName: string; displayName: string;
@ -11,6 +19,7 @@ export type RuntimeDashboardConfig = {
roleKey: string; roleKey: string;
sidebar: Array<{ key: string; label: string; route: string }>; sidebar: Array<{ key: string; label: string; route: string }>;
widgets: Array<{ key: string; title: string; enabled: boolean }>; widgets: Array<{ key: string; title: string; enabled: boolean }>;
dashboard_layout?: RuntimeDashboardLayout;
}; };
export type RuntimeOnboardingFieldOption = { export type RuntimeOnboardingFieldOption = {

View file

@ -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 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 = [ type WidgetStateKind = 'live' | 'empty' | 'pending';
{ title: 'Total Users', value: '12,458', delta: '+12.5%', note: '+1,245 this month', tone: 'up' as const, icon: 'users' as const }, type WidgetType = 'summary' | 'analytics';
{ title: 'Active Companies', value: '1,234', delta: '+8.2%', note: '+94 this month', tone: 'up' as const, icon: 'building' as const }, type SortMode = 'layout' | 'name' | 'status';
{ title: 'Open Leads', value: '847', delta: '-3.1%', note: '27 fewer than last month', tone: 'down' as const, icon: 'trend' as const }, type FilterMode = 'all' | WidgetType;
{ title: 'Credits Purchased', value: '₹45,890', delta: '+18.7%', note: '₹7,234 more this month', tone: 'up' as const, icon: 'card' as const }, type GridLayoutMode = '3x4' | '3x3';
];
const trendSeries = [62, 70, 81, 75, 88, 102]; type WidgetMeta = {
const revSeries = [42000, 48000, 55000, 51000, 62000, 69000]; state: WidgetStateKind;
const maxAmount = 80000; type: WidgetType;
statusLabel: string;
subtitle: string;
emptyMessage?: string;
pendingMessage?: string;
};
function KpiIcon(props: { kind: 'users' | 'building' | 'trend' | 'card' }) { const WIDGET_DEFINITION_MAP = Object.fromEntries(
if (props.kind === 'users') { ADMIN_DASHBOARD_WIDGETS.map((definition) => [definition.widgetKey, definition])
return ( );
<svg class="h-6 w-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" aria-hidden="true">
<path d="M8 13a4 4 0 1 0 0-8 4 4 0 0 0 0 8Z" /> const DEFAULT_LAYOUT: RuntimeDashboardLayout = {
<path d="M16 11a3 3 0 1 0 0-6" /> order: ADMIN_DASHBOARD_WIDGETS.slice()
<path d="M3.5 20a5 5 0 0 1 9 0" /> .sort((a, b) => a.order - b.order)
<path d="M14.5 19.5a4 4 0 0 1 6 0" /> .map((definition) => definition.widgetKey),
</svg> 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<string, WidgetMeta> = {
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<string>();
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) {
<svg class="h-6 w-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" aria-hidden="true"> if (!seen.has(key)) normalizedOrder.push(key);
<rect x="4" y="3" width="16" height="18" rx="2.5" />
<path d="M8 7h2M12 7h2M8 11h2M12 11h2M8 15h2M12 15h2M11 21v-3h2v3" />
</svg>
);
} }
if (props.kind === 'trend') {
return ( const visibility: Record<string, boolean> = {};
<svg class="h-6 w-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" aria-hidden="true"> const size: Record<string, DashboardWidgetSize> = {};
<path d="m3 16 6-6 4 4 8-8" />
<path d="M16 6h5v5" /> for (const definition of ADMIN_DASHBOARD_WIDGETS) {
</svg> 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 <Users size={22} class={cls} />;
if (widgetKey.includes('companies')) return <Building2 size={22} class={cls} />;
if (widgetKey.includes('leads')) return <TrendingUp size={22} class={cls} />;
if (widgetKey.includes('credits')) return <Coins size={22} class={cls} />;
if (widgetKey.includes('revenue')) return <BarChart3 size={22} class={cls} />;
return <LineChart size={22} class={cls} />;
}
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 ( return (
<svg class="h-6 w-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" aria-hidden="true"> <div class="flex h-full flex-col items-center justify-center">
<rect x="3" y="5" width="18" height="14" rx="2.5" /> <CircleDashed size={22} class="text-[#FA5014]" />
<path d="M3 10h18" /> <div class="mt-3 text-2xl font-bold leading-none text-[#9CA3AF]">--</div>
</svg> </div>
); );
} }
function DragHandle() { function PendingPreview() {
return ( return (
<button <div class="flex h-full flex-col items-center justify-center gap-3">
type="button" <LineChart size={22} class="text-[#FA5014]" />
class="cursor-grab touch-none text-[#D1D5DB] hover:text-[#9CA3AF] transition-colors" <div class="w-full max-w-[130px] space-y-2">
aria-label="Drag to reorder" <div class="h-2 rounded bg-[#E5E7EB]" />
> <div class="h-2 w-5/6 rounded bg-[#E5E7EB]" />
<GripVertical size={18} /> <div class="h-2 w-4/6 rounded bg-[#E5E7EB]" />
</button> </div>
</div>
);
}
function LivePreview() {
return (
<div class="flex h-full flex-col items-center justify-center">
<p class="text-[26px] font-bold leading-none text-[#111827]">12,458</p>
<p class="mt-2 inline-flex items-center gap-1 text-xs font-semibold text-[#FA5014]"> +12.5%</p>
</div>
); );
} }
export default function AdminHomePage() { export default function AdminHomePage() {
const [layout, setLayout] = createSignal<RuntimeDashboardLayout>(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<string | null>(null);
const [openMenuId, setOpenMenuId] = createSignal<string | null>(null);
const [search, setSearch] = createSignal('');
const [filterMode, setFilterMode] = createSignal<FilterMode>('all');
const [sortMode, setSortMode] = createSignal<SortMode>('layout');
const [gridLayout, setGridLayout] = createSignal<GridLayoutMode>('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 ( return (
<AdminShell> <AdminShell>
<div class="w-full space-y-8 pb-8"> <div class="w-full bg-[#F9FAFB] pb-12">
<div class="overflow-hidden rounded-2xl border border-[#E5E7EB] bg-white px-8 pt-10 pb-12 shadow-sm md:px-10 md:pt-11 md:pb-13">
<div class="flex flex-col gap-5 lg:flex-row lg:items-center lg:justify-between">
<div class="max-w-3xl" style="padding-left: 40px; padding-right: 20px;">
<h1 class="text-[28px] font-bold leading-tight text-[#111827]">Dashboard Overview</h1>
<p class="mt-1 text-[14px] text-[#6B7280]">Manage widget layout, visibility, sizing, and dashboard presentation</p>
</div>
{/* Page header */} <div class="flex w-full lg:w-auto lg:justify-end" style="margin-right: 20px;">
<div> <button
<div> type="button"
<h1 class="text-[28px] font-bold leading-tight tracking-[-0.01em] text-[#0B1246]">Dashboard Overview</h1> class="inline-flex h-9 w-full items-center justify-center gap-1.5 whitespace-nowrap rounded-xl bg-[#0D0D2A] px-4 text-[13px] font-semibold leading-none text-white shadow-sm lg:w-auto lg:min-w-[190px]"
<p class="mt-1.5 text-[14px] text-[#7E849F]">Welcome back! Here's what's happening with your platform today.</p> onClick={() => {
const next = !settingsOpen();
setSettingsOpen(next);
if (!next) setOpenMenuId(null);
}}
>
<Settings2 size={14} class="text-[#FA5014]" />
{settingsOpen() ? 'Close Settings' : 'Customize Widgets'}
</button>
</div>
</div> </div>
</div> </div>
{/* KPI cards */} <div class={settingsOpen() ? 'h-10' : 'h-0'} />
<div class="grid grid-cols-4 gap-5">
<For each={kpis}> <Show when={settingsOpen()}>
{(item) => ( <div class="rounded-2xl border border-[#E5E7EB] bg-white p-8 shadow-sm md:p-9">
<div class="rounded-[18px] border border-[#D6DAE4] bg-white px-5 pb-4.5 pt-4 shadow-[0_2px_10px_rgba(15,23,42,0.04)]"> <div class="flex flex-wrap items-center justify-between gap-4">
<div class="flex items-center justify-between"> <div class="pl-2 pt-1">
<div class="inline-flex h-10 w-10 items-center justify-center rounded-xl bg-[#FFF5EF] text-[#FA5014]"> <h2 class="text-[15px] font-semibold text-[#111827]">Widget Settings</h2>
<KpiIcon kind={item.icon} /> <p class="mt-2 text-[13px] text-[#6B7280]">Choose visible widgets and select a grid layout.</p>
</div> </div>
<span <button
class={`inline-flex shrink-0 items-center gap-0.5 rounded-full px-2.5 py-1 text-[11px] font-semibold leading-none ${ type="button"
item.tone === 'up' class="inline-flex h-9 items-center gap-1.5 rounded-lg border border-[#E5E7EB] bg-white px-3 text-xs font-semibold text-[#111827] shadow-sm"
? 'bg-[#FFF1EB] text-[#FA5014]' onClick={resetLayout}
: 'bg-[#FEF2F2] text-[#DC2626]' >
}`} <RotateCcw size={13} class="text-[#FA5014]" />
Reset Layout
</button>
</div>
<div class="mt-6 grid gap-5 md:grid-cols-2 xl:grid-cols-4">
<label class="relative">
<Search size={14} class="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-[#FA5014]" />
<input
value={search()}
onInput={(event) => setSearch(event.currentTarget.value)}
placeholder="Search widgets"
class="h-11 w-full rounded-lg border border-[#E5E7EB] bg-white pl-9 pr-3 text-sm text-[#111827] outline-none"
/>
</label>
<select
class="h-11 rounded-lg border border-[#E5E7EB] bg-white px-3 text-sm text-[#111827]"
value={filterMode()}
onChange={(event) => setFilterMode(event.currentTarget.value as FilterMode)}
>
<option value="all">Filter: All Widgets</option>
<option value="summary">Filter: Summary Widgets</option>
<option value="analytics">Filter: Analytical Widgets</option>
</select>
<select
class="h-11 rounded-lg border border-[#E5E7EB] bg-white px-3 text-sm text-[#111827]"
value={sortMode()}
onChange={(event) => setSortMode(event.currentTarget.value as SortMode)}
>
<option value="layout">Sort: Layout Order</option>
<option value="name">Sort: Name</option>
<option value="status">Sort: Status</option>
</select>
<select
class="h-11 rounded-lg border border-[#E5E7EB] bg-white px-3 text-sm text-[#111827]"
value={gridLayout()}
onChange={(event) => setGridLayout(event.currentTarget.value as GridLayoutMode)}
>
<option value="3x4">Grid: 3 x 4</option>
<option value="3x3">Grid: 3 x 3</option>
</select>
</div>
<div class="mt-6 grid gap-5 md:grid-cols-2">
<For each={ADMIN_DASHBOARD_WIDGETS}>
{(definition) => {
const visible = () => layout().visibility[definition.widgetKey] !== false;
return (
<div class="rounded-2xl border border-[#E5E7EB] bg-[#F9FAFB] px-4 py-5">
<div class="flex items-center justify-between gap-3">
<div>
<p class="text-sm font-semibold text-[#111827]">{definition.title}</p>
<p class="mt-0.5 text-[11px] font-semibold uppercase tracking-wide text-[#9CA3AF]">{definition.moduleKey}</p>
</div>
<button
type="button"
class={`inline-flex h-8 items-center gap-1 rounded-md border px-3 text-xs font-semibold ${
visible()
? 'border-[#FDBA8C] bg-[#FFF1EB] text-[#FA5014]'
: 'border-[#E5E7EB] bg-white text-[#6B7280]'
}`}
onClick={() => setWidgetVisibility(definition.widgetKey, !visible())}
>
{visible() ? <Eye size={13} class="text-[#FA5014]" /> : <EyeOff size={13} class="text-[#FA5014]" />} {visible() ? 'Visible' : 'Hidden'}
</button>
</div>
</div>
);
}}
</For>
</div>
<Show when={isAutoSaving() || autoSaveNotice()}>
<p class="mt-4 text-xs text-[#6B7280]">{isAutoSaving() ? 'Saving layout...' : autoSaveNotice()}</p>
</Show>
</div>
</Show>
<div class={settingsOpen() ? 'h-8' : 'h-12'} />
<div class="grid grid-cols-1 gap-7 xl:grid-cols-12">
<For each={orderedWidgets()}>
{(definition) => {
const meta = WIDGET_META[definition.widgetKey];
const state = meta?.state || 'empty';
const isOpenMenu = () => openMenuId() === definition.widgetKey;
return (
<section
draggable={settingsOpen()}
onDragStart={(event) => 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} <Show when={settingsOpen()}>
</span> <>
</div> <button
type="button"
class="absolute left-3 top-3 inline-flex h-8 w-8 items-center justify-center rounded-md border border-[#E5E7EB] bg-white cursor-grab text-[#FA5014]"
aria-label="Drag to reorder"
>
<GripVertical size={14} class="text-[#FA5014]" />
</button>
<div class="absolute right-3 top-3">
<button
type="button"
class="inline-flex h-8 w-8 items-center justify-center rounded-md border border-[#E5E7EB] bg-white text-[#FA5014]"
onClick={() => setOpenMenuId(isOpenMenu() ? null : definition.widgetKey)}
>
<EllipsisVertical size={14} class="text-[#FA5014]" />
</button>
<div class="mt-3.5 space-y-1.5"> <Show when={isOpenMenu()}>
<p class="text-[12px] font-medium uppercase tracking-[0.04em] text-[#8D93AA]">{item.title}</p> <div class="absolute right-0 top-10 z-20 w-40 rounded-lg border border-[#E5E7EB] bg-white p-1 shadow-md">
<p class="text-[28px] font-bold leading-none tracking-[-0.01em] tabular-nums text-[#0B1246]">{item.value}</p> {['Edit Widget', 'Duplicate Widget', 'Hide Widget', 'Remove Widget'].map((item) => (
<div class="border-t border-[#EEF1F6] pt-1.5"> <button
<p class="text-[11px] leading-snug text-[#97A0B8]">{item.note}</p> type="button"
</div> class="block w-full rounded-md px-2 py-2 text-left text-xs font-medium text-[#111827] hover:bg-[#F9FAFB]"
</div> onClick={() => setOpenMenuId(null)}
</div> >
)} {item}
</For> </button>
</div> ))}
</div>
</Show>
</div>
</>
</Show>
{/* Chart widgets */} <div class="flex h-full flex-col items-center justify-center px-4">
<div class="grid grid-cols-2 gap-6"> <div class="mb-3 text-[#FA5014]">
{iconForWidget(definition.widgetKey)}
{/* Leads Trend */} </div>
<div class="rounded-[18px] border border-[#D6DAE4] bg-white shadow-[0_2px_10px_rgba(15,23,42,0.04)]"> <h3 class="text-center text-[15px] font-semibold text-[#111827]">{definition.title}</h3>
<div class="flex items-center justify-between border-b border-[#E8EBF2] px-6 py-4.5"> <div class="mt-4 h-[116px] w-full max-w-[210px] rounded-xl border border-[#E5E7EB] bg-[#F9FAFB] p-4">
<div> <Show when={state === 'live'}>
<h2 class="text-[17px] font-bold text-[#0B1246]">Leads Trend</h2> <LivePreview />
<p class="mt-0.5 text-[11px] text-[#8D93AA]">Monthly leads performance overview</p> </Show>
</div> <Show when={state === 'empty'}>
<DragHandle /> <EmptyPreview />
</div> </Show>
<div class="px-6 pb-5 pt-4"> <Show when={state === 'pending'}>
<div class="grid grid-cols-[44px_1fr] gap-3"> <PendingPreview />
<div class="flex h-56 flex-col justify-between text-right text-[10px] font-semibold text-[#7A81A1]"> </Show>
<span>120</span> </div>
<span>90</span> <span class={`mt-4 inline-flex rounded-full border px-2 py-1 text-[11px] font-semibold ${badgeClass(state)}`}>
<span>60</span> {meta?.statusLabel || 'No Data'}
<span>30</span> </span>
<span>0</span>
</div>
<div>
<div class="relative h-56">
<div class="absolute inset-0">
<For each={[0, 1, 2, 3]}>{() => <div class="h-1/4 border-b border-[#F3F4F6]" />}</For>
</div> </div>
<svg viewBox="0 0 100 40" class="relative h-full w-full overflow-visible" preserveAspectRatio="none" aria-hidden="true"> </section>
<defs> );
<linearGradient id="trendFill" x1="0" y1="0" x2="0" y2="1"> }}
<stop offset="0%" stop-color="#FF5E13" stop-opacity="0.2" /> </For>
<stop offset="100%" stop-color="#FF5E13" stop-opacity="0" />
</linearGradient>
</defs>
<polygon
fill="url(#trendFill)"
points={`0,40 ${trendSeries.map((v, i) => `${i * 20},${40 - v / 3}`).join(' ')} 100,40`}
/>
<polyline
fill="none"
stroke="#FF5E13"
stroke-width="1.5"
stroke-linejoin="round"
stroke-linecap="round"
points={trendSeries.map((v, i) => `${i * 20},${40 - v / 3}`).join(' ')}
/>
</svg>
</div>
<div class="mt-2.5 grid grid-cols-6 text-center text-[10px] font-semibold text-[#6D7390]">
<For each={['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun']}>{(m) => <span>{m}</span>}</For>
</div>
</div>
</div>
</div>
</div>
{/* Revenue Overview */}
<div class="rounded-[18px] border border-[#D6DAE4] bg-white shadow-[0_2px_10px_rgba(15,23,42,0.04)]">
<div class="flex items-center justify-between border-b border-[#E8EBF2] px-6 py-4.5">
<div>
<h2 class="text-[17px] font-bold text-[#0B1246]">Revenue Overview</h2>
<p class="mt-0.5 text-[11px] text-[#8D93AA]">Monthly revenue vs expenses comparison</p>
</div>
<DragHandle />
</div>
<div class="px-6 pb-5 pt-4">
<div class="grid grid-cols-[56px_1fr] gap-3">
<div class="flex h-56 flex-col justify-between text-right text-[10px] font-semibold text-[#7A81A1]">
<span>80k</span>
<span>60k</span>
<span>40k</span>
<span>20k</span>
<span>0</span>
</div>
<div>
<div class="relative h-56">
<div class="absolute inset-0">
<For each={[0, 1, 2, 3]}>{() => <div class="h-1/4 border-b border-[#F3F4F6]" />}</For>
</div>
<div class="relative flex h-full items-end gap-2 px-1">
<For each={revSeries}>
{(value) => (
<div class="flex h-full flex-1 items-end">
<div
class="w-full rounded-t-lg bg-[#06083f] transition-all"
style={{ height: `${(value / maxAmount) * 100}%` }}
/>
</div>
)}
</For>
</div>
</div>
<div class="mt-2.5 grid grid-cols-6 text-center text-[10px] font-semibold text-[#6D7390]">
<For each={['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun']}>{(m) => <span>{m}</span>}</For>
</div>
</div>
</div>
</div>
</div>
</div> </div>
</div> </div>
</AdminShell> </AdminShell>