New Frontend

This commit is contained in:
Urtzi Alfaro
2025-08-16 20:13:40 +02:00
parent 23c5f50111
commit 8914786973
35 changed files with 4223 additions and 538 deletions

View File

@@ -0,0 +1,205 @@
import React from 'react';
import { Package, AlertTriangle, TrendingDown, Clock, MapPin } from 'lucide-react';
import { useBakeryType } from '../../hooks/useBakeryType';
interface InventoryItem {
id: string;
name: string;
currentStock: number;
minStock: number;
unit: string;
expiryDate?: string;
location?: string;
supplier?: string;
category: 'ingredient' | 'product' | 'packaging';
}
interface AdaptiveInventoryWidgetProps {
items: InventoryItem[];
title?: string;
showAlerts?: boolean;
}
export const AdaptiveInventoryWidget: React.FC<AdaptiveInventoryWidgetProps> = ({
items,
title,
showAlerts = true
}) => {
const { isIndividual, isCentral, getInventoryLabel } = useBakeryType();
const getStockStatus = (item: InventoryItem) => {
const ratio = item.currentStock / item.minStock;
if (ratio <= 0.2) return 'critical';
if (ratio <= 0.5) return 'low';
return 'normal';
};
const getStatusColor = (status: string) => {
switch (status) {
case 'critical':
return 'text-red-600 bg-red-100';
case 'low':
return 'text-yellow-600 bg-yellow-100';
default:
return 'text-green-600 bg-green-100';
}
};
const getExpiryWarning = (expiryDate?: string) => {
if (!expiryDate) return null;
const today = new Date();
const expiry = new Date(expiryDate);
const daysUntilExpiry = Math.ceil((expiry.getTime() - today.getTime()) / (1000 * 3600 * 24));
if (daysUntilExpiry <= 1) return 'expires-today';
if (daysUntilExpiry <= 3) return 'expires-soon';
return null;
};
const getItemIcon = (category: string) => {
if (isIndividual) {
switch (category) {
case 'ingredient':
return '🌾';
case 'packaging':
return '📦';
default:
return '🥖';
}
} else {
switch (category) {
case 'product':
return '🥖';
case 'packaging':
return '📦';
default:
return '📋';
}
}
};
const filteredItems = items.filter(item => {
if (isIndividual) {
return item.category === 'ingredient' || item.category === 'packaging';
} else {
return item.category === 'product' || item.category === 'packaging';
}
});
const lowStockItems = filteredItems.filter(item => getStockStatus(item) !== 'normal');
return (
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div className="flex items-center">
<Package className="h-5 w-5 text-gray-600 mr-2" />
<h3 className="text-lg font-semibold text-gray-900">
{title || getInventoryLabel()}
</h3>
</div>
{showAlerts && lowStockItems.length > 0 && (
<div className="flex items-center text-sm text-orange-600 bg-orange-100 px-3 py-1 rounded-full">
<AlertTriangle className="h-4 w-4 mr-1" />
{lowStockItems.length} alertas
</div>
)}
</div>
{/* Items List */}
<div className="space-y-3">
{filteredItems.slice(0, 6).map((item) => {
const stockStatus = getStockStatus(item);
const expiryWarning = getExpiryWarning(item.expiryDate);
return (
<div key={item.id} className="flex items-center justify-between p-3 border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors">
<div className="flex items-center flex-1 min-w-0">
<span className="text-2xl mr-3">{getItemIcon(item.category)}</span>
<div className="flex-1 min-w-0">
<div className="flex items-center">
<h4 className="text-sm font-medium text-gray-900 truncate">
{item.name}
</h4>
{stockStatus !== 'normal' && (
<span className={`ml-2 px-2 py-1 rounded-full text-xs font-medium ${getStatusColor(stockStatus)}`}>
{stockStatus === 'critical' ? 'Crítico' : 'Bajo'}
</span>
)}
</div>
<div className="flex items-center space-x-4 mt-1">
<span className="text-sm text-gray-600">
Stock: {item.currentStock} {item.unit}
</span>
{item.location && isCentral && (
<div className="flex items-center text-xs text-gray-500">
<MapPin className="h-3 w-3 mr-1" />
{item.location}
</div>
)}
{expiryWarning && (
<div className="flex items-center text-xs text-red-600">
<Clock className="h-3 w-3 mr-1" />
{expiryWarning === 'expires-today' ? 'Caduca hoy' : 'Caduca pronto'}
</div>
)}
</div>
{item.supplier && isIndividual && (
<div className="text-xs text-gray-500 mt-1">
Proveedor: {item.supplier}
</div>
)}
</div>
</div>
<div className="ml-4">
<div className="text-right">
<div className={`w-3 h-3 rounded-full ${getStatusColor(stockStatus).replace('text-', 'bg-').replace(' bg-', ' ').replace('100', '500')}`}></div>
</div>
</div>
</div>
);
})}
</div>
{/* Quick Stats */}
<div className="mt-6 pt-4 border-t border-gray-200">
<div className="grid grid-cols-3 gap-4 text-center">
<div>
<div className="text-2xl font-bold text-gray-900">{filteredItems.length}</div>
<div className="text-xs text-gray-500">
{isIndividual ? 'Ingredientes' : 'Productos'}
</div>
</div>
<div>
<div className="text-2xl font-bold text-yellow-600">{lowStockItems.length}</div>
<div className="text-xs text-gray-500">Stock bajo</div>
</div>
<div>
<div className="text-2xl font-bold text-red-600">
{filteredItems.filter(item => getExpiryWarning(item.expiryDate)).length}
</div>
<div className="text-xs text-gray-500">
{isIndividual ? 'Próximos a caducar' : 'Próximos a vencer'}
</div>
</div>
</div>
</div>
{/* Action Button */}
<div className="mt-4">
<button className="w-full px-4 py-2 bg-primary-600 text-white text-sm rounded-lg hover:bg-primary-700 transition-colors">
Ver Todo el {getInventoryLabel()}
</button>
</div>
</div>
);
};

