Files
bakery-ia/frontend/src/components/layout/Header/Header.tsx
2025-10-23 07:44:54 +02:00

289 lines
9.3 KiB
TypeScript

import React, { useState, useCallback, forwardRef } from 'react';
import { clsx } from 'clsx';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useAuthUser, useIsAuthenticated } from '../../../stores';
import { useTheme } from '../../../contexts/ThemeContext';
import { useNotifications } from '../../../hooks/useNotifications';
import { useHasAccess } from '../../../hooks/useAccessControl';
import { Button } from '../../ui';
import { CountBadge } from '../../ui';
import { TenantSwitcher } from '../../ui/TenantSwitcher';
import { ThemeToggle } from '../../ui/ThemeToggle';
import { NotificationPanel } from '../../ui/NotificationPanel/NotificationPanel';
import { CompactLanguageSelector } from '../../ui/LanguageSelector';
import {
Menu,
Bell,
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;
/**
* Custom logo component
*/
logo?: React.ReactNode;
/**
* Custom search placeholder
*/
searchPlaceholder?: string;
/**
* Notification count
*/
notificationCount?: number;
/**
* Custom notification handler
*/
onNotificationClick?: () => void;
}
export interface HeaderRef {
// No search-related methods anymore
}
/**
* Header - Top navigation header with logo, notifications, theme toggle
*
* Features:
* - Logo/brand area with responsive sizing
* - Global search functionality with keyboard shortcuts
* - 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,
logo,
searchPlaceholder,
notificationCount = 0,
onNotificationClick,
}, ref) => {
const { t } = useTranslation();
const navigate = useNavigate();
const user = useAuthUser();
const hasAccess = useHasAccess(); // Check both authentication and demo mode
const { theme, resolvedTheme, setTheme } = useTheme();
const {
notifications,
unreadCount,
isConnected,
markAsRead,
markAllAsRead,
removeNotification,
clearAll
} = useNotifications();
const [isNotificationPanelOpen, setIsNotificationPanelOpen] = useState(false);
// Filter notifications to last 24 hours for the notification bell
// This prevents showing old/stale alerts in the notification panel
const recentNotifications = React.useMemo(() => {
const oneDayAgo = Date.now() - (24 * 60 * 60 * 1000);
return notifications.filter(n => {
const alertTime = new Date(n.timestamp).getTime();
return alertTime > oneDayAgo;
});
}, [notifications]);
const defaultSearchPlaceholder = searchPlaceholder || t('common:forms.search_placeholder', 'Search...');
// Expose ref methods
React.useImperativeHandle(ref, () => ({
// No search functions available anymore
}), []);
// Keyboard shortcuts
React.useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// Escape to close menus
if (e.key === 'Escape') {
setIsNotificationPanelOpen(false);
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, []);
// Close menus when clicking outside
React.useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as Element;
if (!target.closest('[data-notification-panel]')) {
setIsNotificationPanelOpen(false);
}
};
document.addEventListener('click', handleClickOutside);
return () => document.removeEventListener('click', handleClickOutside);
}, []);
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',
'z-[var(--z-fixed)]',
className
)}
role="banner"
aria-label={t('common:header.main_navigation', 'Navegación principal')}
>
{/* Left section */}
<div className="flex items-center gap-2 sm:gap-4 flex-1 min-w-0 h-full">
{/* Mobile menu button */}
<div data-tour="sidebar-menu-toggle">
<Button
variant="ghost"
size="sm"
onClick={onMenuClick}
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"
aria-label={t('common:header.open_menu', 'Abrir menú de navegación')}
>
<Menu className="h-5 w-5 text-[var(--text-primary)]" />
</Button>
</div>
{/* Logo */}
<div className="flex items-center gap-2 sm:gap-3 min-w-0 flex-shrink-0">
{logo || (
<>
<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>
<h1 className={clsx(
'font-semibold text-[var(--text-primary)] transition-opacity duration-300',
'hidden md:block text-lg leading-tight whitespace-nowrap',
'self-center',
sidebarCollapsed ? 'lg:block' : 'lg:hidden xl:block'
)}>
{t('common:app.name', 'Panadería IA')}
</h1>
</>
)}
</div>
{/* Tenant Switcher - Desktop */}
{hasAccess && (
<div className="hidden md:block mx-2 lg:mx-4 flex-shrink-0" data-tour="header-tenant-selector">
<TenantSwitcher
showLabel={true}
className="min-w-[160px] max-w-[220px] lg:min-w-[200px] lg:max-w-[280px]"
/>
</div>
)}
{/* Tenant Switcher - Mobile (in title area) */}
{hasAccess && (
<div className="md:hidden flex-1 min-w-0 ml-3">
<TenantSwitcher
showLabel={false}
className="w-full max-w-none"
/>
</div>
)}
{/* Space for potential future content */ }
{hasAccess && (
<div className="hidden md:flex items-center flex-1 max-w-md mx-4">
&nbsp; {/* Empty space to maintain layout consistency */}
</div>
)}
</div>
{/* Right section */}
{hasAccess && (
<div className="flex items-center gap-1">
{/* Placeholder for potential future items */ }
{/* Language selector */}
<CompactLanguageSelector className="w-auto min-w-[50px]" />
{/* Theme toggle */}
{showThemeToggle && (
<ThemeToggle variant="button" size="md" />
)}
{/* Notifications */}
{showNotifications && (
<div className="relative" data-notification-panel>
<Button
variant="ghost"
size="sm"
onClick={() => setIsNotificationPanelOpen(!isNotificationPanelOpen)}
className={clsx(
"w-10 h-10 p-0 flex items-center justify-center relative",
!isConnected && "opacity-50",
isNotificationPanelOpen && "bg-[var(--bg-secondary)]"
)}
aria-label={`${t('common:navigation.notifications', 'Notifications')}${unreadCount > 0 ? ` (${unreadCount})` : ''}${!isConnected ? ` - ${t('common:status.disconnected', 'Disconnected')}` : ''}`}
title={!isConnected ? t('common:status.no_realtime_connection', 'No real-time connection') : undefined}
aria-expanded={isNotificationPanelOpen}
aria-haspopup="true"
>
<Bell className={clsx(
"h-5 w-5 transition-colors",
unreadCount > 0 && "text-[var(--color-warning)]"
)} />
{unreadCount > 0 && (
<CountBadge
count={unreadCount}
max={99}
variant="error"
size="sm"
overlay
/>
)}
</Button>
<NotificationPanel
notifications={recentNotifications}
isOpen={isNotificationPanelOpen}
onClose={() => setIsNotificationPanelOpen(false)}
onMarkAsRead={markAsRead}
onMarkAllAsRead={markAllAsRead}
onRemoveNotification={removeNotification}
onClearAll={clearAll}
/>
</div>
)}
</div>
)}
</header>
);
});
Header.displayName = 'Header';