Reviewed-on: https://git.pelekan.org/Saeed0920/inogen/pulls/15 Co-authored-by: Saeed Abadiyan <sd.eed1381@gmail.com> Co-committed-by: Saeed Abadiyan <sd.eed1381@gmail.com>
488 lines
16 KiB
TypeScript
488 lines
16 KiB
TypeScript
import {
|
||
ChevronDown,
|
||
GalleryVerticalEnd,
|
||
House,
|
||
LightbulbIcon,
|
||
ListTodo,
|
||
LogOut,
|
||
Radar,
|
||
Settings,
|
||
Star,
|
||
Workflow,
|
||
DiscAlbum,
|
||
LucideLightbulb
|
||
} from "lucide-react";
|
||
import React, { useState } from "react";
|
||
import { Link, useLocation } from "react-router";
|
||
import { useAuth } from "~/contexts/auth-context";
|
||
import { cn } from "~/lib/utils";
|
||
|
||
interface TitleInfo {
|
||
title: string;
|
||
icon?: React.ComponentType<{ className?: string }> | null;
|
||
}
|
||
|
||
interface SidebarProps {
|
||
isCollapsed?: boolean;
|
||
onToggleCollapse?: () => void;
|
||
className?: string;
|
||
onStrategicAlignmentClick?: () => void;
|
||
onTitleChange?: (info: TitleInfo) => void;
|
||
}
|
||
|
||
interface MenuItem {
|
||
id: string;
|
||
label: string;
|
||
icon: React.ComponentType<{ className?: string }> | null;
|
||
href?: string;
|
||
children?: MenuItem[];
|
||
badge?: string | number;
|
||
}
|
||
|
||
const menuItems: MenuItem[] = [
|
||
{
|
||
id: "dashboard",
|
||
label: "صفحه اصلی",
|
||
icon: House,
|
||
href: "/dashboard",
|
||
},
|
||
{
|
||
id: "project-management",
|
||
label: "مدیریت اجرای پروژهها",
|
||
icon: ListTodo,
|
||
href: "/dashboard/project-management",
|
||
},
|
||
{
|
||
id: "innovation-basket",
|
||
label: "سبد فناوری و نوآوری",
|
||
icon: LightbulbIcon,
|
||
children: [
|
||
{
|
||
id: "product-innovation",
|
||
label: "نوآوری در محصول",
|
||
icon: null,
|
||
href: "/dashboard/innovation-basket/product-innovation",
|
||
},
|
||
{
|
||
id: "process-innovation",
|
||
label: "نوآوری در فرآیند",
|
||
icon: null,
|
||
href: "/dashboard/innovation-basket/process-innovation",
|
||
},
|
||
{
|
||
id: "digital-innovation",
|
||
label: "نوآوری دیجیتال",
|
||
icon: null,
|
||
href: "/dashboard/innovation-basket/digital-innovation",
|
||
},
|
||
{
|
||
id: "green-innovation",
|
||
label: "نوآوری سبز",
|
||
icon: null,
|
||
href: "/dashboard/innovation-basket/green-innovation",
|
||
},
|
||
{
|
||
id: "internal-innovation",
|
||
label: "نوآوری ساخت داخل",
|
||
icon: null,
|
||
href: "/dashboard/innovation-basket/internal-innovation",
|
||
},
|
||
],
|
||
},
|
||
{
|
||
id: "ecosystem",
|
||
label: "زیست بوم فناوری و نوآوری",
|
||
icon: Radar,
|
||
href: "/dashboard/ecosystem",
|
||
},
|
||
{
|
||
id: "ideas",
|
||
label: "ایدههای فناوری و نوآوری",
|
||
icon: LucideLightbulb,
|
||
href: "/dashboard/manage-ideas-tech",
|
||
},
|
||
{
|
||
id: "strategic-alignment",
|
||
label: "میزان انطباق راهبردی",
|
||
icon: null,
|
||
href: "#", // This is not a route, it opens a popup
|
||
},
|
||
];
|
||
|
||
const bottomMenuItems: MenuItem[] = [
|
||
{
|
||
id: "settings",
|
||
label: "تنظیمات",
|
||
icon: Settings,
|
||
href: "/dashboard/settings",
|
||
},
|
||
{
|
||
id: "logout",
|
||
label: "خروج",
|
||
icon: LogOut,
|
||
href: "#",
|
||
},
|
||
];
|
||
|
||
export function Sidebar({
|
||
isCollapsed = false,
|
||
onToggleCollapse,
|
||
className,
|
||
onStrategicAlignmentClick,
|
||
onTitleChange,
|
||
}: SidebarProps) {
|
||
const location = useLocation();
|
||
const [expandedItems, setExpandedItems] = useState<string[]>([]);
|
||
const { logout } = useAuth();
|
||
|
||
// Auto-expand parent sections when their children are active
|
||
React.useEffect(() => {
|
||
const autoExpandParents = () => {
|
||
const newExpandedItems: string[] = [];
|
||
|
||
menuItems.forEach((item) => {
|
||
if (item.children) {
|
||
const hasActiveChild = item.children.some(
|
||
(child) => child.href && location.pathname === child.href
|
||
);
|
||
if (hasActiveChild) {
|
||
newExpandedItems.push(item.id);
|
||
}
|
||
}
|
||
});
|
||
|
||
setExpandedItems(newExpandedItems);
|
||
// Update header title based on current route
|
||
// If a child route is active, use that child's label prefixed by parent label
|
||
let activeTitle: string | undefined = undefined;
|
||
let activeIcon:
|
||
| React.ComponentType<{ className?: string }>
|
||
| null
|
||
| undefined = undefined;
|
||
menuItems.forEach((item) => {
|
||
if (item.children) {
|
||
const activeChild = item.children.find(
|
||
(child) => child.href && location.pathname === child.href
|
||
);
|
||
if (activeChild) {
|
||
activeTitle = `${item.label}-${activeChild.label}`;
|
||
// prefer child icon for the page; fallback to parent
|
||
activeIcon = activeChild.icon ?? item.icon ?? null;
|
||
}
|
||
}
|
||
if (!activeTitle && item.href && location.pathname === item.href) {
|
||
activeTitle = item.label;
|
||
activeIcon = item.icon ?? null;
|
||
}
|
||
});
|
||
if (onTitleChange) {
|
||
onTitleChange({
|
||
title: activeTitle ?? "صفحه اول",
|
||
icon: activeIcon ?? null,
|
||
});
|
||
}
|
||
};
|
||
|
||
autoExpandParents();
|
||
}, [location.pathname]);
|
||
|
||
const toggleExpanded = (itemId: string) => {
|
||
setExpandedItems((prev) => {
|
||
// If trying to collapse, check if any child is active
|
||
if (prev.includes(itemId)) {
|
||
const item = menuItems.find((menuItem) => menuItem.id === itemId);
|
||
if (item?.children) {
|
||
const hasActiveChild = item.children.some(
|
||
(child) => child.href && location.pathname === child.href
|
||
);
|
||
// Don't collapse if a child is active
|
||
if (hasActiveChild) {
|
||
return prev;
|
||
}
|
||
}
|
||
return prev.filter((id) => id !== itemId);
|
||
} else {
|
||
return [...prev, itemId];
|
||
}
|
||
});
|
||
};
|
||
|
||
const isActiveRoute = (href?: string, children?: MenuItem[]) => {
|
||
if (href && location.pathname === href) return true;
|
||
if (children) {
|
||
return children.some(
|
||
(child) => child.href && location.pathname === child.href
|
||
);
|
||
}
|
||
return false;
|
||
};
|
||
|
||
const renderMenuItem = (item: MenuItem, level = 0) => {
|
||
const isActive = isActiveRoute(item.href, item.children);
|
||
const isExpanded =
|
||
expandedItems.includes(item.id) ||
|
||
(item.children &&
|
||
item.children.some(
|
||
(child) => child.href && location.pathname === child.href
|
||
));
|
||
const hasChildren = item.children && item.children.length > 0;
|
||
|
||
const ItemIcon = item.icon;
|
||
|
||
const handleClick = () => {
|
||
// Only update header title for navigable items (those with href)
|
||
if (item.href && item.href !== "#") {
|
||
const icon = item.icon ?? null;
|
||
onTitleChange?.({ title: item.label, icon });
|
||
}
|
||
|
||
if (item.id === "strategic-alignment") {
|
||
onStrategicAlignmentClick?.();
|
||
} else if (item.id === "logout") {
|
||
logout();
|
||
} else if (hasChildren) {
|
||
toggleExpanded(item.id);
|
||
}
|
||
};
|
||
|
||
if (item.id === "strategic-alignment") {
|
||
return (
|
||
<button
|
||
key={item.id}
|
||
className={cn(
|
||
"flex items-center justify-center w-full px-2 rounded-none mt-4 transition-all duration-200 group"
|
||
)}
|
||
onClick={handleClick}
|
||
>
|
||
<div className="flex justify-center rounded-xl border-gray-500/20 border-2 cursor-pointer transition-all hover:bg-[#3F415A]/50 bg-[#3F415A] py-2 text-center items-center gap-3 min-w-0 flex-1">
|
||
<span className="font-persian text-sm font-medium truncate">
|
||
{item.label}
|
||
</span>
|
||
</div>
|
||
</button>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div key={item.id} className="relative">
|
||
{item.href && item.href !== "#" ? (
|
||
<Link to={item.href} className="block">
|
||
<div
|
||
className={cn(
|
||
"flex items-center justify-between rounded-none w-full py-2 px-3 transition-all duration-200 group",
|
||
level === 0 ? "mb-1" : "mb-0.5 mr-4",
|
||
isActive
|
||
? " text-pr-green border-r-2 border-pr-green"
|
||
: "text-gray-300 hover:text-pr-green",
|
||
isCollapsed && level === 0 && "justify-center px-2",
|
||
item.id === "logout" && "hover:text-pr-red"
|
||
)}
|
||
>
|
||
<div className="flex items-center gap-3 min-w-0 flex-1">
|
||
{ItemIcon && (
|
||
<ItemIcon
|
||
className={cn(
|
||
"w-5 h-5 flex-shrink-0",
|
||
isActive ? "text-pr-green" : "text-current"
|
||
)}
|
||
/>
|
||
)}
|
||
{!isCollapsed && (
|
||
<span className="font-persian text-sm font-medium truncate">
|
||
{item.label}
|
||
</span>
|
||
)}
|
||
</div>
|
||
|
||
{!isCollapsed && (
|
||
<div className="flex items-center gap-2 flex-shrink-0">
|
||
{item.badge && (
|
||
<span className="bg-gradient-to-r from-emerald-500/20 to-teal-500/20 text-pr-green text-xs font-medium px-1.5 py-0.5 rounded-full min-w-[20px] text-center font-persian">
|
||
{item.badge}
|
||
</span>
|
||
)}
|
||
{hasChildren && (
|
||
<ChevronDown
|
||
className={cn(
|
||
"w-4 h-4 transition-transform duration-200",
|
||
isExpanded ? "rotate-180" : "rotate-0"
|
||
)}
|
||
/>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</Link>
|
||
) : (
|
||
<button
|
||
className={cn(
|
||
"w-full text-right",
|
||
// Disable pointer cursor when child is active (cannot collapse)
|
||
item.children &&
|
||
item.children.some(
|
||
(child) => child.href && location.pathname === child.href
|
||
) &&
|
||
"cursor-not-allowed"
|
||
)}
|
||
onClick={handleClick}
|
||
>
|
||
<div
|
||
className={cn(
|
||
"flex items-center justify-between w-full py-2 px-3 rounded-none transition-all duration-200 group",
|
||
level === 0 ? "mb-1" : "mb-0.5 mr-4",
|
||
isActive
|
||
? " text-pr-green border-r-2 border-pr-green"
|
||
: "text-gray-300 cursor-pointer hover:text-pr-green",
|
||
isCollapsed && level === 0 && "justify-center px-2",
|
||
item.id === "logout" && "hover:text-pr-red"
|
||
)}
|
||
>
|
||
<div className="flex items-center gap-3 min-w-0 flex-1">
|
||
{ItemIcon && (
|
||
<ItemIcon
|
||
className={cn(
|
||
"w-5 h-5 flex-shrink-0",
|
||
isActive ? "text-pr-green" : "text-current"
|
||
)}
|
||
/>
|
||
)}
|
||
{!isCollapsed && (
|
||
<span className="font-persian text-sm font-medium truncate">
|
||
{item.label}
|
||
</span>
|
||
)}
|
||
</div>
|
||
|
||
{!isCollapsed && (
|
||
<div className="flex items-center gap-2 flex-shrink-0">
|
||
{item.badge && (
|
||
<span className="bg-gradient-to-r from-emerald-500/20 to-teal-500/10 text-pr-green text-xs font-medium px-1.5 py-0.5 rounded-full min-w-[20px] text-center font-persian">
|
||
{item.badge}
|
||
</span>
|
||
)}
|
||
{hasChildren && (
|
||
<ChevronDown
|
||
className={cn(
|
||
"w-4 h-4 transition-transform duration-200",
|
||
isExpanded ? "rotate-180" : "rotate-0",
|
||
// Show different color when child is active (cannot collapse)
|
||
item.children &&
|
||
item.children.some(
|
||
(child) =>
|
||
child.href && location.pathname === child.href
|
||
)
|
||
? "text-pr-green"
|
||
: "text-current"
|
||
)}
|
||
/>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</button>
|
||
)}
|
||
{/* Submenu */}
|
||
{hasChildren && isExpanded && !isCollapsed && (
|
||
<div className="mt-1 space-y-0.5">
|
||
{item.children?.map((child) => renderMenuItem(child, level + 1))}
|
||
</div>
|
||
)}
|
||
{/* Tooltip for collapsed state */}
|
||
{isCollapsed && level === 0 && (
|
||
<div className="absolute right-full top-1/2 transform -translate-y-1/2 mr-2 opacity-0 group-hover:opacity-100 transition-opacity duration-200 pointer-events-none z-50">
|
||
<div className="bg-gray-800 border border-emerald-500/30 text-white text-sm px-2 py-1 rounded whitespace-nowrap font-persian">
|
||
{item.label}
|
||
<div className="absolute left-0 top-1/2 transform -translate-y-1/2 translate-x-full">
|
||
<div className="w-0 h-0 border-t-4 border-b-4 border-r-4 border-transparent border-r-gray-800"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
return (
|
||
<div
|
||
className={cn(
|
||
" backdrop-blur-sm h-full flex flex-col transition-all duration-300",
|
||
isCollapsed ? "w-16" : "w-64",
|
||
className
|
||
)}
|
||
>
|
||
{/* Header */}
|
||
<div className={cn("p-4", isCollapsed && "px-2")}>
|
||
<div className="flex items-center justify-start">
|
||
{!isCollapsed ? (
|
||
<div className="flex items-center gap-3">
|
||
<GalleryVerticalEnd
|
||
color="black"
|
||
size={32}
|
||
strokeWidth={1}
|
||
className="bg-green-400 p-1.5 rounded-lg"
|
||
/>
|
||
<div className="font-persian">
|
||
<div className="text-sm font-semibold text-white">
|
||
داشبورد مدیریت فناوری و نوآوری
|
||
</div>
|
||
{/* <div className="text-xs text-gray-400">نسخه ۰.۱</div> */}
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div className="flex justify-center w-full">
|
||
<GalleryVerticalEnd
|
||
color="black"
|
||
size={32}
|
||
strokeWidth={1}
|
||
className="bg-green-400 p-1.5 rounded-lg"
|
||
/>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Main Menu */}
|
||
<div className="flex-1 overflow-y-auto overflow-x-hidden p-3">
|
||
<nav className="space-y-1">
|
||
{!isCollapsed && (
|
||
<div className="text-xs font-medium text-gray-400 uppercase tracking-wider mb-3 px-3 font-persian"></div>
|
||
)}
|
||
{menuItems.map((item) => renderMenuItem(item))}
|
||
</nav>
|
||
</div>
|
||
|
||
{/* Bottom Menu */}
|
||
<div className="p-3">
|
||
<nav className="space-y-1">
|
||
{!isCollapsed && (
|
||
<div className="text-xs font-medium text-gray-400 uppercase tracking-wider mb-3 px-3 font-persian"></div>
|
||
)}
|
||
{bottomMenuItems.map((item) => renderMenuItem(item))}
|
||
</nav>
|
||
</div>
|
||
|
||
{/* Collapse Toggle */}
|
||
{/* {onToggleCollapse && (
|
||
<div className="p-3 border-t border-gray-500/30">
|
||
<button
|
||
onClick={onToggleCollapse}
|
||
className="w-full p-2 rounded-md hover:bg-emerald-500/20 transition-colors flex justify-center items-center gap-2"
|
||
>
|
||
<ChevronRight
|
||
className={cn(
|
||
"w-4 h-4 text-gray-400 transition-transform duration-200",
|
||
isCollapsed ? "rotate-180" : "rotate-0"
|
||
)}
|
||
/>
|
||
{!isCollapsed && (
|
||
<span className="text-sm text-gray-400 font-persian"></span>
|
||
)}
|
||
</button>
|
||
</div>
|
||
)} */}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export default Sidebar;
|