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

447
src/Pages/Dashboard.tsx Normal file
View File

@@ -0,0 +1,447 @@
import moment from "jalali-moment";
import { useEffect, useRef, useState } from "react";
import Typography from "../components/Typography/Typography";
import { useUserProfileStore } from "../context/zustand-store/userStore";
import { ItemWithSubItems } from "../types/userPermissions";
import { getUserPermissions } from "../utils/getUserAvalableItems";
import { getFaPermissions } from "../utils/getFaPermissions";
import { motion, AnimatePresence } from "framer-motion";
import {
MagnifyingGlassIcon,
Squares2X2Icon,
XMarkIcon,
ClockIcon,
CalendarIcon,
ArrowRightCircleIcon,
} from "@heroicons/react/24/outline";
import { checkIsMobile } from "../utils/checkIsMobile";
import { useNavigate } from "@tanstack/react-router";
import { useDashboardTabStore } from "../context/zustand-store/dashboardTabStore";
interface Tab {
id: string;
title: string;
component: React.ComponentType;
path: string;
icon?: React.ComponentType<{ className?: string }>;
}
export default function Dashboard() {
const { profile } = useUserProfileStore();
const { dashboarTabs, setDashboardTabs, activeTabId, setActiveTabId } =
useDashboardTabStore();
const menuItems: ItemWithSubItems[] = getUserPermissions(
profile?.permissions
);
const [tabs, setTabs] = useState<Tab[]>(dashboarTabs || []);
const [search, setSearch] = useState("");
const navigate = useNavigate();
useEffect(() => {
setTabs(dashboarTabs || []);
}, [dashboarTabs]);
useEffect(() => {
setDashboardTabs(tabs);
}, [tabs, setDashboardTabs]);
const persianDate = moment().locale("fa").format("dddd D MMMM YYYY");
const [time, setTime] = useState(
new Date().toLocaleTimeString("fa-IR", {
hour: "2-digit",
minute: "2-digit",
hour12: false,
})
);
useEffect(() => {
const interval = setInterval(() => {
setTime(
new Date().toLocaleTimeString("fa-IR", {
hour: "2-digit",
minute: "2-digit",
hour12: false,
})
);
}, 60000);
return () => clearInterval(interval);
}, []);
const openTab = (subItem: ItemWithSubItems["subItems"][0]) => {
const existingTab = tabs.find((tab) => tab.path === subItem.path);
if (existingTab) {
setActiveTabId(existingTab.id);
} else {
const newTab = {
id: `tab-${Date.now()}`,
title: getFaPermissions(subItem.name),
component: subItem.component,
path: subItem.path,
};
setTabs((prev) => [...prev, newTab]);
setActiveTabId(newTab.id);
}
};
const closeTab = (id: string, e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
const newTabs = tabs.filter((tab) => tab.id !== id);
setTabs(newTabs);
if (activeTabId === id) {
setActiveTabId(
newTabs.length > 0 ? newTabs[newTabs.length - 1].id : null
);
}
};
const closeAllTabs = () => {
setTabs([]);
setActiveTabId(null);
};
const filteredMenuItems = menuItems
.map((item) => ({
...item,
subItems: item.subItems.filter(
(subItem) =>
!subItem.path.includes("$") &&
(search.trim() === "" ||
getFaPermissions(subItem.name).includes(search.trim()))
),
}))
.filter((item) => item.subItems.length > 0);
function findSubItemByPath(
items: ItemWithSubItems[],
path: string
): ItemWithSubItems["subItems"][0] | null {
for (const item of items) {
for (const subItem of item.subItems) {
if (subItem.path === path) return subItem;
}
}
return null;
}
const activeTabObj = tabs.find((tab) => tab.id === activeTabId);
const activeComponentItem =
activeTabObj && findSubItemByPath(menuItems, activeTabObj.path);
const ActiveComponent = activeComponentItem?.component || null;
const draggedTabIndex = useRef<number | null>(null);
const onDragStart = (e: React.DragEvent<HTMLDivElement>, index: number) => {
draggedTabIndex.current = index;
e.dataTransfer.effectAllowed = "move";
};
const onDragOver = (e: React.DragEvent) => {
e.preventDefault();
};
const onDrop = (e: React.DragEvent, dropIndex: number) => {
e.preventDefault();
if (
draggedTabIndex.current === null ||
draggedTabIndex.current === dropIndex
)
return;
const newTabs = [...tabs];
const draggedItem = newTabs[draggedTabIndex.current];
newTabs.splice(draggedTabIndex.current, 1);
newTabs.splice(dropIndex, 0, draggedItem);
draggedTabIndex.current = null;
setTabs(newTabs);
};
const onDragEnd = () => {
draggedTabIndex.current = null;
};
return (
<div className="w-full px-3 py-2 min-h-screen">
<header className="backdrop-blur-xl bg-white/20 dark:bg-dark-800/80 rounded-2xl shadow-lg border border-white/30 dark:border-dark-700/30 p-4 mb-4">
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-3">
<div className="flex items-center gap-2">
<div className="p-2 rounded-lg bg-primary-600 dark:bg-primary-800 backdrop-blur-sm shadow-lg">
<Squares2X2Icon className="w-4 h-4 text-white" />
</div>
<Typography
variant="h6"
className="text-dark-800 dark:text-dark-100 font-semibold"
>
داشبورد
</Typography>
</div>
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-3 w-full sm:w-auto">
<div className="relative w-full sm:w-48 md:w-64">
<div className="absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
<MagnifyingGlassIcon className="w-4 h-4 text-dark-400 dark:text-dark-100" />
</div>
<input
type="text"
placeholder="جستجو..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full pr-8 pl-3 py-2 text-xs rounded-lg backdrop-blur-sm bg-white/30 dark:bg-dark-800/30 border border-white/40 dark:border-dark-600/40 text-dark-700 dark:text-dark-200 placeholder:text-dark-400 focus:outline-none focus:ring-1 focus:ring-primary-500/50 focus:bg-white/50 dark:focus:bg-dark-700/50 transition-all duration-200"
/>
</div>
<div className="flex items-center justify-center sm:justify-start gap-1.5 px-2 py-1.5 rounded-lg backdrop-blur-sm bg-white/20 dark:bg-dark-800/80 border border-white/30 dark:border-dark-600/30">
<CalendarIcon className="w-3 h-3 text-primary-500" />
<span className="text-xs text-dark-600 dark:text-dark-300">
{persianDate}
</span>
<span className="mx-1 h-3 w-px bg-dark-300 dark:bg-dark-600"></span>
<ClockIcon className="w-3 h-3 text-primary-500" />
<span className="text-xs text-dark-600 dark:text-dark-300">
{time}
</span>
</div>
</div>
</div>
</header>
<div className="space-y-3">
{filteredMenuItems.length === 0 ? (
<div className="backdrop-blur-xl bg-white/20 dark:bg-dark-800/80 rounded-xl shadow-lg border border-white/30 dark:border-dark-700/30 p-8">
<div className="flex flex-col items-center justify-center text-center space-y-3">
<div className="p-3 rounded-full backdrop-blur-sm bg-white/30 dark:bg-dark-700/30">
<MagnifyingGlassIcon className="w-6 h-6 text-dark-400" />
</div>
<Typography
variant="body1"
className="text-dark-600 dark:text-dark-300 font-medium"
>
موردی یافت نشد
</Typography>
<Typography
variant="body2"
className="text-dark-400 dark:text-dark-500"
>
هیچ آیتمی با عبارت &quot;{search}&quot; مطابقت ندارد
</Typography>
</div>
</div>
) : (
<div
className={`${
checkIsMobile()
? "space-y-3 pb-20"
: "flex overflow-x-auto pb-2 gap-3 scrollbar-thin scrollbar-thumb-dark-300 dark:scrollbar-thumb-dark-600"
}`}
>
{checkIsMobile()
? filteredMenuItems.map(({ fa, icon: Icon, subItems }, index) => {
const filteredSubItems = subItems.filter(
(item) =>
!item.path.includes("$") &&
getFaPermissions(item.name).includes(search.trim())
);
if (filteredSubItems.length === 0) return null;
return (
<section
key={index}
className="w-full space-y-5 border border-gray-200 dark:border-dark-600 bg-white dark:bg-dark-800 rounded-2xl p-6 shadow-sm"
>
<div className="flex items-center gap-3">
<Icon className="w-6 h-6 text-primary-600 dark:text-primary-400" />
<h2 className="text-xl font-bold text-dark-900 dark:text-white">
{fa}
</h2>
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 xl:grid-cols-5 gap-4">
{filteredSubItems.map((sub, subIndex) => (
<motion.button
key={subIndex}
onClick={() => navigate({ to: sub.path })}
whileHover={{ scale: 1.03 }}
whileTap={{ scale: 0.97 }}
className="flex items-center gap-2 cursor-pointer bg-gray-50 dark:bg-dark-700 text-right border border-gray-200 dark:border-dark-600 hover:border-primary-500 dark:hover:border-primary-400 shadow-sm rounded-xl p-3 transition-all duration-200"
>
<ArrowRightCircleIcon className="w-5 h-5 text-primary-500 dark:text-primary-400" />
<span className="text-sm font-medium text-dark-800 dark:text-white">
{getFaPermissions(sub.name)}
</span>
</motion.button>
))}
</div>
</section>
);
})
: filteredMenuItems.map(({ fa, icon: Icon, subItems }, index) => (
<motion.div
key={index}
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: index * 0.05 }}
className="flex-none w-48 backdrop-blur-xl bg-white/20 dark:bg-dark-800/80 rounded-xl shadow-lg border border-white/30 dark:border-dark-700/30 overflow-hidden"
>
<div className="backdrop-blur-sm bg-white/30 dark:bg-dark-700/30 px-3 py-2 border-b border-white/20 dark:border-dark-600/20">
<div className="flex items-center gap-2">
<div className="p-1.5 rounded-md backdrop-blur-sm bg-primary-500/20 dark:bg-primary-400/20">
<Icon className="w-3.5 h-3.5 text-primary-600 dark:text-primary-400" />
</div>
<span className="text-xs font-semibold text-dark-800 dark:text-dark-100 truncate">
{fa}
</span>
</div>
</div>
<div className="p-1.5 space-y-0.5">
{subItems.map((sub, subIndex) => {
const isActive = tabs.some(
(tab) =>
tab.path === sub.path && activeTabId === tab.id
);
return (
<motion.div
key={subIndex}
initial={{ opacity: 0, x: -5 }}
animate={{ opacity: 1, x: 0 }}
transition={{
delay: index * 0.05 + subIndex * 0.02,
}}
whileHover={{ x: 1 }}
whileTap={{ scale: 0.98 }}
onClick={() => openTab(sub)}
className={`flex items-center gap-1.5 px-2 py-1.5 text-xs rounded-md cursor-pointer transition-all duration-200 focus:outline-none ${
isActive
? "backdrop-blur-sm bg-primary-500/20 dark:bg-primary-400/20 text-primary-700 dark:text-primary-300 "
: "hover:backdrop-blur-sm hover:bg-white/30 dark:hover:bg-dark-600/30 border-none"
}`}
>
<div
className={`w-1.5 h-1.5 rounded-full ${
isActive
? "bg-primary-600 dark:bg-primary-400"
: "bg-dark-400 dark:bg-dark-500"
}`}
/>
<span
className={`truncate ${
isActive
? "text-primary-700 dark:text-white font-medium"
: "text-dark-600 dark:text-dark-200/80"
}`}
>
{getFaPermissions(sub.name)}
</span>
</motion.div>
);
})}
</div>
</motion.div>
))}
</div>
)}
</div>
{tabs.length > 0 && !checkIsMobile() && (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.2 }}
className="backdrop-blur-xl bg-white/20 dark:bg-dark-800/80 rounded-xl shadow-lg border border-white/30 dark:border-dark-700/30 overflow-hidden mt-4"
>
<div className="backdrop-blur-sm bg-white/30 dark:bg-dark-700/30 border-b border-white/20 dark:border-dark-600/20">
<div className="flex items-center justify-between px-3 py-2">
<div className="flex items-center gap-1.5">
<div className="w-1.5 h-1.5 rounded-full bg-green-500"></div>
<span className="text-xs font-medium text-dark-600 dark:text-dark-300">
صفحات باز
</span>
<span className="text-xs text-dark-400 dark:text-dark-500 backdrop-blur-sm bg-white/40 dark:bg-dark-600/40 px-1.5 py-0.5 rounded-full">
{tabs.length}
</span>
</div>
{tabs.length > 1 && (
<button
onClick={closeAllTabs}
className="flex items-center gap-1 text-xs text-dark-500 dark:text-dark-400 hover:text-red-500 dark:hover:text-red-400 px-2 py-1 rounded-md hover:backdrop-blur-sm hover:bg-red-500/20 dark:hover:bg-red-400/20 transition-all duration-200 focus:outline-none"
>
<XMarkIcon className="w-3 h-3" />
بستن همه
</button>
)}
</div>
</div>
<div className="flex items-center overflow-x-auto scrollbar-hide backdrop-blur-sm bg-white/20 dark:bg-dark-700/20 border-b border-white/20 dark:border-dark-600/20">
<AnimatePresence initial={false}>
{tabs.map((tab, index) => (
<motion.div
draggable
key={tab.id}
onDragStart={(e: any) => onDragStart(e, index)}
onDragOver={onDragOver}
onDrop={(e) => onDrop(e, index)}
onDragEnd={onDragEnd}
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 10 }}
transition={{ duration: 0.15 }}
onClick={() => setActiveTabId(tab.id)}
className={`group flex items-center gap-1.5 px-3 py-1 cursor-pointer transition-all duration-200 border-b-2 focus:outline-none ${
activeTabId === tab.id
? "backdrop-blur-sm bg-primary-500/20 dark:bg-primary-400/20 text-primary-700 dark:text-primary-300 border-primary-500 dark:border-primary-400"
: "text-dark-600 dark:text-dark-300 hover:backdrop-blur-sm hover:bg-white/30 dark:hover:bg-dark-600/30 border-transparent hover:border-dark-300 dark:hover:border-dark-500"
}`}
>
{tab.icon && (
<tab.icon
className={`w-3.5 h-3.5 flex-shrink-0 ${
activeTabId === tab.id
? "text-primary-600 dark:text-primary-400"
: "text-dark-400 dark:text-dark-500"
}`}
/>
)}
<span className="text-xs font-medium whitespace-nowrap">
{tab.title}
</span>
<button
onClick={(e) => closeTab(tab.id, e)}
className="opacity-0 group-hover:opacity-100 p-0.5 rounded-full hover:backdrop-blur-sm hover:bg-white/40 dark:hover:bg-dark-500/40 transition-all duration-200 focus:outline-none"
>
<XMarkIcon className="w-2.5 h-2.5" />
</button>
</motion.div>
))}
</AnimatePresence>
</div>
<AnimatePresence mode="wait">
{activeTabId && (
<motion.div
key={activeTabId}
initial={{ opacity: 0, y: 5 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -5 }}
transition={{ duration: 0.2, ease: "easeInOut" }}
className="p-4 backdrop-blur-sm bg-white/10 dark:bg-dark-800/10"
>
{ActiveComponent && <ActiveComponent />}
</motion.div>
)}
</AnimatePresence>
</motion.div>
)}
</div>
);
}