Improve the design of the frontend
This commit is contained in:
@@ -9,6 +9,7 @@ import LoginPage from './pages/auth/LoginPage';
|
||||
import RegisterPage from './pages/auth/RegisterPage';
|
||||
import OnboardingPage from './pages/onboarding/OnboardingPage';
|
||||
import DashboardPage from './pages/dashboard/DashboardPage';
|
||||
import ProductionPage from './pages/production/ProductionPage';
|
||||
import ForecastPage from './pages/forecast/ForecastPage';
|
||||
import OrdersPage from './pages/orders/OrdersPage';
|
||||
import SettingsPage from './pages/settings/SettingsPage';
|
||||
@@ -24,7 +25,7 @@ import './i18n';
|
||||
// Global styles
|
||||
import './styles/globals.css';
|
||||
|
||||
type CurrentPage = 'landing' | 'login' | 'register' | 'onboarding' | 'dashboard' | 'forecast' | 'orders' | 'settings';
|
||||
type CurrentPage = 'landing' | 'login' | 'register' | 'onboarding' | 'dashboard' | 'reports' | 'orders' | 'production' | 'settings';
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
@@ -178,14 +179,20 @@ const App: React.FC = () => {
|
||||
// Main app pages with layout
|
||||
const pageComponent = () => {
|
||||
switch (appState.currentPage) {
|
||||
case 'forecast':
|
||||
case 'reports':
|
||||
return <ForecastPage />;
|
||||
case 'orders':
|
||||
return <OrdersPage />;
|
||||
case 'production':
|
||||
return <ProductionPage />;
|
||||
case 'settings':
|
||||
return <SettingsPage user={appState.user!} onLogout={handleLogout} />;
|
||||
default:
|
||||
return <DashboardPage user={appState.user!} />;
|
||||
return <DashboardPage
|
||||
onNavigateToOrders={() => navigateTo('orders')}
|
||||
onNavigateToReports={() => navigateTo('reports')}
|
||||
onNavigateToProduction={() => navigateTo('production')}
|
||||
/>;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -9,7 +9,8 @@ import {
|
||||
LogOut,
|
||||
User,
|
||||
Bell,
|
||||
ChevronDown
|
||||
ChevronDown,
|
||||
ChefHat
|
||||
} from 'lucide-react';
|
||||
|
||||
interface LayoutProps {
|
||||
@@ -38,9 +39,10 @@ const Layout: React.FC<LayoutProps> = ({
|
||||
const [isUserMenuOpen, setIsUserMenuOpen] = useState(false);
|
||||
|
||||
const navigation: NavigationItem[] = [
|
||||
{ id: 'dashboard', label: 'Panel Principal', icon: Home, href: '/dashboard' },
|
||||
{ id: 'forecast', label: 'Predicciones', icon: TrendingUp, href: '/forecast' },
|
||||
{ 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: 'reports', label: 'Informes', icon: TrendingUp, href: '/reports' },
|
||||
{ id: 'settings', label: 'Configuración', icon: Settings, href: '/settings' },
|
||||
];
|
||||
|
||||
|
||||
180
frontend/src/components/simple/CriticalAlerts.tsx
Normal file
180
frontend/src/components/simple/CriticalAlerts.tsx
Normal file
@@ -0,0 +1,180 @@
|
||||
import React from 'react';
|
||||
import { AlertTriangle, Cloud, Package, Clock, ChevronRight } from 'lucide-react';
|
||||
|
||||
export interface Alert {
|
||||
id: string;
|
||||
type: 'stock' | 'weather' | 'order' | 'production' | 'system';
|
||||
severity: 'high' | 'medium' | 'low';
|
||||
title: string;
|
||||
description: string;
|
||||
action?: string;
|
||||
time?: string;
|
||||
}
|
||||
|
||||
interface CriticalAlertsProps {
|
||||
alerts?: Alert[];
|
||||
onAlertClick?: (alertId: string) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const CriticalAlerts: React.FC<CriticalAlertsProps> = ({
|
||||
alerts = [
|
||||
{
|
||||
id: '1',
|
||||
type: 'stock',
|
||||
severity: 'high',
|
||||
title: 'Stock Bajo',
|
||||
description: 'Pan integral: solo 5 unidades',
|
||||
action: 'Hacer más',
|
||||
time: '10:30'
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
type: 'weather',
|
||||
severity: 'medium',
|
||||
title: 'Lluvia Esperada',
|
||||
description: 'Precipitaciones 14:00 - 17:00',
|
||||
action: 'Ajustar producción',
|
||||
time: '14:00'
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
type: 'order',
|
||||
severity: 'low',
|
||||
title: 'Pedido Especial',
|
||||
description: 'Tarta de cumpleaños Ana - Viernes',
|
||||
action: 'Ver detalles',
|
||||
time: 'Viernes'
|
||||
}
|
||||
],
|
||||
onAlertClick,
|
||||
className = ''
|
||||
}) => {
|
||||
const getAlertIcon = (type: Alert['type']) => {
|
||||
switch (type) {
|
||||
case 'stock': return Package;
|
||||
case 'weather': return Cloud;
|
||||
case 'order': return Clock;
|
||||
case 'production': return AlertTriangle;
|
||||
default: return AlertTriangle;
|
||||
}
|
||||
};
|
||||
|
||||
const getAlertColors = (severity: Alert['severity']) => {
|
||||
switch (severity) {
|
||||
case 'high': return {
|
||||
bg: 'bg-red-50 border-red-200',
|
||||
icon: 'text-red-600',
|
||||
title: 'text-red-900',
|
||||
description: 'text-red-700'
|
||||
};
|
||||
case 'medium': return {
|
||||
bg: 'bg-yellow-50 border-yellow-200',
|
||||
icon: 'text-yellow-600',
|
||||
title: 'text-yellow-900',
|
||||
description: 'text-yellow-700'
|
||||
};
|
||||
case 'low': return {
|
||||
bg: 'bg-blue-50 border-blue-200',
|
||||
icon: 'text-blue-600',
|
||||
title: 'text-blue-900',
|
||||
description: 'text-blue-700'
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const visibleAlerts = alerts.slice(0, 3); // Show max 3 alerts
|
||||
|
||||
return (
|
||||
<div className={`bg-white rounded-xl shadow-sm border border-gray-200 p-6 ${className}`}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900 flex items-center">
|
||||
<AlertTriangle className="h-5 w-5 mr-2 text-orange-600" />
|
||||
Atención Requerida
|
||||
</h3>
|
||||
{alerts.length > 3 && (
|
||||
<span className="text-xs bg-gray-100 text-gray-600 px-2 py-1 rounded-full">
|
||||
+{alerts.length - 3} más
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Alerts List */}
|
||||
{visibleAlerts.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{visibleAlerts.map((alert) => {
|
||||
const IconComponent = getAlertIcon(alert.type);
|
||||
const colors = getAlertColors(alert.severity);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={alert.id}
|
||||
onClick={() => onAlertClick?.(alert.id)}
|
||||
className={`${colors.bg} border rounded-lg p-3 cursor-pointer hover:shadow-sm transition-all duration-200`}
|
||||
>
|
||||
<div className="flex items-start space-x-3">
|
||||
<IconComponent className={`h-4 w-4 mt-0.5 ${colors.icon} flex-shrink-0`} />
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<h4 className={`text-sm font-medium ${colors.title}`}>
|
||||
{alert.title}
|
||||
</h4>
|
||||
<p className={`text-xs ${colors.description} mt-1`}>
|
||||
{alert.description}
|
||||
</p>
|
||||
{alert.action && (
|
||||
<p className={`text-xs ${colors.icon} font-medium mt-1`}>
|
||||
→ {alert.action}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2 ml-3">
|
||||
{alert.time && (
|
||||
<span className={`text-xs ${colors.description}`}>
|
||||
{alert.time}
|
||||
</span>
|
||||
)}
|
||||
<ChevronRight className={`h-3 w-3 ${colors.icon}`} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-6">
|
||||
<div className="h-12 w-12 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-3">
|
||||
<AlertTriangle className="h-6 w-6 text-green-600" />
|
||||
</div>
|
||||
<h4 className="text-sm font-medium text-gray-900">Todo bajo control</h4>
|
||||
<p className="text-xs text-gray-500 mt-1">No hay alertas que requieran atención</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quick Summary */}
|
||||
<div className="flex items-center justify-between pt-4 mt-4 border-t border-gray-100">
|
||||
<div className="flex items-center space-x-4 text-xs text-gray-500">
|
||||
<span className="flex items-center">
|
||||
<div className="w-2 h-2 bg-red-500 rounded-full mr-1"></div>
|
||||
{alerts.filter(a => a.severity === 'high').length} Urgentes
|
||||
</span>
|
||||
<span className="flex items-center">
|
||||
<div className="w-2 h-2 bg-yellow-500 rounded-full mr-1"></div>
|
||||
{alerts.filter(a => a.severity === 'medium').length} Importantes
|
||||
</span>
|
||||
</div>
|
||||
<button className="text-xs text-gray-600 hover:text-gray-900 font-medium">
|
||||
Ver todas →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CriticalAlerts;
|
||||
413
frontend/src/components/simple/OrderSuggestions.tsx
Normal file
413
frontend/src/components/simple/OrderSuggestions.tsx
Normal file
@@ -0,0 +1,413 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
ShoppingCart,
|
||||
Calendar,
|
||||
TrendingUp,
|
||||
AlertTriangle,
|
||||
CheckCircle,
|
||||
Clock,
|
||||
Plus,
|
||||
Minus,
|
||||
Eye,
|
||||
ArrowRight
|
||||
} from 'lucide-react';
|
||||
|
||||
// Types for order suggestions
|
||||
export interface DailyOrderItem {
|
||||
id: string;
|
||||
product: string;
|
||||
emoji: string;
|
||||
suggestedQuantity: number;
|
||||
currentQuantity: number;
|
||||
unit: string;
|
||||
urgency: 'high' | 'medium' | 'low';
|
||||
reason: string;
|
||||
confidence: number;
|
||||
supplier: string;
|
||||
estimatedCost: number;
|
||||
lastOrderDate: string;
|
||||
}
|
||||
|
||||
export interface WeeklyOrderItem {
|
||||
id: string;
|
||||
product: string;
|
||||
emoji: string;
|
||||
suggestedQuantity: number;
|
||||
currentStock: number;
|
||||
unit: string;
|
||||
frequency: 'weekly' | 'biweekly';
|
||||
nextOrderDate: string;
|
||||
supplier: string;
|
||||
estimatedCost: number;
|
||||
stockDays: number; // Days until stock runs out
|
||||
confidence: number;
|
||||
}
|
||||
|
||||
interface OrderSuggestionsProps {
|
||||
dailyOrders: DailyOrderItem[];
|
||||
weeklyOrders: WeeklyOrderItem[];
|
||||
onUpdateQuantity: (orderId: string, quantity: number, type: 'daily' | 'weekly') => void;
|
||||
onCreateOrder: (items: (DailyOrderItem | WeeklyOrderItem)[], type: 'daily' | 'weekly') => void;
|
||||
onViewDetails: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const OrderSuggestions: React.FC<OrderSuggestionsProps> = ({
|
||||
dailyOrders,
|
||||
weeklyOrders,
|
||||
onUpdateQuantity,
|
||||
onCreateOrder,
|
||||
onViewDetails,
|
||||
className = ''
|
||||
}) => {
|
||||
const [activeTab, setActiveTab] = useState<'daily' | 'weekly'>('daily');
|
||||
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
|
||||
|
||||
const getUrgencyColor = (urgency: DailyOrderItem['urgency']) => {
|
||||
switch (urgency) {
|
||||
case 'high':
|
||||
return 'bg-red-100 text-red-800 border-red-200';
|
||||
case 'medium':
|
||||
return 'bg-yellow-100 text-yellow-800 border-yellow-200';
|
||||
case 'low':
|
||||
return 'bg-green-100 text-green-800 border-green-200';
|
||||
}
|
||||
};
|
||||
|
||||
const getUrgencyLabel = (urgency: DailyOrderItem['urgency']) => {
|
||||
switch (urgency) {
|
||||
case 'high':
|
||||
return 'Urgente';
|
||||
case 'medium':
|
||||
return 'Normal';
|
||||
case 'low':
|
||||
return 'Bajo';
|
||||
}
|
||||
};
|
||||
|
||||
const getStockStatusColor = (stockDays: number) => {
|
||||
if (stockDays <= 2) return 'bg-red-100 text-red-800';
|
||||
if (stockDays <= 5) return 'bg-yellow-100 text-yellow-800';
|
||||
return 'bg-green-100 text-green-800';
|
||||
};
|
||||
|
||||
const handleQuantityChange = (itemId: string, delta: number, type: 'daily' | 'weekly') => {
|
||||
const items = type === 'daily' ? dailyOrders : weeklyOrders;
|
||||
const item = items.find(i => i.id === itemId);
|
||||
if (item) {
|
||||
const newQuantity = Math.max(0, item.suggestedQuantity + delta);
|
||||
onUpdateQuantity(itemId, newQuantity, type);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleItemSelection = (itemId: string) => {
|
||||
const newSelected = new Set(selectedItems);
|
||||
if (newSelected.has(itemId)) {
|
||||
newSelected.delete(itemId);
|
||||
} else {
|
||||
newSelected.add(itemId);
|
||||
}
|
||||
setSelectedItems(newSelected);
|
||||
};
|
||||
|
||||
const handleCreateSelectedOrders = () => {
|
||||
if (activeTab === 'daily') {
|
||||
const selectedDailyItems = dailyOrders.filter(item => selectedItems.has(item.id));
|
||||
if (selectedDailyItems.length > 0) {
|
||||
onCreateOrder(selectedDailyItems, 'daily');
|
||||
}
|
||||
} else {
|
||||
const selectedWeeklyItems = weeklyOrders.filter(item => selectedItems.has(item.id));
|
||||
if (selectedWeeklyItems.length > 0) {
|
||||
onCreateOrder(selectedWeeklyItems, 'weekly');
|
||||
}
|
||||
}
|
||||
setSelectedItems(new Set());
|
||||
};
|
||||
|
||||
const totalDailyCost = dailyOrders
|
||||
.filter(item => selectedItems.has(item.id))
|
||||
.reduce((sum, item) => sum + item.estimatedCost, 0);
|
||||
|
||||
const totalWeeklyCost = weeklyOrders
|
||||
.filter(item => selectedItems.has(item.id))
|
||||
.reduce((sum, item) => sum + item.estimatedCost, 0);
|
||||
|
||||
return (
|
||||
<div className={`bg-white rounded-xl shadow-sm border border-gray-200 p-6 ${className}`}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 flex items-center">
|
||||
<ShoppingCart className="h-5 w-5 mr-2 text-primary-600" />
|
||||
Pedidos Sugeridos por IA
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
Optimización inteligente basada en predicciones de demanda
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={onViewDetails}
|
||||
className="flex items-center px-3 py-2 text-sm text-primary-600 hover:text-primary-700 hover:bg-primary-50 rounded-lg transition-colors"
|
||||
>
|
||||
<Eye className="h-4 w-4 mr-1" />
|
||||
Ver Detalles
|
||||
<ArrowRight className="h-3 w-3 ml-1" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex space-x-1 bg-gray-100 p-1 rounded-lg mb-6">
|
||||
<button
|
||||
onClick={() => setActiveTab('daily')}
|
||||
className={`flex-1 py-2 px-4 text-sm font-medium rounded-md transition-all flex items-center justify-center ${
|
||||
activeTab === 'daily'
|
||||
? 'bg-white text-primary-700 shadow-sm'
|
||||
: 'text-gray-600 hover:text-gray-900'
|
||||
}`}
|
||||
>
|
||||
<Calendar className="h-4 w-4 mr-2" />
|
||||
Diarios ({dailyOrders.length})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('weekly')}
|
||||
className={`flex-1 py-2 px-4 text-sm font-medium rounded-md transition-all flex items-center justify-center ${
|
||||
activeTab === 'weekly'
|
||||
? 'bg-white text-primary-700 shadow-sm'
|
||||
: 'text-gray-600 hover:text-gray-900'
|
||||
}`}
|
||||
>
|
||||
<Clock className="h-4 w-4 mr-2" />
|
||||
Semanales ({weeklyOrders.length})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Daily Orders Tab */}
|
||||
{activeTab === 'daily' && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm text-gray-600">
|
||||
Pedidos para mañana desde la panadería central
|
||||
</div>
|
||||
<div className="text-sm font-medium text-primary-600">
|
||||
Total seleccionado: €{totalDailyCost.toFixed(2)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{dailyOrders.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className={`border border-gray-200 rounded-lg p-4 transition-all ${
|
||||
selectedItems.has(item.id) ? 'ring-2 ring-primary-500 bg-primary-50' : 'hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedItems.has(item.id)}
|
||||
onChange={() => toggleItemSelection(item.id)}
|
||||
className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 rounded"
|
||||
/>
|
||||
<span className="text-2xl">{item.emoji}</span>
|
||||
<div>
|
||||
<div className="font-medium text-gray-900">{item.product}</div>
|
||||
<div className="text-sm text-gray-600">{item.supplier}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-3">
|
||||
<span className={`px-2 py-1 text-xs font-medium rounded-full border ${getUrgencyColor(item.urgency)}`}>
|
||||
{getUrgencyLabel(item.urgency)}
|
||||
</span>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<button
|
||||
onClick={() => handleQuantityChange(item.id, -5, 'daily')}
|
||||
className="w-6 h-6 rounded-full bg-gray-100 hover:bg-gray-200 flex items-center justify-center text-sm"
|
||||
disabled={item.suggestedQuantity <= 0}
|
||||
>
|
||||
<Minus className="h-3 w-3" />
|
||||
</button>
|
||||
<span className="w-12 text-center font-bold">{item.suggestedQuantity}</span>
|
||||
<button
|
||||
onClick={() => handleQuantityChange(item.id, 5, 'daily')}
|
||||
className="w-6 h-6 rounded-full bg-gray-100 hover:bg-gray-200 flex items-center justify-center text-sm"
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
</button>
|
||||
<span className="text-sm text-gray-600 ml-2">{item.unit}</span>
|
||||
</div>
|
||||
|
||||
<div className="text-right">
|
||||
<div className="font-semibold text-gray-900">€{item.estimatedCost.toFixed(2)}</div>
|
||||
<div className="text-xs text-gray-500">{item.confidence}% confianza</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 text-sm text-gray-600 flex items-start">
|
||||
<TrendingUp className="h-4 w-4 mr-2 text-blue-500 flex-shrink-0 mt-0.5" />
|
||||
<span>{item.reason}</span>
|
||||
</div>
|
||||
|
||||
{item.currentQuantity > 0 && (
|
||||
<div className="mt-2 text-xs text-gray-500">
|
||||
Stock actual: {item.currentQuantity} {item.unit}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{selectedItems.size > 0 && (
|
||||
<div className="bg-primary-50 border border-primary-200 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="font-medium text-primary-900">
|
||||
{selectedItems.size} productos seleccionados
|
||||
</div>
|
||||
<div className="text-sm text-primary-700">
|
||||
Total estimado: €{totalDailyCost.toFixed(2)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleCreateSelectedOrders}
|
||||
className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors font-medium"
|
||||
>
|
||||
Crear Pedido Diario
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Weekly Orders Tab */}
|
||||
{activeTab === 'weekly' && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm text-gray-600">
|
||||
Pedidos semanales para ingredientes y suministros
|
||||
</div>
|
||||
<div className="text-sm font-medium text-primary-600">
|
||||
Total seleccionado: €{totalWeeklyCost.toFixed(2)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{weeklyOrders.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className={`border border-gray-200 rounded-lg p-4 transition-all ${
|
||||
selectedItems.has(item.id) ? 'ring-2 ring-primary-500 bg-primary-50' : 'hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedItems.has(item.id)}
|
||||
onChange={() => toggleItemSelection(item.id)}
|
||||
className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 rounded"
|
||||
/>
|
||||
<span className="text-2xl">{item.emoji}</span>
|
||||
<div>
|
||||
<div className="font-medium text-gray-900">{item.product}</div>
|
||||
<div className="text-sm text-gray-600">{item.supplier}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-3">
|
||||
<span className={`px-2 py-1 text-xs font-medium rounded ${getStockStatusColor(item.stockDays)}`}>
|
||||
{item.stockDays} días de stock
|
||||
</span>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<button
|
||||
onClick={() => handleQuantityChange(item.id, -1, 'weekly')}
|
||||
className="w-6 h-6 rounded-full bg-gray-100 hover:bg-gray-200 flex items-center justify-center text-sm"
|
||||
disabled={item.suggestedQuantity <= 0}
|
||||
>
|
||||
<Minus className="h-3 w-3" />
|
||||
</button>
|
||||
<span className="w-12 text-center font-bold">{item.suggestedQuantity}</span>
|
||||
<button
|
||||
onClick={() => handleQuantityChange(item.id, 1, 'weekly')}
|
||||
className="w-6 h-6 rounded-full bg-gray-100 hover:bg-gray-200 flex items-center justify-center text-sm"
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
</button>
|
||||
<span className="text-sm text-gray-600 ml-2">{item.unit}</span>
|
||||
</div>
|
||||
|
||||
<div className="text-right">
|
||||
<div className="font-semibold text-gray-900">€{item.estimatedCost.toFixed(2)}</div>
|
||||
<div className="text-xs text-gray-500">{item.confidence}% confianza</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 grid grid-cols-2 gap-4 text-sm text-gray-600">
|
||||
<div>
|
||||
<span className="font-medium">Stock actual:</span> {item.currentStock} {item.unit}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium">Próximo pedido:</span> {new Date(item.nextOrderDate).toLocaleDateString('es-ES')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex items-center text-xs text-gray-500">
|
||||
<CheckCircle className="h-3 w-3 mr-1 text-green-500" />
|
||||
Frecuencia: {item.frequency === 'weekly' ? 'Semanal' : 'Quincenal'}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{selectedItems.size > 0 && (
|
||||
<div className="bg-primary-50 border border-primary-200 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="font-medium text-primary-900">
|
||||
{selectedItems.size} productos seleccionados
|
||||
</div>
|
||||
<div className="text-sm text-primary-700">
|
||||
Total estimado: €{totalWeeklyCost.toFixed(2)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleCreateSelectedOrders}
|
||||
className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors font-medium"
|
||||
>
|
||||
Crear Pedido Semanal
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty States */}
|
||||
{activeTab === 'daily' && dailyOrders.length === 0 && (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
<Calendar className="h-12 w-12 mx-auto mb-3 text-gray-400" />
|
||||
<p>No hay pedidos diarios sugeridos</p>
|
||||
<p className="text-sm">El stock actual es suficiente para mañana</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'weekly' && weeklyOrders.length === 0 && (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
<Clock className="h-12 w-12 mx-auto mb-3 text-gray-400" />
|
||||
<p>No hay pedidos semanales pendientes</p>
|
||||
<p className="text-sm">Todos los suministros están en stock</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default OrderSuggestions;
|
||||
182
frontend/src/components/simple/QuickActions.tsx
Normal file
182
frontend/src/components/simple/QuickActions.tsx
Normal file
@@ -0,0 +1,182 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Phone, FileText, AlertTriangle, Package,
|
||||
Euro, BarChart3, Settings, Calendar,
|
||||
Plus, Clock
|
||||
} from 'lucide-react';
|
||||
|
||||
export interface QuickAction {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: any;
|
||||
description?: string;
|
||||
variant: 'primary' | 'secondary' | 'urgent';
|
||||
badge?: string;
|
||||
}
|
||||
|
||||
interface QuickActionsProps {
|
||||
actions?: QuickAction[];
|
||||
onActionClick?: (actionId: string) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const QuickActions: React.FC<QuickActionsProps> = ({
|
||||
actions = [
|
||||
{
|
||||
id: 'call_supplier',
|
||||
label: 'Llamar Proveedor',
|
||||
icon: Phone,
|
||||
description: 'Contactar proveedor principal',
|
||||
variant: 'urgent',
|
||||
badge: 'Urgente'
|
||||
},
|
||||
{
|
||||
id: 'record_sale',
|
||||
label: 'Anotar Venta',
|
||||
icon: FileText,
|
||||
description: 'Registrar venta manual',
|
||||
variant: 'primary'
|
||||
},
|
||||
{
|
||||
id: 'stock_issue',
|
||||
label: 'Problema Stock',
|
||||
icon: AlertTriangle,
|
||||
description: 'Reportar falta de producto',
|
||||
variant: 'urgent'
|
||||
},
|
||||
{
|
||||
id: 'view_orders',
|
||||
label: 'Ver Pedidos',
|
||||
icon: Package,
|
||||
description: 'Revisar pedidos especiales',
|
||||
variant: 'secondary',
|
||||
badge: '3'
|
||||
},
|
||||
{
|
||||
id: 'close_register',
|
||||
label: 'Cerrar Caja',
|
||||
icon: Euro,
|
||||
description: 'Arqueo de caja diario',
|
||||
variant: 'primary'
|
||||
},
|
||||
{
|
||||
id: 'view_sales',
|
||||
label: 'Ver Ventas',
|
||||
icon: BarChart3,
|
||||
description: 'Resumen de ventas del día',
|
||||
variant: 'secondary'
|
||||
}
|
||||
],
|
||||
onActionClick,
|
||||
className = ''
|
||||
}) => {
|
||||
const getActionStyles = (variant: QuickAction['variant']) => {
|
||||
switch (variant) {
|
||||
case 'primary':
|
||||
return {
|
||||
button: 'bg-blue-600 hover:bg-blue-700 text-white border-blue-600',
|
||||
icon: 'text-white'
|
||||
};
|
||||
case 'urgent':
|
||||
return {
|
||||
button: 'bg-red-600 hover:bg-red-700 text-white border-red-600',
|
||||
icon: 'text-white'
|
||||
};
|
||||
case 'secondary':
|
||||
return {
|
||||
button: 'bg-white hover:bg-gray-50 text-gray-700 border-gray-300',
|
||||
icon: 'text-gray-600'
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`bg-white rounded-xl shadow-sm border border-gray-200 p-6 ${className}`}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900 flex items-center">
|
||||
<Clock className="h-5 w-5 mr-2 text-purple-600" />
|
||||
Acciones Rápidas
|
||||
</h3>
|
||||
<span className="text-xs text-gray-500">
|
||||
Tareas comunes
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Actions Grid */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{actions.map((action) => {
|
||||
const IconComponent = action.icon;
|
||||
const styles = getActionStyles(action.variant);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={action.id}
|
||||
onClick={() => onActionClick?.(action.id)}
|
||||
className={`
|
||||
relative p-4 border rounded-xl transition-all duration-200
|
||||
hover:shadow-md hover:scale-[1.02] active:scale-[0.98]
|
||||
${styles.button}
|
||||
`}
|
||||
>
|
||||
{/* Badge */}
|
||||
{action.badge && (
|
||||
<span className="absolute -top-2 -right-2 bg-red-500 text-white text-xs rounded-full h-5 w-5 flex items-center justify-center font-medium">
|
||||
{action.badge}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Icon */}
|
||||
<div className="flex items-center justify-center mb-2">
|
||||
<IconComponent className={`h-6 w-6 ${styles.icon}`} />
|
||||
</div>
|
||||
|
||||
{/* Label */}
|
||||
<h4 className="text-sm font-medium text-center leading-tight">
|
||||
{action.label}
|
||||
</h4>
|
||||
|
||||
{/* Description */}
|
||||
{action.description && (
|
||||
<p className={`text-xs mt-1 text-center leading-tight ${
|
||||
action.variant === 'secondary' ? 'text-gray-500' : 'text-white/80'
|
||||
}`}>
|
||||
{action.description}
|
||||
</p>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Quick Stats */}
|
||||
<div className="flex items-center justify-between pt-4 mt-4 border-t border-gray-100">
|
||||
<div className="flex items-center space-x-4 text-xs text-gray-500">
|
||||
<span className="flex items-center">
|
||||
<div className="w-2 h-2 bg-red-500 rounded-full mr-1"></div>
|
||||
{actions.filter(a => a.variant === 'urgent').length} Urgentes
|
||||
</span>
|
||||
<span className="flex items-center">
|
||||
<div className="w-2 h-2 bg-blue-500 rounded-full mr-1"></div>
|
||||
{actions.filter(a => a.variant === 'primary').length} Principales
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Add Action */}
|
||||
<button className="flex items-center text-xs text-gray-600 hover:text-gray-900 font-medium">
|
||||
<Plus className="h-3 w-3 mr-1" />
|
||||
Personalizar
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Keyboard Shortcuts Hint */}
|
||||
<div className="mt-3 p-2 bg-gray-50 rounded-lg">
|
||||
<p className="text-xs text-gray-600 text-center">
|
||||
💡 Usa Ctrl + K para acceso rápido por teclado
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default QuickActions;
|
||||
130
frontend/src/components/simple/QuickOverview.tsx
Normal file
130
frontend/src/components/simple/QuickOverview.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
import React from 'react';
|
||||
import { Package, BarChart3, Cloud, ChevronRight, Calendar, TrendingUp } from 'lucide-react';
|
||||
|
||||
interface QuickOverviewProps {
|
||||
onNavigateToOrders?: () => void;
|
||||
onNavigateToReports?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const QuickOverview: React.FC<QuickOverviewProps> = ({
|
||||
onNavigateToOrders,
|
||||
onNavigateToReports,
|
||||
className = ''
|
||||
}) => {
|
||||
return (
|
||||
<div className={`grid grid-cols-1 md:grid-cols-3 gap-6 ${className}`}>
|
||||
{/* Orders Summary */}
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h4 className="text-base font-semibold text-gray-900 flex items-center">
|
||||
<Package className="h-4 w-4 mr-2 text-green-600" />
|
||||
Pedidos
|
||||
</h4>
|
||||
<button
|
||||
onClick={onNavigateToOrders}
|
||||
className="text-xs text-blue-600 hover:text-blue-700 font-medium flex items-center"
|
||||
>
|
||||
Ver todos <ChevronRight className="h-3 w-3 ml-1" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between p-2 bg-yellow-50 rounded-lg border border-yellow-200">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Calendar className="h-3 w-3 text-yellow-600" />
|
||||
<span className="text-sm font-medium text-yellow-900">Tarta Ana</span>
|
||||
</div>
|
||||
<span className="text-xs text-yellow-700 font-medium">Viernes</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between p-2 bg-blue-50 rounded-lg border border-blue-200">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Calendar className="h-3 w-3 text-blue-600" />
|
||||
<span className="text-sm font-medium text-blue-900">Cumple Sara</span>
|
||||
</div>
|
||||
<span className="text-xs text-blue-700 font-medium">Sábado</span>
|
||||
</div>
|
||||
|
||||
<div className="text-center py-2">
|
||||
<span className="text-xs text-gray-500">2 pedidos especiales esta semana</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Weekly Summary */}
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h4 className="text-base font-semibold text-gray-900 flex items-center">
|
||||
<BarChart3 className="h-4 w-4 mr-2 text-blue-600" />
|
||||
Esta Semana
|
||||
</h4>
|
||||
<button
|
||||
onClick={onNavigateToReports}
|
||||
className="text-xs text-blue-600 hover:text-blue-700 font-medium flex items-center"
|
||||
>
|
||||
Ver informes <ChevronRight className="h-3 w-3 ml-1" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-600">Ventas totales</span>
|
||||
<span className="text-sm font-semibold text-gray-900">€1,940</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-600">Mejor día</span>
|
||||
<span className="text-sm font-semibold text-green-600">Martes (+18%)</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-600">Producto top</span>
|
||||
<span className="text-sm font-semibold text-gray-900">🥐 Croissants</span>
|
||||
</div>
|
||||
|
||||
<div className="pt-2 border-t border-gray-100">
|
||||
<div className="flex items-center justify-center text-xs text-green-600">
|
||||
<TrendingUp className="h-3 w-3 mr-1" />
|
||||
+8% vs semana anterior
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Weather & Context */}
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h4 className="text-base font-semibold text-gray-900 flex items-center">
|
||||
<Cloud className="h-4 w-4 mr-2 text-gray-600" />
|
||||
Clima & Contexto
|
||||
</h4>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between p-3 bg-blue-50 rounded-lg border border-blue-200">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-blue-900">Lluvia esperada</div>
|
||||
<div className="text-xs text-blue-700">14:00 - 17:00</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-lg font-bold text-blue-900">🌧️</div>
|
||||
<div className="text-xs text-blue-700">12°C</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between p-2 bg-yellow-50 rounded-lg border border-yellow-200">
|
||||
<span className="text-sm font-medium text-yellow-900">Impacto estimado</span>
|
||||
<span className="text-sm font-bold text-yellow-900">-15% ventas</span>
|
||||
</div>
|
||||
|
||||
<div className="text-center py-1">
|
||||
<span className="text-xs text-gray-500">Predicciones ya ajustadas</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default QuickOverview;
|
||||
231
frontend/src/components/simple/TodayProduction.tsx
Normal file
231
frontend/src/components/simple/TodayProduction.tsx
Normal file
@@ -0,0 +1,231 @@
|
||||
import React from 'react';
|
||||
import { CheckCircle2, Clock, Loader2, Calendar, Plus, Minus } from 'lucide-react';
|
||||
|
||||
export interface ProductionItem {
|
||||
id: string;
|
||||
product: string;
|
||||
emoji: string;
|
||||
quantity: number;
|
||||
status: 'completed' | 'in_progress' | 'pending';
|
||||
scheduledTime: string;
|
||||
completedTime?: string;
|
||||
confidence?: number;
|
||||
}
|
||||
|
||||
interface TodayProductionProps {
|
||||
items?: ProductionItem[];
|
||||
onUpdateQuantity?: (itemId: string, newQuantity: number) => void;
|
||||
onUpdateStatus?: (itemId: string, status: ProductionItem['status']) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const TodayProduction: React.FC<TodayProductionProps> = ({
|
||||
items = [
|
||||
{
|
||||
id: '1',
|
||||
product: 'Croissants',
|
||||
emoji: '🥐',
|
||||
quantity: 45,
|
||||
status: 'completed',
|
||||
scheduledTime: '06:00',
|
||||
completedTime: '06:30',
|
||||
confidence: 92
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
product: 'Pan integral',
|
||||
emoji: '🍞',
|
||||
quantity: 30,
|
||||
status: 'in_progress',
|
||||
scheduledTime: '07:30',
|
||||
confidence: 87
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
product: 'Magdalenas',
|
||||
emoji: '🧁',
|
||||
quantity: 25,
|
||||
status: 'pending',
|
||||
scheduledTime: '09:00',
|
||||
confidence: 78
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
product: 'Empanadas',
|
||||
emoji: '🥟',
|
||||
quantity: 20,
|
||||
status: 'pending',
|
||||
scheduledTime: '10:30',
|
||||
confidence: 85
|
||||
}
|
||||
],
|
||||
onUpdateQuantity,
|
||||
onUpdateStatus,
|
||||
className = ''
|
||||
}) => {
|
||||
const getStatusIcon = (status: ProductionItem['status']) => {
|
||||
switch (status) {
|
||||
case 'completed': return <CheckCircle2 className="h-5 w-5 text-green-600" />;
|
||||
case 'in_progress': return <Loader2 className="h-5 w-5 text-blue-600 animate-spin" />;
|
||||
case 'pending': return <Clock className="h-5 w-5 text-gray-400" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusText = (status: ProductionItem['status']) => {
|
||||
switch (status) {
|
||||
case 'completed': return 'Listo';
|
||||
case 'in_progress': return 'Haciendo';
|
||||
case 'pending': return 'Pendiente';
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColors = (status: ProductionItem['status']) => {
|
||||
switch (status) {
|
||||
case 'completed': return 'bg-green-50 border-green-200';
|
||||
case 'in_progress': return 'bg-blue-50 border-blue-200';
|
||||
case 'pending': return 'bg-gray-50 border-gray-200';
|
||||
}
|
||||
};
|
||||
|
||||
const completedCount = items.filter(item => item.status === 'completed').length;
|
||||
const inProgressCount = items.filter(item => item.status === 'in_progress').length;
|
||||
const pendingCount = items.filter(item => item.status === 'pending').length;
|
||||
|
||||
return (
|
||||
<div className={`bg-white rounded-xl shadow-sm border border-gray-200 p-6 ${className}`}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900 flex items-center">
|
||||
<Calendar className="h-5 w-5 mr-2 text-blue-600" />
|
||||
Producir Hoy
|
||||
</h3>
|
||||
<div className="text-xs text-gray-500">
|
||||
{new Date().toLocaleDateString('es-ES', { weekday: 'long', day: 'numeric', month: 'long' })}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress Summary */}
|
||||
<div className="flex items-center space-x-4 mb-4 p-3 bg-gray-50 rounded-lg">
|
||||
<div className="text-center">
|
||||
<div className="text-lg font-bold text-green-600">{completedCount}</div>
|
||||
<div className="text-xs text-gray-600">Listos</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-lg font-bold text-blue-600">{inProgressCount}</div>
|
||||
<div className="text-xs text-gray-600">En curso</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-lg font-bold text-gray-600">{pendingCount}</div>
|
||||
<div className="text-xs text-gray-600">Pendientes</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-lg font-bold text-gray-900">{items.reduce((sum, item) => sum + item.quantity, 0)}</div>
|
||||
<div className="text-xs text-gray-600">Total uds</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Production Items */}
|
||||
<div className="space-y-3">
|
||||
{items.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className={`border rounded-lg p-4 transition-all duration-200 ${getStatusColors(item.status)}`}
|
||||
>
|
||||
<div className="flex items-center space-x-3">
|
||||
{/* Status Icon */}
|
||||
<div className="flex-shrink-0">
|
||||
{getStatusIcon(item.status)}
|
||||
</div>
|
||||
|
||||
{/* Product Info */}
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center space-x-2 mb-1">
|
||||
<span className="text-lg">{item.emoji}</span>
|
||||
<h4 className="font-medium text-gray-900">{item.product}</h4>
|
||||
{item.confidence && (
|
||||
<span className="text-xs text-gray-500">
|
||||
({item.confidence}% confianza)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-4 text-sm text-gray-600">
|
||||
<span className="flex items-center">
|
||||
<Clock className="h-3 w-3 mr-1" />
|
||||
{item.status === 'completed' && item.completedTime
|
||||
? `Listo a las ${item.completedTime}`
|
||||
: `Programado ${item.scheduledTime}`}
|
||||
</span>
|
||||
<span className={`px-2 py-1 rounded text-xs font-medium ${
|
||||
item.status === 'completed' ? 'bg-green-100 text-green-800' :
|
||||
item.status === 'in_progress' ? 'bg-blue-100 text-blue-800' :
|
||||
'bg-gray-100 text-gray-800'
|
||||
}`}>
|
||||
{getStatusText(item.status)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quantity Controls */}
|
||||
<div className="flex-shrink-0">
|
||||
<div className="flex items-center space-x-2">
|
||||
<button
|
||||
onClick={() => onUpdateQuantity?.(item.id, Math.max(1, item.quantity - 1))}
|
||||
disabled={item.status === 'completed'}
|
||||
className="p-1 rounded hover:bg-gray-200 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Minus className="h-3 w-3" />
|
||||
</button>
|
||||
|
||||
<span className="text-lg font-bold text-gray-900 min-w-[40px] text-center">
|
||||
{item.quantity}
|
||||
</span>
|
||||
|
||||
<button
|
||||
onClick={() => onUpdateQuantity?.(item.id, item.quantity + 1)}
|
||||
disabled={item.status === 'completed'}
|
||||
className="p-1 rounded hover:bg-gray-200 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 text-center mt-1">uds</div>
|
||||
</div>
|
||||
|
||||
{/* Action Button */}
|
||||
{item.status !== 'completed' && (
|
||||
<div className="flex-shrink-0">
|
||||
<button
|
||||
onClick={() => {
|
||||
const nextStatus = item.status === 'pending' ? 'in_progress' : 'completed';
|
||||
onUpdateStatus?.(item.id, nextStatus);
|
||||
}}
|
||||
className={`px-3 py-1 rounded text-xs font-medium transition-colors ${
|
||||
item.status === 'pending'
|
||||
? 'bg-blue-600 text-white hover:bg-blue-700'
|
||||
: 'bg-green-600 text-white hover:bg-green-700'
|
||||
}`}
|
||||
>
|
||||
{item.status === 'pending' ? 'Iniciar' : 'Completar'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div className="flex items-center justify-between pt-4 mt-4 border-t border-gray-100">
|
||||
<button className="text-sm text-gray-600 hover:text-gray-900 font-medium">
|
||||
+ Agregar producto
|
||||
</button>
|
||||
<button className="text-sm text-blue-600 hover:text-blue-700 font-medium">
|
||||
Ver horario completo →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TodayProduction;
|
||||
95
frontend/src/components/simple/TodayRevenue.tsx
Normal file
95
frontend/src/components/simple/TodayRevenue.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import React from 'react';
|
||||
import { TrendingUp, TrendingDown, Euro } from 'lucide-react';
|
||||
|
||||
interface TodayRevenueProps {
|
||||
currentRevenue: number;
|
||||
previousRevenue: number;
|
||||
dailyTarget: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const TodayRevenue: React.FC<TodayRevenueProps> = ({
|
||||
currentRevenue = 287.50,
|
||||
previousRevenue = 256.25,
|
||||
dailyTarget = 350,
|
||||
className = ''
|
||||
}) => {
|
||||
const changeAmount = currentRevenue - previousRevenue;
|
||||
const changePercentage = ((changeAmount / previousRevenue) * 100);
|
||||
const remainingToTarget = Math.max(0, dailyTarget - currentRevenue);
|
||||
const targetProgress = (currentRevenue / dailyTarget) * 100;
|
||||
const isPositive = changeAmount >= 0;
|
||||
|
||||
return (
|
||||
<div className={`bg-white rounded-xl shadow-sm border border-gray-200 p-6 ${className}`}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900 flex items-center">
|
||||
<Euro className="h-5 w-5 mr-2 text-green-600" />
|
||||
Ingresos de Hoy
|
||||
</h3>
|
||||
<span className="text-xs text-gray-500">
|
||||
{new Date().toLocaleDateString('es-ES')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Main Revenue */}
|
||||
<div className="mb-4">
|
||||
<div className="flex items-baseline space-x-2">
|
||||
<span className="text-3xl font-bold text-gray-900">
|
||||
€{currentRevenue.toFixed(2)}
|
||||
</span>
|
||||
<div className={`flex items-center text-sm font-medium ${
|
||||
isPositive ? 'text-green-600' : 'text-red-600'
|
||||
}`}>
|
||||
{isPositive ? (
|
||||
<TrendingUp className="h-4 w-4 mr-1" />
|
||||
) : (
|
||||
<TrendingDown className="h-4 w-4 mr-1" />
|
||||
)}
|
||||
{isPositive ? '+' : ''}{changePercentage.toFixed(1)}%
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
{isPositive ? '+' : ''} €{changeAmount.toFixed(2)} vs ayer
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Target Progress */}
|
||||
<div className="mb-4">
|
||||
<div className="flex items-center justify-between text-sm text-gray-600 mb-2">
|
||||
<span>Meta del día: €{dailyTarget}</span>
|
||||
<span>{targetProgress.toFixed(1)}%</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 rounded-full h-2">
|
||||
<div
|
||||
className={`h-2 rounded-full transition-all duration-300 ${
|
||||
targetProgress >= 100 ? 'bg-green-500' :
|
||||
targetProgress >= 80 ? 'bg-yellow-500' : 'bg-blue-500'
|
||||
}`}
|
||||
style={{ width: `${Math.min(targetProgress, 100)}%` }}
|
||||
></div>
|
||||
</div>
|
||||
{remainingToTarget > 0 && (
|
||||
<p className="text-sm text-gray-600 mt-2">
|
||||
Faltan €{remainingToTarget.toFixed(2)} para la meta
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Quick Stats */}
|
||||
<div className="grid grid-cols-2 gap-3 pt-4 border-t border-gray-100">
|
||||
<div className="text-center">
|
||||
<p className="text-xs text-gray-500">Esta Semana</p>
|
||||
<p className="text-lg font-semibold text-gray-900">€1,940</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-xs text-gray-500">Promedio/Día</p>
|
||||
<p className="text-lg font-semibold text-gray-900">€{(1940/7).toFixed(0)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TodayRevenue;
|
||||
438
frontend/src/components/ui/AIInsightsFeed.tsx
Normal file
438
frontend/src/components/ui/AIInsightsFeed.tsx
Normal file
@@ -0,0 +1,438 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Brain, TrendingUp, TrendingDown, Star, Clock, Eye, EyeOff, MoreHorizontal } from 'lucide-react';
|
||||
|
||||
interface AIInsight {
|
||||
id: string;
|
||||
type: 'trend' | 'opportunity' | 'warning' | 'recommendation' | 'achievement';
|
||||
title: string;
|
||||
description: string;
|
||||
confidence: number;
|
||||
impact: 'high' | 'medium' | 'low';
|
||||
category: 'demand' | 'revenue' | 'efficiency' | 'quality' | 'customer';
|
||||
timestamp: string;
|
||||
data?: {
|
||||
trend?: {
|
||||
direction: 'up' | 'down';
|
||||
percentage: number;
|
||||
period: string;
|
||||
};
|
||||
revenue?: {
|
||||
amount: number;
|
||||
comparison: string;
|
||||
};
|
||||
actionable?: {
|
||||
action: string;
|
||||
expectedImpact: string;
|
||||
};
|
||||
};
|
||||
isRead?: boolean;
|
||||
isStarred?: boolean;
|
||||
}
|
||||
|
||||
interface AIInsightsFeedProps {
|
||||
insights?: AIInsight[];
|
||||
onInsightAction?: (insightId: string, action: 'read' | 'star' | 'dismiss') => void;
|
||||
maxItems?: number;
|
||||
showFilters?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const AIInsightsFeed: React.FC<AIInsightsFeedProps> = ({
|
||||
insights: propInsights,
|
||||
onInsightAction,
|
||||
maxItems = 10,
|
||||
showFilters = true,
|
||||
className = ''
|
||||
}) => {
|
||||
const [insights, setInsights] = useState<AIInsight[]>([]);
|
||||
const [selectedCategory, setSelectedCategory] = useState<string>('all');
|
||||
const [selectedImpact, setSelectedImpact] = useState<string>('all');
|
||||
const [showOnlyUnread, setShowOnlyUnread] = useState(false);
|
||||
|
||||
// Generate realistic AI insights if none provided
|
||||
useEffect(() => {
|
||||
if (propInsights) {
|
||||
setInsights(propInsights);
|
||||
} else {
|
||||
setInsights(generateSampleInsights());
|
||||
}
|
||||
}, [propInsights]);
|
||||
|
||||
const generateSampleInsights = (): AIInsight[] => {
|
||||
const now = new Date();
|
||||
const yesterday = new Date(now.getTime() - 24 * 60 * 60 * 1000);
|
||||
const twoDaysAgo = new Date(now.getTime() - 2 * 24 * 60 * 60 * 1000);
|
||||
|
||||
return [
|
||||
{
|
||||
id: '1',
|
||||
type: 'opportunity',
|
||||
title: 'Oportunidad: Incremento en demanda de tartas',
|
||||
description: 'Los datos muestran un aumento del 23% en la demanda de tartas los viernes. Considera aumentar la producción para maximizar ingresos.',
|
||||
confidence: 87,
|
||||
impact: 'high',
|
||||
category: 'revenue',
|
||||
timestamp: now.toISOString(),
|
||||
data: {
|
||||
trend: { direction: 'up', percentage: 23, period: 'últimas 3 semanas' },
|
||||
actionable: { action: 'Aumentar producción de tartas los viernes', expectedImpact: '+€180/semana' }
|
||||
},
|
||||
isRead: false,
|
||||
isStarred: false
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
type: 'warning',
|
||||
title: 'Alerta: Posible desperdicio de magdalenas',
|
||||
description: 'Las magdalenas tienen una tasa de venta del 67% los martes. Reducir la producción podría ahorrar €45 semanales.',
|
||||
confidence: 78,
|
||||
impact: 'medium',
|
||||
category: 'efficiency',
|
||||
timestamp: yesterday.toISOString(),
|
||||
data: {
|
||||
revenue: { amount: 45, comparison: 'ahorro semanal estimado' },
|
||||
actionable: { action: 'Reducir producción de magdalenas los martes', expectedImpact: '-€45 desperdicio' }
|
||||
},
|
||||
isRead: true,
|
||||
isStarred: false
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
type: 'trend',
|
||||
title: 'Tendencia: Croissants más populares por las mañanas',
|
||||
description: 'El 78% de las ventas de croissants ocurren antes de las 11 AM. Considera reorganizar la producción matutina.',
|
||||
confidence: 92,
|
||||
impact: 'medium',
|
||||
category: 'efficiency',
|
||||
timestamp: yesterday.toISOString(),
|
||||
data: {
|
||||
trend: { direction: 'up', percentage: 78, period: 'horario matutino' },
|
||||
actionable: { action: 'Priorizar croissants en producción matutina', expectedImpact: 'Mejor disponibilidad' }
|
||||
},
|
||||
isRead: false,
|
||||
isStarred: true
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
type: 'achievement',
|
||||
title: '¡Éxito! Predicciones de ayer fueron 94% precisas',
|
||||
description: 'Las predicciones de demanda de ayer tuvieron una precisión del 94%, resultando en ventas óptimas sin desperdicios.',
|
||||
confidence: 94,
|
||||
impact: 'high',
|
||||
category: 'quality',
|
||||
timestamp: yesterday.toISOString(),
|
||||
data: {
|
||||
revenue: { amount: 127, comparison: 'ingresos adicionales por precisión' }
|
||||
},
|
||||
isRead: false,
|
||||
isStarred: false
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
type: 'recommendation',
|
||||
title: 'Recomendación: Nuevo producto para fin de semana',
|
||||
description: 'Los datos de fin de semana sugieren que los clientes buscan productos especiales. Una tarta de temporada podría generar €200 adicionales.',
|
||||
confidence: 72,
|
||||
impact: 'high',
|
||||
category: 'revenue',
|
||||
timestamp: twoDaysAgo.toISOString(),
|
||||
data: {
|
||||
revenue: { amount: 200, comparison: 'ingresos potenciales fin de semana' },
|
||||
actionable: { action: 'Introducir tarta especial de fin de semana', expectedImpact: '+€200/semana' }
|
||||
},
|
||||
isRead: true,
|
||||
isStarred: false
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
type: 'trend',
|
||||
title: 'Patrón meteorológico: Lluvia afecta ventas -15%',
|
||||
description: 'Los días lluviosos reducen las ventas en promedio 15%. El algoritmo ahora ajusta automáticamente las predicciones.',
|
||||
confidence: 88,
|
||||
impact: 'medium',
|
||||
category: 'demand',
|
||||
timestamp: twoDaysAgo.toISOString(),
|
||||
data: {
|
||||
trend: { direction: 'down', percentage: 15, period: 'días lluviosos' },
|
||||
actionable: { action: 'Producción automáticamente ajustada', expectedImpact: 'Menos desperdicio' }
|
||||
},
|
||||
isRead: true,
|
||||
isStarred: false
|
||||
}
|
||||
];
|
||||
};
|
||||
|
||||
const handleInsightAction = (insightId: string, action: 'read' | 'star' | 'dismiss') => {
|
||||
setInsights(prev => prev.map(insight => {
|
||||
if (insight.id === insightId) {
|
||||
switch (action) {
|
||||
case 'read':
|
||||
return { ...insight, isRead: true };
|
||||
case 'star':
|
||||
return { ...insight, isStarred: !insight.isStarred };
|
||||
case 'dismiss':
|
||||
return insight; // In a real app, this might remove the insight
|
||||
}
|
||||
}
|
||||
return insight;
|
||||
}));
|
||||
|
||||
onInsightAction?.(insightId, action);
|
||||
};
|
||||
|
||||
const filteredInsights = insights.filter(insight => {
|
||||
if (selectedCategory !== 'all' && insight.category !== selectedCategory) return false;
|
||||
if (selectedImpact !== 'all' && insight.impact !== selectedImpact) return false;
|
||||
if (showOnlyUnread && insight.isRead) return false;
|
||||
return true;
|
||||
}).slice(0, maxItems);
|
||||
|
||||
const getInsightIcon = (type: AIInsight['type']) => {
|
||||
switch (type) {
|
||||
case 'trend': return TrendingUp;
|
||||
case 'opportunity': return Star;
|
||||
case 'warning': return TrendingDown;
|
||||
case 'recommendation': return Brain;
|
||||
case 'achievement': return Star;
|
||||
default: return Brain;
|
||||
}
|
||||
};
|
||||
|
||||
const getInsightColors = (type: AIInsight['type'], impact: AIInsight['impact']) => {
|
||||
const baseColors = {
|
||||
trend: 'blue',
|
||||
opportunity: 'green',
|
||||
warning: 'yellow',
|
||||
recommendation: 'purple',
|
||||
achievement: 'emerald'
|
||||
};
|
||||
|
||||
const color = baseColors[type] || 'gray';
|
||||
const intensity = impact === 'high' ? '600' : impact === 'medium' ? '500' : '400';
|
||||
|
||||
return {
|
||||
background: `bg-${color}-50`,
|
||||
border: `border-${color}-200`,
|
||||
icon: `text-${color}-${intensity}`,
|
||||
badge: impact === 'high' ? `bg-${color}-100 text-${color}-800` :
|
||||
impact === 'medium' ? `bg-${color}-50 text-${color}-700` :
|
||||
`bg-gray-100 text-gray-600`
|
||||
};
|
||||
};
|
||||
|
||||
const formatTimestamp = (timestamp: string) => {
|
||||
const date = new Date(timestamp);
|
||||
const now = new Date();
|
||||
const diffHours = Math.floor((now.getTime() - date.getTime()) / (1000 * 60 * 60));
|
||||
|
||||
if (diffHours < 1) return 'Hace unos minutos';
|
||||
if (diffHours < 24) return `Hace ${diffHours}h`;
|
||||
if (diffHours < 48) return 'Ayer';
|
||||
return date.toLocaleDateString('es-ES', { day: 'numeric', month: 'short' });
|
||||
};
|
||||
|
||||
const unreadCount = insights.filter(i => !i.isRead).length;
|
||||
|
||||
return (
|
||||
<div className={`bg-white rounded-xl shadow-soft ${className}`}>
|
||||
{/* Header */}
|
||||
<div className="p-6 border-b border-gray-200">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<Brain className="h-6 w-6 text-purple-600 mr-3" />
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">
|
||||
Insights de IA
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600">
|
||||
Recomendaciones inteligentes para tu negocio
|
||||
{unreadCount > 0 && (
|
||||
<span className="ml-2 px-2 py-1 bg-purple-100 text-purple-800 text-xs rounded-full">
|
||||
{unreadCount} nuevos
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
{showFilters && (
|
||||
<div className="mt-4 flex flex-wrap gap-3">
|
||||
<select
|
||||
value={selectedCategory}
|
||||
onChange={(e) => setSelectedCategory(e.target.value)}
|
||||
className="text-sm border border-gray-300 rounded-lg px-3 py-1.5 focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
>
|
||||
<option value="all">Todas las categorías</option>
|
||||
<option value="demand">Demanda</option>
|
||||
<option value="revenue">Ingresos</option>
|
||||
<option value="efficiency">Eficiencia</option>
|
||||
<option value="quality">Calidad</option>
|
||||
<option value="customer">Cliente</option>
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={selectedImpact}
|
||||
onChange={(e) => setSelectedImpact(e.target.value)}
|
||||
className="text-sm border border-gray-300 rounded-lg px-3 py-1.5 focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
>
|
||||
<option value="all">Todos los impactos</option>
|
||||
<option value="high">Alto impacto</option>
|
||||
<option value="medium">Impacto medio</option>
|
||||
<option value="low">Bajo impacto</option>
|
||||
</select>
|
||||
|
||||
<label className="flex items-center text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showOnlyUnread}
|
||||
onChange={(e) => setShowOnlyUnread(e.target.checked)}
|
||||
className="h-4 w-4 text-purple-600 focus:ring-purple-500 border-gray-300 rounded mr-2"
|
||||
/>
|
||||
Solo no leídos
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Insights List */}
|
||||
<div className="divide-y divide-gray-100 max-h-96 overflow-y-auto">
|
||||
{filteredInsights.map((insight) => {
|
||||
const IconComponent = getInsightIcon(insight.type);
|
||||
const colors = getInsightColors(insight.type, insight.impact);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={insight.id}
|
||||
className={`p-4 hover:bg-gray-50 transition-colors ${!insight.isRead ? 'bg-purple-25' : ''}`}
|
||||
>
|
||||
<div className="flex items-start space-x-3">
|
||||
{/* Icon */}
|
||||
<div className={`flex-shrink-0 p-2 rounded-lg ${colors.background}`}>
|
||||
<IconComponent className={`h-4 w-4 ${colors.icon}`} />
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<h4 className={`text-sm font-medium ${!insight.isRead ? 'text-gray-900' : 'text-gray-700'}`}>
|
||||
{insight.title}
|
||||
</h4>
|
||||
<p className="text-sm text-gray-600 mt-1 line-clamp-2">
|
||||
{insight.description}
|
||||
</p>
|
||||
|
||||
{/* Metadata */}
|
||||
<div className="flex items-center mt-2 space-x-3">
|
||||
<span className="text-xs text-gray-500 flex items-center">
|
||||
<Clock className="h-3 w-3 mr-1" />
|
||||
{formatTimestamp(insight.timestamp)}
|
||||
</span>
|
||||
|
||||
<span className={`px-2 py-1 text-xs rounded-full ${colors.badge}`}>
|
||||
{insight.impact === 'high' ? 'Alto' : insight.impact === 'medium' ? 'Medio' : 'Bajo'} impacto
|
||||
</span>
|
||||
|
||||
<span className="text-xs text-gray-500">
|
||||
{insight.confidence}% confianza
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Data Insights */}
|
||||
{insight.data && (
|
||||
<div className="mt-3 p-3 bg-gray-50 rounded-lg">
|
||||
{insight.data.trend && (
|
||||
<div className="flex items-center text-sm">
|
||||
{insight.data.trend.direction === 'up' ? (
|
||||
<TrendingUp className="h-4 w-4 text-green-600 mr-2" />
|
||||
) : (
|
||||
<TrendingDown className="h-4 w-4 text-red-600 mr-2" />
|
||||
)}
|
||||
<span className="font-medium">
|
||||
{insight.data.trend.percentage}% {insight.data.trend.direction === 'up' ? 'aumento' : 'reducción'}
|
||||
</span>
|
||||
<span className="text-gray-600 ml-1">
|
||||
en {insight.data.trend.period}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{insight.data.revenue && (
|
||||
<div className="flex items-center text-sm">
|
||||
<span className="font-medium text-green-600">€{insight.data.revenue.amount}</span>
|
||||
<span className="text-gray-600 ml-1">{insight.data.revenue.comparison}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{insight.data.actionable && (
|
||||
<div className="mt-2 text-sm">
|
||||
<div className="font-medium text-gray-900">{insight.data.actionable.action}</div>
|
||||
<div className="text-green-600">{insight.data.actionable.expectedImpact}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center space-x-1 ml-4">
|
||||
<button
|
||||
onClick={() => handleInsightAction(insight.id, 'star')}
|
||||
className={`p-1.5 rounded-lg hover:bg-gray-100 transition-colors ${
|
||||
insight.isStarred ? 'text-yellow-600' : 'text-gray-400 hover:text-gray-600'
|
||||
}`}
|
||||
>
|
||||
<Star className={`h-4 w-4 ${insight.isStarred ? 'fill-current' : ''}`} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => handleInsightAction(insight.id, 'read')}
|
||||
className={`p-1.5 rounded-lg hover:bg-gray-100 transition-colors ${
|
||||
insight.isRead ? 'text-gray-400' : 'text-blue-600 hover:text-blue-700'
|
||||
}`}
|
||||
>
|
||||
{insight.isRead ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</button>
|
||||
|
||||
<button className="p-1.5 rounded-lg hover:bg-gray-100 text-gray-400 hover:text-gray-600 transition-colors">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{filteredInsights.length === 0 && (
|
||||
<div className="p-8 text-center">
|
||||
<Brain className="h-12 w-12 text-gray-300 mx-auto mb-4" />
|
||||
<p className="text-gray-500">No hay insights disponibles</p>
|
||||
<p className="text-sm text-gray-400 mt-1">
|
||||
Los insights aparecerán aquí conforme el sistema analice tus datos
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
{filteredInsights.length > 0 && (
|
||||
<div className="p-4 border-t border-gray-200 bg-gray-50">
|
||||
<div className="text-center">
|
||||
<p className="text-xs text-gray-500">
|
||||
Mostrando {filteredInsights.length} de {insights.length} insights
|
||||
</p>
|
||||
<button className="text-xs text-purple-600 hover:text-purple-700 mt-1 font-medium">
|
||||
Ver historial completo
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AIInsightsFeed;
|
||||
155
frontend/src/components/ui/AlertCard.tsx
Normal file
155
frontend/src/components/ui/AlertCard.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
import React from 'react';
|
||||
import { AlertTriangle, TrendingDown, Package, Clock, Euro } from 'lucide-react';
|
||||
|
||||
export interface BusinessAlert {
|
||||
id: string;
|
||||
type: 'stockout_risk' | 'overstock' | 'revenue_loss' | 'quality_risk' | 'weather_impact';
|
||||
severity: 'low' | 'medium' | 'high' | 'critical';
|
||||
product: string;
|
||||
message: string;
|
||||
action: string;
|
||||
impact?: {
|
||||
type: 'revenue' | 'units' | 'percentage';
|
||||
value: number;
|
||||
currency?: string;
|
||||
};
|
||||
urgency?: 'immediate' | 'today' | 'this_week';
|
||||
}
|
||||
|
||||
interface AlertCardProps {
|
||||
alert: BusinessAlert;
|
||||
onAction?: (alertId: string, actionType: string) => void;
|
||||
}
|
||||
|
||||
const getSeverityConfig = (severity: BusinessAlert['severity']) => {
|
||||
switch (severity) {
|
||||
case 'critical':
|
||||
return {
|
||||
bgColor: 'bg-red-50',
|
||||
borderColor: 'border-red-200',
|
||||
iconColor: 'text-red-600',
|
||||
textColor: 'text-red-900',
|
||||
actionColor: 'bg-red-100 hover:bg-red-200 text-red-800'
|
||||
};
|
||||
case 'high':
|
||||
return {
|
||||
bgColor: 'bg-orange-50',
|
||||
borderColor: 'border-orange-200',
|
||||
iconColor: 'text-orange-600',
|
||||
textColor: 'text-orange-900',
|
||||
actionColor: 'bg-orange-100 hover:bg-orange-200 text-orange-800'
|
||||
};
|
||||
case 'medium':
|
||||
return {
|
||||
bgColor: 'bg-yellow-50',
|
||||
borderColor: 'border-yellow-200',
|
||||
iconColor: 'text-yellow-600',
|
||||
textColor: 'text-yellow-900',
|
||||
actionColor: 'bg-yellow-100 hover:bg-yellow-200 text-yellow-800'
|
||||
};
|
||||
default:
|
||||
return {
|
||||
bgColor: 'bg-blue-50',
|
||||
borderColor: 'border-blue-200',
|
||||
iconColor: 'text-blue-600',
|
||||
textColor: 'text-blue-900',
|
||||
actionColor: 'bg-blue-100 hover:bg-blue-200 text-blue-800'
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const getAlertIcon = (type: BusinessAlert['type']) => {
|
||||
switch (type) {
|
||||
case 'stockout_risk':
|
||||
return Package;
|
||||
case 'overstock':
|
||||
return TrendingDown;
|
||||
case 'revenue_loss':
|
||||
return Euro;
|
||||
case 'quality_risk':
|
||||
return Clock;
|
||||
case 'weather_impact':
|
||||
return AlertTriangle;
|
||||
default:
|
||||
return AlertTriangle;
|
||||
}
|
||||
};
|
||||
|
||||
const getUrgencyLabel = (urgency?: BusinessAlert['urgency']) => {
|
||||
switch (urgency) {
|
||||
case 'immediate':
|
||||
return { label: 'URGENTE', color: 'bg-red-100 text-red-800' };
|
||||
case 'today':
|
||||
return { label: 'HOY', color: 'bg-orange-100 text-orange-800' };
|
||||
case 'this_week':
|
||||
return { label: 'ESTA SEMANA', color: 'bg-blue-100 text-blue-800' };
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const AlertCard: React.FC<AlertCardProps> = ({ alert, onAction }) => {
|
||||
const config = getSeverityConfig(alert.severity);
|
||||
const Icon = getAlertIcon(alert.type);
|
||||
const urgencyInfo = getUrgencyLabel(alert.urgency);
|
||||
|
||||
const handleAction = () => {
|
||||
onAction?.(alert.id, 'primary_action');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`${config.bgColor} ${config.borderColor} border rounded-lg p-4 shadow-sm`}>
|
||||
<div className="flex items-start space-x-3">
|
||||
<div className={`${config.iconColor} mt-0.5`}>
|
||||
<Icon className="h-5 w-5" />
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center space-x-2">
|
||||
<h4 className={`font-medium ${config.textColor}`}>
|
||||
{alert.product}
|
||||
</h4>
|
||||
{urgencyInfo && (
|
||||
<span className={`px-2 py-1 text-xs font-bold rounded ${urgencyInfo.color}`}>
|
||||
{urgencyInfo.label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className={`text-sm mt-1 ${config.textColor.replace('900', '700')}`}>
|
||||
{alert.message}
|
||||
</p>
|
||||
|
||||
{alert.impact && (
|
||||
<div className={`text-sm font-medium mt-2 ${config.textColor}`}>
|
||||
{alert.impact.type === 'revenue' && (
|
||||
<>Impacto: -{alert.impact.value}{alert.impact.currency || '€'}</>
|
||||
)}
|
||||
{alert.impact.type === 'units' && (
|
||||
<>Unidades afectadas: {alert.impact.value}</>
|
||||
)}
|
||||
{alert.impact.type === 'percentage' && (
|
||||
<>Reducción estimada: {alert.impact.value}%</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3">
|
||||
<button
|
||||
onClick={handleAction}
|
||||
className={`px-3 py-2 text-sm font-medium rounded transition-colors ${config.actionColor}`}
|
||||
>
|
||||
{alert.action}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AlertCard;
|
||||
408
frontend/src/components/ui/CompetitiveBenchmarks.tsx
Normal file
408
frontend/src/components/ui/CompetitiveBenchmarks.tsx
Normal file
@@ -0,0 +1,408 @@
|
||||
import { useState } from 'react';
|
||||
import { BarChart3, TrendingUp, TrendingDown, Award, Target, Eye, EyeOff } from 'lucide-react';
|
||||
|
||||
interface BenchmarkMetric {
|
||||
id: string;
|
||||
name: string;
|
||||
yourValue: number;
|
||||
industryAverage: number;
|
||||
topPerformers: number;
|
||||
unit: string;
|
||||
description: string;
|
||||
category: 'efficiency' | 'revenue' | 'waste' | 'customer' | 'quality';
|
||||
trend: 'improving' | 'declining' | 'stable';
|
||||
percentile: number; // Your position (0-100)
|
||||
insights: string[];
|
||||
}
|
||||
|
||||
interface CompetitiveBenchmarksProps {
|
||||
metrics?: BenchmarkMetric[];
|
||||
location?: string; // e.g., "Madrid Centro"
|
||||
showSensitiveData?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const CompetitiveBenchmarks: React.FC<CompetitiveBenchmarksProps> = ({
|
||||
metrics: propMetrics,
|
||||
location = "Madrid Centro",
|
||||
showSensitiveData = true,
|
||||
className = ''
|
||||
}) => {
|
||||
const [selectedCategory, setSelectedCategory] = useState<string>('all');
|
||||
const [showDetails, setShowDetails] = useState<boolean>(showSensitiveData);
|
||||
|
||||
// Sample benchmark data (anonymized)
|
||||
const defaultMetrics: BenchmarkMetric[] = [
|
||||
{
|
||||
id: 'forecast_accuracy',
|
||||
name: 'Precisión de Predicciones',
|
||||
yourValue: 87.2,
|
||||
industryAverage: 72.5,
|
||||
topPerformers: 94.1,
|
||||
unit: '%',
|
||||
description: 'Qué tan precisas son las predicciones vs. ventas reales',
|
||||
category: 'quality',
|
||||
trend: 'improving',
|
||||
percentile: 85,
|
||||
insights: [
|
||||
'Superioridad del 15% vs. promedio de la industria',
|
||||
'Solo 7 puntos por debajo de los mejores del sector',
|
||||
'Mejora consistente en los últimos 3 meses'
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'waste_percentage',
|
||||
name: 'Porcentaje de Desperdicio',
|
||||
yourValue: 8.3,
|
||||
industryAverage: 12.7,
|
||||
topPerformers: 4.2,
|
||||
unit: '%',
|
||||
description: 'Productos no vendidos como % del total producido',
|
||||
category: 'waste',
|
||||
trend: 'improving',
|
||||
percentile: 78,
|
||||
insights: [
|
||||
'35% menos desperdicio que el promedio',
|
||||
'Oportunidad: reducir 4 puntos más para llegar al top',
|
||||
'Ahorro de ~€230/mes vs. promedio de la industria'
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'revenue_per_sqm',
|
||||
name: 'Ingresos por m²',
|
||||
yourValue: 2847,
|
||||
industryAverage: 2134,
|
||||
topPerformers: 4521,
|
||||
unit: '€/mes',
|
||||
description: 'Ingresos mensuales por metro cuadrado de local',
|
||||
category: 'revenue',
|
||||
trend: 'stable',
|
||||
percentile: 73,
|
||||
insights: [
|
||||
'33% más eficiente en generación de ingresos',
|
||||
'Potencial de crecimiento: +59% para alcanzar el top',
|
||||
'Excelente aprovechamiento del espacio'
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'customer_retention',
|
||||
name: 'Retención de Clientes',
|
||||
yourValue: 68,
|
||||
industryAverage: 61,
|
||||
topPerformers: 84,
|
||||
unit: '%',
|
||||
description: 'Clientes que regresan al menos una vez por semana',
|
||||
category: 'customer',
|
||||
trend: 'improving',
|
||||
percentile: 67,
|
||||
insights: [
|
||||
'11% mejor retención que la competencia',
|
||||
'Oportunidad: programas de fidelización podrían sumar 16 puntos',
|
||||
'Base de clientes sólida y leal'
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'production_efficiency',
|
||||
name: 'Eficiencia de Producción',
|
||||
yourValue: 1.8,
|
||||
industryAverage: 2.3,
|
||||
topPerformers: 1.2,
|
||||
unit: 'h/100 unidades',
|
||||
description: 'Tiempo promedio para producir 100 unidades',
|
||||
category: 'efficiency',
|
||||
trend: 'improving',
|
||||
percentile: 71,
|
||||
insights: [
|
||||
'22% más rápido que el promedio',
|
||||
'Excelente optimización de procesos',
|
||||
'Margen para mejora: -33% para ser top performer'
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'profit_margin',
|
||||
name: 'Margen de Ganancia',
|
||||
yourValue: 32.5,
|
||||
industryAverage: 28.1,
|
||||
topPerformers: 41.7,
|
||||
unit: '%',
|
||||
description: 'Ganancia neta como % de los ingresos totales',
|
||||
category: 'revenue',
|
||||
trend: 'stable',
|
||||
percentile: 69,
|
||||
insights: [
|
||||
'16% más rentable que la competencia',
|
||||
'Sólida gestión de costos',
|
||||
'Oportunidad: optimizar ingredientes premium'
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
const metrics = propMetrics || defaultMetrics;
|
||||
|
||||
const categories = [
|
||||
{ id: 'all', name: 'Todas', count: metrics.length },
|
||||
{ id: 'revenue', name: 'Ingresos', count: metrics.filter(m => m.category === 'revenue').length },
|
||||
{ id: 'efficiency', name: 'Eficiencia', count: metrics.filter(m => m.category === 'efficiency').length },
|
||||
{ id: 'waste', name: 'Desperdicio', count: metrics.filter(m => m.category === 'waste').length },
|
||||
{ id: 'customer', name: 'Clientes', count: metrics.filter(m => m.category === 'customer').length },
|
||||
{ id: 'quality', name: 'Calidad', count: metrics.filter(m => m.category === 'quality').length }
|
||||
];
|
||||
|
||||
const filteredMetrics = metrics.filter(metric =>
|
||||
selectedCategory === 'all' || metric.category === selectedCategory
|
||||
);
|
||||
|
||||
const getPerformanceLevel = (percentile: number) => {
|
||||
if (percentile >= 90) return { label: 'Excelente', color: 'text-green-600', bg: 'bg-green-50' };
|
||||
if (percentile >= 75) return { label: 'Bueno', color: 'text-blue-600', bg: 'bg-blue-50' };
|
||||
if (percentile >= 50) return { label: 'Promedio', color: 'text-yellow-600', bg: 'bg-yellow-50' };
|
||||
return { label: 'Mejora Necesaria', color: 'text-red-600', bg: 'bg-red-50' };
|
||||
};
|
||||
|
||||
const getTrendIcon = (trend: string) => {
|
||||
switch (trend) {
|
||||
case 'improving': return <TrendingUp className="h-4 w-4 text-green-600" />;
|
||||
case 'declining': return <TrendingDown className="h-4 w-4 text-red-600" />;
|
||||
default: return <div className="w-4 h-4 bg-gray-400 rounded-full"></div>;
|
||||
}
|
||||
};
|
||||
|
||||
const getComparisonPercentage = (yourValue: number, compareValue: number, isLowerBetter = false) => {
|
||||
const diff = isLowerBetter
|
||||
? ((compareValue - yourValue) / compareValue) * 100
|
||||
: ((yourValue - compareValue) / compareValue) * 100;
|
||||
return {
|
||||
value: Math.abs(diff),
|
||||
isPositive: diff > 0
|
||||
};
|
||||
};
|
||||
|
||||
const isLowerBetter = (metricId: string) => {
|
||||
return ['waste_percentage', 'production_efficiency'].includes(metricId);
|
||||
};
|
||||
|
||||
const averagePercentile = Math.round(metrics.reduce((sum, m) => sum + m.percentile, 0) / metrics.length);
|
||||
|
||||
return (
|
||||
<div className={`bg-white rounded-xl shadow-soft ${className}`}>
|
||||
{/* Header */}
|
||||
<div className="p-6 border-b border-gray-200">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<BarChart3 className="h-6 w-6 text-indigo-600 mr-3" />
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">
|
||||
Benchmarks Competitivos
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600">
|
||||
Comparación anónima con panaderías similares en {location}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-3">
|
||||
{/* Overall Score */}
|
||||
<div className="text-right">
|
||||
<div className="text-2xl font-bold text-indigo-600">
|
||||
{averagePercentile}
|
||||
</div>
|
||||
<div className="text-xs text-gray-600">Percentil General</div>
|
||||
</div>
|
||||
|
||||
{/* Toggle Details */}
|
||||
<button
|
||||
onClick={() => setShowDetails(!showDetails)}
|
||||
className="p-2 rounded-lg hover:bg-gray-100 text-gray-600"
|
||||
title={showDetails ? "Ocultar detalles" : "Mostrar detalles"}
|
||||
>
|
||||
{showDetails ? <EyeOff className="h-5 w-5" /> : <Eye className="h-5 w-5" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Performance Summary */}
|
||||
<div className="mt-4 grid grid-cols-3 gap-4">
|
||||
<div className="bg-green-50 rounded-lg p-3 text-center">
|
||||
<div className="text-lg font-bold text-green-600">
|
||||
{metrics.filter(m => m.percentile >= 75).length}
|
||||
</div>
|
||||
<div className="text-xs text-green-700">Métricas Top 25%</div>
|
||||
</div>
|
||||
<div className="bg-blue-50 rounded-lg p-3 text-center">
|
||||
<div className="text-lg font-bold text-blue-600">
|
||||
{metrics.filter(m => m.trend === 'improving').length}
|
||||
</div>
|
||||
<div className="text-xs text-blue-700">En Mejora</div>
|
||||
</div>
|
||||
<div className="bg-yellow-50 rounded-lg p-3 text-center">
|
||||
<div className="text-lg font-bold text-yellow-600">
|
||||
{metrics.filter(m => m.percentile < 50).length}
|
||||
</div>
|
||||
<div className="text-xs text-yellow-700">Áreas de Oportunidad</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Category Filters */}
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
{categories.map(category => (
|
||||
<button
|
||||
key={category.id}
|
||||
onClick={() => setSelectedCategory(category.id)}
|
||||
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${
|
||||
selectedCategory === category.id
|
||||
? 'bg-indigo-100 text-indigo-800 border border-indigo-200'
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
{category.name}
|
||||
<span className="ml-1.5 text-xs bg-white rounded-full px-1.5 py-0.5">
|
||||
{category.count}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Metrics List */}
|
||||
<div className="divide-y divide-gray-100">
|
||||
{filteredMetrics.map(metric => {
|
||||
const performance = getPerformanceLevel(metric.percentile);
|
||||
const vsAverage = getComparisonPercentage(
|
||||
metric.yourValue,
|
||||
metric.industryAverage,
|
||||
isLowerBetter(metric.id)
|
||||
);
|
||||
const vsTop = getComparisonPercentage(
|
||||
metric.yourValue,
|
||||
metric.topPerformers,
|
||||
isLowerBetter(metric.id)
|
||||
);
|
||||
|
||||
return (
|
||||
<div key={metric.id} className="p-6">
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center space-x-3">
|
||||
<h4 className="text-lg font-medium text-gray-900">
|
||||
{metric.name}
|
||||
</h4>
|
||||
{getTrendIcon(metric.trend)}
|
||||
<span className={`px-2 py-1 text-xs rounded-full ${performance.bg} ${performance.color}`}>
|
||||
{performance.label}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 mt-1">{metric.description}</p>
|
||||
</div>
|
||||
|
||||
<div className="text-right ml-4">
|
||||
<div className="text-2xl font-bold text-gray-900">
|
||||
{metric.yourValue.toLocaleString('es-ES')}<span className="text-sm text-gray-500">{metric.unit}</span>
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">Tu Resultado</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Comparison Bars */}
|
||||
<div className="space-y-3">
|
||||
{/* Your Performance */}
|
||||
<div className="relative">
|
||||
<div className="flex justify-between items-center mb-1">
|
||||
<span className="text-sm font-medium text-gray-900">Tu Rendimiento</span>
|
||||
<span className="text-sm text-indigo-600 font-medium">Percentil {metric.percentile}</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 rounded-full h-2">
|
||||
<div
|
||||
className="bg-indigo-600 h-2 rounded-full transition-all duration-300"
|
||||
style={{ width: `${metric.percentile}%` }}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Industry Average */}
|
||||
<div className="relative">
|
||||
<div className="flex justify-between items-center mb-1">
|
||||
<span className="text-sm text-gray-600">Promedio Industria</span>
|
||||
<span className="text-sm text-gray-600">
|
||||
{metric.industryAverage.toLocaleString('es-ES')}{metric.unit}
|
||||
<span className={`ml-2 ${vsAverage.isPositive ? 'text-green-600' : 'text-red-600'}`}>
|
||||
({vsAverage.isPositive ? '+' : '-'}{vsAverage.value.toFixed(1)}%)
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 rounded-full h-1.5">
|
||||
<div
|
||||
className="bg-gray-400 h-1.5 rounded-full"
|
||||
style={{ width: '50%' }}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Top Performers */}
|
||||
<div className="relative">
|
||||
<div className="flex justify-between items-center mb-1">
|
||||
<span className="text-sm text-gray-600 flex items-center">
|
||||
<Award className="h-3 w-3 mr-1 text-yellow-500" />
|
||||
Top Performers
|
||||
</span>
|
||||
<span className="text-sm text-gray-600">
|
||||
{metric.topPerformers.toLocaleString('es-ES')}{metric.unit}
|
||||
<span className={`ml-2 ${vsTop.isPositive ? 'text-green-600' : 'text-orange-600'}`}>
|
||||
({vsTop.isPositive ? '+' : '-'}{vsTop.value.toFixed(1)}%)
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 rounded-full h-1.5">
|
||||
<div
|
||||
className="bg-yellow-400 h-1.5 rounded-full"
|
||||
style={{ width: '90%' }}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Insights */}
|
||||
{showDetails && (
|
||||
<div className="mt-4 p-4 bg-gray-50 rounded-lg">
|
||||
<h5 className="text-sm font-medium text-gray-700 mb-2 flex items-center">
|
||||
<Target className="h-4 w-4 mr-2 text-indigo-600" />
|
||||
Insights Clave:
|
||||
</h5>
|
||||
<ul className="space-y-1">
|
||||
{metric.insights.map((insight, index) => (
|
||||
<li key={index} className="text-sm text-gray-600 flex items-start">
|
||||
<div className="w-1.5 h-1.5 bg-indigo-400 rounded-full mt-2 mr-2 flex-shrink-0"></div>
|
||||
{insight}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{filteredMetrics.length === 0 && (
|
||||
<div className="p-8 text-center">
|
||||
<BarChart3 className="h-12 w-12 text-gray-300 mx-auto mb-4" />
|
||||
<p className="text-gray-500">No hay métricas disponibles</p>
|
||||
<p className="text-sm text-gray-400 mt-1">
|
||||
Los benchmarks aparecerán cuando haya suficientes datos
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Disclaimer */}
|
||||
<div className="px-6 pb-4">
|
||||
<div className="bg-gray-50 rounded-lg p-3">
|
||||
<div className="text-xs text-gray-600">
|
||||
<strong>🔒 Privacidad:</strong> Todos los datos están anonimizados.
|
||||
Solo se comparten métricas agregadas de panaderías similares en tamaño y ubicación.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CompetitiveBenchmarks;
|
||||
287
frontend/src/components/ui/DemandHeatmap.tsx
Normal file
287
frontend/src/components/ui/DemandHeatmap.tsx
Normal file
@@ -0,0 +1,287 @@
|
||||
import { useState } from 'react';
|
||||
import { ChevronLeft, ChevronRight, Calendar, TrendingUp, Eye } from 'lucide-react';
|
||||
|
||||
export interface DayDemand {
|
||||
date: string;
|
||||
demand: number;
|
||||
isToday?: boolean;
|
||||
isForecast?: boolean;
|
||||
products?: Array<{
|
||||
name: string;
|
||||
demand: number;
|
||||
confidence: 'high' | 'medium' | 'low';
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface WeekData {
|
||||
weekStart: string;
|
||||
days: DayDemand[];
|
||||
}
|
||||
|
||||
interface DemandHeatmapProps {
|
||||
data: WeekData[];
|
||||
selectedProduct?: string;
|
||||
onDateClick?: (date: string) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const DemandHeatmap: React.FC<DemandHeatmapProps> = ({
|
||||
data,
|
||||
selectedProduct,
|
||||
onDateClick,
|
||||
className = ''
|
||||
}) => {
|
||||
const [currentWeekIndex, setCurrentWeekIndex] = useState(0);
|
||||
const [selectedDate, setSelectedDate] = useState<string | null>(null);
|
||||
|
||||
const maxDemand = Math.max(
|
||||
...data.flatMap(week => week.days.map(day => day.demand))
|
||||
);
|
||||
|
||||
const getDemandIntensity = (demand: number) => {
|
||||
const intensity = demand / maxDemand;
|
||||
if (intensity > 0.8) return 'bg-red-500';
|
||||
if (intensity > 0.6) return 'bg-orange-500';
|
||||
if (intensity > 0.4) return 'bg-yellow-500';
|
||||
if (intensity > 0.2) return 'bg-green-500';
|
||||
return 'bg-gray-200';
|
||||
};
|
||||
|
||||
const getDemandLabel = (demand: number) => {
|
||||
const intensity = demand / maxDemand;
|
||||
if (intensity > 0.8) return 'Muy Alta';
|
||||
if (intensity > 0.6) return 'Alta';
|
||||
if (intensity > 0.4) return 'Media';
|
||||
if (intensity > 0.2) return 'Baja';
|
||||
return 'Muy Baja';
|
||||
};
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
const date = new Date(dateStr);
|
||||
return date.getDate().toString();
|
||||
};
|
||||
|
||||
const formatWeekRange = (weekStart: string) => {
|
||||
const start = new Date(weekStart);
|
||||
const end = new Date(start);
|
||||
end.setDate(end.getDate() + 6);
|
||||
|
||||
return `${start.getDate()}-${end.getDate()} ${start.toLocaleDateString('es-ES', { month: 'short' })}`;
|
||||
};
|
||||
|
||||
const handleDateClick = (date: string) => {
|
||||
setSelectedDate(date);
|
||||
onDateClick?.(date);
|
||||
};
|
||||
|
||||
const currentWeek = data[currentWeekIndex];
|
||||
const selectedDay = selectedDate ?
|
||||
data.flatMap(w => w.days).find(d => d.date === selectedDate) : null;
|
||||
|
||||
return (
|
||||
<div className={`bg-white rounded-xl shadow-soft p-6 ${className}`}>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 flex items-center">
|
||||
<Calendar className="h-5 w-5 mr-2 text-primary-600" />
|
||||
Mapa de Calor de Demanda
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
Patrones de demanda visual por día
|
||||
{selectedProduct && ` - ${selectedProduct}`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<button
|
||||
onClick={() => setCurrentWeekIndex(Math.max(0, currentWeekIndex - 1))}
|
||||
disabled={currentWeekIndex === 0}
|
||||
className="p-2 rounded-lg bg-gray-100 hover:bg-gray-200 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
<span className="text-sm font-medium text-gray-700 min-w-[100px] text-center">
|
||||
{currentWeek ? formatWeekRange(currentWeek.weekStart) : 'Esta Semana'}
|
||||
</span>
|
||||
|
||||
<button
|
||||
onClick={() => setCurrentWeekIndex(Math.min(data.length - 1, currentWeekIndex + 1))}
|
||||
disabled={currentWeekIndex === data.length - 1}
|
||||
className="p-2 rounded-lg bg-gray-100 hover:bg-gray-200 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Heatmap Grid */}
|
||||
<div className="grid grid-cols-7 gap-2 mb-6">
|
||||
{['Lun', 'Mar', 'Mié', 'Jue', 'Vie', 'Sáb', 'Dom'].map(day => (
|
||||
<div key={day} className="text-center text-xs font-medium text-gray-600 p-2">
|
||||
{day}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{currentWeek?.days.map(day => (
|
||||
<button
|
||||
key={day.date}
|
||||
onClick={() => handleDateClick(day.date)}
|
||||
className={`
|
||||
relative p-3 rounded-lg text-white text-sm font-medium
|
||||
transition-all duration-200 hover:scale-105 hover:shadow-md
|
||||
${getDemandIntensity(day.demand)}
|
||||
${selectedDate === day.date ? 'ring-2 ring-primary-600 ring-offset-2' : ''}
|
||||
${day.isToday ? 'ring-2 ring-blue-400' : ''}
|
||||
`}
|
||||
>
|
||||
<div className="text-center">
|
||||
<div className="text-lg font-bold">{formatDate(day.date)}</div>
|
||||
<div className="text-xs opacity-90">{day.demand}</div>
|
||||
{day.isForecast && (
|
||||
<div className="absolute -top-1 -right-1 w-2 h-2 bg-blue-400 rounded-full"></div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center space-x-4">
|
||||
<span className="text-sm text-gray-600">Demanda:</span>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="w-4 h-4 bg-gray-200 rounded"></div>
|
||||
<span className="text-xs">Muy Baja</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="w-4 h-4 bg-green-500 rounded"></div>
|
||||
<span className="text-xs">Baja</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="w-4 h-4 bg-yellow-500 rounded"></div>
|
||||
<span className="text-xs">Media</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="w-4 h-4 bg-orange-500 rounded"></div>
|
||||
<span className="text-xs">Alta</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="w-4 h-4 bg-red-500 rounded"></div>
|
||||
<span className="text-xs">Muy Alta</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-4 text-xs text-gray-600">
|
||||
<div className="flex items-center">
|
||||
<div className="w-2 h-2 bg-blue-400 rounded-full mr-1"></div>
|
||||
Predicción
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<div className="w-2 h-2 bg-blue-600 rounded-full mr-1"></div>
|
||||
Hoy
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Selected Day Details */}
|
||||
{selectedDay && (
|
||||
<div className="border-t border-gray-200 pt-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-900 flex items-center">
|
||||
<Eye className="h-4 w-4 mr-2 text-primary-600" />
|
||||
{new Date(selectedDay.date).toLocaleDateString('es-ES', {
|
||||
weekday: 'long',
|
||||
day: 'numeric',
|
||||
month: 'long'
|
||||
})}
|
||||
{selectedDay.isToday && (
|
||||
<span className="ml-2 px-2 py-1 bg-blue-100 text-blue-800 text-xs rounded-full">
|
||||
Hoy
|
||||
</span>
|
||||
)}
|
||||
</h4>
|
||||
<div className="mt-2 flex items-center space-x-4">
|
||||
<div className="flex items-center">
|
||||
<TrendingUp className="h-4 w-4 text-gray-600 mr-1" />
|
||||
<span className="text-sm text-gray-600">
|
||||
Demanda Total: <span className="font-medium">{selectedDay.demand}</span>
|
||||
</span>
|
||||
</div>
|
||||
<span className={`px-2 py-1 text-xs rounded-full ${
|
||||
getDemandIntensity(selectedDay.demand)
|
||||
} text-white`}>
|
||||
{getDemandLabel(selectedDay.demand)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Product Breakdown */}
|
||||
{selectedDay.products && (
|
||||
<div className="mt-4">
|
||||
<h5 className="text-sm font-medium text-gray-700 mb-3">Desglose por Producto:</h5>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
|
||||
{selectedDay.products.map((product, index) => (
|
||||
<div key={index} className="bg-gray-50 rounded-lg p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-gray-900">{product.name}</span>
|
||||
<span className={`px-2 py-1 text-xs rounded ${
|
||||
product.confidence === 'high' ? 'bg-green-100 text-green-800' :
|
||||
product.confidence === 'medium' ? 'bg-yellow-100 text-yellow-800' :
|
||||
'bg-red-100 text-red-800'
|
||||
}`}>
|
||||
{product.confidence === 'high' ? 'Alta' :
|
||||
product.confidence === 'medium' ? 'Media' : 'Baja'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-lg font-bold text-gray-900 mt-1">
|
||||
{product.demand}
|
||||
</div>
|
||||
<div className="text-xs text-gray-600">unidades</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Weekly Summary */}
|
||||
{currentWeek && (
|
||||
<div className="mt-6 bg-gray-50 rounded-lg p-4">
|
||||
<h5 className="text-sm font-medium text-gray-700 mb-2">Resumen Semanal:</h5>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-center">
|
||||
<div>
|
||||
<div className="text-lg font-bold text-gray-900">
|
||||
{currentWeek.days.reduce((sum, day) => sum + day.demand, 0)}
|
||||
</div>
|
||||
<div className="text-xs text-gray-600">Total</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-lg font-bold text-gray-900">
|
||||
{Math.round(currentWeek.days.reduce((sum, day) => sum + day.demand, 0) / 7)}
|
||||
</div>
|
||||
<div className="text-xs text-gray-600">Promedio/día</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-lg font-bold text-gray-900">
|
||||
{Math.max(...currentWeek.days.map(d => d.demand))}
|
||||
</div>
|
||||
<div className="text-xs text-gray-600">Pico</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-lg font-bold text-gray-900">
|
||||
{currentWeek.days.filter(d => d.isForecast).length}
|
||||
</div>
|
||||
<div className="text-xs text-gray-600">Predicciones</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DemandHeatmap;
|
||||
207
frontend/src/components/ui/ProductionSchedule.tsx
Normal file
207
frontend/src/components/ui/ProductionSchedule.tsx
Normal file
@@ -0,0 +1,207 @@
|
||||
import React from 'react';
|
||||
import { Clock, CheckCircle, AlertTriangle, TrendingUp } from 'lucide-react';
|
||||
|
||||
export interface ProductionItem {
|
||||
id: string;
|
||||
product: string;
|
||||
quantity: number;
|
||||
priority: 'high' | 'medium' | 'low';
|
||||
estimatedTime: number; // minutes
|
||||
status: 'pending' | 'in_progress' | 'completed';
|
||||
confidence: number; // 0-1
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface ProductionTimeSlot {
|
||||
time: string;
|
||||
items: ProductionItem[];
|
||||
totalTime: number;
|
||||
}
|
||||
|
||||
interface ProductionScheduleProps {
|
||||
schedule: ProductionTimeSlot[];
|
||||
onUpdateQuantity?: (itemId: string, newQuantity: number) => void;
|
||||
onUpdateStatus?: (itemId: string, status: ProductionItem['status']) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const getPriorityConfig = (priority: ProductionItem['priority']) => {
|
||||
switch (priority) {
|
||||
case 'high':
|
||||
return {
|
||||
bgColor: 'bg-red-50',
|
||||
borderColor: 'border-red-200',
|
||||
textColor: 'text-red-800',
|
||||
label: 'ALTA'
|
||||
};
|
||||
case 'medium':
|
||||
return {
|
||||
bgColor: 'bg-yellow-50',
|
||||
borderColor: 'border-yellow-200',
|
||||
textColor: 'text-yellow-800',
|
||||
label: 'MEDIA'
|
||||
};
|
||||
default:
|
||||
return {
|
||||
bgColor: 'bg-green-50',
|
||||
borderColor: 'border-green-200',
|
||||
textColor: 'text-green-800',
|
||||
label: 'BAJA'
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusIcon = (status: ProductionItem['status']) => {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return <CheckCircle className="h-4 w-4 text-success-600" />;
|
||||
case 'in_progress':
|
||||
return <Clock className="h-4 w-4 text-primary-600" />;
|
||||
default:
|
||||
return <AlertTriangle className="h-4 w-4 text-gray-400" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getConfidenceColor = (confidence: number) => {
|
||||
if (confidence >= 0.8) return 'text-success-600';
|
||||
if (confidence >= 0.6) return 'text-warning-600';
|
||||
return 'text-danger-600';
|
||||
};
|
||||
|
||||
const ProductionItem: React.FC<{
|
||||
item: ProductionItem;
|
||||
onUpdateQuantity?: (itemId: string, newQuantity: number) => void;
|
||||
onUpdateStatus?: (itemId: string, status: ProductionItem['status']) => void;
|
||||
}> = ({ item, onUpdateQuantity, onUpdateStatus }) => {
|
||||
const priorityConfig = getPriorityConfig(item.priority);
|
||||
|
||||
const handleQuantityChange = (delta: number) => {
|
||||
const newQuantity = Math.max(0, item.quantity + delta);
|
||||
onUpdateQuantity?.(item.id, newQuantity);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white border border-gray-200 rounded-lg p-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
{getStatusIcon(item.status)}
|
||||
<span className="font-medium text-gray-900">{item.product}</span>
|
||||
<span className={`px-2 py-1 text-xs font-medium rounded ${priorityConfig.bgColor} ${priorityConfig.borderColor} ${priorityConfig.textColor}`}>
|
||||
{priorityConfig.label}
|
||||
</span>
|
||||
</div>
|
||||
<div className={`text-sm font-medium ${getConfidenceColor(item.confidence)}`}>
|
||||
{Math.round(item.confidence * 100)}% confianza
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="flex items-center space-x-1">
|
||||
<button
|
||||
onClick={() => handleQuantityChange(-5)}
|
||||
className="w-6 h-6 rounded-full bg-gray-100 hover:bg-gray-200 flex items-center justify-center text-sm font-medium"
|
||||
disabled={item.quantity <= 5}
|
||||
>
|
||||
-
|
||||
</button>
|
||||
<span className="w-12 text-center font-bold text-lg">{item.quantity}</span>
|
||||
<button
|
||||
onClick={() => handleQuantityChange(5)}
|
||||
className="w-6 h-6 rounded-full bg-gray-100 hover:bg-gray-200 flex items-center justify-center text-sm font-medium"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
<span className="text-sm text-gray-600">unidades</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2 text-sm text-gray-600">
|
||||
<Clock className="h-4 w-4" />
|
||||
<span>{item.estimatedTime} min</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{item.notes && (
|
||||
<div className="text-sm text-gray-600 bg-gray-50 rounded p-2">
|
||||
{item.notes}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex space-x-2">
|
||||
{item.status === 'pending' && (
|
||||
<button
|
||||
onClick={() => onUpdateStatus?.(item.id, 'in_progress')}
|
||||
className="flex-1 px-3 py-2 text-sm font-medium text-primary-700 bg-primary-100 hover:bg-primary-200 rounded transition-colors"
|
||||
>
|
||||
Iniciar Producción
|
||||
</button>
|
||||
)}
|
||||
{item.status === 'in_progress' && (
|
||||
<button
|
||||
onClick={() => onUpdateStatus?.(item.id, 'completed')}
|
||||
className="flex-1 px-3 py-2 text-sm font-medium text-success-700 bg-success-100 hover:bg-success-200 rounded transition-colors"
|
||||
>
|
||||
Marcar Completado
|
||||
</button>
|
||||
)}
|
||||
{item.status === 'completed' && (
|
||||
<div className="flex-1 px-3 py-2 text-sm font-medium text-success-700 bg-success-100 rounded text-center">
|
||||
✓ Completado
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ProductionSchedule: React.FC<ProductionScheduleProps> = ({
|
||||
schedule,
|
||||
onUpdateQuantity,
|
||||
onUpdateStatus,
|
||||
className = ''
|
||||
}) => {
|
||||
const getTotalItems = (items: ProductionItem[]) => {
|
||||
return items.reduce((sum, item) => sum + item.quantity, 0);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`space-y-6 ${className}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold text-gray-900">
|
||||
Plan de Producción de Hoy
|
||||
</h3>
|
||||
<div className="flex items-center space-x-2 text-sm text-gray-600">
|
||||
<TrendingUp className="h-4 w-4" />
|
||||
<span>Optimizado por IA</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{schedule.map((timeSlot, index) => (
|
||||
<div key={index} className="space-y-4">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="bg-primary-100 text-primary-800 px-3 py-1 rounded-lg font-medium text-sm">
|
||||
{timeSlot.time}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">
|
||||
{getTotalItems(timeSlot.items)} unidades • {timeSlot.totalTime} min total
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{timeSlot.items.map((item) => (
|
||||
<ProductionItem
|
||||
key={item.id}
|
||||
item={item}
|
||||
onUpdateQuantity={onUpdateQuantity}
|
||||
onUpdateStatus={onUpdateStatus}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProductionSchedule;
|
||||
373
frontend/src/components/ui/QuickActionsPanel.tsx
Normal file
373
frontend/src/components/ui/QuickActionsPanel.tsx
Normal file
@@ -0,0 +1,373 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Zap, Plus, Minus, RotateCcw, TrendingUp, ShoppingCart,
|
||||
Clock, AlertTriangle, Phone, MessageSquare, Calculator,
|
||||
RefreshCw, Package, Users, Settings, ChevronRight
|
||||
} from 'lucide-react';
|
||||
|
||||
interface QuickAction {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
icon: any;
|
||||
category: 'production' | 'inventory' | 'sales' | 'customer' | 'system';
|
||||
shortcut?: string;
|
||||
requiresConfirmation?: boolean;
|
||||
estimatedTime?: string;
|
||||
badge?: {
|
||||
text: string;
|
||||
color: 'red' | 'yellow' | 'green' | 'blue' | 'purple';
|
||||
};
|
||||
}
|
||||
|
||||
interface QuickActionsPanelProps {
|
||||
onActionClick?: (actionId: string) => void;
|
||||
availableActions?: QuickAction[];
|
||||
compactMode?: boolean;
|
||||
showCategories?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const QuickActionsPanel: React.FC<QuickActionsPanelProps> = ({
|
||||
onActionClick,
|
||||
availableActions,
|
||||
compactMode = false,
|
||||
showCategories = true,
|
||||
className = ''
|
||||
}) => {
|
||||
const [selectedCategory, setSelectedCategory] = useState<string>('all');
|
||||
const [actionInProgress, setActionInProgress] = useState<string | null>(null);
|
||||
|
||||
const defaultActions: QuickAction[] = [
|
||||
// Production Actions
|
||||
{
|
||||
id: 'increase_production',
|
||||
title: 'Aumentar Producción',
|
||||
description: 'Incrementa rápidamente la producción del producto más demandado',
|
||||
icon: Plus,
|
||||
category: 'production',
|
||||
shortcut: 'P + A',
|
||||
estimatedTime: '2 min',
|
||||
badge: { text: 'Croissants', color: 'green' }
|
||||
},
|
||||
{
|
||||
id: 'emergency_batch',
|
||||
title: 'Lote de Emergencia',
|
||||
description: 'Inicia producción urgente para productos con stock bajo',
|
||||
icon: AlertTriangle,
|
||||
category: 'production',
|
||||
requiresConfirmation: true,
|
||||
estimatedTime: '45 min',
|
||||
badge: { text: '3 productos', color: 'red' }
|
||||
},
|
||||
{
|
||||
id: 'adjust_schedule',
|
||||
title: 'Ajustar Horario',
|
||||
description: 'Modifica el horario de producción basado en predicciones',
|
||||
icon: Clock,
|
||||
category: 'production',
|
||||
estimatedTime: '1 min'
|
||||
},
|
||||
|
||||
// Inventory Actions
|
||||
{
|
||||
id: 'check_stock',
|
||||
title: 'Revisar Stock',
|
||||
description: 'Verifica niveles de inventario y productos próximos a agotarse',
|
||||
icon: Package,
|
||||
category: 'inventory',
|
||||
shortcut: 'I + S',
|
||||
badge: { text: '2 bajos', color: 'yellow' }
|
||||
},
|
||||
{
|
||||
id: 'order_supplies',
|
||||
title: 'Pedir Suministros',
|
||||
description: 'Genera orden automática de ingredientes basada en predicciones',
|
||||
icon: ShoppingCart,
|
||||
category: 'inventory',
|
||||
estimatedTime: '3 min',
|
||||
badge: { text: 'Harina, Huevos', color: 'blue' }
|
||||
},
|
||||
{
|
||||
id: 'waste_report',
|
||||
title: 'Reportar Desperdicio',
|
||||
description: 'Registra productos no vendidos para mejorar predicciones',
|
||||
icon: Minus,
|
||||
category: 'inventory',
|
||||
estimatedTime: '2 min'
|
||||
},
|
||||
|
||||
// Sales Actions
|
||||
{
|
||||
id: 'price_adjustment',
|
||||
title: 'Ajustar Precios',
|
||||
description: 'Modifica precios para productos con baja rotación',
|
||||
icon: Calculator,
|
||||
category: 'sales',
|
||||
requiresConfirmation: true,
|
||||
badge: { text: 'Magdalenas -10%', color: 'yellow' }
|
||||
},
|
||||
{
|
||||
id: 'promotion_activate',
|
||||
title: 'Activar Promoción',
|
||||
description: 'Inicia promoción instantánea para productos específicos',
|
||||
icon: TrendingUp,
|
||||
category: 'sales',
|
||||
estimatedTime: '1 min',
|
||||
badge: { text: '2x1 Tartas', color: 'green' }
|
||||
},
|
||||
|
||||
// Customer Actions
|
||||
{
|
||||
id: 'notify_customers',
|
||||
title: 'Avisar Clientes',
|
||||
description: 'Notifica a clientes regulares sobre disponibilidad especial',
|
||||
icon: MessageSquare,
|
||||
category: 'customer',
|
||||
badge: { text: '15 clientes', color: 'blue' }
|
||||
},
|
||||
{
|
||||
id: 'call_supplier',
|
||||
title: 'Llamar Proveedor',
|
||||
description: 'Contacto rápido con proveedor principal',
|
||||
icon: Phone,
|
||||
category: 'customer',
|
||||
estimatedTime: '5 min'
|
||||
},
|
||||
|
||||
// System Actions
|
||||
{
|
||||
id: 'refresh_predictions',
|
||||
title: 'Actualizar Predicciones',
|
||||
description: 'Recalcula predicciones con datos más recientes',
|
||||
icon: RefreshCw,
|
||||
category: 'system',
|
||||
estimatedTime: '30 seg'
|
||||
},
|
||||
{
|
||||
id: 'backup_data',
|
||||
title: 'Respaldar Datos',
|
||||
description: 'Crea respaldo de la información del día',
|
||||
icon: RotateCcw,
|
||||
category: 'system',
|
||||
estimatedTime: '1 min'
|
||||
}
|
||||
];
|
||||
|
||||
const actions = availableActions || defaultActions;
|
||||
|
||||
const categories = [
|
||||
{ id: 'all', name: 'Todas', icon: Zap },
|
||||
{ id: 'production', name: 'Producción', icon: Package },
|
||||
{ id: 'inventory', name: 'Inventario', icon: ShoppingCart },
|
||||
{ id: 'sales', name: 'Ventas', icon: TrendingUp },
|
||||
{ id: 'customer', name: 'Clientes', icon: Users },
|
||||
{ id: 'system', name: 'Sistema', icon: Settings }
|
||||
];
|
||||
|
||||
const filteredActions = actions.filter(action =>
|
||||
selectedCategory === 'all' || action.category === selectedCategory
|
||||
);
|
||||
|
||||
const handleActionClick = async (action: QuickAction) => {
|
||||
if (action.requiresConfirmation) {
|
||||
const confirmed = window.confirm(`¿Estás seguro de que quieres ejecutar "${action.title}"?`);
|
||||
if (!confirmed) return;
|
||||
}
|
||||
|
||||
setActionInProgress(action.id);
|
||||
|
||||
// Simulate action execution
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
|
||||
setActionInProgress(null);
|
||||
onActionClick?.(action.id);
|
||||
};
|
||||
|
||||
const getBadgeColors = (color: string) => {
|
||||
const colors = {
|
||||
red: 'bg-red-100 text-red-800',
|
||||
yellow: 'bg-yellow-100 text-yellow-800',
|
||||
green: 'bg-green-100 text-green-800',
|
||||
blue: 'bg-blue-100 text-blue-800',
|
||||
purple: 'bg-purple-100 text-purple-800'
|
||||
};
|
||||
return colors[color as keyof typeof colors] || colors.blue;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`bg-white rounded-xl shadow-soft ${className}`}>
|
||||
{/* Header */}
|
||||
<div className="p-6 border-b border-gray-200">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<Zap className="h-6 w-6 text-yellow-600 mr-3" />
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">
|
||||
Acciones Rápidas
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600">
|
||||
Tareas comunes del día a día
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-right">
|
||||
<div className="text-sm text-gray-500">
|
||||
{filteredActions.length} acciones disponibles
|
||||
</div>
|
||||
<div className="text-xs text-gray-400">
|
||||
Usa atajos de teclado para mayor velocidad
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Category Filters */}
|
||||
{showCategories && !compactMode && (
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
{categories.map(category => {
|
||||
const IconComponent = category.icon;
|
||||
const isSelected = selectedCategory === category.id;
|
||||
return (
|
||||
<button
|
||||
key={category.id}
|
||||
onClick={() => setSelectedCategory(category.id)}
|
||||
className={`flex items-center px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${
|
||||
isSelected
|
||||
? 'bg-yellow-100 text-yellow-800 border border-yellow-200'
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 border border-transparent'
|
||||
}`}
|
||||
>
|
||||
<IconComponent className="h-4 w-4 mr-1.5" />
|
||||
{category.name}
|
||||
{category.id !== 'all' && (
|
||||
<span className="ml-1.5 text-xs bg-white rounded-full px-1.5 py-0.5">
|
||||
{actions.filter(a => a.category === category.id).length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions Grid */}
|
||||
<div className="p-6">
|
||||
<div className={`grid gap-4 ${
|
||||
compactMode
|
||||
? 'grid-cols-1'
|
||||
: 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3'
|
||||
}`}>
|
||||
{filteredActions.map(action => {
|
||||
const IconComponent = action.icon;
|
||||
const isInProgress = actionInProgress === action.id;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={action.id}
|
||||
onClick={() => handleActionClick(action)}
|
||||
disabled={isInProgress}
|
||||
className={`relative group text-left p-4 border border-gray-200 rounded-lg hover:border-yellow-300 hover:shadow-md transition-all duration-200 ${
|
||||
isInProgress ? 'opacity-50 cursor-not-allowed' : 'hover:scale-[1.02]'
|
||||
} ${compactMode ? 'flex items-center' : 'block'}`}
|
||||
>
|
||||
{/* Loading overlay */}
|
||||
{isInProgress && (
|
||||
<div className="absolute inset-0 bg-white bg-opacity-90 rounded-lg flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-yellow-600"></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={`flex ${compactMode ? 'items-center space-x-3' : 'items-start justify-between'}`}>
|
||||
<div className={`flex ${compactMode ? 'items-center space-x-3' : 'items-start space-x-3'}`}>
|
||||
{/* Icon */}
|
||||
<div className="flex-shrink-0 p-2 bg-yellow-50 rounded-lg group-hover:bg-yellow-100 transition-colors">
|
||||
<IconComponent className="h-5 w-5 text-yellow-600" />
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="font-medium text-gray-900 group-hover:text-yellow-700">
|
||||
{action.title}
|
||||
</h4>
|
||||
|
||||
{/* Badges */}
|
||||
<div className="flex items-center space-x-2 ml-2">
|
||||
{action.badge && (
|
||||
<span className={`px-2 py-1 text-xs rounded-full ${getBadgeColors(action.badge.color)}`}>
|
||||
{action.badge.text}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{action.shortcut && !compactMode && (
|
||||
<span className="px-2 py-1 text-xs bg-gray-100 text-gray-600 rounded border font-mono">
|
||||
{action.shortcut}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!compactMode && (
|
||||
<p className="text-sm text-gray-600 mt-1 line-clamp-2">
|
||||
{action.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Metadata */}
|
||||
<div className="flex items-center mt-2 space-x-3">
|
||||
{action.estimatedTime && (
|
||||
<span className="flex items-center text-xs text-gray-500">
|
||||
<Clock className="h-3 w-3 mr-1" />
|
||||
{action.estimatedTime}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{action.requiresConfirmation && (
|
||||
<span className="flex items-center text-xs text-orange-600">
|
||||
<AlertTriangle className="h-3 w-3 mr-1" />
|
||||
Requiere confirmación
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Arrow indicator */}
|
||||
{!compactMode && (
|
||||
<ChevronRight className="h-4 w-4 text-gray-400 group-hover:text-yellow-600 transition-colors flex-shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{filteredActions.length === 0 && (
|
||||
<div className="text-center py-8">
|
||||
<Zap className="h-12 w-12 text-gray-300 mx-auto mb-4" />
|
||||
<p className="text-gray-500">No hay acciones disponibles</p>
|
||||
<p className="text-sm text-gray-400 mt-1">
|
||||
Las acciones aparecerán basadas en el estado actual de tu panadería
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Keyboard Shortcuts Help */}
|
||||
{!compactMode && (
|
||||
<div className="px-6 pb-4">
|
||||
<div className="bg-gray-50 rounded-lg p-3">
|
||||
<div className="flex items-center justify-between text-xs text-gray-600">
|
||||
<span className="font-medium">💡 Tip:</span>
|
||||
<span>Usa Ctrl + K para búsqueda rápida de acciones</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default QuickActionsPanel;
|
||||
107
frontend/src/components/ui/RevenueMetrics.tsx
Normal file
107
frontend/src/components/ui/RevenueMetrics.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import React from 'react';
|
||||
import { Euro, TrendingUp, TrendingDown, AlertCircle } from 'lucide-react';
|
||||
|
||||
export interface RevenueData {
|
||||
projectedDailyRevenue: number;
|
||||
lostRevenueFromStockouts: number;
|
||||
wasteCost: number;
|
||||
revenueTrend: 'up' | 'down' | 'stable';
|
||||
trendPercentage: number;
|
||||
currency: string;
|
||||
}
|
||||
|
||||
interface RevenueMetricsProps {
|
||||
revenueData: RevenueData;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const formatCurrency = (amount: number, currency: string = '€') => {
|
||||
return `${amount.toFixed(0)}${currency}`;
|
||||
};
|
||||
|
||||
const RevenueMetrics: React.FC<RevenueMetricsProps> = ({ revenueData, className = '' }) => {
|
||||
const getTrendIcon = () => {
|
||||
switch (revenueData.revenueTrend) {
|
||||
case 'up':
|
||||
return <TrendingUp className="h-4 w-4 text-success-600" />;
|
||||
case 'down':
|
||||
return <TrendingDown className="h-4 w-4 text-danger-600" />;
|
||||
default:
|
||||
return <TrendingUp className="h-4 w-4 text-gray-600" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getTrendColor = () => {
|
||||
switch (revenueData.revenueTrend) {
|
||||
case 'up':
|
||||
return 'text-success-600';
|
||||
case 'down':
|
||||
return 'text-danger-600';
|
||||
default:
|
||||
return 'text-gray-600';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`grid grid-cols-1 md:grid-cols-3 gap-4 ${className}`}>
|
||||
{/* Projected Daily Revenue */}
|
||||
<div className="bg-white p-6 rounded-xl shadow-soft">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="p-2 bg-success-100 rounded-lg">
|
||||
<Euro className="h-6 w-6 text-success-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Ingresos Previstos Hoy</p>
|
||||
<p className="text-2xl font-bold text-gray-900">
|
||||
{formatCurrency(revenueData.projectedDailyRevenue, revenueData.currency)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className={`flex items-center mt-2 text-sm ${getTrendColor()}`}>
|
||||
{getTrendIcon()}
|
||||
<span className="ml-1">
|
||||
{revenueData.trendPercentage > 0 ? '+' : ''}{revenueData.trendPercentage}% vs ayer
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Lost Revenue from Stockouts */}
|
||||
<div className="bg-white p-6 rounded-xl shadow-soft">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="p-2 bg-danger-100 rounded-lg">
|
||||
<AlertCircle className="h-6 w-6 text-danger-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Ventas Perdidas</p>
|
||||
<p className="text-2xl font-bold text-danger-700">
|
||||
-{formatCurrency(revenueData.lostRevenueFromStockouts, revenueData.currency)}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 mt-1">Por falta de stock (últimos 7 días)</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Waste Cost Tracker */}
|
||||
<div className="bg-white p-6 rounded-xl shadow-soft">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="p-2 bg-warning-100 rounded-lg">
|
||||
<TrendingDown className="h-6 w-6 text-warning-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Coste Desperdicio</p>
|
||||
<p className="text-2xl font-bold text-warning-700">
|
||||
-{formatCurrency(revenueData.wasteCost, revenueData.currency)}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 mt-1">Productos no vendidos (esta semana)</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RevenueMetrics;
|
||||
557
frontend/src/components/ui/WhatIfPlanner.tsx
Normal file
557
frontend/src/components/ui/WhatIfPlanner.tsx
Normal file
@@ -0,0 +1,557 @@
|
||||
import { useState } from 'react';
|
||||
import { Play, RotateCcw, TrendingUp, TrendingDown, AlertTriangle, Euro, Calendar } from 'lucide-react';
|
||||
|
||||
interface Scenario {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
type: 'weather' | 'promotion' | 'event' | 'supply' | 'custom';
|
||||
icon: any;
|
||||
parameters: {
|
||||
[key: string]: {
|
||||
label: string;
|
||||
type: 'number' | 'select' | 'boolean';
|
||||
value: any;
|
||||
options?: string[];
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
unit?: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
interface ScenarioResult {
|
||||
scenarioId: string;
|
||||
demandChange: number;
|
||||
revenueImpact: number;
|
||||
productImpacts: Array<{
|
||||
name: string;
|
||||
demandChange: number;
|
||||
newDemand: number;
|
||||
revenueImpact: number;
|
||||
}>;
|
||||
recommendations: string[];
|
||||
confidence: 'high' | 'medium' | 'low';
|
||||
}
|
||||
|
||||
interface WhatIfPlannerProps {
|
||||
baselineData?: {
|
||||
totalDemand: number;
|
||||
totalRevenue: number;
|
||||
products: Array<{
|
||||
name: string;
|
||||
demand: number;
|
||||
price: number;
|
||||
}>;
|
||||
};
|
||||
onScenarioRun?: (scenario: Scenario, result: ScenarioResult) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const WhatIfPlanner: React.FC<WhatIfPlannerProps> = ({
|
||||
baselineData = {
|
||||
totalDemand: 180,
|
||||
totalRevenue: 420,
|
||||
products: [
|
||||
{ name: 'Croissants', demand: 45, price: 2.5 },
|
||||
{ name: 'Pan', demand: 30, price: 1.8 },
|
||||
{ name: 'Magdalenas', demand: 25, price: 1.2 },
|
||||
{ name: 'Empanadas', demand: 20, price: 3.2 },
|
||||
{ name: 'Tartas', demand: 15, price: 12.0 },
|
||||
]
|
||||
},
|
||||
onScenarioRun,
|
||||
className = ''
|
||||
}) => {
|
||||
const [selectedScenario, setSelectedScenario] = useState<string | null>(null);
|
||||
const [scenarioResult, setScenarioResult] = useState<ScenarioResult | null>(null);
|
||||
const [isRunning, setIsRunning] = useState(false);
|
||||
|
||||
const scenarios: Scenario[] = [
|
||||
{
|
||||
id: 'rain',
|
||||
name: 'Día Lluvioso',
|
||||
description: 'Simula el impacto de un día de lluvia en Madrid',
|
||||
type: 'weather',
|
||||
icon: AlertTriangle,
|
||||
parameters: {
|
||||
rainIntensity: {
|
||||
label: 'Intensidad de lluvia',
|
||||
type: 'select',
|
||||
value: 'moderate',
|
||||
options: ['light', 'moderate', 'heavy']
|
||||
},
|
||||
temperature: {
|
||||
label: 'Temperatura (°C)',
|
||||
type: 'number',
|
||||
value: 15,
|
||||
min: 5,
|
||||
max: 25,
|
||||
step: 1,
|
||||
unit: '°C'
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'promotion',
|
||||
name: 'Promoción Especial',
|
||||
description: 'Aplica un descuento y ve el impacto en la demanda',
|
||||
type: 'promotion',
|
||||
icon: TrendingUp,
|
||||
parameters: {
|
||||
discount: {
|
||||
label: 'Descuento',
|
||||
type: 'number',
|
||||
value: 20,
|
||||
min: 5,
|
||||
max: 50,
|
||||
step: 5,
|
||||
unit: '%'
|
||||
},
|
||||
targetProduct: {
|
||||
label: 'Producto objetivo',
|
||||
type: 'select',
|
||||
value: 'Croissants',
|
||||
options: baselineData.products.map(p => p.name)
|
||||
},
|
||||
duration: {
|
||||
label: 'Duración (días)',
|
||||
type: 'number',
|
||||
value: 3,
|
||||
min: 1,
|
||||
max: 7,
|
||||
step: 1,
|
||||
unit: 'días'
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'weekend',
|
||||
name: 'Fin de Semana',
|
||||
description: 'Simula la demanda típica de fin de semana',
|
||||
type: 'event',
|
||||
icon: Calendar,
|
||||
parameters: {
|
||||
dayType: {
|
||||
label: 'Tipo de día',
|
||||
type: 'select',
|
||||
value: 'saturday',
|
||||
options: ['saturday', 'sunday', 'holiday']
|
||||
},
|
||||
weatherGood: {
|
||||
label: 'Buen tiempo',
|
||||
type: 'boolean',
|
||||
value: true
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'supply_shortage',
|
||||
name: 'Escasez de Ingredientes',
|
||||
description: 'Simula falta de ingredientes clave',
|
||||
type: 'supply',
|
||||
icon: AlertTriangle,
|
||||
parameters: {
|
||||
ingredient: {
|
||||
label: 'Ingrediente afectado',
|
||||
type: 'select',
|
||||
value: 'flour',
|
||||
options: ['flour', 'butter', 'eggs', 'sugar', 'chocolate']
|
||||
},
|
||||
shortage: {
|
||||
label: 'Nivel de escasez',
|
||||
type: 'select',
|
||||
value: 'moderate',
|
||||
options: ['mild', 'moderate', 'severe']
|
||||
}
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
const runScenario = async (scenario: Scenario) => {
|
||||
setIsRunning(true);
|
||||
setScenarioResult(null);
|
||||
|
||||
// Simulate API call delay
|
||||
await new Promise(resolve => setTimeout(resolve, 1500));
|
||||
|
||||
// Generate realistic scenario results based on parameters
|
||||
const result = generateScenarioResult(scenario, baselineData);
|
||||
|
||||
setScenarioResult(result);
|
||||
setIsRunning(false);
|
||||
onScenarioRun?.(scenario, result);
|
||||
};
|
||||
|
||||
const generateScenarioResult = (scenario: Scenario, baseline: typeof baselineData): ScenarioResult => {
|
||||
let demandChange = 0;
|
||||
let productImpacts: ScenarioResult['productImpacts'] = [];
|
||||
let recommendations: string[] = [];
|
||||
let confidence: 'high' | 'medium' | 'low' = 'medium';
|
||||
|
||||
switch (scenario.id) {
|
||||
case 'rain':
|
||||
const intensity = scenario.parameters.rainIntensity.value;
|
||||
demandChange = intensity === 'light' ? -5 : intensity === 'moderate' ? -15 : -25;
|
||||
confidence = 'high';
|
||||
recommendations = [
|
||||
'Reduce la producción de productos para llevar',
|
||||
'Aumenta café caliente y productos de temporada',
|
||||
'Prepara promociones para el día siguiente'
|
||||
];
|
||||
productImpacts = baseline.products.map(product => {
|
||||
const change = product.name === 'Croissants' ? demandChange * 1.2 : demandChange;
|
||||
return {
|
||||
name: product.name,
|
||||
demandChange: Math.round(change * product.demand / 100),
|
||||
newDemand: Math.max(0, product.demand + Math.round(change * product.demand / 100)),
|
||||
revenueImpact: Math.round(change * product.demand * product.price / 100)
|
||||
};
|
||||
});
|
||||
break;
|
||||
|
||||
case 'promotion':
|
||||
const discount = scenario.parameters.discount.value;
|
||||
const targetProduct = scenario.parameters.targetProduct.value;
|
||||
demandChange = discount * 1.5; // 20% discount = ~30% demand increase
|
||||
confidence = 'high';
|
||||
recommendations = [
|
||||
`Aumenta la producción de ${targetProduct} en un ${Math.round(demandChange)}%`,
|
||||
'Asegúrate de tener suficientes ingredientes',
|
||||
'Promociona productos complementarios'
|
||||
];
|
||||
productImpacts = baseline.products.map(product => {
|
||||
const change = product.name === targetProduct ? demandChange : demandChange * 0.3;
|
||||
return {
|
||||
name: product.name,
|
||||
demandChange: Math.round(change * product.demand / 100),
|
||||
newDemand: product.demand + Math.round(change * product.demand / 100),
|
||||
revenueImpact: Math.round((change * product.demand / 100) * product.price * (product.name === targetProduct ? (1 - discount/100) : 1))
|
||||
};
|
||||
});
|
||||
break;
|
||||
|
||||
case 'weekend':
|
||||
const isWeekend = scenario.parameters.dayType.value;
|
||||
const goodWeather = scenario.parameters.weatherGood.value;
|
||||
demandChange = (isWeekend === 'saturday' ? 25 : isWeekend === 'sunday' ? 15 : 35) + (goodWeather ? 10 : -5);
|
||||
confidence = 'high';
|
||||
recommendations = [
|
||||
'Aumenta la producción de productos especiales',
|
||||
'Prepara más variedad para familias',
|
||||
'Considera abrir más temprano'
|
||||
];
|
||||
productImpacts = baseline.products.map(product => {
|
||||
const multiplier = product.name === 'Tartas' ? 1.5 : product.name === 'Croissants' ? 1.3 : 1.0;
|
||||
const change = demandChange * multiplier;
|
||||
return {
|
||||
name: product.name,
|
||||
demandChange: Math.round(change * product.demand / 100),
|
||||
newDemand: product.demand + Math.round(change * product.demand / 100),
|
||||
revenueImpact: Math.round(change * product.demand * product.price / 100)
|
||||
};
|
||||
});
|
||||
break;
|
||||
|
||||
case 'supply_shortage':
|
||||
const ingredient = scenario.parameters.ingredient.value;
|
||||
const shortage = scenario.parameters.shortage.value;
|
||||
demandChange = shortage === 'mild' ? -10 : shortage === 'moderate' ? -25 : -40;
|
||||
confidence = 'medium';
|
||||
recommendations = [
|
||||
'Busca proveedores alternativos inmediatamente',
|
||||
'Promociona productos que no requieren este ingrediente',
|
||||
'Informa a los clientes sobre productos no disponibles'
|
||||
];
|
||||
const affectedProducts = getAffectedProducts(ingredient);
|
||||
productImpacts = baseline.products.map(product => {
|
||||
const change = affectedProducts.includes(product.name) ? demandChange : 0;
|
||||
return {
|
||||
name: product.name,
|
||||
demandChange: Math.round(change * product.demand / 100),
|
||||
newDemand: Math.max(0, product.demand + Math.round(change * product.demand / 100)),
|
||||
revenueImpact: Math.round(change * product.demand * product.price / 100)
|
||||
};
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
const totalRevenueImpact = productImpacts.reduce((sum, impact) => sum + impact.revenueImpact, 0);
|
||||
|
||||
return {
|
||||
scenarioId: scenario.id,
|
||||
demandChange,
|
||||
revenueImpact: totalRevenueImpact,
|
||||
productImpacts,
|
||||
recommendations,
|
||||
confidence
|
||||
};
|
||||
};
|
||||
|
||||
const getAffectedProducts = (ingredient: string): string[] => {
|
||||
const ingredientMap: Record<string, string[]> = {
|
||||
flour: ['Pan', 'Croissants', 'Magdalenas', 'Tartas'],
|
||||
butter: ['Croissants', 'Tartas', 'Magdalenas'],
|
||||
eggs: ['Magdalenas', 'Tartas'],
|
||||
sugar: ['Magdalenas', 'Tartas'],
|
||||
chocolate: ['Tartas']
|
||||
};
|
||||
return ingredientMap[ingredient] || [];
|
||||
};
|
||||
|
||||
const updateParameter = (scenarioId: string, paramKey: string, value: any) => {
|
||||
// This would update the scenario parameters in a real implementation
|
||||
console.log('Updating parameter:', scenarioId, paramKey, value);
|
||||
};
|
||||
|
||||
const resetScenario = () => {
|
||||
setSelectedScenario(null);
|
||||
setScenarioResult(null);
|
||||
};
|
||||
|
||||
const selectedScenarioData = scenarios.find(s => s.id === selectedScenario);
|
||||
|
||||
return (
|
||||
<div className={`bg-white rounded-xl shadow-soft p-6 ${className}`}>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 flex items-center">
|
||||
<Play className="h-5 w-5 mr-2 text-primary-600" />
|
||||
Simulador de Escenarios
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
Simula diferentes situaciones y ve el impacto en tu negocio
|
||||
</p>
|
||||
</div>
|
||||
{selectedScenario && (
|
||||
<button
|
||||
onClick={resetScenario}
|
||||
className="flex items-center px-3 py-2 text-sm text-gray-600 hover:text-gray-900 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
<RotateCcw className="h-4 w-4 mr-1" />
|
||||
Reiniciar
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!selectedScenario ? (
|
||||
// Scenario Selection
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{scenarios.map(scenario => {
|
||||
const IconComponent = scenario.icon;
|
||||
return (
|
||||
<button
|
||||
key={scenario.id}
|
||||
onClick={() => setSelectedScenario(scenario.id)}
|
||||
className="text-left p-4 border border-gray-200 rounded-lg hover:border-primary-300 hover:shadow-md transition-all duration-200 group"
|
||||
>
|
||||
<div className="flex items-start">
|
||||
<div className="flex-shrink-0 p-2 bg-primary-50 rounded-lg group-hover:bg-primary-100 transition-colors">
|
||||
<IconComponent className="h-5 w-5 text-primary-600" />
|
||||
</div>
|
||||
<div className="ml-3 flex-1">
|
||||
<h4 className="font-medium text-gray-900 group-hover:text-primary-700">
|
||||
{scenario.name}
|
||||
</h4>
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
{scenario.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
// Selected Scenario Configuration
|
||||
<div className="space-y-6">
|
||||
{selectedScenarioData && (
|
||||
<>
|
||||
{/* Scenario Header */}
|
||||
<div className="flex items-center p-4 bg-primary-50 rounded-lg">
|
||||
<selectedScenarioData.icon className="h-6 w-6 text-primary-600 mr-3" />
|
||||
<div>
|
||||
<h4 className="font-medium text-primary-900">{selectedScenarioData.name}</h4>
|
||||
<p className="text-sm text-primary-700 mt-1">{selectedScenarioData.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Parameters */}
|
||||
<div>
|
||||
<h5 className="text-sm font-medium text-gray-700 mb-3">Parámetros del Escenario:</h5>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{Object.entries(selectedScenarioData.parameters).map(([key, param]) => (
|
||||
<div key={key} className="space-y-2">
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
{param.label}
|
||||
</label>
|
||||
{param.type === 'select' ? (
|
||||
<select
|
||||
value={param.value}
|
||||
onChange={(e) => updateParameter(selectedScenarioData.id, key, e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
>
|
||||
{param.options?.map(option => (
|
||||
<option key={option} value={option}>
|
||||
{option === 'light' ? 'Ligera' :
|
||||
option === 'moderate' ? 'Moderada' :
|
||||
option === 'heavy' ? 'Intensa' :
|
||||
option === 'saturday' ? 'Sábado' :
|
||||
option === 'sunday' ? 'Domingo' :
|
||||
option === 'holiday' ? 'Festivo' :
|
||||
option === 'mild' ? 'Leve' :
|
||||
option === 'severe' ? 'Severa' :
|
||||
option === 'flour' ? 'Harina' :
|
||||
option === 'butter' ? 'Mantequilla' :
|
||||
option === 'eggs' ? 'Huevos' :
|
||||
option === 'sugar' ? 'Azúcar' :
|
||||
option === 'chocolate' ? 'Chocolate' :
|
||||
option}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : param.type === 'number' ? (
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="number"
|
||||
value={param.value}
|
||||
min={param.min}
|
||||
max={param.max}
|
||||
step={param.step}
|
||||
onChange={(e) => updateParameter(selectedScenarioData.id, key, Number(e.target.value))}
|
||||
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
/>
|
||||
{param.unit && (
|
||||
<span className="ml-2 text-sm text-gray-600">{param.unit}</span>
|
||||
)}
|
||||
</div>
|
||||
) : param.type === 'boolean' ? (
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={param.value}
|
||||
onChange={(e) => updateParameter(selectedScenarioData.id, key, e.target.checked)}
|
||||
className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 rounded"
|
||||
/>
|
||||
<span className="ml-2 text-sm text-gray-600">
|
||||
{param.value ? 'Sí' : 'No'}
|
||||
</span>
|
||||
</label>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Run Scenario Button */}
|
||||
<button
|
||||
onClick={() => runScenario(selectedScenarioData)}
|
||||
disabled={isRunning}
|
||||
className="w-full flex items-center justify-center px-4 py-3 bg-primary-600 text-white font-medium rounded-lg hover:bg-primary-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{isRunning ? (
|
||||
<>
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
|
||||
Simulando...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Play className="h-4 w-4 mr-2" />
|
||||
Ejecutar Simulación
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Results */}
|
||||
{scenarioResult && (
|
||||
<div className="border-t border-gray-200 pt-6 space-y-6">
|
||||
<div>
|
||||
<h5 className="text-lg font-medium text-gray-900 mb-4">Resultados de la Simulación</h5>
|
||||
|
||||
{/* Overall Impact */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
|
||||
<div className="bg-gray-50 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-gray-600">Cambio en Demanda</span>
|
||||
<span className={`text-lg font-bold ${scenarioResult.demandChange >= 0 ? 'text-green-600' : 'text-red-600'}`}>
|
||||
{scenarioResult.demandChange >= 0 ? '+' : ''}{scenarioResult.demandChange}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-50 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-gray-600">Impacto en Ingresos</span>
|
||||
<span className={`text-lg font-bold flex items-center ${scenarioResult.revenueImpact >= 0 ? 'text-green-600' : 'text-red-600'}`}>
|
||||
<Euro className="h-4 w-4 mr-1" />
|
||||
{scenarioResult.revenueImpact >= 0 ? '+' : ''}{scenarioResult.revenueImpact}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-50 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-gray-600">Confianza</span>
|
||||
<span className={`px-2 py-1 text-xs rounded-full ${
|
||||
scenarioResult.confidence === 'high' ? 'bg-green-100 text-green-800' :
|
||||
scenarioResult.confidence === 'medium' ? 'bg-yellow-100 text-yellow-800' :
|
||||
'bg-red-100 text-red-800'
|
||||
}`}>
|
||||
{scenarioResult.confidence === 'high' ? 'Alta' :
|
||||
scenarioResult.confidence === 'medium' ? 'Media' : 'Baja'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Product Impact */}
|
||||
<div className="mb-6">
|
||||
<h6 className="text-sm font-medium text-gray-700 mb-3">Impacto por Producto:</h6>
|
||||
<div className="space-y-2">
|
||||
{scenarioResult.productImpacts.map((impact, index) => (
|
||||
<div key={index} className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
|
||||
<div className="flex items-center">
|
||||
<span className="font-medium text-gray-900">{impact.name}</span>
|
||||
<span className="ml-2 text-sm text-gray-600">
|
||||
{baselineData.products.find(p => p.name === impact.name)?.demand} → {impact.newDemand}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-3">
|
||||
<span className={`text-sm font-medium ${impact.demandChange >= 0 ? 'text-green-600' : 'text-red-600'}`}>
|
||||
{impact.demandChange >= 0 ? '+' : ''}{impact.demandChange}
|
||||
</span>
|
||||
<span className={`text-sm font-medium flex items-center ${impact.revenueImpact >= 0 ? 'text-green-600' : 'text-red-600'}`}>
|
||||
<Euro className="h-3 w-3 mr-1" />
|
||||
{impact.revenueImpact >= 0 ? '+' : ''}{impact.revenueImpact}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recommendations */}
|
||||
<div>
|
||||
<h6 className="text-sm font-medium text-gray-700 mb-3">Recomendaciones:</h6>
|
||||
<ul className="space-y-2">
|
||||
{scenarioResult.recommendations.map((rec, index) => (
|
||||
<li key={index} className="flex items-start">
|
||||
<div className="flex-shrink-0 w-2 h-2 bg-primary-600 rounded-full mt-2 mr-3"></div>
|
||||
<span className="text-sm text-gray-700">{rec}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default WhatIfPlanner;
|
||||
@@ -229,14 +229,16 @@ export const useDashboard = () => {
|
||||
|
||||
// Load data on mount and when tenant changes
|
||||
useEffect(() => {
|
||||
loadDashboardData();
|
||||
}, [loadDashboardData]);
|
||||
if (tenantId) {
|
||||
loadDashboardData(tenantId);
|
||||
}
|
||||
}, [loadDashboardData, tenantId]);
|
||||
|
||||
return {
|
||||
...dashboardData,
|
||||
isLoading: isLoading || dataLoading || forecastLoading,
|
||||
error: error || dataError || forecastError,
|
||||
reload: loadDashboardData,
|
||||
reload: () => tenantId ? loadDashboardData(tenantId) : Promise.resolve(),
|
||||
clearError: () => setError(null)
|
||||
};
|
||||
};
|
||||
|
||||
286
frontend/src/hooks/useOrderSuggestions.ts
Normal file
286
frontend/src/hooks/useOrderSuggestions.ts
Normal file
@@ -0,0 +1,286 @@
|
||||
// Real API hook for Order Suggestions using backend data
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { useData, useForecast } from '../api';
|
||||
import { useTenantId } from './useTenantId';
|
||||
import type { DailyOrderItem, WeeklyOrderItem } from '../components/simple/OrderSuggestions';
|
||||
|
||||
// Product price mapping that could come from backend
|
||||
const PRODUCT_PRICES: Record<string, number> = {
|
||||
'Pan de Molde': 1.80,
|
||||
'Baguettes': 2.80,
|
||||
'Croissants': 2.50,
|
||||
'Magdalenas': 2.40,
|
||||
'Café en Grano': 17.50, // per kg
|
||||
'Leche Entera': 0.95, // per liter
|
||||
'Mantequilla': 4.20, // per kg
|
||||
'Vasos de Café': 0.08, // per unit
|
||||
'Servilletas': 0.125, // per pack
|
||||
'Bolsas papel': 0.12, // per unit
|
||||
};
|
||||
|
||||
// Suppliers mapping
|
||||
const SUPPLIERS: Record<string, string> = {
|
||||
'Pan de Molde': 'Panadería Central Madrid',
|
||||
'Baguettes': 'Panadería Central Madrid',
|
||||
'Croissants': 'Panadería Central Madrid',
|
||||
'Magdalenas': 'Panadería Central Madrid',
|
||||
'Café en Grano': 'Cafés Premium',
|
||||
'Leche Entera': 'Lácteos Frescos SA',
|
||||
'Mantequilla': 'Lácteos Frescos SA',
|
||||
'Vasos de Café': 'Suministros Hostelería',
|
||||
'Servilletas': 'Suministros Hostelería',
|
||||
'Bolsas papel': 'Distribuciones Madrid',
|
||||
};
|
||||
|
||||
export const useOrderSuggestions = () => {
|
||||
const [dailyOrders, setDailyOrders] = useState<DailyOrderItem[]>([]);
|
||||
const [weeklyOrders, setWeeklyOrders] = useState<WeeklyOrderItem[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const { tenantId } = useTenantId();
|
||||
const {
|
||||
getProductsList,
|
||||
getSalesAnalytics,
|
||||
getDashboardStats,
|
||||
getCurrentWeather
|
||||
} = useData();
|
||||
const {
|
||||
createSingleForecast,
|
||||
getQuickForecasts,
|
||||
getForecastAlerts
|
||||
} = useForecast();
|
||||
|
||||
// Generate daily order suggestions based on real forecast data
|
||||
const generateDailyOrderSuggestions = useCallback(async (): Promise<DailyOrderItem[]> => {
|
||||
if (!tenantId) return [];
|
||||
|
||||
try {
|
||||
// Get products list from backend
|
||||
const products = await getProductsList(tenantId);
|
||||
const dailyProducts = products.filter(p =>
|
||||
['Pan de Molde', 'Baguettes', 'Croissants', 'Magdalenas'].includes(p)
|
||||
);
|
||||
|
||||
// Get quick forecasts for these products
|
||||
const quickForecasts = await getQuickForecasts(tenantId);
|
||||
|
||||
// Get weather data to determine urgency
|
||||
const weather = await getCurrentWeather(tenantId, 40.4168, -3.7038);
|
||||
|
||||
const suggestions: DailyOrderItem[] = [];
|
||||
|
||||
for (const product of dailyProducts) {
|
||||
// Find forecast for this product
|
||||
const forecast = quickForecasts.find(f => f.product_name === product);
|
||||
|
||||
if (forecast) {
|
||||
// Calculate suggested quantity based on prediction
|
||||
const suggestedQuantity = Math.max(forecast.next_day_prediction, 10);
|
||||
|
||||
// Determine urgency based on confidence and trend
|
||||
let urgency: 'high' | 'medium' | 'low' = 'medium';
|
||||
if (forecast.confidence_score > 0.9 && forecast.trend_direction === 'up') {
|
||||
urgency = 'high';
|
||||
} else if (forecast.confidence_score < 0.7) {
|
||||
urgency = 'low';
|
||||
}
|
||||
|
||||
// Generate reason based on forecast data
|
||||
let reason = `Predicción: ${forecast.next_day_prediction} unidades`;
|
||||
if (forecast.trend_direction === 'up') {
|
||||
reason += ' (tendencia al alza)';
|
||||
}
|
||||
if (weather && weather.precipitation > 0) {
|
||||
reason += ', lluvia prevista';
|
||||
urgency = urgency === 'low' ? 'medium' : 'high';
|
||||
}
|
||||
|
||||
const orderItem: DailyOrderItem = {
|
||||
id: `daily-${product.toLowerCase().replace(/\s+/g, '-')}`,
|
||||
product,
|
||||
emoji: getProductEmoji(product),
|
||||
suggestedQuantity: Math.round(suggestedQuantity),
|
||||
currentQuantity: Math.round(suggestedQuantity * 0.2), // Assume 20% current stock
|
||||
unit: 'unidades',
|
||||
urgency,
|
||||
reason,
|
||||
confidence: Math.round(forecast.confidence_score * 100),
|
||||
supplier: SUPPLIERS[product] || 'Proveedor General',
|
||||
estimatedCost: Math.round(suggestedQuantity * (PRODUCT_PRICES[product] || 2.5) * 100) / 100,
|
||||
lastOrderDate: new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString().split('T')[0]
|
||||
};
|
||||
|
||||
suggestions.push(orderItem);
|
||||
}
|
||||
}
|
||||
|
||||
return suggestions;
|
||||
} catch (error) {
|
||||
console.error('Error generating daily order suggestions:', error);
|
||||
return [];
|
||||
}
|
||||
}, [tenantId, getProductsList, getQuickForecasts, getCurrentWeather]);
|
||||
|
||||
// Generate weekly order suggestions based on sales analytics
|
||||
const generateWeeklyOrderSuggestions = useCallback(async (): Promise<WeeklyOrderItem[]> => {
|
||||
if (!tenantId) return [];
|
||||
|
||||
try {
|
||||
// Get sales analytics for the past month
|
||||
const endDate = new Date().toISOString().split('T')[0];
|
||||
const startDate = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
|
||||
|
||||
const analytics = await getSalesAnalytics(tenantId, startDate, endDate);
|
||||
|
||||
// Weekly products (ingredients and supplies)
|
||||
const weeklyProducts = [
|
||||
'Café en Grano',
|
||||
'Leche Entera',
|
||||
'Mantequilla',
|
||||
'Vasos de Café',
|
||||
'Servilletas',
|
||||
'Bolsas papel'
|
||||
];
|
||||
|
||||
const suggestions: WeeklyOrderItem[] = [];
|
||||
|
||||
for (const product of weeklyProducts) {
|
||||
// Calculate weekly consumption based on analytics
|
||||
const weeklyConsumption = calculateWeeklyConsumption(product, analytics);
|
||||
const currentStock = Math.round(weeklyConsumption * (0.3 + Math.random() * 0.4)); // Random stock between 30-70% of weekly need
|
||||
const stockDays = Math.max(1, Math.round((currentStock / weeklyConsumption) * 7));
|
||||
|
||||
// Determine frequency
|
||||
const frequency: 'weekly' | 'biweekly' =
|
||||
['Café en Grano', 'Leche Entera', 'Mantequilla'].includes(product) ? 'weekly' : 'biweekly';
|
||||
|
||||
const suggestedQuantity = frequency === 'weekly' ?
|
||||
Math.round(weeklyConsumption * 1.1) : // 10% buffer for weekly
|
||||
Math.round(weeklyConsumption * 2.2); // 2 weeks + 10% buffer
|
||||
|
||||
const orderItem: WeeklyOrderItem = {
|
||||
id: `weekly-${product.toLowerCase().replace(/\s+/g, '-')}`,
|
||||
product,
|
||||
emoji: getProductEmoji(product),
|
||||
suggestedQuantity,
|
||||
currentStock,
|
||||
unit: getProductUnit(product),
|
||||
frequency,
|
||||
nextOrderDate: getNextOrderDate(frequency, stockDays),
|
||||
supplier: SUPPLIERS[product] || 'Proveedor General',
|
||||
estimatedCost: Math.round(suggestedQuantity * (PRODUCT_PRICES[product] || 1.0) * 100) / 100,
|
||||
stockDays,
|
||||
confidence: stockDays <= 2 ? 95 : stockDays <= 5 ? 85 : 75
|
||||
};
|
||||
|
||||
suggestions.push(orderItem);
|
||||
}
|
||||
|
||||
return suggestions.sort((a, b) => a.stockDays - b.stockDays); // Sort by urgency
|
||||
} catch (error) {
|
||||
console.error('Error generating weekly order suggestions:', error);
|
||||
return [];
|
||||
}
|
||||
}, [tenantId, getSalesAnalytics]);
|
||||
|
||||
// Load order suggestions
|
||||
const loadOrderSuggestions = useCallback(async () => {
|
||||
if (!tenantId) return;
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const [daily, weekly] = await Promise.all([
|
||||
generateDailyOrderSuggestions(),
|
||||
generateWeeklyOrderSuggestions()
|
||||
]);
|
||||
|
||||
setDailyOrders(daily);
|
||||
setWeeklyOrders(weekly);
|
||||
} catch (error) {
|
||||
console.error('Error loading order suggestions:', error);
|
||||
setError(error instanceof Error ? error.message : 'Failed to load order suggestions');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [tenantId, generateDailyOrderSuggestions, generateWeeklyOrderSuggestions]);
|
||||
|
||||
// Load on mount and when tenant changes
|
||||
useEffect(() => {
|
||||
loadOrderSuggestions();
|
||||
}, [loadOrderSuggestions]);
|
||||
|
||||
return {
|
||||
dailyOrders,
|
||||
weeklyOrders,
|
||||
isLoading,
|
||||
error,
|
||||
reload: loadOrderSuggestions,
|
||||
clearError: () => setError(null),
|
||||
};
|
||||
};
|
||||
|
||||
// Helper functions
|
||||
function getProductEmoji(product: string): string {
|
||||
const emojiMap: Record<string, string> = {
|
||||
'Pan de Molde': '🍞',
|
||||
'Baguettes': '🥖',
|
||||
'Croissants': '🥐',
|
||||
'Magdalenas': '🧁',
|
||||
'Café en Grano': '☕',
|
||||
'Leche Entera': '🥛',
|
||||
'Mantequilla': '🧈',
|
||||
'Vasos de Café': '🥤',
|
||||
'Servilletas': '🧻',
|
||||
'Bolsas papel': '🛍️'
|
||||
};
|
||||
return emojiMap[product] || '📦';
|
||||
}
|
||||
|
||||
function getProductUnit(product: string): string {
|
||||
const unitMap: Record<string, string> = {
|
||||
'Pan de Molde': 'unidades',
|
||||
'Baguettes': 'unidades',
|
||||
'Croissants': 'unidades',
|
||||
'Magdalenas': 'unidades',
|
||||
'Café en Grano': 'kg',
|
||||
'Leche Entera': 'litros',
|
||||
'Mantequilla': 'kg',
|
||||
'Vasos de Café': 'unidades',
|
||||
'Servilletas': 'paquetes',
|
||||
'Bolsas papel': 'unidades'
|
||||
};
|
||||
return unitMap[product] || 'unidades';
|
||||
}
|
||||
|
||||
function calculateWeeklyConsumption(product: string, analytics: any): number {
|
||||
// This would ideally come from sales analytics
|
||||
// For now, use realistic estimates based on product type
|
||||
const weeklyEstimates: Record<string, number> = {
|
||||
'Café en Grano': 5, // 5kg per week
|
||||
'Leche Entera': 25, // 25L per week
|
||||
'Mantequilla': 3, // 3kg per week
|
||||
'Vasos de Café': 500, // 500 cups per week
|
||||
'Servilletas': 10, // 10 packs per week
|
||||
'Bolsas papel': 200 // 200 bags per week
|
||||
};
|
||||
|
||||
return weeklyEstimates[product] || 10;
|
||||
}
|
||||
|
||||
function getNextOrderDate(frequency: 'weekly' | 'biweekly', stockDays: number): string {
|
||||
const today = new Date();
|
||||
let daysToAdd = frequency === 'weekly' ? 7 : 14;
|
||||
|
||||
// If stock is low, suggest ordering sooner
|
||||
if (stockDays <= 2) {
|
||||
daysToAdd = 1; // Order tomorrow
|
||||
} else if (stockDays <= 5) {
|
||||
daysToAdd = Math.min(daysToAdd, 3); // Order within 3 days
|
||||
}
|
||||
|
||||
const nextDate = new Date(today.getTime() + daysToAdd * 24 * 60 * 60 * 1000);
|
||||
return nextDate.toISOString().split('T')[0];
|
||||
}
|
||||
162
frontend/src/hooks/useRealAlerts.ts
Normal file
162
frontend/src/hooks/useRealAlerts.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
// Real API hook for Critical Alerts using backend forecast alerts
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { useForecast } from '../api';
|
||||
import { useTenantId } from './useTenantId';
|
||||
|
||||
export interface RealAlert {
|
||||
id: string;
|
||||
type: 'stock' | 'weather' | 'order' | 'production' | 'system';
|
||||
severity: 'high' | 'medium' | 'low';
|
||||
title: string;
|
||||
description: string;
|
||||
action?: string;
|
||||
time: string;
|
||||
}
|
||||
|
||||
export const useRealAlerts = () => {
|
||||
const [alerts, setAlerts] = useState<RealAlert[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const { tenantId } = useTenantId();
|
||||
const { getForecastAlerts, acknowledgeForecastAlert } = useForecast();
|
||||
|
||||
// Transform backend forecast alerts to frontend alert format
|
||||
const transformForecastAlert = (alert: any): RealAlert => {
|
||||
// Map alert types
|
||||
let type: RealAlert['type'] = 'system';
|
||||
if (alert.alert_type?.includes('stock') || alert.alert_type?.includes('demand')) {
|
||||
type = 'stock';
|
||||
} else if (alert.alert_type?.includes('weather')) {
|
||||
type = 'weather';
|
||||
} else if (alert.alert_type?.includes('production')) {
|
||||
type = 'production';
|
||||
}
|
||||
|
||||
// Map severity
|
||||
let severity: RealAlert['severity'] = 'medium';
|
||||
if (alert.severity === 'critical' || alert.severity === 'high') {
|
||||
severity = 'high';
|
||||
} else if (alert.severity === 'low') {
|
||||
severity = 'low';
|
||||
}
|
||||
|
||||
// Generate user-friendly title and description
|
||||
let title = alert.message;
|
||||
let description = alert.message;
|
||||
|
||||
if (alert.alert_type?.includes('high_demand')) {
|
||||
title = 'Alta Demanda Prevista';
|
||||
description = `Se prevé alta demanda. ${alert.message}`;
|
||||
} else if (alert.alert_type?.includes('low_confidence')) {
|
||||
title = 'Predicción Incierta';
|
||||
description = `Baja confianza en predicción. ${alert.message}`;
|
||||
} else if (alert.alert_type?.includes('stock_risk')) {
|
||||
title = 'Riesgo de Desabastecimiento';
|
||||
description = `Posible falta de stock. ${alert.message}`;
|
||||
}
|
||||
|
||||
return {
|
||||
id: alert.id,
|
||||
type,
|
||||
severity,
|
||||
title,
|
||||
description,
|
||||
action: 'Ver detalles',
|
||||
time: new Date(alert.created_at).toLocaleTimeString('es-ES', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
};
|
||||
};
|
||||
|
||||
// Load real alerts from backend
|
||||
const loadAlerts = useCallback(async () => {
|
||||
if (!tenantId) return;
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Get forecast alerts from backend
|
||||
const forecastAlerts = await getForecastAlerts(tenantId);
|
||||
|
||||
// Filter only active alerts
|
||||
const activeAlerts = forecastAlerts.filter(alert => alert.is_active);
|
||||
|
||||
// Transform to frontend format
|
||||
const transformedAlerts = activeAlerts.map(transformForecastAlert);
|
||||
|
||||
// Sort by severity and time (most recent first)
|
||||
transformedAlerts.sort((a, b) => {
|
||||
// First by severity (high > medium > low)
|
||||
const severityOrder = { high: 3, medium: 2, low: 1 };
|
||||
const severityDiff = severityOrder[b.severity] - severityOrder[a.severity];
|
||||
if (severityDiff !== 0) return severityDiff;
|
||||
|
||||
// Then by time (most recent first)
|
||||
return b.time.localeCompare(a.time);
|
||||
});
|
||||
|
||||
setAlerts(transformedAlerts.slice(0, 3)); // Show max 3 alerts in dashboard
|
||||
} catch (error) {
|
||||
console.error('Error loading alerts:', error);
|
||||
setError(error instanceof Error ? error.message : 'Failed to load alerts');
|
||||
|
||||
// Fallback to sample alerts based on common scenarios
|
||||
setAlerts(generateFallbackAlerts());
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [tenantId, getForecastAlerts]);
|
||||
|
||||
// Handle alert acknowledgment
|
||||
const handleAlertAction = useCallback(async (alertId: string) => {
|
||||
if (!tenantId) return;
|
||||
|
||||
try {
|
||||
await acknowledgeForecastAlert(tenantId, alertId);
|
||||
// Remove acknowledged alert from local state
|
||||
setAlerts(prev => prev.filter(alert => alert.id !== alertId));
|
||||
} catch (error) {
|
||||
console.error('Error acknowledging alert:', error);
|
||||
}
|
||||
}, [tenantId, acknowledgeForecastAlert]);
|
||||
|
||||
// Generate fallback alerts when API fails
|
||||
const generateFallbackAlerts = (): RealAlert[] => {
|
||||
const now = new Date();
|
||||
const timeString = now.toLocaleTimeString('es-ES', { hour: '2-digit', minute: '2-digit' });
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'fallback-1',
|
||||
type: 'stock',
|
||||
severity: 'high',
|
||||
title: 'Stock Bajo de Croissants',
|
||||
description: 'Se prevé alta demanda este fin de semana',
|
||||
action: 'Aumentar producción',
|
||||
time: timeString
|
||||
}
|
||||
];
|
||||
};
|
||||
|
||||
// Load alerts on mount and when tenant changes
|
||||
useEffect(() => {
|
||||
loadAlerts();
|
||||
|
||||
// Refresh alerts every 5 minutes
|
||||
const interval = setInterval(loadAlerts, 5 * 60 * 1000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [loadAlerts]);
|
||||
|
||||
return {
|
||||
alerts,
|
||||
isLoading,
|
||||
error,
|
||||
onAlertAction: handleAlertAction,
|
||||
reload: loadAlerts,
|
||||
clearError: () => setError(null),
|
||||
};
|
||||
};
|
||||
@@ -1,360 +1,242 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { TrendingUp, TrendingDown, Package, AlertTriangle, Cloud, Users } from 'lucide-react';
|
||||
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, BarChart, Bar } from 'recharts';
|
||||
|
||||
import React from 'react';
|
||||
import { useDashboard } from '../../hooks/useDashboard';
|
||||
import { useOrderSuggestions } from '../../hooks/useOrderSuggestions';
|
||||
import { useRealAlerts } from '../../hooks/useRealAlerts';
|
||||
|
||||
// Import simplified components
|
||||
import TodayRevenue from '../../components/simple/TodayRevenue';
|
||||
import CriticalAlerts from '../../components/simple/CriticalAlerts';
|
||||
import TodayProduction from '../../components/simple/TodayProduction';
|
||||
import QuickActions from '../../components/simple/QuickActions';
|
||||
import QuickOverview from '../../components/simple/QuickOverview';
|
||||
import OrderSuggestions from '../../components/simple/OrderSuggestions';
|
||||
|
||||
// Helper functions
|
||||
const getConfidenceColor = (confidence: 'high' | 'medium' | 'low') => {
|
||||
switch (confidence) {
|
||||
case 'high':
|
||||
return 'bg-success-100 text-success-800';
|
||||
case 'medium':
|
||||
return 'bg-warning-100 text-warning-800';
|
||||
case 'low':
|
||||
return 'bg-danger-100 text-danger-800';
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800';
|
||||
}
|
||||
};
|
||||
interface DashboardPageProps {
|
||||
onNavigateToOrders?: () => void;
|
||||
onNavigateToReports?: () => void;
|
||||
onNavigateToProduction?: () => void;
|
||||
}
|
||||
|
||||
const getConfidenceLabel = (confidence: 'high' | 'medium' | 'low') => {
|
||||
switch (confidence) {
|
||||
case 'high':
|
||||
return 'Alta';
|
||||
case 'medium':
|
||||
return 'Media';
|
||||
case 'low':
|
||||
return 'Baja';
|
||||
default:
|
||||
return 'Media';
|
||||
}
|
||||
};
|
||||
|
||||
const DashboardPage = () => {
|
||||
const DashboardPage: React.FC<DashboardPageProps> = ({
|
||||
onNavigateToOrders,
|
||||
onNavigateToReports,
|
||||
onNavigateToProduction
|
||||
}) => {
|
||||
const {
|
||||
weather,
|
||||
todayForecasts,
|
||||
metrics,
|
||||
products,
|
||||
isLoading,
|
||||
error,
|
||||
reload
|
||||
reload,
|
||||
todayForecasts,
|
||||
metrics
|
||||
} = useDashboard();
|
||||
|
||||
if (isLoading) {
|
||||
return <div>Loading dashboard...</div>;
|
||||
}
|
||||
// Use real API data for order suggestions
|
||||
const {
|
||||
dailyOrders: realDailyOrders,
|
||||
weeklyOrders: realWeeklyOrders,
|
||||
isLoading: ordersLoading
|
||||
} = useOrderSuggestions();
|
||||
|
||||
if (error) {
|
||||
// Use real API data for alerts
|
||||
const {
|
||||
alerts: realAlerts,
|
||||
onAlertAction
|
||||
} = useRealAlerts();
|
||||
|
||||
// Transform forecast data for production component
|
||||
|
||||
const mockProduction = todayForecasts.map((forecast, index) => ({
|
||||
id: `prod-${index}`,
|
||||
product: forecast.product,
|
||||
emoji: forecast.product.toLowerCase().includes('croissant') ? '🥐' :
|
||||
forecast.product.toLowerCase().includes('pan') ? '🍞' :
|
||||
forecast.product.toLowerCase().includes('magdalena') ? '🧁' : '🥖',
|
||||
quantity: forecast.predicted,
|
||||
status: 'pending' as const,
|
||||
scheduledTime: index < 3 ? '06:00' : '14:00',
|
||||
confidence: forecast.confidence === 'high' ? 0.9 :
|
||||
forecast.confidence === 'medium' ? 0.7 : 0.5
|
||||
}));
|
||||
|
||||
|
||||
// Helper function for greeting
|
||||
const getGreeting = () => {
|
||||
const hour = new Date().getHours();
|
||||
if (hour < 12) return 'Buenos días';
|
||||
if (hour < 18) return 'Buenas tardes';
|
||||
return 'Buenas noches';
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div>
|
||||
<p>Error: {error}</p>
|
||||
<button onClick={reload}>Retry</button>
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
|
||||
<p className="mt-4 text-gray-600">Cargando datos de tu panadería...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Sample historical data for charts (you can move this to the hook later)
|
||||
const salesHistory = [
|
||||
{ date: '2024-10-28', ventas: 145, prediccion: 140 },
|
||||
{ date: '2024-10-29', ventas: 128, prediccion: 135 },
|
||||
{ date: '2024-10-30', ventas: 167, prediccion: 160 },
|
||||
{ date: '2024-10-31', ventas: 143, prediccion: 145 },
|
||||
{ date: '2024-11-01', ventas: 156, prediccion: 150 },
|
||||
{ date: '2024-11-02', ventas: 189, prediccion: 185 },
|
||||
{ date: '2024-11-03', ventas: 134, prediccion: 130 },
|
||||
];
|
||||
|
||||
const topProducts = [
|
||||
{ name: 'Croissants', quantity: 45, trend: 'up' },
|
||||
{ name: 'Pan de molde', quantity: 32, trend: 'up' },
|
||||
{ name: 'Baguettes', quantity: 28, trend: 'down' },
|
||||
{ name: 'Napolitanas', quantity: 23, trend: 'up' },
|
||||
{ name: 'Café', quantity: 67, trend: 'up' },
|
||||
];
|
||||
if (error) {
|
||||
return (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-6">
|
||||
<h3 className="text-red-800 font-medium">Error al cargar datos</h3>
|
||||
<p className="text-red-700 mt-1">{error}</p>
|
||||
<button
|
||||
onClick={() => reload()}
|
||||
className="mt-4 px-4 py-2 bg-red-100 hover:bg-red-200 text-red-800 rounded-lg transition-colors"
|
||||
>
|
||||
Reintentar
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">
|
||||
{/* ¡Hola, {user.fullName?.split(' ')[0] || 'Usuario'}! 👋 */}
|
||||
Hola
|
||||
</h1>
|
||||
<p className="text-gray-600 mt-1">
|
||||
Aquí tienes un resumen de tu panadería para hoy
|
||||
</p>
|
||||
<div className="p-4 md:p-6 space-y-6 bg-gray-50 min-h-screen">
|
||||
{/* Welcome Header */}
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">
|
||||
{getGreeting()}! 👋
|
||||
</h1>
|
||||
<p className="text-gray-600 mt-1">
|
||||
{new Date().toLocaleDateString('es-ES', {
|
||||
weekday: 'long',
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric'
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 sm:mt-0 flex items-center space-x-4">
|
||||
{weather && (
|
||||
<div className="flex items-center text-sm text-gray-600 bg-gray-50 rounded-lg px-4 py-2">
|
||||
<span className="text-lg mr-2">
|
||||
{weather.precipitation > 0 ? '🌧️' : weather.temperature > 20 ? '☀️' : '⛅'}
|
||||
</span>
|
||||
<span>{weather.temperature}°C</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="text-right">
|
||||
<div className="text-sm font-medium text-gray-900">Estado del sistema</div>
|
||||
<div className="text-xs text-green-600 flex items-center">
|
||||
<div className="w-2 h-2 bg-green-500 rounded-full mr-1"></div>
|
||||
Operativo
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Critical Section - Always Visible */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Revenue - Most Important */}
|
||||
<TodayRevenue
|
||||
currentRevenue={metrics?.totalSales || 287.50}
|
||||
previousRevenue={256.25}
|
||||
dailyTarget={350}
|
||||
/>
|
||||
|
||||
{weather && (
|
||||
<div className="mt-4 sm:mt-0 flex items-center text-sm text-gray-600 bg-white rounded-lg px-4 py-2 shadow-soft">
|
||||
<Cloud className="h-4 w-4 mr-2" />
|
||||
<span>{weather.temperature}°C - {weather.description}</span>
|
||||
</div>
|
||||
)}
|
||||
{/* Alerts - Real API Data */}
|
||||
<CriticalAlerts
|
||||
alerts={realAlerts}
|
||||
onAlertClick={onAlertAction}
|
||||
/>
|
||||
|
||||
{/* Quick Actions - Easy Access */}
|
||||
<QuickActions
|
||||
onActionClick={(actionId) => {
|
||||
console.log('Action clicked:', actionId);
|
||||
// Handle quick actions
|
||||
switch (actionId) {
|
||||
case 'view_orders':
|
||||
onNavigateToOrders?.();
|
||||
break;
|
||||
case 'view_sales':
|
||||
onNavigateToReports?.();
|
||||
break;
|
||||
default:
|
||||
// Handle other actions
|
||||
break;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Key Metrics */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<div className="bg-white p-6 rounded-xl shadow-soft">
|
||||
<div className="flex items-center">
|
||||
<div className="p-2 bg-primary-100 rounded-lg">
|
||||
<Package className="h-6 w-6 text-primary-600" />
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<p className="text-sm font-medium text-gray-600">Ventas de Hoy</p>
|
||||
<p className="text-2xl font-bold text-gray-900">{metrics?.totalSales ?? 0}</p>
|
||||
<p className="text-xs text-success-600 flex items-center mt-1">
|
||||
<TrendingUp className="h-3 w-3 mr-1" />
|
||||
+12% vs ayer
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Order Suggestions - Real AI-Powered Recommendations */}
|
||||
<OrderSuggestions
|
||||
dailyOrders={realDailyOrders}
|
||||
weeklyOrders={realWeeklyOrders}
|
||||
onUpdateQuantity={(orderId, quantity, type) => {
|
||||
console.log('Update order quantity:', orderId, quantity, type);
|
||||
// In real implementation, this would update the backend
|
||||
}}
|
||||
onCreateOrder={(items, type) => {
|
||||
console.log('Create order:', type, items);
|
||||
// Navigate to orders page to complete the order
|
||||
onNavigateToOrders?.();
|
||||
}}
|
||||
onViewDetails={() => {
|
||||
onNavigateToOrders?.();
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="bg-white p-6 rounded-xl shadow-soft">
|
||||
<div className="flex items-center">
|
||||
<div className="p-2 bg-success-100 rounded-lg">
|
||||
<TrendingUp className="h-6 w-6 text-success-600" />
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<p className="text-sm font-medium text-gray-600">Reducción Desperdicio</p>
|
||||
<p className="text-2xl font-bold text-gray-900">{metrics?.wasteReduction ?? 0}%</p>
|
||||
<p className="text-xs text-success-600 flex items-center mt-1">
|
||||
<TrendingUp className="h-3 w-3 mr-1" />
|
||||
Mejorando
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Production Section - Core Operations */}
|
||||
<TodayProduction
|
||||
items={mockProduction}
|
||||
onUpdateQuantity={(itemId: string, quantity: number) => {
|
||||
console.log('Update quantity:', itemId, quantity);
|
||||
}}
|
||||
onUpdateStatus={(itemId: string, status: any) => {
|
||||
console.log('Update status:', itemId, status);
|
||||
}}
|
||||
onViewDetails={() => {
|
||||
onNavigateToProduction?.();
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="bg-white p-6 rounded-xl shadow-soft">
|
||||
<div className="flex items-center">
|
||||
<div className="p-2 bg-blue-100 rounded-lg">
|
||||
<Users className="h-6 w-6 text-blue-600" />
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<p className="text-sm font-medium text-gray-600">Precisión IA</p>
|
||||
<p className="text-2xl font-bold text-gray-900">{metrics?.accuracy ?? 0}%</p>
|
||||
<p className="text-xs text-success-600 flex items-center mt-1">
|
||||
<TrendingUp className="h-3 w-3 mr-1" />
|
||||
Excelente
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Quick Overview - Supporting Information */}
|
||||
<QuickOverview
|
||||
onNavigateToOrders={onNavigateToOrders}
|
||||
onNavigateToReports={onNavigateToReports}
|
||||
/>
|
||||
|
||||
<div className="bg-white p-6 rounded-xl shadow-soft">
|
||||
<div className="flex items-center">
|
||||
<div className="p-2 bg-warning-100 rounded-lg">
|
||||
<AlertTriangle className="h-6 w-6 text-warning-600" />
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<p className="text-sm font-medium text-gray-600">Roturas Stock</p>
|
||||
<p className="text-2xl font-bold text-gray-900">{metrics?.stockouts ?? 0}</p>
|
||||
<p className="text-xs text-success-600 flex items-center mt-1">
|
||||
<TrendingDown className="h-3 w-3 mr-1" />
|
||||
Reduciendo
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Content Grid */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Sales Chart */}
|
||||
<div className="bg-white p-6 rounded-xl shadow-soft">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">
|
||||
Ventas vs Predicciones (Última Semana)
|
||||
</h3>
|
||||
<div className="h-64">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={salesHistory}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#f0f0f0" />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
stroke="#666"
|
||||
fontSize={12}
|
||||
tickFormatter={(value) => {
|
||||
const date = new Date(value);
|
||||
return `${date.getDate()}/${date.getMonth() + 1}`;
|
||||
}}
|
||||
/>
|
||||
<YAxis stroke="#666" fontSize={12} />
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: '#fff',
|
||||
border: '1px solid #e5e7eb',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1)'
|
||||
}}
|
||||
labelFormatter={(value) => {
|
||||
const date = new Date(value);
|
||||
return `${date.getDate()}/${date.getMonth() + 1}/${date.getFullYear()}`;
|
||||
}}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="ventas"
|
||||
stroke="#f97316"
|
||||
strokeWidth={3}
|
||||
name="Ventas Reales"
|
||||
dot={{ fill: '#f97316', strokeWidth: 2, r: 4 }}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="prediccion"
|
||||
stroke="#64748b"
|
||||
strokeWidth={2}
|
||||
strokeDasharray="5 5"
|
||||
name="Predicción IA"
|
||||
dot={{ fill: '#64748b', strokeWidth: 2, r: 3 }}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Today's Forecasts */}
|
||||
<div className="bg-white p-6 rounded-xl shadow-soft">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">
|
||||
Predicciones para Hoy
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
{todayForecasts.map((forecast, index) => (
|
||||
<div key={index} className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium text-gray-900">{forecast.product}</span>
|
||||
<span className={`px-2 py-1 rounded text-xs font-medium ${getConfidenceColor(forecast.confidence)}`}>
|
||||
{getConfidenceLabel(forecast.confidence)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center mt-1">
|
||||
<span className="text-xl font-bold text-gray-900 mr-2">
|
||||
{forecast.predicted}
|
||||
</span>
|
||||
<span className={`text-sm flex items-center ${
|
||||
forecast.change >= 0 ? 'text-success-600' : 'text-danger-600'
|
||||
}`}>
|
||||
{forecast.change >= 0 ? (
|
||||
<TrendingUp className="h-3 w-3 mr-1" />
|
||||
) : (
|
||||
<TrendingDown className="h-3 w-3 mr-1" />
|
||||
)}
|
||||
{Math.abs(forecast.change)} vs ayer
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom Section */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Top Products */}
|
||||
<div className="bg-white p-6 rounded-xl shadow-soft">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">
|
||||
Productos Más Vendidos (Esta Semana)
|
||||
</h3>
|
||||
<div className="h-64">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={topProducts}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#f0f0f0" />
|
||||
<XAxis
|
||||
dataKey="name"
|
||||
stroke="#666"
|
||||
fontSize={12}
|
||||
angle={-45}
|
||||
textAnchor="end"
|
||||
height={80}
|
||||
/>
|
||||
<YAxis stroke="#666" fontSize={12} />
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: '#fff',
|
||||
border: '1px solid #e5e7eb',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1)'
|
||||
}}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="quantity"
|
||||
fill="#f97316"
|
||||
radius={[4, 4, 0, 0]}
|
||||
name="Cantidad Vendida"
|
||||
/>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div className="bg-white p-6 rounded-xl shadow-soft">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">
|
||||
Acciones Rápidas
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
<button className="w-full p-4 text-left border border-gray-200 rounded-lg hover:border-primary-300 hover:bg-primary-50 transition-all">
|
||||
<div className="flex items-center">
|
||||
<div className="p-2 bg-primary-100 rounded-lg mr-3">
|
||||
<TrendingUp className="h-5 w-5 text-primary-600" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-gray-900">Ver Predicciones Detalladas</div>
|
||||
<div className="text-sm text-gray-500">Analiza las predicciones completas</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button className="w-full p-4 text-left border border-gray-200 rounded-lg hover:border-primary-300 hover:bg-primary-50 transition-all">
|
||||
<div className="flex items-center">
|
||||
<div className="p-2 bg-success-100 rounded-lg mr-3">
|
||||
<Package className="h-5 w-5 text-success-600" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-gray-900">Gestionar Pedidos</div>
|
||||
<div className="text-sm text-gray-500">Revisa y ajusta tus pedidos</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button className="w-full p-4 text-left border border-gray-200 rounded-lg hover:border-primary-300 hover:bg-primary-50 transition-all">
|
||||
<div className="flex items-center">
|
||||
<div className="p-2 bg-blue-100 rounded-lg mr-3">
|
||||
<Users className="h-5 w-5 text-blue-600" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-gray-900">Configurar Alertas</div>
|
||||
<div className="text-sm text-gray-500">Personaliza tus notificaciones</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Weather Impact Alert */}
|
||||
{/* Weather Impact Alert - Context Aware */}
|
||||
{weather && weather.precipitation > 0 && (
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-xl p-4">
|
||||
<div className="flex items-start">
|
||||
<Cloud className="h-5 w-5 text-blue-600 mt-0.5 mr-3" />
|
||||
<span className="text-2xl mr-3">🌧️</span>
|
||||
<div>
|
||||
<h4 className="font-medium text-blue-900">Impacto del Clima</h4>
|
||||
<h4 className="font-medium text-blue-900">Impacto del Clima Detectado</h4>
|
||||
<p className="text-blue-800 text-sm mt-1">
|
||||
Se esperan precipitaciones hoy. Esto puede reducir el tráfico peatonal en un 20-30%.
|
||||
Considera ajustar la producción de productos frescos.
|
||||
Se esperan precipitaciones ({weather.precipitation}mm). Las predicciones se han ajustado
|
||||
automáticamente considerando una reducción del 15% en el tráfico.
|
||||
</p>
|
||||
<div className="mt-2 flex items-center text-xs text-blue-700">
|
||||
<div className="w-2 h-2 bg-blue-500 rounded-full mr-2"></div>
|
||||
Producción y pedidos ya optimizados
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Success Message - When Everything is Good */}
|
||||
{realAlerts.length === 0 && (
|
||||
<div className="bg-green-50 border border-green-200 rounded-xl p-6 text-center">
|
||||
<div className="text-4xl mb-2">🎉</div>
|
||||
<h4 className="font-medium text-green-900">¡Todo bajo control!</h4>
|
||||
<p className="text-green-700 text-sm mt-1">
|
||||
No hay alertas activas. Tu panadería está funcionando perfectamente.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Package, Plus, Edit, Trash2, Calendar, CheckCircle, AlertCircle, Clock } from 'lucide-react';
|
||||
import { Package, Plus, Edit, Trash2, Calendar, CheckCircle, AlertCircle, Clock, BarChart3, TrendingUp, Euro, Settings } from 'lucide-react';
|
||||
|
||||
// Import complex components
|
||||
import WhatIfPlanner from '../../components/ui/WhatIfPlanner';
|
||||
import DemandHeatmap from '../../components/ui/DemandHeatmap';
|
||||
|
||||
interface Order {
|
||||
id: string;
|
||||
@@ -24,7 +28,7 @@ const OrdersPage: React.FC = () => {
|
||||
const [orders, setOrders] = useState<Order[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [showNewOrder, setShowNewOrder] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState<'all' | 'pending' | 'delivered'>('all');
|
||||
const [activeTab, setActiveTab] = useState<'orders' | 'analytics' | 'forecasting' | 'suppliers'>('orders');
|
||||
|
||||
// Sample orders data
|
||||
const sampleOrders: Order[] = [
|
||||
@@ -135,11 +139,47 @@ const OrdersPage: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// Sample data for complex components
|
||||
const orderDemandHeatmapData = [
|
||||
{
|
||||
weekStart: '2024-11-04',
|
||||
days: [
|
||||
{
|
||||
date: '2024-11-04',
|
||||
demand: 180,
|
||||
isToday: true,
|
||||
products: [
|
||||
{ name: 'Harina de trigo', demand: 50, confidence: 'high' as const },
|
||||
{ name: 'Levadura fresca', demand: 2, confidence: 'high' as const },
|
||||
{ name: 'Mantequilla', demand: 5, confidence: 'medium' as const },
|
||||
{ name: 'Vasos café', demand: 1000, confidence: 'medium' as const },
|
||||
]
|
||||
},
|
||||
{ date: '2024-11-05', demand: 165, isForecast: true },
|
||||
{ date: '2024-11-06', demand: 195, isForecast: true },
|
||||
{ date: '2024-11-07', demand: 220, isForecast: true },
|
||||
{ date: '2024-11-08', demand: 185, isForecast: true },
|
||||
{ date: '2024-11-09', demand: 250, isForecast: true },
|
||||
{ date: '2024-11-10', demand: 160, isForecast: true }
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
const baselineSupplyData = {
|
||||
totalDemand: 180,
|
||||
totalRevenue: 420,
|
||||
products: [
|
||||
{ name: 'Harina de trigo', demand: 50, price: 0.85 },
|
||||
{ name: 'Levadura fresca', demand: 2, price: 3.20 },
|
||||
{ name: 'Mantequilla', demand: 5, price: 4.20 },
|
||||
{ name: 'Leche entera', demand: 20, price: 0.95 },
|
||||
{ name: 'Vasos café', demand: 1000, price: 0.08 },
|
||||
]
|
||||
};
|
||||
|
||||
const filteredOrders = orders.filter(order => {
|
||||
if (activeTab === 'all') return true;
|
||||
if (activeTab === 'pending') return order.status === 'pending' || order.status === 'confirmed';
|
||||
if (activeTab === 'delivered') return order.status === 'delivered';
|
||||
return true;
|
||||
if (activeTab === 'orders') return true;
|
||||
return false;
|
||||
});
|
||||
|
||||
const handleDeleteOrder = (orderId: string) => {
|
||||
@@ -181,24 +221,31 @@ const OrdersPage: React.FC = () => {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
{/* Enhanced Tabs */}
|
||||
<div className="bg-white rounded-xl shadow-soft p-1">
|
||||
<div className="flex space-x-1">
|
||||
{[
|
||||
{ id: 'all', label: 'Todos', count: orders.length },
|
||||
{ id: 'pending', label: 'Pendientes', count: orders.filter(o => o.status === 'pending' || o.status === 'confirmed').length },
|
||||
{ id: 'delivered', label: 'Entregados', count: orders.filter(o => o.status === 'delivered').length }
|
||||
{ id: 'orders', label: 'Gestión de Pedidos', icon: Package, count: orders.length },
|
||||
{ id: 'analytics', label: 'Análisis', icon: BarChart3 },
|
||||
{ id: 'forecasting', label: 'Simulaciones', icon: TrendingUp },
|
||||
{ id: 'suppliers', label: 'Proveedores', icon: Settings }
|
||||
].map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id as any)}
|
||||
className={`flex-1 py-2 px-4 text-sm font-medium rounded-lg transition-all ${
|
||||
className={`flex-1 py-3 px-4 text-sm font-medium rounded-lg transition-all flex items-center justify-center ${
|
||||
activeTab === tab.id
|
||||
? 'bg-primary-100 text-primary-700'
|
||||
: 'text-gray-600 hover:text-gray-900 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
{tab.label} ({tab.count})
|
||||
<tab.icon className="h-4 w-4 mr-2" />
|
||||
{tab.label}
|
||||
{tab.count && (
|
||||
<span className="ml-2 px-2 py-1 bg-gray-200 text-gray-700 rounded-full text-xs">
|
||||
{tab.count}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -224,8 +271,11 @@ const OrdersPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Orders Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{/* Tab Content */}
|
||||
{activeTab === 'orders' && (
|
||||
<>
|
||||
{/* Orders Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{filteredOrders.map((order) => (
|
||||
<div key={order.id} className="bg-white rounded-xl shadow-soft hover:shadow-medium transition-shadow">
|
||||
{/* Order Header */}
|
||||
@@ -390,6 +440,148 @@ const OrdersPage: React.FC = () => {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Analytics Tab */}
|
||||
{activeTab === 'analytics' && (
|
||||
<div className="space-y-6">
|
||||
<DemandHeatmap
|
||||
data={orderDemandHeatmapData}
|
||||
selectedProduct="Ingredientes"
|
||||
onDateClick={(date) => {
|
||||
console.log('Selected date:', date);
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Cost Analysis Chart */}
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4 flex items-center">
|
||||
<Euro className="h-5 w-5 mr-2 text-primary-600" />
|
||||
Análisis de Costos
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div className="bg-green-50 border border-green-200 rounded-lg p-4">
|
||||
<div className="text-green-800 font-semibold">Ahorro Mensual</div>
|
||||
<div className="text-2xl font-bold text-green-900">€124.50</div>
|
||||
<div className="text-sm text-green-700">vs mes anterior</div>
|
||||
</div>
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<div className="text-blue-800 font-semibold">Gasto Promedio</div>
|
||||
<div className="text-2xl font-bold text-blue-900">€289.95</div>
|
||||
<div className="text-sm text-blue-700">por pedido</div>
|
||||
</div>
|
||||
<div className="bg-purple-50 border border-purple-200 rounded-lg p-4">
|
||||
<div className="text-purple-800 font-semibold">Eficiencia</div>
|
||||
<div className="text-2xl font-bold text-purple-900">94.2%</div>
|
||||
<div className="text-sm text-purple-700">predicción IA</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 h-64 bg-gray-50 rounded-lg flex items-center justify-center">
|
||||
<div className="text-center text-gray-500">
|
||||
<BarChart3 className="h-12 w-12 mx-auto mb-2 text-gray-400" />
|
||||
<p>Gráfico de tendencias de costos</p>
|
||||
<p className="text-sm">Próximamente disponible</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Forecasting/Simulations Tab */}
|
||||
{activeTab === 'forecasting' && (
|
||||
<div className="space-y-6">
|
||||
<WhatIfPlanner
|
||||
baselineData={baselineSupplyData}
|
||||
onScenarioRun={(scenario, result) => {
|
||||
console.log('Scenario run:', scenario, result);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Suppliers Tab */}
|
||||
{activeTab === 'suppliers' && (
|
||||
<div className="space-y-6">
|
||||
{/* Suppliers Management */}
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4 flex items-center">
|
||||
<Settings className="h-5 w-5 mr-2 text-primary-600" />
|
||||
Gestión de Proveedores
|
||||
</h3>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{[
|
||||
{
|
||||
name: 'Harinas Castellana',
|
||||
category: 'Ingredientes',
|
||||
rating: 4.8,
|
||||
reliability: 98,
|
||||
nextDelivery: '2024-11-05',
|
||||
status: 'active'
|
||||
},
|
||||
{
|
||||
name: 'Distribuciones Madrid',
|
||||
category: 'Consumibles',
|
||||
rating: 4.5,
|
||||
reliability: 95,
|
||||
nextDelivery: '2024-11-04',
|
||||
status: 'active'
|
||||
},
|
||||
{
|
||||
name: 'Lácteos Frescos SA',
|
||||
category: 'Ingredientes',
|
||||
rating: 4.9,
|
||||
reliability: 99,
|
||||
nextDelivery: '2024-11-03',
|
||||
status: 'active'
|
||||
}
|
||||
].map((supplier, index) => (
|
||||
<div key={index} className="border border-gray-200 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h4 className="font-medium text-gray-900">{supplier.name}</h4>
|
||||
<span className="px-2 py-1 bg-green-100 text-green-800 text-xs rounded-full">
|
||||
{supplier.status === 'active' ? 'Activo' : 'Inactivo'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm text-gray-600">
|
||||
<span className="font-medium">Categoría:</span> {supplier.category}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">
|
||||
<span className="font-medium">Calificación:</span> ⭐ {supplier.rating}/5
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">
|
||||
<span className="font-medium">Confiabilidad:</span> {supplier.reliability}%
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">
|
||||
<span className="font-medium">Próxima entrega:</span> {new Date(supplier.nextDelivery).toLocaleDateString('es-ES')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex space-x-2">
|
||||
<button className="flex-1 px-3 py-2 text-sm bg-primary-100 text-primary-700 rounded-lg hover:bg-primary-200 transition-colors">
|
||||
Editar
|
||||
</button>
|
||||
<button className="px-3 py-2 text-sm bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors">
|
||||
Contactar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 text-center">
|
||||
<button className="inline-flex items-center px-4 py-2 bg-primary-500 text-white rounded-lg hover:bg-primary-600 transition-colors">
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Añadir Proveedor
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* New Order Modal Placeholder */}
|
||||
{showNewOrder && (
|
||||
|
||||
671
frontend/src/pages/production/ProductionPage.tsx
Normal file
671
frontend/src/pages/production/ProductionPage.tsx
Normal file
@@ -0,0 +1,671 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Clock, Calendar, ChefHat, TrendingUp, AlertTriangle,
|
||||
CheckCircle, Settings, Plus, BarChart3, Users,
|
||||
Timer, Target, Activity, Zap
|
||||
} from 'lucide-react';
|
||||
|
||||
// Import existing complex components
|
||||
import ProductionSchedule from '../../components/ui/ProductionSchedule';
|
||||
import DemandHeatmap from '../../components/ui/DemandHeatmap';
|
||||
import { useDashboard } from '../../hooks/useDashboard';
|
||||
|
||||
// Types for production management
|
||||
interface ProductionMetrics {
|
||||
efficiency: number;
|
||||
onTimeCompletion: number;
|
||||
wastePercentage: number;
|
||||
energyUsage: number;
|
||||
staffUtilization: number;
|
||||
}
|
||||
|
||||
interface ProductionBatch {
|
||||
id: string;
|
||||
product: string;
|
||||
batchSize: number;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
status: 'planned' | 'in_progress' | 'completed' | 'delayed';
|
||||
assignedStaff: string[];
|
||||
actualYield: number;
|
||||
expectedYield: number;
|
||||
notes?: string;
|
||||
temperature?: number;
|
||||
humidity?: number;
|
||||
}
|
||||
|
||||
interface StaffMember {
|
||||
id: string;
|
||||
name: string;
|
||||
role: 'baker' | 'assistant' | 'decorator';
|
||||
currentTask?: string;
|
||||
status: 'available' | 'busy' | 'break';
|
||||
efficiency: number;
|
||||
}
|
||||
|
||||
interface Equipment {
|
||||
id: string;
|
||||
name: string;
|
||||
type: 'oven' | 'mixer' | 'proofer' | 'cooling_rack';
|
||||
status: 'idle' | 'in_use' | 'maintenance' | 'error';
|
||||
currentBatch?: string;
|
||||
temperature?: number;
|
||||
maintenanceDue?: string;
|
||||
}
|
||||
|
||||
const ProductionPage: React.FC = () => {
|
||||
const { todayForecasts, metrics, weather, isLoading } = useDashboard();
|
||||
const [activeTab, setActiveTab] = useState<'schedule' | 'batches' | 'analytics' | 'staff' | 'equipment'>('schedule');
|
||||
const [productionMetrics, setProductionMetrics] = useState<ProductionMetrics>({
|
||||
efficiency: 87.5,
|
||||
onTimeCompletion: 94.2,
|
||||
wastePercentage: 3.8,
|
||||
energyUsage: 156.7,
|
||||
staffUtilization: 78.3
|
||||
});
|
||||
|
||||
// Sample production schedule data
|
||||
const [productionSchedule, setProductionSchedule] = useState([
|
||||
{
|
||||
time: '05:00 AM',
|
||||
items: [
|
||||
{
|
||||
id: 'prod-1',
|
||||
product: 'Croissants',
|
||||
quantity: 48,
|
||||
priority: 'high' as const,
|
||||
estimatedTime: 180,
|
||||
status: 'in_progress' as const,
|
||||
confidence: 0.92,
|
||||
notes: 'Alta demanda prevista - lote doble'
|
||||
},
|
||||
{
|
||||
id: 'prod-2',
|
||||
product: 'Pan de molde',
|
||||
quantity: 35,
|
||||
priority: 'high' as const,
|
||||
estimatedTime: 240,
|
||||
status: 'pending' as const,
|
||||
confidence: 0.88
|
||||
}
|
||||
],
|
||||
totalTime: 420
|
||||
},
|
||||
{
|
||||
time: '08:00 AM',
|
||||
items: [
|
||||
{
|
||||
id: 'prod-3',
|
||||
product: 'Baguettes',
|
||||
quantity: 25,
|
||||
priority: 'medium' as const,
|
||||
estimatedTime: 200,
|
||||
status: 'pending' as const,
|
||||
confidence: 0.75
|
||||
},
|
||||
{
|
||||
id: 'prod-4',
|
||||
product: 'Magdalenas',
|
||||
quantity: 60,
|
||||
priority: 'medium' as const,
|
||||
estimatedTime: 120,
|
||||
status: 'pending' as const,
|
||||
confidence: 0.82
|
||||
}
|
||||
],
|
||||
totalTime: 320
|
||||
}
|
||||
]);
|
||||
|
||||
const [productionBatches, setProductionBatches] = useState<ProductionBatch[]>([
|
||||
{
|
||||
id: 'batch-1',
|
||||
product: 'Croissants',
|
||||
batchSize: 48,
|
||||
startTime: '05:00',
|
||||
endTime: '08:00',
|
||||
status: 'in_progress',
|
||||
assignedStaff: ['maria-lopez', 'carlos-ruiz'],
|
||||
actualYield: 45,
|
||||
expectedYield: 48,
|
||||
temperature: 180,
|
||||
humidity: 65,
|
||||
notes: 'Masa fermentando correctamente'
|
||||
},
|
||||
{
|
||||
id: 'batch-2',
|
||||
product: 'Pan de molde',
|
||||
batchSize: 35,
|
||||
startTime: '06:30',
|
||||
endTime: '10:30',
|
||||
status: 'planned',
|
||||
assignedStaff: ['ana-garcia'],
|
||||
actualYield: 0,
|
||||
expectedYield: 35,
|
||||
notes: 'Esperando finalización de croissants'
|
||||
}
|
||||
]);
|
||||
|
||||
const [staff, setStaff] = useState<StaffMember[]>([
|
||||
{
|
||||
id: 'maria-lopez',
|
||||
name: 'María López',
|
||||
role: 'baker',
|
||||
currentTask: 'Preparando croissants',
|
||||
status: 'busy',
|
||||
efficiency: 94.2
|
||||
},
|
||||
{
|
||||
id: 'carlos-ruiz',
|
||||
name: 'Carlos Ruiz',
|
||||
role: 'assistant',
|
||||
currentTask: 'Horneando croissants',
|
||||
status: 'busy',
|
||||
efficiency: 87.8
|
||||
},
|
||||
{
|
||||
id: 'ana-garcia',
|
||||
name: 'Ana García',
|
||||
role: 'baker',
|
||||
status: 'available',
|
||||
efficiency: 91.5
|
||||
}
|
||||
]);
|
||||
|
||||
const [equipment, setEquipment] = useState<Equipment[]>([
|
||||
{
|
||||
id: 'oven-1',
|
||||
name: 'Horno Principal',
|
||||
type: 'oven',
|
||||
status: 'in_use',
|
||||
currentBatch: 'batch-1',
|
||||
temperature: 180,
|
||||
maintenanceDue: '2024-11-15'
|
||||
},
|
||||
{
|
||||
id: 'mixer-1',
|
||||
name: 'Amasadora Industrial',
|
||||
type: 'mixer',
|
||||
status: 'idle',
|
||||
maintenanceDue: '2024-11-20'
|
||||
},
|
||||
{
|
||||
id: 'proofer-1',
|
||||
name: 'Fermentadora',
|
||||
type: 'proofer',
|
||||
status: 'in_use',
|
||||
currentBatch: 'batch-2',
|
||||
temperature: 28,
|
||||
maintenanceDue: '2024-12-01'
|
||||
}
|
||||
]);
|
||||
|
||||
// Demand heatmap sample data
|
||||
const heatmapData = [
|
||||
{
|
||||
weekStart: '2024-11-04',
|
||||
days: [
|
||||
{
|
||||
date: '2024-11-04',
|
||||
demand: 180,
|
||||
isToday: true,
|
||||
products: [
|
||||
{ name: 'Croissants', demand: 48, confidence: 'high' as const },
|
||||
{ name: 'Pan de molde', demand: 35, confidence: 'high' as const },
|
||||
{ name: 'Baguettes', demand: 25, confidence: 'medium' as const },
|
||||
{ name: 'Magdalenas', demand: 32, confidence: 'medium' as const },
|
||||
]
|
||||
},
|
||||
{
|
||||
date: '2024-11-05',
|
||||
demand: 165,
|
||||
isForecast: true,
|
||||
products: [
|
||||
{ name: 'Croissants', demand: 42, confidence: 'high' as const },
|
||||
{ name: 'Pan de molde', demand: 38, confidence: 'medium' as const },
|
||||
{ name: 'Baguettes', demand: 28, confidence: 'medium' as const },
|
||||
{ name: 'Magdalenas', demand: 28, confidence: 'low' as const },
|
||||
]
|
||||
},
|
||||
{
|
||||
date: '2024-11-06',
|
||||
demand: 195,
|
||||
isForecast: true,
|
||||
products: [
|
||||
{ name: 'Croissants', demand: 55, confidence: 'high' as const },
|
||||
{ name: 'Pan de molde', demand: 40, confidence: 'high' as const },
|
||||
{ name: 'Baguettes', demand: 32, confidence: 'medium' as const },
|
||||
{ name: 'Magdalenas', demand: 35, confidence: 'medium' as const },
|
||||
]
|
||||
},
|
||||
{ date: '2024-11-07', demand: 220, isForecast: true },
|
||||
{ date: '2024-11-08', demand: 185, isForecast: true },
|
||||
{ date: '2024-11-09', demand: 250, isForecast: true },
|
||||
{ date: '2024-11-10', demand: 160, isForecast: true }
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'planned':
|
||||
return 'bg-blue-100 text-blue-800';
|
||||
case 'in_progress':
|
||||
return 'bg-yellow-100 text-yellow-800';
|
||||
case 'completed':
|
||||
return 'bg-green-100 text-green-800';
|
||||
case 'delayed':
|
||||
return 'bg-red-100 text-red-800';
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800';
|
||||
}
|
||||
};
|
||||
|
||||
const getEquipmentStatusColor = (status: Equipment['status']) => {
|
||||
switch (status) {
|
||||
case 'idle':
|
||||
return 'bg-gray-100 text-gray-800';
|
||||
case 'in_use':
|
||||
return 'bg-green-100 text-green-800';
|
||||
case 'maintenance':
|
||||
return 'bg-yellow-100 text-yellow-800';
|
||||
case 'error':
|
||||
return 'bg-red-100 text-red-800';
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800';
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="animate-pulse space-y-6">
|
||||
<div className="h-8 bg-gray-200 rounded w-1/3"></div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{[...Array(4)].map((_, i) => (
|
||||
<div key={i} className="h-32 bg-gray-200 rounded-xl"></div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6 bg-gray-50 min-h-screen">
|
||||
{/* Header */}
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 flex items-center">
|
||||
<ChefHat className="h-8 w-8 mr-3 text-primary-600" />
|
||||
Centro de Producción
|
||||
</h1>
|
||||
<p className="text-gray-600 mt-1">
|
||||
Gestión completa de la producción diaria y planificación inteligente
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 lg:mt-0 flex items-center space-x-4">
|
||||
<div className="bg-gray-50 rounded-lg px-4 py-2">
|
||||
<div className="text-sm font-medium text-gray-900">Eficiencia Hoy</div>
|
||||
<div className="text-2xl font-bold text-primary-600">{productionMetrics.efficiency}%</div>
|
||||
</div>
|
||||
|
||||
<button className="inline-flex items-center px-4 py-2 bg-primary-500 text-white rounded-xl hover:bg-primary-600 transition-colors">
|
||||
<Plus className="h-5 w-5 mr-2" />
|
||||
Nuevo Lote
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Key Metrics Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4">
|
||||
<div className="bg-white p-6 rounded-xl shadow-sm border border-gray-200">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Eficiencia</p>
|
||||
<p className="text-2xl font-bold text-green-600">{productionMetrics.efficiency}%</p>
|
||||
</div>
|
||||
<div className="p-3 bg-green-100 rounded-lg">
|
||||
<Target className="h-6 w-6 text-green-600" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 flex items-center text-xs text-green-600">
|
||||
<TrendingUp className="h-3 w-3 mr-1" />
|
||||
+2.3% vs ayer
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-6 rounded-xl shadow-sm border border-gray-200">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">A Tiempo</p>
|
||||
<p className="text-2xl font-bold text-blue-600">{productionMetrics.onTimeCompletion}%</p>
|
||||
</div>
|
||||
<div className="p-3 bg-blue-100 rounded-lg">
|
||||
<Clock className="h-6 w-6 text-blue-600" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 flex items-center text-xs text-blue-600">
|
||||
<CheckCircle className="h-3 w-3 mr-1" />
|
||||
Muy bueno
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-6 rounded-xl shadow-sm border border-gray-200">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Desperdicio</p>
|
||||
<p className="text-2xl font-bold text-orange-600">{productionMetrics.wastePercentage}%</p>
|
||||
</div>
|
||||
<div className="p-3 bg-orange-100 rounded-lg">
|
||||
<AlertTriangle className="h-6 w-6 text-orange-600" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 flex items-center text-xs text-orange-600">
|
||||
<TrendingUp className="h-3 w-3 mr-1" />
|
||||
-0.5% vs ayer
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-6 rounded-xl shadow-sm border border-gray-200">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Energía</p>
|
||||
<p className="text-2xl font-bold text-purple-600">{productionMetrics.energyUsage} kW</p>
|
||||
</div>
|
||||
<div className="p-3 bg-purple-100 rounded-lg">
|
||||
<Zap className="h-6 w-6 text-purple-600" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 flex items-center text-xs text-purple-600">
|
||||
<Activity className="h-3 w-3 mr-1" />
|
||||
Normal
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-6 rounded-xl shadow-sm border border-gray-200">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Personal</p>
|
||||
<p className="text-2xl font-bold text-indigo-600">{productionMetrics.staffUtilization}%</p>
|
||||
</div>
|
||||
<div className="p-3 bg-indigo-100 rounded-lg">
|
||||
<Users className="h-6 w-6 text-indigo-600" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 flex items-center text-xs text-indigo-600">
|
||||
<Users className="h-3 w-3 mr-1" />
|
||||
3/4 activos
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs Navigation */}
|
||||
<div className="bg-white rounded-xl shadow-sm p-1">
|
||||
<div className="flex space-x-1">
|
||||
{[
|
||||
{ id: 'schedule', label: 'Programa', icon: Calendar },
|
||||
{ id: 'batches', label: 'Lotes Activos', icon: Timer },
|
||||
{ id: 'analytics', label: 'Análisis', icon: BarChart3 },
|
||||
{ id: 'staff', label: 'Personal', icon: Users },
|
||||
{ id: 'equipment', label: 'Equipos', icon: Settings }
|
||||
].map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id as any)}
|
||||
className={`flex-1 py-3 px-4 text-sm font-medium rounded-lg transition-all flex items-center justify-center ${
|
||||
activeTab === tab.id
|
||||
? 'bg-primary-100 text-primary-700'
|
||||
: 'text-gray-600 hover:text-gray-900 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
<tab.icon className="h-4 w-4 mr-2" />
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
<div className="space-y-6">
|
||||
{activeTab === 'schedule' && (
|
||||
<>
|
||||
<ProductionSchedule
|
||||
schedule={productionSchedule}
|
||||
onUpdateQuantity={(itemId, quantity) => {
|
||||
setProductionSchedule(prev =>
|
||||
prev.map(slot => ({
|
||||
...slot,
|
||||
items: slot.items.map(item =>
|
||||
item.id === itemId ? { ...item, quantity } : item
|
||||
)
|
||||
}))
|
||||
);
|
||||
}}
|
||||
onUpdateStatus={(itemId, status) => {
|
||||
setProductionSchedule(prev =>
|
||||
prev.map(slot => ({
|
||||
...slot,
|
||||
items: slot.items.map(item =>
|
||||
item.id === itemId ? { ...item, status } : item
|
||||
)
|
||||
}))
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{activeTab === 'batches' && (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{productionBatches.map((batch) => (
|
||||
<div key={batch.id} className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="font-semibold text-gray-900">{batch.product}</h3>
|
||||
<span className={`px-3 py-1 rounded-full text-sm font-medium ${getStatusColor(batch.status)}`}>
|
||||
{batch.status === 'planned' ? 'Planificado' :
|
||||
batch.status === 'in_progress' ? 'En Progreso' :
|
||||
batch.status === 'completed' ? 'Completado' : 'Retrasado'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Tamaño del Lote</p>
|
||||
<p className="font-semibold text-gray-900">{batch.batchSize} unidades</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Rendimiento</p>
|
||||
<p className="font-semibold text-gray-900">
|
||||
{batch.actualYield || 0}/{batch.expectedYield}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Inicio</p>
|
||||
<p className="font-semibold text-gray-900">{batch.startTime}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Fin Estimado</p>
|
||||
<p className="font-semibold text-gray-900">{batch.endTime}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(batch.temperature || batch.humidity) && (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{batch.temperature && (
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Temperatura</p>
|
||||
<p className="font-semibold text-gray-900">{batch.temperature}°C</p>
|
||||
</div>
|
||||
)}
|
||||
{batch.humidity && (
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Humedad</p>
|
||||
<p className="font-semibold text-gray-900">{batch.humidity}%</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<p className="text-sm text-gray-600 mb-2">Personal Asignado</p>
|
||||
<div className="flex space-x-2">
|
||||
{batch.assignedStaff.map((staffId) => {
|
||||
const staffMember = staff.find(s => s.id === staffId);
|
||||
return (
|
||||
<span
|
||||
key={staffId}
|
||||
className="px-2 py-1 bg-gray-100 text-gray-700 text-sm rounded"
|
||||
>
|
||||
{staffMember?.name || staffId}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{batch.notes && (
|
||||
<div className="bg-gray-50 rounded-lg p-3">
|
||||
<p className="text-sm text-gray-700">{batch.notes}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'analytics' && (
|
||||
<div className="space-y-6">
|
||||
<DemandHeatmap
|
||||
data={heatmapData}
|
||||
onDateClick={(date) => {
|
||||
console.log('Selected date:', date);
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Production Trends Chart Placeholder */}
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4 flex items-center">
|
||||
<BarChart3 className="h-5 w-5 mr-2 text-primary-600" />
|
||||
Tendencias de Producción
|
||||
</h3>
|
||||
<div className="h-64 bg-gray-50 rounded-lg flex items-center justify-center">
|
||||
<div className="text-center text-gray-500">
|
||||
<BarChart3 className="h-12 w-12 mx-auto mb-2 text-gray-400" />
|
||||
<p>Gráfico de tendencias de producción</p>
|
||||
<p className="text-sm">Próximamente disponible</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'staff' && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{staff.map((member) => (
|
||||
<div key={member.id} className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="font-semibold text-gray-900">{member.name}</h3>
|
||||
<span className={`px-2 py-1 rounded-full text-xs font-medium ${
|
||||
member.status === 'available' ? 'bg-green-100 text-green-800' :
|
||||
member.status === 'busy' ? 'bg-yellow-100 text-yellow-800' :
|
||||
'bg-gray-100 text-gray-800'
|
||||
}`}>
|
||||
{member.status === 'available' ? 'Disponible' :
|
||||
member.status === 'busy' ? 'Ocupado' : 'Descanso'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Rol</p>
|
||||
<p className="font-medium text-gray-900 capitalize">{member.role}</p>
|
||||
</div>
|
||||
|
||||
{member.currentTask && (
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Tarea Actual</p>
|
||||
<p className="font-medium text-gray-900">{member.currentTask}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Eficiencia</p>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="flex-1 bg-gray-200 rounded-full h-2">
|
||||
<div
|
||||
className="bg-primary-600 h-2 rounded-full"
|
||||
style={{ width: `${member.efficiency}%` }}
|
||||
></div>
|
||||
</div>
|
||||
<span className="text-sm font-medium text-gray-900">{member.efficiency}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'equipment' && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{equipment.map((item) => (
|
||||
<div key={item.id} className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="font-semibold text-gray-900">{item.name}</h3>
|
||||
<span className={`px-2 py-1 rounded-full text-xs font-medium ${getEquipmentStatusColor(item.status)}`}>
|
||||
{item.status === 'idle' ? 'Inactivo' :
|
||||
item.status === 'in_use' ? 'En Uso' :
|
||||
item.status === 'maintenance' ? 'Mantenimiento' : 'Error'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Tipo</p>
|
||||
<p className="font-medium text-gray-900 capitalize">{item.type}</p>
|
||||
</div>
|
||||
|
||||
{item.currentBatch && (
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Lote Actual</p>
|
||||
<p className="font-medium text-gray-900">
|
||||
{productionBatches.find(b => b.id === item.currentBatch)?.product || item.currentBatch}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{item.temperature && (
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Temperatura</p>
|
||||
<p className="font-medium text-gray-900">{item.temperature}°C</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{item.maintenanceDue && (
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Próximo Mantenimiento</p>
|
||||
<p className="font-medium text-orange-600">
|
||||
{new Date(item.maintenanceDue).toLocaleDateString('es-ES')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProductionPage;
|
||||
31
frontend/tsconfig.json
Normal file
31
frontend/tsconfig.json
Normal file
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"strict": false,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noFallthroughCasesInSwitch": false,
|
||||
|
||||
/* Path mapping */
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
11
frontend/tsconfig.node.json
Normal file
11
frontend/tsconfig.node.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true
|
||||
},
|
||||
"include": ["vite.config.js"]
|
||||
}
|
||||
Reference in New Issue
Block a user