Align admin management tables and flows with department pattern

This commit is contained in:
Ashwin Kumar 2026-04-02 00:42:42 +02:00
parent ad8a17a766
commit 82036a0608
11 changed files with 4674 additions and 1871 deletions

File diff suppressed because it is too large Load diff

View file

@ -57,6 +57,7 @@ const ONBOARDING_SCHEMA_OPTIONS = [
'social_media_manager_onboarding_v1',
'fitness_trainer_onboarding_v1',
'catering_service_onboarding_v1',
'ugc_content_creator_onboarding_v1',
'default_onboarding_v1',
];
@ -142,7 +143,7 @@ const DEFAULT_PRESETS: Record<string, ExternalRoleConfig> = {
},
customer: {
roleKey: 'customer',
displayName: 'Customer',
displayName: 'Service Seeker',
vertical: 'marketplace',
roleCategory: 'consumer',
enabledModules: ['dashboard', 'profile', 'requirements', 'marketplace', 'wallet', 'notifications', 'settings'],
@ -163,6 +164,21 @@ const DEFAULT_PRESETS: Record<string, ExternalRoleConfig> = {
runtimeConfigVersion: 1,
isActive: true,
},
ugc_content_creator: {
roleKey: 'ugc_content_creator',
displayName: 'UGC Content Creator',
vertical: 'marketplace',
roleCategory: 'provider',
enabledModules: MARKETPLACE_PROVIDER_MODULES,
permissions: MARKETPLACE_PROVIDER_PERMISSIONS,
onboardingSchemaId: 'ugc_content_creator_onboarding_v1',
requiresOnboardingApproval: true,
requiresLeadApproval: true,
requiresJobApproval: false,
featureLimits: { maxOpenLeads: 10 },
runtimeConfigVersion: 1,
isActive: true,
},
photographer: {
roleKey: 'photographer',
displayName: 'Photographer',
@ -476,7 +492,7 @@ export default function ExternalRoleForm(props: ExternalRoleFormProps) {
<option value="job_seeker">Job Seeker</option>
</optgroup>
<optgroup label="Marketplace — Consumer">
<option value="customer">Customer</option>
<option value="customer">Service Seeker</option>
</optgroup>
<optgroup label="Marketplace — Providers">
<option value="photographer">Photographer</option>
@ -484,6 +500,7 @@ export default function ExternalRoleForm(props: ExternalRoleFormProps) {
<option value="tutor">Tutor</option>
<option value="developer">Developer</option>
<option value="video_editor">Video Editor</option>
<option value="ugc_content_creator">UGC Content Creator</option>
<option value="graphic_designer">Graphic Designer</option>
<option value="social_media_manager">Social Media Manager</option>
<option value="fitness_trainer">Fitness Trainer</option>

View file

@ -58,6 +58,7 @@ const ROLE_OPTIONS = [
'job_seeker',
'jobseeker',
'customer',
'service_seeker',
'professional',
'photographer',
'video_editor',
@ -68,6 +69,7 @@ const ROLE_OPTIONS = [
'makeup_artist',
'tutor',
'developer',
'ugc_content_creator',
];
function fallbackRoleOptions(): { value: string; label: string }[] {
@ -203,7 +205,7 @@ export function createDefaultFields(roleKey: string): OnboardingField[] {
'Professional role',
'select',
true,
['photographer', 'makeup_artist', 'tutor', 'developer', 'video_editor', 'graphic_designer', 'social_media_manager', 'fitness_trainer', 'catering_services'],
['photographer', 'makeup_artist', 'tutor', 'developer', 'video_editor', 'ugc_content_creator', 'graphic_designer', 'social_media_manager', 'fitness_trainer', 'catering_services'],
),
createField('full_name', 'Full name', 'text', true),
createField('experience', 'Experience', 'text', true),
@ -225,7 +227,7 @@ export function createDefaultFields(roleKey: string): OnboardingField[] {
'Service category',
'select',
true,
['photographer', 'makeup_artist', 'tutor', 'developer', 'video_editor', 'graphic_designer', 'social_media_manager', 'fitness_trainer', 'catering_services'],
['photographer', 'makeup_artist', 'tutor', 'developer', 'video_editor', 'ugc_content_creator', 'graphic_designer', 'social_media_manager', 'fitness_trainer', 'catering_services'],
),
createField('event_type', 'Event type', 'text', false),
createField('coverage_hours', 'Coverage hours', 'number', false),
@ -325,6 +327,19 @@ export function createDefaultFields(roleKey: string): OnboardingField[] {
createField('availability', 'Availability', 'select', true, ['weekdays', 'weekends', 'all_days']),
createField('identity_document', 'Identity Document', 'file', true),
],
ugc_content_creator: [
createField('full_name', 'Full name', 'text', true),
createField('email', 'Email', 'email', true),
createField('phone', 'Phone', 'tel', true),
createField('city', 'City', 'text', true),
createField('service_area', 'Area / Place', 'text', true),
createField('experience_years', 'Experience (Years)', 'number', false),
createField('specialization', 'UGC specialization', 'text', true),
createField('portfolio_url', 'Portfolio URL', 'url', false),
createField('price_range', 'Price range', 'text', true),
createField('availability', 'Availability', 'select', true, ['weekdays', 'weekends', 'all_days']),
createField('identity_document', 'Identity Document', 'file', true),
],
graphic_designer: [
createField('full_name', 'Full name', 'text', true),
createField('email', 'Email', 'email', true),
@ -377,7 +392,7 @@ export function createDefaultFields(roleKey: string): OnboardingField[] {
if (role === 'company') return company;
if (role === 'job_seeker' || role === 'jobseeker') return jobseeker;
if (role === 'customer') return customer;
if (role === 'customer' || role === 'service_seeker') return customer;
if (role === 'professional') return professionalBase;
if (specialistOverrides[role]) return specialistOverrides[role];

View file

@ -3,6 +3,7 @@ import { createMemo } from 'solid-js';
import AdminShell from '~/components/AdminShell';
import ApprovalManagementPage from './approval';
import VerificationManagementPage from './verification';
import UsersManagementPage from './users';
function toTitle(value: string): string {
return value
@ -46,6 +47,10 @@ export default function LegacyModuleShellPage() {
return <VerificationManagementPage />;
}
if (modulePath === 'users' || modulePath === 'users-management' || modulePath === 'user-management') {
return <UsersManagementPage />;
}
const moduleName = createMemo(() => toTitle(modulePath || 'Management'));
const legacyPath = createMemo(() => resolveLegacyPath(modulePath));
const legacyUrl = createMemo(() => `${LEGACY_ADMIN_ORIGIN}${legacyPath()}`);

View file

@ -3,10 +3,20 @@ import AdminShell from '~/components/AdminShell';
import type { CrudRecord } from '~/lib/admin/types';
const API = '/api/gateway';
const APPROVAL_QUEUE_STORAGE_KEY = 'nxtgauge_admin_approval_queue';
type ApprovalSubmittedField = { label: string; value: string };
type ApprovalDocument = {
id: string;
title: string;
type: 'IMAGE' | 'PDF';
url: string;
status: 'SUBMITTED' | 'MISSING' | 'INVALID';
};
type ApprovalRecord = CrudRecord & {
applicantName?: string;
approvalType: 'PROFILE' | 'BUSINESS' | 'JOB' | 'ORDER' | 'INVOICE' | 'COUPON' | 'DISCOUNT' | 'TAX' | 'ROLE' | 'REQUIREMENT';
approvalType: 'PROFILE' | 'BUSINESS' | 'JOB' | 'ORDER' | 'INVOICE' | 'COUPON' | 'DISCOUNT' | 'TAX' | 'ROLE' | 'REQUIREMENT' | 'PORTFOLIO';
userType: 'CUSTOMER' | 'PROFESSIONAL' | 'COMPANY' | 'JOBSEEKER';
roleTags?: string[];
primaryService?: string;
@ -16,12 +26,44 @@ type ApprovalRecord = CrudRecord & {
assignedApprover?: string;
priority: 'LOW' | 'MEDIUM' | 'HIGH' | 'CRITICAL';
status: 'PENDING' | 'IN_REVIEW' | 'APPROVED' | 'REJECTED' | 'ON_HOLD' | 'ESCALATED';
sourceKey?: string;
submittedFields?: ApprovalSubmittedField[];
documents?: ApprovalDocument[];
payload?: any;
};
type ApprovalQueueItem = {
id: string;
requestType:
| 'Profile Approval'
| 'Portfolio Approval'
| 'Company Approval'
| 'Job Seeker Approval'
| 'Service Seeker Profile Approval'
| 'Service Seeker Requirement'
| 'Job Approval';
applicantName: string;
roleLabel: string;
userType: 'PROFESSIONAL' | 'COMPANY' | 'JOBSEEKER' | 'CUSTOMER';
roleKey: string;
area: string;
submittedOn: string;
documents: ApprovalDocument[];
submittedFields: ApprovalSubmittedField[];
};
const toTitle = (value: string) => String(value || '')
.replace(/_/g, ' ')
.replace(/\b\w/g, (c) => c.toUpperCase());
const normalizeUserType = (value: unknown): ApprovalRecord['userType'] => {
const key = String(value || '').toUpperCase();
if (key.includes('COMPANY')) return 'COMPANY';
if (key.includes('JOB_SEEKER') || key.includes('JOBSEEKER')) return 'JOBSEEKER';
if (key.includes('CUSTOMER') || key.includes('SERVICE_SEEKER')) return 'CUSTOMER';
return 'PROFESSIONAL';
};
function extractRoleTags(source: any): string[] {
const values: string[] = [];
const pushValue = (value: unknown) => {
@ -48,6 +90,225 @@ function extractRoleTags(source: any): string[] {
return Array.from(new Set(values.map((v) => toTitle(v)))).slice(0, 4);
}
function extractSubmittedFields(source: any): ApprovalSubmittedField[] {
const payload = source || {};
const fullName = String(
payload.full_name
|| payload.fullName
|| [payload.first_name || payload.firstName, payload.last_name || payload.lastName].filter(Boolean).join(' ')
|| payload.company_name
|| payload.title
|| '—',
).trim();
const candidates: ApprovalSubmittedField[] = [
{ label: 'Name / Title', value: fullName || '—' },
{ label: 'Email', value: String(payload.email || payload.email_address || payload.emailAddress || '—') },
{ label: 'Mobile', value: String(payload.mobile || payload.mobile_number || payload.phone || payload.contact_number || '—') },
{ label: 'Role / Category', value: String(payload.role_key || payload.role || payload.category || payload.profession || payload.service_category || '—') },
{ label: 'Area', value: String(payload.area || payload.location || payload.city || 'Chennai') },
{ label: 'Place', value: String(payload.place || payload.locality || payload.city || 'Chennai') },
{ label: 'Description', value: String(payload.description || payload.about || payload.bio || '—') },
];
return candidates.filter((item) => item.value && item.value !== '—');
}
function extractDocuments(source: any): ApprovalDocument[] {
const raw = Array.isArray(source?.documents) ? source.documents : [];
if (!raw.length) {
const portfolioImages = Array.isArray(source?.portfolio_images)
? source.portfolio_images
: Array.isArray(source?.images)
? source.images
: Array.isArray(source?.gallery)
? source.gallery
: [];
if (!portfolioImages.length) return [];
return portfolioImages.slice(0, 6).map((asset: any, idx: number) => ({
id: String(asset.id || `portfolio-${idx + 1}`),
title: String(asset.title || asset.name || `Portfolio Image ${idx + 1}`),
type: 'IMAGE',
url: String(asset.url || '/nxtgauge-logo.png'),
status: 'SUBMITTED',
}));
}
return raw.slice(0, 12).map((doc: any, idx: number) => {
const statusRaw = String(doc.status || '').toUpperCase();
return {
id: String(doc.id || `doc-${idx + 1}`),
title: String(doc.title || doc.name || `Document ${idx + 1}`),
type: String(doc.type || '').toUpperCase().includes('PDF') ? 'PDF' : 'IMAGE',
url: String(doc.url || '/nxtgauge-logo.png'),
status: statusRaw === 'MISSING'
? 'MISSING'
: statusRaw === 'INVALID'
? 'INVALID'
: 'SUBMITTED',
};
});
}
const FALLBACK_APPROVAL_ROWS: ApprovalRecord[] = [
{
id: 'AP-P-92841',
name: 'Profile Approval - Sarah Jenkins (Demo)',
applicantName: 'Sarah Jenkins (Demo)',
approvalType: 'PROFILE',
userType: 'PROFESSIONAL',
roleTags: ['Photographer'],
primaryService: 'Photography',
area: 'T. Nagar, Chennai',
submittedDate: '2026-04-01',
verificationStatus: 'VERIFIED',
assignedApprover: 'Unassigned',
priority: 'MEDIUM',
status: 'PENDING',
updatedAt: '2026-04-01',
sourceKey: 'fallback:profile:92841',
submittedFields: [
{ label: 'Name / Title', value: 'Sarah Jenkins' },
{ label: 'Email', value: 'sarah.jenkins@nxtgauge.com' },
{ label: 'Mobile', value: '+91 90000 00001' },
{ label: 'Area', value: 'T. Nagar' },
{ label: 'Place', value: 'Chennai' },
],
documents: [
{ id: 'identity-proof', title: 'Identity Proof', type: 'IMAGE', url: '/nxtgauge-logo.png', status: 'SUBMITTED' },
{ id: 'address-proof', title: 'Address Proof', type: 'PDF', url: '/nxtgauge-icon.png', status: 'SUBMITTED' },
],
},
{
id: 'AP-F-92835',
name: 'Portfolio Approval - Marcus Davis (Demo)',
applicantName: 'Marcus Davis (Demo)',
approvalType: 'PORTFOLIO',
userType: 'PROFESSIONAL',
roleTags: ['Graphic Designer'],
primaryService: 'Design Portfolio',
area: 'Velachery, Chennai',
submittedDate: '2026-04-01',
verificationStatus: 'VERIFIED',
assignedApprover: 'Unassigned',
priority: 'HIGH',
status: 'PENDING',
updatedAt: '2026-04-01',
sourceKey: 'fallback:portfolio:92835',
submittedFields: [
{ label: 'Name / Title', value: 'Marcus Davis' },
{ label: 'Role / Category', value: 'Graphic Designer' },
{ label: 'Area', value: 'Velachery' },
{ label: 'Description', value: 'Portfolio submitted for verification.' },
],
documents: [
{ id: 'portfolio-1', title: 'Portfolio Image 1', type: 'IMAGE', url: '/nxtgauge-logo.png', status: 'SUBMITTED' },
{ id: 'portfolio-2', title: 'Portfolio Image 2', type: 'IMAGE', url: '/nxtgauge-icon.png', status: 'SUBMITTED' },
],
},
{
id: 'AP-B-92839',
name: 'Business Approval - Zenith Tech Hub (Demo)',
applicantName: 'Zenith Tech Hub (Demo)',
approvalType: 'BUSINESS',
userType: 'COMPANY',
roleTags: ['Company'],
primaryService: 'Hiring Company',
area: 'Guindy, Chennai',
submittedDate: '2026-03-31',
verificationStatus: 'VERIFIED',
assignedApprover: 'Unassigned',
priority: 'HIGH',
status: 'PENDING',
updatedAt: '2026-03-31',
sourceKey: 'fallback:company:92839',
submittedFields: [
{ label: 'Name / Title', value: 'Zenith Tech Hub' },
{ label: 'Email', value: 'admin@zenithtechhub.com' },
{ label: 'Area', value: 'Guindy' },
{ label: 'Place', value: 'Chennai' },
],
documents: [
{ id: 'gst-certificate', title: 'GST Certificate', type: 'PDF', url: '/nxtgauge-logo.png', status: 'SUBMITTED' },
{ id: 'incorporation', title: 'Incorporation Certificate', type: 'PDF', url: '/nxtgauge-icon.png', status: 'SUBMITTED' },
],
},
{
id: 'AP-R-92812',
name: 'Requirement Approval - Luxury Wedding Shoot (Demo)',
applicantName: 'Luxury Wedding Shoot (Demo)',
approvalType: 'REQUIREMENT',
userType: 'CUSTOMER',
roleTags: ['Service Seeker', 'Photographer'],
primaryService: 'Service Requirement',
area: 'Adyar, Chennai',
submittedDate: '2026-03-30',
verificationStatus: 'FLAGGED',
assignedApprover: 'Unassigned',
priority: 'CRITICAL',
status: 'ESCALATED',
updatedAt: '2026-03-30',
sourceKey: 'fallback:requirement:92812',
submittedFields: [
{ label: 'Name / Title', value: 'Luxury Wedding Shoot' },
{ label: 'Role / Category', value: 'Photographer' },
{ label: 'Area', value: 'Adyar' },
{ label: 'Description', value: 'Need urgent premium wedding photography team.' },
],
documents: [
{ id: 'requirement-brief', title: 'Requirement Brief', type: 'PDF', url: '/nxtgauge-logo.png', status: 'SUBMITTED' },
{ id: 'reference-moodboard', title: 'Reference Moodboard', type: 'IMAGE', url: '/nxtgauge-icon.png', status: 'SUBMITTED' },
],
},
];
function mergeWithFallbackRows(sourceRows: ApprovalRecord[]): ApprovalRecord[] {
const merged = [...sourceRows];
for (const demo of FALLBACK_APPROVAL_ROWS) {
const duplicate = merged.some((row) => {
const rowKey = String(row.sourceKey || '').toLowerCase();
const rowName = String(row.applicantName || '').toLowerCase();
const rowType = String(row.approvalType || '').toLowerCase();
const demoName = String(demo.applicantName || '').toLowerCase();
const demoType = String(demo.approvalType || '').toLowerCase();
if (rowKey.startsWith('fallback:')) return rowKey === String(demo.sourceKey || '').toLowerCase();
return rowName === demoName && rowType === demoType;
});
if (!duplicate) merged.push(demo);
}
return merged;
}
function verificationToApprovalType(requestType: ApprovalQueueItem['requestType']): ApprovalRecord['approvalType'] {
if (requestType === 'Portfolio Approval') return 'PORTFOLIO';
if (requestType === 'Service Seeker Requirement') return 'REQUIREMENT';
if (requestType === 'Job Approval') return 'JOB';
if (requestType === 'Company Approval') return 'BUSINESS';
return 'PROFILE';
}
function mapQueueItemsToApprovals(items: ApprovalQueueItem[]): ApprovalRecord[] {
return items.map((item) => ({
id: String(item.id),
name: `${item.requestType} - ${item.applicantName}`,
applicantName: item.applicantName,
approvalType: verificationToApprovalType(item.requestType),
userType: normalizeUserType(item.userType),
roleTags: [toTitle(item.roleLabel), toTitle(item.roleKey)].filter((v, i, arr) => v && arr.indexOf(v) === i),
primaryService: toTitle(item.roleLabel || item.roleKey || item.requestType),
area: item.area || 'Chennai',
submittedDate: item.submittedOn || '',
verificationStatus: 'VERIFIED',
assignedApprover: 'Unassigned',
priority: item.requestType === 'Portfolio Approval' || item.requestType === 'Job Approval' ? 'HIGH' : 'MEDIUM',
status: 'PENDING',
updatedAt: item.submittedOn || '',
sourceKey: `verification:${item.id}`,
submittedFields: Array.isArray(item.submittedFields) ? item.submittedFields : [],
documents: Array.isArray(item.documents) ? item.documents : [],
payload: { queueRequestType: item.requestType },
}));
}
function StatusBadge(props: { status: string }) {
const getColors = () => {
switch (props.status) {
@ -112,6 +373,20 @@ export default function ApprovalManagementPage() {
const [error, setError] = createSignal('');
const [isActing, setIsActing] = createSignal(false);
const selectedDocuments = createMemo<ApprovalDocument[]>(() => {
const row = viewingCase();
if (!row) return [];
if (Array.isArray(row.documents) && row.documents.length) return row.documents;
return extractDocuments(row.payload || {});
});
const selectedFields = createMemo<ApprovalSubmittedField[]>(() => {
const row = viewingCase();
if (!row) return [];
if (Array.isArray(row.submittedFields) && row.submittedFields.length) return row.submittedFields;
return extractSubmittedFields(row.payload || row);
});
const load = async () => {
setError('');
try {
@ -127,47 +402,112 @@ export default function ApprovalManagementPage() {
});
if (!res.ok) throw new Error(`Request failed (${res.status})`);
const payload = await res.json().catch(() => ({} as any));
const jobs = Array.isArray(payload?.jobs) ? payload.jobs : [];
const requirements = Array.isArray(payload?.requirements) ? payload.requirements : [];
const profiles = Array.isArray(payload?.profiles) ? payload.profiles : [];
const portfolios = Array.isArray(payload?.portfolios) ? payload.portfolios : [];
const mappedJobs: ApprovalRecord[] = jobs.map((job: any) => ({
id: String(job.id),
id: `AP-J-${String(job.id || Math.random()).slice(0, 8)}`,
name: `Job Approval - ${String(job.title || 'Untitled Job')}`,
applicantName: String(job.title || 'Untitled Job'),
applicantName: String(job.company_name || job.created_by_name || job.title || 'Company Applicant'),
approvalType: 'JOB',
userType: 'COMPANY',
roleTags: extractRoleTags(job),
primaryService: String(job.category || job.department || job.role || 'Job Posting'),
area: String(job.location || job.city || job.work_mode || ''),
area: String(job.location || job.city || job.work_mode || 'Chennai'),
submittedDate: String(job.created_at || ''),
verificationStatus: 'VERIFIED',
assignedApprover: 'Unassigned',
priority: 'HIGH',
status: 'PENDING',
updatedAt: String(job.updated_at || job.created_at || ''),
sourceKey: `jobs:${String(job.id || '')}`,
submittedFields: extractSubmittedFields(job),
documents: extractDocuments(job),
payload: job,
}));
const mappedReqs: ApprovalRecord[] = requirements.map((req: any) => ({
id: String(req.id),
id: `AP-R-${String(req.id || Math.random()).slice(0, 8)}`,
name: `Requirement Approval - ${String(req.title || 'Untitled Requirement')}`,
applicantName: String(req.title || 'Untitled Requirement'),
applicantName: String(req.created_by_name || req.customer_name || req.title || 'Service Seeker Applicant'),
approvalType: 'REQUIREMENT',
userType: 'CUSTOMER',
roleTags: extractRoleTags(req),
primaryService: String(req.category || req.profession || req.service_type || 'Requirement'),
area: String(req.location || req.city || req.area || ''),
area: String(req.location || req.city || req.area || 'Chennai'),
submittedDate: String(req.created_at || ''),
verificationStatus: 'VERIFIED',
assignedApprover: 'Unassigned',
priority: 'MEDIUM',
status: 'PENDING',
updatedAt: String(req.updated_at || req.created_at || ''),
sourceKey: `requirements:${String(req.id || '')}`,
submittedFields: extractSubmittedFields(req),
documents: extractDocuments(req),
payload: req,
}));
setRows([...mappedJobs, ...mappedReqs]);
const mappedProfiles: ApprovalRecord[] = profiles.map((profile: any) => ({
id: `AP-P-${String(profile.id || profile.user_id || Math.random()).slice(0, 8)}`,
name: `Profile Approval - ${String(profile.full_name || profile.name || 'Applicant')}`,
applicantName: String(profile.full_name || profile.name || profile.user_name || 'Applicant'),
approvalType: profile.role_key === 'COMPANY' ? 'BUSINESS' : 'PROFILE',
userType: normalizeUserType(profile.role_key || profile.roleKey || profile.user_type),
roleTags: extractRoleTags(profile),
primaryService: String(profile.role_key || profile.role || profile.category || 'Profile'),
area: String(profile.area || profile.place || profile.city || 'Chennai'),
submittedDate: String(profile.submitted_at || profile.created_at || ''),
verificationStatus: 'VERIFIED',
assignedApprover: 'Unassigned',
priority: 'MEDIUM',
status: 'PENDING',
updatedAt: String(profile.updated_at || profile.created_at || ''),
sourceKey: `profiles:${String(profile.id || profile.user_id || '')}`,
submittedFields: extractSubmittedFields(profile),
documents: extractDocuments(profile),
payload: profile,
}));
const mappedPortfolios: ApprovalRecord[] = portfolios.map((portfolio: any) => ({
id: `AP-F-${String(portfolio.id || portfolio.user_id || Math.random()).slice(0, 8)}`,
name: `Portfolio Approval - ${String(portfolio.full_name || portfolio.name || 'Professional')}`,
applicantName: String(portfolio.full_name || portfolio.name || 'Professional'),
approvalType: 'PORTFOLIO',
userType: 'PROFESSIONAL',
roleTags: extractRoleTags(portfolio),
primaryService: String(portfolio.role_key || portfolio.role || 'Professional Portfolio'),
area: String(portfolio.area || portfolio.place || portfolio.city || 'Chennai'),
submittedDate: String(portfolio.submitted_at || portfolio.created_at || ''),
verificationStatus: 'VERIFIED',
assignedApprover: 'Unassigned',
priority: 'HIGH',
status: 'PENDING',
updatedAt: String(portfolio.updated_at || portfolio.created_at || ''),
sourceKey: `portfolios:${String(portfolio.id || portfolio.user_id || '')}`,
submittedFields: extractSubmittedFields(portfolio),
documents: extractDocuments(portfolio),
payload: portfolio,
}));
const queueRaw = typeof window !== 'undefined' ? window.localStorage.getItem(APPROVAL_QUEUE_STORAGE_KEY) : null;
const queueParsed = queueRaw ? JSON.parse(queueRaw) : [];
const queueItems = Array.isArray(queueParsed) ? queueParsed as ApprovalQueueItem[] : [];
const mappedQueue = mapQueueItemsToApprovals(queueItems);
const merged = [...mappedQueue, ...mappedJobs, ...mappedReqs, ...mappedProfiles, ...mappedPortfolios];
const deduped = merged.filter((row, index, arr) => {
const key = row.sourceKey || row.id;
return arr.findIndex((candidate) => (candidate.sourceKey || candidate.id) === key) === index;
});
const withDemo = mergeWithFallbackRows(deduped);
setRows(withDemo.length ? withDemo : FALLBACK_APPROVAL_ROWS);
} catch (e: any) {
setRows([]);
setError(e?.message || 'Could not reach approvals API.');
setRows(FALLBACK_APPROVAL_ROWS);
setError(e?.message || 'Could not reach approvals API. Showing demo approval queue data.');
}
};
@ -242,12 +582,23 @@ export default function ApprovalManagementPage() {
setOpenMenuId(null);
};
const setLocalStatus = (row: ApprovalRecord, nextStatus: ApprovalRecord['status']) => {
setRows((prev) => prev.map((item) => ((item.sourceKey || item.id) === (row.sourceKey || row.id) ? { ...item, status: nextStatus } : item)));
setViewingCase((current) => {
if (!current) return current;
return (current.sourceKey || current.id) === (row.sourceKey || row.id) ? { ...current, status: nextStatus } : current;
});
};
const runApprovalAction = async (row: ApprovalRecord, action: 'approve' | 'reject') => {
const type = row.approvalType;
const nextStatus: ApprovalRecord['status'] = action === 'approve' ? 'APPROVED' : 'REJECTED';
if (type !== 'JOB' && type !== 'REQUIREMENT') {
setError(`Action is not supported for approval type "${type}".`);
setLocalStatus(row, nextStatus);
return;
}
setIsActing(true);
setError('');
try {
@ -255,8 +606,8 @@ export default function ApprovalManagementPage() {
? sessionStorage.getItem('nxtgauge_admin_access_token') || ''
: '';
const endpoint = type === 'JOB'
? `${API}/api/admin/approvals/jobs/${row.id}/${action}`
: `${API}/api/admin/approvals/requirements/${row.id}/${action}`;
? `${API}/api/admin/approvals/jobs/${String(row.id).replace(/^AP-J-/, '')}/${action}`
: `${API}/api/admin/approvals/requirements/${String(row.id).replace(/^AP-R-/, '')}/${action}`;
const res = await fetch(endpoint, {
method: 'POST',
headers: {
@ -286,8 +637,7 @@ export default function ApprovalManagementPage() {
return (
<AdminShell>
<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]">Approval Management</h1>
<p class="mt-1 text-[14px] text-[#6B7280]">Manage final approval decisions for all platform entities and requests</p>
@ -298,9 +648,8 @@ export default function ApprovalManagementPage() {
</div>
</Show>
{/* ── LIST VIEW ── */}
<Show when={true}>
<div style="margin-top:24px;display:flex;align-items:center;gap:24px;border-bottom:1px solid #E5E7EB">
<div style="margin-top:24px;display:flex;align-items:center;gap:24px;min-height:44px;border-bottom:1px solid #E5E7EB">
{([
{ key: 'all', label: 'All Approvals', action: () => { setListTab('all'); setStatusFilter('all'); } },
{ key: 'escalated', label: `Escalated (${escalatedCount()})`, action: () => { setListTab('escalated'); setStatusFilter('escalated'); } },
@ -309,7 +658,7 @@ export default function ApprovalManagementPage() {
<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'}`}
style={`height:44px;padding:0 2px;font-size:14px;font-weight:500;background:none;border:none;cursor:pointer;${listTab() === tab.key ? 'color:#FF5E13;box-shadow:inset 0 -2px 0 #FF5E13' : 'color:#6B7280'}`}
>
{tab.label}
</button>
@ -340,7 +689,7 @@ export default function ApprovalManagementPage() {
</div>
</div>
<div style="display:flex;align-items:center;gap:4px;border-bottom:1px solid #E5E7EB;padding:0 24px;background:#FAFAFA">
<div style="display:flex;align-items:center;gap:4px;min-height:44px;border-bottom:1px solid #E5E7EB;padding:0 24px;background:#FAFAFA">
{(['overview', 'verification', 'checklist', 'logs'] as const).map((tab, i) => {
const labels = ['Overview', 'Verification Summary', 'Approval Checklist', 'Activity Logs'];
const active = () => detailTab() === tab;
@ -380,25 +729,24 @@ export default function ApprovalManagementPage() {
</div>
</Show>
</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">Approval Decision Path</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:75%;height:2px;background:#FF5E13;z-index:2" />
{[
{ l: 'Submitted', active: true },
{ l: 'Verified', active: true },
{ l: 'Review', active: true },
{ l: 'Decision', 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>
))}
<Show when={selectedFields().length > 0}>
<div style="border:1px solid #E5E7EB;border-radius:12px;padding:20px">
<h3 style="font-size:14px;font-weight:700;color:#111827;margin-bottom:12px">Submitted Details</h3>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px">
<For each={selectedFields()}>
{(field) => (
<div style="border:1px solid #F3F4F6;border-radius:10px;padding:10px;background:#FAFAFA">
<p style="font-size:11px;color:#9CA3AF">{field.label}</p>
<p style="font-size:13px;font-weight:600;color:#111827;line-height:1.4;word-break:break-word">{field.value || '—'}</p>
</div>
)}
</For>
</div>
</div>
</div>
</Show>
</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">Decision Notes</h3>
<textarea placeholder="Add decision note..." style="width:100%;height:100px;border-radius:8px;border:1px solid #E5E7EB;padding:10px;font-size:13px;resize:none;margin-bottom:12px" />
@ -409,21 +757,23 @@ export default function ApprovalManagementPage() {
<Show when={detailTab() === 'verification'}>
<div style="border:1px solid #E5E7EB;border-radius:12px;padding:20px;background:#FAFAFA">
<h3 style="font-size:14px;font-weight:700;color:#111827;margin-bottom:16px">Verification Summary (Read-Only)</h3>
<div style="display:flex;flex-direction:column;gap:12px">
<div style="display:flex;justify-content:space-between;padding:10px;background:white;border-radius:8px;border:1px solid #E5E7EB">
<span style="font-size:13px;color:#6B7280">Identity Verification</span>
<span style="font-size:13px;font-weight:600;color:#16A34A">SUCCESS</span>
<h3 style="font-size:14px;font-weight:700;color:#111827;margin-bottom:16px">Submitted Documents</h3>
<Show when={selectedDocuments().length > 0} fallback={<p style="font-size:13px;color:#6B7280">No documents submitted for this request.</p>}>
<div style="display:flex;flex-direction:column;gap:8px">
<For each={selectedDocuments()}>
{(doc) => (
<div style="display:grid;grid-template-columns:1fr auto auto;gap:12px;align-items:center;padding:10px;border-radius:10px;border:1px solid #E5E7EB;background:white">
<div>
<p style="font-size:13px;font-weight:600;color:#111827">{doc.title}</p>
<p style="font-size:12px;color:#6B7280">{doc.type} {doc.status}</p>
</div>
<a href={doc.url} target="_blank" rel="noreferrer" style="display:inline-flex;height:30px;align-items:center;justify-content:center;border-radius:8px;border:1px solid #E5E7EB;padding:0 12px;font-size:12px;font-weight:600;color:#374151;background:white;text-decoration:none">View</a>
<span style="font-size:12px;color:#6B7280">{doc.status}</span>
</div>
)}
</For>
</div>
<div style="display:flex;justify-content:space-between;padding:10px;background:white;border-radius:8px;border:1px solid #E5E7EB">
<span style="font-size:13px;color:#6B7280">Business Documents</span>
<span style="font-size:13px;font-weight:600;color:#16A34A">SUCCESS</span>
</div>
<div style="display:flex;justify-content:space-between;padding:10px;background:white;border-radius:8px;border:1px solid #E5E7EB">
<span style="font-size:13px;color:#6B7280">Address Validation</span>
<span style="font-size:13px;font-weight:600;color:#16A34A">SUCCESS</span>
</div>
</div>
</Show>
</div>
</Show>
@ -524,7 +874,7 @@ export default function ApprovalManagementPage() {
</button>
</div>
<div class="overflow-x-auto">
<div class="overflow-x-auto overflow-y-visible">
<table class="min-w-full">
<thead>
<tr style="background:#0D0D2A;text-align:left">
@ -539,40 +889,76 @@ export default function ApprovalManagementPage() {
</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}{row.area ? `${row.area}` : ''}</p>
<Show when={row.roleTags?.length}>
<p style="margin-top:2px;font-size:11px;color:#9CA3AF">{(row.roleTags || []).join(', ')}</p>
</Show>
</td>
<td style="padding:12px 20px;font-size:13px;color:#6B7280">{row.approvalType}</td>
<td style="padding:12px 20px"><VerificationBadge status={row.verificationStatus} /></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 Approval</button>
<button type="button" onClick={() => void runApprovalAction(row, 'approve')} 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">Approve</button>
<button type="button" onClick={() => void runApprovalAction(row, 'reject')} 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">Reject</button>
</div>
</Show>
<Show
when={filteredRows().length > 0}
fallback={
<tr>
<td colSpan={8} style="padding:32px;text-align:center">
<p style="font-size:15px;font-weight:600;color:#111827">No approvals found</p>
<p style="margin-top:6px;font-size:13px;color:#6B7280">Try changing filters or search.</p>
</td>
</tr>
)}
</For>
}
>
<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}{row.area ? `${row.area}` : ''}</p>
<Show when={row.roleTags?.length}>
<p style="margin-top:2px;font-size:11px;color:#9CA3AF">{(row.roleTags || []).join(', ')}</p>
</Show>
</td>
<td style="padding:12px 20px;font-size:13px;color:#6B7280">{row.approvalType}</td>
<td style="padding:12px 20px"><VerificationBadge status={row.verificationStatus} /></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: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" onClick={() => openView(row)} style="display:flex;width:100%;align-items:center;gap:10px;border-radius:8px;padding:10px 12px;font-size:13px;color:#374151;background:none;border:none;cursor:pointer;text-align:left">
<svg style="width:16px;height:16px;color:#FF5E13;flex-shrink:0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
View Approval
</button>
<button type="button" onClick={() => void runApprovalAction(row, 'approve')} style="display:flex;width:100%;align-items:center;gap:10px;border-radius:8px;padding:10px 12px;font-size:13px;color:#374151;background:none;border:none;cursor:pointer;text-align:left">
<svg style="width:16px;height:16px;color:#FF5E13;flex-shrink:0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="m8 12 2.5 2.5L16 9"/></svg>
Approve
</button>
<button type="button" onClick={() => void runApprovalAction(row, 'reject')} style="display:flex;width:100%;align-items:center;gap:10px;border-radius:8px;padding:10px 12px;font-size:13px;color:#DC2626;background:none;border:none;cursor:pointer;text-align:left">
<svg style="width:16px;height:16px;flex-shrink:0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="m15 9-6 6"/><path d="m9 9 6 6"/></svg>
Reject
</button>
</div>
</Show>
</td>
</tr>
)}
</For>
</Show>
</tbody>
</table>
</div>
<Show when={filteredRows().length > 0}>
<div style="display:flex;align-items:center;justify-content:space-between;border-top:1px solid #F3F4F6;padding:12px 20px">
<p style="font-size:13px;color:#6B7280">
Showing <strong style="font-weight:600;color:#111827">1{filteredRows().length}</strong> of <strong style="font-weight:600;color:#111827">{filteredRows().length}</strong> approvals
</p>
<div style="display:flex;align-items:center;gap:4px">
<button type="button" style="display:inline-flex;width:30px;height:30px;align-items:center;justify-content:center;border-radius:7px;border:1px solid #E5E7EB;background:white;color:#6B7280;cursor:pointer;font-size:15px"></button>
<button type="button" style="display:inline-flex;width:30px;height:30px;align-items:center;justify-content:center;border-radius:7px;background:#FF5E13;color:white;font-size:13px;font-weight:600;border:none;cursor:pointer">1</button>
<button type="button" style="display:inline-flex;width:30px;height:30px;align-items:center;justify-content:center;border-radius:7px;border:1px solid #E5E7EB;background:white;color:#374151;font-size:13px;font-weight:500;cursor:pointer">2</button>
<button type="button" style="display:inline-flex;width:30px;height:30px;align-items:center;justify-content:center;border-radius:7px;border:1px solid #E5E7EB;background:white;color:#374151;font-size:13px;font-weight:500;cursor:pointer">3</button>
<button type="button" style="display:inline-flex;width:30px;height:30px;align-items:center;justify-content:center;border-radius:7px;border:1px solid #E5E7EB;background:white;color:#6B7280;cursor:pointer;font-size:15px"></button>
</div>
</div>
</Show>
</div>
</div>
</Show>

View file

@ -136,13 +136,14 @@ function sameSidebarOrder(a: string[], b: string[]): boolean {
function rolePreviewPath(roleKey: string): string {
const key = String(roleKey || '').toUpperCase();
if (key.includes('COMPANY')) return '/employers/dashboard';
if (key.includes('CUSTOMER')) return '/users/customer/dashboard';
if (key.includes('CUSTOMER') || key.includes('SERVICE_SEEKER')) return '/users/customer/dashboard';
if (key.includes('JOB_SEEKER') || key.includes('JOBSEEKER')) return '/users/candidate/dashboard';
if (key.includes('PHOTOGRAPHER')) return '/users/photographer/dashboard';
if (key.includes('MAKEUP')) return '/users/makeup/dashboard';
if (key.includes('TUTOR')) return '/users/tutors/dashboard';
if (key.includes('DEVELOPER')) return '/users/developers/dashboard';
if (key.includes('VIDEO')) return '/users/video-editors/dashboard';
if (key.includes('UGC') || (key.includes('CONTENT') && key.includes('CREATOR'))) return '/users/ugc-content-creators/dashboard';
if (key.includes('FITNESS')) return '/users/fitness-trainers/dashboard';
if (key.includes('GRAPHIC')) return '/users/graphic-designers/dashboard';
if (key.includes('SOCIAL')) return '/users/social-media-managers/dashboard';
@ -200,7 +201,8 @@ export default function ExternalDashboardManagementPage() {
const [view, setView] = createSignal<'list' | 'form'>('list');
const [editingId, setEditingId] = createSignal<string | null>(null);
const [formTab, setFormTab] = createSignal<'general' | 'tabs' | 'sidebar' | 'fields' | 'preview'>('general');
const [formTab, setFormTab] = createSignal<'general' | 'tabs' | 'sidebar' | 'fields' | 'preview' | 'full_preview'>('general');
const [isFullscreenPreview, setIsFullscreenPreview] = createSignal(false);
const [listTab, setListTab] = createSignal<'all' | 'create'>('all');
const [search, setSearch] = createSignal('');
@ -228,7 +230,7 @@ export default function ExternalDashboardManagementPage() {
for (const role of roles()) {
const key = role.key.toUpperCase();
if (key.includes('COMPANY')) map[role.id] = 'COMPANY';
else if (key.includes('CUSTOMER')) map[role.id] = 'CUSTOMER';
else if (key.includes('CUSTOMER') || key.includes('SERVICE_SEEKER')) map[role.id] = 'CUSTOMER';
else if (key.includes('JOB_SEEKER') || key.includes('JOBSEEKER')) map[role.id] = 'JOB_SEEKER';
else map[role.id] = 'PROFESSIONAL';
}
@ -239,7 +241,7 @@ export default function ExternalDashboardManagementPage() {
const k = String(key || '').toUpperCase();
if (!k) return null;
if (k.includes('COMPANY')) return 'COMPANY';
if (k.includes('CUSTOMER')) return 'CUSTOMER';
if (k.includes('CUSTOMER') || k.includes('SERVICE_SEEKER')) return 'CUSTOMER';
if (k.includes('JOB_SEEKER') || k.includes('JOBSEEKER')) return 'JOB_SEEKER';
return 'PROFESSIONAL'; // photographer, makeup, tutor, developer, video, graphic, social, fitness, catering, etc.
};
@ -520,7 +522,7 @@ export default function ExternalDashboardManagementPage() {
</div>
<div style="display:flex;gap:4px;border-bottom:1px solid #E5E7EB;padding:0 16px;background:#FAFAFA">
{(['general', 'tabs', 'sidebar', 'fields', 'preview'] as const).map((tab) => (
{(['general', 'tabs', 'sidebar', 'fields', 'preview', 'full_preview'] as const).map((tab) => (
<button type="button" onClick={() => setFormTab(tab)} style={`position:relative;padding:12px 10px;font-size:12px;font-weight:500;border:none;background:none;cursor:pointer;color:${formTab() === tab ? '#FF5E13' : '#6B7280'}`}>
{humanizeLabel(tab)}
<Show when={formTab() === tab}><span style="position:absolute;left:0;right:0;bottom:0;height:2px;background:#FF5E13" /></Show>
@ -626,9 +628,53 @@ export default function ExternalDashboardManagementPage() {
mode={'customer_external'}
roleKey={selectedRoleKey()}
exploreRoles={roles().map((r) => ({ key: r.key, name: r.name }))}
onOpenFullscreen={() => setFormTab('full_preview')}
/>
</div>
</Show>
<Show when={formTab() === 'full_preview'}>
<div style="display:flex;flex-direction:column;gap:10px">
<div style="display:flex;align-items:center;justify-content:space-between;gap:10px;padding:10px 12px;border:1px solid #E5E7EB;border-radius:10px;background:#FAFAFA">
<div>
<p style="margin:0;font-size:13px;font-weight:700;color:#111827">Full External Dashboard Preview</p>
<p style="margin:2px 0 0;font-size:12px;color:#6B7280">Navigate full sidebar and tabs in a larger canvas before saving.</p>
</div>
<div style="display:flex;align-items:center;gap:8px">
<button
type="button"
onClick={() => setIsFullscreenPreview(true)}
style="height:32px;border-radius:8px;border:1px solid #E5E7EB;background:white;padding:0 12px;font-size:12px;font-weight:700;color:#374151;cursor:pointer"
>
Enter Full Screen
</button>
<button
type="button"
onClick={() => setFormTab('preview')}
style="height:32px;border-radius:8px;border:1px solid #E5E7EB;background:white;padding:0 12px;font-size:12px;font-weight:700;color:#374151;cursor:pointer"
>
Back To Compact Preview
</button>
</div>
</div>
<div style="border:1px solid #E5E7EB;border-radius:14px;background:#F3F4F6;padding:12px;min-height:78vh;overflow:auto">
<DashboardDesignPreview
status={isActive() ? 'ACTIVE' : 'INACTIVE'}
sidebarItems={previewSidebarItems()}
activeSidebar={activePreviewSidebar()}
onSidebarSelect={setActivePreviewSidebar}
tabs={previewTabs()}
activeTab={activePreviewTab()}
onTabSelect={setActivePreviewTab}
widgets={widgets()}
fields={fields()}
mode={'customer_external'}
roleKey={selectedRoleKey()}
exploreRoles={roles().map((r) => ({ key: r.key, name: r.name }))}
/>
</div>
</div>
</Show>
</div>
<div style="padding:14px 24px;border-top:1px solid #E5E7EB;display:flex;justify-content:flex-end;gap:10px">
@ -733,6 +779,41 @@ export default function ExternalDashboardManagementPage() {
</Show>
</div>
</Show>
<Show when={isFullscreenPreview()}>
<div style="position:fixed;inset:0;z-index:1000;background:#F3F4F6;padding:12px;display:flex;flex-direction:column;gap:10px">
<div style="display:flex;align-items:center;justify-content:space-between;gap:10px;padding:10px 12px;border:1px solid #E5E7EB;border-radius:10px;background:white">
<div>
<p style="margin:0;font-size:13px;font-weight:700;color:#111827">Full Screen External Dashboard Preview</p>
<p style="margin:2px 0 0;font-size:12px;color:#6B7280">Edge-to-edge preview for final visual validation.</p>
</div>
<button
type="button"
onClick={() => setIsFullscreenPreview(false)}
style="height:34px;border-radius:8px;border:1px solid #E5E7EB;background:white;padding:0 12px;font-size:12px;font-weight:700;color:#374151;cursor:pointer"
>
Exit Full Screen
</button>
</div>
<div style="flex:1;min-height:0;border:1px solid #E5E7EB;border-radius:14px;background:#F3F4F6;padding:12px;overflow:auto">
<DashboardDesignPreview
status={isActive() ? 'ACTIVE' : 'INACTIVE'}
sidebarItems={previewSidebarItems()}
activeSidebar={activePreviewSidebar()}
onSidebarSelect={setActivePreviewSidebar}
tabs={previewTabs()}
activeTab={activePreviewTab()}
onTabSelect={setActivePreviewTab}
widgets={widgets()}
fields={fields()}
mode={'customer_external'}
roleKey={selectedRoleKey()}
exploreRoles={roles().map((r) => ({ key: r.key, name: r.name }))}
/>
</div>
</div>
</Show>
</div>
</AdminShell>
);

View file

@ -60,11 +60,11 @@ function normalizeExternalRole(item: any, index: number): ExternalRoleRecord {
};
}
const USER_TYPE_OPTIONS = ['COMPANY', 'CANDIDATE', 'PHOTOGRAPHER', 'MAKEUP_ARTIST', 'TUTOR', 'DEVELOPER', 'VIDEO_EDITOR', 'FITNESS_TRAINER', 'CATERER', 'GRAPHIC_DESIGNER', 'SOCIAL_MEDIA_MANAGER', 'CUSTOMER'];
const USER_TYPE_OPTIONS = ['COMPANY', 'CANDIDATE', 'PHOTOGRAPHER', 'MAKEUP_ARTIST', 'TUTOR', 'DEVELOPER', 'VIDEO_EDITOR', 'UGC_CONTENT_CREATOR', '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'
'makeup_artist_onboarding_v1', 'tutor_onboarding_v1', 'developer_onboarding_v1', 'video_editor_onboarding_v1', 'ugc_content_creator_onboarding_v1'
];
const MODULES_BY_VERTICAL = {
@ -774,7 +774,7 @@ export default function ExternalRoleManagementPage() {
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('_', ' ')}
{type === 'CUSTOMER' ? 'SERVICE SEEKER' : type.replace(/_/g, ' ')}
</button>
);
}}

View file

@ -76,7 +76,7 @@ const FALLBACK_SCHEMAS: OnboardingSchema[] = [
description: 'Confirm your onboarding role and specialization.',
required: true,
fields: [
{ id: 'f_role', key: 'role', label: 'Professional Role', type: 'select', required: true, options: ['Photographer', 'Makeup Artist', 'Tutor', 'Developer', 'Video Editor'] },
{ id: 'f_role', key: 'role', label: 'Professional Role', type: 'select', required: true, options: ['Photographer', 'Makeup Artist', 'Tutor', 'Developer', 'Video Editor', 'UGC Content Creator'] },
{ id: 'f_special', key: 'specialization', label: 'Specialization', type: 'select', required: true, options: ['Wedding', 'Product', 'Corporate', 'Portrait', 'Events'] },
],
},
@ -163,7 +163,7 @@ const FALLBACK_SCHEMAS: OnboardingSchema[] = [
description: 'Tell us about hiring needs.',
required: true,
fields: [
{ id: 'f_roles', key: 'hire_for', label: 'Hiring For', type: 'multi-select', required: true, options: ['Developers', 'Designers', 'Photographers', 'Tutors'] },
{ id: 'f_roles', key: 'hire_for', label: 'Hiring For', type: 'multi-select', required: true, options: ['Developers', 'Designers', 'Photographers', 'UGC Content Creators', 'Tutors'] },
{ id: 'f_budget', key: 'budget', label: 'Monthly Hiring Budget (₹)', type: 'number', required: true, placeholder: 'e.g., 50000' },
],
},

View file

@ -1,86 +1,181 @@
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';
type RegisteredRole = {
key: string;
name: string;
status: 'ACTIVE' | 'PENDING' | 'INACTIVE';
registeredOn?: string;
};
type ExternalUserRecord = {
id: string;
userCode: string;
name: string;
username?: string;
email: string;
phone?: string;
location: string;
joinedOn: string;
lastActive?: string;
userType: 'CUSTOMER' | 'PROFESSIONAL' | 'COMPANY' | 'JOBSEEKER';
primaryActiveRole?: string;
onboardingStatus: 'NOT_STARTED' | 'IN_PROGRESS' | 'SUBMITTED' | 'COMPLETED';
accountStatus: 'ACTIVE' | 'INACTIVE' | 'SUSPENDED' | 'BLOCKED';
verificationStatus: 'UNVERIFIED' | 'PENDING' | 'IN_REVIEW' | 'VERIFIED' | 'REJECTED' | 'RE_UPLOAD_REQUESTED';
accountStatus: 'ACTIVE' | 'INACTIVE' | 'BLOCKED' | 'SUSPENDED';
createdDate?: string;
lastLogin?: string;
status: 'ACTIVE' | 'INACTIVE';
onboardingStatus: 'NOT_STARTED' | 'IN_PROGRESS' | 'SUBMITTED' | 'COMPLETED';
registeredRoles: RegisteredRole[];
portfolioCount: number;
notes?: string;
updatedAt: string;
};
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' },
{
id: 'u1',
userCode: 'U-102348',
name: 'Marcus Thorne',
email: 'm.thorne@creative.co',
phone: '+91 98765 43210',
location: 'T. Nagar, Chennai',
joinedOn: '2026-01-10',
lastActive: '2 hours ago',
userType: 'PROFESSIONAL',
accountStatus: 'ACTIVE',
verificationStatus: 'VERIFIED',
onboardingStatus: 'COMPLETED',
registeredRoles: [
{ key: 'photographer', name: 'Photographer', status: 'ACTIVE', registeredOn: '2026-01-12' },
{ key: 'designer', name: 'Designer', status: 'ACTIVE', registeredOn: '2026-01-20' },
],
portfolioCount: 15,
notes: 'High response rate profile.',
updatedAt: '2026-04-02',
},
{
id: 'u2',
userCode: 'U-102389',
name: 'Elena Rodriguez',
email: 'elena.rodriguez@techhub.io',
phone: '+91 98765 43211',
location: 'Guindy, Chennai',
joinedOn: '2026-02-04',
lastActive: '5 hours ago',
userType: 'PROFESSIONAL',
accountStatus: 'ACTIVE',
verificationStatus: 'IN_REVIEW',
onboardingStatus: 'SUBMITTED',
registeredRoles: [{ key: 'developer', name: 'Developer', status: 'PENDING', registeredOn: '2026-02-06' }],
portfolioCount: 4,
notes: 'Role pending verification checklist.',
updatedAt: '2026-04-02',
},
{
id: 'u3',
userCode: 'U-102452',
name: 'Sam Jenkins',
email: 'sam.j@no-agency.net',
phone: '+91 98765 43212',
location: 'Adyar, Chennai',
joinedOn: '2026-03-01',
lastActive: '1 day ago',
userType: 'JOBSEEKER',
accountStatus: 'SUSPENDED',
verificationStatus: 'UNVERIFIED',
onboardingStatus: 'NOT_STARTED',
registeredRoles: [],
portfolioCount: 0,
notes: 'No documents submitted. Follow-up campaign candidate.',
updatedAt: '2026-04-01',
},
{
id: 'u4',
userCode: 'U-102510',
name: 'Nodal Agency Ltd',
email: 'billing@nodal.agency',
phone: '+91 98765 43213',
location: 'Velachery, Chennai',
joinedOn: '2026-01-25',
lastActive: '20 mins ago',
userType: 'COMPANY',
accountStatus: 'ACTIVE',
verificationStatus: 'VERIFIED',
onboardingStatus: 'COMPLETED',
registeredRoles: [{ key: 'company', name: 'Company', status: 'ACTIVE', registeredOn: '2026-01-26' }],
portfolioCount: 0,
notes: 'Company account in good standing.',
updatedAt: '2026-04-02',
},
{
id: 'u5',
userCode: 'U-102555',
name: 'Victor Vance',
email: 'vance@archive.io',
phone: '+91 98765 43214',
location: 'Anna Nagar, Chennai',
joinedOn: '2026-02-14',
lastActive: '10 mins ago',
userType: 'PROFESSIONAL',
accountStatus: 'ACTIVE',
verificationStatus: 'PENDING',
onboardingStatus: 'IN_PROGRESS',
registeredRoles: [{ key: 'archivist', name: 'Archivist', status: 'PENDING', registeredOn: '2026-02-18' }],
portfolioCount: 2,
notes: 'Awaiting ID re-upload.',
updatedAt: '2026-04-02',
},
{
id: 'u6',
userCode: 'U-102640',
name: 'Jameson Lee',
email: 'jlee@global.net',
phone: '+91 98765 43215',
location: 'Porur, Chennai',
joinedOn: '2026-02-28',
lastActive: '3 hours ago',
userType: 'CUSTOMER',
accountStatus: 'ACTIVE',
verificationStatus: 'VERIFIED',
onboardingStatus: 'COMPLETED',
registeredRoles: [{ key: 'service-seeker', name: 'Service Seeker', status: 'ACTIVE', registeredOn: '2026-03-01' }],
portfolioCount: 0,
notes: 'Posts requirements regularly.',
updatedAt: '2026-04-02',
},
];
function StatusBadge(props: { status: string; type?: 'account' | 'verification' | 'onboarding' }) {
function StatusBadge(props: { status: string }) {
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';
const suspended = () => props.status === 'SUSPENDED' || props.status === 'REJECTED' || props.status === 'BLOCKED';
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 style={`display:inline-flex;align-items:center;border-radius:9999px;border:1px solid ${active() ? '#B7E4C7' : pending() ? '#FDE68A' : suspended() ? '#FECACA' : '#D1D5DB'};background:${active() ? '#DEF7E8' : pending() ? '#FFFBEB' : suspended() ? '#FEF2F2' : '#F3F4F6'};color:${active() ? '#166534' : pending() ? '#92400E' : suspended() ? '#B91C1C' : '#4B5563'};padding:2px 10px;font-size:12px;font-weight:600`}>
<span style={`display:inline-block;width:6px;height:6px;border-radius:50%;background:${active() ? '#16A34A' : pending() ? '#D97706' : suspended() ? '#DC2626' : '#9CA3AF'};margin-right:5px;flex-shrink:0`} />
{props.status.split('_').map((w) => w.charAt(0) + w.slice(1).toLowerCase()).join(' ')}
</span>
);
}
function FormInput(props: { label: string; required?: boolean; value: string; onInput: (v: string) => void; placeholder?: string; type?: string }) {
function RoleChip(props: { role: RegisteredRole }) {
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>
<span style={`display:inline-flex;align-items:center;height:24px;padding:0 10px;border-radius:8px;font-size:11px;font-weight:600;border:1px solid #E5E7EB;background:${props.role.status === 'ACTIVE' ? '#EEF2FF' : props.role.status === 'PENDING' ? '#FFF7ED' : '#F3F4F6'};color:${props.role.status === 'ACTIVE' ? '#3730A3' : props.role.status === 'PENDING' ? '#C2410C' : '#6B7280'}`}>
{props.role.name}
</span>
);
}
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 [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 [listTab, setListTab] = createSignal<'all' | 'no_role' | 'registered' | 'pending' | 'approved' | 'suspended' | 'view'>('all');
const [detailTab, setDetailTab] = createSignal<'overview' | 'personal' | 'roles' | 'portfolio' | 'verification' | 'activity' | 'notes'>('overview');
// 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 [search, setSearch] = createSignal('');
const [sortBy, setSortBy] = createSignal<'newest' | 'oldest' | 'name_asc' | 'name_desc'>('newest');
const [statusFilter, setStatusFilter] = createSignal<'all' | 'active' | 'pending' | 'suspended' | 'blocked'>('all');
const [roleFilter, setRoleFilter] = createSignal<'all' | 'no_role' | 'professional' | 'company' | 'jobseeker' | 'customer'>('all');
const [sortOpen, setSortOpen] = createSignal(false);
const [filterOpen, setFilterOpen] = createSignal(false);
const [openMenuId, setOpenMenuId] = createSignal<string | null>(null);
const [selectedUser, setSelectedUser] = createSignal<ExternalUserRecord | null>(null);
const currentUser = createMemo<ExternalUserRecord>(() => selectedUser() ?? FALLBACK_USERS[0]);
const load = async () => {
setRows(FALLBACK_USERS);
@ -88,299 +183,461 @@ export default function UsersManagementPage() {
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 metrics = createMemo(() => {
const all = rows();
const noRoleUsers = all.filter((u) => u.registeredRoles.length === 0);
const activeRoleUsers = all.filter((u) => u.registeredRoles.some((r) => r.status === 'ACTIVE'));
const pendingRoles = all.filter((u) => u.registeredRoles.some((r) => r.status === 'PENDING'));
const suspended = all.filter((u) => u.accountStatus === 'SUSPENDED');
const newThisMonth = all.filter((u) => String(u.joinedOn || '').startsWith('2026-04')).length;
return {
totalUsers: all.length,
noRoleUsers: noRoleUsers.length,
activeRoleUsers: activeRoleUsers.length,
pendingRoles: pendingRoles.length,
suspended: suspended.length,
newThisMonth,
};
});
const scopedRows = createMemo(() => {
let list = rows();
if (listTab() === 'no_role') list = list.filter((u) => u.registeredRoles.length === 0);
if (listTab() === 'registered') list = list.filter((u) => u.registeredRoles.length > 0);
if (listTab() === 'pending') list = list.filter((u) => u.registeredRoles.some((r) => r.status === 'PENDING') || u.verificationStatus === 'PENDING' || u.verificationStatus === 'IN_REVIEW');
if (listTab() === 'approved') list = list.filter((u) => u.verificationStatus === 'VERIFIED');
if (listTab() === 'suspended') list = list.filter((u) => u.accountStatus === 'SUSPENDED');
if (statusFilter() === 'active') list = list.filter((u) => u.accountStatus === 'ACTIVE');
if (statusFilter() === 'pending') list = list.filter((u) => u.verificationStatus === 'PENDING' || u.verificationStatus === 'IN_REVIEW');
if (statusFilter() === 'suspended') list = list.filter((u) => u.accountStatus === 'SUSPENDED');
if (statusFilter() === 'blocked') list = list.filter((u) => u.accountStatus === 'BLOCKED');
if (roleFilter() === 'no_role') list = list.filter((u) => u.registeredRoles.length === 0);
if (roleFilter() === 'professional') list = list.filter((u) => u.userType === 'PROFESSIONAL');
if (roleFilter() === 'company') list = list.filter((u) => u.userType === 'COMPANY');
if (roleFilter() === 'jobseeker') list = list.filter((u) => u.userType === 'JOBSEEKER');
if (roleFilter() === 'customer') list = list.filter((u) => u.userType === 'CUSTOMER');
const q = search().trim().toLowerCase();
if (q) {
r = r.filter(r => r.name.toLowerCase().includes(q) || r.email.toLowerCase().includes(q) || r.id.toLowerCase().includes(q));
list = list.filter((u) => [u.userCode, u.name, u.email, u.location].some((v) => String(v || '').toLowerCase().includes(q)));
}
const sorted = [...r];
const mode = sortBy();
const sorted = [...list];
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);
if (sortBy() === 'name_asc') return a.name.localeCompare(b.name);
if (sortBy() === 'name_desc') return b.name.localeCompare(a.name);
if (sortBy() === 'oldest') return String(a.joinedOn).localeCompare(String(b.joinedOn));
return String(b.joinedOn).localeCompare(String(a.joinedOn));
});
return sorted;
});
const resetForm = () => {
setEditingId(null); setViewingUser(null); setName(''); setUsername(''); setEmail(''); setPhone(''); setUserType('CUSTOMER'); setFormTab('basic');
const exportCsv = () => {
const headers = ['User ID', 'Name', 'Email', 'Type', 'Registered Roles', 'Role Count', 'Status', 'Joined On'];
const lines = scopedRows().map((u) => [
u.userCode,
u.name,
u.email,
u.userType,
u.registeredRoles.map((r) => r.name).join(' | ') || 'No Role Assigned',
String(u.registeredRoles.length),
u.accountStatus,
u.joinedOn,
]);
const csv = [headers, ...lines].map((line) => line.map((cell) => `"${String(cell).replace(/"/g, '""')}"`).join(',')).join('\n');
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `users-management-${new Date().toISOString().slice(0, 10)}.csv`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
};
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 openView = (user: ExternalUserRecord) => {
setSelectedUser(user);
setDetailTab('overview');
setListTab('view');
setOpenMenuId(null);
};
const openDetail = (row: ExternalUserRecord) => {
setViewingUser(row); setListTab('view'); setOpenMenuId(null);
const toggleSuspend = (user: ExternalUserRecord) => {
const nextStatus = user.accountStatus === 'SUSPENDED' ? 'ACTIVE' : 'SUSPENDED';
setRows((prev) => prev.map((r) => (r.id === user.id ? { ...r, accountStatus: nextStatus } : r)));
if (selectedUser()?.id === user.id) setSelectedUser({ ...user, accountStatus: nextStatus });
setOpenMenuId(null);
};
const toggleBlock = (user: ExternalUserRecord) => {
const nextStatus = user.accountStatus === 'BLOCKED' ? 'ACTIVE' : 'BLOCKED';
setRows((prev) => prev.map((r) => (r.id === user.id ? { ...r, accountStatus: nextStatus } : r)));
if (selectedUser()?.id === user.id) setSelectedUser({ ...user, accountStatus: nextStatus });
setOpenMenuId(null);
};
return (
<AdminShell>
<div class="w-full space-y-6 pb-8">
<div style="margin-bottom: 1.5rem">
<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>
<p class="mt-1 text-[14px] text-[#6B7280]">Manage all external user accounts, monitor registered roles, and review complete user activity.</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>
))}
<Show when={listTab() !== 'view'}>
<div style="display:grid;grid-template-columns:repeat(6,minmax(0,1fr));gap:14px">
{[
{ label: 'Total Users', value: metrics().totalUsers, accent: '#111827' },
{ label: 'No Role Users', value: metrics().noRoleUsers, accent: '#111827' },
{ label: 'Active Role Users', value: metrics().activeRoleUsers, accent: '#111827' },
{ label: 'Pending Roles', value: metrics().pendingRoles, accent: '#111827' },
{ label: 'Suspended', value: metrics().suspended, accent: '#111827' },
{ label: 'New This Month', value: metrics().newThisMonth, accent: '#111827' },
].map((card) => (
<div style="border:1px solid #E5E7EB;border-radius:14px;background:white;padding:16px 18px;min-height:100px">
<p style="font-size:12px;font-weight:600;text-transform:uppercase;letter-spacing:0.06em;color:#667085">{card.label}</p>
<p style={`margin-top:8px;font-size:40px;line-height:1;font-weight:700;color:${card.accent}`}>{card.value}</p>
</div>
))}
</div>
</Show>
<div style="display:flex;align-items:center;gap:24px;min-height:44px;border-bottom:1px solid #E5E7EB;overflow:auto">
{([
{ key: 'all', label: `All Users (${rows().length})` },
{ key: 'no_role', label: `No Role Users (${metrics().noRoleUsers})` },
{ key: 'registered', label: `Registered Role Users (${metrics().activeRoleUsers})` },
{ key: 'pending', label: `Pending Users (${metrics().pendingRoles})` },
{ key: 'approved', label: 'Approved Users' },
{ key: 'suspended', label: `Suspended (${metrics().suspended})` },
{ key: 'view', label: 'View User' },
] as const).map((tab) => (
<button
type="button"
onClick={() => setListTab(tab.key)}
style={`height:44px;padding:0 2px;white-space:nowrap;font-size:14px;font-weight:500;background:none;border:none;cursor:pointer;${listTab() === tab.key ? 'color:#FF5E13;box-shadow:inset 0 -2px 0 #FF5E13' : 'color:#6B7280'}`}
>
{tab.label}
</button>
))}
</div>
<Show when={listTab() === 'view'}>
<Show when={!selectedUser()}>
<div style="margin-top:18px;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">Open actions menu in table and click <strong>View User</strong>.</p>
</div>
{/* 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>
</Show>
<Show when={selectedUser()}>
<div style="border:1px solid #E5E7EB;border-radius:16px;background:white;overflow:hidden">
<div style="padding:18px 22px;border-bottom:1px solid #E5E7EB;display:flex;align-items:center;justify-content:space-between;gap:16px">
<div>
<h2 style="font-size:22px;font-weight:700;color:#111827">User Profile: {currentUser().name}</h2>
<p style="font-size:14px;color:#6B7280;margin-top:4px">View and manage account information, registered roles, and activity history.</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 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="display:flex;gap:10px">
<button type="button" onClick={() => toggleSuspend(currentUser())} 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">{currentUser().accountStatus === 'SUSPENDED' ? 'Activate' : 'Suspend'}</button>
<button type="button" onClick={() => toggleBlock(currentUser())} 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">{currentUser().accountStatus === 'BLOCKED' ? 'Unblock' : 'Block'}</button>
<button type="button" style="height:40px;border-radius:10px;background:#0D0D2A;padding:0 16px;font-size:13px;font-weight:600;color:white;border:none;cursor:pointer">Edit Profile</button>
</div>
</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">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 style="padding:18px 22px;display:grid;grid-template-columns:2fr 1fr;gap:14px;border-bottom:1px solid #E5E7EB">
<div style="border:1px solid #E5E7EB;border-radius:12px;padding:14px;display:grid;grid-template-columns:120px 1fr 1fr 1fr;gap:14px;align-items:center">
<div style="width:110px;height:110px;border-radius:12px;border:1px solid #E5E7EB;background:#F9FAFB;display:flex;align-items:center;justify-content:center;font-size:32px;font-weight:700;color:#94A3B8">{currentUser().name.charAt(0)}</div>
<div>
<p style="font-size:11px;color:#9CA3AF;text-transform:uppercase;letter-spacing:0.06em">User ID</p>
<p style="font-size:20px;font-weight:700;color:#111827">{currentUser().userCode}</p>
<p style="margin-top:8px;font-size:11px;color:#9CA3AF;text-transform:uppercase;letter-spacing:0.06em">Account Status</p>
<StatusBadge status={currentUser().accountStatus} />
</div>
<div>
<p style="font-size:11px;color:#9CA3AF;text-transform:uppercase;letter-spacing:0.06em">Email Address</p>
<p style="font-size:14px;font-weight:600;color:#111827">{currentUser().email}</p>
<p style="margin-top:8px;font-size:11px;color:#9CA3AF;text-transform:uppercase;letter-spacing:0.06em">Joined Date</p>
<p style="font-size:14px;font-weight:600;color:#111827">{currentUser().joinedOn}</p>
</div>
<div>
<p style="font-size:11px;color:#9CA3AF;text-transform:uppercase;letter-spacing:0.06em">Phone</p>
<p style="font-size:14px;font-weight:600;color:#111827">{currentUser().phone || '—'}</p>
<p style="margin-top:8px;font-size:11px;color:#9CA3AF;text-transform:uppercase;letter-spacing:0.06em">Location</p>
<p style="font-size:14px;font-weight:600;color:#111827">{currentUser().location}</p>
</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px">
<div style="border:1px solid #E5E7EB;border-radius:12px;padding:12px"><p style="font-size:11px;color:#9CA3AF;text-transform:uppercase">Total Roles</p><p style="font-size:30px;font-weight:700;color:#111827">{currentUser().registeredRoles.length}</p></div>
<div style="border:1px solid #E5E7EB;border-radius:12px;padding:12px"><p style="font-size:11px;color:#9CA3AF;text-transform:uppercase">Active Roles</p><p style="font-size:30px;font-weight:700;color:#FF5E13">{currentUser().registeredRoles.filter((r) => r.status === 'ACTIVE').length}</p></div>
<div style="border:1px solid #E5E7EB;border-radius:12px;padding:12px"><p style="font-size:11px;color:#9CA3AF;text-transform:uppercase">Pending</p><p style="font-size:30px;font-weight:700;color:#111827">{currentUser().registeredRoles.filter((r) => r.status === 'PENDING').length}</p></div>
<div style="border:1px solid #E5E7EB;border-radius:12px;padding:12px"><p style="font-size:11px;color:#9CA3AF;text-transform:uppercase">Portfolio</p><p style="font-size:30px;font-weight:700;color:#111827">{currentUser().portfolioCount}</p></div>
</div>
</div>
<div style="display:flex;align-items:center;gap:22px;min-height:44px;border-bottom:1px solid #E5E7EB;padding:0 22px;overflow:auto">
{([
{ key: 'overview', label: 'Overview' },
{ key: 'personal', label: 'Personal Details' },
{ key: 'roles', label: 'Registered Roles' },
{ key: 'portfolio', label: 'Portfolio' },
{ key: 'verification', label: 'Verification & Approval' },
{ key: 'activity', label: 'Activity' },
{ key: 'notes', label: 'Notes' },
] as const).map((tab) => (
<button type="button" onClick={() => setDetailTab(tab.key)} style={`height:44px;padding:0 2px;white-space:nowrap;font-size:14px;font-weight:500;background:none;border:none;cursor:pointer;${detailTab() === tab.key ? 'color:#FF5E13;box-shadow:inset 0 -2px 0 #FF5E13' : 'color:#6B7280'}`}>
{tab.label}
</button>
))}
</div>
<div style="padding:22px">
<Show when={detailTab() === 'overview'}>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px">
<div style="border:1px solid #E5E7EB;border-radius:12px;padding:16px">
<p style="font-size:18px;font-weight:700;color:#111827">Personal Summary</p>
<div style="margin-top:14px;display:flex;flex-direction:column;gap:10px">
<div style="display:flex;justify-content:space-between"><span style="font-size:13px;color:#6B7280">Full Name</span><span style="font-size:14px;font-weight:600;color:#111827">{currentUser().name}</span></div>
<div style="display:flex;justify-content:space-between"><span style="font-size:13px;color:#6B7280">User Type</span><span style="font-size:14px;font-weight:600;color:#111827">{currentUser().userType}</span></div>
<div style="display:flex;justify-content:space-between"><span style="font-size:13px;color:#6B7280">Last Active</span><span style="font-size:14px;font-weight:600;color:#111827">{currentUser().lastActive || '—'}</span></div>
</div>
</div>
<div style="border:1px solid #E5E7EB;border-radius:12px;padding:16px">
<p style="font-size:18px;font-weight:700;color:#111827">Registered Roles</p>
<Show when={currentUser().registeredRoles.length > 0} fallback={<p style="margin-top:14px;font-size:14px;color:#6B7280">No role registered yet. Use this list for re-engagement campaigns.</p>}>
<div style="margin-top:14px;display:flex;flex-wrap:wrap;gap:8px">
<For each={currentUser().registeredRoles}>{(role) => <RoleChip role={role} />}</For>
</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>
<div class="overflow-x-auto">
<table class="min-w-full">
<thead>
<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>
<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>
<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 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>
)}
</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={() => { 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>
</Show>
</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" />
<Show when={detailTab() === 'personal'}>
<div style="border:1px solid #E5E7EB;border-radius:12px;padding:16px;display:grid;grid-template-columns:1fr 1fr;gap:12px">
<div><p style="font-size:12px;color:#9CA3AF">Name</p><p style="font-size:14px;font-weight:600;color:#111827">{currentUser().name}</p></div>
<div><p style="font-size:12px;color:#9CA3AF">Email</p><p style="font-size:14px;font-weight:600;color:#111827">{currentUser().email}</p></div>
<div><p style="font-size:12px;color:#9CA3AF">Phone</p><p style="font-size:14px;font-weight:600;color:#111827">{currentUser().phone || '—'}</p></div>
<div><p style="font-size:12px;color:#9CA3AF">Location</p><p style="font-size:14px;font-weight:600;color:#111827">{currentUser().location}</p></div>
</div>
</Show>
<Show when={detailTab() === 'roles'}>
<div style="border:1px solid #E5E7EB;border-radius:12px;padding:16px">
<Show when={currentUser().registeredRoles.length > 0} fallback={<p style="font-size:14px;color:#6B7280">No role registrations yet.</p>}>
<div style="display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px">
<For each={currentUser().registeredRoles}>
{(role) => (
<div style="border:1px solid #E5E7EB;border-radius:10px;padding:12px">
<div style="display:flex;justify-content:space-between;align-items:center"><p style="font-size:15px;font-weight:700;color:#111827">{role.name}</p><StatusBadge status={role.status} /></div>
<p style="margin-top:6px;font-size:12px;color:#6B7280">Registered on {role.registeredOn || '—'}</p>
</div>
)}
</For>
</div>
</Show>
</div>
</Show>
<Show when={detailTab() === 'portfolio'}>
<div style="border:1px solid #E5E7EB;border-radius:12px;padding:16px">
<p style="font-size:14px;color:#111827;font-weight:600">Portfolio assets submitted: {currentUser().portfolioCount}</p>
</div>
</Show>
<Show when={detailTab() === 'verification'}>
<div style="border:1px solid #E5E7EB;border-radius:12px;padding:16px;display:flex;gap:12px;align-items:center">
<StatusBadge status={currentUser().verificationStatus} />
<span style="font-size:14px;color:#374151">Onboarding: {currentUser().onboardingStatus.split('_').join(' ')}</span>
</div>
</Show>
<Show when={detailTab() === 'activity'}>
<div style="border:1px solid #E5E7EB;border-radius:12px;padding:16px">
<p style="font-size:14px;color:#111827;font-weight:600">Last Active: {currentUser().lastActive || '—'}</p>
<p style="font-size:13px;color:#6B7280;margin-top:8px">Joined on {currentUser().joinedOn}</p>
</div>
</Show>
<Show when={detailTab() === 'notes'}>
<div style="border:1px solid #E5E7EB;border-radius:12px;padding:16px">
<p style="font-size:14px;color:#111827">{currentUser().notes || 'No notes yet.'}</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 User' : 'Create User'}
</button>
<div style="display:flex;gap:10px;padding:14px 22px;border-top:1px solid #E5E7EB">
<button type="button" onClick={() => 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>
</div>
</Show>
</Show>
<Show when={listTab() !== 'view'}>
<div style="position:relative;margin-left:-24px;margin-right:-24px;border-radius:0;border-left:none;border-right:none;overflow:visible;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 ID, name or email..."
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={() => { setSortOpen((v) => !v); setFilterOpen(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={sortOpen()}>
<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)">
{([
{ key: 'newest', label: 'Newest First' },
{ key: 'oldest', label: 'Oldest First' },
{ key: 'name_asc', label: 'Name (A-Z)' },
{ key: 'name_desc', label: 'Name (Z-A)' },
] as const).map((item) => (
<button type="button" onClick={() => { setSortBy(item.key); setSortOpen(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() === item.key ? '#FF5E13' : '#374151'};background:${sortBy() === item.key ? '#FFF1EB' : 'transparent'}`}>{item.label}</button>
))}
</div>
</Show>
</div>
<div style="position:relative">
<button type="button" onClick={() => { setFilterOpen((v) => !v); setSortOpen(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={filterOpen()}>
<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)">
<p style="font-size:11px;color:#9CA3AF;text-transform:uppercase;letter-spacing:0.05em;padding:4px 8px">Status</p>
{([
{ key: 'all', label: 'All Status' },
{ key: 'active', label: 'Active' },
{ key: 'pending', label: 'Pending' },
{ key: 'suspended', label: 'Suspended' },
{ key: 'blocked', label: 'Blocked' },
] as const).map((item) => (
<button type="button" onClick={() => { setStatusFilter(item.key); }} style={`display:block;width:100%;border:none;background:${statusFilter() === item.key ? '#FFF1EB' : 'transparent'};color:${statusFilter() === item.key ? '#FF5E13' : '#374151'};padding:8px 10px;border-radius:8px;text-align:left;font-size:13px;cursor:pointer`}>{item.label}</button>
))}
<div style="height:1px;background:#F3F4F6;margin:6px 0" />
<p style="font-size:11px;color:#9CA3AF;text-transform:uppercase;letter-spacing:0.05em;padding:4px 8px">Role Group</p>
{([
{ key: 'all', label: 'All Roles' },
{ key: 'no_role', label: 'No Role Users' },
{ key: 'professional', label: 'Professionals' },
{ key: 'company', label: 'Companies' },
{ key: 'jobseeker', label: 'Job Seekers' },
{ key: 'customer', label: 'Service Seekers' },
] as const).map((item) => (
<button type="button" onClick={() => { setRoleFilter(item.key); }} style={`display:block;width:100%;border:none;background:${roleFilter() === item.key ? '#FFF1EB' : 'transparent'};color:${roleFilter() === item.key ? '#FF5E13' : '#374151'};padding:8px 10px;border-radius:8px;text-align:left;font-size:13px;cursor:pointer`}>{item.label}</button>
))}
</div>
</Show>
</div>
<button type="button" onClick={exportCsv} 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 overflow-y-visible">
<table class="min-w-full">
<thead>
<tr style="background:#0D0D2A;text-align:left">
<th style="padding:10px 20px;font-size:11px;font-weight:600;letter-spacing:0.05em;color:#FFFFFF;text-transform:uppercase;white-space:nowrap">User ID</th>
<th style="padding:10px 20px;font-size:11px;font-weight:600;letter-spacing:0.05em;color:#FFFFFF;text-transform:uppercase;white-space:nowrap">User Info</th>
<th style="padding:10px 20px;font-size:11px;font-weight:600;letter-spacing:0.05em;color:#FFFFFF;text-transform:uppercase;white-space:nowrap">Registered Roles</th>
<th style="padding:10px 20px;font-size:11px;font-weight:600;letter-spacing:0.05em;color:#FFFFFF;text-transform:uppercase;white-space:nowrap">Count</th>
<th style="padding:10px 20px;font-size:11px;font-weight:600;letter-spacing:0.05em;color:#FFFFFF;text-transform:uppercase;white-space:nowrap">Status</th>
<th style="padding:10px 20px;font-size:11px;font-weight:600;letter-spacing:0.05em;color:#FFFFFF;text-transform:uppercase;white-space:nowrap">Joined On</th>
<th style="padding:10px 20px;font-size:11px;font-weight:600;letter-spacing:0.05em;color:#FFFFFF;text-transform:uppercase;white-space:nowrap">Actions</th>
</tr>
</thead>
<tbody>
<Show
when={scopedRows().length > 0}
fallback={
<tr>
<td colSpan={7} style="padding:32px;text-align:center">
<p style="font-size:15px;font-weight:600;color:#111827">No users found</p>
<p style="margin-top:6px;font-size:13px;color:#6B7280">Try changing filters or search.</p>
</td>
</tr>
}
>
<For each={scopedRows()}>
{(user) => (
<tr style="border-bottom:1px solid #F3F4F6" class="hover:bg-[#FAFAFA] transition-colors">
<td style="padding:12px 20px;font-size:13px;font-weight:700;color:#334155;white-space:nowrap">{user.userCode}</td>
<td style="padding:12px 20px;min-width:260px">
<div style="display:flex;align-items:center;gap:10px">
<div style="width:36px;height:36px;border-radius:50%;border:1px solid #E5E7EB;background:#F8FAFC;display:flex;align-items:center;justify-content:center;font-size:14px;font-weight:700;color:#94A3B8">{user.name.charAt(0)}</div>
<div>
<p style="font-size:14px;font-weight:700;color:#111827;line-height:1.2">{user.name}</p>
<p style="font-size:12px;color:#64748B">{user.email}</p>
</div>
</div>
</td>
<td style="padding:12px 20px;min-width:220px">
<Show when={user.registeredRoles.length > 0} fallback={<span style="font-size:12px;color:#94A3B8;font-style:italic">No Role Assigned</span>}>
<div style="display:flex;flex-wrap:wrap;gap:6px">
<For each={user.registeredRoles}>{(role) => <RoleChip role={role} />}</For>
</div>
</Show>
</td>
<td style="padding:12px 20px;font-size:14px;font-weight:700;color:#334155">{user.registeredRoles.length}</td>
<td style="padding:12px 20px"><StatusBadge status={user.accountStatus} /></td>
<td style="padding:12px 20px;font-size:13px;color:#475569;white-space:nowrap">{user.joinedOn}</td>
<td style="padding:12px 20px;position:relative;white-space:nowrap">
<button type="button" onClick={() => setOpenMenuId(openMenuId() === user.id ? null : user.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() === user.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" onClick={() => openView(user)} style="display:flex;width:100%;align-items:center;gap:10px;border-radius:8px;padding:10px 12px;font-size:13px;color:#374151;background:none;border:none;cursor:pointer;text-align:left">
<svg style="width:16px;height:16px;color:#FF5E13;flex-shrink:0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
View User
</button>
<button type="button" onClick={() => toggleSuspend(user)} style="display:flex;width:100%;align-items:center;gap:10px;border-radius:8px;padding:10px 12px;font-size:13px;color:#374151;background:none;border:none;cursor:pointer;text-align:left">
<svg style="width:16px;height:16px;color:#FF5E13;flex-shrink:0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M4.9 4.9 19.1 19.1"/></svg>
{user.accountStatus === 'SUSPENDED' ? 'Activate User' : 'Suspend User'}
</button>
<button type="button" onClick={() => toggleBlock(user)} style="display:flex;width:100%;align-items:center;gap:10px;border-radius:8px;padding:10px 12px;font-size:13px;color:#B91C1C;background:none;border:none;cursor:pointer;text-align:left">
<svg style="width:16px;height:16px;flex-shrink:0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M8 8l8 8"/><path d="M16 8l-8 8"/></svg>
{user.accountStatus === 'BLOCKED' ? 'Unblock User' : 'Block User'}
</button>
</div>
</Show>
</td>
</tr>
)}
</For>
</Show>
</tbody>
</table>
</div>
<Show when={scopedRows().length > 0}>
<div style="display:flex;align-items:center;justify-content:space-between;border-top:1px solid #F3F4F6;padding:12px 20px">
<p style="font-size:13px;color:#6B7280">
Showing <strong style="font-weight:600;color:#111827">1{scopedRows().length}</strong> of <strong style="font-weight:600;color:#111827">{rows().length}</strong> users
</p>
<div style="display:flex;align-items:center;gap:4px">
<button type="button" style="display:inline-flex;width:30px;height:30px;align-items:center;justify-content:center;border-radius:7px;border:1px solid #E5E7EB;background:white;color:#6B7280;cursor:pointer;font-size:15px"></button>
<button type="button" style="display:inline-flex;width:30px;height:30px;align-items:center;justify-content:center;border-radius:7px;background:#FF5E13;color:white;font-size:13px;font-weight:600;border:none;cursor:pointer">1</button>
<button type="button" style="display:inline-flex;width:30px;height:30px;align-items:center;justify-content:center;border-radius:7px;border:1px solid #E5E7EB;background:white;color:#374151;font-size:13px;font-weight:500;cursor:pointer">2</button>
<button type="button" style="display:inline-flex;width:30px;height:30px;align-items:center;justify-content:center;border-radius:7px;border:1px solid #E5E7EB;background:white;color:#374151;font-size:13px;font-weight:500;cursor:pointer">3</button>
<button type="button" style="display:inline-flex;width:30px;height:30px;align-items:center;justify-content:center;border-radius:7px;border:1px solid #E5E7EB;background:white;color:#6B7280;cursor:pointer;font-size:15px"></button>
</div>
</div>
</Show>
</div>
</Show>
</div>
</AdminShell>
);

File diff suppressed because it is too large Load diff

View file

@ -1,83 +1,414 @@
import { A, useParams } from '@solidjs/router';
import { createMemo, createResource, Show } from 'solid-js';
import { A, useSearchParams, useParams } from '@solidjs/router';
import { For, Show, createMemo, createSignal } from 'solid-js';
import AdminShell from '~/components/AdminShell';
const API = '/api/gateway';
type Status = 'UNDER_REVIEW' | 'DOCUMENTS_REQUESTED' | 'REVISION_REQUESTED' | 'APPROVED' | 'REJECTED';
type Approval = {
id: string;
requestStatus?: string;
status?: string;
requestType?: string;
type?: string;
requester?: { name?: string; email?: string };
requesterName?: string;
requesterEmail?: string;
requestReason?: string;
const statusTone = (status: Status) => {
if (status === 'APPROVED') return { bg: '#ECFDF3', border: '#BBF7D0', text: '#166534', label: 'Approved' };
if (status === 'DOCUMENTS_REQUESTED') return { bg: '#FFF7ED', border: '#FED7AA', text: '#C2410C', label: 'Request Documents' };
if (status === 'REVISION_REQUESTED') return { bg: '#FFF7ED', border: '#FED7AA', text: '#C2410C', label: 'Request Changes' };
if (status === 'REJECTED') return { bg: '#FEF2F2', border: '#FECACA', text: '#B91C1C', label: 'Rejected' };
return { bg: '#EEF2FF', border: '#C7D2FE', text: '#3730A3', label: 'Under Review' };
};
async function fetchApproval(id: string): Promise<Approval | null> {
try {
const res = await fetch(`${API}/api/admin/approvals/${id}`);
if (!res.ok) return null;
return res.json();
} catch {
return null;
}
}
type DocRequestRow = {
key: string;
title: string;
hint: string;
enabled: boolean;
reason: string;
note: string;
};
export default function VerificationDetailPage() {
type FieldRevision = {
id: string;
field: string;
currentValue: string;
reason: string;
instruction: string;
};
export default function VerificationReviewDetailPage() {
const params = useParams();
const [approval] = createResource(() => params.id, fetchApproval);
const [searchParams, setSearchParams] = useSearchParams();
const status = createMemo(() => (approval()?.requestStatus || approval()?.status || 'PENDING').toUpperCase());
const type = createMemo(() => (approval()?.requestType || approval()?.type || 'PROFILE').toUpperCase());
const requester = createMemo(() => approval()?.requester?.name || approval()?.requesterName || 'Unknown');
const email = createMemo(() => approval()?.requester?.email || approval()?.requesterEmail || '—');
const [status, setStatus] = createSignal<Status>('UNDER_REVIEW');
const [tab, setTab] = createSignal<'overview' | 'submitted' | 'documents' | 'missing' | 'requested' | 'activity'>('submitted');
const [docs, setDocs] = createSignal<DocRequestRow[]>([
{ key: 'identity', title: 'Identity Proof', hint: 'Passport, DL or National ID', enabled: true, reason: 'Expired', note: 'Please upload a current valid ID with clear expiry date.' },
{ key: 'address', title: 'Address Proof', hint: 'Utility bill or bank statement (last 3 months)', enabled: false, reason: 'Not Readable', note: '' },
{ key: 'portfolio', title: 'Professional Portfolio', hint: 'Link or PDF showcasing previous work', enabled: false, reason: 'Insufficient', note: '' },
{ key: 'business', title: 'Business Registration', hint: 'Official certificate of incorporation', enabled: false, reason: 'Wrong Document', note: '' },
{ key: 'tax', title: 'Tax Documents', hint: 'GST/VAT registration or annual tax filings', enabled: false, reason: 'Missing', note: '' },
{ key: 'experience', title: 'Experience Certificate', hint: 'Employment letters from previous employers', enabled: false, reason: 'Missing', note: '' },
]);
const [deadline, setDeadline] = createSignal('2026-04-08');
const [notifyUser, setNotifyUser] = createSignal(true);
const [markActionRequired, setMarkActionRequired] = createSignal(true);
const [revisionRows, setRevisionRows] = createSignal<FieldRevision[]>([
{
id: '1',
field: 'Bio',
currentValue: '"I am a good worker with lots of skills."',
reason: 'Vague',
instruction: 'Please provide a detailed professional bio with role-specific outcomes and project context.',
},
{
id: '2',
field: 'Years of Experience',
currentValue: '"1 year"',
reason: 'Inconsistent',
instruction: 'This value does not match documents. Update to accurate experience timeline.',
},
]);
const [activityNotes, setActivityNotes] = createSignal<string[]>([
'Initial submission received.',
'Verification review started by admin.',
]);
const [reviewerNote, setReviewerNote] = createSignal('Please confirm missing documents and profile correction details.');
const activeAction = createMemo<'none' | 'request-documents' | 'request-changes'>(() => {
const action = String(searchParams.action || '').toLowerCase();
if (action === 'request-documents') return 'request-documents';
if (action === 'request-changes') return 'request-changes';
return 'none';
});
const requestCount = createMemo(() => docs().filter((d) => d.enabled).length + revisionRows().length);
const updateDocRow = (key: string, patch: Partial<DocRequestRow>) => {
setDocs((prev) => prev.map((row) => (row.key === key ? { ...row, ...patch } : row)));
};
const openRequestDocuments = () => {
setSearchParams({ ...searchParams, action: 'request-documents' });
setStatus('DOCUMENTS_REQUESTED');
setTab('documents');
};
const openRequestChanges = () => {
setSearchParams({ ...searchParams, action: 'request-changes' });
setStatus('REVISION_REQUESTED');
setTab('missing');
};
const clearActionMode = () => {
const next = { ...searchParams } as Record<string, string>;
delete next.action;
setSearchParams(next);
};
const sendDocumentRequest = () => {
setStatus('DOCUMENTS_REQUESTED');
setTab('requested');
clearActionMode();
setActivityNotes((prev) => [...prev, 'Document request sent to applicant.']);
};
const sendRevisionRequest = () => {
setStatus('REVISION_REQUESTED');
setTab('requested');
clearActionMode();
setActivityNotes((prev) => [...prev, 'Profile revision request sent to applicant.']);
};
const approveSubmission = () => {
setStatus('APPROVED');
clearActionMode();
setActivityNotes((prev) => [...prev, 'Verification completed and moved to Approval Management.']);
};
const rejectSubmission = () => {
setStatus('REJECTED');
clearActionMode();
setActivityNotes((prev) => [...prev, 'Submission rejected by reviewer.']);
};
const addRevisionField = () => {
const id = String(Date.now());
setRevisionRows((prev) => [...prev, { id, field: 'New Field', currentValue: '"current value"', reason: 'Inconsistent', instruction: '' }]);
};
const tone = createMemo(() => statusTone(status()));
const roleLabel = createMemo(() => String(searchParams.type || 'Portfolio Approval'));
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 Review</h1>
<p class="text-sm text-gray-500 mt-0.5">Review submission context, documents, and verification decision state.</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">Back to Verification</A>
<A class="btn-primary" href={`/admin/approval/${params.id}`}>Open Approval Detail</A>
</div>
</div>
<div class="p-6 flex-1">
<div class="w-full pb-8">
<A href="/admin/verification" style="display:inline-flex;align-items:center;gap:8px;margin-bottom:8px;color:#475569;font-size:13px;text-decoration:none">
Back to Verification Management
</A>
<Show when={approval.loading}>
<div class="rounded-xl border border-gray-200 bg-white shadow-sm"><p class="notice">Loading verification detail...</p></div>
</Show>
<Show when={!approval.loading && !approval()}>
<div class="rounded-xl border border-gray-200 bg-white shadow-sm"><p class="notice">Verification request not found.</p></div>
</Show>
<Show when={approval()}>
<div class="grid" style="margin-top:0">
<section class="rounded-xl border border-gray-200 bg-white shadow-sm">
<h2 style="margin-bottom:8px">Summary</h2>
<p class="notice" style="margin:0"><strong>Approval ID:</strong> {approval()!.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>Requester:</strong> {requester()}</p>
<p class="notice" style="margin:8px 0 0"><strong>Email:</strong> {email()}</p>
</section>
<section class="rounded-xl border border-gray-200 bg-white shadow-sm">
<h2 style="margin-bottom:8px">Remark Snapshot</h2>
<p class="notice" style="margin:0">
This route mirrors the Next.js verification detail entry point and delegates action workflow to Approval Management.
</p>
<div class="actions">
<A class="btn-primary" href={`/admin/approval/${params.id}`}>Review & Take Action</A>
</div>
</section>
<div style="display:flex;align-items:flex-start;justify-content:space-between;gap:12px;flex-wrap:wrap">
<div>
<h1 style="margin:0;font-size:42px;line-height:1.08;font-weight:800;color:#111827">Review Submission: #{params.id}</h1>
<p style="margin:8px 0 0;font-size:14px;color:#6B7280">Applicant verification detail and action flow.</p>
</div>
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap">
<span style={`display:inline-flex;align-items:center;height:32px;padding:0 12px;border-radius:999px;border:1px solid ${tone().border};background:${tone().bg};color:${tone().text};font-size:12px;font-weight:700`}>
{tone().label}
</span>
<button type="button" onClick={openRequestDocuments} style="height:36px;border-radius:10px;border:1px solid #E5E7EB;background:white;padding:0 12px;font-size:13px;font-weight:700;color:#374151">Request Documents</button>
<button type="button" onClick={openRequestChanges} style="height:36px;border-radius:10px;border:1px solid #E5E7EB;background:white;padding:0 12px;font-size:13px;font-weight:700;color:#374151">Request Changes</button>
<button type="button" onClick={rejectSubmission} style="height:36px;border-radius:10px;border:1px solid #FECACA;background:white;padding:0 12px;font-size:13px;font-weight:700;color:#B91C1C">Reject</button>
<button type="button" onClick={approveSubmission} style="height:36px;border-radius:10px;border:none;background:#0D0D2A;padding:0 14px;font-size:13px;font-weight:700;color:white">Approve Submission</button>
</div>
</div>
<div style="margin-top:14px;display:grid;grid-template-columns:2fr 1fr;gap:12px">
<div style="border:1px solid #E5E7EB;background:white;border-radius:14px;padding:14px;display:grid;grid-template-columns:1fr 1fr 1fr;gap:14px">
<div><p style="margin:0;font-size:11px;color:#9CA3AF;text-transform:uppercase;letter-spacing:0.04em">Applicant</p><p style="margin:6px 0 0;font-size:30px;font-weight:800;color:#111827;line-height:1.1">Sarah Jenkins</p></div>
<div><p style="margin:0;font-size:11px;color:#9CA3AF;text-transform:uppercase;letter-spacing:0.04em">Type</p><p style="margin:6px 0 0;font-size:18px;font-weight:700;color:#111827">{roleLabel()}</p></div>
<div><p style="margin:0;font-size:11px;color:#9CA3AF;text-transform:uppercase;letter-spacing:0.04em">Location</p><p style="margin:6px 0 0;font-size:18px;font-weight:700;color:#111827">Chennai, India</p></div>
</div>
<div style="border:1px solid #E5E7EB;background:white;border-radius:14px;padding:14px">
<p style="margin:0;font-size:12px;font-weight:700;color:#64748B;letter-spacing:0.06em;text-transform:uppercase">Submission Summary</p>
<div style="margin-top:10px;display:grid;grid-template-columns:1fr auto;gap:6px 12px;font-size:13px;color:#374151">
<span>Sub ID</span><strong style="color:#111827">#{params.id}</strong>
<span>Date Submitted</span><strong style="color:#111827">2026-04-01</strong>
<span>Reviewer</span><strong style="color:#111827">Admin User</strong>
<span>Pending Requests</span><strong style="color:#111827">{requestCount()}</strong>
</div>
</div>
</div>
<div style="margin-top:12px;display:flex;align-items:center;gap:20px;border-bottom:1px solid #E5E7EB;overflow:auto">
{([
{ key: 'overview', label: 'Overview' },
{ key: 'submitted', label: 'Submitted Details' },
{ key: 'documents', label: 'Documents' },
{ key: 'missing', label: 'Missing Information' },
{ key: 'requested', label: `Requested Actions (${requestCount()})` },
{ key: 'activity', label: 'Activity Log' },
] as const).map((item) => (
<button
type="button"
onClick={() => setTab(item.key)}
style={`padding:0 0 10px;font-size:13px;font-weight:500;background:none;border:none;white-space:nowrap;cursor:pointer;${tab() === item.key ? 'color:#FF5E13;border-bottom:2px solid #FF5E13;margin-bottom:-1px' : 'color:#6B7280'}`}
>
{item.label}
</button>
))}
</div>
<div style="margin-top:12px;display:grid;grid-template-columns:2fr 1fr;gap:12px;align-items:start">
<div>
<Show when={activeAction() === 'request-documents'}>
<div class="table-card" style="border-radius:14px;padding:14px">
<h3 style="margin:0;font-size:28px;font-weight:800;color:#111827">Request Documents</h3>
<p style="margin:8px 0 12px;font-size:13px;color:#64748B">Select missing or invalid documents and send one clear request.</p>
<div style="display:grid;gap:10px">
<For each={docs()}>
{(row) => (
<div style="border:1px solid #E5E7EB;border-radius:12px;background:#FAFAFA;padding:12px;display:grid;grid-template-columns:auto 1fr;gap:10px;align-items:flex-start">
<input type="checkbox" checked={row.enabled} onChange={(e) => updateDocRow(row.key, { enabled: e.currentTarget.checked })} style="margin-top:4px;width:16px;height:16px;accent-color:#FF5E13" />
<div>
<p style="margin:0;font-size:18px;font-weight:700;color:#111827">{row.title}</p>
<p style="margin:4px 0 8px;font-size:12px;color:#6B7280">{row.hint}</p>
<Show when={row.enabled}>
<div style="display:grid;grid-template-columns:1fr;gap:8px">
<select value={row.reason} onChange={(e) => updateDocRow(row.key, { reason: e.currentTarget.value })} style="height:36px;border:1px solid #E5E7EB;border-radius:8px;padding:0 10px;background:white;font-size:13px;color:#374151">
<option>Missing</option>
<option>Expired</option>
<option>Not Readable</option>
<option>Wrong Document</option>
<option>Insufficient</option>
</select>
<textarea value={row.note} onInput={(e) => updateDocRow(row.key, { note: e.currentTarget.value })} placeholder="Reviewer note" style="min-height:64px;border:1px solid #E5E7EB;border-radius:8px;padding:8px 10px;resize:vertical;background:white;font-size:13px;color:#374151" />
</div>
</Show>
</div>
</div>
)}
</For>
</div>
<div style="margin-top:12px;display:grid;grid-template-columns:1fr 1fr;gap:10px">
<div style="border:1px solid #E5E7EB;border-radius:10px;background:#FFFFFF;padding:10px">
<p style="margin:0 0 8px;font-size:12px;font-weight:700;color:#6B7280;text-transform:uppercase;letter-spacing:0.04em">Submission Deadline</p>
<input type="date" value={deadline()} onInput={(e) => setDeadline(e.currentTarget.value)} style="height:36px;width:100%;border:1px solid #E5E7EB;border-radius:8px;padding:0 10px;font-size:13px;color:#374151" />
</div>
<div style="border:1px solid #E5E7EB;border-radius:10px;background:#FFFFFF;padding:10px;display:grid;gap:8px">
<label style="display:flex;align-items:center;justify-content:space-between;gap:8px;font-size:13px;color:#374151;font-weight:600">
Notify user via email/SMS
<input type="checkbox" checked={notifyUser()} onChange={(e) => setNotifyUser(e.currentTarget.checked)} />
</label>
<label style="display:flex;align-items:center;justify-content:space-between;gap:8px;font-size:13px;color:#374151;font-weight:600">
Mark submission as action required
<input type="checkbox" checked={markActionRequired()} onChange={(e) => setMarkActionRequired(e.currentTarget.checked)} />
</label>
</div>
</div>
<div style="margin-top:12px;display:flex;justify-content:flex-end;gap:8px">
<button type="button" onClick={clearActionMode} style="height:36px;border-radius:10px;border:1px solid #E5E7EB;background:white;padding:0 12px;font-size:13px;font-weight:700;color:#475569">Cancel</button>
<button type="button" onClick={sendDocumentRequest} style="height:36px;border-radius:10px;border:none;background:#0D0D2A;padding:0 12px;font-size:13px;font-weight:700;color:white">Send Request</button>
</div>
</div>
</Show>
<Show when={activeAction() === 'request-changes'}>
<div class="table-card" style="border-radius:14px;padding:14px">
<h3 style="margin:0;font-size:28px;font-weight:800;color:#111827">Request Information Changes</h3>
<p style="margin:8px 0 12px;font-size:13px;color:#64748B">Detail profile fields that need correction or clarification.</p>
<div style="display:grid;gap:10px">
<For each={revisionRows()}>
{(row) => (
<div style="border:1px solid #E5E7EB;border-radius:12px;background:#FAFAFA;padding:12px">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px">
<div>
<p style="margin:0 0 6px;font-size:11px;color:#9CA3AF;text-transform:uppercase">Field Name</p>
<input value={row.field} onInput={(e) => setRevisionRows((prev) => prev.map((item) => item.id === row.id ? { ...item, field: e.currentTarget.value } : item))} style="height:36px;width:100%;border:1px solid #E5E7EB;border-radius:8px;padding:0 10px;background:white;font-size:13px;color:#374151" />
</div>
<div>
<p style="margin:0 0 6px;font-size:11px;color:#9CA3AF;text-transform:uppercase">Current Value</p>
<input value={row.currentValue} onInput={(e) => setRevisionRows((prev) => prev.map((item) => item.id === row.id ? { ...item, currentValue: e.currentTarget.value } : item))} style="height:36px;width:100%;border:1px solid #E5E7EB;border-radius:8px;padding:0 10px;background:white;font-size:13px;color:#374151" />
</div>
<div>
<p style="margin:0 0 6px;font-size:11px;color:#9CA3AF;text-transform:uppercase">Reason</p>
<select value={row.reason} onChange={(e) => setRevisionRows((prev) => prev.map((item) => item.id === row.id ? { ...item, reason: e.currentTarget.value } : item))} style="height:36px;width:100%;border:1px solid #E5E7EB;border-radius:8px;padding:0 10px;background:white;font-size:13px;color:#374151">
<option>Inconsistent</option>
<option>Vague</option>
<option>Mismatch</option>
<option>Incomplete</option>
</select>
</div>
<div>
<p style="margin:0 0 6px;font-size:11px;color:#9CA3AF;text-transform:uppercase">Reviewer Instruction</p>
<input value={row.instruction} onInput={(e) => setRevisionRows((prev) => prev.map((item) => item.id === row.id ? { ...item, instruction: e.currentTarget.value } : item))} style="height:36px;width:100%;border:1px solid #E5E7EB;border-radius:8px;padding:0 10px;background:white;font-size:13px;color:#374151" />
</div>
</div>
</div>
)}
</For>
</div>
<button type="button" onClick={addRevisionField} style="margin-top:10px;height:36px;border-radius:10px;border:1px dashed #FFD8C2;background:#FFFCFA;padding:0 12px;font-size:13px;font-weight:700;color:#C2410C">+ Add Another Field</button>
<div style="margin-top:12px;display:flex;justify-content:flex-end;gap:8px">
<button type="button" onClick={clearActionMode} style="height:36px;border-radius:10px;border:1px solid #E5E7EB;background:white;padding:0 12px;font-size:13px;font-weight:700;color:#475569">Cancel</button>
<button type="button" onClick={sendRevisionRequest} style="height:36px;border-radius:10px;border:none;background:#0D0D2A;padding:0 12px;font-size:13px;font-weight:700;color:white">Submit Revision Request</button>
</div>
</div>
</Show>
<Show when={activeAction() === 'none' && tab() === 'submitted'}>
<div class="table-card" style="border-radius:14px;padding:14px">
<h3 style="margin:0 0 10px;font-size:24px;font-weight:800;color:#111827">Profile Data</h3>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px">
<div><p style="margin:0;font-size:11px;color:#9CA3AF;letter-spacing:0.04em;text-transform:uppercase">Full Name</p><p style="margin:6px 0 0;font-size:18px;font-weight:700;color:#111827">Sarah Jenkins</p></div>
<div><p style="margin:0;font-size:11px;color:#9CA3AF;letter-spacing:0.04em;text-transform:uppercase">Role</p><p style="margin:6px 0 0;font-size:18px;font-weight:700;color:#111827">Professional Photographer</p></div>
</div>
<p style="margin:14px 0 0;font-size:13px;line-height:1.6;color:#374151">Visual storyteller with a passion for capturing authentic moments. Specializing in high-end wedding and lifestyle photography with focus on natural lighting and candid emotions.</p>
</div>
</Show>
<Show when={activeAction() === 'none' && tab() === 'documents'}>
<div class="table-card" style="border-radius:14px;overflow:hidden">
<table class="data-table w-full text-sm">
<thead><tr>{['Document', 'Current State', 'Action'].map((h) => <th>{h}</th>)}</tr></thead>
<tbody>
<For each={docs()}>{(row) => (
<tr>
<td><div><strong>{row.title}</strong><p style="margin:2px 0 0;font-size:12px;color:#64748B">{row.hint}</p></div></td>
<td>{row.enabled ? 'Requested' : 'Received'}</td>
<td><button type="button" onClick={openRequestDocuments} style="height:30px;border-radius:8px;border:1px solid #E5E7EB;background:white;padding:0 10px;font-size:12px;font-weight:700;color:#374151">Request</button></td>
</tr>
)}</For>
</tbody>
</table>
</div>
</Show>
<Show when={activeAction() === 'none' && tab() === 'missing'}>
<div class="table-card" style="border-radius:14px;padding:14px">
<h3 style="margin:0 0 10px;font-size:22px;font-weight:800;color:#111827">Flagged Fields for Correction</h3>
<div style="display:grid;gap:10px">
<For each={revisionRows()}>{(row) => (
<div style="border:1px solid #E5E7EB;border-radius:12px;background:#FAFAFA;padding:12px">
<p style="margin:0;font-size:14px;font-weight:700;color:#111827">{row.field}</p>
<p style="margin:4px 0 0;font-size:13px;color:#64748B">Current: {row.currentValue}</p>
<p style="margin:6px 0 0;font-size:13px;color:#374151">{row.instruction || 'Please update this field based on documents.'}</p>
</div>
)}</For>
</div>
<button type="button" onClick={openRequestChanges} style="margin-top:10px;height:36px;border-radius:10px;border:1px solid #E5E7EB;background:white;padding:0 12px;font-size:13px;font-weight:700;color:#374151">Request Changes</button>
</div>
</Show>
<Show when={activeAction() === 'none' && tab() === 'requested'}>
<div class="table-card" style="border-radius:14px;padding:14px">
<h3 style="margin:0 0 10px;font-size:22px;font-weight:800;color:#111827">Pending Requests</h3>
<div style="display:grid;gap:10px">
<div style="border:1px solid #E5E7EB;border-radius:12px;background:#FAFAFA;padding:12px">
<p style="margin:0;font-size:18px;font-weight:700;color:#111827">Update Bio - Incomplete</p>
<p style="margin:6px 0 0;font-size:13px;color:#64748B">The professional biography must include specific role experience and outcomes.</p>
<div style="margin-top:8px;display:flex;gap:8px">
<button type="button" style="height:32px;border-radius:10px;border:1px solid #E5E7EB;background:white;padding:0 12px;font-size:13px;font-weight:700;color:#475569">Resend Notification</button>
<button type="button" style="height:32px;border-radius:10px;border:none;background:#0D0D2A;padding:0 12px;font-size:13px;font-weight:700;color:white">Mark as Resolved</button>
</div>
</div>
<div style="border:1px solid #E5E7EB;border-radius:12px;background:#FAFAFA;padding:12px">
<p style="margin:0;font-size:18px;font-weight:700;color:#111827">Re-upload Identity Proof - Expired</p>
<p style="margin:6px 0 0;font-size:13px;color:#64748B">Submitted passport scan shows expired date. Upload current valid ID.</p>
<div style="margin-top:8px;display:flex;gap:8px">
<button type="button" style="height:32px;border-radius:10px;border:1px solid #E5E7EB;background:white;padding:0 12px;font-size:13px;font-weight:700;color:#475569">Resend Notification</button>
<button type="button" style="height:32px;border-radius:10px;border:none;background:#0D0D2A;padding:0 12px;font-size:13px;font-weight:700;color:white">Mark as Resolved</button>
</div>
</div>
</div>
</div>
</Show>
<Show when={activeAction() === 'none' && tab() === 'activity'}>
<div class="table-card" style="border-radius:14px;padding:14px">
<h3 style="margin:0 0 10px;font-size:22px;font-weight:800;color:#111827">Activity Log</h3>
<div style="display:grid;gap:8px">
<For each={activityNotes()}>{(note) => (
<div style="border:1px solid #E5E7EB;border-radius:10px;background:#FAFAFA;padding:10px;font-size:13px;color:#374151">{note}</div>
)}</For>
</div>
</div>
</Show>
<Show when={activeAction() === 'none' && tab() === 'overview'}>
<div class="table-card" style="border-radius:14px;padding:14px">
<h3 style="margin:0 0 10px;font-size:22px;font-weight:800;color:#111827">Overview</h3>
<p style="margin:0;font-size:13px;line-height:1.6;color:#374151">Review submission details, verify uploaded documents, request missing information when needed, then move eligible submissions to final approval management.</p>
</div>
</Show>
</div>
<div style="display:grid;gap:12px">
<div class="table-card" style="border-radius:14px;padding:14px">
<p style="margin:0;font-size:12px;font-weight:700;color:#64748B;letter-spacing:0.06em;text-transform:uppercase">Verification Checklist</p>
<div style="margin-top:10px;display:grid;gap:8px;font-size:13px;color:#111827">
<label style="display:flex;gap:8px;align-items:center"><input type="checkbox" checked /> Identity Verified</label>
<label style="display:flex;gap:8px;align-items:center"><input type="checkbox" checked /> Contact Details Checked</label>
<label style="display:flex;gap:8px;align-items:center"><input type="checkbox" /> Portfolio Quality Check</label>
<label style="display:flex;gap:8px;align-items:center"><input type="checkbox" /> Social Media Verification</label>
</div>
</div>
<div class="table-card" style="border-radius:14px;padding:14px">
<p style="margin:0;font-size:12px;font-weight:700;color:#64748B;letter-spacing:0.06em;text-transform:uppercase">Reviewer Notes</p>
<textarea value={reviewerNote()} onInput={(e) => setReviewerNote(e.currentTarget.value)} style="margin-top:8px;width:100%;min-height:110px;border:1px solid #E5E7EB;border-radius:10px;padding:10px;font-size:13px;color:#374151;resize:vertical" />
<button type="button" style="margin-top:8px;height:36px;width:100%;border-radius:10px;border:none;background:#0D0D2A;color:white;font-size:13px;font-weight:700">Add Note</button>
</div>
<A href="/admin/approval" style="height:36px;border-radius:10px;border:none;background:#0D0D2A;padding:0 12px;font-size:13px;font-weight:700;color:white;display:inline-flex;align-items:center;justify-content:center;text-decoration:none">
Open Approval Management
</A>
</div>
</div>
</Show>
</div>
</div>
</AdminShell>
);