feat: domains in menu and sidebar

This commit is contained in:
2026-02-24 15:36:18 +03:30
parent 043ad77b83
commit e6ff022335
3 changed files with 457 additions and 20 deletions

View File

@@ -7,7 +7,11 @@ import { useUserProfileStore } from "../context/zustand-store/userStore";
import { getUserPermissions } from "../utils/getUserAvalableItems"; import { getUserPermissions } from "../utils/getUserAvalableItems";
import { ItemWithSubItems } from "../types/userPermissions"; import { ItemWithSubItems } from "../types/userPermissions";
import { useNavigate } from "@tanstack/react-router"; import { useNavigate } from "@tanstack/react-router";
import { Bars3Icon, QueueListIcon } from "@heroicons/react/24/outline"; import {
Bars3Icon,
QueueListIcon,
Squares2X2Icon,
} from "@heroicons/react/24/outline";
import { getFaPermissions } from "../utils/getFaPermissions"; import { getFaPermissions } from "../utils/getFaPermissions";
import SVGImage from "../components/SvgImage/SvgImage"; import SVGImage from "../components/SvgImage/SvgImage";
@@ -73,12 +77,62 @@ export const Menu = () => {
}; };
const [openIndex, setOpenIndex] = useState<number | null>(getOpenedItem()); const [openIndex, setOpenIndex] = useState<number | null>(getOpenedItem());
const [openDomains, setOpenDomains] = useState<Record<string, boolean>>({});
const navigate = useNavigate(); const navigate = useNavigate();
const adminItems = menuItems
.map((item, index) => ({ ...item, originalIndex: index }))
.filter((item) => item.en === "admin");
const indexedNonAdminItems = menuItems
.map((item, index) => ({ ...item, originalIndex: index }))
.filter((item) => item.en !== "admin");
const permissionDomainMap = new Map<string, string>();
(profile?.permissions || []).forEach((permission: any) => {
if (permission?.page_name) {
permissionDomainMap.set(
permission.page_name,
permission?.domain_fa_name || "سایر حوزه ها",
);
}
});
const groupedItems = indexedNonAdminItems.reduce(
(acc, item) => {
const firstSubItem = item.subItems?.find((sub) =>
permissionDomainMap.has(sub.name),
);
const domainTitle = firstSubItem
? permissionDomainMap.get(firstSubItem.name) || "سایر حوزه ها"
: "سایر حوزه ها";
if (!acc[domainTitle]) {
acc[domainTitle] = [];
}
acc[domainTitle].push(item);
return acc;
},
{} as Record<
string,
(ItemWithSubItems & { originalIndex: number })[]
>,
);
const showDomainGrouping = Object.keys(groupedItems).length > 1;
const toggleSubmenu = (index: number) => { const toggleSubmenu = (index: number) => {
setOpenIndex((prev) => (prev === index ? null : index)); setOpenIndex((prev) => (prev === index ? null : index));
}; };
const isDomainOpen = (domainTitle: string) =>
openDomains[domainTitle] ?? true;
const toggleDomain = (domainTitle: string) => {
setOpenDomains((prev) => ({
...prev,
[domainTitle]: !(prev[domainTitle] ?? true),
}));
};
return ( return (
<Grid <Grid
container container
@@ -99,14 +153,14 @@ export const Menu = () => {
animate="visible" animate="visible"
className="flex flex-col items-center gap-4 w-full" className="flex flex-col items-center gap-4 w-full"
> >
{menuItems.map(({ fa, icon: Icon, subItems }, index) => ( {adminItems.map(({ fa, icon: Icon, subItems, originalIndex }) => (
<motion.div <motion.div
key={index} key={`admin-${originalIndex}`}
variants={itemVariants} variants={itemVariants}
className="w-full max-w-sm" className="w-full max-w-sm"
> >
<motion.button <motion.button
onClick={() => toggleSubmenu(index)} onClick={() => toggleSubmenu(originalIndex)}
whileTap={{ scale: 0.97 }} whileTap={{ scale: 0.97 }}
className="w-full flex justify-between items-center gap-3 px-4 py-3 rounded-lg shadow-xs dark:shadow-sm shadow-dark-300 dark:shadow-dark-500 backdrop-blur-md bg-gradient-to-r from-transparent to-transparent dark:via-gray-800 via-gray-100 border border-dark-200 dark:border-dark-700 text-dark-800 dark:text-dark-100 transition-all duration-200" className="w-full flex justify-between items-center gap-3 px-4 py-3 rounded-lg shadow-xs dark:shadow-sm shadow-dark-300 dark:shadow-dark-500 backdrop-blur-md bg-gradient-to-r from-transparent to-transparent dark:via-gray-800 via-gray-100 border border-dark-200 dark:border-dark-700 text-dark-800 dark:text-dark-100 transition-all duration-200"
> >
@@ -120,13 +174,13 @@ export const Menu = () => {
</div> </div>
<ChevronDownIcon <ChevronDownIcon
className={`w-5 h-5 text-dark-500 dark:text-dark-300 transition-transform duration-300 ${ className={`w-5 h-5 text-dark-500 dark:text-dark-300 transition-transform duration-300 ${
openIndex === index ? "rotate-180" : "" openIndex === originalIndex ? "rotate-180" : ""
}`} }`}
/> />
</motion.button> </motion.button>
<AnimatePresence> <AnimatePresence>
{openIndex === index && ( {openIndex === originalIndex && (
<motion.div <motion.div
key="submenu" key="submenu"
variants={submenuVariants} variants={submenuVariants}
@@ -156,6 +210,146 @@ export const Menu = () => {
</AnimatePresence> </AnimatePresence>
</motion.div> </motion.div>
))} ))}
{showDomainGrouping
? Object.entries(groupedItems).map(([domainTitle, domainItems]) => (
<div key={domainTitle} className="w-full max-w-sm">
<button
onClick={() => toggleDomain(domainTitle)}
className="w-full px-1 py-1 text-primary-700 dark:text-primary-200 text-sm font-bold flex items-center justify-between cursor-pointer"
>
<div className="flex items-center gap-2">
<Squares2X2Icon className="w-4 h-4 text-primary-600 dark:text-primary-300" />
<span>{domainTitle}</span>
</div>
<ChevronDownIcon
className={`w-4 h-4 transition-transform duration-300 ${
isDomainOpen(domainTitle) ? "rotate-180" : ""
}`}
/>
</button>
{(isDomainOpen(domainTitle) || !domainTitle) && (
<div className="mt-1 mr-2 border-r-2 border-primary-200 dark:border-primary-500/40 pr-2 flex flex-col gap-3">
{domainItems.map(
({ fa, icon: Icon, subItems, originalIndex }) => (
<motion.div
key={`group-${domainTitle}-${originalIndex}`}
variants={itemVariants}
className="w-full"
>
<motion.button
onClick={() => toggleSubmenu(originalIndex)}
whileTap={{ scale: 0.97 }}
className="w-full flex justify-between items-center gap-3 px-4 py-3 rounded-lg shadow-xs dark:shadow-sm shadow-dark-300 dark:shadow-dark-500 backdrop-blur-md bg-gradient-to-r from-transparent to-transparent dark:via-gray-800 via-gray-100 border border-dark-200 dark:border-dark-700 text-dark-800 dark:text-dark-100 transition-all duration-200"
>
<div className="flex items-center gap-3">
<SVGImage
src={Icon}
className={` text-primary-800 dark:text-primary-100`}
/>
<span className="text-base font-medium">{fa}</span>
</div>
<ChevronDownIcon
className={`w-5 h-5 text-dark-500 dark:text-dark-300 transition-transform duration-300 ${
openIndex === originalIndex ? "rotate-180" : ""
}`}
/>
</motion.button>
<AnimatePresence>
{openIndex === originalIndex && (
<motion.div
key="submenu"
variants={submenuVariants}
initial="hidden"
animate="visible"
exit="exit"
className="mt-2 mr-4 border-r-2 border-primary-500 dark:border-primary-400 pr-4 flex flex-col gap-2"
>
{subItems
.filter((item) => !item?.path.includes("$"))
?.map((sub, subIndex) => (
<motion.button
onClick={() => {
navigate({ to: sub.path });
}}
key={subIndex}
whileTap={{ scale: 0.97 }}
className="text-sm flex items-center gap-2 text-dark-700 dark:text-dark-200 bg-white dark:bg-dark-700 shadow-sm px-3 py-2 rounded-lg w-full text-right"
>
<QueueListIcon className="w-3" />
{getFaPermissions(sub.name)}
</motion.button>
))}
</motion.div>
)}
</AnimatePresence>
</motion.div>
),
)}
</div>
)}
</div>
))
: indexedNonAdminItems.map(
({ fa, icon: Icon, subItems, originalIndex }) => (
<motion.div
key={`plain-${originalIndex}`}
variants={itemVariants}
className="w-full max-w-sm"
>
<motion.button
onClick={() => toggleSubmenu(originalIndex)}
whileTap={{ scale: 0.97 }}
className="w-full flex justify-between items-center gap-3 px-4 py-3 rounded-lg shadow-xs dark:shadow-sm shadow-dark-300 dark:shadow-dark-500 backdrop-blur-md bg-gradient-to-r from-transparent to-transparent dark:via-gray-800 via-gray-100 border border-dark-200 dark:border-dark-700 text-dark-800 dark:text-dark-100 transition-all duration-200"
>
<div className="flex items-center gap-3">
<SVGImage
src={Icon}
className={` text-primary-800 dark:text-primary-100`}
/>
<span className="text-base font-medium">{fa}</span>
</div>
<ChevronDownIcon
className={`w-5 h-5 text-dark-500 dark:text-dark-300 transition-transform duration-300 ${
openIndex === originalIndex ? "rotate-180" : ""
}`}
/>
</motion.button>
<AnimatePresence>
{openIndex === originalIndex && (
<motion.div
key="submenu"
variants={submenuVariants}
initial="hidden"
animate="visible"
exit="exit"
className="mt-2 mr-4 border-r-2 border-primary-500 dark:border-primary-400 pr-4 flex flex-col gap-2"
>
{subItems
.filter((item) => !item?.path.includes("$"))
?.map((sub, subIndex) => (
<motion.button
onClick={() => {
navigate({ to: sub.path });
}}
key={subIndex}
whileTap={{ scale: 0.97 }}
className="text-sm flex items-center gap-2 text-dark-700 dark:text-dark-200 bg-white dark:bg-dark-700 shadow-sm px-3 py-2 rounded-lg w-full text-right"
>
<QueueListIcon className="w-3" />
{getFaPermissions(sub.name)}
</motion.button>
))}
</motion.div>
)}
</AnimatePresence>
</motion.div>
),
)}
</motion.div> </motion.div>
</Grid> </Grid>
); );

View File

@@ -8,9 +8,16 @@ interface UseUserProfileStore {
clearProfile: () => void; clearProfile: () => void;
} }
type UserPermission = {
page_name: string;
domain_name?: string;
domain_fa_name?: string;
page_access: string[];
};
const arePermissionsEqual = ( const arePermissionsEqual = (
permissions1?: Array<{ page_name: string; page_access: string[] }>, permissions1?: UserPermission[],
permissions2?: Array<{ page_name: string; page_access: string[] }> permissions2?: UserPermission[]
): boolean => { ): boolean => {
if (!permissions1 && !permissions2) return true; if (!permissions1 && !permissions2) return true;
if (!permissions1 || !permissions2) return false; if (!permissions1 || !permissions2) return false;
@@ -20,11 +27,13 @@ const arePermissionsEqual = (
const map2 = new Map<string, Set<string>>(); const map2 = new Map<string, Set<string>>();
permissions1.forEach((perm) => { permissions1.forEach((perm) => {
map1.set(perm.page_name, new Set(perm.page_access.sort())); const key = `${perm.domain_name || ""}::${perm.domain_fa_name || ""}::${perm.page_name}`;
map1.set(key, new Set([...(perm.page_access || [])].sort()));
}); });
permissions2.forEach((perm) => { permissions2.forEach((perm) => {
map2.set(perm.page_name, new Set(perm.page_access.sort())); const key = `${perm.domain_name || ""}::${perm.domain_fa_name || ""}::${perm.page_name}`;
map2.set(key, new Set([...(perm.page_access || [])].sort()));
}); });
if (map1.size !== map2.size) return false; if (map1.size !== map2.size) return false;

View File

@@ -15,6 +15,7 @@ import {
ChevronRightIcon, ChevronRightIcon,
MagnifyingGlassIcon, MagnifyingGlassIcon,
BuildingOfficeIcon, BuildingOfficeIcon,
Squares2X2Icon,
} from "@heroicons/react/24/outline"; } from "@heroicons/react/24/outline";
const containerVariants = { const containerVariants = {
@@ -58,7 +59,7 @@ export const SideBar = () => {
const isMobile = checkIsMobile(); const isMobile = checkIsMobile();
const { profile } = useUserProfileStore(); const { profile } = useUserProfileStore();
const menuItems: ItemWithSubItems[] = getUserPermissions( const menuItems: ItemWithSubItems[] = getUserPermissions(
profile?.permissions profile?.permissions,
); );
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
@@ -67,7 +68,7 @@ export const SideBar = () => {
const getOpenedItem = () => { const getOpenedItem = () => {
if (window.location.pathname !== "/") { if (window.location.pathname !== "/") {
const matchedIndex = menuItems.findIndex((item) => const matchedIndex = menuItems.findIndex((item) =>
item.subItems.some((sub) => sub.path === window.location.pathname) item.subItems.some((sub) => sub.path === window.location.pathname),
); );
return matchedIndex; return matchedIndex;
} else { } else {
@@ -76,6 +77,7 @@ export const SideBar = () => {
}; };
const [openIndex, setOpenIndex] = useState<number | null>(getOpenedItem()); const [openIndex, setOpenIndex] = useState<number | null>(getOpenedItem());
const [openDomains, setOpenDomains] = useState<Record<string, boolean>>({});
const navigate = useNavigate(); const navigate = useNavigate();
@@ -86,7 +88,7 @@ export const SideBar = () => {
getFaPermissions(subItem.name) getFaPermissions(subItem.name)
.toLowerCase() .toLowerCase()
.includes(search.toLowerCase()) || .includes(search.toLowerCase()) ||
subItem.path.toLowerCase().includes(search.toLowerCase()) subItem.path.toLowerCase().includes(search.toLowerCase()),
); );
return { return {
@@ -97,15 +99,60 @@ export const SideBar = () => {
.filter( .filter(
(item) => (item) =>
item.subItems.length > 0 || item.subItems.length > 0 ||
item.fa.toLowerCase().includes(search.toLowerCase()) item.fa.toLowerCase().includes(search.toLowerCase()),
); );
const permissionDomainMap = new Map<string, string>();
(profile?.permissions || []).forEach((permission: any) => {
if (permission?.page_name) {
permissionDomainMap.set(
permission.page_name,
permission?.domain_fa_name || "سایر حوزه ها",
);
}
});
const adminItems = filteredItems.filter((item) => item.en === "admin");
const nonAdminItems = filteredItems.filter((item) => item.en !== "admin");
const indexedNonAdminItems = nonAdminItems
?.filter((s) => s.subItems)
.map((item, index) => ({ ...item, originalIndex: index }));
const groupedFilteredItems = indexedNonAdminItems.reduce(
(acc, item, index) => {
const firstSubItem = item.subItems?.find((sub) =>
permissionDomainMap.has(sub.name),
);
const domainTitle = firstSubItem
? permissionDomainMap.get(firstSubItem.name) || "سایر حوزه ها"
: "سایر حوزه ها";
if (!acc[domainTitle]) {
acc[domainTitle] = [];
}
acc[domainTitle].push({ ...item, originalIndex: index });
return acc;
},
{} as Record<string, (ItemWithSubItems & { originalIndex: number })[]>,
);
const showDomainGrouping = Object.keys(groupedFilteredItems || {}).length > 1;
if (isMobile) return null; if (isMobile) return null;
const toggleSubmenu = (index: number) => { const toggleSubmenu = (index: number) => {
setOpenIndex((prev) => (prev === index ? null : index)); setOpenIndex((prev) => (prev === index ? null : index));
}; };
const isDomainOpen = (domainTitle: string) =>
openDomains[domainTitle] ?? true;
const toggleDomain = (domainTitle: string) => {
setOpenDomains((prev) => ({
...prev,
[domainTitle]: !(prev[domainTitle] ?? true),
}));
};
return ( return (
<motion.div <motion.div
initial={isSideBarOpen ? "open" : "closed"} initial={isSideBarOpen ? "open" : "closed"}
@@ -183,20 +230,20 @@ export const SideBar = () => {
</button> </button>
</div> </div>
{filteredItems {adminItems
?.filter((s) => s.subItems) ?.filter((s) => s.subItems)
.map(({ fa, icon: Icon, subItems }, index) => ( .map(({ fa, icon: Icon, subItems }, index) => (
<motion.div key={index} variants={itemVariants}> <motion.div key={`admin-${index}`} variants={itemVariants}>
<motion.button <motion.button
onClick={() => { onClick={() => {
toggleSideBar({ state: true }); toggleSideBar({ state: true });
toggleSubmenu(index); toggleSubmenu(-1000 - index);
}} }}
whileTap={{ scale: 0.97 }} whileTap={{ scale: 0.97 }}
className={`w-full flex justify-between items-center transition-all border-gray-200 dark:border-dark-600 cursor-pointer ${ className={`w-full flex justify-between items-center transition-all border-gray-200 dark:border-dark-600 cursor-pointer ${
isSideBarOpen && isSideBarOpen &&
`px-4 py-2 rounded-xl text-right text-dark-800 dark:text-dark-100 border hover:shadow-sm ${ `px-4 py-2 rounded-xl text-right text-dark-800 dark:text-dark-100 border hover:shadow-sm ${
isSideBarOpen && openIndex === index isSideBarOpen && openIndex === -1000 - index
? "bg-primary-50 dark:bg-dark-500" ? "bg-primary-50 dark:bg-dark-500"
: "bg-white dark:bg-dark-700" : "bg-white dark:bg-dark-700"
}` }`
@@ -218,14 +265,14 @@ export const SideBar = () => {
{isSideBarOpen && ( {isSideBarOpen && (
<ChevronDownIcon <ChevronDownIcon
className={`w-5 h-5 text-gray-600 dark:text-gray-300 transition-transform duration-300 ${ className={`w-5 h-5 text-gray-600 dark:text-gray-300 transition-transform duration-300 ${
openIndex === index ? "rotate-180" : "" openIndex === -1000 - index ? "rotate-180" : ""
}`} }`}
/> />
)} )}
</motion.button> </motion.button>
<AnimatePresence> <AnimatePresence>
{openIndex === index && isSideBarOpen && ( {openIndex === -1000 - index && isSideBarOpen && (
<motion.div <motion.div
key="submenu" key="submenu"
variants={submenuVariants} variants={submenuVariants}
@@ -257,6 +304,193 @@ export const SideBar = () => {
</AnimatePresence> </AnimatePresence>
</motion.div> </motion.div>
))} ))}
{showDomainGrouping
? Object.entries(groupedFilteredItems || {}).map(
([domainTitle, domainItems]) => (
<div key={domainTitle} className="flex flex-col gap-1.5">
{isSideBarOpen ? (
<button
onClick={() => toggleDomain(domainTitle)}
className="w-full px-2 py-1.5 text-primary-700 dark:text-primary-200 text-sm font-bold flex items-center justify-between cursor-pointer"
>
<div className="flex items-center gap-2">
<div className="w-7 h-7 rounded-lg flex items-center justify-center">
<Squares2X2Icon className="w-4 h-4 text-primary-600 dark:text-primary-300" />
</div>
<span>{domainTitle}</span>
</div>
<div className="flex items-center gap-2">
<ChevronDownIcon
className={`w-4 h-4 transition-transform duration-300 ${
isDomainOpen(domainTitle) ? "rotate-180" : ""
}`}
/>
</div>
</button>
) : null}
{(isSideBarOpen ? isDomainOpen(domainTitle) : true) && (
<div className="mr-2 pr-3 border-r-2 border-primary-200 dark:border-primary-500/40 flex flex-col gap-2">
{domainItems.map(
({ fa, icon: Icon, subItems, originalIndex }) => (
<motion.div
key={`${domainTitle}-${originalIndex}`}
variants={itemVariants}
>
<motion.button
onClick={() => {
toggleSideBar({ state: true });
toggleSubmenu(originalIndex);
}}
whileTap={{ scale: 0.97 }}
className={`w-full flex justify-between items-center transition-all border-gray-200 dark:border-dark-600 cursor-pointer ${
isSideBarOpen &&
`px-4 py-2 rounded-xl text-right text-dark-800 dark:text-dark-100 border hover:shadow-sm ${
isSideBarOpen && openIndex === originalIndex
? "bg-primary-50 dark:bg-dark-500"
: "bg-white dark:bg-dark-700"
}`
}`}
>
<div className="flex items-center gap-3">
<SVGImage
src={Icon}
className={` text-gray-600 dark:text-primary-100 ${
!isSideBarOpen && "cursor-pointer"
}`}
/>
{isSideBarOpen && (
<span className=" text-gray-600 dark:text-primary-100 font-semibold">
{fa}
</span>
)}
</div>
{isSideBarOpen && (
<ChevronDownIcon
className={`w-5 h-5 text-gray-600 dark:text-gray-300 transition-transform duration-300 ${
openIndex === originalIndex
? "rotate-180"
: ""
}`}
/>
)}
</motion.button>
<AnimatePresence>
{openIndex === originalIndex &&
isSideBarOpen &&
isDomainOpen(domainTitle) && (
<motion.div
key="submenu"
variants={submenuVariants}
initial="hidden"
animate="visible"
exit="exit"
className="mt-2 mr-2 flex flex-col gap-2 border-r-2 border-primary-500 dark:border-primary-400 pr-4"
>
{subItems
.filter((item) => !item?.path.includes("$"))
?.map((sub, subIndex) => (
<motion.button
onClick={() => {
navigate({ to: sub.path });
}}
key={subIndex}
whileTap={{ scale: 0.97 }}
className={`${
location.pathname === sub.path
? "bg-primary-100 dark:bg-dark-500 hover:bg-primary-100 dark:hover:bg-dark-400"
: "bg-white dark:bg-dark-600 hover:bg-primary-100 dark:hover:bg-dark-700"
} text-nowrap text-gray-600 text-sm dark:text-dark-200 px-3 py-2 rounded-lg text-right transition-colors shadow-sm cursor-pointer`}
>
{getFaPermissions(sub.name)}
</motion.button>
))}
</motion.div>
)}
</AnimatePresence>
</motion.div>
),
)}
</div>
)}
</div>
),
)
: indexedNonAdminItems.map(
({ fa, icon: Icon, subItems, originalIndex }) => (
<motion.div key={`plain-${originalIndex}`} variants={itemVariants}>
<motion.button
onClick={() => {
toggleSideBar({ state: true });
toggleSubmenu(originalIndex);
}}
whileTap={{ scale: 0.97 }}
className={`w-full flex justify-between items-center transition-all border-gray-200 dark:border-dark-600 cursor-pointer ${
isSideBarOpen &&
`px-4 py-2 rounded-xl text-right text-dark-800 dark:text-dark-100 border hover:shadow-sm ${
isSideBarOpen && openIndex === originalIndex
? "bg-primary-50 dark:bg-dark-500"
: "bg-white dark:bg-dark-700"
}`
}`}
>
<div className="flex items-center gap-3">
<SVGImage
src={Icon}
className={` text-gray-600 dark:text-primary-100 ${
!isSideBarOpen && "cursor-pointer"
}`}
/>
{isSideBarOpen && (
<span className=" text-gray-600 dark:text-primary-100 font-semibold">
{fa}
</span>
)}
</div>
{isSideBarOpen && (
<ChevronDownIcon
className={`w-5 h-5 text-gray-600 dark:text-gray-300 transition-transform duration-300 ${
openIndex === originalIndex ? "rotate-180" : ""
}`}
/>
)}
</motion.button>
<AnimatePresence>
{openIndex === originalIndex && isSideBarOpen && (
<motion.div
key="submenu"
variants={submenuVariants}
initial="hidden"
animate="visible"
exit="exit"
className="mt-2 mr-2 flex flex-col gap-2 border-r-2 border-primary-500 dark:border-primary-400 pr-4"
>
{subItems
.filter((item) => !item?.path.includes("$"))
?.map((sub, subIndex) => (
<motion.button
onClick={() => {
navigate({ to: sub.path });
}}
key={subIndex}
whileTap={{ scale: 0.97 }}
className={`${
location.pathname === sub.path
? "bg-primary-100 dark:bg-dark-500 hover:bg-primary-100 dark:hover:bg-dark-400"
: "bg-white dark:bg-dark-600 hover:bg-primary-100 dark:hover:bg-dark-700"
} text-nowrap text-gray-600 text-sm dark:text-dark-200 px-3 py-2 rounded-lg text-right transition-colors shadow-sm cursor-pointer`}
>
{getFaPermissions(sub.name)}
</motion.button>
))}
</motion.div>
)}
</AnimatePresence>
</motion.div>
),
)}
</motion.div> </motion.div>
</Grid> </Grid>
</motion.div> </motion.div>