feat(admin): advance Next.js UI parity for shell and core management pages
This commit is contained in:
parent
04a1079f68
commit
09894af444
7 changed files with 716 additions and 247 deletions
262
src/app.css
262
src/app.css
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue