ADD new frontend

This commit is contained in:
Urtzi Alfaro
2025-08-28 10:41:04 +02:00
parent 9c247a5f99
commit 0fd273cfce
492 changed files with 114979 additions and 1632 deletions

View File

@@ -1,67 +0,0 @@
import React from 'react';
import { Outlet } from 'react-router-dom';
import { SecondaryNavigation } from '../navigation/SecondaryNavigation';
import { Breadcrumbs } from '../navigation/Breadcrumbs';
import { useBakeryType } from '../../hooks/useBakeryType';
const AnalyticsLayout: React.FC = () => {
const { bakeryType } = useBakeryType();
const navigationItems = [
{
id: 'forecasting',
label: 'Predicciones',
href: '/app/analytics/forecasting',
icon: 'TrendingUp'
},
{
id: 'sales-analytics',
label: 'Análisis Ventas',
href: '/app/analytics/sales-analytics',
icon: 'BarChart3'
},
{
id: 'production-reports',
label: bakeryType === 'individual' ? 'Reportes Producción' : 'Reportes Distribución',
href: '/app/analytics/production-reports',
icon: 'FileBarChart'
},
{
id: 'financial-reports',
label: 'Reportes Financieros',
href: '/app/analytics/financial-reports',
icon: 'DollarSign'
},
{
id: 'performance-kpis',
label: 'KPIs Rendimiento',
href: '/app/analytics/performance-kpis',
icon: 'Target'
},
{
id: 'ai-insights',
label: 'Insights IA',
href: '/app/analytics/ai-insights',
icon: 'Brain'
}
];
return (
<div className="flex flex-col h-full">
<div className="bg-white border-b border-gray-200">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<Breadcrumbs />
<SecondaryNavigation items={navigationItems} />
</div>
</div>
<div className="flex-1 bg-gray-50">
<div className="max-w-7xl mx-auto">
<Outlet />
</div>
</div>
</div>
);
};
export default AnalyticsLayout;

View File

