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:
parent
9980cd4fe5
commit
2d161c4f15
8 changed files with 1514 additions and 330 deletions
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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() {
|
||||||
|
|
|
||||||
|
|
@ -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
59
src/lib/admin-modules.ts
Normal 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
|
|
@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue