nxtgauge-admin-solid/src/routes/admin/index.tsx
2026-04-26 23:58:42 +02:00

655 lines
24 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 {
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 = "";
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: "live",
type: "summary",
statusLabel: "Live Data",
subtitle: "Powered by USER_MANAGEMENT",
},
kpi_active_companies: {
state: "live",
type: "summary",
statusLabel: "Live Data",
subtitle: "Powered by COMPANY_MANAGEMENT",
},
kpi_open_leads: {
state: "live",
type: "summary",
statusLabel: "Live Data",
subtitle: "Powered by REQUIREMENTS_MANAGEMENT",
},
kpi_pending_approvals: {
state: "live",
type: "summary",
statusLabel: "Live Data",
subtitle: "Powered by APPROVAL_MANAGEMENT",
},
kpi_total_revenue: {
state: "live",
type: "summary",
statusLabel: "Live Data",
subtitle: "Powered by REVENUE_LEDGER",
},
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: "live",
type: "analytics",
statusLabel: "Live Data",
subtitle: "Weekly leads performance overview • Powered by REPORTS",
},
chart_revenue_overview: {
state: "live",
type: "analytics",
statusLabel: "Live Data",
subtitle: "Weekly revenue overview • Powered by REVENUE_LEDGER",
},
};
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} />;
if (widgetKey.includes("approvals")) return <CircleDashed 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_pending_approvals: "approvals",
kpi_total_revenue: "revenue",
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" };
}
if (metrics.loading) return { state: "pending", statusLabel: "Loading..." };
const m = metrics();
if (key === "chart_leads_trend") {
const data = m?.trend_series;
if (data && data.length > 0)
return { state: "live", statusLabel: "Live Data", data: { trend_series: data } };
return { state: "empty", statusLabel: "No Data" };
}
if (key === "chart_revenue_overview") {
const data = m?.rev_series;
if (data && data.length > 0)
return { state: "live", statusLabel: "Live Data", data: { rev_series: data } };
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 (
<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>
);
}