Files
bakery-ia/frontend/src/components/layout/Sidebar/Sidebar.tsx

525 lines
16 KiB
TypeScript
Raw Normal View History

2025-08-28 10:41:04 +02:00
import React, { useState, useCallback, forwardRef } from 'react';
import { clsx } from 'clsx';
import { useLocation, useNavigate } from 'react-router-dom';
import { useAuthUser, useIsAuthenticated } from '../../../stores';
import { getNavigationRoutes, canAccessRoute, ROUTES } from '../../../router/routes.config';
import { Button } from '../../ui';
import { Badge } from '../../ui';
import { Tooltip } from '../../ui';
import {
LayoutDashboard,
Package,
Factory,
BarChart3,
Brain,
ShoppingCart,
Truck,
Zap,
Database,
GraduationCap,
Bell,
Settings,
ChevronLeft,
ChevronRight,
ChevronDown,
Dot,
Menu
} 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;
2025-08-28 18:07:16 +02:00
/**
* Callback when sidebar collapse state is toggled (desktop)
*/
onToggleCollapse?: () => void;
2025-08-28 10:41:04 +02:00
/**
* 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<string, React.ComponentType<{ className?: string }>> = {
dashboard: LayoutDashboard,
inventory: Package,
production: Factory,
sales: BarChart3,
forecasting: Brain,
orders: ShoppingCart,
procurement: Truck,
pos: Zap,
data: Database,
training: GraduationCap,
notifications: Bell,
settings: Settings,
};
/**
* 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<SidebarRef, SidebarProps>(({
className,
isOpen = false,
isCollapsed = false,
onClose,
2025-08-28 18:07:16 +02:00
onToggleCollapse,
2025-08-28 10:41:04 +02:00
customItems,
showCollapseButton = true,
showFooter = true,
}, ref) => {
const location = useLocation();
const navigate = useNavigate();
const user = useAuthUser();
const isAuthenticated = useIsAuthenticated();
const [expandedItems, setExpandedItems] = useState<Set<string>>(new Set());
const sidebarRef = React.useRef<HTMLDivElement>(null);
// Get navigation routes from config
const navigationRoutes = getNavigationRoutes();
// Convert route config to navigation items
const convertRoutesToItems = (routes: typeof navigationRoutes): NavigationItem[] => {
return routes.map(route => ({
id: route.path,
label: route.title,
path: route.path,
icon: route.icon ? iconMap[route.icon] : undefined,
requiredPermissions: route.requiredPermissions,
requiredRoles: route.requiredRoles,
children: route.children ? convertRoutesToItems(route.children) : undefined,
}));
};
const navigationItems = customItems || convertRoutesToItems(navigationRoutes);
// Filter items based on user permissions
const filterItemsByPermissions = (items: NavigationItem[]): NavigationItem[] => {
if (!isAuthenticated || !user) return [];
return items.filter(item => {
const userRoles = user.role ? [user.role] : [];
const userPermissions: string[] = user?.permissions || [];
const hasAccess = !item.requiredPermissions && !item.requiredRoles ||
canAccessRoute(
{
path: item.path,
requiredRoles: item.requiredRoles,
requiredPermissions: item.requiredPermissions
} as any,
isAuthenticated,
userRoles,
userPermissions
);
if (hasAccess && item.children) {
item.children = filterItemsByPermissions(item.children);
}
return hasAccess;
});
};
const visibleItems = filterItemsByPermissions(navigationItems);
// 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]);
// 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
React.useEffect(() => {
const findParentPaths = (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 [];
};
const parentPaths = findParentPaths(visibleItems, location.pathname);
if (parentPaths.length > 0) {
setExpandedItems(prev => new Set([...prev, ...parentPaths]));
}
}, [location.pathname, visibleItems]);
// Expose ref methods
React.useImperativeHandle(ref, () => ({
scrollToItem,
expandItem,
collapseItem,
}), [scrollToItem, expandItem, collapseItem]);
2025-08-28 23:40:44 +02:00
// Handle keyboard navigation and touch gestures
2025-08-28 10:41:04 +02:00
React.useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape' && isOpen && onClose) {
onClose();
}
};
2025-08-28 23:40:44 +02:00
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();
}
};
2025-08-28 10:41:04 +02:00
document.addEventListener('keydown', handleKeyDown);
2025-08-28 23:40:44 +02:00
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);
};
2025-08-28 10:41:04 +02:00
}, [isOpen, onClose]);
// 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 ItemIcon = item.icon;
const itemContent = (
<div
className={clsx(
'flex items-center w-full text-left transition-colors duration-200',
'group relative',
level > 0 && 'pl-6',
)}
>
{ItemIcon && (
<ItemIcon
className={clsx(
'flex-shrink-0 transition-colors duration-200',
isCollapsed ? 'w-5 h-5' : 'w-4 h-4 mr-3',
isActive
? 'text-[var(--color-primary)]'
: 'text-[var(--text-tertiary)] group-hover:text-[var(--text-primary)]'
)}
/>
)}
{!ItemIcon && level > 0 && (
<Dot className={clsx(
'flex-shrink-0 w-4 h-4 mr-3 transition-colors duration-200',
isActive
? 'text-[var(--color-primary)]'
: 'text-[var(--text-tertiary)]'
)} />
)}
{(!isCollapsed || level > 0) && (
<>
<span className={clsx(
'flex-1 truncate transition-colors duration-200 text-sm font-medium',
isActive
? 'text-[var(--color-primary)]'
: 'text-[var(--text-primary)] group-hover:text-[var(--text-primary)]'
)}>
{item.label}
</span>
{item.badge && (
<Badge
variant={item.badge.variant || 'default'}
size="sm"
className="ml-2 text-xs"
>
{item.badge.text}
</Badge>
)}
{hasChildren && (
<ChevronDown className={clsx(
'flex-shrink-0 w-4 h-4 ml-2 transition-transform duration-200',
isExpanded && 'transform rotate-180',
isActive
? 'text-[var(--color-primary)]'
: 'text-[var(--text-tertiary)] group-hover:text-[var(--text-primary)]'
)} />
)}
</>
)}
</div>
);
const button = (
<button
onClick={() => handleItemClick(item)}
disabled={item.disabled}
data-path={item.path}
className={clsx(
2025-08-28 18:07:16 +02:00
'w-full rounded-lg transition-all duration-200',
2025-08-28 10:41:04 +02:00
'focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)]/20',
isActive && 'bg-[var(--color-primary)]/10 border-l-2 border-[var(--color-primary)]',
!isActive && 'hover:bg-[var(--bg-secondary)]',
item.disabled && 'opacity-50 cursor-not-allowed',
2025-08-28 18:07:16 +02:00
isCollapsed && !hasChildren ? 'flex justify-center items-center p-2 mx-1' : 'p-3'
2025-08-28 10:41:04 +02:00
)}
aria-expanded={hasChildren ? isExpanded : undefined}
aria-current={isActive ? 'page' : undefined}
title={isCollapsed ? item.label : undefined}
>
{itemContent}
</button>
);
return (
<li key={item.id} className="relative">
{isCollapsed && !hasChildren && ItemIcon ? (
<Tooltip content={item.label} side="right">
{button}
</Tooltip>
) : (
button
)}
{hasChildren && isExpanded && (!isCollapsed || level > 0) && (
<ul className="mt-1 space-y-1 pl-4">
{item.children?.map(child => renderItem(child, level + 1))}
</ul>
)}
</li>
);
};
if (!isAuthenticated) {
return null;
}
return (
<>
{/* Desktop Sidebar */}
<aside
ref={sidebarRef}
className={clsx(
'fixed left-0 top-[var(--header-height)] bottom-0',
'bg-[var(--bg-primary)] border-r border-[var(--border-primary)]',
'transition-all duration-300 ease-in-out z-[var(--z-fixed)]',
'hidden lg:flex lg:flex-col',
2025-08-28 18:07:16 +02:00
isCollapsed ? 'w-[var(--sidebar-collapsed-width)]' : 'w-[var(--sidebar-width)]',
2025-08-28 10:41:04 +02:00
className
)}
aria-label="Navegación principal"
>
{/* Navigation */}
2025-08-28 18:07:16 +02:00
<nav className={clsx('flex-1 overflow-y-auto', isCollapsed ? 'px-1 py-4' : 'p-4')}>
<ul className={clsx(isCollapsed ? 'space-y-1' : 'space-y-2')}>
2025-08-28 10:41:04 +02:00
{visibleItems.map(item => renderItem(item))}
</ul>
</nav>
{/* Collapse button */}
{showCollapseButton && (
2025-08-28 18:07:16 +02:00
<div className={clsx('border-t border-[var(--border-primary)]', isCollapsed ? 'p-2' : 'p-4')}>
2025-08-28 10:41:04 +02:00
<Button
variant="ghost"
size="sm"
2025-08-28 18:07:16 +02:00
onClick={onToggleCollapse}
className={clsx(
'w-full flex items-center justify-center',
isCollapsed ? 'p-2' : 'px-4 py-2'
)}
2025-08-28 10:41:04 +02:00
aria-label={isCollapsed ? 'Expandir sidebar' : 'Contraer sidebar'}
>
{isCollapsed ? (
<ChevronRight className="w-4 h-4" />
) : (
<>
<ChevronLeft className="w-4 h-4 mr-2" />
<span className="text-sm">Contraer</span>
</>
)}
</Button>
</div>
)}
{/* Footer */}
{showFooter && !isCollapsed && (
<div className="p-4 border-t border-[var(--border-primary)]">
<div className="text-xs text-[var(--text-tertiary)] text-center">
Panadería IA v2.0.0
</div>
</div>
)}
</aside>
{/* Mobile Drawer */}
<aside
className={clsx(
2025-08-28 23:40:44 +02:00
'fixed inset-y-0 left-0 w-[var(--sidebar-width)] max-w-[85vw]',
2025-08-28 10:41:04 +02:00
'bg-[var(--bg-primary)] border-r border-[var(--border-primary)]',
2025-08-28 23:40:44 +02:00
'transition-transform duration-300 ease-in-out z-[var(--z-modal)]',
2025-08-28 10:41:04 +02:00
'lg:hidden flex flex-col',
2025-08-28 23:40:44 +02:00
'shadow-xl',
2025-08-28 10:41:04 +02:00
isOpen ? 'translate-x-0' : '-translate-x-full'
)}
aria-label="Navegación principal"
role="dialog"
aria-modal="true"
>
{/* Mobile header */}
<div className="flex items-center justify-between p-4 border-b border-[var(--border-primary)]">
<h2 className="text-lg font-semibold text-[var(--text-primary)]">
Navegación
</h2>
<Button
variant="ghost"
size="sm"
onClick={onClose}
className="p-2"
aria-label="Cerrar navegación"
>
<Menu className="w-4 h-4" />
</Button>
</div>
{/* Navigation */}
2025-08-28 23:40:44 +02:00
<nav className="flex-1 p-4 overflow-y-auto scrollbar-thin scrollbar-thumb-gray-300 scrollbar-track-transparent">
<ul className="space-y-2 pb-4">
2025-08-28 10:41:04 +02:00
{visibleItems.map(item => renderItem(item))}
</ul>
</nav>
{/* Footer */}
{showFooter && (
<div className="p-4 border-t border-[var(--border-primary)]">
<div className="text-xs text-[var(--text-tertiary)] text-center">
Panadería IA v2.0.0
</div>
</div>
)}
</aside>
</>
);
});
Sidebar.displayName = 'Sidebar';