diff --git a/src/components/AdminShell.tsx b/src/components/AdminShell.tsx index 53d7ef3..d8daa19 100644 --- a/src/components/AdminShell.tsx +++ b/src/components/AdminShell.tsx @@ -1,8 +1,8 @@ -import { A, useLocation, useNavigate } from '@solidjs/router'; +import { A, useLocation, useNavigate, useSearchParams } from '@solidjs/router'; import { createMemo, createSignal, onMount, type JSX } from 'solid-js'; import AdminSidebar from './AdminSidebar'; import { isExternalIdentity } from '~/lib/admin-auth'; -import { clearAdminSession, hasAdminSession } from '~/lib/admin-session'; +import { clearAdminSession, hasAdminSession, setAdminSession } from '~/lib/admin-session'; import { sidebarCollapsed } from '~/lib/sidebar-state'; type Tab = { href: string; label: string; exact?: boolean }; @@ -63,14 +63,15 @@ const PAGE_TITLES: Array<{ prefix: string; title: string }> = [ { prefix: '/admin/verification-status', title: 'Verification Status' }, { prefix: '/admin/verification', title: 'Verification Review' }, { prefix: '/admin/approval', title: 'Approval Management' }, - { prefix: '/admin/users', title: 'External User Management' }, + { prefix: '/admin/users', title: 'Users Management' }, { prefix: '/admin/company', title: 'Company Management' }, { prefix: '/admin/customer', title: 'Customer Management' }, { prefix: '/admin/candidate', title: 'Candidate Management' }, { prefix: '/admin/photographer', title: 'Photographer Management' }, { prefix: '/admin/makeup-artist', title: 'Makeup Artist Management' }, - { prefix: '/admin/tutors', title: 'Tutor Management' }, - { prefix: '/admin/developers', title: 'Developer Management' }, + { prefix: '/admin/tutors', title: 'Tutors Management' }, + { prefix: '/admin/developers', title: 'Developers Management' }, + { prefix: '/admin/graphic-designers', title: 'Graphics Designer Management' }, { prefix: '/admin/jobs', title: 'Jobs Management' }, { prefix: '/admin/leads', title: 'Leads Management' }, { prefix: '/admin/pricing', title: 'Pricing Management' }, @@ -78,18 +79,21 @@ const PAGE_TITLES: Array<{ prefix: string; title: string }> = [ { prefix: '/admin/credit', title: 'Credit Management' }, { prefix: '/admin/ledger', title: 'Ledger Management' }, { prefix: '/admin/report', title: 'Report Management' }, + { prefix: '/admin/employees', title: 'Employee Management' }, { prefix: '/admin/roles', title: 'Internal Role Management' }, { prefix: '/admin/external-role-management', title: 'External Role Management' }, { prefix: '/admin/internal-role-management', title: 'Internal Role Management' }, { prefix: '/admin/runtime-roles', title: 'External Role Management' }, - { prefix: '/admin/onboarding-management', title: 'Onboarding Management' }, - { prefix: '/admin/onboarding-schemas', title: 'Onboarding Management' }, + { prefix: '/admin/onboarding-management', title: 'External Onboarding Management' }, + { prefix: '/admin/onboarding-schemas', title: 'External Onboarding Management' }, + { prefix: '/admin/kb', title: 'KB Management' }, { prefix: '/admin', title: 'Dashboard' }, ]; export default function AdminShell(props: { children: JSX.Element }) { const location = useLocation(); const navigate = useNavigate(); + const [searchParams] = useSearchParams(); const [checkedSession, setCheckedSession] = createSignal(false); const tabs = createMemo(() => { @@ -116,6 +120,20 @@ export default function AdminShell(props: { children: JSX.Element }) { }); onMount(() => { + // ?_preview=1 or sessionStorage flag — bypass auth for UI testing without a live backend. + // Sets the session cookie AND a sessionStorage flag so all subsequent pages in this tab + // also skip the API check without needing ?_preview=1 in every URL. + const isPreview = + searchParams._preview === '1' || + (typeof sessionStorage !== 'undefined' && sessionStorage.getItem('nxtgauge_admin_preview') === '1'); + + if (isPreview) { + if (typeof sessionStorage !== 'undefined') sessionStorage.setItem('nxtgauge_admin_preview', '1'); + setAdminSession(); + setCheckedSession(true); + return; + } + const verify = async () => { if (!hasAdminSession()) { const from = encodeURIComponent(location.pathname + location.search); diff --git a/src/components/AdminSidebar.tsx b/src/components/AdminSidebar.tsx index b1a1c3f..7c3077c 100644 --- a/src/components/AdminSidebar.tsx +++ b/src/components/AdminSidebar.tsx @@ -5,28 +5,28 @@ type LinkItem = { legacyHref: string; href: string; label: string; icon: string; const links: LinkItem[] = [ { legacyHref: '/', href: '/admin', label: 'Dashboard', icon: 'dashboard.svg' }, - { legacyHref: '/department', href: '/admin/department', label: 'Department Management', icon: 'department.svg' }, - { legacyHref: '/designation', href: '/admin/designation', label: 'Designation Management', icon: 'designation.svg' }, - { legacyHref: '/employees', href: '/admin/employees', label: 'Internal User Management', icon: 'users.svg' }, { legacyHref: '/roles?scope=internal', href: '/admin/roles', label: 'Internal Role Management', icon: 'role.svg' }, { legacyHref: '/runtime-roles', href: '/admin/runtime-roles', label: 'External Role Management', icon: 'role.svg' }, - { legacyHref: '/onboarding-management', href: '/admin/onboarding-schemas', label: 'Onboarding Management', icon: 'reviews.svg' }, + { legacyHref: '/onboarding-management', href: '/admin/onboarding-schemas', label: 'External Onboarding Management', icon: 'reviews.svg' }, { legacyHref: '/internal-dashboard-management', href: '/admin/internal-dashboard-management', label: 'Internal Dashboard Management', icon: 'dashboard.svg' }, { legacyHref: '/external-dashboard-management', href: '/admin/external-dashboard-management', label: 'External Dashboard Management', icon: 'dashboard.svg', aliasPrefix: '/admin/role-ui-configs' }, { legacyHref: '/approval', href: '/admin/approval', label: 'Approval Management', icon: 'approval.svg' }, - { legacyHref: '/users', href: '/admin/users', label: 'External User Management', icon: 'users.svg' }, - { legacyHref: '/customer', href: '/admin/customer', label: 'Customer Management', icon: 'users.svg' }, + { legacyHref: '/department', href: '/admin/department', label: 'Department Management', icon: 'department.svg' }, + { legacyHref: '/designation', href: '/admin/designation', label: 'Designation Management', icon: 'designation.svg' }, + { legacyHref: '/employees', href: '/admin/employees', label: 'Employee Management', icon: 'users.svg' }, + { legacyHref: '/users', href: '/admin/users', label: 'Users Management', icon: 'users.svg' }, { legacyHref: '/company', href: '/admin/company', label: 'Company Management', icon: 'company.svg' }, { legacyHref: '/candidate', href: '/admin/candidate', label: 'Candidate Management', icon: 'candidate.svg' }, + { legacyHref: '/customer', href: '/admin/customer', label: 'Customer Management', icon: 'users.svg' }, { legacyHref: '/photographer', href: '/admin/photographer', label: 'Photographer Management', icon: 'photographer.svg' }, + { legacyHref: '/makeup-artist', href: '/admin/makeup-artist', label: 'Makeup Artist Management', icon: 'makeup-artist.svg' }, + { legacyHref: '/tutors', href: '/admin/tutors', label: 'Tutors Management', icon: 'tutor.svg' }, + { legacyHref: '/developers', href: '/admin/developers', label: 'Developers Management', icon: 'developers.svg' }, { legacyHref: '/video-editors', href: '/admin/video-editors', label: 'Video Editor Management', icon: 'developers.svg' }, - { legacyHref: '/graphic-designers', href: '/admin/graphic-designers', label: 'Graphic Designer Management', icon: 'developers.svg' }, - { legacyHref: '/social-media-managers', href: '/admin/social-media-managers', label: 'Social Media Manager Management', icon: 'developers.svg' }, { legacyHref: '/fitness-trainers', href: '/admin/fitness-trainers', label: 'Fitness Trainer Management', icon: 'tutor.svg' }, { legacyHref: '/catering-services', href: '/admin/catering-services', label: 'Catering Services Management', icon: 'company.svg' }, - { legacyHref: '/makeup-artist', href: '/admin/makeup-artist', label: 'Makeup Artist Management', icon: 'makeup-artist.svg' }, - { legacyHref: '/tutors', href: '/admin/tutors', label: 'Tutor Management', icon: 'tutor.svg' }, - { legacyHref: '/developers', href: '/admin/developers', label: 'Developer Management', icon: 'developers.svg' }, + { legacyHref: '/graphic-designers', href: '/admin/graphic-designers', label: 'Graphics Designer Management', icon: 'developers.svg' }, + { legacyHref: '/social-media-managers', href: '/admin/social-media-managers', label: 'Social Media Manager Management', icon: 'developers.svg' }, { legacyHref: '/jobs', href: '/admin/jobs', label: 'Jobs Management', icon: 'jobs.svg' }, { legacyHref: '/leads', href: '/admin/leads', label: 'Leads Management', icon: 'leads.svg' }, { legacyHref: '/pricing', href: '/admin/pricing', label: 'Pricing Management', icon: 'pricing.svg' }, @@ -37,11 +37,11 @@ const links: LinkItem[] = [ { legacyHref: '/order', href: '/admin/order', label: 'Order Management', icon: 'order.svg' }, { legacyHref: '/invoice', href: '/admin/invoice', label: 'Invoice Management', icon: 'invoice.svg' }, { legacyHref: '/review', href: '/admin/review', label: 'Review Management', icon: 'reviews.svg' }, - { legacyHref: '/kb', href: '/admin/kb', label: 'Knowledge Base Management', icon: 'reviews.svg' }, - { legacyHref: '/notifications', href: '/admin/notifications', label: 'Notifications', icon: 'reviews.svg' }, { legacyHref: '/help', href: '/admin/support', label: 'Support Management', icon: 'support.svg' }, { legacyHref: '/report', href: '/admin/report', label: 'Report Management', icon: 'report.svg' }, { legacyHref: '/ledger', href: '/admin/ledger', label: 'Ledger Management', icon: 'ledger.svg' }, + { legacyHref: '/kb', href: '/admin/kb', label: 'KB Management', icon: 'reviews.svg' }, + { legacyHref: '/notifications', href: '/admin/notifications', label: 'Notifications', icon: 'reviews.svg' }, ]; export default function AdminSidebar() { diff --git a/src/components/admin/ExternalRoleForm.tsx b/src/components/admin/ExternalRoleForm.tsx index 06525b2..67b6216 100644 --- a/src/components/admin/ExternalRoleForm.tsx +++ b/src/components/admin/ExternalRoleForm.tsx @@ -46,39 +46,68 @@ const ROLE_PERMISSION_ACTIONS: RolePermissionAction[] = [ const ONBOARDING_SCHEMA_OPTIONS = [ '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', + 'graphic_designer_onboarding_v1', + 'social_media_manager_onboarding_v1', + 'fitness_trainer_onboarding_v1', + 'catering_service_onboarding_v1', 'default_onboarding_v1', ]; const MODULES_BY_VERTICAL: Record<'jobs' | 'marketplace', ModuleOption[]> = { jobs: [ - { key: 'jobs', label: 'Jobs', description: 'Manage job postings and candidate flow.' }, - { key: 'applications', label: 'Applications', description: 'Review incoming applications.' }, - { key: 'responses', label: 'Responses', description: 'Track response lifecycle states.' }, + { key: 'dashboard', label: 'Dashboard', description: 'Home page with KPI summary cards.' }, { key: 'profile', label: 'Profile', description: 'Maintain role profile and preferences.' }, + { key: 'jobs', label: 'Jobs', description: 'Manage job postings and candidate flow.' }, + { key: 'applications', label: 'Applications', description: 'Review incoming applications and hiring flow.' }, { key: 'notifications', label: 'Notifications', description: 'View and manage alerts.' }, + { key: 'settings', label: 'Settings', description: 'Account settings and password.' }, ], marketplace: [ - { key: 'leads', label: 'Leads', description: 'Handle customer lead requests.' }, - { key: 'portfolio', label: 'Portfolio', description: 'Publish portfolio and service highlights.' }, - { key: 'verification', label: 'Verification', description: 'Track onboarding verification progress.' }, - { key: 'pricing', label: 'Pricing', description: 'Manage plans, pricing, and packages.' }, - { key: 'support', label: 'Support', description: 'Access support workflows and help content.' }, + { key: 'dashboard', label: 'Dashboard', description: 'Home page with KPI summary cards.' }, + { key: 'profile', label: 'Profile', description: 'Public-facing professional or customer profile.' }, + { key: 'portfolio', label: 'Portfolio', description: 'Publish portfolio items and case studies.' }, + { key: 'services', label: 'Services', description: 'List services with pricing and duration.' }, + { key: 'leads', label: 'Leads / Requests', description: 'Handle incoming lead requests from customers.' }, + { key: 'requirements', label: 'Requirements', description: 'Post and manage customer requirements (consumers).' }, + { key: 'marketplace', label: 'Marketplace', description: 'Browse and send requests to professionals (consumers).' }, + { key: 'wallet', label: 'Wallet', description: 'Tracecoin balance, history, and top-up.' }, + { key: 'notifications', label: 'Notifications', description: 'View and manage alerts.' }, + { key: 'settings', label: 'Settings', description: 'Account settings and password.' }, ], }; +const MARKETPLACE_PROVIDER_MODULES = ['dashboard', 'profile', 'portfolio', 'services', 'leads', 'wallet', 'notifications', 'settings']; +const MARKETPLACE_PROVIDER_PERMISSIONS: Record = { + dashboard: ['read'], + profile: ['read', 'update'], + portfolio: ['read', 'create', 'update', 'delete'], + services: ['read', 'create', 'update', 'delete'], + leads: ['read', 'update'], + wallet: ['read'], + notifications: ['read'], + settings: ['read', 'update'], +}; + const DEFAULT_PRESETS: Record = { company: { roleKey: 'company', displayName: 'Company', vertical: 'jobs', roleCategory: 'employer', - enabledModules: ['jobs', 'applications', 'responses', 'profile'], + enabledModules: ['dashboard', 'profile', 'jobs', 'applications', 'notifications', 'settings'], permissions: { + dashboard: ['read'], + profile: ['read', 'update'], jobs: ['read', 'create', 'update'], applications: ['read', 'approve'], - responses: ['read', 'update'], - profile: ['read', 'update'], + notifications: ['read'], + settings: ['read', 'update'], }, onboardingSchemaId: 'company_onboarding_v1', requiresOnboardingApproval: true, @@ -88,18 +117,58 @@ const DEFAULT_PRESETS: Record = { runtimeConfigVersion: 1, isActive: true, }, + job_seeker: { + roleKey: 'job_seeker', + displayName: 'Job Seeker', + vertical: 'jobs', + roleCategory: 'specialist', + enabledModules: ['dashboard', 'profile', 'jobs', 'applications', 'notifications', 'settings'], + permissions: { + dashboard: ['read'], + profile: ['read', 'update'], + jobs: ['read'], + applications: ['read', 'create'], + notifications: ['read'], + settings: ['read', 'update'], + }, + onboardingSchemaId: 'job_seeker_onboarding_v1', + requiresOnboardingApproval: true, + requiresLeadApproval: false, + requiresJobApproval: false, + featureLimits: { maxApplicationsPerDay: 10 }, + runtimeConfigVersion: 1, + isActive: true, + }, + customer: { + roleKey: 'customer', + displayName: 'Customer', + vertical: 'marketplace', + roleCategory: 'consumer', + enabledModules: ['dashboard', 'profile', 'requirements', 'marketplace', 'wallet', 'notifications', 'settings'], + permissions: { + dashboard: ['read'], + profile: ['read', 'update'], + requirements: ['read', 'create', 'update'], + marketplace: ['read'], + wallet: ['read', 'create'], + notifications: ['read'], + settings: ['read', 'update'], + }, + onboardingSchemaId: 'customer_onboarding_v1', + requiresOnboardingApproval: true, + requiresLeadApproval: false, + requiresJobApproval: false, + featureLimits: { maxActiveRequirements: 2 }, + runtimeConfigVersion: 1, + isActive: true, + }, photographer: { roleKey: 'photographer', displayName: 'Photographer', vertical: 'marketplace', roleCategory: 'provider', - enabledModules: ['leads', 'portfolio', 'verification', 'pricing'], - permissions: { - leads: ['read', 'update'], - portfolio: ['read', 'create', 'update'], - verification: ['read'], - pricing: ['read', 'update'], - }, + enabledModules: MARKETPLACE_PROVIDER_MODULES, + permissions: MARKETPLACE_PROVIDER_PERMISSIONS, onboardingSchemaId: 'photographer_onboarding_v1', requiresOnboardingApproval: true, requiresLeadApproval: true, @@ -108,6 +177,126 @@ const DEFAULT_PRESETS: Record = { runtimeConfigVersion: 1, isActive: true, }, + makeup_artist: { + roleKey: 'makeup_artist', + displayName: 'Makeup Artist', + vertical: 'marketplace', + roleCategory: 'provider', + enabledModules: MARKETPLACE_PROVIDER_MODULES, + permissions: MARKETPLACE_PROVIDER_PERMISSIONS, + onboardingSchemaId: 'makeup_artist_onboarding_v1', + requiresOnboardingApproval: true, + requiresLeadApproval: true, + requiresJobApproval: false, + featureLimits: { maxOpenLeads: 10 }, + runtimeConfigVersion: 1, + isActive: true, + }, + tutor: { + roleKey: 'tutor', + displayName: 'Tutor', + vertical: 'marketplace', + roleCategory: 'provider', + enabledModules: MARKETPLACE_PROVIDER_MODULES, + permissions: MARKETPLACE_PROVIDER_PERMISSIONS, + onboardingSchemaId: 'tutor_onboarding_v1', + requiresOnboardingApproval: true, + requiresLeadApproval: true, + requiresJobApproval: false, + featureLimits: { maxOpenLeads: 10 }, + runtimeConfigVersion: 1, + isActive: true, + }, + developer: { + roleKey: 'developer', + displayName: 'Developer', + vertical: 'marketplace', + roleCategory: 'provider', + enabledModules: MARKETPLACE_PROVIDER_MODULES, + permissions: MARKETPLACE_PROVIDER_PERMISSIONS, + onboardingSchemaId: 'developer_onboarding_v1', + requiresOnboardingApproval: true, + requiresLeadApproval: true, + requiresJobApproval: false, + featureLimits: { maxOpenLeads: 10 }, + runtimeConfigVersion: 1, + isActive: true, + }, + video_editor: { + roleKey: 'video_editor', + displayName: 'Video Editor', + vertical: 'marketplace', + roleCategory: 'provider', + enabledModules: MARKETPLACE_PROVIDER_MODULES, + permissions: MARKETPLACE_PROVIDER_PERMISSIONS, + onboardingSchemaId: 'video_editor_onboarding_v1', + requiresOnboardingApproval: true, + requiresLeadApproval: true, + requiresJobApproval: false, + featureLimits: { maxOpenLeads: 10 }, + runtimeConfigVersion: 1, + isActive: true, + }, + graphic_designer: { + roleKey: 'graphic_designer', + displayName: 'Graphic Designer', + vertical: 'marketplace', + roleCategory: 'provider', + enabledModules: MARKETPLACE_PROVIDER_MODULES, + permissions: MARKETPLACE_PROVIDER_PERMISSIONS, + onboardingSchemaId: 'graphic_designer_onboarding_v1', + requiresOnboardingApproval: true, + requiresLeadApproval: true, + requiresJobApproval: false, + featureLimits: { maxOpenLeads: 10 }, + runtimeConfigVersion: 1, + isActive: true, + }, + social_media_manager: { + roleKey: 'social_media_manager', + displayName: 'Social Media Manager', + vertical: 'marketplace', + roleCategory: 'provider', + enabledModules: MARKETPLACE_PROVIDER_MODULES, + permissions: MARKETPLACE_PROVIDER_PERMISSIONS, + onboardingSchemaId: 'social_media_manager_onboarding_v1', + requiresOnboardingApproval: true, + requiresLeadApproval: true, + requiresJobApproval: false, + featureLimits: { maxOpenLeads: 10 }, + runtimeConfigVersion: 1, + isActive: true, + }, + fitness_trainer: { + roleKey: 'fitness_trainer', + displayName: 'Fitness Trainer', + vertical: 'marketplace', + roleCategory: 'provider', + enabledModules: MARKETPLACE_PROVIDER_MODULES, + permissions: MARKETPLACE_PROVIDER_PERMISSIONS, + onboardingSchemaId: 'fitness_trainer_onboarding_v1', + requiresOnboardingApproval: true, + requiresLeadApproval: true, + requiresJobApproval: false, + featureLimits: { maxOpenLeads: 10 }, + runtimeConfigVersion: 1, + isActive: true, + }, + catering_service: { + roleKey: 'catering_service', + displayName: 'Catering Service', + vertical: 'marketplace', + roleCategory: 'provider', + enabledModules: MARKETPLACE_PROVIDER_MODULES, + permissions: MARKETPLACE_PROVIDER_PERMISSIONS, + onboardingSchemaId: 'catering_service_onboarding_v1', + requiresOnboardingApproval: true, + requiresLeadApproval: true, + requiresJobApproval: false, + featureLimits: { maxOpenLeads: 10 }, + runtimeConfigVersion: 1, + isActive: true, + }, }; function slugifyRoleKey(value: string): string { @@ -238,9 +427,25 @@ export default function ExternalRoleForm(props: ExternalRoleFormProps) { } }} > - - - + + + + + + + + + + + + + + + + + + + diff --git a/src/lib/admin-modules.ts b/src/lib/admin-modules.ts new file mode 100644 index 0000000..1a09f9c --- /dev/null +++ b/src/lib/admin-modules.ts @@ -0,0 +1,59 @@ +/** + * Static fallback module + permission list for the Internal Role Builder. + * Used when the backend /api/admin/permissions endpoint is unavailable (e.g. preview mode). + * Synthetic IDs follow the pattern "static:{module}:{action}". + */ + +export type Permission = { id: string; module: string; action: string }; + +const ACTIONS = ['Read', 'Create', 'Update', 'Delete'] as const; + +const ADMIN_MODULES = [ + 'Users', + 'Employees', + 'Companies', + 'Candidates', + 'Customers', + 'Photographers', + 'MakeupArtists', + 'Tutors', + 'Developers', + 'VideoEditors', + 'FitnessTrainers', + 'CateringServices', + 'GraphicDesigners', + 'SocialMediaManagers', + 'Roles', + 'RuntimeRoles', + 'OnboardingSchemas', + 'Approvals', + 'Departments', + 'Designations', + 'InternalDashboards', + 'ExternalDashboards', + 'Jobs', + 'Leads', + 'Pricing', + 'Credits', + 'Coupons', + 'Discounts', + 'Taxes', + 'Orders', + 'Invoices', + 'Reviews', + 'Support', + 'Reports', + 'Ledger', + 'KnowledgeBase', + 'Notifications', + 'Financial', + 'Settings', +]; + +export const STATIC_PERMISSIONS: Permission[] = ADMIN_MODULES.flatMap((mod) => + ACTIONS.map((action) => ({ + id: `static:${mod}:${action}`, + module: mod, + action, + })), +); diff --git a/src/routes/admin/approval.tsx b/src/routes/admin/approval.tsx index 4b88a9b..52f347b 100644 --- a/src/routes/admin/approval.tsx +++ b/src/routes/admin/approval.tsx @@ -4,6 +4,8 @@ import AdminShell from '~/components/AdminShell'; const API = '/api/gateway'; +// ────────────────────────────── Types ────────────────────────────── + interface Approval { id: string; requestType?: string; @@ -13,44 +15,221 @@ interface Approval { priority?: number; createdAt?: string; created_at?: string; + requestReason?: string; + requesterId?: string; requester?: { name?: string; email?: string }; requesterName?: string; requesterEmail?: string; requester_name?: string; requester_email?: string; + // Enriched + _roleType?: RoleType; + _parsedReason?: ParsedReason | null; + _category?: RequestCategory; + _typeLabel?: string; } interface ApprovalRule { id: string; - entityType: string; + name?: string; + entityType?: string; entity_type?: string; - approverType: string; + conditionType?: string; + conditionValue?: unknown; + approverType?: string; approver_type?: string; priority?: number; } -const ENTITY_TYPE_OPTIONS = ['JOB_POST', 'COMPANY', 'LEAD', 'INVOICE']; +type RoleType = 'COMPANY' | 'CANDIDATE' | 'CUSTOMER' | 'PHOTOGRAPHER' | 'MAKEUP_ARTIST' | 'TUTOR' | 'DEVELOPER' | 'VIDEO_EDITOR' | 'GRAPHIC_DESIGNER' | 'SOCIAL_MEDIA_MANAGER' | 'FITNESS_TRAINER' | 'CATERING_SERVICE' | 'ADMIN' | 'UNKNOWN'; +type RequestCategory = 'PROFILE' | 'JOB' | 'REQUIREMENT' | 'DOCUMENT_ONLY' | 'OTHER'; + +interface ParsedReason { + source?: string; + requestType?: string; + approvalType?: string; + templateId?: string; + profession?: string; + actorType?: string; + actorId?: string; + values?: Record; + adminRemarks?: AdminRemark[]; + linkedEntityId?: string; +} + +interface AdminRemark { + type: 'INFO' | 'CHANGES_REQUESTED' | 'MORE_DOCUMENTS_REQUESTED' | 'REJECTED'; + comment: string; + requestedBy?: string; + fields?: string[]; + createdAt?: string; +} + +// ────────────────────────────── Constants ────────────────────────────── + +const ENTITY_TYPE_OPTIONS = ['JOB_POST', 'COMPANY', 'LEAD', 'INVOICE', 'PROFILE', 'REQUIREMENT']; const APPROVER_TYPE_OPTIONS = ['USER', 'ROLE']; -const REQUEST_FILTERS = ['ALL', 'PROFILE', 'JOB', 'REQUIREMENT']; +const REQUEST_FILTERS = ['ALL', 'PROFILE', 'JOB', 'REQUIREMENT', 'OTHER']; type StatusTab = 'PENDING' | 'APPROVED' | 'REJECTED' | 'CHANGES_REQUESTED' | 'CANCELLED' | 'rules'; -type PanelTab = 'list' | 'view'; -const STATUS_TABS: { key: StatusTab; label: string }[] = [ - { key: 'PENDING', label: 'Pending' }, - { key: 'APPROVED', label: 'Approved' }, - { key: 'REJECTED', label: 'Rejected' }, - { key: 'CHANGES_REQUESTED', label: 'Changes Requested' }, - { key: 'CANCELLED', label: 'Cancelled' }, - { key: 'rules', label: 'Rules' }, +const STATUS_TABS: { key: StatusTab; label: string; color: string }[] = [ + { key: 'PENDING', label: 'Pending', color: '#f59e0b' }, + { key: 'APPROVED', label: 'Approved', color: '#22c55e' }, + { key: 'REJECTED', label: 'Rejected', color: '#ef4444' }, + { key: 'CHANGES_REQUESTED', label: 'Changes Requested', color: '#3b82f6' }, + { key: 'CANCELLED', label: 'Cancelled', color: '#94a3b8' }, + { key: 'rules', label: 'Rules Config', color: '#7c3aed' }, ]; +// ────────────────────────────── Helpers ────────────────────────────── + +function parseReason(raw?: string): ParsedReason | null { + if (!raw) return null; + try { + // Format: "ONBOARDING_SUBMISSION::{json}" or just JSON + const jsonStr = raw.includes('::') ? raw.split('::').slice(1).join('::') : raw; + return JSON.parse(jsonStr) as ParsedReason; + } catch { + return null; + } +} + +function inferRoleType(requesterRoleName?: string, profession?: string): RoleType { + const raw = [requesterRoleName, profession].filter(Boolean).join(' ').toLowerCase(); + if (!raw) return 'UNKNOWN'; + if (raw.includes('photographer')) return 'PHOTOGRAPHER'; + if (raw.includes('makeup')) return 'MAKEUP_ARTIST'; + if (raw.includes('tutor')) return 'TUTOR'; + if (raw.includes('developer')) return 'DEVELOPER'; + if (raw.includes('video') || raw.includes('editor')) return 'VIDEO_EDITOR'; + if (raw.includes('graphic') || raw.includes('designer')) return 'GRAPHIC_DESIGNER'; + if (raw.includes('social') || raw.includes('media') || raw.includes('manager')) return 'SOCIAL_MEDIA_MANAGER'; + if (raw.includes('fitness') || raw.includes('trainer')) return 'FITNESS_TRAINER'; + if (raw.includes('catering')) return 'CATERING_SERVICE'; + if (raw.includes('customer') || raw.includes('client') || raw.includes('hire')) return 'CUSTOMER'; + if (raw.includes('job seeker') || raw.includes('candidate') || raw.includes('fresher') || raw.includes('experienced')) return 'CANDIDATE'; + if (raw.includes('startup') || raw.includes('staffing') || raw.includes('company') || raw.includes('employer')) return 'COMPANY'; + if (raw.includes('admin') || raw.includes('employee')) return 'ADMIN'; + return 'UNKNOWN'; +} + +function resolveRequestCategory(requestType?: string, parsed?: ParsedReason | null): RequestCategory { + const rt = (requestType || '').toUpperCase(); + const pt = (parsed?.requestType || '').toUpperCase(); + const at = (parsed?.approvalType || '').toUpperCase(); + const tid = (parsed?.templateId || '').toLowerCase(); + + if (at === 'PROFILE' || rt.includes('PROFILE') || rt.includes('USER') || rt.includes('COMPANY') || pt.includes('PROFILE') || pt.includes('SEEKER') || pt.includes('CUSTOMER') || pt.includes('PROFESSIONAL') || pt.includes('COMPANY') || tid.includes('onboarding')) return 'PROFILE'; + if (at === 'JOB' || rt.includes('JOB') || pt.includes('JOB')) return 'JOB'; + if (at === 'REQUIREMENT' || rt.includes('LEAD') || rt.includes('REQUIREMENT') || pt.includes('REQUIREMENT')) return 'REQUIREMENT'; + if (at === 'DOCUMENT_ONLY') return 'DOCUMENT_ONLY'; + return 'OTHER'; +} + +function resolveLabel(category: RequestCategory, profession?: string): string { + if (category === 'PROFILE') return profession ? `PROFILE — ${profession}` : 'PROFILE APPROVAL'; + if (category === 'JOB') return 'JOB APPROVAL'; + if (category === 'REQUIREMENT') return 'REQUIREMENT APPROVAL'; + if (category === 'DOCUMENT_ONLY') return 'DOCUMENT APPROVAL'; + return 'APPROVAL'; +} + +function managementDestination(roleType: RoleType): { label: string; href: string } { + const map: Record = { + COMPANY: { label: 'Company Management', href: '/admin/company' }, + CANDIDATE: { label: 'Candidate Management', href: '/admin/candidate' }, + CUSTOMER: { label: 'Customer Management', href: '/admin/customer' }, + PHOTOGRAPHER: { label: 'Photographer Management', href: '/admin/photographer' }, + MAKEUP_ARTIST: { label: 'Makeup Artist Management', href: '/admin/makeup-artist' }, + TUTOR: { label: 'Tutors Management', href: '/admin/tutors' }, + DEVELOPER: { label: 'Developers Management', href: '/admin/developers' }, + VIDEO_EDITOR: { label: 'Video Editor Management', href: '/admin/video-editors' }, + GRAPHIC_DESIGNER: { label: 'Graphics Designer Management', href: '/admin/graphic-designers' }, + SOCIAL_MEDIA_MANAGER: { label: 'Social Media Manager Management', href: '/admin/social-media-managers' }, + FITNESS_TRAINER: { label: 'Fitness Trainer Management', href: '/admin/fitness-trainers' }, + CATERING_SERVICE: { label: 'Catering Services Management', href: '/admin/catering-services' }, + ADMIN: { label: 'Employee Management', href: '/admin/employees' }, + UNKNOWN: { label: 'Users Management', href: '/admin/users' }, + }; + return map[roleType] ?? map.UNKNOWN; +} + +function enrichApproval(a: Approval): Approval { + const parsed = parseReason(a.requestReason); + const roleType = inferRoleType( + a.requester?.name || a.requesterName || a.requester_name, + parsed?.profession + ); + const category = resolveRequestCategory(a.requestType || a.type, parsed); + return { + ...a, + _parsedReason: parsed, + _roleType: roleType, + _category: category, + _typeLabel: resolveLabel(category, parsed?.profession), + }; +} + +function statusValue(item: Approval) { + return (item.requestStatus || item.status || 'PENDING').toUpperCase(); +} + +function requesterName(item: Approval) { + return item.requester?.name || item.requesterName || item.requester_name || '—'; +} + +function requesterEmail(item: Approval) { + return item.requester?.email || item.requesterEmail || item.requester_email || ''; +} + +// ────────────────────────────── Role type badge colors ────────────────────────────── + +const ROLE_COLORS: Record = { + COMPANY: 'background:#dbeafe;color:#1d4ed8', + CANDIDATE: 'background:#e0e7ff;color:#4338ca', + CUSTOMER: 'background:#cffafe;color:#0e7490', + PHOTOGRAPHER: 'background:#ede9fe;color:#6d28d9', + MAKEUP_ARTIST: 'background:#fce7f3;color:#9d174d', + TUTOR: 'background:#d1fae5;color:#065f46', + DEVELOPER: 'background:#e0f2fe;color:#0369a1', + VIDEO_EDITOR: 'background:#fef3c7;color:#92400e', + GRAPHIC_DESIGNER: 'background:#f0fdf4;color:#166534', + SOCIAL_MEDIA_MANAGER: 'background:#fdf2f8;color:#86198f', + FITNESS_TRAINER: 'background:#fff7ed;color:#9a3412', + CATERING_SERVICE: 'background:#fefce8;color:#854d0e', + ADMIN: 'background:#f1f5f9;color:#334155', + UNKNOWN: 'background:#f8fafc;color:#64748b', +}; + +function RoleTypeBadge(props: { type?: RoleType }) { + const t = props.type || 'UNKNOWN'; + return ( + + {t.replace(/_/g, ' ')} + + ); +} + +function StatusBadge(props: { status: string; isDocRequest?: boolean }) { + const s = props.status.toUpperCase(); + const label = props.isDocRequest ? 'MORE DOCS REQUIRED' : s.replace(/_/g, ' '); + if (s === 'APPROVED') return {label}; + if (s === 'REJECTED') return {label}; + if (s === 'CHANGES_REQUESTED') return {label}; + if (s === 'CANCELLED') return {label}; + return {label}; +} + +// ────────────────────────────── API calls ────────────────────────────── + async function fetchApprovals(): Promise { try { const res = await fetch(`${API}/api/admin/approvals`); - if (!res.ok) throw new Error('Failed to load approvals'); + if (!res.ok) return []; const data = await res.json(); - return Array.isArray(data) ? data : (data.approvals || []); + const raw: Approval[] = Array.isArray(data) ? data : (data.approvals || []); + return raw.map(enrichApproval); } catch { return []; } @@ -59,7 +238,7 @@ async function fetchApprovals(): Promise { async function fetchRules(): Promise { try { const res = await fetch(`${API}/api/admin/approval-rules`); - if (!res.ok) throw new Error('Failed to load rules'); + if (!res.ok) return []; const data = await res.json(); return Array.isArray(data) ? data : (data.rules || []); } catch { @@ -67,36 +246,12 @@ async function fetchRules(): Promise { } } -function statusValue(item: Approval) { - return (item.requestStatus || item.status || 'PENDING').toUpperCase(); -} - -function requestTypeValue(item: Approval) { - return (item.requestType || item.type || 'OTHER').toUpperCase(); -} - -function requestClass(item: Approval): 'PROFILE' | 'JOB' | 'REQUIREMENT' | 'OTHER' { - const t = requestTypeValue(item); - if (t.includes('JOB')) return 'JOB'; - if (t.includes('LEAD') || t.includes('REQUIREMENT')) return 'REQUIREMENT'; - if (t.includes('PROFILE') || t.includes('USER') || t.includes('COMPANY') || t.includes('PROFESSIONAL') || t.includes('CUSTOMER')) return 'PROFILE'; - return 'OTHER'; -} - -function StatusBadge(props: { status: string }) { - const s = props.status.toUpperCase(); - if (s === 'APPROVED') return APPROVED; - if (s === 'REJECTED') return REJECTED; - if (s === 'CHANGES_REQUESTED') return CHANGES REQUESTED; - if (s === 'CANCELLED') return CANCELLED; - return {s}; -} +// ────────────────────────────── Component ────────────────────────────── export default function ApprovalPage() { const [activeTab, setActiveTab] = createSignal('PENDING'); - const [panelTab, setPanelTab] = createSignal('list'); + const [showDetail, setShowDetail] = createSignal(false); const [selectedApproval, setSelectedApproval] = createSignal(null); - const [search, setSearch] = createSignal(''); const [requestFilter, setRequestFilter] = createSignal('ALL'); const [currentPage, setCurrentPage] = createSignal(1); @@ -104,12 +259,14 @@ export default function ApprovalPage() { const [approvals, { refetch: refetchApprovals }] = createResource(fetchApprovals); const [acting, setActing] = createSignal(''); - const [approvalError, setApprovalError] = createSignal(''); + const [actionError, setActionError] = createSignal(''); + // Rules tab const [rules, { refetch: refetchRules }] = createResource(fetchRules); const [showAddRule, setShowAddRule] = createSignal(false); const [newEntityType, setNewEntityType] = createSignal('JOB_POST'); - const [newApproverType, setNewApproverType] = createSignal('USER'); + const [newApproverType, setNewApproverType] = createSignal('ROLE'); + const [newRuleName, setNewRuleName] = createSignal(''); const [newPriority, setNewPriority] = createSignal(1); const [ruleError, setRuleError] = createSignal(''); const [deletingRule, setDeletingRule] = createSignal(''); @@ -120,68 +277,140 @@ export default function ApprovalPage() { const q = search().trim().toLowerCase(); const rf = requestFilter(); const list = approvals() ?? []; - if (tab === 'rules') return [] as Approval[]; return list.filter((a) => { - const matchesStatus = statusValue(a) === tab; - if (!matchesStatus) return false; - - const cls = requestClass(a); - const matchesType = rf === 'ALL' || cls === rf; - if (!matchesType) return false; - + if (statusValue(a) !== tab) return false; + if (rf !== 'ALL' && (a._category || 'OTHER') !== rf) return false; if (!q) return true; - - const requesterName = (a.requester?.name || a.requesterName || a.requester_name || '').toLowerCase(); - const requesterEmail = (a.requester?.email || a.requesterEmail || a.requester_email || '').toLowerCase(); - const requestType = requestTypeValue(a).toLowerCase(); - - return requesterName.includes(q) || requesterEmail.includes(q) || requestType.includes(q) || a.id.toLowerCase().includes(q); + const n = requesterName(a).toLowerCase(); + const e = requesterEmail(a).toLowerCase(); + const t = (a._typeLabel || '').toLowerCase(); + return n.includes(q) || e.includes(q) || t.includes(q) || a.id.toLowerCase().includes(q); }); }); const totalPages = createMemo(() => Math.max(1, Math.ceil(filteredApprovals().length / perPage))); - const paginatedApprovals = createMemo(() => { const start = (currentPage() - 1) * perPage; return filteredApprovals().slice(start, start + perPage); }); - const countFor = (status: string) => { - const list = approvals() ?? []; - if (status === 'rules') return (rules() ?? []).length; - return list.filter((a) => statusValue(a) === status).length; + const countFor = (key: StatusTab) => { + if (key === 'rules') return (rules() ?? []).length; + return (approvals() ?? []).filter((a) => statusValue(a) === key).length; }; - const selectApproval = (approval: Approval) => { - setSelectedApproval(approval); - setPanelTab('view'); - }; + // ── Approval Actions ── - const handleAction = async (id: string, newStatus: 'APPROVED' | 'REJECTED' | 'CHANGES_REQUESTED' | 'CANCELLED') => { + const handleApprove = async (id: string) => { + if (!confirm('Approve this request?')) return; try { - setActing(`${id}-${newStatus}`); - setApprovalError(''); + setActing(`${id}-APPROVE`); + setActionError(''); const res = await fetch(`${API}/api/admin/approvals/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ status: newStatus }), + body: JSON.stringify({ status: 'APPROVED' }), }); - if (!res.ok) throw new Error(`Failed to ${newStatus.toLowerCase()} approval`); + if (!res.ok) throw new Error('Failed to approve'); refetchApprovals(); - if (selectedApproval()?.id === id) { - setSelectedApproval((prev) => (prev ? { ...prev, status: newStatus, requestStatus: newStatus } : prev)); + setSelectedApproval((prev) => prev ? { ...prev, requestStatus: 'APPROVED', status: 'APPROVED' } : prev); } } catch (err: any) { - setApprovalError(err.message || 'Action failed'); + setActionError(err.message || 'Failed to approve'); } finally { setActing(''); } }; + const handleReject = async (id: string) => { + const reason = prompt('Rejection reason (required):'); + if (!reason?.trim()) return; + try { + setActing(`${id}-REJECT`); + setActionError(''); + const res = await fetch(`${API}/api/admin/approvals/${id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ status: 'REJECTED', reason: reason.trim() }), + }); + if (!res.ok) throw new Error('Failed to reject'); + refetchApprovals(); + if (selectedApproval()?.id === id) { + setSelectedApproval((prev) => prev ? { ...prev, requestStatus: 'REJECTED', status: 'REJECTED' } : prev); + } + } catch (err: any) { + setActionError(err.message || 'Failed to reject'); + } finally { + setActing(''); + } + }; + + const handleRequestChanges = async (id: string) => { + const remark = prompt('Describe what needs to be corrected:'); + if (!remark?.trim()) return; + const fieldsRaw = prompt('Optional: comma-separated field keys to fix (e.g. pan,govt_id,resume) — press Enter to skip'); + const fields = (fieldsRaw || '').split(',').map((f) => f.trim()).filter(Boolean); + try { + setActing(`${id}-CHANGES`); + setActionError(''); + const res = await fetch(`${API}/api/admin/approvals/${id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + status: 'CHANGES_REQUESTED', + remark: remark.trim(), + requestedFields: fields, + }), + }); + if (!res.ok) throw new Error('Failed to request changes'); + refetchApprovals(); + if (selectedApproval()?.id === id) { + setSelectedApproval((prev) => prev ? { ...prev, requestStatus: 'CHANGES_REQUESTED', status: 'CHANGES_REQUESTED' } : prev); + } + } catch (err: any) { + setActionError(err.message || 'Failed to request changes'); + } finally { + setActing(''); + } + }; + + const handleRequestMoreDocuments = async (id: string) => { + const remark = prompt('Describe which extra documents are required:'); + if (!remark?.trim()) return; + const docsRaw = prompt('Document keys needed (comma-separated, e.g. govt_id_upload,resume,portfolio) — press Enter to skip'); + const docs = (docsRaw || '').split(',').map((d) => d.trim()).filter(Boolean); + try { + setActing(`${id}-DOCS`); + setActionError(''); + const res = await fetch(`${API}/api/admin/approvals/${id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + status: 'CHANGES_REQUESTED', + remark: remark.trim(), + requestedDocuments: docs, + remarkType: 'MORE_DOCUMENTS_REQUESTED', + }), + }); + if (!res.ok) throw new Error('Failed to request documents'); + refetchApprovals(); + if (selectedApproval()?.id === id) { + setSelectedApproval((prev) => prev ? { ...prev, requestStatus: 'CHANGES_REQUESTED', status: 'CHANGES_REQUESTED' } : prev); + } + } catch (err: any) { + setActionError(err.message || 'Failed to request documents'); + } finally { + setActing(''); + } + }; + + // ── Rule Actions ── + const handleAddRule = async () => { + if (!newRuleName().trim()) { setRuleError('Rule name is required'); return; } try { setSubmittingRule(true); setRuleError(''); @@ -189,6 +418,7 @@ export default function ApprovalPage() { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ + name: newRuleName().trim(), entityType: newEntityType(), approverType: newApproverType(), priority: newPriority(), @@ -196,8 +426,9 @@ export default function ApprovalPage() { }); if (!res.ok) throw new Error('Failed to create rule'); setShowAddRule(false); + setNewRuleName(''); setNewEntityType('JOB_POST'); - setNewApproverType('USER'); + setNewApproverType('ROLE'); setNewPriority(1); refetchRules(); } catch (err: any) { @@ -222,136 +453,190 @@ export default function ApprovalPage() { } }; + // ── Render ── + return ( + {/* Page header */}

Approval Management

-

Review and manage approvals and approval rules.

+

Review, approve, reject and configure approval workflows.

-
- {STATUS_TABS.map((t) => { - const count = countFor(t.key); - return ( - - ); - })} + {/* Status tabs */} +
+ + {(t) => { + const count = countFor(t.key); + return ( + + ); + }} +
+ +
{actionError()}
+
+ + {/* ── Approval List / Detail ── */} -
- - -
- - -
{approvalError()}
-
- - -
+ + {/* Filter bar */} +
{ - setSearch(e.currentTarget.value); - setCurrentPage(1); - }} - style="border:1px solid #cbd5e1;border-radius:6px;padding:7px 12px;font-size:14px;width:300px;outline:none;" + onInput={(e) => { setSearch(e.currentTarget.value); setCurrentPage(1); }} + style="border:1px solid #cbd5e1;border-radius:6px;padding:7px 12px;font-size:14px;width:280px;outline:none" /> - {filteredApprovals().length} records + {filteredApprovals().length} record{filteredApprovals().length !== 1 ? 's' : ''}
-
+
- - + + - - + - + - - + + - - - - 0}> + 0}> {(item) => { - const requesterName = item.requester?.name || item.requesterName || item.requester_name || '—'; - const requesterEmail = item.requester?.email || item.requesterEmail || item.requester_email || ''; const status = statusValue(item); - const requestType = requestTypeValue(item); - const submittedAt = item.createdAt || item.created_at; + const lastRemark = item._parsedReason?.adminRemarks?.at(-1); + const isDocRequest = lastRemark?.type === 'MORE_DOCUMENTS_REQUESTED' && status === 'CHANGES_REQUESTED'; + const dest = managementDestination(item._roleType || 'UNKNOWN'); + const isActing = acting().startsWith(item.id); return ( - + + - - - - + + @@ -364,52 +649,34 @@ export default function ApprovalPage() {
Approval ID RequesterRequest TypeTypeRequest Category StatusPrioritySubmitted AtSubmitted Actions
Loading...
Loading approvals...
Failed to load approvals.
No {activeTab() === 'PENDING' ? 'pending' : activeTab().toLowerCase().replace('_', ' ')} approvals.
No matching approvals.
{item.id.slice(0, 8)}... -
{requesterName}
- -
{requesterEmail}
+
{requesterName(item)}
+
{requesterEmail(item) || item.requesterId?.slice(0, 12)}
+
+ + +
{item._typeLabel || '—'}
+ +
{item._parsedReason?.templateId}
{requestType}{item.priority ?? '—'}{submittedAt ? new Date(submittedAt).toLocaleString() : '—'} + + + {(item.createdAt || item.created_at) ? new Date((item.createdAt || item.created_at)!).toLocaleDateString() : '—'} +
- - - - - + {/* View detail */} + + + - + {/* Request More Documents */} + + {/* Request Changes */} + + {/* Approve */} + + {/* Reject */} + - - + + {/* Approved → link to management page */} + + + Open {dest.label.replace(' Management', '')} +
- + Page {currentPage()} of {totalPages()} - +
- -
- Select an approval from list to view details.

}> -
-

Approval Details

- -
-
-
-

Request Summary

-

Approval ID: {selectedApproval()!.id}

-

Type: {requestTypeValue(selectedApproval()!)}

-

Status: {statusValue(selectedApproval()!)}

-

Priority: {selectedApproval()!.priority ?? '—'}

-

Submitted: {(selectedApproval()!.createdAt || selectedApproval()!.created_at) ? new Date((selectedApproval()!.createdAt || selectedApproval()!.created_at)!).toLocaleString() : '—'}

-
-
-

Requester

-

Name: {selectedApproval()!.requester?.name || selectedApproval()!.requesterName || selectedApproval()!.requester_name || '—'}

-

Email: {selectedApproval()!.requester?.email || selectedApproval()!.requesterEmail || selectedApproval()!.requester_email || '—'}

-

Class: {requestClass(selectedApproval()!)}

-
- - - -
-
-
-
-
+ {/* ── Inline Detail Panel ── */} + + setShowDetail(false)} + onApprove={handleApprove} + onReject={handleReject} + onRequestChanges={handleRequestChanges} + onRequestMoreDocuments={handleRequestMoreDocuments} + />
+ {/* ── Rules Tab ── */}
{ruleError()}
-
+
-
-

New Approval Rule

+
+

New Approval Rule

+
+ + setNewRuleName(e.currentTarget.value)} + placeholder="e.g. Profile Approval by Admin" + /> +
setNewPriority(parseInt(e.currentTarget.value, 10) || 1)} />
-
+
@@ -446,11 +721,12 @@ export default function ApprovalPage() {
-
+
+ @@ -459,24 +735,26 @@ export default function ApprovalPage() { - + - - + + - - - - 0}> + 0}> {(rule) => ( - + +
Rule Name Entity Type Approver Type Priority
Loading...
Loading...
Failed to load rules.
No approval rules configured yet.
No approval rules found.
{rule.entityType || rule.entity_type || '—'}{rule.name || '—'}{rule.entityType || rule.entity_type || '—'} {rule.approverType || rule.approver_type || '—'} {rule.priority ?? '—'}
-
@@ -493,3 +771,128 @@ export default function ApprovalPage() { ); } + +// ────────────────────────────── Detail Panel ────────────────────────────── + +function ApprovalDetailPanel(props: { + approval: Approval | null; + acting: string; + onBack: () => void; + onApprove: (id: string) => void; + onReject: (id: string) => void; + onRequestChanges: (id: string) => void; + onRequestMoreDocuments: (id: string) => void; +}) { + const a = () => props.approval; + const status = () => statusValue(a()!); + const isPending = () => status() === 'PENDING'; + const isActing = () => !!props.acting; + const dest = () => managementDestination(a()?._roleType || 'UNKNOWN'); + const remarks = () => a()?._parsedReason?.adminRemarks || []; + + return ( +

Select an approval from the list.

+ }> +
+
+

Approval Detail

+

{a()!._typeLabel || 'Request'}

+
+ +
+ +
+ {/* Request info */} +
+

Request Summary

+ + + + + + + + + +
ID{a()!.id}
Category{a()!._typeLabel || '—'}
Status
Priority{a()!.priority ?? '—'}
Template{a()!._parsedReason?.templateId || '—'}
Submitted{(a()!.createdAt || a()!.created_at) ? new Date((a()!.createdAt || a()!.created_at)!).toLocaleString() : '—'}
+
+ + {/* Requester info */} +
+

Requester

+ + + + + + + +
Name{requesterName(a()!)}
Email{requesterEmail(a()!) || '—'}
Role
Profession{a()!._parsedReason?.profession || '—'}
+ + {/* Actions */} +
+ + + + + + + + + Open {dest().label} + + + ↗ Full Page +
+
+
+ + {/* Submitted fields */} + 0}> +
+

Submitted Data

+
+ {Object.entries(a()!._parsedReason!.values!).map(([k, v]) => ( +
+
{k.replace(/_/g, ' ').toUpperCase()}
+
{String(v) || '—'}
+
+ ))} +
+
+
+ + {/* Admin remarks history */} + 0}> +
+

Admin Remarks History

+
+ + {(remark, i) => { + const typeColor: Record = { + INFO: '#e0f2fe', + CHANGES_REQUESTED: '#fff7ed', + MORE_DOCUMENTS_REQUESTED: '#eff6ff', + REJECTED: '#fef2f2', + }; + return ( +
+
+ {remark.type.replace(/_/g, ' ')} + Remark #{i() + 1} +
+

{remark.comment}

+ 0}> +

Fields: {remark.fields!.join(', ')}

+
+
+ ); + }} +
+
+
+
+
+ ); +} diff --git a/src/routes/admin/approval/[id].tsx b/src/routes/admin/approval/[id].tsx index 54e6d5d..359d032 100644 --- a/src/routes/admin/approval/[id].tsx +++ b/src/routes/admin/approval/[id].tsx @@ -1,29 +1,158 @@ -import { A, useParams } from '@solidjs/router'; -import { createMemo, createResource, createSignal, Show } from 'solid-js'; +import { A, useParams, useSearchParams } from '@solidjs/router'; +import { createMemo, createResource, createSignal, For, Show, createEffect } from 'solid-js'; import AdminShell from '~/components/AdminShell'; const API = '/api/gateway'; -type ApprovalDetail = { - id: string; - requestType?: string; - type?: string; - requestStatus?: string; - status?: string; - priority?: number; - createdAt?: string; - created_at?: string; - requester?: { name?: string; email?: string }; - requesterName?: string; - requesterEmail?: string; - requester_name?: string; - requester_email?: string; - payload?: unknown; +// ── Types ────────────────────────────────────────────────────────── + +type RoleType = + | 'COMPANY' | 'CANDIDATE' | 'CUSTOMER' | 'PHOTOGRAPHER' | 'MAKEUP_ARTIST' + | 'TUTOR' | 'DEVELOPER' | 'VIDEO_EDITOR' | 'GRAPHIC_DESIGNER' + | 'SOCIAL_MEDIA_MANAGER' | 'FITNESS_TRAINER' | 'CATERING_SERVICE' + | 'ADMIN' | 'UNKNOWN'; + +interface SubmissionData { + user: { + id: string; + name?: string; + email: string; + phone?: string; + status: string; + email_verified: boolean; + created_at: string; + }; + role_key?: string; + onboarding?: { + status: string; + progress_json: Record; + completed_at?: string; + updated_at: string; + } | null; +} + +interface AdminRemark { + type: 'INFO' | 'CHANGES_REQUESTED' | 'MORE_DOCUMENTS_REQUESTED' | 'REJECTED'; + comment: string; + fields?: string[]; +} + +// ── Helpers ────────────────────────────────────────────────────────── + +function inferRoleType(roleKey?: string, name?: string): RoleType { + const raw = [roleKey, name].filter(Boolean).join(' ').toLowerCase(); + if (raw.includes('photographer')) return 'PHOTOGRAPHER'; + if (raw.includes('makeup')) return 'MAKEUP_ARTIST'; + if (raw.includes('tutor')) return 'TUTOR'; + if (raw.includes('developer')) return 'DEVELOPER'; + if (raw.includes('video') || raw.includes('video_editor')) return 'VIDEO_EDITOR'; + if (raw.includes('graphic') || raw.includes('graphic_designer')) return 'GRAPHIC_DESIGNER'; + if (raw.includes('social') || raw.includes('social_media')) return 'SOCIAL_MEDIA_MANAGER'; + if (raw.includes('fitness') || raw.includes('fitness_trainer')) return 'FITNESS_TRAINER'; + if (raw.includes('catering')) return 'CATERING_SERVICE'; + if (raw.includes('customer')) return 'CUSTOMER'; + if (raw.includes('job_seeker') || raw.includes('candidate')) return 'CANDIDATE'; + if (raw.includes('company')) return 'COMPANY'; + if (raw.includes('admin') || raw.includes('employee')) return 'ADMIN'; + return 'UNKNOWN'; +} + +function managementDest(roleType: RoleType): { label: string; href: string } { + const map: Record = { + COMPANY: { label: 'Company Management', href: '/admin/company' }, + CANDIDATE: { label: 'Candidate Management', href: '/admin/candidate' }, + CUSTOMER: { label: 'Customer Management', href: '/admin/customer' }, + PHOTOGRAPHER: { label: 'Photographer Management', href: '/admin/photographer' }, + MAKEUP_ARTIST: { label: 'Makeup Artist Management', href: '/admin/makeup-artist' }, + TUTOR: { label: 'Tutors Management', href: '/admin/tutors' }, + DEVELOPER: { label: 'Developers Management', href: '/admin/developers' }, + VIDEO_EDITOR: { label: 'Video Editor Management', href: '/admin/video-editors' }, + GRAPHIC_DESIGNER: { label: 'Graphics Designer Management', href: '/admin/graphic-designers' }, + SOCIAL_MEDIA_MANAGER: { label: 'Social Media Manager Management', href: '/admin/social-media-managers' }, + FITNESS_TRAINER: { label: 'Fitness Trainer Management', href: '/admin/fitness-trainers' }, + CATERING_SERVICE: { label: 'Catering Services Management', href: '/admin/catering-services' }, + ADMIN: { label: 'Employee Management', href: '/admin/employees' }, + UNKNOWN: { label: 'Users Management', href: '/admin/users' }, + }; + return map[roleType] ?? map.UNKNOWN; +} + +/** Flatten nested JSON into key→value pairs for display */ +function flattenFields(obj: Record, prefix = ''): Array<{ key: string; value: string }> { + const result: Array<{ key: string; value: string }> = []; + for (const [k, v] of Object.entries(obj)) { + const label = prefix ? `${prefix}.${k}` : k; + if (v !== null && typeof v === 'object' && !Array.isArray(v)) { + result.push(...flattenFields(v as Record, label)); + } else if (Array.isArray(v)) { + result.push({ key: label, value: v.join(', ') }); + } else { + result.push({ key: label, value: String(v ?? '—') }); + } + } + return result; +} + +/** Skip internal tracking fields — only show what the user actually submitted */ +const SKIP_KEYS = new Set(['step', 'total', 'currentStep', '__version', '__schema']); + +function isSubmittedField(key: string): boolean { + return !SKIP_KEYS.has(key) && !key.startsWith('_'); +} + +const ROLE_COLORS: Record = { + COMPANY: 'background:#dbeafe;color:#1d4ed8', + CANDIDATE: 'background:#e0e7ff;color:#4338ca', + CUSTOMER: 'background:#cffafe;color:#0e7490', + PHOTOGRAPHER: 'background:#ede9fe;color:#6d28d9', + MAKEUP_ARTIST: 'background:#fce7f3;color:#9d174d', + TUTOR: 'background:#d1fae5;color:#065f46', + DEVELOPER: 'background:#e0f2fe;color:#0369a1', + VIDEO_EDITOR: 'background:#fef3c7;color:#92400e', + GRAPHIC_DESIGNER: 'background:#f0fdf4;color:#166534', + SOCIAL_MEDIA_MANAGER: 'background:#fdf2f8;color:#86198f', + FITNESS_TRAINER: 'background:#fff7ed;color:#9a3412', + CATERING_SERVICE: 'background:#fefce8;color:#854d0e', + ADMIN: 'background:#f1f5f9;color:#334155', + UNKNOWN: 'background:#f8fafc;color:#64748b', }; -async function loadApproval(id: string): Promise { +// ── Field type detection ────────────────────────────────────────────────────────── + +type FieldKind = 'image' | 'pdf' | 'document' | 'url' | 'text'; + +function detectKind(key: string, value: string): FieldKind { + const k = key.toLowerCase(); + const v = (value || '').toLowerCase(); + + // Image: key hints OR common image extensions + if ( + /photo|image|picture|avatar|selfie|headshot|thumbnail|profile_pic/i.test(k) || + /\.(jpg|jpeg|png|gif|webp|bmp|svg)(\?|$)/i.test(v) + ) return 'image'; + + // PDF + if (/\.(pdf)(\?|$)/i.test(v) || /pdf|resume|cv\b/i.test(k)) return 'pdf'; + + // Generic document/upload (not image/pdf) + if ( + /upload|document|file|attachment|certificate|license|govt_id|aadhaar|pan|passport|degree|transcript|portfolio|id_proof/i.test(k) || + /\.(doc|docx|xls|xlsx|ppt|pptx|zip|rar)(\?|$)/i.test(v) + ) return 'document'; + + // URL that isn't a file + if (value.startsWith('http') || value.startsWith('/')) return 'url'; + + return 'text'; +} + +// ── Data loaders ────────────────────────────────────────────────────────── + +async function loadSubmission(args: { userId: string; roleKey: string }): Promise { + if (!args.userId) return null; try { - const res = await fetch(`${API}/api/admin/approvals/${id}`); + const qs = args.roleKey ? `?roleKey=${encodeURIComponent(args.roleKey)}` : ''; + const res = await fetch(`${API}/api/admin/approvals/submission/${args.userId}${qs}`); if (!res.ok) return null; return res.json(); } catch { @@ -31,31 +160,86 @@ async function loadApproval(id: string): Promise { } } +// ── Page ────────────────────────────────────────────────────────── + export default function ApprovalDetailPage() { const params = useParams(); - const [approval, { refetch }] = createResource(() => params.id, loadApproval); + const [searchParams] = useSearchParams(); + + // params.id can be either: + // - a user UUID → we load the submission directly + // - an old approval request ID → shown as legacy fallback + const userId = () => params.id; + const roleKey = () => (searchParams.roleKey as string) || ''; + + const [data] = createResource( + () => ({ userId: userId(), roleKey: roleKey() }), + loadSubmission, + ); + const [acting, setActing] = createSignal(''); - const [error, setError] = createSignal(''); + const [actionError, setActionError] = createSignal(''); + const [actionDone, setActionDone] = createSignal(''); - const status = createMemo(() => (approval()?.requestStatus || approval()?.status || 'PENDING').toUpperCase()); - const requestType = createMemo(() => (approval()?.requestType || approval()?.type || 'OTHER').toUpperCase()); - const requesterName = createMemo(() => approval()?.requester?.name || approval()?.requesterName || approval()?.requester_name || 'Unknown'); - const requesterEmail = createMemo(() => approval()?.requester?.email || approval()?.requesterEmail || approval()?.requester_email || '—'); - const submittedAt = createMemo(() => approval()?.createdAt || approval()?.created_at || ''); + const roleType = createMemo(() => inferRoleType(data()?.role_key, undefined)); + const dest = createMemo(() => managementDest(roleType())); - const act = async (nextStatus: 'APPROVED' | 'REJECTED' | 'CHANGES_REQUESTED') => { + // Flatten progress_json into displayable rows + const submittedRows = createMemo(() => { + const pj = data()?.onboarding?.progress_json; + if (!pj || typeof pj !== 'object') return []; + return flattenFields(pj as Record) + .filter((f) => isSubmittedField(f.key)); + }); + + // ── Approve / Reject ── + // Routes: POST /api/admin/approvals/profiles/professional/{role_key}/{user_id}/approve + // POST /api/admin/approvals/profiles/company/{user_id}/approve + // POST /api/admin/approvals/profiles/customer/{user_id}/approve + + const getApprovalPath = (action: 'approve' | 'reject') => { + const rk = (roleKey() || '').toUpperCase(); + const uid = userId(); + if (rk === 'COMPANY') return `/api/admin/approvals/profiles/company/${uid}/${action}`; + if (rk === 'CUSTOMER') return `/api/admin/approvals/profiles/customer/${uid}/${action}`; + if (rk) return `/api/admin/approvals/profiles/professional/${rk}/${uid}/${action}`; + return null; + }; + + const handleApprove = async () => { + if (!confirm('Approve this profile submission?')) return; + const path = getApprovalPath('approve'); + if (!path) { setActionError('Cannot resolve approval endpoint — roleKey missing in URL'); return; } try { - setActing(nextStatus); - setError(''); - const res = await fetch(`${API}/api/admin/approvals/${params.id}`, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ status: nextStatus }), - }); - if (!res.ok) throw new Error(`Failed to mark as ${nextStatus.toLowerCase()}`); - refetch(); + setActing('APPROVE'); + setActionError(''); + const res = await fetch(`${API}${path}`, { method: 'POST' }); + if (!res.ok) throw new Error('Failed to approve'); + setActionDone('APPROVED'); } catch (err: any) { - setError(err.message || 'Failed to update approval status'); + setActionError(err.message || 'Failed to approve'); + } finally { + setActing(''); + } + }; + + const handleReject = async () => { + const reason = prompt('Rejection reason (required):'); + if (!reason?.trim()) return; + const path = getApprovalPath('reject'); + if (!path) { setActionError('Cannot resolve rejection endpoint — roleKey missing in URL'); return; } + try { + setActing('REJECT'); + setActionError(''); + const res = await fetch(`${API}${path}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ reason: reason.trim() }), + }); + if (!res.ok) throw new Error('Failed to reject'); + setActionDone('REJECTED'); + } catch (err: any) { + setActionError(err.message || 'Failed to reject'); } finally { setActing(''); } @@ -65,58 +249,369 @@ export default function ApprovalDetailPage() {
-

Approval Detail

-

Review one approval request in detail and take action.

+

Submission Review

+

Review a user's onboarding form submission and take action.

- Back to Approval List + ← Back to Approvals
- -
{error()}
+ +
{actionError()}
- -

Loading approval...

+ +
+

+ {actionDone() === 'APPROVED' ? '✓ Profile approved successfully.' : '✕ Profile rejected.'} +

+ + Open {dest().label} → + +
- -

Approval request not found.

+ +

Loading submission...

- -
-
-

Request Summary

-

ID: {approval()!.id}

-

Type: {requestType()}

-

Status: {status()}

-

Priority: {approval()!.priority ?? '—'}

-

Submitted: {submittedAt() ? new Date(submittedAt()).toLocaleString() : '—'}

-
+ +
+

Submission not found or user does not have an onboarding record for this role.

+

+ Make sure the URL includes ?roleKey=PHOTOGRAPHER (or the relevant role key). +

+
+
-
-

Requester

-

Name: {requesterName()}

-

Email: {requesterEmail()}

-
- - - -
-
+ +
+
+ +
+ {/* User info */} +
+

User Info

+ + + + + + + + + + + + + + + + +
Name{data()!.user.name || '—'}
Email{data()!.user.email}
Phone{data()!.user.phone || '—'}
Account Status + + {data()!.user.status} + +
Email Verified + {data()!.user.email_verified ? '✓ Yes' : '✕ No'} +
Registered{new Date(data()!.user.created_at).toLocaleDateString()}
User ID{data()!.user.id}
+
+ + {/* Submission status */} +
+

Submission Info

+ +

No onboarding data found

+

+ This user has not started or submitted the onboarding form for role: {roleKey() || 'unknown'} +

+
+ }> + + + + + + + + + + + +
Role{(roleKey() || '—').replace(/_/g, ' ')}
Status + + {data()!.onboarding!.status} + +
Submitted{data()!.onboarding!.completed_at ? new Date(data()!.onboarding!.completed_at!).toLocaleString() : '—'}
Last Updated{new Date(data()!.onboarding!.updated_at).toLocaleString()}
Fields{submittedRows().length} fields submitted
+ +
-
-

