Add i18 support

This commit is contained in:
Urtzi Alfaro
2025-09-22 11:04:03 +02:00
parent ecfc6a1997
commit ee36c45d25
28 changed files with 2307 additions and 565 deletions

View File

@@ -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>
</>
);