New Frontend
This commit is contained in:
205
frontend/src/components/adaptive/AdaptiveInventoryWidget.tsx
Normal file
205
frontend/src/components/adaptive/AdaptiveInventoryWidget.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
158
frontend/src/components/adaptive/AdaptiveProductionCard.tsx
Normal file
158
frontend/src/components/adaptive/AdaptiveProductionCard.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
36
frontend/src/components/auth/ProtectedRoute.tsx
Normal file
36
frontend/src/components/auth/ProtectedRoute.tsx
Normal 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;
|
||||
58
frontend/src/components/auth/RoleBasedAccess.tsx
Normal file
58
frontend/src/components/auth/RoleBasedAccess.tsx
Normal 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;
|
||||
42
frontend/src/components/auth/RoleBasedRoute.tsx
Normal file
42
frontend/src/components/auth/RoleBasedRoute.tsx
Normal 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;
|
||||
67
frontend/src/components/layout/AnalyticsLayout.tsx
Normal file
67
frontend/src/components/layout/AnalyticsLayout.tsx
Normal 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;
|
||||
12
frontend/src/components/layout/AuthLayout.tsx
Normal file
12
frontend/src/components/layout/AuthLayout.tsx
Normal 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;
|
||||
@@ -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 */}
|
||||
|
||||
99
frontend/src/components/layout/OperationsLayout.tsx
Normal file
99
frontend/src/components/layout/OperationsLayout.tsx
Normal 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;
|
||||
65
frontend/src/components/layout/SettingsLayout.tsx
Normal file
65
frontend/src/components/layout/SettingsLayout.tsx
Normal 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;
|
||||
123
frontend/src/components/navigation/Breadcrumbs.tsx
Normal file
123
frontend/src/components/navigation/Breadcrumbs.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
126
frontend/src/components/navigation/SecondaryNavigation.tsx
Normal file
126
frontend/src/components/navigation/SecondaryNavigation.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
128
frontend/src/components/navigation/TenantSelector.tsx
Normal file
128
frontend/src/components/navigation/TenantSelector.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user