Align admin management tables and flows with department pattern
This commit is contained in:
parent
ad8a17a766
commit
82036a0608
11 changed files with 4674 additions and 1871 deletions
File diff suppressed because it is too large
Load diff
|
|
@ -57,6 +57,7 @@ const ONBOARDING_SCHEMA_OPTIONS = [
|
||||||
'social_media_manager_onboarding_v1',
|
'social_media_manager_onboarding_v1',
|
||||||
'fitness_trainer_onboarding_v1',
|
'fitness_trainer_onboarding_v1',
|
||||||
'catering_service_onboarding_v1',
|
'catering_service_onboarding_v1',
|
||||||
|
'ugc_content_creator_onboarding_v1',
|
||||||
'default_onboarding_v1',
|
'default_onboarding_v1',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
@ -142,7 +143,7 @@ const DEFAULT_PRESETS: Record<string, ExternalRoleConfig> = {
|
||||||
},
|
},
|
||||||
customer: {
|
customer: {
|
||||||
roleKey: 'customer',
|
roleKey: 'customer',
|
||||||
displayName: 'Customer',
|
displayName: 'Service Seeker',
|
||||||
vertical: 'marketplace',
|
vertical: 'marketplace',
|
||||||
roleCategory: 'consumer',
|
roleCategory: 'consumer',
|
||||||
enabledModules: ['dashboard', 'profile', 'requirements', 'marketplace', 'wallet', 'notifications', 'settings'],
|
enabledModules: ['dashboard', 'profile', 'requirements', 'marketplace', 'wallet', 'notifications', 'settings'],
|
||||||
|
|
@ -163,6 +164,21 @@ const DEFAULT_PRESETS: Record<string, ExternalRoleConfig> = {
|
||||||
runtimeConfigVersion: 1,
|
runtimeConfigVersion: 1,
|
||||||
isActive: true,
|
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: {
|
photographer: {
|
||||||
roleKey: 'photographer',
|
roleKey: 'photographer',
|
||||||
displayName: 'Photographer',
|
displayName: 'Photographer',
|
||||||
|
|
@ -476,7 +492,7 @@ export default function ExternalRoleForm(props: ExternalRoleFormProps) {
|
||||||
<option value="job_seeker">Job Seeker</option>
|
<option value="job_seeker">Job Seeker</option>
|
||||||
</optgroup>
|
</optgroup>
|
||||||
<optgroup label="Marketplace — Consumer">
|
<optgroup label="Marketplace — Consumer">
|
||||||
<option value="customer">Customer</option>
|
<option value="customer">Service Seeker</option>
|
||||||
</optgroup>
|
</optgroup>
|
||||||
<optgroup label="Marketplace — Providers">
|
<optgroup label="Marketplace — Providers">
|
||||||
<option value="photographer">Photographer</option>
|
<option value="photographer">Photographer</option>
|
||||||
|
|
@ -484,6 +500,7 @@ export default function ExternalRoleForm(props: ExternalRoleFormProps) {
|
||||||
<option value="tutor">Tutor</option>
|
<option value="tutor">Tutor</option>
|
||||||
<option value="developer">Developer</option>
|
<option value="developer">Developer</option>
|
||||||
<option value="video_editor">Video Editor</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="graphic_designer">Graphic Designer</option>
|
||||||
<option value="social_media_manager">Social Media Manager</option>
|
<option value="social_media_manager">Social Media Manager</option>
|
||||||
<option value="fitness_trainer">Fitness Trainer</option>
|
<option value="fitness_trainer">Fitness Trainer</option>
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,7 @@ const ROLE_OPTIONS = [
|
||||||
'job_seeker',
|
'job_seeker',
|
||||||
'jobseeker',
|
'jobseeker',
|
||||||
'customer',
|
'customer',
|
||||||
|
'service_seeker',
|
||||||
'professional',
|
'professional',
|
||||||
'photographer',
|
'photographer',
|
||||||
'video_editor',
|
'video_editor',
|
||||||
|
|
@ -68,6 +69,7 @@ const ROLE_OPTIONS = [
|
||||||
'makeup_artist',
|
'makeup_artist',
|
||||||
'tutor',
|
'tutor',
|
||||||
'developer',
|
'developer',
|
||||||
|
'ugc_content_creator',
|
||||||
];
|
];
|
||||||
|
|
||||||
function fallbackRoleOptions(): { value: string; label: string }[] {
|
function fallbackRoleOptions(): { value: string; label: string }[] {
|
||||||
|
|
@ -203,7 +205,7 @@ export function createDefaultFields(roleKey: string): OnboardingField[] {
|
||||||
'Professional role',
|
'Professional role',
|
||||||
'select',
|
'select',
|
||||||
true,
|
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('full_name', 'Full name', 'text', true),
|
||||||
createField('experience', 'Experience', 'text', true),
|
createField('experience', 'Experience', 'text', true),
|
||||||
|
|
@ -225,7 +227,7 @@ export function createDefaultFields(roleKey: string): OnboardingField[] {
|
||||||
'Service category',
|
'Service category',
|
||||||
'select',
|
'select',
|
||||||
true,
|
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('event_type', 'Event type', 'text', false),
|
||||||
createField('coverage_hours', 'Coverage hours', 'number', 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('availability', 'Availability', 'select', true, ['weekdays', 'weekends', 'all_days']),
|
||||||
createField('identity_document', 'Identity Document', 'file', true),
|
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: [
|
graphic_designer: [
|
||||||
createField('full_name', 'Full name', 'text', true),
|
createField('full_name', 'Full name', 'text', true),
|
||||||
createField('email', 'Email', 'email', true),
|
createField('email', 'Email', 'email', true),
|
||||||
|
|
@ -377,7 +392,7 @@ export function createDefaultFields(roleKey: string): OnboardingField[] {
|
||||||
|
|
||||||
if (role === 'company') return company;
|
if (role === 'company') return company;
|
||||||
if (role === 'job_seeker' || role === 'jobseeker') return jobseeker;
|
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 (role === 'professional') return professionalBase;
|
||||||
if (specialistOverrides[role]) return specialistOverrides[role];
|
if (specialistOverrides[role]) return specialistOverrides[role];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { createMemo } from 'solid-js';
|
||||||
import AdminShell from '~/components/AdminShell';
|
import AdminShell from '~/components/AdminShell';
|
||||||
import ApprovalManagementPage from './approval';
|
import ApprovalManagementPage from './approval';
|
||||||
import VerificationManagementPage from './verification';
|
import VerificationManagementPage from './verification';
|
||||||
|
import UsersManagementPage from './users';
|
||||||
|
|
||||||
function toTitle(value: string): string {
|
function toTitle(value: string): string {
|
||||||
return value
|
return value
|
||||||
|
|
@ -46,6 +47,10 @@ export default function LegacyModuleShellPage() {
|
||||||
return <VerificationManagementPage />;
|
return <VerificationManagementPage />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (modulePath === 'users' || modulePath === 'users-management' || modulePath === 'user-management') {
|
||||||
|
return <UsersManagementPage />;
|
||||||
|
}
|
||||||
|
|
||||||
const moduleName = createMemo(() => toTitle(modulePath || 'Management'));
|
const moduleName = createMemo(() => toTitle(modulePath || 'Management'));
|
||||||
const legacyPath = createMemo(() => resolveLegacyPath(modulePath));
|
const legacyPath = createMemo(() => resolveLegacyPath(modulePath));
|
||||||
const legacyUrl = createMemo(() => `${LEGACY_ADMIN_ORIGIN}${legacyPath()}`);
|
const legacyUrl = createMemo(() => `${LEGACY_ADMIN_ORIGIN}${legacyPath()}`);
|
||||||
|
|
|
||||||
|
|
@ -3,10 +3,20 @@ import AdminShell from '~/components/AdminShell';
|
||||||
import type { CrudRecord } from '~/lib/admin/types';
|
import type { CrudRecord } from '~/lib/admin/types';
|
||||||
|
|
||||||
const API = '/api/gateway';
|
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 & {
|
type ApprovalRecord = CrudRecord & {
|
||||||
applicantName?: string;
|
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';
|
userType: 'CUSTOMER' | 'PROFESSIONAL' | 'COMPANY' | 'JOBSEEKER';
|
||||||
roleTags?: string[];
|
roleTags?: string[];
|
||||||
primaryService?: string;
|
primaryService?: string;
|
||||||
|
|
@ -16,12 +26,44 @@ type ApprovalRecord = CrudRecord & {
|
||||||
assignedApprover?: string;
|
assignedApprover?: string;
|
||||||
priority: 'LOW' | 'MEDIUM' | 'HIGH' | 'CRITICAL';
|
priority: 'LOW' | 'MEDIUM' | 'HIGH' | 'CRITICAL';
|
||||||
status: 'PENDING' | 'IN_REVIEW' | 'APPROVED' | 'REJECTED' | 'ON_HOLD' | 'ESCALATED';
|
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 || '')
|
const toTitle = (value: string) => String(value || '')
|
||||||
.replace(/_/g, ' ')
|
.replace(/_/g, ' ')
|
||||||
.replace(/\b\w/g, (c) => c.toUpperCase());
|
.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[] {
|
function extractRoleTags(source: any): string[] {
|
||||||
const values: string[] = [];
|
const values: string[] = [];
|
||||||
const pushValue = (value: unknown) => {
|
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);
|
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 }) {
|
function StatusBadge(props: { status: string }) {
|
||||||
const getColors = () => {
|
const getColors = () => {
|
||||||
switch (props.status) {
|
switch (props.status) {
|
||||||
|
|
@ -112,6 +373,20 @@ export default function ApprovalManagementPage() {
|
||||||
const [error, setError] = createSignal('');
|
const [error, setError] = createSignal('');
|
||||||
const [isActing, setIsActing] = createSignal(false);
|
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 () => {
|
const load = async () => {
|
||||||
setError('');
|
setError('');
|
||||||
try {
|
try {
|
||||||
|
|
@ -127,47 +402,112 @@ export default function ApprovalManagementPage() {
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error(`Request failed (${res.status})`);
|
if (!res.ok) throw new Error(`Request failed (${res.status})`);
|
||||||
const payload = await res.json().catch(() => ({} as any));
|
const payload = await res.json().catch(() => ({} as any));
|
||||||
|
|
||||||
const jobs = Array.isArray(payload?.jobs) ? payload.jobs : [];
|
const jobs = Array.isArray(payload?.jobs) ? payload.jobs : [];
|
||||||
const requirements = Array.isArray(payload?.requirements) ? payload.requirements : [];
|
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) => ({
|
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')}`,
|
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',
|
approvalType: 'JOB',
|
||||||
userType: 'COMPANY',
|
userType: 'COMPANY',
|
||||||
roleTags: extractRoleTags(job),
|
roleTags: extractRoleTags(job),
|
||||||
primaryService: String(job.category || job.department || job.role || 'Job Posting'),
|
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 || ''),
|
submittedDate: String(job.created_at || ''),
|
||||||
verificationStatus: 'VERIFIED',
|
verificationStatus: 'VERIFIED',
|
||||||
assignedApprover: 'Unassigned',
|
assignedApprover: 'Unassigned',
|
||||||
priority: 'HIGH',
|
priority: 'HIGH',
|
||||||
status: 'PENDING',
|
status: 'PENDING',
|
||||||
updatedAt: String(job.updated_at || job.created_at || ''),
|
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) => ({
|
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')}`,
|
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',
|
approvalType: 'REQUIREMENT',
|
||||||
userType: 'CUSTOMER',
|
userType: 'CUSTOMER',
|
||||||
roleTags: extractRoleTags(req),
|
roleTags: extractRoleTags(req),
|
||||||
primaryService: String(req.category || req.profession || req.service_type || 'Requirement'),
|
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 || ''),
|
submittedDate: String(req.created_at || ''),
|
||||||
verificationStatus: 'VERIFIED',
|
verificationStatus: 'VERIFIED',
|
||||||
assignedApprover: 'Unassigned',
|
assignedApprover: 'Unassigned',
|
||||||
priority: 'MEDIUM',
|
priority: 'MEDIUM',
|
||||||
status: 'PENDING',
|
status: 'PENDING',
|
||||||
updatedAt: String(req.updated_at || req.created_at || ''),
|
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) {
|
} catch (e: any) {
|
||||||
setRows([]);
|
setRows(FALLBACK_APPROVAL_ROWS);
|
||||||
setError(e?.message || 'Could not reach approvals API.');
|
setError(e?.message || 'Could not reach approvals API. Showing demo approval queue data.');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -242,12 +582,23 @@ export default function ApprovalManagementPage() {
|
||||||
setOpenMenuId(null);
|
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 runApprovalAction = async (row: ApprovalRecord, action: 'approve' | 'reject') => {
|
||||||
const type = row.approvalType;
|
const type = row.approvalType;
|
||||||
|
const nextStatus: ApprovalRecord['status'] = action === 'approve' ? 'APPROVED' : 'REJECTED';
|
||||||
|
|
||||||
if (type !== 'JOB' && type !== 'REQUIREMENT') {
|
if (type !== 'JOB' && type !== 'REQUIREMENT') {
|
||||||
setError(`Action is not supported for approval type "${type}".`);
|
setLocalStatus(row, nextStatus);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsActing(true);
|
setIsActing(true);
|
||||||
setError('');
|
setError('');
|
||||||
try {
|
try {
|
||||||
|
|
@ -255,8 +606,8 @@ export default function ApprovalManagementPage() {
|
||||||
? sessionStorage.getItem('nxtgauge_admin_access_token') || ''
|
? sessionStorage.getItem('nxtgauge_admin_access_token') || ''
|
||||||
: '';
|
: '';
|
||||||
const endpoint = type === 'JOB'
|
const endpoint = type === 'JOB'
|
||||||
? `${API}/api/admin/approvals/jobs/${row.id}/${action}`
|
? `${API}/api/admin/approvals/jobs/${String(row.id).replace(/^AP-J-/, '')}/${action}`
|
||||||
: `${API}/api/admin/approvals/requirements/${row.id}/${action}`;
|
: `${API}/api/admin/approvals/requirements/${String(row.id).replace(/^AP-R-/, '')}/${action}`;
|
||||||
const res = await fetch(endpoint, {
|
const res = await fetch(endpoint, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
|
|
@ -286,8 +637,7 @@ export default function ApprovalManagementPage() {
|
||||||
return (
|
return (
|
||||||
<AdminShell>
|
<AdminShell>
|
||||||
<div class="w-full space-y-6 pb-8">
|
<div class="w-full space-y-6 pb-8">
|
||||||
|
|
||||||
{/* Page header */}
|
|
||||||
<div style="margin-bottom: 1.5rem">
|
<div style="margin-bottom: 1.5rem">
|
||||||
<h1 class="text-[28px] font-bold leading-tight text-[#111827]">Approval Management</h1>
|
<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>
|
<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>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
{/* ── LIST VIEW ── */}
|
|
||||||
<Show when={true}>
|
<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: 'all', label: 'All Approvals', action: () => { setListTab('all'); setStatusFilter('all'); } },
|
||||||
{ key: 'escalated', label: `Escalated (${escalatedCount()})`, action: () => { setListTab('escalated'); setStatusFilter('escalated'); } },
|
{ key: 'escalated', label: `Escalated (${escalatedCount()})`, action: () => { setListTab('escalated'); setStatusFilter('escalated'); } },
|
||||||
|
|
@ -309,7 +658,7 @@ export default function ApprovalManagementPage() {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={tab.action}
|
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}
|
{tab.label}
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -340,7 +689,7 @@ export default function ApprovalManagementPage() {
|
||||||
</div>
|
</div>
|
||||||
</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) => {
|
{(['overview', 'verification', 'checklist', 'logs'] as const).map((tab, i) => {
|
||||||
const labels = ['Overview', 'Verification Summary', 'Approval Checklist', 'Activity Logs'];
|
const labels = ['Overview', 'Verification Summary', 'Approval Checklist', 'Activity Logs'];
|
||||||
const active = () => detailTab() === tab;
|
const active = () => detailTab() === tab;
|
||||||
|
|
@ -380,25 +729,24 @@ export default function ApprovalManagementPage() {
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
<div style="border:1px solid #E5E7EB;border-radius:12px;padding:20px">
|
|
||||||
<h3 style="font-size:14px;font-weight:700;color:#111827;margin-bottom:16px">Approval Decision Path</h3>
|
<Show when={selectedFields().length > 0}>
|
||||||
<div style="display:flex;justify-content:space-between;align-items:center;position:relative">
|
<div style="border:1px solid #E5E7EB;border-radius:12px;padding:20px">
|
||||||
<div style="position:absolute;top:10px;left:0;right:0;height:2px;background:#E5E7EB;z-index:1" />
|
<h3 style="font-size:14px;font-weight:700;color:#111827;margin-bottom:12px">Submitted Details</h3>
|
||||||
<div style="position:absolute;top:10px;left:0;width:75%;height:2px;background:#FF5E13;z-index:2" />
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px">
|
||||||
{[
|
<For each={selectedFields()}>
|
||||||
{ l: 'Submitted', active: true },
|
{(field) => (
|
||||||
{ l: 'Verified', active: true },
|
<div style="border:1px solid #F3F4F6;border-radius:10px;padding:10px;background:#FAFAFA">
|
||||||
{ l: 'Review', active: true },
|
<p style="font-size:11px;color:#9CA3AF">{field.label}</p>
|
||||||
{ l: 'Decision', active: false },
|
<p style="font-size:13px;font-weight:600;color:#111827;line-height:1.4;word-break:break-word">{field.value || '—'}</p>
|
||||||
].map((step) => (
|
</div>
|
||||||
<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`} />
|
</For>
|
||||||
<p style="font-size:11px;margin-top:4px;color:#111827;font-weight:600">{step.l}</p>
|
</div>
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="border:1px solid #E5E7EB;border-radius:12px;padding:20px;background:#F9FAFB">
|
<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>
|
<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" />
|
<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'}>
|
<Show when={detailTab() === 'verification'}>
|
||||||
<div style="border:1px solid #E5E7EB;border-radius:12px;padding:20px;background:#FAFAFA">
|
<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>
|
<h3 style="font-size:14px;font-weight:700;color:#111827;margin-bottom:16px">Submitted Documents</h3>
|
||||||
<div style="display:flex;flex-direction:column;gap:12px">
|
<Show when={selectedDocuments().length > 0} fallback={<p style="font-size:13px;color:#6B7280">No documents submitted for this request.</p>}>
|
||||||
<div style="display:flex;justify-content:space-between;padding:10px;background:white;border-radius:8px;border:1px solid #E5E7EB">
|
<div style="display:flex;flex-direction:column;gap:8px">
|
||||||
<span style="font-size:13px;color:#6B7280">Identity Verification</span>
|
<For each={selectedDocuments()}>
|
||||||
<span style="font-size:13px;font-weight:600;color:#16A34A">SUCCESS</span>
|
{(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>
|
||||||
<div style="display:flex;justify-content:space-between;padding:10px;background:white;border-radius:8px;border:1px solid #E5E7EB">
|
</Show>
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
|
|
@ -524,7 +874,7 @@ export default function ApprovalManagementPage() {
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="overflow-x-auto">
|
<div class="overflow-x-auto overflow-y-visible">
|
||||||
<table class="min-w-full">
|
<table class="min-w-full">
|
||||||
<thead>
|
<thead>
|
||||||
<tr style="background:#0D0D2A;text-align:left">
|
<tr style="background:#0D0D2A;text-align:left">
|
||||||
|
|
@ -539,40 +889,76 @@ export default function ApprovalManagementPage() {
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<For each={filteredRows()}>
|
<Show
|
||||||
{(row) => (
|
when={filteredRows().length > 0}
|
||||||
<tr style="border-bottom:1px solid #F3F4F6" class="hover:bg-[#FAFAFA] transition-colors">
|
fallback={
|
||||||
<td style="padding:12px 20px;font-size:12px;font-family:monospace;color:#6B7280">{row.id}</td>
|
<tr>
|
||||||
<td style="padding:12px 20px">
|
<td colSpan={8} style="padding:32px;text-align:center">
|
||||||
<p style="font-size:14px;font-weight:600;color:#111827">{row.applicantName}</p>
|
<p style="font-size:15px;font-weight:600;color:#111827">No approvals found</p>
|
||||||
<p style="font-size:11px;color:#6B7280">{row.userType}{row.area ? ` • ${row.area}` : ''}</p>
|
<p style="margin-top:6px;font-size:13px;color:#6B7280">Try changing filters or search.</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>
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</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>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
|
||||||
|
|
@ -136,13 +136,14 @@ function sameSidebarOrder(a: string[], b: string[]): boolean {
|
||||||
function rolePreviewPath(roleKey: string): string {
|
function rolePreviewPath(roleKey: string): string {
|
||||||
const key = String(roleKey || '').toUpperCase();
|
const key = String(roleKey || '').toUpperCase();
|
||||||
if (key.includes('COMPANY')) return '/employers/dashboard';
|
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('JOB_SEEKER') || key.includes('JOBSEEKER')) return '/users/candidate/dashboard';
|
||||||
if (key.includes('PHOTOGRAPHER')) return '/users/photographer/dashboard';
|
if (key.includes('PHOTOGRAPHER')) return '/users/photographer/dashboard';
|
||||||
if (key.includes('MAKEUP')) return '/users/makeup/dashboard';
|
if (key.includes('MAKEUP')) return '/users/makeup/dashboard';
|
||||||
if (key.includes('TUTOR')) return '/users/tutors/dashboard';
|
if (key.includes('TUTOR')) return '/users/tutors/dashboard';
|
||||||
if (key.includes('DEVELOPER')) return '/users/developers/dashboard';
|
if (key.includes('DEVELOPER')) return '/users/developers/dashboard';
|
||||||
if (key.includes('VIDEO')) return '/users/video-editors/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('FITNESS')) return '/users/fitness-trainers/dashboard';
|
||||||
if (key.includes('GRAPHIC')) return '/users/graphic-designers/dashboard';
|
if (key.includes('GRAPHIC')) return '/users/graphic-designers/dashboard';
|
||||||
if (key.includes('SOCIAL')) return '/users/social-media-managers/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 [view, setView] = createSignal<'list' | 'form'>('list');
|
||||||
const [editingId, setEditingId] = createSignal<string | null>(null);
|
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 [listTab, setListTab] = createSignal<'all' | 'create'>('all');
|
||||||
|
|
||||||
const [search, setSearch] = createSignal('');
|
const [search, setSearch] = createSignal('');
|
||||||
|
|
@ -228,7 +230,7 @@ export default function ExternalDashboardManagementPage() {
|
||||||
for (const role of roles()) {
|
for (const role of roles()) {
|
||||||
const key = role.key.toUpperCase();
|
const key = role.key.toUpperCase();
|
||||||
if (key.includes('COMPANY')) map[role.id] = 'COMPANY';
|
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 if (key.includes('JOB_SEEKER') || key.includes('JOBSEEKER')) map[role.id] = 'JOB_SEEKER';
|
||||||
else map[role.id] = 'PROFESSIONAL';
|
else map[role.id] = 'PROFESSIONAL';
|
||||||
}
|
}
|
||||||
|
|
@ -239,7 +241,7 @@ export default function ExternalDashboardManagementPage() {
|
||||||
const k = String(key || '').toUpperCase();
|
const k = String(key || '').toUpperCase();
|
||||||
if (!k) return null;
|
if (!k) return null;
|
||||||
if (k.includes('COMPANY')) return 'COMPANY';
|
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';
|
if (k.includes('JOB_SEEKER') || k.includes('JOBSEEKER')) return 'JOB_SEEKER';
|
||||||
return 'PROFESSIONAL'; // photographer, makeup, tutor, developer, video, graphic, social, fitness, catering, etc.
|
return 'PROFESSIONAL'; // photographer, makeup, tutor, developer, video, graphic, social, fitness, catering, etc.
|
||||||
};
|
};
|
||||||
|
|
@ -520,7 +522,7 @@ export default function ExternalDashboardManagementPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="display:flex;gap:4px;border-bottom:1px solid #E5E7EB;padding:0 16px;background:#FAFAFA">
|
<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'}`}>
|
<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)}
|
{humanizeLabel(tab)}
|
||||||
<Show when={formTab() === tab}><span style="position:absolute;left:0;right:0;bottom:0;height:2px;background:#FF5E13" /></Show>
|
<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'}
|
mode={'customer_external'}
|
||||||
roleKey={selectedRoleKey()}
|
roleKey={selectedRoleKey()}
|
||||||
exploreRoles={roles().map((r) => ({ key: r.key, name: r.name }))}
|
exploreRoles={roles().map((r) => ({ key: r.key, name: r.name }))}
|
||||||
|
onOpenFullscreen={() => setFormTab('full_preview')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</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>
|
||||||
|
|
||||||
<div style="padding:14px 24px;border-top:1px solid #E5E7EB;display:flex;justify-content:flex-end;gap:10px">
|
<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>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</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>
|
</div>
|
||||||
</AdminShell>
|
</AdminShell>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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 = [
|
const ONBOARDING_SCHEMAS = [
|
||||||
'company_onboarding_v1', 'job_seeker_onboarding_v1', 'customer_onboarding_v1', 'photographer_onboarding_v1',
|
'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 = {
|
const MODULES_BY_VERTICAL = {
|
||||||
|
|
@ -774,7 +774,7 @@ export default function ExternalRoleManagementPage() {
|
||||||
onClick={() => toggleUserType(type)}
|
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'}`}
|
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>
|
</button>
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
|
|
|
||||||
|
|
@ -76,7 +76,7 @@ const FALLBACK_SCHEMAS: OnboardingSchema[] = [
|
||||||
description: 'Confirm your onboarding role and specialization.',
|
description: 'Confirm your onboarding role and specialization.',
|
||||||
required: true,
|
required: true,
|
||||||
fields: [
|
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'] },
|
{ 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.',
|
description: 'Tell us about hiring needs.',
|
||||||
required: true,
|
required: true,
|
||||||
fields: [
|
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' },
|
{ id: 'f_budget', key: 'budget', label: 'Monthly Hiring Budget (₹)', type: 'number', required: true, placeholder: 'e.g., 50000' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,86 +1,181 @@
|
||||||
import { For, Show, createMemo, createSignal, onMount } from 'solid-js';
|
import { For, Show, createMemo, createSignal, onMount } from 'solid-js';
|
||||||
import { useSearchParams } from '@solidjs/router';
|
|
||||||
import AdminShell from '~/components/AdminShell';
|
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 = {
|
type ExternalUserRecord = {
|
||||||
id: string;
|
id: string;
|
||||||
|
userCode: string;
|
||||||
name: string;
|
name: string;
|
||||||
username?: string;
|
|
||||||
email: string;
|
email: string;
|
||||||
phone?: string;
|
phone?: string;
|
||||||
|
location: string;
|
||||||
|
joinedOn: string;
|
||||||
|
lastActive?: string;
|
||||||
userType: 'CUSTOMER' | 'PROFESSIONAL' | 'COMPANY' | 'JOBSEEKER';
|
userType: 'CUSTOMER' | 'PROFESSIONAL' | 'COMPANY' | 'JOBSEEKER';
|
||||||
primaryActiveRole?: string;
|
accountStatus: 'ACTIVE' | 'INACTIVE' | 'SUSPENDED' | 'BLOCKED';
|
||||||
onboardingStatus: 'NOT_STARTED' | 'IN_PROGRESS' | 'SUBMITTED' | 'COMPLETED';
|
|
||||||
verificationStatus: 'UNVERIFIED' | 'PENDING' | 'IN_REVIEW' | 'VERIFIED' | 'REJECTED' | 'RE_UPLOAD_REQUESTED';
|
verificationStatus: 'UNVERIFIED' | 'PENDING' | 'IN_REVIEW' | 'VERIFIED' | 'REJECTED' | 'RE_UPLOAD_REQUESTED';
|
||||||
accountStatus: 'ACTIVE' | 'INACTIVE' | 'BLOCKED' | 'SUSPENDED';
|
onboardingStatus: 'NOT_STARTED' | 'IN_PROGRESS' | 'SUBMITTED' | 'COMPLETED';
|
||||||
createdDate?: string;
|
registeredRoles: RegisteredRole[];
|
||||||
lastLogin?: string;
|
portfolioCount: number;
|
||||||
status: 'ACTIVE' | 'INACTIVE';
|
notes?: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const FALLBACK_USERS: ExternalUserRecord[] = [
|
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: 'u1',
|
||||||
{ 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' },
|
userCode: 'U-102348',
|
||||||
{ 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' },
|
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 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 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 (
|
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-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() ? '#FF5E13' : pending() ? '#B7791F' : rejected() ? '#DC2626' : '#9CA3AF'};margin-right:5px;flex-shrink:0`} />
|
<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(' ')}
|
{props.status.split('_').map((w) => w.charAt(0) + w.slice(1).toLowerCase()).join(' ')}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function FormInput(props: { label: string; required?: boolean; value: string; onInput: (v: string) => void; placeholder?: string; type?: string }) {
|
function RoleChip(props: { role: RegisteredRole }) {
|
||||||
return (
|
return (
|
||||||
<label style="display:block">
|
<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'}`}>
|
||||||
<span style="font-size:13px;font-weight:600;color:#374151">
|
{props.role.name}
|
||||||
{props.label}{props.required && <span style="margin-left:2px;color:#FF5E13">*</span>}
|
</span>
|
||||||
</span>
|
|
||||||
<input
|
|
||||||
type={props.type ?? 'text'}
|
|
||||||
value={props.value}
|
|
||||||
onInput={(e) => props.onInput(e.currentTarget.value)}
|
|
||||||
placeholder={props.placeholder}
|
|
||||||
style="display:block;margin-top:6px;height:40px;width:100%;border-radius:10px;border:1px solid #E5E7EB;background:white;padding:0 14px;font-size:13px;color:#111827;outline:none;box-sizing:border-box"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function UsersManagementPage() {
|
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 [rows, setRows] = createSignal<ExternalUserRecord[]>([]);
|
||||||
const [viewingUser, setViewingUser] = createSignal<ExternalUserRecord | null>(null);
|
const [listTab, setListTab] = createSignal<'all' | 'no_role' | 'registered' | 'pending' | 'approved' | 'suspended' | 'view'>('all');
|
||||||
const [editingId, setEditingId] = createSignal<string | null>(null);
|
const [detailTab, setDetailTab] = createSignal<'overview' | 'personal' | 'roles' | 'portfolio' | 'verification' | 'activity' | 'notes'>('overview');
|
||||||
const [openMenuId, setOpenMenuId] = createSignal<string | null>(null);
|
|
||||||
|
|
||||||
// Form Signals
|
const [search, setSearch] = createSignal('');
|
||||||
const [name, setName] = createSignal('');
|
const [sortBy, setSortBy] = createSignal<'newest' | 'oldest' | 'name_asc' | 'name_desc'>('newest');
|
||||||
const [username, setUsername] = createSignal('');
|
const [statusFilter, setStatusFilter] = createSignal<'all' | 'active' | 'pending' | 'suspended' | 'blocked'>('all');
|
||||||
const [email, setEmail] = createSignal('');
|
const [roleFilter, setRoleFilter] = createSignal<'all' | 'no_role' | 'professional' | 'company' | 'jobseeker' | 'customer'>('all');
|
||||||
const [phone, setPhone] = createSignal('');
|
const [sortOpen, setSortOpen] = createSignal(false);
|
||||||
const [userType, setUserType] = createSignal<ExternalUserRecord['userType']>('CUSTOMER');
|
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 () => {
|
const load = async () => {
|
||||||
setRows(FALLBACK_USERS);
|
setRows(FALLBACK_USERS);
|
||||||
|
|
@ -88,299 +183,461 @@ export default function UsersManagementPage() {
|
||||||
|
|
||||||
onMount(() => void load());
|
onMount(() => void load());
|
||||||
|
|
||||||
const filteredRows = createMemo(() => {
|
const metrics = createMemo(() => {
|
||||||
let r = rows();
|
const all = rows();
|
||||||
if (statusFilter() !== 'all') r = r.filter((d) => d.accountStatus === statusFilter().toUpperCase());
|
const noRoleUsers = all.filter((u) => u.registeredRoles.length === 0);
|
||||||
const q = search().toLowerCase();
|
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) {
|
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) => {
|
sorted.sort((a, b) => {
|
||||||
if (mode === 'name_desc') return b.name.localeCompare(a.name);
|
if (sortBy() === 'name_asc') return a.name.localeCompare(b.name);
|
||||||
if (mode === 'created_desc') return (b.createdDate || '').localeCompare(a.createdDate || '');
|
if (sortBy() === 'name_desc') return b.name.localeCompare(a.name);
|
||||||
if (mode === 'created_asc') return (a.createdDate || '').localeCompare(b.createdDate || '');
|
if (sortBy() === 'oldest') return String(a.joinedOn).localeCompare(String(b.joinedOn));
|
||||||
return a.name.localeCompare(b.name);
|
return String(b.joinedOn).localeCompare(String(a.joinedOn));
|
||||||
});
|
});
|
||||||
|
|
||||||
return sorted;
|
return sorted;
|
||||||
});
|
});
|
||||||
|
|
||||||
const resetForm = () => {
|
const exportCsv = () => {
|
||||||
setEditingId(null); setViewingUser(null); setName(''); setUsername(''); setEmail(''); setPhone(''); setUserType('CUSTOMER'); setFormTab('basic');
|
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 openView = (user: ExternalUserRecord) => {
|
||||||
const openEdit = (row: ExternalUserRecord) => {
|
setSelectedUser(user);
|
||||||
setEditingId(row.id); setViewingUser(row); setName(row.name); setUsername(row.username || '');
|
setDetailTab('overview');
|
||||||
setEmail(row.email); setPhone(row.phone || ''); setUserType(row.userType);
|
setListTab('view');
|
||||||
setView('form'); setOpenMenuId(null);
|
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 (
|
return (
|
||||||
<AdminShell>
|
<AdminShell>
|
||||||
<div class="w-full space-y-6 pb-8">
|
<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>
|
<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>
|
</div>
|
||||||
|
|
||||||
{/* ── LIST VIEW ── */}
|
<Show when={listTab() !== 'view'}>
|
||||||
<Show when={view() === 'list'}>
|
<div style="display:grid;grid-template-columns:repeat(6,minmax(0,1fr));gap:14px">
|
||||||
<div>
|
{[
|
||||||
{/* Tabs */}
|
{ label: 'Total Users', value: metrics().totalUsers, accent: '#111827' },
|
||||||
<div style="margin-top:24px;display:flex;align-items:center;gap:24px;border-bottom:1px solid #E5E7EB">
|
{ label: 'No Role Users', value: metrics().noRoleUsers, accent: '#111827' },
|
||||||
{([
|
{ label: 'Active Role Users', value: metrics().activeRoleUsers, accent: '#111827' },
|
||||||
{ key: 'all', label: 'All Users', action: () => { setListTab('all'); setStatusFilter('all'); } },
|
{ label: 'Pending Roles', value: metrics().pendingRoles, accent: '#111827' },
|
||||||
{ key: 'create', label: 'Create User', action: () => { setListTab('create'); openCreate(); } },
|
{ label: 'Suspended', value: metrics().suspended, accent: '#111827' },
|
||||||
{ key: 'view', label: 'View Profile', action: () => setListTab('view') },
|
{ label: 'New This Month', value: metrics().newThisMonth, accent: '#111827' },
|
||||||
] as const).map((tab) => (
|
].map((card) => (
|
||||||
<button
|
<div style="border:1px solid #E5E7EB;border-radius:14px;background:white;padding:16px 18px;min-height:100px">
|
||||||
type="button"
|
<p style="font-size:12px;font-weight:600;text-transform:uppercase;letter-spacing:0.06em;color:#667085">{card.label}</p>
|
||||||
onClick={tab.action}
|
<p style={`margin-top:8px;font-size:40px;line-height:1;font-weight:700;color:${card.accent}`}>{card.value}</p>
|
||||||
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'}`}
|
</div>
|
||||||
>
|
))}
|
||||||
{tab.label}
|
</div>
|
||||||
</button>
|
</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>
|
</div>
|
||||||
|
</Show>
|
||||||
{/* View Profile panel */}
|
<Show when={selectedUser()}>
|
||||||
<Show when={listTab() === 'view'}>
|
<div style="border:1px solid #E5E7EB;border-radius:16px;background:white;overflow:hidden">
|
||||||
<Show
|
<div style="padding:18px 22px;border-bottom:1px solid #E5E7EB;display:flex;align-items:center;justify-content:space-between;gap:16px">
|
||||||
when={!viewingUser()}
|
<div>
|
||||||
>
|
<h2 style="font-size:22px;font-weight:700;color:#111827">User Profile: {currentUser().name}</h2>
|
||||||
<div style="margin-top:24px;border-radius:16px;border:1px solid #E5E7EB;background:white;padding:48px 24px;text-align:center">
|
<p style="font-size:14px;color:#6B7280;margin-top:4px">View and manage account information, registered roles, and activity history.</p>
|
||||||
<p style="font-size:15px;font-weight:600;color:#111827">No user selected</p>
|
|
||||||
<p style="margin-top:6px;font-size:13px;color:#6B7280">Click the <strong>⋮</strong> menu on any user row and choose <strong>View Profile</strong>.</p>
|
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
<div style="display:flex;gap:10px">
|
||||||
<Show when={viewingUser()}>
|
<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>
|
||||||
<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">
|
<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>
|
||||||
<div style="padding:24px;border-bottom:1px solid #E5E7EB;display:flex;align-items:center;gap:24px">
|
<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 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">
|
</div>
|
||||||
{viewingUser()!.name.charAt(0)}
|
</div>
|
||||||
</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="padding:24px">
|
<div style="padding:18px 22px;display:grid;grid-template-columns:2fr 1fr;gap:14px;border-bottom:1px solid #E5E7EB">
|
||||||
<Show when={detailTab() === 'overview'}>
|
<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="display:grid;grid-template-columns:1fr 1fr;gap:24px">
|
<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 style="border:1px solid #E5E7EB;border-radius:12px;padding:20px">
|
<div>
|
||||||
<h3 style="font-size:14px;font-weight:700;color:#111827;margin-bottom:16px">Account Summary</h3>
|
<p style="font-size:11px;color:#9CA3AF;text-transform:uppercase;letter-spacing:0.06em">User ID</p>
|
||||||
<div style="display:flex;flex-direction:column;gap:12px">
|
<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>
|
||||||
{ l: 'User Type', v: viewingUser()!.userType },
|
<StatusBadge status={currentUser().accountStatus} />
|
||||||
{ l: 'Primary Role', v: viewingUser()!.primaryActiveRole || 'None' },
|
</div>
|
||||||
{ l: 'Email Verified', v: 'Yes' },
|
<div>
|
||||||
{ l: 'Joined Date', v: viewingUser()!.createdDate || '—' },
|
<p style="font-size:11px;color:#9CA3AF;text-transform:uppercase;letter-spacing:0.06em">Email Address</p>
|
||||||
].map(item => (
|
<p style="font-size:14px;font-weight:600;color:#111827">{currentUser().email}</p>
|
||||||
<div style="display:flex;justify-content:space-between">
|
<p style="margin-top:8px;font-size:11px;color:#9CA3AF;text-transform:uppercase;letter-spacing:0.06em">Joined Date</p>
|
||||||
<span style="font-size:13px;color:#6B7280">{item.l}</span>
|
<p style="font-size:14px;font-weight:600;color:#111827">{currentUser().joinedOn}</p>
|
||||||
<span style="font-size:13px;font-weight:600;color:#111827">{item.v}</span>
|
</div>
|
||||||
</div>
|
<div>
|
||||||
))}
|
<p style="font-size:11px;color:#9CA3AF;text-transform:uppercase;letter-spacing:0.06em">Phone</p>
|
||||||
</div>
|
<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>
|
||||||
</div>
|
</Show>
|
||||||
</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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<Show when={formTab() === 'security'}>
|
<Show when={detailTab() === 'personal'}>
|
||||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:20px">
|
<div style="border:1px solid #E5E7EB;border-radius:12px;padding:16px;display:grid;grid-template-columns:1fr 1fr;gap:12px">
|
||||||
<FormInput label="Password" value="" onInput={() => {}} type="password" />
|
<div><p style="font-size:12px;color:#9CA3AF">Name</p><p style="font-size:14px;font-weight:600;color:#111827">{currentUser().name}</p></div>
|
||||||
<FormInput label="Confirm Password" value="" onInput={() => {}} type="password" />
|
<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>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="display:flex;align-items:center;justify-content:flex-end;gap:10px;border-top:1px solid #E5E7EB;padding:14px 24px">
|
<div style="display:flex;gap:10px;padding:14px 22px;border-top:1px solid #E5E7EB">
|
||||||
<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" 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>
|
||||||
<button type="button" style="height:38px;border-radius:10px;background:#0D0D2A;padding:0 24px;font-size:13px;font-weight:600;color:white;border:none;cursor:pointer">
|
|
||||||
{editingId() ? 'Update User' : 'Create User'}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Show>
|
||||||
</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>
|
</div>
|
||||||
</AdminShell>
|
</AdminShell>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,83 +1,414 @@
|
||||||
import { A, useParams } from '@solidjs/router';
|
import { A, useSearchParams, useParams } from '@solidjs/router';
|
||||||
import { createMemo, createResource, Show } from 'solid-js';
|
import { For, Show, createMemo, createSignal } from 'solid-js';
|
||||||
import AdminShell from '~/components/AdminShell';
|
import AdminShell from '~/components/AdminShell';
|
||||||
|
|
||||||
const API = '/api/gateway';
|
type Status = 'UNDER_REVIEW' | 'DOCUMENTS_REQUESTED' | 'REVISION_REQUESTED' | 'APPROVED' | 'REJECTED';
|
||||||
|
|
||||||
type Approval = {
|
const statusTone = (status: Status) => {
|
||||||
id: string;
|
if (status === 'APPROVED') return { bg: '#ECFDF3', border: '#BBF7D0', text: '#166534', label: 'Approved' };
|
||||||
requestStatus?: string;
|
if (status === 'DOCUMENTS_REQUESTED') return { bg: '#FFF7ED', border: '#FED7AA', text: '#C2410C', label: 'Request Documents' };
|
||||||
status?: string;
|
if (status === 'REVISION_REQUESTED') return { bg: '#FFF7ED', border: '#FED7AA', text: '#C2410C', label: 'Request Changes' };
|
||||||
requestType?: string;
|
if (status === 'REJECTED') return { bg: '#FEF2F2', border: '#FECACA', text: '#B91C1C', label: 'Rejected' };
|
||||||
type?: string;
|
return { bg: '#EEF2FF', border: '#C7D2FE', text: '#3730A3', label: 'Under Review' };
|
||||||
requester?: { name?: string; email?: string };
|
|
||||||
requesterName?: string;
|
|
||||||
requesterEmail?: string;
|
|
||||||
requestReason?: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
async function fetchApproval(id: string): Promise<Approval | null> {
|
type DocRequestRow = {
|
||||||
try {
|
key: string;
|
||||||
const res = await fetch(`${API}/api/admin/approvals/${id}`);
|
title: string;
|
||||||
if (!res.ok) return null;
|
hint: string;
|
||||||
return res.json();
|
enabled: boolean;
|
||||||
} catch {
|
reason: string;
|
||||||
return null;
|
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 params = useParams();
|
||||||
const [approval] = createResource(() => params.id, fetchApproval);
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
|
||||||
const status = createMemo(() => (approval()?.requestStatus || approval()?.status || 'PENDING').toUpperCase());
|
const [status, setStatus] = createSignal<Status>('UNDER_REVIEW');
|
||||||
const type = createMemo(() => (approval()?.requestType || approval()?.type || 'PROFILE').toUpperCase());
|
const [tab, setTab] = createSignal<'overview' | 'submitted' | 'documents' | 'missing' | 'requested' | 'activity'>('submitted');
|
||||||
const requester = createMemo(() => approval()?.requester?.name || approval()?.requesterName || 'Unknown');
|
|
||||||
const email = createMemo(() => approval()?.requester?.email || approval()?.requesterEmail || '—');
|
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 (
|
return (
|
||||||
<AdminShell>
|
<AdminShell>
|
||||||
<div class="flex flex-col -mx-6 -mt-6 min-h-full">
|
<div class="w-full pb-8">
|
||||||
<div class="bg-white border-b border-gray-200 px-6 py-4 flex items-center justify-between">
|
<A href="/admin/verification" style="display:inline-flex;align-items:center;gap:8px;margin-bottom:8px;color:#475569;font-size:13px;text-decoration:none">
|
||||||
<div>
|
← Back to Verification Management
|
||||||
<h1 class="text-xl font-semibold text-gray-900">Verification Review</h1>
|
</A>
|
||||||
<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">
|
|
||||||
|
|
||||||
<Show when={approval.loading}>
|
<div style="display:flex;align-items:flex-start;justify-content:space-between;gap:12px;flex-wrap:wrap">
|
||||||
<div class="rounded-xl border border-gray-200 bg-white shadow-sm"><p class="notice">Loading verification detail...</p></div>
|
<div>
|
||||||
</Show>
|
<h1 style="margin:0;font-size:42px;line-height:1.08;font-weight:800;color:#111827">Review Submission: #{params.id}</h1>
|
||||||
<Show when={!approval.loading && !approval()}>
|
<p style="margin:8px 0 0;font-size:14px;color:#6B7280">Applicant verification detail and action flow.</p>
|
||||||
<div class="rounded-xl border border-gray-200 bg-white shadow-sm"><p class="notice">Verification request not found.</p></div>
|
</div>
|
||||||
</Show>
|
|
||||||
<Show when={approval()}>
|
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap">
|
||||||
<div class="grid" style="margin-top:0">
|
<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`}>
|
||||||
<section class="rounded-xl border border-gray-200 bg-white shadow-sm">
|
{tone().label}
|
||||||
<h2 style="margin-bottom:8px">Summary</h2>
|
</span>
|
||||||
<p class="notice" style="margin:0"><strong>Approval ID:</strong> {approval()!.id}</p>
|
<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>
|
||||||
<p class="notice" style="margin:8px 0 0"><strong>Type:</strong> {type()}</p>
|
<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>
|
||||||
<p class="notice" style="margin:8px 0 0"><strong>Status:</strong> {status()}</p>
|
<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>
|
||||||
<p class="notice" style="margin:8px 0 0"><strong>Requester:</strong> {requester()}</p>
|
<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>
|
||||||
<p class="notice" style="margin:8px 0 0"><strong>Email:</strong> {email()}</p>
|
</div>
|
||||||
</section>
|
</div>
|
||||||
<section class="rounded-xl border border-gray-200 bg-white shadow-sm">
|
|
||||||
<h2 style="margin-bottom:8px">Remark Snapshot</h2>
|
<div style="margin-top:14px;display:grid;grid-template-columns:2fr 1fr;gap:12px">
|
||||||
<p class="notice" style="margin:0">
|
<div style="border:1px solid #E5E7EB;background:white;border-radius:14px;padding:14px;display:grid;grid-template-columns:1fr 1fr 1fr;gap:14px">
|
||||||
This route mirrors the Next.js verification detail entry point and delegates action workflow to Approval Management.
|
<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>
|
||||||
</p>
|
<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 class="actions">
|
<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>
|
||||||
<A class="btn-primary" href={`/admin/approval/${params.id}`}>Review & Take Action</A>
|
</div>
|
||||||
</div>
|
|
||||||
</section>
|
<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>
|
</div>
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</AdminShell>
|
</AdminShell>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue