Files
aeroresolve_frontend/src/app/roles/components/RolesList.tsx
T

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>
);
}