448 lines
18 KiB
TypeScript
448 lines
18 KiB
TypeScript
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"
|
||
>
|
||
هیچ آیتمی با عبارت "{search}" مطابقت ندارد
|
||
</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>
|
||
);
|
||
}
|