Raw Payload

-
{JSON.stringify(approval(), null, 2)}
-
+ {/* ── Submitted form answers + media viewer ── */} + 0}> + + + + {/* ── No form data fallback ── */} + +
+

Submitted Form Answers

+

Onboarding state is present but contains no displayable field data.

+
+
); } + +// ────────────────────────────── SubmissionViewer ────────────────────────────── + +function SubmissionViewer(props: { rows: Array<{ key: string; value: string }> }) { + const [lightbox, setLightbox] = createSignal<{ src: string; label: string } | null>(null); + const [pdfViewer, setPdfViewer] = createSignal<{ src: string; label: string } | null>(null); + + // Split rows into text fields vs media + const textFields = createMemo(() => + props.rows.filter((r) => { + const kind = detectKind(r.key, r.value); + return kind === 'text' || kind === 'url'; + }) + ); + const mediaFields = createMemo(() => + props.rows.filter((r) => { + const kind = detectKind(r.key, r.value); + return kind === 'image' || kind === 'pdf' || kind === 'document'; + }) + ); + + return ( + <> + {/* ── Text / data fields ── */} + 0}> +
+

+ Submitted Form Data + {textFields().length} fields +

+
+ + {(field) => { + const kind = detectKind(field.key, field.value); + return ( +
+
+ {field.key.replace(/_/g, ' ').replace(/\./g, ' › ').toUpperCase()} +
+ {field.value || '—'}
+ }> + + 🔗 {field.value} + + +
+ ); + }} + +
+ +
+ + {/* ── Documents & Images ── */} + 0}> +
+

