nxtgauge-admin-solid/src/routes/admin/roles/index.tsx

359 lines
18 KiB
TypeScript
Raw Normal View History

import { A, useNavigate } from '@solidjs/router';
import { createResource, createSignal, For, Show } from 'solid-js';
import AdminShell from '~/components/AdminShell';
import { ArrowUpDown, Filter, Download, Eye, Pencil } from 'lucide-solid';
const API = '/api/gateway';
type Role = {
id: string;
key: string;
name: string;
audience: string;
description?: string;
department_name?: string;
is_active: boolean;
users_assigned: number;
permissions_count: number;
created_at: string;
};
type ListResponse = { roles: Role[]; total: number; page: number; per_page: number };
const FALLBACK_ROLES: Role[] = [
{ id: 'r1', key: 'ADM-SYS-001', name: 'System Administrator', audience: 'INTERNAL', department_name: 'Information Technology', is_active: true, users_assigned: 12, permissions_count: 0, created_at: '2024-01-12' },
{ id: 'r2', key: 'FIN-MGR-002', name: 'Finance Manager', audience: 'INTERNAL', department_name: 'Accounting & Finance', is_active: true, users_assigned: 4, permissions_count: 42, created_at: '2024-02-05' },
{ id: 'r3', key: 'HR-COOR-003', name: 'HR Coordinator', audience: 'INTERNAL', department_name: 'Human Resources', is_active: false, users_assigned: 8, permissions_count: 28, created_at: '2024-03-18' },
{ id: 'r4', key: 'ENG-LEAD-004', name: 'Lead Developer', audience: 'INTERNAL', department_name: 'Engineering', is_active: true, users_assigned: 25, permissions_count: 64, created_at: '2024-04-02' },
{ id: 'r5', key: 'SLS-ASSC-005', name: 'Sales Associate', audience: 'INTERNAL', department_name: 'Growth & Sales', is_active: true, users_assigned: 48, permissions_count: 15, created_at: '2024-05-11' },
];
async function loadRoles(params: { q: string; page: number }): Promise<ListResponse> {
try {
const qs = new URLSearchParams({ audience: 'INTERNAL', q: params.q, page: String(params.page), per_page: '10' });
const res = await fetch(`${API}/api/admin/roles?${qs}`);
if (!res.ok) throw new Error('Failed');
const data = await res.json();
if (data.roles?.length > 0) return data;
throw new Error('empty');
} catch {
const q = params.q.toLowerCase();
const filtered = q ? FALLBACK_ROLES.filter((r) => r.name.toLowerCase().includes(q) || r.key.toLowerCase().includes(q)) : FALLBACK_ROLES;
return { roles: filtered, total: filtered.length, page: 1, per_page: 10 };
}
}
function formatDate(iso: string) {
try {
const d = new Date(iso);
return d.toLocaleDateString('en-US', { month: 'short', day: '2-digit', year: 'numeric' });
} catch { return '—'; }
}
function StatusBadge(props: { active: boolean }) {
return (
<span class={`inline-flex items-center gap-1.5 rounded-full border px-3 py-1 text-[12px] font-medium ${
props.active
? 'border-[#FF5E13] bg-[#FFF3EE] text-[#FF5E13]'
: 'border-[#D1D5DB] bg-[#F9FAFB] text-[#6B7280]'
}`}>
<span class={`h-1.5 w-1.5 rounded-full ${props.active ? 'bg-[#FF5E13]' : 'bg-[#9CA3AF]'}`} />
{props.active ? 'Active' : 'Inactive'}
</span>
);
}
function UsersBadge(props: { count: number }) {
return (
<span class="inline-flex h-7 w-7 items-center justify-center rounded-full bg-[#EEF2FF] text-[12px] font-semibold text-[#4F46E5]">
{props.count > 99 ? '99+' : String(props.count).padStart(2, '0')}
</span>
);
}
const TABS = [
{ label: 'All Roles', href: '/admin/roles' },
{ label: 'Create Role', href: '/admin/roles/create' },
{ label: 'View Role', href: '/admin/roles/view' },
{ label: 'Edit Role', href: '/admin/roles/edit' },
];
export default function InternalRolesListPage() {
const navigate = useNavigate();
const [search, setSearch] = createSignal('');
const [debouncedSearch, setDebouncedSearch] = createSignal('');
const [page, setPage] = createSignal(1);
let debounceTimer: ReturnType<typeof setTimeout>;
const handleSearch = (val: string) => {
setSearch(val);
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => { setDebouncedSearch(val); setPage(1); }, 300);
};
const [data] = createResource(
() => ({ q: debouncedSearch(), page: page() }),
loadRoles,
);
const totalPages = () => {
const d = data();
if (!d || d.per_page === 0) return 1;
return Math.max(1, Math.ceil(d.total / d.per_page));
};
const roles = () => data()?.roles ?? [];
return (
<AdminShell>
<div class="w-full space-y-6 pb-8">
{/* Page header */}
<div>
<h1 class="text-[28px] font-bold leading-tight text-[#111827]">Internal Role Management</h1>
<p class="mt-1 text-[14px] text-[#6B7280]">Define and manage organizational access levels with granular permission control.</p>
</div>
{/* Tabs */}
<div class="flex items-center gap-6 border-b border-[#E5E7EB]">
<For each={TABS}>
{(tab) => {
const active = () => tab.href === '/admin/roles';
return (
<A
href={tab.href}
class={`pb-3 text-[14px] font-medium transition-colors ${
active()
? 'border-b-2 border-[#FF5E13] text-[#FF5E13]'
: 'text-[#6B7280] hover:text-[#111827]'
}`}
>
{tab.label}
</A>
);
}}
</For>
</div>
{/* Table card */}
<div class="overflow-hidden rounded-2xl border border-[#E5E7EB] bg-white shadow-sm">
{/* Filter bar */}
<div class="flex items-center gap-2 px-5 py-4">
<input
type="text"
placeholder="Filter by role name or code..."
value={search()}
onInput={(e) => handleSearch(e.currentTarget.value)}
class="h-[34px] flex-1 rounded-lg border border-[#E5E7EB] bg-white px-3 text-[13px] text-[#111827] outline-none placeholder:text-[#9CA3AF] focus:border-[#FF5E13] focus:ring-2 focus:ring-[rgba(255,94,19,0.08)] transition-colors"
/>
<button type="button" class="inline-flex h-[34px] items-center gap-1.5 rounded-lg border border-[#E5E7EB] bg-white px-3 text-[12px] font-medium text-[#374151] hover:bg-[#F9FAFB] transition-colors">
<ArrowUpDown size={13} />
Sort
</button>
<button type="button" class="inline-flex h-[34px] items-center gap-1.5 rounded-lg border border-[#E5E7EB] bg-white px-3 text-[12px] font-medium text-[#374151] hover:bg-[#F9FAFB] transition-colors">
<Filter size={13} />
Filters
</button>
<button type="button" class="inline-flex h-[34px] items-center gap-1.5 rounded-lg bg-[#0D0D2A] px-3 text-[12px] font-semibold text-white hover:bg-[#1a1a3e] transition-colors">
<Download size={13} />
Export
</button>
</div>
{/* Table */}
<div class="overflow-x-auto">
<table class="min-w-full">
<thead>
<tr class="bg-[#0D0D2A] text-left">
<th class="px-5 py-3 text-[11px] font-semibold uppercase tracking-wider text-[#8AACC8]">Role Name</th>
<th class="px-5 py-3 text-[11px] font-semibold uppercase tracking-wider text-[#8AACC8]">Role Code</th>
<th class="px-5 py-3 text-[11px] font-semibold uppercase tracking-wider text-[#8AACC8]">Department</th>
<th class="px-5 py-3 text-[11px] font-semibold uppercase tracking-wider text-[#8AACC8]">Users Assigned</th>
<th class="px-5 py-3 text-[11px] font-semibold uppercase tracking-wider text-[#8AACC8]">Permissions</th>
<th class="px-5 py-3 text-[11px] font-semibold uppercase tracking-wider text-[#8AACC8]">Status</th>
<th class="px-5 py-3 text-[11px] font-semibold uppercase tracking-wider text-[#8AACC8]">Created Date</th>
<th class="px-5 py-3 text-[11px] font-semibold uppercase tracking-wider text-[#8AACC8]">AC</th>
</tr>
</thead>
<tbody class="divide-y divide-[#F3F4F6]">
<Show when={data.loading}>
<For each={[0, 1, 2, 3, 4]}>
{() => (
<tr class="animate-pulse">
<td class="px-5 py-3.5"><div class="h-4 w-36 rounded-lg bg-[#F3F4F6]" /></td>
<td class="px-5 py-3.5"><div class="h-4 w-20 rounded-lg bg-[#F3F4F6]" /></td>
<td class="px-5 py-3.5"><div class="h-4 w-28 rounded-lg bg-[#F3F4F6]" /></td>
<td class="px-5 py-3.5"><div class="h-7 w-7 rounded-full bg-[#F3F4F6]" /></td>
<td class="px-5 py-3.5"><div class="h-4 w-20 rounded-lg bg-[#F3F4F6]" /></td>
<td class="px-5 py-3.5"><div class="h-6 w-20 rounded-full bg-[#F3F4F6]" /></td>
<td class="px-5 py-3.5"><div class="h-4 w-24 rounded-lg bg-[#F3F4F6]" /></td>
<td class="px-5 py-3.5"><div class="h-4 w-12 rounded-lg bg-[#F3F4F6]" /></td>
</tr>
)}
</For>
</Show>
<Show when={!data.loading && roles().length === 0}>
<tr>
<td colspan="8" class="px-6 py-16 text-center">
<p class="text-[15px] font-semibold text-[#111827]">No roles found</p>
<p class="mt-1 text-[13px] text-[#6B7280]">Create your first internal role to control admin access.</p>
<A href="/admin/roles/create" class="mt-4 inline-flex items-center gap-2 rounded-xl bg-[#0D0D2A] px-4 py-2 text-[13px] font-semibold text-white">
Create Role
</A>
</td>
</tr>
</Show>
<Show when={!data.loading}>
<For each={roles()}>
{(role) => (
<tr class="hover:bg-[#FAFAFA] transition-colors">
<td class="px-5 py-3.5">
<p class="text-[14px] font-bold text-[#111827]">{role.name}</p>
</td>
<td class="px-5 py-3.5">
<div class="text-[11px] font-mono leading-[1.6] text-[#9CA3AF]">
{role.key.split('-').map((seg, i, arr) => (
<span class="block">{seg}{i < arr.length - 1 ? '-' : ''}</span>
))}
</div>
</td>
<td class="px-5 py-3.5 text-[13px] text-[#374151]">{role.department_name || '—'}</td>
<td class="px-5 py-3.5">
<UsersBadge count={role.users_assigned} />
</td>
<td class="px-5 py-3.5">
<Show when={role.permissions_count === 0} fallback={
<span class="text-[13px] text-[#374151]">{role.permissions_count} Controls</span>
}>
<span class="text-[13px] font-semibold text-[#FF5E13]">All Access</span>
</Show>
</td>
<td class="px-5 py-3.5">
<StatusBadge active={role.is_active} />
</td>
<td class="px-5 py-3.5 text-[13px] text-[#6B7280]">{formatDate(role.created_at)}</td>
<td class="px-5 py-3.5">
<div class="flex items-center gap-3">
<button
type="button"
onClick={() => navigate(`/admin/roles/${role.id}`)}
class="text-[#9CA3AF] hover:text-[#374151] transition-colors"
aria-label="View role"
>
<Eye size={17} />
</button>
<button
type="button"
onClick={() => navigate(`/admin/roles/${role.id}/edit`)}
class="text-[#9CA3AF] hover:text-[#FF5E13] transition-colors"
aria-label="Edit role"
>
<Pencil size={16} />
</button>
</div>
</td>
</tr>
)}
</For>
</Show>
</tbody>
</table>
</div>
{/* Pagination */}
<Show when={!data.loading && roles().length > 0}>
<div class="flex items-center justify-between border-t border-[#F3F4F6] px-6 py-4">
<p class="text-[13px] text-[#6B7280]">
Showing <span class="font-semibold text-[#111827]">{roles().length}</span> of <span class="font-semibold text-[#111827]">{data()?.total ?? 0}</span> roles
</p>
<div class="flex items-center gap-1.5">
<button
type="button"
disabled={page() === 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
class="inline-flex h-8 w-8 items-center justify-center rounded-lg border border-[#E5E7EB] text-[#6B7280] hover:bg-[#F9FAFB] disabled:opacity-40 disabled:cursor-not-allowed transition-colors text-[16px]"
></button>
<For each={Array.from({ length: Math.min(totalPages(), 5) }, (_, i) => i + 1)}>
{(p) => (
<button
type="button"
onClick={() => setPage(p)}
class={`inline-flex h-8 w-8 items-center justify-center rounded-lg text-[13px] font-medium transition-colors ${
page() === p
? 'bg-[#FF5E13] text-white'
: 'border border-[#E5E7EB] text-[#374151] hover:bg-[#F9FAFB]'
}`}
>{p}</button>
)}
</For>
<button
type="button"
disabled={page() >= totalPages()}
onClick={() => setPage((p) => Math.min(totalPages(), p + 1))}
class="inline-flex h-8 w-8 items-center justify-center rounded-lg border border-[#E5E7EB] text-[#6B7280] hover:bg-[#F9FAFB] disabled:opacity-40 disabled:cursor-not-allowed transition-colors text-[16px]"
></button>
</div>
</div>
</Show>
</div>
{/* Bottom stats row */}
<div class="grid grid-cols-2 gap-5">
{/* Distribution card */}
<div class="rounded-2xl border border-[#E5E7EB] bg-white p-6 shadow-sm">
<div class="flex items-center justify-between mb-5">
<p class="text-[15px] font-semibold text-[#111827]">Distribution</p>
<button type="button" class="text-[#9CA3AF] hover:text-[#374151]">
<svg viewBox="0 0 24 24" class="h-5 w-5" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
</button>
</div>
<div class="flex items-end gap-3 h-[100px]">
{[
{ h: 55, color: '#FFD4C2' },
{ h: 80, color: '#FF5E13' },
{ h: 45, color: '#FFD4C2' },
{ h: 65, color: '#FFD4C2' },
{ h: 38, color: '#FFD4C2' },
].map((bar) => (
<div class="flex-1 rounded-t-md" style={{ height: `${bar.h}%`, background: bar.color }} />
))}
</div>
<p class="mt-4 text-[12px] text-[#6B7280]">
Most users are concentrated in <span class="font-semibold text-[#FF5E13]">Sales</span> and{' '}
<span class="font-semibold text-[#FF5E13]">Engineering</span> departments.
</p>
</div>
{/* Audit Readiness Score card */}
<div class="flex items-center justify-between rounded-2xl bg-[#0D0D2A] p-8 shadow-sm">
<div class="flex-1 pr-8">
<p class="text-[18px] font-bold text-white">Audit Readiness Score</p>
<p class="mt-2 text-[13px] leading-relaxed text-[#8AACC8]">
Your organizational permissions currently align with 94% of compliance standards. Review inactive roles to reach 100%.
</p>
<button type="button" class="mt-5 inline-flex h-10 items-center rounded-xl bg-[#FF5E13] px-5 text-[13px] font-semibold text-white hover:bg-[#e54d0a] transition-colors">
Review Audit Log
</button>
</div>
<div class="relative flex h-[100px] w-[100px] shrink-0 items-center justify-center">
<svg class="h-full w-full -rotate-90" viewBox="0 0 100 100">
<circle cx="50" cy="50" r="42" fill="none" stroke="#1E2D3D" stroke-width="10" />
<circle cx="50" cy="50" r="42" fill="none" stroke="#FF5E13" stroke-width="10"
stroke-dasharray={`${2 * Math.PI * 42 * 0.94} ${2 * Math.PI * 42}`}
stroke-linecap="round" />
</svg>
<div class="absolute inset-0 flex flex-col items-center justify-center">
<span class="text-[22px] font-bold text-white">94%</span>
</div>
</div>
</div>
</div>
</div>
</AdminShell>
);
}