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

275 lines
14 KiB
TypeScript
Raw Normal View History

import { A, useNavigate } from '@solidjs/router';
import { createResource, createSignal, For, Show } from 'solid-js';
import { Search } from 'lucide-solid';
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;
};
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: '8',
});
const res = await fetch(`${API}/api/admin/roles?${qs}`);
if (!res.ok) throw new Error('Failed');
const data = await res.json();
if (data.roles) return data;
// flat array fallback
return { roles: Array.isArray(data) ? data : [], total: 0, page: 1, per_page: 8 };
} catch {
return { roles: [], total: 0, page: 1, per_page: 8 };
}
}
function formatDate(iso: string) {
try {
const d = new Date(iso);
return d.toISOString().slice(0, 10);
} catch {
return '—';
}
}
export default function InternalRolesListPage() {
const navigate = useNavigate();
const [search, setSearch] = createSignal('');
const [debouncedSearch, setDebouncedSearch] = createSignal('');
const [page, setPage] = createSignal(1);
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 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('');
}
};
return (
<AdminShell>
<div class="flex flex-col -mx-6 -mt-6 min-h-full">
<div class="p-6 flex-1 max-w-[1600px] mx-auto w-full">
{/* Header & Title */}
<div class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between mb-8">
<div>
<h1 class="text-[32px] font-bold text-[#050026] leading-tight">Internal Role Management</h1>
</div>
<div class="flex items-center gap-3">
<button class="inline-flex h-11 items-center justify-center rounded-xl border border-[#d9dde6] bg-white px-6 text-[14px] font-semibold text-[#050026] transition-colors hover:bg-[#f8f9fc]">
Export Data
</button>
<A
href="/admin/roles/create"
class="inline-flex h-11 items-center justify-center rounded-xl bg-[#050026] px-6 text-[14px] font-semibold text-white transition-colors hover:bg-[#0a0044]"
>
<span class="mr-2 text-lg leading-none">+</span> Add Role
</A>
</div>
</div>
<section class="rounded-[24px] border border-[#e2e6ee] bg-[#f7f7f8] p-1.5 h-full">
<div class="rounded-[20px] bg-white p-5">
{/* Tabs */}
<div class="flex gap-6 mb-6 border-b border-[#e2e6ee]">
<For each={['Active Roles', 'Archived Roles']}>
{(t) => (
<button
class={`pb-3 text-[14px] font-bold transition-colors border-b-2 ${
t === 'Active Roles'
? 'border-[#050026] text-[#050026]'
: 'border-transparent text-[#8087a0] hover:text-[#050026]'
}`}
>
{t}
</button>
)}
</For>
</div>
{/* Filters Row */}
<div class="flex flex-col gap-4 md:flex-row items-center mb-6">
<div class="relative w-full md:w-[320px]">
<div class="absolute inset-y-0 left-4 flex items-center pointer-events-none">
<svg class="h-5 w-5 text-[#a0aabf]" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</div>
<input
type="text"
placeholder="Search roles..."
value={search()}
onInput={(e) => handleSearch(e.currentTarget.value)}
class="h-11 w-full rounded-xl border border-[#d9dde6] bg-[#f9fafb] pl-11 pr-4 text-[14px] text-[#050026] outline-none transition-colors focus:border-[#050026] focus:bg-white"
/>
</div>
<div class="h-11 w-full md:w-[200px] rounded-xl border border-[#d9dde6] bg-[#f9fafb]"></div>
<div class="flex-1"></div>
<Show when={!data.loading}>
<span class="text-[13px] text-[#8087a0] font-medium">
Showing {((page() - 1) * (data()?.per_page ?? 8)) + 1}{Math.min(page() * (data()?.per_page ?? 8), data()?.total ?? 0)} of {data()?.total ?? 0} roles
</span>
</Show>
</div>
{/* Table */}
<div class="overflow-x-auto">
<table class="w-full min-w-[1000px] border-collapse">
<thead>
<tr class="bg-[#050026] text-left text-white">
<th class="px-6 py-4 text-[11px] font-bold uppercase tracking-wider rounded-tl-xl whitespace-nowrap">ROLE ID</th>
<th class="px-6 py-4 text-[11px] font-bold uppercase tracking-wider whitespace-nowrap">ROLE NAME</th>
<th class="px-6 py-4 text-[11px] font-bold uppercase tracking-wider whitespace-nowrap">CATEGORY</th>
<th class="px-6 py-4 text-[11px] font-bold uppercase tracking-wider whitespace-nowrap">ASSOCIATED USERS</th>
<th class="px-6 py-4 text-[11px] font-bold uppercase tracking-wider whitespace-nowrap">ACTIVE PERMISSIONS</th>
<th class="px-6 py-4 text-[11px] font-bold uppercase tracking-wider whitespace-nowrap">LAST UPDATED</th>
<th class="px-6 py-4 text-[11px] font-bold uppercase tracking-wider text-right rounded-tr-xl whitespace-nowrap">ACTION</th>
</tr>
</thead>
<tbody>
<Show when={data.loading}>
<tr><td colspan="7" class="text-center py-12 text-[#8087a0] text-[14px]">Loading roles...</td></tr>
</Show>
<Show when={!data.loading && (data()?.roles?.length ?? 0) === 0}>
<tr><td colspan="7" class="text-center py-12 text-[#8087a0] text-[14px]">No internal roles found.</td></tr>
</Show>
<For each={data()?.roles ?? []}>
{(role) => (
<tr class="border-b border-[#e2e6ee] bg-white transition-colors hover:bg-[#f8f9fc]">
<td class="px-6 py-4 text-[14px] font-semibold text-[#64748b]">{role.key.toUpperCase()}</td>
<td class="px-6 py-4 text-[14px] font-bold text-[#050026]">{role.name}</td>
<td class="px-6 py-4 text-[14px] text-[#475569]">{role.department_name || '—'}</td>
<td class="px-6 py-4 text-[14px] font-semibold text-[#050026]">
<div class="flex -space-x-2 mr-2 inline-flex items-center">
{/* Placeholder for avatar group if users > 0 */}
<Show when={role.users_assigned > 0} fallback={<span class="text-[#c1c7d0] font-normal">0 users</span>}>
<div class="h-6 w-6 rounded-full bg-[#e2e8f0] border-2 border-white flex items-center justify-center text-[10px] font-bold text-[#475569]">U</div>
<div class="h-6 w-6 rounded-full bg-[#f1f5f9] border-2 border-white flex items-center justify-center text-[10px] font-bold text-[#64748b]">+{role.users_assigned - 1}</div>
</Show>
</div>
</td>
<td class="px-6 py-4">
<span class="inline-flex items-center px-3 py-1 rounded-lg bg-[#f8f9fc] text-[#050026] text-[12px] font-bold border border-[#e2e6ee]">
{role.permissions_count} Permissions
</span>
</td>
<td class="px-6 py-4 text-[14px] text-[#475569]">{formatDate(role.created_at)}</td>
<td class="px-6 py-4">
<div class="flex items-center justify-end gap-2">
<A
title="View Details"
href={`/admin/roles/${role.id}`}
class="flex h-8 w-8 items-center justify-center rounded-lg border border-[#e2e6ee] bg-white text-[#64748b] hover:bg-[#f8f9fc] hover:text-[#050026] transition-colors"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" 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>
</A>
<A
title="Edit"
href={`/admin/roles/${role.id}/edit`}
class="flex h-8 w-8 items-center justify-center rounded-lg border border-[#e2e6ee] bg-white text-[#64748b] hover:bg-[#f8f9fc] hover:text-[#050026] transition-colors"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
</svg>
</A>
<button
title="Archive"
disabled={deleting() === role.id}
onClick={() => handleDelete(role.id, role.name)}
class="flex h-8 w-8 items-center justify-center rounded-lg border border-[#e2e6ee] bg-white text-[#64748b] hover:bg-[#f8f9fc] hover:text-[#050026] transition-colors"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4" />
</svg>
</button>
</div>
</td>
</tr>
)}
</For>
</tbody>
</table>
</div>
{/* Pagination */}
<Show when={totalPages() > 1}>
<div class="mt-6 flex items-center justify-between border-t border-[#e2e6ee] pt-4">
<span class="text-[13px] font-medium text-[#8087a0]">Page {page()} of {totalPages()}</span>
<div class="flex items-center gap-2">
<button
disabled={page() === 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
class="flex h-9 w-9 items-center justify-center rounded-lg border border-[#e2e6ee] bg-white text-[#64748b] hover:bg-[#f8f9fc] hover:text-[#050026] disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" /></svg>
</button>
<button
disabled={page() >= totalPages()}
onClick={() => setPage((p) => Math.min(totalPages(), p + 1))}
class="flex h-9 w-9 items-center justify-center rounded-lg border border-[#e2e6ee] bg-white text-[#64748b] hover:bg-[#f8f9fc] hover:text-[#050026] disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" /></svg>
</button>
</div>
</div>
</Show>
</div>
</section>
</div>
</div>
</AdminShell>
);
}