nxtgauge-admin-solid/src/routes/admin/index.tsx

587 lines
23 KiB
TypeScript
Raw Normal View History

import { For, Show, createEffect, createMemo, createSignal, onCleanup, onMount, createResource } from 'solid-js';
import { useSearchParams } from '@solidjs/router';
import {
BarChart3,
Building2,
CircleDashed,
Coins,
EllipsisVertical,
Eye,
EyeOff,
GripVertical,
LineChart,
RotateCcw,
Search,
Settings2,
TrendingUp,
Users,
} from 'lucide-solid';
import AdminShell from '~/components/AdminShell';
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 API = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000';
async function fetchMetrics() {
const accessToken = typeof sessionStorage !== 'undefined'
? (sessionStorage.getItem('nxtgauge_admin_access_token') || '').trim()
: '';
const res = await fetch(`${API}/api/admin/dashboard/metrics`, {
headers: {
Accept: 'application/json',
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
},
credentials: 'include',
});
if (!res.ok) throw new Error('Failed to load metrics');
return res.json();
}
type WidgetStateKind = 'live' | 'empty' | 'pending';
type WidgetType = 'summary' | 'analytics';
type SortMode = 'layout' | 'name' | 'status';
type FilterMode = 'all' | WidgetType;
type GridLayoutMode = '3x4' | '3x3';
type WidgetMeta = {
state: WidgetStateKind;
type: WidgetType;
statusLabel: string;
subtitle: string;
emptyMessage?: string;
pendingMessage?: string;
};
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<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);
}
for (const key of fallbackOrder) {
if (!seen.has(key)) normalizedOrder.push(key);
}
const visibility: Record<string, boolean> = {};
const size: Record<string, DashboardWidgetSize> = {};
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 <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 (
<div class="flex h-full flex-col items-center justify-center">
<CircleDashed size={22} class="text-[#FA5014]" />
<div class="mt-3 text-2xl font-bold leading-none text-[#9CA3AF]">--</div>
</div>
);
}
function PendingPreview() {
return (
<div class="flex h-full flex-col items-center justify-center gap-3">
<LineChart size={22} class="text-[#FA5014]" />
<div class="w-full max-w-[130px] space-y-2">
<div class="h-2 rounded bg-[#E5E7EB]" />
<div class="h-2 w-5/6 rounded bg-[#E5E7EB]" />
<div class="h-2 w-4/6 rounded bg-[#E5E7EB]" />
</div>
</div>
);
}
function LivePreview(props: { value?: string; trend?: string; trendUp?: boolean }) {
return (
<div class="flex h-full flex-col items-center justify-center">
<p class="text-[26px] font-bold leading-none text-[#111827]">{props.value || '0'}</p>
<p class="mt-2 inline-flex items-center gap-1 text-xs font-semibold text-[#FA5014]">
{props.trendUp ? '↗' : '↘'} {props.trend || '0%'}
</p>
</div>
);
}
export default function AdminHomePage() {
const [searchParams] = useSearchParams();
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 [metrics] = createResource(fetchMetrics);
const getWidgetState = (key: string) => {
if (key.startsWith('kpi_')) {
const idMap: Record<string, string> = {
kpi_total_users: 'users',
kpi_active_companies: 'companies',
kpi_open_leads: 'leads',
kpi_credits_purchased: 'credits',
};
if (metrics.loading) return { state: 'pending', statusLabel: 'Loading...' };
const m = metrics()?.kpis?.find((k: any) => k.id === idMap[key]);
if (m) return { state: 'live', statusLabel: 'Live Data', data: m };
return { state: 'empty', statusLabel: 'No Data' };
}
const meta = WIDGET_META[key];
return { state: meta?.state || 'empty', statusLabel: meta?.statusLabel || 'No Data' };
};
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 info = getWidgetState(key);
if (info.state === 'live') return 1;
if (info.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 (
<AdminShell>
<div class="w-full">
<Show when={Boolean(searchParams.denied)}>
<div class="rounded-2xl border border-[#E5E7EB] bg-white shadow-sm" style="margin-bottom: 18px">
<p class="notice">You dont have access to {String(searchParams.denied || '').replace(/_/g, ' ')}.</p>
</div>
</Show>
<div class="rounded-2xl border border-[#E5E7EB] bg-white px-6 py-5 shadow-sm md:px-8" style="margin-bottom: 28px">
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 class="text-[22px] font-bold leading-tight text-[#111827]">Dashboard Overview</h1>
<p class="mt-0.5 text-[13px] text-[#6B7280]">Manage widget layout, visibility, sizing, and dashboard presentation</p>
</div>
<div class="flex shrink-0">
<button
type="button"
class="inline-flex h-9 items-center gap-1.5 whitespace-nowrap rounded-xl bg-[#0D0D2A] px-5 text-[13px] font-semibold leading-none text-white shadow-sm"
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>
<Show when={settingsOpen()}>
<div class="rounded-2xl border border-[#E5E7EB] bg-white p-6 shadow-sm md:p-7">
{/* Settings header */}
<div class="flex flex-wrap items-start justify-between gap-4">
<div>
<h2 class="text-[15px] font-semibold text-[#111827]">Widget Settings</h2>
<p class="mt-1 text-[13px] text-[#6B7280]">Choose visible widgets and select a grid layout.</p>
</div>
<button
type="button"
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"
onClick={resetLayout}
>
<RotateCcw size={13} class="text-[#FA5014]" />
Reset Layout
</button>
</div>
{/* Filter controls */}
<div class="mt-5 flex flex-wrap gap-3">
<div class="relative min-w-[200px] flex-1">
<Search size={14} class="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-[#FA5014]" style="z-index:1" />
<input
value={search()}
onInput={(event) => setSearch(event.currentTarget.value)}
placeholder="Search widgets"
class="h-10 w-full rounded-lg border border-[#E5E7EB] bg-white pl-10 pr-3 text-sm text-[#111827] outline-none focus:border-[#FA5014]"
/>
</div>
<select
class="h-10 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">All Widgets</option>
<option value="summary">Summary Only</option>
<option value="analytics">Analytical Only</option>
</select>
<select
class="h-10 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">Layout Order</option>
<option value="name">Name</option>
<option value="status">Status</option>
</select>
<select
class="h-10 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 × 4</option>
<option value="3x3">Grid 3 × 3</option>
</select>
</div>
{/* Widget list */}
<div class="mt-5 grid gap-3 md:grid-cols-2">
<For each={ADMIN_DASHBOARD_WIDGETS}>
{(definition) => {
const visible = () => layout().visibility[definition.widgetKey] !== false;
const meta = WIDGET_META[definition.widgetKey];
const stateInfo = getWidgetState(definition.widgetKey);
const state = stateInfo.state;
return (
<div class="flex items-center justify-between gap-4 rounded-xl border border-[#E5E7EB] bg-[#F9FAFB] px-4 py-4">
<div class="flex items-center gap-3 min-w-0">
<div class="shrink-0 text-[#FA5014]">
{iconForWidget(definition.widgetKey)}
</div>
<div class="min-w-0">
<p class="text-sm font-semibold text-[#111827]">{definition.title}</p>
<p class="mt-0.5 text-[11px] font-medium uppercase tracking-wide text-[#9CA3AF]">{definition.moduleKey}</p>
</div>
</div>
<div class="flex shrink-0 items-center gap-2">
<span class={`hidden rounded-full border px-2 py-0.5 text-[10px] font-semibold sm:inline-flex ${badgeClass(state as WidgetStateKind)}`}>
{stateInfo.statusLabel}
</span>
<button
type="button"
class={`inline-flex h-8 items-center gap-1.5 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={12} /> : <EyeOff size={12} />}
{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="grid grid-cols-1 gap-5 xl:grid-cols-12" style="margin-top: 28px">
<For each={orderedWidgets()}>
{(definition) => {
const meta = WIDGET_META[definition.widgetKey];
const stateInfo = getWidgetState(definition.widgetKey);
const state = stateInfo.state;
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' : ''}`}
>
<Show when={settingsOpen()}>
<>
<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>
<Show when={isOpenMenu()}>
<div class="absolute right-0 top-10 z-20 w-40 rounded-lg border border-[#E5E7EB] bg-white p-1 shadow-md">
{['Edit Widget', 'Duplicate Widget', 'Hide Widget', 'Remove Widget'].map((item) => (
<button
type="button"
class="block w-full rounded-md px-2 py-2 text-left text-xs font-medium text-[#111827] hover:bg-[#F9FAFB]"
onClick={() => setOpenMenuId(null)}
>
{item}
</button>
))}
</div>
</Show>
</div>
</>
</Show>
<div class="flex h-full flex-col items-center justify-center px-4">
<div class="mb-3 text-[#FA5014]">
{iconForWidget(definition.widgetKey)}
</div>
<h3 class="text-center text-[15px] font-semibold text-[#111827]">{definition.title}</h3>
<div class="mt-4 h-[116px] w-full max-w-[210px] rounded-xl border border-[#E5E7EB] bg-[#F9FAFB] p-4">
<Show when={state === 'live'}>
<LivePreview value={stateInfo.data?.value} trend={stateInfo.data?.trend} trendUp={stateInfo.data?.trendUp} />
</Show>
<Show when={state === 'empty'}>
<EmptyPreview />
</Show>
<Show when={state === 'pending'}>
<PendingPreview />
</Show>
</div>
<span class={`mt-4 inline-flex rounded-full border px-2 py-1 text-[11px] font-semibold ${badgeClass(state as WidgetStateKind)}`}>
{stateInfo.statusLabel}
</span>
</div>
</section>
);
}}
</For>
</div>
</div>
</AdminShell>
);
}