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

497 lines
17 KiB
TypeScript
Raw Normal View History

2025-08-28 10:41:04 +02:00
import React, { useState, useCallback, forwardRef } from 'react';
import { clsx } from 'clsx';
2025-08-28 23:40:44 +02:00
import { useNavigate } from 'react-router-dom';
2025-08-28 10:41:04 +02:00
import { useAuthUser, useIsAuthenticated, useAuthActions } from '../../../stores';
import { useTheme } from '../../../contexts/ThemeContext';
import { Button } from '../../ui';
import { Avatar } from '../../ui';
import { Badge } from '../../ui';
import { Modal } from '../../ui';
import { TenantSwitcher } from '../../ui/TenantSwitcher';
2025-08-28 10:41:04 +02:00
import {
Menu,
Search,
Bell,
Sun,
Moon,
Computer,
Settings,
User,
LogOut,
X
} from 'lucide-react';
export interface HeaderProps {
className?: string;
/**
* Callback when menu button is clicked (for mobile sidebar toggle)
*/
onMenuClick?: () => void;
/**
* Whether the sidebar is currently collapsed (affects logo display)
*/
sidebarCollapsed?: boolean;
/**
* Show/hide search functionality
*/
showSearch?: boolean;
/**
* Show/hide notifications
*/
showNotifications?: boolean;
/**
* Show/hide theme toggle
*/
showThemeToggle?: boolean;
/**
* Show/hide user menu
*/
showUserMenu?: boolean;
/**
* Custom logo component
*/
logo?: React.ReactNode;
/**
* Custom search placeholder
*/
searchPlaceholder?: string;
/**
* Notification count
*/
notificationCount?: number;
/**
* Custom notification handler
*/
onNotificationClick?: () => void;
}
export interface HeaderRef {
focusSearch: () => void;
toggleUserMenu: () => void;
closeUserMenu: () => void;
}
/**
* Header - Top navigation header with logo, user menu, notifications, theme toggle
*
* Features:
* - Logo/brand area with responsive sizing
* - Global search functionality with keyboard shortcuts
* - User avatar with dropdown menu
* - Notifications bell with badge count
* - Theme toggle button (light/dark/system)
* - Mobile hamburger menu integration
* - Keyboard navigation support
*/
export const Header = forwardRef<HeaderRef, HeaderProps>(({
className,
onMenuClick,
sidebarCollapsed = false,
showSearch = true,
showNotifications = true,
showThemeToggle = true,
showUserMenu = true,
logo,
searchPlaceholder = 'Buscar...',
notificationCount = 0,
onNotificationClick,
}, ref) => {
2025-08-28 23:40:44 +02:00
const navigate = useNavigate();
2025-08-28 10:41:04 +02:00
const user = useAuthUser();
const isAuthenticated = useIsAuthenticated();
const { logout } = useAuthActions();
const { theme, resolvedTheme, setTheme } = useTheme();
const [isUserMenuOpen, setIsUserMenuOpen] = useState(false);
const [isSearchFocused, setIsSearchFocused] = useState(false);
const [searchValue, setSearchValue] = useState('');
const [isThemeMenuOpen, setIsThemeMenuOpen] = useState(false);
const searchInputRef = React.useRef<HTMLInputElement>(null);
// Focus search input
const focusSearch = useCallback(() => {
searchInputRef.current?.focus();
}, []);
// Toggle user menu
const toggleUserMenu = useCallback(() => {
setIsUserMenuOpen(prev => !prev);
}, []);
// Close user menu
const closeUserMenu = useCallback(() => {
setIsUserMenuOpen(false);
}, []);
// Expose ref methods
React.useImperativeHandle(ref, () => ({
focusSearch,
toggleUserMenu,
closeUserMenu,
}), [focusSearch, toggleUserMenu, closeUserMenu]);
// 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();
}, []);
// Handle logout
const handleLogout = useCallback(async () => {
await logout();
setIsUserMenuOpen(false);
}, [logout]);
// Keyboard shortcuts
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') {
setIsUserMenuOpen(false);
setIsThemeMenuOpen(false);
if (isSearchFocused) {
searchInputRef.current?.blur();
}
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [focusSearch, isSearchFocused]);
// Close menus when clicking outside
React.useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as Element;
if (!target.closest('[data-user-menu]')) {
setIsUserMenuOpen(false);
}
if (!target.closest('[data-theme-menu]')) {
setIsThemeMenuOpen(false);
}
};
document.addEventListener('click', handleClickOutside);
return () => document.removeEventListener('click', handleClickOutside);
}, []);
const themeIcons = {
light: Sun,
dark: Moon,
auto: Computer,
};
const ThemeIcon = themeIcons[theme] || Sun;
return (
<header
className={clsx(
'fixed top-0 left-0 right-0 h-[var(--header-height)]',
'bg-[var(--bg-primary)] border-b border-[var(--border-primary)]',
'flex items-center justify-between px-4 lg:px-6',
'transition-all duration-300 ease-in-out',
'backdrop-blur-sm bg-[var(--bg-primary)]/95',
2025-08-28 17:15:29 +02:00
'z-[var(--z-fixed)]',
2025-08-28 10:41:04 +02:00
className
)}
role="banner"
aria-label="Navegación principal"
>
{/* Left section */}
2025-08-31 22:14:05 +02:00
<div className="flex items-center gap-2 sm:gap-4 flex-1 min-w-0 h-full">
2025-08-28 10:41:04 +02:00
{/* Mobile menu button */}
<Button
variant="ghost"
size="sm"
onClick={onMenuClick}
2025-08-28 23:40:44 +02:00
className="lg:hidden w-10 h-10 p-0 flex items-center justify-center hover:bg-[var(--bg-secondary)] active:scale-95 transition-all duration-150"
2025-08-28 10:41:04 +02:00
aria-label="Abrir menú de navegación"
>
2025-08-28 23:40:44 +02:00
<Menu className="h-5 w-5 text-[var(--text-primary)]" />
2025-08-28 10:41:04 +02:00
</Button>
{/* Logo */}
2025-08-31 22:14:05 +02:00
<div className="flex items-center gap-2 sm:gap-3 min-w-0 flex-shrink-0">
2025-08-28 10:41:04 +02:00
{logo || (
<>
2025-08-28 17:15:29 +02:00
<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">
2025-08-28 10:41:04 +02:00
PI
</div>
<h1 className={clsx(
'font-semibold text-[var(--text-primary)] transition-opacity duration-300',
2025-08-31 22:14:05 +02:00
'hidden md:block text-lg leading-tight whitespace-nowrap',
2025-08-28 17:15:29 +02:00
'self-center',
2025-08-28 10:41:04 +02:00
sidebarCollapsed ? 'lg:block' : 'lg:hidden xl:block'
)}>
Panadería IA
</h1>
</>
)}
</div>
{/* Tenant Switcher - Desktop */}
{isAuthenticated && (
2025-08-31 22:14:05 +02:00
<div className="hidden md:block mx-2 lg:mx-4 flex-shrink-0">
<TenantSwitcher
showLabel={true}
2025-08-31 22:14:05 +02:00
className="min-w-[160px] max-w-[220px] lg:min-w-[200px] lg:max-w-[280px]"
/>
</div>
)}
{/* Tenant Switcher - Mobile (in title area) */}
{isAuthenticated && (
2025-08-31 22:14:05 +02:00
<div className="md:hidden flex-1 min-w-0 ml-3">
<TenantSwitcher
showLabel={false}
2025-08-31 22:14:05 +02:00
className="w-full max-w-none"
/>
</div>
)}
2025-08-28 10:41:04 +02:00
{/* Search */}
{showSearch && isAuthenticated && (
<form
onSubmit={handleSearchSubmit}
className="hidden md:flex items-center flex-1 max-w-md mx-4"
>
<div className="relative w-full">
2025-08-28 17:15:29 +02:00
<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>
2025-08-28 10:41:04 +02:00
<input
ref={searchInputRef}
type="text"
value={searchValue}
onChange={handleSearchChange}
onFocus={() => setIsSearchFocused(true)}
onBlur={() => setIsSearchFocused(false)}
placeholder={searchPlaceholder}
className={clsx(
2025-08-28 17:15:29 +02:00
'w-full pl-10 pr-12 py-2.5 text-sm',
2025-08-28 10:41:04 +02:00
'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)]',
2025-08-28 17:15:29 +02:00
'placeholder:text-[var(--text-tertiary)]',
'h-9'
2025-08-28 10:41:04 +02:00
)}
aria-label="Buscar en la aplicación"
/>
2025-08-28 17:15:29 +02:00
{searchValue ? (
2025-08-28 10:41:04 +02:00
<button
type="button"
onClick={clearSearch}
2025-08-28 17:15:29 +02:00
className="absolute right-3 top-0 bottom-0 flex items-center p-1 hover:bg-[var(--bg-tertiary)] rounded-full transition-colors"
2025-08-28 10:41:04 +02:00
aria-label="Limpiar búsqueda"
>
<X className="h-3 w-3 text-[var(--text-tertiary)]" />
</button>
2025-08-28 17:15:29 +02:00
) : (
<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>
2025-08-28 10:41:04 +02:00
)}
</div>
</form>
)}
</div>
{/* Right section */}
{isAuthenticated && (
2025-08-30 19:21:15 +02:00
<div className="flex items-center gap-1">
2025-08-28 10:41:04 +02:00
{/* Mobile search */}
{showSearch && (
<Button
variant="ghost"
size="sm"
onClick={focusSearch}
2025-08-28 17:15:29 +02:00
className="md:hidden w-10 h-10 p-0 flex items-center justify-center"
2025-08-28 10:41:04 +02:00
aria-label="Buscar"
>
<Search className="h-5 w-5" />
</Button>
)}
{/* Theme toggle */}
{showThemeToggle && (
<div className="relative" data-theme-menu>
<Button
variant="ghost"
size="sm"
onClick={() => setIsThemeMenuOpen(!isThemeMenuOpen)}
2025-08-28 17:15:29 +02:00
className="w-10 h-10 p-0 flex items-center justify-center"
2025-08-28 10:41:04 +02:00
aria-label={`Tema actual: ${theme}`}
aria-expanded={isThemeMenuOpen}
aria-haspopup="true"
>
<ThemeIcon className="h-5 w-5" />
</Button>
{isThemeMenuOpen && (
<div className="absolute right-0 top-full mt-2 w-48 bg-[var(--bg-primary)] border border-[var(--border-primary)] rounded-lg shadow-lg py-1 z-[var(--z-dropdown)]">
{[
{ key: 'light' as const, label: 'Claro', icon: Sun },
{ key: 'dark' as const, label: 'Oscuro', icon: Moon },
{ key: 'auto' as const, label: 'Sistema', icon: Computer },
].map(({ key, label, icon: Icon }) => (
<button
key={key}
onClick={() => {
setTheme(key);
setIsThemeMenuOpen(false);
}}
className={clsx(
'w-full px-4 py-2 text-left text-sm flex items-center gap-3',
'hover:bg-[var(--bg-secondary)] transition-colors',
theme === key && 'bg-[var(--bg-secondary)] text-[var(--color-primary)]'
)}
>
<Icon className="h-4 w-4" />
{label}
</button>
))}
</div>
)}
</div>
)}
{/* Notifications */}
{showNotifications && (
<div className="relative">
<Button
variant="ghost"
size="sm"
onClick={onNotificationClick}
2025-08-28 17:15:29 +02:00
className="w-10 h-10 p-0 flex items-center justify-center relative"
2025-08-28 10:41:04 +02:00
aria-label={`Notificaciones${notificationCount > 0 ? ` (${notificationCount})` : ''}`}
>
<Bell className="h-5 w-5" />
{notificationCount > 0 && (
<Badge
variant="error"
size="sm"
className="absolute -top-1 -right-1 min-w-[18px] h-[18px] text-xs flex items-center justify-center"
>
{notificationCount > 99 ? '99+' : notificationCount}
</Badge>
)}
</Button>
</div>
)}
{/* User menu */}
{showUserMenu && user && (
<div className="relative" data-user-menu>
<Button
variant="ghost"
size="sm"
onClick={toggleUserMenu}
2025-08-30 19:21:15 +02:00
className={clsx(
"flex items-center gap-2 pl-2 pr-3 py-1.5 h-9 min-w-0 rounded-lg",
"hover:bg-[var(--bg-secondary)] hover:ring-2 hover:ring-[var(--color-primary)]/20",
"transition-all duration-200 ease-in-out",
"active:scale-95",
isUserMenuOpen && "bg-[var(--bg-secondary)] ring-2 ring-[var(--color-primary)]/20"
)}
2025-08-28 10:41:04 +02:00
aria-label="Menú de usuario"
aria-expanded={isUserMenuOpen}
aria-haspopup="true"
>
<Avatar
2025-08-30 19:32:53 +02:00
src={user.avatar}
alt={user.name}
2025-08-31 15:16:40 +02:00
name={user.name}
2025-08-30 19:21:15 +02:00
size="xs"
className={clsx(
"flex-shrink-0 transition-all duration-200",
"hover:ring-2 hover:ring-[var(--color-primary)]/30",
isUserMenuOpen && "ring-2 ring-[var(--color-primary)]/30"
)}
2025-08-28 10:41:04 +02:00
/>
</Button>
{isUserMenuOpen && (
<div className="absolute right-0 top-full mt-2 w-56 bg-[var(--bg-primary)] border border-[var(--border-primary)] rounded-lg shadow-lg py-2 z-[var(--z-dropdown)]">
{/* User info */}
2025-08-31 15:16:40 +02:00
<div className="px-4 py-3 border-b border-[var(--border-primary)] flex items-center gap-3">
<Avatar
src={user.avatar || undefined}
alt={user.name}
name={user.name}
size="sm"
className="flex-shrink-0"
/>
<div className="min-w-0 flex-1">
<div className="text-xs text-[var(--text-tertiary)] truncate">
{user.email}
</div>
2025-08-28 10:41:04 +02:00
</div>
</div>
{/* Menu items */}
<div className="py-1">
<button
onClick={() => {
2025-08-28 23:40:44 +02:00
navigate('/app/settings/profile');
2025-08-28 10:41:04 +02:00
setIsUserMenuOpen(false);
}}
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={() => {
2025-08-28 23:40:44 +02:00
navigate('/app/settings');
2025-08-28 10:41:04 +02:00
setIsUserMenuOpen(false);
}}
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>
{/* Logout */}
<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>
)}
</header>
);
});
Header.displayName = 'Header';