Files
saas_frontend/src/application/roles/components/AllRoles.tsx
T

621 lines
21 KiB
TypeScript

import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Link } from "react-router-dom";
import { Edit2, Eye, Trash2 } from "lucide-react";
import { useTranslation } from "react-i18next";
import {
CustomButton,
CustomColumnFilter,
CustomConfirmationModal,
CustomInput,
CustomModal,
CustomTable,
CustomLoader,
CustomActionMenu,
CustomActionItem,
} from "../../../components/custom";
import { GroupedAccessSelector } from "./GroupedAccessSelector";
import { GroupedAccessViewer } from "./GroupedAccessViewer";
import type { ColumnDef } from "../../../components/custom/CustomTable";
import {
buildColumnFilterOptions,
resolveColumnSortState,
} from "../../../components/custom/CustomColumnFilter.utils";
import type { ColumnSortDirection } from "../../../components/custom/CustomColumnFilter";
import { formatDate } from "../../../lib/dateFormat";
import type {
Role,
RoleAccess,
RoleUpdateRequest,
RolePaginatedResponse,
} from "../RolesTypes";
import { rolesApi } from "../RolesApi";
import { tenantsApi } from "../../tenants/TenantsApi";
import type { Tenant } from "../../tenants/TenantsTypes";
import { ProtectedComponent } from "../../../components/auth/ProtectedComponent";
import { useAuth } from "../../../context/AuthContext";
import { useDebounce } from "../../../components/hooks/useDebounce";
import {
DEFAULT_TABLE_PAGE_SIZE_OPTIONS,
SHARED_ADMIN_TABLE_PAGE_SIZE_STORAGE_KEY,
resolveStoredTablePageSize,
} from "../../../lib/tablePageSize";
const AllRoles = () => {
const { t, i18n } = useTranslation(['roles', 'common']);
const { user: currentUser, hasAccess, isLoading: isAuthLoading } = useAuth();
const canReadAllTenants = hasAccess("superadmin.tenant.read");
const [roles, setRoles] = useState<Role[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [errorMessage, setErrorMessage] = useState("");
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(() =>
resolveStoredTablePageSize({
storageKey: SHARED_ADMIN_TABLE_PAGE_SIZE_STORAGE_KEY,
pageSizeOptions: DEFAULT_TABLE_PAGE_SIZE_OPTIONS,
defaultPageSize: 10,
})
);
const [search, setSearch] = useState("");
const [roleNameFilter, setRoleNameFilter] = useState<string[]>([]);
const [tenantFilter, setTenantFilter] = useState<string[]>([]);
const [totalRows, setTotalRows] = useState(0);
const [, setTotalPages] = useState(0);
const [activeSort, setActiveSort] = useState<{
column: "role_name" | "tenant_id" | null;
direction: ColumnSortDirection;
}>({ column: null, direction: null });
const debouncedSearch = useDebounce(search, 500);
const searchInputRef = useRef<HTMLInputElement>(null);
const prevLoadingRef = useRef(isLoading);
const latestRolesRequestRef = useRef(0);
const [selectedRole, setSelectedRole] = useState<Role | null>(null);
const [selectedRoleAccesses, setSelectedRoleAccesses] = useState<RoleAccess[] | null>(null);
const [isViewOpen, setIsViewOpen] = useState(false);
const [isEditOpen, setIsEditOpen] = useState(false);
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
const [isDetailLoading, setIsDetailLoading] = useState(false);
const [accessOptions, setAccessOptions] = useState<RoleAccess[]>([]);
const [isAccessLoading, setIsAccessLoading] = useState(false);
const [tenants, setTenants] = useState<Tenant[]>([]);
const [allRolesForCounts, setAllRolesForCounts] = useState<Role[]>([]);
const [currentTenant, setCurrentTenant] = useState<Tenant | null>(null);
const [editForm, setEditForm] = useState<{ role_name: string; is_default?: boolean }>({ role_name: "" });
const [editAccessIds, setEditAccessIds] = useState<string[]>([]);
const [editError, setEditError] = useState("");
const [deleteError, setDeleteError] = useState("");
const [isSaving, setIsSaving] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const availableAccessOptions = useMemo(() => {
if (canReadAllTenants) return accessOptions;
if (!currentUser?.role?.accesses) return [];
const accessMap = new Map(accessOptions.map((a) => [a.id, a]));
const includedIds = new Set<string>();
accessOptions.forEach((option) => {
if (currentUser.role?.accesses.includes(option.access_code)) {
let current: RoleAccess | undefined = option;
while (current) {
if (includedIds.has(current.id)) break;
includedIds.add(current.id);
if (current.parent_id) {
current = accessMap.get(current.parent_id);
} else {
current = undefined;
}
}
}
});
return accessOptions.filter((option) => includedIds.has(option.id));
}, [accessOptions, currentUser, canReadAllTenants]);
useEffect(() => {
let isMounted = true;
const loadSupportingData = async () => {
setIsAccessLoading(true);
try {
const accesses = await rolesApi.getAccesses();
if (isMounted) setAccessOptions(accesses);
const allRoles = await rolesApi.getAll();
if (isMounted) setAllRolesForCounts(allRoles);
if (canReadAllTenants) {
const tenantsData = await tenantsApi.getAll();
if (isMounted) setTenants(tenantsData);
} else if (currentUser?.tenant_id) {
const tenant = await tenantsApi.getMine();
if (isMounted) setCurrentTenant(tenant);
}
} catch (err) {
console.error("Failed to load supporting data:", err);
} finally {
if (isMounted) setIsAccessLoading(false);
}
};
const loadRoles = async () => {
const requestId = ++latestRolesRequestRef.current;
setIsLoading(true);
setErrorMessage("");
try {
const response: RolePaginatedResponse = await rolesApi.getPaginated({
page,
page_size: pageSize,
search: debouncedSearch || undefined,
filter_role_names: roleNameFilter,
filter_tenant_ids: tenantFilter,
sort_by:
activeSort.column === "role_name"
? "name"
: activeSort.column === "tenant_id"
? "tenant"
: undefined,
sort_order: activeSort.direction ?? undefined,
});
if (isMounted && requestId === latestRolesRequestRef.current) {
setRoles(response.items);
setTotalRows(response.total);
setTotalPages(response.total_pages);
}
} catch (err) {
if (isMounted && requestId === latestRolesRequestRef.current) {
const msg = err instanceof Error ? err.message : t('messages.error');
setErrorMessage(msg);
}
} finally {
if (isMounted && requestId === latestRolesRequestRef.current) setIsLoading(false);
}
};
if (!isAuthLoading) {
loadSupportingData();
loadRoles();
}
return () => {
isMounted = false;
};
}, [
isAuthLoading,
canReadAllTenants,
currentUser?.tenant_id,
page,
pageSize,
debouncedSearch,
roleNameFilter,
tenantFilter,
activeSort,
t
]);
useEffect(() => {
setPage(1);
}, [debouncedSearch, roleNameFilter, tenantFilter, activeSort]);
useEffect(() => {
if (prevLoadingRef.current && !isLoading && search.trim()) {
searchInputRef.current?.focus({ preventScroll: true });
}
prevLoadingRef.current = isLoading;
}, [isLoading, search]);
const getTenantName = useCallback(
(tenantId?: string | null) => {
if (!tenantId) return "";
const found = tenants.find((t) => t.id === tenantId);
if (found) return found.tenant_name;
if (currentTenant?.id === tenantId) return currentTenant.tenant_name;
if (currentUser?.tenant_id === tenantId && currentUser?.tenant_name) {
return currentUser.tenant_name;
}
return tenantId;
},
[tenants, currentTenant, currentUser]
);
const roleNameCounts = useMemo(() => {
const counts = new Map<string, number>();
allRolesForCounts.forEach((role) => {
counts.set(role.role_name, (counts.get(role.role_name) ?? 0) + 1);
});
return counts;
}, [allRolesForCounts]);
const tenantCounts = useMemo(() => {
const counts = new Map<string, number>();
allRolesForCounts.forEach((role) => {
if (!role.tenant_id) return;
counts.set(role.tenant_id, (counts.get(role.tenant_id) ?? 0) + 1);
});
return counts;
}, [allRolesForCounts]);
const openView = useCallback(async (role: Role) => {
setSelectedRole(role);
setSelectedRoleAccesses(null);
setIsViewOpen(true);
setIsDetailLoading(true);
try {
const details = await rolesApi.getById(role.id);
setSelectedRoleAccesses(details.accesses || []);
} catch {
setSelectedRoleAccesses([]);
} finally {
setIsDetailLoading(false);
}
}, []);
const openEdit = useCallback(async (role: Role) => {
setSelectedRole(role);
setEditForm({ role_name: role.role_name, is_default: role.is_default });
setEditAccessIds([]);
setEditError("");
setIsEditOpen(true);
try {
const details = await rolesApi.getById(role.id);
setEditAccessIds(details.accesses?.map((a) => a.id) || []);
} catch (err) {
setEditError(err instanceof Error ? err.message : t('messages.error'));
}
}, [t]);
const openDelete = useCallback((role: Role) => {
setSelectedRole(role);
setDeleteError("");
setIsDeleteOpen(true);
}, []);
const closeView = useCallback(() => {
setIsViewOpen(false);
setSelectedRole(null);
setSelectedRoleAccesses(null);
}, []);
const closeEdit = useCallback(() => {
setIsEditOpen(false);
setSelectedRole(null);
setEditError("");
}, []);
const closeDelete = useCallback(() => {
setIsDeleteOpen(false);
setSelectedRole(null);
setDeleteError("");
}, []);
const handleEditChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value, type, checked } = e.target;
setEditForm((prev) => ({
...prev,
[name]: type === "checkbox" ? checked : value,
}));
},
[]
);
const handleUpdate = async (e: React.FormEvent) => {
e.preventDefault();
if (!selectedRole) return;
setIsSaving(true);
setEditError("");
try {
const payload: RoleUpdateRequest = {
role_name: editForm.role_name.trim(),
access_ids: editAccessIds.length ? editAccessIds : null,
is_default: editForm.is_default,
};
const updated = await rolesApi.update(selectedRole.id, payload);
setRoles((prev) => prev.map((r) => (r.id === updated.id ? updated : r)));
closeEdit();
} catch (err) {
setEditError(err instanceof Error ? err.message : t('messages.error'));
} finally {
setIsSaving(false);
}
};
const handleDelete = async () => {
if (!selectedRole) return;
setIsDeleting(true);
try {
await rolesApi.remove(selectedRole.id);
setRoles((prev) => prev.filter((r) => r.id !== selectedRole.id));
closeDelete();
} catch (err) {
setDeleteError(err instanceof Error ? err.message : t('messages.error'));
} finally {
setIsDeleting(false);
}
};
const columns = useMemo<Array<ColumnDef<Role>>>(
() => [
{
key: "role_name",
visibilityLabel: t('columns.roleName'),
header: (
<div className="flex items-center">
{t('columns.roleName')}
<CustomColumnFilter
title={t('columns.roleName')}
options={buildColumnFilterOptions(roleNameCounts.entries())}
selectedValues={roleNameFilter}
sortDirection={activeSort.column === "role_name" ? activeSort.direction : null}
onApply={(values, direction) => {
setPage(1);
setRoleNameFilter(values);
setActiveSort(resolveColumnSortState(activeSort, "role_name", direction));
}}
/>
</div>
),
},
{
key: "tenant_id",
visibilityLabel: t('columns.tenant'),
header: (
<div className="flex items-center">
{t('columns.tenant')}
{canReadAllTenants ? (
<CustomColumnFilter
title={t('columns.tenant')}
options={tenants.map((tenant) => ({
label: `${tenant.tenant_name} (${tenantCounts.get(tenant.id) ?? 0})`,
value: tenant.id,
}))}
selectedValues={tenantFilter}
sortDirection={activeSort.column === "tenant_id" ? activeSort.direction : null}
onApply={(values, direction) => {
setPage(1);
setTenantFilter(values);
setActiveSort(resolveColumnSortState(activeSort, "tenant_id", direction));
}}
/>
) : null}
</div>
),
render: (row) => getTenantName(row.tenant_id),
},
{
key: "created_at",
header: t('columns.created'),
render: (row) => (
<div className="text-sm font-medium text-(--text-primary)">
{formatDate(row.created_at, i18n.language)}
</div>
),
},
{
key: "updated_at",
header: t('columns.updated'),
render: (row) => (
<div className="text-sm font-medium text-(--text-primary)">
{formatDate(row.updated_at, i18n.language)}
</div>
),
},
{
key: "id",
header: t('columns.actions'),
searchable: false,
render: (row) => (
<CustomActionMenu>
<ProtectedComponent requiredAccess="admin.role.read">
<CustomActionItem onClick={() => openView(row)}>
<Eye size={16} className="mr-2" /> {t('actions.view')}
</CustomActionItem>
</ProtectedComponent>
<ProtectedComponent requiredAccess="admin.role.update">
{(!row.is_default || canReadAllTenants) && (
<CustomActionItem onClick={() => openEdit(row)}>
<Edit2 size={16} className="mr-2" /> {t('actions.edit')}
</CustomActionItem>
)}
</ProtectedComponent>
<ProtectedComponent requiredAccess="admin.role.delete">
{(!row.is_default || canReadAllTenants) && (
<CustomActionItem onClick={() => openDelete(row)} className="text-red-600 hover:text-red-700 hover:bg-red-50">
<Trash2 size={16} className="mr-2" /> {t('actions.delete')}
</CustomActionItem>
)}
</ProtectedComponent>
</CustomActionMenu>
),
},
],
[
activeSort,
canReadAllTenants,
getTenantName,
i18n.language,
openDelete,
openEdit,
openView,
roleNameCounts,
roleNameFilter,
t,
tenantCounts,
tenantFilter,
tenants,
]
);
return (
<div className="space-y-6">
<div className="flex flex-wrap items-center justify-between gap-4">
<div>
<h1 className="text-2xl font-semibold text-[var(--text-primary)]">{t('title')}</h1>
</div>
<ProtectedComponent requiredAccess="admin.role.create">
<Link to="/roles/add">
<CustomButton variant="primary">+ {t('add')}</CustomButton>
</Link>
</ProtectedComponent>
</div>
{isLoading ? (
<div className="rounded-lg border border-[var(--card-border)] bg-[var(--card-bg)] p-6">
<CustomLoader />
</div>
) : errorMessage ? (
<div className="rounded-lg border border-red-200 bg-red-50 p-6 text-sm text-red-600">
{errorMessage}
</div>
) : (
<CustomTable
data={roles}
columns={columns}
columnVisibilityEnabled
columnVisibilityStorageKey="roles-table-columns"
pageSizeStorageKey={SHARED_ADMIN_TABLE_PAGE_SIZE_STORAGE_KEY}
exportEnabled={false}
getRowId={(row) => row.id}
manualPagination
manualFiltering
totalRows={totalRows}
page={page}
pageSize={pageSize}
search={search}
onPageChange={setPage}
onPageSizeChange={setPageSize}
onSearchChange={setSearch}
searchInputRef={searchInputRef}
/>
)}
{/* View Modal */}
<CustomModal
isOpen={isViewOpen}
onClose={closeView}
title={t('details')}
size="lg"
footer={<CustomButton variant="outlined" onClick={closeView}>{t('actions.close')}</CustomButton>}
>
{selectedRole ? (
<div className="space-y-4">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<p className="text-xs uppercase tracking-wide text-[var(--text-secondary)]">{t('columns.roleName')}</p>
<p className="text-sm font-medium text-[var(--text-primary)]">{selectedRole.role_name}</p>
</div>
<div>
<p className="text-xs uppercase tracking-wide text-[var(--text-secondary)]">{t('columns.tenant')}</p>
<p className="text-sm font-medium text-[var(--text-primary)]">{getTenantName(selectedRole.tenant_id)}</p>
</div>
<div>
<p className="text-xs uppercase tracking-wide text-[var(--text-secondary)]">{t('columns.created')}</p>
<p className="text-sm font-medium text-[var(--text-primary)]">{formatDate(selectedRole.created_at, i18n.language)}</p>
</div>
<div>
<p className="text-xs uppercase tracking-wide text-[var(--text-secondary)]">{t('columns.updated')}</p>
<p className="text-sm font-medium text-[var(--text-primary)]">{formatDate(selectedRole.updated_at, i18n.language)}</p>
</div>
</div>
<div>
<p className="text-xs uppercase tracking-wide text-[var(--text-secondary)] mb-2">Accesses</p>
{isDetailLoading ? (
<div className="rounded-lg border border-[var(--card-border)] bg-[var(--card-bg)] p-3 text-sm text-[var(--text-secondary)]">
<CustomLoader />
</div>
) : (
<GroupedAccessViewer accesses={selectedRoleAccesses || []} />
)}
</div>
</div>
) : (
<p className="text-sm text-[var(--text-secondary)]">{t('common.noData')}</p>
)}
</CustomModal>
{/* Edit Modal */}
<CustomModal
isOpen={isEditOpen}
onClose={closeEdit}
title={t('edit')}
size="lg"
footer={
<>
<CustomButton variant="outlined" onClick={closeEdit} disabled={isSaving}>{t('actions.cancel')}</CustomButton>
<CustomButton type="submit" form="edit-role-form" variant="primary" loading={isSaving}>{t('actions.update')}</CustomButton>
</>
}
>
<form id="edit-role-form" onSubmit={handleUpdate} className="space-y-6">
<CustomInput
label={t('columns.roleName')}
name="role_name"
value={editForm.role_name}
onChange={handleEditChange}
required
/>
{canReadAllTenants && (
<div className="flex items-center space-x-2">
<input
type="checkbox"
id="edit_is_default"
name="is_default"
checked={editForm.is_default || false}
onChange={handleEditChange}
className="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500"
/>
<label htmlFor="edit_is_default" className="text-sm font-medium text-[var(--text-primary)]">
Set as Default Role
</label>
</div>
)}
<GroupedAccessSelector
allAccesses={availableAccessOptions}
selectedIds={editAccessIds}
onChange={setEditAccessIds}
isLoading={isAccessLoading}
/>
{editError && (
<div className="rounded-lg border border-red-200 bg-red-50 px-4 py-2 text-sm text-red-600">
{editError}
</div>
)}
</form>
</CustomModal>
{/* Delete Modal */}
<CustomConfirmationModal
isOpen={isDeleteOpen}
onClose={closeDelete}
onConfirm={handleDelete}
title={t('messages.confirmDelete')}
description={deleteError || t('messages.confirmDelete')}
confirmText={t('actions.delete')}
variant="danger"
isLoading={isDeleting}
/>
</div>
);
};
export default AllRoles;