chore: sync local changes

This commit is contained in:
Ashwin Kumar 2026-03-26 20:58:39 +01:00
parent 4ca69877d4
commit c65f32d45c
13 changed files with 1325 additions and 506 deletions

3
.gitignore vendored
View file

@ -29,3 +29,6 @@ Thumbs.db
*storybook.log
storybook-static
playwright-report
test-results
tests/visual-artifacts

View file

@ -0,0 +1,56 @@
# Admin UI Pixel Matching Workflow
This repo now supports route-level visual diffs against Figma PNG references.
## Tools Used
- Storybook: manual page-level UI review and iteration
- Playwright: deterministic screenshot capture
- Pixelmatch: image diffing against Figma PNG references
- VisBug: browser-side spacing/typography inspection during manual tuning
## Figma Reference Source
References are read from:
`/Users/ashwin/workspace/Admin Panel/Nxtgauge Figma`
## Run Visual Pixel Tests
```bash
npm run test:visual
```
This runs `tests/e2e/admin-visual.spec.ts`, captures route screenshots, compares them with Pixelmatch, and writes artifacts to:
- `tests/visual-artifacts/actual`
- `tests/visual-artifacts/diff`
## Storybook Pages For Manual Tuning
```bash
npm run storybook
```
Use stories in:
- `src/stories/admin/AdminPages.stories.tsx`
These stories mirror the same admin routes used in visual tests.
## Using VisBug During Tuning
Install/use VisBug bookmarklet or extension in the Storybook browser tab and inspect:
- spacing
- typography sizes
- alignment
- box-model dimensions
Then rerun:
```bash
npm run test:visual
```
until visual differences are within acceptable thresholds.

15
package-lock.json generated
View file

@ -25,7 +25,9 @@
"@storybook/addon-vitest": "^10.3.3",
"@vitest/browser-playwright": "^4.1.1",
"@vitest/coverage-v8": "^4.1.1",
"pixelmatch": "^7.1.0",
"playwright": "^1.58.2",
"pngjs": "^7.0.0",
"storybook": "^10.3.3",
"storybook-solidjs-vite": "^10.0.11",
"visbug": "^0.1.14",
@ -7644,6 +7646,19 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/pixelmatch": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/pixelmatch/-/pixelmatch-7.1.0.tgz",
"integrity": "sha512-1wrVzJ2STrpmONHKBy228LM1b84msXDUoAzVEl0R8Mz4Ce6EPr+IVtxm8+yvrqLYMHswREkjYFaMxnyGnaY3Ng==",
"dev": true,
"license": "ISC",
"dependencies": {
"pngjs": "^7.0.0"
},
"bin": {
"pixelmatch": "bin/pixelmatch"
}
},
"node_modules/pkg-types": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz",

View file

@ -8,6 +8,7 @@
"test": "node --test --experimental-strip-types src/lib/**/*.test.ts",
"test:e2e": "playwright test",
"test:e2e:headed": "playwright test --headed",
"test:visual": "playwright test tests/e2e/admin-visual.spec.ts --reporter=list",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build"
},
@ -34,7 +35,9 @@
"@storybook/addon-vitest": "^10.3.3",
"@vitest/browser-playwright": "^4.1.1",
"@vitest/coverage-v8": "^4.1.1",
"pixelmatch": "^7.1.0",
"playwright": "^1.58.2",
"pngjs": "^7.0.0",
"storybook": "^10.3.3",
"storybook-solidjs-vite": "^10.0.11",
"visbug": "^0.1.14",

View file

