feat: phase 1+2 — shell redesign + drag-and-drop dashboard
Shell (AdminShell + AdminSidebar): - Logo moved to header-left section, width syncs with sidebar collapse state - Global search bar with debounced multi-module API calls and grouped dropdown - Bell notification icon with badge, gear → /admin/settings, user dropdown with logout - Sidebar: 7 grouped nav sections with dividers, orange left-border active state, removed "Active" badge pill, user info (avatar + name + role) pinned to bottom - Fixed all sidebar labels to match Figma (Employee Management, External Onboarding Management, Users Management, Verification Management) - Added missing sidebar items: Verification, Fitness Trainers, Graphic Designers, Social Media, Video Editors, Catering Services, Applications, Responses Dashboard (admin/index): - Rebuilt to match Figma: "Dashboard Overview" title, Export Report button - 4 stat cards (Total Users, Active Companies, Open Leads, Credits Purchased) with real API fetch, orange icons, delta badges, graceful — fallback - ApexCharts: Leads Trend (orange gradient line) + Revenue Overview (navy bars) - Drag-and-drop widget system via @thisbeyond/solid-dnd — sortable stat cards and chart cards with handles and remove buttons in Customise mode - Add Widget panel shows all available widgets not on dashboard - 8 stat widgets + 3 chart widgets available; layout persists in localStorage Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
801bf293a0
commit
13b428913f
5 changed files with 1172 additions and 394 deletions
21
package-lock.json
generated
21
package-lock.json
generated
|
|
@ -10,6 +10,8 @@
|
|||
"@solidjs/router": "^0.15.0",
|
||||
"@solidjs/start": "^1.3.2",
|
||||
"@tailwindcss/vite": "^4.2.2",
|
||||
"@thisbeyond/solid-dnd": "^0.7.5",
|
||||
"apexcharts": "^5.10.4",
|
||||
"lucide-solid": "^1.0.1",
|
||||
"solid-js": "^1.9.5",
|
||||
"tailwindcss": "^4.2.2",
|
||||
|
|
@ -2650,6 +2652,19 @@
|
|||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
}
|
||||
},
|
||||
"node_modules/@thisbeyond/solid-dnd": {
|
||||
"version": "0.7.5",
|
||||
"resolved": "https://registry.npmjs.org/@thisbeyond/solid-dnd/-/solid-dnd-0.7.5.tgz",
|
||||
"integrity": "sha512-DfI5ff+yYGpK9M21LhYwIPlbP2msKxN2ARwuu6GF8tT1GgNVDTI8VCQvH4TJFoVApP9d44izmAcTh/iTCH2UUw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18.0.0",
|
||||
"pnpm": ">=8.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"solid-js": "^1.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@tybys/wasm-util": {
|
||||
"version": "0.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
|
||||
|
|
@ -3046,6 +3061,12 @@
|
|||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/apexcharts": {
|
||||
"version": "5.10.4",
|
||||
"resolved": "https://registry.npmjs.org/apexcharts/-/apexcharts-5.10.4.tgz",
|
||||
"integrity": "sha512-gt0VUqZ2+mr25ScbUcKZgJr96jKYm4vjOcxEWCEh/E5F4dWqhyo3dBhPRvNNnkKiWxkMd2cBwj3ZYH3rK39fkA==",
|
||||
"license": "SEE LICENSE IN LICENSE"
|
||||
},
|
||||
"node_modules/archiver": {
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmjs.org/archiver/-/archiver-7.0.1.tgz",
|
||||
|
|
|
|||
|
|
@ -14,6 +14,8 @@
|
|||
"@solidjs/router": "^0.15.0",
|
||||
"@solidjs/start": "^1.3.2",
|
||||
"@tailwindcss/vite": "^4.2.2",
|
||||
"@thisbeyond/solid-dnd": "^0.7.5",
|
||||
"apexcharts": "^5.10.4",
|
||||
"lucide-solid": "^1.0.1",
|
||||
"solid-js": "^1.9.5",
|
||||
"tailwindcss": "^4.2.2",
|
||||
|
|
|
|||
|
|
@ -1,12 +1,22 @@
|
|||
import { A, useLocation, useNavigate, useSearchParams } from '@solidjs/router';
|
||||
import { For, createEffect, createMemo, createSignal, onCleanup, onMount, type JSX } from 'solid-js';
|
||||
import {
|
||||
For, Show, createEffect, createMemo, createSignal,
|
||||
onCleanup, onMount, type JSX,
|
||||
} from 'solid-js';
|
||||
import AdminSidebar from './AdminSidebar';
|
||||
import { isExternalIdentity } from '~/lib/admin-auth';
|
||||
import { clearAdminSession, hasAdminSession, setAdminSession } from '~/lib/admin-session';
|
||||
import { Bell } from 'lucide-solid';
|
||||
import { Bell, Search, Settings, LogOut, User, ChevronDown } from 'lucide-solid';
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
type Tab = { href: string; label: string; exact?: boolean };
|
||||
|
||||
type SearchResult = { id: string; title: string; subtitle: string; href: string };
|
||||
type SearchGroup = { label: string; viewAllHref: string; results: SearchResult[] };
|
||||
|
||||
// ── Tab sets ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const TAB_SETS: Array<{ prefixes: string[]; tabs: Tab[] }> = [
|
||||
{
|
||||
prefixes: ['/admin/roles'],
|
||||
|
|
@ -26,22 +36,373 @@ const TAB_SETS: Array<{ prefixes: string[]; tabs: Tab[] }> = [
|
|||
},
|
||||
];
|
||||
|
||||
// ── Global search ─────────────────────────────────────────────────────────────
|
||||
|
||||
const SEARCH_MODULES = [
|
||||
{
|
||||
label: 'Users',
|
||||
viewAllHref: '/admin/users',
|
||||
api: '/api/gateway/api/admin/users',
|
||||
listKeys: ['users', 'items'],
|
||||
titleKeys: ['full_name', 'name'],
|
||||
subtitleKeys: ['email', 'phone'],
|
||||
detailBase: '/admin/users',
|
||||
},
|
||||
{
|
||||
label: 'Companies',
|
||||
viewAllHref: '/admin/company',
|
||||
api: '/api/gateway/api/admin/companies',
|
||||
listKeys: ['companies', 'items'],
|
||||
titleKeys: ['name', 'companyName'],
|
||||
subtitleKeys: ['email', 'phone'],
|
||||
detailBase: '/admin/company',
|
||||
},
|
||||
{
|
||||
label: 'Employees',
|
||||
viewAllHref: '/admin/employees',
|
||||
api: '/api/gateway/api/admin/employees',
|
||||
listKeys: ['employees', 'items'],
|
||||
titleKeys: ['full_name', 'name'],
|
||||
subtitleKeys: ['email', 'department_name'],
|
||||
detailBase: '/admin/employees',
|
||||
},
|
||||
{
|
||||
label: 'Jobs',
|
||||
viewAllHref: '/admin/jobs',
|
||||
api: '/api/gateway/api/admin/jobs',
|
||||
listKeys: ['jobs', 'items'],
|
||||
titleKeys: ['title', 'name'],
|
||||
subtitleKeys: ['status', 'company_name'],
|
||||
detailBase: '/admin/jobs',
|
||||
},
|
||||
{
|
||||
label: 'Leads',
|
||||
viewAllHref: '/admin/leads',
|
||||
api: '/api/gateway/api/admin/leads',
|
||||
listKeys: ['leads', 'items'],
|
||||
titleKeys: ['name', 'full_name'],
|
||||
subtitleKeys: ['email', 'status'],
|
||||
detailBase: '/admin/leads',
|
||||
},
|
||||
{
|
||||
label: 'Candidates',
|
||||
viewAllHref: '/admin/candidate',
|
||||
api: '/api/gateway/api/admin/candidates',
|
||||
listKeys: ['candidates', 'items'],
|
||||
titleKeys: ['full_name', 'name'],
|
||||
subtitleKeys: ['email', 'phone'],
|
||||
detailBase: '/admin/candidate',
|
||||
},
|
||||
];
|
||||
|
||||
function pickStr(obj: Record<string, any>, keys: string[]): string {
|
||||
for (const k of keys) if (obj[k]) return String(obj[k]);
|
||||
return '—';
|
||||
}
|
||||
|
||||
function extractList(data: any, keys: string[]): any[] {
|
||||
if (Array.isArray(data)) return data;
|
||||
for (const k of keys) if (Array.isArray(data[k])) return data[k];
|
||||
return [];
|
||||
}
|
||||
|
||||
function GlobalSearch() {
|
||||
const [query, setQuery] = createSignal('');
|
||||
const [open, setOpen] = createSignal(false);
|
||||
const [groups, setGroups] = createSignal<SearchGroup[]>([]);
|
||||
const [searching, setSearching] = createSignal(false);
|
||||
let wrapRef!: HTMLDivElement;
|
||||
let timer: ReturnType<typeof setTimeout>;
|
||||
|
||||
const doSearch = async (q: string) => {
|
||||
const trimmed = q.trim();
|
||||
if (trimmed.length < 2) { setGroups([]); setOpen(false); return; }
|
||||
setSearching(true);
|
||||
const settled = await Promise.allSettled(
|
||||
SEARCH_MODULES.map(async (mod) => {
|
||||
const res = await fetch(
|
||||
`${mod.api}?search=${encodeURIComponent(trimmed)}&limit=4`,
|
||||
).catch(() => null);
|
||||
if (!res?.ok) return null;
|
||||
const data = await res.json().catch(() => null);
|
||||
if (!data) return null;
|
||||
const items = extractList(data, mod.listKeys).slice(0, 4);
|
||||
if (!items.length) return null;
|
||||
return {
|
||||
label: mod.label,
|
||||
viewAllHref: mod.viewAllHref,
|
||||
results: items.map((item: any) => ({
|
||||
id: item.id,
|
||||
title: pickStr(item, mod.titleKeys),
|
||||
subtitle: pickStr(item, mod.subtitleKeys),
|
||||
href: `${mod.detailBase}/${item.id}`,
|
||||
})),
|
||||
} satisfies SearchGroup;
|
||||
}),
|
||||
);
|
||||
setGroups(settled.flatMap((r) => (r.status === 'fulfilled' && r.value ? [r.value] : [])));
|
||||
setOpen(true);
|
||||
setSearching(false);
|
||||
};
|
||||
|
||||
const handleInput = (val: string) => {
|
||||
setQuery(val);
|
||||
clearTimeout(timer);
|
||||
if (val.trim().length < 2) { setGroups([]); setOpen(false); return; }
|
||||
timer = setTimeout(() => doSearch(val), 400);
|
||||
};
|
||||
|
||||
const close = () => { setOpen(false); setQuery(''); setGroups([]); };
|
||||
|
||||
const onOutside = (e: MouseEvent) => {
|
||||
if (!wrapRef.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
onMount(() => document.addEventListener('mousedown', onOutside));
|
||||
onCleanup(() => document.removeEventListener('mousedown', onOutside));
|
||||
|
||||
return (
|
||||
<div ref={wrapRef!} class="relative flex-1 max-w-[480px]">
|
||||
{/* Search icon */}
|
||||
<Search
|
||||
size={15}
|
||||
class="pointer-events-none absolute left-3.5 top-1/2 -translate-y-1/2 text-gray-400"
|
||||
/>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search for anything..."
|
||||
value={query()}
|
||||
onInput={(e) => handleInput(e.currentTarget.value)}
|
||||
onFocus={() => groups().length > 0 && setOpen(true)}
|
||||
onKeyDown={(e) => e.key === 'Escape' && close()}
|
||||
class="h-10 w-full rounded-xl border border-gray-200 bg-gray-50 pl-10 pr-4 text-sm text-gray-700 placeholder:text-gray-400 transition-colors focus:border-gray-300 focus:bg-white focus:outline-none"
|
||||
/>
|
||||
|
||||
{/* Results dropdown */}
|
||||
<Show when={open() && groups().length > 0}>
|
||||
<div class="absolute left-0 right-0 top-[calc(100%+6px)] z-[200] max-h-[500px] overflow-y-auto rounded-xl border border-gray-200 bg-white shadow-2xl shadow-black/10">
|
||||
<For each={groups()}>
|
||||
{(group) => (
|
||||
<div class="border-b border-gray-100 last:border-0">
|
||||
<div class="flex items-center justify-between px-4 pt-3 pb-1.5">
|
||||
<span class="text-[10px] font-black uppercase tracking-widest text-gray-400">
|
||||
{group.label}
|
||||
</span>
|
||||
<A
|
||||
href={group.viewAllHref}
|
||||
onClick={close}
|
||||
class="text-[11px] font-semibold text-orange-500 hover:text-orange-600"
|
||||
>
|
||||
View all →
|
||||
</A>
|
||||
</div>
|
||||
<For each={group.results}>
|
||||
{(item) => (
|
||||
<A
|
||||
href={item.href}
|
||||
onClick={close}
|
||||
class="flex items-center gap-3 px-4 py-2.5 transition-colors hover:bg-gray-50"
|
||||
>
|
||||
<div class="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-orange-100 text-sm font-bold text-orange-600">
|
||||
{item.title.trim().slice(0, 1).toUpperCase()}
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<p class="truncate text-sm font-semibold text-gray-800">{item.title}</p>
|
||||
<p class="truncate text-xs text-gray-500">{item.subtitle}</p>
|
||||
</div>
|
||||
</A>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* No results */}
|
||||
<Show when={open() && !searching() && query().trim().length >= 2 && groups().length === 0}>
|
||||
<div class="absolute left-0 right-0 top-[calc(100%+6px)] z-[200] rounded-xl border border-gray-200 bg-white px-4 py-6 text-center shadow-2xl shadow-black/10">
|
||||
<p class="text-sm text-gray-500">
|
||||
No results for "<span class="font-semibold">{query()}</span>"
|
||||
</p>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Notification bell ─────────────────────────────────────────────────────────
|
||||
|
||||
function NotificationBell(props: { count: number }) {
|
||||
const [open, setOpen] = createSignal(false);
|
||||
let ref!: HTMLDivElement;
|
||||
|
||||
const onOutside = (e: MouseEvent) => {
|
||||
if (!ref.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
onMount(() => document.addEventListener('mousedown', onOutside));
|
||||
onCleanup(() => document.removeEventListener('mousedown', onOutside));
|
||||
|
||||
return (
|
||||
<div ref={ref!} class="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
aria-label="Notifications"
|
||||
class="relative flex h-9 w-9 items-center justify-center rounded-full text-gray-500 transition-colors hover:bg-gray-100"
|
||||
>
|
||||
<Bell size={19} />
|
||||
<Show when={props.count > 0}>
|
||||
<span class="absolute right-1.5 top-1.5 flex h-2.5 w-2.5 items-center justify-center rounded-full bg-orange-500 ring-2 ring-white" />
|
||||
</Show>
|
||||
</button>
|
||||
|
||||
<Show when={open()}>
|
||||
<div class="absolute right-0 top-[calc(100%+6px)] z-[200] w-80 rounded-xl border border-gray-200 bg-white shadow-2xl shadow-black/10">
|
||||
<div class="flex items-center justify-between border-b border-gray-100 px-4 py-3">
|
||||
<span class="text-[13px] font-bold text-gray-800">Notifications</span>
|
||||
<Show when={props.count > 0}>
|
||||
<span class="rounded-full bg-orange-100 px-2 py-0.5 text-[11px] font-bold text-orange-600">
|
||||
{props.count} new
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="flex flex-col items-center justify-center py-10 text-center">
|
||||
<Bell size={28} class="mb-3 text-gray-300" />
|
||||
<p class="text-sm font-medium text-gray-500">Real-time notifications</p>
|
||||
<p class="mt-1 text-xs text-gray-400">Connecting to live feed…</p>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── User menu ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function UserMenu(props: { name: string; initials: string; onLogout: () => void }) {
|
||||
const [open, setOpen] = createSignal(false);
|
||||
let ref!: HTMLDivElement;
|
||||
|
||||
const onOutside = (e: MouseEvent) => {
|
||||
if (!ref.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
onMount(() => document.addEventListener('mousedown', onOutside));
|
||||
onCleanup(() => document.removeEventListener('mousedown', onOutside));
|
||||
|
||||
return (
|
||||
<div ref={ref!} class="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
class="flex items-center gap-2.5 rounded-lg px-2 py-1.5 transition-colors hover:bg-gray-50"
|
||||
>
|
||||
<div class="text-right hidden sm:block">
|
||||
<p class="text-[13px] font-semibold leading-tight text-gray-800">{props.name}</p>
|
||||
<p class="text-[11px] leading-tight text-gray-500">Super Admin</p>
|
||||
</div>
|
||||
<div class="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-orange-500 text-sm font-bold text-white">
|
||||
{props.initials}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<Show when={open()}>
|
||||
<div class="absolute right-0 top-[calc(100%+6px)] z-[200] w-48 rounded-xl border border-gray-200 bg-white py-1 shadow-2xl shadow-black/10">
|
||||
<A
|
||||
href="/admin/profile"
|
||||
onClick={() => setOpen(false)}
|
||||
class="flex items-center gap-2.5 px-4 py-2.5 text-sm text-gray-700 transition-colors hover:bg-gray-50"
|
||||
>
|
||||
<User size={15} class="text-gray-400" />
|
||||
My Profile
|
||||
</A>
|
||||
<A
|
||||
href="/admin/settings"
|
||||
onClick={() => setOpen(false)}
|
||||
class="flex items-center gap-2.5 px-4 py-2.5 text-sm text-gray-700 transition-colors hover:bg-gray-50"
|
||||
>
|
||||
<Settings size={15} class="text-gray-400" />
|
||||
Settings
|
||||
</A>
|
||||
<div class="my-1 border-t border-gray-100" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setOpen(false); props.onLogout(); }}
|
||||
class="flex w-full items-center gap-2.5 px-4 py-2.5 text-sm text-red-600 transition-colors hover:bg-red-50"
|
||||
>
|
||||
<LogOut size={15} />
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Tabs ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
function ShowTabs(props: {
|
||||
tabs: Tab[];
|
||||
isTabActive: (tab: Tab) => boolean;
|
||||
setTabsTrackEl: (el: HTMLDivElement) => void;
|
||||
setTabRefs: (fn: (prev: Record<string, HTMLAnchorElement>) => Record<string, HTMLAnchorElement>) => void;
|
||||
tabIndicator: () => { left: number; width: number; ready: boolean };
|
||||
}) {
|
||||
if (props.tabs.length === 0) return null;
|
||||
return (
|
||||
<div
|
||||
ref={props.setTabsTrackEl}
|
||||
class="relative mb-6 flex items-center gap-1 border-b border-gray-200"
|
||||
>
|
||||
<For each={props.tabs}>
|
||||
{(tab) => (
|
||||
<A
|
||||
href={tab.href}
|
||||
ref={(el) => props.setTabRefs((prev) => ({ ...prev, [tab.href]: el }))}
|
||||
aria-current={props.isTabActive(tab) ? 'page' : undefined}
|
||||
class={`px-4 pb-3 pt-3 text-[14px] font-semibold transition-colors ${
|
||||
props.isTabActive(tab)
|
||||
? 'text-[#fd6216]'
|
||||
: 'text-slate-500 hover:text-slate-800'
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</A>
|
||||
)}
|
||||
</For>
|
||||
<div
|
||||
class={`absolute bottom-0 h-[2px] bg-[#fd6216] transition-all duration-300 ease-out ${
|
||||
props.tabIndicator().ready ? 'opacity-100' : 'opacity-0'
|
||||
}`}
|
||||
style={{ left: `${props.tabIndicator().left}px`, width: `${props.tabIndicator().width}px` }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Main shell ────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function AdminShell(props: { children: JSX.Element }) {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
const [checkedSession, setCheckedSession] = createSignal(false);
|
||||
const [adminName, setAdminName] = createSignal('Admin');
|
||||
const [sidebarOpen, setSidebarOpen] = createSignal(false);
|
||||
const [adminName, setAdminName] = createSignal('Admin User');
|
||||
const [sidebarOpen, setSidebarOpen] = createSignal(false);
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = createSignal(false);
|
||||
const [notifCount] = createSignal(0); // wired in Phase 2 (SSE)
|
||||
|
||||
const [tabsTrackEl, setTabsTrackEl] = createSignal<HTMLDivElement>();
|
||||
const [tabRefs, setTabRefs] = createSignal<Record<string, HTMLAnchorElement>>({});
|
||||
const [tabRefs, setTabRefs] = createSignal<Record<string, HTMLAnchorElement>>({});
|
||||
const [tabIndicator, setTabIndicator] = createSignal({ left: 0, width: 0, ready: false });
|
||||
|
||||
// ── Tab logic ──────────────────────────────────────────────────────────────
|
||||
const tabs = createMemo<Tab[]>(() => {
|
||||
const path = location.pathname;
|
||||
for (const set of TAB_SETS) {
|
||||
if (set.prefixes.some((p) => path === p || path.startsWith(`${p}/`))) return set.tabs;
|
||||
if (set.prefixes.some((p) => path === p || path.startsWith(`${p}/`)))
|
||||
return set.tabs;
|
||||
}
|
||||
return [];
|
||||
});
|
||||
|
|
@ -54,31 +415,24 @@ export default function AdminShell(props: { children: JSX.Element }) {
|
|||
const refreshTabIndicator = () => {
|
||||
const activeTab = tabs().find((tab) => isTabActive(tab));
|
||||
const track = tabsTrackEl();
|
||||
if (!activeTab || !track) {
|
||||
setTabIndicator((prev) => ({ ...prev, ready: false }));
|
||||
return;
|
||||
}
|
||||
if (!activeTab || !track) { setTabIndicator((p) => ({ ...p, ready: false })); return; }
|
||||
const el = tabRefs()[activeTab.href];
|
||||
if (!el) return;
|
||||
setTabIndicator({ left: el.offsetLeft, width: el.offsetWidth, ready: true });
|
||||
};
|
||||
|
||||
createEffect(() => {
|
||||
tabs();
|
||||
location.pathname;
|
||||
tabs(); location.pathname;
|
||||
requestAnimationFrame(refreshTabIndicator);
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
const onResize = () => refreshTabIndicator();
|
||||
window.addEventListener('resize', onResize);
|
||||
onCleanup(() => window.removeEventListener('resize', onResize));
|
||||
window.addEventListener('resize', refreshTabIndicator);
|
||||
onCleanup(() => window.removeEventListener('resize', refreshTabIndicator));
|
||||
|
||||
const isLocalDev =
|
||||
typeof window !== 'undefined' &&
|
||||
const isLocalDev = typeof window !== 'undefined' &&
|
||||
(window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1');
|
||||
const isPreview =
|
||||
searchParams._preview === '1' ||
|
||||
const isPreview = searchParams._preview === '1' ||
|
||||
(typeof sessionStorage !== 'undefined' &&
|
||||
sessionStorage.getItem('nxtgauge_admin_preview') === '1');
|
||||
|
||||
|
|
@ -92,15 +446,13 @@ export default function AdminShell(props: { children: JSX.Element }) {
|
|||
|
||||
const verify = async () => {
|
||||
if (!hasAdminSession()) {
|
||||
const from = encodeURIComponent(location.pathname + location.search);
|
||||
navigate(`/login?from=${from}`, { replace: true });
|
||||
navigate(`/login?from=${encodeURIComponent(location.pathname + location.search)}`, { replace: true });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const accessToken =
|
||||
typeof sessionStorage !== 'undefined'
|
||||
? sessionStorage.getItem('nxtgauge_admin_access_token') || ''
|
||||
: '';
|
||||
const accessToken = typeof sessionStorage !== 'undefined'
|
||||
? sessionStorage.getItem('nxtgauge_admin_access_token') || ''
|
||||
: '';
|
||||
const response = await fetch('/api/gateway/users/auth/me', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
|
|
@ -116,11 +468,9 @@ export default function AdminShell(props: { children: JSX.Element }) {
|
|||
setCheckedSession(true);
|
||||
} catch {
|
||||
clearAdminSession();
|
||||
const from = encodeURIComponent(location.pathname + location.search);
|
||||
navigate(`/login?from=${from}`, { replace: true });
|
||||
navigate(`/login?from=${encodeURIComponent(location.pathname + location.search)}`, { replace: true });
|
||||
}
|
||||
};
|
||||
|
||||
void verify();
|
||||
});
|
||||
|
||||
|
|
@ -141,98 +491,110 @@ export default function AdminShell(props: { children: JSX.Element }) {
|
|||
const adminInitials = createMemo(() => {
|
||||
const parts = adminName().split(' ').map((s) => s.trim()).filter(Boolean);
|
||||
if (parts.length === 0) return 'U';
|
||||
if (parts.length === 1) return parts[0].slice(0, 1).toUpperCase();
|
||||
return `${parts[0][0] || ''}`.toUpperCase();
|
||||
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
|
||||
return `${parts[0][0]}${parts[1][0]}`.toUpperCase();
|
||||
});
|
||||
|
||||
const sidebarWidth = () => (sidebarCollapsed() ? 'w-20' : 'w-64');
|
||||
// Sidebar width — synced between header logo section and sidebar
|
||||
const sidebarW = () => (sidebarCollapsed() ? 'w-[72px]' : 'w-64');
|
||||
|
||||
return (
|
||||
<div class="min-h-screen bg-white">
|
||||
|
||||
{/* ── Header ── */}
|
||||
<header class="fixed inset-x-0 top-0 z-50 flex h-16 w-full items-center justify-between border-b border-gray-200 bg-white px-6 shadow-sm">
|
||||
{/* Left: logo + role title */}
|
||||
<div class="flex items-center gap-8">
|
||||
<A href="/admin" class="flex h-10 items-center">
|
||||
<img src="/nxtgauge-logo.png" alt="NXTGAUGE" class="h-9 w-auto object-contain" />
|
||||
<header class="fixed inset-x-0 top-0 z-50 flex h-16 w-full items-center border-b border-gray-200 bg-white">
|
||||
|
||||
{/* Logo section — same width as sidebar */}
|
||||
<div
|
||||
class={`flex shrink-0 items-center justify-center border-r border-gray-200 transition-all duration-300 h-full ${sidebarW()}`}
|
||||
>
|
||||
<A href="/admin">
|
||||
<img
|
||||
src="/nxtgauge-icon.png"
|
||||
alt="Nxtgauge"
|
||||
class="h-8 w-8 object-contain"
|
||||
/>
|
||||
</A>
|
||||
<h1 class="text-base font-semibold text-gray-800">Super Admin</h1>
|
||||
</div>
|
||||
|
||||
{/* Search bar */}
|
||||
<div class="flex flex-1 items-center px-5">
|
||||
<GlobalSearch />
|
||||
</div>
|
||||
|
||||
{/* Mobile menu button */}
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex h-9 w-9 items-center justify-center rounded-lg text-slate-600 hover:bg-slate-100 lg:hidden"
|
||||
class="mr-4 flex h-9 w-9 items-center justify-center rounded-lg text-gray-500 hover:bg-gray-100 lg:hidden"
|
||||
onClick={() => setSidebarOpen((v) => !v)}
|
||||
aria-label="Toggle sidebar"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" d="M4 7h16M4 12h16M4 17h16" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Right: bell + avatar + logout */}
|
||||
<div class="hidden items-center gap-6 lg:flex">
|
||||
{/* Bell */}
|
||||
<div class="relative">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Notifications"
|
||||
class="relative rounded-full p-2 text-gray-500 transition-colors hover:bg-gray-100"
|
||||
>
|
||||
<Bell size={20} />
|
||||
</button>
|
||||
</div>
|
||||
{/* Right: Bell + Gear + User */}
|
||||
<div class="hidden items-center gap-1 pr-4 lg:flex">
|
||||
<NotificationBell count={notifCount()} />
|
||||
|
||||
{/* Avatar + name */}
|
||||
<div class="flex items-center gap-2">
|
||||
<button class="flex items-center gap-3 rounded-lg p-1 pr-2 transition-colors hover:bg-gray-100">
|
||||
<div class="flex h-8 w-8 items-center justify-center overflow-hidden rounded-full border border-orange-200 bg-orange-100 text-sm font-semibold text-orange-700">
|
||||
{adminInitials()}
|
||||
</div>
|
||||
<div class="flex flex-col items-start">
|
||||
<span class="mb-0.5 text-xs font-semibold leading-none text-gray-700">{adminName()}</span>
|
||||
<span class="text-[10px] leading-none text-gray-500">Super Admin</span>
|
||||
</div>
|
||||
</button>
|
||||
<A
|
||||
href="/admin/settings"
|
||||
class="flex h-9 w-9 items-center justify-center rounded-full text-gray-500 transition-colors hover:bg-gray-100"
|
||||
aria-label="Settings"
|
||||
>
|
||||
<Settings size={19} />
|
||||
</A>
|
||||
|
||||
{/* Logout */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onLogout}
|
||||
class="rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-red-50 hover:text-red-600"
|
||||
aria-label="Sign out"
|
||||
>
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="mx-2 h-6 w-px bg-gray-200" />
|
||||
|
||||
<UserMenu
|
||||
name={adminName()}
|
||||
initials={adminInitials()}
|
||||
onLogout={onLogout}
|
||||
/>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* ── Body ── */}
|
||||
{checkedSession() ? (
|
||||
<Show
|
||||
when={checkedSession()}
|
||||
fallback={
|
||||
<div class="fixed inset-0 top-16 flex">
|
||||
<div class={`hidden border-r border-gray-200 bg-white lg:block shrink-0 ${sidebarW()}`} />
|
||||
<main class="flex flex-1 items-center justify-center bg-gray-50">
|
||||
<p class="text-sm text-gray-400">Checking session…</p>
|
||||
</main>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div class="fixed inset-0 top-16 flex">
|
||||
{/* Mobile overlay */}
|
||||
<div
|
||||
class={`absolute inset-0 z-20 bg-black/30 transition-opacity lg:hidden ${sidebarOpen() ? 'pointer-events-auto opacity-100' : 'pointer-events-none opacity-0'}`}
|
||||
class={`absolute inset-0 z-20 bg-black/30 transition-opacity lg:hidden ${
|
||||
sidebarOpen()
|
||||
? 'pointer-events-auto opacity-100'
|
||||
: 'pointer-events-none opacity-0'
|
||||
}`}
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
/>
|
||||
|
||||
{/* Sidebar */}
|
||||
<div
|
||||
class={`absolute inset-y-0 left-0 z-30 -translate-x-full transition-transform duration-200 lg:static lg:translate-x-0 ${sidebarOpen() ? 'translate-x-0' : ''}`}
|
||||
class={`absolute inset-y-0 left-0 z-30 -translate-x-full transition-transform duration-200 lg:static lg:translate-x-0 ${
|
||||
sidebarOpen() ? 'translate-x-0' : ''
|
||||
}`}
|
||||
>
|
||||
<AdminSidebar
|
||||
collapsed={sidebarCollapsed()}
|
||||
onToggle={() => setSidebarCollapsed((v) => !v)}
|
||||
onNavigate={() => setSidebarOpen(false)}
|
||||
onLogout={onLogout}
|
||||
adminName={adminName()}
|
||||
adminInitials={adminInitials()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Main */}
|
||||
{/* Main content */}
|
||||
<main class="scrollbar min-w-0 flex-1 overflow-y-auto bg-gray-50 p-6">
|
||||
<ShowTabs
|
||||
tabs={tabs()}
|
||||
|
|
@ -244,49 +606,7 @@ export default function AdminShell(props: { children: JSX.Element }) {
|
|||
{props.children}
|
||||
</main>
|
||||
</div>
|
||||
) : (
|
||||
<div class="fixed inset-0 top-16 flex">
|
||||
<div class="hidden w-64 border-r border-slate-200 bg-[#fcfcfd] lg:block" />
|
||||
<main class="flex flex-1 items-center justify-center bg-gray-50">
|
||||
<p class="text-sm text-gray-400">Checking session…</p>
|
||||
</main>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ShowTabs(props: {
|
||||
tabs: Tab[];
|
||||
isTabActive: (tab: Tab) => boolean;
|
||||
setTabsTrackEl: (el: HTMLDivElement) => void;
|
||||
setTabRefs: (fn: (prev: Record<string, HTMLAnchorElement>) => Record<string, HTMLAnchorElement>) => void;
|
||||
tabIndicator: () => { left: number; width: number; ready: boolean };
|
||||
}) {
|
||||
if (props.tabs.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div ref={props.setTabsTrackEl} class="relative mb-6 flex items-center gap-1 border-b border-gray-200">
|
||||
<For each={props.tabs}>
|
||||
{(tab) => (
|
||||
<A
|
||||
href={tab.href}
|
||||
ref={(el) => props.setTabRefs((prev) => ({ ...prev, [tab.href]: el }))}
|
||||
aria-current={props.isTabActive(tab) ? 'page' : undefined}
|
||||
class={`px-4 pb-3 pt-3 text-[14px] font-semibold transition-colors ${
|
||||
props.isTabActive(tab)
|
||||
? 'text-[#fd6216]'
|
||||
: 'text-slate-500 hover:text-slate-800'
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</A>
|
||||
)}
|
||||
</For>
|
||||
<div
|
||||
class={`absolute bottom-0 h-[2px] bg-[#fd6216] transition-all duration-300 ease-out ${props.tabIndicator().ready ? 'opacity-100' : 'opacity-0'}`}
|
||||
style={{ left: `${props.tabIndicator().left}px`, width: `${props.tabIndicator().width}px` }}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,79 +1,120 @@
|
|||
import { A, useLocation } from '@solidjs/router';
|
||||
import { For, Show, createSignal } from 'solid-js';
|
||||
import { For, Show } from 'solid-js';
|
||||
import {
|
||||
LayoutGrid, Building2, Briefcase, Users, ShieldCheck, FileText,
|
||||
LayoutDashboard, ClipboardList, UserRoundSearch, UserCircle,
|
||||
Camera, Palette, BookOpen, Code2, BriefcaseBusiness, HandHelping,
|
||||
WalletCards, CreditCard, Tag, Percent, Receipt, ShoppingCart,
|
||||
FileCheck, Star, HeadphonesIcon, BarChart3, BookMarked, Bell,
|
||||
ChevronLeft,
|
||||
ChevronLeft, BadgeCheck, Activity, Film, Utensils, PenTool,
|
||||
MessageSquare, Megaphone,
|
||||
} from 'lucide-solid';
|
||||
|
||||
type Item = {
|
||||
type NavItem = {
|
||||
href: string;
|
||||
label: string;
|
||||
icon: any;
|
||||
aliasPrefix?: string;
|
||||
};
|
||||
|
||||
const items: Item[] = [
|
||||
{ href: '/admin', label: 'Dashboard', icon: LayoutGrid },
|
||||
{ href: '/admin/department', label: 'Department Management', icon: Building2 },
|
||||
{ href: '/admin/designation', label: 'Designation Management', icon: Briefcase },
|
||||
{ href: '/admin/employees', label: 'Internal User Management', icon: Users },
|
||||
{ href: '/admin/roles', label: 'Internal Role Management', icon: ShieldCheck },
|
||||
{ href: '/admin/runtime-roles', label: 'External Role Management', icon: ShieldCheck },
|
||||
{ href: '/admin/onboarding-management', label: 'Onboarding Management', icon: FileText, aliasPrefix: '/admin/onboarding-schemas' },
|
||||
{ href: '/admin/internal-dashboard-management',label: 'Internal Dashboard Management', icon: LayoutDashboard },
|
||||
{ href: '/admin/external-dashboard-management',label: 'External Dashboard Management', icon: LayoutDashboard, aliasPrefix: '/admin/role-ui-configs' },
|
||||
{ href: '/admin/approval', label: 'Approval Management', icon: ClipboardList },
|
||||
{ href: '/admin/users', label: 'External User Management', icon: UserRoundSearch },
|
||||
{ href: '/admin/customer', label: 'Customer Management', icon: UserCircle },
|
||||
{ href: '/admin/company', label: 'Company Management', icon: Building2 },
|
||||
{ href: '/admin/candidate', label: 'Candidate Management', icon: UserCircle },
|
||||
{ href: '/admin/photographer', label: 'Photographer Management', icon: Camera },
|
||||
{ href: '/admin/makeup-artist', label: 'Makeup Artist Management', icon: Palette },
|
||||
{ href: '/admin/tutors', label: 'Tutor Management', icon: BookOpen },
|
||||
{ href: '/admin/developers', label: 'Developer Management', icon: Code2 },
|
||||
{ href: '/admin/jobs', label: 'Jobs Management', icon: BriefcaseBusiness },
|
||||
{ href: '/admin/leads', label: 'Leads Management', icon: HandHelping },
|
||||
{ href: '/admin/pricing', label: 'Pricing Management', icon: WalletCards },
|
||||
{ href: '/admin/credit', label: 'Credit Management', icon: CreditCard },
|
||||
{ href: '/admin/coupon', label: 'Coupon Management', icon: Tag },
|
||||
{ href: '/admin/discount', label: 'Discount Management', icon: Percent },
|
||||
{ href: '/admin/tax', label: 'Tax Management', icon: Receipt },
|
||||
{ href: '/admin/order', label: 'Order Management', icon: ShoppingCart },
|
||||
{ href: '/admin/invoice', label: 'Invoice Management', icon: FileCheck },
|
||||
{ href: '/admin/review', label: 'Review Management', icon: Star },
|
||||
{ href: '/admin/kb', label: 'Knowledge Base Management', icon: BookMarked },
|
||||
{ href: '/admin/notifications', label: 'Notifications', icon: Bell },
|
||||
{ href: '/admin/support', label: 'Support Management', icon: HeadphonesIcon },
|
||||
{ href: '/admin/report', label: 'Report Management', icon: BarChart3 },
|
||||
{ href: '/admin/ledger', label: 'Ledger Management', icon: Receipt },
|
||||
// Groups match Figma sidebar order with dividers between sections
|
||||
const GROUPS: NavItem[][] = [
|
||||
// ── Internal management ──────────────────────────────────────────────────
|
||||
[
|
||||
{ href: '/admin', label: 'Dashboard', icon: LayoutGrid },
|
||||
{ href: '/admin/department', label: 'Department Management', icon: Building2 },
|
||||
{ href: '/admin/designation', label: 'Designation Management', icon: Briefcase },
|
||||
{ href: '/admin/internal-role-management', label: 'Internal Role Management', icon: ShieldCheck },
|
||||
{ href: '/admin/employees', label: 'Employee Management', icon: Users },
|
||||
],
|
||||
// ── External configuration ───────────────────────────────────────────────
|
||||
[
|
||||
{ href: '/admin/runtime-roles', label: 'External Role Management', icon: ShieldCheck, aliasPrefix: '/admin/external-role-management' },
|
||||
{ href: '/admin/onboarding-management', label: 'External Onboarding Management', icon: FileText, aliasPrefix: '/admin/onboarding-schemas' },
|
||||
{ href: '/admin/internal-dashboard-management', label: 'Internal Dashboard Management', icon: LayoutDashboard },
|
||||
{ href: '/admin/external-dashboard-management', label: 'External Dashboard Management', icon: LayoutDashboard, aliasPrefix: '/admin/role-ui-configs' },
|
||||
],
|
||||
// ── Compliance ───────────────────────────────────────────────────────────
|
||||
[
|
||||
{ href: '/admin/verification-status', label: 'Verification Management', icon: BadgeCheck },
|
||||
{ href: '/admin/approval', label: 'Approval Management', icon: ClipboardList },
|
||||
],
|
||||
// ── External users ───────────────────────────────────────────────────────
|
||||
[
|
||||
{ href: '/admin/users', label: 'Users Management', icon: UserRoundSearch },
|
||||
{ href: '/admin/company', label: 'Company Management', icon: Building2 },
|
||||
{ href: '/admin/candidate', label: 'Candidate Management', icon: UserCircle },
|
||||
{ href: '/admin/customer', label: 'Customer Management', icon: UserCircle },
|
||||
{ href: '/admin/photographer', label: 'Photographer Management', icon: Camera },
|
||||
{ href: '/admin/makeup-artist', label: 'Makeup Artist Management', icon: Palette },
|
||||
{ href: '/admin/tutors', label: 'Tutor Management', icon: BookOpen },
|
||||
{ href: '/admin/developers', label: 'Developer Management', icon: Code2 },
|
||||
{ href: '/admin/fitness-trainers', label: 'Fitness Trainer Management', icon: Activity },
|
||||
{ href: '/admin/graphic-designers', label: 'Graphic Designer Management', icon: PenTool },
|
||||
{ href: '/admin/social-media-managers', label: 'Social Media Management', icon: Megaphone },
|
||||
{ href: '/admin/video-editors', label: 'Video Editor Management', icon: Film },
|
||||
{ href: '/admin/catering-services', label: 'Catering Services Management', icon: Utensils },
|
||||
],
|
||||
// ── Business operations ──────────────────────────────────────────────────
|
||||
[
|
||||
{ href: '/admin/jobs', label: 'Jobs Management', icon: BriefcaseBusiness },
|
||||
{ href: '/admin/leads', label: 'Leads Management', icon: HandHelping },
|
||||
{ href: '/admin/applications', label: 'Applications Management', icon: ClipboardList },
|
||||
{ href: '/admin/responses', label: 'Responses Management', icon: MessageSquare },
|
||||
{ href: '/admin/review', label: 'Review Management', icon: Star },
|
||||
],
|
||||
// ── Finance ──────────────────────────────────────────────────────────────
|
||||
[
|
||||
{ href: '/admin/pricing', label: 'Pricing Management', icon: WalletCards },
|
||||
{ href: '/admin/credit', label: 'Credit Management', icon: CreditCard },
|
||||
{ href: '/admin/coupon', label: 'Coupon Management', icon: Tag },
|
||||
{ href: '/admin/discount', label: 'Discount Management', icon: Percent },
|
||||
{ href: '/admin/tax', label: 'Tax Management', icon: Receipt },
|
||||
{ href: '/admin/order', label: 'Order Management', icon: ShoppingCart },
|
||||
{ href: '/admin/invoice', label: 'Invoice Management', icon: FileCheck },
|
||||
{ href: '/admin/ledger', label: 'Ledger Management', icon: Receipt },
|
||||
],
|
||||
// ── Platform operations ──────────────────────────────────────────────────
|
||||
[
|
||||
{ href: '/admin/kb', label: 'Knowledge Base Management', icon: BookMarked },
|
||||
{ href: '/admin/notifications', label: 'Notifications', icon: Bell },
|
||||
{ href: '/admin/support', label: 'Support Management', icon: HeadphonesIcon },
|
||||
{ href: '/admin/report', label: 'Report Management', icon: BarChart3 },
|
||||
],
|
||||
];
|
||||
|
||||
export default function AdminSidebar(props: {
|
||||
collapsed: boolean;
|
||||
onToggle: () => void;
|
||||
onNavigate?: () => void;
|
||||
onLogout?: () => void;
|
||||
adminName: string;
|
||||
adminInitials: string;
|
||||
}) {
|
||||
const location = useLocation();
|
||||
|
||||
const active = (item: Item) => {
|
||||
const isActive = (item: NavItem) => {
|
||||
if (item.href === '/admin') return location.pathname === '/admin';
|
||||
if (item.aliasPrefix && location.pathname.startsWith(item.aliasPrefix)) return true;
|
||||
return location.pathname === item.href || location.pathname.startsWith(`${item.href}/`);
|
||||
};
|
||||
|
||||
return (
|
||||
<aside class={`flex h-full flex-col border-r border-slate-200 bg-[#fcfcfd] transition-all duration-300 ${props.collapsed ? 'w-20' : 'w-64'}`}>
|
||||
{/* Collapse toggle */}
|
||||
<div class="flex justify-end px-3 pt-4 pb-3">
|
||||
<aside
|
||||
class={`flex h-full flex-col border-r border-gray-200 bg-white transition-all duration-300 ${
|
||||
props.collapsed ? 'w-[72px]' : 'w-64'
|
||||
}`}
|
||||
>
|
||||
{/* ── Collapse toggle ── */}
|
||||
<div
|
||||
class={`flex shrink-0 items-center border-b border-gray-100 px-3 py-3 ${
|
||||
props.collapsed ? 'justify-center' : 'justify-end'
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => props.onToggle()}
|
||||
class="rounded-lg p-2 text-slate-500 transition hover:bg-slate-100 hover:text-slate-700"
|
||||
onClick={props.onToggle}
|
||||
title={props.collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
|
||||
class="flex h-7 w-7 items-center justify-center rounded-lg text-gray-400 transition-colors hover:bg-gray-100 hover:text-gray-600"
|
||||
>
|
||||
<ChevronLeft
|
||||
size={16}
|
||||
|
|
@ -82,61 +123,76 @@ export default function AdminSidebar(props: {
|
|||
</button>
|
||||
</div>
|
||||
|
||||
{/* Nav */}
|
||||
<nav class="scrollbar min-h-0 flex-1 space-y-1.5 overflow-y-auto px-3 pb-3">
|
||||
<For each={items}>
|
||||
{(item) => {
|
||||
const isActive = () => active(item);
|
||||
const Icon = item.icon;
|
||||
{/* ── Navigation ── */}
|
||||
<nav class="scrollbar min-h-0 flex-1 overflow-y-auto py-2">
|
||||
<For each={GROUPS}>
|
||||
{(group, gi) => (
|
||||
<>
|
||||
{/* Divider between groups */}
|
||||
<Show when={gi() > 0}>
|
||||
<div class="mx-3 my-2 border-t border-gray-100" />
|
||||
</Show>
|
||||
|
||||
return (
|
||||
<A
|
||||
href={item.href}
|
||||
onClick={() => props.onNavigate?.()}
|
||||
title={props.collapsed ? item.label : undefined}
|
||||
aria-current={isActive() ? 'page' : undefined}
|
||||
class={`group relative flex items-center gap-3 rounded-xl border px-3 py-3 text-[15px] leading-5 transition-all ${
|
||||
props.collapsed ? 'justify-center px-2' : ''
|
||||
} ${
|
||||
isActive()
|
||||
? 'border-orange-200 bg-gradient-to-r from-orange-50 to-orange-100/70 text-slate-900'
|
||||
: 'border-transparent text-slate-500 hover:border-slate-200 hover:bg-white hover:text-slate-800'
|
||||
}`}
|
||||
>
|
||||
{/* Right orange accent bar */}
|
||||
<span
|
||||
class={`absolute right-0 top-2 bottom-2 w-[3px] rounded-l-full bg-orange-500 transition-opacity ${
|
||||
isActive() ? 'opacity-100' : 'opacity-0'
|
||||
}`}
|
||||
/>
|
||||
<div class="px-2">
|
||||
<For each={group}>
|
||||
{(item) => {
|
||||
const active = () => isActive(item);
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<A
|
||||
href={item.href}
|
||||
onClick={props.onNavigate}
|
||||
title={props.collapsed ? item.label : undefined}
|
||||
aria-current={active() ? 'page' : undefined}
|
||||
class={`relative mb-0.5 flex items-center gap-3 rounded-lg py-2.5 text-[13px] font-medium leading-5 transition-all duration-150 ${
|
||||
props.collapsed ? 'justify-center px-2' : 'px-3'
|
||||
} ${
|
||||
active()
|
||||
? 'bg-orange-50 text-gray-900'
|
||||
: 'text-gray-600 hover:bg-gray-50 hover:text-gray-900'
|
||||
}`}
|
||||
>
|
||||
{/* Left orange accent bar on active */}
|
||||
<Show when={active()}>
|
||||
<span class="absolute left-0 top-1.5 bottom-1.5 w-[3px] rounded-r-full bg-orange-500" />
|
||||
</Show>
|
||||
|
||||
<Icon
|
||||
size={18}
|
||||
class={`shrink-0 transition-colors ${
|
||||
isActive() ? 'text-orange-600' : 'text-slate-500 group-hover:text-slate-700'
|
||||
}`}
|
||||
/>
|
||||
<Icon
|
||||
size={17}
|
||||
class={`shrink-0 ${active() ? 'text-orange-500' : 'text-gray-400'}`}
|
||||
/>
|
||||
|
||||
<Show when={!props.collapsed}>
|
||||
<span class={`min-w-0 flex-1 truncate font-medium ${isActive() ? 'text-slate-900' : 'text-slate-600 group-hover:text-slate-800'}`}>
|
||||
{item.label}
|
||||
</span>
|
||||
<Show when={isActive()}>
|
||||
<span class="ml-auto shrink-0 rounded-full border border-orange-200 bg-orange-100 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-orange-700">
|
||||
Active
|
||||
</span>
|
||||
</Show>
|
||||
</Show>
|
||||
|
||||
{/* Collapsed active dot */}
|
||||
<Show when={props.collapsed && isActive()}>
|
||||
<span class="absolute -right-0.5 top-1/2 h-2 w-2 -translate-y-1/2 rounded-full bg-orange-500" />
|
||||
</Show>
|
||||
</A>
|
||||
);
|
||||
}}
|
||||
<Show when={!props.collapsed}>
|
||||
<span class="min-w-0 flex-1 truncate">{item.label}</span>
|
||||
</Show>
|
||||
</A>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</For>
|
||||
</nav>
|
||||
|
||||
{/* ── User info pinned at bottom ── */}
|
||||
<div class="shrink-0 border-t border-gray-100 p-3">
|
||||
<div
|
||||
class={`flex items-center gap-3 rounded-lg px-2 py-2 ${
|
||||
props.collapsed ? 'justify-center' : ''
|
||||
}`}
|
||||
>
|
||||
<div class="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-orange-500 text-sm font-bold text-white">
|
||||
{props.adminInitials}
|
||||
</div>
|
||||
<Show when={!props.collapsed}>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="truncate text-[13px] font-semibold text-gray-800">{props.adminName}</p>
|
||||
<p class="text-[11px] text-gray-500">Super Admin</p>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,209 +1,588 @@
|
|||
import {
|
||||
For, Show, createResource, createSignal, onCleanup, onMount,
|
||||
} from 'solid-js';
|
||||
import { A } from '@solidjs/router';
|
||||
import AdminShell from '~/components/AdminShell';
|
||||
import {
|
||||
Users, Building2, Briefcase, Clock, Ticket, Receipt,
|
||||
Package, Megaphone, Shield, UserCog, FormInput, LayoutDashboard,
|
||||
BadgeCheck, ArrowRight,
|
||||
Users, Building2, TrendingUp, CreditCard, Clock, BadgeCheck,
|
||||
BriefcaseBusiness, HeadphonesIcon, Download, Plus, X, GripVertical,
|
||||
} from 'lucide-solid';
|
||||
import type { Component } from 'solid-js';
|
||||
import {
|
||||
DragDropProvider, DragDropSensors, SortableProvider,
|
||||
createSortable, closestCenter, type DragEventHandler,
|
||||
} from '@thisbeyond/solid-dnd';
|
||||
|
||||
type KpiCard = {
|
||||
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
type StatWidget = {
|
||||
id: string;
|
||||
label: string;
|
||||
value: string;
|
||||
badge?: string;
|
||||
badgeColor: string;
|
||||
icon: any;
|
||||
apiPath: string;
|
||||
totalKey: string[];
|
||||
prefix?: string;
|
||||
deltaLabel: string;
|
||||
deltaValue: string;
|
||||
deltaUp: boolean;
|
||||
href: string;
|
||||
icon: Component<any>;
|
||||
};
|
||||
|
||||
const KPI: KpiCard[] = [
|
||||
{ label: 'Total Users', value: '—', badge: '+0%', badgeColor: 'text-emerald-700 bg-emerald-50', href: '/admin/users', icon: Users },
|
||||
{ label: 'Total Revenue', value: '—', badge: 'Weekly', badgeColor: 'text-[#0a1d37] bg-slate-100', href: '/admin/invoice', icon: Receipt },
|
||||
{ label: 'Active Roles', value: '—', badge: 'Live', badgeColor: 'text-amber-700 bg-amber-50', href: '/admin/runtime-roles', icon: Briefcase},
|
||||
{ label: 'Pending Approvals', value: '—', badge: 'Action Required', badgeColor: 'text-white bg-red-500', href: '/admin/approval', icon: Clock },
|
||||
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 ACTIVITY = [
|
||||
{ day: 'MON', h: 40 }, { day: 'TUE', h: 65 }, { day: 'WED', h: 95 },
|
||||
{ day: 'THU', h: 70 }, { day: 'FRI', h: 55 }, { day: 'SAT', h: 30 }, { day: 'SUN', h: 45 },
|
||||
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' },
|
||||
];
|
||||
|
||||
const QUICK_ACTIONS = [
|
||||
{ label: 'Create New Role', href: '/admin/roles/create', icon: Shield },
|
||||
{ label: 'Configure Dashboard', href: '/admin/external-dashboard-management', icon: LayoutDashboard },
|
||||
{ label: 'Add Employee', href: '/admin/employees', icon: Users },
|
||||
];
|
||||
// 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';
|
||||
|
||||
const PIPELINE = [
|
||||
{ name: 'Candidate Roles', type: 'External Role', status: 'Active', statusColor: 'text-emerald-700 bg-emerald-50', progress: 85, barColor: 'bg-[#0a1d37]' },
|
||||
{ name: 'Onboarding Flows', type: 'Schema Builder', status: 'Pending', statusColor: 'text-amber-700 bg-amber-50', progress: 42, barColor: 'bg-amber-400' },
|
||||
{ name: 'Dashboard Config', type: 'UI Config', status: 'Draft', statusColor: 'text-slate-500 bg-slate-100', progress: 12, barColor: 'bg-slate-300' },
|
||||
];
|
||||
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 };
|
||||
}
|
||||
|
||||
const CONTROL: Array<{ label: string; href: string; desc: string; icon: Component<any>; iconBg: string; iconFg: string }> = [
|
||||
{ label: 'Internal Roles', href: '/admin/roles', desc: 'Permissions & access levels for internal staff.', icon: Shield, iconBg: 'bg-blue-50', iconFg: 'text-blue-600' },
|
||||
{ label: 'External Roles', href: '/admin/runtime-roles', desc: 'Modules, credits & capabilities per external role.', icon: UserCog, iconBg: 'bg-violet-50', iconFg: 'text-violet-600' },
|
||||
{ label: 'Onboarding Flows', href: '/admin/onboarding-schemas', desc: 'Schema-driven onboarding forms per external role.', icon: FormInput, iconBg: 'bg-amber-50', iconFg: 'text-amber-600' },
|
||||
{ label: 'External Dashboards', href: '/admin/external-dashboard-management', desc: 'Sidebar, widgets & runtimeConfig per role.', icon: LayoutDashboard, iconBg: 'bg-teal-50', iconFg: 'text-teal-600' },
|
||||
{ label: 'Internal Dashboards', href: '/admin/internal-dashboard-management', desc: 'Home widgets & KPI panels for internal staff.', icon: LayoutDashboard, iconBg: 'bg-sky-50', iconFg: 'text-sky-600' },
|
||||
{ label: 'Approval Queue', href: '/admin/approval', desc: 'Review, approve or reject pending action requests.', icon: BadgeCheck, iconBg: 'bg-rose-50', iconFg: 'text-rose-600' },
|
||||
];
|
||||
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">
|
||||
|
||||
{/* ── KPI Cards ── */}
|
||||
<section class="rounded-xl border border-slate-200/70 bg-white p-5 shadow-[0_4px_16px_-8px_rgba(10,29,55,0.18)]">
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<h2 class="text-[15px] font-black tracking-tight text-[#0a1d37]">Quick Stats</h2>
|
||||
<span class="rounded-md bg-[#0a1d37]/8 px-2.5 py-1 text-[10px] font-bold uppercase tracking-[0.14em] text-[#0a1d37]">Live Overview</span>
|
||||
{/* ── 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="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||
{KPI.map((kpi) => {
|
||||
const Icon = kpi.icon;
|
||||
return (
|
||||
<A
|
||||
href={kpi.href}
|
||||
class="relative overflow-hidden rounded-lg border border-slate-200/70 bg-[#f9fafc] p-4 transition-all duration-200 hover:-translate-y-px hover:border-[#0a1d37]/20 hover:shadow-[0_8px_20px_-12px_rgba(10,29,55,0.35)]"
|
||||
<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"
|
||||
>
|
||||
{/* Left navy accent bar */}
|
||||
<div class="absolute left-0 top-0 h-full w-1 rounded-l-lg bg-[#0a1d37]" />
|
||||
<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>
|
||||
|
||||
<div class="flex items-start justify-between pl-2">
|
||||
<div class="flex h-8 w-8 items-center justify-center rounded-md bg-[#0a1d37]/8">
|
||||
<Icon class="h-4 w-4 text-[#0a1d37]" />
|
||||
</div>
|
||||
{kpi.badge && (
|
||||
<span class={`rounded px-1.5 py-0.5 text-[10px] font-bold ${kpi.badgeColor}`}>
|
||||
{kpi.badge}
|
||||
</span>
|
||||
{/* ── 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)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div class="mt-3 pl-2">
|
||||
<h3 class="text-[10px] font-bold uppercase tracking-widest text-slate-400">
|
||||
{kpi.label}
|
||||
</h3>
|
||||
<p class="mt-1 text-2xl font-black tabular-nums tracking-tight text-[#0a1d37]">
|
||||
{kpi.value}
|
||||
</p>
|
||||
</div>
|
||||
</A>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── Activity + Intelligence Hub ── */}
|
||||
<div class="grid grid-cols-1 gap-4 lg:grid-cols-3">
|
||||
|
||||
{/* Activity Chart */}
|
||||
<div class="lg:col-span-2 rounded-xl border border-slate-200/70 bg-white p-5 shadow-[0_4px_16px_-8px_rgba(10,29,55,0.18)]">
|
||||
<div class="mb-5 flex items-end justify-between">
|
||||
<div>
|
||||
<h2 class="text-[15px] font-black tracking-tight text-[#0a1d37]">System Activity</h2>
|
||||
<p class="mt-0.5 text-[12px] text-slate-400">Platform traffic & Tracecoin velocity</p>
|
||||
</For>
|
||||
</div>
|
||||
<select class="rounded-md border border-slate-200 bg-white py-1.5 pl-3 pr-7 text-[11px] font-semibold text-[#0a1d37] focus:outline-none focus:ring-2 focus:ring-[#0a1d37]/10">
|
||||
<option>Last 7 Days</option>
|
||||
<option>Last 30 Days</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex h-40 items-end justify-between gap-2">
|
||||
{ACTIVITY.map((d) => (
|
||||
<div class="group flex flex-1 flex-col items-center gap-1.5">
|
||||
<div
|
||||
class={`w-full rounded-t transition-all duration-200 ${d.day === 'WED' ? 'bg-[#0a1d37] shadow-md shadow-[#0a1d37]/20' : 'bg-slate-100 group-hover:bg-slate-200'}`}
|
||||
style={`height: ${d.h}%`}
|
||||
/>
|
||||
<span class={`text-[10px] font-bold ${d.day === 'WED' ? 'text-[#0a1d37]' : 'text-slate-400'}`}>
|
||||
{d.day}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</SortableProvider>
|
||||
</DragDropProvider>
|
||||
</Show>
|
||||
|
||||
{/* Intelligence Hub + Pipeline */}
|
||||
<div class="space-y-3">
|
||||
{/* Intelligence Hub */}
|
||||
<div class="relative overflow-hidden rounded-xl bg-[#0a1d37] p-4 text-white shadow-[0_8px_24px_-10px_rgba(10,29,55,0.55)]">
|
||||
<div class="absolute -right-4 -top-4 h-20 w-20 rounded-full bg-white/5 blur-2xl" />
|
||||
<h2 class="mb-3 text-[13px] font-black">Intelligence Hub</h2>
|
||||
<div class="space-y-1.5">
|
||||
{QUICK_ACTIONS.map((a) => {
|
||||
const Icon = a.icon;
|
||||
return (
|
||||
<A
|
||||
href={a.href}
|
||||
class="group flex w-full items-center justify-between rounded-md bg-white/10 px-3 py-2.5 transition-all hover:bg-white/15"
|
||||
>
|
||||
<span class="text-[12.5px] font-semibold">{a.label}</span>
|
||||
<Icon class="h-3.5 w-3.5 text-white/60 transition-transform group-hover:translate-x-0.5" />
|
||||
</A>
|
||||
);
|
||||
})}
|
||||
{/* ── 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>
|
||||
</div>
|
||||
</SortableProvider>
|
||||
</DragDropProvider>
|
||||
</Show>
|
||||
|
||||
{/* Pipeline Status */}
|
||||
<div class="rounded-xl border border-slate-200/70 bg-white p-4 shadow-[0_4px_16px_-8px_rgba(10,29,55,0.18)]">
|
||||
<h2 class="mb-3 text-[10px] font-bold uppercase tracking-widest text-[#0a1d37]">
|
||||
Pipeline Status
|
||||
</h2>
|
||||
<div class="space-y-3.5">
|
||||
{PIPELINE.map((p) => (
|
||||
<div>
|
||||
<div class="mb-1.5 flex items-center justify-between gap-2">
|
||||
<div class="min-w-0">
|
||||
<p class="truncate text-[12.5px] font-bold text-[#0a1d37]">{p.name}</p>
|
||||
<p class="text-[10px] text-slate-400">{p.type}</p>
|
||||
</div>
|
||||
<span class={`shrink-0 rounded px-1.5 py-0.5 text-[10px] font-bold uppercase ${p.statusColor}`}>
|
||||
{p.status}
|
||||
</span>
|
||||
</div>
|
||||
<div class="h-1 w-full overflow-hidden rounded-full bg-slate-100">
|
||||
<div class={`h-full rounded-full ${p.barColor}`} style={`width: ${p.progress}%`} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{/* 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>
|
||||
</div>
|
||||
|
||||
{/* ── Control Plane ── */}
|
||||
<div>
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<h2 class="text-[15px] font-black tracking-tight text-[#0a1d37]">Control Plane</h2>
|
||||
<span class="rounded border border-slate-200 bg-white px-2 py-0.5 text-[10px] font-bold uppercase tracking-widest text-slate-400">
|
||||
{CONTROL.length} modules
|
||||
</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{CONTROL.map((item) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<A
|
||||
href={item.href}
|
||||
class="group flex items-start gap-3 rounded-lg border border-slate-200/70 bg-white p-3.5 shadow-[0_2px_8px_-4px_rgba(10,29,55,0.18)] transition-all duration-200 hover:-translate-y-px hover:border-[#0a1d37]/20 hover:shadow-[0_8px_20px_-8px_rgba(10,29,55,0.28)]"
|
||||
>
|
||||
<div class={`mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-md ${item.iconBg}`}>
|
||||
<Icon class={`h-3.5 w-3.5 ${item.iconFg}`} />
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="truncate text-[13px] font-bold text-[#0a1d37] transition-colors group-hover:text-[#0a1d37]">{item.label}</p>
|
||||
<p class="mt-0.5 text-[11px] leading-relaxed text-slate-400">{item.desc}</p>
|
||||
</div>
|
||||
<ArrowRight class="mt-0.5 h-3.5 w-3.5 shrink-0 text-slate-200 transition-all group-hover:translate-x-0.5 group-hover:text-[#0a1d37]" />
|
||||
</A>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
</div>
|
||||
</AdminShell>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue