Qassure-frontend/src/components/shared/RolesTable.tsx

546 lines
17 KiB
TypeScript

import { useState, useEffect, type ReactElement } from 'react';
import {
PrimaryButton,
StatusBadge,
ActionDropdown,
NewRoleModal,
ViewRoleModal,
EditRoleModal,
DeleteConfirmationModal,
DataTable,
Pagination,
FilterDropdown,
type Column,
} from '@/components/shared';
import { Plus, Download, ArrowUpDown } from 'lucide-react';
import { roleService } from '@/services/role-service';
import type { Role, CreateRoleRequest, UpdateRoleRequest } from '@/types/role';
import { showToast } from '@/utils/toast';
import { formatDate } from '@/utils/format-date';
// Helper function to get scope badge variant
const getScopeVariant = (scope: string): 'success' | 'failure' | 'process' => {
switch (scope.toLowerCase()) {
case 'platform':
return 'success';
case 'tenant':
return 'process';
case 'module':
return 'failure';
default:
return 'success';
}
};
interface RolesTableProps {
tenantId?: string | null; // If provided, fetch roles for this tenant only
showHeader?: boolean; // Show header with title and actions (default: true)
compact?: boolean; // Compact mode for tabs (default: false)
}
export const RolesTable = ({ tenantId, showHeader = true, compact = false }: RolesTableProps): ReactElement => {
const [roles, setRoles] = useState<Role[]>([]);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
const [isModalOpen, setIsModalOpen] = useState<boolean>(false);
const [isCreating, setIsCreating] = useState<boolean>(false);
// Pagination state
const [currentPage, setCurrentPage] = useState<number>(1);
const [limit, setLimit] = useState<number>(compact ? 10 : 20);
const [pagination, setPagination] = useState<{
page: number;
limit: number;
total: number;
totalPages: number;
hasMore: boolean;
}>({
page: 1,
limit: compact ? 10 : 20,
total: 0,
totalPages: 1,
hasMore: false,
});
// Filter state
const [scopeFilter, setScopeFilter] = useState<string | null>(null);
const [orderBy, setOrderBy] = useState<string[] | null>(null);
// View, Edit, Delete modals
const [viewModalOpen, setViewModalOpen] = useState<boolean>(false);
const [editModalOpen, setEditModalOpen] = useState<boolean>(false);
const [deleteModalOpen, setDeleteModalOpen] = useState<boolean>(false);
const [selectedRoleId, setSelectedRoleId] = useState<string | null>(null);
const [selectedRoleName, setSelectedRoleName] = useState<string>('');
const [isUpdating, setIsUpdating] = useState<boolean>(false);
const [isDeleting, setIsDeleting] = useState<boolean>(false);
const fetchRoles = async (
page: number,
itemsPerPage: number,
scope: string | null = null,
sortBy: string[] | null = null
): Promise<void> => {
try {
setIsLoading(true);
setError(null);
const response = tenantId
? await roleService.getByTenant(tenantId, page, itemsPerPage, scope, sortBy)
: await roleService.getAll(page, itemsPerPage, scope, sortBy);
if (response.success) {
setRoles(response.data);
setPagination(response.pagination);
} else {
setError('Failed to load roles');
}
} catch (err: any) {
setError(err?.response?.data?.error?.message || 'Failed to load roles');
} finally {
setIsLoading(false);
}
};
useEffect(() => {
fetchRoles(currentPage, limit, scopeFilter, orderBy);
}, [currentPage, limit, scopeFilter, orderBy, tenantId]);
const handleCreateRole = async (data: CreateRoleRequest): Promise<void> => {
try {
setIsCreating(true);
const response = await roleService.create(data);
const message = response.message || `Role created successfully`;
const description = response.message ? undefined : `${data.name} has been added`;
showToast.success(message, description);
setIsModalOpen(false);
await fetchRoles(currentPage, limit, scopeFilter, orderBy);
} catch (err: any) {
throw err;
} finally {
setIsCreating(false);
}
};
// View role handler
const handleViewRole = (roleId: string): void => {
setSelectedRoleId(roleId);
setViewModalOpen(true);
};
// Edit role handler
const handleEditRole = (roleId: string, roleName: string): void => {
setSelectedRoleId(roleId);
setSelectedRoleName(roleName);
setEditModalOpen(true);
};
// Update role handler
const handleUpdateRole = async (id: string, data: UpdateRoleRequest): Promise<void> => {
try {
setIsUpdating(true);
const response = await roleService.update(id, data);
const message = response.message || `Role updated successfully`;
const description = response.message ? undefined : `${data.name} has been updated`;
showToast.success(message, description);
setEditModalOpen(false);
setSelectedRoleId(null);
setSelectedRoleName('');
await fetchRoles(currentPage, limit, scopeFilter, orderBy);
} catch (err: any) {
throw err;
} finally {
setIsUpdating(false);
}
};
// Delete role handler
const handleDeleteRole = (roleId: string, roleName: string): void => {
setSelectedRoleId(roleId);
setSelectedRoleName(roleName);
setDeleteModalOpen(true);
};
// Confirm delete handler
const handleConfirmDelete = async (): Promise<void> => {
if (!selectedRoleId) return;
try {
setIsDeleting(true);
await roleService.delete(selectedRoleId);
setDeleteModalOpen(false);
setSelectedRoleId(null);
setSelectedRoleName('');
await fetchRoles(currentPage, limit, scopeFilter, orderBy);
} catch (err: any) {
throw err; // Let the modal handle the error display
} finally {
setIsDeleting(false);
}
};
// Load role for view/edit
const loadRole = async (id: string): Promise<Role> => {
const response = await roleService.getById(id);
return response.data;
};
// Table columns
const columns: Column<Role>[] = [
{
key: 'name',
label: 'Name',
render: (role) => (
<span className="text-sm font-normal text-[#0f1724]">{role.name}</span>
),
},
{
key: 'code',
label: 'Code',
render: (role) => (
<span className="text-sm font-normal text-[#0f1724] font-mono">{role.code}</span>
),
},
{
key: 'scope',
label: 'Scope',
render: (role) => (
<StatusBadge variant={getScopeVariant(role.scope)}>{role.scope}</StatusBadge>
),
},
{
key: 'description',
label: 'Description',
render: (role) => (
<span className="text-sm font-normal text-[#6b7280]">
{role.description || 'N/A'}
</span>
),
},
{
key: 'is_system',
label: 'System Role',
render: (role) => (
<span className="text-sm font-normal text-[#0f1724]">
{role.is_system ? 'Yes' : 'No'}
</span>
),
},
{
key: 'created_at',
label: 'Created Date',
render: (role) => (
<span className="text-sm font-normal text-[#6b7280]">{formatDate(role.created_at)}</span>
),
},
{
key: 'actions',
label: 'Actions',
align: 'right',
render: (role) => (
<div className="flex justify-end">
<ActionDropdown
onView={() => handleViewRole(role.id)}
onEdit={() => handleEditRole(role.id, role.name)}
onDelete={() => handleDeleteRole(role.id, role.name)}
/>
</div>
),
},
];
// Mobile card renderer
const mobileCardRenderer = (role: Role) => (
<div className="p-4">
<div className="flex items-start justify-between gap-3 mb-3">
<div className="flex-1 min-w-0">
<h3 className="text-sm font-medium text-[#0f1724] truncate">{role.name}</h3>
<p className="text-xs text-[#6b7280] mt-0.5 font-mono">{role.code}</p>
</div>
<ActionDropdown
onView={() => handleViewRole(role.id)}
onEdit={() => handleEditRole(role.id, role.name)}
onDelete={() => handleDeleteRole(role.id, role.name)}
/>
</div>
<div className="grid grid-cols-2 gap-3 text-xs">
<div>
<span className="text-[#9aa6b2]">Scope:</span>
<div className="mt-1">
<StatusBadge variant={getScopeVariant(role.scope)}>{role.scope}</StatusBadge>
</div>
</div>
<div>
<span className="text-[#9aa6b2]">Created:</span>
<p className="text-[#0f1724] font-normal mt-1">{formatDate(role.created_at)}</p>
</div>
{role.description && (
<div className="col-span-2">
<span className="text-[#9aa6b2]">Description:</span>
<p className="text-[#0f1724] font-normal mt-1">{role.description}</p>
</div>
)}
</div>
</div>
);
if (compact) {
// Compact mode for tabs
return (
<>
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold text-[#0f1724]">Roles</h3>
<div className="flex items-center gap-2">
<FilterDropdown
label="Scope"
options={[
{ value: '', label: 'All Scope' },
{ value: 'platform', label: 'Platform' },
{ value: 'tenant', label: 'Tenant' },
{ value: 'module', label: 'Module' },
]}
value={scopeFilter || ''}
onChange={(value) => {
setScopeFilter(Array.isArray(value) ? null : value || null);
setCurrentPage(1);
}}
placeholder="Filter by scope"
/>
<PrimaryButton
size="default"
className="flex items-center gap-2"
onClick={() => setIsModalOpen(true)}
>
<Plus className="w-3.5 h-3.5" />
<span className="text-xs">New Role</span>
</PrimaryButton>
</div>
</div>
<DataTable
data={roles}
columns={columns}
keyExtractor={(role) => role.id}
mobileCardRenderer={mobileCardRenderer}
emptyMessage="No roles found"
isLoading={isLoading}
error={error}
/>
{pagination.totalPages > 1 && (
<Pagination
currentPage={currentPage}
totalPages={pagination.totalPages}
totalItems={pagination.total}
limit={limit}
onPageChange={(page: number) => {
setCurrentPage(page);
}}
onLimitChange={(newLimit: number) => {
setLimit(newLimit);
setCurrentPage(1);
}}
/>
)}
</div>
{/* Modals */}
<NewRoleModal
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
onSubmit={handleCreateRole}
isLoading={isCreating}
defaultTenantId={tenantId || undefined}
/>
<ViewRoleModal
isOpen={viewModalOpen}
onClose={() => {
setViewModalOpen(false);
setSelectedRoleId(null);
}}
roleId={selectedRoleId}
onLoadRole={loadRole}
/>
<EditRoleModal
isOpen={editModalOpen}
onClose={() => {
setEditModalOpen(false);
setSelectedRoleId(null);
setSelectedRoleName('');
}}
roleId={selectedRoleId}
onLoadRole={loadRole}
onSubmit={handleUpdateRole}
isLoading={isUpdating}
defaultTenantId={tenantId || undefined}
/>
<DeleteConfirmationModal
isOpen={deleteModalOpen}
onClose={() => {
setDeleteModalOpen(false);
setSelectedRoleId(null);
setSelectedRoleName('');
}}
onConfirm={handleConfirmDelete}
title="Delete Role"
message={`Are you sure you want to delete this role`}
itemName={selectedRoleName}
isLoading={isDeleting}
/>
</>
);
}
// Full mode with header
return (
<>
{/* Table Container */}
<div className="bg-white border border-[rgba(0,0,0,0.08)] rounded-lg shadow-[0px_4px_24px_0px_rgba(0,0,0,0.02)] overflow-hidden">
{/* Table Header with Filters */}
{showHeader && (
<div className="border-b border-[rgba(0,0,0,0.08)] px-4 md:px-5 py-3 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3">
{/* Filters */}
<div className="flex flex-wrap items-center gap-3">
{/* Scope Filter */}
<FilterDropdown
label="Scope"
options={[
{ value: 'platform', label: 'Platform' },
{ value: 'tenant', label: 'Tenant' },
{ value: 'module', label: 'Module' },
]}
value={scopeFilter}
onChange={(value) => {
setScopeFilter(value as string | null);
setCurrentPage(1);
}}
placeholder="All"
/>
{/* Sort Filter */}
<FilterDropdown
label="Sort by"
options={[
{ value: ['name', 'asc'], label: 'Name (A-Z)' },
{ value: ['name', 'desc'], label: 'Name (Z-A)' },
{ value: ['code', 'asc'], label: 'Code (A-Z)' },
{ value: ['code', 'desc'], label: 'Code (Z-A)' },
{ value: ['created_at', 'asc'], label: 'Created (Oldest)' },
{ value: ['created_at', 'desc'], label: 'Created (Newest)' },
{ value: ['updated_at', 'asc'], label: 'Updated (Oldest)' },
{ value: ['updated_at', 'desc'], label: 'Updated (Newest)' },
]}
value={orderBy}
onChange={(value) => {
setOrderBy(value as string[] | null);
setCurrentPage(1);
}}
placeholder="Default"
showIcon
icon={<ArrowUpDown className="w-3.5 h-3.5 text-[#475569]" />}
/>
</div>
{/* Actions */}
<div className="flex items-center gap-2">
{/* Export Button */}
<button
type="button"
className="flex items-center gap-1.5 px-3 py-2 bg-white border border-[rgba(0,0,0,0.08)] rounded-md text-xs text-[#0f1724] hover:bg-gray-50 transition-colors cursor-pointer"
>
<Download className="w-3.5 h-3.5" />
<span>Export</span>
</button>
{/* New Role Button */}
<PrimaryButton
size="default"
className="flex items-center gap-2"
onClick={() => setIsModalOpen(true)}
>
<Plus className="w-3.5 h-3.5" />
<span className="text-xs">New Role</span>
</PrimaryButton>
</div>
</div>
)}
{/* Data Table */}
<DataTable
data={roles}
columns={columns}
keyExtractor={(role) => role.id}
mobileCardRenderer={mobileCardRenderer}
emptyMessage="No roles found"
isLoading={isLoading}
error={error}
/>
{/* Table Footer with Pagination */}
{pagination.total > 0 && (
<Pagination
currentPage={currentPage}
totalPages={pagination.totalPages}
totalItems={pagination.total}
limit={limit}
onPageChange={(page: number) => {
setCurrentPage(page);
}}
onLimitChange={(newLimit: number) => {
setLimit(newLimit);
setCurrentPage(1);
}}
/>
)}
</div>
{/* Modals */}
<NewRoleModal
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
onSubmit={handleCreateRole}
isLoading={isCreating}
defaultTenantId={tenantId || undefined}
/>
<ViewRoleModal
isOpen={viewModalOpen}
onClose={() => {
setViewModalOpen(false);
setSelectedRoleId(null);
}}
roleId={selectedRoleId}
onLoadRole={loadRole}
/>
<EditRoleModal
isOpen={editModalOpen}
onClose={() => {
setEditModalOpen(false);
setSelectedRoleId(null);
setSelectedRoleName('');
}}
roleId={selectedRoleId}
onLoadRole={loadRole}
onSubmit={handleUpdateRole}
isLoading={isUpdating}
defaultTenantId={tenantId || undefined}
/>
<DeleteConfirmationModal
isOpen={deleteModalOpen}
onClose={() => {
setDeleteModalOpen(false);
setSelectedRoleId(null);
setSelectedRoleName('');
}}
onConfirm={handleConfirmDelete}
title="Delete Role"
message={`Are you sure you want to delete this role`}
itemName={selectedRoleName}
isLoading={isDeleting}
/>
</>
);
};