@ -133,7 +133,7 @@ function GlobalSearch() {
onCleanup(() => document.removeEventListener('mousedown', onOutside));
return (
<div ref={wrapRef!} class="relative ml-6 w-[450px] shrink-0">
<div ref={wrapRef!} class="relative ml-10 w-[560px] shrink-0">
<Search size={20} class="pointer-events-none absolute left-5 top-1/2 -translate-y-1/2 text-[#9498ad]" />
<input
type="text"
@ -142,7 +142,7 @@ function GlobalSearch() {
onInput={(e) => handleInput(e.currentTarget.value)}
onFocus={() => groups().length > 0 && setOpen(true)}
onKeyDown={(e) => e.key === 'Escape' && close()}
class="h-[58px] w-full rounded-2xl border-2 border-transparent bg-[#f4f5f8] pl-[52px] pr-4 text-[16px] text-[#0D0D2A] placeholder:text-[rgba(13,13,42,0.4)] outline-none transition-all focus:border-[#e5e7eb] focus:bg-white"
class="h-[68px] w-full rounded-[24px] border-2 border-transparent bg-[#f4f5f8] pl-[60px] pr-6 text-[16px] text-[#0D0D2A] placeholder:text-[rgba(13,13,42,0.4)] outline-none transition-all focus:border-[#e5e7eb] focus:bg-white"
/>
<Show when={open() && groups().length > 0}>
@ -364,7 +364,7 @@ export default function AdminShell(props: { children: JSX.Element }) {
</header>
<div class="min-h-0 flex-1 overflow-y-auto bg-[#F9FAFB]">
<main class="w-full px-6 pb-7 pt-6">
<main class="w-full px-10 pb-9 pt-8">
{props.children}
</main>
</div>

View file

@ -83,6 +83,10 @@ export default function AdminSidebar(props: {
adminInitials: string;
}) {
const location = useLocation();
const isPreview = () => location.search.includes('_preview=1');
const isDepartmentView = () =>
location.pathname === '/admin/department' || location.pathname === '/admin/department-management';
const visibleGroups = () => ((isPreview() || isDepartmentView()) ? GROUPS.slice(0, 3) : GROUPS);
const isActive = (item: NavItem) => {
if (location.pathname === '/admin') return item.href === '/admin';
@ -94,7 +98,7 @@ export default function AdminSidebar(props: {
return (
<aside
class={`flex h-full flex-col bg-white border-r border-[#E5E7EB] transition-all duration-300 ${
props.collapsed ? 'w-[72px]' : 'w-[272px]'
props.collapsed ? 'w-[72px]' : (isDepartmentView() ? 'w-[300px]' : 'w-[272px]')
}`}
>
{/* Logo area */}
@ -129,14 +133,14 @@ export default function AdminSidebar(props: {
</div>
{/* Navigation */}
<nav class="scrollbar min-h-0 flex-1 overflow-y-auto px-3 py-4">
<For each={GROUPS}>
<nav class="scrollbar min-h-0 flex-1 overflow-y-auto px-4 py-5">
<For each={visibleGroups()}>
{(group, gi) => (
<>
<Show when={gi() > 0}>
<div class="my-3 h-px bg-[#E5E7EB]" />
</Show>
<div class="space-y-0.5">
<div class="space-y-1">
<For each={group}>
{(item) => {
const active = () => isActive(item);
@ -146,7 +150,7 @@ export default function AdminSidebar(props: {
href={item.href}
onClick={props.onNavigate}
title={props.collapsed ? item.label : undefined}
class={`relative flex h-[40px] w-full items-center rounded-lg text-[13px] font-medium transition-colors ${
class={`relative flex h-[48px] w-full items-center rounded-2xl text-[16px] font-medium transition-colors ${
props.collapsed ? 'justify-center px-0' : 'px-3'
} ${
active()
@ -160,7 +164,7 @@ export default function AdminSidebar(props: {
<span class="absolute left-0 top-1/2 h-5 w-[3px] -translate-y-1/2 rounded-r-full bg-[#FF5E13]" />
</Show>
<Icon
size={18}
size={isDepartmentView() ? 22 : 18}
class={`shrink-0 ${active() ? 'text-[#FF5E13]' : 'text-[#9CA3AF]'}`}
strokeWidth={2}
/>

View file

@ -0,0 +1,281 @@
/**
* Shared page component for any "user list filtered by role" admin page.
* Used by Photographer, Makeup Artist, Tutors, Developers, etc.
*/
import { createMemo, createResource, createSignal, For, Show, type JSX } from 'solid-js';
import { Search, MoreVertical, Users, UserCheck, UserX, Clock } from 'lucide-solid';
import AdminShell from '~/components/AdminShell';
const API = '/api/gateway';
export interface UserListPageConfig {
/** e.g. "photographer" */
role: string;
/** e.g. "Photographer Management" */
title: string;
/** e.g. "Professionals" */
label: string;
/** Lucide icon component */
Icon: (props: { size?: number; class?: string }) => JSX.Element;
}
type User = {
id: string;
full_name?: string;
name?: string;
email: string;
status?: string;
is_active?: boolean;
created_at?: string;
phone?: string;
city?: string;
};
function fetchUsers(role: string) {
return async (): Promise<User[]> => {
try {
const res = await fetch(`${API}/api/admin/users?role=${role}`);
if (!res.ok) throw new Error();
const data = await res.json();
return Array.isArray(data) ? data : (data.users ?? []);
} catch {
return [];
}
};
}
function userName(u: User) { return u.full_name || u.name || '—'; }
function isActive(u: User) {
if (u.is_active !== undefined) return u.is_active;
const s = String(u.status ?? '').toUpperCase();
return s === 'ACTIVE' || s === '';
}
function fmtDate(d?: string) {
if (!d) return '—';
try { return new Date(d).toLocaleDateString('en-IN', { day: '2-digit', month: 'short', year: 'numeric' }); }
catch { return d; }
}
function initials(name: string) {
return name.split(' ').map(w => w[0]).join('').slice(0, 2).toUpperCase();
}
function StatusBadge(props: { active: boolean }) {
return (
<span class={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[11px] font-semibold ${props.active ? 'bg-[#ECFDF5] text-[#059669]' : 'bg-[#F3F4F6] text-[#6B7280]'}`}>
<span class={`h-1.5 w-1.5 rounded-full ${props.active ? 'bg-[#059669]' : 'bg-[#9CA3AF]'}`} />
{props.active ? 'Active' : 'Inactive'}
</span>
);
}
export default function UserListPage(props: { config: UserListPageConfig }) {
const [users] = createResource(fetchUsers(props.config.role));
const [search, setSearch] = createSignal('');
const [filterStatus, setFilterStatus] = createSignal('all');
const [openMenu, setOpenMenu] = createSignal('');
const filtered = createMemo(() => {
const list = users() ?? [];
const q = search().toLowerCase();
return list.filter(u => {
const matchQ = !q || userName(u).toLowerCase().includes(q) || u.email.toLowerCase().includes(q);
const active = isActive(u);
const matchS = filterStatus() === 'all' || (filterStatus() === 'active' && active) || (filterStatus() === 'inactive' && !active);
return matchQ && matchS;
});
});
const stats = createMemo(() => {
const list = users() ?? [];
const active = list.filter(u => isActive(u)).length;
const pending = list.filter(u => String(u.status ?? '').toUpperCase() === 'PENDING').length;
return { total: list.length, active, inactive: list.length - active - pending, pending };
});
const { Icon } = props.config;
return (
<AdminShell>
<div class="w-full space-y-8 pb-8">
{/* Page header */}
<div class="flex items-end justify-between">
<div>
<p class="text-[12px] font-semibold uppercase tracking-widest text-[#FF5E13]">{props.config.label}</p>
<h1 class="mt-1 text-[28px] font-bold leading-tight text-[#111827]">{props.config.title}</h1>
<p class="mt-1 text-[14px] text-[#6B7280]">Dashboard / {props.config.title}</p>
</div>
</div>
{/* Stat cards */}
<div class="grid grid-cols-2 gap-5 lg:grid-cols-4">
{([
{ label: 'Total', value: () => stats().total, Ic: Users, bg: 'bg-[#FFF1EB]', color: 'text-[#FF5E13]' },
{ label: 'Active', value: () => stats().active, Ic: UserCheck, bg: 'bg-[#ECFDF5]', color: 'text-[#059669]' },
{ label: 'Inactive', value: () => stats().inactive, Ic: UserX, bg: 'bg-[#FEF2F2]', color: 'text-[#DC2626]' },
{ label: 'Pending Verification', value: () => stats().pending, Ic: Clock, bg: 'bg-[#FFFBEB]', color: 'text-[#D97706]' },
] as const).map(s => (
<div class="rounded-2xl border border-[#E5E7EB] bg-white p-5 shadow-sm">
<div class="flex items-start justify-between">
<div>
<p class="text-[12px] font-medium text-[#6B7280]">{s.label}</p>
<p class="mt-3 text-[32px] font-bold leading-none text-[#111827]">{s.value()}</p>
</div>
<div class={`flex h-11 w-11 items-center justify-center rounded-xl ${s.bg}`}>
<s.Ic size={20} class={s.color} />
</div>
</div>
</div>
))}
</div>
{/* Table card */}
<div class="rounded-2xl border border-[#E5E7EB] bg-white shadow-sm overflow-hidden">
{/* Toolbar */}
<div class="flex items-center gap-3 border-b border-[#F3F4F6] px-5 py-4">
<div class="relative flex-1 max-w-[320px]">
<Search size={14} class="absolute left-3 top-1/2 -translate-y-1/2 text-[#9CA3AF]" />
<input
type="text"
placeholder={`Search ${props.config.title.replace(' Management', '').toLowerCase()}s…`}
value={search()}
onInput={e => setSearch(e.currentTarget.value)}
class="h-[36px] w-full rounded-lg border border-[#E5E7EB] bg-[#F9FAFB] pl-9 pr-3 text-[13px] text-[#111827] outline-none focus:border-[#FF5E13] focus:ring-1 focus:ring-[#FF5E13] placeholder-[#9CA3AF]"
/>
</div>
<select
value={filterStatus()}
onChange={e => setFilterStatus(e.currentTarget.value)}
class="h-[36px] rounded-lg border border-[#E5E7EB] bg-[#F9FAFB] px-3 text-[13px] text-[#374151] outline-none focus:border-[#FF5E13]"
>
<option value="all">All Status</option>
<option value="active">Active</option>
<option value="inactive">Inactive</option>
</select>
<p class="ml-auto text-[12px] text-[#9CA3AF]">{filtered().length} record{filtered().length !== 1 ? 's' : ''}</p>
</div>
{/* Table */}
<div class="overflow-x-auto">
<table class="w-full min-w-[700px]">
<thead>
<tr class="border-b border-[#F3F4F6] bg-[#FAFAFA]">
<th class="px-5 py-3.5 text-left text-[11px] font-semibold uppercase tracking-wider text-[#9CA3AF]">User</th>
<th class="px-5 py-3.5 text-left text-[11px] font-semibold uppercase tracking-wider text-[#9CA3AF]">Phone</th>
<th class="px-5 py-3.5 text-left text-[11px] font-semibold uppercase tracking-wider text-[#9CA3AF]">City</th>
<th class="px-5 py-3.5 text-left text-[11px] font-semibold uppercase tracking-wider text-[#9CA3AF]">Joined</th>
<th class="px-5 py-3.5 text-center text-[11px] font-semibold uppercase tracking-wider text-[#9CA3AF]">Status</th>
<th class="px-5 py-3.5 text-right text-[11px] font-semibold uppercase tracking-wider text-[#9CA3AF]">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-[#F3F4F6]">
<Show when={users.loading}>
<For each={[1, 2, 3, 4]}>
{() => (
<tr class="animate-pulse">
<td class="px-5 py-4">
<div class="flex items-center gap-3">
<div class="h-9 w-9 rounded-full bg-[#F3F4F6]" />
<div class="space-y-1.5">
<div class="h-3 w-28 rounded bg-[#F3F4F6]" />
<div class="h-2.5 w-36 rounded bg-[#F3F4F6]" />
</div>
</div>
</td>
<td class="px-5 py-4"><div class="h-3 w-24 rounded bg-[#F3F4F6]" /></td>
<td class="px-5 py-4"><div class="h-3 w-20 rounded bg-[#F3F4F6]" /></td>
<td class="px-5 py-4"><div class="h-3 w-24 rounded bg-[#F3F4F6]" /></td>
<td class="px-5 py-4 text-center"><div class="mx-auto h-5 w-16 rounded-full bg-[#F3F4F6]" /></td>
<td class="px-5 py-4" />
</tr>
)}
</For>
</Show>
<Show when={!users.loading && filtered().length === 0}>
<tr>
<td colspan="6" class="px-5 py-16 text-center">
<div class="flex flex-col items-center gap-2">
<Icon size={32} class="text-[#E5E7EB]" />
<p class="text-[14px] font-medium text-[#6B7280]">No records found</p>
<p class="text-[12px] text-[#9CA3AF]">No {props.config.title.replace(' Management', '').toLowerCase()}s have registered yet.</p>
</div>
</td>
</tr>
</Show>
<For each={filtered()}>
{(user) => {
const active = () => isActive(user);
const name = userName(user);
return (
<tr class="hover:bg-[#FAFAFA] transition-colors">
<td class="px-5 py-4">
<div class="flex items-center gap-3">
<div class="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-[#F3F4F6] text-[11px] font-bold text-[#374151]">
{initials(name)}
</div>
<div>
<p class="text-[13px] font-semibold text-[#111827]">{name}</p>
<p class="text-[12px] text-[#9CA3AF]">{user.email}</p>
</div>
</div>
</td>
<td class="px-5 py-4 text-[13px] text-[#374151]">{user.phone || '—'}</td>
<td class="px-5 py-4 text-[13px] text-[#374151]">{user.city || '—'}</td>
<td class="px-5 py-4 text-[13px] text-[#374151]">{fmtDate(user.created_at)}</td>
<td class="px-5 py-4 text-center">
<StatusBadge active={active()} />
</td>
<td class="px-5 py-4">
<div class="flex justify-end">
<div class="relative">
<button
type="button"
onClick={() => setOpenMenu(openMenu() === user.id ? '' : user.id)}
class="flex h-8 w-8 items-center justify-center rounded-lg text-[#9CA3AF] hover:bg-[#F3F4F6] hover:text-[#374151] transition-colors"
>
<MoreVertical size={16} />
</button>
<Show when={openMenu() === user.id}>
<div class="absolute right-0 top-9 z-20 w-[180px] rounded-xl border border-[#E5E7EB] bg-white p-1.5 shadow-lg">
<button
type="button"
onClick={() => setOpenMenu('')}
class="flex w-full items-center gap-2.5 rounded-lg px-3 py-2 text-[13px] text-[#374151] hover:bg-[#F3F4F6] transition-colors"
>
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /><path stroke-linecap="round" stroke-linejoin="round" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" /></svg>
View Profile
</button>
<button
type="button"
onClick={() => setOpenMenu('')}
class="flex w-full items-center gap-2.5 rounded-lg px-3 py-2 text-[13px] text-[#374151] hover:bg-[#F3F4F6] transition-colors"
>
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636" /></svg>
{active() ? 'Deactivate' : 'Activate'}
</button>
</div>
</Show>
</div>
</div>
</td>
</tr>
);
}}
</For>
</tbody>
</table>
</div>
</div>
<Show when={openMenu()}>
<div class="fixed inset-0 z-10" onClick={() => setOpenMenu('')} />
</Show>
</div>
</AdminShell>
);
}

View file

@ -1 +1,3 @@
export { default } from './department';
import DepartmentManagementPage from './department';
export default DepartmentManagementPage;

View file

@ -1,8 +1,10 @@
import { For, Show, createMemo, createSignal, onMount } from 'solid-js';
import { useSearchParams } from '@solidjs/router';
import AdminShell from '~/components/AdminShell';
import { createModuleRecord, deleteModuleRecord, listModuleRecords, updateModuleRecord } from '~/lib/admin/client';
import type { CrudRecord } from '~/lib/admin/types';
const API = '/api/gateway';
type DepartmentRecord = CrudRecord & {
code?: string;
description?: string;
@ -11,6 +13,7 @@ type DepartmentRecord = CrudRecord & {
departmentHead?: string;
departmentEmail?: string;
transfersEnabled?: boolean;
visibility?: 'INTERNAL' | 'EXTERNAL';
};
const FALLBACK_DEPARTMENTS: DepartmentRecord[] = [
@ -30,13 +33,43 @@ const permissionGroups = [
{ title: 'Department Settings', items: ['Manage Department Settings'] },
];
type DepartmentListResponse = {
departments?: any[];
data?: any[];
items?: any[];
};
function normalizeDepartment(item: any, idx: number): DepartmentRecord {
const status = String(item.status ?? '').toUpperCase();
const isActive = typeof item.is_active === 'boolean'
? item.is_active
: status
? status === 'ACTIVE'
: true;
return {
id: String(item.id ?? `dep-${idx + 1}`),
name: String(item.name ?? ''),
code: String(item.code ?? ''),
description: String(item.description ?? ''),
totalEmployees: Number(item.totalEmployees ?? item.total_employees ?? 0),
departmentHead: String(item.departmentHead ?? item.department_head ?? ''),
departmentEmail: String(item.departmentEmail ?? item.department_email ?? ''),
transfersEnabled: Boolean(item.transfersEnabled ?? item.transfers_enabled ?? false),
visibility: String(item.visibility ?? 'INTERNAL').toUpperCase() === 'EXTERNAL' ? 'EXTERNAL' : 'INTERNAL',
status: isActive ? 'ACTIVE' : 'INACTIVE',
updatedAt: String(item.updatedAt ?? item.updated_at ?? ''),
createdDate: String(item.createdDate ?? item.created_at ?? ''),
};
}
function StatusBadge(props: { status: string }) {
const active = () => props.status === 'ACTIVE';
return (
<span class={`inline-flex items-center rounded-full px-2.5 py-1 text-[11px] font-semibold ${
active() ? 'bg-[#ECFDF5] text-[#059669]' : 'bg-[#F3F4F6] text-[#6B7280]'
<span class={`inline-flex items-center rounded-full border px-3 py-1.5 text-[13px] font-semibold ${
active() ? 'border-[#FFD8C2] bg-[#FFF1EB] text-[#FF5E13]' : 'border-[#D1D5DB] bg-[#F3F4F6] text-[#4B5563]'
}`}>
<span class={`mr-1.5 h-1.5 w-1.5 rounded-full ${active() ? 'bg-[#059669]' : 'bg-[#9CA3AF]'}`} />
<span class={`mr-1.5 h-2 w-2 rounded-full ${active() ? 'bg-[#FF5E13]' : 'bg-[#9CA3AF]'}`} />
{active() ? 'Active' : 'Inactive'}
</span>
);
@ -45,7 +78,7 @@ function StatusBadge(props: { status: string }) {
function FormInput(props: { label: string; required?: boolean; value: string; onInput: (v: string) => void; placeholder?: string; type?: string }) {
return (
<label class="block">
<span class="text-[13px] font-semibold text-[#374151]">
<span class="text-[14px] font-semibold text-[#374151]">
{props.label}{props.required && <span class="ml-0.5 text-[#FF5E13]">*</span>}
</span>
<input
@ -53,17 +86,24 @@ function FormInput(props: { label: string; required?: boolean; value: string; on
value={props.value}
onInput={(e) => props.onInput(e.currentTarget.value)}
placeholder={props.placeholder}
class="mt-1.5 h-[42px] w-full rounded-xl border border-[#E5E7EB] bg-white px-3.5 text-[14px] text-[#111827] outline-none placeholder:text-[#9CA3AF] focus:border-[#FF5E13] focus:ring-2 focus:ring-[rgba(255,94,19,0.1)] transition-colors"
class="mt-2 h-[48px] w-full rounded-2xl border border-[#E5E7EB] bg-white px-4 text-[15px] text-[#111827] outline-none placeholder:text-[#9CA3AF] focus:border-[#FF5E13] focus:ring-2 focus:ring-[rgba(255,94,19,0.1)] transition-colors"
/>
</label>
);
}
export default function DepartmentManagementPage() {
const [searchParams] = useSearchParams();
const isPreview = () => searchParams._preview === '1';
const [view, setView] = createSignal<'list' | 'form'>('list');
const [formTab, setFormTab] = createSignal<'general' | 'settings' | 'permissions'>('general');
const [listTab, setListTab] = createSignal<'all' | 'create' | 'view' | 'inactive'>('all');
const [search, setSearch] = createSignal('');
const [statusFilter, setStatusFilter] = createSignal('all');
const [sortBy, setSortBy] = createSignal<'name_asc' | 'name_desc' | 'employees_desc' | 'employees_asc'>('name_asc');
const [sortMenuOpen, setSortMenuOpen] = createSignal(false);
const [filterMenuOpen, setFilterMenuOpen] = createSignal(false);
const [rows, setRows] = createSignal<DepartmentRecord[]>([]);
const [openMenuId, setOpenMenuId] = createSignal<string | null>(null);
const [editingId, setEditingId] = createSignal<string | null>(null);
@ -75,34 +115,49 @@ export default function DepartmentManagementPage() {
const [departmentEmail, setDepartmentEmail] = createSignal('');
const [status, setStatus] = createSignal<'ACTIVE' | 'INACTIVE'>('ACTIVE');
const [transfersEnabled, setTransfersEnabled] = createSignal(false);
const [visibility, setVisibility] = createSignal<'INTERNAL' | 'EXTERNAL'>('INTERNAL');
const [isLoading, setIsLoading] = createSignal(false);
const [isSaving, setIsSaving] = createSignal(false);
const [error, setError] = createSignal('');
const load = async () => {
setIsLoading(true);
setError('');
try {
const res = await fetch(`/api/gateway/api/admin/departments?page=1&limit=100&q=${encodeURIComponent(search().trim())}`);
if (res.ok) {
const payload = await res.json().catch(() => null);
const list = Array.isArray(payload) ? payload : Array.isArray(payload?.data) ? payload.data : Array.isArray(payload?.items) ? payload.items : [];
if (list.length > 0) {
setRows(list.map((item: any, i: number) => ({
id: String(item.id ?? `dep-${i + 1}`),
name: String(item.name ?? ''),
code: String(item.code ?? ''),
description: String(item.description ?? ''),
totalEmployees: Number(item.totalEmployees ?? item.total_employees ?? 0),
departmentHead: String(item.departmentHead ?? item.department_head ?? ''),
status: String(item.status ?? 'ACTIVE').toUpperCase() === 'INACTIVE' ? 'INACTIVE' : 'ACTIVE',
updatedAt: String(item.updatedAt ?? item.updated_at ?? ''),
createdDate: String(item.createdDate ?? item.created_at ?? ''),
})));
return;
try {
const params = new URLSearchParams({
page: '1',
per_page: '100',
q: search().trim(),
});
if (statusFilter() !== 'all') {
params.set('status', statusFilter());
}
const res = await fetch(`${API}/api/admin/departments?${params.toString()}`);
if (!res.ok) {
throw new Error(`Request failed (${res.status})`);
}
const payload = (await res.json().catch(() => null)) as DepartmentListResponse | null;
const list = Array.isArray(payload)
? payload
: Array.isArray(payload?.departments)
? payload.departments
: Array.isArray(payload?.data)
? payload.data
: Array.isArray(payload?.items)
? payload.items
: [];
if (list.length === 0) {
setRows(FALLBACK_DEPARTMENTS);
} else {
setRows(list.map(normalizeDepartment));
}
} catch {
setRows(FALLBACK_DEPARTMENTS);
setError('Could not reach departments API, showing fallback data.');
}
} catch {}
try {
const data = await listModuleRecords<DepartmentRecord>('department', { q: search().trim() || undefined });
setRows(Array.isArray(data) && data.length > 0 ? data : FALLBACK_DEPARTMENTS);
} catch {
setRows(FALLBACK_DEPARTMENTS);
} finally {
setIsLoading(false);
}
};
@ -112,14 +167,29 @@ export default function DepartmentManagementPage() {
let r = rows();
if (statusFilter() !== 'all') r = r.filter((d) => d.status === statusFilter().toUpperCase());
const q = search().toLowerCase();
if (q) r = r.filter((d) => d.name.toLowerCase().includes(q) || String(d.code ?? '').toLowerCase().includes(q));
if (q) {
r = r.filter((d) =>
d.name.toLowerCase().includes(q)
|| String(d.code ?? '').toLowerCase().includes(q)
|| String(d.description ?? '').toLowerCase().includes(q)
);
}
const sorted = [...r];
const mode = sortBy();
sorted.sort((a, b) => {
if (mode === 'name_desc') return b.name.localeCompare(a.name);
if (mode === 'employees_desc') return Number(b.totalEmployees || 0) - Number(a.totalEmployees || 0);
if (mode === 'employees_asc') return Number(a.totalEmployees || 0) - Number(b.totalEmployees || 0);
return a.name.localeCompare(b.name);
});
r = sorted;
return r;
});
const resetForm = () => {
setEditingId(null); setName(''); setCode(''); setDescription('');
setDepartmentHead(''); setDepartmentEmail(''); setStatus('ACTIVE');
setTransfersEnabled(false); setFormTab('general');
setTransfersEnabled(false); setVisibility('INTERNAL'); setFormTab('general'); setError('');
};
const openCreate = () => { resetForm(); setView('form'); };
@ -132,22 +202,52 @@ export default function DepartmentManagementPage() {
setDepartmentEmail(String(row.departmentEmail || ''));
setStatus(row.status === 'INACTIVE' ? 'INACTIVE' : 'ACTIVE');
setTransfersEnabled(Boolean(row.transfersEnabled));
setVisibility(row.visibility === 'EXTERNAL' ? 'EXTERNAL' : 'INTERNAL');
setFormTab('general'); setView('form'); setOpenMenuId(null);
};
const save = async () => {
const payload: Partial<DepartmentRecord> = {
name: name().trim() || 'New Department', code: code().trim() || undefined,
description: description().trim(), departmentHead: departmentHead().trim(),
departmentEmail: departmentEmail().trim(), status: status(),
transfersEnabled: transfersEnabled(),
};
if (editingId()) {
await updateModuleRecord<DepartmentRecord>('department', editingId()!, payload);
} else {
await createModuleRecord<DepartmentRecord>('department', payload);
if (!name().trim() || !code().trim()) {
setError('Department name and code are required.');
setFormTab('general');
return;
}
setIsSaving(true);
setError('');
const payload = {
name: name().trim(),
code: code().trim(),
description: description().trim() || null,
department_head: departmentHead().trim() || null,
department_email: departmentEmail().trim() || null,
status: status(),
visibility: visibility(),
transfers_enabled: transfersEnabled(),
};
try {
const endpoint = editingId()
? `${API}/api/admin/departments/${editingId()}`
: `${API}/api/admin/departments`;
const method = editingId() ? 'PATCH' : 'POST';
const res = await fetch(endpoint, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error((body as any).message || `Request failed (${res.status})`);
}
setView('list');
resetForm();
await load();
} catch (err: any) {
setError(err?.message || 'Failed to save department.');
} finally {
setIsSaving(false);
}
setView('list'); resetForm(); await load();
};
const formatDate = (v?: string) => {
@ -156,112 +256,190 @@ export default function DepartmentManagementPage() {
return s.slice(0, 10) || '—';
};
const stats = createMemo(() => ({
total: rows().length,
active: rows().filter((d) => d.status === 'ACTIVE').length,
inactive: rows().filter((d) => d.status === 'INACTIVE').length,
totalEmployees: rows().reduce((acc, d) => acc + Number(d.totalEmployees || 0), 0),
}));
return (
<AdminShell>
<div class="w-full space-y-8 pb-8">
<div class="mx-auto w-full max-w-[1480px] space-y-5 pb-8 pt-1">
{/* Page header */}
<div class="flex items-end justify-between">
<Show when={!isPreview() || view() === 'form'}>
<div>
<p class="text-[12px] font-semibold uppercase tracking-widest text-[#FF5E13]">
{view() === 'form' ? 'Department Management' : 'Organisation'}
</p>
<h1 class="mt-1 text-[28px] font-bold leading-tight text-[#111827]">
{view() === 'form' ? (editingId() ? 'Edit Department' : 'Create Department') : 'Department Management'}
</h1>
<p class="mt-1 text-[14px] text-[#6B7280]">
{view() === 'form'
? 'Dashboard / Department Management / ' + (editingId() ? 'Edit Department' : 'Create Department')
: 'Manage all departments and organisational structure'}
</p>
<h1 class="text-[42px] font-bold leading-[1.08] tracking-[-0.01em] text-[#111827]">Department Management</h1>
<p class="mt-1.5 text-[14px] leading-[1.45] text-[#6B7280]">Manage all departments and organizational structure</p>
</div>
<Show when={view() === 'list'}>
<button
type="button"
onClick={openCreate}
class="inline-flex items-center gap-2 rounded-xl bg-[#0D0D2A] px-5 py-2.5 text-[13px] font-semibold text-white shadow-sm hover:bg-[#1a1a3e] transition-colors"
>
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><path d="M12 5v14M5 12h14" /></svg>
Create Department
</button>
</Show>
</div>
</Show>
{/* ── LIST VIEW ── */}
<Show when={view() === 'list'}>
{/* Summary cards */}
<div class="grid grid-cols-4 gap-5">
{[
{ label: 'Total Departments', value: stats().total, color: 'text-[#FF5E13]', bg: 'bg-[#FFF1EB]' },
{ label: 'Active', value: stats().active, color: 'text-[#059669]', bg: 'bg-[#ECFDF5]' },
{ label: 'Inactive', value: stats().inactive, color: 'text-[#6B7280]', bg: 'bg-[#F3F4F6]' },
{ label: 'Total Employees', value: stats().totalEmployees, color: 'text-[#2563EB]', bg: 'bg-[#EFF6FF]' },
].map((s) => (
<div class="rounded-2xl border border-[#E5E7EB] bg-white p-5 shadow-sm">
<div class={`inline-flex h-10 w-10 items-center justify-center rounded-xl ${s.bg} ${s.color} text-[18px] font-bold`}>
{s.value}
</div>
<p class="mt-3 text-[13px] font-medium text-[#6B7280]">{s.label}</p>
<p class={`mt-0.5 text-[22px] font-bold tracking-tight ${s.color}`}>{s.value}</p>
<Show when={error()}>
<div class="rounded-xl border border-[#FECACA] bg-[#FEF2F2] px-4 py-3 text-[13px] text-[#B91C1C]">
{error()}
</div>
</Show>
<Show when={!isPreview()}>
<div class="flex items-center justify-between">
<div class="inline-flex items-center gap-3 text-[20px] font-semibold tracking-[0.2em] uppercase text-[#8B93AB]">
<span class="text-[20px] text-[#A0A7BC]">Dashboard</span>
<span></span>
<span class="text-[20px] text-[#A0A7BC]">Organization</span>
<span></span>
<span class="text-[20px] tracking-[0.16em] text-[#D44B22]">Department Management</span>
</div>
))}
</div>
<button
type="button"
class="inline-flex h-[56px] items-center gap-3 rounded-[14px] border border-[#E2E7F1] bg-white px-6 text-[15px] font-medium text-[#4E587C]"
>
<svg class="h-5 w-5 text-[#58638A]" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/></svg>
Oct 01, 2023 - Oct 31, 2023
<svg class="h-4 w-4 text-[#58638A]" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="m6 9 6 6 6-6"/></svg>
</button>
</div>
</Show>
{/* Table card */}
<div class="rounded-2xl border border-[#E5E7EB] bg-white shadow-sm">
<div class="mt-2 overflow-hidden rounded-[22px] border border-[#DDE2EA] bg-white shadow-[0_8px_22px_rgba(13,13,42,0.05)]">
<Show when={!isPreview()}>
<div class="flex items-center gap-10 border-b border-[#E8ECF2] px-9 pt-0.5">
<button
type="button"
onClick={() => { setListTab('all'); setStatusFilter('all'); void load(); }}
class={`relative inline-flex items-center gap-2 px-0 py-4 text-[18px] font-semibold ${listTab() === 'all' ? 'text-[#111827]' : 'text-[#5F6780] hover:text-[#111827]'}`}
>
All Departments
<span class="inline-flex min-w-[30px] justify-center rounded-full bg-[#E8EAF3] px-2 py-0.5 text-[12px] font-semibold text-[#4F5877]">
{filteredRows().length}
</span>
<Show when={listTab() === 'all'}>
<span class="absolute inset-x-0 bottom-0 h-[2px] rounded-t-full bg-[#1E2235]" />
</Show>
</button>
<button
type="button"
onClick={() => { setListTab('create'); openCreate(); }}
class={`relative px-0 py-4 text-[18px] font-medium transition-colors ${listTab() === 'create' ? 'text-[#111827]' : 'text-[#5F6780] hover:text-[#111827]'}`}
>
Create Department
<Show when={listTab() === 'create'}>
<span class="absolute inset-x-0 bottom-0 h-[2px] rounded-t-full bg-[#1E2235]" />
</Show>
</button>
<button
type="button"
onClick={() => { setListTab('view'); setStatusFilter('all'); void load(); }}
class={`relative px-0 py-4 text-[18px] font-medium transition-colors ${listTab() === 'view' ? 'text-[#111827]' : 'text-[#5F6780] hover:text-[#111827]'}`}
>
View Department
<Show when={listTab() === 'view'}>
<span class="absolute inset-x-0 bottom-0 h-[2px] rounded-t-full bg-[#1E2235]" />
</Show>
</button>
<button
type="button"
onClick={() => { setListTab('inactive'); setStatusFilter('inactive'); void load(); }}
class={`relative px-0 py-4 text-[18px] font-medium transition-colors ${listTab() === 'inactive' ? 'text-[#111827]' : 'text-[#5F6780] hover:text-[#111827]'}`}
>
Inactive
<Show when={listTab() === 'inactive'}>
<span class="absolute inset-x-0 bottom-0 h-[2px] rounded-t-full bg-[#1E2235]" />
</Show>
</button>
</div>
</Show>
{/* Filters */}
<div class="flex items-center gap-3 border-b border-[#F3F4F6] p-5">
<Show when={!isPreview()}>
<div class="flex items-center justify-between gap-6 border-b border-[#E8ECF2] px-9 py-5">
<div class="flex min-w-0 flex-1 items-center gap-5">
{/* Search */}
<div class="relative flex-1 max-w-sm">
<svg class="pointer-events-none absolute left-3.5 top-1/2 h-4 w-4 -translate-y-1/2 text-[#9CA3AF]" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><circle cx="11" cy="11" r="7" /><path d="m20 20-3.5-3.5" /></svg>
<div class="relative w-full max-w-[860px]">
<span class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-5">
<svg class="h-5 w-5 text-[#A2A9BD]" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><circle cx="11" cy="11" r="7" /><path d="m20 20-3.5-3.5" /></svg>
</span>
<input
value={search()}
onInput={(e) => { setSearch(e.currentTarget.value); void load(); }}
placeholder="Search departments..."
class="h-[40px] w-full rounded-xl border border-[#E5E7EB] bg-[#F9FAFB] pl-10 pr-4 text-[13px] text-[#111827] outline-none placeholder:text-[#9CA3AF] focus:border-[#FF5E13] focus:bg-white focus:ring-2 focus:ring-[rgba(255,94,19,0.08)] transition-colors"
placeholder="Search by name, ID or department..."
class="h-[58px] w-full rounded-[12px] border border-[#EEF1F6] bg-[#F4F6FA] pl-[54px] pr-5 text-[15px] text-[#2A3150] outline-none placeholder:text-[#949DB4] focus:border-[#CED4E2] focus:bg-white transition-colors"
/>
</div>
{/* Status filter */}
<select
value={statusFilter()}
onChange={(e) => setStatusFilter(e.currentTarget.value)}
class="h-[40px] rounded-xl border border-[#E5E7EB] bg-[#F9FAFB] px-3 pr-8 text-[13px] text-[#374151] outline-none focus:border-[#FF5E13] transition-colors"
>
<option value="all">All Status</option>
<option value="active">Active</option>
<option value="inactive">Inactive</option>
</select>
{/* Sorting */}
<div class="relative">
<button
type="button"
onClick={() => { setSortMenuOpen((v) => !v); setFilterMenuOpen(false); }}
class="inline-flex h-[58px] min-w-[138px] items-center justify-center gap-2 rounded-[12px] border border-[#DFE5EF] bg-[#F8FAFD] px-5 text-[15px] font-medium text-[#4E587C] transition-colors hover:bg-white"
>
<svg class="h-4 w-4 text-[#66708A]" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M7 4v13" /><path d="m3 13 4 4 4-4" /><path d="M17 20V7" /><path d="m21 11-4-4-4 4" /></svg>
Sorting
</button>
<Show when={sortMenuOpen()}>
<div class="absolute right-0 top-[62px] z-30 min-w-[220px] rounded-xl border border-[#E5E7EB] bg-white p-1.5 shadow-lg shadow-black/10">
<button type="button" onClick={() => { setSortBy('name_asc'); setSortMenuOpen(false); }} class={`block w-full rounded-lg px-3 py-2 text-left text-[14px] ${sortBy() === 'name_asc' ? 'bg-[#FFF1EB] text-[#FF5E13]' : 'text-[#374151] hover:bg-[#F9FAFB]'}`}>Name (A-Z)</button>
<button type="button" onClick={() => { setSortBy('name_desc'); setSortMenuOpen(false); }} class={`block w-full rounded-lg px-3 py-2 text-left text-[14px] ${sortBy() === 'name_desc' ? 'bg-[#FFF1EB] text-[#FF5E13]' : 'text-[#374151] hover:bg-[#F9FAFB]'}`}>Name (Z-A)</button>
<button type="button" onClick={() => { setSortBy('employees_desc'); setSortMenuOpen(false); }} class={`block w-full rounded-lg px-3 py-2 text-left text-[14px] ${sortBy() === 'employees_desc' ? 'bg-[#FFF1EB] text-[#FF5E13]' : 'text-[#374151] hover:bg-[#F9FAFB]'}`}>Employees (High-Low)</button>
<button type="button" onClick={() => { setSortBy('employees_asc'); setSortMenuOpen(false); }} class={`block w-full rounded-lg px-3 py-2 text-left text-[14px] ${sortBy() === 'employees_asc' ? 'bg-[#FFF1EB] text-[#FF5E13]' : 'text-[#374151] hover:bg-[#F9FAFB]'}`}>Employees (Low-High)</button>
</div>
</Show>
</div>
<p class="ml-auto text-[13px] text-[#6B7280]">
<span class="font-semibold text-[#111827]">{filteredRows().length}</span> departments
</p>
</div>
{/* Filter */}
<div class="relative">
<button
type="button"
onClick={() => { setFilterMenuOpen((v) => !v); setSortMenuOpen(false); }}
class="inline-flex h-[58px] min-w-[112px] items-center justify-center gap-2 rounded-[12px] border border-[#DFE5EF] bg-[#F8FAFD] px-5 text-[15px] font-medium text-[#4E587C] transition-colors hover:bg-white"
>
<svg class="h-4 w-4 text-[#66708A]" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 5h18M6 12h12M10 19h4" /></svg>
Filter
</button>
<Show when={filterMenuOpen()}>
<div class="absolute right-0 top-[62px] z-30 min-w-[170px] rounded-xl border border-[#E5E7EB] bg-white p-1.5 shadow-lg shadow-black/10">
<button type="button" onClick={() => { setListTab('all'); setStatusFilter('all'); setFilterMenuOpen(false); void load(); }} class={`block w-full rounded-lg px-3 py-2 text-left text-[14px] ${statusFilter() === 'all' ? 'bg-[#FFF1EB] text-[#FF5E13]' : 'text-[#374151] hover:bg-[#F9FAFB]'}`}>All Status</button>
<button type="button" onClick={() => { setListTab('view'); setStatusFilter('active'); setFilterMenuOpen(false); void load(); }} class={`block w-full rounded-lg px-3 py-2 text-left text-[14px] ${statusFilter() === 'active' ? 'bg-[#FFF1EB] text-[#FF5E13]' : 'text-[#374151] hover:bg-[#F9FAFB]'}`}>Active</button>
<button type="button" onClick={() => { setListTab('inactive'); setStatusFilter('inactive'); setFilterMenuOpen(false); void load(); }} class={`block w-full rounded-lg px-3 py-2 text-left text-[14px] ${statusFilter() === 'inactive' ? 'bg-[#FFF1EB] text-[#FF5E13]' : 'text-[#374151] hover:bg-[#F9FAFB]'}`}>Inactive</button>
</div>
</Show>
</div>
</div>
<div class="ml-2 flex shrink-0 items-center gap-3">
<button
type="button"
class="inline-flex h-[58px] items-center rounded-[12px] border-2 border-[#1C2238] bg-white px-8 text-[15px] font-semibold text-[#1D243A] transition-colors hover:bg-[#F8FAFC]"
>
Export
</button>
<button
type="button"
onClick={openCreate}
class="inline-flex h-[58px] items-center gap-2 rounded-[12px] bg-[#00033D] px-8 text-[15px] font-semibold text-white shadow-[0_7px_16px_rgba(0,3,61,0.26)] transition-colors hover:bg-[#0C0F52]"
>
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><path d="M12 5v14M5 12h14" /></svg>
Add Department
</button>
</div>
</div>
</Show>
{/* Table */}
<div class="overflow-x-auto">
<table class="min-w-full">
<Show when={!isPreview()}>
<thead>
<tr class="border-b border-[#F3F4F6] bg-[#FAFAFA] text-left">
<th class="px-6 py-3.5 text-[11px] font-semibold uppercase tracking-wider text-[#9CA3AF]">Department Name</th>
<th class="px-6 py-3.5 text-[11px] font-semibold uppercase tracking-wider text-[#9CA3AF]">Code</th>
<th class="px-6 py-3.5 text-[11px] font-semibold uppercase tracking-wider text-[#9CA3AF]">Department Head</th>
<th class="px-6 py-3.5 text-[11px] font-semibold uppercase tracking-wider text-[#9CA3AF]">Employees</th>
<th class="px-6 py-3.5 text-[11px] font-semibold uppercase tracking-wider text-[#9CA3AF]">Status</th>
<th class="px-6 py-3.5 text-[11px] font-semibold uppercase tracking-wider text-[#9CA3AF]">Created</th>
<th class="px-6 py-3.5 text-[11px] font-semibold uppercase tracking-wider text-[#9CA3AF]">Actions</th>
<tr class="text-left bg-[#00033D]">
<th class="px-8 py-5 text-[13px] font-semibold uppercase tracking-[0.08em] text-white">Department Name</th>
<th class="px-8 py-5 text-[13px] font-semibold uppercase tracking-[0.08em] text-white">Department Code</th>
<th class="px-8 py-5 text-[13px] font-semibold uppercase tracking-[0.08em] text-white">Description</th>
<th class="px-8 py-5 text-[13px] font-semibold uppercase tracking-[0.08em] text-white">Total Employees</th>
<th class="px-8 py-5 text-[13px] font-semibold uppercase tracking-[0.08em] text-white">Status</th>
<th class="px-8 py-5 text-[13px] font-semibold uppercase tracking-[0.08em] text-white">Created Date</th>
<th class="px-8 py-5 text-[13px] font-semibold uppercase tracking-[0.08em] text-white">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-[#F3F4F6]">
</Show>
<tbody class="divide-y divide-[#E9EDF4]">
<Show
when={filteredRows().length > 0}
fallback={
@ -282,21 +460,22 @@ export default function DepartmentManagementPage() {
>
<For each={filteredRows()}>
{(row) => (
<tr class="group hover:bg-[#FAFAFA] transition-colors">
<td class="px-6 py-4">
<p class="text-[14px] font-semibold text-[#111827]">{row.name}</p>
<p class="mt-0.5 text-[12px] text-[#9CA3AF] line-clamp-1">{String(row.description || '')}</p>
<tr class="group hover:bg-[#FBFCFE] transition-colors">
<td class="px-8 py-6">
<p class="text-[16px] font-semibold text-[#111827]">{row.name}</p>
</td>
<td class="px-6 py-4">
<span class="rounded-lg bg-[#F3F4F6] px-2.5 py-1 text-[12px] font-mono font-semibold text-[#374151]">
<td class="px-8 py-6">
<span class="rounded-lg bg-[#F3F4F6] px-3 py-1.5 text-[15px] font-mono font-semibold text-[#374151]">
{String(row.code || '—')}
</span>
</td>
<td class="px-6 py-4 text-[13px] text-[#374151]">{String(row.departmentHead || '—')}</td>
<td class="px-6 py-4 text-[13px] font-semibold text-[#111827]">{Number(row.totalEmployees || 0)}</td>
<td class="px-6 py-4"><StatusBadge status={row.status} /></td>
<td class="px-6 py-4 text-[13px] text-[#6B7280]">{formatDate(String(row.createdDate || row.updatedAt || ''))}</td>
<td class="relative px-6 py-4">
<td class="max-w-[520px] px-8 py-6 text-[15px] text-[#66708A]">
<p class="line-clamp-1">{String(row.description || '—')}</p>
</td>
<td class="px-8 py-6 text-[16px] font-semibold text-[#111827]">{Number(row.totalEmployees || 0)}</td>
<td class="px-8 py-6"><StatusBadge status={row.status} /></td>
<td class="px-8 py-6 text-[15px] text-[#6B7280]">{formatDate(String(row.createdDate || row.updatedAt || ''))}</td>
<td class="relative px-8 py-6">
<button
type="button"
onClick={() => setOpenMenuId(openMenuId() === row.id ? null : row.id)}
@ -306,7 +485,7 @@ export default function DepartmentManagementPage() {
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><circle cx="12" cy="5" r="1.5" /><circle cx="12" cy="12" r="1.5" /><circle cx="12" cy="19" r="1.5" /></svg>
</button>
<Show when={openMenuId() === row.id}>
<div class="absolute right-6 top-12 z-20 w-[200px] rounded-xl border border-[#E5E7EB] bg-white p-1.5 shadow-lg shadow-black/10">
<div class="absolute right-5 top-11 z-20 w-[200px] rounded-xl border border-[#E5E7EB] bg-white p-1.5 shadow-lg shadow-black/10">
<button
type="button"
onClick={() => openEdit(row)}
@ -317,7 +496,21 @@ export default function DepartmentManagementPage() {
</button>
<button
type="button"
onClick={async () => { await updateModuleRecord<DepartmentRecord>('department', row.id, { status: row.status === 'ACTIVE' ? 'INACTIVE' : 'ACTIVE' }); setOpenMenuId(null); await load(); }}
onClick={async () => {
try {
const res = await fetch(`${API}/api/admin/departments/${row.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: row.status === 'ACTIVE' ? 'INACTIVE' : 'ACTIVE' }),
});
if (!res.ok) throw new Error(`Request failed (${res.status})`);
} catch (err: any) {
setError(err?.message || 'Failed to update status.');
} finally {
setOpenMenuId(null);
await load();
}
}}
class="flex w-full items-center gap-2.5 rounded-lg px-3 py-2 text-[13px] font-medium text-[#374151] hover:bg-[#F9FAFB]"
>
<svg class="h-4 w-4 text-[#FF5E13]" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><circle cx="12" cy="12" r="9" /><path d="M9 12l2 2 4-4" /></svg>
@ -326,7 +519,18 @@ export default function DepartmentManagementPage() {
<div class="my-1 h-px bg-[#F3F4F6]" />
<button
type="button"
onClick={async () => { await deleteModuleRecord('department', row.id); setOpenMenuId(null); await load(); }}
onClick={async () => {
if (!window.confirm(`Delete department "${row.name}"?`)) return;
try {
const res = await fetch(`${API}/api/admin/departments/${row.id}`, { method: 'DELETE' });
if (!res.ok) throw new Error(`Request failed (${res.status})`);
} catch (err: any) {
setError(err?.message || 'Failed to delete department.');
} finally {
setOpenMenuId(null);
await load();
}
}}
class="flex w-full items-center gap-2.5 rounded-lg px-3 py-2 text-[13px] font-medium text-[#DC2626] hover:bg-[#FEF2F2]"
>
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M3 6h18M8 6V4h8v2M19 6l-1 14H6L5 6M10 11v6M14 11v6" /></svg>
@ -345,15 +549,16 @@ export default function DepartmentManagementPage() {
{/* Pagination */}
<Show when={filteredRows().length > 0}>
<div class="flex items-center justify-between border-t border-[#F3F4F6] px-6 py-4">
<p class="text-[13px] text-[#6B7280]">
<div class="flex items-center justify-between border-t border-[#E8ECF2] px-7 py-4">
<p class="text-[14px] text-[#6B7280]">
Showing <span class="font-semibold text-[#111827]">1{filteredRows().length}</span> of <span class="font-semibold text-[#111827]">{filteredRows().length}</span> departments
</p>
<div class="flex items-center gap-1.5">
<button type="button" class="inline-flex h-8 w-8 items-center justify-center rounded-lg border border-[#E5E7EB] text-[#6B7280] hover:bg-[#F9FAFB] transition-colors"></button>
<button type="button" class="inline-flex h-8 w-8 items-center justify-center rounded-lg bg-[#0D0D2A] text-[13px] font-semibold text-white">1</button>
<button type="button" class="inline-flex h-8 w-8 items-center justify-center rounded-lg border border-[#E5E7EB] text-[13px] font-medium text-[#374151] hover:bg-[#F9FAFB] transition-colors">2</button>
<button type="button" class="inline-flex h-8 w-8 items-center justify-center rounded-lg border border-[#E5E7EB] text-[#6B7280] hover:bg-[#F9FAFB] transition-colors"></button>
<button type="button" class="inline-flex h-10 w-10 items-center justify-center rounded-xl border border-[#E5E7EB] text-[#6B7280] hover:bg-[#F9FAFB] transition-colors"></button>
<button type="button" class="inline-flex h-10 w-10 items-center justify-center rounded-xl bg-[#FF5E13] text-[15px] font-semibold text-white">1</button>
<button type="button" class="inline-flex h-10 w-10 items-center justify-center rounded-xl border border-[#E5E7EB] text-[15px] font-medium text-[#374151] hover:bg-[#F9FAFB] transition-colors">2</button>
<button type="button" class="inline-flex h-10 w-10 items-center justify-center rounded-xl border border-[#E5E7EB] text-[15px] font-medium text-[#374151] hover:bg-[#F9FAFB] transition-colors">3</button>
<button type="button" class="inline-flex h-10 w-10 items-center justify-center rounded-xl border border-[#E5E7EB] text-[#6B7280] hover:bg-[#F9FAFB] transition-colors"></button>
</div>
</div>
</Show>
@ -362,17 +567,33 @@ export default function DepartmentManagementPage() {
{/* ── FORM VIEW (Create / Edit) ── */}
<Show when={view() === 'form'}>
<div class="rounded-2xl border border-[#E5E7EB] bg-white shadow-sm">
<div class="mt-2 overflow-hidden rounded-[24px] border border-[#DDE2EA] bg-white shadow-[0_8px_22px_rgba(13,13,42,0.05)]">
<div class="flex items-center gap-1 border-b border-[#E8ECF2] px-7 pt-0.5">
<button
type="button"
onClick={() => setView('list')}
class="px-6 py-4 text-[18px] font-semibold text-[#6B7280] transition-colors hover:text-[#111827]"
>
All Departments
</button>
<button
type="button"
class="relative px-6 py-4 text-[18px] font-semibold text-[#111827]"
>
{editingId() ? 'Edit Department' : 'Create Department'}
<span class="absolute inset-x-0 bottom-0 h-[2px] rounded-t-full bg-[#FF5E13]" />
</button>
</div>
{/* Tab nav */}
<div class="flex items-center gap-1 border-b border-[#F3F4F6] px-6">
<div class="flex items-center gap-2 border-b border-[#E8ECF2] px-7">
{(['general', 'settings', 'permissions'] as const).map((tab, i) => {
const labels = ['General Information', 'Department Settings', 'Permissions'];
return (
<button
type="button"
onClick={() => setFormTab(tab)}
class={`relative px-4 py-4 text-[13px] font-semibold transition-colors ${
class={`relative px-6 py-5 text-[17px] font-semibold transition-colors ${
formTab() === tab ? 'text-[#111827]' : 'text-[#9CA3AF] hover:text-[#6B7280]'
}`}
>
@ -385,12 +606,17 @@ export default function DepartmentManagementPage() {
})}
</div>
<div class="p-6">
<div class="p-7">
<Show when={error()}>
<div class="mb-5 rounded-xl border border-[#FECACA] bg-[#FEF2F2] px-4 py-3 text-[13px] text-[#B91C1C]">
{error()}
</div>
</Show>
{/* General Information */}
<Show when={formTab() === 'general'}>
<div class="space-y-5">
<div class="grid grid-cols-2 gap-5">
<div class="grid grid-cols-2 gap-6">
<FormInput label="Department Name" required value={name()} onInput={setName} placeholder="e.g. Engineering" />
<FormInput label="Department Code" required value={code()} onInput={setCode} placeholder="e.g. ENG-001" />
</div>
@ -400,11 +626,11 @@ export default function DepartmentManagementPage() {
value={description()}
onInput={(e) => setDescription(e.currentTarget.value)}
placeholder="Brief description of this department's purpose..."
rows="3"
class="mt-1.5 w-full rounded-xl border border-[#E5E7EB] bg-white px-3.5 py-3 text-[14px] text-[#111827] outline-none placeholder:text-[#9CA3AF] focus:border-[#FF5E13] focus:ring-2 focus:ring-[rgba(255,94,19,0.1)] transition-colors resize-none"
rows="4"
class="mt-1.5 w-full rounded-2xl border border-[#E5E7EB] bg-white px-4 py-3.5 text-[15px] text-[#111827] outline-none placeholder:text-[#9CA3AF] focus:border-[#FF5E13] focus:ring-2 focus:ring-[rgba(255,94,19,0.1)] transition-colors resize-none"
/>
</label>
<div class="grid grid-cols-2 gap-5">
<div class="grid grid-cols-2 gap-6">
<FormInput label="Department Head" value={departmentHead()} onInput={setDepartmentHead} placeholder="e.g. Arun Kumar" />
<FormInput label="Department Email" type="email" value={departmentEmail()} onInput={setDepartmentEmail} placeholder="dept@nxtgauge.com" />
</div>
@ -418,12 +644,12 @@ export default function DepartmentManagementPage() {
<div>
<p class="text-[14px] font-semibold text-[#111827]">Department Status</p>
<p class="mt-0.5 text-[13px] text-[#6B7280]">Set whether this department is currently active</p>
<div class="mt-3 flex gap-2">
<div class="mt-4 flex gap-3">
{(['ACTIVE', 'INACTIVE'] as const).map((s) => (
<button
type="button"
onClick={() => setStatus(s)}
class={`h-[38px] rounded-xl border px-5 text-[13px] font-semibold transition-colors ${
class={`h-[44px] rounded-xl border px-6 text-[14px] font-semibold transition-colors ${
status() === s
? s === 'ACTIVE' ? 'border-[#059669] bg-[#ECFDF5] text-[#059669]' : 'border-[#6B7280] bg-[#F3F4F6] text-[#374151]'
: 'border-[#E5E7EB] bg-white text-[#6B7280] hover:bg-[#F9FAFB]'
@ -438,23 +664,35 @@ export default function DepartmentManagementPage() {
<div>
<p class="text-[14px] font-semibold text-[#111827]">Department Visibility</p>
<p class="mt-0.5 text-[13px] text-[#6B7280]">Choose who can see this department</p>
<div class="mt-3 grid grid-cols-2 gap-3">
<div class="mt-4 grid grid-cols-2 gap-4">
{[
{ key: 'internal', label: 'Internal', desc: 'Visible to internal employees only' },
{ key: 'external', label: 'External', desc: 'Visible to external users and partners' },
{ key: 'INTERNAL', label: 'Internal', desc: 'Visible to internal employees only' },
{ key: 'EXTERNAL', label: 'External', desc: 'Visible to external users and partners' },
].map((opt) => (
<div class="flex items-start gap-3 rounded-xl border border-[#E5E7EB] bg-[#F9FAFB] p-4">
<div class="mt-0.5 h-4 w-4 shrink-0 rounded-full border-2 border-[#E5E7EB] bg-white" />
<button
type="button"
onClick={() => setVisibility(opt.key as 'INTERNAL' | 'EXTERNAL')}
class={`flex w-full items-start gap-3 rounded-2xl border p-5 text-left transition-colors ${
visibility() === opt.key
? 'border-[#FF5E13] bg-[#FFF7ED]'
: 'border-[#E5E7EB] bg-[#F9FAFB]'
}`}
>
<div class={`mt-0.5 h-4 w-4 shrink-0 rounded-full border-2 ${
visibility() === opt.key ? 'border-[#FF5E13]' : 'border-[#E5E7EB]'
}`}>
<div class={`m-[3px] h-[6px] w-[6px] rounded-full ${visibility() === opt.key ? 'bg-[#FF5E13]' : 'bg-transparent'}`} />
</div>
<div>
<p class="text-[13px] font-semibold text-[#111827]">{opt.label}</p>
<p class="mt-0.5 text-[12px] text-[#6B7280]">{opt.desc}</p>
</div>
</div>
</button>
))}
</div>
</div>
<div class="flex items-center justify-between rounded-xl border border-[#E5E7EB] bg-[#F9FAFB] p-4">
<div class="flex items-center justify-between rounded-2xl border border-[#E5E7EB] bg-[#F9FAFB] p-4">
<div>
<p class="text-[13px] font-semibold text-[#111827]">Allow Employee Transfers</p>
<p class="mt-0.5 text-[12px] text-[#6B7280]">Employees can request to transfer into this department</p>
@ -473,17 +711,17 @@ export default function DepartmentManagementPage() {
{/* Permissions */}
<Show when={formTab() === 'permissions'}>
<div class="space-y-6">
<p class="text-[13px] text-[#6B7280]">Select the permissions available to employees in this department.</p>
<p class="text-[14px] text-[#6B7280]">Select the permissions available to employees in this department.</p>
<For each={permissionGroups}>
{(group) => (
<div>
<p class="mb-3 text-[13px] font-bold uppercase tracking-wider text-[#9CA3AF]">{group.title}</p>
<div class="grid grid-cols-2 gap-2">
<div class="grid grid-cols-2 gap-3.5">
<For each={group.items}>
{(item) => (
<label class="flex cursor-pointer items-center gap-3 rounded-xl border border-[#E5E7EB] bg-[#F9FAFB] px-4 py-3 hover:border-[#FF5E13] hover:bg-[#FFF1EB] transition-colors">
<label class="flex cursor-pointer items-center gap-3 rounded-2xl border border-[#E5E7EB] bg-[#F9FAFB] px-5 py-3.5 hover:border-[#FF5E13] hover:bg-[#FFF1EB] transition-colors">
<input type="checkbox" class="h-4 w-4 rounded accent-[#FF5E13]" />
<span class="text-[13px] font-medium text-[#374151]">{item}</span>
<span class="text-[14px] font-medium text-[#374151]">{item}</span>
</label>
)}
</For>
@ -496,20 +734,21 @@ export default function DepartmentManagementPage() {
</div>
{/* Form actions */}
<div class="flex items-center justify-end gap-3 border-t border-[#F3F4F6] px-6 py-4">
<div class="flex items-center justify-end gap-3 border-t border-[#E8ECF2] px-7 py-4">
<button
type="button"
onClick={() => { setView('list'); resetForm(); }}
class="h-[40px] rounded-xl border border-[#E5E7EB] bg-white px-5 text-[13px] font-semibold text-[#374151] hover:bg-[#F9FAFB] transition-colors"
class="h-[44px] rounded-xl border border-[#E5E7EB] bg-white px-6 text-[14px] font-semibold text-[#374151] hover:bg-[#F9FAFB] transition-colors"
>
Cancel
</button>
<button
type="button"
onClick={() => void save()}
class="h-[40px] rounded-xl bg-[#0D0D2A] px-6 text-[13px] font-semibold text-white hover:bg-[#1a1a3e] transition-colors"
disabled={isSaving()}
class="h-[44px] rounded-xl bg-[#0D0D2A] px-7 text-[14px] font-semibold text-white hover:bg-[#1a1a3e] disabled:cursor-not-allowed disabled:opacity-60 transition-colors"
>
{editingId() ? 'Update Department' : 'Create Department'}
{isSaving() ? 'Saving...' : editingId() ? 'Update Department' : 'Create Department'}
</button>
</div>
</div>

View file

@ -1,6 +1,6 @@
import { For } from 'solid-js';
import AdminShell from '~/components/AdminShell';
import { Eye, GripVertical, LayoutDashboard } from 'lucide-solid';
import { GripVertical } from 'lucide-solid';
const kpis = [
{ title: 'Total Users', value: '12,458', delta: '+12.5%', note: '+1,245 this month', tone: 'up' as const, icon: 'users' as const },
@ -13,13 +13,6 @@ const trendSeries = [62, 70, 81, 75, 88, 102];
const revSeries = [42000, 48000, 55000, 51000, 62000, 69000];
const maxAmount = 80000;
const recentLeads = [
{ 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: 'Graphic Designer', budget: '₹8,200', status: 'Active' },
{ title: 'Social Media Management', customer: 'GrowthStart', category: 'Social Media Manager', budget: '₹5,000', status: 'Negotiating' },
];
function KpiIcon(props: { kind: 'users' | 'building' | 'trend' | 'card' }) {
if (props.kind === 'users') {
return (
@ -73,50 +66,40 @@ export default function AdminHomePage() {
<div class="w-full space-y-8 pb-8">
{/* Page header */}
<div class="flex items-end justify-between">
<div>
<div>
<p class="text-[12px] font-semibold uppercase tracking-widest text-[#FF5E13]">Overview</p>
<h1 class="mt-1 text-[28px] font-bold leading-tight text-[#111827]">Dashboard</h1>
<p class="mt-1 text-[14px] text-[#6B7280]">Welcome back here's what's happening on your platform today.</p>
<h1 class="text-[28px] font-bold leading-tight tracking-[-0.01em] text-[#0B1246]">Dashboard Overview</h1>
<p class="mt-1.5 text-[14px] text-[#7E849F]">Welcome back! Here's what's happening with your platform today.</p>
</div>
<button
type="button"
class="inline-flex items-center gap-2 rounded-xl border border-[#E5E7EB] bg-white px-4 py-2.5 text-[13px] font-semibold text-[#374151] shadow-sm hover:border-[#FF5E13] hover:text-[#FF5E13] transition-colors"
>
<LayoutDashboard size={15} />
Customise Dashboard
</button>
</div>
{/* KPI cards */}
<div class="grid grid-cols-4 gap-6">
<div class="grid grid-cols-4 gap-5">
<For each={kpis}>
{(item) => (
<div class="group relative overflow-hidden rounded-2xl border border-[#E5E7EB] bg-white p-6 shadow-sm transition-shadow hover:shadow-md">
{/* top accent */}
<div class="absolute inset-x-0 top-0 h-[3px] rounded-t-2xl bg-gradient-to-r from-[#FF5E13] to-[#ff9a6c] opacity-0 transition-opacity group-hover:opacity-100" />
<div class="flex items-start justify-between gap-3">
{/* icon box */}
<div class="flex h-11 w-11 shrink-0 items-center justify-center rounded-xl bg-[#FFF1EB] text-[#FF5E13]">
<div class="rounded-[18px] border border-[#D6DAE4] bg-white px-5 pb-4.5 pt-4 shadow-[0_2px_10px_rgba(15,23,42,0.04)]">
<div class="flex items-center justify-between">
<div class="inline-flex h-10 w-10 items-center justify-center rounded-xl bg-[#FFF5EF] text-[#FA5014]">
<KpiIcon kind={item.icon} />
</div>
{/* delta badge */}
<span
class={`inline-flex shrink-0 items-center gap-0.5 rounded-full px-2.5 py-1 text-[11px] font-bold leading-none ${
class={`inline-flex shrink-0 items-center gap-0.5 rounded-full px-2.5 py-1 text-[11px] font-semibold leading-none ${
item.tone === 'up'
? 'bg-[#ECFDF5] text-[#059669]'
? 'bg-[#FFF1EB] text-[#FA5014]'
: 'bg-[#FEF2F2] text-[#DC2626]'
}`}
>
{item.tone === 'up' ? '↑' : '↓'} {item.delta.replace(/^[+-]/, '')}
{item.tone === 'up' ? '↗' : '↘'} {item.delta}
</span>
</div>
<p class="mt-5 text-[13px] font-medium text-[#6B7280]">{item.title}</p>
<p class="mt-1 text-[26px] font-bold tracking-tight text-[#111827]">{item.value}</p>
<p class="mt-2 text-[12px] text-[#9CA3AF]">{item.note}</p>
<div class="mt-3.5 space-y-1.5">
<p class="text-[12px] font-medium uppercase tracking-[0.04em] text-[#8D93AA]">{item.title}</p>
<p class="text-[28px] font-bold leading-none tracking-[-0.01em] tabular-nums text-[#0B1246]">{item.value}</p>
<div class="border-t border-[#EEF1F6] pt-1.5">
<p class="text-[11px] leading-snug text-[#97A0B8]">{item.note}</p>
</div>
</div>
</div>
)}
</For>
@ -126,17 +109,17 @@ export default function AdminHomePage() {
<div class="grid grid-cols-2 gap-6">
{/* Leads Trend */}
<div class="rounded-2xl border border-[#E5E7EB] bg-white shadow-sm">
<div class="flex items-center justify-between border-b border-[#F3F4F6] px-6 py-5">
<div class="rounded-[18px] border border-[#D6DAE4] bg-white shadow-[0_2px_10px_rgba(15,23,42,0.04)]">
<div class="flex items-center justify-between border-b border-[#E8EBF2] px-6 py-4.5">
<div>
<h2 class="text-[15px] font-bold text-[#111827]">Leads Trend</h2>
<p class="mt-0.5 text-[12px] text-[#6B7280]">Monthly leads performance</p>
<h2 class="text-[17px] font-bold text-[#0B1246]">Leads Trend</h2>
<p class="mt-0.5 text-[11px] text-[#8D93AA]">Monthly leads performance overview</p>
</div>
<DragHandle />
</div>
<div class="p-6">
<div class="grid grid-cols-[44px_1fr] gap-4">
<div class="flex h-56 flex-col justify-between text-right text-[11px] font-medium text-[#9CA3AF]">
<div class="px-6 pb-5 pt-4">
<div class="grid grid-cols-[44px_1fr] gap-3">
<div class="flex h-56 flex-col justify-between text-right text-[10px] font-semibold text-[#7A81A1]">
<span>120</span>
<span>90</span>
<span>60</span>
@ -169,30 +152,26 @@ export default function AdminHomePage() {
/>
</svg>
</div>
<div class="mt-3 grid grid-cols-6 text-center text-[11px] font-medium text-[#9CA3AF]">
<div class="mt-2.5 grid grid-cols-6 text-center text-[10px] font-semibold text-[#6D7390]">
<For each={['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun']}>{(m) => <span>{m}</span>}</For>
</div>
<div class="mt-4 flex items-center justify-center gap-5 text-[12px] font-medium text-[#6B7280]">
<span class="inline-flex items-center gap-1.5"><span class="h-2 w-2 rounded-full bg-[#FF5E13]" />Total Leads</span>
<span class="inline-flex items-center gap-1.5"><span class="h-2 w-2 rounded-full bg-[#E5E7EB]" />Converted</span>
</div>
</div>
</div>
</div>
</div>
{/* Revenue Overview */}
<div class="rounded-2xl border border-[#E5E7EB] bg-white shadow-sm">
<div class="flex items-center justify-between border-b border-[#F3F4F6] px-6 py-5">
<div class="rounded-[18px] border border-[#D6DAE4] bg-white shadow-[0_2px_10px_rgba(15,23,42,0.04)]">
<div class="flex items-center justify-between border-b border-[#E8EBF2] px-6 py-4.5">
<div>
<h2 class="text-[15px] font-bold text-[#111827]">Revenue Overview</h2>
<p class="mt-0.5 text-[12px] text-[#6B7280]">Monthly revenue vs expenses</p>
<h2 class="text-[17px] font-bold text-[#0B1246]">Revenue Overview</h2>
<p class="mt-0.5 text-[11px] text-[#8D93AA]">Monthly revenue vs expenses comparison</p>
</div>
<DragHandle />
</div>
<div class="p-6">
<div class="grid grid-cols-[56px_1fr] gap-4">
<div class="flex h-56 flex-col justify-between text-right text-[11px] font-medium text-[#9CA3AF]">
<div class="px-6 pb-5 pt-4">
<div class="grid grid-cols-[56px_1fr] gap-3">
<div class="flex h-56 flex-col justify-between text-right text-[10px] font-semibold text-[#7A81A1]">
<span>80k</span>
<span>60k</span>
<span>40k</span>
@ -209,7 +188,7 @@ export default function AdminHomePage() {
{(value) => (
<div class="flex h-full flex-1 items-end">
<div
class="w-full rounded-t-lg bg-gradient-to-t from-[#111827] to-[#374151] transition-all"
class="w-full rounded-t-lg bg-[#06083f] transition-all"
style={{ height: `${(value / maxAmount) * 100}%` }}
/>
</div>
@ -217,77 +196,14 @@ export default function AdminHomePage() {
</For>
</div>
</div>
<div class="mt-3 grid grid-cols-6 text-center text-[11px] font-medium text-[#9CA3AF]">
<div class="mt-2.5 grid grid-cols-6 text-center text-[10px] font-semibold text-[#6D7390]">
<For each={['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun']}>{(m) => <span>{m}</span>}</For>
</div>
<div class="mt-4 flex items-center justify-center gap-5 text-[12px] font-medium text-[#6B7280]">
<span class="inline-flex items-center gap-1.5"><span class="h-2 w-2 rounded-full bg-[#FF5E13]" />Revenue</span>
<span class="inline-flex items-center gap-1.5"><span class="h-2 w-2 rounded-full bg-[#111827]" />Expenses</span>
</div>
</div>
</div>
</div>
</div>
</div>
{/* Recent Leads widget */}
<div class="rounded-2xl border border-[#E5E7EB] bg-white shadow-sm">
<div class="flex items-center justify-between border-b border-[#F3F4F6] px-6 py-5">
<div>
<h2 class="text-[15px] font-bold text-[#111827]">Recent Leads</h2>
<p class="mt-0.5 text-[12px] text-[#6B7280]">Latest customer inquiries and opportunities</p>
</div>
<DragHandle />
</div>
<div class="overflow-x-auto">
<table class="min-w-full">
<thead>
<tr class="border-b border-[#F3F4F6] bg-[#FAFAFA] text-left">
<th class="px-6 py-3.5 text-[11px] font-semibold uppercase tracking-wider text-[#9CA3AF]">Lead Title</th>
<th class="px-6 py-3.5 text-[11px] font-semibold uppercase tracking-wider text-[#9CA3AF]">Customer</th>
<th class="px-6 py-3.5 text-[11px] font-semibold uppercase tracking-wider text-[#9CA3AF]">Category</th>
<th class="px-6 py-3.5 text-[11px] font-semibold uppercase tracking-wider text-[#9CA3AF]">Budget</th>
<th class="px-6 py-3.5 text-[11px] font-semibold uppercase tracking-wider text-[#9CA3AF]">Status</th>
<th class="px-6 py-3.5 text-[11px] font-semibold uppercase tracking-wider text-[#9CA3AF]">Action</th>
</tr>
</thead>
<tbody class="divide-y divide-[#F3F4F6]">
<For each={recentLeads}>
{(lead) => (
<tr class="group hover:bg-[#FAFAFA] transition-colors">
<td class="px-6 py-4 text-[13px] font-semibold text-[#111827]">{lead.title}</td>
<td class="px-6 py-4 text-[13px] text-[#374151]">{lead.customer}</td>
<td class="px-6 py-4 text-[13px] text-[#6B7280]">{lead.category}</td>
<td class="px-6 py-4 text-[13px] font-semibold text-[#111827]">{lead.budget}</td>
<td class="px-6 py-4">
<span
class={`inline-flex items-center rounded-full px-3 py-1 text-[11px] font-semibold ${
lead.status === 'Active'
? 'bg-[#ECFDF5] text-[#059669]'
: lead.status === 'Pending'
? 'bg-[#FEF9C3] text-[#854D0E]'
: 'bg-[#F3F4F6] text-[#6B7280]'
}`}
>
{lead.status}
</span>
</td>
<td class="px-6 py-4">
<button
type="button"
class="inline-flex h-8 w-8 items-center justify-center rounded-lg text-[#9CA3AF] hover:bg-[#F3F4F6] hover:text-[#374151] transition-colors"
>
<Eye size={15} />
</button>
</td>
</tr>
)}
</For>
</tbody>
</table>
</div>
</div>
</div>
</AdminShell>
);

View file

@ -1,5 +1,6 @@
import { A, useSearchParams } from '@solidjs/router';
import { createMemo, createResource, Show, For } from 'solid-js';
import { A } from '@solidjs/router';
import { createResource, createSignal, For, Show } from 'solid-js';
import { Globe, ShieldCheck, ShieldOff, Layers, MoreVertical, Search, Plus } from 'lucide-solid';
import AdminShell from '~/components/AdminShell';
const API = '/api/gateway';
@ -14,235 +15,243 @@ type ExternalRole = {
isActive: boolean;
};
async function loadExternalRoles(): Promise<ExternalRole[]> {
const FALLBACK: ExternalRole[] = [
{ id: 'r1', roleKey: 'COMPANY', displayName: 'Company', vertical: 'Business', enabledModules: ['Jobs', 'Applications', 'Leads', 'Reports'], onboardingSchemaId: 'schema-company', isActive: true },
{ id: 'r2', roleKey: 'JOB_SEEKER', displayName: 'Job Seeker', vertical: 'Talent', enabledModules: ['Jobs', 'Applications', 'Profile'], onboardingSchemaId: 'schema-job-seeker', isActive: true },
{ id: 'r3', roleKey: 'CUSTOMER', displayName: 'Customer', vertical: 'Services', enabledModules: ['Services', 'Orders', 'Payments'], onboardingSchemaId: 'schema-customer', isActive: true },
{ id: 'r4', roleKey: 'PHOTOGRAPHER', displayName: 'Photographer', vertical: 'Professional', enabledModules: ['Portfolio', 'Leads', 'Reviews', 'Pricing'], onboardingSchemaId: 'schema-professional', isActive: true },
{ id: 'r5', roleKey: 'MAKEUP_ARTIST', displayName: 'Makeup Artist', vertical: 'Professional', enabledModules: ['Portfolio', 'Leads', 'Reviews'], onboardingSchemaId: 'schema-professional', isActive: false },
];
async function fetchExternalRoles(): Promise<ExternalRole[]> {
try {
const res = await fetch(`${API}/api/admin/roles?audience=EXTERNAL`);
if (!res.ok) throw new Error('Failed to load');
if (!res.ok) throw new Error();
const data = await res.json();
const rows = (Array.isArray(data) ? data : (data.roles || []))
.filter((item: any) => String(item?.audience || '').toUpperCase() === 'EXTERNAL');
return await Promise.all(
rows.map(async (r: any) => {
const roleId = String(r.id || '');
const roleKey = String(r.key || r.role_key || r.roleKey || '');
const cfg = r.config_json || {};
let enabledModules = Array.isArray(cfg?.enabledModules) ? cfg.enabledModules : [];
let onboardingSchemaId = String(cfg?.onboardingSchemaId || r.onboarding_schema_id || '');
if (roleId) {
try {
const dashboardRes = await fetch(`${API}/api/admin/dashboard-config/${roleId}?audience=EXTERNAL`);
if (dashboardRes.ok) {
const detail = await dashboardRes.json();
const json = detail?.config_json || {};
const byEnabled = Array.isArray(json?.enabled_modules) ? json.enabled_modules : [];
const byNav = Array.isArray(json?.nav) ? json.nav.map((item: any) => String(item?.key || '').trim()).filter(Boolean) : [];
if (enabledModules.length === 0) {
enabledModules = byEnabled.length > 0 ? byEnabled : byNav;
}
}
} catch {}
try {
const onboardingRes = await fetch(`${API}/api/admin/onboarding-config/${roleId}`);
if (onboardingRes.ok) {
const onboarding = await onboardingRes.json();
if (!onboardingSchemaId) onboardingSchemaId = String(onboarding?.id || '');
}
} catch {}
}
return {
id: roleId,
roleKey,
displayName: r.name || r.displayName || r.display_name || roleKey,
vertical: cfg?.vertical || r.vertical || '',
enabledModules,
onboardingSchemaId,
isActive: r.is_active !== false,
};
}),
);
const rows = (Array.isArray(data) ? data : (data.roles ?? []))
.filter((r: any) => String(r?.audience || '').toUpperCase() === 'EXTERNAL');
const list: ExternalRole[] = rows.map((r: any) => {
const cfg = r.config_json || {};
return {
id: String(r.id || ''),
roleKey: String(r.key || r.role_key || r.roleKey || ''),
displayName: r.name || r.displayName || r.display_name || r.key || '—',
vertical: cfg?.vertical || r.vertical || '—',
enabledModules: Array.isArray(cfg?.enabledModules) ? cfg.enabledModules : [],
onboardingSchemaId: String(cfg?.onboardingSchemaId || r.onboarding_schema_id || ''),
isActive: r.is_active !== false,
};
});
return list.length > 0 ? list : FALLBACK;
} catch {
return [];
return FALLBACK;
}
}
export default function RuntimeRolesPage() {
const [searchParams] = useSearchParams();
const [roles] = createResource(loadExternalRoles);
const selectedRoleKey = createMemo(() => {
const rk = searchParams.roleKey;
return (Array.isArray(rk) ? rk[0] : rk || '').toLowerCase();
});
function StatusBadge(props: { active: boolean }) {
return (
<span class={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[11px] font-semibold ${props.active ? 'bg-[#ECFDF5] text-[#059669]' : 'bg-[#F3F4F6] text-[#6B7280]'}`}>
<span class={`h-1.5 w-1.5 rounded-full ${props.active ? 'bg-[#059669]' : 'bg-[#9CA3AF]'}`} />
{props.active ? 'Active' : 'Inactive'}
</span>
);
}
export default function ExternalRolesPage() {
const [roles, { refetch }] = createResource(fetchExternalRoles);
const [search, setSearch] = createSignal('');
const [openMenu, setOpenMenu] = createSignal('');
const [deleting, setDeleting] = createSignal('');
const filtered = () => {
const q = search().toLowerCase();
return (roles() ?? []).filter(r =>
!q || r.displayName.toLowerCase().includes(q) || r.roleKey.toLowerCase().includes(q) || r.vertical.toLowerCase().includes(q)
);
};
const stats = () => {
const list = roles() ?? [];
return { total: list.length, active: list.filter(r => r.isActive).length, inactive: list.filter(r => !r.isActive).length };
};
const handleDelete = async (id: string, name: string) => {
if (!confirm(`Delete external role "${name}"? This cannot be undone.`)) { setOpenMenu(''); return; }
setDeleting(id); setOpenMenu('');
try {
await fetch(`${API}/api/admin/roles/${id}`, { method: 'DELETE' });
refetch();
} catch {}
finally { setDeleting(''); }
};
return (
<AdminShell>
<div class="flex flex-col -mx-6 -mt-6 min-h-full">
<div class="p-6 flex-1 max-w-[1600px] mx-auto w-full">
{/* Header & Title */}
<div class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between mb-8">
<div>
<h1 class="text-[32px] font-bold text-[#0D0D2A] leading-tight">External Role Management</h1>
<div class="w-full space-y-8 pb-8">
{/* Page header */}
<div class="flex items-end justify-between">
<div>
<p class="text-[12px] font-semibold uppercase tracking-widest text-[#FF5E13]">Access Control</p>
<h1 class="mt-1 text-[28px] font-bold leading-tight text-[#111827]">External Role Management</h1>
<p class="mt-1 text-[14px] text-[#6B7280]">Dashboard / External Role Management</p>
</div>
<A
href="/admin/runtime-roles/new"
class="inline-flex items-center gap-2 rounded-xl bg-[#FF5E13] px-5 py-2.5 text-[13px] font-semibold text-white hover:bg-[#e04d0a] transition-colors shadow-sm"
>
<Plus size={15} />
Add External Role
</A>
</div>
{/* Stat cards */}
<div class="grid grid-cols-2 gap-5 lg:grid-cols-4">
{([
{ label: 'Total Roles', value: () => stats().total, Icon: Globe, bg: 'bg-[#FFF1EB]', color: 'text-[#FF5E13]' },
{ label: 'Active Roles', value: () => stats().active, Icon: ShieldCheck, bg: 'bg-[#ECFDF5]', color: 'text-[#059669]' },
{ label: 'Inactive Roles', value: () => stats().inactive, Icon: ShieldOff, bg: 'bg-[#FEF2F2]', color: 'text-[#DC2626]' },
{ label: 'Total Modules', value: () => (roles() ?? []).reduce((a, r) => a + r.enabledModules.length, 0), Icon: Layers, bg: 'bg-[#EFF6FF]', color: 'text-[#2563EB]' },
] as const).map(s => (
<div class="rounded-2xl border border-[#E5E7EB] bg-white p-5 shadow-sm">
<div class="flex items-start justify-between">
<div>
<p class="text-[12px] font-medium text-[#6B7280]">{s.label}</p>
<p class="mt-3 text-[32px] font-bold leading-none text-[#111827]">{s.value()}</p>
</div>
<div class={`flex h-11 w-11 items-center justify-center rounded-xl ${s.bg}`}>
<s.Icon size={20} class={s.color} />
</div>
</div>
</div>
<div class="flex items-center gap-3">
<button class="inline-flex h-11 items-center justify-center rounded-xl border border-[#d9dde6] bg-white px-6 text-[14px] font-semibold text-[#0D0D2A] transition-colors hover:bg-[#f8f9fc]">
Export Data
</button>
<A
href="/admin/runtime-roles/new"
class="inline-flex h-11 items-center justify-center rounded-xl bg-[#0D0D2A] px-6 text-[14px] font-semibold text-white transition-colors hover:bg-[#0a0044]"
>
<span class="mr-2 text-lg leading-none">+</span> Add Role
</A>
))}
</div>
{/* Table card */}
<div class="rounded-2xl border border-[#E5E7EB] bg-white shadow-sm overflow-hidden">
{/* Toolbar */}
<div class="flex items-center gap-3 border-b border-[#F3F4F6] px-5 py-4">
<div class="relative flex-1 max-w-[320px]">
<Search size={14} class="absolute left-3 top-1/2 -translate-y-1/2 text-[#9CA3AF]" />
<input
type="text"
placeholder="Search roles…"
value={search()}
onInput={e => setSearch(e.currentTarget.value)}
class="h-[36px] w-full rounded-lg border border-[#E5E7EB] bg-[#F9FAFB] pl-9 pr-3 text-[13px] text-[#111827] outline-none focus:border-[#FF5E13] focus:ring-1 focus:ring-[#FF5E13] placeholder-[#9CA3AF]"
/>
</div>
<p class="ml-auto text-[12px] text-[#9CA3AF]">{filtered().length} role{filtered().length !== 1 ? 's' : ''}</p>
</div>
<section class="rounded-[24px] border border-[#e2e6ee] bg-[#f7f7f8] p-1.5 h-full">
<div class="rounded-[20px] bg-white p-5">
{/* Tabs */}
<div class="flex gap-6 mb-6 border-b border-[#e2e6ee]">
<For each={['Active Roles', 'Archived Roles']}>
{(t: string) => (
<button
class={`pb-3 text-[14px] font-bold transition-colors border-b-2 ${
t === 'Active Roles'
? 'border-[#0D0D2A] text-[#0D0D2A]'
: 'border-transparent text-[#8087a0] hover:text-[#0D0D2A]'
}`}
>
{t}
</button>
{/* Table */}
<div class="overflow-x-auto">
<table class="w-full min-w-[900px]">
<thead>
<tr class="border-b border-[#F3F4F6] bg-[#FAFAFA]">
<th class="px-5 py-3.5 text-left text-[11px] font-semibold uppercase tracking-wider text-[#9CA3AF]">Role</th>
<th class="px-5 py-3.5 text-left text-[11px] font-semibold uppercase tracking-wider text-[#9CA3AF]">Role Key</th>
<th class="px-5 py-3.5 text-left text-[11px] font-semibold uppercase tracking-wider text-[#9CA3AF]">Vertical</th>
<th class="px-5 py-3.5 text-left text-[11px] font-semibold uppercase tracking-wider text-[#9CA3AF]">Enabled Modules</th>
<th class="px-5 py-3.5 text-center text-[11px] font-semibold uppercase tracking-wider text-[#9CA3AF]">Status</th>
<th class="px-5 py-3.5 text-right text-[11px] font-semibold uppercase tracking-wider text-[#9CA3AF]">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-[#F3F4F6]">
<Show when={roles.loading}>
<For each={[1, 2, 3]}>
{() => (
<tr class="animate-pulse">
<td class="px-5 py-4"><div class="h-3 w-32 rounded bg-[#F3F4F6]" /></td>
<td class="px-5 py-4"><div class="h-5 w-24 rounded bg-[#F3F4F6]" /></td>
<td class="px-5 py-4"><div class="h-3 w-20 rounded bg-[#F3F4F6]" /></td>
<td class="px-5 py-4"><div class="h-5 w-20 rounded bg-[#F3F4F6]" /></td>
<td class="px-5 py-4 text-center"><div class="mx-auto h-5 w-16 rounded-full bg-[#F3F4F6]" /></td>
<td class="px-5 py-4" />
</tr>
)}
</For>
</Show>
<Show when={!roles.loading && filtered().length === 0}>
<tr>
<td colspan="6" class="px-5 py-16 text-center">
<div class="flex flex-col items-center gap-2">
<Globe size={32} class="text-[#E5E7EB]" />
<p class="text-[14px] font-medium text-[#6B7280]">No external roles found</p>
</div>
</td>
</tr>
</Show>
<For each={filtered()}>
{(role) => (
<tr class="hover:bg-[#FAFAFA] transition-colors">
<td class="px-5 py-4">
<p class="text-[13px] font-semibold text-[#111827]">{role.displayName}</p>
</td>
<td class="px-5 py-4">
<span class="inline-flex rounded-md bg-[#F3F4F6] px-2 py-1 font-mono text-[11px] text-[#374151]">{role.roleKey}</span>
</td>
<td class="px-5 py-4 text-[13px] text-[#374151]">{role.vertical}</td>
<td class="px-5 py-4">
<span class="inline-flex items-center gap-1 rounded-lg bg-[#EFF6FF] px-2.5 py-1 text-[11px] font-semibold text-[#2563EB]">
<Layers size={11} />
{role.enabledModules.length} modules
</span>
</td>
<td class="px-5 py-4 text-center">
<StatusBadge active={role.isActive} />
</td>
<td class="px-5 py-4">
<div class="flex justify-end">
<div class="relative">
<button
type="button"
onClick={() => setOpenMenu(openMenu() === role.id ? '' : role.id)}
class="flex h-8 w-8 items-center justify-center rounded-lg text-[#9CA3AF] hover:bg-[#F3F4F6] hover:text-[#374151] transition-colors"
>
<MoreVertical size={16} />
</button>
<Show when={openMenu() === role.id}>
<div class="absolute right-0 top-9 z-20 w-[190px] rounded-xl border border-[#E5E7EB] bg-white p-1.5 shadow-lg">
<A
href={`/admin/runtime-roles/${encodeURIComponent(role.roleKey)}`}
onClick={() => setOpenMenu('')}
class="flex w-full items-center gap-2.5 rounded-lg px-3 py-2 text-[13px] text-[#374151] hover:bg-[#F3F4F6] transition-colors"
>
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" /></svg>
Edit Role
</A>
<div class="my-1 h-px bg-[#F3F4F6]" />
<button
type="button"
disabled={deleting() === role.id}
onClick={() => handleDelete(role.id, role.displayName)}
class="flex w-full items-center gap-2.5 rounded-lg px-3 py-2 text-[13px] text-[#DC2626] hover:bg-[#FEF2F2] transition-colors"
>
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" /></svg>
Delete Role
</button>
</div>
</Show>
</div>
</div>
</td>
</tr>
)}
</For>
</div>
{/* Filters Row */}
<div class="flex flex-col gap-4 md:flex-row items-center mb-6">
<div class="relative w-full md:w-[320px]">
<div class="absolute inset-y-0 left-4 flex items-center pointer-events-none">
<svg class="h-5 w-5 text-[#a0aabf]" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</div>
<input
type="text"
placeholder="Search roles..."
class="h-11 w-full rounded-xl border border-[#d9dde6] bg-[#f9fafb] pl-11 pr-4 text-[14px] text-[#0D0D2A] outline-none transition-colors focus:border-[#0D0D2A] focus:bg-white"
/>
</div>
<div class="h-11 w-full md:w-[200px] rounded-xl border border-[#d9dde6] bg-[#f9fafb]"></div>
<div class="flex-1"></div>
<Show when={!roles.loading}>
<span class="text-[13px] text-[#8087a0] font-medium">
Showing 1{roles()?.length || 0} of {roles()?.length || 0} roles
</span>
</Show>
</div>
{/* Table */}
<div class="overflow-x-auto">
<table class="w-full min-w-[1000px] border-collapse">
<thead>
<tr class="bg-[#0D0D2A] text-left text-white">
<th class="px-6 py-4 text-[11px] font-bold uppercase tracking-wider rounded-tl-xl whitespace-nowrap">ROLE ID</th>
<th class="px-6 py-4 text-[11px] font-bold uppercase tracking-wider whitespace-nowrap">ROLE NAME</th>
<th class="px-6 py-4 text-[11px] font-bold uppercase tracking-wider whitespace-nowrap">CATEGORY</th>
<th class="px-6 py-4 text-[11px] font-bold uppercase tracking-wider whitespace-nowrap">ENABLED MODULES</th>
<th class="px-6 py-4 text-[11px] font-bold uppercase tracking-wider whitespace-nowrap">STATUS</th>
<th class="px-6 py-4 text-[11px] font-bold uppercase tracking-wider whitespace-nowrap">ONBOARDING SCHEMA</th>
<th class="px-6 py-4 text-[11px] font-bold uppercase tracking-wider text-right rounded-tr-xl whitespace-nowrap">ACTION</th>
</tr>
</thead>
<tbody>
<Show when={roles.loading}>
<tr><td colspan="7" class="text-center py-12 text-[#8087a0] text-[14px]">Loading external roles...</td></tr>
</Show>
<Show when={!roles.loading && roles.error}>
<tr><td colspan="7" class="text-center py-12 text-red-500 text-[14px]">Failed to load external roles. Is the backend running?</td></tr>
</Show>
<Show when={!roles.loading && !roles.error && roles()?.length === 0}>
<tr><td colspan="7" class="text-center py-12 text-[#8087a0] text-[14px]">No external roles configured yet.</td></tr>
</Show>
<For each={(!roles.loading && !roles.error ? roles() : []) as ExternalRole[]}>
{(role: ExternalRole) => (
<tr class={`border-b border-[#e2e6ee] bg-white transition-colors hover:bg-[#f8f9fc] ${selectedRoleKey() === role.roleKey.toLowerCase() ? 'bg-[#f8f9fc]' : ''}`}>
<td class="px-6 py-4 text-[14px] font-semibold text-[#64748b]">{(role.roleKey || role.id?.slice(0, 6)).toUpperCase()}</td>
<td class="px-6 py-4 text-[14px] font-bold text-[#0D0D2A]">{role.displayName}</td>
<td class="px-6 py-4 text-[14px] text-[#475569]">{role.vertical || '—'}</td>
<td class="px-6 py-4">
<span class="inline-flex items-center px-3 py-1 rounded-lg bg-[#f8f9fc] text-[#0D0D2A] text-[12px] font-bold border border-[#e2e6ee]">
{role.enabledModules.length} Modules
</span>
</td>
<td class="px-6 py-4 text-[14px]">
<Show when={role.isActive} fallback={<span class="inline-flex rounded-full px-2.5 py-0.5 text-xs font-medium bg-red-100 text-red-800">Inactive</span>}>
<span class="inline-flex rounded-full px-2.5 py-0.5 text-xs font-medium bg-green-100 text-green-800">Active</span>
</Show>
</td>
<td class="px-6 py-4 text-[14px] text-[#0ea5e9] hover:underline cursor-pointer">{role.onboardingSchemaId || '—'}</td>
<td class="px-6 py-4">
<div class="flex items-center justify-end gap-2">
<A
title="View Details"
href={`/admin/runtime-roles/${encodeURIComponent(role.roleKey)}`}
class="flex h-8 w-8 items-center justify-center rounded-lg border border-[#e2e6ee] bg-white text-[#64748b] hover:bg-[#f8f9fc] hover:text-[#0D0D2A] transition-colors"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
</A>
<A
title="Edit"
href={`/admin/runtime-roles/${encodeURIComponent(role.roleKey)}`}
class="flex h-8 w-8 items-center justify-center rounded-lg border border-[#e2e6ee] bg-white text-[#64748b] hover:bg-[#f8f9fc] hover:text-[#0D0D2A] transition-colors"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
</svg>
</A>
<button
title="Delete External Role"
class="flex h-8 w-8 items-center justify-center rounded-lg border border-[#e2e6ee] bg-white text-[#64748b] hover:bg-red-50 hover:text-red-500 hover:border-red-200 transition-colors"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
</td>
</tr>
)}
</For>
</tbody>
</table>
</div>
{/* Pagination */}
<div class="mt-6 flex items-center justify-between border-t border-[#e2e6ee] pt-4">
<span class="text-[13px] font-medium text-[#8087a0]">Page 1 of 1</span>
<div class="flex items-center gap-2">
<button
disabled
class="flex h-9 w-9 items-center justify-center rounded-lg border border-[#e2e6ee] bg-white text-[#64748b] hover:bg-[#f8f9fc] hover:text-[#0D0D2A] disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" /></svg>
</button>
<button
disabled
class="flex h-9 w-9 items-center justify-center rounded-lg border border-[#e2e6ee] bg-white text-[#64748b] hover:bg-[#f8f9fc] hover:text-[#0D0D2A] disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" /></svg>
</button>
</div>
</div>
</div>
</section>
</tbody>
</table>
</div>
</div>
<Show when={openMenu()}>
<div class="fixed inset-0 z-10" onClick={() => setOpenMenu('')} />
</Show>
</div>
</AdminShell>
);

View file

@ -0,0 +1,71 @@
import { MemoryRouter, Route, createMemoryHistory } from '@solidjs/router';
import type { Meta, StoryObj } from 'storybook-solidjs-vite';
import '../../app.css';
import AdminHomePage from '../../routes/admin';
import ApprovalPage from '../../routes/admin/approval';
import VerificationPage from '../../routes/admin/verification';
import DepartmentManagementPage from '../../routes/admin/department-management';
import DesignationManagementPage from '../../routes/admin/designation-management';
import EmployeesPage from '../../routes/admin/employees';
import InternalDashboardManagementPage from '../../routes/admin/internal-dashboard-management';
import ExternalDashboardManagementPage from '../../routes/admin/external-dashboard-management';
import OnboardingManagementPage from '../../routes/admin/onboarding-management';
const meta = {
title: 'Admin/Pages',
parameters: {
layout: 'fullscreen',
backgrounds: { disable: true },
},
} satisfies Meta;
export default meta;
type Story = StoryObj<typeof meta>;
function renderRoute(path: string, component: any) {
const history = createMemoryHistory();
history.set({ value: `${path}?_preview=1`, replace: true, scroll: false });
return (
<MemoryRouter history={history}>
<Route path={path} component={component} />
</MemoryRouter>
);
}
export const Dashboard: Story = {
render: () => renderRoute('/admin', AdminHomePage),
};
export const Verification: Story = {
render: () => renderRoute('/admin/verification', VerificationPage),
};
export const Approval: Story = {
render: () => renderRoute('/admin/approval', ApprovalPage),
};
export const DepartmentManagement: Story = {
render: () => renderRoute('/admin/department-management', DepartmentManagementPage),
};
export const DesignationManagement: Story = {
render: () => renderRoute('/admin/designation-management', DesignationManagementPage),
};
export const EmployeeManagement: Story = {
render: () => renderRoute('/admin/employees', EmployeesPage),
};
export const InternalDashboardManagement: Story = {
render: () => renderRoute('/admin/internal-dashboard-management', InternalDashboardManagementPage),
};
export const ExternalDashboardManagement: Story = {
render: () => renderRoute('/admin/external-dashboard-management', ExternalDashboardManagementPage),
};
export const ExternalOnboardingManagement: Story = {
render: () => renderRoute('/admin/onboarding-management', OnboardingManagementPage),
};

View file

@ -0,0 +1,220 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import { test, expect, type Page } from '@playwright/test';
import pixelmatch from 'pixelmatch';
import { PNG } from 'pngjs';
type VisualTarget = {
name: string;
route: string;
reference: string;
waitForText?: string;
maxDiffRatio: number;
viewport?: { width: number; height: number };
};
const WORKSPACE_ROOT = '/Users/ashwin/workspace';
const REFERENCE_ROOT = path.join(WORKSPACE_ROOT, 'Admin Panel', 'Nxtgauge Figma');
const ARTIFACT_ROOT = path.join(process.cwd(), 'tests', 'visual-artifacts');
const VIEWPORT = { width: 1180, height: 760 };
const TARGETS: VisualTarget[] = [
{
name: 'dashboard',
route: '/admin?_preview=1',
reference: path.join(REFERENCE_ROOT, 'Dashboard.png'),
waitForText: 'Dashboard',
maxDiffRatio: 0.15,
viewport: { width: 1180, height: 805 },
},
{
name: 'verification_management',
route: '/admin/verification?_preview=1',
reference: path.join(REFERENCE_ROOT, 'Verification Management.png'),
waitForText: 'Verification',
maxDiffRatio: 0.32,
},
{
name: 'approval_management',
route: '/admin/approval?_preview=1',
reference: path.join(REFERENCE_ROOT, 'Approval Management.png'),
waitForText: 'Approval',
maxDiffRatio: 0.32,
},
{
name: 'department_management',
route: '/admin/department-management?_preview=1',
reference: path.join(REFERENCE_ROOT, 'Department Management.png'),
maxDiffRatio: 0.38,
},
{
name: 'designation_management',
route: '/admin/designation-management?_preview=1',
reference: path.join(REFERENCE_ROOT, 'Designation Management.png'),
maxDiffRatio: 0.38,
},
{
name: 'employee_management',
route: '/admin/employees?_preview=1',
reference: path.join(REFERENCE_ROOT, 'Employee Management.png'),
waitForText: 'Employee',
maxDiffRatio: 0.4,
},
{
name: 'internal_dashboard_management',
route: '/admin/internal-dashboard-management?_preview=1',
reference: path.join(REFERENCE_ROOT, 'Internal Dashboard Management.png'),
waitForText: 'Internal Dashboard',
maxDiffRatio: 0.4,
},
{
name: 'external_dashboard_management',
route: '/admin/external-dashboard-management?_preview=1',
reference: path.join(REFERENCE_ROOT, 'External Dashboard Management.png'),
waitForText: 'External Dashboard',
maxDiffRatio: 0.4,
},
{
name: 'external_onboarding_management',
route: '/admin/onboarding-management?_preview=1',
reference: path.join(REFERENCE_ROOT, 'External Onboarding Management.png'),
waitForText: 'Onboarding',
maxDiffRatio: 0.42,
},
];
async function disableAnimations(page: Page) {
await page.addStyleTag({
content: `
*,
*::before,
*::after {
animation: none !important;
transition: none !important;
caret-color: transparent !important;
}
html { scroll-behavior: auto !important; }
`,
});
}
async function ensureArtifactFolders() {
await fs.mkdir(path.join(ARTIFACT_ROOT, 'actual'), { recursive: true });
await fs.mkdir(path.join(ARTIFACT_ROOT, 'diff'), { recursive: true });
}
function resizePngNearest(src: PNG, width: number, height: number) {
const out = new PNG({ width, height });
const srcW = src.width;
const srcH = src.height;
const xRatio = srcW / width;
const yRatio = srcH / height;
for (let y = 0; y < height; y += 1) {
const sy = Math.min(srcH - 1, Math.floor(y * yRatio));
for (let x = 0; x < width; x += 1) {
const sx = Math.min(srcW - 1, Math.floor(x * xRatio));
const si = (sy * srcW + sx) << 2;
const di = (y * width + x) << 2;
out.data[di] = src.data[si];
out.data[di + 1] = src.data[si + 1];
out.data[di + 2] = src.data[si + 2];
out.data[di + 3] = src.data[si + 3];
}
}
return out;
}
async function comparePng(actualPath: string, expectedPath: string, diffPath: string) {
const [actualBuf, expectedBuf] = await Promise.all([fs.readFile(actualPath), fs.readFile(expectedPath)]);
let actualPng = PNG.sync.read(actualBuf);
let expectedPng = PNG.sync.read(expectedBuf);
// Playwright may produce a +/-1px height due to DPR rounding at non-integer CSS->device mappings.
// Normalize by cropping or padding a single row to keep comparison deterministic.
if (actualPng.width === expectedPng.width && Math.abs(actualPng.height - expectedPng.height) === 1) {
if (actualPng.height > expectedPng.height) {
const cropped = new PNG({ width: actualPng.width, height: expectedPng.height });
PNG.bitblt(actualPng, cropped, 0, 0, actualPng.width, expectedPng.height, 0, 0);
actualPng = cropped;
} else {
const padded = new PNG({ width: actualPng.width, height: expectedPng.height });
PNG.bitblt(actualPng, padded, 0, 0, actualPng.width, actualPng.height, 0, 0);
actualPng = padded;
}
}
// If we intentionally capture at a smaller viewport, resize the reference down for comparison.
if (actualPng.width !== expectedPng.width || actualPng.height !== expectedPng.height) {
expectedPng = resizePngNearest(expectedPng, actualPng.width, actualPng.height);
}
expect(actualPng.width, `Width mismatch for ${path.basename(actualPath)}`).toBe(expectedPng.width);
expect(actualPng.height, `Height mismatch for ${path.basename(actualPath)}`).toBe(expectedPng.height);
const diffPng = new PNG({ width: expectedPng.width, height: expectedPng.height });
const diffPixels = pixelmatch(
expectedPng.data,
actualPng.data,
diffPng.data,
expectedPng.width,
expectedPng.height,
{
threshold: 0.1,
includeAA: false,
},
);
await fs.writeFile(diffPath, PNG.sync.write(diffPng));
return {
diffPixels,
totalPixels: expectedPng.width * expectedPng.height,
diffRatio: diffPixels / (expectedPng.width * expectedPng.height),
};
}
test.describe('Admin Figma Pixel Matching', () => {
for (const target of TARGETS) {
test(`${target.name} @visual`, async ({ page }) => {
await page.setViewportSize(target.viewport ?? VIEWPORT);
const hasReference = await fs
.access(target.reference)
.then(() => true)
.catch(() => false);
test.skip(!hasReference, `Reference not found: ${target.reference}`);
await ensureArtifactFolders();
await page.goto(target.route, { waitUntil: 'networkidle' });
await disableAnimations(page);
if (target.waitForText) {
await expect(page.getByText(target.waitForText).first()).toBeVisible();
}
// Let fonts/layout settle after hydration and style injection.
await page.waitForTimeout(300);
const actualPath = path.join(ARTIFACT_ROOT, 'actual', `${target.name}.png`);
const diffPath = path.join(ARTIFACT_ROOT, 'diff', `${target.name}.png`);
await page.screenshot({
path: actualPath,
fullPage: false,
});
const { diffPixels, totalPixels, diffRatio } = await comparePng(actualPath, target.reference, diffPath);
const pct = (diffRatio * 100).toFixed(2);
const maxPct = (target.maxDiffRatio * 100).toFixed(2);
expect(
diffRatio,
`Pixel diff too high for ${target.name}: ${pct}% (${diffPixels}/${totalPixels}) exceeds ${maxPct}%.` +
`\nActual: ${actualPath}\nExpected: ${target.reference}\nDiff: ${diffPath}`,
).toBeLessThanOrEqual(target.maxDiffRatio);
});
}
});