first commit
This commit is contained in:
447
src/Pages/Dashboard.tsx
Normal file
447
src/Pages/Dashboard.tsx
Normal 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"
|
||||
>
|
||||
هیچ آیتمی با عبارت "{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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user