360 lines
11 KiB
TypeScript
360 lines
11 KiB
TypeScript
import { useEffect, useState, useMemo, useCallback } from 'react';
|
|
import { RolesApi, type RoleItem } from '../RolesApi';
|
|
import {
|
|
CustomTable,
|
|
CustomInput,
|
|
CustomButton,
|
|
CustomAlertBanner,
|
|
CustomConfirmationModal,
|
|
CustomActionMenu,
|
|
CustomActionItem,
|
|
CustomCheckBox,
|
|
Skeleton,
|
|
} from '../../../components/custom';
|
|
import type { Column } from '../../../components/custom/CustomTable';
|
|
import {
|
|
ShieldCheckIcon,
|
|
PlusIcon,
|
|
LockKeyIcon,
|
|
UsersIcon,
|
|
KeyIcon,
|
|
PencilSimpleIcon,
|
|
TrashIcon,
|
|
MagnifyingGlassIcon,
|
|
} from '@phosphor-icons/react';
|
|
import Can from '../../../components/common/Can';
|
|
import { formatDate } from '../../../utils/formatDate';
|
|
|
|
const PAGE_SIZE = 10;
|
|
|
|
function HeaderLabel({
|
|
text,
|
|
rightIcon,
|
|
}: {
|
|
text: string;
|
|
rightIcon?: React.ReactNode;
|
|
}) {
|
|
return (
|
|
<div className="flex items-center gap-1">
|
|
<span className="text-[13px] font-semibold text-[#6C766D] tracking-[0px]">
|
|
{text}
|
|
</span>
|
|
{rightIcon && <span className="text-[#6C766D]">{rightIcon}</span>}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
interface RolesListProps {
|
|
onAddRole: () => void;
|
|
onEditRole: (role: RoleItem) => void;
|
|
}
|
|
|
|
export default function RolesList({ onAddRole, onEditRole }: RolesListProps) {
|
|
const [roles, setRoles] = useState<RoleItem[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [successMsg, setSuccessMsg] = useState<string | null>(null);
|
|
|
|
// Pagination & Search
|
|
const [currentPage, setCurrentPage] = useState(1);
|
|
const [search, setSearch] = useState('');
|
|
|
|
// Row selection
|
|
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
|
|
|
// Delete modal state
|
|
const [deleteConfirmRole, setDeleteConfirmRole] = useState<RoleItem | null>(null);
|
|
const [deleting, setDeleting] = useState(false);
|
|
|
|
const loadRoles = useCallback(async () => {
|
|
setLoading(true);
|
|
setError(null);
|
|
try {
|
|
const data = await RolesApi.getRoles();
|
|
setRoles(data);
|
|
} catch (err: any) {
|
|
setError(err?.response?.data?.message || 'Failed to load roles list.');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
loadRoles();
|
|
}, [loadRoles]);
|
|
|
|
const handleDelete = async () => {
|
|
if (!deleteConfirmRole) return;
|
|
setDeleting(true);
|
|
try {
|
|
await RolesApi.deleteRole(deleteConfirmRole.id);
|
|
setSuccessMsg(`Role "${deleteConfirmRole.name}" was deleted successfully.`);
|
|
setDeleteConfirmRole(null);
|
|
loadRoles();
|
|
} catch (err: any) {
|
|
setError(err?.response?.data?.message || 'Failed to delete role.');
|
|
} finally {
|
|
setDeleting(false);
|
|
}
|
|
};
|
|
|
|
// Filter & Pagination
|
|
const filteredRoles = useMemo(() => {
|
|
const q = search.toLowerCase().trim();
|
|
if (!q) return roles;
|
|
return roles.filter(
|
|
(r) =>
|
|
r.name.toLowerCase().includes(q) ||
|
|
r.slug.toLowerCase().includes(q) ||
|
|
(r.description || '').toLowerCase().includes(q)
|
|
);
|
|
}, [roles, search]);
|
|
|
|
const totalItems = filteredRoles.length;
|
|
const totalPages = Math.ceil(totalItems / PAGE_SIZE) || 1;
|
|
const startIndex = totalItems > 0 ? (currentPage - 1) * PAGE_SIZE + 1 : 0;
|
|
const endIndex = Math.min(currentPage * PAGE_SIZE, totalItems);
|
|
|
|
const paginatedRoles = useMemo(() => {
|
|
const start = (currentPage - 1) * PAGE_SIZE;
|
|
return filteredRoles.slice(start, start + PAGE_SIZE);
|
|
}, [filteredRoles, currentPage]);
|
|
|
|
const handlePageChange = (page: number) => {
|
|
setCurrentPage(page);
|
|
};
|
|
|
|
const handleSearchChange = (val: string) => {
|
|
setSearch(val);
|
|
setCurrentPage(1);
|
|
};
|
|
|
|
// Row selection handlers
|
|
const toggleSelectAll = () => {
|
|
if (paginatedRoles.length > 0 && selectedIds.size === paginatedRoles.length) {
|
|
setSelectedIds(new Set());
|
|
} else {
|
|
setSelectedIds(new Set(paginatedRoles.map((r) => r.id)));
|
|
}
|
|
};
|
|
|
|
const toggleSelectOne = (id: string) => {
|
|
setSelectedIds((prev) => {
|
|
const next = new Set(prev);
|
|
if (next.has(id)) {
|
|
next.delete(id);
|
|
} else {
|
|
next.add(id);
|
|
}
|
|
return next;
|
|
});
|
|
};
|
|
|
|
// Table columns definition
|
|
const columns: Column<RoleItem>[] = [
|
|
{
|
|
header: (
|
|
<CustomCheckBox
|
|
checked={paginatedRoles.length > 0 && selectedIds.size === paginatedRoles.length}
|
|
onChange={toggleSelectAll}
|
|
/>
|
|
),
|
|
className: 'w-[40px] pr-0',
|
|
accessor: (row) => (
|
|
<CustomCheckBox
|
|
checked={selectedIds.has(row.id)}
|
|
onChange={() => toggleSelectOne(row.id)}
|
|
onClick={(e) => e.stopPropagation()}
|
|
/>
|
|
),
|
|
},
|
|
{
|
|
header: <HeaderLabel text="Role Name" />,
|
|
accessor: (row) => (
|
|
<div className="flex items-center gap-3">
|
|
<div
|
|
className={`w-9 h-9 rounded-[10px] flex items-center justify-center font-bold text-sm shrink-0 ${
|
|
row.isSystem
|
|
? 'bg-amber-100 text-amber-800'
|
|
: 'bg-emerald-100 text-[#1B9869]'
|
|
}`}
|
|
>
|
|
<ShieldCheckIcon size={20} weight="bold" />
|
|
</div>
|
|
<div>
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-[13px] font-semibold text-[#0F172B] leading-[18px]">
|
|
{row.name}
|
|
</span>
|
|
{row.isSystem && (
|
|
<span className="inline-flex items-center gap-1 text-[10px] font-semibold px-2 py-0.5 rounded-full bg-amber-50 text-amber-700 border border-amber-200 shrink-0">
|
|
<LockKeyIcon size={11} weight="bold" />
|
|
System Default
|
|
</span>
|
|
)}
|
|
</div>
|
|
<span className="text-[11px] font-mono text-[#6C766D] block mt-0.5">
|
|
{row.slug}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
),
|
|
},
|
|
{
|
|
header: <HeaderLabel text="Description" />,
|
|
accessor: (row) => (
|
|
<p className="text-[12px] font-medium text-[#6C766D] line-clamp-2 max-w-sm leading-relaxed">
|
|
{row.description || 'No description configured for this operational role.'}
|
|
</p>
|
|
),
|
|
},
|
|
{
|
|
header: <HeaderLabel text="Permissions" />,
|
|
accessor: (row) => (
|
|
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-semibold bg-[#E8F3EF] text-[#1E7D5C] border border-emerald-100">
|
|
<KeyIcon size={13} weight="bold" />
|
|
{row.permissionCount} rules
|
|
</span>
|
|
),
|
|
},
|
|
{
|
|
header: <HeaderLabel text="Assigned Users" />,
|
|
accessor: (row) => (
|
|
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-semibold bg-blue-50 text-blue-700 border border-blue-100">
|
|
<UsersIcon size={13} weight="bold" />
|
|
{row.userCount} users
|
|
</span>
|
|
),
|
|
},
|
|
{
|
|
header: <HeaderLabel text="Created At" />,
|
|
accessor: (row) => (
|
|
<span className="text-[12px] font-medium text-[#6C766D]">
|
|
{row.createdAt ? formatDate(row.createdAt) : '—'}
|
|
</span>
|
|
),
|
|
},
|
|
{
|
|
header: <HeaderLabel text="Action" />,
|
|
accessor: (row) => (
|
|
<div className="flex items-center pl-2">
|
|
<CustomActionMenu>
|
|
<Can permission="roles:view">
|
|
<CustomActionItem
|
|
onClick={() => onEditRole(row)}
|
|
icon={<PencilSimpleIcon size={16} className="text-yellow-500" />}
|
|
>
|
|
{row.isSystem ? 'View Permissions' : 'Edit Role'}
|
|
</CustomActionItem>
|
|
</Can>
|
|
|
|
{!row.isSystem && (
|
|
<Can permission="roles:delete">
|
|
<CustomActionItem
|
|
variant="danger"
|
|
icon={<TrashIcon size={16} className="text-red-500" />}
|
|
onClick={() => setDeleteConfirmRole(row)}
|
|
>
|
|
Delete Role
|
|
</CustomActionItem>
|
|
</Can>
|
|
)}
|
|
</CustomActionMenu>
|
|
</div>
|
|
),
|
|
},
|
|
];
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="w-full flex flex-col bg-white rounded-[20px] shadow-sm border border-gray-100 overflow-hidden">
|
|
<div className="flex items-center justify-between p-4 border-b border-gray-100">
|
|
<Skeleton width={320} height={36} />
|
|
<div className="flex items-center gap-3">
|
|
<Skeleton width={148} height={36} />
|
|
</div>
|
|
</div>
|
|
<div className="p-6 flex flex-col gap-4">
|
|
{Array.from({ length: 6 }).map((_, i) => (
|
|
<Skeleton key={i} height={52} />
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="w-full flex flex-col gap-6">
|
|
{error && (
|
|
<CustomAlertBanner
|
|
message={error}
|
|
type="error"
|
|
onClose={() => setError(null)}
|
|
/>
|
|
)}
|
|
{successMsg && (
|
|
<CustomAlertBanner
|
|
message={successMsg}
|
|
type="success"
|
|
onClose={() => setSuccessMsg(null)}
|
|
/>
|
|
)}
|
|
|
|
{/* Standardized Table Section */}
|
|
<CustomTable<RoleItem>
|
|
columns={columns}
|
|
data={paginatedRoles}
|
|
leftHeaderActions={
|
|
<div className="w-[320px]">
|
|
<CustomInput
|
|
placeholder="Search roles by name, slug..."
|
|
value={search}
|
|
onChange={(e) => handleSearchChange(e.target.value)}
|
|
leftIcon={<MagnifyingGlassIcon size={16} />}
|
|
className="!bg-[#F3F6F5] !rounded-[10px] !h-[40px] !border !border-[#E5E7EB]"
|
|
containerClassName="!gap-0"
|
|
/>
|
|
</div>
|
|
}
|
|
rightHeaderActions={
|
|
<Can permission="roles:create">
|
|
<CustomButton
|
|
variant="primary"
|
|
size="md"
|
|
leftIcon={<PlusIcon size={16} />}
|
|
className="!rounded-[10px] !gap-[8px] !h-[40px] !bg-[#1B9869] hover:!bg-[#14704E]"
|
|
onClick={onAddRole}
|
|
>
|
|
Create Role
|
|
</CustomButton>
|
|
</Can>
|
|
}
|
|
currentPage={currentPage}
|
|
totalPages={totalPages}
|
|
totalItems={totalItems}
|
|
startIndex={startIndex}
|
|
endIndex={endIndex}
|
|
onPageChange={handlePageChange}
|
|
itemName="Roles"
|
|
rowClassName={() => 'bg-white border-b border-gray-100 hover:bg-gray-50/60'}
|
|
/>
|
|
|
|
{/* Delete Confirmation Modal */}
|
|
<CustomConfirmationModal
|
|
isOpen={!!deleteConfirmRole}
|
|
onClose={() => setDeleteConfirmRole(null)}
|
|
onConfirm={handleDelete}
|
|
title="Delete Role"
|
|
description={`Are you sure you want to permanently delete the role "${deleteConfirmRole?.name}"? ${
|
|
deleteConfirmRole && deleteConfirmRole.userCount > 0
|
|
? `Warning: ${deleteConfirmRole.userCount} user(s) are currently assigned to this role.`
|
|
: ''
|
|
}`}
|
|
confirmText={deleting ? 'Deleting...' : 'Confirm Delete'}
|
|
variant="danger"
|
|
isLoading={deleting}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|