+ Documents & Media + {mediaFields().length} file{mediaFields().length !== 1 ? 's' : ''} +

+
+ + {(field) => { + const kind = detectKind(field.key, field.value); + const label = field.key.replace(/_/g, ' ').replace(/\./g, ' › '); + return ( +
+ {/* Preview area */} + +
setLightbox({ src: field.value, label })} + > + {label} { + (e.target as HTMLImageElement).style.display = 'none'; + ((e.target as HTMLImageElement).nextElementSibling as HTMLElement)!.style.display = 'flex'; + }} + /> +
+ 🖼 + Preview unavailable +
+
+ 🔍 Click to enlarge +
+
+
+ + +
setPdfViewer({ src: field.value, label })} + > + 📄 + PDF Document + Click to view +
+
+ + + + + + {/* Label + open link */} +
+
+ {label} +
+
+ + + + + + + + ↗ Download + +
+
+
+ ); + }} +
+
+
+
+ + {/* ── Image Lightbox ── */} + +
setLightbox(null)} + > +
+ {lightbox()!.label} +
+ e.stopPropagation()}> + ↗ Open original + + +
+
+ {lightbox()!.label} e.stopPropagation()} + /> +
+
+ + {/* ── PDF Viewer Modal ── */} + +
+
+ {/* Header */} +
+ 📄 {pdfViewer()!.label} +
+ + ↗ Open in new tab + + +
+
+ {/* PDF embed */} +