Add Onboarding Schema Editor, replacing the deprecated stub
All checks were successful
build-and-release / build (push) Successful in 50s

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 <noreply@anthropic.com>
This commit is contained in:
Ashwin Kumar Sivakumar 2026-07-28 20:11:12 +05:30
parent cb99b90774
commit 5316811b45
5 changed files with 434 additions and 42 deletions

View file

@ -1,36 +0,0 @@
import { A } from '@solidjs/router';
export default function OnboardingDeprecatedPage() {
return (
<div class="w-full space-y-4">
<div>
<h1 class="text-[28px] font-bold leading-tight text-[#111827]">Onboarding Management Deprecated</h1>
<p class="mt-1 text-[14px] text-[#6B7280]">
Legacy onboarding schema management is no longer used in the active platform flow.
</p>
</div>
<div style="border-radius:12px;border:1px solid #FDE68A;background:#FFFBEB;padding:16px">
<p style="margin:0;color:#92400E;font-size:14px;line-height:1.6">
Current flow: user signs up with intent, lands on role dashboard, completes My Profile/My Portfolio,
then enters Verification and final Approval workflows.
</p>
</div>
<div style="display:flex;gap:10px;flex-wrap:wrap">
<A
href="/admin/external-dashboard-management"
style="display:inline-flex;align-items:center;justify-content:center;height:38px;border-radius:10px;background:#0D0D2A;color:white;padding:0 16px;font-size:13px;font-weight:700;text-decoration:none"
>
Open External Dashboard Management
</A>
<A
href="/admin/external-roles"
style="display:inline-flex;align-items:center;justify-content:center;height:38px;border-radius:10px;border:1px solid #E5E7EB;background:white;color:#374151;padding:0 16px;font-size:13px;font-weight:600;text-decoration:none"
>
Open External Role Management
</A>
</div>
</div>
);
}

View file

