import React, { useState, useCallback, forwardRef, useMemo, useEffect } from 'react'; import { clsx } from 'clsx'; import { useLocation, useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { useAuthUser, useIsAuthenticated, useAuthActions } from '../../../stores'; import { useCurrentTenantAccess } from '../../../stores/tenant.store'; import { useHasAccess } from '../../../hooks/useAccessControl'; import { getNavigationRoutes, canAccessRoute, ROUTES } from '../../../router/routes.config'; import { useSubscriptionAwareRoutes } from '../../../hooks/useSubscriptionAwareRoutes'; import { useSubscriptionEvents } from '../../../contexts/SubscriptionEventsContext'; import { Button } from '../../ui'; import { Badge } from '../../ui'; import { Tooltip } from '../../ui'; import { Avatar } from '../../ui'; import { LayoutDashboard, Package, Factory, BarChart3, Brain, ShoppingCart, Truck, Zap, Database, Store, GraduationCap, Bell, Settings, User, CreditCard, ChevronLeft, ChevronRight, ChevronDown, Dot, Menu, LogOut, MoreHorizontal, X, Search, Leaf, ChefHat, ClipboardCheck, BrainCircuit, Cog } from 'lucide-react'; export interface SidebarProps { className?: string; /** * Whether the sidebar is open (mobile drawer state) */ isOpen?: boolean; /** * Whether the sidebar is collapsed (desktop state) */ isCollapsed?: boolean; /** * Callback when sidebar is closed (mobile) */ onClose?: () => void; /** * Callback when sidebar collapse state is toggled (desktop) */ onToggleCollapse?: () => void; /** * Custom navigation items */ customItems?: NavigationItem[]; /** * Show/hide collapse button */ showCollapseButton?: boolean; /** * Show/hide footer */ showFooter?: boolean; } export interface NavigationItem { id: string; label: string; path: string; icon?: React.ComponentType<{ className?: string }>; badge?: { text: string; variant?: 'default' | 'primary' | 'success' | 'warning' | 'error'; }; children?: NavigationItem[]; requiredPermissions?: string[]; requiredRoles?: string[]; disabled?: boolean; external?: boolean; } export interface SidebarRef { scrollToItem: (path: string) => void; expandItem: (path: string) => void; collapseItem: (path: string) => void; } const iconMap: Record> = { dashboard: LayoutDashboard, inventory: Package, production: Factory, sales: BarChart3, forecasting: Brain, orders: ShoppingCart, procurement: Truck, pos: Zap, data: Database, database: Store, training: GraduationCap, notifications: Bell, bell: Bell, settings: Settings, user: User, 'credit-card': CreditCard, leaf: Leaf, 'chef-hat': ChefHat, 'clipboard-check': ClipboardCheck, 'brain-circuit': BrainCircuit, cog: Cog, }; /** * Sidebar - Navigation sidebar with collapsible menu items and role-based access * * Features: * - Hierarchical navigation menu with icons * - Icons for menu items with fallback support * - Collapsible sections with smooth animations * - Active state highlighting with proper contrast * - Role-based menu filtering and permissions * - Responsive design with mobile drawer mode * - Keyboard navigation support * - Smooth scrolling to items * - Badge support for notifications/counts */ export const Sidebar = forwardRef(({ className, isOpen = false, isCollapsed = false, onClose, onToggleCollapse, customItems, showCollapseButton = true, showFooter = true, }, ref) => { const { t } = useTranslation(); const location = useLocation(); const navigate = useNavigate(); const user = useAuthUser(); const isAuthenticated = useIsAuthenticated(); // Keep for logout check const hasAccess = useHasAccess(); // For UI visibility const currentTenantAccess = useCurrentTenantAccess(); const { logout } = useAuthActions(); const [expandedItems, setExpandedItems] = useState>(new Set()); const [hoveredItem, setHoveredItem] = useState(null); const [isProfileMenuOpen, setIsProfileMenuOpen] = useState(false); const [isSearchFocused, setIsSearchFocused] = useState(false); const [searchValue, setSearchValue] = useState(''); const searchInputRef = React.useRef(null); const sidebarRef = React.useRef(null); const { subscriptionVersion } = useSubscriptionEvents(); // Get subscription-aware navigation routes const baseNavigationRoutes = useMemo(() => getNavigationRoutes(), []); const { filteredRoutes: subscriptionFilteredRoutes } = useSubscriptionAwareRoutes(baseNavigationRoutes); // Map route paths to translation keys const getTranslationKey = (routePath: string): string => { const pathMappings: Record = { '/app/dashboard': 'navigation.dashboard', '/app/operations': 'navigation.operations', '/app/operations/procurement': 'navigation.procurement', '/app/operations/production': 'navigation.production', '/app/operations/maquinaria': 'navigation.equipment', '/app/operations/pos': 'navigation.pos', '/app/bakery': 'navigation.bakery', '/app/bakery/recipes': 'navigation.recipes', '/app/database': 'navigation.data', '/app/database/inventory': 'navigation.inventory', '/app/analytics': 'navigation.analytics', '/app/analytics/forecasting': 'navigation.forecasting', '/app/analytics/scenario-simulation': 'navigation.scenario_simulation', '/app/analytics/sales': 'navigation.sales', '/app/analytics/performance': 'navigation.performance', '/app/ai': 'navigation.insights', '/app/communications': 'navigation.communications', '/app/communications/notifications': 'navigation.notifications', '/app/communications/alerts': 'navigation.alerts', }; return pathMappings[routePath] || routePath; }; // Convert routes to navigation items - memoized const navigationItems = useMemo(() => { const convertRoutesToItems = (routes: typeof subscriptionFilteredRoutes): NavigationItem[] => { return routes.map(route => { const translationKey = getTranslationKey(route.path); const label = translationKey.startsWith('navigation.') ? t(`common:${translationKey}`, route.title) : route.title; return { id: route.path, label, path: route.path, icon: route.icon ? iconMap[route.icon] : undefined, requiredPermissions: route.requiredPermissions, requiredRoles: route.requiredRoles, children: route.children ? convertRoutesToItems(route.children) : undefined, }; }); }; return customItems || convertRoutesToItems(subscriptionFilteredRoutes); }, [customItems, subscriptionFilteredRoutes, t]); // Filter items based on user permissions - memoized to prevent infinite re-renders const visibleItems = useMemo(() => { const filterItemsByPermissions = (items: NavigationItem[]): NavigationItem[] => { if (!hasAccess) return []; return items.map(item => ({ ...item, // Create a shallow copy to avoid mutation children: item.children ? filterItemsByPermissions(item.children) : item.children })).filter(item => { // Combine global and tenant roles for comprehensive access control const globalUserRoles = user?.role ? [user.role as string] : []; const tenantRole = currentTenantAccess?.role; const tenantRoles = tenantRole ? [tenantRole as string] : []; const allUserRoles = [...globalUserRoles, ...tenantRoles]; const tenantPermissions = currentTenantAccess?.permissions || []; // If no specific permissions/roles required, allow access if (!item.requiredPermissions && !item.requiredRoles) { return true; } // Check access based on roles and permissions const canAccessItem = canAccessRoute( { path: item.path, requiredRoles: item.requiredRoles, requiredPermissions: item.requiredPermissions } as any, isAuthenticated, allUserRoles, tenantPermissions ); return canAccessItem; }); }; return filterItemsByPermissions(navigationItems); }, [navigationItems, hasAccess, isAuthenticated, user, currentTenantAccess]); // Handle item click const handleItemClick = useCallback((item: NavigationItem) => { if (item.disabled) return; if (item.children && item.children.length > 0) { // Toggle expansion for parent items setExpandedItems(prev => { const newSet = new Set(prev); if (newSet.has(item.id)) { newSet.delete(item.id); } else { newSet.add(item.id); } return newSet; }); } else { // Navigate to item if (item.external) { window.open(item.path, '_blank'); } else { navigate(item.path); if (onClose) onClose(); // Close mobile drawer } } }, [navigate, onClose]); // Handle search const handleSearchChange = useCallback((e: React.ChangeEvent) => { setSearchValue(e.target.value); }, []); // Filter navigation items based on search const filteredItems = useMemo(() => { if (!searchValue.trim()) { return visibleItems; } const searchLower = searchValue.toLowerCase(); const filterItems = (items: NavigationItem[]): NavigationItem[] => { return items.filter(item => { // Check if item label matches const labelMatches = item.label.toLowerCase().includes(searchLower); // Check if any child matches if (item.children && item.children.length > 0) { const childrenMatch = item.children.some(child => child.label.toLowerCase().includes(searchLower) ); return labelMatches || childrenMatch; } return labelMatches; }).map(item => { // If item has children, filter them too if (item.children && item.children.length > 0) { return { ...item, children: item.children.filter(child => child.label.toLowerCase().includes(searchLower) ) }; } return item; }); }; return filterItems(visibleItems); }, [visibleItems, searchValue]); const handleSearchSubmit = useCallback((e: React.FormEvent) => { e.preventDefault(); // Search is now live-filtered as user types if (searchValue.trim() && filteredItems.length > 0) { // Navigate to first matching item const firstItem = filteredItems[0]; if (firstItem.to) { navigate(firstItem.to); setSearchValue(''); } else if (firstItem.children && firstItem.children.length > 0) { const firstChild = firstItem.children[0]; if (firstChild.to) { navigate(firstChild.to); setSearchValue(''); } } } }, [searchValue, filteredItems, navigate]); const clearSearch = useCallback(() => { setSearchValue(''); searchInputRef.current?.focus(); }, []); // Focus search input const focusSearch = useCallback(() => { searchInputRef.current?.focus(); }, []); // Handle logout const handleLogout = useCallback(async () => { await logout(); setIsProfileMenuOpen(false); }, [logout]); // Handle profile menu toggle const handleProfileMenuToggle = useCallback(() => { setIsProfileMenuOpen(prev => !prev); }, []); // Scroll to item const scrollToItem = useCallback((path: string) => { const element = sidebarRef.current?.querySelector(`[data-path="${path}"]`); if (element) { element.scrollIntoView({ behavior: 'smooth', block: 'center' }); } }, []); // Expand item const expandItem = useCallback((path: string) => { setExpandedItems(prev => new Set(prev).add(path)); }, []); // Collapse item const collapseItem = useCallback((path: string) => { setExpandedItems(prev => { const newSet = new Set(prev); newSet.delete(path); return newSet; }); }, []); // Auto-expand parent items for active path const findParentPaths = useCallback((items: NavigationItem[], targetPath: string, parents: string[] = []): string[] => { for (const item of items) { const currentPath = [...parents, item.id]; if (item.path === targetPath) { return parents; } if (item.children) { const found = findParentPaths(item.children, targetPath, currentPath); if (found.length > 0) { return found; } } } return []; }, []); React.useEffect(() => { const parentPaths = findParentPaths(visibleItems, location.pathname); if (parentPaths.length > 0) { setExpandedItems(prev => new Set([...prev, ...parentPaths])); } }, [location.pathname, findParentPaths, visibleItems]); // Expose ref methods React.useImperativeHandle(ref, () => ({ scrollToItem, expandItem, collapseItem, }), [scrollToItem, expandItem, collapseItem]); // Handle keyboard navigation and touch gestures React.useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape') { if (isProfileMenuOpen) { setIsProfileMenuOpen(false); } else if (isOpen && onClose) { onClose(); } } }; let touchStartX = 0; let touchStartY = 0; const handleTouchStart = (e: TouchEvent) => { if (isOpen) { touchStartX = e.touches[0].clientX; touchStartY = e.touches[0].clientY; } }; const handleTouchMove = (e: TouchEvent) => { if (!isOpen || !onClose) return; const touchCurrentX = e.touches[0].clientX; const touchCurrentY = e.touches[0].clientY; const deltaX = touchStartX - touchCurrentX; const deltaY = Math.abs(touchStartY - touchCurrentY); // Only trigger swipe left to close if it's more horizontal than vertical // and the swipe distance is significant if (deltaX > 50 && deltaX > deltaY * 2) { onClose(); } }; document.addEventListener('keydown', handleKeyDown); document.addEventListener('touchstart', handleTouchStart, { passive: true }); document.addEventListener('touchmove', handleTouchMove, { passive: true }); return () => { document.removeEventListener('keydown', handleKeyDown); document.removeEventListener('touchstart', handleTouchStart); document.removeEventListener('touchmove', handleTouchMove); }; }, [isOpen, onClose, isProfileMenuOpen]); // Handle click outside profile menu React.useEffect(() => { const handleClickOutside = (event: MouseEvent) => { const target = event.target as Element; if (!target.closest('[data-profile-menu]')) { setIsProfileMenuOpen(false); } }; if (isProfileMenuOpen) { document.addEventListener('click', handleClickOutside); return () => document.removeEventListener('click', handleClickOutside); } }, [isProfileMenuOpen]); // Keyboard shortcuts for search and other functionality React.useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { // Cmd/Ctrl + K for search if ((e.metaKey || e.ctrlKey) && e.key === 'k') { e.preventDefault(); focusSearch(); } // Escape to close menus if (e.key === 'Escape') { setIsProfileMenuOpen(false); if (onClose) onClose(); // Close mobile sidebar if (isSearchFocused) { searchInputRef.current?.blur(); } } }; document.addEventListener('keydown', handleKeyDown); return () => document.removeEventListener('keydown', handleKeyDown); }, [focusSearch, isSearchFocused, setIsProfileMenuOpen, onClose]); // Close search and menus when clicking outside React.useEffect(() => { const handleClickOutside = (event: MouseEvent) => { const target = event.target as Element; if (!target.closest('[data-profile-menu]') && !target.closest('[data-search]')) { setIsProfileMenuOpen(false); } }; document.addEventListener('click', handleClickOutside); return () => document.removeEventListener('click', handleClickOutside); }, []); // Render submenu overlay for collapsed sidebar const renderSubmenuOverlay = (item: NavigationItem) => { if (!item.children || item.children.length === 0) return null; return (
{item.label}
    {item.children.map(child => (
  • ))}
); }; // Render navigation item const renderItem = (item: NavigationItem, level = 0) => { const isActive = location.pathname === item.path || location.pathname.startsWith(item.path + '/'); const isExpanded = expandedItems.has(item.id); const hasChildren = item.children && item.children.length > 0; const isHovered = hoveredItem === item.id; const ItemIcon = item.icon; // Add tour data attributes for main navigation sections const getTourAttribute = (itemPath: string) => { if (itemPath === '/app/database') return 'sidebar-database'; if (itemPath === '/app/operations') return 'sidebar-operations'; if (itemPath === '/app/analytics') return 'sidebar-analytics'; return undefined; }; const tourAttr = getTourAttribute(item.path); const itemContent = (
0 && 'pl-6', )} >
{ItemIcon && ( )} {/* Submenu indicator for collapsed sidebar */} {isCollapsed && hasChildren && level === 0 && item.children && item.children.length > 0 && (
)}
{!ItemIcon && level > 0 && ( )} {(!isCollapsed || level > 0) && ( <> {item.label} {item.badge && ( {item.badge.text} )} {hasChildren && ( )} )}
); const button = (
{/* Submenu overlay for collapsed sidebar */} {isCollapsed && hasChildren && level === 0 && isHovered && item.children && item.children.length > 0 && (
setHoveredItem(item.id)} onMouseLeave={() => setHoveredItem(null)} > {renderSubmenuOverlay(item)}
)}
); return (
  • {isCollapsed && !hasChildren && ItemIcon ? ( {button} ) : ( button )} {hasChildren && isExpanded && (!isCollapsed || level > 0) && (
      {item.children?.map(child => renderItem(child, level + 1))}
    )}
  • ); }; if (!hasAccess) { return null; } return ( <> {/* Desktop Sidebar */} {/* Mobile Drawer */} ); }); Sidebar.displayName = 'Sidebar';