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

328 lines
19 KiB
TypeScript
Raw Normal View History

import { A } from '@solidjs/router';
import { createResource, createSignal, For, Show } from 'solid-js';
import AdminShell from '~/components/AdminShell';
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: 'SUPER_ADMIN', name: 'Super Admin', audience: 'INTERNAL', department_name: 'Engineering', is_active: true, users_assigned: 3, permissions_count: 42, created_at: '2026-01-10' },
{ id: 'r2', key: 'OPS_MANAGER', name: 'Operations Manager', audience: 'INTERNAL', department_name: 'Operations', is_active: true, users_assigned: 7, permissions_count: 28, created_at: '2026-01-15' },
{ id: 'r3', key: 'HR_ADMIN', name: 'HR Admin', audience: 'INTERNAL', department_name: 'Human Resources', is_active: true, users_assigned: 4, permissions_count: 16, created_at: '2026-01-20' },
{ id: 'r4', key: 'FINANCE_VIEWER', name: 'Finance Viewer', audience: 'INTERNAL', department_name: 'Finance', is_active: true, users_assigned: 6, permissions_count: 8, created_at: '2026-02-01' },
{ id: 'r5', key: 'SUPPORT_AGENT', name: 'Support Agent', audience: 'INTERNAL', department_name: 'Customer Success', is_active: false, users_assigned: 12, permissions_count: 10, created_at: '2026-02-10' },
{ id: 'r6', key: 'CONTENT_MOD', name: 'Content Moderator', audience: 'INTERNAL', department_name: 'Marketing', is_active: true, users_assigned: 5, permissions_count: 14, created_at: '2026-02-15' },
];
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 { return new Date(iso).toISOString().slice(0, 10); } catch { return '—'; }
}
function StatusBadge(props: { active: boolean }) {
return (
<span class={`inline-flex items-center rounded-full px-2.5 py-1 text-[11px] font-semibold ${
props.active ? 'bg-[#ECFDF5] text-[#059669]' : 'bg-[#F3F4F6] text-[#6B7280]'
}`}>
<span class={`mr-1.5 h-1.5 w-1.5 rounded-full ${props.active ? 'bg-[#059669]' : 'bg-[#9CA3AF]'}`} />
{props.active ? 'Active' : 'Inactive'}
</span>
);
}
export default function InternalRolesListPage() {
const [search, setSearch] = createSignal('');
const [debouncedSearch, setDebouncedSearch] = createSignal('');
const [page, setPage] = createSignal(1);
const [openMenuId, setOpenMenuId] = createSignal<string | null>(null);
const [deleting, setDeleting] = createSignal('');
let debounceTimer: ReturnType<typeof setTimeout>;
const handleSearch = (val: string) => {
setSearch(val);
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => { setDebouncedSearch(val); setPage(1); }, 300);
};
const [data, { refetch }] = 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 ?? [];
const stats = () => ({
total: data()?.total ?? 0,
active: roles().filter((r) => r.is_active).length,
inactive: roles().filter((r) => !r.is_active).length,
totalUsers: roles().reduce((acc, r) => acc + r.users_assigned, 0),
});
const handleDelete = async (id: string, name: string) => {
if (!confirm(`Delete role "${name}"? This cannot be undone.`)) return;
setDeleting(id);
try {
const res = await fetch(`${API}/api/admin/roles/${id}`, { method: 'DELETE' });
if (!res.ok) throw new Error('Failed');
refetch();
} finally {
setDeleting(''); setOpenMenuId(null);
}
};
return (
<AdminShell>
<div class="w-full space-y-8 pb-8">
{/* Header */}
<div class="flex items-end justify-between">
<div>
<p class="text-[12px] font-semibold uppercase tracking-widest text-[#FF5E13]">Access Control</p>
<h1 class="mt-1 text-[28px] font-bold leading-tight text-[#111827]">Internal Role Management</h1>
<p class="mt-1 text-[14px] text-[#6B7280]">Define and manage roles for internal admin users</p>
</div>
<A
href="/admin/roles/create"
class="inline-flex items-center gap-2 rounded-xl bg-[#0D0D2A] px-5 py-2.5 text-[13px] font-semibold text-white shadow-sm hover:bg-[#1a1a3e] transition-colors"
>
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><path d="M12 5v14M5 12h14" /></svg>
Create Role
</A>
</div>
{/* Summary cards */}
<Show when={!data.loading}>
<div class="grid grid-cols-4 gap-5">
{[
{ label: 'Total Roles', value: stats().total, color: 'text-[#FF5E13]', bg: 'bg-[#FFF1EB]' },
{ label: 'Active Roles', value: stats().active, color: 'text-[#059669]', bg: 'bg-[#ECFDF5]' },
{ label: 'Inactive Roles', value: stats().inactive, color: 'text-[#6B7280]', bg: 'bg-[#F3F4F6]' },
{ label: 'Users Assigned', value: stats().totalUsers, color: 'text-[#2563EB]', bg: 'bg-[#EFF6FF]' },
].map((s) => (
<div class="rounded-2xl border border-[#E5E7EB] bg-white p-5 shadow-sm">
<div class={`inline-flex h-10 w-10 items-center justify-center rounded-xl ${s.bg} ${s.color} text-[16px] font-bold`}>
{s.value}
</div>
<p class="mt-3 text-[13px] font-medium text-[#6B7280]">{s.label}</p>
<p class={`mt-0.5 text-[22px] font-bold tracking-tight ${s.color}`}>{s.value}</p>
</div>
))}
</div>
</Show>
{/* Table card */}
<div class="rounded-2xl border border-[#E5E7EB] bg-white shadow-sm">
{/* Filters */}
<div class="flex items-center gap-3 border-b border-[#F3F4F6] p-5">
<div class="relative flex-1 max-w-sm">
<svg class="pointer-events-none absolute left-3.5 top-1/2 h-4 w-4 -translate-y-1/2 text-[#9CA3AF]" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><circle cx="11" cy="11" r="7" /><path d="m20 20-3.5-3.5" /></svg>
<input
type="text"
placeholder="Search roles..."
value={search()}
onInput={(e) => handleSearch(e.currentTarget.value)}
class="h-[40px] w-full rounded-xl border border-[#E5E7EB] bg-[#F9FAFB] pl-10 pr-4 text-[13px] text-[#111827] outline-none placeholder:text-[#9CA3AF] focus:border-[#FF5E13] focus:bg-white focus:ring-2 focus:ring-[rgba(255,94,19,0.08)] transition-colors"
/>
</div>
<select class="h-[40px] rounded-xl border border-[#E5E7EB] bg-[#F9FAFB] px-3 pr-8 text-[13px] text-[#374151] outline-none focus:border-[#FF5E13] transition-colors">
<option>All Departments</option>
<option>Engineering</option>
<option>Operations</option>
<option>Finance</option>
</select>
<select class="h-[40px] rounded-xl border border-[#E5E7EB] bg-[#F9FAFB] px-3 pr-8 text-[13px] text-[#374151] outline-none focus:border-[#FF5E13] transition-colors">
<option>All Status</option>
<option>Active</option>
<option>Inactive</option>
</select>
<p class="ml-auto text-[13px] text-[#6B7280]">
<Show when={!data.loading}>
<span class="font-semibold text-[#111827]">{data()?.total ?? 0}</span> roles
</Show>
</p>
</div>
{/* Table */}
<div class="overflow-x-auto">
<table class="min-w-full">
<thead>
<tr class="border-b border-[#F3F4F6] bg-[#FAFAFA] text-left">
<th class="px-6 py-3.5 text-[11px] font-semibold uppercase tracking-wider text-[#9CA3AF]">Role Name</th>
<th class="px-6 py-3.5 text-[11px] font-semibold uppercase tracking-wider text-[#9CA3AF]">Role Key</th>
<th class="px-6 py-3.5 text-[11px] font-semibold uppercase tracking-wider text-[#9CA3AF]">Department</th>
<th class="px-6 py-3.5 text-[11px] font-semibold uppercase tracking-wider text-[#9CA3AF]">Users Assigned</th>
<th class="px-6 py-3.5 text-[11px] font-semibold uppercase tracking-wider text-[#9CA3AF]">Permissions</th>
<th class="px-6 py-3.5 text-[11px] font-semibold uppercase tracking-wider text-[#9CA3AF]">Status</th>
<th class="px-6 py-3.5 text-[11px] font-semibold uppercase tracking-wider text-[#9CA3AF]">Created</th>
<th class="px-6 py-3.5 text-[11px] font-semibold uppercase tracking-wider text-[#9CA3AF]">Actions</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-6 py-4"><div class="h-4 w-36 rounded-lg bg-[#F3F4F6]" /></td>
<td class="px-6 py-4"><div class="h-4 w-24 rounded-lg bg-[#F3F4F6]" /></td>
<td class="px-6 py-4"><div class="h-4 w-28 rounded-lg bg-[#F3F4F6]" /></td>
<td class="px-6 py-4"><div class="h-4 w-12 rounded-lg bg-[#F3F4F6]" /></td>
<td class="px-6 py-4"><div class="h-4 w-16 rounded-lg bg-[#F3F4F6]" /></td>
<td class="px-6 py-4"><div class="h-6 w-16 rounded-full bg-[#F3F4F6]" /></td>
<td class="px-6 py-4"><div class="h-4 w-24 rounded-lg bg-[#F3F4F6]" /></td>
<td class="px-6 py-4"><div class="h-8 w-8 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">
<div class="mx-auto flex h-14 w-14 items-center justify-center rounded-2xl bg-[#F9FAFB]">
<svg class="h-7 w-7 text-[#9CA3AF]" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true"><path d="M12 15v2m0 0v2m0-2h2m-2 0H10M9 11V7a3 3 0 0 1 6 0v4" /><path d="M5 11h14a2 2 0 0 1 2 2v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-6a2 2 0 0 1 2-2Z" /></svg>
</div>
<p class="mt-3 text-[15px] font-semibold text-[#111827]">No internal 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">
<svg class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><path d="M12 5v14M5 12h14" /></svg>
Create Role
</A>
</td>
</tr>
</Show>
<Show when={!data.loading}>
<For each={roles()}>
{(role) => (
<tr class="group hover:bg-[#FAFAFA] transition-colors">
<td class="px-6 py-4">
<p class="text-[14px] font-semibold text-[#111827]">{role.name}</p>
<p class="mt-0.5 text-[12px] text-[#9CA3AF] line-clamp-1">{role.description || ''}</p>
</td>
<td class="px-6 py-4">
<span class="rounded-lg bg-[#F3F4F6] px-2.5 py-1 text-[11px] font-mono font-semibold text-[#374151]">
{role.key}
</span>
</td>
<td class="px-6 py-4 text-[13px] text-[#374151]">{role.department_name || '—'}</td>
<td class="px-6 py-4 text-[13px] font-semibold text-[#111827]">{role.users_assigned}</td>
<td class="px-6 py-4">
<span class="inline-flex items-center gap-1 rounded-lg bg-[#EFF6FF] px-2.5 py-1 text-[11px] font-semibold text-[#2563EB]">
<svg class="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><path d="M9 12l2 2 4-4" /><path d="M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" /></svg>
{role.permissions_count}
</span>
</td>
<td class="px-6 py-4"><StatusBadge active={role.is_active} /></td>
<td class="px-6 py-4 text-[13px] text-[#6B7280]">{formatDate(role.created_at)}</td>
<td class="relative px-6 py-4">
<button
type="button"
onClick={() => setOpenMenuId(openMenuId() === role.id ? null : role.id)}
class="inline-flex h-8 w-8 items-center justify-center rounded-lg text-[#9CA3AF] hover:bg-[#F3F4F6] hover:text-[#374151] transition-colors"
aria-label="More actions"
>
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><circle cx="12" cy="5" r="1.5" /><circle cx="12" cy="12" r="1.5" /><circle cx="12" cy="19" r="1.5" /></svg>
</button>
<Show when={openMenuId() === role.id}>
<div class="absolute right-6 top-12 z-20 w-[200px] rounded-xl border border-[#E5E7EB] bg-white p-1.5 shadow-lg shadow-black/10">
<A href={`/admin/roles/${role.id}`} onClick={() => setOpenMenuId(null)} class="flex w-full items-center gap-2.5 rounded-lg px-3 py-2 text-[13px] font-medium text-[#374151] hover:bg-[#F9FAFB]">
<svg class="h-4 w-4 text-[#FF5E13]" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><circle cx="12" cy="12" r="3" /><path d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" /></svg>
View Role
</A>
<A href={`/admin/roles/${role.id}/edit`} onClick={() => setOpenMenuId(null)} class="flex w-full items-center gap-2.5 rounded-lg px-3 py-2 text-[13px] font-medium text-[#374151] hover:bg-[#F9FAFB]">
<svg class="h-4 w-4 text-[#FF5E13]" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M12 20h9" /><path d="m16.5 3.5 4 4L7 21H3v-4L16.5 3.5Z" /></svg>
Edit Role
</A>
<div class="my-1 h-px bg-[#F3F4F6]" />
<button
type="button"
disabled={deleting() === role.id}
onClick={() => handleDelete(role.id, role.name)}
class="flex w-full items-center gap-2.5 rounded-lg px-3 py-2 text-[13px] font-medium text-[#DC2626] hover:bg-[#FEF2F2]"
>
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M3 6h18M8 6V4h8v2M19 6l-1 14H6L5 6M10 11v6M14 11v6" /></svg>
{deleting() === role.id ? 'Deleting…' : 'Delete Role'}
</button>
</div>
</Show>
</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]">
Page <span class="font-semibold text-[#111827]">{page()}</span> of <span class="font-semibold text-[#111827]">{totalPages()}</span>
</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"
></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-[#0D0D2A] 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"
></button>
</div>
</div>
</Show>
</div>
</div>
</AdminShell>
);
}