@ -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<Role[]> {
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<StepType, string> = {
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<number | null>(null);
const [portfolioModel, setPortfolioModel] = createSignal<PortfolioModel>('none');
const [enableWizardFlow, setEnableWizardFlow] = createSignal(false);
const [steps, setSteps] = createSignal<SchemaStep[]>([]);
const [expandedStep, setExpandedStep] = createSignal<string | null>(null);
const [dragStepId, setDragStepId] = createSignal<string | null>(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<SchemaStep>) =>
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<SchemaField>) =>
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 (
<div class="w-full space-y-4">
<div>
<h1 class="text-[28px] font-bold leading-tight text-[#111827]">Onboarding Schema Editor</h1>
<p class="mt-1 text-[14px] text-[#6B7280]">
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.
</p>
</div>
<div style="border-radius:12px;border:1px solid #E5E7EB;background:white;padding:16px;display:flex;gap:16px;align-items:flex-end;flex-wrap:wrap">
<label style="display:flex;flex-direction:column;gap:4px;min-width:240px">
<span style="font-size:11px;font-weight:800;color:#6B7280;text-transform:uppercase">Role</span>
<select
value={roleId()}
onChange={(e) => setRoleId(e.currentTarget.value)}
style="height:36px;border-radius:8px;border:1px solid #E5E7EB;padding:0 10px;font-size:13px"
>
<option value="">Select a role...</option>
<For each={roles() || []}>{(r) => <option value={r.id}>{r.name} ({r.key})</option>}</For>
</select>
</label>
<Show when={roleId()}>
<label style="display:flex;flex-direction:column;gap:4px">
<span style="font-size:11px;font-weight:800;color:#6B7280;text-transform:uppercase">Portfolio Model</span>
<select
value={portfolioModel()}
onChange={(e) => setPortfolioModel(e.currentTarget.value as PortfolioModel)}
style="height:36px;border-radius:8px;border:1px solid #E5E7EB;padding:0 10px;font-size:13px"
>
<option value="none">None (no portfolio step)</option>
<option value="custom_data">Custom data (job-seeker style)</option>
<option value="professional">Professional (flat fields + showcase items)</option>
</select>
</label>
<label style="display:flex;align-items:center;gap:8px;height:36px;font-size:13px;color:#374151">
<input type="checkbox" checked={enableWizardFlow()} onChange={(e) => setEnableWizardFlow(e.currentTarget.checked)} />
Enable verification wizard flow for this role
</label>
<button
type="button"
disabled={saving() || loading()}
onClick={handleSave}
style={`height:36px;border-radius:8px;border:none;background:#0D0D2A;color:white;padding:0 16px;font-size:13px;font-weight:700;cursor:pointer;opacity:${saving() || loading() ? 0.6 : 1}`}
>
{saving() ? 'Saving...' : 'Save Schema'}
</button>
<Show when={loadedVersion() !== null}>
<span style="font-size:12px;color:#6B7280">Current version: {loadedVersion()}</span>
</Show>
</Show>
</div>
<Show when={message()}>
<div style="border-radius:10px;border:1px solid #E5E7EB;background:#F9FAFB;padding:10px 14px;font-size:13px;color:#374151">
{message()}
</div>
</Show>
<Show when={roleId() && !loading()}>
<div style="display:flex;flex-direction:column;gap:12px">
<For each={steps()}>
{(step) => (
<div
draggable
onDragStart={() => 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`}
>
<div style="display:flex;align-items:center;gap:10px;padding:12px 16px;cursor:grab;background:#FAFAFA;border-bottom:1px solid #F3F4F6">
<span style="font-size:14px;color:#9CA3AF">::</span>
<input
value={step.title}
onInput={(e) => 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"
/>
<select
value={step.type}
onChange={(e) => updateStep(step.id, { type: e.currentTarget.value as StepType })}
style="height:32px;border-radius:6px;border:1px solid #E5E7EB;padding:0 8px;font-size:12px"
>
<For each={STEP_TYPES}>{(t) => <option value={t}>{t}</option>}</For>
</select>
<button
type="button"
onClick={() => setExpandedStep(expandedStep() === step.id ? null : step.id)}
style="height:32px;border-radius:6px;border:1px solid #E5E7EB;background:white;padding:0 10px;font-size:12px;font-weight:700;cursor:pointer"
>
{expandedStep() === step.id ? 'Collapse' : `${step.fields.length} field(s)`}
</button>
<button
type="button"
onClick={() => removeStep(step.id)}
style="height:32px;border-radius:6px;border:1px solid #FECACA;background:#FEF2F2;color:#B91C1C;padding:0 10px;font-size:12px;font-weight:700;cursor:pointer"
>
Remove Step
</button>
</div>
<Show when={expandedStep() === step.id}>
<div style="padding:12px 16px;display:flex;flex-direction:column;gap:8px">
<For each={step.fields}>
{(field, idx) => (
<div
draggable
onDragStart={() => 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"
>
<input
placeholder="field id (e.g. first_name)"
value={field.id}
onInput={(e) => updateField(step.id, idx(), { id: e.currentTarget.value })}
style="height:30px;border-radius:6px;border:1px solid #E5E7EB;padding:0 8px;font-size:12px"
/>
<input
placeholder="label"
value={field.label}
onInput={(e) => updateField(step.id, idx(), { label: e.currentTarget.value })}
style="height:30px;border-radius:6px;border:1px solid #E5E7EB;padding:0 8px;font-size:12px"
/>
<select
value={field.type}
onChange={(e) => updateField(step.id, idx(), { type: e.currentTarget.value as FieldType })}
style="height:30px;border-radius:6px;border:1px solid #E5E7EB;font-size:12px"
>
<For each={FIELD_TYPES}>{(t) => <option value={t}>{t}</option>}</For>
</select>
<label style="display:flex;align-items:center;gap:4px;font-size:11px;color:#374151">
<input
type="checkbox"
checked={field.required}
onChange={(e) => updateField(step.id, idx(), { required: e.currentTarget.checked })}
/>
Req
</label>
<label style="display:flex;align-items:center;gap:4px;font-size:11px;color:#B45309">
<input
type="checkbox"
checked={field.lockAfterApproval}
onChange={(e) => updateField(step.id, idx(), { lockAfterApproval: e.currentTarget.checked })}
/>
Lock
</label>
<input
placeholder="options (comma-separated, select only)"
value={field.options || ''}
onInput={(e) => 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'}
/>
<button
type="button"
onClick={() => removeField(step.id, idx())}
style="height:30px;border-radius:6px;border:1px solid #FECACA;background:#FEF2F2;color:#B91C1C;font-size:11px;font-weight:700;cursor:pointer"
>
Remove
</button>
</div>
)}
</For>
<button
type="button"
onClick={() => addField(step.id)}
style="align-self:flex-start;height:30px;border-radius:6px;border:1px solid #E5E7EB;background:white;padding:0 10px;font-size:12px;font-weight:700;cursor:pointer"
>
+ Add Field
</button>
</div>
</Show>
</div>
)}
</For>
<div style="display:flex;gap:8px;flex-wrap:wrap">
<For each={STEP_TYPES}>
{(t) => (
<button
type="button"
onClick={() => addStep(t)}
style="height:34px;border-radius:8px;border:1px solid #E5E7EB;background:white;padding:0 12px;font-size:12px;font-weight:700;cursor:pointer"
>
+ Add {t} step
</button>
)}
</For>
</div>
</div>
</Show>
<Show when={loading()}>
<p style="font-size:13px;color:#6B7280">Loading schema...</p>
</Show>
</div>
);
}

View file

@ -1,5 +1,5 @@
import OnboardingDeprecatedPage from '~/components/admin/OnboardingDeprecatedPage';
import OnboardingSchemaEditor from '~/components/admin/OnboardingSchemaEditor';
export default function OnboardingSchemasDetailRoute() {
return <OnboardingDeprecatedPage />;
return <OnboardingSchemaEditor />;
}

View file

@ -1,5 +1,5 @@
import OnboardingDeprecatedPage from '~/components/admin/OnboardingDeprecatedPage';
import OnboardingSchemaEditor from '~/components/admin/OnboardingSchemaEditor';
export default function OnboardingSchemasIndexRoute() {
return <OnboardingDeprecatedPage />;
return <OnboardingSchemaEditor />;
}

View file

@ -1,5 +1,5 @@
import OnboardingDeprecatedPage from '~/components/admin/OnboardingDeprecatedPage';
import OnboardingSchemaEditor from '~/components/admin/OnboardingSchemaEditor';
export default function OnboardingSchemasNewRoute() {
return <OnboardingDeprecatedPage />;
return <OnboardingSchemaEditor />;
}