Fix admin navigation/refresh and complete role/dashboard/onboarding builder parity updates
This commit is contained in:
parent
8a01ff9041
commit
0050277922
27 changed files with 1932 additions and 587 deletions
210
src/app.css
210
src/app.css
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
15
src/routes/admin/approval-management.tsx
Normal file
15
src/routes/admin/approval-management.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
15
src/routes/admin/approvals.tsx
Normal file
15
src/routes/admin/approvals.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
8
src/routes/admin/onboarding-management/[schemaId].tsx
Normal file
8
src/routes/admin/onboarding-management/[schemaId].tsx
Normal 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)}`} />;
|
||||
}
|
||||
5
src/routes/admin/onboarding-management/new.tsx
Normal file
5
src/routes/admin/onboarding-management/new.tsx
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { Navigate } from '@solidjs/router';
|
||||
|
||||
export default function OnboardingManagementCreateAliasPage() {
|
||||
return <Navigate href="/admin/onboarding-schemas/new" />;
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
121
src/routes/admin/requirements/[id].tsx
Normal file
121
src/routes/admin/requirements/[id].tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 [];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.'));
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue