ADD new frontend
This commit is contained in:
@@ -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;
|
||||
261
frontend/src/components/layout/AppShell/AppShell.tsx
Normal file
261
frontend/src/components/layout/AppShell/AppShell.tsx
Normal 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';
|
||||
2
frontend/src/components/layout/AppShell/index.ts
Normal file
2
frontend/src/components/layout/AppShell/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { AppShell } from './AppShell';
|
||||
export type { AppShellProps, AppShellRef } from './AppShell';
|
||||
@@ -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;
|
||||
337
frontend/src/components/layout/Breadcrumbs/Breadcrumbs.tsx
Normal file
337
frontend/src/components/layout/Breadcrumbs/Breadcrumbs.tsx
Normal 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';
|
||||
2
frontend/src/components/layout/Breadcrumbs/index.ts
Normal file
2
frontend/src/components/layout/Breadcrumbs/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { Breadcrumbs } from './Breadcrumbs';
|
||||
export type { BreadcrumbsProps, BreadcrumbsRef, BreadcrumbItem } from './Breadcrumbs';
|
||||
431
frontend/src/components/layout/Footer/Footer.tsx
Normal file
431
frontend/src/components/layout/Footer/Footer.tsx
Normal 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';
|
||||
481
frontend/src/components/layout/Footer/Footer.tsx.backup
Normal file
481
frontend/src/components/layout/Footer/Footer.tsx.backup
Normal 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';
|
||||
9
frontend/src/components/layout/Footer/index.ts
Normal file
9
frontend/src/components/layout/Footer/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export { Footer } from './Footer';
|
||||
export type {
|
||||
FooterProps,
|
||||
FooterRef,
|
||||
FooterLink,
|
||||
FooterSection,
|
||||
SocialLink,
|
||||
CompanyInfo
|
||||
} from './Footer';
|
||||
454
frontend/src/components/layout/Header/Header.tsx
Normal file
454
frontend/src/components/layout/Header/Header.tsx
Normal 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';
|
||||
2
frontend/src/components/layout/Header/index.ts
Normal file
2
frontend/src/components/layout/Header/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { Header } from './Header';
|
||||
export type { HeaderProps, HeaderRef } from './Header';
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
441
frontend/src/components/layout/PageHeader/PageHeader.tsx
Normal file
441
frontend/src/components/layout/PageHeader/PageHeader.tsx
Normal 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';
|
||||
2
frontend/src/components/layout/PageHeader/index.ts
Normal file
2
frontend/src/components/layout/PageHeader/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { PageHeader } from './PageHeader';
|
||||
export type { PageHeaderProps, PageHeaderRef, ActionButton, MetadataItem } from './PageHeader';
|
||||
245
frontend/src/components/layout/PublicHeader/PublicHeader.tsx
Normal file
245
frontend/src/components/layout/PublicHeader/PublicHeader.tsx
Normal 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';
|
||||
1
frontend/src/components/layout/PublicHeader/index.ts
Normal file
1
frontend/src/components/layout/PublicHeader/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { PublicHeader, type PublicHeaderProps, type PublicHeaderRef } from './PublicHeader';
|
||||
200
frontend/src/components/layout/PublicLayout/PublicLayout.tsx
Normal file
200
frontend/src/components/layout/PublicLayout/PublicLayout.tsx
Normal 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';
|
||||
1
frontend/src/components/layout/PublicLayout/index.ts
Normal file
1
frontend/src/components/layout/PublicLayout/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { PublicLayout, type PublicLayoutProps, type PublicLayoutRef } from './PublicLayout';
|
||||
@@ -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;
|
||||
487
frontend/src/components/layout/Sidebar/Sidebar.tsx
Normal file
487
frontend/src/components/layout/Sidebar/Sidebar.tsx
Normal 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';
|
||||
2
frontend/src/components/layout/Sidebar/index.ts
Normal file
2
frontend/src/components/layout/Sidebar/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { Sidebar } from './Sidebar';
|
||||
export type { SidebarProps, SidebarRef, NavigationItem } from './Sidebar';
|
||||
19
frontend/src/components/layout/index.ts
Normal file
19
frontend/src/components/layout/index.ts
Normal 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';
|
||||
Reference in New Issue
Block a user