733 lines
30 KiB
TypeScript
733 lines
30 KiB
TypeScript
import React, { useState, useMemo } from 'react';
|
|
import { Calendar, TrendingUp, AlertTriangle, BarChart3, Download, Settings, Loader, Zap, Brain, Target, CloudRain, Sun, Thermometer } from 'lucide-react';
|
|
import { Button, Card, Badge, Select, Table, StatsGrid } from '../../../../components/ui';
|
|
import type { TableColumn } from '../../../../components/ui';
|
|
import { PageHeader } from '../../../../components/layout';
|
|
import { DemandChart, ForecastTable } from '../../../../components/domain/forecasting';
|
|
import { useTenantForecasts, useCreateSingleForecast } from '../../../../api/hooks/forecasting';
|
|
import { useIngredients } from '../../../../api/hooks/inventory';
|
|
import { useModels } from '../../../../api/hooks/training';
|
|
import { useCurrentTenant } from '../../../../stores/tenant.store';
|
|
import { ForecastResponse } from '../../../../api/types/forecasting';
|
|
import { forecastingService } from '../../../../api/services/forecasting';
|
|
|
|
const ForecastingPage: React.FC = () => {
|
|
const [selectedProduct, setSelectedProduct] = useState('');
|
|
const [forecastPeriod, setForecastPeriod] = useState('7');
|
|
const [viewMode, setViewMode] = useState<'chart' | 'table'>('chart');
|
|
const [isGenerating, setIsGenerating] = useState(false);
|
|
const [hasGeneratedForecast, setHasGeneratedForecast] = useState(false);
|
|
const [currentForecastData, setCurrentForecastData] = useState<ForecastResponse[]>([]);
|
|
|
|
// Get tenant ID from tenant store
|
|
const currentTenant = useCurrentTenant();
|
|
const tenantId = currentTenant?.id || '';
|
|
|
|
// Calculate date range based on selected period
|
|
const endDate = new Date();
|
|
const startDate = new Date();
|
|
startDate.setDate(startDate.getDate() - parseInt(forecastPeriod));
|
|
|
|
// Fetch existing forecasts
|
|
const {
|
|
data: forecastsData,
|
|
isLoading: forecastsLoading,
|
|
error: forecastsError
|
|
} = useTenantForecasts(tenantId, {
|
|
start_date: startDate.toISOString().split('T')[0],
|
|
end_date: endDate.toISOString().split('T')[0],
|
|
...(selectedProduct && { inventory_product_id: selectedProduct }),
|
|
limit: 100
|
|
}, {
|
|
enabled: !!tenantId && hasGeneratedForecast && !!selectedProduct
|
|
});
|
|
|
|
|
|
// Fetch real inventory data
|
|
const {
|
|
data: ingredientsData,
|
|
isLoading: ingredientsLoading,
|
|
error: ingredientsError
|
|
} = useIngredients(tenantId);
|
|
|
|
// Fetch trained models to filter products
|
|
const {
|
|
data: modelsData,
|
|
isLoading: modelsLoading,
|
|
error: modelsError
|
|
} = useModels(tenantId, { active_only: true });
|
|
|
|
// Forecast generation mutation
|
|
const createForecastMutation = useCreateSingleForecast({
|
|
onSuccess: (data) => {
|
|
setIsGenerating(false);
|
|
setHasGeneratedForecast(true);
|
|
// Store the generated forecast data locally for immediate display
|
|
setCurrentForecastData([data]);
|
|
},
|
|
onError: (error) => {
|
|
setIsGenerating(false);
|
|
console.error('Error generating forecast:', error);
|
|
},
|
|
});
|
|
|
|
// Build products list from ingredients that have trained models
|
|
const products = useMemo(() => {
|
|
if (!ingredientsData || !modelsData?.models) {
|
|
return [];
|
|
}
|
|
|
|
// Get inventory product IDs that have trained models
|
|
const modelProductIds = new Set(modelsData.models.map(model => model.inventory_product_id));
|
|
|
|
// Filter ingredients to only those with models
|
|
const ingredientsWithModels = ingredientsData.filter(ingredient =>
|
|
modelProductIds.has(ingredient.id)
|
|
);
|
|
|
|
return ingredientsWithModels.map(ingredient => ({
|
|
id: ingredient.id,
|
|
name: ingredient.name,
|
|
category: ingredient.category,
|
|
hasModel: true
|
|
}));
|
|
}, [ingredientsData, modelsData]);
|
|
|
|
const periods = [
|
|
{ value: '7', label: '7 días' },
|
|
{ value: '14', label: '14 días' },
|
|
{ value: '30', label: '30 días' },
|
|
{ value: '90', label: '3 meses' },
|
|
];
|
|
|
|
// Handle forecast generation
|
|
const handleGenerateForecast = async () => {
|
|
if (!tenantId || !selectedProduct) {
|
|
alert('Por favor, selecciona un ingrediente para generar predicciones.');
|
|
return;
|
|
}
|
|
|
|
setIsGenerating(true);
|
|
|
|
try {
|
|
const today = new Date();
|
|
const forecastRequest = {
|
|
inventory_product_id: selectedProduct,
|
|
forecast_date: today.toISOString().split('T')[0],
|
|
forecast_days: parseInt(forecastPeriod),
|
|
location: 'default',
|
|
confidence_level: 0.8,
|
|
};
|
|
|
|
// Use the new multi-day endpoint for all forecasts
|
|
const multiDayResult = await forecastingService.createMultiDayForecast(tenantId, forecastRequest);
|
|
setIsGenerating(false);
|
|
setHasGeneratedForecast(true);
|
|
// Use the forecasts from the multi-day response
|
|
setCurrentForecastData(multiDayResult.forecasts);
|
|
} catch (error) {
|
|
console.error('Failed to generate forecast:', error);
|
|
setIsGenerating(false);
|
|
}
|
|
};
|
|
|
|
// Transform forecast data for table display - only real data
|
|
const transformForecastsForTable = (forecasts: ForecastResponse[]) => {
|
|
return forecasts.map(forecast => ({
|
|
id: forecast.id,
|
|
product: forecast.inventory_product_id,
|
|
forecastDate: forecast.forecast_date,
|
|
forecastDemand: forecast.predicted_demand,
|
|
confidence: Math.round(forecast.confidence_level * 100),
|
|
confidenceRange: `${forecast.confidence_lower?.toFixed(1) || 'N/A'} - ${forecast.confidence_upper?.toFixed(1) || 'N/A'}`,
|
|
algorithm: forecast.algorithm,
|
|
}));
|
|
};
|
|
|
|
|
|
// Extract weather data from all forecasts for 7-day view
|
|
const getWeatherImpact = (forecasts: ForecastResponse[]) => {
|
|
if (!forecasts || forecasts.length === 0) return null;
|
|
|
|
// Calculate average temperature across all forecast days
|
|
const avgTemp = forecasts.reduce((sum, f) => sum + (f.weather_temperature || 0), 0) / forecasts.length;
|
|
const tempRange = {
|
|
min: Math.min(...forecasts.map(f => f.weather_temperature || 0)),
|
|
max: Math.max(...forecasts.map(f => f.weather_temperature || 0))
|
|
};
|
|
|
|
// Aggregate weather descriptions
|
|
const weatherTypes = forecasts
|
|
.map(f => f.weather_description)
|
|
.filter(Boolean)
|
|
.reduce((acc, desc) => {
|
|
acc[desc] = (acc[desc] || 0) + 1;
|
|
return acc;
|
|
}, {} as Record<string, number>);
|
|
|
|
const dominantWeather = Object.entries(weatherTypes)
|
|
.sort(([,a], [,b]) => b - a)[0]?.[0] || 'N/A';
|
|
|
|
return {
|
|
avgTemperature: Math.round(avgTemp),
|
|
tempRange,
|
|
dominantWeather,
|
|
forecastDays: forecasts.length,
|
|
dailyForecasts: forecasts.map(f => ({
|
|
date: f.forecast_date,
|
|
temperature: f.weather_temperature,
|
|
description: f.weather_description,
|
|
predicted_demand: f.predicted_demand
|
|
}))
|
|
};
|
|
};
|
|
|
|
|
|
const forecastColumns: TableColumn[] = [
|
|
{
|
|
key: 'product',
|
|
title: 'Producto ID',
|
|
dataIndex: 'product',
|
|
},
|
|
{
|
|
key: 'forecastDate',
|
|
title: 'Fecha',
|
|
dataIndex: 'forecastDate',
|
|
render: (value) => new Date(value).toLocaleDateString('es-ES'),
|
|
},
|
|
{
|
|
key: 'forecastDemand',
|
|
title: 'Demanda Prevista',
|
|
dataIndex: 'forecastDemand',
|
|
render: (value) => (
|
|
<span className="font-medium text-[var(--color-info)]">{value?.toFixed(2) || 'N/A'}</span>
|
|
),
|
|
},
|
|
{
|
|
key: 'confidence',
|
|
title: 'Confianza',
|
|
dataIndex: 'confidence',
|
|
render: (value) => `${value}%`,
|
|
},
|
|
{
|
|
key: 'confidenceRange',
|
|
title: 'Rango de Confianza',
|
|
dataIndex: 'confidenceRange',
|
|
},
|
|
{
|
|
key: 'algorithm',
|
|
title: 'Algoritmo',
|
|
dataIndex: 'algorithm',
|
|
},
|
|
];
|
|
|
|
// Use either current forecast data or fetched data
|
|
const forecasts = currentForecastData.length > 0 ? currentForecastData : (forecastsData?.forecasts || []);
|
|
const transformedForecasts = transformForecastsForTable(forecasts);
|
|
const weatherImpact = getWeatherImpact(forecasts);
|
|
const isLoading = forecastsLoading || ingredientsLoading || modelsLoading || isGenerating;
|
|
const hasError = forecastsError || ingredientsError || modelsError;
|
|
|
|
// Calculate metrics from real data
|
|
const totalDemand = forecasts.reduce((sum, f) => sum + f.predicted_demand, 0);
|
|
const averageConfidence = forecasts.length > 0
|
|
? Math.round((forecasts.reduce((sum, f) => sum + f.confidence_level, 0) / forecasts.length) * 100)
|
|
: 0;
|
|
|
|
// Get forecast insights from the latest forecast - only real backend data
|
|
const getForecastInsights = (forecast: ForecastResponse) => {
|
|
const insights = [];
|
|
|
|
// Weather data (only factual)
|
|
if (forecast.weather_temperature) {
|
|
insights.push({
|
|
type: 'weather',
|
|
icon: Thermometer,
|
|
title: 'Temperatura',
|
|
description: `${forecast.weather_temperature}°C`,
|
|
impact: 'info'
|
|
});
|
|
}
|
|
|
|
if (forecast.weather_description) {
|
|
insights.push({
|
|
type: 'weather',
|
|
icon: CloudRain,
|
|
title: 'Condición Climática',
|
|
description: forecast.weather_description,
|
|
impact: 'info'
|
|
});
|
|
}
|
|
|
|
// Temporal factors (only factual)
|
|
if (forecast.is_weekend) {
|
|
insights.push({
|
|
type: 'temporal',
|
|
icon: Calendar,
|
|
title: 'Fin de Semana',
|
|
description: 'Día de fin de semana',
|
|
impact: 'info'
|
|
});
|
|
}
|
|
|
|
if (forecast.is_holiday) {
|
|
insights.push({
|
|
type: 'temporal',
|
|
icon: Calendar,
|
|
title: 'Día Festivo',
|
|
description: 'Día festivo',
|
|
impact: 'info'
|
|
});
|
|
}
|
|
|
|
// Model confidence (factual)
|
|
insights.push({
|
|
type: 'model',
|
|
icon: Target,
|
|
title: 'Confianza del Modelo',
|
|
description: `${Math.round(forecast.confidence_level * 100)}%`,
|
|
impact: forecast.confidence_level > 0.8 ? 'positive' : forecast.confidence_level > 0.6 ? 'moderate' : 'high'
|
|
});
|
|
|
|
return insights;
|
|
};
|
|
|
|
const currentInsights = forecasts.length > 0 ? getForecastInsights(forecasts[0]) : [];
|
|
|
|
|
|
return (
|
|
<div className="p-6 space-y-6">
|
|
<PageHeader
|
|
title="Predicción de Demanda"
|
|
description="Predicciones inteligentes basadas en IA para optimizar tu producción"
|
|
action={
|
|
<div className="flex space-x-2">
|
|
<Button variant="outline">
|
|
<Settings className="w-4 h-4 mr-2" />
|
|
Configurar
|
|
</Button>
|
|
<Button variant="outline">
|
|
<Download className="w-4 h-4 mr-2" />
|
|
Exportar
|
|
</Button>
|
|
</div>
|
|
}
|
|
/>
|
|
|
|
{isLoading && (
|
|
<Card className="p-6 flex items-center justify-center">
|
|
<Loader className="h-6 w-6 animate-spin mr-2" />
|
|
<span>
|
|
{isGenerating ? 'Generando nuevas predicciones...' : 'Cargando predicciones...'}
|
|
</span>
|
|
</Card>
|
|
)}
|
|
|
|
{hasError && (
|
|
<Card className="p-6 bg-[var(--color-error-50)] border-[var(--color-error-200)]">
|
|
<div className="flex items-center">
|
|
<AlertTriangle className="h-5 w-5 text-[var(--color-error-600)] mr-2" />
|
|
<span className="text-[var(--color-error-800)]">Error al cargar las predicciones. Por favor, inténtalo de nuevo.</span>
|
|
</div>
|
|
</Card>
|
|
)}
|
|
|
|
{!isLoading && !hasError && (
|
|
<>
|
|
|
|
</>
|
|
)}
|
|
|
|
{/* Forecast Configuration */}
|
|
<Card className="p-6">
|
|
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4">Configurar Predicción</h3>
|
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
|
{/* Step 1: Select Ingredient */}
|
|
<div>
|
|
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
|
|
<span className="flex items-center">
|
|
<span className="bg-[var(--color-info-600)] text-white rounded-full w-6 h-6 flex items-center justify-center text-xs mr-2">1</span>
|
|
Seleccionar Ingrediente
|
|
</span>
|
|
</label>
|
|
<select
|
|
value={selectedProduct}
|
|
onChange={(e) => setSelectedProduct(e.target.value)}
|
|
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md"
|
|
disabled={isGenerating}
|
|
>
|
|
<option value="">Selecciona un ingrediente...</option>
|
|
{products.map(product => (
|
|
<option key={product.id} value={product.id}>
|
|
🤖 {product.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
{products.length === 0 && (
|
|
<p className="text-xs text-[var(--text-tertiary)] mt-1">
|
|
No hay ingredientes con modelos entrenados
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* Step 2: Select Period */}
|
|
<div>
|
|
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
|
|
<span className="flex items-center">
|
|
<span className={`rounded-full w-6 h-6 flex items-center justify-center text-xs mr-2 ${
|
|
selectedProduct ? 'bg-[var(--color-info-600)] text-white' : 'bg-gray-300 text-gray-600'
|
|
}`}>2</span>
|
|
Período de Predicción
|
|
</span>
|
|
</label>
|
|
<select
|
|
value={forecastPeriod}
|
|
onChange={(e) => setForecastPeriod(e.target.value)}
|
|
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md"
|
|
disabled={!selectedProduct || isGenerating}
|
|
>
|
|
{periods.map(period => (
|
|
<option key={period.value} value={period.value}>{period.label}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
{/* Step 3: Generate */}
|
|
<div>
|
|
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
|
|
<span className="flex items-center">
|
|
<span className={`rounded-full w-6 h-6 flex items-center justify-center text-xs mr-2 ${
|
|
selectedProduct && forecastPeriod ? 'bg-[var(--color-info-600)] text-white' : 'bg-gray-300 text-gray-600'
|
|
}`}>3</span>
|
|
Generar Predicción
|
|
</span>
|
|
</label>
|
|
<Button
|
|
onClick={handleGenerateForecast}
|
|
disabled={!selectedProduct || !forecastPeriod || isGenerating}
|
|
className="w-full bg-[var(--color-primary)] text-white hover:bg-[var(--color-primary)]/90"
|
|
>
|
|
{isGenerating ? (
|
|
<>
|
|
<Loader className="w-4 h-4 mr-2 animate-spin" />
|
|
Generando...
|
|
</>
|
|
) : (
|
|
<>
|
|
<Zap className="w-4 h-4 mr-2" />
|
|
Generar Predicción
|
|
</>
|
|
)}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{selectedProduct && (
|
|
<div className="mt-4 p-3 bg-[var(--color-info-50)] border border-[var(--color-info-200)] rounded-lg">
|
|
<p className="text-sm text-[var(--color-info-800)]">
|
|
<strong>Ingrediente seleccionado:</strong> {products.find(p => p.id === selectedProduct)?.name}
|
|
</p>
|
|
<p className="text-xs text-[var(--color-info-600)] mt-1">
|
|
Se generará una predicción de demanda para los próximos {forecastPeriod} días usando IA
|
|
</p>
|
|
</div>
|
|
)}
|
|
</Card>
|
|
|
|
{/* Results Section - Only show after generating forecast */}
|
|
{hasGeneratedForecast && forecasts.length > 0 && (
|
|
<>
|
|
{/* Enhanced Layout Structure */}
|
|
<div className="space-y-8">
|
|
|
|
{/* Key Metrics Row - Using StatsGrid */}
|
|
<StatsGrid
|
|
columns={4}
|
|
gap="md"
|
|
stats={[
|
|
{
|
|
title: "Confianza del Modelo",
|
|
value: `${averageConfidence}%`,
|
|
icon: Target,
|
|
variant: "success",
|
|
size: "sm"
|
|
},
|
|
{
|
|
title: "Demanda Total",
|
|
value: Math.round(totalDemand),
|
|
icon: TrendingUp,
|
|
variant: "info",
|
|
size: "sm",
|
|
subtitle: `próximos ${forecastPeriod} días`
|
|
},
|
|
{
|
|
title: "Días Predichos",
|
|
value: forecasts.length,
|
|
icon: Calendar,
|
|
variant: "default",
|
|
size: "sm"
|
|
},
|
|
{
|
|
title: "Variabilidad",
|
|
value: (Math.max(...forecasts.map(f => f.predicted_demand)) - Math.min(...forecasts.map(f => f.predicted_demand))).toFixed(1),
|
|
icon: BarChart3,
|
|
variant: "warning",
|
|
size: "sm"
|
|
}
|
|
]}
|
|
/>
|
|
|
|
{/* Main Content Grid */}
|
|
<div className="grid grid-cols-12 gap-6">
|
|
|
|
{/* Chart Section - Takes most space */}
|
|
<div className="col-span-12 lg:col-span-8">
|
|
<Card className="h-full">
|
|
<div className="p-6 border-b border-gray-200">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h3 className="text-xl font-bold text-gray-900">Predicción de Demanda</h3>
|
|
<p className="text-sm text-gray-600 mt-1">
|
|
{products.find(p => p.id === selectedProduct)?.name} • {forecastPeriod} días • {forecasts.length} puntos
|
|
</p>
|
|
</div>
|
|
<div className="flex items-center space-x-3">
|
|
<div className="flex rounded-lg border border-gray-300 overflow-hidden">
|
|
<button
|
|
onClick={() => setViewMode('chart')}
|
|
className={`px-4 py-2 text-sm font-medium transition-colors ${
|
|
viewMode === 'chart'
|
|
? 'bg-[var(--color-info-600)] text-white'
|
|
: 'bg-white text-gray-700 hover:bg-gray-50'
|
|
}`}
|
|
>
|
|
📊 Gráfico
|
|
</button>
|
|
<button
|
|
onClick={() => setViewMode('table')}
|
|
className={`px-4 py-2 text-sm font-medium transition-colors border-l ${
|
|
viewMode === 'table'
|
|
? 'bg-[var(--color-info-600)] text-white'
|
|
: 'bg-white text-gray-700 hover:bg-gray-50'
|
|
}`}
|
|
>
|
|
📋 Tabla
|
|
</button>
|
|
</div>
|
|
<Button variant="outline" size="sm">
|
|
<Download className="w-4 h-4 mr-2" />
|
|
Exportar
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="p-6">
|
|
{viewMode === 'chart' ? (
|
|
<DemandChart
|
|
data={forecasts}
|
|
product={selectedProduct}
|
|
period={forecastPeriod}
|
|
loading={isLoading}
|
|
error={hasError ? 'Error al cargar las predicciones' : null}
|
|
height={450}
|
|
title=""
|
|
/>
|
|
) : (
|
|
<ForecastTable forecasts={transformedForecasts} />
|
|
)}
|
|
</div>
|
|
</Card>
|
|
</div>
|
|
|
|
{/* Right Sidebar - Insights */}
|
|
<div className="col-span-12 lg:col-span-4 space-y-6">
|
|
{/* Weather & External Factors */}
|
|
<div className="space-y-6">
|
|
{/* Forecast Insights */}
|
|
{currentInsights.length > 0 && (
|
|
<Card className="p-6">
|
|
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4">Factores que Afectan la Predicción</h3>
|
|
<div className="space-y-3">
|
|
{currentInsights.map((insight, index) => {
|
|
const IconComponent = insight.icon;
|
|
return (
|
|
<div key={index} className="flex items-start space-x-3 p-3 bg-[var(--bg-secondary)] rounded-lg">
|
|
<div className={`p-2 rounded-lg ${
|
|
insight.impact === 'positive' ? 'bg-[var(--color-success-100)] text-[var(--color-success-600)]' :
|
|
insight.impact === 'high' ? 'bg-[var(--color-error-100)] text-[var(--color-error-600)]' :
|
|
insight.impact === 'moderate' ? 'bg-[var(--color-warning-100)] text-[var(--color-warning-600)]' :
|
|
'bg-[var(--color-info-100)] text-[var(--color-info-600)]'
|
|
}`}>
|
|
<IconComponent className="w-4 h-4" />
|
|
</div>
|
|
<div className="flex-1">
|
|
<p className="text-sm font-medium text-[var(--text-primary)]">{insight.title}</p>
|
|
<p className="text-xs text-[var(--text-secondary)]">{insight.description}</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</Card>
|
|
)}
|
|
|
|
{/* Weather Impact */}
|
|
{weatherImpact && (
|
|
<Card className="overflow-hidden">
|
|
<div className="bg-gradient-to-r from-[var(--color-warning-500)] to-[var(--color-error-500)] p-4">
|
|
<h3 className="text-lg font-bold text-white flex items-center">
|
|
<Sun className="w-5 h-5 mr-2" />
|
|
Clima ({weatherImpact.forecastDays} días)
|
|
</h3>
|
|
<p className="text-[var(--color-warning-100)] text-sm">Impacto meteorológico en la demanda</p>
|
|
</div>
|
|
<div className="p-4 space-y-4">
|
|
{/* Temperature Overview */}
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div className="bg-[var(--color-warning-50)] p-3 rounded-lg border border-[var(--color-warning-200)]">
|
|
<div className="flex items-center justify-between">
|
|
<Thermometer className="w-4 h-4 text-[var(--color-warning-600)]" />
|
|
<span className="text-lg font-bold text-[var(--color-warning-800)]">{weatherImpact.avgTemperature}°C</span>
|
|
</div>
|
|
<p className="text-xs text-[var(--color-warning-600)] mt-1">Promedio</p>
|
|
</div>
|
|
<div className="bg-[var(--color-info-50)] p-3 rounded-lg border border-[var(--color-info-200)]">
|
|
<div className="text-center">
|
|
<p className="text-sm font-medium text-[var(--color-info-800)]">{weatherImpact.dominantWeather}</p>
|
|
<p className="text-xs text-[var(--color-info-600)] mt-1">Condición</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Daily forecast - compact */}
|
|
<div className="space-y-1">
|
|
<p className="text-xs font-medium text-gray-700">Pronóstico detallado:</p>
|
|
<div className="max-h-24 overflow-y-auto space-y-1">
|
|
{weatherImpact.dailyForecasts.slice(0, 5).map((day, index) => (
|
|
<div key={index} className="flex items-center justify-between text-xs p-2 bg-gray-50 rounded">
|
|
<span className="text-gray-600 font-medium">
|
|
{new Date(day.date).toLocaleDateString('es-ES', { weekday: 'short', day: 'numeric' })}
|
|
</span>
|
|
<div className="flex items-center space-x-2">
|
|
<span className="text-[var(--color-warning-600)]">{day.temperature}°C</span>
|
|
<span className="text-[var(--color-info-600)] font-bold">{day.predicted_demand?.toFixed(0)}</span>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
)}
|
|
|
|
|
|
{/* Model Information */}
|
|
{forecasts.length > 0 && (
|
|
<Card className="overflow-hidden">
|
|
<div className="bg-gradient-to-r from-[var(--chart-quinary)] to-[var(--color-info-700)] p-4">
|
|
<h3 className="text-lg font-bold text-white flex items-center">
|
|
<Brain className="w-5 h-5 mr-2" />
|
|
Modelo IA
|
|
</h3>
|
|
<p className="text-purple-100 text-sm">Información técnica del algoritmo</p>
|
|
</div>
|
|
<div className="p-4 space-y-3">
|
|
<div className="grid grid-cols-1 gap-2">
|
|
<div className="flex items-center justify-between p-2 bg-[var(--color-info-50)] rounded border border-[var(--color-info-200)]">
|
|
<span className="text-sm font-medium text-[var(--color-info-800)]">Algoritmo</span>
|
|
<Badge variant="purple">{forecasts[0]?.algorithm || 'N/A'}</Badge>
|
|
</div>
|
|
<div className="flex items-center justify-between p-2 bg-gray-50 rounded">
|
|
<span className="text-xs text-gray-600">Versión</span>
|
|
<span className="text-xs font-mono text-gray-800">{forecasts[0]?.model_version || 'N/A'}</span>
|
|
</div>
|
|
<div className="flex items-center justify-between p-2 bg-gray-50 rounded">
|
|
<span className="text-xs text-gray-600">Tiempo</span>
|
|
<span className="text-xs font-mono text-gray-800">{forecasts[0]?.processing_time_ms || 'N/A'}ms</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{/* Detailed Forecasts Table */}
|
|
{!isLoading && !hasError && transformedForecasts.length > 0 && (
|
|
<Card className="p-6">
|
|
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4">Predicciones Detalladas</h3>
|
|
<Table
|
|
columns={forecastColumns}
|
|
data={transformedForecasts}
|
|
rowKey="id"
|
|
hover={true}
|
|
variant="default"
|
|
size="md"
|
|
/>
|
|
</Card>
|
|
)}
|
|
|
|
{/* Empty States */}
|
|
{!isLoading && !hasError && products.length === 0 && (
|
|
<Card className="p-6 text-center">
|
|
<div className="py-8">
|
|
<TrendingUp className="h-12 w-12 text-[var(--color-warning-400)] mx-auto mb-4" />
|
|
<h3 className="text-lg font-medium text-gray-600 mb-2">No hay ingredientes con modelos entrenados</h3>
|
|
<p className="text-gray-500 mb-6">
|
|
Para generar predicciones, primero necesitas entrenar modelos de IA para tus ingredientes.
|
|
Ve a la página de Modelos IA para entrenar modelos para tus ingredientes.
|
|
</p>
|
|
<div className="flex justify-center space-x-4">
|
|
<Button
|
|
variant="primary"
|
|
onClick={() => {
|
|
window.location.href = '/app/database/models';
|
|
}}
|
|
>
|
|
<Settings className="w-4 h-4 mr-2" />
|
|
Configurar Modelos IA
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
)}
|
|
|
|
{!isLoading && !hasError && products.length > 0 && !hasGeneratedForecast && (
|
|
<Card className="p-6 text-center">
|
|
<div className="py-8">
|
|
<BarChart3 className="h-12 w-12 text-[var(--color-info-400)] mx-auto mb-4" />
|
|
<h3 className="text-lg font-medium text-gray-600 mb-2">Listo para Generar Predicciones</h3>
|
|
<p className="text-gray-500 mb-6">
|
|
Tienes {products.length} ingrediente{products.length > 1 ? 's' : ''} con modelos entrenados disponibles.
|
|
Selecciona un ingrediente y período para comenzar.
|
|
</p>
|
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 max-w-2xl mx-auto">
|
|
<div className="text-center p-4 border rounded-lg">
|
|
<div className="bg-[var(--color-info-600)] text-white rounded-full w-8 h-8 flex items-center justify-center mx-auto mb-2 text-sm">1</div>
|
|
<p className="text-sm font-medium text-gray-600">Selecciona Ingrediente</p>
|
|
<p className="text-xs text-gray-500">Elige un ingrediente con modelo IA</p>
|
|
</div>
|
|
<div className="text-center p-4 border rounded-lg">
|
|
<div className="bg-gray-300 text-gray-600 rounded-full w-8 h-8 flex items-center justify-center mx-auto mb-2 text-sm">2</div>
|
|
<p className="text-sm font-medium text-gray-600">Define Período</p>
|
|
<p className="text-xs text-gray-500">Establece días a predecir</p>
|
|
</div>
|
|
<div className="text-center p-4 border rounded-lg">
|
|
<div className="bg-gray-300 text-gray-600 rounded-full w-8 h-8 flex items-center justify-center mx-auto mb-2 text-sm">3</div>
|
|
<p className="text-sm font-medium text-gray-600">Generar Predicción</p>
|
|
<p className="text-xs text-gray-500">Obtén insights de IA</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default ForecastingPage; |