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

1004 lines
36 KiB
TypeScript
Raw Normal View History

2025-08-30 19:11:15 +02:00
import React, { useState, useCallback, forwardRef, useMemo } from 'react';
2025-08-28 10:41:04 +02:00
import { clsx } from 'clsx';
import { useLocation, useNavigate } from 'react-router-dom';
2025-09-22 11:04:03 +02:00
import { useTranslation } from 'react-i18next';
import { useAuthUser, useIsAuthenticated, useAuthActions } from '../../../stores';
import { useCurrentTenantAccess } from '../../../stores/tenant.store';
2025-08-28 10:41:04 +02:00
import { getNavigationRoutes, canAccessRoute, ROUTES } from '../../../router/routes.config';
2025-09-21 13:27:50 +02:00
import { useSubscriptionAwareRoutes } from '../../../hooks/useSubscriptionAwareRoutes';
2025-08-28 10:41:04 +02:00
import { Button } from '../../ui';
import { Badge } from '../../ui';
import { Tooltip } from '../../ui';
2025-09-22 11:04:03 +02:00
import { Avatar } from '../../ui';
2025-08-28 10:41:04 +02:00
import {
LayoutDashboard,
Package,
Factory,
BarChart3,
Brain,
ShoppingCart,
Truck,
Zap,
Database,
2025-09-19 12:06:26 +02:00
Store,
2025-08-28 10:41:04 +02:00
GraduationCap,
Bell,
Settings,
2025-08-31 22:14:05 +02:00
User,
2025-09-01 19:21:12 +02:00
CreditCard,
2025-08-28 10:41:04 +02:00
ChevronLeft,
ChevronRight,
ChevronDown,
Dot,
2025-09-22 11:04:03 +02:00
Menu,
LogOut,
MoreHorizontal,
2025-09-24 19:40:51 +02:00
X,
Search
2025-08-28 10:41:04 +02:00
} 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,
2025-09-19 12:06:26 +02:00
database: Store,
2025-08-28 10:41:04 +02:00
training: GraduationCap,
notifications: Bell,
2025-09-24 22:22:01 +02:00
bell: Bell,
2025-08-28 10:41:04 +02:00
settings: Settings,
2025-08-31 22:14:05 +02:00
user: User,
2025-09-01 19:21:12 +02:00
'credit-card': CreditCard,
2025-08-28 10:41:04 +02:00
};
/**
* 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) => {
2025-09-22 11:04:03 +02:00
const { t } = useTranslation();
2025-08-28 10:41:04 +02:00
const location = useLocation();
const navigate = useNavigate();
const user = useAuthUser();
const isAuthenticated = useIsAuthenticated();
const currentTenantAccess = useCurrentTenantAccess();
2025-09-22 11:04:03 +02:00
const { logout } = useAuthActions();
2025-08-28 10:41:04 +02:00
const [expandedItems, setExpandedItems] = useState<Set<string>>(new Set());
const [hoveredItem, setHoveredItem] = useState<string | null>(null);
2025-09-22 11:04:03 +02:00
const [isProfileMenuOpen, setIsProfileMenuOpen] = useState(false);
2025-09-24 19:40:51 +02:00
const [isSearchFocused, setIsSearchFocused] = useState(false);
const [searchValue, setSearchValue] = useState('');
const searchInputRef = React.useRef<HTMLInputElement>(null);
2025-08-28 10:41:04 +02:00
const sidebarRef = React.useRef<HTMLDivElement>(null);
2025-09-21 13:27:50 +02:00
// Get subscription-aware navigation routes
const baseNavigationRoutes = useMemo(() => getNavigationRoutes(), []);
const { filteredRoutes: subscriptionFilteredRoutes } = useSubscriptionAwareRoutes(baseNavigationRoutes);
2025-09-22 11:04:03 +02:00
// Map route paths to translation keys
const getTranslationKey = (routePath: string): string => {
const pathMappings: Record<string, string> = {
'/app/dashboard': 'navigation.dashboard',
'/app/operations': 'navigation.operations',
'/app/operations/procurement': 'navigation.procurement',
'/app/operations/production': 'navigation.production',
2025-09-23 19:24:22 +02:00
'/app/operations/maquinaria': 'navigation.equipment',
2025-09-22 11:04:03 +02:00
'/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/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;
};
2025-09-21 13:27:50 +02:00
// Convert routes to navigation items - memoized
2025-08-30 19:11:15 +02:00
const navigationItems = useMemo(() => {
2025-09-21 13:27:50 +02:00
const convertRoutesToItems = (routes: typeof subscriptionFilteredRoutes): NavigationItem[] => {
2025-09-22 11:04:03 +02:00
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,
};
});
2025-08-30 19:11:15 +02:00
};
2025-08-28 10:41:04 +02:00
2025-09-21 13:27:50 +02:00
return customItems || convertRoutesToItems(subscriptionFilteredRoutes);
2025-09-22 11:04:03 +02:00
}, [customItems, subscriptionFilteredRoutes, t]);
2025-08-28 10:41:04 +02:00
2025-08-30 19:11:15 +02:00
// Filter items based on user permissions - memoized to prevent infinite re-renders
const visibleItems = useMemo(() => {
const filterItemsByPermissions = (items: NavigationItem[]): NavigationItem[] => {
if (!isAuthenticated || !user) return [];
2025-08-28 10:41:04 +02:00
2025-08-30 19:11:15 +02:00
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 || [];
2025-08-28 10:41:04 +02:00
2025-08-30 19:11:15 +02:00
const hasAccess = !item.requiredPermissions && !item.requiredRoles ||
canAccessRoute(
{
path: item.path,
requiredRoles: item.requiredRoles,
requiredPermissions: item.requiredPermissions
} as any,
isAuthenticated,
allUserRoles,
tenantPermissions
2025-08-30 19:11:15 +02:00
);
2025-08-28 10:41:04 +02:00
2025-08-30 19:11:15 +02:00
return hasAccess;
});
};
2025-08-28 10:41:04 +02:00
2025-08-30 19:11:15 +02:00
return filterItemsByPermissions(navigationItems);
}, [navigationItems, isAuthenticated, user, currentTenantAccess]);
2025-08-28 10:41:04 +02:00
// 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]);
2025-09-24 19:40:51 +02:00
// Handle search
const handleSearchChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
setSearchValue(e.target.value);
}, []);
const handleSearchSubmit = useCallback((e: React.FormEvent) => {
e.preventDefault();
if (searchValue.trim()) {
// TODO: Implement search functionality
console.log('Search:', searchValue);
}
}, [searchValue]);
const clearSearch = useCallback(() => {
setSearchValue('');
searchInputRef.current?.focus();
}, []);
// Focus search input
const focusSearch = useCallback(() => {
searchInputRef.current?.focus();
}, []);
2025-09-22 11:04:03 +02:00
// Handle logout
const handleLogout = useCallback(async () => {
await logout();
setIsProfileMenuOpen(false);
}, [logout]);
// Handle profile menu toggle
const handleProfileMenuToggle = useCallback(() => {
setIsProfileMenuOpen(prev => !prev);
}, []);
2025-08-28 10:41:04 +02:00
// 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
2025-08-30 19:11:15 +02:00
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;
2025-08-28 10:41:04 +02:00
}
}
2025-08-30 19:11:15 +02:00
}
return [];
}, []);
2025-08-28 10:41:04 +02:00
2025-08-30 19:11:15 +02:00
React.useEffect(() => {
2025-08-28 10:41:04 +02:00
const parentPaths = findParentPaths(visibleItems, location.pathname);
if (parentPaths.length > 0) {
setExpandedItems(prev => new Set([...prev, ...parentPaths]));
}
2025-08-30 19:11:15 +02:00
}, [location.pathname, findParentPaths, visibleItems]);
2025-08-28 10:41:04 +02:00
// 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) => {
2025-09-22 11:04:03 +02:00
if (e.key === 'Escape') {
if (isProfileMenuOpen) {
setIsProfileMenuOpen(false);
} else if (isOpen && onClose) {
onClose();
}
2025-08-28 10:41:04 +02:00
}
};
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 });
2025-09-22 11:04:03 +02:00
2025-08-28 23:40:44 +02:00
return () => {
document.removeEventListener('keydown', handleKeyDown);
document.removeEventListener('touchstart', handleTouchStart);
document.removeEventListener('touchmove', handleTouchMove);
};
2025-09-22 11:04:03 +02:00
}, [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]);
2025-08-28 10:41:04 +02:00
2025-09-24 19:40:51 +02:00
// 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 (
<div className="fixed left-[var(--sidebar-collapsed-width)] top-0 z-[var(--z-popover)] min-w-[200px] bg-[var(--bg-primary)] border border-[var(--border-primary)] rounded-lg shadow-lg py-2">
<div className="px-3 py-2 border-b border-[var(--border-primary)]">
<span className="text-sm font-medium text-[var(--text-secondary)]">
{item.label}
</span>
</div>
<ul className="py-1">
{item.children.map(child => (
<li key={child.id}>
<button
onClick={() => handleItemClick(child)}
disabled={child.disabled}
className={clsx(
'w-full text-left px-3 py-2 text-sm transition-colors duration-200',
'hover:bg-[var(--bg-secondary)]',
location.pathname === child.path && 'bg-[var(--color-primary)]/10 text-[var(--color-primary)] border-r-2 border-[var(--color-primary)]',
child.disabled && 'opacity-50 cursor-not-allowed'
)}
>
<div className="flex items-center">
{child.icon && (
<child.icon className="w-4 h-4 mr-3 flex-shrink-0" />
)}
<span className="truncate">{child.label}</span>
{child.badge && (
<Badge
variant={child.badge.variant || 'default'}
size="sm"
className="ml-2 text-xs"
>
{child.badge.text}
</Badge>
)}
</div>
</button>
</li>
))}
</ul>
</div>
);
};
2025-08-28 10:41:04 +02:00
// 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;
2025-08-28 10:41:04 +02:00
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',
)}
>
2025-09-24 21:54:49 +02:00
<div className={clsx(
'relative',
isCollapsed && level === 0 && 'flex items-center justify-center'
)}>
{ItemIcon && (
2025-09-22 11:04:03 +02:00
<ItemIcon
className={clsx(
'flex-shrink-0 transition-colors duration-200',
2025-09-24 21:54:49 +02:00
isCollapsed && level === 0 ? 'w-4 h-4' : 'w-4 h-4 mr-3',
2025-09-22 11:04:03 +02:00
isActive
? 'text-[var(--color-primary)]'
: 'text-[var(--text-tertiary)] group-hover:text-[var(--text-primary)]'
2025-09-22 11:04:03 +02:00
)}
/>
)}
{/* Submenu indicator for collapsed sidebar */}
2025-09-24 21:54:49 +02:00
{isCollapsed && hasChildren && level === 0 && item.children && item.children.length > 0 && (
<div className="absolute -bottom-1 -right-1 w-2 h-2 bg-[var(--color-primary)] rounded-full opacity-75" />
)}
</div>
2025-08-28 10:41:04 +02:00
{!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 = (
<div className="relative">
<button
onClick={() => handleItemClick(item)}
disabled={item.disabled}
data-path={item.path}
onMouseEnter={() => {
2025-09-24 21:54:49 +02:00
if (isCollapsed && hasChildren && level === 0 && item.children && item.children.length > 0) {
setHoveredItem(item.id);
}
}}
onMouseLeave={() => {
2025-09-24 21:54:49 +02:00
if (isCollapsed && hasChildren && level === 0 && item.children && item.children.length > 0) {
setHoveredItem(null);
}
}}
className={clsx(
'w-full rounded-lg transition-all duration-200',
'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-09-24 21:54:49 +02:00
isCollapsed && level === 0 ? 'flex justify-center items-center p-2 h-10 w-10 min-w-10 max-w-10' : 'p-3'
)}
aria-expanded={hasChildren ? isExpanded : undefined}
aria-current={isActive ? 'page' : undefined}
title={isCollapsed ? item.label : undefined}
>
{itemContent}
</button>
{/* Submenu overlay for collapsed sidebar */}
2025-09-24 21:54:49 +02:00
{isCollapsed && hasChildren && level === 0 && isHovered && item.children && item.children.length > 0 && (
<div
className="absolute left-full top-0 ml-2 z-[var(--z-popover)]"
onMouseEnter={() => setHoveredItem(item.id)}
onMouseLeave={() => setHoveredItem(null)}
>
{renderSubmenuOverlay(item)}
</div>
2025-08-28 10:41:04 +02:00
)}
</div>
2025-08-28 10:41:04 +02:00
);
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
)}
2025-09-22 11:04:03 +02:00
aria-label={t('common:accessibility.menu', 'Main navigation')}
2025-08-28 10:41:04 +02:00
>
2025-09-24 19:40:51 +02:00
{/* Search */}
{!isCollapsed && (
<div className="px-4 pt-4" data-search>
<form
onSubmit={handleSearchSubmit}
className="relative"
>
<div className="absolute left-3 top-0 bottom-0 flex items-center pointer-events-none">
<Search className="h-4 w-4 text-[var(--text-tertiary)]" />
</div>
<input
ref={searchInputRef}
type="text"
value={searchValue}
onChange={handleSearchChange}
onFocus={() => setIsSearchFocused(true)}
onBlur={() => setIsSearchFocused(false)}
placeholder={t('common:forms.search_placeholder', 'Search...')}
className={clsx(
'w-full pl-10 pr-12 py-2.5 text-sm',
'bg-[var(--bg-secondary)] border border-[var(--border-primary)]',
'rounded-lg transition-colors duration-200',
'focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)]/20',
'focus:border-[var(--color-primary)]',
'placeholder:text-[var(--text-tertiary)]',
'h-9'
)}
aria-label={t('common:accessibility.search', 'Search in the application')}
/>
{searchValue ? (
<button
type="button"
onClick={clearSearch}
className="absolute right-3 top-0 bottom-0 flex items-center p-1 hover:bg-[var(--bg-tertiary)] rounded-full transition-colors"
aria-label={t('common:actions.clear', 'Clear search')}
>
<X className="h-3 w-3 text-[var(--text-tertiary)]" />
</button>
) : (
<div className="absolute right-3 top-0 bottom-0 flex items-center pointer-events-none">
<kbd className="hidden lg:inline-flex items-center justify-center h-5 px-1.5 text-xs text-[var(--text-tertiary)] font-mono bg-[var(--bg-tertiary)] rounded border border-[var(--border-primary)]">
K
</kbd>
</div>
)}
</form>
</div>
)}
2025-08-28 10:41:04 +02:00
{/* Navigation */}
2025-09-24 21:54:49 +02:00
<nav className={clsx('flex-1 overflow-y-auto overflow-x-hidden', isCollapsed ? 'px-1 py-4' : 'p-4')}>
<ul className={clsx(isCollapsed ? 'space-y-1 flex flex-col items-center' : 'space-y-2')}>
2025-08-28 10:41:04 +02:00
{visibleItems.map(item => renderItem(item))}
</ul>
</nav>
2025-09-22 11:04:03 +02:00
{/* Profile section */}
{user && (
2025-09-24 21:54:49 +02:00
<div className={clsx('border-t border-[var(--border-primary)]', isCollapsed && 'p-1 flex justify-center')}>
2025-09-22 11:04:03 +02:00
<div className="relative" data-profile-menu>
<button
onClick={handleProfileMenuToggle}
className={clsx(
'w-full flex items-center transition-all duration-200',
'hover:bg-[var(--bg-secondary)] focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)]/20',
2025-09-24 21:54:49 +02:00
isCollapsed ? 'justify-center p-2 h-10 w-10 mx-auto rounded-lg' : 'p-4 gap-3',
2025-09-22 11:04:03 +02:00
isProfileMenuOpen && 'bg-[var(--bg-secondary)]'
)}
aria-label="Menú de perfil"
aria-expanded={isProfileMenuOpen}
aria-haspopup="true"
>
<Avatar
2025-09-24 21:54:49 +02:00
src={user.avatar || undefined}
alt={user.full_name || 'Usuario'}
name={user.avatar ? user.full_name : undefined}
2025-09-22 11:04:03 +02:00
size={isCollapsed ? "sm" : "md"}
className="flex-shrink-0"
/>
{!isCollapsed && (
<>
<div className="flex-1 min-w-0 text-left">
<div className="text-sm font-medium text-[var(--text-primary)] truncate">
{user.name}
</div>
<div className="text-xs text-[var(--text-tertiary)] truncate">
{user.email}
</div>
</div>
<MoreHorizontal className="w-4 h-4 text-[var(--text-tertiary)] flex-shrink-0" />
</>
)}
</button>
{/* Profile menu dropdown */}
{isProfileMenuOpen && (
<div className={clsx(
'absolute bg-[var(--bg-primary)] border border-[var(--border-primary)] rounded-lg shadow-lg py-2 z-[var(--z-dropdown)]',
isCollapsed ? 'left-full bottom-0 ml-2 min-w-[200px]' : 'left-4 right-4 bottom-full mb-2'
)}>
<div className="py-1">
<button
onClick={() => {
navigate('/app/settings/profile');
setIsProfileMenuOpen(false);
if (onClose) onClose();
}}
className="w-full px-4 py-2 text-left text-sm flex items-center gap-3 hover:bg-[var(--bg-secondary)] transition-colors"
>
<User className="h-4 w-4" />
Perfil
</button>
<button
onClick={() => {
navigate('/app/settings');
setIsProfileMenuOpen(false);
if (onClose) onClose();
}}
className="w-full px-4 py-2 text-left text-sm flex items-center gap-3 hover:bg-[var(--bg-secondary)] transition-colors"
>
<Settings className="h-4 w-4" />
Configuración
</button>
</div>
<div className="border-t border-[var(--border-primary)] pt-1">
<button
onClick={handleLogout}
className="w-full px-4 py-2 text-left text-sm flex items-center gap-3 hover:bg-[var(--bg-secondary)] transition-colors text-[var(--color-error)]"
>
<LogOut className="h-4 w-4" />
Cerrar Sesión
</button>
</div>
</div>
)}
</div>
</div>
)}
2025-08-28 10:41:04 +02:00
{/* Collapse button */}
{showCollapseButton && (
2025-09-24 21:54:49 +02:00
<div className={clsx('border-t border-[var(--border-primary)]', isCollapsed ? 'p-1 flex justify-center' : '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(
2025-09-24 21:54:49 +02:00
'flex items-center transition-colors duration-200',
isCollapsed ? 'justify-center h-10 w-10 p-2 rounded-lg' : 'w-full justify-start px-4 py-2'
2025-08-28 18:07:16 +02:00
)}
2025-09-22 11:04:03 +02:00
aria-label={isCollapsed ? t('common:actions.expand', 'Expand sidebar') : t('common:actions.collapse', 'Collapse sidebar')}
2025-08-28 10:41:04 +02:00
>
{isCollapsed ? (
<ChevronRight className="w-4 h-4" />
) : (
<>
<ChevronLeft className="w-4 h-4 mr-2" />
2025-09-22 11:04:03 +02:00
<span className="text-sm">{t('common:actions.collapse', 'Collapse')}</span>
2025-08-28 10:41:04 +02:00
</>
)}
</Button>
</div>
)}
</aside>
{/* Mobile Drawer */}
<aside
className={clsx(
2025-09-22 11:04:03 +02:00
'fixed inset-y-0 left-0 w-[var(--sidebar-width)] max-w-[90vw]',
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-09-22 11:04:03 +02:00
'shadow-2xl backdrop-blur-sm',
2025-08-28 10:41:04 +02:00
isOpen ? 'translate-x-0' : '-translate-x-full'
)}
2025-09-22 11:04:03 +02:00
aria-label={t('common:accessibility.menu', 'Main navigation')}
2025-08-28 10:41:04 +02:00
role="dialog"
aria-modal="true"
>
{/* Mobile header */}
<div className="flex items-center justify-between p-4 border-b border-[var(--border-primary)]">
2025-09-22 11:04:03 +02:00
<div className="flex items-center gap-3">
<div className="w-8 h-8 bg-gradient-to-br from-[var(--color-primary)] to-[var(--color-primary-dark)] rounded-lg flex items-center justify-center text-white font-bold text-sm flex-shrink-0">
PI
</div>
<h2 className="text-lg font-semibold text-[var(--text-primary)]">
Panadería IA
</h2>
</div>
2025-08-28 10:41:04 +02:00
<Button
variant="ghost"
size="sm"
onClick={onClose}
2025-09-22 11:04:03 +02:00
className="p-2 hover:bg-[var(--bg-secondary)]"
2025-08-28 10:41:04 +02:00
aria-label="Cerrar navegación"
>
2025-09-22 11:04:03 +02:00
<X className="w-5 h-5" />
2025-08-28 10:41:04 +02:00
</Button>
</div>
2025-09-24 19:40:51 +02:00
{/* Mobile search - always visible in mobile view */}
<div className="p-4 border-b border-[var(--border-primary)]">
<form
onSubmit={handleSearchSubmit}
className="relative"
>
<div className="absolute left-3 top-0 bottom-0 flex items-center pointer-events-none">
<Search className="h-4 w-4 text-[var(--text-tertiary)]" />
</div>
<input
ref={searchInputRef}
type="text"
value={searchValue}
onChange={handleSearchChange}
onFocus={() => setIsSearchFocused(true)}
onBlur={() => setIsSearchFocused(false)}
placeholder={t('common:forms.search_placeholder', 'Search...')}
className={clsx(
'w-full pl-10 pr-12 py-2.5 text-sm',
'bg-[var(--bg-secondary)] border border-[var(--border-primary)]',
'rounded-lg transition-colors duration-200',
'focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)]/20',
'focus:border-[var(--color-primary)]',
'placeholder:text-[var(--text-tertiary)]',
'h-9'
)}
aria-label={t('common:accessibility.search', 'Search in the application')}
/>
{searchValue ? (
<button
type="button"
onClick={clearSearch}
className="absolute right-3 top-0 bottom-0 flex items-center p-1 hover:bg-[var(--bg-tertiary)] rounded-full transition-colors"
aria-label={t('common:actions.clear', 'Clear search')}
>
<X className="h-3 w-3 text-[var(--text-tertiary)]" />
</button>
) : (
<div className="absolute right-3 top-0 bottom-0 flex items-center pointer-events-none">
<kbd className="hidden lg:inline-flex items-center justify-center h-5 px-1.5 text-xs text-[var(--text-tertiary)] font-mono bg-[var(--bg-tertiary)] rounded border border-[var(--border-primary)]">
K
</kbd>
</div>
)}
</form>
</div>
2025-08-28 10:41:04 +02:00
{/* 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>
2025-09-22 11:04:03 +02:00
{/* Profile section - Mobile */}
{user && (
<div className="border-t border-[var(--border-primary)]">
<div className="relative" data-profile-menu>
<button
onClick={handleProfileMenuToggle}
className={clsx(
'w-full flex items-center p-4 gap-3 transition-all duration-200',
'hover:bg-[var(--bg-secondary)] focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)]/20',
isProfileMenuOpen && 'bg-[var(--bg-secondary)]'
)}
aria-label="Menú de perfil"
aria-expanded={isProfileMenuOpen}
aria-haspopup="true"
>
<Avatar
2025-09-24 21:54:49 +02:00
src={user.avatar || undefined}
alt={user.full_name || 'Usuario'}
name={user.avatar ? user.full_name : undefined}
2025-09-22 11:04:03 +02:00
size="md"
className="flex-shrink-0"
/>
<div className="flex-1 min-w-0 text-left">
<div className="text-sm font-medium text-[var(--text-primary)] truncate">
{user.name}
</div>
<div className="text-xs text-[var(--text-tertiary)] truncate">
{user.email}
</div>
</div>
<MoreHorizontal className="w-4 h-4 text-[var(--text-tertiary)] flex-shrink-0" />
</button>
{/* Profile menu dropdown - Mobile */}
{isProfileMenuOpen && (
<div className="mx-4 mb-2 bg-[var(--bg-secondary)] border border-[var(--border-primary)] rounded-lg py-2">
<div className="py-1">
<button
onClick={() => {
navigate('/app/settings/profile');
setIsProfileMenuOpen(false);
if (onClose) onClose();
}}
className="w-full px-4 py-2 text-left text-sm flex items-center gap-3 hover:bg-[var(--bg-tertiary)] transition-colors"
>
<User className="h-4 w-4" />
Perfil
</button>
<button
onClick={() => {
navigate('/app/settings');
setIsProfileMenuOpen(false);
if (onClose) onClose();
}}
className="w-full px-4 py-2 text-left text-sm flex items-center gap-3 hover:bg-[var(--bg-tertiary)] transition-colors"
>
<Settings className="h-4 w-4" />
Configuración
</button>
</div>
<div className="border-t border-[var(--border-primary)] pt-1">
<button
onClick={handleLogout}
className="w-full px-4 py-2 text-left text-sm flex items-center gap-3 hover:bg-[var(--bg-tertiary)] transition-colors text-[var(--color-error)]"
>
<LogOut className="h-4 w-4" />
Cerrar Sesión
</button>
</div>
</div>
)}
2025-08-28 10:41:04 +02:00
</div>
</div>
)}
2025-09-22 11:04:03 +02:00
2025-08-28 10:41:04 +02:00
</aside>
</>
);
});
Sidebar.displayName = 'Sidebar';