@@ -0,0 +1,261 @@
import React, { useState, useCallback, forwardRef } from 'react';
import { clsx } from 'clsx';
import { useAuthUser, useIsAuthenticated } from '../../../stores';
import { useTheme } from '../../../contexts/ThemeContext';
import { Header } from '../Header';
import { Sidebar } from '../Sidebar';
import { Footer } from '../Footer';
export interface AppShellProps {
children: React.ReactNode;
className?: string;
/**
* Control sidebar visibility
*/
showSidebar?: boolean;
/**
* Control header visibility
*/
showHeader?: boolean;
/**
* Control footer visibility
*/
showFooter?: boolean;
/**
* Make the layout fullscreen (hide header and footer)
*/
fullScreen?: boolean;
/**
* Add padding to the main content area
*/
padded?: boolean;
/**
* Initial sidebar collapsed state
*/
initialSidebarCollapsed?: boolean;
/**
* Custom loading component
*/
loadingComponent?: React.ReactNode;
/**
* Custom error boundary
*/
errorBoundary?: React.ComponentType<{ children: React.ReactNode; error?: Error }>;
}
export interface AppShellRef {
toggleSidebar: () => void;
collapseSidebar: () => void;
expandSidebar: () => void;
isSidebarOpen: boolean;
isSidebarCollapsed: boolean;
}
/**
* AppShell - Main application shell that combines header, sidebar, and main content area
*
* Features:
* - Responsive layout with collapsible sidebar
* - Mobile-friendly with drawer overlay
* - Content area that adjusts to sidebar state
* - Supports fullscreen mode for special pages
* - Integrated with authentication and theming
*/
export const AppShell = forwardRef<AppShellRef, AppShellProps>(({
children,
className,
showSidebar = true,
showHeader = true,
showFooter = true,
fullScreen = false,
padded = true,
initialSidebarCollapsed = false,
loadingComponent,
errorBoundary: ErrorBoundary,
}, ref) => {
const isAuthenticated = useIsAuthenticated();
const authLoading = false; // Since we're in a protected route, auth loading should be false
const { resolvedTheme } = useTheme();
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(initialSidebarCollapsed);
const [error, setError] = useState<Error | null>(null);
// Sidebar control functions
const toggleSidebar = useCallback(() => {
if (window.innerWidth < 1024) {
// Mobile: toggle drawer
setIsSidebarOpen(prev => !prev);
} else {
// Desktop: toggle collapsed state
setIsSidebarCollapsed(prev => !prev);
}
}, []);
const collapseSidebar = useCallback(() => {
if (window.innerWidth < 1024) {
setIsSidebarOpen(false);
} else {
setIsSidebarCollapsed(true);
}
}, []);
const expandSidebar = useCallback(() => {
if (window.innerWidth < 1024) {
setIsSidebarOpen(true);
} else {
setIsSidebarCollapsed(false);
}
}, []);
// Close mobile sidebar when clicking outside
const handleOverlayClick = useCallback(() => {
if (window.innerWidth < 1024) {
setIsSidebarOpen(false);
}
}, []);
// Expose ref methods
React.useImperativeHandle(ref, () => ({
toggleSidebar,
collapseSidebar,
expandSidebar,
isSidebarOpen,
isSidebarCollapsed,
}), [toggleSidebar, collapseSidebar, expandSidebar, isSidebarOpen, isSidebarCollapsed]);
// Handle responsive sidebar state
React.useEffect(() => {
const handleResize = () => {
if (window.innerWidth >= 1024) {
// Desktop: close mobile drawer
setIsSidebarOpen(false);
}
};
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
// Error boundary handling
React.useEffect(() => {
const handleError = (event: ErrorEvent) => {
setError(new Error(event.message));
};
const handleUnhandledRejection = (event: PromiseRejectionEvent) => {
setError(new Error(event.reason?.message || 'Promise rejection'));
};
window.addEventListener('error', handleError);
window.addEventListener('unhandledrejection', handleUnhandledRejection);
return () => {
window.removeEventListener('error', handleError);
window.removeEventListener('unhandledrejection', handleUnhandledRejection);
};
}, []);
// Show loading state
if (authLoading) {
return (
<div className="min-h-screen flex items-center justify-center bg-[var(--bg-primary)]">
{loadingComponent || (
<div className="flex flex-col items-center gap-4">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-[var(--color-primary)]"></div>
<p className="text-[var(--text-secondary)]">Cargando aplicación...</p>
</div>
)}
</div>
);
}
// Show error state
if (error && ErrorBoundary) {
return <ErrorBoundary error={error}>{children}</ErrorBoundary>;
}
const shouldShowSidebar = showSidebar && isAuthenticated && !fullScreen;
const shouldShowHeader = showHeader && !fullScreen;
const shouldShowFooter = showFooter && !fullScreen;
return (
<div
className={clsx(
'min-h-screen bg-[var(--bg-primary)] flex flex-col',
resolvedTheme,
className
)}
data-testid="app-shell"
>
{/* Header */}
{shouldShowHeader && (
<Header
onMenuClick={toggleSidebar}
sidebarCollapsed={isSidebarCollapsed}
className="z-[var(--z-fixed)]"
/>
)}
<div className="flex flex-1 relative">
{/* Sidebar */}
{shouldShowSidebar && (
<>
<Sidebar
isOpen={isSidebarOpen}
isCollapsed={isSidebarCollapsed}
onClose={() => setIsSidebarOpen(false)}
className="z-[var(--z-fixed)]"
/>
{/* Mobile overlay */}
{isSidebarOpen && (
<div
className="fixed inset-0 bg-black/50 z-[var(--z-modal-backdrop)] lg:hidden"
onClick={handleOverlayClick}
aria-hidden="true"
/>
)}
</>
)}
{/* Main content */}
<main
className={clsx(
'flex-1 flex flex-col transition-all duration-300 ease-in-out',
// Adjust margins based on sidebar state
shouldShowSidebar && isAuthenticated && {
'lg:ml-[var(--sidebar-width)]': !isSidebarCollapsed,
'lg:ml-16': isSidebarCollapsed,
},
// Add header offset
shouldShowHeader && 'pt-[var(--header-height)]',
// Add padding to content
padded && 'p-4 lg:p-6'
)}
role="main"
aria-label="Contenido principal"
>
{children}
</main>
</div>
{/* Footer */}
{shouldShowFooter && (
<Footer
compact={true}
showPrivacyLinks={true}
className={clsx(
'transition-all duration-300 ease-in-out',
shouldShowSidebar && isAuthenticated && {
'lg:ml-[var(--sidebar-width)]': !isSidebarCollapsed,
'lg:ml-16': isSidebarCollapsed,
}
)}
/>
)}
</div>
);
});
AppShell.displayName = 'AppShell';

View File

@@ -0,0 +1,2 @@
export { AppShell } from './AppShell';
export type { AppShellProps, AppShellRef } from './AppShell';

View File

@@ -1,12 +0,0 @@
import React from 'react';
import { Outlet } from 'react-router-dom';
const AuthLayout: React.FC = () => {
return (
<div className="min-h-screen bg-gray-50">
<Outlet />
</div>
);
};
export default AuthLayout;

View File

@@ -0,0 +1,337 @@
import React, { forwardRef } from 'react';
import { clsx } from 'clsx';
import { useLocation, Link } from 'react-router-dom';
import { getBreadcrumbs, getRouteByPath } from '../../../router/routes.config';
import {
ChevronRight,
Home,
MoreHorizontal,
ExternalLink
} from 'lucide-react';
export interface BreadcrumbItem {
label: string;
path: string;
icon?: React.ComponentType<{ className?: string }>;
disabled?: boolean;
external?: boolean;
}
export interface BreadcrumbsProps {
className?: string;
/**
* Custom breadcrumb items (overrides auto-generated ones)
*/
items?: BreadcrumbItem[];
/**
* Show home icon on first item
*/
showHome?: boolean;
/**
* Home path (defaults to '/')
*/
homePath?: string;
/**
* Home label
*/
homeLabel?: string;
/**
* Custom separator component
*/
separator?: React.ReactNode;
/**
* Maximum number of items to show before truncating
*/
maxItems?: number;
/**
* Show truncation in the middle (true) or at the beginning (false)
*/
truncateMiddle?: boolean;
/**
* Custom truncation component
*/
truncationComponent?: React.ReactNode;
/**
* Custom link component for external handling
*/
linkComponent?: React.ComponentType<{
to: string;
className?: string;
children: React.ReactNode;
external?: boolean;
}>;
/**
* Hide breadcrumbs for certain paths
*/
hiddenPaths?: string[];
/**
* Custom item renderer
*/
itemRenderer?: (item: BreadcrumbItem, index: number, isLast: boolean) => React.ReactNode;
}
export interface BreadcrumbsRef {
scrollToEnd: () => void;
getCurrentPath: () => string;
}
/**
* Breadcrumbs - Breadcrumb navigation component with links and separators
*
* Features:
* - Auto-generate breadcrumbs from current route path
* - Clickable navigation links with proper hover states
* - Custom separators with good visual hierarchy
* - Truncation for long paths with smart middle/start truncation
* - Home icon and custom icons support
* - External link handling with indicators
* - Keyboard navigation support
* - Responsive design with mobile adaptations
* - Custom renderers for advanced use cases
*/
export const Breadcrumbs = forwardRef<BreadcrumbsRef, BreadcrumbsProps>(({
className,
items: customItems,
showHome = true,
homePath = '/',
homeLabel = 'Inicio',
separator,
maxItems = 5,
truncateMiddle = true,
truncationComponent,
linkComponent: CustomLink,
hiddenPaths = [],
itemRenderer,
}, ref) => {
const location = useLocation();
const containerRef = React.useRef<HTMLDivElement>(null);
// Get breadcrumbs from router config or use custom items
const routeBreadcrumbs = getBreadcrumbs(location.pathname);
const generateItems = (): BreadcrumbItem[] => {
if (customItems) {
return customItems;
}
const items: BreadcrumbItem[] = [];
// Add home if enabled
if (showHome && location.pathname !== homePath) {
items.push({
label: homeLabel,
path: homePath,
icon: Home,
});
}
// Add route breadcrumbs
routeBreadcrumbs.forEach(route => {
// Use breadcrumbTitle if available, otherwise use title
const label = route.meta?.breadcrumbTitle || route.title;
items.push({
label,
path: route.path,
disabled: route.path === location.pathname, // Current page is disabled
});
});
return items;
};
const allItems = generateItems();
// Filter out hidden paths
const visibleItems = allItems.filter(item => !hiddenPaths.includes(item.path));
// Handle truncation
const getTruncatedItems = (): BreadcrumbItem[] => {
if (visibleItems.length <= maxItems) {
return visibleItems;
}
if (truncateMiddle && visibleItems.length > 3) {
// Keep first, last, and some middle items
const first = visibleItems[0];
const last = visibleItems[visibleItems.length - 1];
const secondToLast = visibleItems[visibleItems.length - 2];
return [first, { label: '...', path: '', disabled: true }, secondToLast, last];
} else {
// Keep last maxItems
return [
{ label: '...', path: '', disabled: true },
...visibleItems.slice(-(maxItems - 1))
];
}
};
const displayItems = getTruncatedItems();
// Scroll to end
const scrollToEnd = React.useCallback(() => {
if (containerRef.current) {
containerRef.current.scrollTo({
left: containerRef.current.scrollWidth,
behavior: 'smooth'
});
}
}, []);
// Get current path
const getCurrentPath = React.useCallback(() => {
return location.pathname;
}, [location.pathname]);
// Expose ref methods
React.useImperativeHandle(ref, () => ({
scrollToEnd,
getCurrentPath,
}), [scrollToEnd, getCurrentPath]);
// Auto-scroll to end when items change
React.useEffect(() => {
scrollToEnd();
}, [scrollToEnd, displayItems.length]);
// Default separator
const defaultSeparator = (
<ChevronRight className="w-4 h-4 text-[var(--text-tertiary)] flex-shrink-0" />
);
// Default truncation component
const defaultTruncationComponent = (
<div className="flex items-center gap-1 px-2 py-1 text-[var(--text-tertiary)]">
<MoreHorizontal className="w-4 h-4" />
</div>
);
// Default link component
const DefaultLink: React.FC<{
to: string;
className?: string;
children: React.ReactNode;
external?: boolean;
}> = ({ to, className, children, external }) => {
if (external) {
return (
<a
href={to}
target="_blank"
rel="noopener noreferrer"
className={className}
>
{children}
</a>
);
}
return (
<Link to={to} className={className}>
{children}
</Link>
);
};
const LinkComponent = CustomLink || DefaultLink;
// Render single item
const renderItem = (item: BreadcrumbItem, index: number, isLast: boolean) => {
if (itemRenderer) {
return itemRenderer(item, index, isLast);
}
const ItemIcon = item.icon;
// Handle truncation item
if (item.label === '...') {
return (
<li key={`truncation-${index}`} className="flex items-center">
{truncationComponent || defaultTruncationComponent}
</li>
);
}
const content = (
<div className="flex items-center gap-2 min-w-0">
{ItemIcon && (
<ItemIcon className="w-4 h-4 text-[var(--text-tertiary)] flex-shrink-0" />
)}
<span className={clsx(
'truncate transition-colors duration-200',
isLast
? 'text-[var(--text-primary)] font-medium'
: 'text-[var(--text-secondary)]'
)}>
{item.label}
</span>
{item.external && (
<ExternalLink className="w-3 h-3 text-[var(--text-tertiary)] flex-shrink-0" />
)}
</div>
);
return (
<li key={item.path || index} className="flex items-center min-w-0">
{item.disabled || isLast ? (
<span className="flex items-center gap-2 px-2 py-1 min-w-0">
{content}
</span>
) : (
<LinkComponent
to={item.path}
external={item.external}
className={clsx(
'flex items-center gap-2 px-2 py-1 rounded-md min-w-0',
'transition-colors duration-200',
'hover:bg-[var(--bg-secondary)] hover:text-[var(--text-primary)]',
'focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)]/20',
'focus:bg-[var(--bg-secondary)]'
)}
>
{content}
</LinkComponent>
)}
</li>
);
};
// Don't render if no items or path is hidden
if (displayItems.length === 0 || hiddenPaths.includes(location.pathname)) {
return null;
}
return (
<nav
ref={containerRef}
className={clsx(
'flex items-center gap-1 text-sm overflow-x-auto',
'scrollbar-none pb-1', // Hide scrollbar but allow scrolling
className
)}
aria-label="Breadcrumb"
role="navigation"
>
<ol className="flex items-center gap-1 min-w-0 flex-nowrap">
{displayItems.map((item, index) => {
const isLast = index === displayItems.length - 1;
return (
<React.Fragment key={item.path || index}>
{renderItem(item, index, isLast)}
{!isLast && (
<li className="flex items-center flex-shrink-0 mx-1" aria-hidden="true">
{separator || defaultSeparator}
</li>
)}
</React.Fragment>
);
})}
</ol>
</nav>
);
});
Breadcrumbs.displayName = 'Breadcrumbs';

View File

@@ -0,0 +1,2 @@
export { Breadcrumbs } from './Breadcrumbs';
export type { BreadcrumbsProps, BreadcrumbsRef, BreadcrumbItem } from './Breadcrumbs';

View File

@@ -0,0 +1,431 @@
import React, { forwardRef } from 'react';
import { clsx } from 'clsx';
import { Link } from 'react-router-dom';
import { Button } from '../../ui';
import {
Heart,
ExternalLink,
Mail,
Phone,
MapPin,
Github,
Twitter,
Linkedin,
Globe,
Shield,
FileText,
HelpCircle,
MessageSquare
} from 'lucide-react';
export interface FooterLink {
id: string;
label: string;
href: string;
external?: boolean;
icon?: React.ComponentType<{ className?: string }>;
}
export interface FooterSection {
id: string;
title: string;
links: FooterLink[];
}
export interface SocialLink {
id: string;
label: string;
href: string;
icon: React.ComponentType<{ className?: string }>;
}
export interface CompanyInfo {
name: string;
description?: string;
logo?: React.ReactNode;
email?: string;
phone?: string;
address?: string;
website?: string;
}
export interface FooterProps {
className?: string;
/**
* Company information
*/
companyInfo?: CompanyInfo;
/**
* Footer sections with links
*/
sections?: FooterSection[];
/**
* Social media links
*/
socialLinks?: SocialLink[];
/**
* Copyright text (auto-generated if not provided)
*/
copyrightText?: string;
/**
* Show version info
*/
showVersion?: boolean;
/**
* Version string
*/
version?: string;
/**
* Show language selector
*/
showLanguageSelector?: boolean;
/**
* Available languages
*/
languages?: Array<{ code: string; name: string }>;
/**
* Current language
*/
currentLanguage?: string;
/**
* Language change handler
*/
onLanguageChange?: (language: string) => void;
/**
* Show theme toggle
*/
showThemeToggle?: boolean;
/**
* Theme toggle handler
*/
onThemeToggle?: () => void;
/**
* Show privacy links
*/
showPrivacyLinks?: boolean;
/**
* Compact mode (smaller footer)
*/
compact?: boolean;
/**
* Custom content
*/
children?: React.ReactNode;
}
export interface FooterRef {
scrollIntoView: () => void;
}
/**
* Footer - Application footer with links and copyright info
*
* Features:
* - Company information and branding
* - Organized link sections for easy navigation
* - Social media links with proper icons
* - Copyright notice with automatic year
* - Version information display
* - Language selector for i18n
* - Privacy and legal links
* - Responsive design with mobile adaptations
* - Accessible link handling with external indicators
* - Customizable sections and content
*/
export const Footer = forwardRef<FooterRef, FooterProps>(({
className,
companyInfo,
sections,
socialLinks,
copyrightText,
showVersion = true,
version = '2.0.0',
showLanguageSelector = false,
languages = [],
currentLanguage = 'es',
onLanguageChange,
showThemeToggle = false,
onThemeToggle,
showPrivacyLinks = true,
compact = false,
children,
}, ref) => {
const footerRef = React.useRef<HTMLDivElement>(null);
const currentYear = new Date().getFullYear();
// Company info - full for public pages, minimal for internal
const defaultCompanyInfo: CompanyInfo = compact ? {
name: 'Panadería IA',
} : {
name: 'Panadería IA',
description: 'Sistema inteligente de gestión para panaderías. Optimiza tu producción, inventario y ventas con inteligencia artificial.',
email: 'contacto@panaderia-ia.com',
website: 'https://panaderia-ia.com',
};
const company = companyInfo || defaultCompanyInfo;
// Sections - full for public pages, empty for internal
const defaultSections: FooterSection[] = compact ? [] : [
{
id: 'product',
title: 'Producto',
links: [
{ id: 'dashboard', label: 'Dashboard', href: '/dashboard' },
{ id: 'inventory', label: 'Inventario', href: '/inventory' },
{ id: 'production', label: 'Producción', href: '/production' },
{ id: 'sales', label: 'Ventas', href: '/sales' },
{ id: 'forecasting', label: 'Predicciones', href: '/forecasting' },
],
},
{
id: 'support',
title: 'Soporte',
links: [
{ id: 'help', label: 'Centro de Ayuda', href: '/help', icon: HelpCircle },
{ id: 'docs', label: 'Documentación', href: '/help/docs', icon: FileText },
{ id: 'contact', label: 'Contacto', href: '/help/support', icon: MessageSquare },
{ id: 'feedback', label: 'Feedback', href: '/help/feedback' },
],
},
{
id: 'company',
title: 'Empresa',
links: [
{ id: 'about', label: 'Acerca de', href: '/about', external: true },
{ id: 'blog', label: 'Blog', href: 'https://blog.panaderia-ia.com', external: true },
{ id: 'careers', label: 'Carreras', href: 'https://careers.panaderia-ia.com', external: true },
{ id: 'press', label: 'Prensa', href: '/press', external: true },
],
},
];
const footerSections = sections || defaultSections;
// Social links - none for internal business application, full set for public pages
const defaultSocialLinks: SocialLink[] = compact ? [] : [
{
id: 'twitter',
label: 'Twitter',
href: 'https://twitter.com/panaderia-ia',
icon: Twitter,
},
{
id: 'linkedin',
label: 'LinkedIn',
href: 'https://linkedin.com/company/panaderia-ia',
icon: Linkedin,
},
{
id: 'github',
label: 'GitHub',
href: 'https://github.com/panaderia-ia',
icon: Github,
},
];
const socialLinksToShow = socialLinks || defaultSocialLinks;
// Privacy links
const privacyLinks: FooterLink[] = [
{ id: 'privacy', label: 'Privacidad', href: '/privacy', icon: Shield },
{ id: 'terms', label: 'Términos', href: '/terms', icon: FileText },
{ id: 'cookies', label: 'Cookies', href: '/cookies' },
];
// Scroll into view
const scrollIntoView = React.useCallback(() => {
footerRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
}, []);
// Expose ref methods
React.useImperativeHandle(ref, () => ({
scrollIntoView,
}), [scrollIntoView]);
// Render link
const renderLink = (link: FooterLink) => {
const LinkIcon = link.icon;
const linkContent = (
<span className="flex items-center gap-2 text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors duration-200">
{LinkIcon && <LinkIcon className="w-4 h-4" />}
{link.label}
{link.external && <ExternalLink className="w-3 h-3" />}
</span>
);
if (link.external) {
return (
<a
key={link.id}
href={link.href}
target="_blank"
rel="noopener noreferrer"
className="hover:underline focus:outline-none focus:underline"
>
{linkContent}
</a>
);
}
return (
<Link
key={link.id}
to={link.href}
className="hover:underline focus:outline-none focus:underline"
>
{linkContent}
</Link>
);
};
// Render social link
const renderSocialLink = (social: SocialLink) => {
const SocialIcon = social.icon;
return (
<a
key={social.id}
href={social.href}
target="_blank"
rel="noopener noreferrer"
className="p-2 rounded-lg text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-secondary)] transition-colors duration-200"
aria-label={social.label}
>
<SocialIcon className="w-5 h-5" />
</a>
);
};
return (
<footer
ref={footerRef}
className={clsx(
'bg-[var(--bg-secondary)] border-t border-[var(--border-primary)]',
'mt-auto', // Push to bottom when using flex layout
compact ? 'py-6' : 'py-12',
className
)}
role="contentinfo"
>
<div className="max-w-7xl mx-auto px-4 lg:px-6">
{/* Custom children - only show if provided */}
{children && (
<div className="mb-6">
{children}
</div>
)}
{/* Full footer content for public pages */}
{!compact && (
<>
{/* Company info and links */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8 mb-8">
{/* Company info */}
<div className="space-y-4">
<div className="flex items-center gap-3">
{company.logo}
<h3 className="text-lg font-semibold text-[var(--text-primary)]">{company.name}</h3>
</div>
{company.description && (
<p className="text-sm text-[var(--text-secondary)]">{company.description}</p>
)}
{/* Contact info */}
<div className="space-y-2">
{company.email && (
<div className="flex items-center gap-2 text-sm text-[var(--text-secondary)]">
<Mail className="w-4 h-4" />
<a href={`mailto:${company.email}`} className="hover:text-[var(--text-primary)]">
{company.email}
</a>
</div>
)}
{company.phone && (
<div className="flex items-center gap-2 text-sm text-[var(--text-secondary)]">
<Phone className="w-4 h-4" />
<a href={`tel:${company.phone}`} className="hover:text-[var(--text-primary)]">
{company.phone}
</a>
</div>
)}
{company.website && (
<div className="flex items-center gap-2 text-sm text-[var(--text-secondary)]">
<Globe className="w-4 h-4" />
<a href={company.website} target="_blank" rel="noopener noreferrer" className="hover:text-[var(--text-primary)]">
{company.website}
</a>
</div>
)}
</div>
</div>
{/* Footer sections */}
{footerSections.map((section) => (
<div key={section.id} className="space-y-4">
<h4 className="text-base font-medium text-[var(--text-primary)]">{section.title}</h4>
<nav className="space-y-2">
{section.links.map((link) => renderLink(link))}
</nav>
</div>
))}
</div>
{/* Social links */}
{socialLinksToShow.length > 0 && (
<div className="border-t border-[var(--border-primary)] pt-6 mb-6">
<div className="flex flex-col sm:flex-row items-center justify-between gap-4">
<p className="text-sm text-[var(--text-secondary)]">Síguenos en redes sociales</p>
<div className="flex items-center gap-2">
{socialLinksToShow.map((social) => renderSocialLink(social))}
</div>
</div>
</div>
)}
</>
)}
{/* Minimal bottom bar */}
<div className="flex flex-col sm:flex-row items-center justify-between gap-4 text-sm text-[var(--text-tertiary)]">
{/* Copyright and version */}
<div className="flex items-center gap-4">
<span>© {currentYear} {company.name}</span>
{showVersion && (
<span>v{version}</span>
)}
</div>
{/* Essential utilities only */}
<div className="flex items-center gap-4">
{/* Privacy links - minimal set */}
{showPrivacyLinks && (
<div className="flex items-center gap-3">
<Link
to="/privacy"
className="text-[var(--text-tertiary)] hover:text-[var(--text-primary)] transition-colors duration-200"
>
Privacidad
</Link>
<Link
to="/terms"
className="text-[var(--text-tertiary)] hover:text-[var(--text-primary)] transition-colors duration-200"
>
Términos
</Link>
</div>
)}
</div>
</div>
{/* Copyright text override */}
{copyrightText && (
<div className="text-center text-sm text-[var(--text-tertiary)] mt-4 pt-4 border-t border-[var(--border-primary)]">
{copyrightText}
</div>
)}
</div>
</footer>
);
});
Footer.displayName = 'Footer';

View File

@@ -0,0 +1,481 @@
import React, { forwardRef } from 'react';
import { clsx } from 'clsx';
import { Link } from 'react-router-dom';
import { Button } from '../../ui';
import {
Heart,
ExternalLink,
Mail,
Phone,
MapPin,
Github,
Twitter,
Linkedin,
Globe,
Shield,
FileText,
HelpCircle,
MessageSquare
} from 'lucide-react';
export interface FooterLink {
id: string;
label: string;
href: string;
external?: boolean;
icon?: React.ComponentType<{ className?: string }>;
}
export interface FooterSection {
id: string;
title: string;
links: FooterLink[];
}
export interface SocialLink {
id: string;
label: string;
href: string;
icon: React.ComponentType<{ className?: string }>;
}
export interface CompanyInfo {
name: string;
description?: string;
logo?: React.ReactNode;
email?: string;
phone?: string;
address?: string;
website?: string;
}
export interface FooterProps {
className?: string;
/**
* Company information
*/
companyInfo?: CompanyInfo;
/**
* Footer sections with links
*/
sections?: FooterSection[];
/**
* Social media links
*/
socialLinks?: SocialLink[];
/**
* Copyright text (auto-generated if not provided)
*/
copyrightText?: string;
/**
* Show version info
*/
showVersion?: boolean;
/**
* Version string
*/
version?: string;
/**
* Show language selector
*/
showLanguageSelector?: boolean;
/**
* Available languages
*/
languages?: Array<{ code: string; name: string }>;
/**
* Current language
*/
currentLanguage?: string;
/**
* Language change handler
*/
onLanguageChange?: (language: string) => void;
/**
* Show theme toggle
*/
showThemeToggle?: boolean;
/**
* Theme toggle handler
*/
onThemeToggle?: () => void;
/**
* Show privacy links
*/
showPrivacyLinks?: boolean;
/**
* Compact mode (smaller footer)
*/
compact?: boolean;
/**
* Custom content
*/
children?: React.ReactNode;
}
export interface FooterRef {
scrollIntoView: () => void;
}
/**
* Footer - Application footer with links and copyright info
*
* Features:
* - Company information and branding
* - Organized link sections for easy navigation
* - Social media links with proper icons
* - Copyright notice with automatic year
* - Version information display
* - Language selector for i18n
* - Privacy and legal links
* - Responsive design with mobile adaptations
* - Accessible link handling with external indicators
* - Customizable sections and content
*/
export const Footer = forwardRef<FooterRef, FooterProps>(({
className,
companyInfo,
sections,
socialLinks,
copyrightText,
showVersion = true,
version = '2.0.0',
showLanguageSelector = false,
languages = [],
currentLanguage = 'es',
onLanguageChange,
showThemeToggle = false,
onThemeToggle,
showPrivacyLinks = true,
compact = false,
children,
}, ref) => {
const footerRef = React.useRef<HTMLDivElement>(null);
const currentYear = new Date().getFullYear();
// Default company info
const defaultCompanyInfo: CompanyInfo = {
name: 'Panadería IA',
description: 'Sistema inteligente de gestión para panaderías. Optimiza tu producción, inventario y ventas con inteligencia artificial.',
email: 'contacto@panaderia-ia.com',
website: 'https://panaderia-ia.com',
};
const company = companyInfo || defaultCompanyInfo;
// Default sections
const defaultSections: FooterSection[] = [
{
id: 'product',
title: 'Producto',
links: [
{ id: 'dashboard', label: 'Dashboard', href: '/dashboard' },
{ id: 'inventory', label: 'Inventario', href: '/inventory' },
{ id: 'production', label: 'Producción', href: '/production' },
{ id: 'sales', label: 'Ventas', href: '/sales' },
{ id: 'forecasting', label: 'Predicciones', href: '/forecasting' },
],
},
{
id: 'support',
title: 'Soporte',
links: [
{ id: 'help', label: 'Centro de Ayuda', href: '/help', icon: HelpCircle },
{ id: 'docs', label: 'Documentación', href: '/help/docs', icon: FileText },
{ id: 'contact', label: 'Contacto', href: '/help/support', icon: MessageSquare },
{ id: 'feedback', label: 'Feedback', href: '/help/feedback' },
],
},
{
id: 'company',
title: 'Empresa',
links: [
{ id: 'about', label: 'Acerca de', href: '/about', external: true },
{ id: 'blog', label: 'Blog', href: 'https://blog.panaderia-ia.com', external: true },
{ id: 'careers', label: 'Carreras', href: 'https://careers.panaderia-ia.com', external: true },
{ id: 'press', label: 'Prensa', href: '/press', external: true },
],
},
];
const footerSections = sections || defaultSections;
// Default social links
const defaultSocialLinks: SocialLink[] = [
{
id: 'github',
label: 'GitHub',
href: 'https://github.com/panaderia-ia',
icon: Github,
},
{
id: 'twitter',
label: 'Twitter',
href: 'https://twitter.com/panaderia_ia',
icon: Twitter,
},
{
id: 'linkedin',
label: 'LinkedIn',
href: 'https://linkedin.com/company/panaderia-ia',
icon: Linkedin,
},
];
const socialLinksToShow = socialLinks || defaultSocialLinks;
// Privacy links
const privacyLinks: FooterLink[] = [
{ id: 'privacy', label: 'Privacidad', href: '/privacy', icon: Shield },
{ id: 'terms', label: 'Términos', href: '/terms', icon: FileText },
{ id: 'cookies', label: 'Cookies', href: '/cookies' },
];
// Scroll into view
const scrollIntoView = React.useCallback(() => {
footerRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
}, []);
// Expose ref methods
React.useImperativeHandle(ref, () => ({
scrollIntoView,
}), [scrollIntoView]);
// Render link
const renderLink = (link: FooterLink) => {
const LinkIcon = link.icon;
const linkContent = (
<span className="flex items-center gap-2 text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors duration-200">
{LinkIcon && <LinkIcon className="w-4 h-4" />}
{link.label}
{link.external && <ExternalLink className="w-3 h-3" />}
</span>
);
if (link.external) {
return (
<a
key={link.id}
href={link.href}
target="_blank"
rel="noopener noreferrer"
className="hover:underline focus:outline-none focus:underline"
>
{linkContent}
</a>
);
}
return (
<Link
key={link.id}
to={link.href}
className="hover:underline focus:outline-none focus:underline"
>
{linkContent}
</Link>
);
};
// Render social link
const renderSocialLink = (social: SocialLink) => {
const SocialIcon = social.icon;
return (
<a
key={social.id}
href={social.href}
target="_blank"
rel="noopener noreferrer"
className="p-2 rounded-lg text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-secondary)] transition-colors duration-200"
aria-label={social.label}
>
<SocialIcon className="w-5 h-5" />
</a>
);
};
return (
<footer
ref={footerRef}
className={clsx(
'bg-[var(--bg-secondary)] border-t border-[var(--border-primary)]',
'mt-auto', // Push to bottom when using flex layout
compact ? 'py-6' : 'py-12',
className
)}
role="contentinfo"
>
<div className="max-w-7xl mx-auto px-4 lg:px-6">
{!compact && (
<>
{/* Main footer content */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8 mb-8">
{/* Company info */}
<div className="lg:col-span-2">
<div className="flex items-center gap-3 mb-4">
{company.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">
PI
</div>
)}
<span className="text-lg font-semibold text-[var(--text-primary)]">
{company.name}
</span>
</div>
{company.description && (
<p className="text-sm text-[var(--text-secondary)] mb-4 max-w-md">
{company.description}
</p>
)}
{/* Contact info */}
<div className="space-y-2">
{company.email && (
<a
href={`mailto:${company.email}`}
className="flex items-center gap-2 text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors duration-200"
>
<Mail className="w-4 h-4" />
{company.email}
</a>
)}
{company.phone && (
<a
href={`tel:${company.phone}`}
className="flex items-center gap-2 text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors duration-200"
>
<Phone className="w-4 h-4" />
{company.phone}
</a>
)}
{company.address && (
<div className="flex items-center gap-2 text-sm text-[var(--text-secondary)]">
<MapPin className="w-4 h-4" />
{company.address}
</div>
)}
{company.website && (
<a
href={company.website}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors duration-200"
>
<Globe className="w-4 h-4" />
{company.website}
<ExternalLink className="w-3 h-3" />
</a>
)}
</div>
</div>
{/* Footer sections */}
{footerSections.map(section => (
<div key={section.id}>
<h3 className="text-sm font-semibold text-[var(--text-primary)] mb-4">
{section.title}
</h3>
<ul className="space-y-3">
{section.links.map(link => (
<li key={link.id}>
{renderLink(link)}
</li>
))}
</ul>
</div>
))}
</div>
{/* Social links */}
{socialLinksToShow.length > 0 && (
<div className="flex items-center justify-center lg:justify-start gap-2 mb-8">
{socialLinksToShow.map(renderSocialLink)}
</div>
)}
{/* Custom children */}
{children && (
<div className="mb-8">
{children}
</div>
)}
</>
)}
{/* Bottom bar */}
<div className={clsx(
'flex flex-col sm:flex-row items-center justify-between gap-4',
!compact && 'pt-8 border-t border-[var(--border-primary)]'
)}>
{/* Copyright and version */}
<div className="flex flex-col sm:flex-row items-center gap-4 text-sm text-[var(--text-tertiary)]">
<div className="flex items-center gap-1">
<span>© {currentYear}</span>
<span>{company.name}</span>
<Heart className="w-4 h-4 text-red-500 mx-1" />
<span>Hecho en España</span>
</div>
{showVersion && (
<div className="flex items-center gap-1">
<span>v{version}</span>
</div>
)}
</div>
{/* Right side utilities */}
<div className="flex items-center gap-4">
{/* Language selector */}
{showLanguageSelector && languages.length > 0 && (
<select
value={currentLanguage}
onChange={(e) => onLanguageChange?.(e.target.value)}
className="text-sm bg-transparent border-none text-[var(--text-secondary)] hover:text-[var(--text-primary)] cursor-pointer focus:outline-none"
>
{languages.map(lang => (
<option key={lang.code} value={lang.code}>
{lang.name}
</option>
))}
</select>
)}
{/* Theme toggle */}
{showThemeToggle && (
<Button
variant="ghost"
size="sm"
onClick={onThemeToggle}
className="text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
>
Cambiar tema
</Button>
)}
{/* Privacy links */}
{showPrivacyLinks && (
<div className="flex items-center gap-4">
{privacyLinks.map(link => renderLink(link))}
</div>
)}
</div>
</div>
{/* Copyright text override */}
{copyrightText && (
<div className="text-center text-sm text-[var(--text-tertiary)] mt-4 pt-4 border-t border-[var(--border-primary)]">
{copyrightText}
</div>
)}
</div>
</footer>
);
});
Footer.displayName = 'Footer';

View File

@@ -0,0 +1,9 @@
export { Footer } from './Footer';
export type {
FooterProps,
FooterRef,
FooterLink,
FooterSection,
SocialLink,
CompanyInfo
} from './Footer';

View File

@@ -0,0 +1,454 @@
import React, { useState, useCallback, forwardRef } from 'react';
import { clsx } from 'clsx';
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 {
Menu,
Search,
Bell,
Sun,
Moon,
Computer,
Settings,
User,
LogOut,
ChevronDown,
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) => {
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',
className
)}
role="banner"
aria-label="Navegación principal"
>
{/* Left section */}
<div className="flex items-center gap-4 flex-1 min-w-0">
{/* Mobile menu button */}
<Button
variant="ghost"
size="sm"
onClick={onMenuClick}
className="lg:hidden p-2"
aria-label="Abrir menú de navegación"
>
<Menu className="h-5 w-5" />
</Button>
{/* Logo */}
<div className="flex items-center gap-3 min-w-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">
PI
</div>
<h1 className={clsx(
'font-semibold text-[var(--text-primary)] transition-opacity duration-300',
'hidden sm:block',
sidebarCollapsed ? 'lg:block' : 'lg:hidden xl:block'
)}>
Panadería IA
</h1>
</>
)}
</div>
{/* Search */}
{showSearch && isAuthenticated && (
<form
onSubmit={handleSearchSubmit}
className="hidden md:flex items-center flex-1 max-w-md mx-4"
>
<div className="relative w-full">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-[var(--text-tertiary)]" />
<input
ref={searchInputRef}
type="text"
value={searchValue}
onChange={handleSearchChange}
onFocus={() => setIsSearchFocused(true)}
onBlur={() => setIsSearchFocused(false)}
placeholder={searchPlaceholder}
className={clsx(
'w-full pl-10 pr-10 py-2 text-sm',
'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)]',
'placeholder:text-[var(--text-tertiary)]'
)}
aria-label="Buscar en la aplicación"
/>
{searchValue && (
<button
type="button"
onClick={clearSearch}
className="absolute right-3 top-1/2 transform -translate-y-1/2 p-1 hover:bg-[var(--bg-tertiary)] rounded-full transition-colors"
aria-label="Limpiar búsqueda"
>
<X className="h-3 w-3 text-[var(--text-tertiary)]" />
</button>
)}
<kbd className="absolute right-3 top-1/2 transform -translate-y-1/2 hidden lg:inline-flex items-center gap-1 text-xs text-[var(--text-tertiary)] font-mono">
K
</kbd>
</div>
</form>
)}
</div>
{/* Right section */}
{isAuthenticated && (
<div className="flex items-center gap-2">
{/* Mobile search */}
{showSearch && (
<Button
variant="ghost"
size="sm"
onClick={focusSearch}
className="md:hidden p-2"
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)}
className="p-2"
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}
className="p-2 relative"
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}
className="flex items-center gap-2 pl-2 pr-3 py-1 h-auto"
aria-label="Menú de usuario"
aria-expanded={isUserMenuOpen}
aria-haspopup="true"
>
<Avatar
src={user.avatar_url}
alt={user.full_name}
fallback={user.full_name}
size="sm"
/>
<span className="hidden sm:block text-sm font-medium text-[var(--text-primary)] truncate max-w-[120px]">
{user.full_name}
</span>
<ChevronDown className="h-4 w-4 text-[var(--text-tertiary)]" />
</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 */}
<div className="px-4 py-2 border-b border-[var(--border-primary)]">
<div className="text-sm font-medium text-[var(--text-primary)] truncate">
{user.full_name}
</div>
<div className="text-xs text-[var(--text-tertiary)] truncate">
{user.email}
</div>
</div>
{/* Menu items */}
<div className="py-1">
<button
onClick={() => {
// TODO: Navigate to profile
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={() => {
// TODO: Navigate to settings
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';

View File

@@ -0,0 +1,2 @@
export { Header } from './Header';
export type { HeaderProps, HeaderRef } from './Header';

View File

@@ -1,243 +0,0 @@
import React, { useState } from 'react';
import { Outlet, Link, useLocation, useNavigate } from 'react-router-dom';
import {
Home,
TrendingUp,
Package,
Settings,
Menu,
X,
LogOut,
User,
Bell,
ChevronDown,
BarChart3,
Building
} from 'lucide-react';
import { useSelector, useDispatch } from 'react-redux';
import { RootState } from '../../store';
import { logout } from '../../store/slices/authSlice';
import { TenantSelector } from '../navigation/TenantSelector';
import { usePermissions } from '../../hooks/usePermissions';
interface LayoutProps {
// No props needed - using React Router
}
interface NavigationItem {
id: string;
label: string;
icon: React.ComponentType<{ className?: string }>;
href: string;
requiresRole?: string[];
}
const Layout: React.FC<LayoutProps> = () => {
const location = useLocation();
const navigate = useNavigate();
const dispatch = useDispatch();
const { user } = useSelector((state: RootState) => state.auth);
const { hasRole } = usePermissions();
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
const [isUserMenuOpen, setIsUserMenuOpen] = useState(false);
const navigation: NavigationItem[] = [
{ id: 'dashboard', label: 'Dashboard', icon: Home, href: '/app/dashboard' },
{ id: 'operations', label: 'Operaciones', icon: Package, href: '/app/operations' },
{
id: 'analytics',
label: 'Analytics',
icon: BarChart3,
href: '/app/analytics',
requiresRole: ['admin', 'manager']
},
{ id: 'settings', label: 'Configuración', icon: Settings, href: '/app/settings' },
];
// Filter navigation based on user role
const filteredNavigation = navigation.filter(item => {
if (!item.requiresRole) return true;
return item.requiresRole.some(role => hasRole(role));
});
const handleLogout = () => {
if (window.confirm('¿Estás seguro de que quieres cerrar sesión?')) {
dispatch(logout());
localStorage.removeItem('auth_token');
localStorage.removeItem('user_data');
localStorage.removeItem('selectedTenantId');
navigate('/');
}
};
const isActiveRoute = (href: string): boolean => {
if (href === '/app/dashboard') {
return location.pathname === '/app/dashboard' || location.pathname === '/app';
}
return location.pathname.startsWith(href);
};
return (
<div className="min-h-screen bg-gray-50">
{/* Top Navigation Bar */}
<nav className="bg-white shadow-soft border-b border-gray-200">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between h-16">
{/* Left side - Logo and Navigation */}
<div className="flex items-center">
{/* Mobile menu button */}
<button
type="button"
className="md:hidden p-2 rounded-md text-gray-600 hover:text-gray-900 hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-primary-500"
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
>
{isMobileMenuOpen ? (
<X className="h-6 w-6" />
) : (
<Menu className="h-6 w-6" />
)}
</button>
{/* Logo */}
<div className="flex items-center ml-4 md:ml-0">
<div className="h-8 w-8 bg-primary-500 rounded-lg flex items-center justify-center mr-3">
<span className="text-white text-sm font-bold">🥖</span>
</div>
<span className="text-xl font-bold text-gray-900">PanIA</span>
</div>
{/* Desktop Navigation */}
<div className="hidden md:flex md:ml-10 md:space-x-1">
{filteredNavigation.map((item) => {
const Icon = item.icon;
const isActive = isActiveRoute(item.href);
return (
<Link
key={item.id}
to={item.href}
className={`
flex items-center px-3 py-2 rounded-lg text-sm font-medium transition-all duration-200
${isActive
? 'bg-primary-100 text-primary-700 shadow-soft'
: 'text-gray-600 hover:text-gray-900 hover:bg-gray-100'
}
`}
onClick={() => setIsMobileMenuOpen(false)}
>
<Icon className="h-4 w-4 mr-2" />
{item.label}
</Link>
);
})}
</div>
</div>
{/* Right side - Tenant Selector, Notifications and User Menu */}
<div className="flex items-center space-x-4">
{/* Tenant Selector */}
<TenantSelector />
{/* Notifications */}
<button className="p-2 text-gray-600 hover:text-gray-900 hover:bg-gray-100 rounded-lg transition-colors relative">
<Bell className="h-5 w-5" />
<span className="absolute top-0 right-0 h-2 w-2 bg-red-500 rounded-full"></span>
</button>
{/* User Menu */}
<div className="relative">
<button
onClick={() => setIsUserMenuOpen(!isUserMenuOpen)}
className="flex items-center text-sm bg-white rounded-lg p-2 hover:bg-gray-50 transition-colors focus:outline-none focus:ring-2 focus:ring-primary-500"
>
<div className="h-8 w-8 bg-primary-500 rounded-full flex items-center justify-center mr-2">
<User className="h-4 w-4 text-white" />
</div>
<span className="hidden md:block text-gray-700 font-medium">
{user.fullName?.split(' ')[0] || 'Usuario'}
</span>
<ChevronDown className="hidden md:block h-4 w-4 ml-1 text-gray-500" />
</button>
{/* User Dropdown */}
{isUserMenuOpen && (
<div className="absolute right-0 mt-2 w-48 bg-white rounded-xl shadow-strong border border-gray-200 py-1 z-50">
<div className="px-4 py-3 border-b border-gray-100">
<p className="text-sm font-medium text-gray-900">{user.fullName}</p>
<p className="text-sm text-gray-500">{user.email}</p>
</div>
<Link
to="/app/settings"
onClick={() => setIsUserMenuOpen(false)}
className="w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-50 flex items-center"
>
<Settings className="h-4 w-4 mr-2" />
Configuración
</Link>
<button
onClick={() => {
handleLogout();
setIsUserMenuOpen(false);
}}
className="w-full text-left px-4 py-2 text-sm text-red-600 hover:bg-red-50 flex items-center"
>
<LogOut className="h-4 w-4 mr-2" />
Cerrar sesión
</button>
</div>
)}
</div>
</div>
</div>
</div>
{/* Mobile Navigation Menu */}
{isMobileMenuOpen && (
<div className="md:hidden border-t border-gray-200 bg-white">
<div className="px-2 pt-2 pb-3 space-y-1">
{filteredNavigation.map((item) => {
const Icon = item.icon;
const isActive = isActiveRoute(item.href);
return (
<Link
key={item.id}
to={item.href}
onClick={() => setIsMobileMenuOpen(false)}
className={`
w-full flex items-center px-3 py-2 rounded-lg text-base font-medium transition-all duration-200
${isActive
? 'bg-primary-100 text-primary-700'
: 'text-gray-600 hover:text-gray-900 hover:bg-gray-100'
}
`}
>
<Icon className="h-5 w-5 mr-3" />
{item.label}
</Link>
);
})}
</div>
</div>
)}
</nav>
{/* Main Content */}
<main className="flex-1">
<Outlet />
</main>
{/* Click outside handler for dropdowns */}
{(isUserMenuOpen || isMobileMenuOpen) && (
<div
className="fixed inset-0 z-40"
onClick={() => {
setIsUserMenuOpen(false);
setIsMobileMenuOpen(false);
}}
/>
)}
</div>
);
};
export default Layout;

View File

@@ -1,109 +0,0 @@
import React from 'react';
import { Outlet } from 'react-router-dom';
import { SecondaryNavigation } from '../navigation/SecondaryNavigation';
import { Breadcrumbs } from '../navigation/Breadcrumbs';
import { useBakeryType } from '../../hooks/useBakeryType';
const OperationsLayout: React.FC = () => {
const { bakeryType } = useBakeryType();
// Define navigation items based on bakery type
const getNavigationItems = () => {
const baseItems = [
{
id: 'production',
label: bakeryType === 'individual' ? 'Producción' : 'Distribución',
href: '/app/operations/production',
icon: 'ChefHat',
children: bakeryType === 'individual' ? [
{ id: 'schedule', label: 'Programación', href: '/app/operations/production/schedule' },
{ id: 'active-batches', label: 'Lotes Activos', href: '/app/operations/production/active-batches' },
{ id: 'equipment', label: 'Equipamiento', href: '/app/operations/production/equipment' }
] : [
{ id: 'schedule', label: 'Distribución', href: '/app/operations/production/schedule' },
{ id: 'active-batches', label: 'Asignaciones', href: '/app/operations/production/active-batches' },
{ id: 'equipment', label: 'Logística', href: '/app/operations/production/equipment' }
]
},
{
id: 'orders',
label: 'Pedidos',
href: '/app/operations/orders',
icon: 'Package',
children: [
{ id: 'incoming', label: bakeryType === 'individual' ? 'Entrantes' : 'Puntos de Venta', href: '/app/operations/orders/incoming' },
{ id: 'in-progress', label: 'En Proceso', href: '/app/operations/orders/in-progress' },
{ id: 'supplier-orders', label: bakeryType === 'individual' ? 'Proveedores' : 'Productos', href: '/app/operations/orders/supplier-orders' }
]
},
{
id: 'inventory',
label: 'Inventario',
href: '/app/operations/inventory',
icon: 'Warehouse',
children: [
{ id: 'stock-levels', label: bakeryType === 'individual' ? 'Ingredientes' : 'Productos', href: '/app/operations/inventory/stock-levels' },
{ id: 'movements', label: bakeryType === 'individual' ? 'Uso' : 'Distribución', href: '/app/operations/inventory/movements' },
{ id: 'alerts', label: bakeryType === 'individual' ? 'Caducidad' : 'Retrasos', href: '/app/operations/inventory/alerts' }
]
},
{
id: 'sales',
label: 'Ventas',
href: '/app/operations/sales',
icon: 'ShoppingCart',
children: [
{ id: 'daily-sales', label: 'Ventas Diarias', href: '/app/operations/sales/daily-sales' },
{ id: 'customer-orders', label: bakeryType === 'individual' ? 'Pedidos Cliente' : 'Pedidos Punto', href: '/app/operations/sales/customer-orders' }
]
},
{
id: 'pos',
label: bakeryType === 'individual' ? 'TPV' : 'Sistema TPV',
href: '/app/operations/pos',
icon: 'CreditCard',
children: [
{ id: 'integrations', label: 'Integraciones', href: '/app/operations/pos/integrations' },
{ id: 'sync-status', label: 'Estado Sincronización', href: '/app/operations/pos/sync-status' },
{ id: 'transactions', label: 'Transacciones', href: '/app/operations/pos/transactions' }
]
}
];
// Add recipes for individual bakeries, hide for central
if (bakeryType === 'individual') {
baseItems.push({
id: 'recipes',
label: 'Recetas',
href: '/app/operations/recipes',
icon: 'BookOpen',
children: [
{ id: 'active-recipes', label: 'Recetas Activas', href: '/app/operations/recipes/active-recipes' },
{ id: 'development', label: 'Desarrollo', href: '/app/operations/recipes/development' },
{ id: 'costing', label: 'Costeo', href: '/app/operations/recipes/costing' }
]
});
}
return baseItems;
};
return (
<div className="flex flex-col h-full">
<div className="bg-white border-b border-gray-200">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<Breadcrumbs />
<SecondaryNavigation items={getNavigationItems()} />
</div>
</div>
<div className="flex-1 bg-gray-50">
<div className="max-w-7xl mx-auto">
<Outlet />
</div>
</div>
</div>
);
};
export default OperationsLayout;

View File

@@ -0,0 +1,441 @@
import React, { forwardRef } from 'react';
import { clsx } from 'clsx';
import { useNavigate, useLocation } from 'react-router-dom';
import { Button } from '../../ui';
import { Badge } from '../../ui';
import { Breadcrumbs } from '../Breadcrumbs';
import {
ArrowLeft,
RefreshCw,
Download,
Upload,
Settings,
MoreVertical,
Calendar,
Clock,
User
} from 'lucide-react';
export interface ActionButton {
id: string;
label: string;
icon?: React.ComponentType<{ className?: string }>;
onClick: () => void;
variant?: 'primary' | 'secondary' | 'outline' | 'ghost';
size?: 'sm' | 'md' | 'lg';
disabled?: boolean;
loading?: boolean;
badge?: {
text: string;
variant?: 'default' | 'primary' | 'success' | 'warning' | 'error';
};
tooltip?: string;
external?: boolean;
href?: string;
}
export interface MetadataItem {
id: string;
label: string;
value: string | React.ReactNode;
icon?: React.ComponentType<{ className?: string }>;
tooltip?: string;
copyable?: boolean;
}
export interface PageHeaderProps {
className?: string;
/**
* Page title
*/
title: string;
/**
* Optional subtitle
*/
subtitle?: string;
/**
* Page description
*/
description?: string;
/**
* Action buttons
*/
actions?: ActionButton[];
/**
* Metadata items (last updated, created by, etc.)
*/
metadata?: MetadataItem[];
/**
* Show back button
*/
showBackButton?: boolean;
/**
* Custom back button handler
*/
onBack?: () => void;
/**
* Show breadcrumbs
*/
showBreadcrumbs?: boolean;
/**
* Custom breadcrumb props
*/
breadcrumbProps?: any;
/**
* Status badge
*/
status?: {
text: string;
variant?: 'default' | 'primary' | 'success' | 'warning' | 'error';
};
/**
* Page icon
*/
icon?: React.ComponentType<{ className?: string }>;
/**
* Loading state
*/
loading?: boolean;
/**
* Error state
*/
error?: string;
/**
* Custom refresh handler
*/
onRefresh?: () => void;
/**
* Show refresh button
*/
showRefreshButton?: boolean;
/**
* Sticky header
*/
sticky?: boolean;
/**
* Compact mode (smaller padding and text)
*/
compact?: boolean;
/**
* Center align content
*/
centered?: boolean;
/**
* Custom content in header
*/
children?: React.ReactNode;
}
export interface PageHeaderRef {
scrollIntoView: () => void;
refresh: () => void;
}
/**
* PageHeader - Page-level header with title, actions, and metadata
*
* Features:
* - Page title with optional subtitle and description
* - Action buttons area with various button types and states
* - Metadata display (last updated, created by, etc.) with icons
* - Optional back button with navigation handling
* - Integrated breadcrumb navigation
* - Status badges and page icons
* - Loading and error states
* - Responsive layout with mobile adaptations
* - Sticky positioning support
* - Keyboard shortcuts and accessibility
*/
export const PageHeader = forwardRef<PageHeaderRef, PageHeaderProps>(({
className,
title,
subtitle,
description,
actions = [],
metadata = [],
showBackButton = false,
onBack,
showBreadcrumbs = true,
breadcrumbProps,
status,
icon: PageIcon,
loading = false,
error,
onRefresh,
showRefreshButton = false,
sticky = false,
compact = false,
centered = false,
children,
}, ref) => {
const navigate = useNavigate();
const location = useLocation();
const headerRef = React.useRef<HTMLDivElement>(null);
// Handle back navigation
const handleBack = () => {
if (onBack) {
onBack();
} else {
navigate(-1);
}
};
// Handle refresh
const handleRefresh = () => {
if (onRefresh) {
onRefresh();
} else {
window.location.reload();
}
};
// Scroll into view
const scrollIntoView = React.useCallback(() => {
headerRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
}, []);
// Expose ref methods
React.useImperativeHandle(ref, () => ({
scrollIntoView,
refresh: handleRefresh,
}), [scrollIntoView, handleRefresh]);
// Render action button
const renderAction = (action: ActionButton) => {
const ActionIcon = action.icon;
const buttonContent = (
<>
{ActionIcon && <ActionIcon className="w-4 h-4" />}
<span className={clsx(
compact ? 'text-sm' : 'text-sm',
ActionIcon && 'ml-2'
)}>
{action.label}
</span>
{action.badge && (
<Badge
variant={action.badge.variant || 'default'}
size="sm"
className="ml-2"
>
{action.badge.text}
</Badge>
)}
</>
);
if (action.href) {
return (
<a
key={action.id}
href={action.href}
target={action.external ? '_blank' : undefined}
rel={action.external ? 'noopener noreferrer' : undefined}
className={clsx(
'inline-flex items-center px-4 py-2 rounded-lg font-medium transition-colors duration-200',
'focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)]/20',
action.disabled && 'pointer-events-none opacity-50'
)}
title={action.tooltip}
>
{buttonContent}
</a>
);
}
return (
<Button
key={action.id}
variant={action.variant || 'outline'}
size={action.size || 'md'}
onClick={action.onClick}
disabled={action.disabled || loading}
loading={action.loading}
title={action.tooltip}
className={compact ? 'px-3 py-1.5' : undefined}
>
{buttonContent}
</Button>
);
};
// Render metadata item
const renderMetadata = (item: MetadataItem) => {
const ItemIcon = item.icon;
const handleCopy = async () => {
if (item.copyable && typeof item.value === 'string') {
try {
await navigator.clipboard.writeText(item.value);
// TODO: Show toast notification
} catch (error) {
console.error('Failed to copy:', error);
}
}
};
return (
<div
key={item.id}
className="flex items-center gap-2 text-sm text-[var(--text-secondary)]"
title={item.tooltip}
>
{ItemIcon && <ItemIcon className="w-4 h-4 text-[var(--text-tertiary)]" />}
<span className="font-medium">{item.label}:</span>
{item.copyable && typeof item.value === 'string' ? (
<button
onClick={handleCopy}
className="hover:text-[var(--text-primary)] transition-colors duration-200 cursor-pointer"
>
{item.value}
</button>
) : (
<span>{item.value}</span>
)}
</div>
);
};
return (
<div
ref={headerRef}
className={clsx(
'bg-[var(--bg-primary)] border-b border-[var(--border-primary)]',
sticky && 'sticky top-[var(--header-height)] z-[var(--z-sticky)]',
compact ? 'py-4' : 'py-6',
className
)}
>
<div className={clsx(
'max-w-full px-4 lg:px-6',
centered && 'mx-auto max-w-4xl'
)}>
{/* Breadcrumbs */}
{showBreadcrumbs && (
<div className="mb-4">
<Breadcrumbs {...breadcrumbProps} />
</div>
)}
{/* Main header content */}
<div className="flex flex-col lg:flex-row lg:items-start lg:justify-between gap-4">
{/* Left section - Title and metadata */}
<div className="flex-1 min-w-0">
{/* Title row */}
<div className="flex items-center gap-3 mb-2">
{showBackButton && (
<Button
variant="ghost"
size="sm"
onClick={handleBack}
className="p-2 -ml-2"
aria-label="Volver"
>
<ArrowLeft className="w-4 h-4" />
</Button>
)}
{PageIcon && (
<PageIcon className={clsx(
'text-[var(--text-tertiary)]',
compact ? 'w-5 h-5' : 'w-6 h-6'
)} />
)}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-3 flex-wrap">
<h1 className={clsx(
'font-bold text-[var(--text-primary)] truncate',
compact ? 'text-xl' : 'text-2xl lg:text-3xl'
)}>
{title}
</h1>
{status && (
<Badge
variant={status.variant || 'default'}
size={compact ? 'sm' : 'md'}
>
{status.text}
</Badge>
)}
{loading && (
<RefreshCw className="w-4 h-4 text-[var(--text-tertiary)] animate-spin" />
)}
</div>
{subtitle && (
<p className={clsx(
'text-[var(--text-secondary)] mt-1',
compact ? 'text-sm' : 'text-lg'
)}>
{subtitle}
</p>
)}
</div>
{showRefreshButton && (
<Button
variant="ghost"
size="sm"
onClick={handleRefresh}
disabled={loading}
className="p-2"
aria-label="Actualizar"
>
<RefreshCw className={clsx(
'w-4 h-4',
loading && 'animate-spin'
)} />
</Button>
)}
</div>
{/* Description */}
{description && (
<p className={clsx(
'text-[var(--text-secondary)] mb-3',
compact ? 'text-sm' : 'text-base'
)}>
{description}
</p>
)}
{/* Error */}
{error && (
<div className="mb-3 p-3 bg-[var(--color-error)]/10 border border-[var(--color-error)]/20 rounded-lg">
<p className="text-sm text-[var(--color-error)]">{error}</p>
</div>
)}
{/* Metadata */}
{metadata.length > 0 && (
<div className="flex flex-wrap items-center gap-x-6 gap-y-2">
{metadata.map(renderMetadata)}
</div>
)}
{/* Custom children */}
{children && (
<div className="mt-4">
{children}
</div>
)}
</div>
{/* Right section - Actions */}
{actions.length > 0 && (
<div className="flex items-center gap-2 flex-wrap lg:flex-nowrap">
{actions.map(renderAction)}
</div>
)}
</div>
</div>
</div>
);
});
PageHeader.displayName = 'PageHeader';

View File

@@ -0,0 +1,2 @@
export { PageHeader } from './PageHeader';
export type { PageHeaderProps, PageHeaderRef, ActionButton, MetadataItem } from './PageHeader';

View File

@@ -0,0 +1,245 @@
import React, { forwardRef } from 'react';
import { clsx } from 'clsx';
import { Link } from 'react-router-dom';
import { Button, ThemeToggle } from '../../ui';
export interface PublicHeaderProps {
className?: string;
/**
* Custom logo component
*/
logo?: React.ReactNode;
/**
* Show theme toggle
*/
showThemeToggle?: boolean;
/**
* Show authentication buttons (login/register)
*/
showAuthButtons?: boolean;
/**
* Custom navigation items
*/
navigationItems?: Array<{
id: string;
label: string;
href: string;
external?: boolean;
}>;
/**
* Header variant
*/
variant?: 'default' | 'transparent' | 'minimal';
}
export interface PublicHeaderRef {
scrollIntoView: () => void;
}
/**
* PublicHeader - Header component for public pages (landing, login, register)
*
* Features:
* - Clean, minimal design suitable for public pages
* - Integrated theme toggle for consistent theming
* - Authentication buttons (login/register)
* - Optional custom navigation items
* - Multiple visual variants
* - Responsive design with mobile optimization
* - Accessible navigation structure
*/
export const PublicHeader = forwardRef<PublicHeaderRef, PublicHeaderProps>(({
className,
logo,
showThemeToggle = true,
showAuthButtons = true,
navigationItems = [],
variant = 'default',
}, ref) => {
const headerRef = React.useRef<HTMLDivElement>(null);
// Default navigation items
const defaultNavItems = [
// { id: 'features', label: 'Características', href: '#features', external: false },
// { id: 'pricing', label: 'Precios', href: '#pricing', external: false },
// { id: 'contact', label: 'Contacto', href: '#contact', external: false },
];
const navItems = navigationItems.length > 0 ? navigationItems : defaultNavItems;
// Scroll into view
const scrollIntoView = React.useCallback(() => {
headerRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
}, []);
// Expose ref methods
React.useImperativeHandle(ref, () => ({
scrollIntoView,
}), [scrollIntoView]);
// Render navigation link
const renderNavLink = (item: typeof navItems[0]) => {
const linkContent = (
<span className="text-sm font-medium text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors duration-200">
{item.label}
</span>
);
if (item.external || item.href.startsWith('http') || item.href.startsWith('#')) {
return (
<a
key={item.id}
href={item.href}
target={item.external ? '_blank' : undefined}
rel={item.external ? 'noopener noreferrer' : undefined}
className="hover:underline focus:outline-none focus:underline"
>
{linkContent}
</a>
);
}
return (
<Link
key={item.id}
to={item.href}
className="hover:underline focus:outline-none focus:underline"
>
{linkContent}
</Link>
);
};
return (
<header
ref={headerRef}
className={clsx(
'w-full',
// Base styles
variant === 'default' && 'bg-[var(--bg-primary)] border-b border-[var(--border-primary)] shadow-sm',
variant === 'transparent' && 'bg-transparent',
variant === 'minimal' && 'bg-[var(--bg-primary)]',
className
)}
role="banner"
aria-label="Navegación principal"
>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between items-center py-4 lg:py-6">
{/* Logo and brand */}
<div className="flex items-center gap-3 min-w-0">
<Link to="/" className="flex items-center gap-3 hover:opacity-80 transition-opacity">
{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">
PI
</div>
<h1 className="text-xl lg:text-2xl font-bold text-[var(--text-primary)]">
Panadería IA
</h1>
</>
)}
</Link>
</div>
{/* Desktop navigation */}
<nav className="hidden md:flex items-center space-x-8" role="navigation">
{navItems.map(renderNavLink)}
</nav>
{/* Right side actions */}
<div className="flex items-center gap-3">
{/* Theme toggle */}
{showThemeToggle && (
<ThemeToggle
variant="button"
size="md"
className="hidden sm:flex"
/>
)}
{/* Authentication buttons */}
{showAuthButtons && (
<div className="flex items-center gap-2">
<Link to="/login">
<Button
variant="ghost"
size="sm"
className="hidden sm:inline-flex"
>
Iniciar Sesión
</Button>
</Link>
<Link to="/register">
<Button
size="sm"
className="bg-[var(--color-primary)] hover:bg-[var(--color-primary-dark)] text-white"
>
<span className="hidden sm:inline">Comenzar Gratis</span>
<span className="sm:hidden">Registro</span>
</Button>
</Link>
</div>
)}
{/* Mobile theme toggle */}
{showThemeToggle && (
<ThemeToggle
variant="button"
size="sm"
className="sm:hidden"
/>
)}
{/* Mobile menu button */}
<div className="md:hidden">
<Button
variant="ghost"
size="sm"
className="p-2"
aria-label="Abrir menú de navegación"
onClick={() => {
// TODO: Implement mobile menu
console.log('Mobile menu toggle');
}}
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
</svg>
</Button>
</div>
</div>
</div>
{/* Mobile navigation */}
<nav className="md:hidden pb-4" role="navigation">
<div className="flex flex-col space-y-2">
{navItems.map(item => (
<div key={item.id} className="py-2 border-b border-[var(--border-primary)] last:border-b-0">
{renderNavLink(item)}
</div>
))}
{/* Mobile auth buttons */}
{showAuthButtons && (
<div className="flex flex-col gap-2 pt-4 sm:hidden">
<Link to="/login">
<Button variant="ghost" size="sm" className="w-full">
Iniciar Sesión
</Button>
</Link>
<Link to="/register">
<Button size="sm" className="w-full bg-[var(--color-primary)] hover:bg-[var(--color-primary-dark)] text-white">
Comenzar Gratis
</Button>
</Link>
</div>
)}
</div>
</nav>
</div>
</header>
);
});
PublicHeader.displayName = 'PublicHeader';

View File

@@ -0,0 +1 @@
export { PublicHeader, type PublicHeaderProps, type PublicHeaderRef } from './PublicHeader';

View File

@@ -0,0 +1,200 @@
import React, { forwardRef } from 'react';
import { clsx } from 'clsx';
import { useTheme } from '../../../contexts/ThemeContext';
import { PublicHeader } from '../PublicHeader';
import { Footer } from '../Footer';
export interface PublicLayoutProps {
children: React.ReactNode;
className?: string;
/**
* Control header visibility
*/
showHeader?: boolean;
/**
* Control footer visibility
*/
showFooter?: boolean;
/**
* Header configuration
*/
headerProps?: {
showThemeToggle?: boolean;
showAuthButtons?: boolean;
navigationItems?: Array<{
id: string;
label: string;
href: string;
external?: boolean;
}>;
variant?: 'default' | 'transparent' | 'minimal';
logo?: React.ReactNode;
};
/**
* Footer configuration
*/
footerProps?: {
compact?: boolean;
showPrivacyLinks?: boolean;
showThemeToggle?: boolean;
showVersion?: boolean;
};
/**
* Layout variant
*/
variant?: 'default' | 'centered' | 'full-width';
/**
* Add minimum height to content area
*/
minHeight?: 'screen' | 'content' | 'none';
/**
* Content container max width
*/
maxWidth?: 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '7xl' | 'full' | 'none';
/**
* Content padding
*/
contentPadding?: 'none' | 'sm' | 'md' | 'lg';
}
export interface PublicLayoutRef {
scrollToTop: () => void;
scrollToBottom: () => void;
}
/**
* PublicLayout - Layout wrapper for public pages (landing, login, register)
*
* Features:
* - Modular header and footer with configurable props
* - Multiple layout variants for different page types
* - Responsive design with flexible content areas
* - Theme integration and consistent styling
* - Accessible structure with proper landmarks
* - Scroll utilities for navigation
*/
export const PublicLayout = forwardRef<PublicLayoutRef, PublicLayoutProps>(({
children,
className,
showHeader = true,
showFooter = true,
headerProps = {},
footerProps = {},
variant = 'default',
minHeight = 'screen',
maxWidth = '7xl',
contentPadding = 'md',
}, ref) => {
const { resolvedTheme } = useTheme();
const layoutRef = React.useRef<HTMLDivElement>(null);
// Scroll utilities
const scrollToTop = React.useCallback(() => {
layoutRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
}, []);
const scrollToBottom = React.useCallback(() => {
layoutRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' });
}, []);
// Expose ref methods
React.useImperativeHandle(ref, () => ({
scrollToTop,
scrollToBottom,
}), [scrollToTop, scrollToBottom]);
// Max width classes
const maxWidthClasses = {
sm: 'max-w-sm',
md: 'max-w-md',
lg: 'max-w-lg',
xl: 'max-w-xl',
'2xl': 'max-w-2xl',
'7xl': 'max-w-7xl',
full: 'max-w-full',
none: '',
};
// Content padding classes
const paddingClasses = {
none: '',
sm: 'px-4 py-6',
md: 'px-4 py-8 sm:px-6 lg:px-8',
lg: 'px-4 py-12 sm:px-6 lg:px-8 lg:py-16',
};
// Min height classes
const minHeightClasses = {
screen: 'min-h-screen',
content: 'min-h-[50vh]',
none: '',
};
return (
<div
ref={layoutRef}
className={clsx(
'flex flex-col',
minHeightClasses[minHeight],
'bg-[var(--bg-primary)]',
resolvedTheme,
className
)}
data-testid="public-layout"
>
{/* Header */}
{showHeader && (
<PublicHeader
showThemeToggle={true}
showAuthButtons={true}
variant="default"
{...headerProps}
/>
)}
{/* Main content */}
<main
role="main"
className={clsx(
'flex-1 flex flex-col',
variant === 'centered' && 'items-center justify-center',
variant === 'full-width' && 'w-full',
variant === 'default' && 'w-full'
)}
aria-label="Contenido principal"
>
{variant === 'centered' ? (
// Centered variant - useful for login/register pages
<div className={clsx(
'w-full mx-auto flex flex-col',
maxWidthClasses[maxWidth],
paddingClasses[contentPadding]
)}>
{children}
</div>
) : (
// Default and full-width variants
<div className={clsx(
variant === 'default' && maxWidthClasses[maxWidth] && `mx-auto w-full ${maxWidthClasses[maxWidth]}`,
paddingClasses[contentPadding]
)}>
{children}
</div>
)}
</main>
{/* Footer */}
{showFooter && (
<Footer
compact={false}
showPrivacyLinks={true}
showThemeToggle={false} // Header already has theme toggle
showVersion={true}
{...footerProps}
/>
)}
</div>
);
});
PublicLayout.displayName = 'PublicLayout';

View File

@@ -0,0 +1 @@
export { PublicLayout, type PublicLayoutProps, type PublicLayoutRef } from './PublicLayout';

View File

@@ -1,65 +0,0 @@
import React from 'react';
import { Outlet } from 'react-router-dom';
import { SecondaryNavigation } from '../navigation/SecondaryNavigation';
import { Breadcrumbs } from '../navigation/Breadcrumbs';
import { usePermissions } from '../../hooks/usePermissions';
const SettingsLayout: React.FC = () => {
const { hasRole } = usePermissions();
const getNavigationItems = () => {
const baseItems = [
{
id: 'general',
label: 'General',
href: '/app/settings/general',
icon: 'Settings'
},
{
id: 'account',
label: 'Cuenta',
href: '/app/settings/account',
icon: 'User'
}
];
// Add admin-only items
if (hasRole('admin')) {
baseItems.unshift(
{
id: 'bakeries',
label: 'Panaderías',
href: '/app/settings/bakeries',
icon: 'Building'
},
{
id: 'users',
label: 'Usuarios',
href: '/app/settings/users',
icon: 'Users'
}
);
}
return baseItems;
};
return (
<div className="flex flex-col h-full">
<div className="bg-white border-b border-gray-200">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<Breadcrumbs />
<SecondaryNavigation items={getNavigationItems()} />
</div>
</div>
<div className="flex-1 bg-gray-50">
<div className="max-w-7xl mx-auto">
<Outlet />
</div>
</div>
</div>
);
};
export default SettingsLayout;

View File

@@ -0,0 +1,487 @@
import React, { useState, useCallback, forwardRef } from 'react';
import { clsx } from 'clsx';
import { useLocation, useNavigate } from 'react-router-dom';
import { useAuthUser, useIsAuthenticated } from '../../../stores';
import { getNavigationRoutes, canAccessRoute, ROUTES } from '../../../router/routes.config';
import { Button } from '../../ui';
import { Badge } from '../../ui';
import { Tooltip } from '../../ui';
import {
LayoutDashboard,
Package,
Factory,
BarChart3,
Brain,
ShoppingCart,
Truck,
Zap,
Database,
GraduationCap,
Bell,
Settings,
ChevronLeft,
ChevronRight,
ChevronDown,
Dot,
Menu
} 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;
/**
* 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,
training: GraduationCap,
notifications: Bell,
settings: Settings,
};
/**
* 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,
customItems,
showCollapseButton = true,
showFooter = true,
}, ref) => {
const location = useLocation();
const navigate = useNavigate();
const user = useAuthUser();
const isAuthenticated = useIsAuthenticated();
const [expandedItems, setExpandedItems] = useState<Set<string>>(new Set());
const sidebarRef = React.useRef<HTMLDivElement>(null);
// Get navigation routes from config
const navigationRoutes = getNavigationRoutes();
// Convert route config to navigation items
const convertRoutesToItems = (routes: typeof navigationRoutes): 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,
}));
};
const navigationItems = customItems || convertRoutesToItems(navigationRoutes);
// Filter items based on user permissions
const filterItemsByPermissions = (items: NavigationItem[]): NavigationItem[] => {
if (!isAuthenticated || !user) return [];
return items.filter(item => {
const userRoles = user.role ? [user.role] : [];
const userPermissions: string[] = user?.permissions || [];
const hasAccess = !item.requiredPermissions && !item.requiredRoles ||
canAccessRoute(
{
path: item.path,
requiredRoles: item.requiredRoles,
requiredPermissions: item.requiredPermissions
} as any,
isAuthenticated,
userRoles,
userPermissions
);
if (hasAccess && item.children) {
item.children = filterItemsByPermissions(item.children);
}
return hasAccess;
});
};
const visibleItems = filterItemsByPermissions(navigationItems);
// 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]);
// 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
React.useEffect(() => {
const findParentPaths = (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;
}
}
}
return [];
};
const parentPaths = findParentPaths(visibleItems, location.pathname);
if (parentPaths.length > 0) {
setExpandedItems(prev => new Set([...prev, ...parentPaths]));
}
}, [location.pathname, visibleItems]);
// Expose ref methods
React.useImperativeHandle(ref, () => ({
scrollToItem,
expandItem,
collapseItem,
}), [scrollToItem, expandItem, collapseItem]);
// Handle keyboard navigation
React.useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape' && isOpen && onClose) {
onClose();
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [isOpen, onClose]);
// 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;
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',
)}
>
{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)]'
: 'text-[var(--text-tertiary)] group-hover:text-[var(--text-primary)]'
)}
/>
)}
{!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 = (
<button
onClick={() => handleItemClick(item)}
disabled={item.disabled}
data-path={item.path}
className={clsx(
'w-full p-3 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',
isCollapsed && !hasChildren && 'flex justify-center p-3'
)}
aria-expanded={hasChildren ? isExpanded : undefined}
aria-current={isActive ? 'page' : undefined}
title={isCollapsed ? item.label : undefined}
>
{itemContent}
</button>
);
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',
isCollapsed ? 'w-16' : 'w-[var(--sidebar-width)]',
className
)}
aria-label="Navegación principal"
>
{/* Navigation */}
<nav className="flex-1 p-4 overflow-y-auto">
<ul className="space-y-2">
{visibleItems.map(item => renderItem(item))}
</ul>
</nav>
{/* Collapse button */}
{showCollapseButton && (
<div className="p-4 border-t border-[var(--border-primary)]">
<Button
variant="ghost"
size="sm"
onClick={() => {
// This should be handled by parent component
console.log('Toggle collapse');
}}
className="w-full flex items-center justify-center"
aria-label={isCollapsed ? 'Expandir sidebar' : 'Contraer sidebar'}
>
{isCollapsed ? (
<ChevronRight className="w-4 h-4" />
) : (
<>
<ChevronLeft className="w-4 h-4 mr-2" />
<span className="text-sm">Contraer</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 left-0 top-[var(--header-height)] bottom-0 w-[var(--sidebar-width)]',
'bg-[var(--bg-primary)] border-r border-[var(--border-primary)]',
'transition-transform duration-300 ease-in-out z-[var(--z-fixed)]',
'lg:hidden flex flex-col',
isOpen ? 'translate-x-0' : '-translate-x-full'
)}
aria-label="Navegación principal"
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>
<Button
variant="ghost"
size="sm"
onClick={onClose}
className="p-2"
aria-label="Cerrar navegación"
>
<Menu className="w-4 h-4" />
</Button>
</div>
{/* Navigation */}
<nav className="flex-1 p-4 overflow-y-auto">
<ul className="space-y-2">
{visibleItems.map(item => renderItem(item))}
</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
</div>
</div>
)}
</aside>
</>
);
});
Sidebar.displayName = 'Sidebar';

View File

@@ -0,0 +1,2 @@
export { Sidebar } from './Sidebar';
export type { SidebarProps, SidebarRef, NavigationItem } from './Sidebar';

View File

@@ -0,0 +1,19 @@
// Layout Components
export { AppShell } from './AppShell';
export { Header } from './Header';
export { Sidebar } from './Sidebar';
export { Breadcrumbs } from './Breadcrumbs';
export { PageHeader } from './PageHeader';
export { Footer } from './Footer';
export { PublicHeader } from './PublicHeader';
export { PublicLayout } from './PublicLayout';
// Export types
export type { AppShellProps } from './AppShell';
export type { HeaderProps } from './Header';
export type { SidebarProps, NavigationItem } from './Sidebar';
export type { BreadcrumbsProps, BreadcrumbItem } from './Breadcrumbs';
export type { PageHeaderProps } from './PageHeader';
export type { FooterProps } from './Footer';
export type { PublicHeaderProps, PublicHeaderRef } from './PublicHeader';
export type { PublicLayoutProps, PublicLayoutRef } from './PublicLayout';