first commit

This commit is contained in:
2026-01-19 13:08:58 +03:30
commit 850b4a3f1e
293 changed files with 51775 additions and 0 deletions

View File

@@ -0,0 +1,758 @@
import { useState, useRef, useEffect, createContext, useContext } from "react";
import {
Cog6ToothIcon,
InformationCircleIcon,
MinusIcon,
DocumentTextIcon,
KeyIcon,
CheckIcon,
} from "@heroicons/react/24/outline";
import { motion, AnimatePresence } from "framer-motion";
import { Grid } from "../Grid/Grid";
import { createPortal } from "react-dom";
import { useUserProfileStore } from "../../context/zustand-store/userStore";
import { Tooltip } from "../Tooltip/Tooltip";
import Button from "../Button/Button";
import { useModalStore } from "../../context/zustand-store/appStore";
import Typography from "../Typography/Typography";
import { useToast } from "../../hooks/useToast";
import { ItemWithSubItems } from "../../types/userPermissions";
import { getUserPermissions } from "../../utils/getUserAvalableItems";
import { useApiRequest } from "../../utils/useApiRequest";
import Checkbox from "../CheckBox/CheckBox";
import { useFetchProfile } from "../../hooks/useFetchProfile";
import { useQueryClient } from "@tanstack/react-query";
import api from "../../utils/axios";
import { getFaPermissions } from "../../utils/getFaPermissions";
export const PopOverContext = createContext<boolean>(false);
export const usePopOverContext = () => useContext(PopOverContext);
const PermissionsContent = ({
filterProps,
showToast,
matchedSubItem,
}: {
filterProps: Array<{ page: string; access: string; title: string }>;
showToast: (text: string, type?: "success" | "error" | "default") => void;
matchedSubItem: any;
}) => {
const [copiedItem, setCopiedItem] = useState<{
type: "page" | "access";
index: number;
} | null>(null);
const [expandedPermissions, setExpandedPermissions] = useState<{
[key: string]: boolean;
}>({});
const queryClient = useQueryClient();
const { getProfile } = useFetchProfile();
const [selectedRoleIdsByKey, setSelectedRoleIdsByKey] = useState<{
[key: string]: Set<number>;
}>({});
const [submittingKey, setSubmittingKey] = useState<string | null>(null);
const { data: rolesData, refetch: refetchRoles } = useApiRequest({
api: "/auth/api/v1/role/",
method: "get",
params: { page: 1, page_size: 1000 },
queryKey: ["roles-permissions"],
});
const { data: permissionsData } = useApiRequest({
api: "/auth/api/v1/permission/",
method: "get",
params: { page: 1, page_size: 1000 },
queryKey: ["all-permissions"],
});
const findPermissionId = (page: string, access: string): number | null => {
if (!permissionsData?.results) return null;
const permission = permissionsData.results.find(
(p: any) => p.page === page && p.name === access
);
return permission?.id || null;
};
const checkRoleHasPermission = (
role: any,
page: string,
access: string
): boolean => {
if (!role?.permissions || !Array.isArray(role.permissions)) {
return false;
}
if (!permissionsData?.results) return false;
const permissionId = findPermissionId(page, access);
if (!permissionId) return false;
const rolePermissionIds = role.permissions.map((p: any) => p.id || p);
return rolePermissionIds.includes(permissionId);
};
const ensureInitialized = (key: string, page: string, access: string) => {
if (selectedRoleIdsByKey[key]) return;
if (!rolesData?.results) return;
const next = new Set<number>();
rolesData.results.forEach((role: any) => {
if (checkRoleHasPermission(role, page, access)) {
next.add(role.id);
}
});
setSelectedRoleIdsByKey((prev) => ({ ...prev, [key]: next }));
};
const toggleLocal = (key: string, roleId: number) => {
setSelectedRoleIdsByKey((prev) => {
const current = new Set(prev[key] || []);
if (current.has(roleId)) current.delete(roleId);
else current.add(roleId);
return { ...prev, [key]: current };
});
};
const handleCancel = (key: string, page: string, access: string) => {
if (!rolesData?.results) return;
const next = new Set<number>();
rolesData.results.forEach((role: any) => {
if (checkRoleHasPermission(role, page, access)) {
next.add(role.id);
}
});
setSelectedRoleIdsByKey((prev) => ({ ...prev, [key]: next }));
};
const handleSubmit = async (key: string, page: string, access: string) => {
try {
const permissionId = findPermissionId(page, access);
if (!permissionId) {
showToast("دسترسی یافت نشد", "error");
return;
}
if (!rolesData?.results) return;
let selected = selectedRoleIdsByKey[key];
if (!selected) {
const init = new Set<number>();
rolesData.results.forEach((role: any) => {
if (checkRoleHasPermission(role, page, access)) {
init.add(role.id);
}
});
setSelectedRoleIdsByKey((prev) => ({ ...prev, [key]: init }));
selected = init;
}
const updates = rolesData.results.flatMap((role: any) => {
const currentPermissionIds = (role.permissions || []).map(
(p: any) => p.id || p
);
const currentlyHas = currentPermissionIds.includes(permissionId);
const shouldHave = selected.has(role.id);
if (currentlyHas === shouldHave) return [];
const updatedPermissionIds = shouldHave
? currentPermissionIds.includes(permissionId)
? currentPermissionIds
: [...currentPermissionIds, permissionId]
: currentPermissionIds.filter((id: number) => id !== permissionId);
return [
api.patch(`/auth/api/v1/role/${role.id}/`, {
permissions: updatedPermissionIds,
}),
];
});
if (updates.length === 0) {
showToast("تغییری برای ارسال وجود ندارد", "default");
return;
}
setSubmittingKey(key);
await Promise.all(updates);
showToast("به‌روزرسانی نقش‌ها با موفقیت انجام شد", "success");
queryClient.invalidateQueries({ queryKey: ["roles"] });
queryClient.invalidateQueries({ queryKey: ["roles-permissions"] });
refetchRoles();
getProfile();
} catch (error: any) {
showToast(
error?.response?.data?.message || "خطا در به‌روزرسانی نقش‌ها",
"error"
);
} finally {
setSubmittingKey(null);
}
};
const handleCopy = async (
text: string,
type: "page" | "access",
index: number
) => {
try {
await navigator.clipboard.writeText(text);
setCopiedItem({ type, index });
showToast(
type === "page" ? `صفحه "${text}" کپی شد` : `دسترسی "${text}" کپی شد`,
"success"
);
setTimeout(() => {
setCopiedItem(null);
}, 2000);
} catch {
showToast("خطا در کپی کردن", "error");
}
};
return (
<div className="">
<Grid
container
className="mb-2 gap-1 border-gray-200/60 dark:border-gray-700/40 justify-center items-center"
>
{matchedSubItem?.name && (
<div className="px-3 py-1.5 rounded-lg bg-gradient-to-r from-gray-50 to-gray-100/50 dark:from-gray-800/50 dark:to-gray-800/30 border border-gray-200/50 dark:border-gray-700/30 shadow-sm">
<Typography
variant="caption"
className="text-gray-700 dark:text-gray-300 font-medium text-sm line-clamp-1"
>
صفحه فعلی: {matchedSubItem?.name} (
{getFaPermissions(matchedSubItem?.name)})
</Typography>
</div>
)}
</Grid>
<div className="space-y-3">
{filterProps && filterProps.length > 0 ? (
filterProps.map(
(
item: {
page: string;
access: string;
title: string;
},
index: number
) => (
<motion.div
key={`${item.page}-${index}`}
initial={{ opacity: 0, y: 5 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.03 }}
className="relative bg-gradient-to-br from-gray-50 to-gray-100/50 dark:from-dark-700 dark:to-dark-800 rounded-lg p-2.5 border border-gray-200/60 dark:border-gray-600/40 shadow-sm hover:shadow transition-all duration-200 group"
>
<div className="flex items-center gap-2.5">
<div className="flex-shrink-0 w-7 h-7 rounded-md bg-primary-100 dark:bg-primary-900/30 flex items-center justify-center group-hover:bg-primary-200 dark:group-hover:bg-primary-900/50 transition-colors">
<span className="text-xs text-primary-600 dark:text-primary-400 font-bold">
{index + 1}
</span>
</div>
<div className="flex-1 min-w-0">
{item.title && (
<Typography
variant="subtitle2"
className="text-gray-700 dark:text-gray-300 font-medium text-sm mb-1.5 line-clamp-1"
>
{item.title}
</Typography>
)}
<div className="flex flex-wrap items-center gap-2">
{item.page && (
<div
onClick={(e) => {
e.stopPropagation();
handleCopy(item.page, "page", index);
}}
className="flex items-center gap-1.5 px-2 py-1 rounded-md bg-white/60 dark:bg-dark-600/60 border border-gray-200/50 dark:border-gray-700/50 cursor-pointer hover:bg-white/80 dark:hover:bg-dark-600/80 transition-colors active:scale-95"
>
{copiedItem?.type === "page" &&
copiedItem?.index === index ? (
<CheckIcon className="w-3.5 h-3.5 text-green-500 dark:text-green-400 flex-shrink-0" />
) : (
<DocumentTextIcon className="w-3.5 h-3.5 text-gray-500 dark:text-gray-400 flex-shrink-0" />
)}
<Typography
variant="caption"
className="text-gray-700 dark:text-gray-300 text-xs font-medium truncate max-w-[120px]"
>
{item.page}
</Typography>
</div>
)}
{item.access && (
<span
onClick={(e) => {
e.stopPropagation();
handleCopy(item.access, "access", index);
}}
className="inline-flex items-center gap-1.5 px-2 py-1 rounded-md text-xs font-semibold bg-primary-100 dark:bg-primary-900/40 text-primary-700 dark:text-primary-300 border border-primary-200 dark:border-primary-800 cursor-pointer hover:bg-primary-200 dark:hover:bg-primary-900/60 transition-colors active:scale-95"
>
{copiedItem?.type === "access" &&
copiedItem?.index === index ? (
<CheckIcon className="w-3.5 h-3.5 text-green-500 dark:text-green-400" />
) : (
<KeyIcon className="w-3.5 h-3.5" />
)}
{item.access}
</span>
)}
</div>
{rolesData?.results && rolesData.results.length > 0 && (
<div className="mt-3 pt-3 border-t border-gray-200/60 dark:border-gray-700/40">
<button
onClick={(e) => {
e.stopPropagation();
setExpandedPermissions((prev) => ({
...prev,
[`${item.page}-${item.access}-${index}`]:
!prev[`${item.page}-${item.access}-${index}`],
}));
const k = `${item.page}-${item.access}-${index}`;
ensureInitialized(k, item.page, item.access);
}}
className="flex items-center gap-2 text-xs text-gray-600 dark:text-gray-400 hover:text-primary-600 dark:hover:text-primary-400 transition-colors mb-2"
>
<span>
{expandedPermissions[
`${item.page}-${item.access}-${index}`
]
? "▲"
: "▼"}{" "}
نقشها (
{
rolesData.results.filter((role: any) =>
checkRoleHasPermission(
role,
item.page,
item.access
)
).length
}
/{rolesData.results.length})
</span>
</button>
{expandedPermissions[
`${item.page}-${item.access}-${index}`
] && (
<div className="space-y-2 max-h-60 overflow-y-auto">
{rolesData.results.map((role: any) => {
const k = `${item.page}-${item.access}-${index}`;
const selectedSet = selectedRoleIdsByKey[k];
const hasPermission =
selectedSet?.has(role.id) ??
checkRoleHasPermission(
role,
item.page,
item.access
);
return (
<div
key={role.id}
className="flex items-center gap-2 px-2 py-1.5 rounded-md bg-white/40 dark:bg-dark-600/40 border border-gray-200/50 dark:border-gray-700/50 hover:bg-white/60 dark:hover:bg-dark-600/60 transition-colors"
>
<Checkbox
checked={hasPermission}
disabled={submittingKey === k}
onChange={(e) => {
e.stopPropagation();
ensureInitialized(
k,
item.page,
item.access
);
toggleLocal(k, role.id);
}}
size="small"
/>
<Typography
variant="caption"
className="text-xs text-gray-700 dark:text-gray-300 flex-1"
>
{role.role_name}
</Typography>
{role.type?.name && (
<Typography
variant="caption"
className="text-xs text-gray-500 dark:text-gray-400"
>
({role.type.name})
</Typography>
)}
</div>
);
})}
</div>
)}
{expandedPermissions[
`${item.page}-${item.access}-${index}`
] && (
<div className="flex items-center justify-between gap-2 mt-2">
<button
className="px-3 py-1.5 text-xs rounded-md bg-gray-100 dark:bg-dark-600 text-gray-700 dark:text-gray-200 hover:bg-gray-200/80 dark:hover:bg-dark-500"
onClick={(e) => {
e.stopPropagation();
const k = `${item.page}-${item.access}-${index}`;
handleCancel(k, item.page, item.access);
}}
disabled={
submittingKey ===
`${item.page}-${item.access}-${index}`
}
>
انصراف
</button>
<button
className="px-3 py-1.5 text-xs rounded-md bg-primary-600 text-white hover:bg-primary-700 disabled:opacity-60"
onClick={(e) => {
e.stopPropagation();
const k = `${item.page}-${item.access}-${index}`;
handleSubmit(k, item.page, item.access);
}}
disabled={
submittingKey ===
`${item.page}-${item.access}-${index}`
}
>
{submittingKey ===
`${item.page}-${item.access}-${index}`
? "در حال ارسال..."
: "ثبت تغییرات"}
</button>
</div>
)}
</div>
)}
</div>
</div>
</motion.div>
)
)
) : (
<div className="text-center py-6 text-gray-500 dark:text-gray-400">
<Typography variant="body2" className="text-sm">
دسترسیای یافت نشد
</Typography>
</div>
)}
</div>
</div>
);
};
export const Popover = ({
children,
className,
}: {
children: React.ReactNode | any;
className?: string;
}) => {
const { openModal } = useModalStore();
const { profile } = useUserProfileStore();
const menuItems: ItemWithSubItems[] = getUserPermissions(
profile?.permissions
);
const currentPath = window.location.pathname;
const matchedSubItem = menuItems
.flatMap((item) => item.subItems)
.find(
(sub) =>
sub.path === currentPath ||
sub.path.includes(currentPath.split("/")[1] + "/")
);
const [isOpen, setIsOpen] = useState(false);
const [isMobile, setIsMobile] = useState(false);
const buttonRef = useRef<HTMLButtonElement>(null);
const popoverRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const checkMobile = () => setIsMobile(window.innerWidth < 640);
checkMobile();
window.addEventListener("resize", checkMobile);
return () => window.removeEventListener("resize", checkMobile);
}, []);
const buttonRect = buttonRef?.current?.getBoundingClientRect();
const showToast = useToast();
const filterProps: Array<{ page: string; access: string; title: string }> =
!Array.isArray(children)
? [
{
page:
children?.props?.page ||
children?.props?.children?.props?.page ||
"",
access:
children?.props?.access ||
children?.props?.children?.props?.access ||
"",
title: children?.props?.tooltipText || children?.props?.title || "",
},
]
: children
?.filter(
(item: any) =>
(item?.props?.page && item?.props?.access) ||
(item?.props?.children?.props?.access &&
item?.props?.children?.props?.page)
)
?.map((option: any) => {
return {
page:
option?.props?.page ||
option?.props?.children?.props?.page ||
"",
access:
option?.props?.access ||
option?.props?.children?.props?.access ||
"",
title: option?.props?.tooltipText || option?.props?.title || "",
};
}) || [];
const ableToSeeAnyButton = (
filterProps: Array<{ page: string; access: string }>
) => {
if (!filterProps || filterProps.length === 0) {
return true;
}
return filterProps?.some(({ page, access }) => {
if (!access || !page) {
return true;
}
const foundPermission = profile?.permissions?.find(
(item: any) => item.page_name === page
);
return foundPermission && foundPermission.page_access.includes(access);
});
};
useEffect(() => {
const handleClickOutside = (e: MouseEvent | TouchEvent) => {
if (
!buttonRef.current?.contains(e.target as Node) &&
!popoverRef.current?.contains(e.target as Node)
) {
setIsOpen(false);
}
};
if (isOpen) {
document.addEventListener("mousedown", handleClickOutside);
if (isMobile) document.body.style.overflow = "hidden";
}
return () => {
document.removeEventListener("mousedown", handleClickOutside);
if (isMobile) document.body.style.overflow = "";
};
}, [isOpen, isMobile]);
const togglePopover = () => setIsOpen(!isOpen);
const [position, setPosition] = useState({ top: 0, left: 0 });
useEffect(() => {
if (!isOpen || isMobile) return;
const updatePosition = () => {
const buttonRect = buttonRef.current?.getBoundingClientRect();
const popoverElement = popoverRef.current;
if (!buttonRect) return;
const viewportHeight = window.innerHeight;
const viewportWidth = window.innerWidth;
const scrollY = window.scrollY;
const scrollX = window.scrollX;
const padding = 16;
const maxPopoverHeight = viewportHeight - padding * 2;
let top = buttonRect.top + scrollY;
let left = buttonRect.right + scrollX + 8;
if (popoverElement) {
const popoverRect = popoverElement.getBoundingClientRect();
const popoverHeight = Math.min(popoverRect.height, maxPopoverHeight);
const popoverWidth = popoverRect.width;
const popoverBottomInViewport = buttonRect.top + popoverHeight;
if (popoverBottomInViewport > viewportHeight - padding) {
const overflow = popoverBottomInViewport - (viewportHeight - padding);
top = buttonRect.top + scrollY - overflow;
if (top < scrollY + padding) {
top = scrollY + padding;
}
}
const popoverRightInViewport = buttonRect.right + popoverWidth + 8;
if (popoverRightInViewport > viewportWidth - padding) {
left = buttonRect.left + scrollX - popoverWidth - 8;
if (left < scrollX + padding) {
left = scrollX + padding;
}
}
} else {
const estimatedHeight = Math.min(
(Array.isArray(children) ? children.length : 1) * 50 + 100,
maxPopoverHeight
);
if (buttonRect.top + estimatedHeight > viewportHeight - padding) {
const overflow =
buttonRect.top + estimatedHeight - (viewportHeight - padding);
top = buttonRect.top + scrollY - overflow;
if (top < scrollY + padding) {
top = scrollY + padding;
}
}
const estimatedWidth = 300;
if (buttonRect.right + estimatedWidth + 8 > viewportWidth - padding) {
left = buttonRect.left + scrollX - estimatedWidth - 8;
if (left < scrollX + padding) {
left = scrollX + padding;
}
}
}
setPosition({ top, left });
};
updatePosition();
const timeoutId = setTimeout(updatePosition, 0);
window.addEventListener("scroll", updatePosition, true);
window.addEventListener("resize", updatePosition);
return () => {
clearTimeout(timeoutId);
window.removeEventListener("scroll", updatePosition, true);
window.removeEventListener("resize", updatePosition);
};
}, [isOpen, isMobile, children]);
return (
<PopOverContext.Provider value={true}>
<div className={`relative ${className}`}>
<motion.button
disabled={!ableToSeeAnyButton(filterProps)}
ref={buttonRef}
onClick={togglePopover}
className={`p-2 rounded-full hover:bg-gray-100/50 dark:hover:bg-gray-800 transition-colors`}
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
>
<motion.div animate={{ rotate: isOpen ? 90 : 0 }}>
{!ableToSeeAnyButton(filterProps) ? (
<MinusIcon className="h-5 w-5 text-red-600 cursor-not-allowed" />
) : (
<Cog6ToothIcon
className={`h-5 w-5 ${
isOpen
? "text-primary-600"
: "text-gray-600 dark:text-gray-300"
}`}
/>
)}
</motion.div>
</motion.button>
{isOpen &&
createPortal(
<AnimatePresence>
<>
{isMobile && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 0.4 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-[9998] bg-black/40 backdrop-blur-sm"
onClick={() => setIsOpen(false)}
/>
)}
<motion.div
ref={popoverRef}
id="popover-content"
initial={isMobile ? { y: "100%" } : { opacity: 0, y: -10 }}
animate={isMobile ? { y: 0 } : { opacity: 1, y: 0 }}
exit={isMobile ? { y: "100%" } : { opacity: 0, y: -10 }}
transition={{ type: "spring", damping: 20, stiffness: 300 }}
className={`z-[9999] bg-white dark:bg-dark-700 rounded-lg shadow-lg ring-1 ring-black/10 dark:ring-white/10 ${
isMobile
? "fixed bottom-0 inset-x-0 rounded-t-3xl pb-[env(safe-area-inset-bottom)]"
: "fixed max-h-[calc(100vh-32px)]"
}`}
style={
!isMobile
? {
top: `${
buttonRect?.bottom && buttonRect?.bottom > 700
? position.top - 100
: position.top
}px`,
left: `${position.left}px`,
}
: {}
}
>
{isMobile && (
<div className="flex justify-center py-2">
<div className="w-10 h-1 bg-gray-300 dark:bg-gray-600 rounded-full" />
</div>
)}
<PopOverContext.Provider value={true}>
<Grid
container
column
className="gap-2 p-2 max-h-[calc(100vh-32px)] overflow-y-auto"
onClick={() => setIsOpen(false)}
>
{children}
{profile?.role?.type?.key === "ADM" && (
<Tooltip
title="نمایش دسترسی های این قسمت"
position="right"
>
<Button
icon={
<InformationCircleIcon className="w-5 h-5 text-gray-600 dark:text-red-400" />
}
variant="detail"
onClick={() => {
openModal({
title: "دسترسی های این قسمت",
content: (
<PermissionsContent
filterProps={filterProps}
showToast={showToast}
matchedSubItem={matchedSubItem}
/>
),
});
}}
/>
</Tooltip>
)}
</Grid>
</PopOverContext.Provider>
</motion.div>
</>
</AnimatePresence>,
document.body
)}
</div>
</PopOverContext.Provider>
);
};