diff --git a/src/components/admin/ExternalRoleForm.tsx b/src/components/admin/ExternalRoleForm.tsx new file mode 100644 index 0000000..06525b2 --- /dev/null +++ b/src/components/admin/ExternalRoleForm.tsx @@ -0,0 +1,377 @@ +import { createEffect, createMemo, createSignal, For, Show } from 'solid-js'; + +export type ExternalRoleConfig = { + id?: string; + roleKey: string; + displayName: string; + vertical: 'jobs' | 'marketplace'; + roleCategory: 'provider' | 'employer' | 'consumer' | 'specialist'; + enabledModules: string[]; + permissions: Record; + onboardingSchemaId: string; + requiresOnboardingApproval: boolean; + requiresLeadApproval: boolean; + requiresJobApproval: boolean; + featureLimits: Record; + runtimeConfigVersion: number; + isActive: boolean; +}; + +type RolePermissionAction = { + key: string; + label: string; +}; + +type ModuleOption = { + key: string; + label: string; + description: string; +}; + +type ExternalRoleFormProps = { + initialValue: ExternalRoleConfig; + saving?: boolean; + submitLabel: string; + onSubmit: (value: ExternalRoleConfig) => Promise | void; +}; + +const ROLE_PERMISSION_ACTIONS: RolePermissionAction[] = [ + { key: 'read', label: 'Read' }, + { key: 'create', label: 'Create' }, + { key: 'update', label: 'Update' }, + { key: 'delete', label: 'Delete' }, + { key: 'approve', label: 'Approve' }, +]; + +const ONBOARDING_SCHEMA_OPTIONS = [ + 'company_onboarding_v1', + 'job_seeker_onboarding_v1', + 'photographer_onboarding_v1', + 'default_onboarding_v1', +]; + +const MODULES_BY_VERTICAL: Record<'jobs' | 'marketplace', ModuleOption[]> = { + jobs: [ + { key: 'jobs', label: 'Jobs', description: 'Manage job postings and candidate flow.' }, + { key: 'applications', label: 'Applications', description: 'Review incoming applications.' }, + { key: 'responses', label: 'Responses', description: 'Track response lifecycle states.' }, + { key: 'profile', label: 'Profile', description: 'Maintain role profile and preferences.' }, + { key: 'notifications', label: 'Notifications', description: 'View and manage alerts.' }, + ], + marketplace: [ + { key: 'leads', label: 'Leads', description: 'Handle customer lead requests.' }, + { key: 'portfolio', label: 'Portfolio', description: 'Publish portfolio and service highlights.' }, + { key: 'verification', label: 'Verification', description: 'Track onboarding verification progress.' }, + { key: 'pricing', label: 'Pricing', description: 'Manage plans, pricing, and packages.' }, + { key: 'support', label: 'Support', description: 'Access support workflows and help content.' }, + ], +}; + +const DEFAULT_PRESETS: Record = { + company: { + roleKey: 'company', + displayName: 'Company', + vertical: 'jobs', + roleCategory: 'employer', + enabledModules: ['jobs', 'applications', 'responses', 'profile'], + permissions: { + jobs: ['read', 'create', 'update'], + applications: ['read', 'approve'], + responses: ['read', 'update'], + profile: ['read', 'update'], + }, + onboardingSchemaId: 'company_onboarding_v1', + requiresOnboardingApproval: true, + requiresLeadApproval: false, + requiresJobApproval: true, + featureLimits: { maxActiveJobs: 5 }, + runtimeConfigVersion: 1, + isActive: true, + }, + photographer: { + roleKey: 'photographer', + displayName: 'Photographer', + vertical: 'marketplace', + roleCategory: 'provider', + enabledModules: ['leads', 'portfolio', 'verification', 'pricing'], + permissions: { + leads: ['read', 'update'], + portfolio: ['read', 'create', 'update'], + verification: ['read'], + pricing: ['read', 'update'], + }, + onboardingSchemaId: 'photographer_onboarding_v1', + requiresOnboardingApproval: true, + requiresLeadApproval: true, + requiresJobApproval: false, + featureLimits: { maxOpenLeads: 10 }, + runtimeConfigVersion: 1, + isActive: true, + }, +}; + +function slugifyRoleKey(value: string): string { + return value + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, '_') + .replace(/^_+|_+$/g, ''); +} + +function parseFeatureLimits(raw: string): Record { + const parsed = JSON.parse(raw || '{}'); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error('Feature limits must be a JSON object.'); + } + return parsed as Record; +} + +export default function ExternalRoleForm(props: ExternalRoleFormProps) { + const [config, setConfig] = createSignal(props.initialValue); + const [featureLimitsRaw, setFeatureLimitsRaw] = createSignal(JSON.stringify(props.initialValue.featureLimits || {}, null, 2)); + const [errors, setErrors] = createSignal>({}); + + createEffect(() => { + setConfig(props.initialValue); + setFeatureLimitsRaw(JSON.stringify(props.initialValue.featureLimits || {}, null, 2)); + setErrors({}); + }); + + const moduleOptions = createMemo(() => MODULES_BY_VERTICAL[config().vertical]); + + createEffect(() => { + const allowed = new Set(moduleOptions().map((item) => item.key)); + setConfig((current) => ({ + ...current, + enabledModules: current.enabledModules.filter((item) => allowed.has(item)), + permissions: Object.fromEntries(Object.entries(current.permissions).filter(([key]) => allowed.has(key))), + })); + }); + + const setConfigPatch = (patch: Partial) => setConfig((current) => ({ ...current, ...patch })); + + const applyPreset = (presetKey: string) => { + const preset = DEFAULT_PRESETS[presetKey]; + if (!preset) return; + const next = { + ...preset, + runtimeConfigVersion: (config().runtimeConfigVersion || 1) + 1, + }; + setConfig(next); + setFeatureLimitsRaw(JSON.stringify(next.featureLimits || {}, null, 2)); + setErrors({}); + }; + + const updateModuleSelection = (moduleKey: string, enabled: boolean) => { + setConfig((current) => { + const enabledModules = enabled + ? Array.from(new Set([...current.enabledModules, moduleKey])) + : current.enabledModules.filter((value) => value !== moduleKey); + const nextPermissions = { ...current.permissions }; + if (!enabled) delete nextPermissions[moduleKey]; + return { ...current, enabledModules, permissions: nextPermissions }; + }); + }; + + const togglePermission = (moduleKey: string, action: string) => { + setConfig((current) => { + const next = new Set(current.permissions[moduleKey] || []); + if (next.has(action)) next.delete(action); + else next.add(action); + return { + ...current, + permissions: { + ...current.permissions, + [moduleKey]: Array.from(next), + }, + }; + }); + }; + + const validateAndSubmit = async () => { + const nextErrors: Record = {}; + const normalizedRoleKey = slugifyRoleKey(config().roleKey); + if (!normalizedRoleKey) nextErrors.roleKey = 'Role key is required.'; + if (!config().displayName.trim()) nextErrors.displayName = 'Display name is required.'; + if (config().enabledModules.length === 0) nextErrors.enabledModules = 'Enable at least one module.'; + + const modulesWithoutPermissions = config().enabledModules.filter((moduleKey) => !(config().permissions[moduleKey] || []).length); + if (modulesWithoutPermissions.length > 0) { + nextErrors.permissions = `Assign at least one permission for: ${modulesWithoutPermissions.join(', ')}.`; + } + + let featureLimits: Record = {}; + try { + featureLimits = parseFeatureLimits(featureLimitsRaw()); + } catch (error: any) { + nextErrors.featureLimits = error?.message || 'Feature limits JSON is invalid.'; + } + + setErrors(nextErrors); + if (Object.keys(nextErrors).length > 0) return; + + await props.onSubmit({ + ...config(), + roleKey: normalizedRoleKey, + featureLimits, + }); + }; + + return ( +
+
+
+
+

Role Configuration

+

+ Configure the external runtime role model for jobs and marketplace verticals. +

+
+
+ + +
+
+ +
+
+ + setConfigPatch({ roleKey: event.currentTarget.value })} placeholder="company" /> +

{errors().roleKey}

+
+
+ + setConfigPatch({ displayName: event.currentTarget.value })} placeholder="Company" /> +

{errors().displayName}

+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+ +
+

Enabled Modules

+

Choose the modules this role can access at runtime.

+
+ + {(option) => { + const checked = () => config().enabledModules.includes(option.key); + return ( + + ); + }} + +
+

{errors().enabledModules}

+
+ +
+

Permission Matrix

+

Assign CRUD and approval capabilities per enabled module.

+
+ + + + + + {(action) => } + + + + + + + + config().enabledModules.includes(option.key))}> + {(option) => ( + + + + {(action) => { + const checked = () => (config().permissions[option.key] || []).includes(action.key); + return ( + + ); + }} + + + )} + + +
Module{action.label}
Enable modules to configure permissions.
{option.label} + togglePermission(option.key, action.key)} /> +
+
+

{errors().permissions}

+
+ +
+

Approval Rules & Limits

+
+ + + +
+
+ +