chore: checkpoint current workspace changes

This commit is contained in:
Ashwin Kumar 2026-03-25 22:15:06 +01:00
parent 13b428913f
commit 3f20065257
9 changed files with 1578 additions and 1401 deletions

View file

@ -4,7 +4,7 @@
{
"name": "admin-solid",
"runtimeExecutable": "sh",
"runtimeArgs": ["-c", "cd /Users/ashwin/workspace/nxtgauge-admin-solid && npm run dev -- --port 3020 --host"],
"runtimeArgs": ["-c", "cd /Users/ashwin/workspace/nxtgauge-admin-solid && npm run dev"],
"port": 3020
}
]

View file

@ -3,29 +3,16 @@ import {
For, Show, createEffect, createMemo, createSignal,
onCleanup, onMount, type JSX,
} from 'solid-js';
import { Bell, Search, Settings, User } from 'lucide-solid';
import AdminSidebar from './AdminSidebar';
import { isExternalIdentity } from '~/lib/admin-auth';
import { clearAdminSession, hasAdminSession, setAdminSession } from '~/lib/admin-session';
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 ─────────────────────────────────────────────────────────────────
type SearchGroup = { label: string; viewAllHref: string; results: SearchResult[] };
const TAB_SETS: Array<{ prefixes: string[]; tabs: Tab[] }> = [
{
prefixes: ['/admin/roles'],
tabs: [
{ href: '/admin/roles', label: 'Roles', exact: true },
{ href: '/admin/roles/create', label: 'Create Role' },
{ href: '/admin/roles/templates', label: 'View Roles' },
],
},
{
prefixes: ['/admin/runtime-roles'],
tabs: [
@ -36,8 +23,6 @@ const TAB_SETS: Array<{ prefixes: string[]; tabs: Tab[] }> = [
},
];
// ── Global search ─────────────────────────────────────────────────────────────
const SEARCH_MODULES = [
{
label: 'Users',
@ -84,15 +69,6 @@ const SEARCH_MODULES = [
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 {
@ -107,9 +83,9 @@ function extractList(data: any, keys: string[]): any[] {
}
function GlobalSearch() {
const [query, setQuery] = createSignal('');
const [open, setOpen] = createSignal(false);
const [groups, setGroups] = createSignal<SearchGroup[]>([]);
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>;
@ -120,9 +96,7 @@ function GlobalSearch() {
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);
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;
@ -149,198 +123,67 @@ function GlobalSearch() {
setQuery(val);
clearTimeout(timer);
if (val.trim().length < 2) { setGroups([]); setOpen(false); return; }
timer = setTimeout(() => doSearch(val), 400);
timer = setTimeout(() => doSearch(val), 350);
};
const close = () => { setOpen(false); setQuery(''); setGroups([]); };
const onOutside = (e: MouseEvent) => { if (!wrapRef.contains(e.target as Node)) setOpen(false); };
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"
/>
<div ref={wrapRef!} class="relative w-full max-w-[418px]">
<Search size={20} class="pointer-events-none absolute left-4 top-1/2 -translate-y-1/2 text-[#9498ad]" />
<input
type="text"
placeholder="Search for anything..."
value={query()}
placeholder="Search for anything..."
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"
class="h-14 w-full rounded-2xl border-2 border-transparent bg-[#f9fafb] pl-12 pr-4 text-[16px] text-[#000032] placeholder:text-[rgba(0,0,50,0.4)] outline-none transition-all focus:border-[#e5e7eb] focus:bg-white"
/>
{/* 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">
<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-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 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-[#fa5014]">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(250,80,20,0.12)] text-[12px] font-bold text-[#fa5014]">
{item.title.trim().slice(0, 1).toUpperCase()}
</div>
<div class="min-w-0">
<p class="truncate text-[13px] font-semibold text-[#000032]">{item.title}</p>
<p class="truncate text-[12px] text-[rgba(0,0,50,0.55)]">{item.subtitle}</p>
</div>
</A>
)}
</For>
</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 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(0,0,50,0.55)]">
No results for "{query()}"
</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;
@ -350,10 +193,7 @@ function ShowTabs(props: {
}) {
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"
>
<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
@ -361,9 +201,7 @@ function ShowTabs(props: {
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'
props.isTabActive(tab) ? 'text-[#fa5014]' : 'text-[rgba(0,0,50,0.6)] hover:text-[#000032]'
}`}
>
{tab.label}
@ -371,38 +209,32 @@ function ShowTabs(props: {
)}
</For>
<div
class={`absolute bottom-0 h-[2px] bg-[#fd6216] transition-all duration-300 ease-out ${
props.tabIndicator().ready ? 'opacity-100' : 'opacity-0'
}`}
class={`absolute bottom-0 h-[2px] bg-[#fa5014] 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 User');
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 [notifCount] = createSignal(0);
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 [];
});
@ -422,7 +254,8 @@ export default function AdminShell(props: { children: JSX.Element }) {
};
createEffect(() => {
tabs(); location.pathname;
tabs();
location.pathname;
requestAnimationFrame(refreshTabIndicator);
});
@ -433,12 +266,10 @@ export default function AdminShell(props: { children: JSX.Element }) {
const isLocalDev = typeof window !== 'undefined' &&
(window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1');
const isPreview = searchParams._preview === '1' ||
(typeof sessionStorage !== 'undefined' &&
sessionStorage.getItem('nxtgauge_admin_preview') === '1');
(typeof sessionStorage !== 'undefined' && sessionStorage.getItem('nxtgauge_admin_preview') === '1');
if (isPreview || isLocalDev) {
if (typeof sessionStorage !== 'undefined')
sessionStorage.setItem('nxtgauge_admin_preview', '1');
if (typeof sessionStorage !== 'undefined') sessionStorage.setItem('nxtgauge_admin_preview', '1');
setAdminSession();
setCheckedSession(true);
return;
@ -471,23 +302,10 @@ export default function AdminShell(props: { children: JSX.Element }) {
navigate(`/login?from=${encodeURIComponent(location.pathname + location.search)}`, { replace: true });
}
};
void verify();
});
const onLogout = async () => {
await fetch('/api/gateway/users/auth/logout', {
method: 'POST',
headers: { Accept: 'application/json', 'x-portal-target': 'admin' },
credentials: 'include',
}).catch(() => {});
clearAdminSession();
if (typeof sessionStorage !== 'undefined') {
sessionStorage.removeItem('nxtgauge_admin_access_token');
sessionStorage.removeItem('nxtgauge_admin_preview');
}
navigate('/login', { replace: true });
};
const adminInitials = createMemo(() => {
const parts = adminName().split(' ').map((s) => s.trim()).filter(Boolean);
if (parts.length === 0) return 'U';
@ -495,96 +313,16 @@ export default function AdminShell(props: { children: JSX.Element }) {
return `${parts[0][0]}${parts[1][0]}`.toUpperCase();
});
// 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 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>
</div>
{/* Search bar */}
<div class="flex flex-1 items-center px-5">
<GlobalSearch />
</div>
{/* Mobile menu button */}
<button
type="button"
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-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 + Gear + User */}
<div class="hidden items-center gap-1 pr-4 lg:flex">
<NotificationBell count={notifCount()} />
<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>
<div class="mx-2 h-6 w-px bg-gray-200" />
<UserMenu
name={adminName()}
initials={adminInitials()}
onLogout={onLogout}
/>
</div>
</header>
{/* ── Body ── */}
<div class="min-h-screen bg-[#f3f4f6] text-[#000032]">
<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>
}
fallback={<div class="flex min-h-screen items-center justify-center text-[14px] text-[rgba(0,0,50,0.55)]">Checking session</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'
}`}
onClick={() => setSidebarOpen(false)}
/>
<div class="flex min-h-screen">
<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)} />
{/* 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' : ''
}`}
>
<div class={`fixed inset-y-0 left-0 z-30 -translate-x-full transition-transform duration-200 lg:relative lg:translate-x-0 ${sidebarOpen() ? 'translate-x-0' : ''}`}>
<AdminSidebar
collapsed={sidebarCollapsed()}
onToggle={() => setSidebarCollapsed((v) => !v)}
@ -594,17 +332,49 @@ export default function AdminShell(props: { children: JSX.Element }) {
/>
</div>
{/* Main content */}
<main class="scrollbar min-w-0 flex-1 overflow-y-auto bg-gray-50 p-6">
<ShowTabs
tabs={tabs()}
isTabActive={isTabActive}
setTabsTrackEl={setTabsTrackEl}
setTabRefs={setTabRefs}
tabIndicator={tabIndicator}
/>
{props.children}
</main>
<div class="flex min-w-0 flex-1 flex-col">
<header class="h-[97px] border-b border-[#e5e7eb] bg-white shadow-[0px_1px_3px_0px_rgba(0,0,0,0.1),0px_1px_2px_0px_rgba(0,0,0,0.1)]">
<div class="mx-auto flex h-full w-full max-w-[783px] items-center justify-between px-6">
<GlobalSearch />
<div class="flex items-center gap-3">
<button type="button" class="relative inline-flex h-11 w-11 items-center justify-center rounded-[14px] text-[#000032] hover:bg-[#f9fafb]" aria-label="Notifications">
<Bell size={20} />
<Show when={notifCount() > 0}>
<span class="absolute right-2 top-2 h-2.5 w-2.5 rounded-full border-2 border-white bg-[#fa5014]" />
</Show>
</button>
<button type="button" class="inline-flex h-11 w-11 items-center justify-center rounded-[14px] text-[#000032] hover:bg-[#f9fafb]" aria-label="Settings">
<Settings size={20} />
</button>
<div class="flex items-center gap-3 border-l-2 border-[#e5e7eb] pl-[18px]">
<div class="hidden text-right sm:block">
<p class="text-[14px] font-semibold leading-5 text-[#000032]">{adminName()}</p>
<p class="text-[12px] leading-4 text-[rgba(0,0,50,0.5)]">Super Admin</p>
</div>
<button type="button" class="inline-flex h-11 w-11 items-center justify-center rounded-[14px] bg-gradient-to-br from-[#fa5014] to-[#ff6b3d] text-[14px] font-bold text-white shadow-[0px_10px_15px_0px_rgba(0,0,0,0.1),0px_4px_6px_0px_rgba(0,0,0,0.1)]">
{adminInitials()}
</button>
</div>
<button type="button" class="inline-flex h-11 w-11 items-center justify-center rounded-[14px] text-[#71759a] lg:hidden" onClick={() => setSidebarOpen((v) => !v)}>
<User size={20} />
</button>
</div>
</div>
</header>
<div class="min-h-0 flex-1 overflow-y-auto">
<main class="mx-auto w-full max-w-[783px] px-6 pb-8 pt-6">
<ShowTabs
tabs={tabs()}
isTabActive={isTabActive}
setTabsTrackEl={setTabsTrackEl}
setTabRefs={setTabRefs}
tabIndicator={tabIndicator}
/>
{props.children}
</main>
</div>
</div>
</div>
</Show>
</div>

View file

@ -17,69 +17,61 @@ type NavItem = {
aliasPrefix?: string;
};
// 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 },
{ 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, aliasPrefix: '/admin/roles' },
{ 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' },
{ 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 },
{ 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 },
{ 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 },
{ 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 },
{ 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 },
{ 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 },
],
];
@ -93,47 +85,40 @@ export default function AdminSidebar(props: {
const location = useLocation();
const isActive = (item: NavItem) => {
if (item.href === '/admin') return location.pathname === '/admin';
if (location.pathname === '/admin') return item.href === '/admin/department';
if (item.href === '/admin') return false;
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-gray-200 bg-white transition-all duration-300 ${
props.collapsed ? 'w-[72px]' : 'w-64'
class={`flex h-full flex-col border-r border-[#e5e7eb] bg-white shadow-[0px_20px_25px_0px_rgba(0,0,0,0.1),0px_8px_10px_0px_rgba(0,0,0,0.1)] transition-all duration-300 ${
props.collapsed ? 'w-[92px]' : 'w-[288px]'
}`}
>
{/* ── Collapse toggle ── */}
<div
class={`flex shrink-0 items-center border-b border-gray-100 px-3 py-3 ${
props.collapsed ? 'justify-center' : 'justify-end'
}`}
>
<div class="relative h-[101px] shrink-0 border-b border-[#e5e7eb] px-8 pt-8">
<A href="/admin" class="inline-flex h-[36px] w-[54px] items-center justify-center" onClick={props.onNavigate}>
<img src="/nxtgauge-icon.png" alt="Nxtgauge" class="h-[36px] w-auto object-contain" />
</A>
<button
type="button"
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"
class="absolute right-6 top-8 inline-flex h-5 w-5 items-center justify-center text-[#9195ad] hover:text-[#000032]"
aria-label="Toggle sidebar"
>
<ChevronLeft
size={16}
class={`transition-transform duration-300 ${props.collapsed ? 'rotate-180' : ''}`}
/>
<ChevronLeft size={20} class={`${props.collapsed ? 'rotate-180' : ''} transition-transform`} />
</button>
</div>
{/* ── Navigation ── */}
<nav class="scrollbar min-h-0 flex-1 overflow-y-auto py-2">
<nav class="scrollbar min-h-0 flex-1 overflow-y-auto px-4 py-6">
<For each={GROUPS}>
{(group, gi) => (
<>
{/* Divider between groups */}
<Show when={gi() > 0}>
<div class="mx-3 my-2 border-t border-gray-100" />
<div class="my-4 h-px bg-[#e5e7eb]" />
</Show>
<div class="px-2">
<div class="space-y-0.5">
<For each={group}>
{(item) => {
const active = () => isActive(item);
@ -143,27 +128,19 @@ export default function AdminSidebar(props: {
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'
} ${
class={`relative flex h-12 items-center rounded-[14px] px-4 text-[14px] font-medium leading-5 transition-colors ${
active()
? 'bg-orange-50 text-gray-900'
: 'text-gray-600 hover:bg-gray-50 hover:text-gray-900'
? 'bg-[rgba(250,80,20,0.1)] text-[#fa5014]'
: 'text-[#000032] hover:bg-[#f9fafb]'
}`}
aria-current={active() ? 'page' : undefined}
>
{/* 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" />
<span class="absolute left-0 top-2 h-8 w-1 rounded-r-full bg-[#fa5014]" />
</Show>
<Icon
size={17}
class={`shrink-0 ${active() ? 'text-orange-500' : 'text-gray-400'}`}
/>
<Icon size={20} class={`${active() ? 'text-[#fa5014]' : 'text-[#71759a]'} shrink-0`} />
<Show when={!props.collapsed}>
<span class="min-w-0 flex-1 truncate">{item.label}</span>
<span class="ml-4 truncate">{item.label}</span>
</Show>
</A>
);
@ -175,20 +152,15 @@ export default function AdminSidebar(props: {
</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">
<div class="h-[99px] shrink-0 border-t border-[#e5e7eb] bg-[#f9fafb] px-4 pt-[17px]">
<div class={`flex h-[66px] items-center rounded-[14px] border border-[#e5e7eb] bg-white px-[17px] ${props.collapsed ? 'justify-center' : 'gap-3'}`}>
<div class="flex h-10 w-10 items-center justify-center rounded-full bg-gradient-to-br from-[#fa5014] to-[#ff6b3d] text-[14px] font-bold text-white shadow-[0px_4px_6px_0px_rgba(0,0,0,0.1),0px_2px_4px_0px_rgba(0,0,0,0.1)]">
{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 class="min-w-0">
<p class="truncate text-[14px] font-semibold leading-5 text-[#000032]">{props.adminName}</p>
<p class="text-[12px] leading-4 text-[rgba(0,0,50,0.5)]">Super Admin</p>
</div>
</Show>
</div>

View file

@ -1,589 +1,266 @@
import {
For, Show, createResource, createSignal, onCleanup, onMount,
} from 'solid-js';
import { For, onCleanup, onMount } from 'solid-js';
import { A } from '@solidjs/router';
import { Users, Building2, TrendingUp, CreditCard, Download, Eye, Pencil, Trash2 } from 'lucide-solid';
import AdminShell from '~/components/AdminShell';
import {
Users, Building2, TrendingUp, CreditCard, Clock, BadgeCheck,
BriefcaseBusiness, HeadphonesIcon, Download, Plus, X, GripVertical,
} from 'lucide-solid';
import {
DragDropProvider, DragDropSensors, SortableProvider,
createSortable, closestCenter, type DragEventHandler,
} from '@thisbeyond/solid-dnd';
// ── Types ─────────────────────────────────────────────────────────────────────
type StatWidget = {
type StatDef = {
id: string;
label: string;
icon: any;
apiPath: string;
totalKey: string[];
prefix?: string;
deltaLabel: string;
deltaValue: string;
deltaUp: boolean;
href: string;
value: string;
delta: string;
deltaPositive?: boolean;
subtext: string;
};
type ChartWidget = {
id: string;
label: string;
subtitle: string;
type: 'line' | 'bar';
};
// ── Stat widget definitions ───────────────────────────────────────────────────
const STAT_DEFS: StatWidget[] = [
const STAT_DEFS: StatDef[] = [
{
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',
value: '12,458',
delta: '+12.5%',
deltaPositive: true,
subtext: '+1,245 from last month',
},
{
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',
value: '1,234',
delta: '+8.2%',
deltaPositive: true,
subtext: '+94 from last month',
},
{
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',
value: '847',
delta: '-3.1%',
deltaPositive: false,
subtext: '-27 from last month',
},
{
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',
value: '$45,890',
delta: '+18.7%',
deltaPositive: true,
subtext: '+$7,234 from last month',
},
];
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' },
type RecentLead = {
title: string;
customer: string;
category: string;
budget: string;
status: 'Active' | 'Pending' | 'Negotiating';
};
const RECENT_LEADS: RecentLead[] = [
{ title: 'Website Redesign Project', customer: 'TechCorp Inc.', category: 'Developers', budget: '$15,000', status: 'Active' },
{ title: 'Corporate Event Photography', customer: 'EventMasters LLC', category: 'Photographer', budget: '$3,500', status: 'Pending' },
{ title: 'Marketing Campaign Design', customer: 'BrandHub Co.', category: 'Graphics Designer', budget: '$8,200', status: 'Active' },
{ title: 'Social Media Management', customer: 'GrowthStart', category: 'Social Media Manager', budget: '$5,000', status: 'Negotiating' },
];
// Default layout
const DEFAULT_STATS = ['total-users', 'active-companies', 'open-leads', 'credits-purchased'];
const DEFAULT_CHARTS = ['leads-trend', 'revenue-overview'];
const STORAGE_KEY = 'nxtgauge_admin_dash_v1';
function loadLayout(): { stats: string[]; charts: string[] } {
if (typeof localStorage === 'undefined') return { stats: DEFAULT_STATS, charts: DEFAULT_CHARTS };
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw) return JSON.parse(raw);
} catch { /* */ }
return { stats: DEFAULT_STATS, charts: DEFAULT_CHARTS };
function classForStatus(status: RecentLead['status']) {
if (status === 'Active') return 'bg-[rgba(250,80,20,0.1)] border-[rgba(250,80,20,0.2)] text-[#fa5014]';
if (status === 'Pending') return 'bg-[rgba(0,0,50,0.1)] border-[rgba(0,0,50,0.2)] text-[#000032]';
return 'bg-[#f9fafb] border-[#e5e7eb] text-[rgba(0,0,50,0.6)]';
}
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 }) {
function MiniChart(props: { type: 'line' | 'bar' }) {
let el!: HTMLDivElement;
let chartInstance: any = null;
let chart: any = null;
// onCleanup must be registered synchronously (outside async) to stay in reactive scope
onCleanup(() => { chartInstance?.destroy(); chartInstance = null; });
onCleanup(() => { chart?.destroy(); chart = 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 isLine = props.type === 'line';
const options: Record<string, any> = {
chart = new ApexCharts(el, {
chart: {
type: props.type,
height: props.height ?? 240,
height: 240,
toolbar: { show: false },
fontFamily: 'Exo 2, sans-serif',
animations: { enabled: true, speed: 400 },
},
grid: {
borderColor: '#f1f5f9',
borderColor: '#e5e7eb',
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' } },
labels: { style: { colors: '#000032', fontSize: '11px', fontWeight: 700 } },
},
tooltip: { theme: 'light', style: { fontFamily: 'Exo 2, sans-serif' } },
yaxis: {
labels: { style: { colors: '#000032', fontSize: '11px', fontWeight: 700 } },
},
tooltip: { theme: 'light' },
legend: { show: false },
dataLabels: { enabled: false },
...(isLine
? {
series: [{ name: 'Leads', data: [65, 59, 80, 81, 90, 115] }],
stroke: { curve: 'smooth', width: 2.5, colors: ['#fd6216'] },
series: [{ name: 'Total Leads', data: [65, 75, 90, 80, 95, 110] }],
stroke: { width: 3, curve: 'smooth', colors: ['#000032'] },
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 },
],
opacityFrom: 0.2,
opacityTo: 0.01,
colorStops: [{ offset: 0, color: '#000032', opacity: 0.16 }, { offset: 100, color: '#000032', opacity: 0.01 }],
},
},
markers: { size: 0 },
colors: ['#fd6216'],
yaxis: { labels: { style: { colors: '#94a3b8', fontSize: '12px' } }, min: 0, max: 130, tickAmount: 4 },
colors: ['#000032'],
yaxis: { min: 0, max: 120, tickAmount: 4, labels: { style: { colors: '#000032', fontSize: '11px', fontWeight: 700 } } },
}
: {
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`,
},
},
series: [{ name: 'Revenue', data: [42000, 48000, 55000, 51000, 62000, 69000] }],
plotOptions: { bar: { columnWidth: '38%', borderRadius: 4 } },
colors: ['#000032'],
yaxis: { min: 0, max: 80000, tickAmount: 4, labels: { formatter: (v: number) => `${v}` } },
}),
};
});
chartInstance = new ApexCharts(el, options);
await chartInstance.render();
await chart.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));
function StatCard(props: { def: StatDef }) {
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 class="rounded-2xl border-2 border-[#e5e7eb] bg-white px-[22px] pb-[22px] pt-[22px]">
<div class="mb-3 flex items-start justify-between">
<div class="flex h-10 w-10 items-center justify-center rounded-xl bg-[rgba(250,80,20,0.06)]">
<Icon size={22} class="text-[#fa5014]" />
</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 class={`flex h-6 items-center gap-1 rounded-[10px] px-[10px] text-[12px] font-bold ${props.def.deltaPositive ? 'bg-[rgba(250,80,20,0.1)] text-[#fa5014]' : 'bg-[rgba(0,0,50,0.1)] text-[#000032]'}`}>
<span>{props.def.deltaPositive ? '↗' : '↘'}</span>
<span>{props.def.delta}</span>
</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 class="text-[12px] font-medium leading-4 text-[rgba(0,0,50,0.6)]">{props.def.label}</p>
<p class="mt-1 text-[24px] font-bold leading-[32px] text-[#000032]">
{props.def.value}
</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>
<p class="text-[12px] leading-4 text-[rgba(0,0,50,0.5)]">{props.def.subtext}</p>
</div>
);
}
// ── Sortable chart card ───────────────────────────────────────────────────────
function SortableChartCard(props: {
def: ChartWidget;
onRemove: () => void;
editMode: boolean;
}) {
const sortable = createSortable(props.def.id);
return (
<div
// @ts-ignore
use:sortable
class={`relative rounded-2xl border border-gray-200 bg-white p-5 shadow-sm ${
sortable.isActiveDraggable ? 'opacity-50 shadow-lg ring-2 ring-orange-300' : ''
}`}
>
<Show when={props.editMode}>
<div class="absolute left-2 top-1/2 -translate-y-1/2 cursor-grab text-gray-300 active:cursor-grabbing">
<GripVertical size={16} />
</div>
<button
type="button"
onClick={props.onRemove}
class="absolute -right-2 -top-2 flex h-5 w-5 items-center justify-center rounded-full bg-red-500 text-white shadow-md hover:bg-red-600"
>
<X size={10} />
</button>
</Show>
<div class="mb-4">
<h3 class="text-[15px] font-bold text-gray-900">{props.def.label}</h3>
<p class="mt-0.5 text-[12px] text-gray-400">{props.def.subtitle}</p>
</div>
<Chart id={props.def.id} type={props.def.type} height={220} />
</div>
);
}
// ── Add Widget Panel ──────────────────────────────────────────────────────────
function AddWidgetPanel(props: {
activeStats: string[];
activeCharts: string[];
onAddStat: (id: string) => void;
onAddChart: (id: string) => void;
onClose: () => void;
}) {
const availableStats = () => STAT_DEFS.filter((d) => !props.activeStats.includes(d.id));
const availableCharts = () => CHART_DEFS.filter((d) => !props.activeCharts.includes(d.id));
return (
<div class="rounded-2xl border border-dashed border-orange-300 bg-orange-50/40 p-5">
<div class="mb-4 flex items-center justify-between">
<h3 class="text-[14px] font-bold text-gray-800">Add Widgets</h3>
<button
type="button"
onClick={props.onClose}
class="text-gray-400 hover:text-gray-600"
>
<X size={16} />
</button>
</div>
<Show when={availableStats().length > 0}>
<p class="mb-2 text-[11px] font-bold uppercase tracking-widest text-gray-400">Stat Cards</p>
<div class="mb-4 flex flex-wrap gap-2">
<For each={availableStats()}>
{(def) => {
const Icon = def.icon;
return (
<button
type="button"
onClick={() => props.onAddStat(def.id)}
class="flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 text-[13px] font-medium text-gray-700 shadow-sm transition-all hover:border-orange-300 hover:bg-orange-50 hover:text-orange-600"
>
<Icon size={14} class="text-orange-400" />
{def.label}
<Plus size={12} class="text-gray-400" />
</button>
);
}}
</For>
</div>
</Show>
<Show when={availableCharts().length > 0}>
<p class="mb-2 text-[11px] font-bold uppercase tracking-widest text-gray-400">Charts</p>
<div class="flex flex-wrap gap-2">
<For each={availableCharts()}>
{(def) => (
<button
type="button"
onClick={() => props.onAddChart(def.id)}
class="flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 text-[13px] font-medium text-gray-700 shadow-sm transition-all hover:border-orange-300 hover:bg-orange-50 hover:text-orange-600"
>
{def.label}
<Plus size={12} class="text-gray-400" />
</button>
)}
</For>
</div>
</Show>
<Show when={availableStats().length === 0 && availableCharts().length === 0}>
<p class="text-[13px] text-gray-400">All widgets are already on your dashboard.</p>
</Show>
</div>
);
}
// ── Dashboard page ────────────────────────────────────────────────────────────
export default function AdminDashboard() {
const layout = loadLayout();
const [statIds, setStatIds] = createSignal<string[]>(layout.stats);
const [chartIds, setChartIds] = createSignal<string[]>(layout.charts);
const [editMode, setEditMode] = createSignal(false);
const [showAdd, setShowAdd] = createSignal(false);
// Persist whenever layout changes
const persist = () => saveLayout(statIds(), chartIds());
const statDefs = () => statIds().map((id) => STAT_DEFS.find((d) => d.id === id)!).filter(Boolean);
const chartDefs = () => chartIds().map((id) => CHART_DEFS.find((d) => d.id === id)!).filter(Boolean);
// ── Stat drag ──────────────────────────────────────────────────────────────
const onStatDragEnd: DragEventHandler = ({ draggable, droppable }) => {
if (!droppable || draggable.id === droppable.id) return;
const ids = [...statIds()];
const from = ids.indexOf(String(draggable.id));
const to = ids.indexOf(String(droppable.id));
if (from < 0 || to < 0) return;
ids.splice(to, 0, ...ids.splice(from, 1));
setStatIds(ids);
persist();
};
// ── Chart drag ─────────────────────────────────────────────────────────────
const onChartDragEnd: DragEventHandler = ({ draggable, droppable }) => {
if (!droppable || draggable.id === droppable.id) return;
const ids = [...chartIds()];
const from = ids.indexOf(String(draggable.id));
const to = ids.indexOf(String(droppable.id));
if (from < 0 || to < 0) return;
ids.splice(to, 0, ...ids.splice(from, 1));
setChartIds(ids);
persist();
};
const removeStatWidget = (id: string) => { setStatIds((p) => p.filter((x) => x !== id)); persist(); };
const removeChartWidget = (id: string) => { setChartIds((p) => p.filter((x) => x !== id)); persist(); };
const addStatWidget = (id: string) => { setStatIds((p) => [...p, id]); persist(); setShowAdd(false); };
const addChartWidget = (id: string) => { setChartIds((p) => [...p, id]); persist(); setShowAdd(false); };
const handleExport = () => window.print();
return (
<AdminShell>
<div class="space-y-6">
{/* ── Page header ── */}
<div class="flex items-start justify-between gap-4">
<div class="flex items-center justify-between">
<div>
<h1 class="text-2xl font-black text-gray-900">Dashboard Overview</h1>
<p class="mt-1 text-[13px] text-gray-500">
<h1 class="text-[24px] font-bold leading-8 text-[#000032]">Dashboard Overview</h1>
<p class="mt-1 text-[12px] leading-4 text-[rgba(0,0,50,0.6)]">
Welcome back! Here's what's happening with your platform today.
</p>
</div>
<div class="flex shrink-0 items-center gap-2">
<button
type="button"
onClick={() => { setEditMode((v) => !v); setShowAdd(false); }}
class={`flex items-center gap-2 rounded-xl border px-4 py-2.5 text-[13px] font-semibold transition-all ${
editMode()
? 'border-orange-300 bg-orange-50 text-orange-600'
: 'border-gray-200 bg-white text-gray-700 hover:border-gray-300 hover:bg-gray-50'
}`}
>
{editMode() ? 'Done' : 'Customise'}
</button>
<Show when={editMode()}>
<button
type="button"
onClick={() => setShowAdd((v) => !v)}
class="flex items-center gap-2 rounded-xl border border-dashed border-orange-400 bg-orange-50 px-4 py-2.5 text-[13px] font-semibold text-orange-600 hover:bg-orange-100"
>
<Plus size={15} />
Add Widget
</button>
</Show>
<button
type="button"
onClick={handleExport}
class="flex items-center gap-2 rounded-xl bg-gray-900 px-4 py-2.5 text-[13px] font-semibold text-white transition-colors hover:bg-gray-800"
>
<Download size={15} />
Export Report
</button>
</div>
<button
type="button"
onClick={handleExport}
class="inline-flex h-10 items-center gap-2 rounded-2xl bg-[#000032] px-5 text-[14px] font-medium text-white"
>
<Download size={16} />
Export Report
</button>
</div>
{/* ── Add widget panel ── */}
<Show when={showAdd()}>
<AddWidgetPanel
activeStats={statIds()}
activeCharts={chartIds()}
onAddStat={addStatWidget}
onAddChart={addChartWidget}
onClose={() => setShowAdd(false)}
/>
</Show>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4">
<For each={STAT_DEFS}>{(def) => <StatCard def={def} />}</For>
</div>
{/* ── Stat cards ── */}
<Show when={statDefs().length > 0}>
<DragDropProvider onDragEnd={onStatDragEnd} collisionDetector={closestCenter}>
<DragDropSensors />
<SortableProvider ids={statIds()}>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4">
<For each={statDefs()}>
{(def) => (
<SortableStatCard
def={def}
editMode={editMode()}
onRemove={() => removeStatWidget(def.id)}
/>
)}
</For>
</div>
</SortableProvider>
</DragDropProvider>
</Show>
<div class="grid grid-cols-1 gap-4 lg:grid-cols-2">
<section class="rounded-2xl border-2 border-[#e5e7eb] bg-white px-[22px] pb-[22px] pt-[22px] shadow-[0px_1px_3px_0px_rgba(0,0,0,0.1),0px_1px_2px_0px_rgba(0,0,0,0.1)]">
<h2 class="text-[18px] font-bold leading-7 text-[#000032]">Leads Trend</h2>
<p class="text-[12px] leading-4 text-[rgba(0,0,50,0.6)]">Monthly leads performance overview</p>
<div class="mt-4">
<MiniChart type="line" />
</div>
</section>
{/* ── Charts ── */}
<Show when={chartDefs().length > 0}>
<DragDropProvider onDragEnd={onChartDragEnd} collisionDetector={closestCenter}>
<DragDropSensors />
<SortableProvider ids={chartIds()}>
<div class="grid grid-cols-1 gap-4 lg:grid-cols-2">
<For each={chartDefs()}>
{(def) => (
<SortableChartCard
def={def}
editMode={editMode()}
onRemove={() => removeChartWidget(def.id)}
/>
)}
</For>
</div>
</SortableProvider>
</DragDropProvider>
</Show>
<section class="rounded-2xl border-2 border-[#e5e7eb] bg-white px-[22px] pb-[22px] pt-[22px] shadow-[0px_1px_3px_0px_rgba(0,0,0,0.1),0px_1px_2px_0px_rgba(0,0,0,0.1)]">
<h2 class="text-[18px] font-bold leading-7 text-[#000032]">Revenue Overview</h2>
<p class="text-[12px] leading-4 text-[rgba(0,0,50,0.6)]">Monthly revenue vs expenses comparison</p>
<div class="mt-4">
<MiniChart type="bar" />
</div>
</section>
</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>
<section class="overflow-hidden rounded-2xl border-2 border-[#e5e7eb] bg-white shadow-[0px_1px_3px_0px_rgba(0,0,0,0.1),0px_1px_2px_-1px_rgba(0,0,0,0.1)]">
<div class="flex h-20 items-center justify-between border-b-2 border-[#e5e7eb] bg-gradient-to-r from-white to-[#f9fafb] px-6">
<div>
<h2 class="text-[18px] font-bold leading-7 text-[#000032]">Recent Leads</h2>
<p class="text-[12px] leading-4 text-[rgba(0,0,50,0.6)]">Latest customer inquiries and opportunities</p>
</div>
<A href="/admin/leads" class="inline-flex h-9 items-center rounded-2xl bg-[#000032] px-5 text-[12px] font-semibold text-white">
View All Leads
</A>
</div>
</Show>
<div class="overflow-x-auto">
<table class="w-full min-w-[760px] border-collapse">
<thead>
<tr class="bg-[#f9fafb]">
<th class="px-6 py-3 text-left text-[12px] font-bold uppercase tracking-[0.6px] text-[rgba(0,0,50,0.6)]">Lead Title</th>
<th class="px-6 py-3 text-left text-[12px] font-bold uppercase tracking-[0.6px] text-[rgba(0,0,50,0.6)]">Customer</th>
<th class="px-6 py-3 text-left text-[12px] font-bold uppercase tracking-[0.6px] text-[rgba(0,0,50,0.6)]">Category</th>
<th class="px-6 py-3 text-left text-[12px] font-bold uppercase tracking-[0.6px] text-[rgba(0,0,50,0.6)]">Budget</th>
<th class="px-6 py-3 text-left text-[12px] font-bold uppercase tracking-[0.6px] text-[rgba(0,0,50,0.6)]">Status</th>
<th class="px-6 py-3 text-left text-[12px] font-bold uppercase tracking-[0.6px] text-[rgba(0,0,50,0.6)]">Actions</th>
</tr>
</thead>
<tbody>
<For each={RECENT_LEADS}>
{(lead) => (
<tr class="border-t border-[#e5e7eb]">
<td class="px-6 py-4 text-[14px] font-semibold leading-5 text-[#000032]">{lead.title}</td>
<td class="px-6 py-4 text-[14px] text-[rgba(0,0,50,0.8)]">{lead.customer}</td>
<td class="px-6 py-4 text-[14px] text-[rgba(0,0,50,0.6)]">{lead.category}</td>
<td class="px-6 py-4 text-[14px] font-bold text-[#000032]">{lead.budget}</td>
<td class="px-6 py-4">
<span class={`inline-flex h-[30px] items-center rounded-[10px] border px-3 text-[12px] font-bold ${classForStatus(lead.status)}`}>{lead.status}</span>
</td>
<td class="px-6 py-4">
<div class="flex items-center gap-1.5">
<button type="button" class="inline-flex h-8 w-8 items-center justify-center rounded-[10px] text-[#71759a] hover:bg-[#f9fafb]" aria-label="View">
<Eye size={16} />
</button>
<button type="button" class="inline-flex h-8 w-8 items-center justify-center rounded-[10px] text-[#71759a] hover:bg-[#f9fafb]" aria-label="Edit">
<Pencil size={16} />
</button>
<button type="button" class="inline-flex h-8 w-8 items-center justify-center rounded-[10px] text-[#71759a] hover:bg-[#f9fafb]" aria-label="Delete">
<Trash2 size={16} />
</button>
</div>
</td>
</tr>
)}
</For>
</tbody>
</table>
</div>
</section>
</div>
</AdminShell>
);

View file

@ -44,6 +44,7 @@ export default function KbArticleDetailPage() {
<A class="btn-primary" href={`/admin/kb/articles/${params.id}/edit`}>Edit Article</A>
</div>
</div>
<div class="p-6 flex-1">
<Show when={article.loading}>
<section class="rounded-xl border border-gray-200 bg-white shadow-sm"><p class="notice">Loading article...</p></section>

View file

@ -1,106 +1,193 @@
import { A, useNavigate, useParams } from '@solidjs/router';
import { createEffect, createMemo, createResource, createSignal, Show } from 'solid-js';
import { createEffect, createMemo, createResource, createSignal, For, Show } from 'solid-js';
import AdminShell from '~/components/AdminShell';
import { STATIC_PERMISSIONS, type Permission } from '~/lib/admin-modules';
const API = '/api/gateway';
type Role = { id: string; name: string; description?: string; permissions: Permission[] };
type Permission = { key: string; module: string; action: string };
type Department = { id: string; name: string };
type RoleDetail = {
id: string;
key: string;
name: string;
audience: string;
description?: string;
department_id?: string;
department_name?: string;
is_active: boolean;
can_approve_requests: boolean;
can_manage_system_settings: boolean;
permission_keys: string[];
created_at: string;
};
async function loadRoleAndPerms(id: string) {
const STATIC_MODULES = [
'Department Management', 'Designation Management', 'Internal Role Management',
'Employee Management', 'External Role Management', 'External Onboarding Management',
'Internal Dashboard Management', 'External Dashboard Management', 'Verification Management',
'Approval Management', 'Users Management', 'Company Management', 'Candidate Management',
'Customer Management', 'Photographer Management', 'Makeup Artist Management',
'Tutor Management', 'Developer Management', 'Fitness Trainer Management',
'Graphic Designer Management', 'Social Media Management', 'Video Editor Management',
'Catering Services Management', 'Jobs Management', 'Leads Management',
'Applications Management', 'Responses Management', 'Review Management',
'Pricing Management', 'Credit Management', 'Coupon Management', 'Discount Management',
'Tax Management', 'Order Management', 'Invoice Management', 'Ledger Management',
'Knowledge Base Management', 'Support Management', 'Report Management', 'Notifications',
];
const ACTIONS = ['View', 'Create', 'Update', 'Delete'] as const;
function makeKey(module: string, action: string) {
return `${module.replace(/ /g, '_').toLowerCase()}:${action.toLowerCase()}`;
}
async function loadPermissions(): Promise<Permission[]> {
try {
const [roleRes, permsRes] = await Promise.all([
fetch(`${API}/api/admin/roles/${id}`),
fetch(`${API}/api/admin/permissions`),
]);
if (!roleRes.ok) return null;
const role: Role = await roleRes.json();
const permsData = permsRes.ok ? await permsRes.json() : {};
const fetched: Permission[] = Array.isArray(permsData) ? permsData : (permsData.permissions || []);
const allPerms = fetched.length > 0 ? fetched : STATIC_PERMISSIONS;
return { role, allPerms };
const res = await fetch(`${API}/api/admin/permissions`);
if (!res.ok) throw new Error();
const data = await res.json();
return Array.isArray(data) ? data : [];
} catch {
return STATIC_MODULES.flatMap((module) =>
ACTIONS.map((action) => ({
key: makeKey(module, action),
module,
action,
})),
);
}
}
async function loadDepartments(): Promise<Department[]> {
try {
const res = await fetch(`${API}/api/admin/departments`);
if (!res.ok) return [];
const data = await res.json();
return Array.isArray(data) ? data : (data.departments ?? []);
} catch {
return [];
}
}
async function loadRole(id: string): Promise<RoleDetail | null> {
try {
const res = await fetch(`${API}/api/admin/roles/${id}`);
if (!res.ok) return null;
return res.json();
} catch {
return null;
}
}
type SubTab = 'general' | 'module' | 'settings';
export default function EditInternalRolePage() {
const params = useParams();
const navigate = useNavigate();
const [data] = createResource(() => params.id, loadRoleAndPerms);
const [role] = createResource(() => params.id, loadRole);
const [permissions] = createResource(loadPermissions);
const [departments] = createResource(loadDepartments);
const [subTab, setSubTab] = createSignal<SubTab>('general');
// General Information
const [roleName, setRoleName] = createSignal('');
const [roleCode, setRoleCode] = createSignal('');
const [departmentId, setDepartmentId] = createSignal('');
const [description, setDescription] = createSignal('');
const [assignedModules, setAssignedModules] = createSignal<string[]>([]);
const [permissionIds, setPermissionIds] = createSignal<string[]>([]);
// Module Access
const [selectedKeys, setSelectedKeys] = createSignal<Set<string>>(new Set());
// Role Settings
const [isActive, setIsActive] = createSignal(true);
const [canApprove, setCanApprove] = createSignal(false);
const [canManage, setCanManage] = createSignal(false);
const [saving, setSaving] = createSignal(false);
const [error, setError] = createSignal('');
// Pre-populate from loaded data
// Pre-populate from loaded role
createEffect(() => {
const d = data();
if (!d) return;
setRoleName(d.role.name || '');
setDescription(d.role.description || '');
const ids = (d.role.permissions || []).map((p) => p.id);
setPermissionIds(ids);
const mods = [...new Set((d.role.permissions || []).map((p) => p.module))];
setAssignedModules(mods);
const r = role();
if (!r) return;
setRoleName(r.name);
setRoleCode(r.key);
setDepartmentId(r.department_id ?? '');
setDescription(r.description ?? '');
setIsActive(r.is_active);
setCanApprove(r.can_approve_requests);
setCanManage(r.can_manage_system_settings);
setSelectedKeys(new Set(r.permission_keys ?? []));
});
// Group permissions by module
const permsByModule = createMemo(() => {
const src = permissions() ?? [];
const map: Record<string, Permission[]> = {};
(data()?.allPerms || []).forEach((p) => {
src.forEach((p) => {
if (!map[p.module]) map[p.module] = [];
map[p.module].push(p);
});
// ensure all static modules present
STATIC_MODULES.forEach((module) => {
if (!map[module]) {
map[module] = ACTIONS.map((action) => ({ key: makeKey(module, action), module, action }));
}
});
return map;
});
const allModules = createMemo(() => Object.keys(permsByModule()).sort());
const allModules = createMemo(() => STATIC_MODULES);
const toggleModule = (mod: string) => {
const current = assignedModules();
if (current.includes(mod)) {
setAssignedModules(current.filter((m) => m !== mod));
const idsToRemove = (permsByModule()[mod] || []).map((p) => p.id);
setPermissionIds(permissionIds().filter((id) => !idsToRemove.includes(id)));
} else {
setAssignedModules([...current, mod]);
}
const toggleKey = (key: string) => {
const next = new Set(selectedKeys());
if (next.has(key)) next.delete(key);
else next.add(key);
setSelectedKeys(next);
};
const togglePermission = (id: string) => {
const current = permissionIds();
if (current.includes(id)) {
setPermissionIds(current.filter((p) => p !== id));
} else {
setPermissionIds([...current, id]);
}
const toggleRow = (module: string) => {
const perms = permsByModule()[module] ?? [];
const allSelected = perms.every((p) => selectedKeys().has(p.key));
const next = new Set(selectedKeys());
if (allSelected) perms.forEach((p) => next.delete(p.key));
else perms.forEach((p) => next.add(p.key));
setSelectedKeys(next);
};
const selectAll = () => {
const all = STATIC_MODULES.flatMap((m) => ACTIONS.map((a) => makeKey(m, a)));
setSelectedKeys(new Set(all));
};
const deselectAll = () => setSelectedKeys(new Set());
const allSelected = () => {
const total = STATIC_MODULES.length * ACTIONS.length;
return selectedKeys().size === total;
};
const handleSave = async () => {
if (!roleName().trim()) { setError('Role name is required'); return; }
if (assignedModules().length === 0) { setError('Select at least one module'); return; }
if (permissionIds().length === 0) { setError('Assign at least one permission'); return; }
if (!roleName().trim()) { setError('Role name is required'); setSubTab('general'); return; }
setError('');
try {
setSaving(true);
setError('');
const res = await fetch(`${API}/api/admin/roles/${params.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: roleName().trim(),
description: description().trim(),
audience: 'INTERNAL',
modules: assignedModules(),
permissionIds: permissionIds(),
description: description().trim() || null,
department_id: departmentId() || null,
is_active: isActive(),
can_approve_requests: canApprove(),
can_manage_system_settings: canManage(),
permission_keys: [...selectedKeys()],
}),
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.message || 'Failed to update role');
throw new Error((body as any).message || 'Failed to update role');
}
navigate(`/admin/roles/${params.id}`);
} catch (err: any) {
@ -113,133 +200,292 @@ export default function EditInternalRolePage() {
return (
<AdminShell>
<div class="flex flex-col -mx-6 -mt-6 min-h-full">
<div class="bg-white border-b border-gray-200 px-6 py-4 flex items-center justify-between">
<div>
<h1 class="text-xl font-semibold text-gray-900">Edit Internal Role</h1>
<p class="text-sm text-gray-500 mt-0.5">Update role name, access areas, and permissions.</p>
</div>
<A class="rounded-lg border border-gray-200 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors" href={`/admin/roles/${params.id}`}>Back to Role</A>
</div>
<div class="p-6">
<nav class="hidden" aria-label="Role Management Navigation">
<A class="hidden" href="/admin/roles">Internal Roles</A>
<A class="hidden" href="/admin/runtime-roles">External Runtime Roles</A>
<A class="hidden" href="/admin/onboarding-schemas">Onboarding Schemas</A>
</nav>
<Show when={data.loading}>
<div class="rounded-xl border border-gray-200 bg-white shadow-sm"><p class="notice">Loading role details...</p></div>
</Show>
<Show when={data.error}>
<div class="mb-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">Failed to load role. Check that the backend is running.</div>
</Show>
<Show when={error()}>
<div class="mb-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{error()}</div>
</Show>
<Show when={!data.loading && data()}>
{/* Role Details */}
<div class="mb-6 rounded-xl border border-gray-200 bg-white p-6 shadow-sm">
<h3>Role Basics</h3>
<p>Update the role name and description.</p>
<div class="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-2">
<div class="field">
<label>Role Name <span style="color:#ef4444">*</span></label>
<input
value={roleName()}
onInput={(e) => setRoleName(e.currentTarget.value)}
placeholder="e.g. Customer Support Rep"
/>
</div>
<div class="field">
<label>Description</label>
<input
value={description()}
onInput={(e) => setDescription(e.currentTarget.value)}
placeholder="Short description of this role"
/>
</div>
</div>
{/* Page title */}
<div class="bg-white border-b border-[#e5e7eb] px-6 py-5">
<h1 class="text-[20px] font-semibold text-[#000032]">Internal Role Management</h1>
<p class="text-[13px] text-[rgba(0,0,50,0.5)] mt-0.5">Manage internal roles and permissions</p>
</div>
{/* Module Access */}
<div class="mb-6 rounded-xl border border-gray-200 bg-white p-6 shadow-sm">
<h3>Area Access</h3>
<p>Select which areas this role can access.</p>
<div class="mt-3 flex flex-wrap gap-2">
{allModules().map((mod) => (
<button
type="button"
class={`flex cursor-pointer items-center gap-1.5 rounded-full border border-gray-200 bg-white px-3 py-1.5 text-xs font-medium text-gray-600 hover:border-gray-300 ${assignedModules().includes(mod) ? 'selected' : ''}`}
onClick={() => toggleModule(mod)}
<div class="p-6">
<div class="rounded-xl border border-[#e5e7eb] bg-white shadow-sm overflow-hidden">
{/* Outer tabs */}
<div class="flex border-b border-[#e5e7eb] px-6">
<A
href="/admin/roles"
class="py-4 text-[14px] font-medium text-[rgba(0,0,50,0.5)] hover:text-[#000032] mr-6"
>
<span style={`width:14px;height:14px;border-radius:3px;border:2px solid ${assignedModules().includes(mod) ? '#c2410c' : '#cbd5e1'};background:${assignedModules().includes(mod) ? '#c2410c' : '#fff'};flex-shrink:0;display:inline-block`} />
{mod}
All Roles
</A>
<button class="relative py-4 text-[14px] font-semibold text-[#fa5014]">
Edit Role
<span class="absolute bottom-0 left-0 right-0 h-[2px] bg-[#fa5014] rounded-t" />
</button>
))}
</div>
</div>
{/* Permission Table */}
<Show when={assignedModules().length > 0}>
<div class="mb-6 rounded-xl border border-gray-200 bg-white p-6 shadow-sm">
<h3>Permissions</h3>
<p>Choose what this role can do in each selected area.</p>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr>
<th style="width:45%">Area</th>
<th style="width:11%">No Access</th>
<th style="width:11%">Read</th>
<th style="width:11%">Create</th>
<th style="width:11%">Update</th>
<th style="width:11%">Delete</th>
</tr>
</thead>
<tbody>
{assignedModules().sort().map((mod) => {
const perms = permsByModule()[mod] || [];
const actionMap: Record<string, string> = {};
perms.forEach((p) => { actionMap[p.action] = p.id; });
const hasRead = !!actionMap['Read'] && permissionIds().includes(actionMap['Read']);
const hasCreate = !!actionMap['Create'] && permissionIds().includes(actionMap['Create']);
const hasUpdate = !!actionMap['Update'] && permissionIds().includes(actionMap['Update']);
const hasDelete = !!actionMap['Delete'] && permissionIds().includes(actionMap['Delete']);
const noAccess = !hasRead && !hasCreate && !hasUpdate && !hasDelete;
return (
<tr>
<td style="font-weight:500">{mod}</td>
<td><input type="checkbox" checked={noAccess} disabled aria-label={`${mod} no access`} /></td>
<td>{actionMap['Read'] ? <input type="checkbox" checked={hasRead} onChange={() => togglePermission(actionMap['Read'])} aria-label={`${mod} read`} /> : <span style="color:#cbd5e1"></span>}</td>
<td>{actionMap['Create'] ? <input type="checkbox" checked={hasCreate} onChange={() => togglePermission(actionMap['Create'])} aria-label={`${mod} create`} /> : <span style="color:#cbd5e1"></span>}</td>
<td>{actionMap['Update'] ? <input type="checkbox" checked={hasUpdate} onChange={() => togglePermission(actionMap['Update'])} aria-label={`${mod} update`} /> : <span style="color:#cbd5e1"></span>}</td>
<td>{actionMap['Delete'] ? <input type="checkbox" checked={hasDelete} onChange={() => togglePermission(actionMap['Delete'])} aria-label={`${mod} delete`} /> : <span style="color:#cbd5e1"></span>}</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
</Show>
{/* Save */}
<div class="flex justify-end mt-2">
<button
class="btn-primary"
onClick={handleSave}
disabled={saving() || !roleName().trim()}
>
{saving() ? 'Saving…' : 'Save Changes'}
</button>
<Show when={role.loading}>
<div class="px-6 py-10 text-center text-[13px] text-[rgba(0,0,50,0.4)]">Loading</div>
</Show>
<Show when={!role.loading}>
{/* Inner sub-tabs */}
<div class="flex border-b border-[#e5e7eb] px-6 gap-6">
{(
[
{ key: 'general', label: 'General Information' },
{ key: 'module', label: 'Module Access' },
{ key: 'settings', label: 'Role Settings' },
] as const
).map((t) => (
<button
onClick={() => setSubTab(t.key)}
class={`relative py-3.5 text-[13px] font-medium transition-colors ${
subTab() === t.key
? 'text-[#000032] font-semibold'
: 'text-[rgba(0,0,50,0.5)] hover:text-[#000032]'
}`}
>
{t.label}
<Show when={subTab() === t.key}>
<span class="absolute bottom-0 left-0 right-0 h-[2px] bg-[#000032] rounded-t" />
</Show>
</button>
))}
</div>
{/* Error banner */}
<Show when={error()}>
<div class="mx-6 mt-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-[13px] text-red-700">
{error()}
</div>
</Show>
{/* ── Tab: General Information ── */}
<Show when={subTab() === 'general'}>
<div class="p-6 space-y-5">
<div class="grid grid-cols-2 gap-5">
<div>
<label class="block text-[13px] font-medium text-[#000032] mb-1.5">
Role Name <span class="text-red-500">*</span>
</label>
<input
type="text"
placeholder="Enter role name"
value={roleName()}
onInput={(e) => setRoleName(e.currentTarget.value)}
class="w-full px-3 py-2.5 text-[13px] border border-[#e5e7eb] rounded-lg outline-none focus:border-[#fa5014] focus:ring-1 focus:ring-[#fa5014] text-[#000032] placeholder-[rgba(0,0,50,0.3)]"
/>
</div>
<div>
<label class="block text-[13px] font-medium text-[#000032] mb-1.5">
Role Code
</label>
<input
type="text"
value={roleCode()}
disabled
class="w-full px-3 py-2.5 text-[13px] border border-[#e5e7eb] rounded-lg bg-[#fafafa] text-[rgba(0,0,50,0.4)] cursor-not-allowed"
/>
</div>
</div>
<div>
<label class="block text-[13px] font-medium text-[#000032] mb-1.5">Department</label>
<select
value={departmentId()}
onChange={(e) => setDepartmentId(e.currentTarget.value)}
class="w-full px-3 py-2.5 text-[13px] border border-[#e5e7eb] rounded-lg outline-none focus:border-[#fa5014] focus:ring-1 focus:ring-[#fa5014] bg-white text-[#000032]"
>
<option value="">Select department</option>
<For each={departments() ?? []}>
{(dept) => (
<option value={dept.id} selected={dept.id === departmentId()}>
{dept.name}
</option>
)}
</For>
</select>
</div>
<div>
<label class="block text-[13px] font-medium text-[#000032] mb-1.5">Description</label>
<textarea
placeholder="Enter role description"
value={description()}
onInput={(e) => setDescription(e.currentTarget.value)}
rows={4}
class="w-full px-3 py-2.5 text-[13px] border border-[#e5e7eb] rounded-lg outline-none focus:border-[#fa5014] focus:ring-1 focus:ring-[#fa5014] text-[#000032] placeholder-[rgba(0,0,50,0.3)] resize-none"
/>
</div>
</div>
</Show>
{/* ── Tab: Module Access ── */}
<Show when={subTab() === 'module'}>
<div class="p-6">
<p class="text-[13px] text-[rgba(0,0,50,0.5)] mb-4">
Configure module access permissions for this role.
</p>
<div class="overflow-x-auto rounded-lg border border-[#e5e7eb]">
<table class="w-full">
<thead>
<tr class="bg-[#000032] text-white text-[12px] font-semibold uppercase tracking-wide">
<th class="px-5 py-3 text-left w-[40%]"> </th>
<th class="px-4 py-3 text-center">View</th>
<th class="px-4 py-3 text-center">Create</th>
<th class="px-4 py-3 text-center">Update</th>
<th class="px-4 py-3 text-center">Delete</th>
<th class="px-4 py-3 text-center">
<button
type="button"
onClick={() => (allSelected() ? deselectAll() : selectAll())}
class="text-[11px] font-semibold text-white hover:text-[#fca87c] transition-colors whitespace-nowrap"
>
{allSelected() ? 'Deselect All' : 'Select All'}
</button>
</th>
</tr>
</thead>
<tbody class="divide-y divide-[#e5e7eb]">
<For each={allModules()}>
{(module) => {
const rowAllSelected = () =>
ACTIONS.every((a) => selectedKeys().has(makeKey(module, a)));
return (
<tr class="hover:bg-[#fafafa]">
<td class="px-5 py-3.5 text-[13px] font-medium text-[#000032]">
{module}
</td>
<For each={ACTIONS}>
{(action) => {
const key = makeKey(module, action);
return (
<td class="px-4 py-3.5 text-center">
<input
type="checkbox"
checked={selectedKeys().has(key)}
onChange={() => toggleKey(key)}
class="h-4 w-4 accent-[#fa5014] cursor-pointer"
/>
</td>
);
}}
</For>
<td class="px-4 py-3.5 text-center">
<input
type="checkbox"
checked={rowAllSelected()}
onChange={() => toggleRow(module)}
class="h-4 w-4 accent-[#fa5014] cursor-pointer"
/>
</td>
</tr>
);
}}
</For>
</tbody>
</table>
</div>
</div>
</Show>
{/* ── Tab: Role Settings ── */}
<Show when={subTab() === 'settings'}>
<div class="p-6 space-y-6">
<div>
<p class="text-[13px] font-semibold text-[#000032] mb-3">Role Status</p>
<div class="flex items-center gap-2">
<button
type="button"
onClick={() => setIsActive(true)}
class={`px-5 py-2 rounded-lg text-[13px] font-medium transition-colors ${
isActive()
? 'bg-[#fa5014] text-white'
: 'border border-[#e5e7eb] text-[rgba(0,0,50,0.6)] hover:border-[#000032]'
}`}
>
Active
</button>
<button
type="button"
onClick={() => setIsActive(false)}
class={`px-5 py-2 rounded-lg text-[13px] font-medium transition-colors ${
!isActive()
? 'bg-[#fa5014] text-white'
: 'border border-[#e5e7eb] text-[rgba(0,0,50,0.6)] hover:border-[#000032]'
}`}
>
Inactive
</button>
</div>
</div>
<div class="space-y-3">
<SettingToggle
label="Allow Role to Approve Requests"
description="Enable this role to approve various requests"
value={canApprove()}
onChange={setCanApprove}
/>
<SettingToggle
label="Allow Role to Manage System Settings"
description="Enable this role to manage system settings and configurations"
value={canManage()}
onChange={setCanManage}
/>
</div>
</div>
</Show>
{/* Footer */}
<div class="flex items-center justify-end gap-3 px-6 py-4 border-t border-[#e5e7eb]">
<A
href={`/admin/roles/${params.id}`}
class="px-5 py-2.5 text-[13px] font-medium border border-[#e5e7eb] rounded-lg text-[rgba(0,0,50,0.7)] hover:border-[#000032] transition-colors"
>
Cancel
</A>
<button
onClick={handleSave}
disabled={saving()}
class="px-5 py-2.5 text-[13px] font-semibold bg-[#000032] text-white rounded-lg hover:bg-[#000050] transition-colors disabled:opacity-60"
>
{saving() ? 'Saving…' : 'Save Changes'}
</button>
</div>
</Show>
</div>
</div>
</Show>
</div>
</div>
</AdminShell>
);
}
function SettingToggle(props: {
label: string;
description: string;
value: boolean;
onChange: (v: boolean) => void;
}) {
return (
<div class="flex items-center justify-between rounded-xl border border-[#e5e7eb] px-5 py-4">
<div>
<p class="text-[13px] font-semibold text-[#000032]">{props.label}</p>
<p class="text-[12px] text-[rgba(0,0,50,0.5)] mt-0.5">{props.description}</p>
</div>
<button
type="button"
role="switch"
aria-checked={props.value}
onClick={() => props.onChange(!props.value)}
class={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none ${
props.value ? 'bg-[#fa5014]' : 'bg-[#d1d5db]'
}`}
>
<span
class={`inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform ${
props.value ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
</div>
);
}

View file

@ -1,23 +1,50 @@
import { A, useParams } from '@solidjs/router';
import { createMemo, createResource, Show } from 'solid-js';
import { createMemo, createResource, For, Show } from 'solid-js';
import AdminShell from '~/components/AdminShell';
const API = '/api/gateway';
type Permission = { id: string; module: string; action: string };
type Role = { id: string; name: string; description?: string; permissions: Permission[] };
type Permission = { key: string; module: string; action: string };
type RoleDetail = {
id: string;
key: string;
name: string;
audience: string;
description?: string;
department_id?: string;
department_name?: string;
is_active: boolean;
can_approve_requests: boolean;
can_manage_system_settings: boolean;
permission_keys: string[];
created_at: string;
};
async function loadRoleDetail(id: string) {
const STATIC_MODULES = [
'Department Management', 'Designation Management', 'Internal Role Management',
'Employee Management', 'External Role Management', 'External Onboarding Management',
'Internal Dashboard Management', 'External Dashboard Management', 'Verification Management',
'Approval Management', 'Users Management', 'Company Management', 'Candidate Management',
'Customer Management', 'Photographer Management', 'Makeup Artist Management',
'Tutor Management', 'Developer Management', 'Fitness Trainer Management',
'Graphic Designer Management', 'Social Media Management', 'Video Editor Management',
'Catering Services Management', 'Jobs Management', 'Leads Management',
'Applications Management', 'Responses Management', 'Review Management',
'Pricing Management', 'Credit Management', 'Coupon Management', 'Discount Management',
'Tax Management', 'Order Management', 'Invoice Management', 'Ledger Management',
'Knowledge Base Management', 'Support Management', 'Report Management', 'Notifications',
];
const ACTIONS = ['View', 'Create', 'Update', 'Delete'] as const;
function makeKey(module: string, action: string) {
return `${module.replace(/ /g, '_').toLowerCase()}:${action.toLowerCase()}`;
}
async function loadRole(id: string): Promise<RoleDetail | null> {
try {
const [roleRes, permsRes] = await Promise.all([
fetch(`${API}/api/admin/roles/${id}`),
fetch(`${API}/api/admin/permissions`),
]);
if (!roleRes.ok) return null;
const role: Role = await roleRes.json();
const permsData = permsRes.ok ? await permsRes.json() : { permissions: [] };
const allPerms: Permission[] = Array.isArray(permsData) ? permsData : (permsData.permissions || []);
return { role, allPerms };
const res = await fetch(`${API}/api/admin/roles/${id}`);
if (!res.ok) return null;
return res.json();
} catch {
return null;
}
@ -25,117 +52,204 @@ async function loadRoleDetail(id: string) {
export default function RoleDetailPage() {
const params = useParams();
const [data] = createResource(() => params.id, loadRoleDetail);
const [role] = createResource(() => params.id, loadRole);
const grouped = createMemo(() => {
if (!data()?.allPerms) return {};
const map: Record<string, Permission[]> = {};
(data()!.allPerms).forEach((p) => {
if (!map[p.module]) map[p.module] = [];
map[p.module].push(p);
});
return map;
});
const rolePermIds = createMemo(() => new Set((data()?.role?.permissions || []).map((p) => p.id)));
const modules = createMemo(() => Object.keys(grouped()).sort());
const permSet = createMemo(() => new Set(role()?.permission_keys ?? []));
return (
<AdminShell>
<div class="flex flex-col -mx-6 -mt-6 min-h-full">
<div class="bg-white border-b border-gray-200 px-6 py-4 flex items-center justify-between">
<div>
<h1 class="text-xl font-semibold text-gray-900">Role Details</h1>
<p class="text-sm text-gray-500 mt-0.5">View role information and assigned permissions.</p>
{/* Page title */}
<div class="bg-white border-b border-[#e5e7eb] px-6 py-5">
<h1 class="text-[20px] font-semibold text-[#000032]">Internal Role Management</h1>
<p class="text-[13px] text-[rgba(0,0,50,0.5)] mt-0.5">Manage internal roles and permissions</p>
</div>
<div class="flex items-center gap-2">
<A class="rounded-lg border border-gray-200 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors" href="/admin/roles">Back to List</A>
<Show when={data()?.role}>
<A class="btn-primary" href={`/admin/roles/${params.id}/edit`}>Edit Role</A>
</Show>
</div>
</div>
<div class="p-6">
<nav class="hidden" aria-label="Role Management Navigation">
<A class="hidden" href="/admin/roles">Internal Roles</A>
<A class="hidden" href="/admin/runtime-roles">External Runtime Roles</A>
<A class="hidden" href="/admin/onboarding-schemas">Onboarding Schemas</A>
</nav>
<div class="p-6">
<div class="rounded-xl border border-[#e5e7eb] bg-white shadow-sm overflow-hidden">
<Show when={data.loading}>
<div class="rounded-xl border border-gray-200 bg-white shadow-sm">
<p class="notice">Loading role details...</p>
</div>
</Show>
<Show when={data.error}>
<div class="mb-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">Failed to load role. Check that the backend is running.</div>
</Show>
<Show when={data()?.role}>
{/* Role Info */}
<div class="role-detail-card">
<div class="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-2">
<div class="field">
<label>Role Name</label>
<input class="role-field-readonly" value={data()!.role.name} readOnly disabled />
{/* Outer tabs */}
<div class="flex border-b border-[#e5e7eb] px-6">
<A
href="/admin/roles"
class="py-4 text-[14px] font-medium text-[rgba(0,0,50,0.5)] hover:text-[#000032] mr-6"
>
All Roles
</A>
<A
href="/admin/roles/create"
class="py-4 text-[14px] font-medium text-[rgba(0,0,50,0.5)] hover:text-[#000032]"
>
Create Role
</A>
</div>
<div class="field">
<label>Description</label>
<input class="role-field-readonly" value={data()!.role.description || '—'} readOnly disabled />
</div>
</div>
</div>
{/* Permission Matrix */}
<div class="rounded-xl border border-gray-200 bg-white shadow-sm" style="padding: 0; overflow: hidden;">
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr>
<th style="width:45%">Name of the module</th>
<th style="width:11%">No Access</th>
<th style="width:11%">Read</th>
<th style="width:11%">Create</th>
<th style="width:11%">Update</th>
<th style="width:11%">Delete</th>
</tr>
</thead>
<tbody>
<Show when={modules().length === 0}>
<tr>
<td colspan="6" style="text-align:center;padding:24px;color:#94a3b8;">No permissions defined.</td>
</tr>
<Show when={role.loading}>
<div class="px-6 py-10 text-center text-[13px] text-[rgba(0,0,50,0.4)]">
Loading role
</div>
</Show>
<Show when={!role.loading && !role()}>
<div class="px-6 py-10 text-center text-[13px] text-red-500">
Role not found or backend is not running.
</div>
</Show>
<Show when={role()}>
{/* Action bar */}
<div class="flex items-center justify-between px-6 py-4 border-b border-[#e5e7eb]">
<div>
<h2 class="text-[16px] font-semibold text-[#000032]">{role()!.name}</h2>
<p class="text-[12px] text-[rgba(0,0,50,0.5)] mt-0.5">{role()!.key}</p>
</div>
<div class="flex items-center gap-2">
<A
href="/admin/roles"
class="px-4 py-2 text-[13px] font-medium border border-[#e5e7eb] rounded-lg text-[rgba(0,0,50,0.7)] hover:border-[#000032] transition-colors"
>
Back to List
</A>
<A
href={`/admin/roles/${params.id}/edit`}
class="px-4 py-2 text-[13px] font-semibold bg-[#000032] text-white rounded-lg hover:bg-[#000050] transition-colors"
>
Edit Role
</A>
</div>
</div>
{/* General info */}
<div class="px-6 py-5 border-b border-[#e5e7eb]">
<h3 class="text-[13px] font-semibold text-[#000032] mb-4">General Information</h3>
<div class="grid grid-cols-2 gap-5 mb-4">
<ReadField label="Role Name" value={role()!.name} />
<ReadField label="Role Code" value={role()!.key} />
</div>
<div class="grid grid-cols-2 gap-5">
<ReadField label="Department" value={role()!.department_name || '—'} />
<ReadField
label="Status"
value=""
custom={
<span
class={`inline-flex items-center px-2.5 py-1 rounded-md text-[12px] font-semibold ${
role()!.is_active
? 'bg-[rgba(34,197,94,0.1)] text-[#16a34a]'
: 'bg-[#f1f1f1] text-[rgba(0,0,50,0.5)]'
}`}
>
{role()!.is_active ? 'Active' : 'Inactive'}
</span>
}
/>
</div>
<Show when={role()!.description}>
<div class="mt-4">
<ReadField label="Description" value={role()!.description!} />
</div>
</Show>
{modules().map((mod) => {
const perms = grouped()[mod] || [];
const actionMap: Record<string, string> = {};
perms.forEach((p) => { actionMap[p.action] = p.id; });
const hasRead = !!actionMap['Read'] && rolePermIds().has(actionMap['Read']);
const hasCreate = !!actionMap['Create'] && rolePermIds().has(actionMap['Create']);
const hasUpdate = !!actionMap['Update'] && rolePermIds().has(actionMap['Update']);
const hasDelete = !!actionMap['Delete'] && rolePermIds().has(actionMap['Delete']);
const noAccess = !hasRead && !hasCreate && !hasUpdate && !hasDelete;
return (
<tr>
<td style="font-weight:500">{mod}</td>
<td><input type="checkbox" checked={noAccess} disabled aria-label={`${mod} no access`} /></td>
<td>{actionMap['Read'] ? <input type="checkbox" checked={hasRead} disabled aria-label={`${mod} read`} /> : <span style="color:#cbd5e1"></span>}</td>
<td>{actionMap['Create'] ? <input type="checkbox" checked={hasCreate} disabled aria-label={`${mod} create`} /> : <span style="color:#cbd5e1"></span>}</td>
<td>{actionMap['Update'] ? <input type="checkbox" checked={hasUpdate} disabled aria-label={`${mod} update`} /> : <span style="color:#cbd5e1"></span>}</td>
<td>{actionMap['Delete'] ? <input type="checkbox" checked={hasDelete} disabled aria-label={`${mod} delete`} /> : <span style="color:#cbd5e1"></span>}</td>
</tr>
);
})}
</tbody>
</table>
</div>
{/* Module Access */}
<div class="px-6 py-5 border-b border-[#e5e7eb]">
<h3 class="text-[13px] font-semibold text-[#000032] mb-4">Module Access</h3>
<div class="overflow-x-auto rounded-lg border border-[#e5e7eb]">
<table class="w-full">
<thead>
<tr class="bg-[#000032] text-white text-[12px] font-semibold uppercase tracking-wide">
<th class="px-5 py-3 text-left w-[40%]"> </th>
<th class="px-4 py-3 text-center">View</th>
<th class="px-4 py-3 text-center">Create</th>
<th class="px-4 py-3 text-center">Update</th>
<th class="px-4 py-3 text-center">Delete</th>
</tr>
</thead>
<tbody class="divide-y divide-[#e5e7eb]">
<For each={STATIC_MODULES}>
{(module) => (
<tr class="hover:bg-[#fafafa]">
<td class="px-5 py-3.5 text-[13px] font-medium text-[#000032]">
{module}
</td>
<For each={ACTIONS}>
{(action) => {
const key = makeKey(module, action);
const checked = () => permSet().has(key);
return (
<td class="px-4 py-3.5 text-center">
<input
type="checkbox"
checked={checked()}
disabled
class="h-4 w-4 accent-[#fa5014]"
/>
</td>
);
}}
</For>
</tr>
)}
</For>
</tbody>
</table>
</div>
</div>
{/* Role Settings */}
<div class="px-6 py-5">
<h3 class="text-[13px] font-semibold text-[#000032] mb-4">Role Settings</h3>
<div class="space-y-3">
<SettingRow
label="Allow Role to Approve Requests"
description="Enable this role to approve various requests"
value={role()!.can_approve_requests}
/>
<SettingRow
label="Allow Role to Manage System Settings"
description="Enable this role to manage system settings and configurations"
value={role()!.can_manage_system_settings}
/>
</div>
</div>
</Show>
</div>
</div>
</Show>
</div>
</div>
</AdminShell>
);
}
function ReadField(props: { label: string; value: string; custom?: any }) {
return (
<div>
<p class="text-[12px] text-[rgba(0,0,50,0.5)] mb-1">{props.label}</p>
{props.custom ?? (
<p class="text-[14px] font-medium text-[#000032]">{props.value || '—'}</p>
)}
</div>
);
}
function SettingRow(props: { label: string; description: string; value: boolean }) {
return (
<div class="flex items-center justify-between rounded-xl border border-[#e5e7eb] px-5 py-4">
<div>
<p class="text-[13px] font-semibold text-[#000032]">{props.label}</p>
<p class="text-[12px] text-[rgba(0,0,50,0.5)] mt-0.5">{props.description}</p>
</div>
<div
class={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
props.value ? 'bg-[#fa5014]' : 'bg-[#d1d5db]'
}`}
>
<span
class={`inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform ${
props.value ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</div>
</div>
);
}

View file

@ -1,87 +1,154 @@
import { A, useNavigate } from '@solidjs/router';
import { createMemo, createResource, createSignal, Show } from 'solid-js';
import { createMemo, createResource, createSignal, For, Show } from 'solid-js';
import AdminShell from '~/components/AdminShell';
import { STATIC_PERMISSIONS, type Permission } from '~/lib/admin-modules';
const API = '/api/gateway';
type Permission = { key: string; module: string; action: string };
type Department = { id: string; name: string };
async function loadPermissions(): Promise<Permission[]> {
try {
const res = await fetch(`${API}/api/admin/permissions`);
if (!res.ok) return STATIC_PERMISSIONS;
if (!res.ok) throw new Error();
const data = await res.json();
const rows: Permission[] = Array.isArray(data) ? data : (data.permissions || []);
return rows.length > 0 ? rows : STATIC_PERMISSIONS;
return Array.isArray(data) ? data : [];
} catch {
return STATIC_PERMISSIONS;
}
}
async function loadDepartments(): Promise<Department[]> {
try {
const res = await fetch(`${API}/api/admin/departments`);
if (!res.ok) return [];
const data = await res.json();
return Array.isArray(data) ? data : (data.departments ?? []);
} catch {
return [];
}
}
// Fallback static permissions matching backend MODULES
const STATIC_MODULES = [
'Department Management', 'Designation Management', 'Internal Role Management',
'Employee Management', 'External Role Management', 'External Onboarding Management',
'Internal Dashboard Management', 'External Dashboard Management', 'Verification Management',
'Approval Management', 'Users Management', 'Company Management', 'Candidate Management',
'Customer Management', 'Photographer Management', 'Makeup Artist Management',
'Tutor Management', 'Developer Management', 'Fitness Trainer Management',
'Graphic Designer Management', 'Social Media Management', 'Video Editor Management',
'Catering Services Management', 'Jobs Management', 'Leads Management',
'Applications Management', 'Responses Management', 'Review Management',
'Pricing Management', 'Credit Management', 'Coupon Management', 'Discount Management',
'Tax Management', 'Order Management', 'Invoice Management', 'Ledger Management',
'Knowledge Base Management', 'Support Management', 'Report Management', 'Notifications',
];
const ACTIONS = ['View', 'Create', 'Update', 'Delete'] as const;
const STATIC_PERMISSIONS: Permission[] = STATIC_MODULES.flatMap((module) =>
ACTIONS.map((action) => ({
key: `${module.replace(/ /g, '_').toLowerCase()}:${action.toLowerCase()}`,
module,
action,
})),
);
type SubTab = 'general' | 'module' | 'settings';
export default function CreateInternalRolePage() {
const navigate = useNavigate();
const [permissions] = createResource(loadPermissions);
const [departments] = createResource(loadDepartments);
const [subTab, setSubTab] = createSignal<SubTab>('general');
// General Information
const [roleName, setRoleName] = createSignal('');
const [roleCode, setRoleCode] = createSignal('');
const [departmentId, setDepartmentId] = createSignal('');
const [description, setDescription] = createSignal('');
const [assignedModules, setAssignedModules] = createSignal<string[]>([]);
const [permissionIds, setPermissionIds] = createSignal<string[]>([]);
// Module Access: selected permission keys
const [selectedKeys, setSelectedKeys] = createSignal<Set<string>>(new Set());
// Role Settings
const [isActive, setIsActive] = createSignal(true);
const [canApprove, setCanApprove] = createSignal(false);
const [canManage, setCanManage] = createSignal(false);
const [saving, setSaving] = createSignal(false);
const [error, setError] = createSignal('');
// Group permissions by module
const permsByModule = createMemo(() => {
const src = permissions() ?? STATIC_PERMISSIONS;
const map: Record<string, Permission[]> = {};
(permissions() || []).forEach((p) => {
src.forEach((p) => {
if (!map[p.module]) map[p.module] = [];
map[p.module].push(p);
});
return map;
});
const allModules = createMemo(() => Object.keys(permsByModule()).sort());
const allModules = createMemo(() => Object.keys(permsByModule()));
const toggleModule = (mod: string) => {
const current = assignedModules();
if (current.includes(mod)) {
setAssignedModules(current.filter((m) => m !== mod));
// remove permissions for this module
const idsToRemove = (permsByModule()[mod] || []).map((p) => p.id);
setPermissionIds(permissionIds().filter((id) => !idsToRemove.includes(id)));
} else {
setAssignedModules([...current, mod]);
}
// Toggle a single permission key
const toggleKey = (key: string) => {
const next = new Set(selectedKeys());
if (next.has(key)) next.delete(key);
else next.add(key);
setSelectedKeys(next);
};
const togglePermission = (id: string) => {
const current = permissionIds();
if (current.includes(id)) {
setPermissionIds(current.filter((p) => p !== id));
// Toggle entire row (all actions for a module)
const toggleRow = (module: string) => {
const perms = permsByModule()[module] ?? [];
const allSelected = perms.every((p) => selectedKeys().has(p.key));
const next = new Set(selectedKeys());
if (allSelected) {
perms.forEach((p) => next.delete(p.key));
} else {
setPermissionIds([...current, id]);
perms.forEach((p) => next.add(p.key));
}
setSelectedKeys(next);
};
// Select all / deselect all
const selectAll = () => {
const all = (permissions() ?? STATIC_PERMISSIONS).map((p) => p.key);
setSelectedKeys(new Set(all));
};
const deselectAll = () => setSelectedKeys(new Set());
const allSelected = () => {
const src = permissions() ?? STATIC_PERMISSIONS;
return src.length > 0 && src.every((p) => selectedKeys().has(p.key));
};
const handleSave = async () => {
if (!roleName().trim()) { setError('Role name is required'); return; }
if (assignedModules().length === 0) { setError('Select at least one module'); return; }
if (permissionIds().length === 0) { setError('Assign at least one permission'); return; }
if (!roleName().trim()) { setError('Role name is required'); setSubTab('general'); return; }
if (!roleCode().trim()) { setError('Role code is required'); setSubTab('general'); return; }
setError('');
try {
setSaving(true);
setError('');
const res = await fetch(`${API}/api/admin/roles`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
key: roleCode().trim(),
name: roleName().trim(),
description: description().trim(),
audience: 'INTERNAL',
modules: assignedModules(),
permissionIds: permissionIds(),
description: description().trim() || null,
department_id: departmentId() || null,
is_active: isActive(),
can_approve_requests: canApprove(),
can_manage_system_settings: canManage(),
permission_keys: [...selectedKeys()],
}),
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.message || 'Failed to create role');
throw new Error((body as any).message || 'Failed to create role');
}
navigate('/admin/roles');
} catch (err: any) {
@ -94,131 +161,304 @@ export default function CreateInternalRolePage() {
return (
<AdminShell>
<div class="flex flex-col -mx-6 -mt-6 min-h-full">
<div class="bg-white border-b border-gray-200 px-6 py-4">
<h1 class="text-xl font-semibold text-gray-900">Create Internal Role</h1>
<p class="text-sm text-gray-500 mt-0.5">Create a new internal role and choose what it can access.</p>
{/* Page title */}
<div class="bg-white border-b border-[#e5e7eb] px-6 py-5">
<h1 class="text-[20px] font-semibold text-[#000032]">Internal Role Management</h1>
<p class="text-[13px] text-[rgba(0,0,50,0.5)] mt-0.5">Manage internal roles and permissions</p>
</div>
<div class="flex-1 p-6">
<nav class="hidden" aria-label="Role Management Navigation">
<A class="hidden" href="/admin/roles">Internal Roles</A>
<A class="hidden" href="/admin/runtime-roles">External Runtime Roles</A>
<A class="hidden" href="/admin/onboarding-schemas">Onboarding Schemas</A>
</nav>
<div class="p-6">
<div class="rounded-xl border border-[#e5e7eb] bg-white shadow-sm overflow-hidden">
<Show when={error()}>
<div class="mb-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{error()}</div>
</Show>
{/* Role Details */}
<div class="mb-6 rounded-xl border border-gray-200 bg-white p-6 shadow-sm">
<h3>Role Basics</h3>
<p>Start by giving this role a clear name.</p>
<div class="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-2">
<div class="field">
<label class="mb-1.5 block text-sm font-medium text-gray-700">Role Name <span class="text-red-500">*</span></label>
<input
class="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm outline-none focus:border-[#0a1d37] focus:ring-1 focus:ring-[#0a1d37]"
value={roleName()}
onInput={(e) => setRoleName(e.currentTarget.value)}
placeholder="e.g. Customer Support Rep"
/>
</div>
<div class="field">
<label class="mb-1.5 block text-sm font-medium text-gray-700">Description</label>
<input
class="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm outline-none focus:border-[#0a1d37] focus:ring-1 focus:ring-[#0a1d37]"
value={description()}
onInput={(e) => setDescription(e.currentTarget.value)}
placeholder="Short description of this role"
/>
</div>
{/* Outer tabs: All Roles | Create Role */}
<div class="flex border-b border-[#e5e7eb] px-6">
<A
href="/admin/roles"
class="py-4 text-[14px] font-medium text-[rgba(0,0,50,0.5)] hover:text-[#000032] mr-6"
>
All Roles
</A>
<button class="relative py-4 text-[14px] font-semibold text-[#fa5014]">
Create Role
<span class="absolute bottom-0 left-0 right-0 h-[2px] bg-[#fa5014] rounded-t" />
</button>
</div>
</div>
{/* Module Access */}
<div class="mb-6 rounded-xl border border-gray-200 bg-white p-6 shadow-sm">
<h3>Area Access</h3>
<p>Select which areas this role can access. You can set permissions for selected areas below.</p>
<Show when={permissions.loading}>
<p class="notice">Loading available areas...</p>
{/* Inner sub-tabs */}
<div class="flex border-b border-[#e5e7eb] px-6 gap-6">
{(
[
{ key: 'general', label: 'General Information' },
{ key: 'module', label: 'Module Access' },
{ key: 'settings', label: 'Role Settings' },
] as const
).map((t) => (
<button
onClick={() => setSubTab(t.key)}
class={`relative py-3.5 text-[13px] font-medium transition-colors ${
subTab() === t.key
? 'text-[#000032] font-semibold'
: 'text-[rgba(0,0,50,0.5)] hover:text-[#000032]'
}`}
>
{t.label}
<Show when={subTab() === t.key}>
<span class="absolute bottom-0 left-0 right-0 h-[2px] bg-[#000032] rounded-t" />
</Show>
</button>
))}
</div>
{/* Error banner */}
<Show when={error()}>
<div class="mx-6 mt-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-[13px] text-red-700">
{error()}
</div>
</Show>
<Show when={!permissions.loading && allModules().length > 0}>
<div class="mt-3 flex flex-wrap gap-2">
{allModules().map((mod) => (
<button
type="button"
class={`flex cursor-pointer items-center gap-1.5 rounded-full border border-gray-200 bg-white px-3 py-1.5 text-xs font-medium text-gray-600 hover:border-gray-300 ${assignedModules().includes(mod) ? 'selected' : ''}`}
onClick={() => toggleModule(mod)}
{/* ── Tab: General Information ── */}
<Show when={subTab() === 'general'}>
<div class="p-6 space-y-5">
<div class="grid grid-cols-2 gap-5">
<div>
<label class="block text-[13px] font-medium text-[#000032] mb-1.5">
Role Name <span class="text-red-500">*</span>
</label>
<input
type="text"
placeholder="Enter role name"
value={roleName()}
onInput={(e) => setRoleName(e.currentTarget.value)}
class="w-full px-3 py-2.5 text-[13px] border border-[#e5e7eb] rounded-lg outline-none focus:border-[#fa5014] focus:ring-1 focus:ring-[#fa5014] text-[#000032] placeholder-[rgba(0,0,50,0.3)]"
/>
</div>
<div>
<label class="block text-[13px] font-medium text-[#000032] mb-1.5">
Role Code <span class="text-red-500">*</span>
</label>
<input
type="text"
placeholder="e.g., ENG-LEAD-001"
value={roleCode()}
onInput={(e) => setRoleCode(e.currentTarget.value)}
class="w-full px-3 py-2.5 text-[13px] border border-[#e5e7eb] rounded-lg outline-none focus:border-[#fa5014] focus:ring-1 focus:ring-[#fa5014] text-[#000032] placeholder-[rgba(0,0,50,0.3)]"
/>
</div>
</div>
<div>
<label class="block text-[13px] font-medium text-[#000032] mb-1.5">
Department <span class="text-red-500">*</span>
</label>
<select
value={departmentId()}
onChange={(e) => setDepartmentId(e.currentTarget.value)}
class="w-full px-3 py-2.5 text-[13px] border border-[#e5e7eb] rounded-lg outline-none focus:border-[#fa5014] focus:ring-1 focus:ring-[#fa5014] bg-white text-[#000032]"
>
<span class={`inline-block w-3.5 h-3.5 rounded-[3px] border-2 flex-shrink-0 ${assignedModules().includes(mod) ? 'border-[#0a1d37] bg-[#0a1d37]' : 'border-slate-300 bg-white'}`} />
{mod}
</button>
))}
<option value="">Select department</option>
<For each={departments() ?? []}>
{(dept) => <option value={dept.id}>{dept.name}</option>}
</For>
</select>
</div>
<div>
<label class="block text-[13px] font-medium text-[#000032] mb-1.5">Description</label>
<textarea
placeholder="Enter role description"
value={description()}
onInput={(e) => setDescription(e.currentTarget.value)}
rows={4}
class="w-full px-3 py-2.5 text-[13px] border border-[#e5e7eb] rounded-lg outline-none focus:border-[#fa5014] focus:ring-1 focus:ring-[#fa5014] text-[#000032] placeholder-[rgba(0,0,50,0.3)] resize-none"
/>
</div>
</div>
</Show>
<Show when={!permissions.loading && allModules().length === 0}>
<p class="notice">No areas available.</p>
</Show>
</div>
{/* Permission Table */}
<Show when={assignedModules().length > 0}>
<div class="mb-6 rounded-xl border border-gray-200 bg-white p-6 shadow-sm">
<h3>Permissions</h3>
<p>Choose what this role can do in each selected area.</p>
<div class="table-card overflow-x-auto">
<table data-table class="w-full text-sm">
<thead>
<tr>
<th style="width:45%">Area</th>
<th style="width:11%">No Access</th>
<th style="width:11%">Read</th>
<th style="width:11%">Create</th>
<th style="width:11%">Update</th>
<th style="width:11%">Delete</th>
</tr>
</thead>
<tbody>
{assignedModules().sort().map((mod) => {
const perms = permsByModule()[mod] || [];
const actionMap: Record<string, string> = {};
perms.forEach((p) => { actionMap[p.action] = p.id; });
const hasRead = !!actionMap['Read'] && permissionIds().includes(actionMap['Read']);
const hasCreate = !!actionMap['Create'] && permissionIds().includes(actionMap['Create']);
const hasUpdate = !!actionMap['Update'] && permissionIds().includes(actionMap['Update']);
const hasDelete = !!actionMap['Delete'] && permissionIds().includes(actionMap['Delete']);
const noAccess = !hasRead && !hasCreate && !hasUpdate && !hasDelete;
return (
<tr class="hover:bg-slate-50">
<td class="font-semibold text-slate-900">{mod}</td>
<td><input type="checkbox" checked={noAccess} disabled aria-label={`${mod} no access`} /></td>
<td>{actionMap['Read'] ? <input type="checkbox" checked={hasRead} onChange={() => togglePermission(actionMap['Read'])} aria-label={`${mod} read`} /> : <span class="text-slate-300"></span>}</td>
<td>{actionMap['Create'] ? <input type="checkbox" checked={hasCreate} onChange={() => togglePermission(actionMap['Create'])} aria-label={`${mod} create`} /> : <span class="text-slate-300"></span>}</td>
<td>{actionMap['Update'] ? <input type="checkbox" checked={hasUpdate} onChange={() => togglePermission(actionMap['Update'])} aria-label={`${mod} update`} /> : <span class="text-slate-300"></span>}</td>
<td>{actionMap['Delete'] ? <input type="checkbox" checked={hasDelete} onChange={() => togglePermission(actionMap['Delete'])} aria-label={`${mod} delete`} /> : <span class="text-slate-300"></span>}</td>
{/* ── Tab: Module Access ── */}
<Show when={subTab() === 'module'}>
<div class="p-6">
<p class="text-[13px] text-[rgba(0,0,50,0.5)] mb-4">
Configure module access permissions for this role.
</p>
<div class="overflow-x-auto rounded-lg border border-[#e5e7eb]">
<table class="w-full">
<thead>
<tr class="bg-[#000032] text-white text-[12px] font-semibold uppercase tracking-wide">
<th class="px-5 py-3 text-left w-[40%]"> </th>
<th class="px-4 py-3 text-center">View</th>
<th class="px-4 py-3 text-center">Create</th>
<th class="px-4 py-3 text-center">Update</th>
<th class="px-4 py-3 text-center">Delete</th>
<th class="px-4 py-3 text-center">
<button
type="button"
onClick={() => (allSelected() ? deselectAll() : selectAll())}
class="text-[11px] font-semibold text-white hover:text-[#fca87c] transition-colors whitespace-nowrap"
>
{allSelected() ? 'Deselect All' : 'Select All'}
</button>
</th>
</tr>
</thead>
<tbody class="divide-y divide-[#e5e7eb]">
<Show when={permissions.loading}>
<tr>
<td colspan="6" class="px-5 py-6 text-center text-[13px] text-[rgba(0,0,50,0.4)]">
Loading modules
</td>
</tr>
);
})}
</tbody>
</table>
</Show>
<For each={allModules()}>
{(module) => {
const perms = () => permsByModule()[module] ?? [];
const byAction = () => {
const m: Record<string, Permission> = {};
perms().forEach((p) => { m[p.action] = p; });
return m;
};
const rowAllSelected = () => perms().every((p) => selectedKeys().has(p.key));
return (
<tr class="hover:bg-[#fafafa]">
<td class="px-5 py-3.5 text-[13px] font-medium text-[#000032]">
{module}
</td>
{ACTIONS.map((action) => {
const p = () => byAction()[action];
return (
<td class="px-4 py-3.5 text-center">
<Show
when={p()}
fallback={<span class="text-[#d1d5db] text-xs"></span>}
>
<input
type="checkbox"
checked={selectedKeys().has(p()!.key)}
onChange={() => toggleKey(p()!.key)}
class="h-4 w-4 accent-[#fa5014] cursor-pointer"
/>
</Show>
</td>
);
})}
<td class="px-4 py-3.5 text-center">
<input
type="checkbox"
checked={rowAllSelected()}
onChange={() => toggleRow(module)}
class="h-4 w-4 accent-[#fa5014] cursor-pointer"
/>
</td>
</tr>
);
}}
</For>
</tbody>
</table>
</div>
</div>
</div>
</Show>
</Show>
{/* Save */}
<div class="flex justify-end gap-3 mt-2">
<A class="rounded-lg border border-gray-200 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors" href="/admin/roles">Cancel</A>
<button
class="btn-primary"
onClick={handleSave}
disabled={saving() || !roleName().trim()}
>
{saving() ? 'Creating...' : 'Create Role'}
</button>
{/* ── Tab: Role Settings ── */}
<Show when={subTab() === 'settings'}>
<div class="p-6 space-y-6">
{/* Status toggle */}
<div>
<p class="text-[13px] font-semibold text-[#000032] mb-3">Role Status</p>
<div class="flex items-center gap-2">
<button
type="button"
onClick={() => setIsActive(true)}
class={`px-5 py-2 rounded-lg text-[13px] font-medium transition-colors ${
isActive()
? 'bg-[#fa5014] text-white'
: 'border border-[#e5e7eb] text-[rgba(0,0,50,0.6)] hover:border-[#000032]'
}`}
>
Active
</button>
<button
type="button"
onClick={() => setIsActive(false)}
class={`px-5 py-2 rounded-lg text-[13px] font-medium transition-colors ${
!isActive()
? 'bg-[#fa5014] text-white'
: 'border border-[#e5e7eb] text-[rgba(0,0,50,0.6)] hover:border-[#000032]'
}`}
>
Inactive
</button>
</div>
</div>
{/* Setting toggles */}
<div class="space-y-3">
<SettingToggle
label="Allow Role to Approve Requests"
description="Enable this role to approve various requests"
value={canApprove()}
onChange={setCanApprove}
/>
<SettingToggle
label="Allow Role to Manage System Settings"
description="Enable this role to manage system settings and configurations"
value={canManage()}
onChange={setCanManage}
/>
</div>
</div>
</Show>
{/* Footer actions */}
<div class="flex items-center justify-end gap-3 px-6 py-4 border-t border-[#e5e7eb]">
<A
href="/admin/roles"
class="px-5 py-2.5 text-[13px] font-medium border border-[#e5e7eb] rounded-lg text-[rgba(0,0,50,0.7)] hover:border-[#000032] transition-colors"
>
Cancel
</A>
<button
onClick={handleSave}
disabled={saving()}
class="px-5 py-2.5 text-[13px] font-semibold bg-[#000032] text-white rounded-lg hover:bg-[#000050] transition-colors disabled:opacity-60"
>
{saving() ? 'Creating…' : 'Create Role'}
</button>
</div>
</div>
</div>
</div>
</AdminShell>
);
}
// ── Setting toggle row ────────────────────────────────────────────────────────
function SettingToggle(props: {
label: string;
description: string;
value: boolean;
onChange: (v: boolean) => void;
}) {
return (
<div class="flex items-center justify-between rounded-xl border border-[#e5e7eb] px-5 py-4">
<div>
<p class="text-[13px] font-semibold text-[#000032]">{props.label}</p>
<p class="text-[12px] text-[rgba(0,0,50,0.5)] mt-0.5">{props.description}</p>
</div>
<button
type="button"
role="switch"
aria-checked={props.value}
onClick={() => props.onChange(!props.value)}
class={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none ${
props.value ? 'bg-[#fa5014]' : 'bg-[#d1d5db]'
}`}
>
<span
class={`inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform ${
props.value ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
</div>
);
}

View file

@ -1,48 +1,93 @@
import { A } from '@solidjs/router';
import { createResource, createSignal, Show, For } from 'solid-js';
import { Eye, Pencil, Trash2 } from 'lucide-solid';
import { A, useNavigate } from '@solidjs/router';
import { createResource, createSignal, For, Show } from 'solid-js';
import { Search } from 'lucide-solid';
import AdminShell from '~/components/AdminShell';
const API = '/api/gateway';
type Role = {
id: string;
key: string;
name: string;
audience: string;
description?: string;
code?: string;
department_name?: string;
is_active: boolean;
users_assigned: number;
permissions_count: number;
created_at: string;
};
async function loadInternalRoles(): Promise<Role[]> {
type ListResponse = {
roles: Role[];
total: number;
page: number;
per_page: number;
};
async function loadRoles(params: { q: string; page: number }): Promise<ListResponse> {
try {
const res = await fetch(`${API}/api/admin/roles?audience=INTERNAL`);
if (!res.ok) throw new Error('Failed to load');
const qs = new URLSearchParams({
audience: 'INTERNAL',
q: params.q,
page: String(params.page),
per_page: '8',
});
const res = await fetch(`${API}/api/admin/roles?${qs}`);
if (!res.ok) throw new Error('Failed');
const data = await res.json();
const rows = Array.isArray(data) ? data : (data.roles || []);
return rows.map((r: any) => ({
id: r.id,
name: r.name,
description: r.description || '',
code: r.code || r.key || '',
}));
if (data.roles) return data;
// flat array fallback
return { roles: Array.isArray(data) ? data : [], total: 0, page: 1, per_page: 8 };
} catch {
return [];
return { roles: [], total: 0, page: 1, per_page: 8 };
}
}
export default function InternalRolesPage() {
const [roles, { refetch }] = createResource(loadInternalRoles);
function formatDate(iso: string) {
try {
const d = new Date(iso);
return d.toISOString().slice(0, 10);
} catch {
return '—';
}
}
export default function InternalRolesListPage() {
const navigate = useNavigate();
const [search, setSearch] = createSignal('');
const [debouncedSearch, setDebouncedSearch] = createSignal('');
const [page, setPage] = createSignal(1);
const [deleting, setDeleting] = createSignal('');
const [deleteError, setDeleteError] = createSignal('');
let debounceTimer: ReturnType<typeof setTimeout>;
const handleSearch = (val: string) => {
setSearch(val);
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
setDebouncedSearch(val);
setPage(1);
}, 300);
};
const [data, { refetch }] = createResource(
() => ({ q: debouncedSearch(), page: page() }),
loadRoles,
);
const totalPages = () => {
const d = data();
if (!d || d.per_page === 0) return 1;
return Math.max(1, Math.ceil(d.total / d.per_page));
};
const handleDelete = async (id: string, name: string) => {
if (!confirm(`Delete role "${name}"? This cannot be undone.`)) return;
setDeleting(id); setDeleteError('');
setDeleting(id);
try {
const res = await fetch(`${API}/api/admin/roles/${id}`, { method: 'DELETE' });
if (!res.ok) throw new Error('Failed to delete role');
if (!res.ok) throw new Error('Failed');
refetch();
} catch (err: any) {
setDeleteError(err.message || 'Failed to delete role');
} finally {
setDeleting('');
}
@ -50,73 +95,145 @@ export default function InternalRolesPage() {
return (
<AdminShell>
<div class="flex flex-col -mx-6 -mt-6 min-h-full">
<div class="flex flex-col gap-0 -mx-6 -mt-6 min-h-full">
{/* ── Page header ── */}
<div class="bg-white border-b border-gray-200 px-6 py-4 flex items-center justify-between">
<div>
<h1 class="text-xl font-semibold text-gray-900">Internal Role Management</h1>
<p class="text-sm text-gray-500 mt-0.5">Manage internal employee roles and permissions.</p>
</div>
<A
href="/admin/roles/create"
class="btn-primary"
>
Create Internal Role
</A>
{/* Page title */}
<div class="bg-white border-b border-[#e5e7eb] px-6 py-5">
<h1 class="text-[20px] font-semibold text-[#000032]">Internal Role Management</h1>
<p class="text-[13px] text-[rgba(0,0,50,0.5)] mt-0.5">Manage internal roles and permissions</p>
</div>
{/* ── Content ── */}
{/* Card */}
<div class="p-6">
<Show when={deleteError()}>
<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>
<div class="rounded-xl border border-[#e5e7eb] bg-white shadow-sm overflow-hidden">
<div class="table-card">
{/* Tabs */}
<div class="flex border-b border-[#e5e7eb] px-6">
<button
class="relative py-4 text-[14px] font-semibold text-[#fa5014] mr-6"
>
All Roles
<span class="absolute bottom-0 left-0 right-0 h-[2px] bg-[#fa5014] rounded-t" />
</button>
<A
href="/admin/roles/create"
class="py-4 text-[14px] font-medium text-[rgba(0,0,50,0.5)] hover:text-[#000032]"
>
Create Role
</A>
</div>
{/* Toolbar */}
<div class="flex items-center gap-3 px-6 py-4 border-b border-[#e5e7eb]">
<div class="relative flex-1 max-w-[280px]">
<Search size={15} class="absolute left-3 top-1/2 -translate-y-1/2 text-[#9195ad]" />
<input
type="text"
placeholder="Search roles..."
value={search()}
onInput={(e) => handleSearch(e.currentTarget.value)}
class="w-full pl-9 pr-3 py-2 text-[13px] border border-[#e5e7eb] rounded-lg outline-none focus:border-[#fa5014] focus:ring-1 focus:ring-[#fa5014] bg-white"
/>
</div>
<select class="text-[13px] border border-[#e5e7eb] rounded-lg px-3 py-2 outline-none bg-white text-[rgba(0,0,50,0.6)] focus:border-[#fa5014]">
<option value="">All Departments</option>
</select>
<select class="text-[13px] border border-[#e5e7eb] rounded-lg px-3 py-2 outline-none bg-white text-[rgba(0,0,50,0.6)] focus:border-[#fa5014]">
<option value="">All Statuses</option>
<option value="active">Active</option>
<option value="inactive">Inactive</option>
</select>
</div>
{/* Table */}
<div class="overflow-x-auto">
<table class="data-table w-full text-sm">
<table class="w-full">
<thead>
<tr>
<th>Name</th>
<th>Description</th>
<th class="text-right">Actions</th>
<tr class="bg-[#000032] text-white text-[12px] font-semibold uppercase tracking-wide">
<th class="px-6 py-3 text-left">Role Name</th>
<th class="px-6 py-3 text-left">Department</th>
<th class="px-6 py-3 text-left">Users Assigned</th>
<th class="px-6 py-3 text-left">Permissions Count</th>
<th class="px-6 py-3 text-left">Status</th>
<th class="px-6 py-3 text-left">Created Date</th>
<th class="px-6 py-3 text-left">Actions</th>
</tr>
</thead>
<tbody>
<Show when={roles.loading}>
<tr><td colspan="3" class="py-10 text-center text-sm text-slate-400">Loading internal roles</td></tr>
<tbody class="divide-y divide-[#e5e7eb]">
<Show when={data.loading}>
<tr>
<td colspan="7" class="px-6 py-10 text-center text-[13px] text-[rgba(0,0,50,0.4)]">
Loading roles
</td>
</tr>
</Show>
<Show when={!roles.loading && roles.error}>
<tr><td colspan="3" class="py-10 text-center text-sm text-red-500">Failed to load roles. Is the backend running?</td></tr>
<Show when={!data.loading && (data()?.roles?.length ?? 0) === 0}>
<tr>
<td colspan="7" class="px-6 py-10 text-center text-[13px] text-[rgba(0,0,50,0.4)]">
No internal roles found.{' '}
<A href="/admin/roles/create" class="text-[#fa5014] hover:underline">
Create your first role.
</A>
</td>
</tr>
</Show>
<Show when={!roles.loading && !roles.error && (roles()?.length ?? 0) === 0}>
<tr><td colspan="3" class="py-10 text-center text-sm text-slate-400">No internal roles found. Create your first role.</td></tr>
</Show>
<For each={roles()}>
<For each={data()?.roles ?? []}>
{(role) => (
<tr class="hover:bg-slate-50">
<td>
<p class="font-medium text-gray-900">{role.name}</p>
<p class="mt-0.5 text-xs text-slate-500">{role.code || role.id?.slice(0, 8) || '—'}</p>
<tr class="hover:bg-[#fafafa] transition-colors">
<td class="px-6 py-4">
<p class="text-[14px] font-semibold text-[#000032]">{role.name}</p>
<p class="text-[12px] text-[rgba(0,0,50,0.4)] mt-0.5">{role.key}</p>
</td>
<td class="text-slate-600">{role.description || 'No description added yet.'}</td>
<td>
<div class="flex items-center justify-end gap-2">
<A href={`/admin/roles/${role.id}`} title="View Role"
class="action-btn flex items-center justify-center hover:bg-gray-50 transition-colors">
<Eye size={14} class="text-gray-600" />
<td class="px-6 py-4 text-[13px] text-[rgba(0,0,50,0.7)]">
{role.department_name || '—'}
</td>
<td class="px-6 py-4 text-[14px] font-semibold text-[#000032]">
{role.users_assigned}
</td>
<td class="px-6 py-4">
<span class="inline-flex items-center px-2.5 py-1 rounded-md bg-[rgba(250,80,20,0.1)] text-[#fa5014] text-[12px] font-semibold">
{role.permissions_count} Permissions
</span>
</td>
<td class="px-6 py-4">
<Show
when={role.is_active}
fallback={
<span class="inline-flex items-center px-2.5 py-1 rounded-md bg-[#f1f1f1] text-[rgba(0,0,50,0.5)] text-[12px] font-semibold">
Inactive
</span>
}
>
<span class="inline-flex items-center px-2.5 py-1 rounded-md bg-[rgba(34,197,94,0.1)] text-[#16a34a] text-[12px] font-semibold">
Active
</span>
</Show>
</td>
<td class="px-6 py-4 text-[13px] text-[rgba(0,0,50,0.6)]">
{formatDate(role.created_at)}
</td>
<td class="px-6 py-4">
<div class="flex items-center gap-2">
<A
href={`/admin/roles/${role.id}`}
class="text-[12px] font-medium text-[#000032] hover:text-[#fa5014] transition-colors"
>
View
</A>
<A href={`/admin/roles/${role.id}/edit`} title="Edit Role"
class="action-btn flex items-center justify-center hover:bg-gray-50 transition-colors">
<Pencil size={14} class="text-gray-600" />
<span class="text-[#e5e7eb]">|</span>
<A
href={`/admin/roles/${role.id}/edit`}
class="text-[12px] font-medium text-[#000032] hover:text-[#fa5014] transition-colors"
>
Edit
</A>
<span class="text-[#e5e7eb]">|</span>
<button
title="Delete Role"
disabled={deleting() === role.id}
onClick={() => handleDelete(role.id, role.name)}
class="action-btn flex items-center justify-center border-red-100 bg-red-50 hover:bg-red-100 transition-colors"
class="text-[12px] font-medium text-red-500 hover:text-red-700 transition-colors disabled:opacity-50"
>
<Trash2 size={14} class="text-red-600" />
{deleting() === role.id ? '…' : 'Delete'}
</button>
</div>
</td>
@ -126,6 +243,46 @@ export default function InternalRolesPage() {
</tbody>
</table>
</div>
{/* Pagination */}
<Show when={!data.loading && (data()?.total ?? 0) > 0}>
<div class="flex items-center justify-between px-6 py-4 border-t border-[#e5e7eb]">
<p class="text-[13px] text-[rgba(0,0,50,0.5)]">
Showing {((page() - 1) * (data()?.per_page ?? 8)) + 1}
{Math.min(page() * (data()?.per_page ?? 8), data()?.total ?? 0)} of {data()?.total ?? 0} roles
</p>
<div class="flex items-center gap-1">
<button
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page() <= 1}
class="h-8 w-8 flex items-center justify-center rounded-lg border border-[#e5e7eb] text-[rgba(0,0,50,0.5)] hover:border-[#000032] disabled:opacity-40 transition-colors text-[13px]"
>
</button>
<For each={Array.from({ length: totalPages() }, (_, i) => i + 1)}>
{(p) => (
<button
onClick={() => setPage(p)}
class={`h-8 w-8 flex items-center justify-center rounded-lg text-[13px] font-medium transition-colors ${
p === page()
? 'bg-[#fa5014] text-white'
: 'border border-[#e5e7eb] text-[rgba(0,0,50,0.6)] hover:border-[#000032]'
}`}
>
{p}
</button>
)}
</For>
<button
onClick={() => setPage((p) => Math.min(totalPages(), p + 1))}
disabled={page() >= totalPages()}
class="h-8 w-8 flex items-center justify-center rounded-lg border border-[#e5e7eb] text-[rgba(0,0,50,0.5)] hover:border-[#000032] disabled:opacity-40 transition-colors text-[13px]"
>
</button>
</div>
</div>
</Show>
</div>
</div>
</div>