390 lines
15 KiB
TypeScript
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>
|
|
);
|
|
};
|