Add i18 support
This commit is contained in:
@@ -1,13 +1,15 @@
|
||||
import React, { useState, useCallback, forwardRef, useMemo } from 'react';
|
||||
import { clsx } from 'clsx';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useAuthUser, useIsAuthenticated } from '../../../stores';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAuthUser, useIsAuthenticated, useAuthActions } from '../../../stores';
|
||||
import { useCurrentTenantAccess } from '../../../stores/tenant.store';
|
||||
import { getNavigationRoutes, canAccessRoute, ROUTES } from '../../../router/routes.config';
|
||||
import { useSubscriptionAwareRoutes } from '../../../hooks/useSubscriptionAwareRoutes';
|
||||
import { Button } from '../../ui';
|
||||
import { Badge } from '../../ui';
|
||||
import { Tooltip } from '../../ui';
|
||||
import { Avatar } from '../../ui';
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Package,
|
||||
@@ -28,7 +30,10 @@ import {
|
||||
ChevronRight,
|
||||
ChevronDown,
|
||||
Dot,
|
||||
Menu
|
||||
Menu,
|
||||
LogOut,
|
||||
MoreHorizontal,
|
||||
X
|
||||
} from 'lucide-react';
|
||||
|
||||
export interface SidebarProps {
|
||||
@@ -127,36 +132,71 @@ export const Sidebar = forwardRef<SidebarRef, SidebarProps>(({
|
||||
showCollapseButton = true,
|
||||
showFooter = true,
|
||||
}, ref) => {
|
||||
const { t } = useTranslation();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const user = useAuthUser();
|
||||
const isAuthenticated = useIsAuthenticated();
|
||||
const currentTenantAccess = useCurrentTenantAccess();
|
||||
const { logout } = useAuthActions();
|
||||
|
||||
const [expandedItems, setExpandedItems] = useState<Set<string>>(new Set());
|
||||
const [hoveredItem, setHoveredItem] = useState<string | null>(null);
|
||||
const [isProfileMenuOpen, setIsProfileMenuOpen] = useState(false);
|
||||
const sidebarRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
// 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<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;
|
||||
};
|
||||
|
||||
// Convert routes to navigation items - memoized
|
||||
const navigationItems = useMemo(() => {
|
||||
const convertRoutesToItems = (routes: typeof subscriptionFilteredRoutes): 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,
|
||||
}));
|
||||
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]);
|
||||
}, [customItems, subscriptionFilteredRoutes, t]);
|
||||
|
||||
// Filter items based on user permissions - memoized to prevent infinite re-renders
|
||||
const visibleItems = useMemo(() => {
|
||||
@@ -219,6 +259,17 @@ export const Sidebar = forwardRef<SidebarRef, SidebarProps>(({
|
||||
}
|
||||
}, [navigate, onClose]);
|
||||
|
||||
// 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}"]`);
|
||||
@@ -277,8 +328,12 @@ export const Sidebar = forwardRef<SidebarRef, SidebarProps>(({
|
||||
// Handle keyboard navigation and touch gestures
|
||||
React.useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && isOpen && onClose) {
|
||||
onClose();
|
||||
if (e.key === 'Escape') {
|
||||
if (isProfileMenuOpen) {
|
||||
setIsProfileMenuOpen(false);
|
||||
} else if (isOpen && onClose) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -310,13 +365,28 @@ export const Sidebar = forwardRef<SidebarRef, SidebarProps>(({
|
||||
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]);
|
||||
}, [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]);
|
||||
|
||||
// Render submenu overlay for collapsed sidebar
|
||||
const renderSubmenuOverlay = (item: NavigationItem) => {
|
||||
@@ -383,14 +453,14 @@ export const Sidebar = forwardRef<SidebarRef, SidebarProps>(({
|
||||
>
|
||||
<div className="relative">
|
||||
{ItemIcon && (
|
||||
<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)]'
|
||||
isCollapsed && level === 0 ? '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)]'
|
||||
)}
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -466,7 +536,7 @@ export const Sidebar = forwardRef<SidebarRef, SidebarProps>(({
|
||||
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',
|
||||
isCollapsed && !hasChildren ? 'flex justify-center items-center p-2 mx-1' : 'p-3'
|
||||
isCollapsed && level === 0 ? 'flex justify-center items-center p-3 aspect-square' : 'p-3'
|
||||
)}
|
||||
aria-expanded={hasChildren ? isExpanded : undefined}
|
||||
aria-current={isActive ? 'page' : undefined}
|
||||
@@ -524,15 +594,99 @@ export const Sidebar = forwardRef<SidebarRef, SidebarProps>(({
|
||||
isCollapsed ? 'w-[var(--sidebar-collapsed-width)]' : 'w-[var(--sidebar-width)]',
|
||||
className
|
||||
)}
|
||||
aria-label="Navegación principal"
|
||||
aria-label={t('common:accessibility.menu', 'Main navigation')}
|
||||
>
|
||||
{/* Navigation */}
|
||||
<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')}>
|
||||
<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')}>
|
||||
{visibleItems.map(item => renderItem(item))}
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
{/* 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>
|
||||
)}
|
||||
|
||||
{/* Collapse button */}
|
||||
{showCollapseButton && (
|
||||
<div className={clsx('border-t border-[var(--border-primary)]', isCollapsed ? 'p-2' : 'p-4')}>
|
||||
@@ -541,60 +695,57 @@ export const Sidebar = forwardRef<SidebarRef, SidebarProps>(({
|
||||
size="sm"
|
||||
onClick={onToggleCollapse}
|
||||
className={clsx(
|
||||
'w-full flex items-center justify-center',
|
||||
isCollapsed ? 'p-2' : 'px-4 py-2'
|
||||
'w-full flex items-center transition-colors duration-200',
|
||||
isCollapsed ? 'justify-center p-3 aspect-square' : 'justify-start px-4 py-2'
|
||||
)}
|
||||
aria-label={isCollapsed ? 'Expandir sidebar' : 'Contraer sidebar'}
|
||||
aria-label={isCollapsed ? t('common:actions.expand', 'Expand sidebar') : t('common:actions.collapse', 'Collapse sidebar')}
|
||||
>
|
||||
{isCollapsed ? (
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
) : (
|
||||
<>
|
||||
<ChevronLeft className="w-4 h-4 mr-2" />
|
||||
<span className="text-sm">Contraer</span>
|
||||
<span className="text-sm">{t('common:actions.collapse', 'Collapse')}</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(
|
||||
'fixed inset-y-0 left-0 w-[var(--sidebar-width)] max-w-[85vw]',
|
||||
'fixed inset-y-0 left-0 w-[var(--sidebar-width)] max-w-[90vw]',
|
||||
'bg-[var(--bg-primary)] border-r border-[var(--border-primary)]',
|
||||
'transition-transform duration-300 ease-in-out z-[var(--z-modal)]',
|
||||
'lg:hidden flex flex-col',
|
||||
'shadow-xl',
|
||||
'shadow-2xl backdrop-blur-sm',
|
||||
isOpen ? 'translate-x-0' : '-translate-x-full'
|
||||
)}
|
||||
aria-label="Navegación principal"
|
||||
aria-label={t('common:accessibility.menu', 'Main navigation')}
|
||||
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>
|
||||
<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>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onClose}
|
||||
className="p-2"
|
||||
className="p-2 hover:bg-[var(--bg-secondary)]"
|
||||
aria-label="Cerrar navegación"
|
||||
>
|
||||
<Menu className="w-4 h-4" />
|
||||
<X className="w-5 h-5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -605,14 +756,82 @@ export const Sidebar = forwardRef<SidebarRef, SidebarProps>(({
|
||||
</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
|
||||
{/* 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>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</aside>
|
||||
</>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user