View File

@@ -0,0 +1,158 @@
import React from 'react';
import { ChefHat, Truck, Clock, Users, Package, MapPin } from 'lucide-react';
import { useBakeryType } from '../../hooks/useBakeryType';
interface ProductionItem {
id: string;
name: string;
quantity: number;
status: 'pending' | 'in_progress' | 'completed';
scheduledTime?: string;
location?: string;
assignedTo?: string;
}
interface AdaptiveProductionCardProps {
item: ProductionItem;
onStatusChange?: (id: string, status: string) => void;
onQuantityChange?: (id: string, quantity: number) => void;
}
export const AdaptiveProductionCard: React.FC<AdaptiveProductionCardProps> = ({
item,
onStatusChange,
onQuantityChange
}) => {
const { isIndividual, isCentral, getProductionLabel } = useBakeryType();
const getStatusColor = (status: string) => {
switch (status) {
case 'pending':
return 'bg-yellow-100 text-yellow-800';
case 'in_progress':
return 'bg-blue-100 text-blue-800';
case 'completed':
return 'bg-green-100 text-green-800';
default:
return 'bg-gray-100 text-gray-800';
}
};
const getIcon = () => {
return isIndividual ? <ChefHat className="h-5 w-5" /> : <Truck className="h-5 w-5" />;
};
const getStatusLabels = () => {
if (isIndividual) {
return {
pending: 'Pendiente',
in_progress: 'Horneando',
completed: 'Terminado'
};
} else {
return {
pending: 'Pendiente',
in_progress: 'Distribuyendo',
completed: 'Entregado'
};
}
};
const statusLabels = getStatusLabels();
return (
<div className="bg-white rounded-lg border border-gray-200 p-4 hover:shadow-md transition-shadow">
{/* Header */}
<div className="flex items-center justify-between mb-3">
<div className="flex items-center">
<div className="h-8 w-8 bg-primary-100 rounded-lg flex items-center justify-center mr-3">
{getIcon()}
</div>
<div>
<h4 className="font-medium text-gray-900">{item.name}</h4>
<p className="text-sm text-gray-500">
{isIndividual ? 'Lote de producción' : 'Envío a puntos de venta'}
</p>
</div>
</div>
<span className={`px-2 py-1 rounded-full text-xs font-medium ${getStatusColor(item.status)}`}>
{statusLabels[item.status as keyof typeof statusLabels]}
</span>
</div>
{/* Quantity */}
<div className="flex items-center justify-between mb-3">
<div className="flex items-center text-sm text-gray-600">
<Package className="h-4 w-4 mr-1" />
<span>Cantidad:</span>
</div>
<div className="flex items-center">
{onQuantityChange ? (
<input
type="number"
value={item.quantity}
onChange={(e) => onQuantityChange(item.id, parseInt(e.target.value))}
className="w-20 px-2 py-1 text-sm border border-gray-300 rounded text-right"
min="0"
/>
) : (
<span className="font-medium">{item.quantity}</span>
)}
<span className="ml-1 text-sm text-gray-500">
{isIndividual ? 'unidades' : 'cajas'}
</span>
</div>
</div>
{/* Additional Info for Bakery Type */}
{item.scheduledTime && (
<div className="flex items-center text-sm text-gray-600 mb-2">
<Clock className="h-4 w-4 mr-2" />
<span>
{isIndividual ? 'Hora de horneado:' : 'Hora de entrega:'} {item.scheduledTime}
</span>
</div>
)}
{item.location && isCentral && (
<div className="flex items-center text-sm text-gray-600 mb-2">
<MapPin className="h-4 w-4 mr-2" />
<span>Destino: {item.location}</span>
</div>
)}
{item.assignedTo && (
<div className="flex items-center text-sm text-gray-600 mb-3">
<Users className="h-4 w-4 mr-2" />
<span>
{isIndividual ? 'Panadero:' : 'Conductor:'} {item.assignedTo}
</span>
</div>
)}
{/* Actions */}
{onStatusChange && item.status !== 'completed' && (
<div className="flex space-x-2">
{item.status === 'pending' && (
<button
onClick={() => onStatusChange(item.id, 'in_progress')}
className="flex-1 px-3 py-2 bg-blue-600 text-white text-sm rounded-lg hover:bg-blue-700 transition-colors"
>
{isIndividual ? 'Iniciar Horneado' : 'Iniciar Distribución'}
</button>
)}
{item.status === 'in_progress' && (
<button
onClick={() => onStatusChange(item.id, 'completed')}
className="flex-1 px-3 py-2 bg-green-600 text-white text-sm rounded-lg hover:bg-green-700 transition-colors"
>
{isIndividual ? 'Marcar Terminado' : 'Marcar Entregado'}
</button>
)}
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,36 @@
import React from 'react';
import { Navigate, useLocation } from 'react-router-dom';
import { useSelector } from 'react-redux';
import { RootState } from '../../store';
interface ProtectedRouteProps {
children: React.ReactNode;
}
const ProtectedRoute: React.FC<ProtectedRouteProps> = ({ children }) => {
const location = useLocation();
const { isAuthenticated, user } = useSelector((state: RootState) => state.auth);
// Check if user is authenticated
if (!isAuthenticated || !user) {
// Redirect to login with the attempted location
return <Navigate to="/login" state={{ from: location }} replace />;
}
// Check if user needs onboarding (except for onboarding and settings routes)
const isOnboardingRoute = location.pathname.includes('/onboarding');
const isSettingsRoute = location.pathname.includes('/settings');
if (!user.isOnboardingComplete && !isOnboardingRoute && !isSettingsRoute) {
return <Navigate to="/app/onboarding" replace />;
}
// If user completed onboarding but is on onboarding route, redirect to dashboard
if (user.isOnboardingComplete && isOnboardingRoute) {
return <Navigate to="/app/dashboard" replace />;
}
return <>{children}</>;
};
export default ProtectedRoute;

View File

@@ -0,0 +1,58 @@
import React from 'react';
import { usePermissions, UserRole } from '../../hooks/usePermissions';
interface RoleBasedAccessProps {
children: React.ReactNode;
requiredRoles?: UserRole[];
requiredPermissions?: string[];
fallback?: React.ReactNode;
hideIfNoAccess?: boolean;
}
export const RoleBasedAccess: React.FC<RoleBasedAccessProps> = ({
children,
requiredRoles = [],
requiredPermissions = [],
fallback = null,
hideIfNoAccess = false
}) => {
const { hasRole, hasPermission } = usePermissions();
// Check role requirements
const hasRequiredRole = requiredRoles.length === 0 || requiredRoles.some(role => hasRole(role));
// Check permission requirements
const hasRequiredPermission = requiredPermissions.length === 0 || requiredPermissions.some(permission => hasPermission(permission));
const hasAccess = hasRequiredRole && hasRequiredPermission;
if (!hasAccess) {
if (hideIfNoAccess) {
return null;
}
return <>{fallback}</>;
}
return <>{children}</>;
};
// Convenience components for common use cases
export const AdminOnly: React.FC<{ children: React.ReactNode; fallback?: React.ReactNode }> = ({ children, fallback }) => (
<RoleBasedAccess requiredRoles={['admin', 'owner']} fallback={fallback}>
{children}
</RoleBasedAccess>
);
export const ManagerAndUp: React.FC<{ children: React.ReactNode; fallback?: React.ReactNode }> = ({ children, fallback }) => (
<RoleBasedAccess requiredRoles={['manager', 'admin', 'owner']} fallback={fallback}>
{children}
</RoleBasedAccess>
);
export const OwnerOnly: React.FC<{ children: React.ReactNode; fallback?: React.ReactNode }> = ({ children, fallback }) => (
<RoleBasedAccess requiredRoles={['owner']} fallback={fallback}>
{children}
</RoleBasedAccess>
);
export default RoleBasedAccess;

View File

@@ -0,0 +1,42 @@
import React from 'react';
import { Navigate } from 'react-router-dom';
import { useSelector } from 'react-redux';
import { RootState } from '../../store';
import { usePermissions } from '../../hooks/usePermissions';
interface RoleBasedRouteProps {
children: React.ReactNode;
requiredRoles?: string[];
requiredPermissions?: string[];
fallbackPath?: string;
}
const RoleBasedRoute: React.FC<RoleBasedRouteProps> = ({
children,
requiredRoles = [],
requiredPermissions = [],
fallbackPath = '/app/dashboard'
}) => {
const { user } = useSelector((state: RootState) => state.auth);
const { hasRole, hasPermission } = usePermissions();
// Check role requirements
if (requiredRoles.length > 0) {
const hasRequiredRole = requiredRoles.some(role => hasRole(role));
if (!hasRequiredRole) {
return <Navigate to={fallbackPath} replace />;
}
}
// Check permission requirements
if (requiredPermissions.length > 0) {
const hasRequiredPermission = requiredPermissions.some(permission => hasPermission(permission));
if (!hasRequiredPermission) {
return <Navigate to={fallbackPath} replace />;
}
}
return <>{children}</>;
};
export default RoleBasedRoute;

View File

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

View File

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

View File

@@ -1,4 +1,5 @@
import React, { useState } from 'react';
import { Outlet, Link, useLocation, useNavigate } from 'react-router-dom';
import {
Home,
TrendingUp,
@@ -10,18 +11,17 @@ import {
User,
Bell,
ChevronDown,
ChefHat,
Warehouse,
ShoppingCart,
BookOpen
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 {
children: React.ReactNode;
user: any;
currentPage: string;
onNavigate: (page: string) => void;
onLogout: () => void;
// No props needed - using React Router
}
interface NavigationItem {
@@ -29,32 +29,52 @@ interface NavigationItem {
label: string;
icon: React.ComponentType<{ className?: string }>;
href: string;
requiresRole?: string[];
}
const Layout: React.FC<LayoutProps> = ({
children,
user,
currentPage,
onNavigate,
onLogout
}) => {
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: 'Inicio', icon: Home, href: '/dashboard' },
{ id: 'orders', label: 'Pedidos', icon: Package, href: '/orders' },
{ id: 'production', label: 'Producción', icon: ChefHat, href: '/production' },
{ id: 'recipes', label: 'Recetas', icon: BookOpen, href: '/recipes' },
{ id: 'inventory', label: 'Inventario', icon: Warehouse, href: '/inventory' },
{ id: 'sales', label: 'Ventas', icon: ShoppingCart, href: '/sales' },
{ id: 'reports', label: 'Informes', icon: TrendingUp, href: '/reports' },
{ id: 'settings', label: 'Configuración', icon: Settings, href: '/settings' },
{ 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' },
];
const handleNavigate = (pageId: string) => {
onNavigate(pageId);
setIsMobileMenuOpen(false);
// 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 (
@@ -88,14 +108,14 @@ const Layout: React.FC<LayoutProps> = ({
{/* Desktop Navigation */}
<div className="hidden md:flex md:ml-10 md:space-x-1">
{navigation.map((item) => {
{filteredNavigation.map((item) => {
const Icon = item.icon;
const isActive = currentPage === item.id;
const isActive = isActiveRoute(item.href);
return (
<button
<Link
key={item.id}
onClick={() => handleNavigate(item.id)}
to={item.href}
className={`
flex items-center px-3 py-2 rounded-lg text-sm font-medium transition-all duration-200
${isActive
@@ -103,17 +123,20 @@ const Layout: React.FC<LayoutProps> = ({
: 'text-gray-600 hover:text-gray-900 hover:bg-gray-100'
}
`}
onClick={() => setIsMobileMenuOpen(false)}
>
<Icon className="h-4 w-4 mr-2" />
{item.label}
</button>
</Link>
);
})}
</div>
</div>
{/* Right side - Notifications and User Menu */}
{/* 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" />
@@ -142,19 +165,17 @@ const Layout: React.FC<LayoutProps> = ({
<p className="text-sm font-medium text-gray-900">{user.fullName}</p>
<p className="text-sm text-gray-500">{user.email}</p>
</div>
<button
onClick={() => {
handleNavigate('settings');
setIsUserMenuOpen(false);
}}
<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
</button>
</Link>
<button
onClick={() => {
onLogout();
handleLogout();
setIsUserMenuOpen(false);
}}
className="w-full text-left px-4 py-2 text-sm text-red-600 hover:bg-red-50 flex items-center"
@@ -173,14 +194,15 @@ const Layout: React.FC<LayoutProps> = ({
{isMobileMenuOpen && (
<div className="md:hidden border-t border-gray-200 bg-white">
<div className="px-2 pt-2 pb-3 space-y-1">
{navigation.map((item) => {
{filteredNavigation.map((item) => {
const Icon = item.icon;
const isActive = currentPage === item.id;
const isActive = isActiveRoute(item.href);
return (
<button
<Link
key={item.id}
onClick={() => handleNavigate(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
@@ -191,7 +213,7 @@ const Layout: React.FC<LayoutProps> = ({
>
<Icon className="h-5 w-5 mr-3" />
{item.label}
</button>
</Link>
);
})}
</div>
@@ -201,9 +223,7 @@ const Layout: React.FC<LayoutProps> = ({
{/* Main Content */}
<main className="flex-1">
<div className="max-w-7xl mx-auto">
{children}
</div>
<Outlet />
</main>
{/* Click outside handler for dropdowns */}

View File

@@ -0,0 +1,99 @@
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-integration', label: bakeryType === 'individual' ? 'TPV' : 'Multi-TPV', href: '/app/operations/sales/pos-integration' }
]
}
];
// Add recipes for individual bakeries, hide for central
if (bakeryType === 'individual') {
baseItems.push({
id: 'recipes',
label: 'Recetas',
href: '/app/operations/recipes',
icon: 'BookOpen',
children: [
{ id: 'active-recipes', label: 'Recetas Activas', href: '/app/operations/recipes/active-recipes' },
{ id: 'development', label: 'Desarrollo', href: '/app/operations/recipes/development' },
{ id: 'costing', label: 'Costeo', href: '/app/operations/recipes/costing' }
]
});
}
return baseItems;
};
return (
<div className="flex flex-col h-full">
<div className="bg-white border-b border-gray-200">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<Breadcrumbs />
<SecondaryNavigation items={getNavigationItems()} />
</div>
</div>
<div className="flex-1 bg-gray-50">
<div className="max-w-7xl mx-auto">
<Outlet />
</div>
</div>
</div>
);
};
export default OperationsLayout;

View File

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

View File

@@ -0,0 +1,123 @@
import React from 'react';
import { Link, useLocation } from 'react-router-dom';
import { ChevronRight, Home } from 'lucide-react';
interface BreadcrumbItem {
label: string;
href?: string;
}
export const Breadcrumbs: React.FC = () => {
const location = useLocation();
const getBreadcrumbs = (): BreadcrumbItem[] => {
const pathSegments = location.pathname.split('/').filter(Boolean);
// Remove 'app' from the beginning if present
if (pathSegments[0] === 'app') {
pathSegments.shift();
}
const breadcrumbs: BreadcrumbItem[] = [
{ label: 'Inicio', href: '/app/dashboard' }
];
const segmentMap: Record<string, string> = {
// Main sections
'dashboard': 'Dashboard',
'operations': 'Operaciones',
'analytics': 'Analytics',
'settings': 'Configuración',
// Operations subsections
'production': 'Producción',
'orders': 'Pedidos',
'inventory': 'Inventario',
'sales': 'Ventas',
'recipes': 'Recetas',
// Operations sub-pages
'schedule': 'Programación',
'active-batches': 'Lotes Activos',
'equipment': 'Equipamiento',
'incoming': 'Entrantes',
'in-progress': 'En Proceso',
'supplier-orders': 'Proveedores',
'stock-levels': 'Niveles Stock',
'movements': 'Movimientos',
'alerts': 'Alertas',
'daily-sales': 'Ventas Diarias',
'customer-orders': 'Pedidos Cliente',
'pos-integration': 'Integración TPV',
'active-recipes': 'Recetas Activas',
'development': 'Desarrollo',
'costing': 'Costeo',
// Analytics subsections
'forecasting': 'Predicciones',
'sales-analytics': 'Análisis Ventas',
'production-reports': 'Reportes Producción',
'financial-reports': 'Reportes Financieros',
'performance-kpis': 'KPIs',
'ai-insights': 'Insights IA',
// Settings subsections
'general': 'General',
'users': 'Usuarios',
'bakeries': 'Panaderías',
'account': 'Cuenta'
};
let currentPath = '/app';
pathSegments.forEach((segment, index) => {
currentPath += `/${segment}`;
const label = segmentMap[segment] || segment.charAt(0).toUpperCase() + segment.slice(1);
// Don't make the last item clickable
const isLast = index === pathSegments.length - 1;
breadcrumbs.push({
label,
href: isLast ? undefined : currentPath
});
});
return breadcrumbs;
};
const breadcrumbs = getBreadcrumbs();
if (breadcrumbs.length <= 1) {
return null;
}
return (
<nav className="flex items-center space-x-2 py-3 text-sm" aria-label="Breadcrumb">
<ol className="flex items-center space-x-2">
{breadcrumbs.map((breadcrumb, index) => (
<li key={index} className="flex items-center">
{index > 0 && (
<ChevronRight className="h-4 w-4 text-gray-400 mx-2" />
)}
{breadcrumb.href ? (
<Link
to={breadcrumb.href}
className="flex items-center text-gray-600 hover:text-gray-900 transition-colors"
>
{index === 0 && <Home className="h-4 w-4 mr-1" />}
{breadcrumb.label}
</Link>
) : (
<span className="flex items-center text-gray-900 font-medium">
{index === 0 && <Home className="h-4 w-4 mr-1" />}
{breadcrumb.label}
</span>
)}
</li>
))}
</ol>
</nav>
);
};

View File

@@ -0,0 +1,126 @@
import React, { useState } from 'react';
import { Link, useLocation } from 'react-router-dom';
import { ChevronDown, ChevronRight } from 'lucide-react';
import * as Icons from 'lucide-react';
interface NavigationChild {
id: string;
label: string;
href: string;
}
interface NavigationItem {
id: string;
label: string;
href: string;
icon: string;
children?: NavigationChild[];
}
interface SecondaryNavigationProps {
items: NavigationItem[];
}
export const SecondaryNavigation: React.FC<SecondaryNavigationProps> = ({ items }) => {
const location = useLocation();
const [expandedItems, setExpandedItems] = useState<Set<string>>(new Set());
const toggleExpanded = (itemId: string) => {
const newExpanded = new Set(expandedItems);
if (newExpanded.has(itemId)) {
newExpanded.delete(itemId);
} else {
newExpanded.add(itemId);
}
setExpandedItems(newExpanded);
};
const isActive = (href: string): boolean => {
return location.pathname === href || location.pathname.startsWith(href + '/');
};
const hasActiveChild = (children?: NavigationChild[]): boolean => {
if (!children) return false;
return children.some(child => isActive(child.href));
};
// Auto-expand items with active children
React.useEffect(() => {
const itemsToExpand = new Set(expandedItems);
items.forEach(item => {
if (hasActiveChild(item.children)) {
itemsToExpand.add(item.id);
}
});
setExpandedItems(itemsToExpand);
}, [location.pathname]);
const getIcon = (iconName: string) => {
const IconComponent = (Icons as any)[iconName];
return IconComponent || Icons.Circle;
};
return (
<nav className="border-b border-gray-200">
<div className="flex space-x-8 overflow-x-auto">
{items.map((item) => {
const Icon = getIcon(item.icon);
const isItemActive = isActive(item.href);
const hasChildren = item.children && item.children.length > 0;
const isExpanded = expandedItems.has(item.id);
const hasActiveChildItem = hasActiveChild(item.children);
return (
<div key={item.id} className="relative group">
<div className="flex items-center">
<Link
to={item.href}
className={`flex items-center px-4 py-4 text-sm font-medium border-b-2 transition-colors whitespace-nowrap ${
isItemActive || hasActiveChildItem
? 'border-primary-500 text-primary-600'
: 'border-transparent text-gray-600 hover:text-gray-900 hover:border-gray-300'
}`}
>
<Icon className="h-4 w-4 mr-2" />
{item.label}
</Link>
{hasChildren && (
<button
onClick={() => toggleExpanded(item.id)}
className="ml-1 p-1 rounded hover:bg-gray-100 transition-colors"
>
{isExpanded ? (
<ChevronDown className="h-4 w-4 text-gray-500" />
) : (
<ChevronRight className="h-4 w-4 text-gray-500" />
)}
</button>
)}
</div>
{/* Dropdown for children */}
{hasChildren && isExpanded && (
<div className="absolute top-full left-0 mt-1 w-64 bg-white rounded-lg shadow-lg border border-gray-200 py-2 z-50">
{item.children!.map((child) => (
<Link
key={child.id}
to={child.href}
className={`block px-4 py-2 text-sm transition-colors ${
isActive(child.href)
? 'bg-primary-50 text-primary-700 border-r-2 border-primary-500'
: 'text-gray-700 hover:bg-gray-50'
}`}
>
{child.label}
</Link>
))}
</div>
)}
</div>
);
})}
</div>
</nav>
);
};

View File

@@ -0,0 +1,128 @@
import React, { useState, useEffect } from 'react';
import { ChevronDown, Building, Check } from 'lucide-react';
import { useSelector, useDispatch } from 'react-redux';
import { RootState } from '../../store';
import { setCurrentTenant } from '../../store/slices/tenantSlice';
import { useTenant } from '../../api/hooks/useTenant';
import toast from 'react-hot-toast';
export const TenantSelector: React.FC = () => {
const dispatch = useDispatch();
const { currentTenant } = useSelector((state: RootState) => state.tenant);
const { user } = useSelector((state: RootState) => state.auth);
const [isOpen, setIsOpen] = useState(false);
const {
tenants,
getUserTenants,
isLoading,
error
} = useTenant();
useEffect(() => {
if (user) {
getUserTenants();
}
}, [user, getUserTenants]);
const handleTenantChange = async (tenant: any) => {
try {
dispatch(setCurrentTenant(tenant));
localStorage.setItem('selectedTenantId', tenant.id);
setIsOpen(false);
toast.success(`Cambiado a ${tenant.name}`);
// Force a page reload to update data with new tenant context
window.location.reload();
} catch (error) {
toast.error('Error al cambiar de panadería');
}
};
if (isLoading || tenants.length <= 1) {
return null;
}
return (
<div className="relative">
<button
onClick={() => setIsOpen(!isOpen)}
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 border border-gray-200"
>
<Building className="h-4 w-4 text-gray-600 mr-2" />
<span className="text-gray-700 font-medium max-w-32 truncate">
{currentTenant?.name || 'Seleccionar panadería'}
</span>
<ChevronDown className="h-4 w-4 ml-1 text-gray-500" />
</button>
{isOpen && (
<>
{/* Backdrop */}
<div
className="fixed inset-0 z-40"
onClick={() => setIsOpen(false)}
/>
{/* Dropdown */}
<div className="absolute right-0 mt-2 w-64 bg-white rounded-xl shadow-strong border border-gray-200 py-2 z-50">
<div className="px-4 py-2 border-b border-gray-100">
<p className="text-xs font-medium text-gray-500 uppercase tracking-wide">
Mis Panaderías
</p>
</div>
<div className="max-h-64 overflow-y-auto">
{tenants.map((tenant) => (
<button
key={tenant.id}
onClick={() => handleTenantChange(tenant)}
className="w-full text-left px-4 py-3 text-sm hover:bg-gray-50 flex items-center justify-between transition-colors"
>
<div className="flex items-center min-w-0">
<Building className="h-4 w-4 text-gray-400 mr-3 flex-shrink-0" />
<div className="min-w-0">
<p className="font-medium text-gray-900 truncate">
{tenant.name}
</p>
<p className="text-xs text-gray-500 truncate">
{tenant.address}
</p>
<div className="flex items-center mt-1">
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${
tenant.business_type === 'individual'
? 'bg-blue-100 text-blue-800'
: 'bg-purple-100 text-purple-800'
}`}>
{tenant.business_type === 'individual' ? 'Individual' : 'Obrador Central'}
</span>
</div>
</div>
</div>
{currentTenant?.id === tenant.id && (
<Check className="h-4 w-4 text-primary-600 flex-shrink-0" />
)}
</button>
))}
</div>
<div className="px-4 py-2 border-t border-gray-100">
<button
onClick={() => {
setIsOpen(false);
// Navigate to bakeries management
window.location.href = '/app/settings/bakeries';
}}
className="text-xs text-primary-600 hover:text-primary-700 font-medium"
>
+ Administrar panaderías
</button>
</div>
</div>
</>
)}
</div>
);
};