From 5316811b459fb6f1375c25ba7093d26027644ef9 Mon Sep 17 00:00:00 2001 From: Ashwin Kumar Sivakumar Date: Tue, 28 Jul 2026 20:11:12 +0530 Subject: [PATCH] Add Onboarding Schema Editor, replacing the deprecated stub MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lets an admin define each role's verification wizard steps and fields (with drag-to-reorder), which fields lock permanently once approved, the portfolio persistence model, and whether the wizard is enabled at all for that role — writing to onboarding_configs.schema_json via the (now admin-gated) config API. This is the admin surface for the new runtime-config-driven wizard the frontend/backend now consume, so nothing about a role's onboarding flow needs a code change to adjust. Co-Authored-By: Claude Sonnet 5 --- .../admin/OnboardingDeprecatedPage.tsx | 36 -- .../admin/OnboardingSchemaEditor.tsx | 428 ++++++++++++++++++ .../admin/onboarding-schemas/[schemaId].tsx | 4 +- src/routes/admin/onboarding-schemas/index.tsx | 4 +- src/routes/admin/onboarding-schemas/new.tsx | 4 +- 5 files changed, 434 insertions(+), 42 deletions(-) delete mode 100644 src/components/admin/OnboardingDeprecatedPage.tsx create mode 100644 src/components/admin/OnboardingSchemaEditor.tsx diff --git a/src/components/admin/OnboardingDeprecatedPage.tsx b/src/components/admin/OnboardingDeprecatedPage.tsx deleted file mode 100644 index 8d8e3cc..0000000 --- a/src/components/admin/OnboardingDeprecatedPage.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import { A } from '@solidjs/router'; - -export default function OnboardingDeprecatedPage() { - return ( -
-
-

Onboarding Management Deprecated

-

- Legacy onboarding schema management is no longer used in the active platform flow. -

-
- -
-

- Current flow: user signs up with intent, lands on role dashboard, completes My Profile/My Portfolio, - then enters Verification and final Approval workflows. -

-
- -
- - Open External Dashboard Management - - - Open External Role Management - -
-
- ); -} diff --git a/src/components/admin/OnboardingSchemaEditor.tsx b/src/components/admin/OnboardingSchemaEditor.tsx new file mode 100644 index 0000000..3a6c97d --- /dev/null +++ b/src/components/admin/OnboardingSchemaEditor.tsx @@ -0,0 +1,428 @@ +import { For, Show, createEffect, createResource, createSignal } from 'solid-js'; + +const API = ''; + +type FieldType = 'text' | 'textarea' | 'select' | 'number' | 'email' | 'tel' | 'url' | 'date' | 'file'; +type StepType = 'basic' | 'documents' | 'portfolio' | 'review'; +type PortfolioModel = 'none' | 'custom_data' | 'professional'; + +type SchemaField = { + id: string; + label: string; + type: FieldType; + required: boolean; + lockAfterApproval: boolean; + options?: string; // comma-separated, editable as plain text; only meaningful for type "select" +}; + +type SchemaStep = { + id: string; + title: string; + type: StepType; + fields: SchemaField[]; +}; + +type Role = { id: string; key: string; name: string }; + +function authHeaders() { + const token = typeof sessionStorage !== 'undefined' ? sessionStorage.getItem('nxtgauge_admin_access_token') || '' : ''; + return { Accept: 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) }; +} + +async function fetchRoles(): Promise { + const res = await fetch(`${API}/api/admin/roles?audience=EXTERNAL`, { headers: authHeaders() }); + if (!res.ok) return []; + const data = await res.json().catch(() => []); + const rows = Array.isArray(data) ? data : (data.roles || []); + return rows.map((r: any) => ({ id: String(r.id), key: String(r.key), name: String(r.name || r.key) })); +} + +function emptyField(): SchemaField { + return { id: '', label: '', type: 'text', required: false, lockAfterApproval: false }; +} + +function emptyStep(type: StepType): SchemaStep { + const id = `${type}_${Math.random().toString(36).slice(2, 8)}`; + const titles: Record = { + basic: 'Basic Information', + documents: 'Documents', + portfolio: 'My Portfolio', + review: 'Review and Submit', + }; + return { id, title: titles[type], type, fields: [] }; +} + +function parseSchema(schema_json: any): { portfolioModel: PortfolioModel; enableWizardFlow: boolean; steps: SchemaStep[] } { + const steps: SchemaStep[] = Array.isArray(schema_json?.steps) + ? schema_json.steps.map((s: any) => ({ + id: String(s.id || `step_${Math.random().toString(36).slice(2, 8)}`), + title: String(s.title || ''), + type: (['basic', 'documents', 'portfolio', 'review'].includes(s.type) ? s.type : 'basic') as StepType, + fields: Array.isArray(s.fields) + ? s.fields.map((f: any) => ({ + id: String(f.id || ''), + label: String(f.label || ''), + type: (f.type || 'text') as FieldType, + required: Boolean(f.required), + lockAfterApproval: Boolean(f.lockAfterApproval), + options: Array.isArray(f.options) + ? f.options.map((o: any) => (typeof o === 'string' ? o : o?.label ?? o?.value ?? '')).join(', ') + : '', + })) + : [], + })) + : []; + return { + portfolioModel: (['none', 'custom_data', 'professional'].includes(schema_json?.portfolioModel) ? schema_json.portfolioModel : 'none') as PortfolioModel, + enableWizardFlow: Boolean(schema_json?.enableWizardFlow), + steps, + }; +} + +function serializeSchema(portfolioModel: PortfolioModel, enableWizardFlow: boolean, steps: SchemaStep[]) { + return { + portfolioModel, + enableWizardFlow, + steps: steps.map((s) => ({ + id: s.id, + title: s.title, + type: s.type, + fields: s.fields.map((f) => ({ + id: f.id, + label: f.label, + type: f.type, + required: f.required, + ...(f.lockAfterApproval ? { lockAfterApproval: true } : {}), + ...(f.type === 'select' && f.options?.trim() + ? { options: f.options.split(',').map((o) => o.trim()).filter(Boolean).map((o) => ({ label: o, value: o })) } + : {}), + })), + })), + }; +} + +export default function OnboardingSchemaEditor() { + const [roles] = createResource(fetchRoles); + const [roleId, setRoleId] = createSignal(''); + const [loadedVersion, setLoadedVersion] = createSignal(null); + const [portfolioModel, setPortfolioModel] = createSignal('none'); + const [enableWizardFlow, setEnableWizardFlow] = createSignal(false); + const [steps, setSteps] = createSignal([]); + const [expandedStep, setExpandedStep] = createSignal(null); + const [dragStepId, setDragStepId] = createSignal(null); + const [dragField, setDragField] = createSignal<{ stepId: string; fieldIdx: number } | null>(null); + const [saving, setSaving] = createSignal(false); + const [message, setMessage] = createSignal(''); + const [loading, setLoading] = createSignal(false); + + const loadSchemaForRole = async (id: string) => { + if (!id) return; + setLoading(true); + setMessage(''); + try { + const res = await fetch(`${API}/api/admin/onboarding-config/${id}`, { headers: authHeaders() }); + if (res.ok) { + const data = await res.json(); + const parsed = parseSchema(data.schema_json); + setPortfolioModel(parsed.portfolioModel); + setEnableWizardFlow(parsed.enableWizardFlow); + setSteps(parsed.steps); + setLoadedVersion(typeof data.version === 'number' ? data.version : null); + } else { + // No active schema yet for this role - start from a blank template. + setPortfolioModel('none'); + setEnableWizardFlow(false); + setSteps([emptyStep('basic'), emptyStep('documents'), emptyStep('review')]); + setLoadedVersion(null); + } + } finally { + setLoading(false); + } + }; + + createEffect(() => { + const id = roleId(); + if (id) void loadSchemaForRole(id); + }); + + const addStep = (type: StepType) => setSteps((prev) => [...prev, emptyStep(type)]); + const removeStep = (id: string) => setSteps((prev) => prev.filter((s) => s.id !== id)); + const updateStep = (id: string, patch: Partial) => + setSteps((prev) => prev.map((s) => (s.id === id ? { ...s, ...patch } : s))); + const moveStep = (movingId: string, targetId: string) => { + if (!movingId || !targetId || movingId === targetId) return; + setSteps((prev) => { + const from = prev.findIndex((s) => s.id === movingId); + const to = prev.findIndex((s) => s.id === targetId); + if (from === -1 || to === -1) return prev; + const next = [...prev]; + const [item] = next.splice(from, 1); + next.splice(to, 0, item); + return next; + }); + }; + + const addField = (stepId: string) => + setSteps((prev) => prev.map((s) => (s.id === stepId ? { ...s, fields: [...s.fields, emptyField()] } : s))); + const removeField = (stepId: string, idx: number) => + setSteps((prev) => prev.map((s) => (s.id === stepId ? { ...s, fields: s.fields.filter((_, i) => i !== idx) } : s))); + const updateField = (stepId: string, idx: number, patch: Partial) => + setSteps((prev) => + prev.map((s) => + s.id === stepId ? { ...s, fields: s.fields.map((f, i) => (i === idx ? { ...f, ...patch } : f)) } : s + ) + ); + const moveField = (stepId: string, from: number, to: number) => { + if (from === to) return; + setSteps((prev) => + prev.map((s) => { + if (s.id !== stepId) return s; + const next = [...s.fields]; + const [item] = next.splice(from, 1); + next.splice(to, 0, item); + return { ...s, fields: next }; + }) + ); + }; + + const handleSave = async () => { + const id = roleId(); + if (!id) return; + setSaving(true); + setMessage(''); + try { + const schema_json = serializeSchema(portfolioModel(), enableWizardFlow(), steps()); + const res = await fetch(`${API}/api/admin/onboarding-config`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...authHeaders() }, + body: JSON.stringify({ role_id: id, schema_json }), + }); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body?.error || `Save failed (${res.status})`); + } + const saved = await res.json(); + setLoadedVersion(typeof saved.version === 'number' ? saved.version : null); + setMessage('Saved — this is now the active schema for this role.'); + } catch (e: any) { + setMessage(e?.message || 'Save failed.'); + } finally { + setSaving(false); + } + }; + + const FIELD_TYPES: FieldType[] = ['text', 'textarea', 'select', 'number', 'email', 'tel', 'url', 'date', 'file']; + const STEP_TYPES: StepType[] = ['basic', 'documents', 'portfolio', 'review']; + + return ( +
+
+

Onboarding Schema Editor

+

+ Defines the verification wizard's steps and fields per role — which fields are required, and which + become permanently locked once the profile is approved. This replaces per-role hardcoding in the + frontend. +

+
+ +
+ + + + + + + + + + + Current version: {loadedVersion()} + + +
+ + +
+ {message()} +
+
+ + +
+ + {(step) => ( +
setDragStepId(step.id)} + onDragOver={(e) => e.preventDefault()} + onDrop={(e) => { + e.preventDefault(); + moveStep(dragStepId() || '', step.id); + setDragStepId(null); + }} + style={`border-radius:12px;border:1px solid #E5E7EB;background:${dragStepId() === step.id ? '#FFF7ED' : 'white'};overflow:hidden`} + > +
+ :: + updateStep(step.id, { title: e.currentTarget.value })} + style="flex:1;height:32px;border-radius:6px;border:1px solid #E5E7EB;padding:0 10px;font-size:13px;font-weight:700" + /> + + + +
+ + +
+ + {(field, idx) => ( +
setDragField({ stepId: step.id, fieldIdx: idx() })} + onDragOver={(e) => e.preventDefault()} + onDrop={(e) => { + e.preventDefault(); + const from = dragField(); + if (from && from.stepId === step.id) moveField(step.id, from.fieldIdx, idx()); + setDragField(null); + }} + style="display:grid;grid-template-columns:1fr 1fr 110px 70px 90px 1fr 70px;gap:8px;align-items:center;border:1px solid #E5E7EB;border-radius:8px;padding:8px;cursor:grab" + > + updateField(step.id, idx(), { id: e.currentTarget.value })} + style="height:30px;border-radius:6px;border:1px solid #E5E7EB;padding:0 8px;font-size:12px" + /> + updateField(step.id, idx(), { label: e.currentTarget.value })} + style="height:30px;border-radius:6px;border:1px solid #E5E7EB;padding:0 8px;font-size:12px" + /> + + + + updateField(step.id, idx(), { options: e.currentTarget.value })} + style="height:30px;border-radius:6px;border:1px solid #E5E7EB;padding:0 8px;font-size:12px" + disabled={field.type !== 'select'} + /> + +
+ )} +
+ +
+
+
+ )} +
+ +
+ + {(t) => ( + + )} + +
+
+
+ + +

Loading schema...

+
+
+ ); +} diff --git a/src/routes/admin/onboarding-schemas/[schemaId].tsx b/src/routes/admin/onboarding-schemas/[schemaId].tsx index bd7f2db..3093406 100644 --- a/src/routes/admin/onboarding-schemas/[schemaId].tsx +++ b/src/routes/admin/onboarding-schemas/[schemaId].tsx @@ -1,5 +1,5 @@ -import OnboardingDeprecatedPage from '~/components/admin/OnboardingDeprecatedPage'; +import OnboardingSchemaEditor from '~/components/admin/OnboardingSchemaEditor'; export default function OnboardingSchemasDetailRoute() { - return ; + return ; } diff --git a/src/routes/admin/onboarding-schemas/index.tsx b/src/routes/admin/onboarding-schemas/index.tsx index c189d1f..aea2f9d 100644 --- a/src/routes/admin/onboarding-schemas/index.tsx +++ b/src/routes/admin/onboarding-schemas/index.tsx @@ -1,5 +1,5 @@ -import OnboardingDeprecatedPage from '~/components/admin/OnboardingDeprecatedPage'; +import OnboardingSchemaEditor from '~/components/admin/OnboardingSchemaEditor'; export default function OnboardingSchemasIndexRoute() { - return ; + return ; } diff --git a/src/routes/admin/onboarding-schemas/new.tsx b/src/routes/admin/onboarding-schemas/new.tsx index cf60f4a..6ba8c1a 100644 --- a/src/routes/admin/onboarding-schemas/new.tsx +++ b/src/routes/admin/onboarding-schemas/new.tsx @@ -1,5 +1,5 @@ -import OnboardingDeprecatedPage from '~/components/admin/OnboardingDeprecatedPage'; +import OnboardingSchemaEditor from '~/components/admin/OnboardingSchemaEditor'; export default function OnboardingSchemasNewRoute() { - return ; + return ; }