feat(admin-parity): approval management, submission viewer, role builder offline fallback

- Rebuilt approval.tsx: reject with reason prompt, request changes with field
  keys, request more documents flow, RoleTypeBadge per role type, parsed
  requestReason (ONBOARDING_SUBMISSION prefix), management page routing for
  approved items, inline ApprovalDetailPanel with remarks timeline
- Rebuilt approval/[id].tsx: full onboarding submission viewer loading from
  GET /api/admin/approvals/submission/{user_id}?roleKey=X, flattenFields
  recursive flattener, detectKind image/pdf/document/url/text classifier,
  image lightbox, PDF iframe modal, approve/reject per role type routing
- Added src/lib/admin-modules.ts: STATIC_PERMISSIONS fallback (39 modules x4
  actions) for Internal Role Builder when backend is offline
- roles/create.tsx + roles/[id]/edit.tsx: use STATIC_PERMISSIONS when API
  returns empty or fails, builder now works offline
- AdminShell + AdminSidebar: alignment/style fixes from parity review
- ExternalRoleForm: extended fields for external runtime role management

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Ashwin Kumar 2026-03-23 00:34:38 +01:00
parent 9980cd4fe5
commit 2d161c4f15
8 changed files with 1514 additions and 330 deletions

View file

@ -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 { createMemo, createSignal, onMount, type JSX } from 'solid-js';
import AdminSidebar from './AdminSidebar'; import AdminSidebar from './AdminSidebar';
import { isExternalIdentity } from '~/lib/admin-auth'; 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'; import { sidebarCollapsed } from '~/lib/sidebar-state';
type Tab = { href: string; label: string; exact?: boolean }; 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-status', title: 'Verification Status' },
{ prefix: '/admin/verification', title: 'Verification Review' }, { prefix: '/admin/verification', title: 'Verification Review' },
{ prefix: '/admin/approval', title: 'Approval Management' }, { 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/company', title: 'Company Management' },
{ prefix: '/admin/customer', title: 'Customer Management' }, { prefix: '/admin/customer', title: 'Customer Management' },
{ prefix: '/admin/candidate', title: 'Candidate Management' }, { prefix: '/admin/candidate', title: 'Candidate Management' },
{ prefix: '/admin/photographer', title: 'Photographer Management' }, { prefix: '/admin/photographer', title: 'Photographer Management' },
{ prefix: '/admin/makeup-artist', title: 'Makeup Artist Management' }, { prefix: '/admin/makeup-artist', title: 'Makeup Artist Management' },
{ prefix: '/admin/tutors', title: 'Tutor Management' }, { prefix: '/admin/tutors', title: 'Tutors Management' },
{ prefix: '/admin/developers', title: 'Developer Management' }, { prefix: '/admin/developers', title: 'Developers Management' },
{ prefix: '/admin/graphic-designers', title: 'Graphics Designer Management' },
{ prefix: '/admin/jobs', title: 'Jobs Management' }, { prefix: '/admin/jobs', title: 'Jobs Management' },
{ prefix: '/admin/leads', title: 'Leads Management' }, { prefix: '/admin/leads', title: 'Leads Management' },
{ prefix: '/admin/pricing', title: 'Pricing 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/credit', title: 'Credit Management' },
{ prefix: '/admin/ledger', title: 'Ledger Management' }, { prefix: '/admin/ledger', title: 'Ledger Management' },
{ prefix: '/admin/report', title: 'Report Management' }, { prefix: '/admin/report', title: 'Report Management' },
{ prefix: '/admin/employees', title: 'Employee Management' },
{ prefix: '/admin/roles', title: 'Internal Role Management' }, { prefix: '/admin/roles', title: 'Internal Role Management' },
{ prefix: '/admin/external-role-management', title: 'External Role Management' }, { prefix: '/admin/external-role-management', title: 'External Role Management' },
{ prefix: '/admin/internal-role-management', title: 'Internal Role Management' }, { prefix: '/admin/internal-role-management', title: 'Internal Role Management' },
{ prefix: '/admin/runtime-roles', title: 'External Role Management' }, { prefix: '/admin/runtime-roles', title: 'External Role Management' },
{ prefix: '/admin/onboarding-management', title: 'Onboarding Management' }, { prefix: '/admin/onboarding-management', title: 'External Onboarding Management' },
{ prefix: '/admin/onboarding-schemas', title: 'Onboarding Management' }, { prefix: '/admin/onboarding-schemas', title: 'External Onboarding Management' },
{ prefix: '/admin/kb', title: 'KB Management' },
{ prefix: '/admin', title: 'Dashboard' }, { prefix: '/admin', title: 'Dashboard' },
]; ];
export default function AdminShell(props: { children: JSX.Element }) { export default function AdminShell(props: { children: JSX.Element }) {
const location = useLocation(); const location = useLocation();
const navigate = useNavigate(); const navigate = useNavigate();
const [searchParams] = useSearchParams();
const [checkedSession, setCheckedSession] = createSignal(false); const [checkedSession, setCheckedSession] = createSignal(false);
const tabs = createMemo<Tab[]>(() => { const tabs = createMemo<Tab[]>(() => {
@ -116,6 +120,20 @@ export default function AdminShell(props: { children: JSX.Element }) {
}); });
onMount(() => { 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 () => { const verify = async () => {
if (!hasAdminSession()) { if (!hasAdminSession()) {
const from = encodeURIComponent(location.pathname + location.search); const from = encodeURIComponent(location.pathname + location.search);

View file

@ -5,28 +5,28 @@ type LinkItem = { legacyHref: string; href: string; label: string; icon: string;
const links: LinkItem[] = [ const links: LinkItem[] = [
{ legacyHref: '/', href: '/admin', label: 'Dashboard', icon: 'dashboard.svg' }, { 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: '/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: '/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: '/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: '/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: '/approval', href: '/admin/approval', label: 'Approval Management', icon: 'approval.svg' },
{ legacyHref: '/users', href: '/admin/users', label: 'External User Management', icon: 'users.svg' }, { legacyHref: '/department', href: '/admin/department', label: 'Department Management', icon: 'department.svg' },
{ legacyHref: '/customer', href: '/admin/customer', label: 'Customer Management', icon: 'users.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: '/company', href: '/admin/company', label: 'Company Management', icon: 'company.svg' },
{ legacyHref: '/candidate', href: '/admin/candidate', label: 'Candidate Management', icon: 'candidate.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: '/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: '/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: '/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: '/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: '/graphic-designers', href: '/admin/graphic-designers', label: 'Graphics Designer Management', icon: 'developers.svg' },
{ legacyHref: '/tutors', href: '/admin/tutors', label: 'Tutor Management', icon: 'tutor.svg' }, { legacyHref: '/social-media-managers', href: '/admin/social-media-managers', label: 'Social Media Manager Management', icon: 'developers.svg' },
{ legacyHref: '/developers', href: '/admin/developers', label: 'Developer Management', icon: 'developers.svg' },
{ legacyHref: '/jobs', href: '/admin/jobs', label: 'Jobs Management', icon: 'jobs.svg' }, { legacyHref: '/jobs', href: '/admin/jobs', label: 'Jobs Management', icon: 'jobs.svg' },
{ legacyHref: '/leads', href: '/admin/leads', label: 'Leads Management', icon: 'leads.svg' }, { legacyHref: '/leads', href: '/admin/leads', label: 'Leads Management', icon: 'leads.svg' },
{ legacyHref: '/pricing', href: '/admin/pricing', label: 'Pricing Management', icon: 'pricing.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: '/order', href: '/admin/order', label: 'Order Management', icon: 'order.svg' },
{ legacyHref: '/invoice', href: '/admin/invoice', label: 'Invoice Management', icon: 'invoice.svg' }, { legacyHref: '/invoice', href: '/admin/invoice', label: 'Invoice Management', icon: 'invoice.svg' },
{ legacyHref: '/review', href: '/admin/review', label: 'Review Management', icon: 'reviews.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: '/help', href: '/admin/support', label: 'Support Management', icon: 'support.svg' },
{ legacyHref: '/report', href: '/admin/report', label: 'Report Management', icon: 'report.svg' }, { legacyHref: '/report', href: '/admin/report', label: 'Report Management', icon: 'report.svg' },
{ legacyHref: '/ledger', href: '/admin/ledger', label: 'Ledger Management', icon: 'ledger.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() { export default function AdminSidebar() {

View file

@ -46,39 +46,68 @@ const ROLE_PERMISSION_ACTIONS: RolePermissionAction[] = [
const ONBOARDING_SCHEMA_OPTIONS = [ const ONBOARDING_SCHEMA_OPTIONS = [
'company_onboarding_v1', 'company_onboarding_v1',
'job_seeker_onboarding_v1', 'job_seeker_onboarding_v1',
'customer_onboarding_v1',
'photographer_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', 'default_onboarding_v1',
]; ];
const MODULES_BY_VERTICAL: Record<'jobs' | 'marketplace', ModuleOption[]> = { const MODULES_BY_VERTICAL: Record<'jobs' | 'marketplace', ModuleOption[]> = {
jobs: [ jobs: [
{ key: 'jobs', label: 'Jobs', description: 'Manage job postings and candidate flow.' }, { key: 'dashboard', label: 'Dashboard', description: 'Home page with KPI summary cards.' },
{ key: 'applications', label: 'Applications', description: 'Review incoming applications.' },
{ key: 'responses', label: 'Responses', description: 'Track response lifecycle states.' },
{ key: 'profile', label: 'Profile', description: 'Maintain role profile and preferences.' }, { 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: 'notifications', label: 'Notifications', description: 'View and manage alerts.' },
{ key: 'settings', label: 'Settings', description: 'Account settings and password.' },
], ],
marketplace: [ marketplace: [
{ key: 'leads', label: 'Leads', description: 'Handle customer lead requests.' }, { key: 'dashboard', label: 'Dashboard', description: 'Home page with KPI summary cards.' },
{ key: 'portfolio', label: 'Portfolio', description: 'Publish portfolio and service highlights.' }, { key: 'profile', label: 'Profile', description: 'Public-facing professional or customer profile.' },
{ key: 'verification', label: 'Verification', description: 'Track onboarding verification progress.' }, { key: 'portfolio', label: 'Portfolio', description: 'Publish portfolio items and case studies.' },
{ key: 'pricing', label: 'Pricing', description: 'Manage plans, pricing, and packages.' }, { key: 'services', label: 'Services', description: 'List services with pricing and duration.' },
{ key: 'support', label: 'Support', description: 'Access support workflows and help content.' }, { 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<string, string[]> = {
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<string, ExternalRoleConfig> = { const DEFAULT_PRESETS: Record<string, ExternalRoleConfig> = {
company: { company: {
roleKey: 'company', roleKey: 'company',
displayName: 'Company', displayName: 'Company',
vertical: 'jobs', vertical: 'jobs',
roleCategory: 'employer', roleCategory: 'employer',
enabledModules: ['jobs', 'applications', 'responses', 'profile'], enabledModules: ['dashboard', 'profile', 'jobs', 'applications', 'notifications', 'settings'],
permissions: { permissions: {
dashboard: ['read'],
profile: ['read', 'update'],
jobs: ['read', 'create', 'update'], jobs: ['read', 'create', 'update'],
applications: ['read', 'approve'], applications: ['read', 'approve'],
responses: ['read', 'update'], notifications: ['read'],
profile: ['read', 'update'], settings: ['read', 'update'],
}, },
onboardingSchemaId: 'company_onboarding_v1', onboardingSchemaId: 'company_onboarding_v1',
requiresOnboardingApproval: true, requiresOnboardingApproval: true,
@ -88,18 +117,58 @@ const DEFAULT_PRESETS: Record<string, ExternalRoleConfig> = {
runtimeConfigVersion: 1, runtimeConfigVersion: 1,
isActive: true, 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: { photographer: {
roleKey: 'photographer', roleKey: 'photographer',
displayName: 'Photographer', displayName: 'Photographer',
vertical: 'marketplace', vertical: 'marketplace',
roleCategory: 'provider', roleCategory: 'provider',
enabledModules: ['leads', 'portfolio', 'verification', 'pricing'], enabledModules: MARKETPLACE_PROVIDER_MODULES,
permissions: { permissions: MARKETPLACE_PROVIDER_PERMISSIONS,
leads: ['read', 'update'],
portfolio: ['read', 'create', 'update'],
verification: ['read'],
pricing: ['read', 'update'],
},
onboardingSchemaId: 'photographer_onboarding_v1', onboardingSchemaId: 'photographer_onboarding_v1',
requiresOnboardingApproval: true, requiresOnboardingApproval: true,
requiresLeadApproval: true, requiresLeadApproval: true,
@ -108,6 +177,126 @@ const DEFAULT_PRESETS: Record<string, ExternalRoleConfig> = {
runtimeConfigVersion: 1, runtimeConfigVersion: 1,
isActive: true, 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 { function slugifyRoleKey(value: string): string {
@ -238,9 +427,25 @@ export default function ExternalRoleForm(props: ExternalRoleFormProps) {
} }
}} }}
> >
<option value="">Choose a default role</option> <option value="">Choose a role preset</option>
<option value="company">Company</option> <optgroup label="Jobs Vertical">
<option value="photographer">Photographer</option> <option value="company">Company (Employer)</option>
<option value="job_seeker">Job Seeker</option>
</optgroup>
<optgroup label="Marketplace — Consumer">
<option value="customer">Customer</option>
</optgroup>
<optgroup label="Marketplace — Providers">
<option value="photographer">Photographer</option>
<option value="makeup_artist">Makeup Artist</option>
<option value="tutor">Tutor</option>
<option value="developer">Developer</option>
<option value="video_editor">Video Editor</option>
<option value="graphic_designer">Graphic Designer</option>
<option value="social_media_manager">Social Media Manager</option>
<option value="fitness_trainer">Fitness Trainer</option>
<option value="catering_service">Catering Service</option>
</optgroup>
</select> </select>
</div> </div>
</div> </div>

59
src/lib/admin-modules.ts Normal file
View file

@ -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,
})),
);

File diff suppressed because it is too large Load diff

View file

@ -1,29 +1,158 @@
import { A, useParams } from '@solidjs/router'; import { A, useParams, useSearchParams } from '@solidjs/router';
import { createMemo, createResource, createSignal, Show } from 'solid-js'; import { createMemo, createResource, createSignal, For, Show, createEffect } from 'solid-js';
import AdminShell from '~/components/AdminShell'; import AdminShell from '~/components/AdminShell';
const API = '/api/gateway'; const API = '/api/gateway';
type ApprovalDetail = { // ── Types ──────────────────────────────────────────────────────────
id: string;
requestType?: string; type RoleType =
type?: string; | 'COMPANY' | 'CANDIDATE' | 'CUSTOMER' | 'PHOTOGRAPHER' | 'MAKEUP_ARTIST'
requestStatus?: string; | 'TUTOR' | 'DEVELOPER' | 'VIDEO_EDITOR' | 'GRAPHIC_DESIGNER'
status?: string; | 'SOCIAL_MEDIA_MANAGER' | 'FITNESS_TRAINER' | 'CATERING_SERVICE'
priority?: number; | 'ADMIN' | 'UNKNOWN';
createdAt?: string;
created_at?: string; interface SubmissionData {
requester?: { name?: string; email?: string }; user: {
requesterName?: string; id: string;
requesterEmail?: string; name?: string;
requester_name?: string; email: string;
requester_email?: string; phone?: string;
payload?: unknown; status: string;
email_verified: boolean;
created_at: string;
};
role_key?: string;
onboarding?: {
status: string;
progress_json: Record<string, unknown>;
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<RoleType, { label: string; href: string }> = {
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<string, unknown>, 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<string, unknown>, 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<RoleType, string> = {
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<ApprovalDetail | null> { // ── 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<SubmissionData | null> {
if (!args.userId) return null;
try { 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; if (!res.ok) return null;
return res.json(); return res.json();
} catch { } catch {
@ -31,31 +160,86 @@ async function loadApproval(id: string): Promise<ApprovalDetail | null> {
} }
} }
// ── Page ──────────────────────────────────────────────────────────
export default function ApprovalDetailPage() { export default function ApprovalDetailPage() {
const params = useParams(); 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 [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 roleType = createMemo(() => inferRoleType(data()?.role_key, undefined));
const requestType = createMemo(() => (approval()?.requestType || approval()?.type || 'OTHER').toUpperCase()); const dest = createMemo(() => managementDest(roleType()));
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 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<string, unknown>)
.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 { try {
setActing(nextStatus); setActing('APPROVE');
setError(''); setActionError('');
const res = await fetch(`${API}/api/admin/approvals/${params.id}`, { const res = await fetch(`${API}${path}`, { method: 'POST' });
method: 'PATCH', if (!res.ok) throw new Error('Failed to approve');
headers: { 'Content-Type': 'application/json' }, setActionDone('APPROVED');
body: JSON.stringify({ status: nextStatus }),
});
if (!res.ok) throw new Error(`Failed to mark as ${nextStatus.toLowerCase()}`);
refetch();
} catch (err: any) { } 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 { } finally {
setActing(''); setActing('');
} }
@ -65,58 +249,369 @@ export default function ApprovalDetailPage() {
<AdminShell> <AdminShell>
<div class="page-hero-card page-actions"> <div class="page-hero-card page-actions">
<div> <div>
<h1 class="page-title">Approval Detail</h1> <h1 class="page-title">Submission Review</h1>
<p class="page-subtitle">Review one approval request in detail and take action.</p> <p class="page-subtitle">Review a user's onboarding form submission and take action.</p>
</div> </div>
<A class="btn" href="/admin/approval">Back to Approval List</A> <A class="btn" href="/admin/approval"> Back to Approvals</A>
</div> </div>
<Show when={error()}> <Show when={actionError()}>
<div class="error-box">{error()}</div> <div class="error-box" style="margin-bottom:12px">{actionError()}</div>
</Show> </Show>
<Show when={approval.loading}> <Show when={actionDone()}>
<div class="card"><p class="notice">Loading approval...</p></div> <div class="card" style="margin-bottom:12px;border-left:4px solid #22c55e;padding:12px 16px">
<p style="margin:0;font-weight:600;color:#166534">
{actionDone() === 'APPROVED' ? '✓ Profile approved successfully.' : '✕ Profile rejected.'}
</p>
<A href={dest().href} style="font-size:13px;color:#15803d;text-decoration:underline">
Open {dest().label}
</A>
</div>
</Show> </Show>
<Show when={!approval.loading && !approval()}> <Show when={data.loading}>
<div class="card"><p class="notice">Approval request not found.</p></div> <div class="card"><p class="notice">Loading submission...</p></div>
</Show> </Show>
<Show when={approval()}> <Show when={!data.loading && !data()}>
<div class="grid" style="margin-top:0"> <div class="card">
<section class="card"> <p class="notice">Submission not found or user does not have an onboarding record for this role.</p>
<h2 style="margin-bottom:8px">Request Summary</h2> <p style="font-size:13px;color:#64748b;margin-top:8px">
<p class="notice" style="margin:0"><strong>ID:</strong> {approval()!.id}</p> Make sure the URL includes <code>?roleKey=PHOTOGRAPHER</code> (or the relevant role key).
<p class="notice" style="margin:8px 0 0"><strong>Type:</strong> {requestType()}</p> </p>
<p class="notice" style="margin:8px 0 0"><strong>Status:</strong> {status()}</p> </div>
<p class="notice" style="margin:8px 0 0"><strong>Priority:</strong> {approval()!.priority ?? '—'}</p> </Show>
<p class="notice" style="margin:8px 0 0"><strong>Submitted:</strong> {submittedAt() ? new Date(submittedAt()).toLocaleString() : '—'}</p>
</section>
<section class="card"> <Show when={data()}>
<h2 style="margin-bottom:8px">Requester</h2> {/* ── Action bar ── */}
<p class="notice" style="margin:0"><strong>Name:</strong> {requesterName()}</p> <Show when={!actionDone()}>
<p class="notice" style="margin:8px 0 0"><strong>Email:</strong> {requesterEmail()}</p> <div class="card" style="display:flex;flex-wrap:wrap;align-items:center;gap:10px;margin-bottom:16px;padding:12px 16px">
<div class="actions"> <span style={`display:inline-block;padding:3px 10px;border-radius:999px;font-size:12px;font-weight:600;${ROLE_COLORS[roleType()]}`}>
<button class="btn primary" type="button" disabled={!!acting()} onClick={() => act('APPROVED')}> {(roleKey() || 'UNKNOWN').replace(/_/g, ' ')}
{acting() === 'APPROVED' ? 'Approving...' : 'Approve'} </span>
<span style="font-size:13px;color:#64748b">
Onboarding: <strong style={`color:${data()!.onboarding?.status === 'COMPLETED' ? '#15803d' : '#c2410c'}`}>
{data()!.onboarding?.status ?? 'NO DATA'}
</strong>
</span>
<div style="flex:1" />
<Show when={data()!.onboarding?.status === 'COMPLETED' && !actionDone()}>
<button
class="btn"
style="background:#f0fdf4;color:#15803d;border-color:#bbf7d0"
disabled={!!acting()}
onClick={handleApprove}
>
{acting() === 'APPROVE' ? 'Approving...' : '✓ Approve Profile'}
</button> </button>
<button class="btn" type="button" disabled={!!acting()} onClick={() => act('CHANGES_REQUESTED')}> <button
{acting() === 'CHANGES_REQUESTED' ? 'Updating...' : 'Request Changes'} class="btn danger"
disabled={!!acting()}
onClick={handleReject}
>
{acting() === 'REJECT' ? 'Rejecting...' : '✕ Reject Profile'}
</button> </button>
<button class="btn danger" type="button" disabled={!!acting()} onClick={() => act('REJECTED')}> </Show>
{acting() === 'REJECTED' ? 'Rejecting...' : 'Reject'} </div>
</button> </Show>
</div>
</section> <div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-bottom:16px">
{/* User info */}
<div class="card">
<h3 style="margin:0 0 12px;font-size:15px;font-weight:600;color:#0f172a">User Info</h3>
<table style="width:100%;border-collapse:collapse;font-size:13px">
<tbody>
<tr><td style="color:#64748b;padding:5px 10px 5px 0;white-space:nowrap;width:40%">Name</td><td style="font-weight:500">{data()!.user.name || '—'}</td></tr>
<tr><td style="color:#64748b;padding:5px 10px 5px 0">Email</td><td style="color:#475569">{data()!.user.email}</td></tr>
<tr><td style="color:#64748b;padding:5px 10px 5px 0">Phone</td><td style="color:#475569">{data()!.user.phone || '—'}</td></tr>
<tr>
<td style="color:#64748b;padding:5px 10px 5px 0">Account Status</td>
<td>
<span class={`status-chip${data()!.user.status === 'ACTIVE' ? ' active' : ''}`}>
{data()!.user.status}
</span>
</td>
</tr>
<tr>
<td style="color:#64748b;padding:5px 10px 5px 0">Email Verified</td>
<td style={`font-weight:500;color:${data()!.user.email_verified ? '#15803d' : '#b91c1c'}`}>
{data()!.user.email_verified ? '✓ Yes' : '✕ No'}
</td>
</tr>
<tr><td style="color:#64748b;padding:5px 10px 5px 0">Registered</td><td style="color:#475569">{new Date(data()!.user.created_at).toLocaleDateString()}</td></tr>
<tr><td style="color:#64748b;padding:5px 10px 5px 0">User ID</td><td style="font-family:ui-monospace,monospace;font-size:11px;color:#94a3b8">{data()!.user.id}</td></tr>
</tbody>
</table>
</div>
{/* Submission status */}
<div class="card">
<h3 style="margin:0 0 12px;font-size:15px;font-weight:600;color:#0f172a">Submission Info</h3>
<Show when={data()!.onboarding} fallback={
<div style="background:#fef2f2;border:1px solid #fecaca;border-radius:8px;padding:14px">
<p style="margin:0;color:#b91c1c;font-weight:500">No onboarding data found</p>
<p style="margin:6px 0 0;font-size:13px;color:#7f1d1d">
This user has not started or submitted the onboarding form for role: <strong>{roleKey() || 'unknown'}</strong>
</p>
</div>
}>
<table style="width:100%;border-collapse:collapse;font-size:13px">
<tbody>
<tr><td style="color:#64748b;padding:5px 10px 5px 0;white-space:nowrap;width:40%">Role</td><td style="font-weight:500">{(roleKey() || '—').replace(/_/g, ' ')}</td></tr>
<tr>
<td style="color:#64748b;padding:5px 10px 5px 0">Status</td>
<td>
<span style={`display:inline-block;padding:2px 8px;border-radius:4px;font-size:12px;font-weight:600;${data()!.onboarding!.status === 'COMPLETED' ? 'background:#dcfce7;color:#166534' : 'background:#fef9c3;color:#713f12'}`}>
{data()!.onboarding!.status}
</span>
</td>
</tr>
<tr><td style="color:#64748b;padding:5px 10px 5px 0">Submitted</td><td style="color:#475569">{data()!.onboarding!.completed_at ? new Date(data()!.onboarding!.completed_at!).toLocaleString() : '—'}</td></tr>
<tr><td style="color:#64748b;padding:5px 10px 5px 0">Last Updated</td><td style="color:#475569">{new Date(data()!.onboarding!.updated_at).toLocaleString()}</td></tr>
<tr><td style="color:#64748b;padding:5px 10px 5px 0">Fields</td><td style="color:#475569">{submittedRows().length} fields submitted</td></tr>
</tbody>
</table>
</Show>
</div>
</div> </div>
<section class="card" style="margin-top:16px"> {/* ── Submitted form answers + media viewer ── */}
<h2 style="margin-bottom:10px">Raw Payload</h2> <Show when={submittedRows().length > 0}>
<pre class="json">{JSON.stringify(approval(), null, 2)}</pre> <SubmissionViewer rows={submittedRows()} />
</section> </Show>
{/* ── No form data fallback ── */}
<Show when={data()!.onboarding && submittedRows().length === 0}>
<div class="card" style="margin-bottom:16px">
<h3 style="margin:0 0 10px;font-size:15px;font-weight:600;color:#0f172a">Submitted Form Answers</h3>
<p class="notice">Onboarding state is present but contains no displayable field data.</p>
</div>
</Show>
</Show> </Show>
</AdminShell> </AdminShell>
); );
} }
// ────────────────────────────── 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 ── */}
<Show when={textFields().length > 0}>
<div class="card" style="margin-bottom:16px">
<h3 style="margin:0 0 14px;font-size:15px;font-weight:600;color:#0f172a">
Submitted Form Data
<span style="margin-left:8px;font-size:12px;font-weight:400;color:#64748b">{textFields().length} fields</span>
</h3>
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:8px">
<For each={textFields()}>
{(field) => {
const kind = detectKind(field.key, field.value);
return (
<div style="background:#f8fafc;border:1px solid #e2e8f0;border-radius:8px;padding:10px 12px">
<div style="font-size:10px;color:#94a3b8;margin-bottom:4px;font-weight:700;letter-spacing:0.05em">
{field.key.replace(/_/g, ' ').replace(/\./g, ' ').toUpperCase()}
</div>
<Show when={kind === 'url'} fallback={
<div style="font-size:13px;color:#0f172a;word-break:break-all;line-height:1.5">{field.value || '—'}</div>
}>
<a href={field.value} target="_blank" rel="noreferrer"
style="font-size:13px;color:#2563eb;word-break:break-all">
🔗 {field.value}
</a>
</Show>
</div>
);
}}
</For>
</div>
</div>
</Show>
{/* ── Documents & Images ── */}
<Show when={mediaFields().length > 0}>
<div class="card" style="margin-bottom:16px">
<h3 style="margin:0 0 14px;font-size:15px;font-weight:600;color:#0f172a">
Documents & Media
<span style="margin-left:8px;font-size:12px;font-weight:400;color:#64748b">{mediaFields().length} file{mediaFields().length !== 1 ? 's' : ''}</span>
</h3>
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:12px">
<For each={mediaFields()}>
{(field) => {
const kind = detectKind(field.key, field.value);
const label = field.key.replace(/_/g, ' ').replace(/\./g, ' ');
return (
<div style="border:1px solid #e2e8f0;border-radius:10px;overflow:hidden;background:#fff">
{/* Preview area */}
<Show when={kind === 'image'}>
<div
style="background:#f1f5f9;cursor:pointer;position:relative;height:140px;display:flex;align-items:center;justify-content:center;overflow:hidden"
onClick={() => setLightbox({ src: field.value, label })}
>
<img
src={field.value}
alt={label}
style="max-width:100%;max-height:140px;object-fit:contain"
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
((e.target as HTMLImageElement).nextElementSibling as HTMLElement)!.style.display = 'flex';
}}
/>
<div style="display:none;width:100%;height:100%;align-items:center;justify-content:center;flex-direction:column;gap:4px;color:#94a3b8">
<span style="font-size:32px">🖼</span>
<span style="font-size:11px">Preview unavailable</span>
</div>
<div style="position:absolute;top:6px;right:6px;background:rgba(0,0,0,.5);color:#fff;border-radius:4px;font-size:10px;padding:2px 6px">
🔍 Click to enlarge
</div>
</div>
</Show>
<Show when={kind === 'pdf'}>
<div
style="background:#fef2f2;cursor:pointer;height:140px;display:flex;align-items:center;justify-content:center;flex-direction:column;gap:6px"
onClick={() => setPdfViewer({ src: field.value, label })}
>
<span style="font-size:40px">📄</span>
<span style="font-size:12px;color:#b91c1c;font-weight:600">PDF Document</span>
<span style="font-size:11px;color:#94a3b8">Click to view</span>
</div>
</Show>
<Show when={kind === 'document'}>
<div style="background:#eff6ff;height:140px;display:flex;align-items:center;justify-content:center;flex-direction:column;gap:6px">
<span style="font-size:40px">📎</span>
<span style="font-size:12px;color:#1d4ed8;font-weight:600">Document</span>
<a href={field.value} target="_blank" rel="noreferrer"
style="font-size:11px;color:#2563eb;text-decoration:underline"
onClick={(e) => e.stopPropagation()}>
Download
</a>
</div>
</Show>
{/* Label + open link */}
<div style="padding:8px 10px;border-top:1px solid #e2e8f0">
<div style="font-size:11px;color:#0f172a;font-weight:600;margin-bottom:4px;text-transform:capitalize">
{label}
</div>
<div style="display:flex;gap:6px">
<Show when={kind === 'image'}>
<button
type="button"
style="font-size:11px;color:#2563eb;background:none;border:none;padding:0;cursor:pointer;text-decoration:underline"
onClick={() => setLightbox({ src: field.value, label })}
>
🔍 View Full
</button>
</Show>
<Show when={kind === 'pdf'}>
<button
type="button"
style="font-size:11px;color:#b91c1c;background:none;border:none;padding:0;cursor:pointer;text-decoration:underline"
onClick={() => setPdfViewer({ src: field.value, label })}
>
📄 Open PDF
</button>
</Show>
<a href={field.value} target="_blank" rel="noreferrer"
style="font-size:11px;color:#64748b;text-decoration:underline">
Download
</a>
</div>
</div>
</div>
);
}}
</For>
</div>
</div>
</Show>
{/* ── Image Lightbox ── */}
<Show when={lightbox()}>
<div
style="position:fixed;inset:0;background:rgba(0,0,0,.85);z-index:9999;display:flex;flex-direction:column;align-items:center;justify-content:center;padding:20px"
onClick={() => setLightbox(null)}
>
<div style="display:flex;align-items:center;justify-content:space-between;width:100%;max-width:900px;margin-bottom:12px">
<span style="color:#f8fafc;font-size:14px;font-weight:600">{lightbox()!.label}</span>
<div style="display:flex;gap:10px">
<a href={lightbox()!.src} target="_blank" rel="noreferrer"
style="color:#93c5fd;font-size:13px;text-decoration:underline"
onClick={(e) => e.stopPropagation()}>
Open original
</a>
<button
type="button"
style="color:#f8fafc;background:none;border:none;font-size:22px;cursor:pointer;line-height:1"
onClick={() => setLightbox(null)}
>
</button>
</div>
</div>
<img
src={lightbox()!.src}
alt={lightbox()!.label}
style="max-width:900px;max-height:80vh;object-fit:contain;border-radius:8px;box-shadow:0 0 40px rgba(0,0,0,.6)"
onClick={(e) => e.stopPropagation()}
/>
</div>
</Show>
{/* ── PDF Viewer Modal ── */}
<Show when={pdfViewer()}>
<div
style="position:fixed;inset:0;background:rgba(0,0,0,.75);z-index:9999;display:flex;flex-direction:column;align-items:center;justify-content:center;padding:20px"
>
<div style="background:#fff;border-radius:12px;overflow:hidden;width:100%;max-width:900px;max-height:90vh;display:flex;flex-direction:column;box-shadow:0 25px 60px rgba(0,0,0,.5)">
{/* Header */}
<div style="display:flex;align-items:center;justify-content:space-between;padding:12px 16px;border-bottom:1px solid #e2e8f0;background:#f8fafc">
<span style="font-size:14px;font-weight:600;color:#0f172a">📄 {pdfViewer()!.label}</span>
<div style="display:flex;gap:8px;align-items:center">
<a href={pdfViewer()!.src} target="_blank" rel="noreferrer"
style="font-size:12px;color:#2563eb;text-decoration:underline">
Open in new tab
</a>
<button
type="button"
class="btn"
style="padding:4px 10px;font-size:13px"
onClick={() => setPdfViewer(null)}
>
Close
</button>
</div>
</div>
{/* PDF embed */}
<iframe
src={`${pdfViewer()!.src}#toolbar=1&view=FitH`}
style="flex:1;min-height:600px;width:100%;border:none"
title={pdfViewer()!.label}
/>
</div>
</div>
</Show>
</>
);
}

View file

@ -1,10 +1,10 @@
import { A, useNavigate, useParams } from '@solidjs/router'; import { A, useNavigate, useParams } from '@solidjs/router';
import { createEffect, createMemo, createResource, createSignal, Show } from 'solid-js'; import { createEffect, createMemo, createResource, createSignal, Show } from 'solid-js';
import AdminShell from '~/components/AdminShell'; import AdminShell from '~/components/AdminShell';
import { STATIC_PERMISSIONS, type Permission } from '~/lib/admin-modules';
const API = '/api/gateway'; const API = '/api/gateway';
type Permission = { id: string; module: string; action: string };
type Role = { id: string; name: string; description?: string; permissions: Permission[] }; type Role = { id: string; name: string; description?: string; permissions: Permission[] };
async function loadRoleAndPerms(id: string) { async function loadRoleAndPerms(id: string) {
@ -15,8 +15,9 @@ async function loadRoleAndPerms(id: string) {
]); ]);
if (!roleRes.ok) return null; if (!roleRes.ok) return null;
const role: Role = await roleRes.json(); const role: Role = await roleRes.json();
const permsData = permsRes.ok ? await permsRes.json() : { permissions: [] }; const permsData = permsRes.ok ? await permsRes.json() : {};
const allPerms: Permission[] = Array.isArray(permsData) ? permsData : (permsData.permissions || []); const fetched: Permission[] = Array.isArray(permsData) ? permsData : (permsData.permissions || []);
const allPerms = fetched.length > 0 ? fetched : STATIC_PERMISSIONS;
return { role, allPerms }; return { role, allPerms };
} catch { } catch {
return null; return null;

View file

@ -1,17 +1,20 @@
import { A, useNavigate } from '@solidjs/router'; import { A, useNavigate } from '@solidjs/router';
import { createMemo, createResource, createSignal, Show } from 'solid-js'; import { createMemo, createResource, createSignal, Show } from 'solid-js';
import AdminShell from '~/components/AdminShell'; import AdminShell from '~/components/AdminShell';
import { STATIC_PERMISSIONS, type Permission } from '~/lib/admin-modules';
const API = '/api/gateway'; const API = '/api/gateway';
type Permission = { id: string; module: string; action: string };
async function loadPermissions(): Promise<Permission[]> { async function loadPermissions(): Promise<Permission[]> {
const res = await fetch(`${API}/api/admin/permissions`); try {
if (!res.ok) return []; const res = await fetch(`${API}/api/admin/permissions`);
const data = await res.json(); if (!res.ok) return STATIC_PERMISSIONS;
const rows = Array.isArray(data) ? data : (data.permissions || []); const data = await res.json();
return rows; const rows: Permission[] = Array.isArray(data) ? data : (data.permissions || []);
return rows.length > 0 ? rows : STATIC_PERMISSIONS;
} catch {
return STATIC_PERMISSIONS;
}
} }
export default function CreateInternalRolePage() { export default function CreateInternalRolePage() {
@ -154,7 +157,7 @@ export default function CreateInternalRolePage() {
</div> </div>
</Show> </Show>
<Show when={!permissions.loading && allModules().length === 0}> <Show when={!permissions.loading && allModules().length === 0}>
<p class="notice">No modules available. Is the backend running?</p> <p class="notice">No modules available.</p>
</Show> </Show>
</div> </div>