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';
|
2025-09-09 07:32:59 +02:00
|
|
|
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,
|
|
|
|
|
X
|
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,
|
|
|
|
|
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();
|
2025-09-09 07:32:59 +02:00
|
|
|
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());
|
2025-08-31 10:46:13 +02:00
|
|
|
const [hoveredItem, setHoveredItem] = useState<string | null>(null);
|
2025-09-22 11:04:03 +02:00
|
|
|
const [isProfileMenuOpen, setIsProfileMenuOpen] = useState(false);
|
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',
|
|
|
|
|
'/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 => {
|
2025-09-09 07:32:59 +02:00
|
|
|
// 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,
|
2025-09-09 07:32:59 +02:00
|
|
|
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);
|
2025-09-09 07:32:59 +02:00
|
|
|
}, [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-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-08-31 10:46:13 +02:00
|
|
|
// 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;
|
2025-08-31 10:46:13 +02:00
|
|
|
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-08-31 10:46:13 +02:00
|
|
|
<div className="relative">
|
|
|
|
|
{ItemIcon && (
|
2025-09-22 11:04:03 +02:00
|
|
|
<ItemIcon
|
2025-08-31 10:46:13 +02:00
|
|
|
className={clsx(
|
|
|
|
|
'flex-shrink-0 transition-colors duration-200',
|
2025-09-22 11:04:03 +02:00
|
|
|
isCollapsed && level === 0 ? 'w-5 h-5' : 'w-4 h-4 mr-3',
|
|
|
|
|
isActive
|
|
|
|
|
? 'text-[var(--color-primary)]'
|
2025-08-31 10:46:13 +02:00
|
|
|
: 'text-[var(--text-tertiary)] group-hover:text-[var(--text-primary)]'
|
2025-09-22 11:04:03 +02:00
|
|
|
)}
|
2025-08-31 10:46:13 +02:00
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* Submenu indicator for collapsed sidebar */}
|
|
|
|
|
{isCollapsed && hasChildren && level === 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 = (
|
2025-08-31 10:46:13 +02:00
|
|
|
<div className="relative">
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => handleItemClick(item)}
|
|
|
|
|
disabled={item.disabled}
|
|
|
|
|
data-path={item.path}
|
|
|
|
|
onMouseEnter={() => {
|
|
|
|
|
if (isCollapsed && hasChildren && level === 0) {
|
|
|
|
|
setHoveredItem(item.id);
|
|
|
|
|
}
|
|
|
|
|
}}
|
|
|
|
|
onMouseLeave={() => {
|
|
|
|
|
if (isCollapsed && hasChildren && level === 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-22 11:04:03 +02:00
|
|
|
isCollapsed && level === 0 ? 'flex justify-center items-center p-3 aspect-square' : 'p-3'
|
2025-08-31 10:46:13 +02:00
|
|
|
)}
|
|
|
|
|
aria-expanded={hasChildren ? isExpanded : undefined}
|
|
|
|
|
aria-current={isActive ? 'page' : undefined}
|
|
|
|
|
title={isCollapsed ? item.label : undefined}
|
|
|
|
|
>
|
|
|
|
|
{itemContent}
|
|
|
|
|
</button>
|
|
|
|
|
|
|
|
|
|
{/* Submenu overlay for collapsed sidebar */}
|
|
|
|
|
{isCollapsed && hasChildren && level === 0 && isHovered && (
|
|
|
|
|
<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
|
|
|
)}
|
2025-08-31 10:46:13 +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
|
|
|
>
|
|
|
|
|
{/* Navigation */}
|
2025-09-22 11:04:03 +02:00
|
|
|
<nav className={clsx('flex-1 overflow-y-auto', isCollapsed ? 'px-2 py-4' : 'p-4')}>
|
|
|
|
|
<ul className={clsx(isCollapsed ? 'space-y-2' : '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 && (
|
|
|
|
|
<div className="border-t border-[var(--border-primary)]">
|
|
|
|
|
<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',
|
|
|
|
|
isCollapsed ? 'justify-center p-3' : 'p-4 gap-3',
|
|
|
|
|
isProfileMenuOpen && 'bg-[var(--bg-secondary)]'
|
|
|
|
|
)}
|
|
|
|
|
aria-label="Menú de perfil"
|
|
|
|
|
aria-expanded={isProfileMenuOpen}
|
|
|
|
|
aria-haspopup="true"
|
|
|
|
|
>
|
|
|
|
|
<Avatar
|
|
|
|
|
src={user.avatar}
|
|
|
|
|
alt={user.name}
|
|
|
|
|
name={user.name}
|
|
|
|
|
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-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(
|
2025-09-22 11:04:03 +02:00
|
|
|
'w-full flex items-center transition-colors duration-200',
|
|
|
|
|
isCollapsed ? 'justify-center p-3 aspect-square' : '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>
|
|
|
|
|
|
|
|
|
|
{/* 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
|
|
|
|
|
src={user.avatar}
|
|
|
|
|
alt={user.name}
|
|
|
|
|
name={user.name}
|
|
|
|
|
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';
|