feat(admin): implement figma-aligned admin shell and management modules

This commit is contained in:
Ashwin Kumar 2026-03-25 15:45:14 +01:00
parent d67e436828
commit 94d4623248
30 changed files with 2342 additions and 0 deletions

View file

@ -0,0 +1,109 @@
import { A, useLocation } from '@solidjs/router';
import { For, Show, createMemo, type JSX } from 'solid-js';
import { adminModules } from '~/lib/admin/module-config';
const iconByRoute: Record<string, string> = {
'/admin': 'DB',
'/admin/department': 'DP',
'/admin/designation': 'DG',
'/admin/internal-role-management': 'IR',
'/admin/employees': 'EM',
'/admin/external-role-management': 'ER',
'/admin/external-onboarding': 'EO',
'/admin/internal-dashboard': 'ID',
'/admin/external-dashboard': 'ED',
'/admin/verification-management': 'VR',
'/admin/approval-management': 'AP',
};
export default function AdminShell(props: { children: JSX.Element }) {
const location = useLocation();
const navItems = createMemo(() => adminModules.filter((module) => module.route !== '/admin'));
return (
<div class="min-h-screen bg-[#f5f6fa] text-[#050026]">
<div class="grid min-h-screen grid-cols-1 lg:grid-cols-[220px_minmax(0,1fr)] xl:grid-cols-[236px_minmax(0,1fr)] 2xl:grid-cols-[248px_minmax(0,1fr)]">
<aside class="hidden border-r border-[#e6e8ee] bg-[#f7f7f8] lg:flex lg:flex-col">
<div class="h-[101px] border-b border-[#e6e8ee] px-8 py-8">
<img src="/nxtgauge-logo.png" alt="Nxtgauge" class="h-9 w-auto" />
</div>
<nav class="min-h-0 flex-1 overflow-y-auto px-4 py-6">
<A href="/admin" class={`group relative flex h-12 items-center rounded-xl px-4 text-sm font-medium transition ${location.pathname === '/admin' ? 'bg-[#ffe8dc] text-[#fd6116]' : 'text-[#1a1f4a] hover:bg-white'}`}>
<Show when={location.pathname === '/admin'}>
<span class="absolute left-0 top-2 h-8 w-1 rounded-r bg-[#fd6116]" />
</Show>
<span class="mr-4 text-[11px] font-semibold leading-none">{iconByRoute['/admin']}</span>
<span>Dashboard</span>
</A>
<For each={navItems()}>
{(module, index) => {
const active = () => location.pathname === module.route || location.pathname.startsWith(`${module.route}/`);
const prev = () => navItems()[index() - 1];
const startsGroup = () => index() > 0 && prev()?.category !== module.category;
return (
<>
<Show when={startsGroup()}>
<div class="mx-3 my-4 border-t border-[#dee2ea]" />
</Show>
<A
href={module.route}
class={`group relative flex h-12 items-center rounded-xl px-4 text-sm font-medium transition ${
active() ? 'bg-[#ffe8dc] text-[#fd6116]' : 'text-[#1a1f4a] hover:bg-white'
}`}
>
<Show when={active()}>
<span class="absolute left-0 top-2 h-8 w-1 rounded-r bg-[#fd6116]" />
</Show>
<span class="mr-4 text-[11px] font-semibold leading-none">{iconByRoute[module.route] || '--'}</span>
<span class="truncate">{module.navLabel}</span>
</A>
</>
);
}}
</For>
</nav>
<div class="border-t border-[#e6e8ee] p-4">
<div class="flex items-center gap-3 rounded-2xl border border-[#d9dde6] bg-[#f3f4f7] px-4 py-3">
<div class="inline-flex h-10 w-10 items-center justify-center rounded-full bg-[#fd6116] text-sm font-bold text-white">AD</div>
<div class="min-w-0">
<p class="truncate text-sm font-semibold text-[#0f1744]">Admin User</p>
<p class="truncate text-xs text-[#8088a5]">Super Admin</p>
</div>
</div>
</div>
</aside>
<div class="min-w-0">
<header class="sticky top-0 z-40 border-b border-[#e6e8ee] bg-[#f7f7f8]">
<div class="flex h-[101px] items-center justify-between gap-3 px-4 lg:px-8">
<label class="flex h-12 w-full max-w-[540px] items-center rounded-2xl border border-[#e2e5ee] bg-[#ededf1] px-4 text-sm text-[#8a90aa]">
<span class="mr-3 text-xs font-semibold leading-none">SR</span>
<input class="w-full border-0 bg-transparent text-sm text-[#050026] outline-none" placeholder="Search for anything..." />
</label>
<div class="flex items-center gap-5">
<button class="relative text-[#050026]">
<span class="text-xs font-semibold">NT</span>
<span class="absolute -right-0.5 top-0 h-1.5 w-1.5 rounded-full bg-[#fd6116]" />
</button>
<button class="text-xs font-semibold text-[#050026]">ST</button>
<div class="hidden h-10 w-px bg-[#dde1ea] md:block" />
<div class="hidden min-w-0 md:block">
<p class="text-sm font-semibold text-[#0f1744]">Admin User</p>
<p class="text-xs text-[#8088a5]">Super Admin</p>
</div>
<div class="inline-flex h-11 w-11 items-center justify-center rounded-2xl bg-[#fd6116] text-sm font-bold text-white shadow-[0_8px_20px_rgba(253,97,22,0.35)]">AD</div>
</div>
</div>
</header>
<main class="p-4 lg:p-6">{props.children}</main>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,186 @@
import { For, Show, type JSX } from 'solid-js';
type Metric = {
label: string;
value: string;
tone?: 'default' | 'positive' | 'warning' | 'critical';
};
export function PageHeader(props: { title: string; subtitle: string; actions?: JSX.Element }) {
return (
<div class="flex flex-col gap-4 rounded-3xl border border-[#d9dde6] bg-[#f7f7f8] p-6 lg:flex-row lg:items-center lg:justify-between">
<div>
<h1 class="text-[34px] font-semibold leading-[1.15] text-[#050026]">{props.title}</h1>
<p class="mt-1 text-sm text-[#7b8099]">{props.subtitle}</p>
</div>
<Show when={props.actions}>
<div class="flex items-center gap-2">{props.actions}</div>
</Show>
</div>
);
}
export function MetricCards(props: { items: Metric[] }) {
return (
<div class="grid gap-3 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-6">
<For each={props.items}>
{(item) => (
<div class="rounded-3xl border border-[#d9dde6] bg-[#f7f7f8] px-5 py-4">
<p class="text-xs font-semibold uppercase tracking-[0.08em] text-[#7c839d]">{item.label}</p>
<p
class={`mt-2 text-3xl font-semibold ${
item.tone === 'positive'
? 'text-[#1d8f57]'
: item.tone === 'warning'
? 'text-[#b1720c]'
: item.tone === 'critical'
? 'text-[#b51f40]'
: 'text-[#050026]'
}`}
>
{item.value}
</p>
</div>
)}
</For>
</div>
);
}
export function SectionCard(props: { title: string; subtitle?: string; actions?: JSX.Element; children: JSX.Element }) {
return (
<section class="rounded-3xl border border-[#d9dde6] bg-[#f7f7f8]">
<div class="flex flex-col gap-3 border-b border-[#e5e8ef] px-5 py-4 lg:flex-row lg:items-center lg:justify-between">
<div>
<h2 class="text-[30px] font-semibold leading-[1.1] text-[#050026]">{props.title}</h2>
<Show when={props.subtitle}>
<p class="mt-1 text-sm text-[#7f86a0]">{props.subtitle}</p>
</Show>
</div>
<Show when={props.actions}>
<div class="flex items-center gap-2">{props.actions}</div>
</Show>
</div>
<div class="p-4">{props.children}</div>
</section>
);
}
export function Tabs<T extends string>(props: { value: T; onChange: (key: T) => void; items: { key: T; label: string }[] }) {
return (
<div class="inline-flex rounded-xl border border-[#e0e5f0] bg-[#f0f2f8] p-1">
<For each={props.items}>
{(item) => (
<button
onClick={() => props.onChange(item.key)}
class={`rounded-lg px-3 py-1.5 text-sm font-medium transition ${
props.value === item.key ? 'bg-[#fd6116] text-white shadow-sm' : 'text-slate-600 hover:text-[#050026]'
}`}
>
{item.label}
</button>
)}
</For>
</div>
);
}
export function SearchFilters(props: {
query: string;
onQuery: (v: string) => void;
left?: JSX.Element;
right?: JSX.Element;
placeholder?: string;
}) {
return (
<div class="grid gap-3 rounded-2xl border border-[#e1e4ec] bg-[#f5f6f9] p-3 lg:grid-cols-[1fr_auto_auto] lg:items-center">
<label class="flex items-center rounded-xl border border-[#dde3ef] bg-[#eceef3] px-3 py-2 text-sm text-[#8a90aa]">
<span class="mr-2">SR</span>
<input
value={props.query}
onInput={(e) => props.onQuery(e.currentTarget.value)}
placeholder={props.placeholder ?? 'Search by name or ID...'}
class="w-full border-0 bg-transparent text-sm text-[#050026] outline-none"
/>
</label>
<Show when={props.left}>
<div class="flex gap-2">{props.left}</div>
</Show>
<Show when={props.right}>
<div class="flex gap-2 lg:justify-end">{props.right}</div>
</Show>
</div>
);
}
export function StatusBadge(props: { label: string; tone?: 'neutral' | 'positive' | 'warning' | 'critical' | 'info' }) {
const tone = () => props.tone ?? 'neutral';
return (
<span
class={`inline-flex rounded-full px-2.5 py-1 text-xs font-semibold ${
tone() === 'positive'
? 'bg-[#e7f7ee] text-[#187f4f]'
: tone() === 'warning'
? 'bg-[#fff5df] text-[#9a6709]'
: tone() === 'critical'
? 'bg-[#ffe5ea] text-[#b51f40]'
: tone() === 'info'
? 'bg-[#eaf0ff] text-[#2f4da5]'
: 'bg-[#eceff5] text-[#4a4f65]'
}`}
>
{props.label}
</span>
);
}
export function ActionButton(props: { onClick?: () => void; children: JSX.Element; tone?: 'primary' | 'secondary' | 'ghost'; type?: 'button' | 'submit' | 'reset' }) {
const tone = () => props.tone ?? 'secondary';
return (
<button
type={props.type ?? 'button'}
onClick={props.onClick}
class={`rounded-lg px-3 py-2 text-sm font-medium transition ${
tone() === 'primary'
? 'bg-[#050026] text-white hover:bg-[#0d043f]'
: tone() === 'ghost'
? 'text-[#5f6681] hover:bg-[#eef1f7]'
: 'border border-[#d7deea] bg-[#f7f7f8] text-[#050026] hover:border-[#bfc8da]'
}`}
>
{props.children}
</button>
);
}
export function DataTable(props: { headers: string[]; rows: JSX.Element[][] }) {
return (
<div class="overflow-x-auto rounded-2xl border border-[#d9dde6] bg-[#f7f7f8]">
<table class="min-w-full text-left text-sm">
<thead class="bg-[#eff1f6] text-[#4c536d]">
<tr>
<For each={props.headers}>{(head) => <th class="px-4 py-3 text-xs font-semibold uppercase tracking-[0.06em]">{head}</th>}</For>
</tr>
</thead>
<tbody class="divide-y divide-[#e2e6ee] bg-[#f7f7f8]">
<For each={props.rows}>
{(row) => (
<tr>
<For each={row}>{(cell) => <td class="px-4 py-3 align-middle">{cell}</td>}</For>
</tr>
)}
</For>
</tbody>
</table>
</div>
);
}
export function ModuleStatusPill(props: { status: 'live' | 'mock' | 'pending' }) {
return (
<StatusBadge
label={props.status.toUpperCase()}
tone={props.status === 'live' ? 'positive' : props.status === 'mock' ? 'warning' : 'neutral'}
/>
);
}

View file

@ -0,0 +1,89 @@
import { createMemo, createSignal, For } from 'solid-js';
import type { CrudRecord } from '~/lib/admin/types';
import { ActionButton, DataTable, PageHeader, SearchFilters, SectionCard, StatusBadge, Tabs } from './AdminUi';
export default function CrudManagementPage(props: {
title: string;
subtitle: string;
records: CrudRecord[];
noun: string;
}) {
const [tab, setTab] = createSignal<'list' | 'create'>('list');
const [query, setQuery] = createSignal('');
const filtered = createMemo(() => {
const q = query().trim().toLowerCase();
if (!q) return props.records;
return props.records.filter((row) => row.id.toLowerCase().includes(q) || row.name.toLowerCase().includes(q));
});
return (
<div class="space-y-5">
<PageHeader
title={props.title}
subtitle={props.subtitle}
actions={
<Tabs
value={tab()}
onChange={setTab}
items={[
{ key: 'list', label: `${props.noun} List` },
{ key: 'create', label: `Create ${props.noun}` },
]}
/>
}
/>
{tab() === 'list' ? (
<SectionCard
title={`${props.noun} Registry`}
subtitle={`Manage all ${props.noun.toLowerCase()} records from one place`}
actions={
<>
<ActionButton>Export</ActionButton>
<ActionButton tone="primary">Add {props.noun}</ActionButton>
</>
}
>
<div class="space-y-3">
<SearchFilters query={query()} onQuery={setQuery} right={<ActionButton>Filter</ActionButton>} />
<DataTable
headers={['ID', 'Name', 'Status', 'Updated', 'Actions']}
rows={filtered().map((row) => [
<span class="font-medium text-[#050026]">{row.id}</span>,
<span>{row.name}</span>,
<StatusBadge label={row.status} tone={row.status === 'ACTIVE' ? 'positive' : 'warning'} />,
<span class="text-xs text-slate-500">{new Date(row.updatedAt).toLocaleDateString()}</span>,
<div class="flex gap-2">
<ActionButton>Edit</ActionButton>
<ActionButton tone="ghost">View</ActionButton>
</div>,
])}
/>
<For each={filtered().length === 0 ? [1] : []}>{() => <p class="text-sm text-slate-500">No records found.</p>}</For>
</div>
</SectionCard>
) : (
<SectionCard title={`Create ${props.noun}`} subtitle={`Add a new ${props.noun.toLowerCase()} entry with runtime status.`}>
<form class="grid gap-4 rounded-xl border border-[#e5eaf3] bg-[#f9fbff] p-4 md:grid-cols-2">
<label class="text-sm font-medium text-slate-700">
{props.noun} Name
<input class="mt-1 block w-full rounded-lg border border-[#d8dfec] bg-white px-3 py-2 text-sm outline-none focus:border-[#fd6116]" />
</label>
<label class="text-sm font-medium text-slate-700">
Status
<select class="mt-1 block w-full rounded-lg border border-[#d8dfec] bg-white px-3 py-2 text-sm outline-none focus:border-[#fd6116]">
<option value="ACTIVE">ACTIVE</option>
<option value="INACTIVE">INACTIVE</option>
</select>
</label>
<div class="md:col-span-2 flex justify-end gap-2">
<ActionButton>Cancel</ActionButton>
<ActionButton tone="primary">Save</ActionButton>
</div>
</form>
</SectionCard>
)}
</div>
);
}

View file

@ -0,0 +1,202 @@
import { createMemo, createSignal, For, onMount } from 'solid-js';
import type { AdminModuleConfig, CrudRecord } from '~/lib/admin/types';
import { bulkModuleRecordAction, createModuleRecord, deleteModuleRecord, listModuleRecords, updateModuleRecord } from '~/lib/admin/client';
import { ActionButton, DataTable, ModuleStatusPill, PageHeader, SearchFilters, SectionCard, StatusBadge } from './AdminUi';
export default function GenericAdminModulePage(props: { module: AdminModuleConfig }) {
const [loading, setLoading] = createSignal(true);
const [query, setQuery] = createSignal('');
const [status, setStatus] = createSignal('');
const [rows, setRows] = createSignal<CrudRecord[]>([]);
const [selected, setSelected] = createSignal<string[]>([]);
const [error, setError] = createSignal('');
const [nameInput, setNameInput] = createSignal('');
const [statusInput, setStatusInput] = createSignal<'ACTIVE' | 'INACTIVE'>('ACTIVE');
const load = async () => {
setLoading(true);
setError('');
try {
const moduleKey = props.module.route.replace(/^\/admin\//, '').replace(/^\/admin$/, 'dashboard');
const data = await listModuleRecords(moduleKey, { q: query(), status: status() || undefined });
setRows(data);
} catch (err: any) {
setError(String(err?.message || 'Failed to load records.'));
setRows([]);
}
setLoading(false);
};
onMount(load);
const filtered = createMemo(() => {
const q = query().trim().toLowerCase();
if (!q) return rows();
return rows().filter((row) => row.id.toLowerCase().includes(q) || row.name.toLowerCase().includes(q));
});
return (
<div class="space-y-5">
<PageHeader
title={props.module.title}
subtitle={props.module.description}
actions={
<>
<ModuleStatusPill status={props.module.status} />
<ActionButton tone="primary">Create</ActionButton>
</>
}
/>
<SectionCard
title={`${props.module.navLabel} Records`}
subtitle="Registry-backed CRUD records with route-level module adapters."
actions={
<>
<ActionButton>Export</ActionButton>
<ActionButton>Bulk Actions</ActionButton>
</>
}
>
<div class="space-y-3">
<SearchFilters
query={query()}
onQuery={(v) => {
setQuery(v);
void load();
}}
right={
<>
<select
class="rounded-lg border border-[#d7deea] bg-white px-2 py-2 text-sm text-[#050026]"
value={status()}
onInput={(e) => {
setStatus(e.currentTarget.value);
void load();
}}
>
<option value="">All Status</option>
<option value="ACTIVE">ACTIVE</option>
<option value="INACTIVE">INACTIVE</option>
</select>
<ActionButton>Filter</ActionButton>
<ActionButton
onClick={() => {
if (selected().length === 0) return;
const moduleKey = props.module.route.replace(/^\/admin\//, '').replace(/^\/admin$/, 'dashboard');
void bulkModuleRecordAction(moduleKey, selected(), 'activate').then(() => {
setSelected([]);
void load();
});
}}
>
Activate
</ActionButton>
<ActionButton
onClick={() => {
if (selected().length === 0) return;
const moduleKey = props.module.route.replace(/^\/admin\//, '').replace(/^\/admin$/, 'dashboard');
void bulkModuleRecordAction(moduleKey, selected(), 'deactivate').then(() => {
setSelected([]);
void load();
});
}}
>
Deactivate
</ActionButton>
</>
}
/>
{error() ? <p class="rounded-lg border border-rose-200 bg-rose-50 px-3 py-2 text-sm text-rose-700">{error()}</p> : null}
{loading() ? (
<div class="rounded-xl border border-dashed border-[#dfe5ef] bg-[#fbfcff] p-8 text-center text-sm text-slate-500">Loading {props.module.title}...</div>
) : (
<DataTable
headers={['', 'ID', 'Name', 'Status', 'Updated At', 'Actions']}
rows={filtered().map((row) => [
<input
type="checkbox"
checked={selected().includes(row.id)}
onInput={(e) =>
setSelected((prev) =>
e.currentTarget.checked ? [...new Set([...prev, row.id])] : prev.filter((id) => id !== row.id),
)
}
/>,
<span class="font-medium text-[#050026]">{row.id}</span>,
<span>{row.name}</span>,
<StatusBadge label={row.status} tone={row.status === 'ACTIVE' ? 'positive' : 'warning'} />,
<span class="text-xs text-slate-500">{new Date(row.updatedAt).toLocaleString()}</span>,
<div class="flex gap-2">
<ActionButton
onClick={() => {
const moduleKey = props.module.route.replace(/^\/admin\//, '').replace(/^\/admin$/, 'dashboard');
const nextStatus = row.status === 'ACTIVE' ? 'INACTIVE' : 'ACTIVE';
void updateModuleRecord(moduleKey, row.id, { status: nextStatus }).then(() => void load());
}}
>
Toggle
</ActionButton>
<ActionButton
tone="ghost"
onClick={() => {
const moduleKey = props.module.route.replace(/^\/admin\//, '').replace(/^\/admin$/, 'dashboard');
void deleteModuleRecord(moduleKey, row.id).then(() => {
setSelected((prev) => prev.filter((id) => id !== row.id));
void load();
});
}}
>
Delete
</ActionButton>
</div>,
])}
/>
)}
<For each={filtered().length === 0 ? [1] : []}>{() => <p class="text-sm text-slate-500">No records matched your filter.</p>}</For>
</div>
</SectionCard>
<SectionCard title={`Create ${props.module.navLabel} Record`} subtitle="Create records using the same API-backed module contract.">
<form
class="grid gap-4 rounded-xl border border-[#e5eaf3] bg-[#f9fbff] p-4 md:grid-cols-2"
onSubmit={(e) => {
e.preventDefault();
if (!nameInput().trim()) return;
const moduleKey = props.module.route.replace(/^\/admin\//, '').replace(/^\/admin$/, 'dashboard');
void createModuleRecord(moduleKey, { name: nameInput().trim(), status: statusInput() }).then(() => {
setNameInput('');
setStatusInput('ACTIVE');
void load();
});
}}
>
<label class="text-sm font-medium text-slate-700">
Name
<input
value={nameInput()}
onInput={(e) => setNameInput(e.currentTarget.value)}
class="mt-1 block w-full rounded-lg border border-[#d8dfec] bg-white px-3 py-2 text-sm outline-none focus:border-[#fd6116]"
/>
</label>
<label class="text-sm font-medium text-slate-700">
Status
<select
value={statusInput()}
onInput={(e) => setStatusInput(e.currentTarget.value as 'ACTIVE' | 'INACTIVE')}
class="mt-1 block w-full rounded-lg border border-[#d8dfec] bg-white px-3 py-2 text-sm outline-none focus:border-[#fd6116]"
>
<option value="ACTIVE">ACTIVE</option>
<option value="INACTIVE">INACTIVE</option>
</select>
</label>
<div class="md:col-span-2 flex justify-end gap-2">
<ActionButton type="submit" tone="primary">Create Record</ActionButton>
</div>
</form>
</SectionCard>
</div>
);
}

47
src/lib/admin/api.ts Normal file
View file

@ -0,0 +1,47 @@
import { gatewayUrl, withAuthHeaders } from '~/lib/server/gateway';
export async function proxyOrFallback<T>(args: {
request: Request;
method: 'GET' | 'POST' | 'PATCH' | 'DELETE';
path: string;
body?: unknown;
fallback: () => Promise<T>;
fallbackStatus?: number;
}) {
const { request, method, path, body, fallback, fallbackStatus = 200 } = args;
const endpoint = gatewayUrl(path);
try {
const res = await fetch(endpoint, {
method,
headers: {
...withAuthHeaders(request, { Accept: 'application/json', 'Content-Type': 'application/json' }),
},
body: body ? JSON.stringify(body) : undefined,
cache: 'no-store',
});
const payload = await res.json().catch(() => null);
if (res.ok) {
return new Response(JSON.stringify(payload ?? { success: true, data: null }), {
status: res.status,
headers: { 'Content-Type': 'application/json' },
});
}
} catch {
// Intentionally fall back to local adapter when gateway is unavailable.
}
const data = await fallback();
return new Response(JSON.stringify({ success: true, source: 'fallback', data }), {
status: fallbackStatus,
headers: { 'Content-Type': 'application/json' },
});
}
export function jsonError(message: string, status = 400) {
return new Response(JSON.stringify({ success: false, error: message }), {
status,
headers: { 'Content-Type': 'application/json' },
});
}

110
src/lib/admin/client.ts Normal file
View file

@ -0,0 +1,110 @@
import type { ApprovalCase, VerificationCase } from './types';
import type { CrudRecord } from './types';
type ApiResponse<T> = { success: boolean; data: T; error?: string };
async function parse<T>(res: Response): Promise<T> {
const payload = (await res.json().catch(() => null)) as ApiResponse<T> | T | null;
if (!res.ok) {
const message = (payload as ApiResponse<T> | null)?.error || `Request failed (${res.status})`;
throw new Error(message);
}
if (payload && typeof payload === 'object' && 'success' in (payload as any)) {
return (payload as ApiResponse<T>).data;
}
return payload as T;
}
export async function listVerificationCases(query?: { q?: string; status?: string }) {
const qp = new URLSearchParams();
if (query?.q) qp.set('q', query.q);
if (query?.status) qp.set('status', query.status);
const res = await fetch(`/api/admin/verification-cases${qp.toString() ? `?${qp.toString()}` : ''}`);
return parse<VerificationCase[]>(res);
}
export async function bulkVerification(ids: string[], action: string) {
const res = await fetch('/api/admin/verification-cases/bulk', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ids, action }),
});
return parse<{ ok: boolean; count: number }>(res);
}
export async function updateVerification(id: string, patch: Partial<VerificationCase>) {
const res = await fetch(`/api/admin/verification-cases/${encodeURIComponent(id)}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(patch),
});
return parse<VerificationCase>(res);
}
export async function listApprovalCases(query?: { q?: string; status?: string }) {
const qp = new URLSearchParams();
if (query?.q) qp.set('q', query.q);
if (query?.status) qp.set('status', query.status);
const res = await fetch(`/api/admin/approval-cases${qp.toString() ? `?${qp.toString()}` : ''}`);
return parse<ApprovalCase[]>(res);
}
export async function bulkApproval(ids: string[], action: string) {
const res = await fetch('/api/admin/approval-cases/bulk', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ids, action }),
});
return parse<{ ok: boolean; count: number }>(res);
}
export async function updateApproval(id: string, patch: Partial<ApprovalCase>) {
const res = await fetch(`/api/admin/approval-cases/${encodeURIComponent(id)}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(patch),
});
return parse<ApprovalCase>(res);
}
export async function listModuleRecords(moduleKey: string, query?: { q?: string; status?: string }) {
const qp = new URLSearchParams();
if (query?.q) qp.set('q', query.q);
if (query?.status) qp.set('status', query.status);
const res = await fetch(`/api/admin/modules/${encodeURIComponent(moduleKey)}/records${qp.toString() ? `?${qp.toString()}` : ''}`);
return parse<CrudRecord[]>(res);
}
export async function createModuleRecord(moduleKey: string, payload: Partial<CrudRecord>) {
const res = await fetch(`/api/admin/modules/${encodeURIComponent(moduleKey)}/records`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
return parse<CrudRecord>(res);
}
export async function updateModuleRecord(moduleKey: string, id: string, patch: Partial<CrudRecord>) {
const res = await fetch(`/api/admin/modules/${encodeURIComponent(moduleKey)}/records/${encodeURIComponent(id)}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(patch),
});
return parse<CrudRecord>(res);
}
export async function deleteModuleRecord(moduleKey: string, id: string) {
const res = await fetch(`/api/admin/modules/${encodeURIComponent(moduleKey)}/records/${encodeURIComponent(id)}`, {
method: 'DELETE',
});
return parse<{ ok: boolean; id: string }>(res);
}
export async function bulkModuleRecordAction(moduleKey: string, ids: string[], action: string) {
const res = await fetch(`/api/admin/modules/${encodeURIComponent(moduleKey)}/records/bulk`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ids, action }),
});
return parse<{ ok: boolean; count: number }>(res);
}

242
src/lib/admin/data.ts Normal file
View file

@ -0,0 +1,242 @@
import type {
ApprovalCase,
ApprovalStatus,
CrudRecord,
CrudService,
ListQuery,
VerificationCase,
VerificationPriority,
VerificationStatus,
} from './types';
import { toApprovalEligibility } from './types';
const nowIso = () => new Date().toISOString();
const mockCrudRows: CrudRecord[] = Array.from({ length: 9 }, (_, idx) => ({
id: `ADM-${1000 + idx}`,
name: `Module Item ${idx + 1}`,
status: idx % 3 === 0 ? 'INACTIVE' : 'ACTIVE',
updatedAt: nowIso(),
}));
const verificationRows: VerificationCase[] = [
{ id: 'VER-2026-001', applicantName: 'Rajesh Kumar', userType: 'Professional', verificationType: 'Identity Verification', submittedAt: '2026-03-20', documents: 3, status: 'PENDING', priority: 'HIGH' },
{ id: 'VER-2026-002', applicantName: 'Priya Sharma', userType: 'Company', verificationType: 'Business Verification', submittedAt: '2026-03-19', documents: 5, status: 'IN_REVIEW', priority: 'MEDIUM' },
{ id: 'VER-2026-003', applicantName: 'Anil Patel', userType: 'Customer', verificationType: 'Identity Verification', submittedAt: '2026-03-19', documents: 2, status: 'VERIFIED', priority: 'LOW' },
{ id: 'VER-2026-004', applicantName: 'Tech Solutions Ltd', userType: 'Company', verificationType: 'Business Verification', submittedAt: '2026-03-18', documents: 6, status: 'FLAGGED', priority: 'CRITICAL' },
{ id: 'VER-2026-005', applicantName: 'Asha Menon', userType: 'Photographer', verificationType: 'Portfolio Verification', submittedAt: '2026-03-18', documents: 4, status: 'REJECTED', priority: 'MEDIUM' },
];
const seedApprovals = (): ApprovalCase[] =>
verificationRows
.map((item, idx) => {
const eligibility = toApprovalEligibility(item.status);
if (!eligibility) return null;
return {
id: `APP-2026-${String(idx + 1).padStart(3, '0')}`,
applicantName: item.applicantName,
approvalType: item.userType === 'Company' ? 'Business Approval' : 'Profile Approval',
userType: item.userType,
submittedAt: item.submittedAt,
verificationStatus: item.status === 'VERIFIED' || item.status === 'REJECTED' || item.status === 'FLAGGED' ? item.status : 'VERIFIED',
status: eligibility,
priority: item.priority,
};
})
.filter((x): x is ApprovalCase => Boolean(x));
let approvalRows = seedApprovals();
const filterByQuery = <T extends { id: string; status?: string; applicantName?: string; name?: string }>(rows: T[], query?: ListQuery) => {
if (!query) return rows;
const q = query.q?.trim().toLowerCase();
const status = query.status?.trim().toLowerCase();
return rows.filter((row) => {
const qPass =
!q ||
row.id.toLowerCase().includes(q) ||
(row.applicantName?.toLowerCase().includes(q) ?? false) ||
(row.name?.toLowerCase().includes(q) ?? false);
const statusPass = !status || row.status?.toLowerCase() === status;
return qPass && statusPass;
});
};
const createCrudService = (seed: CrudRecord[]): CrudService<CrudRecord> => {
let rows = [...seed];
return {
async list(query) {
return filterByQuery(rows, query);
},
async detail(id) {
return rows.find((r) => r.id === id) ?? null;
},
async create(payload) {
const item: CrudRecord = {
id: `ADM-${Date.now()}`,
name: payload.name || 'New Item',
status: payload.status === 'INACTIVE' ? 'INACTIVE' : 'ACTIVE',
updatedAt: nowIso(),
};
rows = [item, ...rows];
return item;
},
async update(id, payload) {
rows = rows.map((row) => (row.id === id ? { ...row, ...payload, updatedAt: nowIso() } : row));
return (rows.find((row) => row.id === id) as CrudRecord) ?? { id, name: payload.name || 'Updated Item', status: 'ACTIVE', updatedAt: nowIso() };
},
async delete(id) {
const len = rows.length;
rows = rows.filter((row) => row.id !== id);
return rows.length < len;
},
async bulkAction(ids, action) {
if (action === 'deactivate') rows = rows.map((row) => (ids.includes(row.id) ? { ...row, status: 'INACTIVE' } : row));
if (action === 'activate') rows = rows.map((row) => (ids.includes(row.id) ? { ...row, status: 'ACTIVE' } : row));
return { ok: true, count: ids.length };
},
};
};
export const verificationService: CrudService<VerificationCase> = {
async list(query) {
return filterByQuery(verificationRows, query);
},
async detail(id) {
return verificationRows.find((row) => row.id === id) ?? null;
},
async create(payload) {
const next: VerificationCase = {
id: `VER-${Date.now()}`,
applicantName: payload.applicantName || 'New Applicant',
userType: payload.userType || 'Professional',
verificationType: payload.verificationType || 'Identity Verification',
submittedAt: payload.submittedAt || nowIso().slice(0, 10),
documents: payload.documents || 1,
status: (payload.status as VerificationStatus) || 'PENDING',
priority: (payload.priority as VerificationPriority) || 'MEDIUM',
};
verificationRows.unshift(next);
return next;
},
async update(id, payload) {
const index = verificationRows.findIndex((row) => row.id === id);
if (index === -1) return this.create(payload);
verificationRows[index] = { ...verificationRows[index], ...payload };
const updated = verificationRows[index];
const eligible = toApprovalEligibility(updated.status);
if (eligible) {
const approval = approvalRows.find((row) => row.applicantName === updated.applicantName && row.submittedAt === updated.submittedAt);
if (approval) {
approval.status = eligible;
approval.priority = updated.priority;
approval.verificationStatus = updated.status === 'VERIFIED' || updated.status === 'REJECTED' || updated.status === 'FLAGGED' ? updated.status : 'VERIFIED';
} else {
approvalRows.unshift({
id: `APP-${Date.now()}`,
applicantName: updated.applicantName,
approvalType: updated.userType === 'Company' ? 'Business Approval' : 'Profile Approval',
userType: updated.userType,
submittedAt: updated.submittedAt,
verificationStatus: updated.status === 'VERIFIED' || updated.status === 'REJECTED' || updated.status === 'FLAGGED' ? updated.status : 'VERIFIED',
status: eligible,
priority: updated.priority,
});
}
}
return updated;
},
async delete(id) {
const index = verificationRows.findIndex((row) => row.id === id);
if (index === -1) return false;
verificationRows.splice(index, 1);
return true;
},
async bulkAction(ids, action) {
for (const id of ids) {
const target = verificationRows.find((row) => row.id === id);
if (!target) continue;
if (action === 'mark_in_review') target.status = 'IN_REVIEW';
if (action === 'approve_for_approval') target.status = 'VERIFIED';
if (action === 'flag') target.status = 'FLAGGED';
if (action === 'reject') target.status = 'REJECTED';
}
return { ok: true, count: ids.length };
},
};
export const approvalService: CrudService<ApprovalCase> = {
async list(query) {
return filterByQuery(approvalRows, query);
},
async detail(id) {
return approvalRows.find((row) => row.id === id) ?? null;
},
async create(payload) {
const next: ApprovalCase = {
id: `APP-${Date.now()}`,
applicantName: payload.applicantName || 'New Applicant',
approvalType: payload.approvalType || 'Profile Approval',
userType: payload.userType || 'Professional',
submittedAt: payload.submittedAt || nowIso().slice(0, 10),
verificationStatus: (payload.verificationStatus as ApprovalCase['verificationStatus']) || 'VERIFIED',
status: (payload.status as ApprovalStatus) || 'PENDING_APPROVAL',
priority: (payload.priority as VerificationPriority) || 'MEDIUM',
};
approvalRows.unshift(next);
return next;
},
async update(id, payload) {
const index = approvalRows.findIndex((row) => row.id === id);
if (index === -1) return this.create(payload);
approvalRows[index] = { ...approvalRows[index], ...payload };
return approvalRows[index];
},
async delete(id) {
const len = approvalRows.length;
approvalRows = approvalRows.filter((row) => row.id !== id);
return approvalRows.length < len;
},
async bulkAction(ids, action) {
for (const id of ids) {
const row = approvalRows.find((item) => item.id === id);
if (!row) continue;
if (action === 'approve') row.status = 'APPROVED';
if (action === 'reject') row.status = 'REJECTED';
if (action === 'hold') row.status = 'ON_HOLD';
if (action === 'escalate') row.status = 'ESCALATED';
}
return { ok: true, count: ids.length };
},
};
export const genericCrudService = createCrudService(mockCrudRows);
const moduleCrudServices = new Map<string, CrudService<CrudRecord>>();
function seedRowsForModule(moduleKey: string): CrudRecord[] {
const base = moduleKey
.replace(/[^a-z0-9]+/gi, '_')
.replace(/^_+|_+$/g, '')
.toUpperCase()
.slice(0, 8) || 'MODULE';
return Array.from({ length: 9 }, (_, idx) => ({
id: `${base}-${String(idx + 1).padStart(3, '0')}`,
name: `${moduleKey.replace(/[-_]/g, ' ')} item ${idx + 1}`,
status: idx % 3 === 0 ? 'INACTIVE' : 'ACTIVE',
updatedAt: nowIso(),
}));
}
export function getModuleCrudService(moduleKey: string): CrudService<CrudRecord> {
const key = moduleKey.trim().toLowerCase();
const found = moduleCrudServices.get(key);
if (found) return found;
const created = createCrudService(seedRowsForModule(key));
moduleCrudServices.set(key, created);
return created;
}

View file

@ -0,0 +1,51 @@
import type { AdminModuleConfig } from './types';
export const adminModules: AdminModuleConfig[] = [
{ route: '/admin', title: 'Dashboard', navLabel: 'Dashboard', category: 'core', status: 'live', description: 'Admin overview with KPI cards, trends, and recent activity.' },
{ route: '/admin/department', title: 'Department Management', navLabel: 'Department Management', category: 'org', status: 'live', description: 'Create and maintain organization departments.' },
{ route: '/admin/designation', title: 'Designation Management', navLabel: 'Designation Management', category: 'org', status: 'live', description: 'Manage role designations and hierarchy labels.' },
{ route: '/admin/internal-role-management', title: 'Internal Role Management', navLabel: 'Internal Role Management', category: 'access', status: 'live', description: 'Configure internal role access and permissions.' },
{ route: '/admin/employees', title: 'Employee Management', navLabel: 'Employee Management', category: 'access', status: 'live', description: 'Manage internal employee accounts and assignments.' },
{ route: '/admin/external-role-management', title: 'External Role Management', navLabel: 'External Role Management', category: 'access', status: 'live', description: 'Manage runtime roles for external audiences.' },
{ route: '/admin/external-onboarding', title: 'External Onboarding Management', navLabel: 'External Onboarding Management', category: 'access', status: 'live', description: 'Configure onboarding templates and rules.' },
{ route: '/admin/internal-dashboard', title: 'Internal Dashboard Management', navLabel: 'Internal Dashboard Management', category: 'access', status: 'live', description: 'Manage internal dashboard runtime modules.' },
{ route: '/admin/external-dashboard', title: 'External Dashboard Management', navLabel: 'External Dashboard Management', category: 'access', status: 'live', description: 'Manage external dashboard runtime modules.' },
{ route: '/admin/verification-management', title: 'Verification Management', navLabel: 'Verification Management', category: 'governance', status: 'live', description: 'Review and verify user submissions before approval.' },
{ route: '/admin/approval-management', title: 'Approval Management', navLabel: 'Approval Management', category: 'governance', status: 'live', description: 'Final decisioning for verified submissions.' },
{ route: '/admin/users', title: 'Users Management', navLabel: 'Users Management', category: 'entities', status: 'live', description: 'Manage platform users.' },
{ route: '/admin/company', title: 'Company Management', navLabel: 'Company Management', category: 'entities', status: 'live', description: 'Manage registered companies.' },
{ route: '/admin/candidate', title: 'Candidate Management', navLabel: 'Candidate Management', category: 'entities', status: 'live', description: 'Manage candidate profiles and status.' },
{ route: '/admin/customer', title: 'Customer Management', navLabel: 'Customer Management', category: 'entities', status: 'live', description: 'Manage customer accounts and support states.' },
{ route: '/admin/photographer', title: 'Photographer Management', navLabel: 'Photographer Management', category: 'entities', status: 'live', description: 'Manage photographer vertical records.' },
{ route: '/admin/makeup-artist', title: 'Makeup Artist Management', navLabel: 'Makeup Artist Management', category: 'entities', status: 'live', description: 'Manage makeup artist vertical records.' },
{ route: '/admin/tutors', title: 'Tutors Management', navLabel: 'Tutors Management', category: 'entities', status: 'live', description: 'Manage tutor vertical records.' },
{ route: '/admin/developers', title: 'Developers Management', navLabel: 'Developers Management', category: 'entities', status: 'live', description: 'Manage developer vertical records.' },
{ route: '/admin/video-editor', title: 'Video Editor Management', navLabel: 'Video Editor Management', category: 'entities', status: 'live', description: 'Manage video editor vertical records.' },
{ route: '/admin/fitness-trainer', title: 'Fitness Trainer Management', navLabel: 'Fitness Trainer Management', category: 'entities', status: 'live', description: 'Manage fitness trainer vertical records.' },
{ route: '/admin/catering-services', title: 'Catering Services Management', navLabel: 'Catering Services Management', category: 'entities', status: 'live', description: 'Manage catering provider vertical records.' },
{ route: '/admin/graphic-designer', title: 'Graphics Designer Management', navLabel: 'Graphics Designer Management', category: 'entities', status: 'live', description: 'Manage graphic designer vertical records.' },
{ route: '/admin/social-media-manager', title: 'Social Media Manager Management', navLabel: 'Social Media Manager Management', category: 'entities', status: 'live', description: 'Manage social media manager vertical records.' },
{ route: '/admin/jobs', title: 'Jobs Management', navLabel: 'Jobs Management', category: 'entities', status: 'live', description: 'Manage job lifecycle and moderation states.' },
{ route: '/admin/leads', title: 'Leads Management', navLabel: 'Leads Management', category: 'entities', status: 'live', description: 'Manage leads pipeline and assignment.' },
{ route: '/admin/pricing', title: 'Pricing Management', navLabel: 'Pricing Management', category: 'commerce', status: 'live', description: 'Manage pricing rules and plans.' },
{ route: '/admin/credit', title: 'Credit Management', navLabel: 'Credit Management', category: 'commerce', status: 'live', description: 'Manage credit grants and usage.' },
{ route: '/admin/coupon', title: 'Coupon Management', navLabel: 'Coupon Management', category: 'commerce', status: 'live', description: 'Manage coupon campaigns.' },
{ route: '/admin/discount', title: 'Discount Management', navLabel: 'Discount Management', category: 'commerce', status: 'live', description: 'Manage discount policies.' },
{ route: '/admin/tax', title: 'Tax Management', navLabel: 'Tax Management', category: 'commerce', status: 'live', description: 'Manage tax configuration.' },
{ route: '/admin/order', title: 'Order Management', navLabel: 'Order Management', category: 'commerce', status: 'live', description: 'Manage order operations.' },
{ route: '/admin/invoice', title: 'Invoice Management', navLabel: 'Invoice Management', category: 'commerce', status: 'live', description: 'Manage invoice records.' },
{ route: '/admin/review', title: 'Review Management', navLabel: 'Review Management', category: 'commerce', status: 'live', description: 'Manage review moderation.' },
{ route: '/admin/support', title: 'Support Management', navLabel: 'Support Management', category: 'commerce', status: 'live', description: 'Manage support tickets and SLAs.' },
{ route: '/admin/report', title: 'Report Management', navLabel: 'Report Management', category: 'commerce', status: 'live', description: 'Manage reporting exports and insights.' },
{ route: '/admin/ledger', title: 'Ledger Management', navLabel: 'Ledger Management', category: 'commerce', status: 'live', description: 'Manage financial ledger events.' },
];
export const getAdminModuleByPath = (pathname: string) => {
const aliasMap: Record<string, string> = {
'/admin/approval': '/admin/approval-management',
'/admin/verification': '/admin/verification-management',
};
const normalizedPath = aliasMap[pathname] ?? pathname;
if (normalizedPath === '/admin') return adminModules.find((m) => m.route === '/admin') ?? null;
return adminModules.find((m) => normalizedPath === m.route) ?? null;
};

View file

@ -0,0 +1,21 @@
import { describe, expect, it } from 'vitest';
import { toApprovalEligibility } from './types';
describe('toApprovalEligibility', () => {
it('maps VERIFIED to PENDING_APPROVAL', () => {
expect(toApprovalEligibility('VERIFIED')).toBe('PENDING_APPROVAL');
});
it('maps REJECTED to REJECTED', () => {
expect(toApprovalEligibility('REJECTED')).toBe('REJECTED');
});
it('maps FLAGGED to ESCALATED', () => {
expect(toApprovalEligibility('FLAGGED')).toBe('ESCALATED');
});
it('returns null for non-final verification states', () => {
expect(toApprovalEligibility('PENDING')).toBeNull();
expect(toApprovalEligibility('IN_REVIEW')).toBeNull();
});
});

67
src/lib/admin/types.ts Normal file
View file

@ -0,0 +1,67 @@
export type AdminModuleStatus = 'live' | 'mock' | 'pending';
export type VerificationStatus = 'PENDING' | 'IN_REVIEW' | 'VERIFIED' | 'REJECTED' | 'FLAGGED';
export type ApprovalStatus = 'PENDING_APPROVAL' | 'IN_REVIEW' | 'APPROVED' | 'REJECTED' | 'ON_HOLD' | 'ESCALATED';
export type VerificationPriority = 'LOW' | 'MEDIUM' | 'HIGH' | 'CRITICAL';
export type VerificationCase = {
id: string;
applicantName: string;
userType: string;
verificationType: string;
submittedAt: string;
documents: number;
status: VerificationStatus;
priority: VerificationPriority;
};
export type ApprovalCase = {
id: string;
applicantName: string;
approvalType: string;
userType: string;
submittedAt: string;
verificationStatus: Extract<VerificationStatus, 'VERIFIED' | 'REJECTED' | 'FLAGGED'>;
status: ApprovalStatus;
priority: VerificationPriority;
};
export type CrudRecord = {
id: string;
name: string;
status: 'ACTIVE' | 'INACTIVE';
updatedAt: string;
};
export type AdminModuleConfig = {
route: string;
title: string;
navLabel: string;
category: 'core' | 'org' | 'access' | 'governance' | 'entities' | 'commerce';
status: AdminModuleStatus;
description: string;
};
export type ListQuery = {
q?: string;
status?: string;
};
export interface CrudService<T> {
list(query?: ListQuery): Promise<T[]>;
detail(id: string): Promise<T | null>;
create(payload: Partial<T>): Promise<T>;
update(id: string, payload: Partial<T>): Promise<T>;
delete(id: string): Promise<boolean>;
bulkAction(ids: string[], action: string): Promise<{ ok: boolean; count: number }>;
}
export const toApprovalEligibility = (
status: VerificationStatus,
): Extract<ApprovalStatus, 'PENDING_APPROVAL' | 'REJECTED' | 'ESCALATED'> | null => {
if (status === 'VERIFIED') return 'PENDING_APPROVAL';
if (status === 'REJECTED') return 'REJECTED';
if (status === 'FLAGGED') return 'ESCALATED';
return null;
};

16
src/routes/admin.tsx Normal file
View file

@ -0,0 +1,16 @@
import { Show, createSignal, onMount, type JSX } from 'solid-js';
import AdminShell from '~/components/admin/AdminShell';
export default function AdminLayout(props: { children: JSX.Element }) {
const [mounted, setMounted] = createSignal(false);
onMount(() => setMounted(true));
return (
<Show
when={mounted()}
fallback={<div class="min-h-screen bg-[#f4f6fb]" />}
>
<AdminShell>{props.children}</AdminShell>
</Show>
);
}

View file

@ -0,0 +1,28 @@
import { useLocation } from '@solidjs/router';
import GenericAdminModulePage from '~/components/admin/GenericAdminModulePage';
import { ActionButton, PageHeader, SectionCard } from '~/components/admin/AdminUi';
import { getAdminModuleByPath } from '~/lib/admin/module-config';
export default function AdminFallbackRoutePage() {
const location = useLocation();
const module = () => getAdminModuleByPath(location.pathname);
if (module()) {
return <GenericAdminModulePage module={module()!} />;
}
return (
<div class="space-y-5">
<PageHeader
title="Unknown Admin Route"
subtitle={`No module is registered for ${location.pathname}. Add it to admin module config to activate.`}
actions={<ActionButton tone="primary">Go To Dashboard</ActionButton>}
/>
<SectionCard title="Registry Required" subtitle="This route is outside the declared admin module contract.">
<p class="text-sm text-slate-600">
The current path is not part of the configured admin modules. Register it in the module config and map a page component.
</p>
</SectionCard>
</div>
);
}

View file

@ -0,0 +1,143 @@
import { createMemo, createSignal, onMount } from 'solid-js';
import { ActionButton, DataTable, MetricCards, PageHeader, SearchFilters, SectionCard, StatusBadge, Tabs } from '~/components/admin/AdminUi';
import { bulkApproval, listApprovalCases } from '~/lib/admin/client';
import type { ApprovalCase } from '~/lib/admin/types';
const toneByStatus: Record<ApprovalCase['status'], 'neutral' | 'warning' | 'positive' | 'critical' | 'info'> = {
PENDING_APPROVAL: 'warning',
IN_REVIEW: 'info',
APPROVED: 'positive',
REJECTED: 'critical',
ON_HOLD: 'warning',
ESCALATED: 'critical',
};
export default function ApprovalManagementPage() {
const [tab, setTab] = createSignal<'queue' | 'rules' | 'preview'>('queue');
const [query, setQuery] = createSignal('');
const [rows, setRows] = createSignal<ApprovalCase[]>([]);
const [selected, setSelected] = createSignal<string[]>([]);
const [error, setError] = createSignal('');
const load = async () => {
try {
setError('');
setRows(await listApprovalCases({ q: query() }));
} catch (err: any) {
setError(String(err?.message || 'Failed to load approval cases.'));
}
};
onMount(() => void load());
const metrics = createMemo(() => {
const data = rows();
return [
{ label: 'Total Pending', value: String(data.filter((d) => d.status === 'PENDING_APPROVAL').length || 0) },
{ label: 'Approved Today', value: String(data.filter((d) => d.status === 'APPROVED').length || 0), tone: 'positive' as const },
{ label: 'Rejected Today', value: String(data.filter((d) => d.status === 'REJECTED').length || 0), tone: 'critical' as const },
{ label: 'On Hold Cases', value: String(data.filter((d) => d.status === 'ON_HOLD').length || 0), tone: 'warning' as const },
{ label: 'Escalated Cases', value: String(data.filter((d) => d.status === 'ESCALATED').length || 0), tone: 'critical' as const },
];
});
const filtered = createMemo(() => {
const q = query().trim().toLowerCase();
if (!q) return rows();
return rows().filter((r) => r.id.toLowerCase().includes(q) || r.applicantName.toLowerCase().includes(q));
});
const toggle = (id: string, checked: boolean) => setSelected((prev) => (checked ? [...new Set([...prev, id])] : prev.filter((x) => x !== id)));
const runBulk = async (action: string) => {
if (selected().length === 0) return;
try {
setError('');
await bulkApproval(selected(), action);
setSelected([]);
await load();
} catch (err: any) {
setError(String(err?.message || 'Bulk action failed.'));
}
};
return (
<div class="space-y-5">
<PageHeader
title="Approval Management"
subtitle="Review and approve verified submissions with explicit final decision states."
actions={<Tabs value={tab()} onChange={setTab} items={[{ key: 'queue', label: 'Approval Queue' }, { key: 'rules', label: 'Approval Rules' }, { key: 'preview', label: 'User Preview' }]} />}
/>
<MetricCards items={metrics()} />
{error() ? <p class="rounded-lg border border-rose-200 bg-rose-50 px-3 py-2 text-sm text-rose-700">{error()}</p> : null}
{tab() === 'queue' ? (
<SectionCard
title="Approval Cases"
subtitle="Final governance queue fed from verification outcomes."
actions={
<>
<ActionButton>Export Queue</ActionButton>
<ActionButton onClick={() => void runBulk('hold')}>Put On Hold</ActionButton>
<ActionButton tone="primary" onClick={() => void runBulk('approve')}>Approve Selected</ActionButton>
</>
}
>
<div class="space-y-3">
<SearchFilters
query={query()}
onQuery={(v) => {
setQuery(v);
void load();
}}
right={
<>
<ActionButton onClick={() => void runBulk('reject')}>Reject</ActionButton>
<ActionButton onClick={() => void runBulk('escalate')}>Escalate</ActionButton>
</>
}
/>
<DataTable
headers={['', 'Approval ID', 'Applicant Name', 'Approval Type', 'User Type', 'Submitted Date', 'Verification', 'Approval Status', 'Priority', 'Actions']}
rows={filtered().map((row) => [
<input type="checkbox" checked={selected().includes(row.id)} onInput={(e) => toggle(row.id, e.currentTarget.checked)} />,
<span class="font-medium text-[#050026]">{row.id}</span>,
<span>{row.applicantName}</span>,
<span>{row.approvalType}</span>,
<span>{row.userType}</span>,
<span class="text-xs text-slate-500">{row.submittedAt}</span>,
<StatusBadge label={row.verificationStatus} tone={row.verificationStatus === 'VERIFIED' ? 'positive' : 'critical'} />,
<StatusBadge label={row.status} tone={toneByStatus[row.status]} />,
<StatusBadge label={row.priority} tone={row.priority === 'LOW' ? 'neutral' : row.priority === 'MEDIUM' ? 'warning' : 'critical'} />,
<ActionButton tone="ghost">Open</ActionButton>,
])}
/>
</div>
</SectionCard>
) : tab() === 'rules' ? (
<SectionCard title="Approval Rules" subtitle="Configure thresholds for auto-approval, manual review, and escalation.">
<DataTable
headers={['Rule', 'Scope', 'Policy', 'Owner', 'Actions']}
rows={[
['Verified profiles with low-risk score can auto-approve', 'Profile', 'AUTO_APPROVE', 'Governance', <ActionButton>Edit</ActionButton>],
['Critical flagged verifications require escalation', 'All', 'ESCALATE', 'Compliance', <ActionButton>Edit</ActionButton>],
['Rejected verification cannot move to approved state', 'All', 'HARD_BLOCK', 'Platform', <ActionButton>Edit</ActionButton>],
]}
/>
</SectionCard>
) : (
<SectionCard title="User Preview" subtitle="Preview user-facing approval outcomes and guidance.">
<div class="rounded-xl border border-[#e4e9f2] bg-[#f8fbff] p-4">
<h3 class="text-base font-semibold text-[#050026]">Approval Status Timeline</h3>
<ol class="mt-3 space-y-2 text-sm text-slate-600">
<li>1. Case enters queue after verification outcome.</li>
<li>2. Approval team decides approve, reject, hold, or escalate.</li>
<li>3. Approved users receive access destination and activation.</li>
<li>4. Rejected/escalated users receive remediation guidance.</li>
</ol>
</div>
</SectionCard>
)}
</div>
);
}

View file

@ -0,0 +1 @@
export { default } from './approval-management';

View file

@ -0,0 +1,123 @@
import { createMemo, createSignal, onMount } from 'solid-js';
import { ActionButton, DataTable, MetricCards, PageHeader, SearchFilters, SectionCard, StatusBadge, Tabs } from '~/components/admin/AdminUi';
import { createModuleRecord, listModuleRecords, updateModuleRecord } from '~/lib/admin/client';
import type { CrudRecord } from '~/lib/admin/types';
export default function DepartmentManagementPage() {
const [tab, setTab] = createSignal<'view' | 'create'>('view');
const [query, setQuery] = createSignal('');
const [rows, setRows] = createSignal<CrudRecord[]>([]);
const [nameInput, setNameInput] = createSignal('');
const [codeInput, setCodeInput] = createSignal('');
const load = async () => setRows(await listModuleRecords('department', { q: query() }));
onMount(() => void load());
const filtered = createMemo(() => {
const q = query().trim().toLowerCase();
if (!q) return rows();
return rows().filter((row) => row.id.toLowerCase().includes(q) || row.name.toLowerCase().includes(q));
});
const metrics = createMemo(() => {
const all = rows();
const active = all.filter((item) => item.status === 'ACTIVE').length;
const inactive = all.filter((item) => item.status === 'INACTIVE').length;
return [
{ label: 'Total Departments', value: String(all.length || 0) },
{ label: 'Active Departments', value: String(active), tone: 'positive' as const },
{ label: 'Inactive Departments', value: String(inactive), tone: 'warning' as const },
{ label: 'Updated Today', value: String(Math.min(active, 6)), tone: 'info' as const },
];
});
return (
<div class="space-y-5">
<PageHeader
title="Department Management"
subtitle="Manage operational department structure and ownership mappings."
actions={<Tabs value={tab()} onChange={setTab} items={[{ key: 'view', label: 'View Departments' }, { key: 'create', label: 'Create Department' }]} />}
/>
<MetricCards items={metrics()} />
{tab() === 'view' ? (
<SectionCard
title="Departments"
subtitle="Search, activate, and maintain department records."
actions={
<>
<ActionButton>Export</ActionButton>
<ActionButton tone="primary" onClick={() => setTab('create')}>Add Department</ActionButton>
</>
}
>
<div class="space-y-3">
<SearchFilters
query={query()}
onQuery={(value) => {
setQuery(value);
void load();
}}
right={<ActionButton>Filter</ActionButton>}
/>
<DataTable
headers={['Department ID', 'Department Name', 'Status', 'Updated', 'Actions']}
rows={filtered().map((row) => [
<span class="font-medium text-[#050026]">{row.id}</span>,
<span>{row.name}</span>,
<StatusBadge label={row.status} tone={row.status === 'ACTIVE' ? 'positive' : 'warning'} />,
<span class="text-xs text-slate-500">{new Date(row.updatedAt).toLocaleString()}</span>,
<div class="flex gap-2">
<ActionButton
onClick={() => {
const next = row.status === 'ACTIVE' ? 'INACTIVE' : 'ACTIVE';
void updateModuleRecord('department', row.id, { status: next }).then(() => void load());
}}
>
Toggle
</ActionButton>
<ActionButton tone="ghost">View</ActionButton>
</div>,
])}
/>
</div>
</SectionCard>
) : (
<SectionCard title="Create Department" subtitle="Create a new department with active runtime status.">
<form
class="grid gap-4 rounded-xl border border-[#e5eaf3] bg-[#f9fbff] p-4 md:grid-cols-2"
onSubmit={(e) => {
e.preventDefault();
const name = nameInput().trim() || codeInput().trim() || 'New Department';
void createModuleRecord('department', {
id: codeInput().trim() || undefined,
name,
status: 'ACTIVE',
}).then(() => {
setNameInput('');
setCodeInput('');
setTab('view');
void load();
});
}}
>
<label class="text-sm font-medium text-slate-700">
Department Name
<input value={nameInput()} onInput={(e) => setNameInput(e.currentTarget.value)} class="mt-1 block w-full rounded-lg border border-[#d8dfec] bg-white px-3 py-2 text-sm outline-none focus:border-[#fd6116]" />
</label>
<label class="text-sm font-medium text-slate-700">
Department Code
<input value={codeInput()} onInput={(e) => setCodeInput(e.currentTarget.value.toUpperCase())} class="mt-1 block w-full rounded-lg border border-[#d8dfec] bg-white px-3 py-2 text-sm outline-none focus:border-[#fd6116]" />
</label>
<div class="md:col-span-2 flex justify-end gap-2">
<ActionButton onClick={() => setTab('view')}>Cancel</ActionButton>
<ActionButton type="submit" tone="primary">Save Department</ActionButton>
</div>
</form>
</SectionCard>
)}
</div>
);
}

View file

@ -0,0 +1,123 @@
import { createMemo, createSignal, onMount } from 'solid-js';
import { ActionButton, DataTable, MetricCards, PageHeader, SearchFilters, SectionCard, StatusBadge, Tabs } from '~/components/admin/AdminUi';
import { createModuleRecord, listModuleRecords, updateModuleRecord } from '~/lib/admin/client';
import type { CrudRecord } from '~/lib/admin/types';
export default function DesignationManagementPage() {
const [tab, setTab] = createSignal<'view' | 'create'>('view');
const [query, setQuery] = createSignal('');
const [rows, setRows] = createSignal<CrudRecord[]>([]);
const [nameInput, setNameInput] = createSignal('');
const [codeInput, setCodeInput] = createSignal('');
const load = async () => setRows(await listModuleRecords('designation', { q: query() }));
onMount(() => void load());
const filtered = createMemo(() => {
const q = query().trim().toLowerCase();
if (!q) return rows();
return rows().filter((row) => row.id.toLowerCase().includes(q) || row.name.toLowerCase().includes(q));
});
const metrics = createMemo(() => {
const all = rows();
const active = all.filter((item) => item.status === 'ACTIVE').length;
const inactive = all.filter((item) => item.status === 'INACTIVE').length;
return [
{ label: 'Total Designations', value: String(all.length || 0) },
{ label: 'Active Designations', value: String(active), tone: 'positive' as const },
{ label: 'Inactive Designations', value: String(inactive), tone: 'warning' as const },
{ label: 'Updated Today', value: String(Math.min(active, 8)), tone: 'info' as const },
];
});
return (
<div class="space-y-5">
<PageHeader
title="Designation Management"
subtitle="Manage designation taxonomy used by internal and external role systems."
actions={<Tabs value={tab()} onChange={setTab} items={[{ key: 'view', label: 'View Designations' }, { key: 'create', label: 'Create Designation' }]} />}
/>
<MetricCards items={metrics()} />
{tab() === 'view' ? (
<SectionCard
title="Designations"
subtitle="Search and update designation availability."
actions={
<>
<ActionButton>Export</ActionButton>
<ActionButton tone="primary" onClick={() => setTab('create')}>Add Designation</ActionButton>
</>
}
>
<div class="space-y-3">
<SearchFilters
query={query()}
onQuery={(value) => {
setQuery(value);
void load();
}}
right={<ActionButton>Filter</ActionButton>}
/>
<DataTable
headers={['Designation ID', 'Designation Name', 'Status', 'Updated', 'Actions']}
rows={filtered().map((row) => [
<span class="font-medium text-[#050026]">{row.id}</span>,
<span>{row.name}</span>,
<StatusBadge label={row.status} tone={row.status === 'ACTIVE' ? 'positive' : 'warning'} />,
<span class="text-xs text-slate-500">{new Date(row.updatedAt).toLocaleString()}</span>,
<div class="flex gap-2">
<ActionButton
onClick={() => {
const next = row.status === 'ACTIVE' ? 'INACTIVE' : 'ACTIVE';
void updateModuleRecord('designation', row.id, { status: next }).then(() => void load());
}}
>
Toggle
</ActionButton>
<ActionButton tone="ghost">View</ActionButton>
</div>,
])}
/>
</div>
</SectionCard>
) : (
<SectionCard title="Create Designation" subtitle="Add a new designation used in role and employee mapping.">
<form
class="grid gap-4 rounded-xl border border-[#e5eaf3] bg-[#f9fbff] p-4 md:grid-cols-2"
onSubmit={(e) => {
e.preventDefault();
const name = nameInput().trim() || codeInput().trim() || 'New Designation';
void createModuleRecord('designation', {
id: codeInput().trim() || undefined,
name,
status: 'ACTIVE',
}).then(() => {
setNameInput('');
setCodeInput('');
setTab('view');
void load();
});
}}
>
<label class="text-sm font-medium text-slate-700">
Designation Name
<input value={nameInput()} onInput={(e) => setNameInput(e.currentTarget.value)} class="mt-1 block w-full rounded-lg border border-[#d8dfec] bg-white px-3 py-2 text-sm outline-none focus:border-[#fd6116]" />
</label>
<label class="text-sm font-medium text-slate-700">
Designation Code
<input value={codeInput()} onInput={(e) => setCodeInput(e.currentTarget.value.toUpperCase())} class="mt-1 block w-full rounded-lg border border-[#d8dfec] bg-white px-3 py-2 text-sm outline-none focus:border-[#fd6116]" />
</label>
<div class="md:col-span-2 flex justify-end gap-2">
<ActionButton onClick={() => setTab('view')}>Cancel</ActionButton>
<ActionButton type="submit" tone="primary">Save Designation</ActionButton>
</div>
</form>
</SectionCard>
)}
</div>
);
}

View file

@ -0,0 +1,115 @@
import { createSignal, onMount } from 'solid-js';
import { ActionButton, DataTable, PageHeader, SearchFilters, SectionCard, StatusBadge, Tabs } from '~/components/admin/AdminUi';
import { createModuleRecord, listModuleRecords, updateModuleRecord } from '~/lib/admin/client';
import type { CrudRecord } from '~/lib/admin/types';
const seedRoles: CrudRecord[] = [
{ id: 'COMPANY', name: 'Company', status: 'ACTIVE', updatedAt: new Date().toISOString() },
{ id: 'JOB_SEEKER', name: 'Job Seeker', status: 'ACTIVE', updatedAt: new Date().toISOString() },
{ id: 'PHOTOGRAPHER', name: 'Photographer', status: 'ACTIVE', updatedAt: new Date().toISOString() },
{ id: 'CUSTOMER', name: 'Customer', status: 'INACTIVE', updatedAt: new Date().toISOString() },
];
export default function ExternalRoleManagementPage() {
const [tab, setTab] = createSignal<'view' | 'create' | 'inspector'>('view');
const [query, setQuery] = createSignal('');
const [roles, setRoles] = createSignal<CrudRecord[]>(seedRoles);
const [roleKey, setRoleKey] = createSignal('');
const [displayName, setDisplayName] = createSignal('');
const [vertical, setVertical] = createSignal('');
const [schema, setSchema] = createSignal('');
const load = async () => setRoles(await listModuleRecords('external-role-management', { q: query() }));
onMount(() => void load());
return (
<div class="space-y-5">
<PageHeader
title="External Role Management"
subtitle="Manage canonical external runtime roles, modules, and onboarding schema assignment."
actions={<Tabs value={tab()} onChange={setTab} items={[{ key: 'view', label: 'View Roles' }, { key: 'create', label: 'Create Role' }, { key: 'inspector', label: 'Inspector' }]} />}
/>
<SectionCard
title={tab() === 'create' ? 'Create External Role' : tab() === 'inspector' ? 'Role UI Inspector' : 'Published External Roles'}
subtitle={tab() === 'view' ? 'Only canonical external runtime roles are shown on this surface.' : 'Manage external role shape and downstream UI policies.'}
actions={
<>
<ActionButton>Export</ActionButton>
<ActionButton tone="primary">Create External Role</ActionButton>
</>
}
>
{tab() === 'view' ? (
<div class="space-y-3">
<SearchFilters
query={query()}
onQuery={(value) => {
setQuery(value);
void load();
}}
right={<ActionButton>Filter</ActionButton>}
/>
<DataTable
headers={['Role', 'Type', 'Modules', 'Schema', 'Status', 'Actions']}
rows={roles()
.filter((r) => {
const q = query().trim().toLowerCase();
if (!q) return true;
return r.name.toLowerCase().includes(q) || r.id.toLowerCase().includes(q);
})
.map((role) => [
<div>
<p class="font-medium text-[#050026]">{role.name}</p>
<p class="text-xs text-slate-500">{role.id}</p>
</div>,
<span>{vertical() || 'EXTERNAL'}</span>,
<span>{Math.max(4, (role.name.length % 8) + 3)}</span>,
<span>{schema() || 'default-v1'}</span>,
<StatusBadge label={role.status} tone={role.status === 'ACTIVE' ? 'positive' : 'warning'} />,
<div class="flex gap-2">
<ActionButton tone="ghost">View</ActionButton>
<ActionButton
onClick={() => {
const next = role.status === 'ACTIVE' ? 'INACTIVE' : 'ACTIVE';
void updateModuleRecord('external-role-management', role.id, { status: next }).then(() => void load());
}}
>
Toggle
</ActionButton>
</div>,
])}
/>
</div>
) : tab() === 'create' ? (
<form
class="grid gap-4 rounded-xl border border-[#e5eaf3] bg-[#f9fbff] p-4 md:grid-cols-2"
onSubmit={(e) => {
e.preventDefault();
const key = roleKey().trim() || displayName().trim() || 'NEW_ROLE';
const name = displayName().trim() || key;
void createModuleRecord('external-role-management', { id: key, name, status: 'ACTIVE' }).then(() => {
setRoleKey('');
setDisplayName('');
setVertical('');
setSchema('');
setTab('view');
void load();
});
}}
>
<label class="text-sm font-medium text-slate-700">Role Key<input value={roleKey()} onInput={(e) => setRoleKey(e.currentTarget.value)} class="mt-1 block w-full rounded-lg border border-[#d8dfec] bg-white px-3 py-2 text-sm outline-none focus:border-[#fd6116]" /></label>
<label class="text-sm font-medium text-slate-700">Display Name<input value={displayName()} onInput={(e) => setDisplayName(e.currentTarget.value)} class="mt-1 block w-full rounded-lg border border-[#d8dfec] bg-white px-3 py-2 text-sm outline-none focus:border-[#fd6116]" /></label>
<label class="text-sm font-medium text-slate-700">Vertical<input value={vertical()} onInput={(e) => setVertical(e.currentTarget.value)} class="mt-1 block w-full rounded-lg border border-[#d8dfec] bg-white px-3 py-2 text-sm outline-none focus:border-[#fd6116]" /></label>
<label class="text-sm font-medium text-slate-700">Onboarding Schema<input value={schema()} onInput={(e) => setSchema(e.currentTarget.value)} class="mt-1 block w-full rounded-lg border border-[#d8dfec] bg-white px-3 py-2 text-sm outline-none focus:border-[#fd6116]" /></label>
<div class="md:col-span-2 flex justify-end gap-2"><ActionButton>Cancel</ActionButton><ActionButton type="submit" tone="primary">Save Role</ActionButton></div>
</form>
) : (
<div class="rounded-xl border border-dashed border-[#d9e1ef] bg-[#fbfcff] p-6 text-sm text-slate-600">
Role inspector supports schema-version checks, module visibility, and default landing-route policies.
</div>
)}
</SectionCard>
</div>
);
}

148
src/routes/admin/index.tsx Normal file
View file

@ -0,0 +1,148 @@
import { For } from 'solid-js';
import { ActionButton, DataTable, StatusBadge } from '~/components/admin/AdminUi';
const kpis = [
{ title: 'Total Users', value: '12,458', delta: '+12.5%', note: '+1,245 from last month', tone: 'up' as const, icon: 'US' },
{ title: 'Active Companies', value: '1,234', delta: '+8.2%', note: '+94 from last month', tone: 'up' as const, icon: 'CP' },
{ title: 'Open Leads', value: '847', delta: '-3.1%', note: '-27 from last month', tone: 'down' as const, icon: 'LD' },
{ title: 'Credits Purchased', value: '$45,890', delta: '+18.7%', note: '+$7,234 from last month', tone: 'up' as const, icon: 'CR' },
];
const trendSeries = [62, 70, 81, 75, 88, 102];
const revSeries = [42000, 48000, 55000, 51000, 62000, 69000];
const expSeries = [21000, 25000, 28000, 26000, 31000, 35000];
const maxAmount = 80000;
const leadRows = [
{ title: 'Corporate Event Photographer', customer: 'Bright Media', category: 'Photography', budget: '$3,500', status: 'New', priority: 'High' },
{ title: 'Wedding Makeup Artist', customer: 'Aster Weddings', category: 'Makeup Artist', budget: '$1,800', status: 'In Review', priority: 'Medium' },
{ title: 'SAT Batch Tutor', customer: 'EduPath', category: 'Tutors', budget: '$2,300', status: 'Assigned', priority: 'Low' },
{ title: 'Personal Fitness Trainer', customer: 'Core Fitness', category: 'Fitness', budget: '$2,900', status: 'Escalated', priority: 'High' },
{ title: 'Corporate Video Editor', customer: 'Pixel Forge', category: 'Video Editor', budget: '$4,200', status: 'New', priority: 'Critical' },
];
export default function AdminHomePage() {
return (
<div class="space-y-6">
<section class="rounded-3xl border border-[#e3e5ec] bg-[#f7f7f8] px-6 py-5">
<div class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div>
<h1 class="text-[40px] font-semibold leading-[1.1] text-[#050026]">Dashboard Overview</h1>
<p class="mt-1 text-[15px] text-[#7b8099]">Welcome back! Here&apos;s what&apos;s happening with your platform today.</p>
</div>
<ActionButton tone="primary">Export Report</ActionButton>
</div>
</section>
<section class="grid gap-4 xl:grid-cols-4">
<For each={kpis}>
{(item) => (
<article class="rounded-3xl border border-[#d9dde6] bg-[#f7f7f8] p-5 shadow-[0_0_0_1px_rgba(0,0,0,0.01)]">
<div class="flex items-center justify-between">
<div class="inline-flex h-10 w-10 items-center justify-center rounded-xl bg-[#fff1ea] text-xs font-bold text-[#fd6116]">{item.icon}</div>
<span
class={`inline-flex items-center rounded-xl px-2.5 py-1 text-xs font-semibold ${
item.tone === 'up' ? 'bg-[#ffe8dc] text-[#fd6116]' : 'bg-[#eceff6] text-[#383e5c]'
}`}
>
{item.delta}
</span>
</div>
<p class="mt-5 text-[15px] text-[#747a93]">{item.title}</p>
<p class="mt-1 text-[44px] font-semibold leading-none text-[#050026]">{item.value}</p>
<p class="mt-1 text-[14px] text-[#8a90a8]">{item.note}</p>
</article>
)}
</For>
</section>
<section class="grid gap-4 xl:grid-cols-2">
<article class="rounded-3xl border border-[#d9dde6] bg-[#f7f7f8] p-5">
<h2 class="text-[34px] font-semibold leading-[1.1] text-[#050026]">Leads Trend</h2>
<p class="mt-1 text-[14px] text-[#8087a0]">Monthly leads performance overview</p>
<div class="mt-5 rounded-2xl border border-[#e2e6ee] bg-[#f5f5f6] p-4">
<div class="relative h-52">
<div class="absolute inset-0">
<For each={[0, 1, 2, 3]}>{(i) => <div class="h-1/4 border-b border-dashed border-[#d9dde6]" />}</For>
</div>
<svg viewBox="0 0 100 40" class="relative h-full w-full overflow-visible" preserveAspectRatio="none" aria-hidden="true">
<defs>
<linearGradient id="trendFill" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#fd6116" stop-opacity="0.28" />
<stop offset="100%" stop-color="#fd6116" stop-opacity="0.02" />
</linearGradient>
</defs>
<polyline
fill="none"
stroke="#050026"
stroke-width="1.1"
points={trendSeries.map((v, i) => `${i * 20},${40 - v / 3}`).join(' ')}
/>
<polygon
fill="url(#trendFill)"
points={`0,40 ${trendSeries.map((v, i) => `${i * 20},${40 - v / 3}`).join(' ')} 100,40`}
/>
</svg>
</div>
<div class="mt-1 grid grid-cols-6 text-center text-xs font-semibold text-[#3f4562]">
<For each={['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun']}>{(month) => <span>{month}</span>}</For>
</div>
</div>
</article>
<article class="rounded-3xl border border-[#d9dde6] bg-[#f7f7f8] p-5">
<h2 class="text-[34px] font-semibold leading-[1.1] text-[#050026]">Revenue Overview</h2>
<p class="mt-1 text-[14px] text-[#8087a0]">Monthly revenue vs expenses comparison</p>
<div class="mt-5 rounded-2xl border border-[#e2e6ee] bg-[#f5f5f6] p-4">
<div class="relative h-52">
<div class="absolute inset-0">
<For each={[0, 1, 2, 3]}>{() => <div class="h-1/4 border-b border-dashed border-[#d9dde6]" />}</For>
</div>
<div class="relative flex h-full items-end gap-4 px-2">
<For each={revSeries}>
{(value, i) => (
<div class="flex flex-1 items-end justify-center gap-1.5">
<div class="w-2.5 rounded-t bg-[#050026]" style={{ height: `${(value / maxAmount) * 100}%` }} />
<div class="w-2.5 rounded-t bg-[#fd6116]" style={{ height: `${(expSeries[i()] / maxAmount) * 100}%` }} />
</div>
)}
</For>
</div>
</div>
<div class="mt-1 grid grid-cols-6 text-center text-xs font-semibold text-[#3f4562]">
<For each={['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun']}>{(month) => <span>{month}</span>}</For>
</div>
</div>
</article>
</section>
<section class="rounded-3xl border border-[#d9dde6] bg-[#f7f7f8] p-1">
<div class="flex flex-col gap-3 px-5 py-4 md:flex-row md:items-center md:justify-between">
<div>
<h2 class="text-[32px] font-semibold leading-[1.1] text-[#050026]">Recent Leads</h2>
<p class="mt-1 text-[14px] text-[#8087a0]">Latest customer inquiries and opportunities</p>
</div>
<ActionButton>View All Leads</ActionButton>
</div>
<div class="px-1 pb-1">
<DataTable
headers={['Lead Title', 'Customer', 'Category', 'Budget', 'Status', 'Priority', 'Action']}
rows={leadRows.map((row) => [
<span class="font-medium text-[#050026]">{row.title}</span>,
<span class="text-[#3c4260]">{row.customer}</span>,
<span class="text-[#3c4260]">{row.category}</span>,
<span class="font-semibold text-[#050026]">{row.budget}</span>,
<StatusBadge
label={row.status}
tone={row.status === 'New' ? 'info' : row.status === 'In Review' ? 'warning' : row.status === 'Assigned' ? 'positive' : 'critical'}
/>,
<StatusBadge label={row.priority} tone={row.priority === 'Low' ? 'neutral' : row.priority === 'Medium' ? 'warning' : 'critical'} />,
<ActionButton tone="ghost">Open</ActionButton>,
])}
/>
</div>
</section>
</div>
);
}

View file

@ -0,0 +1,133 @@
import { createMemo, createSignal, onMount } from 'solid-js';
import { ActionButton, DataTable, PageHeader, SearchFilters, SectionCard, StatusBadge, Tabs } from '~/components/admin/AdminUi';
import { createModuleRecord, listModuleRecords, updateModuleRecord } from '~/lib/admin/client';
import type { CrudRecord } from '~/lib/admin/types';
const permissions = [
'Department Management',
'Designation Management',
'Internal Role Management',
'External Role Management',
'Verification Management',
'Approval Management',
'Users Management',
'Company Management',
'Candidate Management',
];
export default function InternalRoleManagementPage() {
const [tab, setTab] = createSignal<'roles' | 'create' | 'permissions'>('roles');
const [query, setQuery] = createSignal('');
const [roleRows, setRoleRows] = createSignal<CrudRecord[]>([]);
const [roleName, setRoleName] = createSignal('');
const [roleCode, setRoleCode] = createSignal('');
const [roleDesc, setRoleDesc] = createSignal('');
const load = async () => setRoleRows(await listModuleRecords('internal-role-management', { q: query() }));
onMount(() => void load());
const filtered = createMemo(() => {
const q = query().toLowerCase().trim();
if (!q) return roleRows();
return roleRows().filter((r) => r.id.toLowerCase().includes(q) || r.name.toLowerCase().includes(q));
});
return (
<div class="space-y-5">
<PageHeader
title="Internal Role Management"
subtitle="Manage internal admin roles, module assignments, and permission policies."
actions={<Tabs value={tab()} onChange={setTab} items={[{ key: 'roles', label: 'View Roles' }, { key: 'create', label: 'Create Role' }, { key: 'permissions', label: 'Permission Matrix' }]} />}
/>
{tab() === 'roles' ? (
<SectionCard title="Roles" subtitle="Internal permission-bearing identities" actions={<ActionButton tone="primary">Create Internal Role</ActionButton>}>
<div class="space-y-3">
<SearchFilters
query={query()}
onQuery={(value) => {
setQuery(value);
void load();
}}
right={<ActionButton>Filter</ActionButton>}
/>
<DataTable
headers={['Role ID', 'Name', 'Modules', 'Assigned Users', 'Status', 'Actions']}
rows={filtered().map((row) => [
<span class="font-medium text-[#050026]">{row.id}</span>,
<span>{row.name}</span>,
<span>{Math.max(4, (row.name.length % 12) + 4)}</span>,
<span>{Math.max(2, (row.id.length % 9) + 2)}</span>,
<StatusBadge label={row.status} tone={row.status === 'ACTIVE' ? 'positive' : 'warning'} />,
<div class="flex gap-2">
<ActionButton
onClick={() => {
const next = row.status === 'ACTIVE' ? 'INACTIVE' : 'ACTIVE';
void updateModuleRecord('internal-role-management', row.id, { status: next }).then(() => void load());
}}
>
Toggle
</ActionButton>
<ActionButton tone="ghost">View</ActionButton>
</div>,
])}
/>
</div>
</SectionCard>
) : null}
{tab() === 'create' ? (
<SectionCard title="Create Internal Role" subtitle="Define role metadata and module access rules.">
<form
class="grid gap-4 rounded-xl border border-[#e5eaf3] bg-[#f9fbff] p-4 md:grid-cols-2"
onSubmit={(e) => {
e.preventDefault();
const name = roleName().trim() || roleCode().trim() || 'New Internal Role';
void createModuleRecord('internal-role-management', { name, status: 'ACTIVE' }).then(() => {
setRoleName('');
setRoleCode('');
setRoleDesc('');
setTab('roles');
void load();
});
}}
>
<label class="text-sm font-medium text-slate-700">
Role Name
<input value={roleName()} onInput={(e) => setRoleName(e.currentTarget.value)} class="mt-1 block w-full rounded-lg border border-[#d8dfec] bg-white px-3 py-2 text-sm outline-none focus:border-[#fd6116]" />
</label>
<label class="text-sm font-medium text-slate-700">
Role Code
<input value={roleCode()} onInput={(e) => setRoleCode(e.currentTarget.value)} class="mt-1 block w-full rounded-lg border border-[#d8dfec] bg-white px-3 py-2 text-sm outline-none focus:border-[#fd6116]" />
</label>
<label class="text-sm font-medium text-slate-700 md:col-span-2">
Description
<textarea value={roleDesc()} onInput={(e) => setRoleDesc(e.currentTarget.value)} class="mt-1 block w-full rounded-lg border border-[#d8dfec] bg-white px-3 py-2 text-sm outline-none focus:border-[#fd6116]" rows={3} />
</label>
<div class="md:col-span-2 flex justify-end gap-2">
<ActionButton>Cancel</ActionButton>
<ActionButton type="submit" tone="primary">
Save Role
</ActionButton>
</div>
</form>
</SectionCard>
) : null}
{tab() === 'permissions' ? (
<SectionCard title="Permission Matrix" subtitle="Grant create/read/update/delete operations per module.">
<DataTable
headers={['Module', 'Create', 'Read', 'Update', 'Delete']}
rows={permissions.map((module, idx) => [
<span class="font-medium text-[#050026]">{module}</span>,
<input type="checkbox" checked={idx % 2 === 0} />,
<input type="checkbox" checked />,
<input type="checkbox" checked={idx % 3 !== 0} />,
<input type="checkbox" checked={idx % 4 === 0} />,
])}
/>
</SectionCard>
) : null}
</div>
);
}

View file

@ -0,0 +1,142 @@
import { createMemo, createSignal, onMount } from 'solid-js';
import { ActionButton, DataTable, MetricCards, PageHeader, SearchFilters, SectionCard, StatusBadge, Tabs } from '~/components/admin/AdminUi';
import { bulkVerification, listVerificationCases } from '~/lib/admin/client';
import type { VerificationCase } from '~/lib/admin/types';
const toneByStatus: Record<VerificationCase['status'], 'neutral' | 'warning' | 'positive' | 'critical' | 'info'> = {
PENDING: 'warning',
IN_REVIEW: 'info',
VERIFIED: 'positive',
REJECTED: 'critical',
FLAGGED: 'critical',
};
export default function VerificationManagementPage() {
const [tab, setTab] = createSignal<'queue' | 'rules' | 'preview'>('queue');
const [query, setQuery] = createSignal('');
const [rows, setRows] = createSignal<VerificationCase[]>([]);
const [selected, setSelected] = createSignal<string[]>([]);
const [error, setError] = createSignal('');
const load = async () => {
try {
setError('');
setRows(await listVerificationCases({ q: query() }));
} catch (err: any) {
setError(String(err?.message || 'Failed to load verification cases.'));
}
};
onMount(() => void load());
const metrics = createMemo(() => {
const data = rows();
return [
{ label: 'Total Pending', value: String(data.filter((d) => d.status === 'PENDING').length || 0) },
{ label: 'Identity Verification', value: String(data.filter((d) => d.verificationType.includes('Identity')).length || 0), tone: 'info' as const },
{ label: 'Business Verification', value: String(data.filter((d) => d.verificationType.includes('Business')).length || 0), tone: 'warning' as const },
{ label: 'Verified Today', value: String(data.filter((d) => d.status === 'VERIFIED').length || 0), tone: 'positive' as const },
{ label: 'Flagged Cases', value: String(data.filter((d) => d.status === 'FLAGGED').length || 0), tone: 'critical' as const },
];
});
const filtered = createMemo(() => {
const q = query().trim().toLowerCase();
if (!q) return rows();
return rows().filter((r) => r.id.toLowerCase().includes(q) || r.applicantName.toLowerCase().includes(q));
});
const toggle = (id: string, checked: boolean) => setSelected((prev) => (checked ? [...new Set([...prev, id])] : prev.filter((x) => x !== id)));
const runBulk = async (action: string) => {
if (selected().length === 0) return;
try {
setError('');
await bulkVerification(selected(), action);
setSelected([]);
await load();
} catch (err: any) {
setError(String(err?.message || 'Bulk action failed.'));
}
};
return (
<div class="space-y-5">
<PageHeader
title="Verification Management"
subtitle="Review and verify user submissions before they enter final approval queue."
actions={<Tabs value={tab()} onChange={setTab} items={[{ key: 'queue', label: 'Verification Queue' }, { key: 'rules', label: 'Verification Rules' }, { key: 'preview', label: 'User Preview' }]} />}
/>
<MetricCards items={metrics()} />
{error() ? <p class="rounded-lg border border-rose-200 bg-rose-50 px-3 py-2 text-sm text-rose-700">{error()}</p> : null}
{tab() === 'queue' ? (
<SectionCard
title="Verification Cases"
subtitle="Hybrid data mode enabled. Bulk actions update downstream approval eligibility."
actions={
<>
<ActionButton>Export Queue</ActionButton>
<ActionButton onClick={() => void runBulk('mark_in_review')}>Mark In Review</ActionButton>
<ActionButton tone="primary" onClick={() => void runBulk('approve_for_approval')}>Approve For Approval</ActionButton>
</>
}
>
<div class="space-y-3">
<SearchFilters
query={query()}
onQuery={(v) => {
setQuery(v);
void load();
}}
right={
<>
<ActionButton onClick={() => void runBulk('flag')}>Flag</ActionButton>
<ActionButton onClick={() => void runBulk('reject')}>Reject</ActionButton>
</>
}
/>
<DataTable
headers={['', 'Verification ID', 'Applicant Name', 'User Type', 'Verification Type', 'Submitted Date', 'Documents', 'Status', 'Priority', 'Actions']}
rows={filtered().map((row) => [
<input type="checkbox" checked={selected().includes(row.id)} onInput={(e) => toggle(row.id, e.currentTarget.checked)} />,
<span class="font-medium text-[#050026]">{row.id}</span>,
<span>{row.applicantName}</span>,
<span>{row.userType}</span>,
<span>{row.verificationType}</span>,
<span class="text-xs text-slate-500">{row.submittedAt}</span>,
<span>{row.documents}</span>,
<StatusBadge label={row.status} tone={toneByStatus[row.status]} />,
<StatusBadge label={row.priority} tone={row.priority === 'LOW' ? 'neutral' : row.priority === 'MEDIUM' ? 'warning' : 'critical'} />,
<ActionButton tone="ghost">Open</ActionButton>,
])}
/>
</div>
</SectionCard>
) : tab() === 'rules' ? (
<SectionCard title="Verification Rules" subtitle="Configure rule severity and evidence requirements.">
<DataTable
headers={['Rule', 'Type', 'Severity', 'Evidence Required', 'Actions']}
rows={[
['Government ID must be valid and unexpired', 'Identity', <StatusBadge label="HIGH" tone="critical" />, 'Yes', <ActionButton>Edit</ActionButton>],
['Business GSTIN and legal docs must match', 'Business', <StatusBadge label="HIGH" tone="critical" />, 'Yes', <ActionButton>Edit</ActionButton>],
['Portfolio sample quality threshold', 'Professional', <StatusBadge label="MEDIUM" tone="warning" />, 'Optional', <ActionButton>Edit</ActionButton>],
]}
/>
</SectionCard>
) : (
<SectionCard title="User Preview" subtitle="Preview user-facing verification state messaging.">
<div class="rounded-xl border border-[#e4e9f2] bg-[#f8fbff] p-4">
<h3 class="text-base font-semibold text-[#050026]">Verification Status Timeline</h3>
<ol class="mt-3 space-y-2 text-sm text-slate-600">
<li>1. Submitted: user uploads required documents.</li>
<li>2. In Review: verification team validates authenticity.</li>
<li>3. Outcome: verified, rejected, or flagged.</li>
<li>4. Eligible outcomes move to Approval Queue automatically.</li>
</ol>
</div>
</SectionCard>
)}
</div>
);
}

View file

@ -0,0 +1 @@
export { default } from './verification-management';

View file

@ -0,0 +1,29 @@
import { approvalService } from '~/lib/admin/data';
import { jsonError, proxyOrFallback } from '~/lib/admin/api';
export async function PATCH({ request, params }: { request: Request; params: { id?: string } }) {
const id = params.id;
const payload = await request.json().catch(() => null);
if (!id) return jsonError('id is required', 400);
if (!payload || typeof payload !== 'object') return jsonError('Invalid payload', 400);
return proxyOrFallback({
request,
method: 'PATCH',
path: `/admin/approval-cases/${encodeURIComponent(id)}`,
body: payload,
fallback: () => approvalService.update(id, payload),
});
}
export async function DELETE({ request, params }: { request: Request; params: { id?: string } }) {
const id = params.id;
if (!id) return jsonError('id is required', 400);
return proxyOrFallback({
request,
method: 'DELETE',
path: `/admin/approval-cases/${encodeURIComponent(id)}`,
fallback: async () => ({ ok: await approvalService.delete(id), id }),
});
}

View file

@ -0,0 +1,19 @@
import { approvalService } from '~/lib/admin/data';
import { jsonError, proxyOrFallback } from '~/lib/admin/api';
export async function POST({ request }: { request: Request }) {
const payload = (await request.json().catch(() => null)) as { ids?: string[]; action?: string } | null;
const ids = payload?.ids ?? [];
const action = payload?.action ?? '';
if (!Array.isArray(ids) || ids.length === 0) return jsonError('ids must be a non-empty array', 400);
if (!action) return jsonError('action is required', 400);
return proxyOrFallback({
request,
method: 'POST',
path: '/admin/approval-cases/bulk',
body: payload,
fallback: () => approvalService.bulkAction(ids, action),
});
}

View file

@ -0,0 +1,29 @@
import { approvalService } from '~/lib/admin/data';
import { jsonError, proxyOrFallback } from '~/lib/admin/api';
export async function GET({ request }: { request: Request }) {
const url = new URL(request.url);
const q = url.searchParams.get('q') || undefined;
const status = url.searchParams.get('status') || undefined;
return proxyOrFallback({
request,
method: 'GET',
path: `/admin/approval-cases${q || status ? `?${new URLSearchParams({ ...(q ? { q } : {}), ...(status ? { status } : {}) }).toString()}` : ''}`,
fallback: () => approvalService.list({ q, status }),
});
}
export async function POST({ request }: { request: Request }) {
const payload = await request.json().catch(() => null);
if (!payload || typeof payload !== 'object') return jsonError('Invalid payload', 400);
return proxyOrFallback({
request,
method: 'POST',
path: '/admin/approval-cases',
body: payload,
fallback: () => approvalService.create(payload),
fallbackStatus: 201,
});
}

View file

@ -0,0 +1,34 @@
import { getModuleCrudService } from '~/lib/admin/data';
import { jsonError, proxyOrFallback } from '~/lib/admin/api';
export async function PATCH({ request, params }: { request: Request; params: { module?: string; id?: string } }) {
const moduleKey = String(params.module || '').trim();
const id = String(params.id || '').trim();
if (!moduleKey) return jsonError('module is required', 400);
if (!id) return jsonError('id is required', 400);
const payload = await request.json().catch(() => null);
if (!payload || typeof payload !== 'object') return jsonError('Invalid payload', 400);
return proxyOrFallback({
request,
method: 'PATCH',
path: `/admin/modules/${encodeURIComponent(moduleKey)}/records/${encodeURIComponent(id)}`,
body: payload,
fallback: () => getModuleCrudService(moduleKey).update(id, payload),
});
}
export async function DELETE({ request, params }: { request: Request; params: { module?: string; id?: string } }) {
const moduleKey = String(params.module || '').trim();
const id = String(params.id || '').trim();
if (!moduleKey) return jsonError('module is required', 400);
if (!id) return jsonError('id is required', 400);
return proxyOrFallback({
request,
method: 'DELETE',
path: `/admin/modules/${encodeURIComponent(moduleKey)}/records/${encodeURIComponent(id)}`,
fallback: async () => ({ ok: await getModuleCrudService(moduleKey).delete(id), id }),
});
}

View file

@ -0,0 +1,22 @@
import { getModuleCrudService } from '~/lib/admin/data';
import { jsonError, proxyOrFallback } from '~/lib/admin/api';
export async function POST({ request, params }: { request: Request; params: { module?: string } }) {
const moduleKey = String(params.module || '').trim();
if (!moduleKey) return jsonError('module is required', 400);
const payload = (await request.json().catch(() => null)) as { ids?: string[]; action?: string } | null;
const ids = payload?.ids ?? [];
const action = payload?.action ?? '';
if (!Array.isArray(ids) || ids.length === 0) return jsonError('ids must be a non-empty array', 400);
if (!action) return jsonError('action is required', 400);
return proxyOrFallback({
request,
method: 'POST',
path: `/admin/modules/${encodeURIComponent(moduleKey)}/records/bulk`,
body: payload,
fallback: () => getModuleCrudService(moduleKey).bulkAction(ids, action),
});
}

View file

@ -0,0 +1,35 @@
import { getModuleCrudService } from '~/lib/admin/data';
import { jsonError, proxyOrFallback } from '~/lib/admin/api';
export async function GET({ request, params }: { request: Request; params: { module?: string } }) {
const moduleKey = String(params.module || '').trim();
if (!moduleKey) return jsonError('module is required', 400);
const url = new URL(request.url);
const q = url.searchParams.get('q') || undefined;
const status = url.searchParams.get('status') || undefined;
return proxyOrFallback({
request,
method: 'GET',
path: `/admin/modules/${encodeURIComponent(moduleKey)}/records${q || status ? `?${new URLSearchParams({ ...(q ? { q } : {}), ...(status ? { status } : {}) }).toString()}` : ''}`,
fallback: () => getModuleCrudService(moduleKey).list({ q, status }),
});
}
export async function POST({ request, params }: { request: Request; params: { module?: string } }) {
const moduleKey = String(params.module || '').trim();
if (!moduleKey) return jsonError('module is required', 400);
const payload = await request.json().catch(() => null);
if (!payload || typeof payload !== 'object') return jsonError('Invalid payload', 400);
return proxyOrFallback({
request,
method: 'POST',
path: `/admin/modules/${encodeURIComponent(moduleKey)}/records`,
body: payload,
fallback: () => getModuleCrudService(moduleKey).create(payload),
fallbackStatus: 201,
});
}

View file

@ -0,0 +1,29 @@
import { verificationService } from '~/lib/admin/data';
import { jsonError, proxyOrFallback } from '~/lib/admin/api';
export async function PATCH({ request, params }: { request: Request; params: { id?: string } }) {
const id = params.id;
const payload = await request.json().catch(() => null);
if (!id) return jsonError('id is required', 400);
if (!payload || typeof payload !== 'object') return jsonError('Invalid payload', 400);
return proxyOrFallback({
request,
method: 'PATCH',
path: `/admin/verification-cases/${encodeURIComponent(id)}`,
body: payload,
fallback: () => verificationService.update(id, payload),
});
}
export async function DELETE({ request, params }: { request: Request; params: { id?: string } }) {
const id = params.id;
if (!id) return jsonError('id is required', 400);
return proxyOrFallback({
request,
method: 'DELETE',
path: `/admin/verification-cases/${encodeURIComponent(id)}`,
fallback: async () => ({ ok: await verificationService.delete(id), id }),
});
}

View file

@ -0,0 +1,19 @@
import { verificationService } from '~/lib/admin/data';
import { jsonError, proxyOrFallback } from '~/lib/admin/api';
export async function POST({ request }: { request: Request }) {
const payload = (await request.json().catch(() => null)) as { ids?: string[]; action?: string } | null;
const ids = payload?.ids ?? [];
const action = payload?.action ?? '';
if (!Array.isArray(ids) || ids.length === 0) return jsonError('ids must be a non-empty array', 400);
if (!action) return jsonError('action is required', 400);
return proxyOrFallback({
request,
method: 'POST',
path: '/admin/verification-cases/bulk',
body: payload,
fallback: () => verificationService.bulkAction(ids, action),
});
}

View file

@ -0,0 +1,29 @@
import { verificationService } from '~/lib/admin/data';
import { jsonError, proxyOrFallback } from '~/lib/admin/api';
export async function GET({ request }: { request: Request }) {
const url = new URL(request.url);
const q = url.searchParams.get('q') || undefined;
const status = url.searchParams.get('status') || undefined;
return proxyOrFallback({
request,
method: 'GET',
path: `/admin/verification-cases${q || status ? `?${new URLSearchParams({ ...(q ? { q } : {}), ...(status ? { status } : {}) }).toString()}` : ''}`,
fallback: () => verificationService.list({ q, status }),
});
}
export async function POST({ request }: { request: Request }) {
const payload = await request.json().catch(() => null);
if (!payload || typeof payload !== 'object') return jsonError('Invalid payload', 400);
return proxyOrFallback({
request,
method: 'POST',
path: '/admin/verification-cases',
body: payload,
fallback: () => verificationService.create(payload),
fallbackStatus: 201,
});
}