nxtgauge-admin-solid/src/components/AdminShell.tsx

657 lines
30 KiB
TypeScript
Raw Normal View History

import { A, useLocation, useNavigate, useSearchParams } from '@solidjs/router';
import {
For, Show, createEffect, createMemo, createSignal,
onCleanup, onMount, type JSX,
} from 'solid-js';
import { Bell, Moon, Search, Settings, Sun, User } from 'lucide-solid';
import AdminSidebar from './AdminSidebar';
import { isExternalIdentity } from '~/lib/admin-auth';
import { clearAdminSession, hasAdminSession, setAdminSession } from '~/lib/admin-session';
import { normalizeAllowedModules } from '~/lib/admin/module-access';
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[] };
const PAGE_TITLES: Array<{ prefix: string; label: string; exact?: boolean }> = [
{ prefix: '/admin', label: 'Dashboard', exact: true },
{ prefix: '/admin/department', label: 'Department Management' },
{ prefix: '/admin/designation', label: 'Designation Management' },
{ prefix: '/admin/roles', label: 'Internal Role Management' },
{ prefix: '/admin/employees', label: 'Employee Management' },
{ prefix: '/admin/external-roles', label: 'External Role Management' },
{ prefix: '/admin/internal-dashboard-management', label: 'Internal Dashboard Management' },
{ prefix: '/admin/external-dashboard-management', label: 'External Dashboard Management' },
{ prefix: '/admin/role-ui-configs', label: 'External Dashboard Management' },
{ prefix: '/admin/verification', label: 'Verification Management' },
{ prefix: '/admin/verification-status', label: 'Verification Management' },
{ prefix: '/admin/approval', label: 'Approval Management' },
{ prefix: '/admin/approvals', label: 'Approval Management' },
{ prefix: '/admin/approval-management', label: 'Approval Management' },
{ prefix: '/admin/users', label: 'Users Management' },
{ prefix: '/admin/company', label: 'Company Management' },
{ prefix: '/admin/candidate', label: 'Candidate Management' },
{ prefix: '/admin/customer', label: 'Customer Management' },
{ prefix: '/admin/photographer', label: 'Photographer Management' },
{ prefix: '/admin/makeup-artist', label: 'Makeup Artist Management' },
{ prefix: '/admin/tutors', label: 'Tutors Management' },
{ prefix: '/admin/developers', label: 'Developers Management' },
{ prefix: '/admin/video-editors', label: 'Video Editor Management' },
{ prefix: '/admin/fitness-trainers', label: 'Fitness Trainer Management' },
{ prefix: '/admin/catering-services', label: 'Catering Services Management' },
{ prefix: '/admin/ugc-content-creators', label: 'UGC Content Creator Management' },
{ prefix: '/admin/graphic-designers', label: 'Graphic Designer Management' },
{ prefix: '/admin/social-media-managers', label: 'Social Media Manager Management' },
{ prefix: '/admin/jobs', label: 'Jobs Management' },
{ prefix: '/admin/leads', label: 'Leads Management' },
{ prefix: '/admin/applications', label: 'Applications Management' },
{ prefix: '/admin/responses', label: 'Responses Management' },
{ prefix: '/admin/pricing', label: 'Pricing Management' },
{ prefix: '/admin/credit', label: 'Credit Management' },
{ prefix: '/admin/coupon', label: 'Coupon Management' },
{ prefix: '/admin/discount', label: 'Discount Management' },
{ prefix: '/admin/tax', label: 'Tax Management' },
{ prefix: '/admin/order', label: 'Order Management' },
{ prefix: '/admin/invoice', label: 'Invoice Management' },
{ prefix: '/admin/payment-gateway', label: 'Payment Gateway Management' },
{ prefix: '/admin/smtp', label: 'SMTP Management' },
{ prefix: '/admin/kb', label: 'Knowledge Base Management' },
{ prefix: '/admin/notifications', label: 'Notifications' },
{ prefix: '/admin/review', label: 'Review Management' },
{ prefix: '/admin/support', label: 'Support Management' },
{ prefix: '/admin/report', label: 'Report Management' },
{ prefix: '/admin/ledger', label: 'Ledger Management' },
];
const TAB_SETS: Array<{ prefixes: string[]; tabs: Tab[] }> = [];
const ROUTE_MODULE_KEYS: Array<{ prefix: string; keys: string[] }> = [
{ prefix: '/admin', keys: ['ADMIN_DASHBOARD', 'DASHBOARD'] },
{ prefix: '/admin/department', keys: ['DEPARTMENT_MANAGEMENT', 'DEPARTMENTS'] },
{ prefix: '/admin/department-management', keys: ['DEPARTMENT_MANAGEMENT', 'DEPARTMENTS'] },
{ prefix: '/admin/designation', keys: ['DESIGNATION_MANAGEMENT', 'DESIGNATIONS'] },
{ prefix: '/admin/designation-management', keys: ['DESIGNATION_MANAGEMENT', 'DESIGNATIONS'] },
{ prefix: '/admin/roles', keys: ['INTERNAL_ROLE_MANAGEMENT', 'ROLES'] },
{ prefix: '/admin/employees', keys: ['EMPLOYEE_MANAGEMENT', 'EMPLOYEES'] },
{ prefix: '/admin/external-roles', keys: ['EXTERNAL_ROLE_MANAGEMENT', 'EXTERNAL_ROLES'] },
{ prefix: '/admin/internal-dashboard-management', keys: ['INTERNAL_DASHBOARD_MANAGEMENT', 'INTERNAL_DASHBOARDS', 'INTERNAL_DASHBOARD_CONFIG'] },
{ prefix: '/admin/external-dashboard-management', keys: ['DASHBOARD_CONFIG_MANAGEMENT', 'EXTERNAL_DASHBOARD_MANAGEMENT', 'EXTERNAL_DASHBOARDS', 'EXTERNAL_DASHBOARD_CONFIG', 'RUNTIME_ROLES'] },
{ prefix: '/admin/role-ui-configs', keys: ['DASHBOARD_CONFIG_MANAGEMENT', 'EXTERNAL_DASHBOARD_MANAGEMENT', 'EXTERNAL_DASHBOARDS', 'EXTERNAL_DASHBOARD_CONFIG', 'RUNTIME_ROLES'] },
{ prefix: '/admin/verification', keys: ['VERIFICATION_MANAGEMENT', 'VERIFICATIONS'] },
{ prefix: '/admin/verification-status', keys: ['VERIFICATION_MANAGEMENT', 'VERIFICATIONS'] },
{ prefix: '/admin/approval', keys: ['APPROVAL_MANAGEMENT', 'APPROVALS'] },
{ prefix: '/admin/approvals', keys: ['APPROVAL_MANAGEMENT', 'APPROVALS'] },
{ prefix: '/admin/approval-management', keys: ['APPROVAL_MANAGEMENT', 'APPROVALS'] },
{ prefix: '/admin/users', keys: ['USER_MANAGEMENT', 'USERS'] },
{ prefix: '/admin/company', keys: ['COMPANY_MANAGEMENT', 'COMPANIES'] },
{ prefix: '/admin/candidate', keys: ['CANDIDATE_MANAGEMENT', 'CANDIDATES'] },
{ prefix: '/admin/customer', keys: ['CUSTOMER_MANAGEMENT', 'CUSTOMERS'] },
{ prefix: '/admin/photographer', keys: ['PHOTOGRAPHER_MANAGEMENT', 'PHOTOGRAPHERS'] },
{ prefix: '/admin/makeup-artist', keys: ['MAKEUP_ARTIST_MANAGEMENT', 'MAKEUP_ARTISTS'] },
{ prefix: '/admin/tutors', keys: ['TUTOR_MANAGEMENT', 'TUTORS'] },
{ prefix: '/admin/developers', keys: ['DEVELOPER_MANAGEMENT', 'DEVELOPERS'] },
{ prefix: '/admin/video-editors', keys: ['VIDEO_EDITOR_MANAGEMENT', 'VIDEO_EDITORS'] },
{ prefix: '/admin/fitness-trainers', keys: ['FITNESS_TRAINER_MANAGEMENT', 'FITNESS_TRAINERS'] },
{ prefix: '/admin/catering-services', keys: ['CATERING_SERVICES_MANAGEMENT', 'CATERING_SERVICES'] },
{ prefix: '/admin/ugc-content-creator', keys: ['UGC_CONTENT_CREATOR_MANAGEMENT', 'UGC_CONTENT_CREATOR'] },
{ prefix: '/admin/graphic-designers', keys: ['GRAPHIC_DESIGNER_MANAGEMENT', 'GRAPHIC_DESIGNERS'] },
{ prefix: '/admin/social-media-managers', keys: ['SOCIAL_MEDIA_MANAGEMENT', 'SOCIAL_MEDIA_MANAGER_MANAGEMENT', 'SOCIAL_MEDIA_MANAGERS'] },
{ prefix: '/admin/jobs', keys: ['JOBS_MANAGEMENT', 'JOBS'] },
{ prefix: '/admin/leads', keys: ['LEADS_MANAGEMENT', 'LEADS', 'REQUIREMENTS_MANAGEMENT', 'REQUIREMENTS'] },
{ prefix: '/admin/applications', keys: ['APPLICATIONS_MANAGEMENT', 'APPLICATIONS'] },
{ prefix: '/admin/responses', keys: ['RESPONSES_MANAGEMENT', 'RESPONSES'] },
{ prefix: '/admin/pricing', keys: ['PRICING_MANAGEMENT', 'PRICING'] },
{ prefix: '/admin/credit', keys: ['CREDIT_MANAGEMENT', 'CREDITS'] },
{ prefix: '/admin/coupon', keys: ['COUPON_MANAGEMENT', 'COUPONS'] },
{ prefix: '/admin/discount', keys: ['DISCOUNT_MANAGEMENT', 'DISCOUNTS'] },
{ prefix: '/admin/tax', keys: ['TAX_MANAGEMENT', 'TAXES'] },
{ prefix: '/admin/order', keys: ['ORDER_MANAGEMENT', 'ORDERS'] },
{ prefix: '/admin/invoice', keys: ['INVOICE_MANAGEMENT', 'INVOICES'] },
{ prefix: '/admin/payment-gateway', keys: ['PAYMENT_GATEWAY_MANAGEMENT', 'PAYMENT_GATEWAY'] },
{ prefix: '/admin/smtp', keys: ['SMTP_MANAGEMENT', 'SMTP'] },
{ prefix: '/admin/kb', keys: ['KNOWLEDGE_BASE_MANAGEMENT', 'KNOWLEDGE_BASE', 'KB'] },
{ prefix: '/admin/notifications', keys: ['NOTIFICATIONS_MANAGEMENT', 'NOTIFICATIONS'] },
{ prefix: '/admin/review', keys: ['REVIEW_MANAGEMENT', 'REVIEWS'] },
{ prefix: '/admin/support', keys: ['SUPPORT_MANAGEMENT', 'SUPPORT'] },
{ prefix: '/admin/report', keys: ['REPORT_MANAGEMENT', 'REPORTS'] },
{ prefix: '/admin/ledger', keys: ['LEDGER', 'LEDGER_MANAGEMENT'] },
];
const SEARCH_MODULES = [
{
label: 'Users',
viewAllHref: '/admin/users',
api: '/api/admin/users',
listKeys: ['users', 'items'],
titleKeys: ['full_name', 'name'],
subtitleKeys: ['email', 'phone'],
detailBase: '/admin/users',
},
{
label: 'Companies',
viewAllHref: '/admin/company',
api: '/api/admin/companies',
listKeys: ['companies', 'items'],
titleKeys: ['name', 'companyName'],
subtitleKeys: ['email', 'phone'],
detailBase: '/admin/company',
},
{
label: 'Employees',
viewAllHref: '/admin/employees',
api: '/api/admin/employees',
listKeys: ['employees', 'items'],
titleKeys: ['full_name', 'name'],
subtitleKeys: ['email', 'department_name'],
detailBase: '/admin/employees',
},
{
label: 'Jobs',
viewAllHref: '/admin/jobs',
api: '/api/admin/jobs',
listKeys: ['jobs', 'items'],
titleKeys: ['title', 'name'],
subtitleKeys: ['status', 'company_name'],
detailBase: '/admin/jobs',
},
{
label: 'Leads',
viewAllHref: '/admin/leads',
api: '/api/admin/leads',
listKeys: ['leads', 'items'],
titleKeys: ['name', 'full_name'],
subtitleKeys: ['email', 'status'],
detailBase: '/admin/leads',
},
];
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), 350);
};
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 (
2026-03-26 20:58:39 +01:00
<div ref={wrapRef!} class="relative ml-10 w-[560px] shrink-0">
<Search size={20} class="pointer-events-none absolute left-5 top-1/2 -translate-y-1/2 text-[#9498ad]" />
<input
type="text"
value={query()}
placeholder="Search system resources..."
onInput={(e) => handleInput(e.currentTarget.value)}
onFocus={() => groups().length > 0 && setOpen(true)}
onKeyDown={(e) => e.key === 'Escape' && close()}
2026-03-26 20:58:39 +01:00
class="h-[68px] w-full rounded-[24px] border-2 border-transparent bg-[#f4f5f8] pl-[60px] pr-6 text-[16px] text-[#0D0D2A] placeholder:text-[rgba(13,13,42,0.4)] outline-none transition-all focus:border-[#e5e7eb] focus:bg-white"
/>
<Show when={open() && groups().length > 0}>
<div class="absolute left-0 right-0 top-[calc(100%+8px)] z-[300] max-h-[460px] overflow-y-auto rounded-2xl border border-[#e5e7eb] bg-white shadow-xl">
<For each={groups()}>
{(group) => (
<div class="border-b border-[#f1f2f5] px-4 py-3 last:border-b-0">
<div class="mb-2 flex items-center justify-between">
<span class="text-[10px] font-bold uppercase tracking-[0.12em] text-[#9aa0b9]">{group.label}</span>
<A href={group.viewAllHref} onClick={close} class="text-[12px] font-semibold text-[#FF5E13]">View all</A>
</div>
<div class="space-y-1">
<For each={group.results}>
{(item) => (
<A href={item.href} onClick={close} class="flex items-center gap-3 rounded-xl px-2 py-2 hover:bg-[#f9fafb]">
<div class="flex h-8 w-8 items-center justify-center rounded-full bg-[rgba(255,94,19,0.12)] text-[12px] font-bold text-[#FF5E13]">
{item.title.trim().slice(0, 1).toUpperCase()}
</div>
<div class="min-w-0">
<p class="truncate text-[13px] font-semibold text-[#0D0D2A]">{item.title}</p>
<p class="truncate text-[12px] text-[rgba(13,13,42,0.55)]">{item.subtitle}</p>
</div>
</A>
)}
</For>
</div>
</div>
)}
</For>
</div>
</Show>
<Show when={open() && !searching() && query().trim().length >= 2 && groups().length === 0}>
<div class="absolute left-0 right-0 top-[calc(100%+8px)] z-[300] rounded-2xl border border-[#e5e7eb] bg-white px-4 py-6 text-center text-[13px] text-[rgba(13,13,42,0.55)]">
No results for "{query()}"
</div>
</Show>
</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 mt-1 flex items-center gap-1 border-b border-[#e5e7eb]">
<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-[#FF5E13]' : 'text-[rgba(13,13,42,0.6)] hover:text-[#0D0D2A]'
}`}
>
{tab.label}
</A>
)}
</For>
<div
class={`absolute bottom-0 h-[2px] bg-[#FF5E13] 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>
);
}
export default function AdminShell(props: { children: JSX.Element }) {
const location = useLocation();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const [checkedSession, setCheckedSession] = createSignal(false);
const [adminName, setAdminName] = createSignal('Admin User');
const [allowedModules, setAllowedModules] = createSignal<string[] | null>(null);
const [isSuperAdmin, setIsSuperAdmin] = createSignal(false);
const [sidebarOpen, setSidebarOpen] = createSignal(false);
const [sidebarCollapsed, setSidebarCollapsed] = createSignal(false);
const [unreadCount, setUnreadCount] = createSignal(0);
const [theme, setTheme] = createSignal<'light' | 'dark'>('light');
const [routeTransitioning, setRouteTransitioning] = createSignal(false);
const [tabsTrackEl, setTabsTrackEl] = createSignal<HTMLDivElement>();
const [tabRefs, setTabRefs] = createSignal<Record<string, HTMLAnchorElement>>({});
const [tabIndicator, setTabIndicator] = createSignal({ left: 0, width: 0, ready: false });
let contentScrollRef: HTMLDivElement | undefined;
const logout = async () => {
try {
const accessToken = typeof sessionStorage !== 'undefined'
? sessionStorage.getItem('nxtgauge_admin_access_token') || ''
: '';
await fetch('/api/auth/logout', {
method: 'POST',
headers: {
Accept: 'application/json',
'x-portal-target': 'admin',
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
},
credentials: 'include',
}).catch(() => null);
} finally {
if (typeof sessionStorage !== 'undefined') {
sessionStorage.removeItem('nxtgauge_admin_access_token');
sessionStorage.removeItem('nxtgauge_admin_preview');
}
clearAdminSession();
navigate('/login', { replace: true });
}
};
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;
}
return [];
});
const isTabActive = (tab: Tab) =>
tab.exact
? location.pathname === tab.href
: location.pathname === tab.href || location.pathname.startsWith(`${tab.href}/`);
const refreshTabIndicator = () => {
const activeTab = tabs().find((tab) => isTabActive(tab));
const track = tabsTrackEl();
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;
requestAnimationFrame(refreshTabIndicator);
});
createEffect(() => {
location.pathname;
setRouteTransitioning(true);
requestAnimationFrame(() => {
requestAnimationFrame(() => setRouteTransitioning(false));
});
if (!contentScrollRef) return;
const prefersReducedMotion = typeof window !== 'undefined'
? window.matchMedia('(prefers-reduced-motion: reduce)').matches
: false;
contentScrollRef.scrollTo({
top: 0,
behavior: prefersReducedMotion ? 'auto' : 'smooth',
});
});
onMount(() => {
const savedTheme = (typeof localStorage !== 'undefined'
? localStorage.getItem('nxtgauge_admin_theme')
: null) as 'light' | 'dark' | null;
const nextTheme = savedTheme === 'dark' ? 'dark' : 'light';
setTheme(nextTheme);
if (typeof document !== 'undefined') {
document.documentElement.setAttribute('data-theme', nextTheme);
}
window.addEventListener('resize', refreshTabIndicator);
onCleanup(() => window.removeEventListener('resize', refreshTabIndicator));
// Fetch unread notification count and poll every 30 seconds
const fetchUnreadCount = async () => {
try {
const accessToken = typeof sessionStorage !== 'undefined'
? sessionStorage.getItem('nxtgauge_admin_access_token') || ''
: '';
if (!accessToken) return;
const res = await fetch('/api/me/notifications/unread-count', {
method: 'GET',
headers: {
Accept: 'application/json',
'x-portal-target': 'admin',
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
},
credentials: 'include',
});
if (res.ok) {
const data = await res.json();
setUnreadCount(data.unread_count || 0);
}
} catch (e) {
console.error('Failed to fetch unread count:', e);
}
};
fetchUnreadCount();
const interval = setInterval(fetchUnreadCount, 30000);
onCleanup(() => clearInterval(interval));
const isPreview = searchParams._preview === '1' ||
(typeof sessionStorage !== 'undefined' && sessionStorage.getItem('nxtgauge_admin_preview') === '1');
if (isPreview) {
if (typeof sessionStorage !== 'undefined') sessionStorage.setItem('nxtgauge_admin_preview', '1');
setAdminSession();
setCheckedSession(true);
return;
}
const verify = async () => {
if (!hasAdminSession()) {
navigate(`/login?from=${encodeURIComponent(location.pathname + location.search)}`, { replace: true });
return;
}
try {
const accessToken = typeof sessionStorage !== 'undefined'
? sessionStorage.getItem('nxtgauge_admin_access_token') || ''
: '';
const response = await fetch('/api/auth/session', {
method: 'GET',
headers: {
Accept: 'application/json',
'x-portal-target': 'admin',
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
},
credentials: 'include',
});
const payload = await response.json().catch(() => ({}));
if (!response.ok || isExternalIdentity(payload)) throw new Error('Unauthorized');
if (payload?.full_name) setAdminName(payload.full_name);
const roleKey = String(
payload?.active_role
|| payload?.role
|| payload?.user?.active_role
|| payload?.user?.active_role_key
|| payload?.user?.role
|| payload?.user?.role_key
|| '',
).toUpperCase();
setIsSuperAdmin(roleKey === 'SUPER_ADMIN');
try {
const res = await fetch('/api/runtime-config', {
method: 'GET',
headers: {
Accept: 'application/json',
'x-portal-target': 'admin',
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
},
credentials: 'include',
});
const runtime = await res.json().catch(() => ({}));
if (res.ok) {
setAllowedModules(normalizeAllowedModules(runtime));
const activeRole = String(runtime?.active_role || runtime?.user?.active_role || roleKey || '').toUpperCase();
if (activeRole) setIsSuperAdmin(activeRole === 'SUPER_ADMIN');
} else {
setAllowedModules(null);
}
} catch {
setAllowedModules(null);
}
setCheckedSession(true);
} catch {
clearAdminSession();
navigate(`/login?from=${encodeURIComponent(location.pathname + location.search)}`, { replace: true });
}
};
void verify();
});
const pageTitle = createMemo(() => {
const path = location.pathname;
for (const entry of PAGE_TITLES) {
if (entry.exact ? path === entry.prefix : (path === entry.prefix || path.startsWith(`${entry.prefix}/`))) {
return entry.label;
}
}
return 'Admin';
});
const adminInitials = createMemo(() => {
if (adminName().trim().toLowerCase() === 'admin user') return 'AD';
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, 2).toUpperCase();
return `${parts[0][0]}${parts[1][0]}`.toUpperCase();
});
createEffect(() => {
const t = theme();
if (typeof localStorage !== 'undefined') localStorage.setItem('nxtgauge_admin_theme', t);
if (typeof document !== 'undefined') document.documentElement.setAttribute('data-theme', t);
});
const toggleTheme = () => setTheme((v) => (v === 'dark' ? 'light' : 'dark'));
const isDark = () => theme() === 'dark';
createEffect(() => {
if (!checkedSession()) return;
if (isSuperAdmin()) return;
const modules = allowedModules();
if (!modules || modules.length === 0) return;
const path = location.pathname;
if (path === '/admin') return;
const matches = ROUTE_MODULE_KEYS.filter(
(entry) => path === entry.prefix || path.startsWith(`${entry.prefix}/`),
);
const guard = matches.sort((a, b) => b.prefix.length - a.prefix.length)[0];
if (!guard) return;
const allowed = new Set(modules.map((m) => String(m || '').trim().toUpperCase()).filter(Boolean));
const ok = guard.keys.some((k) => allowed.has(String(k).toUpperCase()));
if (ok) return;
navigate(`/admin?denied=${encodeURIComponent(guard.keys[0])}&from=${encodeURIComponent(path)}`, { replace: true });
});
return (
<div class="min-h-screen" style={{ background: isDark() ? '#0B1220' : '#F9FAFB', color: isDark() ? '#E5E7EB' : '#0D0D2A' }}>
<Show
when={checkedSession()}
fallback={<div class="flex min-h-screen items-center justify-center text-[14px] text-[rgba(13,13,42,0.55)]">Checking session</div>}
>
<div style="display:flex;height:100vh;overflow:hidden">
<div class={`fixed 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)} />
<div style="height:100%;display:flex;flex-shrink:0">
<AdminSidebar
collapsed={sidebarCollapsed()}
onToggle={() => setSidebarCollapsed((v) => !v)}
onNavigate={() => setSidebarOpen(false)}
adminName={adminName()}
adminInitials={adminInitials()}
theme={theme()}
allowedModules={allowedModules()}
isSuperAdmin={isSuperAdmin()}
/>
</div>
<div class="flex min-w-0 flex-1 flex-col">
<header style={`height:64px;border-bottom:1px solid ${isDark() ? '#1F2937' : '#E5E7EB'};background:${isDark() ? '#111827' : 'white'};flex-shrink:0`}>
<div style="display:flex;height:100%;width:100%;align-items:center;justify-content:flex-end;padding:0 32px">
<div style="display:flex;align-items:center;gap:4px">
<button type="button" onClick={toggleTheme} style={`display:inline-flex;width:36px;height:36px;align-items:center;justify-content:center;border-radius:8px;color:${isDark() ? '#CBD5E1' : '#6B7280'};background:none;border:none;cursor:pointer`} aria-label="Toggle theme">
<Show when={isDark()} fallback={<Moon size={18} />}>
<Sun size={18} />
</Show>
</button>
<button type="button" style={`position:relative;display:inline-flex;width:36px;height:36px;align-items:center;justify-content:center;border-radius:8px;color:${isDark() ? '#CBD5E1' : '#6B7280'};background:none;border:none;cursor:pointer`} aria-label="Notifications">
<Bell size={18} />
<Show when={unreadCount() > 0}>
<span style={`position:absolute;right:8px;top:8px;width:7px;height:7px;border-radius:50%;border:2px solid ${isDark() ? '#111827' : 'white'};background:#FF5E13`} />
</Show>
</button>
<button type="button" style={`display:inline-flex;width:36px;height:36px;align-items:center;justify-content:center;border-radius:8px;color:${isDark() ? '#CBD5E1' : '#6B7280'};background:none;border:none;cursor:pointer`} aria-label="Settings">
<Settings size={18} />
</button>
<div style={`width:1px;height:24px;background:${isDark() ? '#1F2937' : '#E5E7EB'};margin:0 8px`} />
<button
type="button"
style="display:inline-flex;align-items:center;gap:8px;border-radius:8px;padding:4px 8px 4px 4px;background:none;border:none;cursor:pointer"
aria-label="User menu"
>
<div style="width:32px;height:32px;border-radius:50%;background:linear-gradient(135deg,#FF5E13,#ff7a3d);display:flex;align-items:center;justify-content:center;font-size:12px;font-weight:700;color:white;flex-shrink:0">
{adminInitials()}
</div>
<div style="text-align:left">
<p style={`font-size:13px;font-weight:600;color:${isDark() ? '#E5E7EB' : '#111827'};line-height:1.3`}>{adminName()}</p>
<p style={`font-size:11px;color:${isDark() ? '#94A3B8' : '#6B7280'};line-height:1.3`}>Super Admin</p>
</div>
</button>
<button
type="button"
onClick={() => void logout()}
style={`height:32px;border-radius:8px;border:1px solid ${isDark() ? '#374151' : '#E5E7EB'};background:${isDark() ? '#1F2937' : 'white'};padding:0 12px;font-size:12px;font-weight:600;color:${isDark() ? '#E5E7EB' : '#374151'};cursor:pointer`}
>
Logout
</button>
</div>
</div>
</header>
<div
ref={(el) => { contentScrollRef = el; }}
class="min-h-0 flex-1 overflow-y-scroll"
style={{ background: isDark() ? '#0B1220' : '#F9FAFB', 'scrollbar-gutter': 'stable' }}
>
<main
class="admin-main"
style={{
width: '100%',
padding: '28px 24px 36px 24px',
filter: isDark() ? 'brightness(0.96)' : 'none',
transition: 'opacity 150ms ease',
opacity: routeTransitioning() ? '0.92' : '1',
}}
>
{props.children}
</main>
</div>
</div>
</div>
</Show>
</div>
);
}