feat(admin): advance Next.js UI parity for shell and core management pages

This commit is contained in:
Ashwin Kumar 2026-03-19 13:50:20 +01:00
parent 04a1079f68
commit 09894af444
7 changed files with 716 additions and 247 deletions

View file

@ -176,22 +176,31 @@ body {
}
.admin-header {
position: sticky;
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 40;
border-bottom: 1px solid #e2e8f0;
background: rgba(255, 255, 255, 0.94);
backdrop-filter: blur(12px);
background: #fff;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.08);
}
.admin-header-inner {
width: min(1440px, calc(100% - 36px));
margin: 0 auto;
min-height: 64px;
width: calc(100% - 48px);
margin: 0 24px;
height: 64px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
gap: 16px;
}
.admin-header-left {
display: flex;
align-items: center;
gap: 28px;
min-width: 0;
}
.admin-brand {
@ -201,30 +210,24 @@ body {
}
.admin-brand img {
height: 44px;
height: 40px;
width: auto;
}
.admin-brand-kicker {
.admin-page-heading {
margin: 0;
color: #64748b;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.admin-brand h1 {
margin: 2px 0 0;
font-size: 17px;
font-weight: 800;
color: var(--brand-navy);
font-size: 16px;
font-weight: 600;
color: #1f2937;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.admin-header-actions {
display: flex;
align-items: center;
gap: 10px;
gap: 8px;
}
.admin-role-chip {
@ -240,12 +243,84 @@ body {
text-transform: uppercase;
}
.admin-avatar-btn {
border: 0;
border-radius: 10px;
background: transparent;
display: flex;
align-items: center;
gap: 10px;
padding: 4px 8px;
cursor: pointer;
color: #475569;
}
.admin-avatar-btn:hover {
background: #f3f4f6;
}
.admin-avatar {
width: 32px;
height: 32px;
border-radius: 999px;
border: 1px solid #fed7aa;
background: #ffedd5;
color: #c2410c;
font-size: 13px;
font-weight: 700;
display: inline-flex;
align-items: center;
justify-content: center;
}
.admin-avatar-meta {
display: flex;
flex-direction: column;
align-items: flex-start;
line-height: 1.1;
}
.admin-avatar-name {
font-size: 12px;
font-weight: 600;
color: #334155;
}
.admin-avatar-role {
font-size: 10px;
color: #64748b;
}
.admin-logout-btn {
border: 0;
border-radius: 8px;
background: transparent;
color: #64748b;
padding: 6px;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
}
.admin-logout-btn svg {
width: 18px;
height: 18px;
}
.admin-logout-btn:hover {
background: #fef2f2;
color: #dc2626;
}
.shell {
display: grid;
grid-template-columns: 264px 1fr;
height: calc(100vh - 64px);
height: calc(100vh - 80px);
overflow: hidden;
transition: grid-template-columns 300ms ease;
padding-top: 80px;
background: #f9fafb;
}
.shell.sidebar-collapsed {
@ -337,10 +412,10 @@ body {
text-decoration: none;
color: #475569;
border: 1px solid transparent;
border-radius: 10px;
padding: 10px 12px;
border-radius: 12px;
padding: 11px 12px;
margin-bottom: 2px;
font-size: 13.5px;
font-size: 14px;
line-height: 1.35;
font-weight: 500;
transition: background 180ms ease, border-color 180ms ease, color 180ms ease, box-shadow 180ms ease;
@ -412,11 +487,12 @@ body {
min-width: 0;
overflow-y: auto;
height: 100%;
background: #f9fafb;
}
.main-inner {
max-width: 1200px;
padding: 20px 24px 32px;
max-width: none;
padding: 24px;
}
.admin-tab-wrap {
@ -471,6 +547,78 @@ body {
color: #64748b;
}
.page-hero-card {
margin-bottom: 16px;
border: 1px solid #fed7aa;
border-radius: 16px;
background: #fff;
box-shadow: 0 10px 28px -24px rgba(15, 23, 42, 0.42);
padding: 18px;
}
.admin-link-tabs {
display: inline-flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 14px;
}
.admin-link-tab {
text-decoration: none;
border: 1px solid #cbd5e1;
border-radius: 10px;
background: #fff;
color: #475569;
padding: 8px 12px;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.01em;
transition: all 150ms ease;
}
.admin-link-tab:hover {
border-color: #94a3b8;
color: #0f172a;
}
.admin-link-tab.active {
border-color: #ffc9ac;
background: #fff1e8;
color: #c2410c;
}
.admin-segmented {
margin: 14px 0 12px;
display: inline-flex;
gap: 6px;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 10px;
padding: 4px;
}
.admin-segment {
border: 1px solid transparent;
background: transparent;
color: #475569;
border-radius: 8px;
padding: 7px 12px;
font-size: 12px;
font-weight: 700;
cursor: pointer;
}
.admin-segment.active {
background: #fff1e8;
border-color: #ffc9ac;
color: #c2410c;
}
.admin-segment:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.grid {
display: grid;
grid-template-columns: 1.2fr 1fr;
@ -642,6 +790,10 @@ body {
background: #fff7ed;
}
.list-table tbody tr.row-selected td {
background: #fff7ed;
}
.align-right {
text-align: right !important;
}
@ -652,6 +804,42 @@ body {
gap: 8px;
}
.action-icon-btn {
border: 1px solid #d1d5db;
background: #fff;
color: #334155;
border-radius: 8px;
width: 30px;
height: 30px;
display: inline-flex;
align-items: center;
justify-content: center;
text-decoration: none;
cursor: pointer;
font-size: 13px;
}
.action-icon-btn:hover {
background: #f8fafc;
}
.action-icon-btn.danger {
color: #b91c1c;
border-color: #fca5a5;
}
.admin-pagination {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 10px;
padding: 12px;
border-top: 1px solid #e2e8f0;
background: #fff;
font-size: 12px;
color: #475569;
}
.status-chip {
display: inline-flex;
align-items: center;
@ -714,6 +902,8 @@ body {
@media (max-width: 1000px) {
.shell {
grid-template-columns: 1fr;
padding-top: 72px;
height: calc(100vh - 72px);
}
.sidebar {
@ -727,11 +917,23 @@ body {
}
.admin-header-inner {
min-height: 62px;
height: 56px;
width: calc(100% - 24px);
margin: 0 12px;
gap: 10px;
}
.admin-brand img {
height: 36px;
height: 32px;
}
.admin-page-heading {
font-size: 14px;
}
.admin-role-chip,
.admin-avatar-meta {
display: none;
}
}

View file

@ -49,6 +49,29 @@ const TAB_SETS: Array<{ prefixes: string[]; tabs: Tab[] }> = [
},
];
const PAGE_TITLES: Array<{ prefix: string; title: string }> = [
{ prefix: '/admin/approval', title: 'Approval Management' },
{ prefix: '/admin/users', title: 'External User Management' },
{ prefix: '/admin/company', title: 'Company Management' },
{ prefix: '/admin/customer', title: 'Customer Management' },
{ prefix: '/admin/candidate', title: 'Candidate Management' },
{ prefix: '/admin/photographer', title: 'Photographer Management' },
{ prefix: '/admin/makeup-artist', title: 'Makeup Artist Management' },
{ prefix: '/admin/tutors', title: 'Tutor Management' },
{ prefix: '/admin/developers', title: 'Developer Management' },
{ prefix: '/admin/jobs', title: 'Jobs Management' },
{ prefix: '/admin/leads', title: 'Leads Management' },
{ prefix: '/admin/pricing', title: 'Pricing Management' },
{ prefix: '/admin/invoice', title: 'Invoice Management' },
{ prefix: '/admin/credit', title: 'Credit Management' },
{ prefix: '/admin/ledger', title: 'Ledger Management' },
{ prefix: '/admin/report', title: 'Report Management' },
{ prefix: '/admin/roles', title: 'Internal Role Management' },
{ prefix: '/admin/runtime-roles', title: 'External Role Management' },
{ prefix: '/admin/onboarding-schemas', title: 'Onboarding Management' },
{ prefix: '/admin', title: 'Dashboard' },
];
export default function AdminShell(props: { children: JSX.Element }) {
const location = useLocation();
const navigate = useNavigate();
@ -69,6 +92,14 @@ export default function AdminShell(props: { children: JSX.Element }) {
return location.pathname === tab.href || location.pathname.startsWith(`${tab.href}/`);
};
const pageTitle = createMemo(() => {
const path = location.pathname;
for (const item of PAGE_TITLES) {
if (path === item.prefix || path.startsWith(`${item.prefix}/`)) return item.title;
}
return 'Dashboard';
});
onMount(() => {
if (!hasAdminSession()) {
const from = encodeURIComponent(location.pathname + location.search);
@ -87,16 +118,26 @@ export default function AdminShell(props: { children: JSX.Element }) {
<div class="admin-root">
<header class="admin-header">
<div class="admin-header-inner">
<div class="admin-brand">
<img src="/nxtgauge-logo.png" alt="NXTGAUGE" />
<div>
<p class="admin-brand-kicker">Administration</p>
<h1>NXTGAUGE Admin Panel</h1>
<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="btn" type="button" onClick={onLogout}>Logout</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 4H9m4 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
</svg>
</button>
</div>
</div>
</header>

View file

@ -30,8 +30,10 @@ interface ApprovalRule {
const ENTITY_TYPE_OPTIONS = ['JOB_POST', 'COMPANY', 'LEAD', 'INVOICE'];
const APPROVER_TYPE_OPTIONS = ['USER', 'ROLE'];
const REQUEST_FILTERS = ['ALL', 'PROFILE', 'JOB', 'REQUIREMENT'];
type StatusTab = 'PENDING' | 'APPROVED' | 'REJECTED' | 'CHANGES_REQUESTED' | 'CANCELLED' | 'rules';
type PanelTab = 'list' | 'view';
const STATUS_TABS: { key: StatusTab; label: string }[] = [
{ key: 'PENDING', label: 'Pending' },
@ -64,17 +66,40 @@ async function fetchRules(): Promise<ApprovalRule[]> {
}
}
function statusValue(item: Approval) {
return (item.requestStatus || item.status || 'PENDING').toUpperCase();
}
function requestTypeValue(item: Approval) {
return (item.requestType || item.type || 'OTHER').toUpperCase();
}
function requestClass(item: Approval): 'PROFILE' | 'JOB' | 'REQUIREMENT' | 'OTHER' {
const t = requestTypeValue(item);
if (t.includes('JOB')) return 'JOB';
if (t.includes('LEAD') || t.includes('REQUIREMENT')) return 'REQUIREMENT';
if (t.includes('PROFILE') || t.includes('USER') || t.includes('COMPANY') || t.includes('PROFESSIONAL') || t.includes('CUSTOMER')) return 'PROFILE';
return 'OTHER';
}
function StatusBadge(props: { status: string }) {
const s = (props.status || '').toUpperCase();
const s = props.status.toUpperCase();
if (s === 'APPROVED') return <span class="status-chip active">APPROVED</span>;
if (s === 'REJECTED') return <span class="status-chip" style="background:#ef4444;color:#fff;border-color:#ef4444">REJECTED</span>;
if (s === 'CHANGES_REQUESTED') return <span class="status-chip" style="background:#3b82f6;color:#fff;border-color:#3b82f6">CHANGES REQUESTED</span>;
if (s === 'CANCELLED') return <span class="status-chip" style="background:#94a3b8;color:#fff;border-color:#94a3b8">CANCELLED</span>;
return <span class="status-chip" style="background:#f59e0b;color:#fff;border-color:#f59e0b">{props.status || 'PENDING'}</span>;
return <span class="status-chip" style="background:#f59e0b;color:#fff;border-color:#f59e0b">{s}</span>;
}
export default function ApprovalPage() {
const [activeTab, setActiveTab] = createSignal<StatusTab>('PENDING');
const [panelTab, setPanelTab] = createSignal<PanelTab>('list');
const [selectedApproval, setSelectedApproval] = createSignal<Approval | null>(null);
const [search, setSearch] = createSignal('');
const [requestFilter, setRequestFilter] = createSignal('ALL');
const [currentPage, setCurrentPage] = createSignal(1);
const perPage = 10;
const [approvals, { refetch: refetchApprovals }] = createResource(fetchApprovals);
const [acting, setActing] = createSignal('');
@ -89,21 +114,48 @@ export default function ApprovalPage() {
const [deletingRule, setDeletingRule] = createSignal('');
const [submittingRule, setSubmittingRule] = createSignal(false);
const tabApprovals = createMemo(() => {
const filteredApprovals = createMemo(() => {
const tab = activeTab();
if (tab === 'rules') return [];
const q = search().trim().toLowerCase();
const rf = requestFilter();
const list = approvals() ?? [];
if (tab === 'rules') return [] as Approval[];
return list.filter((a) => {
const s = (a.requestStatus || a.status || 'PENDING').toUpperCase();
return s === tab;
const matchesStatus = statusValue(a) === tab;
if (!matchesStatus) return false;
const cls = requestClass(a);
const matchesType = rf === 'ALL' || cls === rf;
if (!matchesType) return false;
if (!q) return true;
const requesterName = (a.requester?.name || a.requesterName || a.requester_name || '').toLowerCase();
const requesterEmail = (a.requester?.email || a.requesterEmail || a.requester_email || '').toLowerCase();
const requestType = requestTypeValue(a).toLowerCase();
return requesterName.includes(q) || requesterEmail.includes(q) || requestType.includes(q) || a.id.toLowerCase().includes(q);
});
});
// Count per status for badges
const totalPages = createMemo(() => Math.max(1, Math.ceil(filteredApprovals().length / perPage)));
const paginatedApprovals = createMemo(() => {
const start = (currentPage() - 1) * perPage;
return filteredApprovals().slice(start, start + perPage);
});
const countFor = (status: string) => {
const list = approvals() ?? [];
if (status === 'rules') return (rules() ?? []).length;
return list.filter((a) => (a.requestStatus || a.status || 'PENDING').toUpperCase() === status).length;
return list.filter((a) => statusValue(a) === status).length;
};
const selectApproval = (approval: Approval) => {
setSelectedApproval(approval);
setPanelTab('view');
};
const handleAction = async (id: string, newStatus: 'APPROVED' | 'REJECTED' | 'CHANGES_REQUESTED' | 'CANCELLED') => {
@ -117,6 +169,10 @@ export default function ApprovalPage() {
});
if (!res.ok) throw new Error(`Failed to ${newStatus.toLowerCase()} approval`);
refetchApprovals();
if (selectedApproval()?.id === id) {
setSelectedApproval((prev) => (prev ? { ...prev, status: newStatus, requestStatus: newStatus } : prev));
}
} catch (err: any) {
setApprovalError(err.message || 'Action failed');
} finally {
@ -174,15 +230,18 @@ export default function ApprovalPage() {
</div>
</div>
{/* Status tabs */}
<div style="display:flex;border-bottom:2px solid #e2e8f0;margin-bottom:20px;gap:0;overflow-x:auto;">
<div style="display:flex;border-bottom:2px solid #e2e8f0;margin-bottom:16px;gap:0;overflow-x:auto;">
{STATUS_TABS.map((t) => {
const count = countFor(t.key);
return (
<button
type="button"
class={`admin-tab${activeTab() === t.key ? ' active' : ''}`}
onClick={() => setActiveTab(t.key)}
onClick={() => {
setActiveTab(t.key);
setPanelTab('list');
setCurrentPage(1);
}}
style="white-space:nowrap;display:flex;align-items:center;gap:6px"
>
{t.label}
@ -196,101 +255,153 @@ export default function ApprovalPage() {
})}
</div>
{/* Approvals content */}
<Show when={activeTab() !== 'rules'}>
<div class="admin-segmented" style="margin-top:0; margin-bottom:12px;">
<button class={`admin-segment ${panelTab() === 'list' ? 'active' : ''}`} type="button" onClick={() => setPanelTab('list')}>
Approval List
</button>
<button class={`admin-segment ${panelTab() === 'view' ? 'active' : ''}`} type="button" disabled={!selectedApproval()} onClick={() => setPanelTab('view')}>
Approval Detail
</button>
</div>
<Show when={approvalError()}>
<div class="error-box" style="margin-bottom:12px">{approvalError()}</div>
</Show>
<section class="card" style="padding: 0; overflow: hidden;">
<div class="table-wrap">
<table class="list-table">
<thead>
<tr>
<th>Requester</th>
<th>Request Type</th>
<th>Status</th>
<th>Priority</th>
<th>Submitted At</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...</td></tr>
</Show>
<Show when={!approvals.loading && approvals.error}>
<tr><td colspan="6" style="text-align:center;padding:32px;color:#b91c1c">Failed to load approvals.</td></tr>
</Show>
<Show when={!approvals.loading && !approvals.error && tabApprovals().length === 0}>
<tr><td colspan="6" style="text-align:center;padding:32px;color:#94a3b8">No {activeTab().toLowerCase().replace('_', ' ')} approvals.</td></tr>
</Show>
<Show when={!approvals.loading && !approvals.error && tabApprovals().length > 0}>
<For each={tabApprovals()}>
{(item) => {
const requesterName = item.requester?.name || item.requesterName || item.requester_name || '—';
const requesterEmail = item.requester?.email || item.requesterEmail || item.requester_email || '';
const status = (item.requestStatus || item.status || 'PENDING').toUpperCase();
const requestType = item.requestType || item.type || '—';
const submittedAt = item.createdAt || item.created_at;
return (
<tr>
<td>
<div style="font-weight:600;color:#0f172a">{requesterName}</div>
<Show when={requesterEmail}>
<div style="font-size:12px;color:#64748b">{requesterEmail}</div>
</Show>
</td>
<td style="color:#475569">{requestType}</td>
<td><StatusBadge status={status} /></td>
<td style="color:#475569">{item.priority ?? '—'}</td>
<td style="color:#475569">{submittedAt ? new Date(submittedAt).toLocaleString() : '—'}</td>
<td>
<div class="table-actions">
<Show when={status !== 'APPROVED'}>
<button
class="btn navy"
style="font-size:12px;padding:5px 10px"
disabled={!!acting()}
onClick={() => handleAction(item.id, 'APPROVED')}
>
{acting() === `${item.id}-APPROVED` ? '...' : 'Approve'}
</button>
</Show>
<Show when={status === 'PENDING'}>
<button
class="btn"
style="font-size:12px;padding:5px 10px"
disabled={!!acting()}
onClick={() => handleAction(item.id, 'CHANGES_REQUESTED')}
>
{acting() === `${item.id}-CHANGES_REQUESTED` ? '...' : 'Request Changes'}
</button>
</Show>
<Show when={status !== 'REJECTED' && status !== 'CANCELLED'}>
<button
class="btn danger"
style="font-size:12px;padding:5px 10px"
disabled={!!acting()}
onClick={() => handleAction(item.id, 'REJECTED')}
>
{acting() === `${item.id}-REJECTED` ? '...' : 'Reject'}
</button>
</Show>
</div>
</td>
</tr>
);
}}
</For>
</Show>
</tbody>
</table>
<Show when={panelTab() === 'list'}>
<div class="card" style="margin-bottom:12px;display:flex;gap:10px;flex-wrap:wrap;align-items:center;">
<input
type="text"
placeholder="Search by requester, email, type, or ID..."
value={search()}
onInput={(e) => {
setSearch(e.currentTarget.value);
setCurrentPage(1);
}}
style="border:1px solid #cbd5e1;border-radius:6px;padding:7px 12px;font-size:14px;width:300px;outline:none;"
/>
<select
value={requestFilter()}
onChange={(e) => {
setRequestFilter(e.currentTarget.value);
setCurrentPage(1);
}}
style="border:1px solid #cbd5e1;border-radius:6px;padding:7px 12px;font-size:14px;background:#fff;outline:none;"
>
<For each={REQUEST_FILTERS}>{(r) => <option value={r}>{r}</option>}</For>
</select>
<span style="font-size:12px;color:#64748b;margin-left:auto;">{filteredApprovals().length} records</span>
</div>
</section>
<section class="card" style="padding: 0; overflow: hidden;">
<div class="table-wrap">
<table class="list-table">
<thead>
<tr>
<th>Approval ID</th>
<th>Requester</th>
<th>Request Type</th>
<th>Status</th>
<th>Priority</th>
<th>Submitted At</th>
<th class="align-right">Actions</th>
</tr>
</thead>
<tbody>
<Show when={approvals.loading}>
<tr><td colspan="7" style="text-align:center;padding:32px;color:#64748b">Loading...</td></tr>
</Show>
<Show when={!approvals.loading && approvals.error}>
<tr><td colspan="7" style="text-align:center;padding:32px;color:#b91c1c">Failed to load approvals.</td></tr>
</Show>
<Show when={!approvals.loading && !approvals.error && paginatedApprovals().length === 0}>
<tr><td colspan="7" style="text-align:center;padding:32px;color:#94a3b8">No matching approvals.</td></tr>
</Show>
<Show when={!approvals.loading && !approvals.error && paginatedApprovals().length > 0}>
<For each={paginatedApprovals()}>
{(item) => {
const requesterName = item.requester?.name || item.requesterName || item.requester_name || '—';
const requesterEmail = item.requester?.email || item.requesterEmail || item.requester_email || '';
const status = statusValue(item);
const requestType = requestTypeValue(item);
const submittedAt = item.createdAt || item.created_at;
return (
<tr>
<td style="font-family:ui-monospace,SFMono-Regular,Menlo,monospace;color:#64748b">{item.id.slice(0, 8)}...</td>
<td>
<div style="font-weight:600;color:#0f172a">{requesterName}</div>
<Show when={requesterEmail}>
<div style="font-size:12px;color:#64748b">{requesterEmail}</div>
</Show>
</td>
<td style="color:#475569">{requestType}</td>
<td><StatusBadge status={status} /></td>
<td style="color:#475569">{item.priority ?? '—'}</td>
<td style="color:#475569">{submittedAt ? new Date(submittedAt).toLocaleString() : '—'}</td>
<td>
<div class="table-actions">
<button class="action-icon-btn" type="button" onClick={() => selectApproval(item)} title="View Detail">👁</button>
<Show when={status !== 'APPROVED'}>
<button class="action-icon-btn" type="button" disabled={!!acting()} onClick={() => handleAction(item.id, 'APPROVED')} title="Approve"></button>
</Show>
<Show when={status === 'PENDING'}>
<button class="action-icon-btn" type="button" disabled={!!acting()} onClick={() => handleAction(item.id, 'CHANGES_REQUESTED')} title="Request Changes"></button>
</Show>
<Show when={status !== 'REJECTED' && status !== 'CANCELLED'}>
<button class="action-icon-btn danger" type="button" disabled={!!acting()} onClick={() => handleAction(item.id, 'REJECTED')} title="Reject"></button>
</Show>
</div>
</td>
</tr>
);
}}
</For>
</Show>
</tbody>
</table>
</div>
<div class="admin-pagination">
<button class="btn" type="button" disabled={currentPage() <= 1} onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}>Previous</button>
<span>Page {currentPage()} of {totalPages()}</span>
<button class="btn" type="button" disabled={currentPage() >= totalPages()} onClick={() => setCurrentPage((p) => Math.min(totalPages(), p + 1))}>Next</button>
</div>
</section>
</Show>
<Show when={panelTab() === 'view'}>
<section class="card">
<Show when={selectedApproval()} fallback={<p class="notice">Select an approval from list to view details.</p>}>
<div class="list-header">
<h2>Approval Details</h2>
<button class="btn" type="button" onClick={() => setPanelTab('list')}>Back To List</button>
</div>
<div class="grid" style="margin-top:0;">
<div class="list-item">
<h3>Request Summary</h3>
<p><strong>Approval ID:</strong> {selectedApproval()!.id}</p>
<p><strong>Type:</strong> {requestTypeValue(selectedApproval()!)}</p>
<p><strong>Status:</strong> {statusValue(selectedApproval()!)}</p>
<p><strong>Priority:</strong> {selectedApproval()!.priority ?? '—'}</p>
<p><strong>Submitted:</strong> {(selectedApproval()!.createdAt || selectedApproval()!.created_at) ? new Date((selectedApproval()!.createdAt || selectedApproval()!.created_at)!).toLocaleString() : '—'}</p>
</div>
<div class="list-item">
<h3>Requester</h3>
<p><strong>Name:</strong> {selectedApproval()!.requester?.name || selectedApproval()!.requesterName || selectedApproval()!.requester_name || '—'}</p>
<p><strong>Email:</strong> {selectedApproval()!.requester?.email || selectedApproval()!.requesterEmail || selectedApproval()!.requester_email || '—'}</p>
<p><strong>Class:</strong> {requestClass(selectedApproval()!)}</p>
<div class="actions">
<button class="btn navy" type="button" disabled={!!acting()} onClick={() => handleAction(selectedApproval()!.id, 'APPROVED')}>Approve</button>
<button class="btn" type="button" disabled={!!acting()} onClick={() => handleAction(selectedApproval()!.id, 'CHANGES_REQUESTED')}>Request Changes</button>
<button class="btn danger" type="button" disabled={!!acting()} onClick={() => handleAction(selectedApproval()!.id, 'REJECTED')}>Reject</button>
</div>
</div>
</div>
</Show>
</section>
</Show>
</Show>
{/* Rules tab */}
<Show when={activeTab() === 'rules'}>
<Show when={ruleError()}>
<div class="error-box" style="margin-bottom:12px">{ruleError()}</div>
@ -321,7 +432,7 @@ export default function ApprovalPage() {
</div>
<div class="field">
<label>Priority</label>
<input type="number" min="1" value={newPriority()} onInput={(e) => setNewPriority(parseInt(e.currentTarget.value) || 1)} />
<input type="number" min="1" value={newPriority()} onInput={(e) => setNewPriority(parseInt(e.currentTarget.value, 10) || 1)} />
</div>
</div>
<div class="actions" style="margin-top:14px;">
@ -363,11 +474,7 @@ export default function ApprovalPage() {
<td style="color:#475569">{rule.priority ?? '—'}</td>
<td>
<div class="table-actions">
<button
class="btn danger"
disabled={deletingRule() === rule.id}
onClick={() => handleDeleteRule(rule.id)}
>
<button class="btn danger" disabled={deletingRule() === rule.id} onClick={() => handleDeleteRule(rule.id)}>
{deletingRule() === rule.id ? '...' : 'Delete'}
</button>
</div>

View file

@ -1,4 +1,4 @@
import { A, useNavigate } from '@solidjs/router';
import { A } from '@solidjs/router';
import { createResource, createSignal, Show } from 'solid-js';
import AdminShell from '~/components/AdminShell';
@ -33,7 +33,6 @@ async function loadSchemas(): Promise<OnboardingSchema[]> {
}
export default function OnboardingSchemasPage() {
const navigate = useNavigate();
const [schemas, { refetch }] = createResource(loadSchemas);
const [deleteError, setDeleteError] = createSignal('');
const [deleting, setDeleting] = createSignal('');
@ -63,6 +62,12 @@ export default function OnboardingSchemasPage() {
<A class="btn navy" href="/admin/onboarding-schemas/new">Create Onboarding Flow</A>
</div>
<nav class="admin-link-tabs" aria-label="Onboarding Management Navigation">
<A class="admin-link-tab active" href="/admin/onboarding-schemas">Onboarding Schemas</A>
<A class="admin-link-tab" href="/admin/runtime-roles">External Runtime Roles</A>
<A class="admin-link-tab" href="/admin/roles">Internal Roles</A>
</nav>
<Show when={deleteError()}>
<div class="error-box">{deleteError()}</div>
</Show>
@ -108,13 +113,14 @@ export default function OnboardingSchemasPage() {
</td>
<td>
<div class="table-actions">
<button class="btn" onClick={() => navigate(`/admin/onboarding-schemas/${schema.id}`)}>Open</button>
<A class="action-icon-btn" href={`/admin/onboarding-schemas/${schema.id}`} title="Open Flow">👁</A>
<button
class="btn danger"
class="action-icon-btn danger"
disabled={deleting() === schema.id}
onClick={() => handleDelete(schema.id, schema.title)}
title="Delete Flow"
>
{deleting() === schema.id ? '...' : 'Delete'}
{deleting() === schema.id ? '...' : '🗑'}
</button>
</div>
</td>

View file

@ -1,4 +1,4 @@
import { A, useNavigate } from '@solidjs/router';
import { A } from '@solidjs/router';
import { createResource, createSignal, Show } from 'solid-js';
import AdminShell from '~/components/AdminShell';
@ -29,7 +29,6 @@ async function loadInternalRoles(): Promise<Role[]> {
}
export default function InternalRolesPage() {
const navigate = useNavigate();
const [roles, { refetch }] = createResource(loadInternalRoles);
const [deleting, setDeleting] = createSignal('');
const [deleteError, setDeleteError] = createSignal('');
@ -51,7 +50,7 @@ export default function InternalRolesPage() {
return (
<AdminShell>
<div class="page-actions">
<div class="page-hero-card page-actions">
<div>
<h1 class="page-title">Internal Role Management</h1>
<p class="page-subtitle">Manage internal employee roles and permissions from one clean list.</p>
@ -59,6 +58,12 @@ export default function InternalRolesPage() {
<A class="btn navy" href="/admin/roles/create">Create Internal Role</A>
</div>
<nav class="admin-link-tabs" aria-label="Role Management Navigation">
<A class="admin-link-tab active" href="/admin/roles">Internal Roles</A>
<A class="admin-link-tab" href="/admin/runtime-roles">External Runtime Roles</A>
<A class="admin-link-tab" href="/admin/onboarding-schemas">Onboarding Schemas</A>
</nav>
<Show when={deleteError()}>
<div class="error-box">{deleteError()}</div>
</Show>
@ -101,19 +106,15 @@ export default function InternalRolesPage() {
<td style="color:#475569;">{role.description || 'No description added yet.'}</td>
<td>
<div class="table-actions">
<A class="btn" href={`/admin/roles/${role.id}`}>View</A>
<A class="action-icon-btn" href={`/admin/roles/${role.id}`} title="View Role">👁</A>
<A class="action-icon-btn" href={`/admin/roles/${role.id}/edit`} title="Edit Role"></A>
<button
class="btn"
onClick={() => navigate(`/admin/roles/${role.id}/edit`)}
>
Edit
</button>
<button
class="btn danger"
class="action-icon-btn danger"
disabled={deleting() === role.id}
onClick={() => handleDelete(role.id, role.name)}
title="Delete Role"
>
{deleting() === role.id ? 'Deleting...' : 'Delete'}
{deleting() === role.id ? '...' : '🗑'}
</button>
</div>
</td>

View file

@ -1,5 +1,5 @@
import { A, useNavigate } from '@solidjs/router';
import { createResource, createSignal, Show } from 'solid-js';
import { A, useSearchParams } from '@solidjs/router';
import { createMemo, createResource, createSignal, Show } from 'solid-js';
import AdminShell from '~/components/AdminShell';
const API = '/api/gateway';
@ -35,10 +35,11 @@ async function loadExternalRoles(): Promise<ExternalRole[]> {
}
export default function RuntimeRolesPage() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const [roles, { refetch }] = createResource(loadExternalRoles);
const [deleting, setDeleting] = createSignal('');
const [deleteError, setDeleteError] = createSignal('');
const selectedRoleKey = createMemo(() => (searchParams.roleKey || '').toLowerCase());
const handleDelete = async (id: string, name: string) => {
if (!confirm(`Delete external role "${name}"?`)) return;
@ -61,9 +62,18 @@ export default function RuntimeRolesPage() {
<h1 class="page-title">External Role Management</h1>
<p class="page-subtitle">Manage canonical external runtime roles, enabled modules, onboarding assignment, and approval gates from one place.</p>
</div>
<A class="btn navy" href="/admin/runtime-roles/new">Create External Role</A>
<div class="actions" style="margin-top:0">
<A class="btn" href="/admin/role-ui-configs">Inspector</A>
<A class="btn navy" href="/admin/runtime-roles/new">Create External Role</A>
</div>
</div>
<nav class="admin-link-tabs" aria-label="External Management Navigation">
<A class="admin-link-tab active" href="/admin/runtime-roles">External Runtime Roles</A>
<A class="admin-link-tab" href="/admin/onboarding-schemas">Onboarding Schemas</A>
<A class="admin-link-tab" href="/admin/roles">Internal Roles</A>
</nav>
<Show when={deleteError()}>
<div class="error-box">{deleteError()}</div>
</Show>
@ -102,7 +112,7 @@ export default function RuntimeRolesPage() {
</Show>
<Show when={!roles.loading && !roles.error && (roles()?.length ?? 0) > 0}>
{roles()!.map((role) => (
<tr>
<tr class={selectedRoleKey() === role.roleKey.toLowerCase() ? 'row-selected' : ''}>
<td>
<div>
<p style="margin:0;font-weight:600;color:#0f172a">{role.displayName}</p>
@ -119,13 +129,15 @@ export default function RuntimeRolesPage() {
</td>
<td>
<div class="table-actions">
<A class="btn" href={`/admin/runtime-roles/${encodeURIComponent(role.roleKey)}`}>Edit</A>
<A class="action-icon-btn" href={`/admin/runtime-roles/${encodeURIComponent(role.roleKey)}`} target="_blank" rel="noreferrer" title="View External Role">👁</A>
<A class="action-icon-btn" href={`/admin/runtime-roles/${encodeURIComponent(role.roleKey)}`} title="Edit External Role"></A>
<button
class="btn danger"
class="action-icon-btn danger"
disabled={deleting() === role.id}
onClick={() => handleDelete(role.id, role.displayName)}
title="Delete External Role"
>
{deleting() === role.id ? '...' : 'Delete'}
{deleting() === role.id ? '...' : '🗑'}
</button>
</div>
</td>

View file

@ -14,6 +14,9 @@ interface User {
status: 'ACTIVE' | 'INACTIVE' | 'PENDING';
created_at?: string;
createdAt?: string;
roleId?: string;
role_name?: string;
userType?: string | number;
}
const ROLE_OPTIONS = [
@ -60,9 +63,13 @@ function StatusBadge(props: { status: string }) {
export default function UsersPage() {
const [users, { refetch }] = createResource(fetchUsers);
const [activeTab, setActiveTab] = createSignal<'list' | 'view'>('list');
const [selectedUser, setSelectedUser] = createSignal<User | null>(null);
const [search, setSearch] = createSignal('');
const [filterRole, setFilterRole] = createSignal('');
const [filterStatus, setFilterStatus] = createSignal('');
const [currentPage, setCurrentPage] = createSignal(1);
const usersPerPage = 10;
const filtered = createMemo(() => {
const list = users() ?? [];
@ -79,6 +86,25 @@ export default function UsersPage() {
});
});
const totalPages = createMemo(() => {
const count = filtered().length;
return Math.max(1, Math.ceil(count / usersPerPage));
});
const paginated = createMemo(() => {
const page = currentPage();
const start = (page - 1) * usersPerPage;
return filtered().slice(start, start + usersPerPage);
});
const shortId = (id: string) => `${id.slice(0, 8)}...`;
const registrationRole = (u: User) => (u.role_name || u.role || 'UNKNOWN').toUpperCase();
const onView = (user: User) => {
setSelectedUser(user);
setActiveTab('view');
};
return (
<AdminShell>
<div class="page-actions">
@ -88,93 +114,167 @@ export default function UsersPage() {
</div>
</div>
{/* Filters */}
<div class="card" style="margin-bottom:16px;display:flex;gap:12px;flex-wrap:wrap;align-items:center;">
<input
type="text"
placeholder="Search by name or email..."
value={search()}
onInput={(e) => setSearch(e.currentTarget.value)}
style="border:1px solid #cbd5e1;border-radius:6px;padding:7px 12px;font-size:14px;width:260px;outline:none;"
/>
<select
value={filterRole()}
onChange={(e) => setFilterRole(e.currentTarget.value)}
style="border:1px solid #cbd5e1;border-radius:6px;padding:7px 12px;font-size:14px;background:#fff;outline:none;"
<div class="admin-segmented">
<button
class={`admin-segment ${activeTab() === 'list' ? 'active' : ''}`}
type="button"
onClick={() => setActiveTab('list')}
>
<option value="">All Roles</option>
<For each={ROLE_OPTIONS}>
{(r) => <option value={r}>{r}</option>}
</For>
</select>
<select
value={filterStatus()}
onChange={(e) => setFilterStatus(e.currentTarget.value)}
style="border:1px solid #cbd5e1;border-radius:6px;padding:7px 12px;font-size:14px;background:#fff;outline:none;"
User List
</button>
<button
class={`admin-segment ${activeTab() === 'view' ? 'active' : ''}`}
type="button"
disabled={!selectedUser()}
onClick={() => setActiveTab('view')}
>
<option value="">All Status</option>
<option value="ACTIVE">ACTIVE</option>
<option value="INACTIVE">INACTIVE</option>
<option value="PENDING">PENDING</option>
</select>
<Show when={!users.loading}>
<span style="font-size:13px;color:#64748b;margin-left:auto;">
Showing {filtered().length} of {users()?.length ?? 0} users
</span>
</Show>
View Details
</button>
</div>
<section class="card" style="padding: 0; overflow: hidden;">
<div class="table-wrap">
<table class="list-table">
<thead>
<tr>
<th>Name</th>
<th>Email</th>
<th>Role</th>
<th>Status</th>
<th>Created At</th>
<th class="align-right">Actions</th>
</tr>
</thead>
<tbody>
<Show when={users.loading}>
<tr><td colspan="6" style="text-align:center;padding:32px;color:#64748b">Loading...</td></tr>
</Show>
<Show when={!users.loading && users.error}>
<tr><td colspan="6" style="text-align:center;padding:32px;color:#b91c1c">Failed to load. Is the backend running?</td></tr>
</Show>
<Show when={!users.loading && !users.error && filtered().length === 0}>
<tr><td colspan="6" style="text-align:center;padding:32px;color:#94a3b8">No users found.</td></tr>
</Show>
<Show when={!users.loading && !users.error && filtered().length > 0}>
<For each={filtered()}>
{(item) => (
<tr>
<td style="font-weight:600;color:#0f172a">{item.name || item.full_name || '—'}</td>
<td style="color:#475569">{item.email}</td>
<td style="color:#475569">{item.role_name || item.role || '—'}</td>
<td>
<StatusBadge status={item.status} />
</td>
<td style="color:#475569">
{(item.created_at || item.createdAt)
? new Date((item.created_at || item.createdAt)!).toLocaleDateString()
: '—'}
</td>
<td>
<div class="table-actions">
<A class="btn" href={`/admin/users/${item.id}`}>View</A>
</div>
</td>
</tr>
)}
</For>
</Show>
</tbody>
</table>
{/* Filters */}
<Show when={activeTab() === 'list'}>
<div class="card" style="margin-bottom:16px;display:flex;gap:12px;flex-wrap:wrap;align-items:center;">
<input
type="text"
placeholder="Search by name or email..."
value={search()}
onInput={(e) => {
setSearch(e.currentTarget.value);
setCurrentPage(1);
}}
style="border:1px solid #cbd5e1;border-radius:6px;padding:7px 12px;font-size:14px;width:260px;outline:none;"
/>
<select
value={filterRole()}
onChange={(e) => {
setFilterRole(e.currentTarget.value);
setCurrentPage(1);
}}
style="border:1px solid #cbd5e1;border-radius:6px;padding:7px 12px;font-size:14px;background:#fff;outline:none;"
>
<option value="">All Roles</option>
<For each={ROLE_OPTIONS}>
{(r) => <option value={r}>{r}</option>}
</For>
</select>
<select
value={filterStatus()}
onChange={(e) => {
setFilterStatus(e.currentTarget.value);
setCurrentPage(1);
}}
style="border:1px solid #cbd5e1;border-radius:6px;padding:7px 12px;font-size:14px;background:#fff;outline:none;"
>
<option value="">All Status</option>
<option value="ACTIVE">ACTIVE</option>
<option value="INACTIVE">INACTIVE</option>
<option value="PENDING">PENDING</option>
</select>
<Show when={!users.loading}>
<span style="font-size:13px;color:#64748b;margin-left:auto;">
Showing {paginated().length} of {filtered().length} users
</span>
</Show>
</div>
</section>
</Show>
<Show when={activeTab() === 'list'}>
<section class="card" style="padding: 0; overflow: hidden;">
<div class="table-wrap">
<table class="list-table">
<thead>
<tr>
<th>User ID</th>
<th>Name</th>
<th>Email</th>
<th>Registration Role</th>
<th>Status</th>
<th>Created</th>
<th class="align-right">Actions</th>
</tr>
</thead>
<tbody>
<Show when={users.loading}>
<tr><td colspan="7" style="text-align:center;padding:32px;color:#64748b">Loading...</td></tr>
</Show>
<Show when={!users.loading && users.error}>
<tr><td colspan="7" style="text-align:center;padding:32px;color:#b91c1c">Failed to load. Is the backend running?</td></tr>
</Show>
<Show when={!users.loading && !users.error && paginated().length === 0}>
<tr><td colspan="7" style="text-align:center;padding:32px;color:#94a3b8">No users found.</td></tr>
</Show>
<Show when={!users.loading && !users.error && paginated().length > 0}>
<For each={paginated()}>
{(item) => (
<tr>
<td style="font-family:ui-monospace,SFMono-Regular,Menlo,monospace;color:#64748b">{shortId(item.id)}</td>
<td style="font-weight:600;color:#0f172a">{item.name || item.full_name || '—'}</td>
<td style="color:#475569">{item.email}</td>
<td style="color:#475569">{registrationRole(item)}</td>
<td>
<StatusBadge status={item.status} />
</td>
<td style="color:#475569">
{(item.created_at || item.createdAt)
? new Date((item.created_at || item.createdAt)!).toLocaleDateString()
: '—'}
</td>
<td>
<div class="table-actions">
<button class="action-icon-btn" type="button" onClick={() => onView(item)} title="View Details">👁</button>
<A class="action-icon-btn" href={`/admin/users/${item.id}/edit`} title="Edit User"></A>
<button class="action-icon-btn danger" type="button" title="Delete User">🗑</button>
</div>
</td>
</tr>
)}
</For>
</Show>
</tbody>
</table>
</div>
<div class="admin-pagination">
<button class="btn" type="button" disabled={currentPage() <= 1} onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}>
Previous
</button>
<span>Page {currentPage()} of {totalPages()}</span>
<button class="btn" type="button" disabled={currentPage() >= totalPages()} onClick={() => setCurrentPage((p) => Math.min(totalPages(), p + 1))}>
Next
</button>
</div>
</section>
</Show>
<Show when={activeTab() === 'view'}>
<section class="card">
<Show when={selectedUser()} fallback={<p class="notice">Select a user from list to view details.</p>}>
<div class="list-header">
<h2>User Details</h2>
<button class="btn" type="button" onClick={() => setActiveTab('list')}>Back To List</button>
</div>
<div class="grid" style="margin-top:0">
<div class="list-item">
<h3>Profile</h3>
<p><strong>User ID:</strong> {selectedUser()!.id}</p>
<p><strong>Name:</strong> {selectedUser()!.name || selectedUser()!.full_name || '—'}</p>
<p><strong>Email:</strong> {selectedUser()!.email}</p>
<p><strong>Role:</strong> {registrationRole(selectedUser()!)}</p>
<p><strong>Status:</strong> {selectedUser()!.status}</p>
</div>
<div class="list-item">
<h3>Account</h3>
<p><strong>Created:</strong> {(selectedUser()!.created_at || selectedUser()!.createdAt) ? new Date((selectedUser()!.created_at || selectedUser()!.createdAt)!).toLocaleString() : '—'}</p>
<p><strong>Role ID:</strong> {selectedUser()!.roleId || '—'}</p>
<div class="actions">
<A class="btn navy" href={`/admin/users/${selectedUser()!.id}/edit`}>Edit User</A>
<button class="btn danger" type="button">Deactivate</button>
</div>
</div>
</div>
</Show>
</section>
</Show>
</AdminShell>
);
}