Files
saas_frontend/src/application/roles/components/GroupedAccessSelector.tsx
T
2026-08-31 20:40:20 -04:00

390 lines
15 KiB
TypeScript

import { useMemo, useState } from "react";
import type { RoleAccess } from "../RolesTypes";
import { CustomLoader } from "../../../components/custom";
import { ChevronDown, ChevronRight } from "lucide-react";
interface GroupedAccessSelectorProps {
allAccesses: RoleAccess[];
selectedIds: string[];
onChange: (ids: string[]) => void;
isLoading?: boolean;
title?: string;
}
export const GroupedAccessSelector = ({
allAccesses,
selectedIds = [],
onChange,
isLoading = false,
title = "Role Permissions",
}: GroupedAccessSelectorProps) => {
const { moduleGroups, childMap, allIds } = useMemo(() => {
const ids = new Set(allAccesses.map((a) => a.id));
const cMap: Record<string, RoleAccess[]> = {};
const roots: RoleAccess[] = [];
allAccesses.forEach((access) => {
const hasParent = access.parent_id && ids.has(access.parent_id);
if (hasParent) {
if (!cMap[access.parent_id!]) {
cMap[access.parent_id!] = [];
}
cMap[access.parent_id!].push(access);
} else {
roots.push(access);
}
});
const modGroups: Record<string, Record<string, RoleAccess[]>> = {};
roots.forEach((root) => {
const moduleName = root.module_name || "SaaS (Internal)";
const cat = root.category || "General";
if (!modGroups[moduleName]) {
modGroups[moduleName] = {};
}
if (!modGroups[moduleName][cat]) {
modGroups[moduleName][cat] = [];
}
modGroups[moduleName][cat].push(root);
});
const sortedModuleNames = Object.keys(modGroups).sort((a, b) => {
if (a === "SaaS (Internal)") return -1;
if (b === "SaaS (Internal)") return 1;
return a.localeCompare(b);
});
const orderedGroups = sortedModuleNames.map(name => ({
name,
categories: modGroups[name]
}));
return { moduleGroups: orderedGroups, childMap: cMap, allIds: ids };
}, [allAccesses]);
const [expandedModules, setExpandedModules] = useState<Record<string, boolean>>({});
const toggleModuleExpansion = (moduleName: string) => {
setExpandedModules(prev => ({
...prev,
[moduleName]: !prev[moduleName]
}));
};
const getBranchIds = (id: string): string[] => {
const ids = [id];
const children = childMap[id] || [];
children.forEach((c) => {
ids.push(...getBranchIds(c.id));
});
return ids;
};
const isBranchSelected = (id: string) => {
const ids = getBranchIds(id);
return ids.every((i) => selectedIds.includes(i));
};
const isBranchIndeterminate = (id: string) => {
const ids = getBranchIds(id);
const selectedCount = ids.filter((i) => selectedIds.includes(i)).length;
return selectedCount > 0 && selectedCount < ids.length;
};
const toggleBranch = (id: string) => {
const ids = getBranchIds(id);
const allSelected = ids.every((i) => selectedIds.includes(i));
let newIds: string[];
if (allSelected) {
newIds = selectedIds.filter((i) => !ids.includes(i));
} else {
const unique = new Set([...selectedIds, ...ids]);
newIds = Array.from(unique);
}
onChange(newIds);
};
const toggleSingle = (id: string) => {
if (selectedIds.includes(id)) {
onChange(selectedIds.filter((sid) => sid !== id));
} else {
onChange([...selectedIds, id]);
}
};
const getCategoryIds = (roots: RoleAccess[]) => {
const ids: string[] = [];
roots.forEach((root) => {
ids.push(...getBranchIds(root.id));
});
return ids;
};
const isCategorySelected = (roots: RoleAccess[]) => {
const ids = getCategoryIds(roots);
return ids.length > 0 && ids.every((id) => selectedIds.includes(id));
};
const isCategoryIndeterminate = (roots: RoleAccess[]) => {
const ids = getCategoryIds(roots);
const count = ids.filter((id) => selectedIds.includes(id)).length;
return count > 0 && count < ids.length;
};
const toggleCategory = (roots: RoleAccess[]) => {
const ids = getCategoryIds(roots);
const allSelected = ids.every((id) => selectedIds.includes(id));
if (allSelected) {
onChange(selectedIds.filter((id) => !ids.includes(id)));
} else {
const unique = new Set([...selectedIds, ...ids]);
onChange(Array.from(unique));
}
};
const getModuleIds = (categories: Record<string, RoleAccess[]>) => {
return Object.values(categories).flatMap(roots => getCategoryIds(roots));
};
const isModuleSelected = (categories: Record<string, RoleAccess[]>) => {
const ids = getModuleIds(categories);
return ids.length > 0 && ids.every(id => selectedIds.includes(id));
};
const isModuleIndeterminate = (categories: Record<string, RoleAccess[]>) => {
const ids = getModuleIds(categories);
const count = ids.filter(id => selectedIds.includes(id)).length;
return count > 0 && count < ids.length;
};
const toggleModule = (categories: Record<string, RoleAccess[]>) => {
const ids = getModuleIds(categories);
const allSelected = ids.every(id => selectedIds.includes(id));
if (allSelected) {
onChange(selectedIds.filter(id => !ids.includes(id)));
} else {
const unique = new Set([...selectedIds, ...ids]);
onChange(Array.from(unique));
}
};
const isAllSelected = () =>
allIds.size > 0 && Array.from(allIds).every((id) => selectedIds.includes(id));
const isAllIndeterminate = () =>
selectedIds.length > 0 && selectedIds.length < allIds.size;
const toggleAll = () => {
if (isAllSelected()) {
onChange([]);
} else {
onChange(Array.from(allIds));
}
};
const renderNode = (node: RoleAccess, depth: number = 0) => {
const children = childMap[node.id];
const hasChildren = children && children.length > 0;
const isRoot = depth === 0;
const renderChildrenList = (items: RoleAccess[]) => {
if (items.some(c => childMap[c.id])) {
return (
<div className="flex flex-col gap-3">
{items.map(child => renderNode(child, depth + 1))}
</div>
);
} else {
return (
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
{items.map(child => renderNode(child, depth + 1))}
</div>
);
}
};
if (hasChildren) {
if (isRoot) {
const childGroups: Record<string, RoleAccess[]> = {};
let hasMultipleGroups = false;
children.forEach(c => {
const cat = c.category || 'General';
if (!childGroups[cat]) childGroups[cat] = [];
childGroups[cat].push(c);
});
hasMultipleGroups = Object.keys(childGroups).length > 1;
return (
<div key={node.id} className="rounded-md border border-gray-200 bg-white p-3 mb-3 break-inside-avoid shadow-xs">
<label className="flex items-center gap-2 mb-3 cursor-pointer group pb-2 border-b border-gray-50/50 select-none">
<input
type="checkbox"
className="size-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500 cursor-pointer"
checked={isBranchSelected(node.id)}
ref={(el) => { if (el) el.indeterminate = isBranchIndeterminate(node.id); }}
onChange={() => toggleBranch(node.id)}
/>
<span className="font-semibold text-gray-800">{node.name}</span>
</label>
<div className="pl-2">
{hasMultipleGroups ? (
<div className="space-y-4">
{Object.entries(childGroups).map(([groupName, groupItems]) => (
<div key={groupName} className="bg-gray-50/50 rounded-md border border-gray-200/50 p-3">
<h5 className="text-xs font-bold text-gray-500 uppercase tracking-wider mb-2 border-b border-gray-100 pb-1">
{groupName}
</h5>
{renderChildrenList(groupItems)}
</div>
))}
</div>
) : (
renderChildrenList(children)
)}
</div>
</div>
);
} else {
return (
<div key={node.id} className="rounded-md border border-gray-100 bg-white p-3">
<div className="flex items-center gap-2 mb-2 pb-2 border-b border-gray-50 select-none">
<input
type="checkbox"
className="size-3.5 rounded border-gray-300 text-blue-600 focus:ring-blue-500 cursor-pointer"
checked={isBranchSelected(node.id)}
ref={(el) => { if (el) el.indeterminate = isBranchIndeterminate(node.id); }}
onChange={() => toggleBranch(node.id)}
/>
<span className="font-medium text-sm text-gray-700">{node.name}</span>
</div>
{renderChildrenList(children)}
</div>
);
}
} else {
return (
<label
key={node.id}
className="flex items-start gap-2 cursor-pointer p-1.5 hover:bg-gray-50/50 rounded-md transition-colors select-none"
>
<input
type="checkbox"
className="mt-0.5 size-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500 cursor-pointer"
checked={selectedIds.includes(node.id)}
onChange={() => toggleSingle(node.id)}
/>
<span className="text-sm font-medium text-gray-700">{node.name}</span>
</label>
);
}
};
if (isLoading) return <CustomLoader />;
if (allAccesses.length === 0)
return <p className="text-sm text-gray-500">No permissions available.</p>;
return (
<div className="space-y-4 pt-2">
<div className="flex items-center justify-between border-b pb-4 mb-6">
<h3 className="text-lg font-semibold text-gray-900">{title}</h3>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="select-all"
className="size-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500 cursor-pointer"
checked={isAllSelected()}
ref={(el) => {
if (el) el.indeterminate = isAllIndeterminate();
}}
onChange={toggleAll}
/>
<label
htmlFor="select-all"
className="text-sm font-medium text-gray-700 cursor-pointer select-none"
>
Select All
</label>
</div>
</div>
<div className="space-y-6">
{moduleGroups.map((moduleGroup) => {
const isExpanded = expandedModules[moduleGroup.name];
return (
<div key={moduleGroup.name} className="border border-gray-200 rounded-lg bg-white shadow-sm overflow-hidden">
<div
className="flex items-center justify-between p-4 bg-gray-50 cursor-pointer hover:bg-gray-100 transition-colors"
onClick={() => toggleModuleExpansion(moduleGroup.name)}
>
<div className="flex items-center gap-3">
{isExpanded ? <ChevronDown size={20} className="text-gray-500" /> : <ChevronRight size={20} className="text-gray-500" />}
<div
className="flex items-center gap-2"
onClick={(e) => e.stopPropagation()}
>
<input
type="checkbox"
className="size-5 rounded border-gray-300 text-blue-600 focus:ring-blue-500 cursor-pointer"
checked={isModuleSelected(moduleGroup.categories)}
ref={(el) => {
if (el) el.indeterminate = isModuleIndeterminate(moduleGroup.categories);
}}
onChange={() => toggleModule(moduleGroup.categories)}
/>
<h2 className="text-base font-bold text-gray-900 select-none">{moduleGroup.name}</h2>
</div>
</div>
</div>
{isExpanded && (
<div className="p-4 border-t border-gray-200 animate-in fade-in slide-in-from-top-1 duration-200">
<div className="space-y-8">
{Object.entries(moduleGroup.categories).map(([category, roots]) => (
<div
key={category}
className="rounded-lg border border-gray-200 bg-gray-50/10 p-4"
>
<div className="mb-4 flex items-center gap-3 border-b border-gray-200 pb-3">
<input
type="checkbox"
className="size-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500 cursor-pointer"
checked={isCategorySelected(roots)}
ref={(el) => {
if (el) el.indeterminate = isCategoryIndeterminate(roots);
}}
onChange={() => toggleCategory(roots)}
/>
<span className="text-sm font-bold text-gray-700 uppercase tracking-widest select-none">
{category}
</span>
</div>
<div className="space-y-4">
{roots
.filter((r) => childMap[r.id]?.length > 0)
.map((root) => renderNode(root, 0))}
{roots.some((r) => !childMap[r.id]?.length) && (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 mt-4 ml-1">
{roots
.filter((r) => !childMap[r.id]?.length)
.map((root) => renderNode(root, 0))}
</div>
)}
</div>
</div>
))}
</div>
</div>
)}
</div>
)})}
</div>
</div>
);
};