Revamp admin shell/sidebar and align login form style

This commit is contained in:
Ashwin Kumar 2026-03-24 02:36:40 +01:00
parent 1f59fbbc4c
commit 9b1ffdacf6
5 changed files with 490 additions and 510 deletions

View file

@ -1,5 +1,5 @@
import { A, useLocation, useNavigate, useSearchParams } from '@solidjs/router'; import { A, useLocation, useNavigate, useSearchParams } from '@solidjs/router';
import { createMemo, createSignal, onMount, type JSX } from 'solid-js'; import { For, createEffect, createMemo, createSignal, onCleanup, onMount, type JSX } from 'solid-js';
import AdminSidebar from './AdminSidebar'; import AdminSidebar from './AdminSidebar';
import { isExternalIdentity } from '~/lib/admin-auth'; import { isExternalIdentity } from '~/lib/admin-auth';
import { clearAdminSession, hasAdminSession, setAdminSession } from '~/lib/admin-session'; import { clearAdminSession, hasAdminSession, setAdminSession } from '~/lib/admin-session';
@ -10,88 +10,57 @@ const TAB_SETS: Array<{ prefixes: string[]; tabs: Tab[] }> = [
{ {
prefixes: ['/admin/roles'], prefixes: ['/admin/roles'],
tabs: [ tabs: [
{ href: '/admin/roles', label: 'Internal Roles', exact: true }, { href: '/admin/roles', label: 'Roles', exact: true },
{ href: '/admin/roles/create', label: 'Create Role' }, { href: '/admin/roles/create', label: 'Create Role' },
{ href: '/admin/roles/templates', label: 'Role Templates' }, { href: '/admin/roles/templates', label: 'View Roles' },
], ],
}, },
{ {
prefixes: ['/admin/runtime-roles'], prefixes: ['/admin/runtime-roles'],
tabs: [ tabs: [
{ href: '/admin/runtime-roles', label: 'External Roles', exact: true }, { href: '/admin/runtime-roles', label: 'Roles', exact: true },
{ href: '/admin/runtime-roles/new', label: 'Create External Role' }, { href: '/admin/runtime-roles/new', label: 'Create Role' },
], { href: '/admin/role-ui-configs', label: 'View Roles' },
},
{
prefixes: ['/admin/onboarding-schemas'],
tabs: [
{ href: '/admin/onboarding-schemas', label: 'Onboarding Flows', exact: true },
{ href: '/admin/onboarding-schemas/new', label: 'Create Flow' },
],
},
{
prefixes: ['/admin/internal-dashboard-management'],
tabs: [
{ href: '/admin/internal-dashboard-management', label: 'Internal Dashboards' },
],
},
{
prefixes: ['/admin/external-dashboard-management'],
tabs: [
{ href: '/admin/external-dashboard-management', label: 'External Dashboards' },
],
},
{
prefixes: ['/admin/role-ui-configs'],
tabs: [
{ href: '/admin/role-ui-configs', label: 'Config Inspector', exact: true },
{ href: '/admin/role-ui-configs/new', label: 'Create Config' },
], ],
}, },
]; ];
const PAGE_TITLES: Array<{ prefix: string; title: string }> = [ function IconBell() {
{ prefix: '/admin/employees', title: 'Employee Management' }, return (
{ prefix: '/admin/department', title: 'Department Management' }, <svg class="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9">
{ prefix: '/admin/designation', title: 'Designation Management' }, <path d="M18 8a6 6 0 0 0-12 0c0 7-3 9-3 9h18s-3-2-3-9" />
{ prefix: '/admin/roles', title: 'Internal Role Management' }, <path d="M13.73 21a2 2 0 0 1-3.46 0" />
{ prefix: '/admin/runtime-roles', title: 'External Role Management' }, </svg>
{ prefix: '/admin/onboarding-schemas', title: 'External Onboarding Management' }, );
{ prefix: '/admin/internal-dashboard-management', title: 'Internal Dashboard Management' }, }
{ prefix: '/admin/external-dashboard-management', title: 'External Dashboard Management' },
{ prefix: '/admin/role-ui-configs', title: 'External Dashboard Management' }, function IconSearch() {
{ prefix: '/admin/approval', title: 'Approval Management' }, return (
{ prefix: '/admin/users', title: 'Users Management' }, <svg class="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
{ prefix: '/admin/company', title: 'Company Management' }, <circle cx="11" cy="11" r="7.5" />
{ prefix: '/admin/candidate', title: 'Candidate Management' }, <path d="m20 20-3.8-3.8" />
{ prefix: '/admin/customer', title: 'Customer Management' }, </svg>
{ prefix: '/admin/photographer', title: 'Photographer Management' }, );
{ prefix: '/admin/makeup-artist', title: 'Makeup Artist Management' }, }
{ prefix: '/admin/tutors', title: 'Tutors Management' },
{ prefix: '/admin/developers', title: 'Developers Management' }, function IconHelp() {
{ prefix: '/admin/video-editors', title: 'Video Editor Management' }, return (
{ prefix: '/admin/fitness-trainers', title: 'Fitness Trainer Management' }, <svg class="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
{ prefix: '/admin/catering-services', title: 'Catering Services Management' }, <circle cx="12" cy="12" r="9" />
{ prefix: '/admin/graphic-designers', title: 'Graphics Designer Management' }, <path d="M9.3 9a2.8 2.8 0 1 1 4.9 2c-.8.9-1.7 1.4-1.7 2.5" />
{ prefix: '/admin/social-media-managers', title: 'Social Media Manager Management' }, <path d="M12 17h.01" />
{ prefix: '/admin/jobs', title: 'Jobs Management' }, </svg>
{ prefix: '/admin/leads', title: 'Leads Management' }, );
{ prefix: '/admin/pricing', title: 'Pricing Management' }, }
{ prefix: '/admin/credit', title: 'Credit Management' },
{ prefix: '/admin/coupon', title: 'Coupon Management' }, function IconCog() {
{ prefix: '/admin/discount', title: 'Discount Management' }, return (
{ prefix: '/admin/tax', title: 'Tax Management' }, <svg class="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
{ prefix: '/admin/order', title: 'Order Management' }, <path d="M12 15.5A3.5 3.5 0 1 0 12 8.5a3.5 3.5 0 0 0 0 7Z" />
{ prefix: '/admin/invoice', title: 'Invoice Management' }, <path d="M19.4 15a1.6 1.6 0 0 0 .3 1.7l.1.1a2 2 0 0 1-2.8 2.8l-.1-.1a1.6 1.6 0 0 0-1.7-.3 1.6 1.6 0 0 0-1 1.5V21a2 2 0 0 1-4 0v-.2a1.6 1.6 0 0 0-1-1.5 1.6 1.6 0 0 0-1.7.3l-.1.1a2 2 0 0 1-2.8-2.8l.1-.1a1.6 1.6 0 0 0 .3-1.7 1.6 1.6 0 0 0-1.5-1H3a2 2 0 0 1 0-4h.2a1.6 1.6 0 0 0 1.5-1 1.6 1.6 0 0 0-.3-1.7l-.1-.1a2 2 0 0 1 2.8-2.8l.1.1a1.6 1.6 0 0 0 1.7.3h.1a1.6 1.6 0 0 0 .9-1.5V3a2 2 0 1 1 4 0v.2a1.6 1.6 0 0 0 .9 1.5h.1a1.6 1.6 0 0 0 1.7-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.6 1.6 0 0 0-.3 1.7v.1a1.6 1.6 0 0 0 1.5.9H21a2 2 0 0 1 0 4h-.2a1.6 1.6 0 0 0-1.5 1Z" />
{ prefix: '/admin/review', title: 'Review Management' }, </svg>
{ prefix: '/admin/support', title: 'Support Management' }, );
{ prefix: '/admin/kb', title: 'Knowledge Base Management' }, }
{ prefix: '/admin/notifications', title: 'Notifications' },
{ prefix: '/admin/report', title: 'Report Management' },
{ prefix: '/admin/ledger', title: 'Ledger Management' },
{ prefix: '/admin/workspace', title: 'Dashboard Workspace' },
{ prefix: '/admin', title: 'Dashboard' },
];
export default function AdminShell(props: { children: JSX.Element }) { export default function AdminShell(props: { children: JSX.Element }) {
const location = useLocation(); const location = useLocation();
@ -99,35 +68,46 @@ export default function AdminShell(props: { children: JSX.Element }) {
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const [checkedSession, setCheckedSession] = createSignal(false); const [checkedSession, setCheckedSession] = createSignal(false);
const [adminName, setAdminName] = createSignal('Admin'); const [adminName, setAdminName] = createSignal('Admin');
const [adminRole, setAdminRole] = createSignal('Super Admin'); const [sidebarOpen, setSidebarOpen] = createSignal(false);
const [tabsTrackEl, setTabsTrackEl] = createSignal<HTMLDivElement>();
const [tabRefs, setTabRefs] = createSignal<Record<string, HTMLAnchorElement>>({});
const [tabIndicator, setTabIndicator] = createSignal({ left: 0, width: 0, ready: false });
const tabs = createMemo<Tab[]>(() => { const tabs = createMemo<Tab[]>(() => {
const path = location.pathname; const path = location.pathname;
for (const set of TAB_SETS) { for (const set of TAB_SETS) {
if (set.prefixes.some((p) => path === p || path.startsWith(`${p}/`))) { if (set.prefixes.some((p) => path === p || path.startsWith(`${p}/`))) return set.tabs;
return set.tabs;
}
} }
return []; return [];
}); });
const isTabActive = (tab: Tab) => { const isTabActive = (tab: Tab) => (tab.exact ? location.pathname === tab.href : location.pathname === tab.href || location.pathname.startsWith(`${tab.href}/`));
if (tab.exact) return location.pathname === tab.href;
return 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((prev) => ({ ...prev, ready: false }));
return;
}
const el = tabRefs()[activeTab.href];
if (!el) return;
setTabIndicator({ left: el.offsetLeft, width: el.offsetWidth, ready: true });
}; };
const pageTitle = createMemo(() => { createEffect(() => {
const path = location.pathname; tabs();
for (const item of PAGE_TITLES) { location.pathname;
if (path === item.prefix || path.startsWith(`${item.prefix}/`)) return item.title; requestAnimationFrame(refreshTabIndicator);
}
return 'Dashboard';
}); });
onMount(() => { onMount(() => {
const onResize = () => refreshTabIndicator();
window.addEventListener('resize', onResize);
onCleanup(() => window.removeEventListener('resize', onResize));
const isLocalDev = const isLocalDev =
typeof window !== 'undefined' && typeof window !== 'undefined' && (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1');
(window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1');
const isPreview = const isPreview =
searchParams._preview === '1' || searchParams._preview === '1' ||
(typeof sessionStorage !== 'undefined' && sessionStorage.getItem('nxtgauge_admin_preview') === '1'); (typeof sessionStorage !== 'undefined' && sessionStorage.getItem('nxtgauge_admin_preview') === '1');
@ -146,10 +126,7 @@ export default function AdminShell(props: { children: JSX.Element }) {
return; return;
} }
try { try {
const accessToken = const accessToken = typeof sessionStorage !== 'undefined' ? sessionStorage.getItem('nxtgauge_admin_access_token') || '' : '';
typeof sessionStorage !== 'undefined'
? sessionStorage.getItem('nxtgauge_admin_access_token') || ''
: '';
const response = await fetch('/api/gateway/users/auth/me', { const response = await fetch('/api/gateway/users/auth/me', {
method: 'GET', method: 'GET',
headers: { headers: {
@ -162,7 +139,6 @@ export default function AdminShell(props: { children: JSX.Element }) {
const payload = await response.json().catch(() => ({})); const payload = await response.json().catch(() => ({}));
if (!response.ok || isExternalIdentity(payload)) throw new Error('Unauthorized'); if (!response.ok || isExternalIdentity(payload)) throw new Error('Unauthorized');
if (payload?.full_name) setAdminName(payload.full_name); if (payload?.full_name) setAdminName(payload.full_name);
if (payload?.role?.name) setAdminRole(payload.role.name);
setCheckedSession(true); setCheckedSession(true);
} catch { } catch {
clearAdminSession(); clearAdminSession();
@ -188,110 +164,126 @@ export default function AdminShell(props: { children: JSX.Element }) {
navigate('/login', { replace: true }); navigate('/login', { replace: true });
}; };
const initials = () => adminName().charAt(0).toUpperCase() || 'A'; const adminInitials = createMemo(() => {
const parts = adminName().split(' ').map((s) => s.trim()).filter(Boolean);
if (parts.length === 0) return 'AD';
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
return `${parts[0][0] || ''}${parts[1][0] || ''}`.toUpperCase();
});
return ( return (
<div class="min-h-screen bg-[#f9f9fd]"> <div class="min-h-screen bg-[#f0f1f6]">
{/* ── Fixed Header ── */} <header class="fixed inset-x-0 top-0 z-50 border-b border-[#d8dbe3] bg-[#f6f7fb]">
<header class="fixed top-0 z-50 flex h-16 w-full items-center justify-between bg-white/80 px-6 shadow-[0_4px_24px_rgba(10,29,55,0.04)] backdrop-blur-xl"> <div class="flex h-[86px] items-center justify-between px-8">
{/* Left: logo */} <div class="flex min-w-0 items-center gap-6">
<div class="flex w-64 shrink-0 items-center"> <button
<img src="/nxtgauge-logo.png" alt="NXTGAUGE" class="h-8 w-auto object-contain" /> type="button"
</div> class="inline-flex h-10 w-10 items-center justify-center rounded-lg border border-slate-200 text-slate-600 hover:bg-slate-50 lg:hidden"
onClick={() => setSidebarOpen((v) => !v)}
{/* Center: search */} aria-label="Toggle sidebar"
<div class="relative mx-8 w-full max-w-xl"> >
<svg class="absolute left-3.5 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"> <svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<circle cx="11" cy="11" r="8"/><path stroke-linecap="round" d="M21 21l-4.35-4.35"/> <path stroke-linecap="round" d="M4 7h16M4 12h16M4 17h16" />
</svg> </svg>
<input </button>
type="text" <A href="/admin" class="flex shrink-0 items-center">
placeholder="Search roles, users, or reports…" <img src="/nxtgauge-logo.png" alt="NXTGAUGE" class="h-12 w-auto object-contain" />
class="w-full rounded-full border-0 bg-slate-50 py-2 pl-10 pr-4 text-sm text-[#0a1d37] placeholder-slate-400 outline-none ring-0 transition-all focus:bg-white focus:ring-2 focus:ring-[#fd6216]/20" </A>
/>
</div>
{/* Right: actions + avatar */}
<div class="flex items-center gap-3">
{/* Notification bell */}
<button type="button" aria-label="Notifications"
class="relative flex h-9 w-9 items-center justify-center rounded-full text-[#0a1d37]/60 transition-colors hover:bg-slate-50"
>
<svg class="h-5 w-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9" />
<path stroke-linecap="round" stroke-linejoin="round" d="M13.73 21a2 2 0 0 1-3.46 0" />
</svg>
<span class="absolute right-2 top-2 h-2 w-2 rounded-full border-2 border-white bg-[#fd6216]" />
</button>
{/* Settings */}
<button type="button" aria-label="Settings"
class="flex h-9 w-9 items-center justify-center rounded-full text-[#0a1d37]/60 transition-colors hover:bg-slate-50"
>
<svg class="h-5 w-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="3"/>
<path stroke-linecap="round" d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>
</svg>
</button>
<div class="h-8 w-px bg-slate-200" />
{/* Name + avatar */}
<div class="flex items-center gap-2.5">
<span class="text-sm font-bold text-[#0a1d37]">{adminName()}</span>
<div class="flex h-8 w-8 items-center justify-center rounded-full border-2 border-[#fd6216] bg-[#fd6216] text-xs font-black text-white">
{initials()}
</div>
</div> </div>
{/* Logout */} <div class="flex items-center gap-4">
<button type="button" onClick={onLogout} aria-label="Logout" <div class="hidden h-[54px] w-[760px] items-center gap-3 rounded-2xl border border-[#daddE8] bg-[#edeef4] px-5 text-[15px] text-[#6a7285] lg:flex">
class="flex h-9 w-9 items-center justify-center rounded-full text-[#0a1d37]/60 transition-colors hover:bg-red-50 hover:text-red-500" <IconSearch />
> <span>Search system operations...</span>
<svg class="h-5 w-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"> </div>
<path stroke-linecap="round" stroke-linejoin="round" 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" /> <div class="hidden h-10 w-px bg-[#d9dde7] lg:block" />
</svg> <button type="button" aria-label="Notifications" class="relative inline-flex h-10 w-10 items-center justify-center rounded-full text-[#3e4559] hover:bg-white">
</button> <span class="absolute right-2 top-2 h-1.5 w-1.5 rounded-full bg-[#fd6216]" />
<IconBell />
</button>
<button type="button" aria-label="Help" class="hidden h-10 w-10 items-center justify-center rounded-full text-[#3e4559] hover:bg-white lg:inline-flex">
<IconHelp />
</button>
<button type="button" aria-label="Settings" class="hidden h-10 w-10 items-center justify-center rounded-full text-[#3e4559] hover:bg-white lg:inline-flex">
<IconCog />
</button>
<div class="hidden h-10 w-px bg-[#d9dde7] lg:block" />
<div class="hidden items-center gap-3 lg:flex">
<div class="text-right leading-tight">
<p class="text-[17px] font-semibold text-[#111827]">{adminName()}</p>
<p class="text-[14px] text-[#6b7280]">Administrator</p>
</div>
<div class="flex h-11 w-11 items-center justify-center overflow-hidden rounded-xl border border-[#d9dce7] bg-gradient-to-br from-[#fef3eb] to-[#ffd9c4] text-[15px] font-bold text-[#fd6216]">
{adminInitials()}
</div>
</div>
</div>
</div> </div>
</header> </header>
{/* ── Body: sidebar + main (fixed, below header) ── */}
{checkedSession() ? ( {checkedSession() ? (
<div class="fixed inset-0 top-16 grid grid-cols-[auto_1fr]"> <div class="fixed inset-0 top-[86px] flex">
{/* Sidebar */} <div
<AdminSidebar /> class={`absolute inset-0 z-20 bg-[#0a1d37]/35 transition-opacity lg:hidden ${sidebarOpen() ? 'pointer-events-auto opacity-100' : 'pointer-events-none opacity-0'}`}
onClick={() => setSidebarOpen(false)}
/>
{/* Main content */} <div class={`absolute inset-y-0 left-0 z-30 w-[310px] -translate-x-full transition-transform duration-200 lg:static lg:translate-x-0 ${sidebarOpen() ? 'translate-x-0' : ''}`}>
<main class="scrollbar min-w-0 overflow-y-auto bg-[#f9f9fd] p-8"> <AdminSidebar onNavigate={() => setSidebarOpen(false)} onLogout={onLogout} />
{/* Sub-tabs (shown for multi-tab sections) */} </div>
{tabs().length > 0 && (
<div class="mb-6 flex gap-1 border-b border-slate-200"> <main class="scrollbar min-w-0 flex-1 overflow-y-auto bg-[#f0f1f6] px-8 pb-8 pt-9">
{tabs().map((tab) => ( <ShowTabs
<A tabs={tabs()}
href={tab.href} isTabActive={isTabActive}
class={`relative px-3 pb-2.5 pt-0.5 text-[13px] font-medium transition-colors ${ setTabsTrackEl={setTabsTrackEl}
isTabActive(tab) setTabRefs={setTabRefs}
? 'text-slate-900 after:absolute after:bottom-0 after:left-0 after:right-0 after:h-0.5 after:rounded-t-full after:bg-orange-500 after:content-[""]' tabIndicator={tabIndicator}
: 'text-slate-500 hover:text-slate-700' />
}`}
>
{tab.label}
</A>
))}
</div>
)}
{props.children} {props.children}
</main> </main>
</div> </div>
) : ( ) : (
/* Session check loading state */ <div class="fixed inset-0 top-[86px] flex">
<div class="fixed inset-0 top-16 grid grid-cols-[auto_1fr]"> <div class="hidden w-[310px] border-r border-[#d7d8df] bg-[#f6f6f8] lg:block" />
<div class="w-64 border-r border-slate-100 bg-white" /> <main class="flex flex-1 items-center justify-center bg-[#f0f1f6]">
<main class="flex items-center justify-center bg-gray-50"> <p class="text-sm text-gray-500">Checking session...</p>
<p class="text-sm text-gray-400">Checking session</p>
</main> </main>
</div> </div>
)} )}
</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-7 flex items-center gap-8 border-b border-[#d8dbe3] px-1">
<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={`relative rounded-t-xl px-6 pb-4 pt-4 text-[16px] font-semibold transition-colors ${
props.isTabActive(tab) ? 'bg-white text-[#111827]' : 'text-[#636b7f] hover:text-[#111827]'
}`}
>
{tab.label}
</A>
)}
</For>
<div
class={`absolute bottom-0 h-[3px] 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>
);
}

View file

@ -1,144 +1,122 @@
import { A, useLocation } from '@solidjs/router'; import { A, useLocation } from '@solidjs/router';
import { For, Show } from 'solid-js';
import {
Bell,
Briefcase,
ClipboardList,
FileText,
FolderCog,
HandHelping,
LayoutGrid,
Percent,
Receipt,
Sparkles,
UserCircle2,
Users,
WalletCards,
} from 'lucide-solid';
type LinkItem = { type Item = {
href: string; href: string;
label: string; label: string;
icon: string; iconPath?: string;
icon?: any;
aliasPrefix?: string; aliasPrefix?: string;
group: string; separatorBefore?: boolean;
}; };
const links: LinkItem[] = [ const items: Item[] = [
{ href: '/admin', label: 'Dashboard', icon: 'dashboard.svg', group: '__top__' }, { href: '/admin', label: 'Dashboard', iconPath: '/sidebar-icons/dashboard.svg', icon: LayoutGrid },
{ href: '/admin/roles', label: 'Internal Role Management', iconPath: '/sidebar-icons/role.svg', icon: FolderCog },
{ href: '/admin/roles', label: 'Internal Role Management', icon: 'role.svg', group: 'Management' }, { href: '/admin/runtime-roles', label: 'External Role Management', iconPath: '/sidebar-icons/users.svg', icon: Users },
{ href: '/admin/runtime-roles', label: 'External Role Management', icon: 'role.svg', group: 'Management' }, { href: '/admin/onboarding-management', label: 'External Onboarding', iconPath: '/sidebar-icons/users.svg', icon: Users, aliasPrefix: '/admin/onboarding-schemas' },
{ href: '/admin/onboarding-schemas', label: 'External Onboarding', icon: 'reviews.svg', group: 'Management' }, { href: '/admin/internal-dashboard-management', label: 'Internal Dashboards', iconPath: '/sidebar-icons/dashboard.svg', icon: LayoutGrid },
{ href: '/admin/internal-dashboard-management', label: 'Internal Dashboard Mgmt.', icon: 'dashboard.svg', group: 'Management' }, { href: '/admin/external-dashboard-management', label: 'External Dashboards', iconPath: '/sidebar-icons/dashboard.svg', icon: LayoutGrid, aliasPrefix: '/admin/role-ui-configs' },
{ href: '/admin/external-dashboard-management', label: 'External Dashboard Mgmt.', icon: 'dashboard.svg', aliasPrefix: '/admin/role-ui-configs', group: 'Management' }, { href: '/admin/approval', label: 'Approval Management', iconPath: '/sidebar-icons/approval.svg', icon: ClipboardList },
{ href: '/admin/approval', label: 'Approval Management', icon: 'approval.svg', group: 'Management' }, { href: '/admin/department', label: 'Department Management', iconPath: '/sidebar-icons/department.svg', icon: Briefcase, separatorBefore: true },
{ href: '/admin/department', label: 'Department Management', icon: 'department.svg', group: 'Management' }, { href: '/admin/designation', label: 'Designation Management', iconPath: '/sidebar-icons/designation.svg', icon: Briefcase },
{ href: '/admin/designation', label: 'Designation Management', icon: 'designation.svg', group: 'Management' }, { href: '/admin/employees', label: 'Employee Management', iconPath: '/sidebar-icons/users.svg', icon: UserCircle2 },
{ href: '/admin/employees', label: 'Employee Management', icon: 'users.svg', group: 'Management' }, { href: '/admin/users', label: 'Users Management', iconPath: '/sidebar-icons/users.svg', icon: Users },
{ href: '/admin/users', label: 'Users Management', icon: 'users.svg', group: 'Management' }, { href: '/admin/company', label: 'Company Management', iconPath: '/sidebar-icons/company.svg', icon: Briefcase },
{ href: '/admin/company', label: 'Company Management', icon: 'company.svg', group: 'Management' }, { href: '/admin/candidate', label: 'Candidate Management', iconPath: '/sidebar-icons/candidate.svg', icon: UserCircle2 },
{ href: '/admin/candidate', label: 'Candidate Management', icon: 'candidate.svg', group: 'Management' }, { href: '/admin/customer', label: 'Customer Management', iconPath: '/sidebar-icons/support.svg', icon: UserCircle2 },
{ href: '/admin/customer', label: 'Customer Management', icon: 'users.svg', group: 'Management' }, { href: '/admin/photographer', label: 'Photographer Management', iconPath: '/sidebar-icons/photographer.svg', icon: Sparkles, separatorBefore: true },
{ href: '/admin/photographer', label: 'Photographer Management', icon: 'photographer.svg', group: 'Management' }, { href: '/admin/makeup-artist', label: 'Makeup Artist Management', iconPath: '/sidebar-icons/makeup-artist.svg', icon: Sparkles },
{ href: '/admin/makeup-artist', label: 'Makeup Artist Management', icon: 'makeup-artist.svg',group: 'Management' }, { href: '/admin/tutors', label: 'Tutors Management', iconPath: '/sidebar-icons/tutor.svg', icon: Sparkles },
{ href: '/admin/tutors', label: 'Tutors Management', icon: 'tutor.svg', group: 'Management' }, { href: '/admin/developers', label: 'Developers Management', iconPath: '/sidebar-icons/developers.svg', icon: Sparkles },
{ href: '/admin/developers', label: 'Developers Management', icon: 'developers.svg', group: 'Management' }, { href: '/admin/jobs', label: 'Jobs Management', iconPath: '/sidebar-icons/jobs.svg', icon: Briefcase },
{ href: '/admin/video-editors', label: 'Video Editor Management', icon: 'developers.svg', group: 'Management' }, { href: '/admin/leads', label: 'Leads Management', iconPath: '/sidebar-icons/leads.svg', icon: HandHelping },
{ href: '/admin/fitness-trainers', label: 'Fitness Trainer Management', icon: 'tutor.svg', group: 'Management' }, { href: '/admin/pricing', label: 'Pricing Management', iconPath: '/sidebar-icons/pricing.svg', icon: WalletCards },
{ href: '/admin/catering-services', label: 'Catering Services', icon: 'company.svg', group: 'Management' }, { href: '/admin/credit', label: 'Credit Management', iconPath: '/sidebar-icons/credits.svg', icon: WalletCards },
{ href: '/admin/graphic-designers', label: 'Graphics Designer Mgmt.', icon: 'developers.svg', group: 'Management' }, { href: '/admin/coupon', label: 'Coupon Management', iconPath: '/sidebar-icons/coupon.svg', icon: Percent },
{ href: '/admin/social-media-managers', label: 'Social Media Mgr.', icon: 'developers.svg', group: 'Management' }, { href: '/admin/discount', label: 'Discount Management', iconPath: '/sidebar-icons/discount.svg', icon: Percent },
{ href: '/admin/jobs', label: 'Jobs Management', icon: 'jobs.svg', group: 'Management' }, { href: '/admin/tax', label: 'Tax Management', iconPath: '/sidebar-icons/tax.svg', icon: Receipt },
{ href: '/admin/leads', label: 'Leads Management', icon: 'leads.svg', group: 'Management' }, { href: '/admin/order', label: 'Order Management', iconPath: '/sidebar-icons/order.svg', icon: FileText },
{ href: '/admin/invoice', label: 'Invoice Management', iconPath: '/sidebar-icons/invoice.svg', icon: FileText },
{ href: '/admin/pricing', label: 'Pricing Management', icon: 'pricing.svg', group: 'Finance' }, { href: '/admin/review', label: 'Review Management', iconPath: '/sidebar-icons/reviews.svg', icon: FileText },
{ href: '/admin/credit', label: 'Credit Management', icon: 'credits.svg', group: 'Finance' }, { href: '/admin/support', label: 'Support Management', iconPath: '/sidebar-icons/support.svg', icon: UserCircle2 },
{ href: '/admin/coupon', label: 'Coupon Management', icon: 'coupon.svg', group: 'Finance' }, { href: '/admin/report', label: 'Report Management', iconPath: '/sidebar-icons/report.svg', icon: Bell },
{ href: '/admin/discount', label: 'Discount Management', icon: 'discount.svg', group: 'Finance' }, { href: '/admin/ledger', label: 'Ledger Management', iconPath: '/sidebar-icons/ledger.svg', icon: Receipt },
{ href: '/admin/tax', label: 'Tax Management', icon: 'tax.svg', group: 'Finance' }, { href: '/admin/kb', label: 'Knowledge Base', icon: FileText },
{ href: '/admin/order', label: 'Order Management', icon: 'order.svg', group: 'Finance' }, { href: '/admin/notifications', label: 'Notifications', icon: Bell },
{ href: '/admin/invoice', label: 'Invoice Management', icon: 'invoice.svg', group: 'Finance' },
{ href: '/admin/ledger', label: 'Ledger Management', icon: 'ledger.svg', group: 'Finance' },
{ href: '/admin/review', label: 'Review Management', icon: 'reviews.svg', group: 'Platform' },
{ href: '/admin/support', label: 'Support Management', icon: 'support.svg', group: 'Platform' },
{ href: '/admin/kb', label: 'Knowledge Base', icon: 'reviews.svg', group: 'Platform' },
{ href: '/admin/notifications', label: 'Notifications', icon: 'reviews.svg', group: 'Platform' },
{ href: '/admin/report', label: 'Report Management', icon: 'report.svg', group: 'Platform' },
]; ];
export default function AdminSidebar() { function renderIcon(item: Item, isActive: boolean) {
if (item.iconPath) {
return <img src={item.iconPath} alt="" class={`h-[18px] w-[18px] object-contain ${isActive ? 'opacity-95' : 'opacity-70'}`} />;
}
const Icon = item.icon || FileText;
return <Icon size={18} class={isActive ? 'text-[#0f172a]' : 'text-slate-500'} />;
}
export default function AdminSidebar(props: { onNavigate?: () => void; onLogout?: () => void }) {
const location = useLocation(); const location = useLocation();
const isActive = (href: string, aliasPrefix?: string) => { const active = (item: Item) => {
if (href === '/admin') return location.pathname === '/admin'; if (item.href === '/admin') return location.pathname === '/admin';
if (aliasPrefix && location.pathname.startsWith(aliasPrefix)) return true; if (item.aliasPrefix && location.pathname.startsWith(item.aliasPrefix)) return true;
return location.pathname === href || location.pathname.startsWith(`${href}/`); return location.pathname === item.href || location.pathname.startsWith(`${item.href}/`);
}; };
const NavItem = (item: LinkItem) => {
const active = isActive(item.href, item.aliasPrefix);
return (
<A
href={item.href}
activeClass=""
inactiveClass=""
title={item.label}
class={`relative flex min-w-0 items-center gap-2.5 rounded-lg px-3 py-2 text-[13px] font-semibold transition-colors duration-150 ${
active
? 'bg-slate-100 text-[#0a1d37] before:absolute before:left-0 before:top-1/2 before:h-5 before:-translate-y-1/2 before:w-[3px] before:rounded-r-full before:bg-[#fd6216] before:content-[\'\']'
: 'text-[#0a1d37]/55 hover:bg-slate-50 hover:text-[#0a1d37]'
}`}
>
<img
src={`/sidebar-icons/${item.icon}`}
alt=""
class="h-4 w-4 shrink-0"
style={active ? 'opacity:0.85' : 'opacity:0.35'}
/>
<span class="truncate">{item.label}</span>
</A>
);
};
const topLinks = links.filter(l => l.group === '__top__');
const mgmtLinks = links.filter(l => l.group === 'Management');
const finLinks = links.filter(l => l.group === 'Finance');
const platLinks = links.filter(l => l.group === 'Platform');
return ( return (
<aside class="flex h-full w-60 flex-col border-r border-slate-100 bg-white"> <aside class="flex h-full w-[310px] flex-col border-r border-[#d7d8df] bg-[#f6f6f8]">
<nav class="scrollbar flex-1 overflow-y-auto px-6 py-5">
<For each={items}>
{(item) => {
const isActive = active(item);
return (
<>
<Show when={item.separatorBefore}>
<div class="my-4 border-t border-[#dfdfe5]" />
</Show>
<A
href={item.href}
onClick={() => props.onNavigate?.()}
class={`group relative mb-1.5 flex min-h-[48px] items-center gap-3 rounded-xl px-4 text-[17px] font-medium leading-tight transition ${
isActive ? 'bg-[#ffece3] text-[#fd6216]' : 'text-[#2f3647] hover:bg-white hover:text-[#111827]'
}`}
>
<span class={`absolute bottom-0 left-0 top-0 w-[4px] rounded-r-md bg-[#fd6216] transition-opacity ${isActive ? 'opacity-100' : 'opacity-0'}`} />
{renderIcon(item, isActive)}
<span class="truncate">{item.label}</span>
</A>
</>
);
}}
</For>
</nav>
{/* Top: Dashboard link */} <div class="border-t border-[#dfdfe5] px-6 py-5">
<div class="px-3 pt-4 pb-2"> <button
{topLinks.map(NavItem)} type="button"
</div> onClick={() => props.onLogout?.()}
class="flex h-[50px] w-full items-center gap-3 rounded-xl px-4 text-left text-[17px] font-semibold text-[#c51d1d] transition hover:bg-[#fff1f1]"
{/* Scrollable groups */} >
<div class="scrollbar flex-1 overflow-y-auto px-3 pb-3" style="min-height:0"> <svg class="h-[18px] w-[18px]" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M17 16l4-4m0 0l-4-4m4 4H9m8 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h8a3 3 0 013 3v1" />
{/* Management */} </svg>
<p class="mt-3 mb-1.5 px-2 text-[10px] font-bold uppercase tracking-widest text-slate-400"> <span>Sign Out</span>
Management </button>
</p>
<div class="space-y-0.5">
{mgmtLinks.map(NavItem)}
</div>
{/* Finance */}
<p class="mt-4 mb-1.5 px-2 text-[10px] font-bold uppercase tracking-widest text-slate-400">
Finance
</p>
<div class="space-y-0.5">
{finLinks.map(NavItem)}
</div>
{/* Platform */}
<p class="mt-4 mb-1.5 px-2 text-[10px] font-bold uppercase tracking-widest text-slate-400">
Platform
</p>
<div class="space-y-0.5">
{platLinks.map(NavItem)}
</div>
</div>
{/* User card */}
<div class="border-t border-slate-100 px-3 py-3">
<div class="flex items-center gap-2.5 rounded-lg bg-slate-50 px-3 py-2.5">
<div class="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-[#0a1d37] text-xs font-black text-white">
A
</div>
<div class="min-w-0 flex-1">
<p class="truncate text-[12px] font-bold text-[#0a1d37]">Admin User</p>
<p class="truncate text-[10px] text-slate-400">master_admin@nxtgauge.io</p>
</div>
</div>
</div> </div>
</aside> </aside>
); );

View file

@ -1,6 +1,7 @@
import { A } from '@solidjs/router'; import { A } from '@solidjs/router';
import { createResource, createSignal, Show } from 'solid-js'; import { createResource, createSignal, Show } from 'solid-js';
import AdminShell from '~/components/AdminShell'; import AdminShell from '~/components/AdminShell';
import { Eye, Pencil, Trash2 } from 'lucide-solid';
const API = '/api/gateway'; const API = '/api/gateway';
@ -50,12 +51,9 @@ export default function InternalRolesPage() {
return ( return (
<AdminShell> <AdminShell>
<div class="mb-6 flex items-start justify-between gap-4"> <div class="mb-8">
<div> <h1 class="text-[52px] font-extrabold tracking-tight text-[#071b3d] sm:text-[36px] lg:text-[52px]">Roles Management</h1>
<h1 class="text-2xl font-bold text-gray-900">Internal Role Management</h1> <p class="mt-2 text-[17px] text-[#4b5563]">Configure and maintain internal system roles and access privileges.</p>
<p class="mt-1 text-sm text-gray-500">Manage internal employee roles and permissions from one clean list.</p>
</div>
<A class="inline-flex items-center rounded-lg bg-[#fd6216] px-4 py-2 text-sm font-semibold text-white hover:bg-orange-600 transition-colors" href="/admin/roles/create">Create Internal Role</A>
</div> </div>
<nav class="hidden" aria-label="Role Management Navigation"> <nav class="hidden" aria-label="Role Management Navigation">
@ -68,55 +66,60 @@ export default function InternalRolesPage() {
<div class="mb-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{deleteError()}</div> <div class="mb-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{deleteError()}</div>
</Show> </Show>
<section class="rounded-xl border border-gray-200 bg-white shadow-sm" style="padding: 0; overflow: hidden;"> <section class="overflow-hidden rounded-[22px] border border-[#d8dbe5] bg-white shadow-[0_14px_28px_-20px_rgba(15,23,42,0.35)]">
<div class="overflow-x-auto"> <div class="overflow-x-auto">
<table class="w-full text-sm"> <table class="w-full min-w-[860px] text-sm">
<thead> <thead>
<tr> <tr class="bg-[#071b3d] text-white">
<th>Name</th> <th class="px-8 py-5 text-left text-[14px] font-semibold uppercase tracking-[0.08em]">ID</th>
<th>Description</th> <th class="px-8 py-5 text-left text-[14px] font-semibold uppercase tracking-[0.08em]">Name</th>
<th class="text-right">Actions</th> <th class="px-8 py-5 text-left text-[14px] font-semibold uppercase tracking-[0.08em]">Issue Type</th>
<th class="px-8 py-5 text-center text-[14px] font-semibold uppercase tracking-[0.08em]">Edit</th>
<th class="px-8 py-5 text-center text-[14px] font-semibold uppercase tracking-[0.08em]">Delete</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<Show when={roles.loading}> <Show when={roles.loading}>
<tr> <tr>
<td colspan="3" style="text-align:center;padding:32px;color:#64748b;">Loading internal roles...</td> <td colspan="5" style="text-align:center;padding:32px;color:#64748b;">Loading internal roles...</td>
</tr> </tr>
</Show> </Show>
<Show when={!roles.loading && roles.error}> <Show when={!roles.loading && roles.error}>
<tr> <tr>
<td colspan="3" style="text-align:center;padding:32px;color:#b91c1c;">Failed to load roles. Is the backend running?</td> <td colspan="5" style="text-align:center;padding:32px;color:#b91c1c;">Failed to load roles. Is the backend running?</td>
</tr> </tr>
</Show> </Show>
<Show when={!roles.loading && !roles.error && roles()?.length === 0}> <Show when={!roles.loading && !roles.error && roles()?.length === 0}>
<tr> <tr>
<td colspan="3" style="text-align:center;padding:32px;color:#94a3b8;">No internal roles found. Create your first role.</td> <td colspan="5" style="text-align:center;padding:32px;color:#94a3b8;">No internal roles found. Create your first role.</td>
</tr> </tr>
</Show> </Show>
<Show when={!roles.loading && !roles.error && (roles()?.length ?? 0) > 0}> <Show when={!roles.loading && !roles.error && (roles()?.length ?? 0) > 0}>
{roles()!.map((role) => ( {roles()!.map((role) => (
<tr> <tr class="border-b border-[#e4e7ef] text-[17px]">
<td> <td class="px-8 py-7 font-medium text-[#364152]">{role.code || role.id?.slice(0, 6).toUpperCase() || 'ROL247'}</td>
<div> <td class="px-8 py-7 font-semibold text-[#0f172a]">{role.name}</td>
<p style="margin:0;font-weight:600;color:#0f172a;">{role.name}</p> <td class="px-8 py-7">
<p style="margin:2px 0 0;font-size:11px;color:#94a3b8;">{role.code || role.id?.slice(0, 8)}</p> <A class="inline-flex items-center gap-2 font-semibold text-[#fd6216] hover:text-orange-700" href={`/admin/roles/${role.id}`} title="View Role" aria-label={`View ${role.name}`}>
</div> <span>View</span>
<Eye size={16} />
</A>
</td> </td>
<td style="color:#475569;">{role.description || 'No description added yet.'}</td> <td class="px-8 py-7 text-center">
<td> <A class="inline-flex h-9 w-9 items-center justify-center rounded-md text-[#071b3d] hover:bg-slate-100" href={`/admin/roles/${role.id}/edit`} title="Edit Role" aria-label={`Edit ${role.name}`}>
<div class="flex items-center justify-end gap-1"> <Pencil size={17} />
<A class="rounded p-1.5 text-gray-500 hover:bg-gray-100 hover:text-gray-700 text-sm" href={`/admin/roles/${role.id}`} title="View Role">👁</A> </A>
<A class="rounded p-1.5 text-gray-500 hover:bg-gray-100 hover:text-gray-700 text-sm" href={`/admin/roles/${role.id}/edit`} title="Edit Role"></A> </td>
<button <td class="px-8 py-7 text-center">
class="rounded p-1.5 text-red-500 hover:bg-red-50 hover:text-red-700 text-sm" <button
disabled={deleting() === role.id} class="inline-flex h-9 w-9 items-center justify-center rounded-md text-[#c81e1e] hover:bg-red-50 disabled:opacity-60"
onClick={() => handleDelete(role.id, role.name)} disabled={deleting() === role.id}
title="Delete Role" onClick={() => handleDelete(role.id, role.name)}
> title="Delete Role"
{deleting() === role.id ? '...' : '🗑'} aria-label={`Delete ${role.name}`}
</button> >
</div> {deleting() === role.id ? '...' : <Trash2 size={17} />}
</button>
</td> </td>
</tr> </tr>
))} ))}
@ -124,6 +127,16 @@ export default function InternalRolesPage() {
</tbody> </tbody>
</table> </table>
</div> </div>
<div class="flex items-center justify-between border-t border-[#e4e7ef] px-8 py-5">
<p class="text-[14px] font-semibold uppercase tracking-[0.1em] text-[#485163]">Showing 1 to 5 of {(roles()?.length || 0) || 5} entries</p>
<div class="flex items-center gap-2">
<button class="h-11 min-w-11 rounded-xl border border-[#d4d8e2] bg-white px-3 text-[#111827]">{'<'}</button>
<button class="h-11 min-w-11 rounded-xl bg-[#fd6216] px-3 font-bold text-white">1</button>
<button class="h-11 min-w-11 rounded-xl border border-[#d4d8e2] bg-white px-3 font-bold text-[#111827]">2</button>
<button class="h-11 min-w-11 rounded-xl border border-[#d4d8e2] bg-white px-3 font-bold text-[#111827]">3</button>
<button class="h-11 min-w-11 rounded-xl border border-[#d4d8e2] bg-white px-3 text-[#111827]">{'>'}</button>
</div>
</div>
</section> </section>
</AdminShell> </AdminShell>
); );

View file

@ -1,7 +1,6 @@
import { A, useSearchParams } from '@solidjs/router'; import { A, useSearchParams } from '@solidjs/router';
import { createMemo, createResource, Show } from 'solid-js'; import { createMemo, createResource, Show } from 'solid-js';
import AdminShell from '~/components/AdminShell'; import AdminShell from '~/components/AdminShell';
import ExternalRoleTabs from '~/components/admin/ExternalRoleTabs';
const API = '/api/gateway'; const API = '/api/gateway';
@ -77,73 +76,49 @@ export default function RuntimeRolesPage() {
return ( return (
<AdminShell> <AdminShell>
<div class="mb-6 flex items-start justify-between gap-4"> <div class="mb-8">
<div> <h1 class="text-[52px] font-extrabold tracking-tight text-[#071b3d] sm:text-[36px] lg:text-[52px]">Roles Management</h1>
<h1 class="text-2xl font-bold text-gray-900">External Role Management</h1> <p class="mt-2 text-[17px] text-[#4b5563]">Configure and maintain external system roles and access privileges.</p>
<p class="mt-1 text-sm text-gray-500">Manage canonical external runtime roles, enabled modules, onboarding assignment, approval gates, and default runtime destinations from one place.</p>
</div>
<div class="flex items-center gap-2">
<A class="inline-flex items-center rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors" href="/admin/role-ui-configs">Inspector</A>
<A class="inline-flex items-center rounded-lg bg-[#fd6216] px-4 py-2 text-sm font-semibold text-white hover:bg-orange-600 transition-colors" href="/admin/runtime-roles/new">Create External Role</A>
</div>
</div> </div>
<ExternalRoleTabs /> <section class="overflow-hidden rounded-[22px] border border-[#d8dbe5] bg-white shadow-[0_14px_28px_-20px_rgba(15,23,42,0.35)]">
<section class="rounded-xl border border-gray-200 bg-white shadow-sm" style="padding: 0; overflow: hidden;">
<div style="display:flex;align-items:center;justify-content:space-between;padding:16px 20px;border-bottom:1px solid #e2e8f0">
<div>
<h2 style="margin:0;font-size:17px;font-weight:700">Published External Roles</h2>
<p style="margin:4px 0 0;font-size:12px;color:#64748b">Only canonical external runtime roles are shown here. Legacy or malformed role rows are hidden from this management surface.</p>
</div>
<Show when={!roles.loading}>
<span style="font-size:13px;color:#64748b">{roles()?.length || 0} roles</span>
</Show>
</div>
<div class="overflow-x-auto"> <div class="overflow-x-auto">
<table class="w-full text-sm"> <table class="w-full min-w-[860px] text-sm">
<thead> <thead>
<tr> <tr class="bg-[#071b3d] text-white">
<th>Role</th> <th class="px-8 py-5 text-left text-[14px] font-semibold uppercase tracking-[0.08em]">ID</th>
<th>Type</th> <th class="px-8 py-5 text-left text-[14px] font-semibold uppercase tracking-[0.08em]">Name</th>
<th>Modules</th> <th class="px-8 py-5 text-left text-[14px] font-semibold uppercase tracking-[0.08em]">Issue Type</th>
<th>Schema</th> <th class="px-8 py-5 text-center text-[14px] font-semibold uppercase tracking-[0.08em]">Edit</th>
<th>Status</th> <th class="px-8 py-5 text-center text-[14px] font-semibold uppercase tracking-[0.08em]">Delete</th>
<th class="text-right">Actions</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<Show when={roles.loading}> <Show when={roles.loading}>
<tr><td colspan="6" style="text-align:center;padding:32px;color:#64748b">Loading external roles...</td></tr> <tr><td colspan="5" style="text-align:center;padding:32px;color:#64748b">Loading external roles...</td></tr>
</Show> </Show>
<Show when={!roles.loading && roles.error}> <Show when={!roles.loading && roles.error}>
<tr><td colspan="6" style="text-align:center;padding:32px;color:#b91c1c">Failed to load external roles. Is the backend running?</td></tr> <tr><td colspan="5" style="text-align:center;padding:32px;color:#b91c1c">Failed to load external roles. Is the backend running?</td></tr>
</Show> </Show>
<Show when={!roles.loading && !roles.error && roles()?.length === 0}> <Show when={!roles.loading && !roles.error && roles()?.length === 0}>
<tr><td colspan="6" style="text-align:center;padding:32px;color:#94a3b8">No external roles configured yet.</td></tr> <tr><td colspan="5" style="text-align:center;padding:32px;color:#94a3b8">No external roles configured yet.</td></tr>
</Show> </Show>
<Show when={!roles.loading && !roles.error && (roles()?.length ?? 0) > 0}> <Show when={!roles.loading && !roles.error && (roles()?.length ?? 0) > 0}>
{roles()!.map((role) => ( {roles()!.map((role) => (
<tr class={selectedRoleKey() === role.roleKey.toLowerCase() ? 'bg-orange-50' : ''}> <tr class={`border-b border-[#e4e7ef] text-[17px] ${selectedRoleKey() === role.roleKey.toLowerCase() ? 'bg-[#fff7f2]' : ''}`}>
<td> <td class="px-8 py-7 font-medium text-[#364152]">{role.roleKey || role.id?.slice(0, 6).toUpperCase()}</td>
<div> <td class="px-8 py-7 font-semibold text-[#0f172a]">{role.displayName}</td>
<p style="margin:0;font-weight:600;color:#0f172a">{role.displayName}</p> <td class="px-8 py-7">
<p style="margin:2px 0 0;font-size:11px;color:#94a3b8">{role.roleKey}</p> <A class="inline-flex items-center gap-2 font-semibold text-[#fd6216] hover:text-orange-700" href={`/admin/runtime-roles/${encodeURIComponent(role.roleKey)}`} target="_blank" rel="noreferrer">
</div> <span>View</span>
<span class="text-[18px]"></span>
</A>
</td> </td>
<td style="color:#475569">{role.vertical || '—'}</td> <td class="px-8 py-7 text-center">
<td style="color:#475569">{role.enabledModules.length}</td> <A class="inline-flex h-9 w-9 items-center justify-center rounded-md text-[#071b3d] hover:bg-slate-100" href={`/admin/runtime-roles/${encodeURIComponent(role.roleKey)}`} title="Edit External Role"></A>
<td style="color:#475569;font-size:12px">{role.onboardingSchemaId || '—'}</td>
<td>
<span class={`inline-flex items-center rounded-full bg-gray-100 px-2.5 py-0.5 text-xs font-medium text-gray-600 ${role.isActive ? 'active' : ''}`}>
{role.isActive ? 'Active' : 'Inactive'}
</span>
</td> </td>
<td> <td class="px-8 py-7 text-center">
<div class="flex items-center justify-end gap-1"> <button class="inline-flex h-9 w-9 items-center justify-center rounded-md text-[#c81e1e] hover:bg-red-50" title="Delete External Role" aria-label={`Delete ${role.displayName}`}>🗑</button>
<A class="rounded p-1.5 text-gray-500 hover:bg-gray-100 hover:text-gray-700 text-sm" href={`/admin/runtime-roles/${encodeURIComponent(role.roleKey)}`} target="_blank" rel="noreferrer" title="View External Role">👁</A>
<A class="rounded p-1.5 text-gray-500 hover:bg-gray-100 hover:text-gray-700 text-sm" href={`/admin/runtime-roles/${encodeURIComponent(role.roleKey)}`} title="Edit External Role"></A>
</div>
</td> </td>
</tr> </tr>
))} ))}
@ -151,6 +126,16 @@ export default function RuntimeRolesPage() {
</tbody> </tbody>
</table> </table>
</div> </div>
<div class="flex items-center justify-between border-t border-[#e4e7ef] px-8 py-5">
<p class="text-[14px] font-semibold uppercase tracking-[0.1em] text-[#485163]">Showing 1 to 5 of {(roles()?.length || 0) || 5} entries</p>
<div class="flex items-center gap-2">
<button class="h-11 min-w-11 rounded-xl border border-[#d4d8e2] bg-white px-3 text-[#111827]">{'<'}</button>
<button class="h-11 min-w-11 rounded-xl bg-[#fd6216] px-3 font-bold text-white">1</button>
<button class="h-11 min-w-11 rounded-xl border border-[#d4d8e2] bg-white px-3 font-bold text-[#111827]">2</button>
<button class="h-11 min-w-11 rounded-xl border border-[#d4d8e2] bg-white px-3 font-bold text-[#111827]">3</button>
<button class="h-11 min-w-11 rounded-xl border border-[#d4d8e2] bg-white px-3 text-[#111827]">{'>'}</button>
</div>
</div>
</section> </section>
</AdminShell> </AdminShell>
); );

View file

@ -1,5 +1,5 @@
import { useNavigate } from '@solidjs/router'; import { useNavigate } from '@solidjs/router';
import { createMemo, createSignal, onMount } from 'solid-js'; import { Show, createMemo, createSignal, onMount } from 'solid-js';
import { isExternalIdentity, pickManagementLoginError } from '~/lib/admin-auth'; import { isExternalIdentity, pickManagementLoginError } from '~/lib/admin-auth';
import { hasAdminSession, setAdminSession } from '~/lib/admin-session'; import { hasAdminSession, setAdminSession } from '~/lib/admin-session';
@ -307,142 +307,154 @@ export default function LoginPage() {
}; };
return ( return (
<main class="auth-page auth-page-login"> <main class="min-h-screen bg-[radial-gradient(circle_at_10%_10%,rgba(253,98,22,0.18),transparent_35%),radial-gradient(circle_at_90%_0%,rgba(34,197,94,0.12),transparent_32%),linear-gradient(180deg,#0f1630_0%,#0b1226_100%)]">
<div class="auth-bg" /> <div class="mx-auto grid min-h-screen w-full max-w-[1240px] grid-cols-1 items-center gap-6 px-4 py-8 lg:grid-cols-[1.05fr_0.95fr] lg:px-8">
<div class="auth-layout"> <section class="relative hidden min-h-[620px] overflow-hidden rounded-[28px] border border-white/20 bg-white/10 p-8 text-white shadow-[0_28px_60px_-34px_rgba(2,6,23,0.88)] backdrop-blur lg:block">
<section class="auth-visual"> <img src="/nxtgauge-logo.png" alt="NXTGAUGE" class="h-12 w-auto brightness-0 invert" />
<p class="auth-visual-kicker">Internal Access</p> <p class="mt-8 inline-flex rounded-full border border-white/30 bg-white/10 px-3 py-1 text-[11px] font-bold uppercase tracking-[0.1em] text-orange-100">Internal Admin Portal</p>
<h1>Welcome back to Nxtgauge.</h1> <h1 class="mt-6 max-w-[520px] text-[52px] font-extrabold leading-[1.05] text-white">Welcome back to Nxtgauge.</h1>
<p>Sign in securely to access the admin control panel.</p> <p class="mt-4 max-w-[520px] text-[17px] leading-relaxed text-slate-200">Sign in to manage operations, roles, and approval workflows from one secure control panel.</p>
<img <div class="absolute bottom-8 left-8 right-8 rounded-2xl border border-white/20 bg-white/10 p-4 text-[14px] text-slate-100">
src="https://images.unsplash.com/photo-1497366216548-37526070297c?auto=format&fit=crop&w=1200&q=80" <p class="font-semibold">Secure login with internal access policies.</p>
alt="Office workspace" </div>
/>
</section> </section>
<section class="auth-card auth-form-card"> <section class="rounded-[28px] border border-white/30 bg-white/95 p-6 shadow-[0_28px_60px_-34px_rgba(2,6,23,0.88)] backdrop-blur sm:p-8">
<img src="/nxtgauge-logo.png" alt="NXTGAUGE" class="auth-logo" /> <div class="mx-auto w-full max-w-[560px]">
<h2 class="auth-title">{mode() === 'login' ? 'Employee Login' : 'Reset Password'}</h2> <h2 class="text-[46px] font-extrabold tracking-tight text-[#101228] sm:text-[38px] lg:text-[46px]">{mode() === 'login' ? 'Sign In' : 'Reset Password'}</h2>
<p class="mt-2 text-[15px] text-[#535e7a]">{mode() === 'login' ? 'Internal team access only.' : 'Use your internal email to reset access.'}</p>
<form class="auth-form-grid"> <form class="mt-6 space-y-5">
<div class="field"> <div class="space-y-4">
<label>Email address</label> <div>
<input <label class="mb-1 block text-[11px] font-bold uppercase tracking-[0.11em] text-[#4b546f]">Email</label>
type="email"
value={email()}
onInput={(event) => {
setEmail(event.currentTarget.value);
clearMessages();
}}
placeholder="Enter your email"
/>
</div>
{mode() === 'login' ? (
<>
<div class="field">
<label>Password</label>
<input <input
type="password" type="email"
value={password()} value={email()}
onInput={(event) => { onInput={(event) => {
setPassword(event.currentTarget.value); setEmail(event.currentTarget.value);
clearMessages(); clearMessages();
}} }}
placeholder="Enter your password" placeholder="Enter your email"
/> class="h-11 w-full rounded-xl border border-[#cfd4e3] bg-white px-3.5 text-[15px] text-[#101228] outline-none transition focus:border-[#fd6216] focus:ring-2 focus:ring-[#ffd8c3]"
</div>
<div class="auth-switch">
<button type="button" class="auth-link-btn" onClick={() => switchMode('reset')}>
Forgot password?
</button>
</div>
<div class="actions">
<button
type="button"
class="btn primary block-btn"
disabled={isSubmitting() || !canSubmitLoginCredentials()}
onClick={directSignIn}
>
{isSubmitting() ? 'Signing in...' : 'Sign in'}
</button>
</div>
</>
) : (
<>
<div class="field">
<label>New password</label>
<input
type="password"
value={newPassword()}
onInput={(event) => {
setNewPassword(event.currentTarget.value);
clearMessages();
}}
placeholder="Enter your new password"
/>
</div>
<div class="field">
<label>Confirm password</label>
<input
type="password"
value={confirmPassword()}
onInput={(event) => {
setConfirmPassword(event.currentTarget.value);
clearMessages();
}}
placeholder="Confirm your new password"
/> />
</div> </div>
{resetStep() === 'verify' ? ( <Show when={mode() === 'login'}>
<div class="field"> <div>
<label>Verification code</label> <div class="mb-1 flex items-center justify-between gap-2">
<label class="text-[11px] font-bold uppercase tracking-[0.11em] text-[#4b546f]">Password</label>
<button type="button" class="text-[12px] font-bold text-[#fd6216] underline" onClick={() => switchMode('reset')}>
Forgot?
</button>
</div>
<input <input
type="text" type="password"
inputMode="numeric" value={password()}
maxLength={6}
value={resetCode()}
onInput={(event) => { onInput={(event) => {
setResetCode(event.currentTarget.value.replace(/\D/g, '').slice(0, 6)); setPassword(event.currentTarget.value);
clearMessages(); clearMessages();
}} }}
placeholder="Enter 6-digit code" placeholder="Enter your password"
class="h-11 w-full rounded-xl border border-[#cfd4e3] bg-white px-3.5 text-[15px] text-[#101228] outline-none transition focus:border-[#fd6216] focus:ring-2 focus:ring-[#ffd8c3]"
/> />
<p class="hint">Code sent to {maskedEmail() || email()}.</p>
</div> </div>
) : null} </Show>
<div class="auth-switch"> <Show when={mode() === 'reset'}>
<button type="button" class="auth-link-btn" onClick={() => switchMode('login')}> <div class="space-y-4">
<div>
<label class="mb-1 block text-[11px] font-bold uppercase tracking-[0.11em] text-[#4b546f]">New Password</label>
<input
type="password"
value={newPassword()}
onInput={(event) => {
setNewPassword(event.currentTarget.value);
clearMessages();
}}
placeholder="Minimum 8 characters"
class="h-11 w-full rounded-xl border border-[#cfd4e3] bg-white px-3.5 text-[15px] text-[#101228] outline-none transition focus:border-[#fd6216] focus:ring-2 focus:ring-[#ffd8c3]"
/>
</div>
<div>
<label class="mb-1 block text-[11px] font-bold uppercase tracking-[0.11em] text-[#4b546f]">Confirm Password</label>
<input
type="password"
value={confirmPassword()}
onInput={(event) => {
setConfirmPassword(event.currentTarget.value);
clearMessages();
}}
placeholder="Confirm new password"
class="h-11 w-full rounded-xl border border-[#cfd4e3] bg-white px-3.5 text-[15px] text-[#101228] outline-none transition focus:border-[#fd6216] focus:ring-2 focus:ring-[#ffd8c3]"
/>
</div>
<Show when={resetStep() === 'verify'}>
<div>
<label class="mb-1 block text-[11px] font-bold uppercase tracking-[0.11em] text-[#4b546f]">Verification Code</label>
<input
type="text"
inputMode="numeric"
maxLength={6}
value={resetCode()}
onInput={(event) => {
setResetCode(event.currentTarget.value.replace(/\D/g, '').slice(0, 6));
clearMessages();
}}
placeholder="000000"
class="h-11 w-full rounded-xl border border-[#cfd4e3] bg-white px-3.5 text-center text-[18px] tracking-[0.2em] text-[#101228] outline-none transition focus:border-[#fd6216] focus:ring-2 focus:ring-[#ffd8c3]"
/>
<p class="mt-2 rounded-xl border border-emerald-200 bg-emerald-50 px-3 py-2 text-[12px] text-emerald-900">
Code sent to <span class="font-semibold">{maskedEmail() || email()}</span>.
</p>
</div>
</Show>
</div>
</Show>
</div>
<Show when={mode() === 'login'}>
<button
type="button"
class="h-11 w-full rounded-xl bg-[#fd6216] text-[15px] font-bold text-white transition hover:bg-[#e4570f] disabled:cursor-not-allowed disabled:opacity-70"
disabled={isSubmitting() || !canSubmitLoginCredentials()}
onClick={directSignIn}
>
{isSubmitting() ? 'Signing In...' : 'Sign In'}
</button>
</Show>
<Show when={mode() === 'reset'}>
<div class="flex items-center justify-between">
<button type="button" class="text-[13px] font-semibold text-[#fd6216] underline" onClick={() => switchMode('login')}>
Back to sign in Back to sign in
</button> </button>
</div> </div>
<div class="actions"> <Show when={resetStep() === 'request'} fallback={(
{resetStep() === 'request' ? ( <button
<button type="button"
type="button" class="h-11 w-full rounded-xl bg-[#fd6216] text-[15px] font-bold text-white transition hover:bg-[#e4570f] disabled:cursor-not-allowed disabled:opacity-70"
class="btn primary block-btn" disabled={isSubmitting() || !canSubmitResetVerify()}
disabled={isSubmitting() || !canSubmitResetRequest()} onClick={verifyResetCode}
onClick={requestResetCode} >
> {isSubmitting() ? 'Resetting Password...' : 'Verify & Reset Password'}
{isSubmitting() ? 'Sending code...' : 'Send reset code'} </button>
</button> )}>
) : ( <button
<button type="button"
type="button" class="h-11 w-full rounded-xl bg-[#fd6216] text-[15px] font-bold text-white transition hover:bg-[#e4570f] disabled:cursor-not-allowed disabled:opacity-70"
class="btn primary block-btn" disabled={isSubmitting() || !canSubmitResetRequest()}
disabled={isSubmitting() || !canSubmitResetVerify()} onClick={requestResetCode}
onClick={verifyResetCode} >
> {isSubmitting() ? 'Sending Code...' : 'Send Reset Code'}
{isSubmitting() ? 'Resetting password...' : 'Verify & reset password'} </button>
</button> </Show>
)} </Show>
</div> </form>
</>
)} {info() ? <p class="mt-4 rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-[14px] text-emerald-700">{info()}</p> : null}
</form> {error() ? <p class="mt-4 rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-[14px] text-red-700">{error()}</p> : null}
{info() ? <p class="inline-note auth-inline-msg">{info()}</p> : null} </div>
{error() ? <p class="error-note auth-inline-msg">{error()}</p> : null}
</section> </section>
</div> </div>
</main> </main>