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

591 lines
22 KiB
TypeScript
Raw Normal View History

import {
For, Show, createResource, createSignal, onCleanup, onMount,
} from 'solid-js';
import { A } from '@solidjs/router';
import AdminShell from '~/components/AdminShell';
import {
Users, Building2, TrendingUp, CreditCard, Clock, BadgeCheck,
BriefcaseBusiness, HeadphonesIcon, Download, Plus, X, GripVertical,
} from 'lucide-solid';
import {
DragDropProvider, DragDropSensors, SortableProvider,
createSortable, closestCenter, type DragEventHandler,
} from '@thisbeyond/solid-dnd';
// ── Types ─────────────────────────────────────────────────────────────────────
type StatWidget = {
id: string;
label: string;
icon: any;
apiPath: string;
totalKey: string[];
prefix?: string;
deltaLabel: string;
deltaValue: string;
deltaUp: boolean;
href: string;
};
type ChartWidget = {
id: string;
label: string;
subtitle: string;
type: 'line' | 'bar';
};
// ── Stat widget definitions ───────────────────────────────────────────────────
const STAT_DEFS: StatWidget[] = [
{
id: 'total-users',
label: 'Total Users',
icon: Users,
apiPath: '/api/gateway/api/admin/users?limit=1',
totalKey: ['total', 'count'],
deltaLabel: 'from last month',
deltaValue: '+12.5%',
deltaUp: true,
href: '/admin/users',
},
{
id: 'active-companies',
label: 'Active Companies',
icon: Building2,
apiPath: '/api/gateway/api/admin/companies?limit=1',
totalKey: ['total', 'count'],
deltaLabel: 'from last month',
deltaValue: '+8.2%',
deltaUp: true,
href: '/admin/company',
},
{
id: 'open-leads',
label: 'Open Leads',
icon: TrendingUp,
apiPath: '/api/gateway/api/admin/leads?status=open&limit=1',
totalKey: ['total', 'count'],
deltaLabel: 'from last month',
deltaValue: '-3.1%',
deltaUp: false,
href: '/admin/leads',
},
{
id: 'credits-purchased',
label: 'Credits Purchased',
icon: CreditCard,
apiPath: '/api/gateway/api/admin/credits?limit=1',
totalKey: ['total', 'count'],
prefix: '₹',
deltaLabel: 'from last month',
deltaValue: '+18.7%',
deltaUp: true,
href: '/admin/credit',
},
{
id: 'pending-approvals',
label: 'Pending Approvals',
icon: Clock,
apiPath: '/api/gateway/api/admin/approvals?status=pending&limit=1',
totalKey: ['total', 'count'],
deltaLabel: 'action required',
deltaValue: 'Today',
deltaUp: false,
href: '/admin/approval',
},
{
id: 'pending-verifications',
label: 'Pending Verifications',
icon: BadgeCheck,
apiPath: '/api/gateway/api/admin/verifications?status=pending&limit=1',
totalKey: ['total', 'count'],
deltaLabel: 'in queue',
deltaValue: 'Today',
deltaUp: false,
href: '/admin/verification-status',
},
{
id: 'jobs-posted',
label: 'Jobs Posted',
icon: BriefcaseBusiness,
apiPath: '/api/gateway/api/admin/jobs?limit=1',
totalKey: ['total', 'count'],
deltaLabel: 'from last month',
deltaValue: '+5.4%',
deltaUp: true,
href: '/admin/jobs',
},
{
id: 'support-tickets',
label: 'Support Tickets',
icon: HeadphonesIcon,
apiPath: '/api/gateway/api/admin/support?status=open&limit=1',
totalKey: ['total', 'count'],
deltaLabel: 'open tickets',
deltaValue: 'Active',
deltaUp: false,
href: '/admin/support',
},
];
const CHART_DEFS: ChartWidget[] = [
{ id: 'leads-trend', label: 'Leads Trend', subtitle: 'Monthly leads performance overview', type: 'line' },
{ id: 'revenue-overview', label: 'Revenue Overview', subtitle: 'Monthly revenue vs expenses comparison', type: 'bar' },
{ id: 'user-growth', label: 'User Growth', subtitle: 'Monthly new user registrations', type: 'line' },
];
// Default layout
const DEFAULT_STATS = ['total-users', 'active-companies', 'open-leads', 'credits-purchased'];
const DEFAULT_CHARTS = ['leads-trend', 'revenue-overview'];
const STORAGE_KEY = 'nxtgauge_admin_dash_v1';
function loadLayout(): { stats: string[]; charts: string[] } {
if (typeof localStorage === 'undefined') return { stats: DEFAULT_STATS, charts: DEFAULT_CHARTS };
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw) return JSON.parse(raw);
} catch { /* */ }
return { stats: DEFAULT_STATS, charts: DEFAULT_CHARTS };
}
function saveLayout(stats: string[], charts: string[]) {
if (typeof localStorage === 'undefined') return;
localStorage.setItem(STORAGE_KEY, JSON.stringify({ stats, charts }));
}
// ── ApexCharts wrapper ────────────────────────────────────────────────────────
function Chart(props: { id: string; type: 'line' | 'bar'; height?: number }) {
let el!: HTMLDivElement;
let chartInstance: any = null;
// onCleanup must be registered synchronously (outside async) to stay in reactive scope
onCleanup(() => { chartInstance?.destroy(); chartInstance = null; });
onMount(async () => {
const { default: ApexCharts } = await import('apexcharts');
if (!el) return;
const isLine = props.type === 'line';
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun'];
const options: Record<string, any> = {
chart: {
type: props.type,
height: props.height ?? 240,
toolbar: { show: false },
fontFamily: 'Exo 2, sans-serif',
animations: { enabled: true, speed: 400 },
},
grid: {
borderColor: '#f1f5f9',
strokeDashArray: 4,
xaxis: { lines: { show: false } },
yaxis: { lines: { show: true } },
},
xaxis: {
categories: months,
axisBorder: { show: false },
axisTicks: { show: false },
labels: { style: { colors: '#94a3b8', fontSize: '12px' } },
},
tooltip: { theme: 'light', style: { fontFamily: 'Exo 2, sans-serif' } },
legend: { show: false },
...(isLine
? {
series: [{ name: 'Leads', data: [65, 59, 80, 81, 90, 115] }],
stroke: { curve: 'smooth', width: 2.5, colors: ['#fd6216'] },
fill: {
type: 'gradient',
gradient: {
shadeIntensity: 1,
opacityFrom: 0.35,
opacityTo: 0.02,
colorStops: [
{ offset: 0, color: '#fd6216', opacity: 0.35 },
{ offset: 100, color: '#fd6216', opacity: 0 },
],
},
},
markers: { size: 0 },
colors: ['#fd6216'],
yaxis: { labels: { style: { colors: '#94a3b8', fontSize: '12px' } }, min: 0, max: 130, tickAmount: 4 },
}
: {
series: [{ name: 'Revenue (₹)', data: [35000, 41000, 38000, 52000, 61000, 67000] }],
plotOptions: { bar: { borderRadius: 4, columnWidth: '50%' } },
fill: { colors: ['#0a1d37'] },
colors: ['#0a1d37'],
dataLabels: { enabled: false },
yaxis: {
labels: {
style: { colors: '#94a3b8', fontSize: '12px' },
formatter: (v: number) => `${(v / 1000).toFixed(0)}k`,
},
},
}),
};
chartInstance = new ApexCharts(el, options);
await chartInstance.render();
});
return <div ref={el!} />;
}
// ── Stat count fetcher ────────────────────────────────────────────────────────
async function fetchCount(path: string, keys: string[]): Promise<string> {
try {
const res = await fetch(path);
if (!res.ok) return '—';
const data = await res.json();
for (const k of keys) {
if (data[k] !== undefined) return String(Number(data[k]).toLocaleString('en-IN'));
}
return '—';
} catch {
return '—';
}
}
// ── Sortable stat card ────────────────────────────────────────────────────────
function SortableStatCard(props: {
def: StatWidget;
onRemove: () => void;
editMode: boolean;
}) {
const sortable = createSortable(props.def.id);
const [count] = createResource(() => fetchCount(props.def.apiPath, props.def.totalKey));
const Icon = props.def.icon;
return (
<div
// @ts-ignore
use:sortable
class={`relative rounded-2xl border border-gray-200 bg-white p-5 shadow-sm transition-shadow hover:shadow-md ${
sortable.isActiveDraggable ? 'opacity-50 shadow-lg ring-2 ring-orange-300' : ''
}`}
>
{/* Drag handle */}
<Show when={props.editMode}>
<div class="absolute left-2 top-1/2 -translate-y-1/2 cursor-grab text-gray-300 active:cursor-grabbing">
<GripVertical size={16} />
</div>
<button
type="button"
onClick={props.onRemove}
class="absolute -right-2 -top-2 flex h-5 w-5 items-center justify-center rounded-full bg-red-500 text-white shadow-md hover:bg-red-600"
>
<X size={10} />
</button>
</Show>
{/* Top row: icon + delta badge */}
<div class="flex items-start justify-between">
<div class="flex h-12 w-12 items-center justify-center rounded-2xl bg-orange-50">
<Icon size={22} class="text-orange-500" />
</div>
<span
class={`flex items-center gap-1 rounded-full px-2.5 py-1 text-[12px] font-bold ${
props.def.deltaUp
? 'bg-green-50 text-green-600'
: props.def.deltaValue === 'Today' || props.def.deltaValue === 'Active'
? 'bg-blue-50 text-blue-600'
: 'bg-red-50 text-red-500'
}`}
>
<span>{props.def.deltaUp ? '↑' : props.def.deltaValue === 'Today' || props.def.deltaValue === 'Active' ? '' : '↓'}</span>
{props.def.deltaValue}
</span>
</div>
{/* Label */}
<p class="mt-4 text-[13px] font-medium text-gray-500">{props.def.label}</p>
{/* Value */}
<p class="mt-1 text-3xl font-black tracking-tight text-gray-900">
<Show when={!count.loading} fallback={<span class="text-xl text-gray-300">Loading</span>}>
{props.def.prefix ?? ''}{count()}
</Show>
</p>
{/* Delta label */}
<p class="mt-1 text-[12px] text-gray-400">{props.def.deltaLabel}</p>
{/* Link overlay */}
<Show when={!props.editMode}>
<A href={props.def.href} class="absolute inset-0 rounded-2xl" aria-label={props.def.label} />
</Show>
</div>
);
}
// ── Sortable chart card ───────────────────────────────────────────────────────
function SortableChartCard(props: {
def: ChartWidget;
onRemove: () => void;
editMode: boolean;
}) {
const sortable = createSortable(props.def.id);
return (
<div
// @ts-ignore
use:sortable
class={`relative rounded-2xl border border-gray-200 bg-white p-5 shadow-sm ${
sortable.isActiveDraggable ? 'opacity-50 shadow-lg ring-2 ring-orange-300' : ''
}`}
>
<Show when={props.editMode}>
<div class="absolute left-2 top-1/2 -translate-y-1/2 cursor-grab text-gray-300 active:cursor-grabbing">
<GripVertical size={16} />
</div>
<button
type="button"
onClick={props.onRemove}
class="absolute -right-2 -top-2 flex h-5 w-5 items-center justify-center rounded-full bg-red-500 text-white shadow-md hover:bg-red-600"
>
<X size={10} />
</button>
</Show>
<div class="mb-4">
<h3 class="text-[15px] font-bold text-gray-900">{props.def.label}</h3>
<p class="mt-0.5 text-[12px] text-gray-400">{props.def.subtitle}</p>
</div>
<Chart id={props.def.id} type={props.def.type} height={220} />
</div>
);
}
// ── Add Widget Panel ──────────────────────────────────────────────────────────
function AddWidgetPanel(props: {
activeStats: string[];
activeCharts: string[];
onAddStat: (id: string) => void;
onAddChart: (id: string) => void;
onClose: () => void;
}) {
const availableStats = () => STAT_DEFS.filter((d) => !props.activeStats.includes(d.id));
const availableCharts = () => CHART_DEFS.filter((d) => !props.activeCharts.includes(d.id));
return (
<div class="rounded-2xl border border-dashed border-orange-300 bg-orange-50/40 p-5">
<div class="mb-4 flex items-center justify-between">
<h3 class="text-[14px] font-bold text-gray-800">Add Widgets</h3>
<button
type="button"
onClick={props.onClose}
class="text-gray-400 hover:text-gray-600"
>
<X size={16} />
</button>
</div>
<Show when={availableStats().length > 0}>
<p class="mb-2 text-[11px] font-bold uppercase tracking-widest text-gray-400">Stat Cards</p>
<div class="mb-4 flex flex-wrap gap-2">
<For each={availableStats()}>
{(def) => {
const Icon = def.icon;
return (
<button
type="button"
onClick={() => props.onAddStat(def.id)}
class="flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 text-[13px] font-medium text-gray-700 shadow-sm transition-all hover:border-orange-300 hover:bg-orange-50 hover:text-orange-600"
>
<Icon size={14} class="text-orange-400" />
{def.label}
<Plus size={12} class="text-gray-400" />
</button>
);
}}
</For>
</div>
</Show>
<Show when={availableCharts().length > 0}>
<p class="mb-2 text-[11px] font-bold uppercase tracking-widest text-gray-400">Charts</p>
<div class="flex flex-wrap gap-2">
<For each={availableCharts()}>
{(def) => (
<button
type="button"
onClick={() => props.onAddChart(def.id)}
class="flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 text-[13px] font-medium text-gray-700 shadow-sm transition-all hover:border-orange-300 hover:bg-orange-50 hover:text-orange-600"
>
{def.label}
<Plus size={12} class="text-gray-400" />
</button>
)}
</For>
</div>
</Show>
<Show when={availableStats().length === 0 && availableCharts().length === 0}>
<p class="text-[13px] text-gray-400">All widgets are already on your dashboard.</p>
</Show>
</div>
);
}
// ── Dashboard page ────────────────────────────────────────────────────────────
export default function AdminDashboard() {
const layout = loadLayout();
const [statIds, setStatIds] = createSignal<string[]>(layout.stats);
const [chartIds, setChartIds] = createSignal<string[]>(layout.charts);
const [editMode, setEditMode] = createSignal(false);
const [showAdd, setShowAdd] = createSignal(false);
// Persist whenever layout changes
const persist = () => saveLayout(statIds(), chartIds());
const statDefs = () => statIds().map((id) => STAT_DEFS.find((d) => d.id === id)!).filter(Boolean);
const chartDefs = () => chartIds().map((id) => CHART_DEFS.find((d) => d.id === id)!).filter(Boolean);
// ── Stat drag ──────────────────────────────────────────────────────────────
const onStatDragEnd: DragEventHandler = ({ draggable, droppable }) => {
if (!droppable || draggable.id === droppable.id) return;
const ids = [...statIds()];
const from = ids.indexOf(String(draggable.id));
const to = ids.indexOf(String(droppable.id));
if (from < 0 || to < 0) return;
ids.splice(to, 0, ...ids.splice(from, 1));
setStatIds(ids);
persist();
};
// ── Chart drag ─────────────────────────────────────────────────────────────
const onChartDragEnd: DragEventHandler = ({ draggable, droppable }) => {
if (!droppable || draggable.id === droppable.id) return;
const ids = [...chartIds()];
const from = ids.indexOf(String(draggable.id));
const to = ids.indexOf(String(droppable.id));
if (from < 0 || to < 0) return;
ids.splice(to, 0, ...ids.splice(from, 1));
setChartIds(ids);
persist();
};
const removeStatWidget = (id: string) => { setStatIds((p) => p.filter((x) => x !== id)); persist(); };
const removeChartWidget = (id: string) => { setChartIds((p) => p.filter((x) => x !== id)); persist(); };
const addStatWidget = (id: string) => { setStatIds((p) => [...p, id]); persist(); setShowAdd(false); };
const addChartWidget = (id: string) => { setChartIds((p) => [...p, id]); persist(); setShowAdd(false); };
const handleExport = () => window.print();
return (
<AdminShell>
<div class="space-y-6">
{/* ── Page header ── */}
<div class="flex items-start justify-between gap-4">
<div>
<h1 class="text-2xl font-black text-gray-900">Dashboard Overview</h1>
<p class="mt-1 text-[13px] text-gray-500">
Welcome back! Here's what's happening with your platform today.
</p>
</div>
<div class="flex shrink-0 items-center gap-2">
<button
type="button"
onClick={() => { setEditMode((v) => !v); setShowAdd(false); }}
class={`flex items-center gap-2 rounded-xl border px-4 py-2.5 text-[13px] font-semibold transition-all ${
editMode()
? 'border-orange-300 bg-orange-50 text-orange-600'
: 'border-gray-200 bg-white text-gray-700 hover:border-gray-300 hover:bg-gray-50'
}`}
>
{editMode() ? 'Done' : 'Customise'}
</button>
<Show when={editMode()}>
<button
type="button"
onClick={() => setShowAdd((v) => !v)}
class="flex items-center gap-2 rounded-xl border border-dashed border-orange-400 bg-orange-50 px-4 py-2.5 text-[13px] font-semibold text-orange-600 hover:bg-orange-100"
>
<Plus size={15} />
Add Widget
</button>
</Show>
<button
type="button"
onClick={handleExport}
class="flex items-center gap-2 rounded-xl bg-gray-900 px-4 py-2.5 text-[13px] font-semibold text-white transition-colors hover:bg-gray-800"
>
<Download size={15} />
Export Report
</button>
</div>
</div>
{/* ── Add widget panel ── */}
<Show when={showAdd()}>
<AddWidgetPanel
activeStats={statIds()}
activeCharts={chartIds()}
onAddStat={addStatWidget}
onAddChart={addChartWidget}
onClose={() => setShowAdd(false)}
/>
</Show>
{/* ── Stat cards ── */}
<Show when={statDefs().length > 0}>
<DragDropProvider onDragEnd={onStatDragEnd} collisionDetector={closestCenter}>
<DragDropSensors />
<SortableProvider ids={statIds()}>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4">
<For each={statDefs()}>
{(def) => (
<SortableStatCard
def={def}
editMode={editMode()}
onRemove={() => removeStatWidget(def.id)}
/>
)}
</For>
</div>
</SortableProvider>
</DragDropProvider>
</Show>
{/* ── Charts ── */}
<Show when={chartDefs().length > 0}>
<DragDropProvider onDragEnd={onChartDragEnd} collisionDetector={closestCenter}>
<DragDropSensors />
<SortableProvider ids={chartIds()}>
<div class="grid grid-cols-1 gap-4 lg:grid-cols-2">
<For each={chartDefs()}>
{(def) => (
<SortableChartCard
def={def}
editMode={editMode()}
onRemove={() => removeChartWidget(def.id)}
/>
)}
</For>
</div>
</SortableProvider>
</DragDropProvider>
</Show>
{/* Empty state */}
<Show when={statDefs().length === 0 && chartDefs().length === 0}>
<div class="flex flex-col items-center justify-center rounded-2xl border border-dashed border-gray-300 py-20 text-center">
<p class="text-[15px] font-semibold text-gray-500">Your dashboard is empty.</p>
<p class="mt-1 text-[13px] text-gray-400">Click "Customise" then "Add Widget" to get started.</p>
</div>
</Show>
</div>
</AdminShell>
);
}