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

292 lines
12 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 gap-0 -mx-6 -mt-6 min-h-full">
{/* Page title */}
<div class="bg-white border-b border-[#e5e7eb] px-6 py-5">
<h1 class="text-[20px] font-semibold text-[#000032]">Internal Role Management</h1>
<p class="text-[13px] text-[rgba(0,0,50,0.5)] mt-0.5">Manage internal roles and permissions</p>
</div>
{/* Card */}
<div class="p-6">
<div class="rounded-xl border border-[#e5e7eb] bg-white shadow-sm overflow-hidden">
{/* Tabs */}
<div class="flex border-b border-[#e5e7eb] px-6">
<button
class="relative py-4 text-[14px] font-semibold text-[#fa5014] mr-6"
>
All Roles
<span class="absolute bottom-0 left-0 right-0 h-[2px] bg-[#fa5014] rounded-t" />
</button>
<A
href="/admin/roles/create"
class="py-4 text-[14px] font-medium text-[rgba(0,0,50,0.5)] hover:text-[#000032]"
>
Create Role
</A>
</div>
{/* Toolbar */}
<div class="flex items-center gap-3 px-6 py-4 border-b border-[#e5e7eb]">
<div class="relative flex-1 max-w-[280px]">
<Search size={15} class="absolute left-3 top-1/2 -translate-y-1/2 text-[#9195ad]" />
<input
type="text"
placeholder="Search roles..."
value={search()}
onInput={(e) => handleSearch(e.currentTarget.value)}
class="w-full pl-9 pr-3 py-2 text-[13px] border border-[#e5e7eb] rounded-lg outline-none focus:border-[#fa5014] focus:ring-1 focus:ring-[#fa5014] bg-white"
/>
</div>
<select class="text-[13px] border border-[#e5e7eb] rounded-lg px-3 py-2 outline-none bg-white text-[rgba(0,0,50,0.6)] focus:border-[#fa5014]">
<option value="">All Departments</option>
</select>
<select class="text-[13px] border border-[#e5e7eb] rounded-lg px-3 py-2 outline-none bg-white text-[rgba(0,0,50,0.6)] focus:border-[#fa5014]">
<option value="">All Statuses</option>
<option value="active">Active</option>
<option value="inactive">Inactive</option>
</select>
</div>
{/* Table */}
<div class="overflow-x-auto">
<table class="w-full">
<thead>
<tr class="bg-[#000032] text-white text-[12px] font-semibold uppercase tracking-wide">
<th class="px-6 py-3 text-left">Role Name</th>
<th class="px-6 py-3 text-left">Department</th>
<th class="px-6 py-3 text-left">Users Assigned</th>
<th class="px-6 py-3 text-left">Permissions Count</th>
<th class="px-6 py-3 text-left">Status</th>
<th class="px-6 py-3 text-left">Created Date</th>
<th class="px-6 py-3 text-left">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-[#e5e7eb]">
<Show when={data.loading}>
<tr>
<td colspan="7" class="px-6 py-10 text-center text-[13px] text-[rgba(0,0,50,0.4)]">
Loading roles
</td>
</tr>
</Show>
<Show when={!data.loading && (data()?.roles?.length ?? 0) === 0}>
<tr>
<td colspan="7" class="px-6 py-10 text-center text-[13px] text-[rgba(0,0,50,0.4)]">
No internal roles found.{' '}
<A href="/admin/roles/create" class="text-[#fa5014] hover:underline">
Create your first role.
</A>
</td>
</tr>
</Show>
<For each={data()?.roles ?? []}>
{(role) => (
<tr class="hover:bg-[#fafafa] transition-colors">
<td class="px-6 py-4">
<p class="text-[14px] font-semibold text-[#000032]">{role.name}</p>
<p class="text-[12px] text-[rgba(0,0,50,0.4)] mt-0.5">{role.key}</p>
</td>
<td class="px-6 py-4 text-[13px] text-[rgba(0,0,50,0.7)]">
{role.department_name || '—'}
</td>
<td class="px-6 py-4 text-[14px] font-semibold text-[#000032]">
{role.users_assigned}
</td>
<td class="px-6 py-4">
<span class="inline-flex items-center px-2.5 py-1 rounded-md bg-[rgba(250,80,20,0.1)] text-[#fa5014] text-[12px] font-semibold">
{role.permissions_count} Permissions
</span>
</td>
<td class="px-6 py-4">
<Show
when={role.is_active}
fallback={
<span class="inline-flex items-center px-2.5 py-1 rounded-md bg-[#f1f1f1] text-[rgba(0,0,50,0.5)] text-[12px] font-semibold">
Inactive
</span>
}
>
<span class="inline-flex items-center px-2.5 py-1 rounded-md bg-[rgba(34,197,94,0.1)] text-[#16a34a] text-[12px] font-semibold">
Active
</span>
</Show>
</td>
<td class="px-6 py-4 text-[13px] text-[rgba(0,0,50,0.6)]">
{formatDate(role.created_at)}
</td>
<td class="px-6 py-4">
<div class="flex items-center gap-2">
<A
href={`/admin/roles/${role.id}`}
class="text-[12px] font-medium text-[#000032] hover:text-[#fa5014] transition-colors"
>
View
</A>
<span class="text-[#e5e7eb]">|</span>
<A
href={`/admin/roles/${role.id}/edit`}
class="text-[12px] font-medium text-[#000032] hover:text-[#fa5014] transition-colors"
>
Edit
</A>
<span class="text-[#e5e7eb]">|</span>
<button
disabled={deleting() === role.id}
onClick={() => handleDelete(role.id, role.name)}
class="text-[12px] font-medium text-red-500 hover:text-red-700 transition-colors disabled:opacity-50"
>
{deleting() === role.id ? '…' : 'Delete'}
</button>
</div>
</td>
</tr>
)}
</For>
</tbody>
</table>
</div>
{/* Pagination */}
<Show when={!data.loading && (data()?.total ?? 0) > 0}>
<div class="flex items-center justify-between px-6 py-4 border-t border-[#e5e7eb]">
<p class="text-[13px] text-[rgba(0,0,50,0.5)]">
Showing {((page() - 1) * (data()?.per_page ?? 8)) + 1}
{Math.min(page() * (data()?.per_page ?? 8), data()?.total ?? 0)} of {data()?.total ?? 0} roles
</p>
<div class="flex items-center gap-1">
<button
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page() <= 1}
class="h-8 w-8 flex items-center justify-center rounded-lg border border-[#e5e7eb] text-[rgba(0,0,50,0.5)] hover:border-[#000032] disabled:opacity-40 transition-colors text-[13px]"
>
</button>
<For each={Array.from({ length: totalPages() }, (_, i) => i + 1)}>
{(p) => (
<button
onClick={() => setPage(p)}
class={`h-8 w-8 flex items-center justify-center rounded-lg text-[13px] font-medium transition-colors ${
p === page()
? 'bg-[#fa5014] text-white'
: 'border border-[#e5e7eb] text-[rgba(0,0,50,0.6)] hover:border-[#000032]'
}`}
>
{p}
</button>
)}
</For>
<button
onClick={() => setPage((p) => Math.min(totalPages(), p + 1))}
disabled={page() >= totalPages()}
class="h-8 w-8 flex items-center justify-center rounded-lg border border-[#e5e7eb] text-[rgba(0,0,50,0.5)] hover:border-[#000032] disabled:opacity-40 transition-colors text-[13px]"
>
</button>
</div>
</div>
</Show>
</div>
</div>
</div>
</AdminShell>
);
}