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

586 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>
);
}