Fix admin approval/verification routing and align management UIs
This commit is contained in:
parent
a95c955ad4
commit
717a6c799e
27 changed files with 6328 additions and 4462 deletions
|
|
@ -18,12 +18,13 @@ const PAGE_TITLES: Array<{ prefix: string; label: string; exact?: boolean }> = [
|
|||
{ prefix: '/admin/designation', label: 'Designation Management' },
|
||||
{ prefix: '/admin/roles', label: 'Internal Role Management' },
|
||||
{ prefix: '/admin/employees', label: 'Employee Management' },
|
||||
{ prefix: '/admin/runtime-roles', label: 'External Role Management' },
|
||||
{ prefix: '/admin/external-roles', label: 'External Role Management' },
|
||||
{ prefix: '/admin/onboarding-management', label: 'Onboarding Management' },
|
||||
{ prefix: '/admin/onboarding-schemas', label: 'Onboarding Management' },
|
||||
{ prefix: '/admin/internal-dashboard-management', label: 'Internal Dashboard Management' },
|
||||
{ prefix: '/admin/external-dashboard-management', label: 'External Dashboard Management' },
|
||||
{ prefix: '/admin/role-ui-configs', label: 'External Dashboard Management' },
|
||||
{ prefix: '/admin/verification', label: 'Verification Management' },
|
||||
{ prefix: '/admin/verification-status', label: 'Verification Management' },
|
||||
{ prefix: '/admin/approval', label: 'Approval Management' },
|
||||
{ prefix: '/admin/users', label: 'Users Management' },
|
||||
|
|
@ -54,16 +55,7 @@ const PAGE_TITLES: Array<{ prefix: string; label: string; exact?: boolean }> = [
|
|||
{ prefix: '/admin/ledger', label: 'Ledger Management' },
|
||||
];
|
||||
|
||||
const TAB_SETS: Array<{ prefixes: string[]; tabs: Tab[] }> = [
|
||||
{
|
||||
prefixes: ['/admin/runtime-roles'],
|
||||
tabs: [
|
||||
{ href: '/admin/runtime-roles', label: 'Roles', exact: true },
|
||||
{ href: '/admin/runtime-roles/new', label: 'Create Role' },
|
||||
{ href: '/admin/role-ui-configs', label: 'View Roles' },
|
||||
],
|
||||
},
|
||||
];
|
||||
const TAB_SETS: Array<{ prefixes: string[]; tabs: Tab[] }> = [];
|
||||
|
||||
const SEARCH_MODULES = [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -28,13 +28,13 @@ const GROUPS: NavItem[][] = [
|
|||
{ href: '/admin/employees', label: 'Employee Management', icon: Users },
|
||||
],
|
||||
[
|
||||
{ href: '/admin/runtime-roles', label: 'External Role Management', icon: ShieldCheck },
|
||||
{ href: '/admin/external-roles', label: 'External Role Management', icon: ShieldCheck },
|
||||
{ href: '/admin/onboarding-management', label: 'External Onboarding Management', icon: FileText, aliasPrefix: '/admin/onboarding-schemas' },
|
||||
{ href: '/admin/internal-dashboard-management', label: 'Internal Dashboard Management', icon: LayoutDashboard },
|
||||
{ href: '/admin/external-dashboard-management', label: 'External Dashboard Management', icon: LayoutDashboard, aliasPrefix: '/admin/role-ui-configs' },
|
||||
],
|
||||
[
|
||||
{ href: '/admin/verification-status', label: 'Verification Management', icon: BadgeCheck },
|
||||
{ href: '/admin/verification', label: 'Verification Management', icon: BadgeCheck },
|
||||
{ href: '/admin/approval', label: 'Approval Management', icon: ClipboardList },
|
||||
],
|
||||
[
|
||||
|
|
@ -93,8 +93,17 @@ export default function AdminSidebar(props: {
|
|||
|
||||
return (
|
||||
<aside
|
||||
style="overflow:hidden;display:flex;flex-direction:column;height:100%;background:white;border-right:1px solid #E5E7EB;transition:width 0.3s;flex-shrink:0"
|
||||
style:width={props.collapsed ? '64px' : '220px'}
|
||||
style={{
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
'flex-direction': 'column',
|
||||
height: '100%',
|
||||
background: 'white',
|
||||
'border-right': '1px solid #E5E7EB',
|
||||
transition: 'width 0.3s',
|
||||
'flex-shrink': 0,
|
||||
width: props.collapsed ? '64px' : '220px'
|
||||
}}
|
||||
>
|
||||
{/* Logo area */}
|
||||
<div style="position:relative;height:64px;display:flex;align-items:center;border-bottom:1px solid #E5E7EB;flex-shrink:0;padding:0 14px">
|
||||
|
|
|
|||
|
|
@ -8,17 +8,20 @@ type Props = {
|
|||
export default function ExternalRoleTabs(props: Props) {
|
||||
const location = useLocation();
|
||||
const [searchParams] = useSearchParams();
|
||||
const activeRoleKey = createMemo(() => props.roleKey || searchParams.roleKey || '');
|
||||
const activeRoleKey = createMemo(() => {
|
||||
const val = props.roleKey || searchParams.roleKey || '';
|
||||
return (Array.isArray(val) ? val[0] : val) || '';
|
||||
});
|
||||
const pathname = createMemo(() => location.pathname);
|
||||
|
||||
const isRoles = createMemo(() => pathname() === '/admin/runtime-roles' || pathname().startsWith('/admin/runtime-roles/'));
|
||||
const isCreate = createMemo(() => pathname() === '/admin/runtime-roles/new');
|
||||
const isRoles = createMemo(() => pathname() === '/admin/external-roles');
|
||||
const isCreate = createMemo(() => pathname() === '/admin/external-roles' && searchParams.view === 'form');
|
||||
const isInspector = createMemo(() => pathname() === '/admin/role-ui-configs');
|
||||
|
||||
return (
|
||||
<div class="admin-link-tabs" style="margin-top:0">
|
||||
<A class={`admin-link-tab ${isRoles() ? 'active' : ''}`} href="/admin/runtime-roles">View Roles</A>
|
||||
<A class={`admin-link-tab ${isCreate() ? 'active' : ''}`} href="/admin/runtime-roles/new">Create Role</A>
|
||||
<A class={`admin-link-tab ${isRoles() && !isCreate() ? 'active' : ''}`} href="/admin/external-roles">View Roles</A>
|
||||
<A class={`admin-link-tab ${isCreate() ? 'active' : ''}`} href="/admin/external-roles?view=form">Create Role</A>
|
||||
<A class={`admin-link-tab ${isInspector() ? 'active' : ''}`} href={activeRoleKey() ? `/admin/role-ui-configs?roleKey=${encodeURIComponent(activeRoleKey())}` : '/admin/role-ui-configs'}>Inspector</A>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -6,13 +6,13 @@ export default function OnboardingManagementTabs() {
|
|||
const pathname = createMemo(() => location.pathname);
|
||||
const onList = createMemo(() => pathname() === '/admin/onboarding-schemas');
|
||||
const onCreate = createMemo(() => pathname() === '/admin/onboarding-schemas/new');
|
||||
const onRoleMapping = createMemo(() => pathname() === '/admin/runtime-roles' || pathname().startsWith('/admin/runtime-roles/') || pathname() === '/admin/role-ui-configs');
|
||||
const onRoleMapping = createMemo(() => pathname() === '/admin/external-roles' || pathname().startsWith('/admin/external-roles/') || pathname() === '/admin/role-ui-configs');
|
||||
|
||||
return (
|
||||
<div class="admin-link-tabs" style="margin-top:0">
|
||||
<A class={`admin-link-tab ${onList() ? 'active' : ''}`} href="/admin/onboarding-schemas">View Flows</A>
|
||||
<A class={`admin-link-tab ${onCreate() ? 'active' : ''}`} href="/admin/onboarding-schemas/new">Create Flow</A>
|
||||
<A class={`admin-link-tab ${onRoleMapping() ? 'active' : ''}`} href="/admin/runtime-roles">Role Mapping</A>
|
||||
<A class={`admin-link-tab ${onRoleMapping() ? 'active' : ''}`} href="/admin/external-roles">Role Mapping</A>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ export type ApprovalCase = {
|
|||
export type CrudRecord = {
|
||||
id: string;
|
||||
name: string;
|
||||
status: 'ACTIVE' | 'INACTIVE';
|
||||
status: string;
|
||||
updatedAt: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import { A, useParams } from '@solidjs/router';
|
||||
import { createMemo, onMount } from 'solid-js';
|
||||
import { useNavigate } from '@solidjs/router';
|
||||
import { createMemo } from 'solid-js';
|
||||
import AdminShell from '~/components/AdminShell';
|
||||
import ApprovalManagementPage from './approval';
|
||||
import VerificationManagementPage from './verification';
|
||||
|
||||
function toTitle(value: string): string {
|
||||
return value
|
||||
|
|
@ -35,16 +36,20 @@ function resolveLegacyPath(modulePath: string): string {
|
|||
|
||||
export default function LegacyModuleShellPage() {
|
||||
const params = useParams();
|
||||
const navigate = useNavigate();
|
||||
const modulePath = String((params as any).module || '').trim();
|
||||
|
||||
if (modulePath === 'approval' || modulePath === 'approval-management' || modulePath === 'approvals' || modulePath === 'approval-status') {
|
||||
return <ApprovalManagementPage />;
|
||||
}
|
||||
|
||||
if (modulePath === 'verification' || modulePath === 'verification-status' || modulePath === 'verification-management') {
|
||||
return <VerificationManagementPage />;
|
||||
}
|
||||
|
||||
const moduleName = createMemo(() => toTitle(modulePath || 'Management'));
|
||||
const legacyPath = createMemo(() => resolveLegacyPath(modulePath));
|
||||
const legacyUrl = createMemo(() => `${LEGACY_ADMIN_ORIGIN}${legacyPath()}`);
|
||||
|
||||
onMount(() => {
|
||||
if (modulePath === 'approval') navigate('/admin/approval', { replace: true });
|
||||
});
|
||||
|
||||
return (
|
||||
<AdminShell>
|
||||
<h1 class="text-2xl font-bold text-gray-900">{moduleName()}</h1>
|
||||
|
|
|
|||
5
src/routes/admin/approval-management.tsx
Normal file
5
src/routes/admin/approval-management.tsx
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { Navigate } from '@solidjs/router';
|
||||
|
||||
export default function ApprovalManagementLegacyRedirectPage() {
|
||||
return <Navigate href="/admin/approval" />;
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
2
src/routes/admin/approval/index.tsx
Normal file
2
src/routes/admin/approval/index.tsx
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export { default } from '../approval';
|
||||
|
||||
5
src/routes/admin/approvals.tsx
Normal file
5
src/routes/admin/approvals.tsx
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { Navigate } from '@solidjs/router';
|
||||
|
||||
export default function ApprovalsLegacyRedirectPage() {
|
||||
return <Navigate href="/admin/approval" />;
|
||||
}
|
||||
|
|
@ -1,123 +1,268 @@
|
|||
import { A } from '@solidjs/router';
|
||||
import { createMemo, createResource, createSignal, For, Show } from 'solid-js';
|
||||
import { For, Show, createMemo, createSignal, onMount } from 'solid-js';
|
||||
import AdminShell from '~/components/AdminShell';
|
||||
import type { CrudRecord } from '~/lib/admin/types';
|
||||
|
||||
const API = '/api/gateway';
|
||||
|
||||
async function fetchUsers(): Promise<any[]> {
|
||||
try {
|
||||
const res = await fetch(`${API}/api/admin/users?role=job_seeker`);
|
||||
if (!res.ok) throw new Error('Failed to load');
|
||||
const data = await res.json();
|
||||
return Array.isArray(data) ? data : (data.users || []);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
type CandidateRecord = CrudRecord & {
|
||||
email: string;
|
||||
phone?: string;
|
||||
location?: string;
|
||||
experience?: string;
|
||||
skills?: string[];
|
||||
status: 'ACTIVE' | 'INACTIVE' | 'PENDING';
|
||||
registeredDate?: string;
|
||||
};
|
||||
|
||||
const FALLBACK_CANDIDATES: CandidateRecord[] = [
|
||||
{ id: 'c1', name: 'Arjun Sharma', email: 'arjun.s@example.com', phone: '+91 98765 43210', location: 'Mumbai', experience: '5 years', status: 'ACTIVE', registeredDate: '2026-01-15', updatedAt: '2026-03-27' },
|
||||
{ id: 'c2', name: 'Priya Nair', email: 'priya.n@example.com', phone: '+91 98765 43211', location: 'Bangalore', experience: '3 years', status: 'ACTIVE', registeredDate: '2026-02-10', updatedAt: '2026-03-27' },
|
||||
{ id: 'c3', name: 'Rahul Verma', email: 'rahul.v@example.com', phone: '+91 98765 43212', location: 'Delhi', experience: '1 year', status: 'PENDING', registeredDate: '2026-03-05', updatedAt: '2026-03-27' },
|
||||
];
|
||||
|
||||
function StatusBadge(props: { status: string }) {
|
||||
const active = () => props.status === 'ACTIVE';
|
||||
const pending = () => props.status === 'PENDING';
|
||||
return (
|
||||
<span style={`display:inline-flex;align-items:center;border-radius:9999px;border:1px solid ${active() ? '#FFD8C2' : pending() ? '#F6D78F' : '#D1D5DB'};background:${active() ? '#FFF1EB' : pending() ? '#FFF3D6' : '#F3F4F6'};color:${active() ? '#FF5E13' : pending() ? '#B7791F' : '#4B5563'};padding:2px 10px;font-size:12px;font-weight:500`}>
|
||||
<span style={`display:inline-block;width:6px;height:6px;border-radius:50%;background:${active() ? '#FF5E13' : pending() ? '#B7791F' : '#9CA3AF'};margin-right:5px;flex-shrink:0`} />
|
||||
{props.status.charAt(0) + props.status.slice(1).toLowerCase()}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default function CandidatePage() {
|
||||
const [users] = createResource(fetchUsers);
|
||||
export default function CandidateManagementPage() {
|
||||
const [view, setView] = createSignal<'list' | 'detail'>('list');
|
||||
const [listTab, setListTab] = createSignal<'all' | 'active' | 'pending' | 'view'>('all');
|
||||
const [detailTab, setDetailTab] = createSignal<'overview' | 'experience' | 'skills'>('overview');
|
||||
|
||||
const [search, setSearch] = createSignal('');
|
||||
const [statusFilter, setStatusFilter] = createSignal('');
|
||||
const [statusFilter, setStatusFilter] = createSignal('all');
|
||||
const [sortBy, setSortBy] = createSignal<'name_asc' | 'name_desc' | 'registered_desc' | 'registered_asc'>('name_asc');
|
||||
const [sortMenuOpen, setSortMenuOpen] = createSignal(false);
|
||||
const [filterMenuOpen, setFilterMenuOpen] = createSignal(false);
|
||||
const [rows, setRows] = createSignal<CandidateRecord[]>([]);
|
||||
const [selectedCandidate, setSelectedCandidate] = createSignal<CandidateRecord | null>(null);
|
||||
const [openMenuId, setOpenMenuId] = createSignal<string | null>(null);
|
||||
|
||||
const filtered = createMemo(() => {
|
||||
const list = users() ?? [];
|
||||
const load = async () => {
|
||||
try {
|
||||
const res = await fetch(`${API}/api/admin/users?role=job_seeker`);
|
||||
if (!res.ok) throw new Error();
|
||||
const data = await res.json();
|
||||
const list = Array.isArray(data) ? data : (data.users || []);
|
||||
if (list.length === 0) setRows(FALLBACK_CANDIDATES);
|
||||
else setRows(list.map((u: any) => ({
|
||||
id: u.id,
|
||||
name: u.name || u.full_name || '—',
|
||||
email: u.email,
|
||||
status: (u.status || 'ACTIVE').toUpperCase(),
|
||||
updatedAt: u.updated_at || '',
|
||||
registeredDate: u.created_at ? new Date(u.created_at).toLocaleDateString() : '—'
|
||||
} as CandidateRecord)));
|
||||
} catch {
|
||||
setRows(FALLBACK_CANDIDATES);
|
||||
}
|
||||
};
|
||||
|
||||
onMount(() => void load());
|
||||
|
||||
const filteredRows = createMemo(() => {
|
||||
let r = rows();
|
||||
if (statusFilter() !== 'all') r = r.filter((d) => d.status === statusFilter().toUpperCase());
|
||||
const q = search().toLowerCase();
|
||||
const s = statusFilter();
|
||||
return list.filter((u) => {
|
||||
const matchSearch =
|
||||
!q ||
|
||||
(u.name || u.full_name || '').toLowerCase().includes(q) ||
|
||||
(u.email || '').toLowerCase().includes(q);
|
||||
const matchStatus = !s || (u.status || '').toUpperCase() === s;
|
||||
return matchSearch && matchStatus;
|
||||
if (q) {
|
||||
r = r.filter(r => r.name.toLowerCase().includes(q) || r.email.toLowerCase().includes(q));
|
||||
}
|
||||
const sorted = [...r];
|
||||
const mode = sortBy();
|
||||
sorted.sort((a, b) => {
|
||||
if (mode === 'name_desc') return b.name.localeCompare(a.name);
|
||||
if (mode === 'registered_desc') return (b.registeredDate || '').localeCompare(a.registeredDate || '');
|
||||
if (mode === 'registered_asc') return (a.registeredDate || '').localeCompare(b.registeredDate || '');
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
return sorted;
|
||||
});
|
||||
|
||||
const openDetail = (row: CandidateRecord) => { setSelectedCandidate(row); setListTab('view'); setOpenMenuId(null); };
|
||||
|
||||
return (
|
||||
<AdminShell>
|
||||
<div class="flex flex-col -mx-6 -mt-6 min-h-full">
|
||||
{/* White page header */}
|
||||
<div class="bg-white border-b border-gray-200 px-6 py-4">
|
||||
<h1 class="text-xl font-semibold text-gray-900">Candidate Management</h1>
|
||||
<p class="text-sm text-gray-500 mt-0.5">Manage all job seeker accounts on the platform.</p>
|
||||
<div class="w-full space-y-6 pb-8">
|
||||
<div style="margin-bottom: 1.5rem">
|
||||
<h1 class="text-[28px] font-bold leading-tight text-[#111827]">Candidate Management</h1>
|
||||
<p class="mt-1 text-[14px] text-[#6B7280]">Manage and monitor all job seeker accounts on the platform</p>
|
||||
</div>
|
||||
{/* Content */}
|
||||
<div class="flex-1 p-6">
|
||||
<div class="mb-4 flex flex-wrap items-center gap-3">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search by name or email..."
|
||||
value={search()}
|
||||
onInput={(e) => setSearch(e.currentTarget.value)}
|
||||
class="rounded-lg border border-gray-200 px-3 py-2 text-sm outline-none focus:border-[#0a1d37] w-64"
|
||||
/>
|
||||
<select
|
||||
value={statusFilter()}
|
||||
onChange={(e) => setStatusFilter(e.currentTarget.value)}
|
||||
class="rounded-lg border border-gray-200 px-3 py-2 text-sm outline-none focus:border-[#0a1d37]"
|
||||
>
|
||||
<option value="">All Status</option>
|
||||
<option value="ACTIVE">ACTIVE</option>
|
||||
<option value="INACTIVE">INACTIVE</option>
|
||||
<option value="PENDING">PENDING</option>
|
||||
</select>
|
||||
|
||||
{/* ── LIST VIEW ── */}
|
||||
<div>
|
||||
{/* Tabs */}
|
||||
<div style="margin-top:24px;display:flex;align-items:center;gap:24px;border-bottom:1px solid #E5E7EB">
|
||||
{([
|
||||
{ key: 'all', label: 'All Candidates', action: () => { setListTab('all'); setStatusFilter('all'); } },
|
||||
{ key: 'active', label: 'Active', action: () => { setListTab('active'); setStatusFilter('active'); } },
|
||||
{ key: 'pending', label: 'Pending', action: () => { setListTab('pending'); setStatusFilter('pending'); } },
|
||||
{ key: 'view', label: 'View Profile', action: () => setListTab('view') },
|
||||
] as const).map((tab) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={tab.action}
|
||||
style={`padding-bottom:12px;font-size:14px;font-weight:500;background:none;border:none;cursor:pointer;${listTab() === tab.key ? 'color:#FF5E13;border-bottom:2px solid #FF5E13;margin-bottom:-1px' : 'color:#6B7280'}`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div class="table-card">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="data-table w-full text-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Email</th>
|
||||
<th>Status</th>
|
||||
<th>Registered</th>
|
||||
<th class="text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<Show when={users.loading}>
|
||||
<tr><td colspan="5" class="text-center py-8 text-slate-500">Loading...</td></tr>
|
||||
</Show>
|
||||
<Show when={!users.loading && users.error}>
|
||||
<tr><td colspan="5" class="text-center py-8 text-red-700">Failed to load. Is the backend running?</td></tr>
|
||||
</Show>
|
||||
<Show when={!users.loading && !users.error && filtered().length === 0}>
|
||||
<tr><td colspan="5" class="text-center py-8 text-slate-400">No job seeker users found.</td></tr>
|
||||
</Show>
|
||||
<Show when={!users.loading && !users.error && filtered().length > 0}>
|
||||
<For each={filtered()}>
|
||||
{(item) => (
|
||||
<tr class="hover:bg-slate-50">
|
||||
<td class="font-semibold text-slate-900">{item.name || item.full_name || '—'}</td>
|
||||
<td class="text-slate-500">{item.email}</td>
|
||||
<td>
|
||||
{item.status?.toUpperCase() === 'ACTIVE' && (
|
||||
<span class="inline-flex items-center rounded-full bg-green-100 px-2.5 py-0.5 text-xs font-medium text-green-700">ACTIVE</span>
|
||||
)}
|
||||
{item.status?.toUpperCase() === 'INACTIVE' && (
|
||||
<span class="inline-flex items-center rounded-full bg-gray-100 px-2.5 py-0.5 text-xs font-medium text-gray-600">INACTIVE</span>
|
||||
)}
|
||||
{item.status?.toUpperCase() === 'PENDING' && (
|
||||
<span class="inline-flex items-center rounded-full bg-orange-50 px-2.5 py-0.5 text-xs font-medium text-orange-700">PENDING</span>
|
||||
)}
|
||||
{!item.status && <span class="inline-flex items-center rounded-full bg-gray-100 px-2.5 py-0.5 text-xs font-medium text-gray-600">—</span>}
|
||||
</td>
|
||||
<td class="text-slate-500">
|
||||
{item.created_at ? new Date(item.created_at).toLocaleDateString() : '—'}
|
||||
</td>
|
||||
<td>
|
||||
<div class="flex items-center justify-end gap-1">
|
||||
<A class="rounded-lg border border-gray-200 px-3 py-1.5 text-sm hover:bg-gray-50" href={`/admin/users/${item.id}`}>View</A>
|
||||
{/* View Profile panel */}
|
||||
<Show when={listTab() === 'view'}>
|
||||
<Show
|
||||
when={!selectedCandidate()}
|
||||
>
|
||||
<div style="margin-top:24px;border-radius:16px;border:1px solid #E5E7EB;background:white;padding:48px 24px;text-align:center">
|
||||
<p style="font-size:15px;font-weight:600;color:#111827">No candidate selected</p>
|
||||
<p style="margin-top:6px;font-size:13px;color:#6B7280">Click the <strong>⋮</strong> menu on any candidate row and choose <strong>View Profile</strong>.</p>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={selectedCandidate()}>
|
||||
<div style="margin-top:24px;border-radius:16px;border:1px solid #E5E7EB;background:white;box-shadow:0 1px 4px rgba(0,0,0,0.06);overflow:hidden">
|
||||
<div style="padding:20px 24px;border-bottom:1px solid #E5E7EB;display:flex;align-items:center;justify-content:space-between">
|
||||
<div>
|
||||
<h2 style="font-size:18px;font-weight:700;color:#111827">{selectedCandidate()!.name}</h2>
|
||||
<p style="margin-top:2px;font-size:13px;color:#6B7280">{selectedCandidate()!.email} • Joined {selectedCandidate()!.registeredDate}</p>
|
||||
</div>
|
||||
<StatusBadge status={selectedCandidate()!.status} />
|
||||
</div>
|
||||
|
||||
<div style="display:flex;align-items:center;gap:4px;border-bottom:1px solid #E5E7EB;padding:0 24px;background:#FAFAFA">
|
||||
{(['overview', 'experience', 'skills'] as const).map((tab, i) => {
|
||||
const labels = ['Overview', 'Work Experience', 'Skills & Education'];
|
||||
const active = () => detailTab() === tab;
|
||||
return (
|
||||
<button type="button" onClick={() => setDetailTab(tab)} style={`position:relative;padding:14px 12px;font-size:13px;font-weight:600;background:none;border:none;cursor:pointer;color:${active() ? '#FF5E13' : '#6B7280'}`}>
|
||||
{labels[i]}
|
||||
<Show when={active()}><span style="position:absolute;left:0;right:0;bottom:0;height:2px;background:#FF5E13;border-radius:2px 2px 0 0" /></Show>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div style="padding:24px">
|
||||
<Show when={detailTab() === 'overview'}>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:24px">
|
||||
<div style="border:1px solid #E5E7EB;border-radius:12px;padding:20px">
|
||||
<h3 style="font-size:14px;font-weight:700;color:#111827;margin-bottom:16px">Candidate Info</h3>
|
||||
<div style="display:flex;flex-direction:column;gap:12px">
|
||||
{[
|
||||
{ l: 'Location', v: selectedCandidate()!.location || '—' },
|
||||
{ l: 'Experience', v: selectedCandidate()!.experience || '—' },
|
||||
{ l: 'Phone', v: selectedCandidate()!.phone || '—' },
|
||||
{ l: 'Last Active', v: selectedCandidate()!.updatedAt || '—' },
|
||||
].map(item => (
|
||||
<div style="display:flex;justify-content:space-between">
|
||||
<span style="font-size:13px;color:#6B7280">{item.l}</span>
|
||||
<span style="font-size:13px;font-weight:600;color:#111827">{item.v}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;align-items:center;gap:10px;padding:14px 24px;border-top:1px solid #E5E7EB">
|
||||
<button type="button" style="height:36px;border-radius:8px;background:#0D0D2A;padding:0 16px;font-size:13px;font-weight:600;color:white;border:none;cursor:pointer">Edit Profile</button>
|
||||
<button type="button" onClick={() => { setSelectedCandidate(null); setListTab('all'); }} style="height:36px;border-radius:8px;border:1px solid #E5E7EB;background:white;padding:0 16px;font-size:13px;font-weight:600;color:#374151;cursor:pointer">Back to List</button>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</Show>
|
||||
|
||||
<div style={{ display: listTab() === 'view' ? 'none' : 'block' }}>
|
||||
<div style="margin-top:1.5rem;margin-left:-24px;margin-right:-24px;border-radius:0;border-left:none;border-right:none;overflow:hidden;border-top:1px solid #E5E7EB;border-bottom:1px solid #E5E7EB;background:white;box-shadow:0 1px 3px rgba(0,0,0,0.06)">
|
||||
<div style="display:flex;align-items:center;gap:8px;padding:14px 20px;border-bottom:1px solid #F3F4F6">
|
||||
<input
|
||||
value={search()}
|
||||
onInput={(e) => setSearch(e.currentTarget.value)}
|
||||
placeholder="Search candidates..."
|
||||
style="height:34px;flex:1;border-radius:8px;border:1px solid #E5E7EB;background:white;padding:0 12px;font-size:13px;color:#111827;outline:none"
|
||||
/>
|
||||
<div style="position:relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setSortMenuOpen((v) => !v); setFilterMenuOpen(false); }}
|
||||
style="display:inline-flex;height:34px;align-items:center;gap:6px;border-radius:8px;border:1px solid #E5E7EB;background:white;padding:0 12px;font-size:12px;font-weight:500;color:#374151;cursor:pointer"
|
||||
>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M7 4v13"/><path d="m3 13 4 4 4-4"/><path d="M17 20V7"/><path d="m21 11-4-4-4 4"/></svg>
|
||||
Sort
|
||||
</button>
|
||||
<Show when={sortMenuOpen()}>
|
||||
<div style="position:absolute;left:0;top:38px;z-index:30;min-width:200px;border-radius:12px;border:1px solid #E5E7EB;background:white;padding:6px;box-shadow:0 4px 16px rgba(0,0,0,0.1)">
|
||||
{(['name_asc', 'name_desc', 'registered_desc', 'registered_asc'] as const).map((s, i) => (
|
||||
<button type="button" onClick={() => { setSortBy(s); setSortMenuOpen(false); }} style={`display:block;width:100%;border-radius:8px;padding:8px 12px;text-align:left;font-size:13px;background:none;border:none;cursor:pointer;color:${sortBy() === s ? '#FF5E13' : '#374151'};background:${sortBy() === s ? '#FFF1EB' : 'transparent'}`}>
|
||||
{['Name (A-Z)', 'Name (Z-A)', 'Joined (Newest)', 'Joined (Oldest)'][i]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
<div style="position:relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setFilterMenuOpen((v) => !v); setSortMenuOpen(false); }}
|
||||
style="display:inline-flex;height:34px;align-items:center;gap:6px;border-radius:8px;border:1px solid #E5E7EB;background:white;padding:0 12px;font-size:12px;font-weight:500;color:#374151;cursor:pointer"
|
||||
>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 5h18M6 12h12M10 19h4"/></svg>
|
||||
Filters
|
||||
</button>
|
||||
<Show when={filterMenuOpen()}>
|
||||
<div style="position:absolute;left:0;top:38px;z-index:30;min-width:160px;border-radius:12px;border:1px solid #E5E7EB;background:white;padding:6px;box-shadow:0 4px 16px rgba(0,0,0,0.1)">
|
||||
{(['all', 'active', 'pending', 'inactive'] as const).map((s) => (
|
||||
<button type="button" onClick={() => { setStatusFilter(s); setFilterMenuOpen(false); }} style={`display:block;width:100%;border-radius:8px;padding:8px 12px;text-align:left;font-size:13px;background:none;border:none;cursor:pointer;color:${statusFilter() === s ? '#FF5E13' : '#374151'};background:${statusFilter() === s ? '#FFF1EB' : 'transparent'}`}>
|
||||
{s === 'all' ? 'All Status' : s === 'active' ? 'Active' : s === 'pending' ? 'Pending' : 'Inactive'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full">
|
||||
<thead>
|
||||
<tr style="background:#0D0D2A;text-align:left">
|
||||
{['Candidate Name', 'Email', 'Location', 'Registered', 'Status', 'Actions'].map(h => (
|
||||
<th style="padding:10px 20px;font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:0.05em;color:#FFFFFF;white-space:nowrap">{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<For each={filteredRows()}>
|
||||
{(row) => (
|
||||
<tr style="border-bottom:1px solid #F3F4F6" class="hover:bg-[#FAFAFA] transition-colors">
|
||||
<td style="padding:12px 20px;font-size:14px;font-weight:600;color:#111827">{row.name}</td>
|
||||
<td style="padding:12px 20px;font-size:13px;color:#6B7280">{row.email}</td>
|
||||
<td style="padding:12px 20px;font-size:13px;color:#6B7280">{row.location || '—'}</td>
|
||||
<td style="padding:12px 20px;font-size:13px;color:#6B7280">{row.registeredDate}</td>
|
||||
<td style="padding:12px 20px"><StatusBadge status={row.status} /></td>
|
||||
<td style="padding:12px 20px;position:relative">
|
||||
<button type="button" onClick={() => setOpenMenuId(openMenuId() === row.id ? null : row.id)} style="display:inline-flex;height:32px;width:32px;align-items:center;justify-content:center;border-radius:8px;color:#9CA3AF;background:none;border:none;cursor:pointer">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="5" r="1.5"/><circle cx="12" cy="12" r="1.5"/><circle cx="12" cy="19" r="1.5"/></svg>
|
||||
</button>
|
||||
<Show when={openMenuId() === row.id}>
|
||||
<div style="position:absolute;right:20px;top:44px;z-index:20;width:180px;border-radius:12px;border:1px solid #E5E7EB;background:white;padding:6px;box-shadow:0 4px 20px rgba(0,0,0,0.12)">
|
||||
<button type="button" onClick={() => openDetail(row)} style="display:flex;width:100%;align-items:center;gap:10px;border-radius:8px;padding:8px 12px;font-size:13px;color:#374151;background:none;border:none;cursor:pointer;text-align:left">View Profile</button>
|
||||
<button type="button" style="display:flex;width:100%;align-items:center;gap:10px;border-radius:8px;padding:8px 12px;font-size:13px;color:#DC2626;background:none;border:none;cursor:pointer;text-align:left">Deactivate</button>
|
||||
</div>
|
||||
</Show>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</tbody>
|
||||
</table>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,161 +1,266 @@
|
|||
import { A } from '@solidjs/router';
|
||||
import { createMemo, createResource, createSignal, For, Show } from 'solid-js';
|
||||
import { For, Show, createMemo, createSignal, onMount } from 'solid-js';
|
||||
import AdminShell from '~/components/AdminShell';
|
||||
import type { CrudRecord } from '~/lib/admin/types';
|
||||
|
||||
const API = '/api/gateway';
|
||||
|
||||
interface Company {
|
||||
id: string;
|
||||
company_name?: string;
|
||||
companyName?: string;
|
||||
name?: string;
|
||||
type CompanyRecord = CrudRecord & {
|
||||
industry?: string;
|
||||
city?: string;
|
||||
email?: string;
|
||||
status: 'ACTIVE' | 'INACTIVE' | 'PENDING' | 'SUSPENDED' | 'REJECTED';
|
||||
created_at?: string;
|
||||
createdAt?: string;
|
||||
}
|
||||
status: 'ACTIVE' | 'INACTIVE' | 'PENDING' | 'SUSPENDED';
|
||||
};
|
||||
|
||||
async function fetchCompanies(): Promise<Company[]> {
|
||||
try {
|
||||
const res = await fetch(`${API}/api/admin/companies`);
|
||||
if (!res.ok) throw new Error('Failed to load');
|
||||
const data = await res.json();
|
||||
return Array.isArray(data) ? data : (data.companies || []);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
const FALLBACK_COMPANIES: CompanyRecord[] = [
|
||||
{ id: 'cp1', name: 'Tech Solutions Inc', industry: 'Software', city: 'Mumbai', email: 'contact@techsolutions.com', status: 'ACTIVE', updatedAt: '2026-03-27' },
|
||||
{ id: 'cp2', name: 'Creative Designs', industry: 'Design', city: 'Bangalore', email: 'hello@creativedesigns.in', status: 'ACTIVE', updatedAt: '2026-03-27' },
|
||||
{ id: 'cp3', name: 'Global Logistics', industry: 'Logistics', city: 'Chennai', email: 'info@globallogistics.com', status: 'PENDING', updatedAt: '2026-03-27' },
|
||||
];
|
||||
|
||||
function StatusBadge(props: { status: string }) {
|
||||
if (props.status === 'ACTIVE') {
|
||||
return <span class="inline-flex items-center rounded-full bg-green-100 px-2.5 py-0.5 text-xs font-medium text-green-700">ACTIVE</span>;
|
||||
}
|
||||
if (props.status === 'PENDING') {
|
||||
return <span class="inline-flex items-center rounded-full bg-amber-500 px-2.5 py-0.5 text-xs font-medium text-white">PENDING</span>;
|
||||
}
|
||||
return <span class="inline-flex items-center rounded-full bg-gray-100 px-2.5 py-0.5 text-xs font-medium text-gray-600">{props.status}</span>;
|
||||
const active = () => props.status === 'ACTIVE';
|
||||
const pending = () => props.status === 'PENDING';
|
||||
return (
|
||||
<span style={`display:inline-flex;align-items:center;border-radius:9999px;border:1px solid ${active() ? '#FFD8C2' : pending() ? '#F6D78F' : '#D1D5DB'};background:${active() ? '#FFF1EB' : pending() ? '#FFF3D6' : '#F3F4F6'};color:${active() ? '#FF5E13' : pending() ? '#B7791F' : '#4B5563'};padding:2px 10px;font-size:12px;font-weight:500`}>
|
||||
<span style={`display:inline-block;width:6px;height:6px;border-radius:50%;background:${active() ? '#FF5E13' : pending() ? '#B7791F' : '#9CA3AF'};margin-right:5px;flex-shrink:0`} />
|
||||
{props.status.charAt(0) + props.status.slice(1).toLowerCase()}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default function CompanyPage() {
|
||||
const [companies, { refetch }] = createResource(fetchCompanies);
|
||||
export default function CompanyManagementPage() {
|
||||
const [view, setView] = createSignal<'list' | 'detail' | 'form'>('list');
|
||||
const [listTab, setListTab] = createSignal<'all' | 'create' | 'view'>('all');
|
||||
const [detailTab, setDetailTab] = createSignal<'overview' | 'employees' | 'verification'>('overview');
|
||||
|
||||
const [search, setSearch] = createSignal('');
|
||||
const [deleting, setDeleting] = createSignal('');
|
||||
const [actionError, setActionError] = createSignal('');
|
||||
const [statusFilter, setStatusFilter] = createSignal('all');
|
||||
const [sortBy, setSortBy] = createSignal<'name_asc' | 'name_desc' | 'industry_asc' | 'industry_desc'>('name_asc');
|
||||
const [sortMenuOpen, setSortMenuOpen] = createSignal(false);
|
||||
const [filterMenuOpen, setFilterMenuOpen] = createSignal(false);
|
||||
const [rows, setRows] = createSignal<CompanyRecord[]>([]);
|
||||
const [selectedCompany, setSelectedCompany] = createSignal<CompanyRecord | null>(null);
|
||||
const [openMenuId, setOpenMenuId] = createSignal<string | null>(null);
|
||||
|
||||
const filtered = createMemo(() => {
|
||||
const list = companies() ?? [];
|
||||
const q = search().toLowerCase();
|
||||
if (!q) return list;
|
||||
return list.filter((c) => {
|
||||
const name = (c.company_name || c.companyName || c.name || '').toLowerCase();
|
||||
const email = (c.email || '').toLowerCase();
|
||||
return name.includes(q) || email.includes(q);
|
||||
});
|
||||
});
|
||||
|
||||
const handleDelete = async (id: string, name: string) => {
|
||||
if (!confirm(`Delete ${name}?`)) return;
|
||||
const load = async () => {
|
||||
try {
|
||||
setDeleting(id);
|
||||
setActionError('');
|
||||
const res = await fetch(`${API}/api/admin/companies/${id}`, { method: 'DELETE' });
|
||||
if (!res.ok) throw new Error('Failed to delete');
|
||||
refetch();
|
||||
} catch (err: any) {
|
||||
setActionError(err.message || 'Failed to delete company');
|
||||
} finally {
|
||||
setDeleting('');
|
||||
const res = await fetch(`${API}/api/admin/companies`);
|
||||
if (!res.ok) throw new Error();
|
||||
const data = await res.json();
|
||||
const list = Array.isArray(data) ? data : (data.companies || []);
|
||||
if (list.length === 0) setRows(FALLBACK_COMPANIES);
|
||||
else setRows(list.map((c: any) => ({
|
||||
id: c.id,
|
||||
name: c.company_name || c.companyName || c.name || '—',
|
||||
industry: c.industry,
|
||||
city: c.city,
|
||||
email: c.email,
|
||||
status: (c.status || 'ACTIVE').toUpperCase(),
|
||||
updatedAt: c.updated_at || c.createdAt || ''
|
||||
} as CompanyRecord)));
|
||||
} catch {
|
||||
setRows(FALLBACK_COMPANIES);
|
||||
}
|
||||
};
|
||||
|
||||
onMount(() => void load());
|
||||
|
||||
const filteredRows = createMemo(() => {
|
||||
let r = rows();
|
||||
if (statusFilter() !== 'all') r = r.filter((d) => d.status === statusFilter().toUpperCase());
|
||||
const q = search().toLowerCase();
|
||||
if (q) {
|
||||
r = r.filter(r => r.name.toLowerCase().includes(q) || (r.email || '').toLowerCase().includes(q));
|
||||
}
|
||||
const sorted = [...r];
|
||||
const mode = sortBy();
|
||||
sorted.sort((a, b) => {
|
||||
if (mode === 'name_desc') return b.name.localeCompare(a.name);
|
||||
if (mode === 'industry_desc') return (b.industry || '').localeCompare(a.industry || '');
|
||||
if (mode === 'industry_asc') return (a.industry || '').localeCompare(b.industry || '');
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
return sorted;
|
||||
});
|
||||
|
||||
const openDetail = (row: CompanyRecord) => { setSelectedCompany(row); setListTab('view'); setOpenMenuId(null); };
|
||||
|
||||
return (
|
||||
<AdminShell>
|
||||
<div class="flex flex-col -mx-6 -mt-6 min-h-full">
|
||||
{/* White page header */}
|
||||
<div class="bg-white border-b border-gray-200 px-6 py-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-xl font-semibold text-gray-900">Company Management</h1>
|
||||
<p class="text-sm text-gray-500 mt-0.5">Manage all company accounts on the platform.</p>
|
||||
</div>
|
||||
<A class="btn-primary" href="/admin/company/create">Create Company</A>
|
||||
</div>
|
||||
<div class="w-full space-y-6 pb-8">
|
||||
<div style="margin-bottom: 1.5rem">
|
||||
<h1 class="text-[28px] font-bold leading-tight text-[#111827]">Company Management</h1>
|
||||
<p class="mt-1 text-[14px] text-[#6B7280]">Manage and monitor all corporate accounts and business entities</p>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div class="flex-1 p-6">
|
||||
<Show when={actionError()}>
|
||||
<div class="mb-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{actionError()}</div>
|
||||
</Show>
|
||||
|
||||
{/* Search */}
|
||||
<div class="mb-4 flex flex-wrap items-center gap-3">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search by name or email..."
|
||||
value={search()}
|
||||
onInput={(e) => setSearch(e.currentTarget.value)}
|
||||
class="rounded-lg border border-gray-200 px-3 py-2 text-sm outline-none focus:border-[#0a1d37] w-64"
|
||||
/>
|
||||
{/* ── LIST VIEW ── */}
|
||||
<div>
|
||||
{/* Tabs */}
|
||||
<div style="margin-top:24px;display:flex;align-items:center;gap:24px;border-bottom:1px solid #E5E7EB">
|
||||
{([
|
||||
{ key: 'all', label: 'All Companies', action: () => { setListTab('all'); setStatusFilter('all'); } },
|
||||
{ key: 'create', label: 'Create Company', action: () => { setListTab('create'); setView('form'); } },
|
||||
{ key: 'view', label: 'View Profile', action: () => setListTab('view') },
|
||||
] as const).map((tab) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={tab.action}
|
||||
style={`padding-bottom:12px;font-size:14px;font-weight:500;background:none;border:none;cursor:pointer;${listTab() === tab.key ? 'color:#FF5E13;border-bottom:2px solid #FF5E13;margin-bottom:-1px' : 'color:#6B7280'}`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div class="table-card">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="data-table w-full text-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Company Name</th>
|
||||
<th>Industry</th>
|
||||
<th>City</th>
|
||||
<th>Email</th>
|
||||
<th>Status</th>
|
||||
<th class="text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<Show when={companies.loading}>
|
||||
<tr><td colspan="6" class="text-center py-8 text-slate-500">Loading...</td></tr>
|
||||
{/* View Profile panel */}
|
||||
<Show when={listTab() === 'view'}>
|
||||
<Show
|
||||
when={!selectedCompany()}
|
||||
>
|
||||
<div style="margin-top:24px;border-radius:16px;border:1px solid #E5E7EB;background:white;padding:48px 24px;text-align:center">
|
||||
<p style="font-size:15px;font-weight:600;color:#111827">No company selected</p>
|
||||
<p style="margin-top:6px;font-size:13px;color:#6B7280">Click the <strong>⋮</strong> menu on any company row and choose <strong>View Profile</strong>.</p>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={selectedCompany()}>
|
||||
<div style="margin-top:24px;border-radius:16px;border:1px solid #E5E7EB;background:white;box-shadow:0 1px 4px rgba(0,0,0,0.06);overflow:hidden">
|
||||
<div style="padding:20px 24px;border-bottom:1px solid #E5E7EB;display:flex;align-items:center;justify-content:space-between">
|
||||
<div>
|
||||
<h2 style="font-size:18px;font-weight:700;color:#111827">{selectedCompany()!.name}</h2>
|
||||
<p style="margin-top:2px;font-size:13px;color:#6B7280">{selectedCompany()!.industry} • {selectedCompany()!.city}</p>
|
||||
</div>
|
||||
<StatusBadge status={selectedCompany()!.status} />
|
||||
</div>
|
||||
|
||||
<div style="display:flex;align-items:center;gap:4px;border-bottom:1px solid #E5E7EB;padding:0 24px;background:#FAFAFA">
|
||||
{(['overview', 'employees', 'verification'] as const).map((tab, i) => {
|
||||
const labels = ['Overview', 'Employee List', 'Verification'];
|
||||
const active = () => detailTab() === tab;
|
||||
return (
|
||||
<button type="button" onClick={() => setDetailTab(tab)} style={`position:relative;padding:14px 12px;font-size:13px;font-weight:600;background:none;border:none;cursor:pointer;color:${active() ? '#FF5E13' : '#6B7280'}`}>
|
||||
{labels[i]}
|
||||
<Show when={active()}><span style="position:absolute;left:0;right:0;bottom:0;height:2px;background:#FF5E13;border-radius:2px 2px 0 0" /></Show>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div style="padding:24px">
|
||||
<Show when={detailTab() === 'overview'}>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:24px">
|
||||
<div style="border:1px solid #E5E7EB;border-radius:12px;padding:20px">
|
||||
<h3 style="font-size:14px;font-weight:700;color:#111827;margin-bottom:16px">Company Profile</h3>
|
||||
<div style="display:flex;flex-direction:column;gap:12px">
|
||||
{[
|
||||
{ l: 'Industry', v: selectedCompany()!.industry || '—' },
|
||||
{ l: 'City', v: selectedCompany()!.city || '—' },
|
||||
{ l: 'Email', v: selectedCompany()!.email || '—' },
|
||||
{ l: 'Last Updated', v: selectedCompany()!.updatedAt || '—' },
|
||||
].map(item => (
|
||||
<div style="display:flex;justify-content:space-between">
|
||||
<span style="font-size:13px;color:#6B7280">{item.l}</span>
|
||||
<span style="font-size:13px;font-weight:600;color:#111827">{item.v}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={!companies.loading && companies.error}>
|
||||
<tr><td colspan="6" class="text-center py-8 text-red-700">Failed to load. Is the backend running?</td></tr>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;align-items:center;gap:10px;padding:14px 24px;border-top:1px solid #E5E7EB">
|
||||
<button type="button" style="height:36px;border-radius:8px;background:#0D0D2A;padding:0 16px;font-size:13px;font-weight:600;color:white;border:none;cursor:pointer">Edit Company</button>
|
||||
<button type="button" onClick={() => { setSelectedCompany(null); setListTab('all'); }} style="height:36px;border-radius:8px;border:1px solid #E5E7EB;background:white;padding:0 16px;font-size:13px;font-weight:600;color:#374151;cursor:pointer">Back to List</button>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</Show>
|
||||
|
||||
<div style={{ display: listTab() === 'view' ? 'none' : 'block' }}>
|
||||
<div style="margin-top:1.5rem;margin-left:-24px;margin-right:-24px;border-radius:0;border-left:none;border-right:none;overflow:hidden;border-top:1px solid #E5E7EB;border-bottom:1px solid #E5E7EB;background:white;box-shadow:0 1px 3px rgba(0,0,0,0.06)">
|
||||
<div style="display:flex;align-items:center;gap:8px;padding:14px 20px;border-bottom:1px solid #F3F4F6">
|
||||
<input
|
||||
value={search()}
|
||||
onInput={(e) => setSearch(e.currentTarget.value)}
|
||||
placeholder="Search companies..."
|
||||
style="height:34px;flex:1;border-radius:8px;border:1px solid #E5E7EB;background:white;padding:0 12px;font-size:13px;color:#111827;outline:none"
|
||||
/>
|
||||
<div style="position:relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setSortMenuOpen((v) => !v); setFilterMenuOpen(false); }}
|
||||
style="display:inline-flex;height:34px;align-items:center;gap:6px;border-radius:8px;border:1px solid #E5E7EB;background:white;padding:0 12px;font-size:12px;font-weight:500;color:#374151;cursor:pointer"
|
||||
>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M7 4v13"/><path d="m3 13 4 4 4-4"/><path d="M17 20V7"/><path d="m21 11-4-4-4 4"/></svg>
|
||||
Sort
|
||||
</button>
|
||||
<Show when={sortMenuOpen()}>
|
||||
<div style="position:absolute;left:0;top:38px;z-index:30;min-width:200px;border-radius:12px;border:1px solid #E5E7EB;background:white;padding:6px;box-shadow:0 4px 16px rgba(0,0,0,0.1)">
|
||||
{(['name_asc', 'name_desc', 'industry_asc', 'industry_desc'] as const).map((s, i) => (
|
||||
<button type="button" onClick={() => { setSortBy(s); setSortMenuOpen(false); }} style={`display:block;width:100%;border-radius:8px;padding:8px 12px;text-align:left;font-size:13px;background:none;border:none;cursor:pointer;color:${sortBy() === s ? '#FF5E13' : '#374151'};background:${sortBy() === s ? '#FFF1EB' : 'transparent'}`}>
|
||||
{['Name (A-Z)', 'Name (Z-A)', 'Industry (A-Z)', 'Industry (Z-A)'][i]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={!companies.loading && !companies.error && filtered().length === 0}>
|
||||
<tr><td colspan="6" class="text-center py-8 text-slate-400">No companies found.</td></tr>
|
||||
</div>
|
||||
<div style="position:relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setFilterMenuOpen((v) => !v); setSortMenuOpen(false); }}
|
||||
style="display:inline-flex;height:34px;align-items:center;gap:6px;border-radius:8px;border:1px solid #E5E7EB;background:white;padding:0 12px;font-size:12px;font-weight:500;color:#374151;cursor:pointer"
|
||||
>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 5h18M6 12h12M10 19h4"/></svg>
|
||||
Filters
|
||||
</button>
|
||||
<Show when={filterMenuOpen()}>
|
||||
<div style="position:absolute;left:0;top:38px;z-index:30;min-width:160px;border-radius:12px;border:1px solid #E5E7EB;background:white;padding:6px;box-shadow:0 4px 16px rgba(0,0,0,0.1)">
|
||||
{(['all', 'active', 'pending', 'inactive'] as const).map((s) => (
|
||||
<button type="button" onClick={() => { setStatusFilter(s); setFilterMenuOpen(false); }} style={`display:block;width:100%;border-radius:8px;padding:8px 12px;text-align:left;font-size:13px;background:none;border:none;cursor:pointer;color:${statusFilter() === s ? '#FF5E13' : '#374151'};background:${statusFilter() === s ? '#FFF1EB' : 'transparent'}`}>
|
||||
{s === 'all' ? 'All Status' : s === 'active' ? 'Active' : s === 'pending' ? 'Pending' : 'Inactive'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={!companies.loading && !companies.error && filtered().length > 0}>
|
||||
<For each={filtered()}>
|
||||
{(item) => {
|
||||
const displayName = item.company_name || item.companyName || item.name || '—';
|
||||
return (
|
||||
<tr class="hover:bg-slate-50">
|
||||
<td class="font-semibold text-slate-900">{displayName}</td>
|
||||
<td class="text-slate-500">{item.industry || '—'}</td>
|
||||
<td class="text-slate-500">{item.city || '—'}</td>
|
||||
<td class="text-slate-500">{item.email || '—'}</td>
|
||||
<td>
|
||||
<StatusBadge status={item.status} />
|
||||
</td>
|
||||
<td>
|
||||
<div class="flex items-center justify-end gap-1">
|
||||
<A class="inline-flex items-center rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors" href={`/admin/company/${item.id}`}>View</A>
|
||||
<button
|
||||
class="inline-flex items-center rounded-lg border border-red-200 bg-red-50 px-4 py-2 text-sm font-medium text-red-600 hover:bg-red-100 transition-colors"
|
||||
disabled={deleting() === item.id}
|
||||
onClick={() => handleDelete(item.id, displayName)}
|
||||
>
|
||||
{deleting() === item.id ? '...' : 'Delete'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full">
|
||||
<thead>
|
||||
<tr style="background:#0D0D2A;text-align:left">
|
||||
{['Company Name', 'Industry', 'City', 'Email', 'Status', 'Actions'].map(h => (
|
||||
<th style="padding:10px 20px;font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:0.05em;color:#FFFFFF;white-space:nowrap">{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<For each={filteredRows()}>
|
||||
{(row) => (
|
||||
<tr style="border-bottom:1px solid #F3F4F6" class="hover:bg-[#FAFAFA] transition-colors">
|
||||
<td style="padding:12px 20px;font-size:14px;font-weight:600;color:#111827">{row.name}</td>
|
||||
<td style="padding:12px 20px;font-size:13px;color:#6B7280">{row.industry || '—'}</td>
|
||||
<td style="padding:12px 20px;font-size:13px;color:#6B7280">{row.city || '—'}</td>
|
||||
<td style="padding:12px 20px;font-size:13px;color:#6B7280">{row.email || '—'}</td>
|
||||
<td style="padding:12px 20px"><StatusBadge status={row.status} /></td>
|
||||
<td style="padding:12px 20px;position:relative">
|
||||
<button type="button" onClick={() => setOpenMenuId(openMenuId() === row.id ? null : row.id)} style="display:inline-flex;height:32px;width:32px;align-items:center;justify-content:center;border-radius:8px;color:#9CA3AF;background:none;border:none;cursor:pointer">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="5" r="1.5"/><circle cx="12" cy="12" r="1.5"/><circle cx="12" cy="19" r="1.5"/></svg>
|
||||
</button>
|
||||
<Show when={openMenuId() === row.id}>
|
||||
<div style="position:absolute;right:20px;top:44px;z-index:20;width:180px;border-radius:12px;border:1px solid #E5E7EB;background:white;padding:6px;box-shadow:0 4px 20px rgba(0,0,0,0.12)">
|
||||
<button type="button" onClick={() => openDetail(row)} style="display:flex;width:100%;align-items:center;gap:10px;border-radius:8px;padding:8px 12px;font-size:13px;color:#374151;background:none;border:none;cursor:pointer;text-align:left">View Profile</button>
|
||||
<button type="button" style="display:flex;width:100%;align-items:center;gap:10px;border-radius:8px;padding:8px 12px;font-size:13px;color:#374151;background:none;border:none;cursor:pointer;text-align:left">Edit Company</button>
|
||||
<button type="button" style="display:flex;width:100%;align-items:center;gap:10px;border-radius:8px;padding:8px 12px;font-size:13px;color:#DC2626;background:none;border:none;cursor:pointer;text-align:left">Deactivate</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}}
|
||||
</Show>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</tbody>
|
||||
</table>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,123 +1,268 @@
|
|||
import { A } from '@solidjs/router';
|
||||
import { createMemo, createResource, createSignal, For, Show } from 'solid-js';
|
||||
import { For, Show, createMemo, createSignal, onMount } from 'solid-js';
|
||||
import AdminShell from '~/components/AdminShell';
|
||||
import type { CrudRecord } from '~/lib/admin/types';
|
||||
|
||||
const API = '/api/gateway';
|
||||
|
||||
async function fetchUsers(): Promise<any[]> {
|
||||
try {
|
||||
const res = await fetch(`${API}/api/admin/users?role=customer`);
|
||||
if (!res.ok) throw new Error('Failed to load');
|
||||
const data = await res.json();
|
||||
return Array.isArray(data) ? data : (data.users || []);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
type CustomerRecord = CrudRecord & {
|
||||
email: string;
|
||||
phone?: string;
|
||||
location?: string;
|
||||
totalOrders?: number;
|
||||
status: 'ACTIVE' | 'INACTIVE' | 'PENDING';
|
||||
registeredDate?: string;
|
||||
};
|
||||
|
||||
const FALLBACK_CUSTOMERS: CustomerRecord[] = [
|
||||
{ id: 'cs1', name: 'Arjun Sharma', email: 'arjun.s@example.com', phone: '+91 98765 43210', location: 'Mumbai', totalOrders: 12, status: 'ACTIVE', registeredDate: '2026-01-15', updatedAt: '2026-03-27' },
|
||||
{ id: 'cs2', name: 'Priya Nair', email: 'priya.n@example.com', phone: '+91 98765 43211', location: 'Bangalore', totalOrders: 5, status: 'ACTIVE', registeredDate: '2026-02-10', updatedAt: '2026-03-27' },
|
||||
{ id: 'cs3', name: 'Rahul Verma', email: 'rahul.v@example.com', phone: '+91 98765 43212', location: 'Delhi', totalOrders: 0, status: 'PENDING', registeredDate: '2026-03-05', updatedAt: '2026-03-27' },
|
||||
];
|
||||
|
||||
function StatusBadge(props: { status: string }) {
|
||||
const active = () => props.status === 'ACTIVE';
|
||||
const pending = () => props.status === 'PENDING';
|
||||
return (
|
||||
<span style={`display:inline-flex;align-items:center;border-radius:9999px;border:1px solid ${active() ? '#FFD8C2' : pending() ? '#F6D78F' : '#D1D5DB'};background:${active() ? '#FFF1EB' : pending() ? '#FFF3D6' : '#F3F4F6'};color:${active() ? '#FF5E13' : pending() ? '#B7791F' : '#4B5563'};padding:2px 10px;font-size:12px;font-weight:500`}>
|
||||
<span style={`display:inline-block;width:6px;height:6px;border-radius:50%;background:${active() ? '#FF5E13' : pending() ? '#B7791F' : '#9CA3AF'};margin-right:5px;flex-shrink:0`} />
|
||||
{props.status.charAt(0) + props.status.slice(1).toLowerCase()}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default function CustomerPage() {
|
||||
const [users] = createResource(fetchUsers);
|
||||
export default function CustomerManagementPage() {
|
||||
const [view, setView] = createSignal<'list' | 'detail'>('list');
|
||||
const [listTab, setListTab] = createSignal<'all' | 'active' | 'pending' | 'view'>('all');
|
||||
const [detailTab, setDetailTab] = createSignal<'overview' | 'orders' | 'support'>('overview');
|
||||
|
||||
const [search, setSearch] = createSignal('');
|
||||
const [statusFilter, setStatusFilter] = createSignal('');
|
||||
const [statusFilter, setStatusFilter] = createSignal('all');
|
||||
const [sortBy, setSortBy] = createSignal<'name_asc' | 'name_desc' | 'orders_desc' | 'orders_asc'>('name_asc');
|
||||
const [sortMenuOpen, setSortMenuOpen] = createSignal(false);
|
||||
const [filterMenuOpen, setFilterMenuOpen] = createSignal(false);
|
||||
const [rows, setRows] = createSignal<CustomerRecord[]>([]);
|
||||
const [selectedCustomer, setSelectedCustomer] = createSignal<CustomerRecord | null>(null);
|
||||
const [openMenuId, setOpenMenuId] = createSignal<string | null>(null);
|
||||
|
||||
const filtered = createMemo(() => {
|
||||
const list = users() ?? [];
|
||||
const load = async () => {
|
||||
try {
|
||||
const res = await fetch(`${API}/api/admin/users?role=customer`);
|
||||
if (!res.ok) throw new Error();
|
||||
const data = await res.json();
|
||||
const list = Array.isArray(data) ? data : (data.users || []);
|
||||
if (list.length === 0) setRows(FALLBACK_CUSTOMERS);
|
||||
else setRows(list.map((u: any) => ({
|
||||
id: u.id,
|
||||
name: u.name || u.full_name || '—',
|
||||
email: u.email,
|
||||
status: (u.status || 'ACTIVE').toUpperCase(),
|
||||
updatedAt: u.updated_at || '',
|
||||
registeredDate: u.created_at ? new Date(u.created_at).toLocaleDateString() : '—',
|
||||
totalOrders: u.total_orders || 0
|
||||
} as CustomerRecord)));
|
||||
} catch {
|
||||
setRows(FALLBACK_CUSTOMERS);
|
||||
}
|
||||
};
|
||||
|
||||
onMount(() => void load());
|
||||
|
||||
const filteredRows = createMemo(() => {
|
||||
let r = rows();
|
||||
if (statusFilter() !== 'all') r = r.filter((d) => d.status === statusFilter().toUpperCase());
|
||||
const q = search().toLowerCase();
|
||||
const s = statusFilter();
|
||||
return list.filter((u) => {
|
||||
const matchSearch =
|
||||
!q ||
|
||||
(u.name || u.full_name || '').toLowerCase().includes(q) ||
|
||||
(u.email || '').toLowerCase().includes(q);
|
||||
const matchStatus = !s || (u.status || '').toUpperCase() === s;
|
||||
return matchSearch && matchStatus;
|
||||
if (q) {
|
||||
r = r.filter(r => r.name.toLowerCase().includes(q) || r.email.toLowerCase().includes(q));
|
||||
}
|
||||
const sorted = [...r];
|
||||
const mode = sortBy();
|
||||
sorted.sort((a, b) => {
|
||||
if (mode === 'name_desc') return b.name.localeCompare(a.name);
|
||||
if (mode === 'orders_desc') return (b.totalOrders || 0) - (a.totalOrders || 0);
|
||||
if (mode === 'orders_asc') return (a.totalOrders || 0) - (b.totalOrders || 0);
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
return sorted;
|
||||
});
|
||||
|
||||
const openDetail = (row: CustomerRecord) => { setSelectedCustomer(row); setListTab('view'); setOpenMenuId(null); };
|
||||
|
||||
return (
|
||||
<AdminShell>
|
||||
<div class="flex flex-col -mx-6 -mt-6 min-h-full">
|
||||
{/* White page header */}
|
||||
<div class="bg-white border-b border-gray-200 px-6 py-4">
|
||||
<h1 class="text-xl font-semibold text-gray-900">Customer Management</h1>
|
||||
<p class="text-sm text-gray-500 mt-0.5">Manage all customer accounts on the platform.</p>
|
||||
<div class="w-full space-y-6 pb-8">
|
||||
<div style="margin-bottom: 1.5rem">
|
||||
<h1 class="text-[28px] font-bold leading-tight text-[#111827]">Customer Management</h1>
|
||||
<p class="mt-1 text-[14px] text-[#6B7280]">Manage and monitor all customer accounts on the platform</p>
|
||||
</div>
|
||||
{/* Content */}
|
||||
<div class="flex-1 p-6">
|
||||
<div class="mb-4 flex flex-wrap items-center gap-3">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search by name or email..."
|
||||
value={search()}
|
||||
onInput={(e) => setSearch(e.currentTarget.value)}
|
||||
class="rounded-lg border border-gray-200 px-3 py-2 text-sm outline-none focus:border-[#0a1d37] w-64"
|
||||
/>
|
||||
<select
|
||||
value={statusFilter()}
|
||||
onChange={(e) => setStatusFilter(e.currentTarget.value)}
|
||||
class="rounded-lg border border-gray-200 px-3 py-2 text-sm outline-none focus:border-[#0a1d37]"
|
||||
>
|
||||
<option value="">All Status</option>
|
||||
<option value="ACTIVE">ACTIVE</option>
|
||||
<option value="INACTIVE">INACTIVE</option>
|
||||
<option value="PENDING">PENDING</option>
|
||||
</select>
|
||||
|
||||
{/* ── LIST VIEW ── */}
|
||||
<div>
|
||||
{/* Tabs */}
|
||||
<div style="margin-top:24px;display:flex;align-items:center;gap:24px;border-bottom:1px solid #E5E7EB">
|
||||
{([
|
||||
{ key: 'all', label: 'All Customers', action: () => { setListTab('all'); setStatusFilter('all'); } },
|
||||
{ key: 'active', label: 'Active', action: () => { setListTab('active'); setStatusFilter('active'); } },
|
||||
{ key: 'pending', label: 'Pending', action: () => { setListTab('pending'); setStatusFilter('pending'); } },
|
||||
{ key: 'view', label: 'View Profile', action: () => setListTab('view') },
|
||||
] as const).map((tab) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={tab.action}
|
||||
style={`padding-bottom:12px;font-size:14px;font-weight:500;background:none;border:none;cursor:pointer;${listTab() === tab.key ? 'color:#FF5E13;border-bottom:2px solid #FF5E13;margin-bottom:-1px' : 'color:#6B7280'}`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div class="table-card">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="data-table w-full text-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Email</th>
|
||||
<th>Status</th>
|
||||
<th>Registered</th>
|
||||
<th class="text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<Show when={users.loading}>
|
||||
<tr><td colspan="5" class="text-center py-8 text-slate-500">Loading...</td></tr>
|
||||
</Show>
|
||||
<Show when={!users.loading && users.error}>
|
||||
<tr><td colspan="5" class="text-center py-8 text-red-700">Failed to load. Is the backend running?</td></tr>
|
||||
</Show>
|
||||
<Show when={!users.loading && !users.error && filtered().length === 0}>
|
||||
<tr><td colspan="5" class="text-center py-8 text-slate-400">No customer users found.</td></tr>
|
||||
</Show>
|
||||
<Show when={!users.loading && !users.error && filtered().length > 0}>
|
||||
<For each={filtered()}>
|
||||
{(item) => (
|
||||
<tr class="hover:bg-slate-50">
|
||||
<td class="font-semibold text-slate-900">{item.name || item.full_name || '—'}</td>
|
||||
<td class="text-slate-500">{item.email}</td>
|
||||
<td>
|
||||
{item.status?.toUpperCase() === 'ACTIVE' && (
|
||||
<span class="inline-flex items-center rounded-full bg-green-100 px-2.5 py-0.5 text-xs font-medium text-green-700">ACTIVE</span>
|
||||
)}
|
||||
{item.status?.toUpperCase() === 'INACTIVE' && (
|
||||
<span class="inline-flex items-center rounded-full bg-gray-100 px-2.5 py-0.5 text-xs font-medium text-gray-600">INACTIVE</span>
|
||||
)}
|
||||
{item.status?.toUpperCase() === 'PENDING' && (
|
||||
<span class="inline-flex items-center rounded-full bg-orange-50 px-2.5 py-0.5 text-xs font-medium text-orange-700">PENDING</span>
|
||||
)}
|
||||
{!item.status && <span class="inline-flex items-center rounded-full bg-gray-100 px-2.5 py-0.5 text-xs font-medium text-gray-600">—</span>}
|
||||
</td>
|
||||
<td class="text-slate-500">
|
||||
{item.created_at ? new Date(item.created_at).toLocaleDateString() : '—'}
|
||||
</td>
|
||||
<td>
|
||||
<div class="flex items-center justify-end gap-1">
|
||||
<A class="rounded-lg border border-gray-200 px-3 py-1.5 text-sm hover:bg-gray-50" href={`/admin/users/${item.id}`}>View</A>
|
||||
{/* View Profile panel */}
|
||||
<Show when={listTab() === 'view'}>
|
||||
<Show
|
||||
when={!selectedCustomer()}
|
||||
>
|
||||
<div style="margin-top:24px;border-radius:16px;border:1px solid #E5E7EB;background:white;padding:48px 24px;text-align:center">
|
||||
<p style="font-size:15px;font-weight:600;color:#111827">No customer selected</p>
|
||||
<p style="margin-top:6px;font-size:13px;color:#6B7280">Click the <strong>⋮</strong> menu on any customer row and choose <strong>View Profile</strong>.</p>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={selectedCustomer()}>
|
||||
<div style="margin-top:24px;border-radius:16px;border:1px solid #E5E7EB;background:white;box-shadow:0 1px 4px rgba(0,0,0,0.06);overflow:hidden">
|
||||
<div style="padding:20px 24px;border-bottom:1px solid #E5E7EB;display:flex;align-items:center;justify-content:space-between">
|
||||
<div>
|
||||
<h2 style="font-size:18px;font-weight:700;color:#111827">{selectedCustomer()!.name}</h2>
|
||||
<p style="margin-top:2px;font-size:13px;color:#6B7280">{selectedCustomer()!.email} • Joined {selectedCustomer()!.registeredDate}</p>
|
||||
</div>
|
||||
<StatusBadge status={selectedCustomer()!.status} />
|
||||
</div>
|
||||
|
||||
<div style="display:flex;align-items:center;gap:4px;border-bottom:1px solid #E5E7EB;padding:0 24px;background:#FAFAFA">
|
||||
{(['overview', 'orders', 'support'] as const).map((tab, i) => {
|
||||
const labels = ['Overview', 'Order History', 'Support Cases'];
|
||||
const active = () => detailTab() === tab;
|
||||
return (
|
||||
<button type="button" onClick={() => setDetailTab(tab)} style={`position:relative;padding:14px 12px;font-size:13px;font-weight:600;background:none;border:none;cursor:pointer;color:${active() ? '#FF5E13' : '#6B7280'}`}>
|
||||
{labels[i]}
|
||||
<Show when={active()}><span style="position:absolute;left:0;right:0;bottom:0;height:2px;background:#FF5E13;border-radius:2px 2px 0 0" /></Show>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div style="padding:24px">
|
||||
<Show when={detailTab() === 'overview'}>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:24px">
|
||||
<div style="border:1px solid #E5E7EB;border-radius:12px;padding:20px">
|
||||
<h3 style="font-size:14px;font-weight:700;color:#111827;margin-bottom:16px">Customer Summary</h3>
|
||||
<div style="display:flex;flex-direction:column;gap:12px">
|
||||
{[
|
||||
{ l: 'Location', v: selectedCustomer()!.location || '—' },
|
||||
{ l: 'Total Orders', v: selectedCustomer()!.totalOrders || '0' },
|
||||
{ l: 'Phone', v: selectedCustomer()!.phone || '—' },
|
||||
{ l: 'Last Active', v: selectedCustomer()!.updatedAt || '—' },
|
||||
].map(item => (
|
||||
<div style="display:flex;justify-content:space-between">
|
||||
<span style="font-size:13px;color:#6B7280">{item.l}</span>
|
||||
<span style="font-size:13px;font-weight:600;color:#111827">{item.v}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;align-items:center;gap:10px;padding:14px 24px;border-top:1px solid #E5E7EB">
|
||||
<button type="button" style="height:36px;border-radius:8px;background:#0D0D2A;padding:0 16px;font-size:13px;font-weight:600;color:white;border:none;cursor:pointer">Edit Profile</button>
|
||||
<button type="button" onClick={() => { setSelectedCustomer(null); setListTab('all'); }} style="height:36px;border-radius:8px;border:1px solid #E5E7EB;background:white;padding:0 16px;font-size:13px;font-weight:600;color:#374151;cursor:pointer">Back to List</button>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</Show>
|
||||
|
||||
<div style={{ display: listTab() === 'view' ? 'none' : 'block' }}>
|
||||
<div style="margin-top:1.5rem;margin-left:-24px;margin-right:-24px;border-radius:0;border-left:none;border-right:none;overflow:hidden;border-top:1px solid #E5E7EB;border-bottom:1px solid #E5E7EB;background:white;box-shadow:0 1px 3px rgba(0,0,0,0.06)">
|
||||
<div style="display:flex;align-items:center;gap:8px;padding:14px 20px;border-bottom:1px solid #F3F4F6">
|
||||
<input
|
||||
value={search()}
|
||||
onInput={(e) => setSearch(e.currentTarget.value)}
|
||||
placeholder="Search customers..."
|
||||
style="height:34px;flex:1;border-radius:8px;border:1px solid #E5E7EB;background:white;padding:0 12px;font-size:13px;color:#111827;outline:none"
|
||||
/>
|
||||
<div style="position:relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setSortMenuOpen((v) => !v); setFilterMenuOpen(false); }}
|
||||
style="display:inline-flex;height:34px;align-items:center;gap:6px;border-radius:8px;border:1px solid #E5E7EB;background:white;padding:0 12px;font-size:12px;font-weight:500;color:#374151;cursor:pointer"
|
||||
>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M7 4v13"/><path d="m3 13 4 4 4-4"/><path d="M17 20V7"/><path d="m21 11-4-4-4 4"/></svg>
|
||||
Sort
|
||||
</button>
|
||||
<Show when={sortMenuOpen()}>
|
||||
<div style="position:absolute;left:0;top:38px;z-index:30;min-width:200px;border-radius:12px;border:1px solid #E5E7EB;background:white;padding:6px;box-shadow:0 4px 16px rgba(0,0,0,0.1)">
|
||||
{(['name_asc', 'name_desc', 'orders_desc', 'orders_asc'] as const).map((s, i) => (
|
||||
<button type="button" onClick={() => { setSortBy(s); setSortMenuOpen(false); }} style={`display:block;width:100%;border-radius:8px;padding:8px 12px;text-align:left;font-size:13px;background:none;border:none;cursor:pointer;color:${sortBy() === s ? '#FF5E13' : '#374151'};background:${sortBy() === s ? '#FFF1EB' : 'transparent'}`}>
|
||||
{['Name (A-Z)', 'Name (Z-A)', 'Orders (High-Low)', 'Orders (Low-High)'][i]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
<div style="position:relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setFilterMenuOpen((v) => !v); setSortMenuOpen(false); }}
|
||||
style="display:inline-flex;height:34px;align-items:center;gap:6px;border-radius:8px;border:1px solid #E5E7EB;background:white;padding:0 12px;font-size:12px;font-weight:500;color:#374151;cursor:pointer"
|
||||
>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 5h18M6 12h12M10 19h4"/></svg>
|
||||
Filters
|
||||
</button>
|
||||
<Show when={filterMenuOpen()}>
|
||||
<div style="position:absolute;left:0;top:38px;z-index:30;min-width:160px;border-radius:12px;border:1px solid #E5E7EB;background:white;padding:6px;box-shadow:0 4px 16px rgba(0,0,0,0.1)">
|
||||
{(['all', 'active', 'pending', 'inactive'] as const).map((s) => (
|
||||
<button type="button" onClick={() => { setStatusFilter(s); setFilterMenuOpen(false); }} style={`display:block;width:100%;border-radius:8px;padding:8px 12px;text-align:left;font-size:13px;background:none;border:none;cursor:pointer;color:${statusFilter() === s ? '#FF5E13' : '#374151'};background:${statusFilter() === s ? '#FFF1EB' : 'transparent'}`}>
|
||||
{s === 'all' ? 'All Status' : s === 'active' ? 'Active' : s === 'pending' ? 'Pending' : 'Inactive'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full">
|
||||
<thead>
|
||||
<tr style="background:#0D0D2A;text-align:left">
|
||||
{['Customer Name', 'Email', 'Location', 'Orders', 'Status', 'Actions'].map(h => (
|
||||
<th style="padding:10px 20px;font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:0.05em;color:#FFFFFF;white-space:nowrap">{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<For each={filteredRows()}>
|
||||
{(row) => (
|
||||
<tr style="border-bottom:1px solid #F3F4F6" class="hover:bg-[#FAFAFA] transition-colors">
|
||||
<td style="padding:12px 20px;font-size:14px;font-weight:600;color:#111827">{row.name}</td>
|
||||
<td style="padding:12px 20px;font-size:13px;color:#6B7280">{row.email}</td>
|
||||
<td style="padding:12px 20px;font-size:13px;color:#6B7280">{row.location || '—'}</td>
|
||||
<td style="padding:12px 20px;font-size:13px;color:#6B7280">{row.totalOrders || 0} orders</td>
|
||||
<td style="padding:12px 20px"><StatusBadge status={row.status} /></td>
|
||||
<td style="padding:12px 20px;position:relative">
|
||||
<button type="button" onClick={() => setOpenMenuId(openMenuId() === row.id ? null : row.id)} style="display:inline-flex;height:32px;width:32px;align-items:center;justify-content:center;border-radius:8px;color:#9CA3AF;background:none;border:none;cursor:pointer">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="5" r="1.5"/><circle cx="12" cy="12" r="1.5"/><circle cx="12" cy="19" r="1.5"/></svg>
|
||||
</button>
|
||||
<Show when={openMenuId() === row.id}>
|
||||
<div style="position:absolute;right:20px;top:44px;z-index:20;width:180px;border-radius:12px;border:1px solid #E5E7EB;background:white;padding:6px;box-shadow:0 4px 20px rgba(0,0,0,0.12)">
|
||||
<button type="button" onClick={() => openDetail(row)} style="display:flex;width:100%;align-items:center;gap:10px;border-radius:8px;padding:8px 12px;font-size:13px;color:#374151;background:none;border:none;cursor:pointer;text-align:left">View Profile</button>
|
||||
<button type="button" style="display:flex;width:100%;align-items:center;gap:10px;border-radius:8px;padding:8px 12px;font-size:13px;color:#DC2626;background:none;border:none;cursor:pointer;text-align:left">Deactivate</button>
|
||||
</div>
|
||||
</Show>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</tbody>
|
||||
</table>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
810
src/routes/admin/external-roles.tsx
Normal file
810
src/routes/admin/external-roles.tsx
Normal file
|
|
@ -0,0 +1,810 @@
|
|||
import { For, Show, createMemo, createSignal, onMount, createEffect } from 'solid-js';
|
||||
import { useSearchParams } from '@solidjs/router';
|
||||
import AdminShell from '~/components/AdminShell';
|
||||
import type { CrudRecord } from '~/lib/admin/types';
|
||||
|
||||
const API = '/api/gateway';
|
||||
|
||||
type ExternalRoleRecord = {
|
||||
id: string;
|
||||
name: string;
|
||||
code: string;
|
||||
vertical: 'jobs' | 'marketplace';
|
||||
category: 'provider' | 'employer' | 'consumer' | 'specialist';
|
||||
onboardingSchemaId: string;
|
||||
modules: string[];
|
||||
permissions: Record<string, string[]>;
|
||||
requiresOnboardingApproval: boolean;
|
||||
requiresLeadApproval: boolean;
|
||||
requiresJobApproval: boolean;
|
||||
featureLimits: string; // JSON string
|
||||
status: 'ACTIVE' | 'INACTIVE';
|
||||
assignedUsers: number;
|
||||
assignedUserTypes: string[];
|
||||
createdDate: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
const FALLBACK_ROLES: ExternalRoleRecord[] = [
|
||||
{
|
||||
id: 'er1', name: 'Professional Photographer', code: 'photographer', vertical: 'marketplace', category: 'provider',
|
||||
onboardingSchemaId: 'photographer_onboarding_v1', modules: ['dashboard', 'profile', 'portfolio', 'services', 'leads', 'wallet'],
|
||||
permissions: { dashboard: ['read'], profile: ['read', 'update'], leads: ['read', 'update'] },
|
||||
requiresOnboardingApproval: true, requiresLeadApproval: true, requiresJobApproval: false,
|
||||
featureLimits: '{}', status: 'ACTIVE', assignedUsers: 45, assignedUserTypes: ['PHOTOGRAPHER', 'CREATIVE'], createdDate: '2026-01-15', updatedAt: '2026-03-27'
|
||||
},
|
||||
{
|
||||
id: 'er2', name: 'Verified Company', code: 'company', vertical: 'jobs', category: 'employer',
|
||||
onboardingSchemaId: 'company_onboarding_v1', modules: ['dashboard', 'profile', 'jobs', 'applications'],
|
||||
permissions: { jobs: ['read', 'create', 'update'], applications: ['read', 'approve'] },
|
||||
requiresOnboardingApproval: true, requiresLeadApproval: false, requiresJobApproval: true,
|
||||
featureLimits: '{"maxActiveJobs": 5}', status: 'ACTIVE', assignedUsers: 120, assignedUserTypes: ['COMPANY', 'ENTERPRISE'], createdDate: '2026-02-10', updatedAt: '2026-03-27'
|
||||
},
|
||||
];
|
||||
|
||||
const USER_TYPE_OPTIONS = ['COMPANY', 'CANDIDATE', 'PHOTOGRAPHER', 'MAKEUP_ARTIST', 'TUTOR', 'DEVELOPER', 'VIDEO_EDITOR', 'FITNESS_TRAINER', 'CATERER', 'GRAPHIC_DESIGNER', 'SOCIAL_MEDIA_MANAGER', 'CUSTOMER'];
|
||||
|
||||
const ONBOARDING_SCHEMAS = [
|
||||
'company_onboarding_v1', 'job_seeker_onboarding_v1', 'customer_onboarding_v1', 'photographer_onboarding_v1',
|
||||
'makeup_artist_onboarding_v1', 'tutor_onboarding_v1', 'developer_onboarding_v1', 'video_editor_onboarding_v1'
|
||||
];
|
||||
|
||||
const MODULES_BY_VERTICAL = {
|
||||
jobs: [
|
||||
{ key: 'dashboard', label: 'Dashboard', desc: 'KPI summary and overview' },
|
||||
{ key: 'profile', label: 'Profile', desc: 'Maintain role preferences' },
|
||||
{ key: 'jobs', label: 'Jobs', desc: 'Manage job postings' },
|
||||
{ key: 'applications', label: 'Applications', desc: 'Review hiring flow' },
|
||||
{ key: 'settings', label: 'Settings', desc: 'Account security' }
|
||||
],
|
||||
marketplace: [
|
||||
{ key: 'dashboard', label: 'Dashboard', desc: 'KPI summary and overview' },
|
||||
{ key: 'profile', label: 'Profile', desc: 'Public-facing profile' },
|
||||
{ key: 'portfolio', label: 'Portfolio', desc: 'Publish work samples' },
|
||||
{ key: 'services', label: 'Services', desc: 'List service pricing' },
|
||||
{ key: 'leads', label: 'Leads / Requests', desc: 'Handle incoming requests' },
|
||||
{ key: 'wallet', label: 'Wallet', desc: 'Tracecoin balance' }
|
||||
]
|
||||
};
|
||||
|
||||
const PERMISSION_ACTIONS = [
|
||||
{ key: 'read', label: 'Read' },
|
||||
{ key: 'create', label: 'Create' },
|
||||
{ key: 'update', label: 'Update' },
|
||||
{ key: 'delete', label: 'Delete' },
|
||||
{ key: 'approve', label: 'Approve' }
|
||||
];
|
||||
|
||||
const FALLBACK_LOGS = [
|
||||
{ id: 'l1', user: 'Admin Ashwin', action: 'Updated Permissions', target: 'Verified Company', date: '2026-03-27 10:30' },
|
||||
{ id: 'l2', user: 'Admin Ashwin', action: 'Changed Status', target: 'Professional Photographer', date: '2026-03-26 14:15' },
|
||||
{ id: 'l3', user: 'System', action: 'Auto-sync Schema', target: 'Verified Company', date: '2026-03-25 09:00' },
|
||||
];
|
||||
|
||||
const FALLBACK_ASSIGNED_USERS = [
|
||||
{ id: 'u1', name: 'John Doe', email: 'john@example.com', joined: '2026-01-20' },
|
||||
{ id: 'u2', name: 'Jane Smith', email: 'jane@studios.com', joined: '2026-02-05' },
|
||||
{ id: 'u3', name: 'Alice Wong', email: 'alice@photo.me', joined: '2026-03-12' },
|
||||
];
|
||||
|
||||
function StatusBadge(props: { status: string }) {
|
||||
const active = () => props.status === 'ACTIVE';
|
||||
return (
|
||||
<span style={`display:inline-flex;align-items:center;border-radius:9999px;border:1px solid ${active() ? '#FFD8C2' : '#D1D5DB'};background:${active() ? '#FFF1EB' : '#F3F4F6'};color:${active() ? '#FF5E13' : '#4B5563'};padding:2px 10px;font-size:12px;font-weight:500`}>
|
||||
<span style={`display:inline-block;width:6px;height:6px;border-radius:50%;background:${active() ? '#FF5E13' : '#9CA3AF'};margin-right:5px;flex-shrink:0`} />
|
||||
{active() ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function FormInput(props: { label: string; required?: boolean; value: string; onInput: (v: string) => void; placeholder?: string; type?: string }) {
|
||||
return (
|
||||
<label style="display:block">
|
||||
<span style="font-size:13px;font-weight:600;color:#374151">
|
||||
{props.label}{props.required && <span style="margin-left:2px;color:#FF5E13">*</span>}
|
||||
</span>
|
||||
<Show when={props.type === 'textarea'} fallback={
|
||||
<input
|
||||
type={props.type ?? 'text'}
|
||||
value={props.value}
|
||||
onInput={(e) => props.onInput(e.currentTarget.value)}
|
||||
placeholder={props.placeholder}
|
||||
style="display:block;margin-top:6px;height:40px;width:100%;border-radius:10px;border:1px solid #E5E7EB;background:white;padding:0 14px;font-size:13px;color:#111827;outline:none;box-sizing:border-box"
|
||||
/>
|
||||
}>
|
||||
<textarea
|
||||
value={props.value}
|
||||
onInput={(e) => props.onInput(e.currentTarget.value)}
|
||||
placeholder={props.placeholder}
|
||||
style="display:block;margin-top:6px;min-height:100px;width:100%;border-radius:10px;border:1px solid #E5E7EB;background:white;padding:10px 14px;font-size:13px;color:#111827;outline:none;box-sizing:border-box;resize:vertical"
|
||||
/>
|
||||
</Show>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ExternalRoleManagementPage() {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [view, setView] = createSignal<'list' | 'form'>('list');
|
||||
const [listTab, setListTab] = createSignal<'all' | 'create' | 'view'>('all');
|
||||
const [formTab, setFormTab] = createSignal<'general' | 'access' | 'settings'>('general');
|
||||
const [detailTab, setDetailTab] = createSignal<'permissions' | 'users' | 'logs'>('permissions');
|
||||
|
||||
const [search, setSearch] = createSignal('');
|
||||
const [statusFilter, setStatusFilter] = createSignal('all');
|
||||
const [sortBy, setSortBy] = createSignal<'name_asc' | 'name_desc' | 'users_desc' | 'users_asc'>('name_asc');
|
||||
const [sortMenuOpen, setSortMenuOpen] = createSignal(false);
|
||||
const [filterMenuOpen, setFilterMenuOpen] = createSignal(false);
|
||||
const [rows, setRows] = createSignal<ExternalRoleRecord[]>([]);
|
||||
const [viewingRole, setViewingRole] = createSignal<ExternalRoleRecord | null>(null);
|
||||
const [editingId, setEditingId] = createSignal<string | null>(null);
|
||||
const [openMenuId, setOpenMenuId] = createSignal<string | null>(null);
|
||||
|
||||
// Form Signals
|
||||
const [name, setName] = createSignal('');
|
||||
const [code, setCode] = createSignal('');
|
||||
const [vertical, setVertical] = createSignal<'jobs' | 'marketplace'>('marketplace');
|
||||
const [category, setCategory] = createSignal<ExternalRoleRecord['category']>('provider');
|
||||
const [assignedUserTypes, setAssignedUserTypes] = createSignal<string[]>([]);
|
||||
const [onboardingId, setOnboardingId] = createSignal(ONBOARDING_SCHEMAS[0]);
|
||||
const [enabledModules, setEnabledModules] = createSignal<string[]>([]);
|
||||
const [permissions, setPermissions] = createSignal<Record<string, string[]>>({});
|
||||
const [reqOnbAppr, setReqOnbAppr] = createSignal(true);
|
||||
const [reqLeadAppr, setReqLeadAppr] = createSignal(false);
|
||||
const [reqJobAppr, setReqJobAppr] = createSignal(false);
|
||||
const [limitsJson, setLimitsJson] = createSignal('{}');
|
||||
const [status, setStatus] = createSignal<'ACTIVE' | 'INACTIVE'>('ACTIVE');
|
||||
const [isSaving, setIsSaving] = createSignal(false);
|
||||
const [error, setError] = createSignal('');
|
||||
|
||||
createEffect(() => {
|
||||
if (searchParams.view === 'form') setView('form');
|
||||
else if (searchParams.editingId) {
|
||||
const row = rows().find(r => r.id === searchParams.editingId);
|
||||
if (row) openEdit(row);
|
||||
}
|
||||
});
|
||||
|
||||
const load = async () => { setRows(FALLBACK_ROLES); };
|
||||
onMount(() => void load());
|
||||
|
||||
const filteredRows = createMemo(() => {
|
||||
let r = rows();
|
||||
if (statusFilter() !== 'all') r = r.filter((d) => d.status === statusFilter().toUpperCase());
|
||||
const q = search().toLowerCase();
|
||||
if (q) {
|
||||
r = r.filter(r => r.name.toLowerCase().includes(q) || r.code.toLowerCase().includes(q));
|
||||
}
|
||||
const sorted = [...r];
|
||||
const mode = sortBy();
|
||||
sorted.sort((a, b) => {
|
||||
if (mode === 'name_desc') return b.name.localeCompare(a.name);
|
||||
if (mode === 'users_desc') return b.assignedUsers - a.assignedUsers;
|
||||
if (mode === 'users_asc') return a.assignedUsers - b.assignedUsers;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
return sorted;
|
||||
});
|
||||
|
||||
const moduleOptions = createMemo(() => MODULES_BY_VERTICAL[vertical()]);
|
||||
|
||||
const resetForm = () => {
|
||||
setEditingId(null); setName(''); setCode(''); setVertical('marketplace'); setCategory('provider');
|
||||
setAssignedUserTypes([]); setOnboardingId(ONBOARDING_SCHEMAS[0]); setEnabledModules([]); setPermissions({});
|
||||
setReqOnbAppr(true); setReqLeadAppr(false); setReqJobAppr(false); setLimitsJson('{}');
|
||||
setStatus('ACTIVE'); setFormTab('general');
|
||||
setSearchParams({ view: undefined, editingId: undefined });
|
||||
};
|
||||
|
||||
const openCreate = () => { resetForm(); setView('form'); };
|
||||
const openEdit = (row: ExternalRoleRecord) => {
|
||||
setEditingId(row.id); setName(row.name); setCode(row.code); setVertical(row.vertical);
|
||||
setCategory(row.category); setAssignedUserTypes(row.assignedUserTypes || []); setOnboardingId(row.onboardingSchemaId);
|
||||
setEnabledModules(row.modules); setPermissions(row.permissions);
|
||||
setReqOnbAppr(row.requiresOnboardingApproval); setReqLeadAppr(row.requiresLeadApproval);
|
||||
setReqJobAppr(row.requiresJobApproval); setLimitsJson(row.featureLimits);
|
||||
setStatus(row.status); setView('form'); setOpenMenuId(null);
|
||||
};
|
||||
|
||||
const toggleUserType = (type: string) => {
|
||||
setAssignedUserTypes(prev => prev.includes(type) ? prev.filter(t => t !== type) : [...prev, type]);
|
||||
};
|
||||
|
||||
const validateJson = (json: string) => {
|
||||
try {
|
||||
JSON.parse(json);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const toggleModule = (mod: string) => {
|
||||
setEnabledModules(prev => {
|
||||
const next = prev.includes(mod) ? prev.filter(m => m !== mod) : [...prev, mod];
|
||||
if (!next.includes(mod)) {
|
||||
const nextPerms = { ...permissions() };
|
||||
delete nextPerms[mod];
|
||||
setPermissions(nextPerms);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const togglePermission = (mod: string, act: string) => {
|
||||
const current = permissions()[mod] || [];
|
||||
const next = current.includes(act) ? current.filter(a => a !== act) : [...current, act];
|
||||
setPermissions({ ...permissions(), [mod]: next });
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
if (!name().trim() || !code().trim()) {
|
||||
setError('Role name and code are required.');
|
||||
setFormTab('general');
|
||||
return;
|
||||
}
|
||||
if (!validateJson(limitsJson())) {
|
||||
setError('Invalid JSON in feature limits.');
|
||||
setFormTab('settings');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
// Simulate API call
|
||||
await new Promise(r => setTimeout(r, 1000));
|
||||
resetForm();
|
||||
setView('list');
|
||||
} catch (err: any) {
|
||||
setError(err?.message || 'Failed to save role.');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AdminShell>
|
||||
<div class="w-full space-y-6 pb-8">
|
||||
|
||||
<div style="margin-bottom: 1.5rem">
|
||||
<h1 class="text-[28px] font-bold leading-tight text-[#111827]">External Role Management</h1>
|
||||
<p class="mt-1 text-[14px] text-[#6B7280]">Configure granular permissions and workflows for external platform users</p>
|
||||
</div>
|
||||
|
||||
{/* ── LIST VIEW ── */}
|
||||
<Show when={view() === 'list'}>
|
||||
<div>
|
||||
{/* Tabs */}
|
||||
<div style="margin-top:24px;display:flex;align-items:center;gap:24px;border-bottom:1px solid #E5E7EB">
|
||||
{([
|
||||
{ key: 'all', label: 'All External Roles', action: () => { setListTab('all'); setStatusFilter('all'); } },
|
||||
{ key: 'create', label: 'Create External Role', action: () => { setListTab('create'); openCreate(); } },
|
||||
{ key: 'view', label: 'View External Role', action: () => setListTab('view') },
|
||||
] as const).map((tab) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={tab.action}
|
||||
style={`padding-bottom:12px;font-size:14px;font-weight:500;background:none;border:none;cursor:pointer;${listTab() === tab.key ? 'color:#FF5E13;border-bottom:2px solid #FF5E13;margin-bottom:-1px' : 'color:#6B7280'}`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* View Role panel */}
|
||||
<Show when={listTab() === 'view'}>
|
||||
<Show
|
||||
when={!viewingRole()}
|
||||
>
|
||||
<div style="margin-top:24px;border-radius:16px;border:1px solid #E5E7EB;background:white;padding:48px 24px;text-align:center">
|
||||
<p style="font-size:15px;font-weight:600;color:#111827">No role selected</p>
|
||||
<p style="margin-top:6px;font-size:13px;color:#6B7280">Click the <strong>⋮</strong> menu on any role row and choose <strong>View Details</strong>.</p>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={viewingRole()}>
|
||||
<div style="margin-top:24px;border-radius:16px;border:1px solid #E5E7EB;background:white;box-shadow:0 1px 4px rgba(0,0,0,0.06);overflow:hidden">
|
||||
{/* Header */}
|
||||
<div style="padding:20px 24px;border-bottom:1px solid #E5E7EB;display:flex;align-items:center;justify-content:space-between">
|
||||
<div>
|
||||
<h2 style="font-size:18px;font-weight:700;color:#111827">{viewingRole()!.name}</h2>
|
||||
<p style="margin-top:2px;font-size:13px;color:#6B7280">{viewingRole()!.vertical.toUpperCase()} • {viewingRole()!.category.toUpperCase()}</p>
|
||||
</div>
|
||||
<StatusBadge status={viewingRole()!.status} />
|
||||
</div>
|
||||
{/* Detail Tabs */}
|
||||
<div style="display:flex;align-items:center;gap:4px;border-bottom:1px solid #E5E7EB;padding:0 24px;background:#FAFAFA">
|
||||
{(['permissions', 'users', 'logs'] as const).map((tab, i) => {
|
||||
const labels = ['Module Permissions', 'Assigned Users', 'Activity Logs'];
|
||||
const active = () => detailTab() === tab;
|
||||
return (
|
||||
<button type="button" onClick={() => setDetailTab(tab)} style={`position:relative;padding:14px 12px;font-size:13px;font-weight:600;background:none;border:none;cursor:pointer;color:${active() ? '#FF5E13' : '#6B7280'}`}>
|
||||
{labels[i]}
|
||||
<Show when={active()}><span style="position:absolute;left:0;right:0;bottom:0;height:2px;background:#FF5E13;border-radius:2px 2px 0 0" /></Show>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div style="padding:24px">
|
||||
<Show when={detailTab() === 'permissions'}>
|
||||
<div style="border:1px solid #E5E7EB;border-radius:12px;overflow:hidden;opacity:0.8">
|
||||
<table style="width:100%;border-collapse:collapse">
|
||||
<thead style="background:#F9FAFB">
|
||||
<tr style="text-align:left">
|
||||
<th style="padding:12px 16px;font-size:11px;font-weight:600;color:#6B7280;text-transform:uppercase">Module</th>
|
||||
<For each={PERMISSION_ACTIONS}>
|
||||
{act => <th style="padding:12px 16px;font-size:11px;font-weight:600;color:#6B7280;text-transform:uppercase;text-align:center">{act.label}</th>}
|
||||
</For>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<For each={MODULES_BY_VERTICAL[viewingRole()!.vertical]}>
|
||||
{mod => {
|
||||
const isEnabled = viewingRole()!.modules.includes(mod.key);
|
||||
return (
|
||||
<tr style={`border-top:1px solid #E5E7EB; ${!isEnabled ? 'background:#F9FAFB;opacity:0.5' : ''}`}>
|
||||
<td style="padding:12px 16px">
|
||||
<p style="font-size:13px;font-weight:600;color:#111827">{mod.label}</p>
|
||||
</td>
|
||||
<For each={PERMISSION_ACTIONS}>
|
||||
{act => (
|
||||
<td style="padding:12px 16px;text-align:center">
|
||||
<input type="checkbox" checked={isEnabled && (viewingRole()!.permissions[mod.key] || []).includes(act.key)} disabled style="width:16px;height:16px;accent-color:#FF5E13" />
|
||||
</td>
|
||||
)}
|
||||
</For>
|
||||
</tr>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={detailTab() === 'users'}>
|
||||
<div style="border:1px solid #E5E7EB;border-radius:12px;overflow:hidden">
|
||||
<table style="width:100%;border-collapse:collapse">
|
||||
<thead style="background:#F9FAFB">
|
||||
<tr style="text-align:left">
|
||||
<th style="padding:12px 16px;font-size:11px;font-weight:600;color:#6B7280;text-transform:uppercase">User Name</th>
|
||||
<th style="padding:12px 16px;font-size:11px;font-weight:600;color:#6B7280;text-transform:uppercase">Email</th>
|
||||
<th style="padding:12px 16px;font-size:11px;font-weight:600;color:#6B7280;text-transform:uppercase">Joined Date</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<For each={FALLBACK_ASSIGNED_USERS}>
|
||||
{user => (
|
||||
<tr style="border-top:1px solid #E5E7EB">
|
||||
<td style="padding:12px 16px;font-size:13px;font-weight:600;color:#111827">{user.name}</td>
|
||||
<td style="padding:12px 16px;font-size:13px;color:#6B7280">{user.email}</td>
|
||||
<td style="padding:12px 16px;font-size:13px;color:#6B7280">{user.joined}</td>
|
||||
</tr>
|
||||
)}
|
||||
</For>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={detailTab() === 'logs'}>
|
||||
<div style="display:flex;flex-direction:column;gap:12px">
|
||||
<For each={FALLBACK_LOGS}>
|
||||
{log => (
|
||||
<div style="display:flex;align-items:flex-start;gap:12px;padding:14px;border:1px solid #E5E7EB;border-radius:12px;background:#F9FAFB">
|
||||
<div style="width:32px;height:32px;border-radius:50%;background:#FFF3EE;display:flex;align-items:center;justify-content:center;color:#FF5E13;flex-shrink:0">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
|
||||
</div>
|
||||
<div style="flex:1">
|
||||
<p style="font-size:13px;font-weight:600;color:#111827">{log.user} <span style="font-weight:400;color:#6B7280">{log.action}</span> for {log.target}</p>
|
||||
<p style="font-size:11px;color:#9CA3AF;margin-top:2px">{log.date}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div style="display:flex;align-items:center;gap:10px;padding:14px 24px;border-top:1px solid #E5E7EB">
|
||||
<button type="button" onClick={() => openEdit(viewingRole()!)} style="height:36px;border-radius:8px;background:#0D0D2A;padding:0 16px;font-size:13px;font-weight:600;color:white;border:none;cursor:pointer">Edit Role</button>
|
||||
<button type="button" onClick={() => { setViewingRole(null); setListTab('all'); }} style="height:36px;border-radius:8px;border:1px solid #E5E7EB;background:white;padding:0 16px;font-size:13px;font-weight:600;color:#374151;cursor:pointer">Back to List</button>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</Show>
|
||||
|
||||
<div style={{ display: listTab() === 'view' ? 'none' : 'block' }}>
|
||||
<div style="margin-top:1.5rem;margin-left:-24px;margin-right:-24px;border-radius:0;border-left:none;border-right:none;overflow:hidden;border-top:1px solid #E5E7EB;border-bottom:1px solid #E5E7EB;background:white;box-shadow:0 1px 3px rgba(0,0,0,0.06)">
|
||||
<div style="display:flex;align-items:center;gap:8px;padding:14px 20px;border-bottom:1px solid #F3F4F6">
|
||||
<input
|
||||
value={search()}
|
||||
onInput={(e) => setSearch(e.currentTarget.value)}
|
||||
placeholder="Search roles..."
|
||||
style="height:34px;flex:1;border-radius:8px;border:1px solid #E5E7EB;background:white;padding:0 12px;font-size:13px;color:#111827;outline:none"
|
||||
/>
|
||||
<div style="position:relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setSortMenuOpen((v) => !v); setFilterMenuOpen(false); }}
|
||||
style="display:inline-flex;height:34px;align-items:center;gap:6px;border-radius:8px;border:1px solid #E5E7EB;background:white;padding:0 12px;font-size:12px;font-weight:500;color:#374151;cursor:pointer"
|
||||
>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M7 4v13"/><path d="m3 13 4 4 4-4"/><path d="M17 20V7"/><path d="m21 11-4-4-4 4"/></svg>
|
||||
Sort
|
||||
</button>
|
||||
<Show when={sortMenuOpen()}>
|
||||
<div style="position:absolute;left:0;top:38px;z-index:30;min-width:200px;border-radius:12px;border:1px solid #E5E7EB;background:white;padding:6px;box-shadow:0 4px 16px rgba(0,0,0,0.1)">
|
||||
{(['name_asc', 'name_desc', 'users_desc', 'users_asc'] as const).map((s, i) => (
|
||||
<button type="button" onClick={() => { setSortBy(s); setSortMenuOpen(false); }} style={`display:block;width:100%;border-radius:8px;padding:8px 12px;text-align:left;font-size:13px;background:none;border:none;cursor:pointer;color:${sortBy() === s ? '#FF5E13' : '#374151'};background:${sortBy() === s ? '#FFF1EB' : 'transparent'}`}>
|
||||
{['Name (A-Z)', 'Name (Z-A)', 'Users (High-Low)', 'Users (Low-High)'][i]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
<div style="position:relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setFilterMenuOpen((v) => !v); setSortMenuOpen(false); }}
|
||||
style="display:inline-flex;height:34px;align-items:center;gap:6px;border-radius:8px;border:1px solid #E5E7EB;background:white;padding:0 12px;font-size:12px;font-weight:500;color:#374151;cursor:pointer"
|
||||
>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 5h18M6 12h12M10 19h4"/></svg>
|
||||
Filters
|
||||
</button>
|
||||
<Show when={filterMenuOpen()}>
|
||||
<div style="position:absolute;left:0;top:38px;z-index:30;min-width:160px;border-radius:12px;border:1px solid #E5E7EB;background:white;padding:6px;box-shadow:0 4px 16px rgba(0,0,0,0.1)">
|
||||
{(['all', 'active', 'inactive'] as const).map((s) => (
|
||||
<button type="button" onClick={() => { setStatusFilter(s); setFilterMenuOpen(false); }} style={`display:block;width:100%;border-radius:8px;padding:8px 12px;text-align:left;font-size:13px;background:none;border:none;cursor:pointer;color:${statusFilter() === s ? '#FF5E13' : '#374151'};background:${statusFilter() === s ? '#FFF1EB' : 'transparent'}`}>
|
||||
{s === 'all' ? 'All Status' : s === 'active' ? 'Active' : 'Inactive'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full">
|
||||
<thead>
|
||||
<tr style="background:#0D0D2A;text-align:left">
|
||||
{['Role Name', 'Vertical', 'Category', 'Onboarding', 'Status', 'Users', 'Actions'].map(h => (
|
||||
<th style="padding:10px 20px;font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:0.05em;color:#FFFFFF;white-space:nowrap">{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<For each={filteredRows()}>
|
||||
{(row) => (
|
||||
<tr style="border-bottom:1px solid #F3F4F6" class="hover:bg-[#FAFAFA] transition-colors">
|
||||
<td style="padding:12px 20px">
|
||||
<p style="font-size:14px;font-weight:600;color:#111827">{row.name}</p>
|
||||
<div style="display:flex;flex-wrap:wrap;gap:4px;margin-top:4px">
|
||||
<For each={row.assignedUserTypes}>
|
||||
{type => <span style="font-size:10px;padding:1px 6px;border-radius:4px;background:#F3F4F6;color:#6B7280;font-weight:600">{type}</span>}
|
||||
</For>
|
||||
</div>
|
||||
</td>
|
||||
<td style="padding:12px 20px;font-size:12px;text-transform:uppercase;font-weight:700;color:#6B7280">{row.vertical}</td>
|
||||
<td style="padding:12px 20px;font-size:12px;text-transform:capitalize;color:#6B7280">{row.category}</td>
|
||||
<td style="padding:12px 20px;font-size:12px;color:#6B7280">{row.onboardingSchemaId}</td>
|
||||
<td style="padding:12px 20px"><StatusBadge status={row.status} /></td>
|
||||
<td style="padding:12px 20px;font-size:13px;color:#6B7280">{row.assignedUsers} users</td>
|
||||
<td style="padding:12px 20px;position:relative">
|
||||
<button type="button" onClick={() => setOpenMenuId(openMenuId() === row.id ? null : row.id)} style="display:inline-flex;height:32px;width:32px;align-items:center;justify-content:center;border-radius:8px;color:#9CA3AF;background:none;border:none;cursor:pointer">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="5" r="1.5"/><circle cx="12" cy="12" r="1.5"/><circle cx="12" cy="19" r="1.5"/></svg>
|
||||
</button>
|
||||
<Show when={openMenuId() === row.id}>
|
||||
<div style="position:absolute;right:20px;top:44px;z-index:20;width:180px;border-radius:12px;border:1px solid #E5E7EB;background:white;padding:6px;box-shadow:0 4px 20px rgba(0,0,0,0.12)">
|
||||
<button type="button" onClick={() => { setViewingRole(row); setListTab('view'); setOpenMenuId(null); }} style="display:flex;width:100%;align-items:center;gap:10px;border-radius:8px;padding:8px 12px;font-size:13px;color:#374151;background:none;border:none;cursor:pointer;text-align:left">View Details</button>
|
||||
<button type="button" onClick={() => openEdit(row)} style="display:flex;width:100%;align-items:center;gap:10px;border-radius:8px;padding:8px 12px;font-size:13px;color:#374151;background:none;border:none;cursor:pointer;text-align:left">Edit Role</button>
|
||||
<button type="button" style="display:flex;width:100%;align-items:center;gap:10px;border-radius:8px;padding:8px 12px;font-size:13px;color:#DC2626;background:none;border:none;cursor:pointer;text-align:left">Delete Role</button>
|
||||
</div>
|
||||
</Show>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</For>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* ── FORM VIEW ── */}
|
||||
<Show when={view() === 'form'}>
|
||||
<div>
|
||||
<div style="margin-top:24px;display:flex;align-items:center;gap:24px;border-bottom:1px solid #E5E7EB">
|
||||
<button type="button" onClick={() => resetForm()} style="padding-bottom:12px;font-size:14px;font-weight:500;color:#6B7280;background:none;border:none;cursor:pointer">All External Roles</button>
|
||||
<button type="button" style="padding-bottom:12px;font-size:14px;font-weight:500;color:#FF5E13;border-bottom:2px solid #FF5E13;background:none;cursor:pointer;margin-bottom:-1px">{editingId() ? 'Edit External Role' : 'Create External Role'}</button>
|
||||
</div>
|
||||
|
||||
<div style="margin-top:24px;border-radius:16px;border:1px solid #E5E7EB;background:white;box-shadow:0 1px 4px rgba(0,0,0,0.06);overflow:hidden">
|
||||
<div style="display:flex;align-items:center;gap:4px;border-bottom:1px solid #E5E7EB;padding:0 24px">
|
||||
{(['general', 'access', 'settings'] as const).map((tab, i) => {
|
||||
const labels = ['General Config', 'Module Permissions', 'Workflow & Limits'];
|
||||
const active = () => formTab() === tab;
|
||||
return (
|
||||
<button type="button" onClick={() => setFormTab(tab)} style={`position:relative;padding:14px 12px;font-size:13px;font-weight:600;background:none;border:none;cursor:pointer;color:${active() ? '#FF5E13' : '#6B7280'}`}>
|
||||
{labels[i]}
|
||||
<Show when={active()}><span style="position:absolute;left:0;right:0;bottom:0;height:2px;background:#FF5E13;border-radius:2px 2px 0 0" /></Show>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div style="padding:24px">
|
||||
<Show when={error()}>
|
||||
<div style="margin-bottom:20px;border-radius:10px;border:1px solid #FECACA;background:#FEF2F2;padding:12px 16px;font-size:13px;color:#B91C1C">
|
||||
{error()}
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={formTab() === 'general'}>
|
||||
<div style="display:flex;flex-direction:column;gap:24px;max-width:800px">
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:20px">
|
||||
<FormInput label="Role Name" required value={name()} onInput={setName} placeholder="e.g. Verified Photographer" />
|
||||
<FormInput label="Role Code" required value={code()} onInput={setCode} placeholder="e.g. photographer" />
|
||||
</div>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:20px">
|
||||
<label style="display:block">
|
||||
<span style="font-size:13px;font-weight:600;color:#374151">App Vertical *</span>
|
||||
<select value={vertical()} onChange={e => setVertical(e.currentTarget.value as any)} style="display:block;margin-top:6px;height:40px;width:100%;border-radius:10px;border:1px solid #E5E7EB;background:white;padding:0 14px;font-size:13px;outline:none">
|
||||
<option value="jobs">Jobs</option>
|
||||
<option value="marketplace">Marketplace</option>
|
||||
</select>
|
||||
</label>
|
||||
<label style="display:block">
|
||||
<span style="font-size:13px;font-weight:600;color:#374151">Role Category *</span>
|
||||
<select value={category()} onChange={e => setCategory(e.currentTarget.value as any)} style="display:block;margin-top:6px;height:40px;width:100%;border-radius:10px;border:1px solid #E5E7EB;background:white;padding:0 14px;font-size:13px;outline:none">
|
||||
<option value="provider">Provider</option>
|
||||
<option value="employer">Employer</option>
|
||||
<option value="consumer">Consumer</option>
|
||||
<option value="specialist">Specialist</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span style="font-size:13px;font-weight:600;color:#374151">Assigned User Types *</span>
|
||||
<div style="display:flex;flex-wrap:wrap;gap:8px;margin-top:8px">
|
||||
<For each={USER_TYPE_OPTIONS}>
|
||||
{type => {
|
||||
const active = () => assignedUserTypes().includes(type);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleUserType(type)}
|
||||
style={`height:32px;border-radius:8px;padding:0 12px;font-size:11px;font-weight:600;cursor:pointer;transition:all 0.2s;${active() ? 'background:#FF5E13;color:white;border:1px solid #FF5E13' : 'background:white;color:#6B7280;border:1px solid #E5E7EB'}`}
|
||||
>
|
||||
{type.replace('_', ' ')}
|
||||
</button>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label style="display:block">
|
||||
<span style="font-size:13px;font-weight:600;color:#374151">Onboarding Schema *</span>
|
||||
<select value={onboardingId()} onChange={e => setOnboardingId(e.currentTarget.value)} style="display:block;margin-top:6px;height:40px;width:100%;border-radius:10px;border:1px solid #E5E7EB;background:white;padding:0 14px;font-size:13px;outline:none">
|
||||
<For each={ONBOARDING_SCHEMAS}>{id => <option value={id}>{id}</option>}</For>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={formTab() === 'access'}>
|
||||
<div style="border:1px solid #E5E7EB;border-radius:12px;overflow:hidden">
|
||||
<table style="width:100%;border-collapse:collapse">
|
||||
<thead style="background:#F9FAFB">
|
||||
<tr style="text-align:left">
|
||||
<th style="padding:12px 16px;font-size:11px;font-weight:600;color:#6B7280;text-transform:uppercase">Module</th>
|
||||
<th style="padding:12px 16px;font-size:11px;font-weight:600;color:#6B7280;text-transform:uppercase;text-align:center">Enabled</th>
|
||||
<For each={PERMISSION_ACTIONS}>
|
||||
{act => <th style="padding:12px 16px;font-size:11px;font-weight:600;color:#6B7280;text-transform:uppercase;text-align:center">{act.label}</th>}
|
||||
</For>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<For each={moduleOptions()}>
|
||||
{mod => (
|
||||
<tr style="border-top:1px solid #E5E7EB">
|
||||
<td style="padding:12px 16px">
|
||||
<p style="font-size:13px;font-weight:600;color:#111827">{mod.label}</p>
|
||||
<p style="font-size:11px;color:#6B7280">{mod.desc}</p>
|
||||
</td>
|
||||
<td style="padding:12px 16px;text-align:center">
|
||||
<input type="checkbox" checked={enabledModules().includes(mod.key)} onChange={() => toggleModule(mod.key)} style="width:16px;height:16px;accent-color:#FF5E13;cursor:pointer" />
|
||||
</td>
|
||||
<For each={PERMISSION_ACTIONS}>
|
||||
{act => (
|
||||
<td style="padding:12px 16px;text-align:center">
|
||||
<input
|
||||
type="checkbox"
|
||||
disabled={!enabledModules().includes(mod.key)}
|
||||
checked={(permissions()[mod.key] || []).includes(act.key)}
|
||||
onChange={() => togglePermission(mod.key, act.key)}
|
||||
style="width:16px;height:16px;accent-color:#FF5E13;cursor:pointer"
|
||||
/>
|
||||
</td>
|
||||
)}
|
||||
</For>
|
||||
</tr>
|
||||
)}
|
||||
</For>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={formTab() === 'settings'}>
|
||||
<div style="display:flex;flex-direction:column;gap:24px;max-width:800px">
|
||||
<div>
|
||||
<h3 style="font-size:14px;font-weight:700;color:#111827;margin-bottom:12px">Workflow Approvals</h3>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px">
|
||||
{[
|
||||
{ l: 'Review onboarding submissions', v: reqOnbAppr, s: setReqOnbAppr },
|
||||
{ l: 'Review incoming leads', v: reqLeadAppr, s: setReqLeadAppr },
|
||||
{ l: 'Review job posts', v: reqJobAppr, s: setReqJobAppr }
|
||||
].map(item => (
|
||||
<label style="display:flex;align-items:center;gap:10px;padding:12px;border:1px solid #E5E7EB;border-radius:10px;cursor:pointer;background:#F9FAFB">
|
||||
<input type="checkbox" checked={item.v()} onChange={e => item.s(e.currentTarget.checked)} style="width:16px;height:16px;accent-color:#FF5E13" />
|
||||
<span style="font-size:13px;color:#374151">{item.l}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FormInput label="Feature Limits (JSON)" type="textarea" value={limitsJson()} onInput={setLimitsJson} placeholder='{"maxActiveJobs": 5, "maxApplications": 10}' />
|
||||
<Show when={limitsJson().trim() && !validateJson(limitsJson())}>
|
||||
<p style="margin-top:4px;font-size:12px;color:#DC2626;font-weight:500">Invalid JSON format</p>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;padding:12px;border-radius:10px;background:#F9FAFB;border:1px solid #E5E7EB;max-width:400px">
|
||||
<div>
|
||||
<p style="font-size:13px;font-weight:600;color:#111827">Active Status</p>
|
||||
<p style="font-size:12px;color:#6B7280">Role is available for registration.</p>
|
||||
</div>
|
||||
<button onClick={() => setStatus(s => s === 'ACTIVE' ? 'INACTIVE' : 'ACTIVE')} style={`width:40px;height:20px;border-radius:10px;position:relative;cursor:pointer;border:none;transition:background 0.2s;${status() === 'ACTIVE' ? 'background:#FF5E13' : 'background:#D1D5DB'}`}>
|
||||
<div style={`width:16px;height:16px;background:white;border-radius:50%;position:absolute;top:2px;transition:left 0.2s;${status() === 'ACTIVE' ? 'left:22px' : 'left:2px'}`} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;align-items:center;justify-content:flex-end;gap:10px;border-top:1px solid #E5E7EB;padding:14px 24px">
|
||||
<button type="button" onClick={() => resetForm()} style="height:38px;border-radius:10px;border:1px solid #E5E7EB;background:white;padding:0 20px;font-size:13px;font-weight:600;color:#374151;cursor:pointer">Cancel</button>
|
||||
<button type="button" onClick={() => void save()} disabled={isSaving()} style={`height:38px;border-radius:10px;background:#0D0D2A;padding:0 24px;font-size:13px;font-weight:600;color:white;border:none;cursor:pointer;opacity:${isSaving() ? 0.7 : 1}`}>
|
||||
{isSaving() ? 'Saving...' : editingId() ? 'Update Role' : 'Create Role'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* ── DETAIL VIEW ── */}
|
||||
<Show when={listTab() === 'view' && viewingRole()}>
|
||||
<div style="margin-top:24px;border-radius:16px;border:1px solid #E5E7EB;background:white;box-shadow:0 1px 4px rgba(0,0,0,0.06);overflow:hidden">
|
||||
<div style="padding:24px;border-bottom:1px solid #E5E7EB;display:flex;align-items:center;justify-content:space-between">
|
||||
<div>
|
||||
<h2 style="font-size:20px;font-weight:700;color:#111827">{viewingRole()!.name}</h2>
|
||||
<p style="font-size:14px;color:#6B7280;margin-top:2px">{viewingRole()!.vertical.toUpperCase()} • {viewingRole()!.category.toUpperCase()} • Schema: {viewingRole()!.onboardingSchemaId}</p>
|
||||
</div>
|
||||
<div style="display:flex;gap:10px">
|
||||
<button type="button" onClick={() => openEdit(viewingRole()!)} style="height:36px;border-radius:8px;background:#0D0D2A;padding:0 16px;font-size:13px;font-weight:600;color:white;border:none;cursor:pointer">Edit Role</button>
|
||||
<StatusBadge status={viewingRole()!.status} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;align-items:center;gap:4px;border-bottom:1px solid #E5E7EB;padding:0 24px;background:#FAFAFA">
|
||||
{(['permissions', 'users', 'logs'] as const).map((tab, i) => {
|
||||
const labels = ['Module Permissions', 'Assigned Users', 'Activity Logs'];
|
||||
const active = () => detailTab() === tab;
|
||||
return (
|
||||
<button type="button" onClick={() => setDetailTab(tab)} style={`position:relative;padding:14px 12px;font-size:13px;font-weight:600;background:none;border:none;cursor:pointer;color:${active() ? '#FF5E13' : '#6B7280'}`}>
|
||||
{labels[i]}
|
||||
<Show when={active()}><span style="position:absolute;left:0;right:0;bottom:0;height:2px;background:#FF5E13;border-radius:2px 2px 0 0" /></Show>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div style="padding:24px">
|
||||
<Show when={detailTab() === 'permissions'}>
|
||||
<div style="border:1px solid #E5E7EB;border-radius:12px;overflow:hidden;opacity:0.8">
|
||||
<table style="width:100%;border-collapse:collapse">
|
||||
<thead style="background:#F9FAFB">
|
||||
<tr style="text-align:left">
|
||||
<th style="padding:12px 16px;font-size:11px;font-weight:600;color:#6B7280;text-transform:uppercase">Module</th>
|
||||
<For each={PERMISSION_ACTIONS}>
|
||||
{act => <th style="padding:12px 16px;font-size:11px;font-weight:600;color:#6B7280;text-transform:uppercase;text-align:center">{act.label}</th>}
|
||||
</For>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<For each={(MODULES_BY_VERTICAL as any)[viewingRole()!.vertical]}>
|
||||
{(mod: any) => {
|
||||
const isEnabled = viewingRole()!.modules.includes(mod.key);
|
||||
return (
|
||||
<tr style={`border-top:1px solid #E5E7EB; ${!isEnabled ? 'background:#F9FAFB;opacity:0.5' : ''}`}>
|
||||
<td style="padding:12px 16px">
|
||||
<p style="font-size:13px;font-weight:600;color:#111827">{mod.label}</p>
|
||||
</td>
|
||||
<For each={PERMISSION_ACTIONS}>
|
||||
{act => (
|
||||
<td style="padding:12px 16px;text-align:center">
|
||||
<input type="checkbox" checked={isEnabled && (viewingRole()!.permissions[mod.key] || []).includes(act.key)} disabled style="width:16px;height:16px;accent-color:#FF5E13" />
|
||||
</td>
|
||||
)}
|
||||
</For>
|
||||
</tr>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={detailTab() === 'users'}>
|
||||
<div style="border:1px solid #E5E7EB;border-radius:12px;overflow:hidden">
|
||||
<table style="width:100%;border-collapse:collapse">
|
||||
<thead style="background:#F9FAFB">
|
||||
<tr style="text-align:left">
|
||||
<th style="padding:12px 16px;font-size:11px;font-weight:600;color:#6B7280;text-transform:uppercase">User Name</th>
|
||||
<th style="padding:12px 16px;font-size:11px;font-weight:600;color:#6B7280;text-transform:uppercase">Email</th>
|
||||
<th style="padding:12px 16px;font-size:11px;font-weight:600;color:#6B7280;text-transform:uppercase">Joined Date</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<For each={FALLBACK_ASSIGNED_USERS}>
|
||||
{user => (
|
||||
<tr style="border-top:1px solid #E5E7EB">
|
||||
<td style="padding:12px 16px;font-size:13px;font-weight:600;color:#111827">{user.name}</td>
|
||||
<td style="padding:12px 16px;font-size:13px;color:#6B7280">{user.email}</td>
|
||||
<td style="padding:12px 16px;font-size:13px;color:#6B7280">{user.joined}</td>
|
||||
</tr>
|
||||
)}
|
||||
</For>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={detailTab() === 'logs'}>
|
||||
<div style="display:flex;flex-direction:column;gap:12px">
|
||||
<For each={FALLBACK_LOGS}>
|
||||
{log => (
|
||||
<div style="display:flex;align-items:flex-start;gap:12px;padding:14px;border:1px solid #E5E7EB;border-radius:12px;background:#F9FAFB">
|
||||
<div style="width:32px;height:32px;border-radius:50%;background:#FFF3EE;display:flex;align-items:center;justify-content:center;color:#FF5E13;flex-shrink:0">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
|
||||
</div>
|
||||
<div style="flex:1">
|
||||
<p style="font-size:13px;font-weight:600;color:#111827">{log.user} <span style="font-weight:400;color:#6B7280">{log.action}</span> for {log.target}</p>
|
||||
<p style="font-size:11px;color:#9CA3AF;margin-top:2px">{log.date}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<div style="padding:16px 24px;border-top:1px solid #E5E7EB;display:flex;justify-content:flex-end">
|
||||
<button type="button" onClick={() => resetForm()} style="height:36px;border-radius:8px;border:1px solid #E5E7EB;background:white;padding:0 16px;font-size:13px;font-weight:600;color:#374151;cursor:pointer">Back to List</button>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
</div>
|
||||
</AdminShell>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,252 +1,322 @@
|
|||
import { createMemo, createResource, createSignal, For, Show } from 'solid-js';
|
||||
import { A } from '@solidjs/router';
|
||||
import { For, Show, createMemo, createSignal, onMount } from 'solid-js';
|
||||
import AdminShell from '~/components/AdminShell';
|
||||
import type { CrudRecord } from '~/lib/admin/types';
|
||||
|
||||
const API = '/api/gateway';
|
||||
|
||||
type InternalDashboard = {
|
||||
id: string;
|
||||
name: string;
|
||||
department: string;
|
||||
designation: string;
|
||||
role: string;
|
||||
code: string;
|
||||
assignedRole: string;
|
||||
widgetsCount: number;
|
||||
status: string;
|
||||
status: 'ACTIVE' | 'INACTIVE' | 'DRAFT';
|
||||
updatedAt: string;
|
||||
lastUpdated?: string;
|
||||
};
|
||||
|
||||
const FALLBACK: InternalDashboard[] = [
|
||||
{ id: 'id1', name: 'Super Admin Dashboard', department: 'Administration', designation: 'Super Admin', role: 'Super Admin', widgetsCount: 12, status: 'ACTIVE' },
|
||||
{ id: 'id2', name: 'Engineering Lead Dashboard', department: 'Engineering', designation: 'Engineering Lead', role: 'Engineering Lead', widgetsCount: 8, status: 'ACTIVE' },
|
||||
{ id: 'id3', name: 'HR Manager Dashboard', department: 'Human Resources', designation: 'HR Manager', role: 'HR Admin', widgetsCount: 6, status: 'ACTIVE' },
|
||||
{ id: 'id4', name: 'Finance Dashboard', department: 'Finance', designation: 'Finance Analyst', role: 'Finance Controller', widgetsCount: 5, status: 'DRAFT' },
|
||||
const FALLBACK_DASHBOARDS: InternalDashboard[] = [
|
||||
{ id: 'id1', name: 'Global Admin Overview', code: 'INT-DASH-ADM', assignedRole: 'System Administrator', widgetsCount: 18, status: 'ACTIVE', lastUpdated: '2026-03-27', updatedAt: '2026-03-27' },
|
||||
{ id: 'id2', name: 'HR Operations Board', code: 'INT-DASH-HR', assignedRole: 'HR Manager', widgetsCount: 10, status: 'ACTIVE', lastUpdated: '2026-03-26', updatedAt: '2026-03-27' },
|
||||
{ id: 'id3', name: 'Finance Audit View', code: 'INT-DASH-FIN', assignedRole: 'Finance Controller', widgetsCount: 14, status: 'DRAFT', lastUpdated: '2026-03-25', updatedAt: '2026-03-27' },
|
||||
];
|
||||
|
||||
async function loadDashboards(): Promise<InternalDashboard[]> {
|
||||
try {
|
||||
const res = await fetch(`${API}/api/admin/internal-dashboards`);
|
||||
if (!res.ok) throw new Error('Failed');
|
||||
const data = await res.json();
|
||||
const rows = Array.isArray(data) ? data : (data.dashboards || data.templates || []);
|
||||
if (!rows.length) return FALLBACK;
|
||||
return rows.map((item: any) => ({
|
||||
id: String(item.id || ''),
|
||||
name: String(item.name || item.title || 'Untitled Dashboard'),
|
||||
department: String(item.department || item.assigned_department || ''),
|
||||
designation: String(item.designation || item.assigned_designation || ''),
|
||||
role: String(item.role || item.role_name || item.assigned_role || ''),
|
||||
widgetsCount: Number(item.widgets_count || item.widgetsCount || 0),
|
||||
status: item.is_active ? 'ACTIVE' : (String(item.status || 'DRAFT').toUpperCase()),
|
||||
}));
|
||||
} catch {
|
||||
return FALLBACK;
|
||||
}
|
||||
}
|
||||
|
||||
function StatusBadge(props: { status: string }) {
|
||||
const isActive = () => props.status === 'ACTIVE';
|
||||
const isDraft = () => props.status === 'DRAFT';
|
||||
const baseStyle = 'border-radius:9999px;padding:3px 10px;font-size:12px;font-weight:500;display:inline-block';
|
||||
const activeStyle = `${baseStyle};background:#FFF1EB;color:#FF5E13;border:1px solid #FFD8C2`;
|
||||
const draftStyle = `${baseStyle};background:#F3F4F6;color:#6B7280;border:1px solid #D1D5DB`;
|
||||
const otherStyle = `${baseStyle};background:#F0FDF4;color:#166534;border:1px solid #BBF7D0`;
|
||||
const active = () => props.status === 'ACTIVE';
|
||||
const draft = () => props.status === 'DRAFT';
|
||||
return (
|
||||
<span style={isActive() ? activeStyle : isDraft() ? draftStyle : otherStyle}>
|
||||
{props.status}
|
||||
<span style={`display:inline-flex;align-items:center;border-radius:9999px;border:1px solid ${active() ? '#FFD8C2' : draft() ? '#E5E7EB' : '#D1D5DB'};background:${active() ? '#FFF1EB' : draft() ? '#F9FAFB' : '#F3F4F6'};color:${active() ? '#FF5E13' : draft() ? '#6B7280' : '#4B5563'};padding:2px 10px;font-size:12px;font-weight:500`}>
|
||||
<span style={`display:inline-block;width:6px;height:6px;border-radius:50%;background:${active() ? '#FF5E13' : draft() ? '#9CA3AF' : '#9CA3AF'};margin-right:5px;flex-shrink:0`} />
|
||||
{active() ? 'Active' : draft() ? 'Draft' : 'Inactive'}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default function InternalDashboardManagementPage() {
|
||||
const [dashboards, { refetch }] = createResource(loadDashboards);
|
||||
const [view, setView] = createSignal<'list' | 'form'>('list');
|
||||
const [listTab, setListTab] = createSignal<'all' | 'create' | 'view'>('all');
|
||||
const [formTab, setFormTab] = createSignal<'general' | 'builder' | 'permissions'>('general');
|
||||
const [detailTab, setDetailTab] = createSignal<'overview' | 'widgets' | 'preview'>('overview');
|
||||
|
||||
const [search, setSearch] = createSignal('');
|
||||
const [statusFilter, setStatusFilter] = createSignal('All');
|
||||
const [activeTab, setActiveTab] = createSignal<'dashboard' | 'preview'>('dashboard');
|
||||
const [statusFilter, setStatusFilter] = createSignal('all');
|
||||
const [sortBy, setSortBy] = createSignal<'name_asc' | 'name_desc' | 'widgets_desc' | 'widgets_asc'>('name_asc');
|
||||
const [sortMenuOpen, setSortMenuOpen] = createSignal(false);
|
||||
const [filterMenuOpen, setFilterMenuOpen] = createSignal(false);
|
||||
const [rows, setRows] = createSignal<InternalDashboard[]>([]);
|
||||
const [viewingDashboard, setViewingDashboard] = createSignal<InternalDashboard | null>(null);
|
||||
const [editingId, setEditingId] = createSignal<string | null>(null);
|
||||
const [openMenuId, setOpenMenuId] = createSignal<string | null>(null);
|
||||
|
||||
const filtered = createMemo(() => {
|
||||
const load = async () => {
|
||||
setRows(FALLBACK_DASHBOARDS);
|
||||
};
|
||||
|
||||
onMount(() => void load());
|
||||
|
||||
const filteredRows = createMemo(() => {
|
||||
let r = rows();
|
||||
if (statusFilter() !== 'all') r = r.filter((d) => d.status === statusFilter().toUpperCase());
|
||||
const q = search().toLowerCase();
|
||||
const st = statusFilter();
|
||||
return (dashboards() || FALLBACK).filter((d) => {
|
||||
const matchesSearch = !q || d.name.toLowerCase().includes(q) || d.department.toLowerCase().includes(q) || d.role.toLowerCase().includes(q);
|
||||
const matchesStatus = st === 'All' || d.status === st;
|
||||
return matchesSearch && matchesStatus;
|
||||
if (q) {
|
||||
r = r.filter(r => r.name.toLowerCase().includes(q) || r.code.toLowerCase().includes(q));
|
||||
}
|
||||
const sorted = [...r];
|
||||
const mode = sortBy();
|
||||
sorted.sort((a, b) => {
|
||||
if (mode === 'name_desc') return b.name.localeCompare(a.name);
|
||||
if (mode === 'widgets_desc') return b.widgetsCount - a.widgetsCount;
|
||||
if (mode === 'widgets_asc') return a.widgetsCount - b.widgetsCount;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
return sorted;
|
||||
});
|
||||
|
||||
const totalTemplates = createMemo(() => (dashboards() || FALLBACK).length);
|
||||
const activeTemplates = createMemo(() => (dashboards() || FALLBACK).filter((d) => d.status === 'ACTIVE').length);
|
||||
const draftTemplates = createMemo(() => (dashboards() || FALLBACK).filter((d) => d.status === 'DRAFT').length);
|
||||
const assignedRoles = createMemo(() => {
|
||||
const roles = new Set((dashboards() || FALLBACK).map((d) => d.role).filter(Boolean));
|
||||
return roles.size;
|
||||
});
|
||||
const unassignedRoles = createMemo(() => (dashboards() || FALLBACK).filter((d) => !d.role).length);
|
||||
const resetForm = () => {
|
||||
setEditingId(null); setViewingDashboard(null); setFormTab('general');
|
||||
};
|
||||
|
||||
const pillActive = 'background:#0D0D2A;color:white;border:none;height:32px;border-radius:9999px;padding:0 16px;font-size:13px;font-weight:500;cursor:pointer';
|
||||
const pillInactive = 'background:transparent;color:#6B7280;border:1px solid #E5E7EB;height:32px;border-radius:9999px;padding:0 16px;font-size:13px;font-weight:500;cursor:pointer';
|
||||
const openCreate = () => { resetForm(); setView('form'); };
|
||||
const openEdit = (row: InternalDashboard) => { setEditingId(row.id); setViewingDashboard(row); setView('form'); setOpenMenuId(null); };
|
||||
const openDetail = (row: InternalDashboard) => { setViewingDashboard(row); setListTab('view'); setOpenMenuId(null); };
|
||||
|
||||
return (
|
||||
<AdminShell>
|
||||
<div style="width:100%;padding-bottom:32px">
|
||||
<div class="w-full space-y-6 pb-8">
|
||||
|
||||
<div style="margin-bottom: 1.5rem">
|
||||
<h1 class="text-[28px] font-bold leading-tight text-[#111827]">Internal Dashboard Management</h1>
|
||||
<p class="mt-1 text-[14px] text-[#6B7280]">Create and customize internal control panels for different admin roles</p>
|
||||
</div>
|
||||
|
||||
{/* Header */}
|
||||
<div style="display:flex;align-items:flex-start;justify-content:space-between;gap:16px">
|
||||
{/* ── LIST VIEW ── */}
|
||||
<Show when={view() === 'list'}>
|
||||
<div>
|
||||
<h1 style="font-size:28px;font-weight:700;color:#111827">Internal Dashboard Management</h1>
|
||||
<p style="margin-top:4px;font-size:14px;color:#6B7280">Manage internal dashboard templates for departments, designations, and roles.</p>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;align-items:center;flex-shrink:0;margin-top:4px">
|
||||
<button style={activeTab() === 'dashboard' ? pillActive : pillInactive} onClick={() => setActiveTab('dashboard')}>
|
||||
Dashboard Management
|
||||
</button>
|
||||
<button style={activeTab() === 'preview' ? pillActive : pillInactive} onClick={() => setActiveTab('preview')}>
|
||||
Role Preview
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/* Tabs */}
|
||||
<div style="margin-top:24px;display:flex;align-items:center;gap:24px;border-bottom:1px solid #E5E7EB">
|
||||
{([
|
||||
{ key: 'all', label: 'All Dashboards', action: () => { setListTab('all'); setStatusFilter('all'); } },
|
||||
{ key: 'create', label: 'Create Dashboard', action: () => { setListTab('create'); openCreate(); } },
|
||||
{ key: 'view', label: 'View Details', action: () => setListTab('view') },
|
||||
] as const).map((tab) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={tab.action}
|
||||
style={`padding-bottom:12px;font-size:14px;font-weight:500;background:none;border:none;cursor:pointer;${listTab() === tab.key ? 'color:#FF5E13;border-bottom:2px solid #FF5E13;margin-bottom:-1px' : 'color:#6B7280'}`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Stats Row */}
|
||||
<div style="display:flex;gap:16px;margin-top:24px">
|
||||
<div style="border-radius:12px;border:1px solid #E5E7EB;background:white;padding:16px 20px;flex:1">
|
||||
<p style="font-size:13px;color:#6B7280;margin:0">Total Dashboard Templates</p>
|
||||
<p style="font-size:28px;font-weight:700;color:#111827;margin:4px 0 0">{dashboards.loading ? '—' : totalTemplates()}</p>
|
||||
</div>
|
||||
<div style="border-radius:12px;border:1px solid #E5E7EB;background:white;padding:16px 20px;flex:1">
|
||||
<p style="font-size:13px;color:#6B7280;margin:0">Active Templates</p>
|
||||
<p style="font-size:28px;font-weight:700;color:#FF5E13;margin:4px 0 0">{dashboards.loading ? '—' : activeTemplates()}</p>
|
||||
</div>
|
||||
<div style="border-radius:12px;border:1px solid #E5E7EB;background:white;padding:16px 20px;flex:1">
|
||||
<p style="font-size:13px;color:#6B7280;margin:0">Draft Templates</p>
|
||||
<p style="font-size:28px;font-weight:700;color:#6B7280;margin:4px 0 0">{dashboards.loading ? '—' : draftTemplates()}</p>
|
||||
</div>
|
||||
<div style="border-radius:12px;border:1px solid #E5E7EB;background:white;padding:16px 20px;flex:1">
|
||||
<p style="font-size:13px;color:#6B7280;margin:0">Assigned Roles</p>
|
||||
<p style="font-size:28px;font-weight:700;color:#3730A3;margin:4px 0 0">{dashboards.loading ? '—' : assignedRoles()}</p>
|
||||
</div>
|
||||
<div style="border-radius:12px;border:1px solid #E5E7EB;background:white;padding:16px 20px;flex:1">
|
||||
<p style="font-size:13px;color:#6B7280;margin:0">Unassigned Roles</p>
|
||||
<p style="font-size:28px;font-weight:700;color:#111827;margin:4px 0 0">{dashboards.loading ? '—' : unassignedRoles()}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table Card */}
|
||||
<div style="margin-top:24px;border-radius:12px;border:1px solid #E5E7EB;background:white;overflow:hidden">
|
||||
|
||||
{/* Card Header */}
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;padding:16px 20px;border-bottom:1px solid #F3F4F6">
|
||||
<h2 style="font-size:16px;font-weight:600;color:#111827;margin:0">Internal Dashboard Templates</h2>
|
||||
<div style="display:flex;gap:8px">
|
||||
<button style="border:1px solid #E5E7EB;background:white;color:#374151;height:36px;border-radius:8px;padding:0 14px;font-size:13px;font-weight:500;cursor:pointer">
|
||||
Import Layout
|
||||
</button>
|
||||
<button style="border:1px solid #E5E7EB;background:white;color:#374151;height:36px;border-radius:8px;padding:0 14px;font-size:13px;font-weight:500;cursor:pointer">
|
||||
Export Config
|
||||
</button>
|
||||
<A
|
||||
href="/admin/internal-dashboard-management/new"
|
||||
style="background:#0D0D2A;color:white;border:none;height:36px;border-radius:8px;padding:0 16px;font-size:13px;font-weight:600;cursor:pointer;display:inline-flex;align-items:center;gap:6px;text-decoration:none"
|
||||
{/* View Details panel */}
|
||||
<Show when={listTab() === 'view'}>
|
||||
<Show
|
||||
when={!viewingDashboard()}
|
||||
>
|
||||
<span>+</span> Create Dashboard Template
|
||||
</A>
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-top:24px;border-radius:16px;border:1px solid #E5E7EB;background:white;padding:48px 24px;text-align:center">
|
||||
<p style="font-size:15px;font-weight:600;color:#111827">No dashboard selected</p>
|
||||
<p style="margin-top:6px;font-size:13px;color:#6B7280">Click the <strong>⋮</strong> menu on any dashboard row and choose <strong>View Details</strong>.</p>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={viewingDashboard()}>
|
||||
<div style="margin-top:24px;border-radius:16px;border:1px solid #E5E7EB;background:white;box-shadow:0 1px 4px rgba(0,0,0,0.06);overflow:hidden">
|
||||
<div style="padding:20px 24px;border-bottom:1px solid #E5E7EB;display:flex;align-items:center;justify-content:space-between">
|
||||
<div>
|
||||
<h2 style="font-size:18px;font-weight:700;color:#111827">{viewingDashboard()!.name}</h2>
|
||||
<p style="margin-top:2px;font-size:13px;color:#6B7280">{viewingDashboard()!.code} • {viewingDashboard()!.assignedRole}</p>
|
||||
</div>
|
||||
<StatusBadge status={viewingDashboard()!.status} />
|
||||
</div>
|
||||
|
||||
<div style="display:flex;align-items:center;gap:4px;border-bottom:1px solid #E5E7EB;padding:0 24px;background:#FAFAFA">
|
||||
{(['overview', 'widgets', 'preview'] as const).map((tab, i) => {
|
||||
const labels = ['Overview', 'Widget Configuration', 'Live Preview'];
|
||||
const active = () => detailTab() === tab;
|
||||
return (
|
||||
<button type="button" onClick={() => setDetailTab(tab)} style={`position:relative;padding:14px 12px;font-size:13px;font-weight:600;background:none;border:none;cursor:pointer;color:${active() ? '#FF5E13' : '#6B7280'}`}>
|
||||
{labels[i]}
|
||||
<Show when={active()}><span style="position:absolute;left:0;right:0;bottom:0;height:2px;background:#FF5E13;border-radius:2px 2px 0 0" /></Show>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Filter Bar */}
|
||||
<div style="display:flex;gap:12px;align-items:center;padding:12px 20px;border-bottom:1px solid #F3F4F6">
|
||||
<div style="position:relative;flex:1;max-width:320px">
|
||||
<svg style="position:absolute;left:10px;top:50%;transform:translateY(-50%);color:#9CA3AF" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search dashboards..."
|
||||
value={search()}
|
||||
onInput={(e) => setSearch(e.currentTarget.value)}
|
||||
style="height:36px;width:100%;border:1px solid #E5E7EB;border-radius:8px;padding:0 12px 0 32px;font-size:13px;color:#111827;outline:none;box-sizing:border-box"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
value={statusFilter()}
|
||||
onChange={(e) => setStatusFilter(e.currentTarget.value)}
|
||||
style="height:36px;border:1px solid #E5E7EB;border-radius:8px;padding:0 12px;font-size:13px;color:#374151;background:white;outline:none;cursor:pointer"
|
||||
>
|
||||
<option value="All">Status: All</option>
|
||||
<option value="ACTIVE">Active</option>
|
||||
<option value="DRAFT">Draft</option>
|
||||
</select>
|
||||
<select style="height:36px;border:1px solid #E5E7EB;border-radius:8px;padding:0 12px;font-size:13px;color:#374151;background:white;outline:none;cursor:pointer">
|
||||
<option>Department: All</option>
|
||||
<option>Administration</option>
|
||||
<option>Engineering</option>
|
||||
<option>Human Resources</option>
|
||||
<option>Finance</option>
|
||||
</select>
|
||||
</div>
|
||||
<div style="padding:24px">
|
||||
<Show when={detailTab() === 'overview'}>
|
||||
<div style="display:grid;grid-template-columns:repeat(4, 1fr);gap:16px">
|
||||
{[
|
||||
{ l: 'Total Users', v: '450' },
|
||||
{ l: 'Pending Approvals', v: '12' },
|
||||
{ l: 'System Health', v: 'Optimal' },
|
||||
{ l: 'Active Sessions', v: '28' }
|
||||
].map(stat => (
|
||||
<div style="padding:16px;border:1px solid #E5E7EB;border-radius:12px;background:#F9FAFB">
|
||||
<p style="font-size:11px;color:#6B7280;font-weight:700;text-transform:uppercase">{stat.l}</p>
|
||||
<p style="font-size:20px;font-weight:700;color:#111827;margin-top:8px">{stat.v}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div style="overflow-x:auto">
|
||||
<table style="width:100%;border-collapse:collapse;min-width:900px">
|
||||
<thead>
|
||||
<tr style="background:#0D0D2A">
|
||||
<th style="padding:12px 20px;text-align:left;font-size:12px;font-weight:600;color:#FFFFFF;white-space:nowrap">DASHBOARD NAME</th>
|
||||
<th style="padding:12px 20px;text-align:left;font-size:12px;font-weight:600;color:#FFFFFF;white-space:nowrap">ASSIGNED DEPARTMENT</th>
|
||||
<th style="padding:12px 20px;text-align:left;font-size:12px;font-weight:600;color:#FFFFFF;white-space:nowrap">ASSIGNED DESIGNATION</th>
|
||||
<th style="padding:12px 20px;text-align:left;font-size:12px;font-weight:600;color:#FFFFFF;white-space:nowrap">ASSIGNED INTERNAL ROLE</th>
|
||||
<th style="padding:12px 20px;text-align:left;font-size:12px;font-weight:600;color:#FFFFFF;white-space:nowrap">WIDGETS COUNT</th>
|
||||
<th style="padding:12px 20px;text-align:left;font-size:12px;font-weight:600;color:#FFFFFF;white-space:nowrap">STATUS</th>
|
||||
<th style="padding:12px 20px;text-align:right;font-size:12px;font-weight:600;color:#FFFFFF;white-space:nowrap">ACTIONS</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<Show when={dashboards.loading}>
|
||||
<tr>
|
||||
<td colspan="7" style="padding:32px 20px;text-align:center;color:#9CA3AF;font-size:14px">Loading dashboards...</td>
|
||||
</tr>
|
||||
</Show>
|
||||
<Show when={!dashboards.loading && filtered().length === 0}>
|
||||
<tr>
|
||||
<td colspan="7" style="padding:32px 20px;text-align:center;color:#9CA3AF;font-size:14px">No dashboard templates found.</td>
|
||||
</tr>
|
||||
</Show>
|
||||
<For each={filtered()}>
|
||||
{(dashboard, index) => (
|
||||
<tr style={`border-bottom:1px solid #F3F4F6;background:${index() % 2 === 0 ? 'white' : '#FAFAFA'}`}>
|
||||
<td style="padding:12px 20px;font-size:14px;color:#111827;font-weight:500">{dashboard.name}</td>
|
||||
<td style="padding:12px 20px;font-size:14px;color:#374151">{dashboard.department || '—'}</td>
|
||||
<td style="padding:12px 20px;font-size:14px;color:#374151">{dashboard.designation || '—'}</td>
|
||||
<td style="padding:12px 20px;font-size:14px;color:#374151">{dashboard.role || '—'}</td>
|
||||
<td style="padding:12px 20px;font-size:14px;color:#374151">{dashboard.widgetsCount}</td>
|
||||
<td style="padding:12px 20px">
|
||||
<StatusBadge status={dashboard.status} />
|
||||
</td>
|
||||
<td style="padding:12px 20px;text-align:right">
|
||||
<div style="display:inline-flex;gap:8px">
|
||||
<A
|
||||
href={`/admin/internal-dashboard-management/${dashboard.id}`}
|
||||
style="font-size:13px;color:#FF5E13;font-weight:500;text-decoration:none;border:1px solid #FFD8C2;border-radius:6px;padding:4px 10px;background:#FFF1EB"
|
||||
>
|
||||
Edit
|
||||
</A>
|
||||
<button style="font-size:13px;color:#6B7280;font-weight:500;border:1px solid #E5E7EB;border-radius:6px;padding:4px 10px;background:white;cursor:pointer">
|
||||
Preview
|
||||
<div style="display:flex;align-items:center;gap:10px;padding:14px 24px;border-top:1px solid #E5E7EB">
|
||||
<button type="button" onClick={() => openEdit(viewingDashboard()!)} style="height:36px;border-radius:8px;background:#0D0D2A;padding:0 16px;font-size:13px;font-weight:600;color:white;border:none;cursor:pointer">Edit Layout</button>
|
||||
<button type="button" onClick={() => { setViewingDashboard(null); setListTab('all'); }} style="height:36px;border-radius:8px;border:1px solid #E5E7EB;background:white;padding:0 16px;font-size:13px;font-weight:600;color:#374151;cursor:pointer">Back to List</button>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</Show>
|
||||
|
||||
<div style={{ display: listTab() === 'view' ? 'none' : 'block' }}>
|
||||
<div style="margin-top:1.5rem;margin-left:-24px;margin-right:-24px;border-radius:0;border-left:none;border-right:none;overflow:hidden;border-top:1px solid #E5E7EB;border-bottom:1px solid #E5E7EB;background:white;box-shadow:0 1px 3px rgba(0,0,0,0.06)">
|
||||
<div style="display:flex;align-items:center;gap:8px;padding:14px 20px;border-bottom:1px solid #F3F4F6">
|
||||
<input
|
||||
value={search()}
|
||||
onInput={(e) => setSearch(e.currentTarget.value)}
|
||||
placeholder="Search dashboards..."
|
||||
style="height:34px;flex:1;border-radius:8px;border:1px solid #E5E7EB;background:white;padding:0 12px;font-size:13px;color:#111827;outline:none"
|
||||
/>
|
||||
<div style="position:relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setSortMenuOpen((v) => !v); setFilterMenuOpen(false); }}
|
||||
style="display:inline-flex;height:34px;align-items:center;gap:6px;border-radius:8px;border:1px solid #E5E7EB;background:white;padding:0 12px;font-size:12px;font-weight:500;color:#374151;cursor:pointer"
|
||||
>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M7 4v13"/><path d="m3 13 4 4 4-4"/><path d="M17 20V7"/><path d="m21 11-4-4-4 4"/></svg>
|
||||
Sort
|
||||
</button>
|
||||
<Show when={sortMenuOpen()}>
|
||||
<div style="position:absolute;left:0;top:38px;z-index:30;min-width:200px;border-radius:12px;border:1px solid #E5E7EB;background:white;padding:6px;box-shadow:0 4px 16px rgba(0,0,0,0.1)">
|
||||
{(['name_asc', 'name_desc', 'widgets_desc', 'widgets_asc'] as const).map((s, i) => (
|
||||
<button type="button" onClick={() => { setSortBy(s); setSortMenuOpen(false); }} style={`display:block;width:100%;border-radius:8px;padding:8px 12px;text-align:left;font-size:13px;background:none;border:none;cursor:pointer;color:${sortBy() === s ? '#FF5E13' : '#374151'};background:${sortBy() === s ? '#FFF1EB' : 'transparent'}`}>
|
||||
{['Name (A-Z)', 'Name (Z-A)', 'Widgets (High-Low)', 'Widgets (Low-High)'][i]}
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</For>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
<div style="position:relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setFilterMenuOpen((v) => !v); setSortMenuOpen(false); }}
|
||||
style="display:inline-flex;height:34px;align-items:center;gap:6px;border-radius:8px;border:1px solid #E5E7EB;background:white;padding:0 12px;font-size:12px;font-weight:500;color:#374151;cursor:pointer"
|
||||
>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 5h18M6 12h12M10 19h4"/></svg>
|
||||
Filters
|
||||
</button>
|
||||
<Show when={filterMenuOpen()}>
|
||||
<div style="position:absolute;left:0;top:38px;z-index:30;min-width:160px;border-radius:12px;border:1px solid #E5E7EB;background:white;padding:6px;box-shadow:0 4px 16px rgba(0,0,0,0.1)">
|
||||
{(['all', 'active', 'inactive', 'draft'] as const).map((s) => (
|
||||
<button type="button" onClick={() => { setStatusFilter(s); setFilterMenuOpen(false); }} style={`display:block;width:100%;border-radius:8px;padding:8px 12px;text-align:left;font-size:13px;background:none;border:none;cursor:pointer;color:${statusFilter() === s ? '#FF5E13' : '#374151'};background:${statusFilter() === s ? '#FFF1EB' : 'transparent'}`}>
|
||||
{s === 'all' ? 'All Status' : s === 'active' ? 'Active' : s === 'inactive' ? 'Inactive' : 'Draft'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
<div style="display:flex;justify-content:space-between;border-top:1px solid #F3F4F6;padding:12px 20px;align-items:center">
|
||||
<span style="font-size:13px;color:#6B7280">Showing {filtered().length} of {(dashboards() || FALLBACK).length} templates</span>
|
||||
<div style="display:flex;gap:4px">
|
||||
<button style="height:32px;min-width:32px;border:1px solid #E5E7EB;background:white;border-radius:6px;font-size:13px;color:#374151;cursor:pointer;padding:0 10px">Previous</button>
|
||||
<button style="height:32px;min-width:32px;border:1px solid #E5E7EB;background:#0D0D2A;border-radius:6px;font-size:13px;color:white;cursor:pointer;padding:0 10px">1</button>
|
||||
<button style="height:32px;min-width:32px;border:1px solid #E5E7EB;background:white;border-radius:6px;font-size:13px;color:#374151;cursor:pointer;padding:0 10px">Next</button>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full">
|
||||
<thead>
|
||||
<tr style="background:#0D0D2A;text-align:left">
|
||||
{['Dashboard Name', 'Code', 'Assigned Role', 'Widgets', 'Status', 'Actions'].map(h => (
|
||||
<th style="padding:10px 20px;font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:0.05em;color:#FFFFFF;white-space:nowrap">{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<For each={filteredRows()}>
|
||||
{(row) => (
|
||||
<tr style="border-bottom:1px solid #F3F4F6" class="hover:bg-[#FAFAFA] transition-colors">
|
||||
<td style="padding:12px 20px;font-size:14px;font-weight:600;color:#111827">{row.name}</td>
|
||||
<td style="padding:12px 20px;font-size:12px;font-family:monospace;color:#6B7280">{row.code}</td>
|
||||
<td style="padding:12px 20px;font-size:13px;color:#6B7280">{row.assignedRole}</td>
|
||||
<td style="padding:12px 20px;font-size:13px;color:#6B7280">{row.widgetsCount} widgets</td>
|
||||
<td style="padding:12px 20px"><StatusBadge status={row.status} /></td>
|
||||
<td style="padding:12px 20px;position:relative">
|
||||
<button type="button" onClick={() => setOpenMenuId(openMenuId() === row.id ? null : row.id)} style="display:inline-flex;height:32px;width:32px;align-items:center;justify-content:center;border-radius:8px;color:#9CA3AF;background:none;border:none;cursor:pointer">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="5" r="1.5"/><circle cx="12" cy="12" r="1.5"/><circle cx="12" cy="19" r="1.5"/></svg>
|
||||
</button>
|
||||
<Show when={openMenuId() === row.id}>
|
||||
<div style="position:absolute;right:20px;top:44px;z-index:20;width:180px;border-radius:12px;border:1px solid #E5E7EB;background:white;padding:6px;box-shadow:0 4px 20px rgba(0,0,0,0.12)">
|
||||
<button type="button" onClick={() => openDetail(row)} style="display:flex;width:100%;align-items:center;gap:10px;border-radius:8px;padding:8px 12px;font-size:13px;color:#374151;background:none;border:none;cursor:pointer;text-align:left">View Details</button>
|
||||
<button type="button" onClick={() => openEdit(row)} style="display:flex;width:100%;align-items:center;gap:10px;border-radius:8px;padding:8px 12px;font-size:13px;color:#374151;background:none;border:none;cursor:pointer;text-align:left">Edit Layout</button>
|
||||
<button type="button" style="display:flex;width:100%;align-items:center;gap:10px;border-radius:8px;padding:8px 12px;font-size:13px;color:#DC2626;background:none;border:none;cursor:pointer;text-align:left">Deactivate</button>
|
||||
</div>
|
||||
</Show>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</For>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* ── FORM VIEW ── */}
|
||||
<Show when={view() === 'form'}>
|
||||
<div>
|
||||
<div style="margin-top:24px;display:flex;align-items:center;gap:24px;border-bottom:1px solid #E5E7EB">
|
||||
<button type="button" onClick={() => { setView('list'); resetForm(); }} style="padding-bottom:12px;font-size:14px;font-weight:500;color:#6B7280;background:none;border:none;cursor:pointer">All Dashboards</button>
|
||||
<button type="button" style="padding-bottom:12px;font-size:14px;font-weight:500;color:#FF5E13;border-bottom:2px solid #FF5E13;background:none;cursor:pointer;margin-bottom:-1px">{editingId() ? 'Edit Dashboard' : 'Create Dashboard'}</button>
|
||||
</div>
|
||||
|
||||
<div style="margin-top:24px;border-radius:16px;border:1px solid #E5E7EB;background:white;box-shadow:0 1px 4px rgba(0,0,0,0.06);overflow:hidden">
|
||||
<div style="display:flex;align-items:center;gap:4px;border-bottom:1px solid #E5E7EB;padding:0 24px">
|
||||
{(['general', 'builder', 'permissions'] as const).map((tab, i) => {
|
||||
const labels = ['General Config', 'Internal Builder', 'Role Assignments'];
|
||||
const active = () => formTab() === tab;
|
||||
return (
|
||||
<button type="button" onClick={() => setFormTab(tab)} style={`position:relative;padding:14px 8px;font-size:13px;font-weight:500;background:none;border:none;cursor:pointer;color:${active() ? '#FF5E13' : '#6B7280'}`}>
|
||||
{labels[i]}
|
||||
<Show when={active()}><span style="position:absolute;left:0;right:0;bottom:0;height:2px;background:#FF5E13;border-radius:2px 2px 0 0" /></Show>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div style="padding:24px">
|
||||
<Show when={formTab() === 'general'}>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:20px">
|
||||
<label style="display:block">
|
||||
<span style="font-size:13px;font-weight:600;color:#374151">Dashboard Name *</span>
|
||||
<input value={viewingDashboard()?.name || ''} style="display:block;margin-top:6px;height:40px;width:100%;border-radius:10px;border:1px solid #E5E7EB;padding:0 14px;font-size:13px" />
|
||||
</label>
|
||||
<label style="display:block">
|
||||
<span style="font-size:13px;font-weight:600;color:#374151">Internal Role *</span>
|
||||
<select style="display:block;margin-top:6px;height:40px;width:100%;border-radius:10px;border:1px solid #E5E7EB;padding:0 14px;font-size:13px;background:white">
|
||||
<option selected={viewingDashboard()?.assignedRole === 'System Administrator'}>System Administrator</option>
|
||||
<option selected={viewingDashboard()?.assignedRole === 'HR Manager'}>HR Manager</option>
|
||||
<option selected={viewingDashboard()?.assignedRole === 'Finance Controller'}>Finance Controller</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={formTab() === 'builder'}>
|
||||
<div style="text-align:center;padding:100px;border:2px dashed #E5E7EB;border-radius:12px;background:#FAFAFA">
|
||||
<p style="font-size:14px;color:#9CA3AF">Internal dashboard builder canvas goes here</p>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;align-items:center;justify-content:flex-end;gap:10px;border-top:1px solid #E5E7EB;padding:14px 24px">
|
||||
<button type="button" onClick={() => { setView('list'); resetForm(); }} style="height:38px;border-radius:10px;border:1px solid #E5E7EB;background:white;padding:0 20px;font-size:13px;font-weight:600;color:#374151;cursor:pointer">Cancel</button>
|
||||
<button type="button" style="height:38px;border-radius:10px;background:#0D0D2A;padding:0 24px;font-size:13px;font-weight:600;color:white;border:none;cursor:pointer">
|
||||
{editingId() ? 'Update Dashboard' : 'Create Dashboard'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
</div>
|
||||
</AdminShell>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,158 +1,181 @@
|
|||
import { createResource, createSignal, createMemo, Show, For } from 'solid-js';
|
||||
import { A } from '@solidjs/router';
|
||||
import { For, Show, createMemo, createSignal, onMount } from 'solid-js';
|
||||
import AdminShell from '~/components/AdminShell';
|
||||
import type { CrudRecord } from '~/lib/admin/types';
|
||||
|
||||
const API = '/api/gateway';
|
||||
|
||||
type Job = {
|
||||
id: string;
|
||||
type JobRecord = CrudRecord & {
|
||||
title: string;
|
||||
description?: string;
|
||||
required_skills?: string[];
|
||||
experience_level?: string;
|
||||
job_type?: string;
|
||||
status: string;
|
||||
client_name?: string;
|
||||
company_name?: string;
|
||||
company?: string;
|
||||
location?: string;
|
||||
hourly_rate_min?: number;
|
||||
hourly_rate_max?: number;
|
||||
created_at?: string;
|
||||
rate?: string;
|
||||
status: 'ACTIVE' | 'DRAFT' | 'PENDING_APPROVAL' | 'CLOSED' | 'EXPIRED';
|
||||
postedDate?: string;
|
||||
};
|
||||
|
||||
async function loadJobs(): Promise<Job[]> {
|
||||
try {
|
||||
const res = await fetch(`${API}/api/jobs?limit=100`);
|
||||
if (!res.ok) throw new Error('Failed to load');
|
||||
const data = await res.json();
|
||||
return Array.isArray(data) ? data : (data.jobs || []);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
const FALLBACK_JOBS: JobRecord[] = [
|
||||
{ id: 'j1', name: 'Senior UI/UX Designer', title: 'Senior UI/UX Designer', company: 'Creative Designs', location: 'Remote', rate: '₹800–₹1200/hr', status: 'ACTIVE', postedDate: '2026-03-25', updatedAt: '2026-03-27' },
|
||||
{ id: 'j2', name: 'Full Stack Developer', title: 'Full Stack Developer', company: 'Tech Solutions Inc', location: 'Mumbai', rate: '₹1000–₹1500/hr', status: 'PENDING_APPROVAL', postedDate: '2026-03-24', updatedAt: '2026-03-27' },
|
||||
{ id: 'j3', name: 'Marketing Specialist', title: 'Marketing Specialist', company: 'Global Brands', location: 'Delhi', rate: '₹500–₹700/hr', status: 'DRAFT', postedDate: '2026-03-26', updatedAt: '2026-03-27' },
|
||||
];
|
||||
|
||||
function StatusBadge(props: { status: string }) {
|
||||
const getColors = () => {
|
||||
switch (props.status) {
|
||||
case 'ACTIVE': return { border: '#B7E4C7', bg: '#DEF7E8', text: '#0B8A4A', dot: '#0B8A4A' };
|
||||
case 'PENDING_APPROVAL': return { border: '#F6D78F', bg: '#FFF3D6', text: '#B7791F', dot: '#B7791F' };
|
||||
case 'DRAFT': return { border: '#D1D5DB', bg: '#F3F4F6', text: '#4B5563', dot: '#9CA3AF' };
|
||||
case 'CLOSED': return { border: '#FECACA', bg: '#FEF2F2', text: '#DC2626', dot: '#DC2626' };
|
||||
case 'EXPIRED': return { border: '#FECACA', bg: '#FEF2F2', text: '#DC2626', dot: '#DC2626' };
|
||||
default: return { border: '#D1D5DB', bg: '#F3F4F6', text: '#4B5563', dot: '#9CA3AF' };
|
||||
}
|
||||
};
|
||||
const colors = getColors();
|
||||
const label = props.status.split('_').map(w => w.charAt(0) + w.slice(1).toLowerCase()).join(' ');
|
||||
|
||||
return (
|
||||
<span style={`display:inline-flex;align-items:center;border-radius:9999px;border:1px solid ${colors.border};background:${colors.bg};color:${colors.text};padding:2px 10px;font-size:11px;font-weight:600`}>
|
||||
<span style={`display:inline-block;width:6px;height:6px;border-radius:50%;background:${colors.dot};margin-right:5px;flex-shrink:0`} />
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const STATUS_OPTIONS = ['All', 'DRAFT', 'ACTIVE', 'PENDING_APPROVAL', 'CLOSED', 'EXPIRED'];
|
||||
|
||||
function statusChipClass(status: string): string {
|
||||
if (status === 'ACTIVE') return 'status-chip active';
|
||||
if (status === 'PENDING_APPROVAL') return 'status-chip pending';
|
||||
if (status === 'DRAFT') return 'status-chip draft';
|
||||
if (status === 'CLOSED' || status === 'EXPIRED') return 'status-chip danger';
|
||||
return 'status-chip';
|
||||
}
|
||||
|
||||
export default function JobsPage() {
|
||||
const [jobs] = createResource(loadJobs);
|
||||
export default function JobsManagementPage() {
|
||||
const [view, setView] = createSignal<'list' | 'detail'>('list');
|
||||
const [listTab, setListTab] = createSignal<'all' | 'active' | 'pending'>('all');
|
||||
const [search, setSearch] = createSignal('');
|
||||
const [statusFilter, setStatusFilter] = createSignal('All');
|
||||
const [rows, setRows] = createSignal<JobRecord[]>([]);
|
||||
const [selectedJob, setSelectedJob] = createSignal<JobRecord | null>(null);
|
||||
const [openMenuId, setOpenMenuId] = createSignal<string | null>(null);
|
||||
|
||||
const filtered = createMemo(() => {
|
||||
const all = jobs() ?? [];
|
||||
const load = async () => {
|
||||
try {
|
||||
const res = await fetch(`${API}/api/jobs?limit=100`);
|
||||
if (!res.ok) throw new Error();
|
||||
const data = await res.json();
|
||||
const list = Array.isArray(data) ? data : (data.jobs || []);
|
||||
if (list.length === 0) setRows(FALLBACK_JOBS);
|
||||
else setRows(list.map(j => ({
|
||||
id: j.id,
|
||||
name: j.title || '—',
|
||||
title: j.title || '—',
|
||||
company: j.company_name || j.client_name || '—',
|
||||
location: j.location || '—',
|
||||
rate: j.hourly_rate_min ? `₹${j.hourly_rate_min}–₹${j.hourly_rate_max ?? j.hourly_rate_min}/hr` : '—',
|
||||
status: (j.status || 'ACTIVE').toUpperCase(),
|
||||
postedDate: j.created_at ? new Date(j.created_at).toLocaleDateString() : '—',
|
||||
updatedAt: j.updated_at || ''
|
||||
} as JobRecord)));
|
||||
} catch {
|
||||
setRows(FALLBACK_JOBS);
|
||||
}
|
||||
};
|
||||
|
||||
onMount(() => void load());
|
||||
|
||||
const filteredRows = createMemo(() => {
|
||||
let list = rows();
|
||||
if (listTab() === 'active') list = list.filter(r => r.status === 'ACTIVE');
|
||||
if (listTab() === 'pending') list = list.filter(r => r.status === 'PENDING_APPROVAL');
|
||||
const q = search().toLowerCase();
|
||||
const st = statusFilter();
|
||||
return all.filter((job) => {
|
||||
const matchesSearch =
|
||||
!q ||
|
||||
job.title?.toLowerCase().includes(q) ||
|
||||
job.client_name?.toLowerCase().includes(q) ||
|
||||
job.company_name?.toLowerCase().includes(q);
|
||||
const matchesStatus = st === 'All' || job.status === st;
|
||||
return matchesSearch && matchesStatus;
|
||||
});
|
||||
if (q) list = list.filter(r => r.title.toLowerCase().includes(q) || r.company?.toLowerCase().includes(q));
|
||||
return list;
|
||||
});
|
||||
|
||||
const openDetail = (row: JobRecord) => { setSelectedJob(row); setView('detail'); setOpenMenuId(null); };
|
||||
|
||||
return (
|
||||
<AdminShell>
|
||||
<div class="flex flex-col -mx-6 -mt-6 min-h-full">
|
||||
<div class="bg-white border-b border-gray-200 px-6 py-4">
|
||||
<h1 class="text-xl font-semibold text-gray-900">Jobs Management</h1>
|
||||
<p class="text-sm text-gray-500 mt-0.5">Review live company job postings</p>
|
||||
<div class="w-full space-y-6 pb-8">
|
||||
<div style="margin-bottom: 1.5rem">
|
||||
<h1 class="text-[28px] font-bold leading-tight text-[#111827]">Jobs Management</h1>
|
||||
<p class="mt-1 text-[14px] text-[#6B7280]">Review and manage live job postings from companies and clients</p>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 p-6">
|
||||
<div style="display:flex;gap:12px;margin-bottom:16px;flex-wrap:wrap">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search by title or company..."
|
||||
value={search()}
|
||||
onInput={(e) => setSearch(e.currentTarget.value)}
|
||||
style="padding:8px 12px;border:1px solid #e2e8f0;border-radius:6px;font-size:14px;min-width:260px"
|
||||
/>
|
||||
<select
|
||||
value={statusFilter()}
|
||||
onChange={(e) => setStatusFilter(e.currentTarget.value)}
|
||||
style="padding:8px 12px;border:1px solid #e2e8f0;border-radius:6px;font-size:14px"
|
||||
>
|
||||
<For each={STATUS_OPTIONS}>{(opt) => <option value={opt}>{opt === 'All' ? 'All Statuses' : opt}</option>}</For>
|
||||
</select>
|
||||
<Show when={view() === 'list'}>
|
||||
<div style="margin-top:24px;display:flex;align-items:center;gap:24px;border-bottom:1px solid #E5E7EB">
|
||||
{([
|
||||
{ key: 'all', label: 'All Jobs', action: () => setListTab('all') },
|
||||
{ key: 'active', label: 'Active', action: () => setListTab('active') },
|
||||
{ key: 'pending', label: 'Pending Approval', action: () => setListTab('pending') },
|
||||
] as const).map((tab) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={tab.action}
|
||||
style={`padding-bottom:12px;font-size:14px;font-weight:500;background:none;border:none;cursor:pointer;${listTab() === tab.key ? 'color:#FF5E13;border-bottom:2px solid #FF5E13;margin-bottom:-1px' : 'color:#6B7280'}`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div class="table-card">
|
||||
<div style="margin-top:1.5rem;margin-left:-24px;margin-right:-24px;border-radius:0;border-left:none;border-right:none;overflow:hidden;border-top:1px solid #E5E7EB;border-bottom:1px solid #E5E7EB;background:white;box-shadow:0 1px 3px rgba(0,0,0,0.06)">
|
||||
<div style="display:flex;align-items:center;gap:8px;padding:14px 20px;border-bottom:1px solid #F3F4F6">
|
||||
<input
|
||||
value={search()}
|
||||
onInput={(e) => setSearch(e.currentTarget.value)}
|
||||
placeholder="Search jobs..."
|
||||
style="height:34px;flex:1;border-radius:8px;border:1px solid #E5E7EB;background:white;padding:0 12px;font-size:13px;color:#111827;outline:none"
|
||||
/>
|
||||
<button type="button" style="display:inline-flex;height:34px;align-items:center;gap:6px;border-radius:8px;border:1px solid #E5E7EB;background:white;padding:0 12px;font-size:12px;font-weight:500;color:#374151;cursor:pointer">Filters</button>
|
||||
</div>
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<table class="data-table w-full text-sm">
|
||||
<table class="min-w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Title</th>
|
||||
<th>Skills</th>
|
||||
<th>Company / Client</th>
|
||||
<th>Rate</th>
|
||||
<th>Location</th>
|
||||
<th>Status</th>
|
||||
<th class="text-right">Actions</th>
|
||||
<tr style="background:#0D0D2A;text-align:left">
|
||||
{['Job Title', 'Company', 'Rate', 'Location', 'Status', 'Actions'].map(h => (
|
||||
<th style="padding:10px 20px;font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:0.05em;color:#FFFFFF;white-space:nowrap">{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<Show when={jobs.loading}>
|
||||
<tr><td colspan="7" style="text-align:center;padding:32px;color:#64748b">Loading...</td></tr>
|
||||
</Show>
|
||||
<Show when={!jobs.loading && jobs.error}>
|
||||
<tr><td colspan="7" style="text-align:center;padding:32px;color:#b91c1c">Failed to load. Is the backend running?</td></tr>
|
||||
</Show>
|
||||
<Show when={!jobs.loading && !jobs.error && filtered().length === 0}>
|
||||
<tr><td colspan="7" style="text-align:center;padding:32px;color:#94a3b8">No jobs found.</td></tr>
|
||||
</Show>
|
||||
<Show when={!jobs.loading && !jobs.error && filtered().length > 0}>
|
||||
<For each={filtered()}>
|
||||
{(job) => (
|
||||
<tr class="hover:bg-slate-50">
|
||||
<td>
|
||||
<div class="font-semibold text-slate-900">{job.title || '—'}</div>
|
||||
<Show when={job.description}>
|
||||
<div style="font-size:12px;color:#64748b;margin-top:2px;max-width:200px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">
|
||||
{job.description}
|
||||
</div>
|
||||
</Show>
|
||||
</td>
|
||||
<td class="text-slate-500">
|
||||
{job.required_skills?.join(', ') || job.experience_level || '—'}
|
||||
</td>
|
||||
<td class="text-slate-500">{job.client_name || job.company_name || '—'}</td>
|
||||
<td class="text-slate-500">
|
||||
{job.hourly_rate_min != null
|
||||
? `₹${job.hourly_rate_min}–₹${job.hourly_rate_max ?? job.hourly_rate_min}/hr`
|
||||
: '—'}
|
||||
</td>
|
||||
<td class="text-slate-500">{job.location || '—'}</td>
|
||||
<td>
|
||||
<span class={statusChipClass(job.status)}>{job.status || '—'}</span>
|
||||
</td>
|
||||
<td>
|
||||
<div class="flex items-center justify-end gap-1">
|
||||
<A class="inline-flex items-center rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors" href={`/admin/jobs/${job.id}`}>View</A>
|
||||
<Show when={job.status === 'PENDING_APPROVAL'}>
|
||||
<A class="inline-flex items-center rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors" href="/admin/approval">Review</A>
|
||||
</Show>
|
||||
<For each={filteredRows()}>
|
||||
{(row) => (
|
||||
<tr style="border-bottom:1px solid #F3F4F6" class="hover:bg-[#FAFAFA] transition-colors">
|
||||
<td style="padding:12px 20px;font-size:14px;font-weight:600;color:#111827">{row.title}</td>
|
||||
<td style="padding:12px 20px;font-size:13px;color:#6B7280">{row.company || '—'}</td>
|
||||
<td style="padding:12px 20px;font-size:13px;color:#6B7280">{row.rate || '—'}</td>
|
||||
<td style="padding:12px 20px;font-size:13px;color:#6B7280">{row.location || '—'}</td>
|
||||
<td style="padding:12px 20px"><StatusBadge status={row.status} /></td>
|
||||
<td style="padding:12px 20px;position:relative">
|
||||
<button type="button" onClick={() => setOpenMenuId(openMenuId() === row.id ? null : row.id)} style="display:inline-flex;height:32px;width:32px;align-items:center;justify-content:center;border-radius:8px;color:#9CA3AF;background:none;border:none;cursor:pointer">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="5" r="1.5"/><circle cx="12" cy="12" r="1.5"/><circle cx="12" cy="19" r="1.5"/></svg>
|
||||
</button>
|
||||
<Show when={openMenuId() === row.id}>
|
||||
<div style="position:absolute;right:20px;top:44px;z-index:20;width:180px;border-radius:12px;border:1px solid #E5E7EB;background:white;padding:6px;box-shadow:0 4px 20px rgba(0,0,0,0.12)">
|
||||
<button type="button" onClick={() => openDetail(row)} style="display:flex;width:100%;align-items:center;gap:10px;border-radius:8px;padding:8px 12px;font-size:13px;color:#374151;background:none;border:none;cursor:pointer;text-align:left">View Details</button>
|
||||
<button type="button" style="display:flex;width:100%;align-items:center;gap:10px;border-radius:8px;padding:8px 12px;font-size:13px;color:#DC2626;background:none;border:none;cursor:pointer;text-align:left">Close Job</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</Show>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</For>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={view() === 'detail' && selectedJob()}>
|
||||
<div style="margin-top:24px;border-radius:16px;border:1px solid #E5E7EB;background:white;box-shadow:0 1px 4px rgba(0,0,0,0.06);overflow:hidden">
|
||||
<div style="padding:24px;border-bottom:1px solid #E5E7EB;display:flex;align-items:center;justify-content:space-between">
|
||||
<div>
|
||||
<h2 style="font-size:20px;font-weight:700;color:#111827">{selectedJob()!.title}</h2>
|
||||
<p style="font-size:14px;color:#6B7280;margin-top:2px">{selectedJob()!.company} • Posted {selectedJob()!.postedDate}</p>
|
||||
</div>
|
||||
<div style="display:flex;gap:10px">
|
||||
<StatusBadge status={selectedJob()!.status} />
|
||||
<button type="button" onClick={() => setView('list')} style="height:36px;border-radius:8px;border:1px solid #E5E7EB;background:white;padding:0 16px;font-size:13px;font-weight:600;color:#374151;cursor:pointer">Back to List</button>
|
||||
</div>
|
||||
</div>
|
||||
<div style="padding:24px">
|
||||
<p style="font-size:14px;color:#6B7280">Full job description, applicant list, and status history will be displayed here.</p>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</AdminShell>
|
||||
);
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -59,7 +59,10 @@ async function loadRoleByKey(roleKey: string): Promise<ExternalRole | null> {
|
|||
export default function RoleUiConfigsViewPage() {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const roleKey = createMemo(() => searchParams.roleKey || '');
|
||||
const roleKey = createMemo(() => {
|
||||
const val = searchParams.roleKey;
|
||||
return (Array.isArray(val) ? val[0] : val) || '';
|
||||
});
|
||||
const [rows] = createResource(loadRoles);
|
||||
const [selected] = createResource(roleKey, async (key) => (key ? loadRoleByKey(key) : null));
|
||||
const permissionActions = ['read', 'create', 'update', 'delete', 'approve'];
|
||||
|
|
@ -133,7 +136,7 @@ export default function RoleUiConfigsViewPage() {
|
|||
<p class="notice" style="margin-top:3px">This page is the safe inspector for published external dashboard and role configuration.</p>
|
||||
</div>
|
||||
<Show when={selected()}>
|
||||
<A class="inline-flex items-center rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors" href={`/admin/runtime-roles/${encodeURIComponent(selected()!.roleKey)}`}>Open Role Detail</A>
|
||||
<A class="inline-flex items-center rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors" href={`/admin/external-roles?editingId=${encodeURIComponent(selected()!.id)}`}>Open Role Detail</A>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,72 +1,30 @@
|
|||
import { For, Show, createSignal } from 'solid-js';
|
||||
import { For, Show, createMemo, createSignal, onMount } from 'solid-js';
|
||||
import { useSearchParams } from '@solidjs/router';
|
||||
import AdminShell from '~/components/AdminShell';
|
||||
import { ChevronDown, SlidersHorizontal, Download, MoreVertical } from 'lucide-solid';
|
||||
import type { CrudRecord } from '~/lib/admin/types';
|
||||
|
||||
type Role = {
|
||||
id: string;
|
||||
name: string;
|
||||
department: string;
|
||||
usersAssigned: number;
|
||||
permissionsCount: number;
|
||||
const API = '/api/gateway';
|
||||
|
||||
type RoleRecord = CrudRecord & {
|
||||
code?: string;
|
||||
department?: string;
|
||||
usersAssigned?: number;
|
||||
permissionsCount?: number;
|
||||
status: 'ACTIVE' | 'INACTIVE';
|
||||
createdDate: string;
|
||||
createdDate?: string;
|
||||
};
|
||||
|
||||
const FALLBACK_ROLES: Role[] = [
|
||||
{ id: 'r1', name: 'Engineering Lead', department: 'Engineering', usersAssigned: 12, permissionsCount: 28, status: 'ACTIVE', createdDate: '2026-01-15' },
|
||||
{ id: 'r2', name: 'Marketing Manager', department: 'Marketing', usersAssigned: 8, permissionsCount: 18, status: 'ACTIVE', createdDate: '2026-01-20' },
|
||||
{ id: 'r3', name: 'Sales Director', department: 'Sales', usersAssigned: 15, permissionsCount: 32, status: 'ACTIVE', createdDate: '2026-02-01' },
|
||||
{ id: 'r4', name: 'HR Admin', department: 'Human Resources', usersAssigned: 5, permissionsCount: 24, status: 'ACTIVE', createdDate: '2026-02-05' },
|
||||
{ id: 'r5', name: 'Finance Controller', department: 'Finance', usersAssigned: 6, permissionsCount: 20, status: 'ACTIVE', createdDate: '2026-02-10' },
|
||||
{ id: 'r6', name: 'Operations Head', department: 'Operations', usersAssigned: 4, permissionsCount: 16, status: 'INACTIVE', createdDate: '2026-03-01' },
|
||||
{ id: 'r7', name: 'Support Lead', department: 'Customer Support', usersAssigned: 9, permissionsCount: 16, status: 'ACTIVE', createdDate: '2026-03-05' },
|
||||
{ id: 'r8', name: 'Product Owner', department: 'Product', usersAssigned: 7, permissionsCount: 26, status: 'ACTIVE', createdDate: '2026-03-10' },
|
||||
const FALLBACK_ROLES: RoleRecord[] = [
|
||||
{ id: 'r1', name: 'System Administrator', code: 'ADM-SYS', department: 'IT', usersAssigned: 12, permissionsCount: 150, status: 'ACTIVE', createdDate: '2026-01-12' },
|
||||
{ id: 'r2', name: 'HR Manager', code: 'HR-MGR', department: 'HR', usersAssigned: 4, permissionsCount: 45, status: 'ACTIVE', createdDate: '2026-02-05' },
|
||||
{ id: 'r3', name: 'Finance Controller', code: 'FIN-CON', department: 'Finance', usersAssigned: 2, permissionsCount: 60, status: 'INACTIVE', createdDate: '2026-03-18' },
|
||||
];
|
||||
|
||||
const MODULES = [
|
||||
'Department Management',
|
||||
'Designation Management',
|
||||
'Internal Role Management',
|
||||
'Employee Management',
|
||||
'External Role Management',
|
||||
'External Onboarding Management',
|
||||
'Internal Dashboard Management',
|
||||
'External Dashboard Management',
|
||||
'Verification Management',
|
||||
'Approval Management',
|
||||
'Users Management',
|
||||
'Company Management',
|
||||
'Candidate Management',
|
||||
'Customer Management',
|
||||
'Jobs Management',
|
||||
'Leads Management',
|
||||
'Pricing Management',
|
||||
'Credit Management',
|
||||
'Coupon Management',
|
||||
'Discount Management',
|
||||
'Tax Management',
|
||||
'Order Management',
|
||||
'Invoice Management',
|
||||
'Review Management',
|
||||
'Support Management',
|
||||
'Report Management',
|
||||
'Ledger Management',
|
||||
'Employee Management', 'Department Management', 'Designation Management', 'Internal Role Management',
|
||||
'Verification Management', 'Approval Management', 'Users Management', 'Company Management'
|
||||
];
|
||||
|
||||
type PermKey = 'view' | 'create' | 'update' | 'delete';
|
||||
const PERM_KEYS: PermKey[] = ['view', 'create', 'update', 'delete'];
|
||||
|
||||
type ModulePerms = Record<PermKey, boolean>;
|
||||
type PermissionsMap = Record<string, ModulePerms>;
|
||||
|
||||
function defaultPerms(): PermissionsMap {
|
||||
const map: PermissionsMap = {};
|
||||
for (const m of MODULES) {
|
||||
map[m] = { view: false, create: false, update: false, delete: false };
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
function StatusBadge(props: { status: string }) {
|
||||
const active = () => props.status === 'ACTIVE';
|
||||
return (
|
||||
|
|
@ -77,180 +35,133 @@ function StatusBadge(props: { status: string }) {
|
|||
);
|
||||
}
|
||||
|
||||
function Toggle(props: { on: boolean; onChange: (v: boolean) => void }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => props.onChange(!props.on)}
|
||||
style={`width:40px;height:22px;border-radius:11px;background:${props.on ? '#FF5E13' : '#E5E7EB'};position:relative;cursor:pointer;border:none;padding:0;transition:background 0.2s`}
|
||||
>
|
||||
<span style={`position:absolute;width:18px;height:18px;border-radius:50%;background:white;top:2px;transition:left 0.2s;left:${props.on ? '20px' : '2px'}`} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function FormInput(props: { label: string; required?: boolean; value: string; onInput: (v: string) => void; placeholder?: string }) {
|
||||
return (
|
||||
<label style="display:block">
|
||||
<span style="font-size:13px;font-weight:600;color:#374151">
|
||||
{props.label}{props.required && <span style="color:#FF5E13;margin-left:2px">*</span>}
|
||||
{props.label}{props.required && <span style="margin-left:2px;color:#FF5E13">*</span>}
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
value={props.value}
|
||||
onInput={(e) => props.onInput(e.currentTarget.value)}
|
||||
placeholder={props.placeholder}
|
||||
style="display:block;margin-top:6px;height:40px;width:100%;border-radius:10px;border:1px solid #E5E7EB;padding:0 14px;font-size:13px;outline:none;box-sizing:border-box;color:#111827;background:white"
|
||||
style="display:block;margin-top:6px;height:40px;width:100%;border-radius:10px;border:1px solid #E5E7EB;background:white;padding:0 14px;font-size:13px;color:#111827;outline:none;box-sizing:border-box"
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export default function RolesPage() {
|
||||
const [mainTab, setMainTab] = createSignal<'all' | 'create'>('all');
|
||||
const [formTab, setFormTab] = createSignal<'general' | 'access' | 'settings'>('general');
|
||||
|
||||
// All Roles state
|
||||
export default function RoleManagementPage() {
|
||||
const [view, setView] = createSignal<'list' | 'form' | 'detail'>('list');
|
||||
const [listTab, setListTab] = createSignal<'all' | 'create' | 'view'>('all');
|
||||
const [formTab, setFormTab] = createSignal<'general' | 'permissions' | 'settings'>('general');
|
||||
const [detailTab, setDetailTab] = createSignal<'permissions' | 'users' | 'logs'>('permissions');
|
||||
|
||||
const [search, setSearch] = createSignal('');
|
||||
const [rows, setRows] = createSignal<RoleRecord[]>([]);
|
||||
const [viewingRole, setViewingRole] = createSignal<RoleRecord | null>(null);
|
||||
const [editingId, setEditingId] = createSignal<string | null>(null);
|
||||
const [openMenuId, setOpenMenuId] = createSignal<string | null>(null);
|
||||
|
||||
// Create Role state
|
||||
const [roleName, setRoleName] = createSignal('');
|
||||
const [roleCode, setRoleCode] = createSignal('');
|
||||
const [department, setDepartment] = createSignal('');
|
||||
const [description, setDescription] = createSignal('');
|
||||
const [status, setStatus] = createSignal<'ACTIVE' | 'INACTIVE'>('ACTIVE');
|
||||
const [approveRequests, setApproveRequests] = createSignal(true);
|
||||
const [manageSettings, setManageSettings] = createSignal(false);
|
||||
const [permissions, setPermissions] = createSignal<PermissionsMap>(defaultPerms());
|
||||
const [name, setName] = createSignal('');
|
||||
const [code, setCode] = createSignal('');
|
||||
const [dept, setDept] = createSignal('');
|
||||
const [desc, setDesc] = createSignal('');
|
||||
|
||||
const filteredRoles = () => {
|
||||
const load = async () => {
|
||||
setRows(FALLBACK_ROLES);
|
||||
};
|
||||
|
||||
onMount(() => void load());
|
||||
|
||||
const filteredRows = createMemo(() => {
|
||||
const q = search().toLowerCase();
|
||||
if (!q) return FALLBACK_ROLES;
|
||||
return FALLBACK_ROLES.filter(
|
||||
(r) => r.name.toLowerCase().includes(q) || r.department.toLowerCase().includes(q)
|
||||
);
|
||||
if (!q) return rows();
|
||||
return rows().filter(r => r.name.toLowerCase().includes(q) || (r.code || '').toLowerCase().includes(q));
|
||||
});
|
||||
|
||||
const resetForm = () => {
|
||||
setEditingId(null); setName(''); setCode(''); setDept(''); setDesc(''); setFormTab('general');
|
||||
};
|
||||
|
||||
const togglePerm = (mod: string, key: PermKey) => {
|
||||
setPermissions((prev) => ({
|
||||
...prev,
|
||||
[mod]: { ...prev[mod], [key]: !prev[mod][key] },
|
||||
}));
|
||||
const openCreate = () => { resetForm(); setView('form'); };
|
||||
const openEdit = (row: RoleRecord) => {
|
||||
setEditingId(row.id); setName(row.name); setCode(row.code || '');
|
||||
setDept(row.department || ''); setView('form'); setOpenMenuId(null);
|
||||
};
|
||||
|
||||
const toggleSelectAll = (mod: string) => {
|
||||
const p = permissions()[mod];
|
||||
const allOn = PERM_KEYS.every((k) => p[k]);
|
||||
setPermissions((prev) => ({
|
||||
...prev,
|
||||
[mod]: { view: !allOn, create: !allOn, update: !allOn, delete: !allOn },
|
||||
}));
|
||||
const openDetail = (row: RoleRecord) => {
|
||||
setViewingRole(row); setView('detail'); setListTab('view'); setOpenMenuId(null);
|
||||
};
|
||||
|
||||
const formatDate = (d: string) => {
|
||||
if (!d) return '—';
|
||||
const dt = new Date(d);
|
||||
return dt.toLocaleDateString('en-IN', { day: '2-digit', month: 'short', year: 'numeric' });
|
||||
};
|
||||
|
||||
const shown = () => filteredRoles();
|
||||
|
||||
return (
|
||||
<AdminShell>
|
||||
<div style="padding:24px">
|
||||
{/* Page Header */}
|
||||
<div>
|
||||
<h1 style="font-size:24px;font-weight:700;color:#111827;margin:0">Internal Role Management</h1>
|
||||
<p style="font-size:13px;color:#6B7280;margin-top:4px;margin-bottom:0">Manage internal roles and permissions</p>
|
||||
<div class="w-full space-y-6 pb-8">
|
||||
|
||||
<div style="margin-bottom: 1.5rem">
|
||||
<h1 class="text-[28px] font-bold leading-tight text-[#111827]">Internal Role Management</h1>
|
||||
<p class="mt-1 text-[14px] text-[#6B7280]">Define and manage organizational access levels with granular permission control</p>
|
||||
</div>
|
||||
|
||||
{/* Main Tabs */}
|
||||
<div style="display:flex;align-items:center;gap:24px;border-bottom:1px solid #E5E7EB;margin-top:24px">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMainTab('all')}
|
||||
style={`padding-bottom:12px;font-size:14px;font-weight:500;background:none;border:none;cursor:pointer;${mainTab() === 'all' ? 'color:#FF5E13;border-bottom:2px solid #FF5E13;margin-bottom:-1px' : 'color:#6B7280;border-bottom:none'}`}
|
||||
>
|
||||
All Roles
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMainTab('create')}
|
||||
style={`padding-bottom:12px;font-size:14px;font-weight:500;background:none;border:none;cursor:pointer;${mainTab() === 'create' ? 'color:#FF5E13;border-bottom:2px solid #FF5E13;margin-bottom:-1px' : 'color:#6B7280;border-bottom:none'}`}
|
||||
>
|
||||
Create Role
|
||||
</button>
|
||||
</div>
|
||||
{/* ── LIST VIEW ── */}
|
||||
<Show when={view() === 'list'}>
|
||||
<div style="margin-top:24px;display:flex;align-items:center;gap:24px;border-bottom:1px solid #E5E7EB">
|
||||
{([
|
||||
{ key: 'all', label: 'All Roles', action: () => setListTab('all') },
|
||||
{ key: 'create', label: 'Create Role', action: () => { setListTab('create'); openCreate(); } },
|
||||
{ key: 'view', label: 'View Role', action: () => setListTab('view') },
|
||||
] as const).map((tab) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={tab.action}
|
||||
style={`padding-bottom:12px;font-size:14px;font-weight:500;background:none;border:none;cursor:pointer;${listTab() === tab.key ? 'color:#FF5E13;border-bottom:2px solid #FF5E13;margin-bottom:-1px' : 'color:#6B7280'}`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* ── ALL ROLES TAB ── */}
|
||||
<Show when={mainTab() === 'all'}>
|
||||
{/* Edge-to-edge card */}
|
||||
<div style="margin-top:20px;margin-left:-24px;margin-right:-24px;border-radius:0;overflow:hidden;border-top:1px solid #E5E7EB;border-bottom:1px solid #E5E7EB;background:white">
|
||||
{/* Filter Bar */}
|
||||
<div style="margin-top:1.5rem;margin-left:-24px;margin-right:-24px;border-radius:0;border-left:none;border-right:none;overflow:hidden;border-top:1px solid #E5E7EB;border-bottom:1px solid #E5E7EB;background:white;box-shadow:0 1px 3px rgba(0,0,0,0.06)">
|
||||
<div style="display:flex;align-items:center;gap:8px;padding:14px 20px;border-bottom:1px solid #F3F4F6">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search roles..."
|
||||
value={search()}
|
||||
onInput={(e) => setSearch(e.currentTarget.value)}
|
||||
style="height:34px;flex:1;max-width:240px;border-radius:8px;border:1px solid #E5E7EB;padding:0 12px;font-size:13px;outline:none;color:#111827"
|
||||
placeholder="Search roles..."
|
||||
style="height:34px;flex:1;border-radius:8px;border:1px solid #E5E7EB;background:white;padding:0 12px;font-size:13px;color:#111827;outline:none"
|
||||
/>
|
||||
<button type="button" style="height:34px;padding:0 12px;border-radius:8px;border:1px solid #E5E7EB;background:white;font-size:13px;color:#374151;cursor:pointer;display:inline-flex;align-items:center;gap:4px">
|
||||
<ChevronDown size={14} />
|
||||
Sort
|
||||
</button>
|
||||
<button type="button" style="height:34px;padding:0 12px;border-radius:8px;border:1px solid #E5E7EB;background:white;font-size:13px;color:#374151;cursor:pointer;display:inline-flex;align-items:center;gap:4px">
|
||||
<SlidersHorizontal size={14} />
|
||||
Filters
|
||||
</button>
|
||||
<button type="button" style="height:34px;padding:0 14px;border-radius:8px;background:#0D0D2A;color:white;font-size:13px;font-weight:500;border:none;cursor:pointer;display:inline-flex;align-items:center;gap:6px">
|
||||
<Download size={14} />
|
||||
Export
|
||||
</button>
|
||||
<button type="button" style="display:inline-flex;height:34px;align-items:center;gap:6px;border-radius:8px;border:1px solid #E5E7EB;background:white;padding:0 12px;font-size:12px;font-weight:500;color:#374151;cursor:pointer">Filters</button>
|
||||
<button type="button" style="display:inline-flex;height:34px;align-items:center;gap:6px;border-radius:8px;background:#0D0D2A;padding:0 12px;font-size:12px;font-weight:600;color:white;border:none;cursor:pointer">Export</button>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div style="overflow-x:auto">
|
||||
<table style="width:100%;border-collapse:collapse">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full">
|
||||
<thead>
|
||||
<tr style="background:#0D0D2A">
|
||||
<th style="padding:10px 20px;font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:0.05em;color:#FFFFFF;text-align:left;white-space:nowrap">Role Name</th>
|
||||
<th style="padding:10px 20px;font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:0.05em;color:#FFFFFF;text-align:left;white-space:nowrap">Department</th>
|
||||
<th style="padding:10px 20px;font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:0.05em;color:#FFFFFF;text-align:left;white-space:nowrap">Users Assigned</th>
|
||||
<th style="padding:10px 20px;font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:0.05em;color:#FFFFFF;text-align:left;white-space:nowrap">Permissions Count</th>
|
||||
<th style="padding:10px 20px;font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:0.05em;color:#FFFFFF;text-align:left;white-space:nowrap">Status</th>
|
||||
<th style="padding:10px 20px;font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:0.05em;color:#FFFFFF;text-align:left;white-space:nowrap">Created Date</th>
|
||||
<th style="padding:10px 20px;font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:0.05em;color:#FFFFFF;text-align:left;white-space:nowrap"></th>
|
||||
<tr style="background:#0D0D2A;text-align:left">
|
||||
{['Role Name', 'Role Code', 'Department', 'Users', 'Status', 'Actions'].map(h => (
|
||||
<th style="padding:10px 20px;font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:0.05em;color:#FFFFFF;white-space:nowrap">{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<For each={shown()}>
|
||||
{(role) => (
|
||||
<tr style="border-bottom:1px solid #F3F4F6">
|
||||
<td style="padding:12px 20px">
|
||||
<span style="font-size:14px;font-weight:600;color:#111827">{role.name}</span>
|
||||
</td>
|
||||
<td style="padding:12px 20px">
|
||||
<span style="font-size:14px;color:#374151">{role.department}</span>
|
||||
</td>
|
||||
<td style="padding:12px 20px">
|
||||
<span style="font-size:14px;font-weight:600;color:#111827">{role.usersAssigned}</span>
|
||||
</td>
|
||||
<td style="padding:12px 20px">
|
||||
<span style="display:inline-flex;border-radius:8px;background:#FFF1EB;border:1px solid #FFD8C2;color:#FF5E13;padding:2px 10px;font-size:12px;font-weight:600">
|
||||
{role.permissionsCount} Permissions
|
||||
</span>
|
||||
</td>
|
||||
<td style="padding:12px 20px">
|
||||
<StatusBadge status={role.status} />
|
||||
</td>
|
||||
<td style="padding:12px 20px">
|
||||
<span style="font-size:13px;color:#6B7280">{formatDate(role.createdDate)}</span>
|
||||
</td>
|
||||
<td style="padding:12px 20px;text-align:center">
|
||||
<button type="button" style="background:none;border:none;cursor:pointer;color:#6B7280;display:inline-flex;align-items:center">
|
||||
<MoreVertical size={16} />
|
||||
<For each={filteredRows()}>
|
||||
{(row) => (
|
||||
<tr style="border-bottom:1px solid #F3F4F6" class="hover:bg-[#FAFAFA] transition-colors">
|
||||
<td style="padding:12px 20px;font-size:14px;font-weight:600;color:#111827">{row.name}</td>
|
||||
<td style="padding:12px 20px;font-size:12px;font-family:monospace;color:#6B7280">{row.code || '—'}</td>
|
||||
<td style="padding:12px 20px;font-size:13px;color:#6B7280">{row.department || '—'}</td>
|
||||
<td style="padding:12px 20px;font-size:13px;color:#6B7280">{row.usersAssigned} users</td>
|
||||
<td style="padding:12px 20px"><StatusBadge status={row.status} /></td>
|
||||
<td style="padding:12px 20px;position:relative">
|
||||
<button type="button" onClick={() => setOpenMenuId(openMenuId() === row.id ? null : row.id)} style="display:inline-flex;height:32px;width:32px;align-items:center;justify-content:center;border-radius:8px;color:#9CA3AF;background:none;border:none;cursor:pointer">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="5" r="1.5"/><circle cx="12" cy="12" r="1.5"/><circle cx="12" cy="19" r="1.5"/></svg>
|
||||
</button>
|
||||
<Show when={openMenuId() === row.id}>
|
||||
<div style="position:absolute;right:20px;top:44px;z-index:20;width:180px;border-radius:12px;border:1px solid #E5E7EB;background:white;padding:6px;box-shadow:0 4px 20px rgba(0,0,0,0.12)">
|
||||
<button type="button" onClick={() => openDetail(row)} style="display:flex;width:100%;align-items:center;gap:10px;border-radius:8px;padding:8px 12px;font-size:13px;color:#374151;background:none;border:none;cursor:pointer;text-align:left">View Role</button>
|
||||
<button type="button" onClick={() => openEdit(row)} style="display:flex;width:100%;align-items:center;gap:10px;border-radius:8px;padding:8px 12px;font-size:13px;color:#374151;background:none;border:none;cursor:pointer;text-align:left">Edit Role</button>
|
||||
<button type="button" style="display:flex;width:100%;align-items:center;gap:10px;border-radius:8px;padding:8px 12px;font-size:13px;color:#DC2626;background:none;border:none;cursor:pointer;text-align:left">Delete Role</button>
|
||||
</div>
|
||||
</Show>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
|
|
@ -258,206 +169,170 @@ export default function RolesPage() {
|
|||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;padding:12px 20px;border-top:1px solid #F3F4F6">
|
||||
<span style="font-size:13px;color:#6B7280">
|
||||
Showing 1–{shown().length} of {shown().length} roles
|
||||
</span>
|
||||
<div style="display:flex;align-items:center;gap:4px">
|
||||
<button type="button" style="height:30px;min-width:30px;padding:0 10px;border-radius:6px;border:1px solid #E5E7EB;background:#FF5E13;color:white;font-size:13px;font-weight:600;cursor:pointer">1</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* ── CREATE ROLE TAB ── */}
|
||||
<Show when={mainTab() === 'create'}>
|
||||
<div style="margin-top:20px;border-radius:16px;border:1px solid #E5E7EB;background:white;overflow:hidden">
|
||||
{/* Form Sub-tabs */}
|
||||
<div style="display:flex;align-items:center;gap:0;border-bottom:1px solid #E5E7EB;padding:0 24px">
|
||||
{(
|
||||
[
|
||||
{ key: 'general', label: 'General Information' },
|
||||
{ key: 'access', label: 'Module Access' },
|
||||
{ key: 'settings', label: 'Role Settings' },
|
||||
] as const
|
||||
).map(({ key, label }) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFormTab(key)}
|
||||
style={`padding:14px 0;margin-right:24px;font-size:14px;background:none;border:none;cursor:pointer;${formTab() === key ? 'color:#0D0D2A;border-bottom:2px solid #0D0D2A;margin-bottom:-1px;font-weight:600' : 'color:#6B7280;font-weight:400'}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
{/* ── FORM VIEW ── */}
|
||||
<Show when={view() === 'form'}>
|
||||
<div style="margin-top:24px;display:flex;align-items:center;gap:24px;border-bottom:1px solid #E5E7EB">
|
||||
<button type="button" onClick={() => setView('list')} style="padding-bottom:12px;font-size:14px;font-weight:500;color:#6B7280;background:none;border:none;cursor:pointer">All Roles</button>
|
||||
<button type="button" style="padding-bottom:12px;font-size:14px;font-weight:500;color:#FF5E13;border-bottom:2px solid #FF5E13;background:none;cursor:pointer;margin-bottom:-1px">{editingId() ? 'Edit Role' : 'Create Role'}</button>
|
||||
</div>
|
||||
|
||||
<div style="margin-top:24px;border-radius:16px;border:1px solid #E5E7EB;background:white;box-shadow:0 1px 4px rgba(0,0,0,0.06);overflow:hidden">
|
||||
<div style="display:flex;align-items:center;gap:4px;border-bottom:1px solid #E5E7EB;padding:0 24px">
|
||||
{(['general', 'permissions', 'settings'] as const).map((tab, i) => {
|
||||
const labels = ['General Information', 'Module Access', 'Role Settings'];
|
||||
const active = () => formTab() === tab;
|
||||
return (
|
||||
<button type="button" onClick={() => setFormTab(tab)} style={`position:relative;padding:14px 8px;font-size:13px;font-weight:500;background:none;border:none;cursor:pointer;color:${active() ? '#FF5E13' : '#6B7280'}`}>
|
||||
{labels[i]}
|
||||
<Show when={active()}><span style="position:absolute;left:0;right:0;bottom:0;height:2px;background:#FF5E13;border-radius:2px 2px 0 0" /></Show>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* ── General Information ── */}
|
||||
<Show when={formTab() === 'general'}>
|
||||
<div style="padding:24px">
|
||||
<div style="padding:24px">
|
||||
<Show when={formTab() === 'general'}>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:20px">
|
||||
<FormInput
|
||||
label="Role Name"
|
||||
required
|
||||
value={roleName()}
|
||||
onInput={setRoleName}
|
||||
placeholder="e.g. Engineering Lead"
|
||||
/>
|
||||
<FormInput
|
||||
label="Role Code"
|
||||
required
|
||||
value={roleCode()}
|
||||
onInput={setRoleCode}
|
||||
placeholder="e.g. ENG_LEAD"
|
||||
/>
|
||||
<FormInput label="Role Name" required value={name()} onInput={setName} />
|
||||
<FormInput label="Role Code" required value={code()} onInput={setCode} />
|
||||
<FormInput label="Department" value={dept()} onInput={setDept} />
|
||||
</div>
|
||||
<div style="margin-top:20px">
|
||||
<label style="display:block">
|
||||
<span style="font-size:13px;font-weight:600;color:#374151">
|
||||
Department<span style="color:#FF5E13;margin-left:2px">*</span>
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
value={department()}
|
||||
onInput={(e) => setDepartment(e.currentTarget.value)}
|
||||
placeholder="e.g. Engineering"
|
||||
style="display:block;margin-top:6px;height:40px;width:100%;border-radius:10px;border:1px solid #E5E7EB;padding:0 14px;font-size:13px;outline:none;box-sizing:border-box;color:#111827;background:white"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div style="margin-top:20px">
|
||||
<label style="display:block">
|
||||
<span style="font-size:13px;font-weight:600;color:#374151">Description</span>
|
||||
<textarea
|
||||
value={description()}
|
||||
onInput={(e) => setDescription(e.currentTarget.value)}
|
||||
placeholder="Describe this role's responsibilities..."
|
||||
style="display:block;margin-top:6px;height:100px;width:100%;border-radius:10px;border:1px solid #E5E7EB;padding:10px 14px;font-size:13px;outline:none;box-sizing:border-box;color:#111827;background:white;resize:vertical;font-family:inherit"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</Show>
|
||||
|
||||
{/* ── Module Access ── */}
|
||||
<Show when={formTab() === 'access'}>
|
||||
<div style="padding:24px">
|
||||
<p style="font-size:13px;color:#6B7280;margin-top:0;margin-bottom:16px">
|
||||
Configure module access permissions for this role.
|
||||
</p>
|
||||
<div style="overflow-x:auto">
|
||||
<table style="width:100%;border-collapse:collapse;border-radius:12px;overflow:hidden;border:1px solid #E5E7EB">
|
||||
<thead>
|
||||
<tr style="background:#0D0D2A">
|
||||
<th style="padding:10px 16px;font-size:11px;font-weight:600;text-transform:uppercase;color:white;text-align:left">Module Name</th>
|
||||
<th style="padding:10px 16px;font-size:11px;font-weight:600;text-transform:uppercase;color:white;text-align:center">View</th>
|
||||
<th style="padding:10px 16px;font-size:11px;font-weight:600;text-transform:uppercase;color:white;text-align:center">Create</th>
|
||||
<th style="padding:10px 16px;font-size:11px;font-weight:600;text-transform:uppercase;color:white;text-align:center">Update</th>
|
||||
<th style="padding:10px 16px;font-size:11px;font-weight:600;text-transform:uppercase;color:white;text-align:center">Delete</th>
|
||||
<th style="padding:10px 16px;font-size:11px;font-weight:600;text-transform:uppercase;color:white;text-align:center">Select All</th>
|
||||
<Show when={formTab() === 'permissions'}>
|
||||
<div style="border:1px solid #E5E7EB;border-radius:12px;overflow:hidden">
|
||||
<table style="width:100%;border-collapse:collapse">
|
||||
<thead style="background:#F9FAFB">
|
||||
<tr style="text-align:left">
|
||||
<th style="padding:12px 16px;font-size:11px;font-weight:600;color:#6B7280;text-transform:uppercase">Module</th>
|
||||
{['View', 'Create', 'Update', 'Delete'].map(p => (
|
||||
<th style="padding:12px 16px;font-size:11px;font-weight:600;color:#6B7280;text-transform:uppercase;text-align:center">{p}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<For each={MODULES}>
|
||||
{(mod) => {
|
||||
const p = () => permissions()[mod];
|
||||
const allOn = () => PERM_KEYS.every((k) => p()[k]);
|
||||
return (
|
||||
<tr style="border-bottom:1px solid #F3F4F6">
|
||||
<td style="padding:12px 16px;font-size:13px;color:#111827;font-weight:500;text-align:left">{mod}</td>
|
||||
<For each={PERM_KEYS}>
|
||||
{(key) => (
|
||||
<td style="text-align:center;padding:12px">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={p()[key]}
|
||||
onChange={() => togglePerm(mod, key)}
|
||||
style="width:16px;height:16px;accent-color:#FF5E13;cursor:pointer"
|
||||
/>
|
||||
</td>
|
||||
)}
|
||||
</For>
|
||||
<td style="text-align:center;padding:12px">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allOn()}
|
||||
onChange={() => toggleSelectAll(mod)}
|
||||
style="width:16px;height:16px;accent-color:#FF5E13;cursor:pointer"
|
||||
/>
|
||||
{(mod) => (
|
||||
<tr style="border-top:1px solid #E5E7EB">
|
||||
<td style="padding:12px 16px;font-size:13px;font-weight:600;color:#111827">{mod}</td>
|
||||
{[1, 2, 3, 4].map(() => (
|
||||
<td style="padding:12px 16px;text-align:center">
|
||||
<input type="checkbox" style="width:16px;height:16px;accent-color:#FF5E13" />
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}}
|
||||
))}
|
||||
</tr>
|
||||
)}
|
||||
</For>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</Show>
|
||||
|
||||
{/* ── Role Settings ── */}
|
||||
<Show when={formTab() === 'settings'}>
|
||||
<div style="padding:24px;display:flex;flex-direction:column;gap:24px">
|
||||
{/* Role Status */}
|
||||
<div>
|
||||
<p style="font-size:13px;font-weight:600;color:#374151;margin:0 0 10px 0">Role Status</p>
|
||||
<div style="display:flex;gap:8px">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setStatus('ACTIVE')}
|
||||
style={`height:36px;padding:0 20px;border-radius:8px;font-size:14px;font-weight:500;cursor:pointer;${status() === 'ACTIVE' ? 'border:1px solid #FF5E13;background:#FF5E13;color:white' : 'border:1px solid #E5E7EB;background:white;color:#6B7280'}`}
|
||||
>
|
||||
Active
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setStatus('INACTIVE')}
|
||||
style={`height:36px;padding:0 20px;border-radius:8px;font-size:14px;font-weight:500;cursor:pointer;${status() === 'INACTIVE' ? 'border:1px solid #6B7280;background:#6B7280;color:white' : 'border:1px solid #E5E7EB;background:white;color:#6B7280'}`}
|
||||
>
|
||||
Inactive
|
||||
</button>
|
||||
<Show when={formTab() === 'settings'}>
|
||||
<div style="display:flex;flex-direction:column;gap:20px">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;padding:12px;border-radius:8px;background:#F9FAFB;border:1px solid #E5E7EB">
|
||||
<div>
|
||||
<p style="font-size:13px;font-weight:600;color:#111827">Allow Role to Approve Requests</p>
|
||||
<p style="font-size:12px;color:#6B7280">Users with this role can make final decisions in Approval Management.</p>
|
||||
</div>
|
||||
<div style="width:40px;height:20px;background:#FF5E13;border-radius:10px;position:relative;cursor:pointer"><div style="width:16px;height:16px;background:white;border-radius:50%;position:absolute;top:2px;right:2px" /></div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
{/* Toggle: Approve Requests */}
|
||||
<div style="border-radius:12px;border:1px solid #E5E7EB;padding:16px 20px;display:flex;align-items:center;justify-content:space-between">
|
||||
<div>
|
||||
<p style="font-size:14px;font-weight:600;color:#111827;margin:0">Allow Role to Approve Requests</p>
|
||||
<p style="font-size:13px;color:#6B7280;margin:4px 0 0 0">Grant this role the ability to approve or reject submitted requests.</p>
|
||||
</div>
|
||||
<Toggle on={approveRequests()} onChange={setApproveRequests} />
|
||||
</div>
|
||||
|
||||
{/* Toggle: Manage Settings */}
|
||||
<div style="border-radius:12px;border:1px solid #E5E7EB;padding:16px 20px;display:flex;align-items:center;justify-content:space-between">
|
||||
<div>
|
||||
<p style="font-size:14px;font-weight:600;color:#111827;margin:0">Allow Role to Manage System Settings</p>
|
||||
<p style="font-size:13px;color:#6B7280;margin:4px 0 0 0">Grant this role access to configure and modify system-wide settings.</p>
|
||||
</div>
|
||||
<Toggle on={manageSettings()} onChange={setManageSettings} />
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Form Footer */}
|
||||
<div style="display:flex;justify-content:flex-end;gap:12px;padding:16px 24px;border-top:1px solid #E5E7EB">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMainTab('all')}
|
||||
style="height:38px;padding:0 20px;border-radius:8px;font-size:14px;font-weight:500;border:1px solid #E5E7EB;background:white;color:#374151;cursor:pointer"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
style="background:#0D0D2A;color:white;height:38px;padding:0 20px;border-radius:8px;font-size:14px;font-weight:500;border:none;cursor:pointer"
|
||||
>
|
||||
Create Role
|
||||
<div style="display:flex;align-items:center;justify-content:flex-end;gap:10px;border-top:1px solid #E5E7EB;padding:14px 24px">
|
||||
<button type="button" onClick={() => setView('list')} style="height:38px;border-radius:10px;border:1px solid #E5E7EB;background:white;padding:0 20px;font-size:13px;font-weight:600;color:#374151;cursor:pointer">Cancel</button>
|
||||
<button type="button" style="height:38px;border-radius:10px;background:#0D0D2A;padding:0 24px;font-size:13px;font-weight:600;color:white;border:none;cursor:pointer">
|
||||
{editingId() ? 'Update Role' : 'Create Role'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* ── DETAIL VIEW ── */}
|
||||
<Show when={view() === 'detail' && viewingRole()}>
|
||||
<div style="margin-top:24px;border-radius:16px;border:1px solid #E5E7EB;background:white;box-shadow:0 1px 4px rgba(0,0,0,0.06);overflow:hidden">
|
||||
<div style="padding:24px;border-bottom:1px solid #E5E7EB;display:flex;align-items:center;justify-content:space-between">
|
||||
<div>
|
||||
<div style="display:flex;align-items:center;gap:12px">
|
||||
<h2 style="font-size:20px;font-weight:700;color:#111827">{viewingRole()!.name}</h2>
|
||||
<StatusBadge status={viewingRole()!.status} />
|
||||
</div>
|
||||
<p style="font-size:14px;color:#6B7280;margin-top:2px">Code: {viewingRole()!.code} • Dept: {viewingRole()!.department} • Assigned: {viewingRole()!.usersAssigned} users</p>
|
||||
</div>
|
||||
<button type="button" onClick={() => openEdit(viewingRole()!)} style="height:36px;border-radius:8px;background:#0D0D2A;padding:0 16px;font-size:13px;font-weight:600;color:white;border:none;cursor:pointer">Edit Role</button>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;align-items:center;gap:4px;border-bottom:1px solid #E5E7EB;padding:0 24px;background:#FAFAFA">
|
||||
{(['permissions', 'users', 'logs'] as const).map((tab, i) => {
|
||||
const labels = ['Permissions', 'Assigned Users', 'Activity Logs'];
|
||||
const active = () => detailTab() === tab;
|
||||
return (
|
||||
<button type="button" onClick={() => setDetailTab(tab)} style={`position:relative;padding:14px 12px;font-size:13px;font-weight:600;background:none;border:none;cursor:pointer;color:${active() ? '#FF5E13' : '#6B7280'}`}>
|
||||
{labels[i]}
|
||||
<Show when={active()}><span style="position:absolute;left:0;right:0;bottom:0;height:2px;background:#FF5E13;border-radius:2px 2px 0 0" /></Show>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div style="padding:24px">
|
||||
<Show when={detailTab() === 'permissions'}>
|
||||
<div style="border:1px solid #E5E7EB;border-radius:12px;overflow:hidden;opacity:0.7">
|
||||
<table style="width:100%;border-collapse:collapse">
|
||||
<thead style="background:#F9FAFB">
|
||||
<tr style="text-align:left">
|
||||
<th style="padding:12px 16px;font-size:11px;font-weight:600;color:#6B7280;text-transform:uppercase">Module</th>
|
||||
{['View', 'Create', 'Update', 'Delete'].map(p => (
|
||||
<th style="padding:12px 16px;font-size:11px;font-weight:600;color:#6B7280;text-transform:uppercase;text-align:center">{p}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<For each={MODULES}>
|
||||
{(mod) => (
|
||||
<tr style="border-top:1px solid #E5E7EB">
|
||||
<td style="padding:12px 16px;font-size:13px;font-weight:600;color:#111827">{mod}</td>
|
||||
{[1, 2, 3, 4].map(() => (
|
||||
<td style="padding:12px 16px;text-align:center">
|
||||
<input type="checkbox" checked disabled style="width:16px;height:16px;accent-color:#FF5E13" />
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
)}
|
||||
</For>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={detailTab() === 'users'}>
|
||||
<div style="padding:20px;text-align:center;font-size:14px;color:#6B7280">Users assigned to this role will appear here.</div>
|
||||
</Show>
|
||||
|
||||
<Show when={detailTab() === 'logs'}>
|
||||
<div style="display:flex;flex-direction:column;gap:16px">
|
||||
<div style="display:flex;gap:12px">
|
||||
<div style="width:8px;height:8px;border-radius:50%;background:#FF5E13;margin-top:4px" />
|
||||
<div>
|
||||
<p style="font-size:13px;font-weight:600;color:#111827">Permissions Updated</p>
|
||||
<p style="font-size:12px;color:#6B7280">Admin modified module access levels • 1 day ago</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<div style="padding:16px 24px;border-top:1px solid #E5E7EB;display:flex;justify-content:flex-end">
|
||||
<button type="button" onClick={() => { setView('list'); setListTab('all'); }} style="height:36px;border-radius:8px;border:1px solid #E5E7EB;background:white;padding:0 16px;font-size:13px;font-weight:600;color:#374151;cursor:pointer">Back to List</button>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
</div>
|
||||
</AdminShell>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,285 +1,312 @@
|
|||
import { A } from '@solidjs/router';
|
||||
import { createMemo, createResource, createSignal, For, Show } from 'solid-js';
|
||||
import { For, Show, createMemo, createSignal, onMount } from 'solid-js';
|
||||
import { useSearchParams } from '@solidjs/router';
|
||||
import AdminShell from '~/components/AdminShell';
|
||||
import type { CrudRecord } from '~/lib/admin/types';
|
||||
|
||||
const API = '/api/gateway';
|
||||
|
||||
interface User {
|
||||
type ExternalUserRecord = {
|
||||
id: string;
|
||||
name?: string;
|
||||
full_name?: string;
|
||||
name: string;
|
||||
username?: string;
|
||||
email: string;
|
||||
role?: string;
|
||||
role_name?: string;
|
||||
status: 'ACTIVE' | 'INACTIVE' | 'PENDING';
|
||||
created_at?: string;
|
||||
createdAt?: string;
|
||||
roleId?: string;
|
||||
userType?: string | number;
|
||||
}
|
||||
phone?: string;
|
||||
userType: 'CUSTOMER' | 'PROFESSIONAL' | 'COMPANY' | 'JOBSEEKER';
|
||||
primaryActiveRole?: string;
|
||||
onboardingStatus: 'NOT_STARTED' | 'IN_PROGRESS' | 'SUBMITTED' | 'COMPLETED';
|
||||
verificationStatus: 'UNVERIFIED' | 'PENDING' | 'IN_REVIEW' | 'VERIFIED' | 'REJECTED' | 'RE_UPLOAD_REQUESTED';
|
||||
accountStatus: 'ACTIVE' | 'INACTIVE' | 'BLOCKED' | 'SUSPENDED';
|
||||
createdDate?: string;
|
||||
lastLogin?: string;
|
||||
status: 'ACTIVE' | 'INACTIVE';
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
const ROLE_OPTIONS = [
|
||||
'company',
|
||||
'job_seeker',
|
||||
'customer',
|
||||
'photographer',
|
||||
'video_editor',
|
||||
'graphic_designer',
|
||||
'social_media_manager',
|
||||
'fitness_trainer',
|
||||
'catering_services',
|
||||
'makeup_artist',
|
||||
'tutor',
|
||||
'developer',
|
||||
const FALLBACK_USERS: ExternalUserRecord[] = [
|
||||
{ id: 'u1', name: 'Arun Kumar', username: 'arun_pro', email: 'arun.k@example.com', phone: '+91 98765 43210', userType: 'PROFESSIONAL', primaryActiveRole: 'Photographer', onboardingStatus: 'COMPLETED', verificationStatus: 'VERIFIED', accountStatus: 'ACTIVE', createdDate: '2026-01-15', lastLogin: '2026-03-27 10:45 AM', status: 'ACTIVE', updatedAt: '2026-03-27' },
|
||||
{ id: 'u2', name: 'Tech Solutions', username: 'tech_sol', email: 'contact@techsol.com', phone: '+91 98765 43211', userType: 'COMPANY', primaryActiveRole: 'No Active Role', onboardingStatus: 'SUBMITTED', verificationStatus: 'IN_REVIEW', accountStatus: 'ACTIVE', createdDate: '2026-02-10', lastLogin: '2026-03-26 04:20 PM', status: 'ACTIVE', updatedAt: '2026-03-27' },
|
||||
{ id: 'u3', name: 'Priya Sharma', username: 'priya_s', email: 'priya.s@example.com', phone: '+91 98765 43212', userType: 'CUSTOMER', primaryActiveRole: 'Customer', onboardingStatus: 'COMPLETED', verificationStatus: 'VERIFIED', accountStatus: 'ACTIVE', createdDate: '2026-03-01', lastLogin: '2026-03-27 09:15 AM', status: 'ACTIVE', updatedAt: '2026-03-27' },
|
||||
{ id: 'u4', name: 'Deepak Verma', username: 'deepak_v', email: 'deepak.v@example.com', phone: '+91 98765 43213', userType: 'JOBSEEKER', primaryActiveRole: 'No Active Role', onboardingStatus: 'IN_PROGRESS', verificationStatus: 'UNVERIFIED', accountStatus: 'INACTIVE', createdDate: '2026-03-15', lastLogin: '—', status: 'INACTIVE', updatedAt: '2026-03-27' },
|
||||
];
|
||||
|
||||
async function fetchUsers(): Promise<User[]> {
|
||||
try {
|
||||
const res = await fetch(`${API}/api/admin/users`);
|
||||
if (res.status === 404) {
|
||||
const res2 = await fetch(`${API}/api/users`);
|
||||
if (!res2.ok) throw new Error('Failed to load');
|
||||
const data2 = await res2.json();
|
||||
return Array.isArray(data2) ? data2 : (data2.users || []);
|
||||
}
|
||||
if (!res.ok) throw new Error('Failed to load');
|
||||
const data = await res.json();
|
||||
return Array.isArray(data) ? data : (data.users || []);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
function StatusBadge(props: { status: string; type?: 'account' | 'verification' | 'onboarding' }) {
|
||||
const active = () => props.status === 'ACTIVE' || props.status === 'VERIFIED' || props.status === 'COMPLETED';
|
||||
const pending = () => props.status === 'PENDING' || props.status === 'IN_REVIEW' || props.status === 'SUBMITTED' || props.status === 'IN_PROGRESS';
|
||||
const rejected = () => props.status === 'REJECTED' || props.status === 'BLOCKED' || props.status === 'SUSPENDED';
|
||||
|
||||
return (
|
||||
<span style={`display:inline-flex;align-items:center;border-radius:9999px;border:1px solid ${active() ? '#FFD8C2' : pending() ? '#F6D78F' : rejected() ? '#FECACA' : '#D1D5DB'};background:${active() ? '#FFF1EB' : pending() ? '#FFF3D6' : rejected() ? '#FEF2F2' : '#F3F4F6'};color:${active() ? '#FF5E13' : pending() ? '#B7791F' : rejected() ? '#DC2626' : '#4B5563'};padding:2px 10px;font-size:12px;font-weight:500`}>
|
||||
<span style={`display:inline-block;width:6px;height:6px;border-radius:50%;background:${active() ? '#FF5E13' : pending() ? '#B7791F' : rejected() ? '#DC2626' : '#9CA3AF'};margin-right:5px;flex-shrink:0`} />
|
||||
{props.status.split('_').map(w => w.charAt(0) + w.slice(1).toLowerCase()).join(' ')}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBadge(props: { status: string }) {
|
||||
if (props.status === 'ACTIVE') {
|
||||
return <span class="inline-flex items-center rounded-full bg-green-100 px-2.5 py-0.5 text-xs font-medium text-green-700">ACTIVE</span>;
|
||||
}
|
||||
if (props.status === 'PENDING') {
|
||||
return <span class="inline-flex items-center rounded-full bg-amber-500 px-2.5 py-0.5 text-xs font-medium text-white">PENDING</span>;
|
||||
}
|
||||
return <span class="inline-flex items-center rounded-full bg-gray-100 px-2.5 py-0.5 text-xs font-medium text-gray-600">INACTIVE</span>;
|
||||
function FormInput(props: { label: string; required?: boolean; value: string; onInput: (v: string) => void; placeholder?: string; type?: string }) {
|
||||
return (
|
||||
<label style="display:block">
|
||||
<span style="font-size:13px;font-weight:600;color:#374151">
|
||||
{props.label}{props.required && <span style="margin-left:2px;color:#FF5E13">*</span>}
|
||||
</span>
|
||||
<input
|
||||
type={props.type ?? 'text'}
|
||||
value={props.value}
|
||||
onInput={(e) => props.onInput(e.currentTarget.value)}
|
||||
placeholder={props.placeholder}
|
||||
style="display:block;margin-top:6px;height:40px;width:100%;border-radius:10px;border:1px solid #E5E7EB;background:white;padding:0 14px;font-size:13px;color:#111827;outline:none;box-sizing:border-box"
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export default function UsersPage() {
|
||||
const [users, { refetch }] = createResource(fetchUsers);
|
||||
const [activeTab, setActiveTab] = createSignal<'list' | 'view'>('list');
|
||||
const [selectedUser, setSelectedUser] = createSignal<User | null>(null);
|
||||
export default function UsersManagementPage() {
|
||||
const [view, setView] = createSignal<'list' | 'form'>('list');
|
||||
const [listTab, setListTab] = createSignal<'all' | 'create' | 'view'>('all');
|
||||
const [formTab, setFormTab] = createSignal<'basic' | 'status' | 'security'>('basic');
|
||||
const [detailTab, setDetailTab] = createSignal<'overview' | 'roles' | 'onboarding' | 'verification' | 'logs'>('overview');
|
||||
|
||||
const [search, setSearch] = createSignal('');
|
||||
const [filterRole, setFilterRole] = createSignal('');
|
||||
const [filterStatus, setFilterStatus] = createSignal('');
|
||||
const [currentPage, setCurrentPage] = createSignal(1);
|
||||
const usersPerPage = 10;
|
||||
const [statusFilter, setStatusFilter] = createSignal('all');
|
||||
const [sortBy, setSortBy] = createSignal<'name_asc' | 'name_desc' | 'created_desc' | 'created_asc'>('name_asc');
|
||||
const [sortMenuOpen, setSortMenuOpen] = createSignal(false);
|
||||
const [filterMenuOpen, setFilterMenuOpen] = createSignal(false);
|
||||
const [rows, setRows] = createSignal<ExternalUserRecord[]>([]);
|
||||
const [viewingUser, setViewingUser] = createSignal<ExternalUserRecord | null>(null);
|
||||
const [editingId, setEditingId] = createSignal<string | null>(null);
|
||||
const [openMenuId, setOpenMenuId] = createSignal<string | null>(null);
|
||||
|
||||
const filtered = createMemo(() => {
|
||||
const list = users() ?? [];
|
||||
// Form Signals
|
||||
const [name, setName] = createSignal('');
|
||||
const [username, setUsername] = createSignal('');
|
||||
const [email, setEmail] = createSignal('');
|
||||
const [phone, setPhone] = createSignal('');
|
||||
const [userType, setUserType] = createSignal<ExternalUserRecord['userType']>('CUSTOMER');
|
||||
|
||||
const load = async () => {
|
||||
setRows(FALLBACK_USERS);
|
||||
};
|
||||
|
||||
onMount(() => void load());
|
||||
|
||||
const filteredRows = createMemo(() => {
|
||||
let r = rows();
|
||||
if (statusFilter() !== 'all') r = r.filter((d) => d.accountStatus === statusFilter().toUpperCase());
|
||||
const q = search().toLowerCase();
|
||||
const role = filterRole();
|
||||
const status = filterStatus();
|
||||
return list.filter((u) => {
|
||||
const name = (u.name || u.full_name || '').toLowerCase();
|
||||
const email = u.email.toLowerCase();
|
||||
const matchSearch = !q || name.includes(q) || email.includes(q);
|
||||
const matchRole = !role || u.role === role || u.role_name === role;
|
||||
const matchStatus = !status || u.status === status;
|
||||
return matchSearch && matchRole && matchStatus;
|
||||
if (q) {
|
||||
r = r.filter(r => r.name.toLowerCase().includes(q) || r.email.toLowerCase().includes(q) || r.id.toLowerCase().includes(q));
|
||||
}
|
||||
const sorted = [...r];
|
||||
const mode = sortBy();
|
||||
sorted.sort((a, b) => {
|
||||
if (mode === 'name_desc') return b.name.localeCompare(a.name);
|
||||
if (mode === 'created_desc') return (b.createdDate || '').localeCompare(a.createdDate || '');
|
||||
if (mode === 'created_asc') return (a.createdDate || '').localeCompare(b.createdDate || '');
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
return sorted;
|
||||
});
|
||||
|
||||
const totalPages = createMemo(() => {
|
||||
const count = filtered().length;
|
||||
return Math.max(1, Math.ceil(count / usersPerPage));
|
||||
});
|
||||
const resetForm = () => {
|
||||
setEditingId(null); setViewingUser(null); setName(''); setUsername(''); setEmail(''); setPhone(''); setUserType('CUSTOMER'); setFormTab('basic');
|
||||
};
|
||||
|
||||
const paginated = createMemo(() => {
|
||||
const page = currentPage();
|
||||
const start = (page - 1) * usersPerPage;
|
||||
return filtered().slice(start, start + usersPerPage);
|
||||
});
|
||||
|
||||
const shortId = (id: string) => `${id.slice(0, 8)}...`;
|
||||
const registrationRole = (u: User) => (u.role_name || u.role || 'UNKNOWN').toUpperCase();
|
||||
|
||||
const onView = (user: User) => {
|
||||
setSelectedUser(user);
|
||||
setActiveTab('view');
|
||||
const openCreate = () => { resetForm(); setView('form'); };
|
||||
const openEdit = (row: ExternalUserRecord) => {
|
||||
setEditingId(row.id); setViewingUser(row); setName(row.name); setUsername(row.username || '');
|
||||
setEmail(row.email); setPhone(row.phone || ''); setUserType(row.userType);
|
||||
setView('form'); setOpenMenuId(null);
|
||||
};
|
||||
const openDetail = (row: ExternalUserRecord) => {
|
||||
setViewingUser(row); setListTab('view'); setOpenMenuId(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<AdminShell>
|
||||
<div class="flex flex-col -mx-6 -mt-6 min-h-full">
|
||||
<div class="p-6 flex-1 max-w-[1600px] mx-auto w-full">
|
||||
{/* Header & Title */}
|
||||
<div class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between mb-8">
|
||||
<div>
|
||||
<h1 class="text-[32px] font-bold text-[#0D0D2A] leading-tight">User Management</h1>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<button class="inline-flex h-11 items-center justify-center rounded-xl border border-[#d9dde6] bg-white px-6 text-[14px] font-semibold text-[#0D0D2A] transition-colors hover:bg-[#f8f9fc]">
|
||||
Export Data
|
||||
</button>
|
||||
<button class="inline-flex h-11 items-center justify-center rounded-xl bg-[#0D0D2A] px-6 text-[14px] font-semibold text-white transition-colors hover:bg-[#0a0044]">
|
||||
<span class="mr-2 text-lg leading-none">+</span> Add User
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full space-y-6 pb-8">
|
||||
|
||||
<div style="margin-bottom: 1.5rem">
|
||||
<h1 class="text-[28px] font-bold leading-tight text-[#111827]">Users Management</h1>
|
||||
<p class="mt-1 text-[14px] text-[#6B7280]">Manage external accounts, onboarding status, and role registrations</p>
|
||||
</div>
|
||||
|
||||
<Show when={activeTab() === 'list'}>
|
||||
{/* KPI Cards Row */}
|
||||
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
|
||||
<div class="rounded-2xl border border-[#e2e6ee] bg-white p-5 shadow-sm">
|
||||
<p class="text-[13px] text-[#8087a0] font-medium leading-snug">Total Users</p>
|
||||
<p class="mt-3 text-[32px] font-bold text-[#0D0D2A] leading-none">{users()?.length || 0}</p>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-[#e2e6ee] bg-white p-5 shadow-sm">
|
||||
<p class="text-[13px] text-[#8087a0] font-medium leading-snug">Active Users</p>
|
||||
<p class="mt-3 text-[32px] font-bold text-[#00c853] leading-none">{users()?.filter((u) => u.status === 'ACTIVE').length || 0}</p>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-[#e2e6ee] bg-white p-5 shadow-sm">
|
||||
<p class="text-[13px] text-[#8087a0] font-medium leading-snug">Inactive Users</p>
|
||||
<p class="mt-3 text-[32px] font-bold text-[#ff6e30] leading-none">{users()?.filter((u) => u.status === 'INACTIVE').length || 0}</p>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-[#e2e6ee] bg-white p-5 shadow-sm">
|
||||
<p class="text-[13px] text-[#8087a0] font-medium leading-snug">New Users Today</p>
|
||||
<p class="mt-3 text-[32px] font-bold text-[#64748b] leading-none">0</p>
|
||||
</div>
|
||||
{/* ── LIST VIEW ── */}
|
||||
<Show when={view() === 'list'}>
|
||||
<div>
|
||||
{/* Tabs */}
|
||||
<div style="margin-top:24px;display:flex;align-items:center;gap:24px;border-bottom:1px solid #E5E7EB">
|
||||
{([
|
||||
{ key: 'all', label: 'All Users', action: () => { setListTab('all'); setStatusFilter('all'); } },
|
||||
{ key: 'create', label: 'Create User', action: () => { setListTab('create'); openCreate(); } },
|
||||
{ key: 'view', label: 'View Profile', action: () => setListTab('view') },
|
||||
] as const).map((tab) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={tab.action}
|
||||
style={`padding-bottom:12px;font-size:14px;font-weight:500;background:none;border:none;cursor:pointer;${listTab() === tab.key ? 'color:#FF5E13;border-bottom:2px solid #FF5E13;margin-bottom:-1px' : 'color:#6B7280'}`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Main Table Section */}
|
||||
<section class="rounded-[24px] border border-[#e2e6ee] bg-[#f7f7f8] p-1.5 h-full">
|
||||
<div class="rounded-[20px] bg-white p-5">
|
||||
|
||||
{/* Tabs */}
|
||||
<div class="flex gap-6 mb-6 border-b border-[#e2e6ee]">
|
||||
<For each={['All Users', 'Active', 'Inactive']}>
|
||||
{(t) => {
|
||||
const isActiveTab = (t === 'All Users' && !filterStatus()) ||
|
||||
(t === 'Active' && filterStatus() === 'ACTIVE') ||
|
||||
(t === 'Inactive' && filterStatus() === 'INACTIVE');
|
||||
{/* View Profile panel */}
|
||||
<Show when={listTab() === 'view'}>
|
||||
<Show
|
||||
when={!viewingUser()}
|
||||
>
|
||||
<div style="margin-top:24px;border-radius:16px;border:1px solid #E5E7EB;background:white;padding:48px 24px;text-align:center">
|
||||
<p style="font-size:15px;font-weight:600;color:#111827">No user selected</p>
|
||||
<p style="margin-top:6px;font-size:13px;color:#6B7280">Click the <strong>⋮</strong> menu on any user row and choose <strong>View Profile</strong>.</p>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={viewingUser()}>
|
||||
<div style="margin-top:24px;border-radius:16px;border:1px solid #E5E7EB;background:white;box-shadow:0 1px 4px rgba(0,0,0,0.06);overflow:hidden">
|
||||
<div style="padding:24px;border-bottom:1px solid #E5E7EB;display:flex;align-items:center;gap:24px">
|
||||
<div style="width:64px;height:64px;border-radius:50%;background:#F3F4F6;display:flex;align-items:center;justify-content:center;font-size:20px;font-weight:700;color:#9CA3AF;border:1px solid #E5E7EB">
|
||||
{viewingUser()!.name.charAt(0)}
|
||||
</div>
|
||||
<div style="flex:1">
|
||||
<div style="display:flex;align-items:center;gap:12px">
|
||||
<h2 style="font-size:20px;font-weight:700;color:#111827">{viewingUser()!.name}</h2>
|
||||
<StatusBadge status={viewingUser()!.accountStatus} />
|
||||
</div>
|
||||
<p style="font-size:14px;color:#6B7280;margin-top:2px">{viewingUser()!.email} • ID: {viewingUser()!.id}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;align-items:center;gap:4px;border-bottom:1px solid #E5E7EB;padding:0 24px;background:#FAFAFA">
|
||||
{(['overview', 'roles', 'onboarding', 'verification', 'logs'] as const).map((tab, i) => {
|
||||
const labels = ['Overview', 'Registered Roles', 'Onboarding', 'Verification', 'Activity Logs'];
|
||||
const active = () => detailTab() === tab;
|
||||
return (
|
||||
<button
|
||||
onClick={() => {
|
||||
if (t === 'All Users') setFilterStatus('');
|
||||
else if (t === 'Active') setFilterStatus('ACTIVE');
|
||||
else if (t === 'Inactive') setFilterStatus('INACTIVE');
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
class={`pb-3 text-[14px] font-bold transition-colors border-b-2 ${
|
||||
isActiveTab
|
||||
? 'border-[#0D0D2A] text-[#0D0D2A]'
|
||||
: 'border-transparent text-[#8087a0] hover:text-[#0D0D2A]'
|
||||
}`}
|
||||
>
|
||||
{t}
|
||||
<button type="button" onClick={() => setDetailTab(tab)} style={`position:relative;padding:14px 12px;font-size:13px;font-weight:600;background:none;border:none;cursor:pointer;color:${active() ? '#FF5E13' : '#6B7280'}`}>
|
||||
{labels[i]}
|
||||
<Show when={active()}><span style="position:absolute;left:0;right:0;bottom:0;height:2px;background:#FF5E13;border-radius:2px 2px 0 0" /></Show>
|
||||
</button>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
|
||||
{/* Filters Row */}
|
||||
<div class="flex flex-col gap-4 md:flex-row items-center mb-6">
|
||||
<div class="relative w-full md:w-[320px]">
|
||||
<div class="absolute inset-y-0 left-4 flex items-center pointer-events-none">
|
||||
<svg class="h-5 w-5 text-[#a0aabf]" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search users..."
|
||||
value={search()}
|
||||
onInput={(e) => { setSearch(e.currentTarget.value); setCurrentPage(1); }}
|
||||
class="h-11 w-full rounded-xl border border-[#d9dde6] bg-[#f9fafb] pl-11 pr-4 text-[14px] text-[#0D0D2A] outline-none transition-colors focus:border-[#0D0D2A] focus:bg-white"
|
||||
/>
|
||||
})}
|
||||
</div>
|
||||
<select
|
||||
value={filterRole()}
|
||||
onChange={(e) => { setFilterRole(e.currentTarget.value); setCurrentPage(1); }}
|
||||
class="h-11 w-full md:w-[200px] rounded-xl border border-[#d9dde6] bg-[#f9fafb] px-4 text-[14px] outline-none transition-colors focus:border-[#0D0D2A] focus:bg-white appearance-none"
|
||||
>
|
||||
<option value="">All Roles</option>
|
||||
<For each={ROLE_OPTIONS}>{(r) => <option value={r}>{r}</option>}</For>
|
||||
</select>
|
||||
<div class="flex-1"></div>
|
||||
<Show when={!users.loading}>
|
||||
<span class="text-[13px] text-[#8087a0] font-medium">
|
||||
Showing {paginated().length} of {filtered().length} users
|
||||
</span>
|
||||
</Show>
|
||||
|
||||
<div style="padding:24px">
|
||||
<Show when={detailTab() === 'overview'}>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:24px">
|
||||
<div style="border:1px solid #E5E7EB;border-radius:12px;padding:20px">
|
||||
<h3 style="font-size:14px;font-weight:700;color:#111827;margin-bottom:16px">Account Summary</h3>
|
||||
<div style="display:flex;flex-direction:column;gap:12px">
|
||||
{[
|
||||
{ l: 'User Type', v: viewingUser()!.userType },
|
||||
{ l: 'Primary Role', v: viewingUser()!.primaryActiveRole || 'None' },
|
||||
{ l: 'Email Verified', v: 'Yes' },
|
||||
{ l: 'Joined Date', v: viewingUser()!.createdDate || '—' },
|
||||
].map(item => (
|
||||
<div style="display:flex;justify-content:space-between">
|
||||
<span style="font-size:13px;color:#6B7280">{item.l}</span>
|
||||
<span style="font-size:13px;font-weight:600;color:#111827">{item.v}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;align-items:center;gap:10px;padding:14px 24px;border-top:1px solid #E5E7EB">
|
||||
<button type="button" onClick={() => openEdit(viewingUser()!)} style="height:36px;border-radius:8px;background:#0D0D2A;padding:0 16px;font-size:13px;font-weight:600;color:white;border:none;cursor:pointer">Edit Profile</button>
|
||||
<button type="button" onClick={() => { setViewingUser(null); setListTab('all'); }} style="height:36px;border-radius:8px;border:1px solid #E5E7EB;background:white;padding:0 16px;font-size:13px;font-weight:600;color:#374151;cursor:pointer">Back to List</button>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</Show>
|
||||
|
||||
<div style={{ display: listTab() === 'view' ? 'none' : 'block' }}>
|
||||
<div style="margin-top:1.5rem;margin-left:-24px;margin-right:-24px;border-radius:0;border-left:none;border-right:none;overflow:hidden;border-top:1px solid #E5E7EB;border-bottom:1px solid #E5E7EB;background:white;box-shadow:0 1px 3px rgba(0,0,0,0.06)">
|
||||
<div style="display:flex;align-items:center;gap:8px;padding:14px 20px;border-bottom:1px solid #F3F4F6">
|
||||
<input
|
||||
value={search()}
|
||||
onInput={(e) => setSearch(e.currentTarget.value)}
|
||||
placeholder="Search by name, email, or ID..."
|
||||
style="height:34px;flex:1;border-radius:8px;border:1px solid #E5E7EB;background:white;padding:0 12px;font-size:13px;color:#111827;outline:none"
|
||||
/>
|
||||
<div style="position:relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setSortMenuOpen((v) => !v); setFilterMenuOpen(false); }}
|
||||
style="display:inline-flex;height:34px;align-items:center;gap:6px;border-radius:8px;border:1px solid #E5E7EB;background:white;padding:0 12px;font-size:12px;font-weight:500;color:#374151;cursor:pointer"
|
||||
>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M7 4v13"/><path d="m3 13 4 4 4-4"/><path d="M17 20V7"/><path d="m21 11-4-4-4 4"/></svg>
|
||||
Sort
|
||||
</button>
|
||||
<Show when={sortMenuOpen()}>
|
||||
<div style="position:absolute;left:0;top:38px;z-index:30;min-width:200px;border-radius:12px;border:1px solid #E5E7EB;background:white;padding:6px;box-shadow:0 4px 16px rgba(0,0,0,0.1)">
|
||||
{(['name_asc', 'name_desc', 'created_desc', 'created_asc'] as const).map((s, i) => (
|
||||
<button type="button" onClick={() => { setSortBy(s); setSortMenuOpen(false); }} style={`display:block;width:100%;border-radius:8px;padding:8px 12px;text-align:left;font-size:13px;background:none;border:none;cursor:pointer;color:${sortBy() === s ? '#FF5E13' : '#374151'};background:${sortBy() === s ? '#FFF1EB' : 'transparent'}`}>
|
||||
{['Name (A-Z)', 'Name (Z-A)', 'Joined (Newest)', 'Joined (Oldest)'][i]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
<div style="position:relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setFilterMenuOpen((v) => !v); setSortMenuOpen(false); }}
|
||||
style="display:inline-flex;height:34px;align-items:center;gap:6px;border-radius:8px;border:1px solid #E5E7EB;background:white;padding:0 12px;font-size:12px;font-weight:500;color:#374151;cursor:pointer"
|
||||
>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 5h18M6 12h12M10 19h4"/></svg>
|
||||
Filters
|
||||
</button>
|
||||
<Show when={filterMenuOpen()}>
|
||||
<div style="position:absolute;left:0;top:38px;z-index:30;min-width:160px;border-radius:12px;border:1px solid #E5E7EB;background:white;padding:6px;box-shadow:0 4px 16px rgba(0,0,0,0.1)">
|
||||
{(['all', 'active', 'inactive', 'blocked'] as const).map((s) => (
|
||||
<button type="button" onClick={() => { setStatusFilter(s); setFilterMenuOpen(false); }} style={`display:block;width:100%;border-radius:8px;padding:8px 12px;text-align:left;font-size:13px;background:none;border:none;cursor:pointer;color:${statusFilter() === s ? '#FF5E13' : '#374151'};background:${statusFilter() === s ? '#FFF1EB' : 'transparent'}`}>
|
||||
{s === 'all' ? 'All Status' : s === 'active' ? 'Active' : s === 'inactive' ? 'Inactive' : 'Blocked'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
<button type="button" style="display:inline-flex;height:34px;align-items:center;gap:6px;border-radius:8px;border:1px solid #E5E7EB;background:white;padding:0 12px;font-size:12px;font-weight:500;color:#374151;cursor:pointer">Export</button>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full min-w-[1000px] border-collapse">
|
||||
<table class="min-w-full">
|
||||
<thead>
|
||||
<tr class="bg-[#0D0D2A] text-left text-white">
|
||||
<th class="px-6 py-4 text-[11px] font-bold uppercase tracking-wider rounded-tl-xl whitespace-nowrap">USER ID</th>
|
||||
<th class="px-6 py-4 text-[11px] font-bold uppercase tracking-wider whitespace-nowrap">USER DETAILS</th>
|
||||
<th class="px-6 py-4 text-[11px] font-bold uppercase tracking-wider whitespace-nowrap">ROLE</th>
|
||||
<th class="px-6 py-4 text-[11px] font-bold uppercase tracking-wider whitespace-nowrap">JOINING DATE</th>
|
||||
<th class="px-6 py-4 text-[11px] font-bold uppercase tracking-wider whitespace-nowrap">LAST LOGIN</th>
|
||||
<th class="px-6 py-4 text-[11px] font-bold uppercase tracking-wider text-center whitespace-nowrap">STATUS</th>
|
||||
<th class="px-6 py-4 text-[11px] font-bold uppercase tracking-wider text-right rounded-tr-xl whitespace-nowrap">ACTION</th>
|
||||
<tr style="background:#0D0D2A;text-align:left">
|
||||
{['User Details', 'Type', 'Active Role', 'Verification', 'Account Status', 'Actions'].map(h => (
|
||||
<th style="padding:10px 20px;font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:0.05em;color:#FFFFFF;white-space:nowrap">{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<Show when={users.loading}>
|
||||
<tr><td colspan="7" class="text-center py-12 text-[#8087a0] text-[14px]">Loading users...</td></tr>
|
||||
</Show>
|
||||
<Show when={!users.loading && users.error}>
|
||||
<tr><td colspan="7" class="text-center py-12 text-red-500 text-[14px]">Failed to load. Is the backend running?</td></tr>
|
||||
</Show>
|
||||
<Show when={!users.loading && !users.error && paginated().length === 0}>
|
||||
<tr><td colspan="7" class="text-center py-12 text-[#8087a0] text-[14px]">No users found.</td></tr>
|
||||
</Show>
|
||||
<For each={paginated()}>
|
||||
{(item) => (
|
||||
<tr class="border-b border-[#e2e6ee] bg-white transition-colors hover:bg-[#f8f9fc]">
|
||||
<td class="px-6 py-4 text-[14px] font-semibold text-[#64748b]">USR-{item.id.slice(0, 6).toUpperCase()}</td>
|
||||
<td class="px-6 py-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="h-10 w-10 flex items-center justify-center rounded-full bg-[#f1f5f9] text-[#0D0D2A] font-bold uppercase shrink-0">
|
||||
{(item.name || item.full_name || 'U').charAt(0)}
|
||||
<For each={filteredRows()}>
|
||||
{(row) => (
|
||||
<tr style="border-bottom:1px solid #F3F4F6" class="hover:bg-[#FAFAFA] transition-colors">
|
||||
<td style="padding:12px 20px">
|
||||
<div style="display:flex;align-items:center;gap:12px">
|
||||
<div style="width:36px;height:36px;border-radius:50%;background:#F3F4F6;display:flex;align-items:center;justify-content:center;font-size:14px;font-weight:700;color:#9CA3AF;border:1px solid #E5E7EB">
|
||||
{row.name.charAt(0)}
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-[14px] font-bold text-[#0D0D2A]">{item.name || item.full_name || '—'}</div>
|
||||
<div class="text-[13px] text-[#64748b]">{item.email}</div>
|
||||
<p style="font-size:14px;font-weight:600;color:#111827">{row.name}</p>
|
||||
<p style="font-size:11px;color:#6B7280">{row.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 text-[14px] text-[#475569]">{registrationRole(item)}</td>
|
||||
<td class="px-6 py-4 text-[14px] text-[#475569]">
|
||||
{(item.created_at || item.createdAt) ? new Date((item.created_at || item.createdAt)!).toLocaleDateString() : '—'}
|
||||
</td>
|
||||
<td class="px-6 py-4 text-[14px] text-[#475569]">—</td>
|
||||
<td class="px-6 py-4 text-center">
|
||||
<span class={`inline-flex items-center justify-center rounded-lg px-3 py-1 text-[12px] font-bold ${
|
||||
item.status === 'ACTIVE' ? 'bg-[#e6f9ed] text-[#00c853]' : item.status === 'PENDING' ? 'bg-amber-100 text-amber-700' : 'bg-[#fff0eb] text-[#ff6e30]'
|
||||
}`}>
|
||||
{item.status.charAt(0) + item.status.slice(1).toLowerCase()}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<button
|
||||
title="View Details"
|
||||
onClick={() => onView(item)}
|
||||
class="flex h-8 w-8 items-center justify-center rounded-lg border border-[#e2e6ee] bg-white text-[#64748b] hover:bg-[#f8f9fc] hover:text-[#0D0D2A] transition-colors"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
title="Edit"
|
||||
class="flex h-8 w-8 items-center justify-center rounded-lg border border-[#e2e6ee] bg-white text-[#64748b] hover:bg-[#f8f9fc] hover:text-[#0D0D2A] transition-colors"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<td style="padding:12px 20px;font-size:12px;font-weight:600;color:#374151">{row.userType}</td>
|
||||
<td style="padding:12px 20px;font-size:13px;color:#6B7280">{row.primaryActiveRole || '—'}</td>
|
||||
<td style="padding:12px 20px"><StatusBadge status={row.verificationStatus} /></td>
|
||||
<td style="padding:12px 20px"><StatusBadge status={row.accountStatus} /></td>
|
||||
<td style="padding:12px 20px;position:relative">
|
||||
<button type="button" onClick={() => setOpenMenuId(openMenuId() === row.id ? null : row.id)} style="display:inline-flex;height:32px;width:32px;align-items:center;justify-content:center;border-radius:8px;color:#9CA3AF;background:none;border:none;cursor:pointer">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="5" r="1.5"/><circle cx="12" cy="12" r="1.5"/><circle cx="12" cy="19" r="1.5"/></svg>
|
||||
</button>
|
||||
<Show when={openMenuId() === row.id}>
|
||||
<div style="position:absolute;right:20px;top:44px;z-index:20;width:180px;border-radius:12px;border:1px solid #E5E7EB;background:white;padding:6px;box-shadow:0 4px 20px rgba(0,0,0,0.12)">
|
||||
<button type="button" onClick={() => openDetail(row)} style="display:flex;width:100%;align-items:center;gap:10px;border-radius:8px;padding:8px 12px;font-size:13px;color:#374151;background:none;border:none;cursor:pointer;text-align:left">View Profile</button>
|
||||
<button type="button" onClick={() => openEdit(row)} style="display:flex;width:100%;align-items:center;gap:10px;border-radius:8px;padding:8px 12px;font-size:13px;color:#374151;background:none;border:none;cursor:pointer;text-align:left">Edit User</button>
|
||||
<button type="button" style="display:flex;width:100%;align-items:center;gap:10px;border-radius:8px;padding:8px 12px;font-size:13px;color:#DC2626;background:none;border:none;cursor:pointer;text-align:left">Block User</button>
|
||||
</div>
|
||||
</Show>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
|
|
@ -287,82 +314,73 @@ export default function UsersPage() {
|
|||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Pagination */}
|
||||
<Show when={totalPages() > 1}>
|
||||
<div class="mt-6 flex items-center justify-between border-t border-[#e2e6ee] pt-4">
|
||||
<button
|
||||
disabled={currentPage() === 1}
|
||||
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
|
||||
class="flex h-9 px-4 items-center justify-center rounded-lg border border-[#e2e6ee] bg-white text-[13px] font-medium text-[#64748b] hover:bg-[#f8f9fc] hover:text-[#0D0D2A] disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<span class="text-[13px] font-medium text-[#8087a0]">Page {currentPage()} of {totalPages()}</span>
|
||||
<button
|
||||
disabled={currentPage() >= totalPages()}
|
||||
onClick={() => setCurrentPage((p) => Math.min(totalPages(), p + 1))}
|
||||
class="flex h-9 px-4 items-center justify-center rounded-lg border border-[#e2e6ee] bg-white text-[13px] font-medium text-[#64748b] hover:bg-[#f8f9fc] hover:text-[#0D0D2A] disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
Next
|
||||
{/* ── FORM VIEW ── */}
|
||||
<Show when={view() === 'form'}>
|
||||
<div>
|
||||
<div style="margin-top:24px;display:flex;align-items:center;gap:24px;border-bottom:1px solid #E5E7EB">
|
||||
<button type="button" onClick={() => { setView('list'); resetForm(); }} style="padding-bottom:12px;font-size:14px;font-weight:500;color:#6B7280;background:none;border:none;cursor:pointer">All Users</button>
|
||||
<button type="button" style="padding-bottom:12px;font-size:14px;font-weight:500;color:#FF5E13;border-bottom:2px solid #FF5E13;background:none;cursor:pointer;margin-bottom:-1px">{editingId() ? 'Edit User' : 'Create User'}</button>
|
||||
</div>
|
||||
|
||||
<div style="margin-top:24px;border-radius:16px;border:1px solid #E5E7EB;background:white;box-shadow:0 1px 4px rgba(0,0,0,0.06);overflow:hidden">
|
||||
<div style="display:flex;align-items:center;gap:4px;border-bottom:1px solid #E5E7EB;padding:0 24px">
|
||||
{(['basic', 'status', 'security'] as const).map((tab, i) => {
|
||||
const labels = ['Basic Information', 'Account Status', 'Security Settings'];
|
||||
const active = () => formTab() === tab;
|
||||
return (
|
||||
<button type="button" onClick={() => setFormTab(tab)} style={`position:relative;padding:14px 8px;font-size:13px;font-weight:500;background:none;border:none;cursor:pointer;color:${active() ? '#FF5E13' : '#6B7280'}`}>
|
||||
{labels[i]}
|
||||
<Show when={active()}><span style="position:absolute;left:0;right:0;bottom:0;height:2px;background:#FF5E13;border-radius:2px 2px 0 0" /></Show>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div style="padding:24px">
|
||||
<Show when={formTab() === 'basic'}>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:20px">
|
||||
<FormInput label="Full Name" required value={name()} onInput={setName} />
|
||||
<FormInput label="Username" required value={username()} onInput={setUsername} />
|
||||
<FormInput label="Email Address" required value={email()} onInput={setEmail} type="email" />
|
||||
<FormInput label="Phone Number" value={phone()} onInput={setPhone} />
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={formTab() === 'status'}>
|
||||
<div style="display:flex;flex-direction:column;gap:20px">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;padding:12px;border-radius:8px;background:#F9FAFB;border:1px solid #E5E7EB">
|
||||
<div>
|
||||
<p style="font-size:13px;font-weight:600;color:#111827">Email Verified</p>
|
||||
<p style="font-size:12px;color:#6B7280">The user has confirmed their email address.</p>
|
||||
</div>
|
||||
<div style="width:40px;height:20px;background:#FF5E13;border-radius:10px;position:relative;cursor:pointer"><div style="width:16px;height:16px;background:white;border-radius:50%;position:absolute;top:2px;right:2px" /></div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={formTab() === 'security'}>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:20px">
|
||||
<FormInput label="Password" value="" onInput={() => {}} type="password" />
|
||||
<FormInput label="Confirm Password" value="" onInput={() => {}} type="password" />
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</section>
|
||||
</Show>
|
||||
|
||||
{/* Details View */}
|
||||
<Show when={activeTab() === 'view'}>
|
||||
<div class="bg-white rounded-3xl border border-[#e2e6ee] p-6 shadow-sm">
|
||||
<Show when={selectedUser()}>
|
||||
<div class="flex items-center justify-between mb-8 border-b border-[#e2e6ee] pb-6">
|
||||
<h2 class="text-[22px] font-bold text-[#0D0D2A]">User Details</h2>
|
||||
<button
|
||||
class="h-10 rounded-xl border border-[#d9dde6] bg-white px-5 text-[14px] font-semibold text-[#0D0D2A] hover:bg-[#f8f9fc] transition-colors"
|
||||
type="button"
|
||||
onClick={() => setActiveTab('list')}
|
||||
>
|
||||
Back To List
|
||||
</button>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||
<div class="bg-[#f9fafb] p-6 rounded-2xl border border-[#e2e6ee]">
|
||||
<h3 class="text-[16px] font-bold text-[#0D0D2A] mb-4">Profile Information</h3>
|
||||
<div class="space-y-4">
|
||||
<div><p class="text-[13px] text-[#64748b] font-medium">User ID</p><p class="text-[15px] font-semibold text-[#0D0D2A] mt-1">{selectedUser()!.id}</p></div>
|
||||
<div><p class="text-[13px] text-[#64748b] font-medium">Name</p><p class="text-[15px] font-semibold text-[#0D0D2A] mt-1">{selectedUser()!.name || selectedUser()!.full_name || '—'}</p></div>
|
||||
<div><p class="text-[13px] text-[#64748b] font-medium">Email</p><p class="text-[15px] font-semibold text-[#0D0D2A] mt-1">{selectedUser()!.email}</p></div>
|
||||
<div><p class="text-[13px] text-[#64748b] font-medium">Role</p><p class="text-[15px] font-semibold text-[#0D0D2A] mt-1">{registrationRole(selectedUser()!)}</p></div>
|
||||
<div>
|
||||
<p class="text-[13px] text-[#64748b] font-medium mb-1">Status</p>
|
||||
<span class={`inline-flex items-center justify-center rounded-lg px-3 py-1 text-[12px] font-bold ${
|
||||
selectedUser()!.status === 'ACTIVE' ? 'bg-[#e6f9ed] text-[#00c853]' : selectedUser()!.status === 'PENDING' ? 'bg-amber-100 text-amber-700' : 'bg-[#fff0eb] text-[#ff6e30]'
|
||||
}`}>
|
||||
{selectedUser()!.status.charAt(0) + selectedUser()!.status.slice(1).toLowerCase()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-[#f9fafb] p-6 rounded-2xl border border-[#e2e6ee]">
|
||||
<h3 class="text-[16px] font-bold text-[#0D0D2A] mb-4">Account Metadata</h3>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<p class="text-[13px] text-[#64748b] font-medium">Created</p>
|
||||
<p class="text-[15px] font-semibold text-[#0D0D2A] mt-1">{(selectedUser()!.created_at || selectedUser()!.createdAt) ? new Date((selectedUser()!.created_at || selectedUser()!.createdAt)!).toLocaleString() : '—'}</p>
|
||||
</div>
|
||||
<div><p class="text-[13px] text-[#64748b] font-medium">Role ID</p><p class="text-[15px] font-semibold text-[#0D0D2A] mt-1">{selectedUser()!.roleId || '—'}</p></div>
|
||||
<div class="pt-6 border-t border-[#e2e6ee] mt-6 gap-3 flex">
|
||||
<A class="h-10 rounded-xl bg-[#0D0D2A] px-5 flex items-center justify-center text-[14px] font-semibold text-white hover:bg-[#0a0044] transition-colors" href={`/admin/users/${selectedUser()!.id}/edit`}>Edit User</A>
|
||||
<button class="h-10 rounded-xl border border-red-200 bg-red-50 flex items-center justify-center px-5 text-[14px] font-semibold text-red-600 hover:bg-red-100 transition-colors" type="button">Deactivate</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
<div style="display:flex;align-items:center;justify-content:flex-end;gap:10px;border-top:1px solid #E5E7EB;padding:14px 24px">
|
||||
<button type="button" onClick={() => { setView('list'); resetForm(); }} style="height:38px;border-radius:10px;border:1px solid #E5E7EB;background:white;padding:0 20px;font-size:13px;font-weight:600;color:#374151;cursor:pointer">Cancel</button>
|
||||
<button type="button" style="height:38px;border-radius:10px;background:#0D0D2A;padding:0 24px;font-size:13px;font-weight:600;color:white;border:none;cursor:pointer">
|
||||
{editingId() ? 'Update User' : 'Create User'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
</div>
|
||||
</AdminShell>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,515 +1,8 @@
|
|||
import { createMemo, createResource, createSignal, For, Show } from 'solid-js';
|
||||
import AdminShell from '~/components/AdminShell';
|
||||
import { Clock } from 'lucide-solid';
|
||||
|
||||
const API = '/api/gateway';
|
||||
|
||||
type VerificationStatus = 'PENDING' | 'VERIFIED' | 'FLAGGED' | 'RE_UPLOAD';
|
||||
|
||||
type VerificationRow = {
|
||||
id: string;
|
||||
verificationId?: string;
|
||||
name?: string;
|
||||
requesterName?: string;
|
||||
requester?: { name?: string; email?: string };
|
||||
userType?: string;
|
||||
verificationType?: string;
|
||||
type?: string;
|
||||
submittedDate?: string;
|
||||
createdAt?: string;
|
||||
created_at?: string;
|
||||
documents?: number;
|
||||
status?: string;
|
||||
requestStatus?: string;
|
||||
};
|
||||
|
||||
const FALLBACK: VerificationRow[] = [
|
||||
{ id: 'v1', verificationId: 'VER-2024-001', name: 'Rajesh Kumar', userType: 'Professional', verificationType: 'Identity Verification', submittedDate: '2024-03-20', documents: 3, status: 'PENDING' },
|
||||
{ id: 'v2', verificationId: 'VER-2024-002', name: 'Priya Sharma', userType: 'Company', verificationType: 'Business Verification', submittedDate: '2024-03-19', documents: 5, status: 'VERIFIED' },
|
||||
{ id: 'v3', verificationId: 'VER-2024-003', name: 'Anil Patel', userType: 'Customer', verificationType: 'Profile Verification', submittedDate: '2024-03-18', documents: 2, status: 'PENDING' },
|
||||
{ id: 'v4', verificationId: 'VER-2024-004', name: 'Meera Singh', userType: 'Jobseeker', verificationType: 'Document Verification', submittedDate: '2024-03-17', documents: 4, status: 'RE_UPLOAD' },
|
||||
{ id: 'v5', verificationId: 'VER-2024-005', name: 'Vikram Reddy', userType: 'Professional', verificationType: 'Mixed Verification', submittedDate: '2024-03-16', documents: 6, status: 'FLAGGED' },
|
||||
];
|
||||
|
||||
const FALLBACK_RULES = [
|
||||
{ id: 'r1', name: 'Professional Identity Verification', userType: 'Professional', verificationType: 'Identity Verification', requiredDocs: 3, checklistItems: 8, status: 'ACTIVE' },
|
||||
{ id: 'r2', name: 'Company Business Verification', userType: 'Company', verificationType: 'Business Verification', requiredDocs: 5, checklistItems: 12, status: 'ACTIVE' },
|
||||
{ id: 'r3', name: 'Customer Basic Verification', userType: 'Customer', verificationType: 'Profile Verification', requiredDocs: 2, checklistItems: 6, status: 'ACTIVE' },
|
||||
];
|
||||
|
||||
const STATS = [
|
||||
{ label: 'Total Pending', value: '42', color: '#6B7280' },
|
||||
{ label: 'Identity Verification', value: '18', color: '#3B82F6' },
|
||||
{ label: 'Business Verification', value: '12', color: '#8B5CF6' },
|
||||
{ label: 'Re-upload Review', value: '8', color: '#FF5E13' },
|
||||
{ label: 'Verified Today', value: '15', color: '#10B981' },
|
||||
{ label: 'Flagged Cases', value: '4', color: '#EF4444' },
|
||||
];
|
||||
|
||||
const PREVIEW_STATES = [
|
||||
{ key: 'pending', label: 'Pending Verification' },
|
||||
{ key: 'review', label: 'Under Review' },
|
||||
{ key: 'reupload', label: 'Re-upload Requested' },
|
||||
{ key: 'completed', label: 'Completed' },
|
||||
{ key: 'rejected', label: 'Rejected' },
|
||||
] as const;
|
||||
|
||||
async function fetchVerifications(): Promise<VerificationRow[]> {
|
||||
try {
|
||||
const res = await fetch(`${API}/api/admin/verifications`);
|
||||
if (!res.ok) throw new Error();
|
||||
const data = await res.json();
|
||||
const list = Array.isArray(data) ? data : (data.verifications ?? data.approvals ?? []);
|
||||
return list.length > 0 ? list : FALLBACK;
|
||||
} catch {
|
||||
return FALLBACK;
|
||||
}
|
||||
}
|
||||
|
||||
function rowName(r: VerificationRow) {
|
||||
return r.name || r.requester?.name || r.requesterName || '—';
|
||||
}
|
||||
|
||||
function rowVerifId(r: VerificationRow) {
|
||||
return r.verificationId || r.id || '—';
|
||||
}
|
||||
|
||||
function rowStatus(r: VerificationRow): VerificationStatus {
|
||||
const raw = String(r.status || r.requestStatus || '').toUpperCase();
|
||||
if (raw === 'VERIFIED' || raw === 'APPROVED') return 'VERIFIED';
|
||||
if (raw === 'FLAGGED' || raw === 'REJECTED') return 'FLAGGED';
|
||||
if (raw === 'RE_UPLOAD' || raw === 'CHANGES_REQUESTED') return 'RE_UPLOAD';
|
||||
return 'PENDING';
|
||||
}
|
||||
|
||||
function rowDate(r: VerificationRow) {
|
||||
return r.submittedDate || (r.createdAt ? r.createdAt.split('T')[0] : r.created_at ? r.created_at.split('T')[0] : '—');
|
||||
}
|
||||
|
||||
function StatusBadge(props: { status: VerificationStatus }) {
|
||||
const styles: Record<VerificationStatus, string> = {
|
||||
PENDING: 'display:inline-flex;align-items:center;justify-content:center;min-width:72px;padding:3px 10px;font-size:11px;font-weight:600;border-radius:4px;border:1px solid #FFD8C2;background:#FFF1EB;color:#FF5E13',
|
||||
VERIFIED: 'display:inline-flex;align-items:center;justify-content:center;min-width:72px;padding:3px 10px;font-size:11px;font-weight:600;border-radius:4px;background:#ECFDF5;color:#059669;border:1px solid #A7F3D0',
|
||||
FLAGGED: 'display:inline-flex;align-items:center;justify-content:center;min-width:72px;padding:3px 10px;font-size:11px;font-weight:600;border-radius:4px;background:#FEF2F2;color:#DC2626;border:1px solid #FECACA',
|
||||
RE_UPLOAD: 'display:inline-flex;align-items:center;justify-content:center;min-width:72px;padding:3px 10px;font-size:11px;font-weight:600;border-radius:4px;background:#EFF6FF;color:#3B82F6;border:1px solid #BFDBFE',
|
||||
};
|
||||
const labels: Record<VerificationStatus, string> = {
|
||||
PENDING: 'Pending',
|
||||
VERIFIED: 'Verified',
|
||||
FLAGGED: 'Flagged',
|
||||
RE_UPLOAD: 'Re-upload',
|
||||
};
|
||||
return <span style={styles[props.status]}>{labels[props.status]}</span>;
|
||||
}
|
||||
import { useNavigate } from '@solidjs/router';
|
||||
import { onMount } from 'solid-js';
|
||||
|
||||
export default function VerificationStatusPage() {
|
||||
const [rows] = createResource(fetchVerifications);
|
||||
const [search, setSearch] = createSignal('');
|
||||
const [userTypeFilter, setUserTypeFilter] = createSignal('');
|
||||
const [verifTypeFilter, setVerifTypeFilter] = createSignal('');
|
||||
const [statusFilter, setStatusFilter] = createSignal('');
|
||||
const [activeView, setActiveView] = createSignal<'queue' | 'rules' | 'preview'>('queue');
|
||||
const [previewState, setPreviewState] = createSignal('review');
|
||||
const [page, setPage] = createSignal(1);
|
||||
const [openActionId, setOpenActionId] = createSignal<string | null>(null);
|
||||
const [rulesSearch, setRulesSearch] = createSignal('');
|
||||
const [rulesUserType, setRulesUserType] = createSignal('');
|
||||
|
||||
const PAGE_SIZE = 10;
|
||||
|
||||
const filtered = createMemo(() => {
|
||||
const list = rows() ?? [];
|
||||
const q = search().trim().toLowerCase();
|
||||
const sf = statusFilter();
|
||||
const ut = userTypeFilter();
|
||||
const vt = verifTypeFilter();
|
||||
return list.filter((r) => {
|
||||
const matchSearch = !q || [rowVerifId(r), rowName(r), r.userType, r.verificationType].join(' ').toLowerCase().includes(q);
|
||||
const matchStatus = !sf || rowStatus(r) === sf;
|
||||
const matchUserType = !ut || (r.userType || '').toLowerCase() === ut.toLowerCase();
|
||||
const matchVerifType = !vt || (r.verificationType || '').toLowerCase().includes(vt.toLowerCase());
|
||||
return matchSearch && matchStatus && matchUserType && matchVerifType;
|
||||
});
|
||||
});
|
||||
|
||||
const filteredRules = createMemo(() => {
|
||||
const q = rulesSearch().trim().toLowerCase();
|
||||
const ut = rulesUserType();
|
||||
return FALLBACK_RULES.filter((r) => {
|
||||
const matchSearch = !q || r.name.toLowerCase().includes(q) || r.userType.toLowerCase().includes(q);
|
||||
const matchUserType = !ut || r.userType.toLowerCase() === ut.toLowerCase();
|
||||
return matchSearch && matchUserType;
|
||||
});
|
||||
});
|
||||
|
||||
const totalPages = () => Math.max(1, Math.ceil(filtered().length / PAGE_SIZE));
|
||||
const paged = () => filtered().slice((page() - 1) * PAGE_SIZE, page() * PAGE_SIZE);
|
||||
|
||||
function viewBtnStyle(view: 'queue' | 'rules' | 'preview') {
|
||||
const isActive = activeView() === view;
|
||||
const isFirst = view === 'queue';
|
||||
return isActive
|
||||
? `background:#0D0D2A;color:white;padding:8px 16px;font-size:13px;font-weight:600;border:none;cursor:pointer${!isFirst ? ';border-left:1px solid #E5E7EB' : ''}`
|
||||
: `background:white;color:#374151;padding:8px 16px;font-size:13px;font-weight:500;border:none;cursor:pointer${!isFirst ? ';border-left:1px solid #E5E7EB' : ''}`;
|
||||
}
|
||||
|
||||
return (
|
||||
<AdminShell>
|
||||
<div style="width:100%;padding-bottom:32px">
|
||||
|
||||
{/* Page Header */}
|
||||
<div style="display:flex;align-items:flex-start;justify-content:space-between;margin-bottom:24px">
|
||||
<div>
|
||||
<h1 style="font-size:24px;font-weight:700;color:#111827;margin:0">Verification Management</h1>
|
||||
<p style="font-size:13px;color:#6B7280;margin-top:4px;margin-bottom:0">Review and verify user submissions</p>
|
||||
</div>
|
||||
{/* Toggle button group */}
|
||||
<div style="display:flex;align-items:center;gap:0;border-radius:10px;overflow:hidden;border:1px solid #E5E7EB">
|
||||
<button type="button" onClick={() => setActiveView('queue')} style={viewBtnStyle('queue')}>
|
||||
Verification Queue
|
||||
</button>
|
||||
<button type="button" onClick={() => setActiveView('rules')} style={viewBtnStyle('rules')}>
|
||||
Verification Rules
|
||||
</button>
|
||||
<button type="button" onClick={() => setActiveView('preview')} style={viewBtnStyle('preview')}>
|
||||
User Preview
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ─── VERIFICATION QUEUE VIEW ─── */}
|
||||
<Show when={activeView() === 'queue'}>
|
||||
{/* Stats Cards */}
|
||||
<div style="display:grid;grid-template-columns:repeat(6,1fr);gap:16px;margin-bottom:24px">
|
||||
<For each={STATS}>
|
||||
{(stat) => (
|
||||
<div style="border-radius:12px;border:1px solid #E5E7EB;background:white;padding:16px;">
|
||||
<p style="font-size:12px;color:#6B7280;font-weight:500;margin:0 0 8px 0">{stat.label}</p>
|
||||
<p style={`font-size:28px;font-weight:700;color:${stat.color};margin:0`}>{stat.value}</p>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
|
||||
{/* Section Header */}
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px">
|
||||
<span style="font-size:18px;font-weight:700;color:#111827">Verification Cases</span>
|
||||
<div style="display:flex;gap:8px">
|
||||
<button type="button" style="height:34px;padding:0 14px;border-radius:8px;border:1px solid #E5E7EB;background:white;font-size:13px;color:#374151;cursor:pointer">
|
||||
Export Queue
|
||||
</button>
|
||||
<button type="button" style="height:34px;padding:0 14px;border-radius:8px;background:#0D0D2A;color:white;font-size:13px;font-weight:500;border:none;cursor:pointer">
|
||||
Bulk Actions
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filter Bar */}
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:16px;flex-wrap:wrap">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search by name or ID..."
|
||||
value={search()}
|
||||
onInput={(e) => { setSearch(e.currentTarget.value); setPage(1); }}
|
||||
style="height:34px;flex:1;max-width:280px;border-radius:8px;border:1px solid #E5E7EB;padding:0 12px;font-size:13px;outline:none;color:#111827;background:white;box-sizing:border-box"
|
||||
/>
|
||||
<select
|
||||
value={userTypeFilter()}
|
||||
onChange={(e) => { setUserTypeFilter(e.currentTarget.value); setPage(1); }}
|
||||
style="height:34px;border-radius:8px;border:1px solid #E5E7EB;padding:0 12px;font-size:13px;color:#374151;background:white;cursor:pointer"
|
||||
>
|
||||
<option value="">User Type</option>
|
||||
<option value="Professional">Professional</option>
|
||||
<option value="Company">Company</option>
|
||||
<option value="Customer">Customer</option>
|
||||
<option value="Jobseeker">Jobseeker</option>
|
||||
</select>
|
||||
<select
|
||||
value={verifTypeFilter()}
|
||||
onChange={(e) => { setVerifTypeFilter(e.currentTarget.value); setPage(1); }}
|
||||
style="height:34px;border-radius:8px;border:1px solid #E5E7EB;padding:0 12px;font-size:13px;color:#374151;background:white;cursor:pointer"
|
||||
>
|
||||
<option value="">Verification Type</option>
|
||||
<option value="Identity Verification">Identity Verification</option>
|
||||
<option value="Business Verification">Business Verification</option>
|
||||
<option value="Profile Verification">Profile Verification</option>
|
||||
<option value="Document Verification">Document Verification</option>
|
||||
</select>
|
||||
<select
|
||||
value={statusFilter()}
|
||||
onChange={(e) => { setStatusFilter(e.currentTarget.value); setPage(1); }}
|
||||
style="height:34px;border-radius:8px;border:1px solid #E5E7EB;padding:0 12px;font-size:13px;color:#374151;background:white;cursor:pointer"
|
||||
>
|
||||
<option value="">Status</option>
|
||||
<option value="PENDING">Pending</option>
|
||||
<option value="VERIFIED">Verified</option>
|
||||
<option value="FLAGGED">Flagged</option>
|
||||
<option value="RE_UPLOAD">Re-upload</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Table Card */}
|
||||
<div style="border-radius:12px;border:1px solid #E5E7EB;background:white;overflow:hidden">
|
||||
<div style="overflow-x:auto">
|
||||
<table style="width:100%;border-collapse:collapse;min-width:900px">
|
||||
<thead>
|
||||
<tr style="background:#0D0D2A">
|
||||
<th style="padding:10px 20px;font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:0.05em;color:#FFFFFF;text-align:left">Verification ID</th>
|
||||
<th style="padding:10px 20px;font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:0.05em;color:#FFFFFF;text-align:left">Applicant Name</th>
|
||||
<th style="padding:10px 20px;font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:0.05em;color:#FFFFFF;text-align:left">User Type</th>
|
||||
<th style="padding:10px 20px;font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:0.05em;color:#FFFFFF;text-align:left">Verification Type</th>
|
||||
<th style="padding:10px 20px;font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:0.05em;color:#FFFFFF;text-align:left">Submitted Date</th>
|
||||
<th style="padding:10px 20px;font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:0.05em;color:#FFFFFF;text-align:left">Documents</th>
|
||||
<th style="padding:10px 20px;font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:0.05em;color:#FFFFFF;text-align:left">Status</th>
|
||||
<th style="padding:10px 20px;font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:0.05em;color:#FFFFFF;text-align:left">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<Show when={rows.loading}>
|
||||
<tr>
|
||||
<td colspan="8" style="padding:40px 20px;text-align:center;font-size:14px;color:#9CA3AF">Loading verification cases...</td>
|
||||
</tr>
|
||||
</Show>
|
||||
<Show when={!rows.loading && paged().length === 0}>
|
||||
<tr>
|
||||
<td colspan="8" style="padding:40px 20px;text-align:center;font-size:14px;color:#9CA3AF">No verification cases found.</td>
|
||||
</tr>
|
||||
</Show>
|
||||
<For each={paged()}>
|
||||
{(row) => (
|
||||
<tr style="border-bottom:1px solid #F3F4F6">
|
||||
<td style="padding:12px 20px;font-size:13px">
|
||||
<span style="font-size:12px;font-family:monospace;color:#6B7280">{rowVerifId(row)}</span>
|
||||
</td>
|
||||
<td style="padding:12px 20px;font-size:13px;font-weight:600;color:#111827">{rowName(row)}</td>
|
||||
<td style="padding:12px 20px;font-size:13px;color:#374151">{row.userType || '—'}</td>
|
||||
<td style="padding:12px 20px;font-size:13px;color:#374151">{row.verificationType || row.type || '—'}</td>
|
||||
<td style="padding:12px 20px;font-size:13px;color:#6B7280">{rowDate(row)}</td>
|
||||
<td style="padding:12px 20px;font-size:13px;color:#374151">{row.documents ?? '—'}</td>
|
||||
<td style="padding:12px 20px">
|
||||
<StatusBadge status={rowStatus(row)} />
|
||||
</td>
|
||||
<td style="padding:12px 20px;position:relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpenActionId(openActionId() === row.id ? null : row.id)}
|
||||
style="height:30px;padding:0 12px;font-size:12px;font-weight:500;border:1px solid #E5E7EB;background:white;color:#374151;border-radius:6px;cursor:pointer"
|
||||
>
|
||||
Actions ▾
|
||||
</button>
|
||||
<Show when={openActionId() === row.id}>
|
||||
<div style="position:absolute;right:20px;top:44px;z-index:20;width:210px;border-radius:12px;border:1px solid #E5E7EB;background:white;padding:6px;box-shadow:0 4px 20px rgba(0,0,0,0.12)">
|
||||
<button type="button" style="display:block;width:100%;text-align:left;padding:10px 12px;font-size:13px;color:#111827;background:none;border:none;cursor:pointer;border-radius:6px" onMouseEnter={(e) => (e.currentTarget.style.background = '#F9FAFB')} onMouseLeave={(e) => (e.currentTarget.style.background = 'none')}>
|
||||
View Details
|
||||
</button>
|
||||
<button type="button" style="display:block;width:100%;text-align:left;padding:10px 12px;font-size:13px;color:#059669;background:none;border:none;cursor:pointer;border-radius:6px" onMouseEnter={(e) => (e.currentTarget.style.background = '#ECFDF5')} onMouseLeave={(e) => (e.currentTarget.style.background = 'none')}>
|
||||
Approve
|
||||
</button>
|
||||
<button type="button" style="display:block;width:100%;text-align:left;padding:10px 12px;font-size:13px;color:#DC2626;background:none;border:none;cursor:pointer;border-radius:6px" onMouseEnter={(e) => (e.currentTarget.style.background = '#FEF2F2')} onMouseLeave={(e) => (e.currentTarget.style.background = 'none')}>
|
||||
Reject / Flag
|
||||
</button>
|
||||
<button type="button" style="display:block;width:100%;text-align:left;padding:10px 12px;font-size:13px;color:#D97706;background:none;border:none;cursor:pointer;border-radius:6px" onMouseEnter={(e) => (e.currentTarget.style.background = '#FFFBEB')} onMouseLeave={(e) => (e.currentTarget.style.background = 'none')}>
|
||||
Request Re-upload
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</For>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;border-top:1px solid #F3F4F6;padding:12px 20px">
|
||||
<span style="font-size:13px;color:#6B7280">
|
||||
Showing {Math.min((page() - 1) * PAGE_SIZE + 1, filtered().length)}–{Math.min(page() * PAGE_SIZE, filtered().length)} of {filtered().length}
|
||||
</span>
|
||||
<div style="display:flex;gap:4px">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPage(Math.max(1, page() - 1))}
|
||||
disabled={page() === 1}
|
||||
style="height:30px;min-width:30px;padding:0 10px;font-size:13px;border:1px solid #E5E7EB;background:white;color:#374151;border-radius:6px;cursor:pointer"
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
<For each={Array.from({ length: totalPages() }, (_, i) => i + 1)}>
|
||||
{(p) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPage(p)}
|
||||
style={p === page()
|
||||
? 'height:30px;min-width:30px;padding:0 10px;font-size:13px;border:1px solid #FF5E13;background:#FF5E13;color:white;border-radius:6px;cursor:pointer;font-weight:600'
|
||||
: 'height:30px;min-width:30px;padding:0 10px;font-size:13px;border:1px solid #E5E7EB;background:white;color:#374151;border-radius:6px;cursor:pointer'}
|
||||
>
|
||||
{p}
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPage(Math.min(totalPages(), page() + 1))}
|
||||
disabled={page() === totalPages()}
|
||||
style="height:30px;min-width:30px;padding:0 10px;font-size:13px;border:1px solid #E5E7EB;background:white;color:#374151;border-radius:6px;cursor:pointer"
|
||||
>
|
||||
›
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* ─── VERIFICATION RULES VIEW ─── */}
|
||||
<Show when={activeView() === 'rules'}>
|
||||
{/* Section Header */}
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px">
|
||||
<span style="font-size:18px;font-weight:700;color:#111827">Verification Rules</span>
|
||||
<button type="button" style="height:34px;padding:0 14px;border-radius:8px;background:#0D0D2A;color:white;font-size:13px;font-weight:500;border:none;cursor:pointer">
|
||||
+ Create Verification Rule
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Filter */}
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:16px">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search rules..."
|
||||
value={rulesSearch()}
|
||||
onInput={(e) => setRulesSearch(e.currentTarget.value)}
|
||||
style="height:34px;flex:1;max-width:280px;border-radius:8px;border:1px solid #E5E7EB;padding:0 12px;font-size:13px;outline:none;color:#111827;background:white;box-sizing:border-box"
|
||||
/>
|
||||
<select
|
||||
value={rulesUserType()}
|
||||
onChange={(e) => setRulesUserType(e.currentTarget.value)}
|
||||
style="height:34px;border-radius:8px;border:1px solid #E5E7EB;padding:0 12px;font-size:13px;color:#374151;background:white;cursor:pointer"
|
||||
>
|
||||
<option value="">User Type</option>
|
||||
<option value="Professional">Professional</option>
|
||||
<option value="Company">Company</option>
|
||||
<option value="Customer">Customer</option>
|
||||
<option value="Jobseeker">Jobseeker</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Rules Table Card */}
|
||||
<div style="border-radius:12px;border:1px solid #E5E7EB;background:white;overflow:hidden">
|
||||
<div style="overflow-x:auto">
|
||||
<table style="width:100%;border-collapse:collapse;min-width:800px">
|
||||
<thead>
|
||||
<tr style="background:#0D0D2A">
|
||||
<th style="padding:10px 20px;font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:0.05em;color:#FFFFFF;text-align:left">Rule Name</th>
|
||||
<th style="padding:10px 20px;font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:0.05em;color:#FFFFFF;text-align:left">User Type</th>
|
||||
<th style="padding:10px 20px;font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:0.05em;color:#FFFFFF;text-align:left">Verification Type</th>
|
||||
<th style="padding:10px 20px;font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:0.05em;color:#FFFFFF;text-align:left">Required Documents</th>
|
||||
<th style="padding:10px 20px;font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:0.05em;color:#FFFFFF;text-align:left">Checklist Items</th>
|
||||
<th style="padding:10px 20px;font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:0.05em;color:#FFFFFF;text-align:left">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<Show when={filteredRules().length === 0}>
|
||||
<tr>
|
||||
<td colspan="6" style="padding:40px 20px;text-align:center;font-size:14px;color:#9CA3AF">No rules found.</td>
|
||||
</tr>
|
||||
</Show>
|
||||
<For each={filteredRules()}>
|
||||
{(rule) => (
|
||||
<tr style="border-bottom:1px solid #F3F4F6">
|
||||
<td style="padding:12px 20px;font-size:13px;font-weight:600;color:#111827">{rule.name}</td>
|
||||
<td style="padding:12px 20px;font-size:13px;color:#374151">{rule.userType}</td>
|
||||
<td style="padding:12px 20px;font-size:13px;color:#374151">{rule.verificationType}</td>
|
||||
<td style="padding:12px 20px;font-size:13px;color:#374151">{rule.requiredDocs}</td>
|
||||
<td style="padding:12px 20px;font-size:13px;color:#374151">{rule.checklistItems}</td>
|
||||
<td style="padding:12px 20px">
|
||||
<span style="display:inline-flex;align-items:center;justify-content:center;min-width:64px;padding:3px 10px;font-size:11px;font-weight:600;border-radius:4px;background:#ECFDF5;color:#059669;border:1px solid #A7F3D0">
|
||||
{rule.status}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</For>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* ─── USER PREVIEW VIEW ─── */}
|
||||
<Show when={activeView() === 'preview'}>
|
||||
<div style="border-radius:12px;border:1px solid #E5E7EB;background:white;padding:24px">
|
||||
<p style="font-size:16px;font-weight:600;color:#111827;margin:0 0 16px 0">Select Verification State</p>
|
||||
|
||||
{/* State Selector Buttons */}
|
||||
<div style="display:flex;align-items:center;gap:12px;margin-bottom:32px;flex-wrap:wrap">
|
||||
<For each={PREVIEW_STATES}>
|
||||
{(state) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPreviewState(state.key)}
|
||||
style={previewState() === state.key
|
||||
? 'border:2px solid #FF5E13;background:white;color:#FF5E13;padding:8px 16px;border-radius:8px;font-size:14px;font-weight:600;cursor:pointer'
|
||||
: 'border:1px solid #E5E7EB;background:white;color:#374151;padding:8px 16px;border-radius:8px;font-size:14px;font-weight:500;cursor:pointer'}
|
||||
>
|
||||
{state.label}
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
|
||||
{/* Preview Card */}
|
||||
<div style="border-radius:12px;border:1px solid #E5E7EB;background:white;padding:48px;text-align:center">
|
||||
{/* Clock icon */}
|
||||
<div style="display:flex;justify-content:center">
|
||||
<Clock size={48} color="#FF5E13" />
|
||||
</div>
|
||||
|
||||
<p style="font-size:20px;font-weight:700;color:#111827;margin-top:16px;margin-bottom:0">Verification Status</p>
|
||||
<p style="font-size:14px;color:#6B7280;margin-top:8px;max-width:400px;margin-left:auto;margin-right:auto">
|
||||
Your submitted information and documents are currently under review.
|
||||
</p>
|
||||
|
||||
{/* Progress Row */}
|
||||
<div style="display:flex;align-items:center;justify-content:center;gap:0;margin-top:32px">
|
||||
{/* Step 1: Submitted */}
|
||||
<div style="display:flex;flex-direction:column;align-items:center;gap:8px">
|
||||
<div style="width:32px;height:32px;border-radius:50%;background:#10B981;display:flex;align-items:center;justify-content:center">
|
||||
<span style="color:white;font-size:16px;font-weight:700;line-height:1">✓</span>
|
||||
</div>
|
||||
<span style="font-size:12px;font-weight:500;color:#10B981">Submitted</span>
|
||||
</div>
|
||||
|
||||
{/* Connector */}
|
||||
<div style="width:80px;height:2px;background:#FF5E13;margin-bottom:20px" />
|
||||
|
||||
{/* Step 2: Under Review */}
|
||||
<div style="display:flex;flex-direction:column;align-items:center;gap:8px">
|
||||
<div style="width:32px;height:32px;border-radius:50%;background:#FF5E13;display:flex;align-items:center;justify-content:center">
|
||||
<Clock size={16} color="white" />
|
||||
</div>
|
||||
<span style="font-size:12px;font-weight:500;color:#FF5E13">Under Review</span>
|
||||
</div>
|
||||
|
||||
{/* Connector */}
|
||||
<div style="width:80px;height:2px;background:#E5E7EB;margin-bottom:20px" />
|
||||
|
||||
{/* Step 3: Verification Result */}
|
||||
<div style="display:flex;flex-direction:column;align-items:center;gap:8px">
|
||||
<div style="width:32px;height:32px;border-radius:50%;background:#E5E7EB;display:flex;align-items:center;justify-content:center">
|
||||
<span style="color:#9CA3AF;font-size:14px;font-weight:700;line-height:1">?</span>
|
||||
</div>
|
||||
<span style="font-size:12px;font-weight:500;color:#9CA3AF">Verification Result</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="button" style="height:40px;padding:0 24px;border-radius:10px;background:#0D0D2A;color:white;font-size:14px;font-weight:600;border:none;cursor:pointer;margin-top:24px">
|
||||
Back to Dashboard
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
</div>
|
||||
</AdminShell>
|
||||
);
|
||||
const navigate = useNavigate();
|
||||
onMount(() => navigate('/admin/verification', { replace: true }));
|
||||
return null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,88 +1,8 @@
|
|||
import { A, useParams } from '@solidjs/router';
|
||||
import { createMemo, createResource, Show } from 'solid-js';
|
||||
import AdminShell from '~/components/AdminShell';
|
||||
|
||||
const API = '/api/gateway';
|
||||
|
||||
type ApprovalDetail = {
|
||||
id: string;
|
||||
requestType?: string;
|
||||
type?: string;
|
||||
requestStatus?: string;
|
||||
status?: string;
|
||||
requester?: { name?: string; email?: string };
|
||||
requesterName?: string;
|
||||
requesterEmail?: string;
|
||||
createdAt?: string;
|
||||
created_at?: string;
|
||||
requestReason?: string;
|
||||
};
|
||||
|
||||
async function fetchApproval(id: string): Promise<ApprovalDetail | null> {
|
||||
try {
|
||||
const res = await fetch(`${API}/api/admin/approvals/${id}`);
|
||||
if (!res.ok) return null;
|
||||
return res.json();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
import { useNavigate } from '@solidjs/router';
|
||||
import { onMount } from 'solid-js';
|
||||
|
||||
export default function VerificationStatusDetailPage() {
|
||||
const params = useParams();
|
||||
const [detail] = createResource(() => params.id, fetchApproval);
|
||||
|
||||
const status = createMemo(() => (detail()?.requestStatus || detail()?.status || 'UNKNOWN').toUpperCase());
|
||||
const type = createMemo(() => (detail()?.requestType || detail()?.type || 'PROFILE').toUpperCase());
|
||||
const requester = createMemo(() => detail()?.requester?.name || detail()?.requesterName || 'Unknown');
|
||||
const email = createMemo(() => detail()?.requester?.email || detail()?.requesterEmail || '—');
|
||||
const submitted = createMemo(() => detail()?.createdAt || detail()?.created_at || '');
|
||||
|
||||
return (
|
||||
<AdminShell>
|
||||
<div class="flex flex-col -mx-6 -mt-6 min-h-full">
|
||||
<div class="bg-white border-b border-gray-200 px-6 py-4 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-xl font-semibold text-gray-900">Verification Status Detail</h1>
|
||||
<p class="text-sm text-gray-500 mt-0.5">Open one verification status request and jump into approval review when needed.</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<A class="rounded-lg border border-gray-200 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors" href="/admin/verification-status">Back to Status List</A>
|
||||
<A class="btn-primary" href={`/admin/approval/${params.id}`}>Open Approval Detail</A>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-6 flex-1">
|
||||
|
||||
<Show when={detail.loading}>
|
||||
<div class="rounded-xl border border-gray-200 bg-white shadow-sm"><p class="notice">Loading verification status...</p></div>
|
||||
</Show>
|
||||
|
||||
<Show when={!detail.loading && !detail()}>
|
||||
<div class="rounded-xl border border-gray-200 bg-white shadow-sm"><p class="notice">Verification status record not found.</p></div>
|
||||
</Show>
|
||||
|
||||
<Show when={detail()}>
|
||||
<div class="grid" style="margin-top:0">
|
||||
<section class="rounded-xl border border-gray-200 bg-white shadow-sm">
|
||||
<h2 style="margin-bottom:8px">Request</h2>
|
||||
<p class="notice" style="margin:0"><strong>ID:</strong> {detail()!.id}</p>
|
||||
<p class="notice" style="margin:8px 0 0"><strong>Type:</strong> {type()}</p>
|
||||
<p class="notice" style="margin:8px 0 0"><strong>Status:</strong> {status()}</p>
|
||||
<p class="notice" style="margin:8px 0 0"><strong>Submitted:</strong> {submitted() ? new Date(submitted()).toLocaleString() : '—'}</p>
|
||||
</section>
|
||||
<section class="rounded-xl border border-gray-200 bg-white shadow-sm">
|
||||
<h2 style="margin-bottom:8px">Requester</h2>
|
||||
<p class="notice" style="margin:0"><strong>Name:</strong> {requester()}</p>
|
||||
<p class="notice" style="margin:8px 0 0"><strong>Email:</strong> {email()}</p>
|
||||
</section>
|
||||
</div>
|
||||
<section class="rounded-xl border border-gray-200 bg-white shadow-sm" style="margin-top:16px">
|
||||
<h2 style="margin-bottom:10px">Request Reason Payload</h2>
|
||||
<pre class="json">{detail()!.requestReason || 'No requestReason payload found.'}</pre>
|
||||
</section>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</AdminShell>
|
||||
);
|
||||
const navigate = useNavigate();
|
||||
onMount(() => navigate('/admin/verification', { replace: true }));
|
||||
return null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,26 +1,539 @@
|
|||
import { A } from '@solidjs/router';
|
||||
import { For, Show, createMemo, createSignal, onMount } from 'solid-js';
|
||||
import AdminShell from '~/components/AdminShell';
|
||||
import type { CrudRecord } from '~/lib/admin/types';
|
||||
|
||||
const API = '/api/gateway';
|
||||
|
||||
type VerificationRecord = CrudRecord & {
|
||||
applicantName?: string;
|
||||
userType: 'CUSTOMER' | 'PROFESSIONAL' | 'COMPANY' | 'JOBSEEKER';
|
||||
verificationType: 'IDENTITY' | 'BUSINESS' | 'PROFILE' | 'DOCUMENT' | 'MIXED';
|
||||
submittedDate?: string;
|
||||
documentsCount?: number;
|
||||
assignedVerifier?: string;
|
||||
priority: 'LOW' | 'MEDIUM' | 'HIGH';
|
||||
status: 'PENDING' | 'IN_REVIEW' | 'PARTIALLY_VERIFIED' | 'VERIFIED' | 'FLAGGED' | 'RE_UPLOAD_REQUESTED' | 'REJECTED';
|
||||
};
|
||||
|
||||
const FALLBACK_VERIFICATIONS: VerificationRecord[] = [
|
||||
{ id: 'v1', name: 'Identity Check - Arun Kumar', applicantName: 'Arun Kumar', userType: 'PROFESSIONAL', verificationType: 'IDENTITY', submittedDate: '2026-03-25', documentsCount: 2, assignedVerifier: 'Suresh Menon', priority: 'HIGH', status: 'PENDING', updatedAt: '2026-03-25' },
|
||||
{ id: 'v2', name: 'Business Verification - Tech Solutions', applicantName: 'Tech Solutions', userType: 'COMPANY', verificationType: 'BUSINESS', submittedDate: '2026-03-24', documentsCount: 5, assignedVerifier: 'Rekha Nair', priority: 'MEDIUM', status: 'IN_REVIEW', updatedAt: '2026-03-26' },
|
||||
{ id: 'v3', name: 'Profile Review - Priya Sharma', applicantName: 'Priya Sharma', userType: 'CUSTOMER', verificationType: 'PROFILE', submittedDate: '2026-03-26', documentsCount: 1, assignedVerifier: 'Unassigned', priority: 'LOW', status: 'PENDING', updatedAt: '2026-03-26' },
|
||||
{ id: 'v4', name: 'Mixed Verification - Deepak Verma', applicantName: 'Deepak Verma', userType: 'JOBSEEKER', verificationType: 'MIXED', submittedDate: '2026-03-23', documentsCount: 4, assignedVerifier: 'Anita Pillai', priority: 'HIGH', status: 'RE_UPLOAD_REQUESTED', updatedAt: '2026-03-25' },
|
||||
{ id: 'v5', name: 'Document Audit - Manoj Iyer', applicantName: 'Manoj Iyer', userType: 'PROFESSIONAL', verificationType: 'DOCUMENT', submittedDate: '2026-03-22', documentsCount: 3, assignedVerifier: 'Arun Kumar', priority: 'MEDIUM', status: 'VERIFIED', updatedAt: '2026-03-24' },
|
||||
];
|
||||
|
||||
function StatusBadge(props: { status: string }) {
|
||||
const getColors = () => {
|
||||
switch (props.status) {
|
||||
case 'VERIFIED': return { border: '#B7E4C7', bg: '#DEF7E8', text: '#0B8A4A', dot: '#0B8A4A' };
|
||||
case 'IN_REVIEW': return { border: '#F6D78F', bg: '#FFF3D6', text: '#B7791F', dot: '#B7791F' };
|
||||
case 'PENDING': return { border: '#D1D5DB', bg: '#F3F4F6', text: '#4B5563', dot: '#9CA3AF' };
|
||||
case 'RE_UPLOAD_REQUESTED': return { border: '#FDE68A', bg: '#FEF3C7', text: '#D97706', dot: '#D97706' };
|
||||
case 'FLAGGED': return { border: '#FECACA', bg: '#FEF2F2', text: '#DC2626', dot: '#DC2626' };
|
||||
case 'REJECTED': return { border: '#FECACA', bg: '#FEF2F2', text: '#DC2626', dot: '#DC2626' };
|
||||
default: return { border: '#D1D5DB', bg: '#F3F4F6', text: '#4B5563', dot: '#9CA3AF' };
|
||||
}
|
||||
};
|
||||
const colors = getColors();
|
||||
const label = props.status.split('_').map(w => w.charAt(0) + w.slice(1).toLowerCase()).join(' ');
|
||||
|
||||
return (
|
||||
<span style={`display:inline-flex;align-items:center;border-radius:9999px;border:1px solid ${colors.border};background:${colors.bg};color:${colors.text};padding:2px 10px;font-size:12px;font-weight:500`}>
|
||||
<span style={`display:inline-block;width:6px;height:6px;border-radius:50%;background:${colors.dot};margin-right:5px;flex-shrink:0`} />
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function PriorityBadge(props: { priority: string }) {
|
||||
const color = props.priority === 'HIGH' ? '#DC2626' : props.priority === 'MEDIUM' ? '#F59E0B' : '#16A34A';
|
||||
return (
|
||||
<span style={`display:inline-flex;align-items:center;gap:4px;font-size:12px;font-weight:600;color:${color}`}>
|
||||
<span style={`width:6px;height:6px;border-radius:50%;background:${color}`} />
|
||||
{props.priority}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default function VerificationManagementPage() {
|
||||
const [view, setView] = createSignal<'list' | 'form'>('list');
|
||||
const [listTab, setListTab] = createSignal<'all' | 'create' | 'view'>('all');
|
||||
const [detailTab, setDetailTab] = createSignal<'overview' | 'documents' | 'checklist' | 'logs'>('overview');
|
||||
|
||||
const [search, setSearch] = createSignal('');
|
||||
const [rows, setRows] = createSignal<VerificationRecord[]>([]);
|
||||
const [viewingCase, setViewingCase] = createSignal<VerificationRecord | null>(null);
|
||||
const [openMenuId, setOpenMenuId] = createSignal<string | null>(null);
|
||||
|
||||
const [statusFilter, setStatusFilter] = createSignal<'all' | 'pending' | 'flagged'>('all');
|
||||
const [sortBy, setSortBy] = createSignal<'submitted_desc' | 'submitted_asc' | 'priority_desc' | 'priority_asc'>('submitted_desc');
|
||||
const [sortMenuOpen, setSortMenuOpen] = createSignal(false);
|
||||
const [filterMenuOpen, setFilterMenuOpen] = createSignal(false);
|
||||
|
||||
const [ruleName, setRuleName] = createSignal('');
|
||||
const [ruleUserType, setRuleUserType] = createSignal<VerificationRecord['userType']>('PROFESSIONAL');
|
||||
const [ruleVerificationType, setRuleVerificationType] = createSignal<VerificationRecord['verificationType']>('IDENTITY');
|
||||
const [ruleActive, setRuleActive] = createSignal(true);
|
||||
const [formError, setFormError] = createSignal('');
|
||||
|
||||
type VerificationRule = {
|
||||
id: string;
|
||||
name: string;
|
||||
userType: VerificationRecord['userType'];
|
||||
verificationType: VerificationRecord['verificationType'];
|
||||
status: 'ACTIVE' | 'INACTIVE';
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
const [rules, setRules] = createSignal<VerificationRule[]>([
|
||||
{ id: 'vr1', name: 'Professional Identity Rule', userType: 'PROFESSIONAL', verificationType: 'IDENTITY', status: 'ACTIVE', updatedAt: '2026-03-20' },
|
||||
{ id: 'vr2', name: 'Company Business Verification', userType: 'COMPANY', verificationType: 'BUSINESS', status: 'ACTIVE', updatedAt: '2026-03-18' },
|
||||
{ id: 'vr3', name: 'Jobseeker Profile Check', userType: 'JOBSEEKER', verificationType: 'PROFILE', status: 'INACTIVE', updatedAt: '2026-03-12' },
|
||||
]);
|
||||
|
||||
const load = async () => {
|
||||
setRows(FALLBACK_VERIFICATIONS);
|
||||
};
|
||||
|
||||
onMount(() => void load());
|
||||
|
||||
const formatDate = (v?: string) => {
|
||||
const s = v || '';
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(s)) return s;
|
||||
return s.slice(0, 10) || '—';
|
||||
};
|
||||
|
||||
const filteredRows = createMemo(() => {
|
||||
let list = rows();
|
||||
const f = statusFilter();
|
||||
if (f === 'pending') list = list.filter((r) => r.status === 'PENDING' || r.status === 'IN_REVIEW');
|
||||
if (f === 'flagged') list = list.filter((r) => r.status === 'FLAGGED');
|
||||
|
||||
const q = search().trim().toLowerCase();
|
||||
if (q) {
|
||||
list = list.filter((r) =>
|
||||
String(r.applicantName || '').toLowerCase().includes(q)
|
||||
|| String(r.id || '').toLowerCase().includes(q)
|
||||
|| String(r.verificationType || '').toLowerCase().includes(q)
|
||||
);
|
||||
}
|
||||
|
||||
const sorted = [...list];
|
||||
const mode = sortBy();
|
||||
const priorityRank = (p: VerificationRecord['priority']) => (p === 'HIGH' ? 3 : p === 'MEDIUM' ? 2 : 1);
|
||||
sorted.sort((a, b) => {
|
||||
const ad = Date.parse(String(a.submittedDate || a.updatedAt || '')) || 0;
|
||||
const bd = Date.parse(String(b.submittedDate || b.updatedAt || '')) || 0;
|
||||
if (mode === 'submitted_asc') return ad - bd;
|
||||
if (mode === 'priority_desc') return priorityRank(b.priority) - priorityRank(a.priority);
|
||||
if (mode === 'priority_asc') return priorityRank(a.priority) - priorityRank(b.priority);
|
||||
return bd - ad;
|
||||
});
|
||||
|
||||
return sorted;
|
||||
});
|
||||
|
||||
const openView = (row: VerificationRecord) => {
|
||||
setViewingCase(row);
|
||||
setDetailTab('overview');
|
||||
setListTab('view');
|
||||
setOpenMenuId(null);
|
||||
};
|
||||
|
||||
const resetRuleForm = () => {
|
||||
setRuleName('');
|
||||
setRuleUserType('PROFESSIONAL');
|
||||
setRuleVerificationType('IDENTITY');
|
||||
setRuleActive(true);
|
||||
setFormError('');
|
||||
};
|
||||
|
||||
const openCreate = () => {
|
||||
resetRuleForm();
|
||||
setListTab('create');
|
||||
setView('form');
|
||||
};
|
||||
|
||||
const saveRule = () => {
|
||||
if (!ruleName().trim()) {
|
||||
setFormError('Rule name is required.');
|
||||
return;
|
||||
}
|
||||
const now = new Date().toISOString().slice(0, 10);
|
||||
const id = `vr_${Math.random().toString(16).slice(2)}`;
|
||||
setRules((prev) => [
|
||||
{ id, name: ruleName().trim(), userType: ruleUserType(), verificationType: ruleVerificationType(), status: ruleActive() ? 'ACTIVE' : 'INACTIVE', updatedAt: now },
|
||||
...prev,
|
||||
]);
|
||||
setView('list');
|
||||
setListTab('all');
|
||||
resetRuleForm();
|
||||
};
|
||||
|
||||
return (
|
||||
<AdminShell>
|
||||
<div class="flex flex-col -mx-6 -mt-6 min-h-full">
|
||||
<div class="bg-white border-b border-gray-200 px-6 py-4">
|
||||
<h1 class="text-xl font-semibold text-gray-900">Approval Management</h1>
|
||||
<p class="text-sm text-gray-500 mt-0.5">
|
||||
Admin review now lives under Approval Management. Verification remains a user-facing status concept.
|
||||
</p>
|
||||
<div class="w-full space-y-6 pb-8">
|
||||
|
||||
{/* Page header */}
|
||||
<div style="margin-bottom: 1.5rem">
|
||||
<h1 class="text-[28px] font-bold leading-tight text-[#111827]">Verification Management</h1>
|
||||
<p class="mt-1 text-[14px] text-[#6B7280]">Manage user identity and business verification workflows</p>
|
||||
</div>
|
||||
<div class="flex-1 p-6">
|
||||
<div class="table-card">
|
||||
<p class="notice" style="margin:0">
|
||||
Use Approval Management to review companies, customers, candidates, and professional submissions.
|
||||
</p>
|
||||
<div class="actions">
|
||||
<A class="btn-primary" href="/admin/approval">Open Approval Management</A>
|
||||
|
||||
{/* ── LIST VIEW ── */}
|
||||
<Show when={view() === 'list'}>
|
||||
<div style="margin-top:24px;display:flex;align-items:center;gap:24px;border-bottom:1px solid #E5E7EB">
|
||||
{([
|
||||
{ key: 'all', label: 'All Verifications', action: () => { setListTab('all'); setStatusFilter('all'); } },
|
||||
{ key: 'create', label: 'Create Rule', action: () => openCreate() },
|
||||
{ key: 'view', label: 'View Verification', action: () => { setListTab('view'); } },
|
||||
] as const).map((tab) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={tab.action}
|
||||
style={`padding-bottom:12px;font-size:14px;font-weight:500;background:none;border:none;cursor:pointer;${listTab() === tab.key ? 'color:#FF5E13;border-bottom:2px solid #FF5E13;margin-bottom:-1px' : 'color:#6B7280'}`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Show when={listTab() === 'view'}>
|
||||
<Show when={!viewingCase()}>
|
||||
<div style="margin-top:24px;border-radius:16px;border:1px solid #E5E7EB;background:white;padding:48px 24px;text-align:center">
|
||||
<p style="font-size:15px;font-weight:600;color:#111827">No verification selected</p>
|
||||
<p style="margin-top:6px;font-size:13px;color:#6B7280">Click the <strong>⋮</strong> menu on any row and choose <strong>View Verification</strong>.</p>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={viewingCase()}>
|
||||
<div style="margin-top:24px;border-radius:16px;border:1px solid #E5E7EB;background:white;box-shadow:0 1px 4px rgba(0,0,0,0.06);overflow:hidden">
|
||||
<div style="padding:20px 24px;border-bottom:1px solid #E5E7EB;display:flex;align-items:center;justify-content:space-between">
|
||||
<div>
|
||||
<div style="display:flex;align-items:center;gap:12px">
|
||||
<h2 style="font-size:18px;font-weight:700;color:#111827">{viewingCase()!.applicantName}</h2>
|
||||
<StatusBadge status={viewingCase()!.status} />
|
||||
<PriorityBadge priority={viewingCase()!.priority} />
|
||||
</div>
|
||||
<p style="margin-top:2px;font-size:13px;color:#6B7280">ID: {viewingCase()!.id} • {viewingCase()!.verificationType} • Submitted: {formatDate(viewingCase()!.submittedDate)}</p>
|
||||
</div>
|
||||
<div style="display:flex;gap:10px">
|
||||
<button type="button" style="height:36px;border-radius:8px;background:#0D0D2A;padding:0 16px;font-size:13px;font-weight:600;color:white;border:none;cursor:pointer">Mark Verified</button>
|
||||
<button type="button" style="height:36px;border-radius:8px;border:1px solid #E5E7EB;background:white;padding:0 16px;font-size:13px;font-weight:600;color:#374151;cursor:pointer">Request Re-upload</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;align-items:center;gap:4px;border-bottom:1px solid #E5E7EB;padding:0 24px;background:#FAFAFA">
|
||||
{(['overview', 'documents', 'checklist', 'logs'] as const).map((tab, i) => {
|
||||
const labels = ['Overview', 'Documents', 'Checklist', 'Activity Logs'];
|
||||
const active = () => detailTab() === tab;
|
||||
return (
|
||||
<button type="button" onClick={() => setDetailTab(tab)} style={`position:relative;padding:14px 12px;font-size:13px;font-weight:600;background:none;border:none;cursor:pointer;color:${active() ? '#FF5E13' : '#6B7280'}`}>
|
||||
{labels[i]}
|
||||
<Show when={active()}><span style="position:absolute;left:0;right:0;bottom:0;height:2px;background:#FF5E13;border-radius:2px 2px 0 0" /></Show>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div style="padding:24px">
|
||||
<Show when={detailTab() === 'overview'}>
|
||||
<div style="display:grid;grid-template-columns:2fr 1fr;gap:24px">
|
||||
<div style="display:flex;flex-direction:column;gap:24px">
|
||||
<div style="border:1px solid #E5E7EB;border-radius:12px;padding:20px">
|
||||
<h3 style="font-size:14px;font-weight:700;color:#111827;margin-bottom:16px">Case Summary</h3>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px">
|
||||
<div><p style="font-size:11px;color:#9CA3AF">Applicant Name</p><p style="font-size:14px;font-weight:600;color:#111827">{viewingCase()!.applicantName}</p></div>
|
||||
<div><p style="font-size:11px;color:#9CA3AF">User Type</p><p style="font-size:14px;font-weight:600;color:#111827">{viewingCase()!.userType}</p></div>
|
||||
<div><p style="font-size:11px;color:#9CA3AF">Assigned Verifier</p><p style="font-size:14px;font-weight:600;color:#111827">{viewingCase()!.assignedVerifier}</p></div>
|
||||
<div><p style="font-size:11px;color:#9CA3AF">Documents</p><p style="font-size:14px;font-weight:600;color:#111827">{Number(viewingCase()!.documentsCount || 0)}</p></div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="border:1px solid #E5E7EB;border-radius:12px;padding:20px">
|
||||
<h3 style="font-size:14px;font-weight:700;color:#111827;margin-bottom:16px">Verification Tracker</h3>
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;position:relative">
|
||||
<div style="position:absolute;top:10px;left:0;right:0;height:2px;background:#E5E7EB;z-index:1" />
|
||||
<div style="position:absolute;top:10px;left:0;width:50%;height:2px;background:#FF5E13;z-index:2" />
|
||||
{[
|
||||
{ l: 'Submitted', active: true },
|
||||
{ l: 'In Review', active: true },
|
||||
{ l: 'Verified', active: false },
|
||||
].map((step) => (
|
||||
<div style="position:relative;z-index:3;text-align:center">
|
||||
<div style={`width:20px;height:20px;border-radius:50%;background:${step.active ? '#FF5E13' : 'white'};border:2px solid ${step.active ? '#FF5E13' : '#E5E7EB'};margin:0 auto`} />
|
||||
<p style="font-size:11px;margin-top:4px;color:#111827;font-weight:600">{step.l}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="border:1px solid #E5E7EB;border-radius:12px;padding:20px;background:#F9FAFB">
|
||||
<h3 style="font-size:14px;font-weight:700;color:#111827;margin-bottom:16px">Notes</h3>
|
||||
<textarea placeholder="Add internal note..." style="width:100%;height:100px;border-radius:8px;border:1px solid #E5E7EB;padding:10px;font-size:13px;resize:none;margin-bottom:12px" />
|
||||
<button type="button" style="width:100%;height:34px;background:#0D0D2A;color:white;border-radius:8px;font-size:12px;font-weight:600;border:none">Add Note</button>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={detailTab() === 'documents'}>
|
||||
<div style="border:1px solid #E5E7EB;border-radius:12px;overflow:hidden">
|
||||
<table style="width:100%;border-collapse:collapse">
|
||||
<thead style="background:#F9FAFB">
|
||||
<tr style="text-align:left">
|
||||
<th style="padding:12px 16px;font-size:11px;font-weight:600;color:#6B7280;text-transform:uppercase">Document Name</th>
|
||||
<th style="padding:12px 16px;font-size:11px;font-weight:600;color:#6B7280;text-transform:uppercase">Status</th>
|
||||
<th style="padding:12px 16px;font-size:11px;font-weight:600;color:#6B7280;text-transform:uppercase">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<For each={[
|
||||
{ n: 'Passport / National ID', s: 'VERIFIED' },
|
||||
{ n: 'Address Proof (Utility Bill)', s: 'PENDING' },
|
||||
]}>
|
||||
{(doc) => (
|
||||
<tr style="border-top:1px solid #E5E7EB">
|
||||
<td style="padding:12px 16px;font-size:13px;font-weight:600;color:#111827">{doc.n}</td>
|
||||
<td style="padding:12px 16px"><StatusBadge status={doc.s} /></td>
|
||||
<td style="padding:12px 16px;display:flex;gap:8px">
|
||||
<button type="button" style="font-size:12px;color:#FF5E13;background:none;border:none;cursor:pointer;font-weight:600">Preview</button>
|
||||
<button type="button" style="font-size:12px;color:#0D0D2A;background:none;border:none;cursor:pointer;font-weight:600">Approve</button>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</For>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={detailTab() === 'checklist'}>
|
||||
<div style="display:flex;flex-direction:column;gap:12px">
|
||||
{[
|
||||
'Identity matches provided documents',
|
||||
'Address proof is valid and recent',
|
||||
'Business registration is authentic',
|
||||
'Contact information is verified',
|
||||
].map((item) => (
|
||||
<label style="display:flex;align-items:center;gap:12px;padding:12px;border:1px solid #E5E7EB;border-radius:10px;cursor:pointer">
|
||||
<input type="checkbox" style="width:16px;height:16px;accent-color:#FF5E13" />
|
||||
<span style="font-size:13px;color:#111827;font-weight:500">{item}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={detailTab() === 'logs'}>
|
||||
<div style="display:flex;flex-direction:column;gap:16px">
|
||||
<div style="display:flex;gap:12px">
|
||||
<div style="width:8px;height:8px;border-radius:50%;background:#FF5E13;margin-top:4px" />
|
||||
<div>
|
||||
<p style="font-size:13px;font-weight:600;color:#111827">Case Review Started</p>
|
||||
<p style="font-size:12px;color:#6B7280">Verifier started reviewing documents • 2 hours ago</p>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;gap:12px">
|
||||
<div style="width:8px;height:8px;border-radius:50%;background:#E5E7EB;margin-top:4px" />
|
||||
<div>
|
||||
<p style="font-size:13px;font-weight:600;color:#111827">Verification Request Submitted</p>
|
||||
<p style="font-size:12px;color:#6B7280">System received applicant data • 1 day ago</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;align-items:center;gap:10px;padding:14px 24px;border-top:1px solid #E5E7EB">
|
||||
<button type="button" onClick={() => { setViewingCase(null); setListTab('all'); }} style="height:36px;border-radius:8px;border:1px solid #E5E7EB;background:white;padding:0 16px;font-size:13px;font-weight:600;color:#374151;cursor:pointer">Back to List</button>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</Show>
|
||||
|
||||
<div style={`display:${listTab() === 'view' ? 'none' : 'block'}`}>
|
||||
<div style="margin-top:1.5rem;margin-left:-24px;margin-right:-24px;border-radius:0;border-left:none;border-right:none;overflow:hidden;border-top:1px solid #E5E7EB;border-bottom:1px solid #E5E7EB;background:white;box-shadow:0 1px 3px rgba(0,0,0,0.06)">
|
||||
<div style="display:flex;align-items:center;gap:8px;padding:14px 20px;border-bottom:1px solid #F3F4F6">
|
||||
<input
|
||||
value={search()}
|
||||
onInput={(e) => setSearch(e.currentTarget.value)}
|
||||
placeholder="Search verifications..."
|
||||
style="height:34px;flex:1;border-radius:8px;border:1px solid #E5E7EB;background:white;padding:0 12px;font-size:13px;color:#111827;outline:none"
|
||||
/>
|
||||
<div style="position:relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setSortMenuOpen((v) => !v); setFilterMenuOpen(false); }}
|
||||
style="display:inline-flex;height:34px;align-items:center;gap:6px;border-radius:8px;border:1px solid #E5E7EB;background:white;padding:0 12px;font-size:12px;font-weight:500;color:#374151;cursor:pointer"
|
||||
>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M7 4v13"/><path d="m3 13 4 4 4-4"/><path d="M17 20V7"/><path d="m21 11-4-4-4 4"/></svg>
|
||||
Sort
|
||||
</button>
|
||||
<Show when={sortMenuOpen()}>
|
||||
<div style="position:absolute;left:0;top:38px;z-index:30;min-width:220px;border-radius:12px;border:1px solid #E5E7EB;background:white;padding:6px;box-shadow:0 4px 16px rgba(0,0,0,0.1)">
|
||||
{(['submitted_desc', 'submitted_asc', 'priority_desc', 'priority_asc'] as const).map((s, i) => (
|
||||
<button type="button" onClick={() => { setSortBy(s); setSortMenuOpen(false); }} style={`display:block;width:100%;border-radius:8px;padding:8px 12px;text-align:left;font-size:13px;background:none;border:none;cursor:pointer;color:${sortBy() === s ? '#FF5E13' : '#374151'};background:${sortBy() === s ? '#FFF1EB' : 'transparent'}`}>
|
||||
{['Submitted (Newest)', 'Submitted (Oldest)', 'Priority (High-Low)', 'Priority (Low-High)'][i]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
<div style="position:relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setFilterMenuOpen((v) => !v); setSortMenuOpen(false); }}
|
||||
style="display:inline-flex;height:34px;align-items:center;gap:6px;border-radius:8px;border:1px solid #E5E7EB;background:white;padding:0 12px;font-size:12px;font-weight:500;color:#374151;cursor:pointer"
|
||||
>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 5h18M6 12h12M10 19h4"/></svg>
|
||||
Filters
|
||||
</button>
|
||||
<Show when={filterMenuOpen()}>
|
||||
<div style="position:absolute;left:0;top:38px;z-index:30;min-width:200px;border-radius:12px;border:1px solid #E5E7EB;background:white;padding:6px;box-shadow:0 4px 16px rgba(0,0,0,0.1)">
|
||||
{(['all', 'pending', 'flagged'] as const).map((s) => (
|
||||
<button type="button" onClick={() => { setStatusFilter(s); setFilterMenuOpen(false); }} style={`display:block;width:100%;border-radius:8px;padding:8px 12px;text-align:left;font-size:13px;background:none;border:none;cursor:pointer;color:${statusFilter() === s ? '#FF5E13' : '#374151'};background:${statusFilter() === s ? '#FFF1EB' : 'transparent'}`}>
|
||||
{s === 'all' ? 'All Cases' : s === 'pending' ? 'Pending Review' : 'Flagged'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
<button type="button" style="display:inline-flex;height:34px;align-items:center;gap:6px;border-radius:8px;background:#0D0D2A;padding:0 12px;font-size:12px;font-weight:600;color:white;border:none;cursor:pointer">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
|
||||
Export
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full">
|
||||
<thead>
|
||||
<tr style="background:#0D0D2A;text-align:left">
|
||||
<th style="padding:10px 20px;font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:0.05em;color:#FFFFFF;white-space:nowrap">Verification ID</th>
|
||||
<th style="padding:10px 20px;font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:0.05em;color:#FFFFFF;white-space:nowrap">Applicant</th>
|
||||
<th style="padding:10px 20px;font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:0.05em;color:#FFFFFF;white-space:nowrap">Type</th>
|
||||
<th style="padding:10px 20px;font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:0.05em;color:#FFFFFF;white-space:nowrap">Docs</th>
|
||||
<th style="padding:10px 20px;font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:0.05em;color:#FFFFFF;white-space:nowrap">Priority</th>
|
||||
<th style="padding:10px 20px;font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:0.05em;color:#FFFFFF;white-space:nowrap">Status</th>
|
||||
<th style="padding:10px 20px;font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:0.05em;color:#FFFFFF;white-space:nowrap">Submitted</th>
|
||||
<th style="padding:10px 20px;font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:0.05em;color:#FFFFFF;white-space:nowrap">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<For each={filteredRows()}>
|
||||
{(row) => (
|
||||
<tr style="border-bottom:1px solid #F3F4F6" class="hover:bg-[#FAFAFA] transition-colors">
|
||||
<td style="padding:12px 20px;font-size:12px;font-family:monospace;color:#6B7280">{row.id}</td>
|
||||
<td style="padding:12px 20px">
|
||||
<p style="font-size:14px;font-weight:600;color:#111827">{row.applicantName}</p>
|
||||
<p style="font-size:11px;color:#6B7280">{row.userType}</p>
|
||||
</td>
|
||||
<td style="padding:12px 20px;font-size:13px;color:#6B7280">{row.verificationType}</td>
|
||||
<td style="padding:12px 20px;font-size:13px;color:#6B7280">{Number(row.documentsCount || 0)} docs</td>
|
||||
<td style="padding:12px 20px"><PriorityBadge priority={row.priority} /></td>
|
||||
<td style="padding:12px 20px"><StatusBadge status={row.status} /></td>
|
||||
<td style="padding:12px 20px;font-size:13px;color:#6B7280">{formatDate(row.submittedDate || row.updatedAt)}</td>
|
||||
<td style="padding:12px 20px;position:relative">
|
||||
<button type="button" onClick={() => setOpenMenuId(openMenuId() === row.id ? null : row.id)} style="display:inline-flex;height:32px;width:32px;align-items:center;justify-content:center;border-radius:8px;color:#9CA3AF;background:none;border:none;cursor:pointer">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="5" r="1.5"/><circle cx="12" cy="12" r="1.5"/><circle cx="12" cy="19" r="1.5"/></svg>
|
||||
</button>
|
||||
<Show when={openMenuId() === row.id}>
|
||||
<div style="position:absolute;right:20px;top:44px;z-index:20;width:190px;border-radius:12px;border:1px solid #E5E7EB;background:white;padding:6px;box-shadow:0 4px 20px rgba(0,0,0,0.12)">
|
||||
<button type="button" onClick={() => openView(row)} style="display:flex;width:100%;align-items:center;gap:10px;border-radius:8px;padding:8px 12px;font-size:13px;color:#374151;background:none;border:none;cursor:pointer;text-align:left">View Verification</button>
|
||||
<button type="button" style="display:flex;width:100%;align-items:center;gap:10px;border-radius:8px;padding:8px 12px;font-size:13px;color:#374151;background:none;border:none;cursor:pointer;text-align:left">Mark Verified</button>
|
||||
<button type="button" style="display:flex;width:100%;align-items:center;gap:10px;border-radius:8px;padding:8px 12px;font-size:13px;color:#DC2626;background:none;border:none;cursor:pointer;text-align:left">Flag Case</button>
|
||||
</div>
|
||||
</Show>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</For>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={view() === 'form'}>
|
||||
<div style="margin-top:24px;display:flex;align-items:center;gap:24px;border-bottom:1px solid #E5E7EB">
|
||||
<button type="button" onClick={() => { setView('list'); setListTab('all'); }} style="padding-bottom:12px;font-size:14px;font-weight:500;color:#6B7280;background:none;border:none;cursor:pointer">
|
||||
All Verifications
|
||||
</button>
|
||||
<button type="button" style="padding-bottom:12px;font-size:14px;font-weight:500;color:#FF5E13;border:none;border-bottom:2px solid #FF5E13;background:none;cursor:pointer;margin-bottom:-1px">
|
||||
Create Rule
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style="margin-top:24px;border-radius:16px;border:1px solid #E5E7EB;background:white;box-shadow:0 1px 4px rgba(0,0,0,0.06);overflow:hidden">
|
||||
<div style="padding:24px">
|
||||
<Show when={formError()}>
|
||||
<div style="margin-bottom:20px;border-radius:10px;border:1px solid #FECACA;background:#FEF2F2;padding:12px 16px;font-size:13px;color:#B91C1C">
|
||||
{formError()}
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:20px">
|
||||
<label style="display:block">
|
||||
<span style="font-size:13px;font-weight:600;color:#374151">Rule Name<span style="margin-left:2px;color:#FF5E13">*</span></span>
|
||||
<input value={ruleName()} onInput={(e) => setRuleName(e.currentTarget.value)} placeholder="e.g. Professional Identity Review" style="display:block;margin-top:6px;height:40px;width:100%;border-radius:10px;border:1px solid #E5E7EB;background:white;padding:0 14px;font-size:13px;color:#111827;outline:none;box-sizing:border-box" />
|
||||
</label>
|
||||
<label style="display:block">
|
||||
<span style="font-size:13px;font-weight:600;color:#374151">User Type</span>
|
||||
<select value={ruleUserType()} onChange={(e) => setRuleUserType(e.currentTarget.value as VerificationRecord['userType'])} style="display:block;margin-top:6px;height:40px;width:100%;border-radius:10px;border:1px solid #E5E7EB;background:white;padding:0 12px;font-size:13px;color:#111827;outline:none;box-sizing:border-box">
|
||||
{(['CUSTOMER', 'PROFESSIONAL', 'COMPANY', 'JOBSEEKER'] as const).map((t) => <option value={t}>{t}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:20px;margin-top:20px">
|
||||
<label style="display:block">
|
||||
<span style="font-size:13px;font-weight:600;color:#374151">Verification Type</span>
|
||||
<select value={ruleVerificationType()} onChange={(e) => setRuleVerificationType(e.currentTarget.value as VerificationRecord['verificationType'])} style="display:block;margin-top:6px;height:40px;width:100%;border-radius:10px;border:1px solid #E5E7EB;background:white;padding:0 12px;font-size:13px;color:#111827;outline:none;box-sizing:border-box">
|
||||
{(['IDENTITY', 'BUSINESS', 'PROFILE', 'DOCUMENT', 'MIXED'] as const).map((t) => <option value={t}>{t}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<div>
|
||||
<p style="font-size:13px;font-weight:600;color:#374151">Rule Status</p>
|
||||
<div style="margin-top:8px;display:flex;gap:10px">
|
||||
<button type="button" onClick={() => setRuleActive(true)} style={`height:38px;border-radius:10px;padding:0 20px;font-size:13px;font-weight:600;cursor:pointer;border:1px solid ${ruleActive() ? '#FF5E13' : '#E5E7EB'};background:${ruleActive() ? '#FFF3EE' : 'white'};color:${ruleActive() ? '#FF5E13' : '#6B7280'}`}>Active</button>
|
||||
<button type="button" onClick={() => setRuleActive(false)} style={`height:38px;border-radius:10px;padding:0 20px;font-size:13px;font-weight:600;cursor:pointer;border:1px solid ${!ruleActive() ? '#FF5E13' : '#E5E7EB'};background:${!ruleActive() ? '#FFF3EE' : 'white'};color:${!ruleActive() ? '#FF5E13' : '#6B7280'}`}>Inactive</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-top:24px;display:flex;justify-content:flex-end;gap:10px">
|
||||
<button type="button" onClick={() => { setView('list'); setListTab('all'); resetRuleForm(); }} style="height:40px;border-radius:10px;border:1px solid #E5E7EB;background:white;padding:0 16px;font-size:13px;font-weight:600;color:#374151;cursor:pointer">Cancel</button>
|
||||
<button type="button" onClick={saveRule} style="height:40px;border-radius:10px;background:#0D0D2A;padding:0 18px;font-size:13px;font-weight:700;color:white;border:none;cursor:pointer">Save Rule</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-top:24px;border:1px solid #E5E7EB;border-radius:16px;background:white;overflow:hidden">
|
||||
<table style="width:100%;border-collapse:collapse">
|
||||
<thead style="background:#0D0D2A">
|
||||
<tr style="text-align:left">
|
||||
<th style="padding:12px 20px;font-size:11px;font-weight:600;color:white;text-transform:uppercase">Rule Name</th>
|
||||
<th style="padding:12px 20px;font-size:11px;font-weight:600;color:white;text-transform:uppercase">User Type</th>
|
||||
<th style="padding:12px 20px;font-size:11px;font-weight:600;color:white;text-transform:uppercase">Verification Type</th>
|
||||
<th style="padding:12px 20px;font-size:11px;font-weight:600;color:white;text-transform:uppercase">Status</th>
|
||||
<th style="padding:12px 20px;font-size:11px;font-weight:600;color:white;text-transform:uppercase">Updated</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<For each={rules()}>
|
||||
{(rule) => (
|
||||
<tr style="border-top:1px solid #E5E7EB">
|
||||
<td style="padding:14px 20px;font-size:14px;font-weight:600;color:#111827">{rule.name}</td>
|
||||
<td style="padding:14px 20px;font-size:13px;color:#6B7280">{rule.userType}</td>
|
||||
<td style="padding:14px 20px;font-size:13px;color:#6B7280">{rule.verificationType}</td>
|
||||
<td style="padding:14px 20px"><StatusBadge status={rule.status} /></td>
|
||||
<td style="padding:14px 20px;font-size:13px;color:#6B7280">{formatDate(rule.updatedAt)}</td>
|
||||
</tr>
|
||||
)}
|
||||
</For>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
</div>
|
||||
</AdminShell>
|
||||
);
|
||||
|
|
|
|||
2
src/routes/admin/verification/index.tsx
Normal file
2
src/routes/admin/verification/index.tsx
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export { default } from '../verification';
|
||||
|
||||
Loading…
Add table
Reference in a new issue