chore: sync local changes
This commit is contained in:
parent
4ca69877d4
commit
c65f32d45c
13 changed files with 1325 additions and 506 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -29,3 +29,6 @@ Thumbs.db
|
|||
|
||||
*storybook.log
|
||||
storybook-static
|
||||
playwright-report
|
||||
test-results
|
||||
tests/visual-artifacts
|
||||
|
|
|
|||
56
docs/visual-pixel-matching.md
Normal file
56
docs/visual-pixel-matching.md
Normal 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
15
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
281
src/components/admin/UserListPage.tsx
Normal file
281
src/components/admin/UserListPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1 +1,3 @@
|
|||
export { default } from './department';
|
||||
import DepartmentManagementPage from './department';
|
||||
|
||||
export default DepartmentManagementPage;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
71
src/stories/admin/AdminPages.stories.tsx
Normal file
71
src/stories/admin/AdminPages.stories.tsx
Normal 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),
|
||||
};
|
||||
220
tests/e2e/admin-visual.spec.ts
Normal file
220
tests/e2e/admin-visual.spec.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue