feat(admin): implement figma-aligned admin shell and management modules
This commit is contained in:
parent
d67e436828
commit
94d4623248
30 changed files with 2342 additions and 0 deletions
109
src/components/admin/AdminShell.tsx
Normal file
109
src/components/admin/AdminShell.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
186
src/components/admin/AdminUi.tsx
Normal file
186
src/components/admin/AdminUi.tsx
Normal 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'}
|
||||
/>
|
||||
);
|
||||
}
|
||||
89
src/components/admin/CrudManagementPage.tsx
Normal file
89
src/components/admin/CrudManagementPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
202
src/components/admin/GenericAdminModulePage.tsx
Normal file
202
src/components/admin/GenericAdminModulePage.tsx
Normal 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
47
src/lib/admin/api.ts
Normal 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
110
src/lib/admin/client.ts
Normal 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
242
src/lib/admin/data.ts
Normal 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;
|
||||
}
|
||||
51
src/lib/admin/module-config.ts
Normal file
51
src/lib/admin/module-config.ts
Normal 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;
|
||||
};
|
||||
21
src/lib/admin/types.test.ts
Normal file
21
src/lib/admin/types.test.ts
Normal 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
67
src/lib/admin/types.ts
Normal 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
16
src/routes/admin.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
28
src/routes/admin/[...path].tsx
Normal file
28
src/routes/admin/[...path].tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
143
src/routes/admin/approval-management.tsx
Normal file
143
src/routes/admin/approval-management.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
1
src/routes/admin/approval.tsx
Normal file
1
src/routes/admin/approval.tsx
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { default } from './approval-management';
|
||||
123
src/routes/admin/department.tsx
Normal file
123
src/routes/admin/department.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
123
src/routes/admin/designation.tsx
Normal file
123
src/routes/admin/designation.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
115
src/routes/admin/external-role-management.tsx
Normal file
115
src/routes/admin/external-role-management.tsx
Normal 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
148
src/routes/admin/index.tsx
Normal 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's what'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>
|
||||
);
|
||||
}
|
||||
133
src/routes/admin/internal-role-management.tsx
Normal file
133
src/routes/admin/internal-role-management.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
142
src/routes/admin/verification-management.tsx
Normal file
142
src/routes/admin/verification-management.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
1
src/routes/admin/verification.tsx
Normal file
1
src/routes/admin/verification.tsx
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { default } from './verification-management';
|
||||
29
src/routes/api/admin/approval-cases/[id].ts
Normal file
29
src/routes/api/admin/approval-cases/[id].ts
Normal 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 }),
|
||||
});
|
||||
}
|
||||
19
src/routes/api/admin/approval-cases/bulk.ts
Normal file
19
src/routes/api/admin/approval-cases/bulk.ts
Normal 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),
|
||||
});
|
||||
}
|
||||
29
src/routes/api/admin/approval-cases/index.ts
Normal file
29
src/routes/api/admin/approval-cases/index.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
34
src/routes/api/admin/modules/[module]/records/[id].ts
Normal file
34
src/routes/api/admin/modules/[module]/records/[id].ts
Normal 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 }),
|
||||
});
|
||||
}
|
||||
22
src/routes/api/admin/modules/[module]/records/bulk.ts
Normal file
22
src/routes/api/admin/modules/[module]/records/bulk.ts
Normal 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),
|
||||
});
|
||||
}
|
||||
35
src/routes/api/admin/modules/[module]/records/index.ts
Normal file
35
src/routes/api/admin/modules/[module]/records/index.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
29
src/routes/api/admin/verification-cases/[id].ts
Normal file
29
src/routes/api/admin/verification-cases/[id].ts
Normal 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 }),
|
||||
});
|
||||
}
|
||||
19
src/routes/api/admin/verification-cases/bulk.ts
Normal file
19
src/routes/api/admin/verification-cases/bulk.ts
Normal 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),
|
||||
});
|
||||
}
|
||||
29
src/routes/api/admin/verification-cases/index.ts
Normal file
29
src/routes/api/admin/verification-cases/index.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue