chore: checkpoint current workspace changes
This commit is contained in:
parent
13b428913f
commit
3f20065257
9 changed files with 1578 additions and 1401 deletions
|
|
@ -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
|
||||
}
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue