Fix admin navigation/refresh and complete role/dashboard/onboarding builder parity updates

This commit is contained in:
Ashwin Kumar 2026-03-23 21:13:42 +01:00
parent 8a01ff9041
commit 0050277922
27 changed files with 1932 additions and 587 deletions

View file

@ -172,7 +172,7 @@ body {
/* ---- Admin Shell ---- */
.admin-root {
min-height: 100vh;
background: #f8fafc;
background: #f9fafb;
}
.admin-header {
@ -180,26 +180,21 @@ body {
top: 0;
left: 0;
right: 0;
z-index: 40;
border-bottom: 1px solid #e2e8f0;
background: #fff;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.08);
}
.admin-header-inner {
width: calc(100% - 48px);
margin: 0 24px;
z-index: 50;
height: 64px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
border-bottom: 1px solid #e5e7eb;
background: #fff;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.1);
padding: 0 24px;
}
.admin-header-left {
display: flex;
align-items: center;
gap: 28px;
gap: 32px;
min-width: 0;
}
@ -210,7 +205,7 @@ body {
}
.admin-brand img {
height: 40px;
height: 34px;
width: auto;
}
@ -219,6 +214,7 @@ body {
font-size: 16px;
font-weight: 600;
color: #1f2937;
margin-left: 112px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
@ -227,32 +223,20 @@ body {
.admin-header-actions {
display: flex;
align-items: center;
gap: 8px;
}
.admin-role-chip {
margin: 0;
border: 1px solid #fdba74;
background: #fff7ed;
color: #9a3412;
border-radius: 999px;
padding: 6px 11px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
gap: 24px;
}
.admin-avatar-btn {
border: 0;
border-radius: 10px;
border-radius: 8px;
background: transparent;
display: flex;
align-items: center;
gap: 10px;
padding: 4px 8px;
gap: 12px;
padding: 4px 8px 4px 4px;
cursor: pointer;
color: #475569;
color: #6b7280;
transition: background-color 150ms ease;
}
.admin-avatar-btn:hover {
@ -277,7 +261,7 @@ body {
display: flex;
flex-direction: column;
align-items: flex-start;
line-height: 1.1;
line-height: 1;
}
.admin-avatar-name {
@ -291,6 +275,30 @@ body {
color: #64748b;
}
.admin-notification-btn {
position: relative;
border: 0;
border-radius: 999px;
background: transparent;
color: #6b7280;
width: 32px;
height: 32px;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
transition: background-color 150ms ease;
}
.admin-notification-btn svg {
width: 20px;
height: 20px;
}
.admin-notification-btn:hover {
background: #f3f4f6;
}
.admin-logout-btn {
border: 0;
border-radius: 8px;
@ -304,8 +312,8 @@ body {
}
.admin-logout-btn svg {
width: 18px;
height: 18px;
width: 20px;
height: 20px;
}
.admin-logout-btn:hover {
@ -315,33 +323,42 @@ body {
.shell {
display: grid;
grid-template-columns: 264px 1fr;
height: calc(100vh - 80px);
grid-template-columns: auto 1fr;
height: 100vh;
overflow: hidden;
transition: grid-template-columns 300ms ease;
padding-top: 80px;
background: #f9fafb;
}
.shell.sidebar-collapsed {
grid-template-columns: 72px 1fr;
.sidebar-wrap {
height: calc(100vh - 5rem);
border-right: 1px solid #e2e8f0;
background: #fff;
width: 256px;
overflow: hidden;
transition: width 300ms ease;
}
.shell.sidebar-collapsed .sidebar-wrap {
width: 80px;
}
.sidebar {
height: 100%;
border-right: 1px solid #e2e8f0;
background: #fcfcfd;
padding: 20px 12px 12px;
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
min-height: 0;
transition: all 300ms ease;
}
.sidebar-toggle-row {
margin-bottom: 12px;
margin-bottom: 16px;
display: flex;
justify-content: flex-end;
padding: 0 6px;
padding: 0 8px;
}
.sidebar-toggle-btn {
@ -349,10 +366,10 @@ body {
border-radius: 8px;
background: transparent;
color: #64748b;
font-size: 22px;
line-height: 1;
padding: 3px 8px;
line-height: 0;
padding: 8px;
cursor: pointer;
transition: background-color 150ms ease, color 150ms ease;
}
.sidebar-toggle-btn:hover {
@ -362,6 +379,8 @@ body {
.sidebar-chevron {
display: inline-block;
font-size: 16px;
line-height: 1;
transition: transform 300ms ease;
}
@ -371,19 +390,18 @@ body {
/* Collapsed sidebar */
.sidebar.sidebar-collapsed {
padding: 20px 6px 12px;
align-items: center;
align-items: stretch;
}
.sidebar.sidebar-collapsed .sidebar-toggle-row {
padding: 0;
justify-content: center;
padding: 0 8px;
justify-content: flex-end;
}
.sidebar.sidebar-collapsed .nav-item {
justify-content: center;
padding: 10px;
gap: 0;
padding: 12px 8px;
gap: 12px;
}
.collapsed-dot {
@ -399,6 +417,9 @@ body {
.sidebar-nav {
flex: 1;
display: flex;
flex-direction: column;
gap: 6px;
min-height: 0;
overflow-y: auto;
padding-right: 4px;
@ -408,27 +429,16 @@ body {
position: relative;
display: flex;
align-items: center;
gap: 10px;
gap: 12px;
text-decoration: none;
color: #475569;
color: #64748b;
border: 1px solid transparent;
border-radius: 12px;
padding: 11px 12px;
margin-bottom: 2px;
font-size: 14px;
line-height: 1.35;
padding: 12px;
font-size: 15px;
line-height: 1.25;
font-weight: 500;
transition: background 180ms ease, border-color 180ms ease, color 180ms ease, box-shadow 180ms ease;
/* Prevent layout shift: active state changes font-weight which could reflow text */
min-height: 40px;
}
.nav-dot {
width: 8px;
height: 8px;
border-radius: 999px;
background: #cbd5e1;
transition: background-color 180ms ease;
transition: all 180ms ease;
}
.nav-icon {
@ -444,31 +454,35 @@ body {
color: #0f172a;
}
.nav-item:hover .nav-dot {
background: #94a3b8;
}
.nav-item.active {
border-color: var(--brand-orange-200);
background: linear-gradient(to right, var(--brand-orange-50), color-mix(in srgb, var(--brand-orange-100) 70%, white 30%));
color: #111827;
font-weight: 700;
color: #0f172a;
box-shadow: inset 3px 0 0 0 var(--brand-orange);
}
.nav-title {
flex: 1;
/* Reserve space for bold state so text reflow doesn't shift layout */
display: grid;
}
.nav-title::after {
content: attr(data-text);
height: 0;
min-width: 0;
white-space: nowrap;
overflow: hidden;
visibility: hidden;
font-weight: 700;
pointer-events: none;
user-select: none;
text-overflow: ellipsis;
}
.nav-active-rail {
position: absolute;
left: 0;
top: 8px;
bottom: 8px;
width: 3px;
border-radius: 0 999px 999px 0;
opacity: 0;
transition: opacity 180ms ease;
}
.nav-item.active .nav-active-rail {
opacity: 1;
background: var(--brand-orange);
}
.active-badge {
@ -485,7 +499,7 @@ body {
.main {
min-width: 0;
overflow-y: auto;
overflow: hidden;
height: 100%;
background: #f9fafb;
}
@ -493,6 +507,30 @@ body {
.main-inner {
max-width: none;
padding: 24px;
overflow-y: auto;
height: calc(100vh - 80px);
}
.scrollbar {
scrollbar-width: thin;
scrollbar-color: #c7c7c7 transparent;
}
.scrollbar::-webkit-scrollbar {
width: 6px;
}
.scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.scrollbar::-webkit-scrollbar-thumb {
background-color: #c7c7c7;
border-radius: 8px;
}
.scrollbar::-webkit-scrollbar-thumb:hover {
background-color: #aeb4be;
}
.admin-tab-wrap {

View file

@ -74,6 +74,7 @@ const PAGE_TITLES: Array<{ prefix: string; title: string }> = [
{ prefix: '/admin/graphic-designers', title: 'Graphics Designer Management' },
{ prefix: '/admin/jobs', title: 'Jobs Management' },
{ prefix: '/admin/leads', title: 'Leads Management' },
{ prefix: '/admin/requirements', title: 'Requirement Request' },
{ prefix: '/admin/pricing', title: 'Pricing Management' },
{ prefix: '/admin/invoice', title: 'Invoice Management' },
{ prefix: '/admin/credit', title: 'Credit Management' },
@ -123,11 +124,14 @@ export default function AdminShell(props: { children: JSX.Element }) {
// ?_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 isLocalDev =
typeof window !== 'undefined' &&
(window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1');
const isPreview =
searchParams._preview === '1' ||
(typeof sessionStorage !== 'undefined' && sessionStorage.getItem('nxtgauge_admin_preview') === '1');
if (isPreview) {
if (isPreview || isLocalDev) {
if (typeof sessionStorage !== 'undefined') sessionStorage.setItem('nxtgauge_admin_preview', '1');
setAdminSession();
setCheckedSession(true);
@ -142,11 +146,16 @@ export default function AdminShell(props: { children: JSX.Element }) {
}
try {
const accessToken =
typeof sessionStorage !== 'undefined'
? sessionStorage.getItem('nxtgauge_admin_access_token') || ''
: '';
const response = await fetch('/api/gateway/users/auth/me', {
method: 'GET',
headers: {
Accept: 'application/json',
'x-portal-target': 'admin',
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
},
credentials: 'include',
});
@ -176,40 +185,48 @@ export default function AdminShell(props: { children: JSX.Element }) {
}).catch(() => {});
clearAdminSession();
if (typeof sessionStorage !== 'undefined') {
sessionStorage.removeItem('nxtgauge_admin_access_token');
}
navigate('/login', { replace: true });
};
return (
<div class="admin-root">
<header class="admin-header">
<div class="admin-header-inner">
<div class="admin-header-left">
<div class="admin-brand">
<img src="/nxtgauge-logo.png" alt="NXTGAUGE" />
</div>
<h1 class="admin-page-heading">{pageTitle()}</h1>
</div>
<div class="admin-header-actions">
<p class="admin-role-chip">Super Admin</p>
<button class="admin-avatar-btn" type="button" aria-label="Admin profile">
<span class="admin-avatar">A</span>
<span class="admin-avatar-meta">
<span class="admin-avatar-name">Admin</span>
<span class="admin-avatar-role">Super Admin</span>
</span>
</button>
<button class="admin-logout-btn" type="button" onClick={onLogout} aria-label="Logout">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path strokeLinecap="round" strokeLinejoin="round" d="M17 16l4-4m0 0l-4-4m4 4H9m4 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
</svg>
</button>
<div class="admin-header-left">
<div class="admin-brand">
<img src="/nxtgauge-logo.png" alt="NXTGAUGE" />
</div>
<h1 class="admin-page-heading">{pageTitle()}</h1>
</div>
<div class="admin-header-actions">
<button class="admin-notification-btn" type="button" aria-label="Notifications">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path strokeLinecap="round" strokeLinejoin="round" d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9" />
<path strokeLinecap="round" strokeLinejoin="round" d="M13.73 21a2 2 0 0 1-3.46 0" />
</svg>
</button>
<button class="admin-avatar-btn" type="button" aria-label="Admin profile">
<span class="admin-avatar">A</span>
<span class="admin-avatar-meta">
<span class="admin-avatar-name">Admin</span>
<span class="admin-avatar-role">Super Admin</span>
</span>
</button>
<button class="admin-logout-btn" type="button" onClick={onLogout} aria-label="Logout">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path strokeLinecap="round" strokeLinejoin="round" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
</svg>
</button>
</div>
</header>
{checkedSession() ? (
<div class={`shell${sidebarCollapsed() ? ' sidebar-collapsed' : ''}`}>
<AdminSidebar />
<aside class="sidebar-wrap">
<AdminSidebar />
</aside>
<main class="main">
{tabs().length > 0 ? (
<div class="admin-tab-wrap">

View file

@ -67,7 +67,7 @@ export default function AdminSidebar() {
<span class={`sidebar-chevron${collapsed() ? ' collapsed' : ''}`}></span>
</button>
</div>
<nav class="sidebar-nav">
<nav class="sidebar-nav scrollbar">
{links.map((item) => {
const active = isLinkActive(item.href, item.aliasPrefix);
return (
@ -79,9 +79,10 @@ export default function AdminSidebar() {
data-legacy-href={item.legacyHref}
title={collapsed() ? item.label : undefined}
>
<span class="nav-active-rail" />
<img class="nav-icon" src={`/sidebar-icons/${item.icon}`} alt="" />
{!collapsed() && (
<span class="nav-title" data-text={item.label}>{item.label}</span>
<span class="nav-title">{item.label}</span>
)}
{!collapsed() && active && (
<span class="active-badge">Active</span>

View file

@ -30,6 +30,7 @@ type ModuleOption = {
type ExternalRoleFormProps = {
initialValue: ExternalRoleConfig;
onboardingSchemaOptions?: string[];
saving?: boolean;
submitLabel: string;
onSubmit: (value: ExternalRoleConfig) => Promise<void> | void;
@ -310,7 +311,7 @@ function slugifyRoleKey(value: string): string {
function parseFeatureLimits(raw: string): Record<string, unknown> {
const parsed = JSON.parse(raw || '{}');
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new Error('Feature limits must be a JSON object.');
throw new Error('Advanced limits must be valid JSON (object format).');
}
return parsed as Record<string, unknown>;
}
@ -326,7 +327,13 @@ export default function ExternalRoleForm(props: ExternalRoleFormProps) {
setErrors({});
});
const moduleOptions = createMemo(() => MODULES_BY_VERTICAL[config().vertical]);
const moduleOptions = createMemo(() => MODULES_BY_VERTICAL[config().vertical] || MODULES_BY_VERTICAL.marketplace);
const onboardingOptions = createMemo(() => {
const fromProps = Array.isArray(props.onboardingSchemaOptions) ? props.onboardingSchemaOptions : [];
const fromDefaults = ONBOARDING_SCHEMA_OPTIONS;
const current = config().onboardingSchemaId ? [config().onboardingSchemaId] : [];
return Array.from(new Set([...fromProps, ...fromDefaults, ...current].filter(Boolean)));
});
createEffect(() => {
const allowed = new Set(moduleOptions().map((item) => item.key));
@ -362,6 +369,42 @@ export default function ExternalRoleForm(props: ExternalRoleFormProps) {
});
};
const applyRecommendedModules = () => {
const recommended = config().vertical === 'jobs'
? (config().roleCategory === 'employer'
? ['dashboard', 'profile', 'jobs', 'applications', 'notifications', 'settings']
: ['dashboard', 'profile', 'jobs', 'applications', 'notifications', 'settings'])
: (config().roleCategory === 'consumer'
? ['dashboard', 'profile', 'requirements', 'marketplace', 'wallet', 'notifications', 'settings']
: MARKETPLACE_PROVIDER_MODULES);
const allowed = new Set(moduleOptions().map((item) => item.key));
const enabledModules = recommended.filter((key) => allowed.has(key));
const nextPermissions = { ...config().permissions };
enabledModules.forEach((moduleKey) => {
if (!nextPermissions[moduleKey] || nextPermissions[moduleKey].length === 0) {
nextPermissions[moduleKey] = moduleKey === 'dashboard' || moduleKey === 'wallet' || moduleKey === 'notifications'
? ['read']
: ['read', 'update'];
}
});
setConfigPatch({ enabledModules, permissions: nextPermissions });
};
const selectAllModules = () => {
const enabledModules = moduleOptions().map((option) => option.key);
const nextPermissions = { ...config().permissions };
enabledModules.forEach((moduleKey) => {
if (!nextPermissions[moduleKey] || nextPermissions[moduleKey].length === 0) {
nextPermissions[moduleKey] = ['read'];
}
});
setConfigPatch({ enabledModules, permissions: nextPermissions });
};
const clearAllModules = () => {
setConfigPatch({ enabledModules: [], permissions: {} });
};
const togglePermission = (moduleKey: string, action: string) => {
setConfig((current) => {
const next = new Set(current.permissions[moduleKey] || []);
@ -380,13 +423,13 @@ export default function ExternalRoleForm(props: ExternalRoleFormProps) {
const validateAndSubmit = async () => {
const nextErrors: Record<string, string> = {};
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.';
if (!normalizedRoleKey) nextErrors.roleKey = 'Role code is required.';
if (!config().displayName.trim()) nextErrors.displayName = 'Role name is required.';
if (config().enabledModules.length === 0) nextErrors.enabledModules = 'Select at least one area/page.';
const modulesWithoutPermissions = config().enabledModules.filter((moduleKey) => !(config().permissions[moduleKey] || []).length);
if (modulesWithoutPermissions.length > 0) {
nextErrors.permissions = `Assign at least one permission for: ${modulesWithoutPermissions.join(', ')}.`;
nextErrors.permissions = `Choose at least one access permission for: ${modulesWithoutPermissions.join(', ')}.`;
}
let featureLimits: Record<string, unknown> = {};
@ -411,13 +454,13 @@ export default function ExternalRoleForm(props: ExternalRoleFormProps) {
<section class="card">
<div class="list-header">
<div>
<h2>Role Configuration</h2>
<h2>External Role Setup</h2>
<p class="notice" style="margin-top:2px">
Configure the external runtime role model for jobs and marketplace verticals.
Set up what this role can see and do in the app.
</p>
</div>
<div class="field" style="min-width:220px;margin:0">
<label>Load preset</label>
<label>Start from a template</label>
<select
value=""
onChange={(event) => {
@ -427,7 +470,7 @@ export default function ExternalRoleForm(props: ExternalRoleFormProps) {
}
}}
>
<option value="">Choose a role preset</option>
<option value="">Choose a template</option>
<optgroup label="Jobs Vertical">
<option value="company">Company (Employer)</option>
<option value="job_seeker">Job Seeker</option>
@ -452,24 +495,24 @@ export default function ExternalRoleForm(props: ExternalRoleFormProps) {
<div class="field-grid-2">
<div class="field">
<label>Role Key</label>
<label>Role Code</label>
<input value={config().roleKey} onInput={(event) => setConfigPatch({ roleKey: event.currentTarget.value })} placeholder="company" />
<Show when={errors().roleKey}><p class="error-note">{errors().roleKey}</p></Show>
</div>
<div class="field">
<label>Display Name</label>
<label>Role Name</label>
<input value={config().displayName} onInput={(event) => setConfigPatch({ displayName: event.currentTarget.value })} placeholder="Company" />
<Show when={errors().displayName}><p class="error-note">{errors().displayName}</p></Show>
</div>
<div class="field">
<label>Vertical</label>
<label>App Area</label>
<select value={config().vertical} onChange={(event) => setConfigPatch({ vertical: event.currentTarget.value as 'jobs' | 'marketplace' })}>
<option value="jobs">Jobs</option>
<option value="marketplace">Marketplace</option>
</select>
</div>
<div class="field">
<label>Role Category</label>
<label>Role Type</label>
<select value={config().roleCategory} onChange={(event) => setConfigPatch({ roleCategory: event.currentTarget.value as ExternalRoleConfig['roleCategory'] })}>
<option value="provider">Provider</option>
<option value="employer">Employer</option>
@ -478,9 +521,9 @@ export default function ExternalRoleForm(props: ExternalRoleFormProps) {
</select>
</div>
<div class="field">
<label>Onboarding Schema</label>
<label>Onboarding Form</label>
<select value={config().onboardingSchemaId} onChange={(event) => setConfigPatch({ onboardingSchemaId: event.currentTarget.value })}>
<For each={ONBOARDING_SCHEMA_OPTIONS}>
<For each={onboardingOptions()}>
{(schemaId) => <option value={schemaId}>{schemaId}</option>}
</For>
</select>
@ -488,15 +531,21 @@ export default function ExternalRoleForm(props: ExternalRoleFormProps) {
<div class="field" style="display:flex;align-items:flex-end">
<label class="checkbox-label">
<input type="checkbox" checked={config().isActive} onChange={(event) => setConfigPatch({ isActive: event.currentTarget.checked })} />
Active role
This role is active
</label>
</div>
</div>
</section>
<section class="card">
<h2 style="margin-bottom:4px">Enabled Modules</h2>
<p class="notice" style="margin:0 0 10px">Choose the modules this role can access at runtime.</p>
<h2 style="margin-bottom:4px">Visible Areas</h2>
<p class="notice" style="margin:0 0 10px">Choose which pages/areas this role can access.</p>
<div style="display:flex;gap:8px;flex-wrap:wrap;margin:0 0 12px">
<button class="btn" type="button" onClick={applyRecommendedModules}>Use Recommended</button>
<button class="btn" type="button" onClick={selectAllModules}>Select All</button>
<button class="btn" type="button" onClick={clearAllModules}>Clear All</button>
<span class="notice" style="align-self:center">{moduleOptions().length} available modules</span>
</div>
<div class="field-grid-2">
<For each={moduleOptions()}>
{(option) => {
@ -515,17 +564,20 @@ export default function ExternalRoleForm(props: ExternalRoleFormProps) {
}}
</For>
</div>
<Show when={moduleOptions().length === 0}>
<p class="error-note">No module options were found for this app area. Switch the app area and try again.</p>
</Show>
<Show when={errors().enabledModules}><p class="error-note">{errors().enabledModules}</p></Show>
</section>
<section class="card">
<h2 style="margin-bottom:4px">Permission Matrix</h2>
<p class="notice" style="margin:0 0 10px">Assign CRUD and approval capabilities per enabled module.</p>
<h2 style="margin-bottom:4px">Access Permissions</h2>
<p class="notice" style="margin:0 0 10px">Choose what actions this role can do in each selected area.</p>
<div class="table-wrap">
<table class="list-table">
<thead>
<tr>
<th>Module</th>
<th>Area</th>
<For each={ROLE_PERMISSION_ACTIONS}>
{(action) => <th class="align-right">{action.label}</th>}
</For>
@ -533,7 +585,7 @@ export default function ExternalRoleForm(props: ExternalRoleFormProps) {
</thead>
<tbody>
<Show when={config().enabledModules.length === 0}>
<tr><td colspan={ROLE_PERMISSION_ACTIONS.length + 1} style="text-align:center;padding:18px;color:#64748b">Enable modules to configure permissions.</td></tr>
<tr><td colspan={ROLE_PERMISSION_ACTIONS.length + 1} style="text-align:center;padding:18px;color:#64748b">Select at least one area to set permissions.</td></tr>
</Show>
<For each={moduleOptions().filter((option) => config().enabledModules.includes(option.key))}>
{(option) => (
@ -559,15 +611,16 @@ export default function ExternalRoleForm(props: ExternalRoleFormProps) {
</section>
<section class="card">
<h2 style="margin-bottom:6px">Approval Rules & Limits</h2>
<h2 style="margin-bottom:6px">Approvals & Limits</h2>
<div class="field-grid-2">
<label class="checkbox-label"><input type="checkbox" checked={config().requiresOnboardingApproval} onChange={(event) => setConfigPatch({ requiresOnboardingApproval: event.currentTarget.checked })} />Requires onboarding approval</label>
<label class="checkbox-label"><input type="checkbox" checked={config().requiresLeadApproval} onChange={(event) => setConfigPatch({ requiresLeadApproval: event.currentTarget.checked })} />Requires lead approval</label>
<label class="checkbox-label"><input type="checkbox" checked={config().requiresJobApproval} onChange={(event) => setConfigPatch({ requiresJobApproval: event.currentTarget.checked })} />Requires job approval</label>
<label class="checkbox-label"><input type="checkbox" checked={config().requiresOnboardingApproval} onChange={(event) => setConfigPatch({ requiresOnboardingApproval: event.currentTarget.checked })} />Review onboarding submissions before approval</label>
<label class="checkbox-label"><input type="checkbox" checked={config().requiresLeadApproval} onChange={(event) => setConfigPatch({ requiresLeadApproval: event.currentTarget.checked })} />Review incoming leads before approval</label>
<label class="checkbox-label"><input type="checkbox" checked={config().requiresJobApproval} onChange={(event) => setConfigPatch({ requiresJobApproval: event.currentTarget.checked })} />Review job posts before approval</label>
</div>
<div class="field" style="margin-top:10px">
<label>Feature Limits (JSON)</label>
<label>Advanced Limits (optional JSON)</label>
<textarea rows="8" value={featureLimitsRaw()} onInput={(event) => setFeatureLimitsRaw(event.currentTarget.value)} />
<p class="notice" style="margin:6px 0 0">Leave this as `{}` unless you need advanced limits.</p>
<Show when={errors().featureLimits}><p class="error-note">{errors().featureLimits}</p></Show>
</div>
</section>

View file

@ -38,6 +38,8 @@ type Props = {
selectedFields: OnboardingField[];
saving?: boolean;
error?: string;
livePreviewUrl?: string;
livePreviewHint?: string;
primaryLabel: string;
onChange: (next: {
title?: string;
@ -50,7 +52,22 @@ type Props = {
onSubmit: () => void;
};
const ROLE_OPTIONS = ['company', 'job_seeker', 'customer', 'photographer', 'video_editor', 'graphic_designer', 'social_media_manager', 'fitness_trainer', 'catering_services', 'makeup_artist', 'tutor', 'developer'];
const ROLE_OPTIONS = [
'company',
'job_seeker',
'jobseeker',
'customer',
'professional',
'photographer',
'video_editor',
'graphic_designer',
'social_media_manager',
'fitness_trainer',
'catering_services',
'makeup_artist',
'tutor',
'developer',
];
const DEFAULT_LIBRARY: OnboardingQuestion[] = [
{ key: 'full_name', label: 'Full Name', type: 'text', required: true, category: 'profile' },
@ -86,6 +103,31 @@ function createFieldFromQuestion(question: OnboardingQuestion): OnboardingField
};
}
function createField(
id: string,
label: string,
type: OnboardingFieldType,
required: boolean,
options?: string[],
placeholder?: string,
): OnboardingField {
return {
id,
label,
type,
required,
placeholder: placeholder || (type === 'file' ? 'Upload file' : type === 'select' ? `Select ${label}` : `Enter ${label}`),
options: Array.isArray(options) ? options.map((value) => ({ label: value, value })) : undefined,
};
}
function normalizeRoleKey(roleKey: string): string {
return String(roleKey || '')
.trim()
.toLowerCase()
.replace(/[-\s]+/g, '_');
}
function categoryLabel(category: QuestionCategory): string {
if (category === 'business') return 'Business';
if (category === 'documents') return 'Documents';
@ -113,17 +155,231 @@ export function buildStepsFromFields(selectedFields: OnboardingField[], stepCoun
}
export function createDefaultFields(roleKey: string): OnboardingField[] {
if (roleKey === 'company') {
return DEFAULT_LIBRARY.filter((q) => ['full_name', 'email', 'phone', 'gst_number', 'pan_number', 'identity_document'].includes(q.key)).map(createFieldFromQuestion);
}
if (roleKey === 'photographer') {
return DEFAULT_LIBRARY.filter((q) => ['full_name', 'email', 'phone', 'city', 'experience_years', 'portfolio_url', 'identity_document'].includes(q.key)).map(createFieldFromQuestion);
}
return DEFAULT_LIBRARY.filter((q) => ['full_name', 'email', 'phone', 'city', 'identity_document'].includes(q.key)).map(createFieldFromQuestion);
const role = normalizeRoleKey(roleKey);
const company = [
createField('company_name', 'Company name', 'text', true),
createField('legal_name', 'Legal entity name', 'text', true),
createField('industry', 'Industry', 'text', true),
createField('contact_name', 'Contact person', 'text', true),
createField('contact_email', 'Contact email', 'email', true),
createField('contact_phone', 'Contact phone', 'tel', true),
createField('website', 'Website', 'url', false),
createField('hq_city', 'Headquarter city', 'text', true),
createField('team_size', 'Team size', 'select', true, ['1-10', '11-50', '51-200', '200+']),
createField('hiring_for', 'Primary hiring for', 'text', true),
createField('work_mode', 'Preferred work mode', 'select', true, ['onsite', 'remote', 'hybrid']),
createField('monthly_openings', 'Expected monthly openings', 'number', false),
createField('registration_number', 'Company registration number', 'text', true),
createField('official_email', 'Official company email', 'email', true),
createField('authorized_signatory', 'Authorized signatory', 'text', true),
];
const jobseeker = [
createField('full_name', 'Full name', 'text', true),
createField('city', 'City', 'text', true),
createField('skills', 'Skills', 'textarea', true),
createField('preferred_role', 'Preferred role', 'text', true),
createField('expected_salary', 'Expected salary', 'text', false),
createField('experience_years', 'Years of experience', 'number', true),
createField('latest_company', 'Latest company', 'text', false),
createField('notice_period', 'Notice period', 'text', false),
createField('resume_url', 'Resume URL', 'url', true),
createField('linkedin_url', 'LinkedIn URL', 'url', false),
createField('about_me', 'About me', 'textarea', false),
];
const professionalBase = [
createField(
'profession',
'Professional role',
'select',
true,
['photographer', 'makeup_artist', 'tutor', 'developer', 'video_editor', 'graphic_designer', 'social_media_manager', 'fitness_trainer', 'catering_services'],
),
createField('full_name', 'Full name', 'text', true),
createField('experience', 'Experience', 'text', true),
createField('bio', 'Bio', 'textarea', true),
createField('email', 'Email', 'email', true),
createField('phone', 'Phone', 'tel', true),
createField('city', 'City', 'text', true),
createField('primary_service', 'Primary service', 'text', true),
createField('price_range', 'Price range', 'text', true),
createField('availability', 'Availability', 'select', true, ['weekdays', 'weekends', 'all_days']),
createField('portfolio_url', 'Portfolio URL', 'url', false),
createField('portfolio_note', 'Portfolio note', 'textarea', false),
createField('identity_document', 'Identity Document', 'file', true),
];
const customer = [
createField(
'profession',
'Service category',
'select',
true,
['photographer', 'makeup_artist', 'tutor', 'developer', 'video_editor', 'graphic_designer', 'social_media_manager', 'fitness_trainer', 'catering_services'],
),
createField('event_type', 'Event type', 'text', false),
createField('coverage_hours', 'Coverage hours', 'number', false),
createField('photo_style', 'Preferred photo style', 'text', false),
createField('occasion_type', 'Occasion type', 'text', false),
createField('people_count', 'Number of people', 'number', false),
createField('skin_preferences', 'Skin or product preferences', 'textarea', false),
createField('subject', 'Subject', 'text', false),
createField('grade_level', 'Grade or level', 'text', false),
createField('sessions_per_week', 'Sessions per week', 'number', false),
createField('project_type', 'Project type', 'text', false),
createField('platform', 'Platform (web/mobile/backend)', 'text', false),
createField('feature_summary', 'Key features', 'textarea', false),
createField('video_type', 'Video type', 'text', false),
createField('video_duration', 'Approx. duration (minutes)', 'number', false),
createField('editing_style', 'Editing style', 'text', false),
createField('design_type', 'Design type', 'text', false),
createField('brand_guidelines', 'Brand guidelines', 'textarea', false),
createField('asset_count', 'Number of assets', 'number', false),
createField('platforms', 'Platforms', 'text', false),
createField('posting_frequency', 'Posting frequency', 'text', false),
createField('goal', 'Primary goal', 'text', false),
createField('fitness_goal', 'Fitness goal', 'text', false),
createField('sessions_per_week_fitness', 'Sessions per week', 'number', false),
createField('training_mode', 'Training mode preference', 'select', false, ['onsite', 'remote', 'hybrid']),
createField('event_size', 'Event size (people)', 'number', false),
createField('menu_preference', 'Menu preference', 'text', false),
createField('cuisine_type', 'Cuisine type', 'text', false),
createField('budget_range', 'Budget range', 'text', false),
createField('expected_start', 'Expected start date', 'date', false),
createField('urgency', 'Urgency', 'select', false, ['low', 'medium', 'high']),
createField('service_city', 'Service location', 'text', true),
createField('service_mode', 'Service mode', 'select', true, ['onsite', 'remote', 'hybrid']),
createField('address_line', 'Address line', 'text', false),
createField('pin_code', 'PIN code', 'text', false),
createField('summary_note', 'Additional notes', 'textarea', false),
];
const specialistOverrides: Record<string, OnboardingField[]> = {
photographer: [
createField('full_name', 'Full name', 'text', true),
createField('email', 'Email', 'email', true),
createField('phone', 'Phone', 'tel', true),
createField('city', 'City', 'text', true),
createField('experience_years', 'Experience (Years)', 'number', false),
createField('specialization', 'Photography specialization', 'text', true),
createField('portfolio_url', 'Portfolio URL', 'url', false),
createField('price_range', 'Price range', 'text', true),
createField('availability', 'Availability', 'select', true, ['weekdays', 'weekends', 'all_days']),
createField('identity_document', 'Identity Document', 'file', true),
],
makeup_artist: [
createField('full_name', 'Full name', 'text', true),
createField('email', 'Email', 'email', true),
createField('phone', 'Phone', 'tel', true),
createField('city', 'City', 'text', true),
createField('experience_years', 'Experience (Years)', 'number', false),
createField('specialization', 'Makeup specialization', 'text', true),
createField('portfolio_url', 'Portfolio URL', 'url', false),
createField('price_range', 'Price range', 'text', true),
createField('availability', 'Availability', 'select', true, ['weekdays', 'weekends', 'all_days']),
createField('identity_document', 'Identity Document', 'file', true),
],
tutor: [
createField('full_name', 'Full name', 'text', true),
createField('email', 'Email', 'email', true),
createField('phone', 'Phone', 'tel', true),
createField('city', 'City', 'text', true),
createField('experience_years', 'Experience (Years)', 'number', false),
createField('specialization', 'Subjects', 'text', true),
createField('portfolio_url', 'Portfolio URL', 'url', false),
createField('price_range', 'Price range', 'text', true),
createField('availability', 'Availability', 'select', true, ['weekdays', 'weekends', 'all_days']),
createField('identity_document', 'Identity Document', 'file', true),
],
developer: [
createField('full_name', 'Full name', 'text', true),
createField('email', 'Email', 'email', true),
createField('phone', 'Phone', 'tel', true),
createField('city', 'City', 'text', true),
createField('experience_years', 'Experience (Years)', 'number', false),
createField('specialization', 'Tech specialization', 'text', true),
createField('portfolio_url', 'Portfolio URL', 'url', false),
createField('price_range', 'Price range', 'text', true),
createField('availability', 'Availability', 'select', true, ['weekdays', 'weekends', 'all_days']),
createField('identity_document', 'Identity Document', 'file', true),
],
video_editor: [
createField('full_name', 'Full name', 'text', true),
createField('email', 'Email', 'email', true),
createField('phone', 'Phone', 'tel', true),
createField('city', 'City', 'text', true),
createField('experience_years', 'Experience (Years)', 'number', false),
createField('specialization', 'Editing specialization', 'text', true),
createField('portfolio_url', 'Portfolio URL', 'url', false),
createField('price_range', 'Price range', 'text', true),
createField('availability', 'Availability', 'select', true, ['weekdays', 'weekends', 'all_days']),
createField('identity_document', 'Identity Document', 'file', true),
],
graphic_designer: [
createField('full_name', 'Full name', 'text', true),
createField('email', 'Email', 'email', true),
createField('phone', 'Phone', 'tel', true),
createField('city', 'City', 'text', true),
createField('experience_years', 'Experience (Years)', 'number', false),
createField('specialization', 'Design specialization', 'text', true),
createField('portfolio_url', 'Portfolio URL', 'url', false),
createField('price_range', 'Price range', 'text', true),
createField('availability', 'Availability', 'select', true, ['weekdays', 'weekends', 'all_days']),
createField('identity_document', 'Identity Document', 'file', true),
],
social_media_manager: [
createField('full_name', 'Full name', 'text', true),
createField('email', 'Email', 'email', true),
createField('phone', 'Phone', 'tel', true),
createField('city', 'City', 'text', true),
createField('experience_years', 'Experience (Years)', 'number', false),
createField('specialization', 'Platform specialization', 'text', true),
createField('portfolio_url', 'Portfolio URL', 'url', false),
createField('price_range', 'Price range', 'text', true),
createField('availability', 'Availability', 'select', true, ['weekdays', 'weekends', 'all_days']),
createField('identity_document', 'Identity Document', 'file', true),
],
fitness_trainer: [
createField('full_name', 'Full name', 'text', true),
createField('email', 'Email', 'email', true),
createField('phone', 'Phone', 'tel', true),
createField('city', 'City', 'text', true),
createField('experience_years', 'Experience (Years)', 'number', false),
createField('specialization', 'Training specialization', 'text', true),
createField('portfolio_url', 'Portfolio URL', 'url', false),
createField('price_range', 'Price range', 'text', true),
createField('availability', 'Availability', 'select', true, ['weekdays', 'weekends', 'all_days']),
createField('identity_document', 'Identity Document', 'file', true),
],
catering_services: [
createField('full_name', 'Full name', 'text', true),
createField('email', 'Email', 'email', true),
createField('phone', 'Phone', 'tel', true),
createField('city', 'City', 'text', true),
createField('experience_years', 'Experience (Years)', 'number', false),
createField('specialization', 'Catering specialization', 'text', true),
createField('portfolio_url', 'Portfolio URL', 'url', false),
createField('price_range', 'Price range', 'text', true),
createField('availability', 'Availability', 'select', true, ['weekdays', 'weekends', 'all_days']),
createField('identity_document', 'Identity Document', 'file', true),
],
};
if (role === 'company') return company;
if (role === 'job_seeker' || role === 'jobseeker') return jobseeker;
if (role === 'customer') return customer;
if (role === 'professional') return professionalBase;
if (specialistOverrides[role]) return specialistOverrides[role];
return DEFAULT_LIBRARY
.filter((q) => ['full_name', 'email', 'phone', 'city', 'identity_document'].includes(q.key))
.map(createFieldFromQuestion);
}
export default function OnboardingFlowBuilder(props: Props) {
const [activeTab, setActiveTab] = createSignal<'overview' | 'library' | 'selected' | 'preview'>('overview');
const [activeTab, setActiveTab] = createSignal<'overview' | 'library' | 'selected' | 'preview' | 'live'>('overview');
const [customLabel, setCustomLabel] = createSignal('');
const [customType, setCustomType] = createSignal<OnboardingFieldType>('text');
const [customRequired, setCustomRequired] = createSignal(true);
@ -184,9 +440,9 @@ export default function OnboardingFlowBuilder(props: Props) {
return (
<section class="card">
<h2 style="margin-bottom:4px">Flow Builder</h2>
<h2 style="margin-bottom:4px">Onboarding Setup</h2>
<p class="notice" style="margin:0 0 10px">
Choose the external role, how many steps the onboarding should have, which questions appear, add custom questions, and define the final submission message.
Pick who this form is for, choose the steps, select the questions, and set the message users will see after they submit.
</p>
<Show when={props.error}>
@ -195,38 +451,39 @@ export default function OnboardingFlowBuilder(props: Props) {
<div class="admin-segmented">
<button class={`admin-segment ${activeTab() === 'overview' ? 'active' : ''}`} onClick={() => setActiveTab('overview')}>Overview</button>
<button class={`admin-segment ${activeTab() === 'library' ? 'active' : ''}`} onClick={() => setActiveTab('library')}>Question Library</button>
<button class={`admin-segment ${activeTab() === 'selected' ? 'active' : ''}`} onClick={() => setActiveTab('selected')}>Selected Questions</button>
<button class={`admin-segment ${activeTab() === 'preview' ? 'active' : ''}`} onClick={() => setActiveTab('preview')}>Preview</button>
<button class={`admin-segment ${activeTab() === 'library' ? 'active' : ''}`} onClick={() => setActiveTab('library')}>Question List</button>
<button class={`admin-segment ${activeTab() === 'selected' ? 'active' : ''}`} onClick={() => setActiveTab('selected')}>Your Questions</button>
<button class={`admin-segment ${activeTab() === 'preview' ? 'active' : ''}`} onClick={() => setActiveTab('preview')}>Step Preview</button>
<button class={`admin-segment ${activeTab() === 'live' ? 'active' : ''}`} onClick={() => setActiveTab('live')}>Live Preview</button>
</div>
<Show when={activeTab() === 'overview'}>
<div class="field-grid-2">
<div class="field">
<label>Flow title</label>
<label>Form title</label>
<input value={props.title} onInput={(event) => props.onChange({ title: event.currentTarget.value })} />
</div>
<div class="field">
<label>External role</label>
<label>Who is this form for?</label>
<select value={props.roleKey} onChange={(event) => props.onChange({ roleKey: event.currentTarget.value })}>
<option value="">Select a role</option>
<For each={ROLE_OPTIONS}>{(option) => <option value={option}>{option.replace(/_/g, ' ')}</option>}</For>
</select>
</div>
<div class="field">
<label>Flow description</label>
<label>Short description (optional)</label>
<textarea rows="3" value={props.description} onInput={(event) => props.onChange({ description: event.currentTarget.value })} />
</div>
<div class="field">
<label>Number of steps</label>
<label>How many steps should users see?</label>
<input type="number" min={1} max={8} value={props.stepCount} onInput={(event) => props.onChange({ stepCount: Number(event.currentTarget.value) || 1 })} />
</div>
<div class="nested-card" style="display:flex;flex-direction:column;justify-content:center">
<p style="margin:0;font-size:13px;font-weight:700;color:#0f172a">{props.selectedFields.length} fields selected</p>
<p class="notice" style="margin:6px 0 0">Add from the library or create custom questions for this role.</p>
<p style="margin:0;font-size:13px;font-weight:700;color:#0f172a">{props.selectedFields.length} questions selected</p>
<p class="notice" style="margin:6px 0 0">Choose from the question list or add your own custom question.</p>
</div>
<div class="field" style="grid-column:1 / -1">
<label>Final submission message</label>
<label>Success message after submit</label>
<textarea rows="3" value={props.finalSubmissionMessage} onInput={(event) => props.onChange({ finalSubmissionMessage: event.currentTarget.value })} />
</div>
</div>
@ -261,7 +518,7 @@ export default function OnboardingFlowBuilder(props: Props) {
</For>
<section class="sub-card">
<h4>Add Custom Question</h4>
<h4>Add Your Own Question</h4>
<div class="field-grid-2">
<input value={customLabel()} onInput={(event) => setCustomLabel(event.currentTarget.value)} placeholder="Question label" />
<select value={customType()} onChange={(event) => setCustomType(event.currentTarget.value as OnboardingFieldType)}>
@ -272,14 +529,14 @@ export default function OnboardingFlowBuilder(props: Props) {
<input value={customOptions()} onInput={(event) => setCustomOptions(event.currentTarget.value)} placeholder="Option 1, Option 2" />
</Show>
</div>
<div class="actions"><button class="btn navy" type="button" onClick={addCustomField}>Add Custom Question</button></div>
<div class="actions"><button class="btn navy" type="button" onClick={addCustomField}>Add Question</button></div>
</section>
</div>
</Show>
<Show when={activeTab() === 'selected'}>
<div class="space-y-3">
<h3 style="margin:0;font-size:14px;font-weight:700;color:#0f172a">Selected Questions</h3>
<h3 style="margin:0;font-size:14px;font-weight:700;color:#0f172a">Your Selected Questions</h3>
<Show when={props.selectedFields.length === 0}>
<p class="notice">No questions selected yet.</p>
</Show>
@ -312,13 +569,13 @@ export default function OnboardingFlowBuilder(props: Props) {
<Show when={activeTab() === 'preview'}>
<div class="space-y-3">
<p class="notice">Preview uses the same question and step distribution before saving.</p>
<p class="notice">This preview shows how questions are split across steps before you save.</p>
<For each={previewSteps()}>
{(step, index) => (
<section class="sub-card">
<h4 style="margin-bottom:6px">Step {index() + 1}</h4>
<Show when={step.fields.length === 0}>
<p class="notice">No fields in this step.</p>
<p class="notice">No questions in this step yet.</p>
</Show>
<For each={step.fields}>
{(field) => (
@ -334,6 +591,28 @@ export default function OnboardingFlowBuilder(props: Props) {
</div>
</Show>
<Show when={activeTab() === 'live'}>
<div class="space-y-3">
<p class="notice">Live preview opens the real onboarding screen users will see.</p>
<Show when={props.livePreviewHint}>
<p class="notice" style="margin-top:0">{props.livePreviewHint}</p>
</Show>
<Show
when={props.livePreviewUrl}
fallback={<div class="notice" style="padding:10px 12px;border:1px solid #e2e8f0;border-radius:10px;background:#f8fafc">Pick a role first to open live preview.</div>}
>
<div style="border:1px solid #e2e8f0;border-radius:12px;overflow:hidden;background:#fff">
<iframe
src={props.livePreviewUrl}
title="Live Onboarding Preview"
style="width:100%;height:780px;border:0;background:#fff"
loading="lazy"
/>
</div>
</Show>
</div>
</Show>
<div class="actions" style="justify-content:flex-end">
<button class="btn primary" type="button" disabled={props.saving} onClick={props.onSubmit}>
{props.saving ? 'Saving...' : props.primaryLabel}

View file

@ -1,5 +1,6 @@
import { A, useParams } from '@solidjs/router';
import { createMemo } from 'solid-js';
import { createMemo, onMount } from 'solid-js';
import { useNavigate } from '@solidjs/router';
import AdminShell from '~/components/AdminShell';
function toTitle(value: string): string {
@ -10,12 +11,15 @@ function toTitle(value: string): string {
.join(' ');
}
const LEGACY_ADMIN_ORIGIN = 'http://localhost:3002';
const LEGACY_ADMIN_ORIGIN = import.meta.env.VITE_LEGACY_ADMIN_ORIGIN || 'http://localhost:3001';
function resolveLegacyPath(modulePath: string): string {
switch (modulePath) {
case 'roles':
return '/roles?scope=internal';
case 'approval-management':
case 'approvals':
return '/approval';
case 'onboarding-management':
return '/onboarding-management';
case 'internal-dashboard-management':
@ -31,11 +35,16 @@ function resolveLegacyPath(modulePath: string): string {
export default function LegacyModuleShellPage() {
const params = useParams();
const navigate = useNavigate();
const modulePath = String((params as any).module || '').trim();
const moduleName = createMemo(() => toTitle(modulePath || 'Management'));
const legacyPath = createMemo(() => resolveLegacyPath(modulePath));
const legacyUrl = createMemo(() => `${LEGACY_ADMIN_ORIGIN}${legacyPath()}`);
onMount(() => {
if (modulePath === 'approval') navigate('/admin/approval', { replace: true });
});
return (
<AdminShell>
<h1 class="page-title">{moduleName()}</h1>

View file

@ -0,0 +1,15 @@
import { onMount } from 'solid-js';
import { useNavigate } from '@solidjs/router';
import AdminShell from '~/components/AdminShell';
export default function ApprovalManagementAliasPage() {
const navigate = useNavigate();
onMount(() => navigate('/admin/approval', { replace: true }));
return (
<AdminShell>
<div class="card">
<p class="notice">Redirecting to Approval Management...</p>
</div>
</AdminShell>
);
}

View file

@ -27,6 +27,9 @@ interface Approval {
_parsedReason?: ParsedReason | null;
_category?: RequestCategory;
_typeLabel?: string;
_entityKind?: 'JOB' | 'REQUIREMENT' | 'GENERIC';
_supportsSubmissionView?: boolean;
_viewHref?: string;
}
interface ApprovalRule {
@ -41,6 +44,14 @@ interface ApprovalRule {
priority?: number;
}
interface ApprovalsSnapshot {
jobs: number;
requirements: number;
profilePending: number;
totalPending: number;
backendMode: 'LEGACY' | 'RUST' | 'UNKNOWN';
}
type RoleType = 'COMPANY' | 'CANDIDATE' | 'CUSTOMER' | 'PHOTOGRAPHER' | 'MAKEUP_ARTIST' | 'TUTOR' | 'DEVELOPER' | 'VIDEO_EDITOR' | 'GRAPHIC_DESIGNER' | 'SOCIAL_MEDIA_MANAGER' | 'FITNESS_TRAINER' | 'CATERING_SERVICE' | 'ADMIN' | 'UNKNOWN';
type RequestCategory = 'PROFILE' | 'JOB' | 'REQUIREMENT' | 'DOCUMENT_ONLY' | 'OTHER';
@ -95,6 +106,25 @@ function parseReason(raw?: string): ParsedReason | null {
}
}
const DOC_REQUEST_PREFIX = 'DOCS_REQUESTED::';
function encodeDocsRequestedReason(remark: string, docs: string[]) {
return `${DOC_REQUEST_PREFIX}${JSON.stringify({ remark, docs })}`;
}
function decodeDocsRequestedReason(reason?: string): { remark: string; docs: string[] } | null {
if (!reason || !reason.startsWith(DOC_REQUEST_PREFIX)) return null;
try {
const payload = JSON.parse(reason.slice(DOC_REQUEST_PREFIX.length));
return {
remark: String(payload?.remark || ''),
docs: Array.isArray(payload?.docs) ? payload.docs.map((d: unknown) => String(d)).filter(Boolean) : [],
};
} catch {
return null;
}
}
function inferRoleType(requesterRoleName?: string, profession?: string): RoleType {
const raw = [requesterRoleName, profession].filter(Boolean).join(' ').toLowerCase();
if (!raw) return 'UNKNOWN';
@ -156,7 +186,7 @@ function managementDestination(roleType: RoleType): { label: string; href: strin
}
function enrichApproval(a: Approval): Approval {
const parsed = parseReason(a.requestReason);
const parsed = a._parsedReason ?? parseReason(a.requestReason);
const roleType = inferRoleType(
a.requester?.name || a.requesterName || a.requester_name,
parsed?.profession
@ -168,6 +198,8 @@ function enrichApproval(a: Approval): Approval {
_roleType: roleType,
_category: category,
_typeLabel: resolveLabel(category, parsed?.profession),
_entityKind: a._entityKind || 'GENERIC',
_supportsSubmissionView: a._supportsSubmissionView ?? category === 'PROFILE',
};
}
@ -183,6 +215,14 @@ function requesterEmail(item: Approval) {
return item.requester?.email || item.requesterEmail || item.requester_email || '';
}
function latestDocumentRequest(item: Approval): AdminRemark | null {
const remarks = item._parsedReason?.adminRemarks || [];
for (let i = remarks.length - 1; i >= 0; i -= 1) {
if (remarks[i]?.type === 'MORE_DOCUMENTS_REQUESTED') return remarks[i];
}
return null;
}
// ────────────────────────────── Role type badge colors ──────────────────────────────
const ROLE_COLORS: Record<RoleType, string> = {
@ -224,17 +264,151 @@ function StatusBadge(props: { status: string; isDocRequest?: boolean }) {
// ────────────────────────────── API calls ──────────────────────────────
async function fetchApprovals(): Promise<Approval[]> {
if (typeof window === 'undefined') return [];
try {
const res = await fetch(`${API}/api/admin/approvals`);
if (!res.ok) return [];
if (!res.ok) {
const payload = await res.json().catch(() => ({}));
throw new Error(String(payload?.error || payload?.message || `Failed to load approvals (${res.status})`));
}
const data = await res.json();
const raw: Approval[] = Array.isArray(data) ? data : (data.approvals || []);
return raw.map(enrichApproval);
} catch {
return [];
const directRows: Approval[] = Array.isArray(data) ? data : (data.approvals || []);
if (directRows.length > 0) {
return directRows.map(enrichApproval);
}
// Rust backend shape: { jobs: [], requirements: [], profiles_summary: {...} }
const jobs = Array.isArray(data?.jobs) ? data.jobs : [];
const requirements = Array.isArray(data?.requirements) ? data.requirements : [];
const normalizeStatus = (value?: string) => {
const raw = String(value || '').toUpperCase();
if (raw === 'PENDING_APPROVAL' || raw === 'PENDING') return 'PENDING';
if (raw === 'LIVE' || raw === 'OPEN' || raw === 'APPROVED') return 'APPROVED';
if (raw === 'REJECTED') return 'REJECTED';
if (raw === 'CANCELLED') return 'CANCELLED';
return 'PENDING';
};
const fromJobs: Approval[] = jobs.map((job: any) => enrichApproval({
...(decodeDocsRequestedReason(job.rejection_reason)
? {
_parsedReason: {
adminRemarks: [{
type: 'MORE_DOCUMENTS_REQUESTED' as const,
comment: decodeDocsRequestedReason(job.rejection_reason)?.remark || 'Additional documents requested',
fields: decodeDocsRequestedReason(job.rejection_reason)?.docs || [],
}],
},
}
: {}),
id: String(job.id || ''),
requestType: 'JOB',
requestStatus: decodeDocsRequestedReason(job.rejection_reason) ? 'CHANGES_REQUESTED' : normalizeStatus(job.status),
status: decodeDocsRequestedReason(job.rejection_reason) ? 'CHANGES_REQUESTED' : normalizeStatus(job.status),
created_at: job.created_at,
requesterId: job.company_id ? String(job.company_id) : undefined,
requesterName: job.company_name || 'Company',
requesterEmail: job.company_email || '',
requestReason: JSON.stringify({
approvalType: 'JOB',
values: {
title: job.title || '',
description: job.description || '',
location: job.location || '',
job_type: job.job_type || '',
salary_min: job.salary_min ?? null,
salary_max: job.salary_max ?? null,
experience_years: job.experience_years ?? null,
skills: Array.isArray(job.skills) ? job.skills : [],
},
}),
_entityKind: 'JOB',
_supportsSubmissionView: false,
_viewHref: `/admin/jobs/${encodeURIComponent(String(job.id || ''))}`,
}));
const fromRequirements: Approval[] = requirements.map((req: any) => enrichApproval({
...(decodeDocsRequestedReason(req.rejection_reason)
? {
_parsedReason: {
adminRemarks: [{
type: 'MORE_DOCUMENTS_REQUESTED' as const,
comment: decodeDocsRequestedReason(req.rejection_reason)?.remark || 'Additional documents requested',
fields: decodeDocsRequestedReason(req.rejection_reason)?.docs || [],
}],
},
}
: {}),
id: String(req.id || ''),
requestType: 'REQUIREMENT',
requestStatus: decodeDocsRequestedReason(req.rejection_reason) ? 'CHANGES_REQUESTED' : normalizeStatus(req.status),
status: decodeDocsRequestedReason(req.rejection_reason) ? 'CHANGES_REQUESTED' : normalizeStatus(req.status),
created_at: req.created_at,
requesterId: req.customer_id ? String(req.customer_id) : undefined,
requesterName: req.customer_name || 'Customer',
requesterEmail: req.customer_email || '',
requestReason: JSON.stringify({
approvalType: 'REQUIREMENT',
profession: req.profession_key || '',
values: {
title: req.title || '',
description: req.description || '',
location: req.location || '',
budget: req.budget ?? null,
preferred_date: req.preferred_date || '',
profession_key: req.profession_key || '',
request_count: req.request_count ?? 0,
accepted_count: req.accepted_count ?? 0,
},
}),
_entityKind: 'REQUIREMENT',
_supportsSubmissionView: false,
_viewHref: `/admin/requirements/${encodeURIComponent(String(req.id || ''))}`,
}));
return [...fromJobs, ...fromRequirements];
} catch (error: any) {
throw new Error(String(error?.message || 'Failed to load approvals'));
}
}
async function fetchApprovalsSnapshot(): Promise<ApprovalsSnapshot> {
if (typeof window === 'undefined') {
return { jobs: 0, requirements: 0, profilePending: 0, totalPending: 0, backendMode: 'UNKNOWN' };
}
const res = await fetch(`${API}/api/admin/approvals`);
if (!res.ok) {
return { jobs: 0, requirements: 0, profilePending: 0, totalPending: 0, backendMode: 'UNKNOWN' };
}
const data = await res.json().catch(() => ({}));
const directRows: Approval[] = Array.isArray(data) ? data : (data.approvals || []);
if (directRows.length > 0) {
const totalPending = directRows.filter((item) => statusValue(item) === 'PENDING').length;
return {
jobs: 0,
requirements: 0,
profilePending: totalPending,
totalPending,
backendMode: 'LEGACY',
};
}
const jobs = Array.isArray(data?.jobs) ? data.jobs.length : 0;
const requirements = Array.isArray(data?.requirements) ? data.requirements.length : 0;
const profileSummary = (data?.profiles_summary && typeof data.profiles_summary === 'object')
? Object.values(data.profiles_summary).reduce((acc: number, value: unknown) => acc + (Number(value) || 0), 0)
: 0;
return {
jobs,
requirements,
profilePending: profileSummary,
totalPending: jobs + requirements + profileSummary,
backendMode: 'RUST',
};
}
async function fetchRules(): Promise<ApprovalRule[]> {
try {
const res = await fetch(`${API}/api/admin/approval-rules`);
@ -254,10 +428,12 @@ export default function ApprovalPage() {
const [selectedApproval, setSelectedApproval] = createSignal<Approval | null>(null);
const [search, setSearch] = createSignal('');
const [requestFilter, setRequestFilter] = createSignal('ALL');
const [docRequestedOnly, setDocRequestedOnly] = createSignal(false);
const [currentPage, setCurrentPage] = createSignal(1);
const perPage = 10;
const [approvals, { refetch: refetchApprovals }] = createResource(fetchApprovals);
const [snapshot, { refetch: refetchSnapshot }] = createResource(fetchApprovalsSnapshot);
const [acting, setActing] = createSignal('');
const [actionError, setActionError] = createSignal('');
@ -271,6 +447,7 @@ export default function ApprovalPage() {
const [ruleError, setRuleError] = createSignal('');
const [deletingRule, setDeletingRule] = createSignal('');
const [submittingRule, setSubmittingRule] = createSignal(false);
const approvalById = (id: string) => (approvals() ?? []).find((item) => item.id === id) || null;
const filteredApprovals = createMemo(() => {
const tab = activeTab();
@ -282,6 +459,7 @@ export default function ApprovalPage() {
return list.filter((a) => {
if (statusValue(a) !== tab) return false;
if (rf !== 'ALL' && (a._category || 'OTHER') !== rf) return false;
if (docRequestedOnly() && !latestDocumentRequest(a)) return false;
if (!q) return true;
const n = requesterName(a).toLowerCase();
const e = requesterEmail(a).toLowerCase();
@ -301,20 +479,34 @@ export default function ApprovalPage() {
return (approvals() ?? []).filter((a) => statusValue(a) === key).length;
};
const summary = createMemo(() => snapshot() || {
jobs: 0,
requirements: 0,
profilePending: 0,
totalPending: 0,
backendMode: 'UNKNOWN' as const,
});
// ── Approval Actions ──
const handleApprove = async (id: string) => {
if (!confirm('Approve this request?')) return;
const item = approvalById(id);
try {
setActing(`${id}-APPROVE`);
setActionError('');
const res = await fetch(`${API}/api/admin/approvals/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: 'APPROVED' }),
});
const endpoint = item?._entityKind === 'JOB'
? `${API}/api/admin/approvals/jobs/${id}/approve`
: item?._entityKind === 'REQUIREMENT'
? `${API}/api/admin/approvals/requirements/${id}/approve`
: `${API}/api/admin/approvals/${id}`;
const init = item?._entityKind === 'GENERIC'
? { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ status: 'APPROVED' }) }
: { method: 'POST' };
const res = await fetch(endpoint, init);
if (!res.ok) throw new Error('Failed to approve');
refetchApprovals();
refetchSnapshot();
if (selectedApproval()?.id === id) {
setSelectedApproval((prev) => prev ? { ...prev, requestStatus: 'APPROVED', status: 'APPROVED' } : prev);
}
@ -328,16 +520,22 @@ export default function ApprovalPage() {
const handleReject = async (id: string) => {
const reason = prompt('Rejection reason (required):');
if (!reason?.trim()) return;
const item = approvalById(id);
try {
setActing(`${id}-REJECT`);
setActionError('');
const res = await fetch(`${API}/api/admin/approvals/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: 'REJECTED', reason: reason.trim() }),
});
const endpoint = item?._entityKind === 'JOB'
? `${API}/api/admin/approvals/jobs/${id}/reject`
: item?._entityKind === 'REQUIREMENT'
? `${API}/api/admin/approvals/requirements/${id}/reject`
: `${API}/api/admin/approvals/${id}`;
const init = item?._entityKind === 'GENERIC'
? { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ status: 'REJECTED', reason: reason.trim() }) }
: { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ reason: reason.trim() }) };
const res = await fetch(endpoint, init);
if (!res.ok) throw new Error('Failed to reject');
refetchApprovals();
refetchSnapshot();
if (selectedApproval()?.id === id) {
setSelectedApproval((prev) => prev ? { ...prev, requestStatus: 'REJECTED', status: 'REJECTED' } : prev);
}
@ -349,6 +547,11 @@ export default function ApprovalPage() {
};
const handleRequestChanges = async (id: string) => {
const item = approvalById(id);
if (item?._entityKind === 'JOB' || item?._entityKind === 'REQUIREMENT') {
setActionError('Request changes is not available for job/requirement approvals in the current backend. Use Approve or Reject.');
return;
}
const remark = prompt('Describe what needs to be corrected:');
if (!remark?.trim()) return;
const fieldsRaw = prompt('Optional: comma-separated field keys to fix (e.g. pan,govt_id,resume) — press Enter to skip');
@ -367,6 +570,7 @@ export default function ApprovalPage() {
});
if (!res.ok) throw new Error('Failed to request changes');
refetchApprovals();
refetchSnapshot();
if (selectedApproval()?.id === id) {
setSelectedApproval((prev) => prev ? { ...prev, requestStatus: 'CHANGES_REQUESTED', status: 'CHANGES_REQUESTED' } : prev);
}
@ -378,6 +582,7 @@ export default function ApprovalPage() {
};
const handleRequestMoreDocuments = async (id: string) => {
const item = approvalById(id);
const remark = prompt('Describe which extra documents are required:');
if (!remark?.trim()) return;
const docsRaw = prompt('Document keys needed (comma-separated, e.g. govt_id_upload,resume,portfolio) — press Enter to skip');
@ -385,18 +590,31 @@ export default function ApprovalPage() {
try {
setActing(`${id}-DOCS`);
setActionError('');
const res = await fetch(`${API}/api/admin/approvals/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
status: 'CHANGES_REQUESTED',
remark: remark.trim(),
requestedDocuments: docs,
remarkType: 'MORE_DOCUMENTS_REQUESTED',
}),
});
const res = item?._entityKind === 'JOB'
? await fetch(`${API}/api/admin/approvals/jobs/${id}/reject`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ reason: encodeDocsRequestedReason(remark.trim(), docs) }),
})
: item?._entityKind === 'REQUIREMENT'
? await fetch(`${API}/api/admin/approvals/requirements/${id}/reject`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ reason: encodeDocsRequestedReason(remark.trim(), docs) }),
})
: await fetch(`${API}/api/admin/approvals/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
status: 'CHANGES_REQUESTED',
remark: remark.trim(),
requestedDocuments: docs,
remarkType: 'MORE_DOCUMENTS_REQUESTED',
}),
});
if (!res.ok) throw new Error('Failed to request documents');
refetchApprovals();
refetchSnapshot();
if (selectedApproval()?.id === id) {
setSelectedApproval((prev) => prev ? { ...prev, requestStatus: 'CHANGES_REQUESTED', status: 'CHANGES_REQUESTED' } : prev);
}
@ -474,13 +692,14 @@ export default function ApprovalPage() {
<button
type="button"
class={`admin-tab${activeTab() === t.key ? ' active' : ''}`}
onClick={() => {
setActiveTab(t.key);
setShowDetail(false);
setCurrentPage(1);
}}
style="white-space:nowrap;display:flex;align-items:center;gap:6px"
>
onClick={() => {
setActiveTab(t.key);
setShowDetail(false);
setCurrentPage(1);
setDocRequestedOnly(false);
}}
style="white-space:nowrap;display:flex;align-items:center;gap:6px"
>
{t.label}
<Show when={!approvals.loading && count > 0}>
<span style={`display:inline-flex;align-items:center;justify-content:center;min-width:18px;height:18px;border-radius:999px;font-size:10px;font-weight:700;padding:0 5px;background:${activeTab() === t.key ? t.color : '#e2e8f0'};color:${activeTab() === t.key ? '#fff' : '#64748b'}`}>
@ -496,6 +715,33 @@ export default function ApprovalPage() {
<Show when={actionError()}>
<div class="error-box" style="margin-bottom:12px">{actionError()}</div>
</Show>
<Show when={approvals.error}>
<div class="error-box" style="margin-bottom:12px">{String((approvals.error as any)?.message || approvals.error)}</div>
</Show>
<Show when={!approvals.loading && !snapshot.loading}>
<div class="card" style="margin-bottom:12px;background:#f8fafc;border-color:#e2e8f0">
<div style="display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap">
<div style="display:flex;gap:8px;flex-wrap:wrap">
<span class="status-chip" style="background:#e0f2fe;color:#075985;border-color:#bae6fd">Pending Jobs: {summary().jobs}</span>
<span class="status-chip" style="background:#ecfeff;color:#155e75;border-color:#a5f3fc">Pending Requirements: {summary().requirements}</span>
<span class="status-chip" style="background:#eef2ff;color:#3730a3;border-color:#c7d2fe">Pending Profiles: {summary().profilePending}</span>
<span class="status-chip" style="background:#fef3c7;color:#92400e;border-color:#fde68a">Total Pending: {summary().totalPending}</span>
</div>
<button class="btn" type="button" onClick={() => { refetchApprovals(); refetchSnapshot(); }}>Refresh</button>
</div>
<Show when={summary().totalPending === 0}>
<p style="margin:10px 0 0;color:#64748b;font-size:13px">
No pending approval requests are available right now. New submissions will appear here automatically.
</p>
</Show>
<Show when={summary().backendMode === 'RUST' && activeTab() !== 'PENDING'}>
<p style="margin:8px 0 0;color:#64748b;font-size:12px">
Current backend feed returns pending requests only for Approval Management.
</p>
</Show>
</div>
</Show>
{/* ── Approval List / Detail ── */}
<Show when={activeTab() !== 'rules'}>
@ -516,6 +762,14 @@ export default function ApprovalPage() {
>
<For each={REQUEST_FILTERS}>{(r) => <option value={r}>{r}</option>}</For>
</select>
<label style="display:inline-flex;align-items:center;gap:6px;font-size:13px;color:#334155;border:1px solid #cbd5e1;border-radius:8px;padding:7px 10px;background:#fff">
<input
type="checkbox"
checked={docRequestedOnly()}
onChange={(e) => { setDocRequestedOnly(e.currentTarget.checked); setCurrentPage(1); }}
/>
Docs Requested Only
</label>
<span style="font-size:12px;color:#64748b;margin-left:auto">{filteredApprovals().length} record{filteredApprovals().length !== 1 ? 's' : ''}</span>
</div>
@ -523,28 +777,29 @@ export default function ApprovalPage() {
<div class="table-wrap">
<table class="list-table">
<thead>
<tr>
<th>Requester</th>
<th>Type</th>
<th>Request Category</th>
<th>Status</th>
<th>Submitted</th>
<th class="align-right">Actions</th>
</tr>
<tr>
<th>Requester</th>
<th>Type</th>
<th>Request Category</th>
<th>Document Request</th>
<th>Status</th>
<th>Submitted</th>
<th class="align-right">Actions</th>
</tr>
</thead>
<tbody>
<Show when={approvals.loading}>
<tr><td colspan="6" style="text-align:center;padding:32px;color:#64748b">Loading approvals...</td></tr>
<tr><td colspan="7" style="text-align:center;padding:32px;color:#64748b">Loading approvals...</td></tr>
</Show>
<Show when={!approvals.loading && paginatedApprovals().length === 0}>
<tr><td colspan="6" style="text-align:center;padding:32px;color:#94a3b8">No {activeTab() === 'PENDING' ? 'pending' : activeTab().toLowerCase().replace('_', ' ')} approvals.</td></tr>
<tr><td colspan="7" style="text-align:center;padding:32px;color:#94a3b8">{activeTab() === 'PENDING' ? 'No pending approvals right now.' : `No ${activeTab().toLowerCase().replace('_', ' ')} approvals available in this feed.`}</td></tr>
</Show>
<Show when={!approvals.loading && paginatedApprovals().length > 0}>
<For each={paginatedApprovals()}>
{(item) => {
const status = statusValue(item);
const lastRemark = item._parsedReason?.adminRemarks?.at(-1);
const isDocRequest = lastRemark?.type === 'MORE_DOCUMENTS_REQUESTED' && status === 'CHANGES_REQUESTED';
const docRemark = latestDocumentRequest(item);
const isDocRequest = !!docRemark && status === 'CHANGES_REQUESTED';
const dest = managementDestination(item._roleType || 'UNKNOWN');
const isActing = acting().startsWith(item.id);
return (
@ -562,6 +817,19 @@ export default function ApprovalPage() {
<div style="font-size:11px;color:#94a3b8">{item._parsedReason?.templateId}</div>
</Show>
</td>
<td>
<Show when={docRemark} fallback={<span style="font-size:12px;color:#94a3b8"></span>}>
<div style="display:flex;flex-direction:column;gap:4px;max-width:280px">
<span style="display:inline-flex;align-self:flex-start;font-size:10px;font-weight:700;color:#1d4ed8;background:#eff6ff;border:1px solid #bfdbfe;border-radius:999px;padding:2px 8px">
Docs Requested
</span>
<span style="font-size:12px;color:#0f172a;line-height:1.3">{docRemark!.comment}</span>
<Show when={(docRemark!.fields || []).length > 0}>
<span style="font-size:11px;color:#475569">Needed: {(docRemark!.fields || []).join(', ')}</span>
</Show>
</div>
</Show>
</td>
<td>
<StatusBadge status={status} isDocRequest={isDocRequest} />
</td>
@ -572,14 +840,24 @@ export default function ApprovalPage() {
<div class="table-actions">
{/* View detail */}
<button
class="action-icon-btn"
class="btn"
type="button"
title="View Detail"
title="View Request"
style="font-size:12px;padding:4px 10px"
onClick={() => { setSelectedApproval(item); setShowDetail(true); }}
>
👁
View
</button>
<A class="action-icon-btn" href={`/admin/approval/${item.id}`} title="Open full page"></A>
<Show when={item._viewHref}>
<A class="btn" href={item._viewHref!} style="font-size:12px;padding:4px 10px" title="Open full request page">
Full Request
</A>
</Show>
<Show when={item._supportsSubmissionView}>
<A class="btn" href={`/admin/approval/${item.id}`} style="font-size:12px;padding:4px 10px" title="Open full profile review">
Profile Review
</A>
</Show>
<Show when={status === 'PENDING'}>
{/* Request More Documents */}
@ -626,6 +904,18 @@ export default function ApprovalPage() {
</button>
</Show>
<Show when={status === 'CHANGES_REQUESTED'}>
<button
class="btn"
type="button"
title="Update requested documents"
style="font-size:12px;padding:4px 10px;background:#eff6ff;color:#1d4ed8;border-color:#bfdbfe"
disabled={isActing}
onClick={() => handleRequestMoreDocuments(item.id)}
>
Update Docs Request
</button>
</Show>
{/* Approved → link to management page */}
<Show when={status === 'APPROVED'}>
@ -789,6 +1079,7 @@ function ApprovalDetailPanel(props: {
const isActing = () => !!props.acting;
const dest = () => managementDestination(a()?._roleType || 'UNKNOWN');
const remarks = () => a()?._parsedReason?.adminRemarks || [];
const docRemark = createMemo(() => latestDocumentRequest(a()!));
return (
<Show when={a()} fallback={
@ -843,7 +1134,12 @@ function ApprovalDetailPanel(props: {
Open {dest().label}
</A>
</Show>
<A href={`/admin/approval/${a()!.id}`} class="btn" style="font-size:13px"> Full Page</A>
<Show when={a()!._viewHref}>
<A href={a()!._viewHref!} class="btn" style="font-size:13px">Open Full Request</A>
</Show>
<Show when={a()!._supportsSubmissionView}>
<A href={`/admin/approval/${a()!.id}`} class="btn" style="font-size:13px">Open Profile Review</A>
</Show>
</div>
</div>
</div>
@ -863,6 +1159,19 @@ function ApprovalDetailPanel(props: {
</div>
</Show>
{/* Document request spotlight (USP) */}
<Show when={docRemark()}>
<div class="card" style="margin-bottom:16px;border:1px solid #bfdbfe;background:#eff6ff">
<h3 style="margin:0 0 10px;font-size:15px;font-weight:700;color:#1d4ed8">Requested Documents</h3>
<p style="margin:0;font-size:13px;color:#0f172a">{docRemark()!.comment}</p>
<Show when={(docRemark()!.fields || []).length > 0}>
<p style="margin:8px 0 0;font-size:12px;color:#334155">
Required: {(docRemark()!.fields || []).join(', ')}
</p>
</Show>
</div>
</Show>
{/* Admin remarks history */}
<Show when={remarks().length > 0}>
<div class="card">

View file

@ -0,0 +1,15 @@
import { onMount } from 'solid-js';
import { useNavigate } from '@solidjs/router';
import AdminShell from '~/components/AdminShell';
export default function ApprovalsAliasPage() {
const navigate = useNavigate();
onMount(() => navigate('/admin/approval', { replace: true }));
return (
<AdminShell>
<div class="card">
<p class="notice">Redirecting to Approval Management...</p>
</div>
</AdminShell>
);
}

View file

@ -1,20 +1,98 @@
import { useSearchParams } from '@solidjs/router';
import { createMemo, createSignal, For, onMount, Show } from 'solid-js';
import AdminShell from '~/components/AdminShell';
const API = '/api/gateway';
const FRONTEND_PREVIEW_BASE = String(import.meta.env.VITE_FRONTEND_PREVIEW_URL || 'http://localhost:3001').replace(/\/+$/, '');
function normalizePreviewRoleKey(value: string): string {
return String(value || '')
.trim()
.toUpperCase()
.replace(/[-\s]+/g, '_');
}
// ---------- Types ----------
type SidebarItem = { key: string; label: string; moduleKey: string; visible: boolean; order: number };
type Module = { key: string; title: string; kind: 'list' | 'detail' | 'form'; summary: string; visible: boolean };
type Dashboard = { id: string; roleKey: string; title: string; description?: string; defaultRoute: string; status: string; version: number; sidebar: SidebarItem[]; modules: Module[] };
type Dashboard = { id: string; roleId: string; roleKey: string; title: string; description?: string; defaultRoute: string; status: string; version: number; sidebar: SidebarItem[]; modules: Module[] };
type ExternalRole = { id: string; key: string; name: string };
const makeId = (prefix: string) => `${prefix}_${Math.random().toString(36).slice(2, 10)}`;
function toTitleFromKey(roleKey: string): string {
const label = String(roleKey || '')
.trim()
.toLowerCase()
.replace(/[_-]+/g, ' ');
return label
.split(' ')
.filter(Boolean)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(' ');
}
function normalizeDashboardFromConfig(base: Dashboard, configJson: any): Dashboard {
const modulesRaw = Array.isArray(configJson?.modules) ? configJson.modules : [];
const sidebarRaw = Array.isArray(configJson?.sidebar) ? configJson.sidebar : [];
const legacyNav = Array.isArray(configJson?.nav) ? configJson.nav : [];
const legacyEnabledModules = Array.isArray(configJson?.enabled_modules) ? configJson.enabled_modules : [];
const modules: Module[] = modulesRaw.length > 0
? modulesRaw.map((module: any, index: number) => ({
key: String(module?.key || `module_${index + 1}`),
title: String(module?.title || module?.label || `Module ${index + 1}`),
kind: module?.kind === 'detail' || module?.kind === 'form' ? module.kind : 'list',
summary: String(module?.summary || ''),
visible: module?.visible !== false,
}))
: (() => {
const keys = legacyEnabledModules.length > 0
? legacyEnabledModules.map((key: any) => String(key || '').trim()).filter(Boolean)
: legacyNav.map((item: any) => String(item?.key || '').trim()).filter(Boolean);
return keys.map((key) => {
const navItem = legacyNav.find((item: any) => String(item?.key || '') === key);
return {
key,
title: String(navItem?.label || toTitleFromKey(key)),
kind: 'list' as const,
summary: '',
visible: true,
};
});
})();
const sidebar: SidebarItem[] = sidebarRaw.length > 0
? sidebarRaw.map((item: any, index: number) => ({
key: String(item?.key || makeId('sb')),
label: String(item?.label || `Menu ${index + 1}`),
moduleKey: String(item?.moduleKey || item?.module_key || item?.key || ''),
visible: item?.visible !== false,
order: Number(item?.order) || index + 1,
}))
: legacyNav.map((item: any, index: number) => ({
key: String(item?.key || makeId('sb')),
label: String(item?.label || `Menu ${index + 1}`),
moduleKey: String(item?.key || ''),
visible: true,
order: index + 1,
}));
return {
...base,
title: String(configJson?.title || base.title || `${toTitleFromKey(base.roleKey)} Dashboard`),
description: String(configJson?.description || ''),
defaultRoute: String(configJson?.defaultRoute || legacyNav[0]?.path || '/dashboard'),
sidebar,
modules,
};
}
// ---------- Preview ----------
function renderModuleContent(module: Module | null) {
if (!module) return (
<div style="border:1px dashed #cbd5e1;border-radius:12px;padding:32px;text-align:center;font-size:13px;color:#94a3b8">
Select a sidebar item to preview the module.
Select a menu item to preview this page.
</div>
);
if (module.kind === 'detail') return (
@ -94,7 +172,7 @@ function ExternalDashboardPreview(props: { dashboard: Dashboard }) {
const item = visible().find((i) => i.moduleKey === selectedKey());
return item ? moduleMap().get(item.moduleKey) || null : null;
});
const selectedLabel = createMemo(() => visible().find((i) => i.moduleKey === selectedKey())?.label || 'Module Preview');
const selectedLabel = createMemo(() => visible().find((i) => i.moduleKey === selectedKey())?.label || 'Page Preview');
return (
<div class="preview-shell">
@ -131,14 +209,38 @@ function ExternalDashboardPreview(props: { dashboard: Dashboard }) {
// ---------- Main Page ----------
export default function ExternalDashboardManagementPage() {
const [dashboards, setDashboards] = createSignal<Dashboard[]>([]);
const [roles, setRoles] = createSignal<ExternalRole[]>([]);
const [selectedId, setSelectedId] = createSignal('');
const [activeTab, setActiveTab] = createSignal<'overview' | 'sidebar' | 'modules' | 'preview'>('overview');
const [previewMode, setPreviewMode] = createSignal<'configured' | 'live'>('configured');
const [loading, setLoading] = createSignal(true);
const [saving, setSaving] = createSignal(false);
const [creating, setCreating] = createSignal(false);
const [error, setError] = createSignal('');
const [searchParams] = useSearchParams();
onMount(loadDashboards);
const requestedRoleKey = createMemo(() => String(searchParams.roleKey || '').trim().toUpperCase());
const requestedMode = createMemo(() => String(searchParams.mode || '').trim().toLowerCase());
async function loadRoles() {
try {
const res = await fetch(`${API}/api/admin/roles?audience=EXTERNAL`);
if (!res.ok) throw new Error('Failed to load roles');
const data = await res.json();
const rows = Array.isArray(data) ? data : (data.roles || []);
setRoles(
rows
.filter((item: any) => String(item?.audience || '').toUpperCase() === 'EXTERNAL')
.map((item: any) => ({
id: String(item.id || ''),
key: String(item.key || '').toUpperCase(),
name: String(item.name || item.key || 'External Role'),
})),
);
} catch {
setRoles([]);
}
}
async function loadDashboards() {
try {
@ -147,26 +249,82 @@ export default function ExternalDashboardManagementPage() {
const res = await fetch(`${API}/api/admin/dashboard-config?audience=EXTERNAL`);
if (!res.ok) throw new Error('Failed to load external dashboards');
const data = await res.json();
const rows = (Array.isArray(data) ? data : (data.dashboards || [])).map((item: any) => ({
id: item.id,
roleKey: item.config_json?.roleKey || item.role_key || '',
title: item.config_json?.title || 'Untitled Dashboard',
description: item.config_json?.description || '',
defaultRoute: item.config_json?.defaultRoute || '/workspace',
status: item.is_active ? 'published' : 'draft',
version: item.config_json?.version || 1,
sidebar: item.config_json?.sidebar || [],
modules: item.config_json?.modules || [],
}));
const rows = (Array.isArray(data) ? data : (data.dashboards || []))
.filter((item: any) => String(item?.audience || '').toUpperCase() === 'EXTERNAL')
.map((item: any) => ({
id: String(item.id || ''),
roleId: String(item.role_id || ''),
roleKey: String(item.role_key || ''),
title: `${toTitleFromKey(String(item.role_key || 'Role'))} Dashboard`,
description: '',
defaultRoute: '/dashboard',
status: item.is_active ? 'published' : 'draft',
version: Number(item.version) || 1,
sidebar: [],
modules: [],
}));
setDashboards(rows);
return rows;
} catch (err: any) {
setError(err.message || 'Failed to load dashboards');
return [];
} finally {
setLoading(false);
}
}
async function hydrateDashboard(configId: string) {
const base = dashboards().find((item) => item.id === configId);
if (!base || !base.roleId) return;
try {
const res = await fetch(`${API}/api/admin/dashboard-config/${base.roleId}?audience=EXTERNAL`);
if (!res.ok) return;
const detail = await res.json();
const next = normalizeDashboardFromConfig(
{
...base,
status: detail?.is_active ? 'published' : base.status,
version: Number(detail?.version) || base.version,
},
detail?.config_json || {},
);
setDashboards((prev) => prev.map((item) => (item.id === configId ? next : item)));
} catch {
// Keep summary row if detail request fails.
}
}
async function openDashboard(configId: string) {
setSelectedId(configId);
setActiveTab('overview');
await hydrateDashboard(configId);
}
onMount(async () => {
await loadRoles();
const rows = await loadDashboards();
const roleKey = requestedRoleKey();
if (roleKey) {
const match = rows.find((item) => String(item.roleKey || '').toUpperCase() === roleKey);
if (match) {
await openDashboard(match.id);
return;
}
}
if (requestedMode() === 'create') {
await createDashboard();
}
});
const selected = () => dashboards().find((d) => d.id === selectedId()) || null;
const livePreviewRoleKey = createMemo(() => normalizePreviewRoleKey(selected()?.roleKey || ''));
const livePreviewUrl = createMemo(() => {
const key = livePreviewRoleKey();
if (!key) return '';
return `${FRONTEND_PREVIEW_BASE}/dashboard?_preview=${encodeURIComponent(key)}`;
});
const update = (patch: Partial<Dashboard>) =>
setDashboards((prev) => prev.map((d) => d.id === selectedId() ? { ...d, ...patch } : d));
@ -197,19 +355,46 @@ export default function ExternalDashboardManagementPage() {
setCreating(true);
setError('');
let newId = makeId('local');
const selectedRole = roles().find((r) => r.key === requestedRoleKey()) || roles()[0];
try {
const res = await fetch(`${API}/api/admin/dashboard-config`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ audience: 'EXTERNAL', config_json: { roleKey: '', title: 'New External Dashboard', defaultRoute: '/workspace', version: 1, sidebar: [], modules: [] } }),
});
if (res.ok) {
const data = await res.json();
newId = data.id;
if (selectedRole?.id) {
const res = await fetch(`${API}/api/admin/dashboard-config`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
role_id: selectedRole.id,
audience: 'EXTERNAL',
config_json: {
roleKey: selectedRole.key,
title: `${selectedRole.name} Dashboard`,
description: '',
defaultRoute: '/dashboard',
version: 1,
sidebar: [],
modules: [],
enabled_modules: [],
nav: [],
},
}),
});
if (res.ok) {
const data = await res.json();
newId = data.id;
}
}
// If POST fails, fall through to local draft mode silently
} catch { /* backend unavailable — use local draft */ }
const nd: Dashboard = { id: newId, roleKey: '', title: 'New External Dashboard', description: '', defaultRoute: '/workspace', status: 'draft', version: 1, sidebar: [], modules: [] };
const nd: Dashboard = {
id: newId,
roleId: selectedRole?.id || '',
roleKey: selectedRole?.key || requestedRoleKey() || '',
title: `${selectedRole?.name || 'New External'} Dashboard`,
description: '',
defaultRoute: '/dashboard',
status: 'draft',
version: 1,
sidebar: [],
modules: [],
};
setDashboards((prev) => [nd, ...prev]);
setSelectedId(newId);
setActiveTab('overview');
@ -221,15 +406,45 @@ export default function ExternalDashboardManagementPage() {
const saveSelected = async () => {
const d = selected();
if (!d) return;
if (!d.roleId) {
setError('Please select a role before saving.');
return;
}
try {
setSaving(true);
setError('');
const res = await fetch(`${API}/api/admin/dashboard-config/${d.id}`, {
method: 'PATCH',
const nav = d.sidebar
.filter((item) => item.visible)
.sort((a, b) => a.order - b.order)
.map((item) => ({
key: item.moduleKey || item.key,
label: item.label,
path: d.defaultRoute || '/dashboard',
}));
const enabledModules = d.modules.filter((m) => m.visible).map((m) => m.key);
const res = await fetch(`${API}/api/admin/dashboard-config`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ config_json: { roleKey: d.roleKey, title: d.title, description: d.description, defaultRoute: d.defaultRoute, version: d.version, sidebar: d.sidebar, modules: d.modules } }),
body: JSON.stringify({
role_id: d.roleId,
audience: 'EXTERNAL',
config_json: {
roleKey: d.roleKey,
title: d.title,
description: d.description,
defaultRoute: d.defaultRoute,
version: d.version,
sidebar: d.sidebar,
modules: d.modules,
enabled_modules: enabledModules,
nav,
},
}),
});
if (!res.ok) throw new Error('Failed to save dashboard');
const refreshed = await loadDashboards();
const match = refreshed.find((item) => item.roleId === d.roleId && item.roleKey === d.roleKey) || refreshed.find((item) => item.id === d.id);
if (match) await openDashboard(match.id);
} catch (err: any) {
setError(err.message || 'Failed to save dashboard');
} finally {
@ -242,7 +457,7 @@ export default function ExternalDashboardManagementPage() {
<div class="page-hero-card page-actions">
<div>
<h1 class="page-title">External Dashboard Management</h1>
<p class="page-subtitle">Use the tabs and open one external dashboard at a time from the list below.</p>
<p class="page-subtitle">Open one external dashboard at a time from the list below and edit it using simple tabs.</p>
</div>
<a class="btn" href="/admin">Back to Dashboard</a>
</div>
@ -289,14 +504,14 @@ export default function ExternalDashboardManagementPage() {
<For each={dashboards()}>
{(d) => (
<tr>
<td style="font-weight:600;color:#0f172a">{d.roleKey || 'No role key'}</td>
<td style="font-weight:600;color:#0f172a">{d.roleKey || 'No role selected'}</td>
<td style="color:#475569">{d.title}</td>
<td><span class={`status-chip ${d.status === 'published' ? 'active' : ''}`}>{d.status}</span></td>
<td style="color:#64748b">v{d.version}</td>
<td style="color:#64748b">{d.modules.length} modules</td>
<td style="color:#64748b">{d.modules.length} pages</td>
<td>
<div class="table-actions">
<button class="btn" onClick={() => { setSelectedId(d.id); setActiveTab('overview'); }}>View Builder</button>
<button class="btn" onClick={() => void openDashboard(d.id)}>View Builder</button>
</div>
</td>
</tr>
@ -313,7 +528,7 @@ export default function ExternalDashboardManagementPage() {
<div class="builder-header">
<div>
<h2>External Dashboard Builder</h2>
<p>Edit sidebar labels, visibility, module titles, and default route for this role dashboard.</p>
<p>Edit menu labels, page names, and opening page for this role dashboard.</p>
</div>
<div class="builder-header-actions">
<button class="btn" onClick={() => setSelectedId('')}>Back to List</button>
@ -329,7 +544,7 @@ export default function ExternalDashboardManagementPage() {
<div class="builder-tab-bar">
{(['overview', 'sidebar', 'modules', 'preview'] as const).map((t) => (
<button type="button" class={`builder-tab-btn ${activeTab() === t ? 'active' : ''}`} onClick={() => setActiveTab(t)}>
{t.charAt(0).toUpperCase() + t.slice(1)}
{t === 'modules' ? 'Pages' : t.charAt(0).toUpperCase() + t.slice(1)}
</button>
))}
</div>
@ -338,8 +553,24 @@ export default function ExternalDashboardManagementPage() {
<Show when={activeTab() === 'overview'}>
<div class="card">
<div class="field">
<label>Role Key</label>
<input value={selected()!.roleKey} onInput={(e) => update({ roleKey: e.currentTarget.value.toUpperCase().replace(/\s+/g, '_') })} placeholder="e.g. PHOTOGRAPHER" />
<label>Role</label>
<select
value={selected()!.roleId}
onChange={(e) => {
const role = roles().find((item) => item.id === e.currentTarget.value);
if (!role) return;
update({
roleId: role.id,
roleKey: role.key,
title: selected()!.title?.trim() ? selected()!.title : `${role.name} Dashboard`,
});
}}
>
<option value="">Select external role</option>
{roles().map((role) => (
<option value={role.id}>{role.name}</option>
))}
</select>
</div>
<div class="field">
<label>Dashboard Title</label>
@ -350,8 +581,8 @@ export default function ExternalDashboardManagementPage() {
<textarea rows={3} value={selected()!.description || ''} onInput={(e) => update({ description: e.currentTarget.value })} />
</div>
<div class="field">
<label>Default Route</label>
<input value={selected()!.defaultRoute} onInput={(e) => update({ defaultRoute: e.currentTarget.value })} placeholder="/workspace" />
<label>Opening Page</label>
<input value={selected()!.defaultRoute} onInput={(e) => update({ defaultRoute: e.currentTarget.value })} placeholder="Example: /workspace" />
</div>
</div>
</Show>
@ -360,8 +591,8 @@ export default function ExternalDashboardManagementPage() {
<Show when={activeTab() === 'sidebar'}>
<div class="builder-section">
<div class="sub-card-header">
<h4>Sidebar Items</h4>
<button class="btn orange" onClick={addSidebarItem}>Add Sidebar Item</button>
<h4>Menu Items</h4>
<button class="btn orange" onClick={addSidebarItem}>Add Menu Item</button>
</div>
<For each={selected()!.sidebar}>
{(item, idx) => (
@ -369,13 +600,13 @@ export default function ExternalDashboardManagementPage() {
<div>
<input
value={item.label}
placeholder="Sidebar label"
placeholder="Menu label"
onInput={(e) => update({ sidebar: selected()!.sidebar.map((i) => i.key === item.key ? { ...i, label: e.currentTarget.value } : i) })}
style="width:100%;margin-bottom:6px"
/>
<input
value={item.moduleKey}
placeholder="Module key"
placeholder="Linked page key"
onInput={(e) => update({ sidebar: selected()!.sidebar.map((i) => i.key === item.key ? { ...i, moduleKey: e.currentTarget.value } : i) })}
style="width:100%"
/>
@ -398,7 +629,7 @@ export default function ExternalDashboardManagementPage() {
)}
</For>
<Show when={selected()!.sidebar.length === 0}>
<p class="notice">No sidebar items yet. Add the first item above.</p>
<p class="notice">No menu items yet. Add your first menu item above.</p>
</Show>
</div>
</Show>
@ -407,14 +638,14 @@ export default function ExternalDashboardManagementPage() {
<Show when={activeTab() === 'modules'}>
<div class="builder-section">
<div class="sub-card-header">
<h4>Modules</h4>
<button class="btn orange" onClick={addModule}>Add Module</button>
<h4>Pages</h4>
<button class="btn orange" onClick={addModule}>Add Page</button>
</div>
<For each={selected()!.modules}>
{(module) => (
<div class="nested-card">
<div class="field">
<label>Module Key</label>
<label>Page Key</label>
<input
value={module.key}
onInput={(e) => {
@ -424,7 +655,7 @@ export default function ExternalDashboardManagementPage() {
sidebar: selected()!.sidebar.map((i) => i.moduleKey === module.key ? { ...i, moduleKey: newKey } : i),
});
}}
placeholder="module_key"
placeholder="page_key"
/>
</div>
<div class="field">
@ -432,11 +663,11 @@ export default function ExternalDashboardManagementPage() {
<input value={module.title} onInput={(e) => update({ modules: selected()!.modules.map((m) => m.key === module.key ? { ...m, title: e.currentTarget.value } : m) })} />
</div>
<div class="field">
<label>Kind</label>
<label>Page Type</label>
<select value={module.kind} onChange={(e) => update({ modules: selected()!.modules.map((m) => m.key === module.key ? { ...m, kind: e.currentTarget.value as Module['kind'] } : m) })}>
<option value="list">list</option>
<option value="detail">detail</option>
<option value="form">form</option>
<option value="list">List</option>
<option value="detail">Details</option>
<option value="form">Form</option>
</select>
</div>
<div class="field">
@ -444,21 +675,59 @@ export default function ExternalDashboardManagementPage() {
<textarea rows={2} value={module.summary} onInput={(e) => update({ modules: selected()!.modules.map((m) => m.key === module.key ? { ...m, summary: e.currentTarget.value } : m) })} />
</div>
<div style="display:flex;justify-content:flex-end">
<button class="btn danger" onClick={() => removeModule(module.key)}>Remove Module</button>
<button class="btn danger" onClick={() => removeModule(module.key)}>Remove Page</button>
</div>
</div>
)}
</For>
<Show when={selected()!.modules.length === 0}>
<p class="notice">No modules yet. Add the first module above.</p>
<p class="notice">No pages yet. Add your first page above.</p>
</Show>
</div>
</Show>
{/* Preview */}
<Show when={activeTab() === 'preview'}>
<p class="notice" style="margin-bottom:12px">Preview the actual external workspace layout navigate sidebar and modules before saving.</p>
<ExternalDashboardPreview dashboard={selected()!} />
<div style="display:flex;align-items:center;justify-content:space-between;gap:10px;flex-wrap:wrap;margin-bottom:12px">
<p class="notice" style="margin:0">Preview this dashboard as a sample view or open the real live UI.</p>
<div class="admin-segmented" style="margin:0">
<button
type="button"
class={`admin-segment ${previewMode() === 'configured' ? 'active' : ''}`}
onClick={() => setPreviewMode('configured')}
>
Sample Preview
</button>
<button
type="button"
class={`admin-segment ${previewMode() === 'live' ? 'active' : ''}`}
onClick={() => setPreviewMode('live')}
>
Live Preview
</button>
</div>
</div>
<Show when={previewMode() === 'configured'}>
<ExternalDashboardPreview dashboard={selected()!} />
</Show>
<Show when={previewMode() === 'live'}>
<Show
when={livePreviewUrl()}
fallback={<div class="notice" style="padding:10px 12px;border:1px solid #e2e8f0;border-radius:10px;background:#f8fafc">Choose a role (for example PHOTOGRAPHER) to open live preview.</div>}
>
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:10px">
<span class="meta-chip">Role: {livePreviewRoleKey()}</span>
<a class="btn" href={livePreviewUrl()} target="_blank" rel="noreferrer">Open Full Page Preview</a>
</div>
<iframe
src={livePreviewUrl()}
title="Live Role Dashboard Preview"
style="width:100%;height:760px;border:1px solid #e2e8f0;border-radius:14px;background:#fff"
/>
</Show>
</Show>
</Show>
</Show>
</AdminShell>

View file

@ -2,6 +2,7 @@ import { createMemo, createSignal, For, onMount, Show } from 'solid-js';
import AdminShell from '~/components/AdminShell';
const API = '/api/gateway';
const LEGACY_INTERNAL_PREVIEW_BASE = String(import.meta.env.VITE_LEGACY_ADMIN_PREVIEW_URL || 'http://localhost:3002').replace(/\/+$/, '');
// ---------- Types ----------
type SidebarItem = { key: string; label: string; visible: boolean; order: number };
@ -10,11 +11,68 @@ type Tab = { id: string; title: string; fields: Field[] };
type Widget = { id: string; title: string; metric: string; description?: string };
type Section = { id: string; title: string; tabs: Tab[]; widgets: Widget[] };
type Dashboard = { id: string; roleId: string; roleName: string; title: string; description?: string; status: string; version: number; sidebar: SidebarItem[]; sections: Section[] };
type InternalRole = { id: string; name: string };
type InternalRole = { id: string; key: string; name: string };
const FIELD_TYPES: Field['type'][] = ['text', 'number', 'select', 'date'];
const makeId = (prefix: string) => `${prefix}_${Math.random().toString(36).slice(2, 10)}`;
function normalizeInternalDashboardFromConfig(base: Dashboard, configJson: any): Dashboard {
const sidebarRaw = Array.isArray(configJson?.sidebar) ? configJson.sidebar : [];
const sectionsRaw = Array.isArray(configJson?.sections) ? configJson.sections : [];
const legacyNav = Array.isArray(configJson?.nav) ? configJson.nav : [];
const sidebar: SidebarItem[] = sidebarRaw.length > 0
? sidebarRaw.map((item: any, index: number) => ({
key: String(item?.key || makeId('sb')),
label: String(item?.label || `Menu ${index + 1}`),
visible: item?.visible !== false,
order: Number(item?.order) || index + 1,
}))
: legacyNav.map((item: any, index: number) => ({
key: String(item?.key || makeId('sb')),
label: String(item?.label || `Menu ${index + 1}`),
visible: true,
order: index + 1,
}));
const sections: Section[] = sectionsRaw.map((section: any, sectionIndex: number) => ({
id: String(section?.id || `section_${sectionIndex + 1}`),
title: String(section?.title || `Section ${sectionIndex + 1}`),
tabs: Array.isArray(section?.tabs)
? section.tabs.map((tab: any, tabIndex: number) => ({
id: String(tab?.id || `tab_${tabIndex + 1}`),
title: String(tab?.title || `Tab ${tabIndex + 1}`),
fields: Array.isArray(tab?.fields)
? tab.fields.map((field: any, fieldIndex: number) => ({
id: String(field?.id || `field_${fieldIndex + 1}`),
label: String(field?.label || `Field ${fieldIndex + 1}`),
type: field?.type === 'number' || field?.type === 'select' || field?.type === 'date' ? field.type : 'text',
required: Boolean(field?.required),
placeholder: String(field?.placeholder || ''),
}))
: [],
}))
: [],
widgets: Array.isArray(section?.widgets)
? section.widgets.map((widget: any, widgetIndex: number) => ({
id: String(widget?.id || `widget_${widgetIndex + 1}`),
title: String(widget?.title || `Widget ${widgetIndex + 1}`),
metric: String(widget?.metric || '0'),
description: String(widget?.description || ''),
}))
: [],
}));
return {
...base,
title: String(configJson?.title || base.title || 'Internal Dashboard'),
description: String(configJson?.description || ''),
roleName: String(configJson?.roleName || base.roleName || ''),
sidebar,
sections,
};
}
// ---------- Preview ----------
function PreviewSection(props: { section: Section }) {
const [activeTabId, setActiveTabId] = createSignal(props.section.tabs[0]?.id || '');
@ -120,6 +178,7 @@ export default function InternalDashboardManagementPage() {
const [roles, setRoles] = createSignal<InternalRole[]>([]);
const [selectedId, setSelectedId] = createSignal('');
const [activeTab, setActiveTab] = createSignal<'overview' | 'sidebar' | 'sections' | 'preview'>('overview');
const [previewMode, setPreviewMode] = createSignal<'configured' | 'live'>('configured');
const [loading, setLoading] = createSignal(true);
const [saving, setSaving] = createSignal(false);
const [creating, setCreating] = createSignal(false);
@ -136,17 +195,19 @@ export default function InternalDashboardManagementPage() {
const res = await fetch(`${API}/api/admin/dashboard-config?audience=INTERNAL`);
if (!res.ok) throw new Error('Failed to load internal dashboards');
const data = await res.json();
const rows = (Array.isArray(data) ? data : (data.dashboards || [])).map((item: any) => ({
id: item.id,
roleId: item.role_id || '',
roleName: item.config_json?.roleName || '',
title: item.config_json?.title || 'Untitled Dashboard',
description: item.config_json?.description || '',
status: item.is_active ? 'published' : 'draft',
version: item.config_json?.version || 1,
sidebar: item.config_json?.sidebar || [],
sections: item.config_json?.sections || [],
}));
const rows = (Array.isArray(data) ? data : (data.dashboards || []))
.filter((item: any) => String(item?.audience || '').toUpperCase() === 'INTERNAL')
.map((item: any) => ({
id: String(item.id || ''),
roleId: String(item.role_id || ''),
roleName: '',
title: 'Internal Dashboard',
description: '',
status: item.is_active ? 'published' : 'draft',
version: Number(item.version) || 1,
sidebar: [],
sections: [],
}));
setDashboards(rows);
} catch (err: any) {
setError(err.message || 'Failed to load dashboards');
@ -160,11 +221,50 @@ export default function InternalDashboardManagementPage() {
const res = await fetch(`${API}/api/admin/roles?audience=INTERNAL`);
if (!res.ok) return;
const data = await res.json();
setRoles((Array.isArray(data) ? data : (data.roles || [])).map((r: any) => ({ id: r.id, name: r.name })));
setRoles(
(Array.isArray(data) ? data : (data.roles || []))
.filter((r: any) => String(r?.audience || '').toUpperCase() === 'INTERNAL')
.map((r: any) => ({ id: String(r.id || ''), key: String(r.key || ''), name: String(r.name || 'Internal Role') })),
);
} catch { setRoles([]); }
};
const hydrateDashboard = async (configId: string) => {
const base = dashboards().find((item) => item.id === configId);
if (!base || !base.roleId) return;
try {
const res = await fetch(`${API}/api/admin/dashboard-config/${base.roleId}?audience=INTERNAL`);
if (!res.ok) return;
const detail = await res.json();
const role = roles().find((item) => item.id === base.roleId);
const hydrated = normalizeInternalDashboardFromConfig(
{
...base,
roleName: role?.name || base.roleName,
status: detail?.is_active ? 'published' : base.status,
version: Number(detail?.version) || base.version,
},
detail?.config_json || {},
);
setDashboards((prev) => prev.map((item) => (item.id === configId ? hydrated : item)));
} catch {
// Keep list-summary data if detail fetch fails.
}
};
const openDashboard = async (configId: string) => {
setSelectedId(configId);
setActiveTab('overview');
await hydrateDashboard(configId);
};
const selected = () => dashboards().find((d) => d.id === selectedId()) || null;
const livePreviewUrl = createMemo(() => {
const roleId = String(selected()?.roleId || '').trim();
if (!roleId) return `${LEGACY_INTERNAL_PREVIEW_BASE}/internal-dashboard-management`;
return `${LEGACY_INTERNAL_PREVIEW_BASE}/internal-dashboard-management?roleId=${encodeURIComponent(roleId)}`;
});
const livePreviewRoleLabel = createMemo(() => String(selected()?.roleName || '').trim() || 'Unlinked Role');
const update = (patch: Partial<Dashboard>) =>
setDashboards((prev) => prev.map((d) => d.id === selectedId() ? { ...d, ...patch } : d));
@ -236,19 +336,34 @@ export default function InternalDashboardManagementPage() {
setCreating(true);
setError('');
let newId = makeId('local');
const defaultRole = roles()[0];
try {
const res = await fetch(`${API}/api/admin/dashboard-config`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ audience: 'INTERNAL', config_json: { title: 'New Internal Dashboard', version: 1, sidebar: [], sections: [] } }),
});
if (res.ok) {
const data = await res.json();
newId = data.id;
if (defaultRole?.id) {
const res = await fetch(`${API}/api/admin/dashboard-config`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
role_id: defaultRole.id,
audience: 'INTERNAL',
config_json: { title: `${defaultRole.name} Dashboard`, description: '', roleName: defaultRole.name, version: 1, sidebar: [], sections: [], nav: [] },
}),
});
if (res.ok) {
const data = await res.json();
newId = data.id;
}
}
// If POST fails, fall through to local draft mode silently
} catch { /* backend unavailable — use local draft */ }
const nd: Dashboard = { id: newId, roleId: '', roleName: '', title: 'New Internal Dashboard', status: 'draft', version: 1, sidebar: [], sections: [] };
const nd: Dashboard = {
id: newId,
roleId: defaultRole?.id || '',
roleName: defaultRole?.name || '',
title: `${defaultRole?.name || 'New Internal'} Dashboard`,
status: 'draft',
version: 1,
sidebar: [],
sections: [],
};
setDashboards((prev) => [nd, ...prev]);
setSelectedId(newId);
setActiveTab('overview');
@ -260,15 +375,34 @@ export default function InternalDashboardManagementPage() {
const saveSelected = async () => {
const d = selected();
if (!d) return;
if (!d.roleId) {
setError('Please select an internal role before saving.');
return;
}
try {
setSaving(true);
setError('');
const res = await fetch(`${API}/api/admin/dashboard-config/${d.id}`, {
method: 'PATCH',
const nav = d.sidebar
.filter((item) => item.visible)
.sort((a, b) => a.order - b.order)
.map((item) => ({
key: item.key,
label: item.label,
path: '/internal-dashboard-management',
}));
const res = await fetch(`${API}/api/admin/dashboard-config`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ role_id: d.roleId || null, config_json: { title: d.title, description: d.description, roleName: d.roleName, version: d.version, sidebar: d.sidebar, sections: d.sections } }),
body: JSON.stringify({
role_id: d.roleId,
audience: 'INTERNAL',
config_json: { title: d.title, description: d.description, roleName: d.roleName, version: d.version, sidebar: d.sidebar, sections: d.sections, nav },
}),
});
if (!res.ok) throw new Error('Failed to save dashboard');
await loadDashboards();
const next = dashboards().find((item) => item.roleId === d.roleId);
if (next) await openDashboard(next.id);
} catch (err: any) {
setError(err.message || 'Failed to save dashboard');
} finally {
@ -282,7 +416,7 @@ export default function InternalDashboardManagementPage() {
<div class="page-hero-card page-actions">
<div>
<h1 class="page-title">Internal Dashboard Management</h1>
<p class="page-subtitle">Use the tabs and open one internal dashboard at a time from the list below.</p>
<p class="page-subtitle">Open one internal dashboard at a time from the list below and edit it using simple tabs.</p>
</div>
<a class="btn" href="/admin">Back to Dashboard</a>
</div>
@ -335,7 +469,7 @@ export default function InternalDashboardManagementPage() {
<td style="color:#64748b">v{d.version}</td>
<td>
<div class="table-actions">
<button class="btn" onClick={() => { setSelectedId(d.id); setActiveTab('overview'); }}>View Builder</button>
<button class="btn" onClick={() => void openDashboard(d.id)}>View Builder</button>
</div>
</td>
</tr>
@ -352,7 +486,7 @@ export default function InternalDashboardManagementPage() {
<div class="builder-header">
<div>
<h2>Internal Dashboard Builder</h2>
<p>Manage sidebar, sections, tabs, fields, and widgets from one place.</p>
<p>Manage menu items, sections, tabs, form fields, and summary cards from one place.</p>
</div>
<div class="builder-header-actions">
<button class="btn" onClick={() => setSelectedId('')}>Back to List</button>
@ -381,7 +515,7 @@ export default function InternalDashboardManagementPage() {
<Show when={activeTab() === 'overview'}>
<div class="field-grid-2">
<div class="field">
<label>Internal Role</label>
<label>Team Role</label>
<select
value={selected()!.roleId}
onChange={(e) => {
@ -389,7 +523,7 @@ export default function InternalDashboardManagementPage() {
update({ roleId: e.currentTarget.value, roleName: role?.name || '', title: role ? `${role.name} Dashboard` : selected()!.title });
}}
>
<option value="">Select internal role</option>
<option value="">Select team role</option>
{roles().map((r) => <option value={r.id}>{r.name}</option>)}
</select>
</div>
@ -398,13 +532,13 @@ export default function InternalDashboardManagementPage() {
<input value={selected()!.title} onInput={(e) => update({ title: e.currentTarget.value })} />
</div>
<div class="field">
<label>Description</label>
<label>Short description</label>
<input value={selected()!.description || ''} onInput={(e) => update({ description: e.currentTarget.value })} />
</div>
<div class="info-box">
<p style="margin:0 0 4px;font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.1em;color:#64748b">Linked Role</p>
<p style="margin:0;font-weight:600;color:#0f172a">{selected()!.roleName || 'No role selected yet'}</p>
<p style="margin:2px 0 0;font-size:12px;color:#64748b">{selected()!.roleId || 'Select an internal role to bind this dashboard.'}</p>
<p style="margin:2px 0 0;font-size:12px;color:#64748b">{selected()!.roleId || 'Select a team role to connect this dashboard.'}</p>
</div>
</div>
</Show>
@ -413,15 +547,15 @@ export default function InternalDashboardManagementPage() {
<Show when={activeTab() === 'sidebar'}>
<div class="builder-section">
<div class="sub-card-header">
<h4>Internal Sidebar</h4>
<button class="btn orange" onClick={addSidebarItem}>Add Sidebar Item</button>
<h4>Menu</h4>
<button class="btn orange" onClick={addSidebarItem}>Add Menu Item</button>
</div>
<For each={selected()!.sidebar}>
{(item, idx) => (
<div class="builder-item builder-item-row-4">
<input
value={item.label}
placeholder="Sidebar label"
placeholder="Menu label"
onInput={(e) => update({ sidebar: selected()!.sidebar.map((i) => i.key === item.key ? { ...i, label: e.currentTarget.value } : i) })}
/>
<input
@ -443,7 +577,7 @@ export default function InternalDashboardManagementPage() {
)}
</For>
<Show when={selected()!.sidebar.length === 0}>
<p class="notice">No sidebar items yet. Add the first item.</p>
<p class="notice">No menu items yet. Add your first menu item.</p>
</Show>
</div>
</Show>
@ -469,7 +603,7 @@ export default function InternalDashboardManagementPage() {
{/* Tabs */}
<div class="sub-card">
<div class="sub-card-header">
<h4>Tabs and Fields</h4>
<h4>Tabs and Form Fields</h4>
<button class="btn orange" onClick={() => addTab(section.id)}>Add Tab</button>
</div>
<For each={section.tabs}>
@ -499,7 +633,7 @@ export default function InternalDashboardManagementPage() {
<input
value={field.placeholder || ''}
onInput={(e) => updateField(section.id, tab.id, field.id, { placeholder: e.currentTarget.value })}
placeholder="Placeholder text"
placeholder="Help text inside the input"
style="width:100%"
/>
</div>
@ -564,8 +698,44 @@ export default function InternalDashboardManagementPage() {
{/* Preview */}
<Show when={activeTab() === 'preview'}>
<p class="notice" style="margin-bottom:12px">Preview uses the actual internal dashboard layout so you can navigate the sidebar, tabs, fields, and widgets before saving.</p>
<DashboardPreview dashboard={selected()!} />
<div style="display:flex;align-items:center;justify-content:space-between;gap:10px;flex-wrap:wrap;margin-bottom:12px">
<p class="notice" style="margin:0">Preview this dashboard as a sample view or open the live version.</p>
<div class="admin-segmented" style="margin:0">
<button
type="button"
class={`admin-segment ${previewMode() === 'configured' ? 'active' : ''}`}
onClick={() => setPreviewMode('configured')}
>
Sample Preview
</button>
<button
type="button"
class={`admin-segment ${previewMode() === 'live' ? 'active' : ''}`}
onClick={() => setPreviewMode('live')}
>
Live Preview
</button>
</div>
</div>
<Show when={previewMode() === 'configured'}>
<DashboardPreview dashboard={selected()!} />
</Show>
<Show when={previewMode() === 'live'}>
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:10px">
<span class="meta-chip">Role: {livePreviewRoleLabel()}</span>
<Show when={selected()!.roleId}>
<span class="meta-chip">Role ID: {selected()!.roleId}</span>
</Show>
<a class="btn" href={livePreviewUrl()} target="_blank" rel="noreferrer">Open Full Page Preview</a>
</div>
<iframe
src={livePreviewUrl()}
title="Live Internal Dashboard Preview"
style="width:100%;height:760px;border:1px solid #e2e8f0;border-radius:14px;background:#fff"
/>
</Show>
</Show>
</Show>
</AdminShell>

View file

@ -0,0 +1,8 @@
import { Navigate, useParams } from '@solidjs/router';
export default function OnboardingManagementDetailAliasPage() {
const params = useParams();
const schemaId = String(params.schemaId || '').trim();
if (!schemaId) return <Navigate href="/admin/onboarding-schemas" />;
return <Navigate href={`/admin/onboarding-schemas/${encodeURIComponent(schemaId)}`} />;
}

View file

@ -0,0 +1,5 @@
import { Navigate } from '@solidjs/router';
export default function OnboardingManagementCreateAliasPage() {
return <Navigate href="/admin/onboarding-schemas/new" />;
}

View file

@ -10,6 +10,11 @@ import OnboardingFlowBuilder, {
} from '~/components/admin/OnboardingFlowBuilder';
const API = '/api/gateway';
const FRONTEND_PREVIEW_BASE = String(import.meta.env.VITE_FRONTEND_PREVIEW_URL || 'http://localhost:3001').replace(/\/+$/, '');
function normalizeRoleKey(value: string): string {
return String(value || '').trim().toUpperCase().replace(/[-\s]+/g, '_');
}
type OnboardingSchemaPayload = {
id: string;
@ -24,9 +29,9 @@ type OnboardingSchemaPayload = {
is_active?: boolean;
};
async function loadSchema(schemaId: string): Promise<OnboardingSchemaPayload | null> {
async function loadSchema(roleId: string): Promise<OnboardingSchemaPayload | null> {
try {
const res = await fetch(`${API}/api/admin/onboarding-config/${schemaId}`);
const res = await fetch(`${API}/api/admin/onboarding-config/${roleId}`);
if (!res.ok) return null;
return res.json();
} catch {
@ -51,6 +56,7 @@ export default function OnboardingSchemaDetailPage() {
const [saving, setSaving] = createSignal(false);
const [error, setError] = createSignal('');
const [loaded, setLoaded] = createSignal(false);
const [livePreviewUrl, setLivePreviewUrl] = createSignal('');
createEffect(() => {
const next = schema();
@ -82,17 +88,16 @@ export default function OnboardingSchemaDetailPage() {
if (Array.isArray(next.selectedFields)) setSelectedFields(next.selectedFields);
};
const persist = async (publishToggle = false) => {
const persist = async () => {
try {
setSaving(true);
setError('');
const current = schema();
const currentlyActive = Boolean(current?.is_active);
const nextActive = publishToggle ? !currentlyActive : currentlyActive;
const response = await fetch(`${API}/api/admin/onboarding-config/${params.schemaId}`, {
method: 'PATCH',
const response = await fetch(`${API}/api/admin/onboarding-config`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
role_id: params.schemaId,
schema_json: {
title: title(),
roleKey: roleKey(),
@ -101,7 +106,6 @@ export default function OnboardingSchemaDetailPage() {
version: current?.schema_json?.version || 1,
steps: buildStepsFromFields(selectedFields(), stepCount()),
},
is_active: nextActive,
}),
});
const payload = await response.json();
@ -115,6 +119,18 @@ export default function OnboardingSchemaDetailPage() {
}
};
createEffect(() => {
const role = normalizeRoleKey(roleKey());
const schemaId = String(params.schemaId || '').trim();
if (!role) {
setLivePreviewUrl('');
return;
}
const query = new URLSearchParams({ roleKey: role });
if (schemaId) query.set('schemaId', schemaId);
setLivePreviewUrl(`${FRONTEND_PREVIEW_BASE}/onboarding?${query.toString()}`);
});
return (
<AdminShell>
<OnboardingManagementTabs />
@ -122,11 +138,11 @@ export default function OnboardingSchemaDetailPage() {
<div class="page-hero-card page-actions">
<div>
<h1 class="page-title">Onboarding Management</h1>
<p class="page-subtitle">Open one onboarding flow at a time, review its publishing state, then update the role, questions, step count, and final message in the builder.</p>
<p class="page-subtitle">Open one onboarding form at a time, check if it is published, then update the role, questions, steps, and final success message.</p>
</div>
<div class="page-actions-right">
<button class="btn" type="button" disabled={saving()} onClick={() => void persist(true)}>
{schema()?.is_active ? 'Unpublish' : 'Publish'}
Save Active Version
</button>
<A class="btn" href="/admin/onboarding-schemas">Back to Onboarding Management</A>
</div>
@ -158,9 +174,11 @@ export default function OnboardingSchemaDetailPage() {
selectedFields={selectedFields()}
saving={saving()}
error={error()}
livePreviewUrl={livePreviewUrl()}
livePreviewHint="Edit page preview loads the exact flow by schema id in the real onboarding UI."
primaryLabel="Save Onboarding Flow"
onChange={handleChange}
onSubmit={() => void persist(false)}
onSubmit={() => void persist()}
/>
</>
</Show>

View file

@ -7,6 +7,7 @@ const API = '/api/gateway';
type OnboardingSchema = {
id: string;
roleId: string;
title: string;
roleKey: string;
stepCount: number;
@ -20,14 +21,51 @@ async function loadSchemas(): Promise<OnboardingSchema[]> {
if (!res.ok) throw new Error('Failed to load');
const data = await res.json();
const rows = Array.isArray(data) ? data : (data.schemas || data.configs || []);
return rows.map((item: any) => ({
id: item.id,
title: item.schema_json?.title || item.title || item.schema_id || 'Untitled Flow',
roleKey: item.schema_json?.roleKey || item.role_key || '',
stepCount: (item.schema_json?.steps || item.steps || []).length,
version: item.schema_json?.version || item.version || 1,
const baseRows = rows.map((item: any) => ({
id: String(item.id || ''),
roleId: String(item.role_id || ''),
roleKey: String(item.role_key || ''),
version: Number(item.version) || 1,
status: item.is_active ? 'PUBLISHED' : 'DRAFT',
}));
const hydrated = await Promise.all(
baseRows.map(async (row) => {
if (!row.roleId) {
return {
...row,
title: row.roleKey ? `${row.roleKey} Onboarding` : 'Untitled Flow',
stepCount: 0,
};
}
try {
const detailRes = await fetch(`${API}/api/admin/onboarding-config/${row.roleId}`);
if (!detailRes.ok) {
return {
...row,
title: row.roleKey ? `${row.roleKey} Onboarding` : 'Untitled Flow',
stepCount: 0,
};
}
const detail = await detailRes.json();
const schemaJson = detail?.schema_json || {};
const steps = Array.isArray(schemaJson?.steps) ? schemaJson.steps : [];
return {
...row,
title: String(schemaJson?.title || `${row.roleKey} Onboarding`),
stepCount: steps.length,
};
} catch {
return {
...row,
title: row.roleKey ? `${row.roleKey} Onboarding` : 'Untitled Flow',
stepCount: 0,
};
}
}),
);
return hydrated;
} catch {
return [];
}
@ -110,7 +148,7 @@ export default function OnboardingSchemasPage() {
</td>
<td>
<div class="table-actions">
<A class="action-icon-btn" href={`/admin/onboarding-schemas/${schema.id}`} title="Open Flow">👁</A>
<A class="action-icon-btn" href={`/admin/onboarding-schemas/${schema.roleId || schema.id}`} title="Open Flow">👁</A>
<button
class="action-icon-btn danger"
disabled={deleting() === schema.id}

View file

@ -1,5 +1,5 @@
import { A, useNavigate } from '@solidjs/router';
import { createMemo, createSignal } from 'solid-js';
import { createMemo, createSignal, onMount } from 'solid-js';
import AdminShell from '~/components/AdminShell';
import OnboardingManagementTabs from '~/components/admin/OnboardingManagementTabs';
import OnboardingFlowBuilder, {
@ -9,9 +9,15 @@ import OnboardingFlowBuilder, {
} from '~/components/admin/OnboardingFlowBuilder';
const API = '/api/gateway';
const FRONTEND_PREVIEW_BASE = String(import.meta.env.VITE_FRONTEND_PREVIEW_URL || 'http://localhost:3001').replace(/\/+$/, '');
function normalizeRoleKey(value: string): string {
return String(value || '').trim().toUpperCase().replace(/[-\s]+/g, '_');
}
export default function NewOnboardingSchemaPage() {
const navigate = useNavigate();
const [roleMap, setRoleMap] = createSignal<Record<string, string>>({});
const [title, setTitle] = createSignal('');
const [roleKey, setRoleKey] = createSignal('company');
const [description, setDescription] = createSignal('');
@ -21,6 +27,26 @@ export default function NewOnboardingSchemaPage() {
const [saving, setSaving] = createSignal(false);
const [error, setError] = createSignal('');
onMount(async () => {
try {
const res = await fetch(`${API}/api/admin/roles?audience=EXTERNAL`);
if (!res.ok) return;
const payload = await res.json();
const rows = Array.isArray(payload) ? payload : (payload.roles || []);
const map: Record<string, string> = {};
rows
.filter((item: any) => String(item?.audience || '').toUpperCase() === 'EXTERNAL')
.forEach((item: any) => {
const key = String(item?.key || '').trim().toUpperCase();
if (!key) return;
map[key] = String(item?.id || '');
});
setRoleMap(map);
} catch {
setRoleMap({});
}
});
const payload = createMemo(() => ({
title: title(),
roleKey: roleKey(),
@ -28,6 +54,11 @@ export default function NewOnboardingSchemaPage() {
finalSubmissionMessage: finalSubmissionMessage(),
steps: buildStepsFromFields(selectedFields(), stepCount()),
}));
const livePreviewUrl = createMemo(() => {
const role = normalizeRoleKey(roleKey());
if (!role) return '';
return `${FRONTEND_PREVIEW_BASE}/onboarding?${new URLSearchParams({ roleKey: role }).toString()}`;
});
const handleChange = (next: {
title?: string;
@ -52,14 +83,19 @@ export default function NewOnboardingSchemaPage() {
try {
setSaving(true);
setError('');
const normalizedRole = normalizeRoleKey(roleKey());
const roleId = roleMap()[normalizedRole];
if (!roleId) {
throw new Error('Please choose a valid role before creating this flow.');
}
const response = await fetch(`${API}/api/admin/onboarding-config`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ schema_json: payload(), is_active: false }),
body: JSON.stringify({ role_id: roleId, schema_json: payload() }),
});
const body = await response.json();
if (!response.ok) throw new Error(body?.message || 'Failed to create onboarding flow');
navigate(`/admin/onboarding-schemas/${body.id || body.schema?.id}`);
navigate(`/admin/onboarding-schemas/${body.role_id || roleId}`);
} catch (nextError: any) {
setError(nextError?.message || 'Failed to create onboarding flow');
} finally {
@ -74,7 +110,7 @@ export default function NewOnboardingSchemaPage() {
<div class="page-hero-card page-actions">
<div>
<h1 class="page-title">Create Onboarding Flow</h1>
<p class="page-subtitle">Build one onboarding flow at a time. Start with the role, choose the questions, arrange the step count, and finish with the final submission message.</p>
<p class="page-subtitle">Create one onboarding form at a time. Pick the role, choose the questions, set the steps, and write the final success message.</p>
</div>
<A class="btn" href="/admin/onboarding-schemas">Back to Onboarding Management</A>
</div>
@ -94,6 +130,8 @@ export default function NewOnboardingSchemaPage() {
selectedFields={selectedFields()}
saving={saving()}
error={error()}
livePreviewUrl={livePreviewUrl()}
livePreviewHint="Create page preview uses the role-level runtime onboarding flow. Save the flow first to preview the exact saved flow by schema id."
primaryLabel="Create Onboarding Flow"
onChange={handleChange}
onSubmit={handleSubmit}

View file

@ -0,0 +1,121 @@
import { A, useParams } from '@solidjs/router';
import { createMemo, createResource, Show } from 'solid-js';
import AdminShell from '~/components/AdminShell';
const API = '/api/gateway';
type Requirement = {
id: string;
title?: string;
description?: string;
profession_key?: string;
location?: string;
budget?: number;
preferred_date?: string;
status?: string;
request_count?: number;
accepted_count?: number;
customer_id?: string;
created_at?: string;
updated_at?: string;
};
async function fetchRequirement(id: string): Promise<Requirement | null> {
try {
const res = await fetch(`${API}/api/requirements/${id}`);
if (!res.ok) return null;
const data = await res.json();
return data.requirement || data;
} catch {
return null;
}
}
export default function RequirementDetailPage() {
const params = useParams();
const [requirement] = createResource(() => params.id, fetchRequirement);
const createdAt = createMemo(() => requirement()?.created_at || '');
const updatedAt = createMemo(() => requirement()?.updated_at || '');
return (
<AdminShell>
<div class="page-hero-card page-actions">
<div>
<h1 class="page-title">Requirement Request</h1>
<p class="page-subtitle">Review full requirement request details before approval action.</p>
</div>
<A class="btn" href="/admin/approval">Back to Approval Management</A>
</div>
<Show when={requirement.loading}>
<div class="card"><p class="notice">Loading requirement...</p></div>
</Show>
<Show when={!requirement.loading && !requirement()}>
<div class="card"><p class="notice">Requirement not found.</p></div>
</Show>
<Show when={requirement()}>
<section class="card">
<div class="field-grid-2">
<div>
<p class="hint">Title</p>
<p style="margin:6px 0 0;font-weight:700;color:#0f172a">{requirement()!.title || '—'}</p>
</div>
<div>
<p class="hint">Status</p>
<p style="margin:6px 0 0;color:#334155">{requirement()!.status || '—'}</p>
</div>
<div>
<p class="hint">Profession Key</p>
<p style="margin:6px 0 0;color:#334155">{requirement()!.profession_key || '—'}</p>
</div>
<div>
<p class="hint">Location</p>
<p style="margin:6px 0 0;color:#334155">{requirement()!.location || '—'}</p>
</div>
<div>
<p class="hint">Budget</p>
<p style="margin:6px 0 0;color:#334155">
{requirement()!.budget != null ? `${requirement()!.budget}` : '—'}
</p>
</div>
<div>
<p class="hint">Preferred Date</p>
<p style="margin:6px 0 0;color:#334155">{requirement()!.preferred_date || '—'}</p>
</div>
<div>
<p class="hint">Request Count</p>
<p style="margin:6px 0 0;color:#334155">{requirement()!.request_count ?? 0}</p>
</div>
<div>
<p class="hint">Accepted Count</p>
<p style="margin:6px 0 0;color:#334155">{requirement()!.accepted_count ?? 0}</p>
</div>
<div>
<p class="hint">Customer ID</p>
<p style="margin:6px 0 0;color:#334155">{requirement()!.customer_id || '—'}</p>
</div>
<div>
<p class="hint">Created</p>
<p style="margin:6px 0 0;color:#334155">{createdAt() ? new Date(createdAt()).toLocaleString() : '—'}</p>
</div>
<Show when={updatedAt()}>
<div>
<p class="hint">Updated</p>
<p style="margin:6px 0 0;color:#334155">{new Date(updatedAt()!).toLocaleString()}</p>
</div>
</Show>
</div>
<div style="margin-top:18px">
<p class="hint">Description</p>
<p style="margin:6px 0 0;color:#334155;white-space:pre-wrap">{requirement()!.description || '—'}</p>
</div>
</section>
</Show>
</AdminShell>
);
}

View file

@ -1,121 +1,21 @@
import { useParams } from '@solidjs/router';
import { createResource, createSignal, createEffect, onMount, Show } from 'solid-js';
import { useNavigate, useParams } from '@solidjs/router';
import { onMount } from 'solid-js';
import AdminShell from '~/components/AdminShell';
import { getRuntimeConfig, saveRuntimeConfig } from '~/lib/runtime/storage';
import type { RuntimeDashboardConfig } from '~/lib/runtime/types';
export default function EditDashboardPage() {
export default function EditRoleUiConfigRedirectPage() {
const navigate = useNavigate();
const params = useParams();
const [data] = createResource(() => {
if (!params.roleKey) return null;
return getRuntimeConfig<RuntimeDashboardConfig>('dashboard', params.roleKey);
});
const [config, setConfig] = createSignal<RuntimeDashboardConfig | null>(null);
const [statusMessage, setStatusMessage] = createSignal('');
const [isSaving, setIsSaving] = createSignal(false);
onMount(() => {
// We'll sync the resource to the signal once it loads
// However, in Solid it's better to just use the resource or a derived signal
const roleKey = encodeURIComponent(params.roleKey || '');
navigate(`/admin/external-dashboard-management?roleKey=${roleKey}`, { replace: true });
});
// Effect to sync config once data is loaded
createEffect(() => {
const item = data();
if (item?.payload) {
setConfig(JSON.parse(JSON.stringify(item.payload)));
}
});
const persist = async (status: 'draft' | 'published') => {
const payload = config();
if (!payload) return;
setIsSaving(true);
setStatusMessage('Saving to backend...');
try {
await saveRuntimeConfig('dashboard', payload.roleKey, payload, status);
setStatusMessage(status === 'draft' ? 'Draft saved successfully.' : 'Dashboard config published successfully.');
} catch (err) {
setStatusMessage(`Error: ${err instanceof Error ? err.message : String(err)}`);
} finally {
setIsSaving(false);
}
};
return (
<AdminShell>
<h1 class="page-title">Edit Dashboard</h1>
<p class="page-subtitle">Update dashboard runtime config without changing source code.</p>
<Show when={data.loading}>
<section class="card"><p class="notice">Loading dashboard configuration from database...</p></section>
</Show>
<Show when={!data.loading && !config()}>
<section class="card"><p class="notice">Dashboard config "{params.roleKey}" not found in database.</p></section>
</Show>
<Show when={!data.loading && config()}>
<div class="grid">
<section class="card">
<h2>Dashboard Builder</h2>
<div class="field">
<label>Role Key</label>
<input value={config()!.roleKey} onInput={(e) => setConfig({ ...config()!, roleKey: e.currentTarget.value.toUpperCase() })} />
</div>
<div class="field">
<label>Sidebar Items (label|route per line)</label>
<textarea
rows={8}
value={config()!.sidebar.map((x) => `${x.label}|${x.route}`).join('\n')}
onInput={(e) =>
setConfig({
...config()!,
sidebar: e.currentTarget.value
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
.map((line) => {
const [label, route] = line.split('|').map((part) => part.trim());
return { key: label.toLowerCase().replace(/\s+/g, '-'), label, route };
}),
})
}
/>
</div>
<div class="field">
<label>Widgets (title per line)</label>
<textarea
rows={6}
value={config()!.widgets.map((x) => x.title).join('\n')}
onInput={(e) =>
setConfig({
...config()!,
widgets: e.currentTarget.value
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
.map((title) => ({ key: title.toLowerCase().replace(/\s+/g, '-'), title, enabled: true })),
})
}
/>
</div>
<div class="actions">
<button class="btn" onClick={() => persist('draft')} disabled={isSaving()}>
{isSaving() ? 'Saving...' : 'Save Draft'}
</button>
<button class="btn primary" onClick={() => persist('published')} disabled={isSaving()}>
{isSaving() ? 'Publishing...' : 'Publish'}
</button>
</div>
{statusMessage() && <p class="inline-note">{statusMessage()}</p>}
</section>
<section class="card">
<h2>Runtime Config Preview</h2>
<pre class="json">{JSON.stringify(config(), null, 2)}</pre>
</section>
</div>
</Show>
<section class="card">
<p class="notice">Redirecting to External Dashboard Management...</p>
</section>
</AdminShell>
);
}

View file

@ -1,109 +1,19 @@
import { createSignal } from 'solid-js';
import { useNavigate } from '@solidjs/router';
import { onMount } from 'solid-js';
import AdminShell from '~/components/AdminShell';
import { saveRuntimeConfig } from '~/lib/runtime/storage';
import type { RuntimeDashboardConfig } from '~/lib/runtime/types';
export default function CreateDashboardPage() {
const [config, setConfig] = createSignal<RuntimeDashboardConfig>({
roleKey: 'PHOTOGRAPHER',
sidebar: [
{ key: 'dashboard', label: 'Dashboard', route: '/workspace/dashboard' },
{ key: 'leads', label: 'Leads', route: '/workspace/leads' },
{ key: 'portfolio', label: 'Portfolio', route: '/workspace/portfolio' },
{ key: 'verification', label: 'Verification', route: '/workspace/verification' },
],
widgets: [
{ key: 'active-leads', title: 'Active Leads', enabled: true },
{ key: 'recent-requests', title: 'Recent Requests', enabled: true },
],
export default function CreateRoleUiConfigRedirectPage() {
const navigate = useNavigate();
onMount(() => {
navigate('/admin/external-dashboard-management?mode=create', { replace: true });
});
const [statusMessage, setStatusMessage] = createSignal('');
const [isSaving, setIsSaving] = createSignal(false);
const persist = async (status: 'draft' | 'published') => {
const payload = config();
if (!payload.roleKey.trim()) {
setStatusMessage('Role key is required before saving.');
return;
}
setIsSaving(true);
setStatusMessage('Saving to backend...');
try {
await saveRuntimeConfig('dashboard', payload.roleKey, payload, status);
setStatusMessage(status === 'draft' ? 'Draft saved successfully.' : 'Dashboard config published successfully.');
} catch (err) {
setStatusMessage(`Error: ${err instanceof Error ? err.message : String(err)}`);
} finally {
setIsSaving(false);
}
};
return (
<AdminShell>
<h1 class="page-title">Create Dashboard</h1>
<p class="page-subtitle">Edit sidebar and widgets directly. Runtime config stays visible while building.</p>
<div class="grid">
<section class="card">
<h2>Dashboard Builder</h2>
<div class="field">
<label>Role Key</label>
<input value={config().roleKey} onInput={(e) => setConfig({ ...config(), roleKey: e.currentTarget.value.toUpperCase() })} />
</div>
<div class="field">
<label>Sidebar Items (label|route per line)</label>
<textarea
rows={8}
value={config().sidebar.map((x) => `${x.label}|${x.route}`).join('\n')}
onInput={(e) =>
setConfig({
...config(),
sidebar: e.currentTarget.value
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
.map((line) => {
const [label, route] = line.split('|').map((part) => part.trim());
return { key: label.toLowerCase().replace(/\s+/g, '-'), label, route };
}),
})
}
/>
</div>
<div class="field">
<label>Widgets (title per line)</label>
<textarea
rows={6}
value={config().widgets.map((x) => x.title).join('\n')}
onInput={(e) =>
setConfig({
...config(),
widgets: e.currentTarget.value
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
.map((title) => ({ key: title.toLowerCase().replace(/\s+/g, '-'), title, enabled: true })),
})
}
/>
</div>
<div class="actions">
<button class="btn" onClick={() => persist('draft')} disabled={isSaving()}>
{isSaving() ? 'Saving...' : 'Save Draft'}
</button>
<button class="btn primary" onClick={() => persist('published')} disabled={isSaving()}>
{isSaving() ? 'Publishing...' : 'Publish'}
</button>
</div>
{statusMessage() && <p class="inline-note">{statusMessage()}</p>}
</section>
<section class="card">
<h2>Runtime Config Preview</h2>
<pre class="json">{JSON.stringify(config(), null, 2)}</pre>
</section>
</div>
<section class="card">
<p class="notice">Redirecting to External Dashboard Management...</p>
</section>
</AdminShell>
);
}

View file

@ -115,7 +115,7 @@ export default function EditInternalRolePage() {
<div class="page-hero-card page-actions">
<div>
<h1 class="page-title">Edit Internal Role</h1>
<p class="page-subtitle">Update role name, module access, and permissions.</p>
<p class="page-subtitle">Update role name, access areas, and permissions.</p>
</div>
<A class="btn" href={`/admin/roles/${params.id}`}>Back to Role</A>
</div>
@ -127,7 +127,7 @@ export default function EditInternalRolePage() {
</nav>
<Show when={data.loading}>
<div class="card"><p class="notice">Loading role...</p></div>
<div class="card"><p class="notice">Loading role details...</p></div>
</Show>
<Show when={data.error}>
@ -141,11 +141,11 @@ export default function EditInternalRolePage() {
<Show when={!data.loading && data()}>
{/* Role Details */}
<div class="role-form-section">
<h3>Role Details</h3>
<h3>Role Basics</h3>
<p>Update the role name and description.</p>
<div class="field-grid-2">
<div class="field">
<label>Name of the Role <span style="color:#ef4444">*</span></label>
<label>Role Name <span style="color:#ef4444">*</span></label>
<input
value={roleName()}
onInput={(e) => setRoleName(e.currentTarget.value)}
@ -165,8 +165,8 @@ export default function EditInternalRolePage() {
{/* Module Access */}
<div class="role-form-section">
<h3>Module Access</h3>
<p>Select which modules this role can access.</p>
<h3>Area Access</h3>
<p>Select which areas this role can access.</p>
<div class="module-picker">
{allModules().map((mod) => (
<button
@ -185,12 +185,12 @@ export default function EditInternalRolePage() {
<Show when={assignedModules().length > 0}>
<div class="role-form-section">
<h3>Permissions</h3>
<p>Set Read / Create / Update / Delete access for each assigned module.</p>
<p>Choose what this role can do in each selected area.</p>
<div class="table-wrap">
<table class="perm-table">
<thead>
<tr>
<th style="width:45%">Name of the module</th>
<th style="width:45%">Area</th>
<th style="width:11%">No Access</th>
<th style="width:11%">Read</th>
<th style="width:11%">Create</th>

View file

@ -96,7 +96,7 @@ export default function CreateInternalRolePage() {
<div class="page-hero-card page-actions">
<div>
<h1 class="page-title">Create Internal Role</h1>
<p class="page-subtitle">Add a new internal role with module access and permissions.</p>
<p class="page-subtitle">Create a new internal role and choose what it can access.</p>
</div>
<A class="btn" href="/admin/roles">Back to Roles</A>
</div>
@ -113,11 +113,11 @@ export default function CreateInternalRolePage() {
{/* Role Details */}
<div class="role-form-section">
<h3>Role Details</h3>
<p>Start by giving the role a simple business name.</p>
<h3>Role Basics</h3>
<p>Start by giving this role a clear name.</p>
<div class="field-grid-2">
<div class="field">
<label>Name of the Role <span style="color:#ef4444">*</span></label>
<label>Role Name <span style="color:#ef4444">*</span></label>
<input
value={roleName()}
onInput={(e) => setRoleName(e.currentTarget.value)}
@ -137,10 +137,10 @@ export default function CreateInternalRolePage() {
{/* Module Access */}
<div class="role-form-section">
<h3>Module Access</h3>
<p>Select which modules this role can access. Only selected modules will appear in the permission table below.</p>
<h3>Area Access</h3>
<p>Select which areas this role can access. You can set permissions for selected areas below.</p>
<Show when={permissions.loading}>
<p class="notice">Loading modules...</p>
<p class="notice">Loading available areas...</p>
</Show>
<Show when={!permissions.loading && allModules().length > 0}>
<div class="module-picker">
@ -157,7 +157,7 @@ export default function CreateInternalRolePage() {
</div>
</Show>
<Show when={!permissions.loading && allModules().length === 0}>
<p class="notice">No modules available.</p>
<p class="notice">No areas available.</p>
</Show>
</div>
@ -165,12 +165,12 @@ export default function CreateInternalRolePage() {
<Show when={assignedModules().length > 0}>
<div class="role-form-section">
<h3>Permissions</h3>
<p>Set Read / Create / Update / Delete access for each assigned module.</p>
<p>Choose what this role can do in each selected area.</p>
<div class="table-wrap">
<table class="perm-table">
<thead>
<tr>
<th style="width:45%">Name of the module</th>
<th style="width:45%">Area</th>
<th style="width:11%">No Access</th>
<th style="width:11%">Read</th>
<th style="width:11%">Create</th>

View file

@ -6,6 +6,30 @@ import ExternalRoleTabs from '~/components/admin/ExternalRoleTabs';
const API = '/api/gateway';
function normalizeRoleKey(value: string): string {
return String(value || '').trim().toLowerCase().replace(/[-\s]+/g, '_');
}
function inferredVertical(roleKey: string): 'jobs' | 'marketplace' {
const key = normalizeRoleKey(roleKey);
return key === 'company' || key === 'job_seeker' ? 'jobs' : 'marketplace';
}
function inferredCategory(roleKey: string): ExternalRoleConfig['roleCategory'] {
const key = normalizeRoleKey(roleKey);
if (key === 'company') return 'employer';
if (key === 'customer') return 'consumer';
if (key === 'job_seeker') return 'specialist';
return 'provider';
}
function normalizeVertical(raw: unknown, fallbackRoleKey: string): 'jobs' | 'marketplace' {
const value = String(raw || '').trim().toLowerCase();
if (value === 'jobs' || value === 'job') return 'jobs';
if (value === 'marketplace' || value === 'service' || value === 'services') return 'marketplace';
return inferredVertical(fallbackRoleKey);
}
async function loadRole(roleKey: string): Promise<ExternalRoleConfig | null> {
try {
const res = await fetch(`${API}/api/admin/roles?audience=EXTERNAL`);
@ -17,15 +41,40 @@ async function loadRole(roleKey: string): Promise<ExternalRoleConfig | null> {
if (!row) return null;
const cfg = row.config_json || {};
const roleId = String(row.id || '');
let dashboardConfig: any = null;
let onboardingConfig: any = null;
if (roleId) {
const [dashboardRes, onboardingRes] = await Promise.all([
fetch(`${API}/api/admin/dashboard-config/${roleId}?audience=EXTERNAL`).catch(() => null),
fetch(`${API}/api/admin/onboarding-config/${roleId}`).catch(() => null),
]);
dashboardConfig = dashboardRes && dashboardRes.ok ? await dashboardRes.json().catch(() => null) : null;
onboardingConfig = onboardingRes && onboardingRes.ok ? await onboardingRes.json().catch(() => null) : null;
}
const dashboardJson = dashboardConfig?.config_json || {};
const enabledFromDashboard = Array.isArray(dashboardJson?.enabled_modules) ? dashboardJson.enabled_modules : [];
const navFromDashboard = Array.isArray(dashboardJson?.nav) ? dashboardJson.nav : [];
const modulesFromDashboard = Array.isArray(dashboardJson?.modules) ? dashboardJson.modules : [];
const enabledModulesFallback = enabledFromDashboard.length > 0
? enabledFromDashboard
: (
navFromDashboard.length > 0
? navFromDashboard.map((item: any) => String(item?.key || '').trim()).filter(Boolean)
: modulesFromDashboard.map((item: any) => String(item?.key || item?.moduleKey || '').trim()).filter(Boolean)
);
return {
id: row.id,
roleKey: String(cfg.roleKey || row.key || row.role_key || ''),
displayName: String(cfg.displayName || row.name || row.display_name || ''),
vertical: cfg.vertical === 'jobs' ? 'jobs' : 'marketplace',
roleCategory: cfg.roleCategory || 'provider',
enabledModules: Array.isArray(cfg.enabledModules) ? cfg.enabledModules : [],
vertical: normalizeVertical(cfg.vertical || row.vertical, row.key || row.role_key || ''),
roleCategory: cfg.roleCategory || inferredCategory(row.key || row.role_key || ''),
enabledModules: Array.isArray(cfg.enabledModules) && cfg.enabledModules.length > 0 ? cfg.enabledModules : enabledModulesFallback,
permissions: (cfg.permissions && typeof cfg.permissions === 'object') ? cfg.permissions : {},
onboardingSchemaId: String(cfg.onboardingSchemaId || ''),
onboardingSchemaId: String(cfg.onboardingSchemaId || onboardingConfig?.id || ''),
requiresOnboardingApproval: cfg.requiresOnboardingApproval ?? true,
requiresLeadApproval: cfg.requiresLeadApproval ?? false,
requiresJobApproval: cfg.requiresJobApproval ?? false,
@ -45,6 +94,17 @@ export default function EditExternalRolePage() {
const [saving, setSaving] = createSignal(false);
const [error, setError] = createSignal('');
const roleKey = createMemo(() => decodeURIComponent(params.roleKey || ''));
const [onboardingSchemas] = createResource(async () => {
try {
const res = await fetch(`${API}/api/admin/onboarding-config`);
if (!res.ok) return [] as string[];
const data = await res.json();
const rows = Array.isArray(data) ? data : (data.schemas || data.configs || []);
return rows.map((item: any) => String(item?.id || '').trim()).filter(Boolean);
} catch {
return [] as string[];
}
});
const handleSubmit = async (config: ExternalRoleConfig) => {
if (!config.id) {
@ -96,7 +156,7 @@ export default function EditExternalRolePage() {
<div>
<h1 class="page-title">External Role Management</h1>
<p class="page-subtitle">
Manage one external role in detail, including modules, permissions, onboarding assignment, approval gates, and limits.
Update this external role with simple settings: pages, permissions, onboarding form, approvals, and limits.
</p>
</div>
<div class="page-actions-right">
@ -112,14 +172,14 @@ export default function EditExternalRolePage() {
</Show>
<div class="card">
<h2 style="margin-bottom:6px">Publishing Model</h2>
<h2 style="margin-bottom:6px">How Saving Works</h2>
<p class="notice" style="margin:0">
This page writes directly through the external-role admin API and updates published runtime-config for the matching role.
Saving here updates the live role settings used by the app.
</p>
</div>
<Show when={data.loading}>
<div class="card"><p class="notice">Loading runtime role...</p></div>
<div class="card"><p class="notice">Loading external role...</p></div>
</Show>
<Show when={!data.loading && !data()}>
@ -129,8 +189,9 @@ export default function EditExternalRolePage() {
<Show when={!data.loading && data()}>
<ExternalRoleForm
initialValue={data()!}
onboardingSchemaOptions={onboardingSchemas() || []}
saving={saving()}
submitLabel="Save External Role"
submitLabel="Save Role Changes"
onSubmit={handleSubmit}
/>
</Show>

View file

@ -20,16 +20,51 @@ async function loadExternalRoles(): Promise<ExternalRole[]> {
const res = await fetch(`${API}/api/admin/roles?audience=EXTERNAL`);
if (!res.ok) throw new Error('Failed to load');
const data = await res.json();
const rows = Array.isArray(data) ? data : (data.roles || []);
return rows.map((r: any) => ({
id: r.id,
roleKey: r.key || r.role_key || r.roleKey || '',
displayName: r.name || r.displayName || r.display_name || r.key || '',
vertical: r.config_json?.vertical || r.vertical || '',
enabledModules: r.config_json?.enabledModules || r.enabled_modules || [],
onboardingSchemaId: r.config_json?.onboardingSchemaId || r.onboarding_schema_id || '',
isActive: r.is_active !== false,
}));
const rows = (Array.isArray(data) ? data : (data.roles || []))
.filter((item: any) => String(item?.audience || '').toUpperCase() === 'EXTERNAL');
return await Promise.all(
rows.map(async (r: any) => {
const roleId = String(r.id || '');
const roleKey = String(r.key || r.role_key || r.roleKey || '');
const cfg = r.config_json || {};
let enabledModules = Array.isArray(cfg?.enabledModules) ? cfg.enabledModules : [];
let onboardingSchemaId = String(cfg?.onboardingSchemaId || r.onboarding_schema_id || '');
if (roleId) {
try {
const dashboardRes = await fetch(`${API}/api/admin/dashboard-config/${roleId}?audience=EXTERNAL`);
if (dashboardRes.ok) {
const detail = await dashboardRes.json();
const json = detail?.config_json || {};
const byEnabled = Array.isArray(json?.enabled_modules) ? json.enabled_modules : [];
const byNav = Array.isArray(json?.nav) ? json.nav.map((item: any) => String(item?.key || '').trim()).filter(Boolean) : [];
if (enabledModules.length === 0) {
enabledModules = byEnabled.length > 0 ? byEnabled : byNav;
}
}
} catch {}
try {
const onboardingRes = await fetch(`${API}/api/admin/onboarding-config/${roleId}`);
if (onboardingRes.ok) {
const onboarding = await onboardingRes.json();
if (!onboardingSchemaId) onboardingSchemaId = String(onboarding?.id || '');
}
} catch {}
}
return {
id: roleId,
roleKey,
displayName: r.name || r.displayName || r.display_name || roleKey,
vertical: cfg?.vertical || r.vertical || '',
enabledModules,
onboardingSchemaId,
isActive: r.is_active !== false,
};
}),
);
} catch {
return [];
}

View file

@ -1,5 +1,5 @@
import { A, useNavigate } from '@solidjs/router';
import { createSignal, Show } from 'solid-js';
import { createResource, createSignal, Show } from 'solid-js';
import AdminShell from '~/components/AdminShell';
import ExternalRoleForm, { type ExternalRoleConfig } from '~/components/admin/ExternalRoleForm';
import ExternalRoleTabs from '~/components/admin/ExternalRoleTabs';
@ -28,6 +28,19 @@ export default function CreateExternalRolePage() {
const navigate = useNavigate();
const [saving, setSaving] = createSignal(false);
const [error, setError] = createSignal('');
const [onboardingSchemas] = createResource(async () => {
try {
const res = await fetch(`${API}/api/admin/onboarding-config`);
if (!res.ok) return [] as string[];
const data = await res.json();
const rows = Array.isArray(data) ? data : (data.schemas || data.configs || []);
return rows
.map((item: any) => String(item?.id || '').trim())
.filter(Boolean);
} catch {
return [] as string[];
}
});
const handleSubmit = async (config: ExternalRoleConfig) => {
try {
@ -73,12 +86,12 @@ export default function CreateExternalRolePage() {
<AdminShell>
<div class="page-hero-card page-actions">
<div>
<h1 class="page-title">Create Runtime Role</h1>
<h1 class="page-title">Create External Role</h1>
<p class="page-subtitle">
Create an API-backed runtime role for the external runtime shell. This writes through the current runtime roles admin endpoint.
Create a new external role and choose what it can access in the app.
</p>
</div>
<A class="btn" href="/admin/runtime-roles">Back to Runtime Roles</A>
<A class="btn" href="/admin/runtime-roles">Back to External Roles</A>
</div>
<ExternalRoleTabs />
@ -89,8 +102,9 @@ export default function CreateExternalRolePage() {
<ExternalRoleForm
initialValue={defaultConfig()}
onboardingSchemaOptions={onboardingSchemas() || []}
saving={saving()}
submitLabel="Create Runtime Role"
submitLabel="Create External Role"
onSubmit={handleSubmit}
/>
</AdminShell>

View file

@ -32,7 +32,8 @@ async function proxyRequest(method: string, request: Request, params: any) {
pathArray = [pathArray];
}
const path = `/${pathArray.join('/')}`;
const rawPath = `/${pathArray.join('/')}`.replace(/\/{2,}/g, '/');
const path = normalizeGatewayPath(rawPath);
const url = new URL(request.url);
const queryString = url.search ? url.search : '';
@ -74,3 +75,19 @@ async function proxyRequest(method: string, request: Request, params: any) {
);
}
}
function normalizeGatewayPath(rawPath: string): string {
const clean = rawPath.replace(/\/{2,}/g, '/');
// Legacy admin auth endpoints from Next.js build.
if (/^\/(?:users\/)?auth\/internal\/login$/i.test(clean)) return '/api/auth/login';
if (/^\/(?:users\/)?auth\/me$/i.test(clean)) return '/api/auth/session';
if (/^\/(?:users\/)?auth\/logout$/i.test(clean)) return '/api/auth/logout';
// Generic auth fallback: /auth/* or /users/auth/* => /api/auth/*
const authFallback = clean.match(/^\/(?:users\/)?auth\/(.+)$/i);
if (authFallback) return `/api/auth/${authFallback[1]}`;
// Rust gateway routes are mounted under /api/*
return clean.startsWith('/api/') ? clean : `/api${clean}`;
}

View file

@ -142,6 +142,11 @@ export default function LoginPage() {
throw new Error('External users are not allowed on management login. Please use the external user login.');
}
const accessToken = String(payload?.access_token || payload?.accessToken || '').trim();
if (accessToken && typeof sessionStorage !== 'undefined') {
sessionStorage.setItem('nxtgauge_admin_access_token', accessToken);
}
completeAdminLogin();
} catch (nextError: any) {
setError(String(nextError?.message || 'Sign in failed.'));