Files
bakery-ia/frontend/src/pages/app/analytics/forecasting/ForecastingPage.tsx

572 lines
20 KiB
TypeScript
Raw Normal View History

2025-09-11 18:21:32 +02:00
import React, { useState, useMemo } from 'react';
2025-09-21 22:56:55 +02:00
import { Calendar, TrendingUp, AlertTriangle, BarChart3, Download, Settings, Loader, Zap, Brain, Target, CloudRain, Sun, Thermometer, Package, Activity, Clock } from 'lucide-react';
import { Button, Card, Badge, Table, StatsGrid, StatusCard, getStatusColor } from '../../../../components/ui';
2025-08-28 18:07:16 +02:00
import type { TableColumn } from '../../../../components/ui';
2025-08-28 10:41:04 +02:00
import { PageHeader } from '../../../../components/layout';
2025-09-21 22:56:55 +02:00
import { LoadingSpinner } from '../../../../components/shared';
2025-09-20 22:11:05 +02:00
import { DemandChart, ForecastTable } from '../../../../components/domain/forecasting';
import { useTenantForecasts, useCreateSingleForecast } from '../../../../api/hooks/forecasting';
2025-09-11 18:21:32 +02:00
import { useIngredients } from '../../../../api/hooks/inventory';
2025-09-20 22:11:05 +02:00
import { useModels } from '../../../../api/hooks/training';
2025-09-21 22:56:55 +02:00
import { useTenantId } from '../../../../hooks/useTenantId';
2025-09-11 18:21:32 +02:00
import { ForecastResponse } from '../../../../api/types/forecasting';
2025-09-20 22:11:05 +02:00
import { forecastingService } from '../../../../api/services/forecasting';
2025-09-21 22:56:55 +02:00
import { formatters } from '../../../../components/ui/Stats/StatsPresets';
2025-08-28 10:41:04 +02:00
const ForecastingPage: React.FC = () => {
2025-09-20 22:11:05 +02:00
const [selectedProduct, setSelectedProduct] = useState('');
2025-08-28 10:41:04 +02:00
const [forecastPeriod, setForecastPeriod] = useState('7');
const [viewMode, setViewMode] = useState<'chart' | 'table'>('chart');
2025-09-20 22:11:05 +02:00
const [isGenerating, setIsGenerating] = useState(false);
const [hasGeneratedForecast, setHasGeneratedForecast] = useState(false);
const [currentForecastData, setCurrentForecastData] = useState<ForecastResponse[]>([]);
2025-08-28 10:41:04 +02:00
2025-09-20 22:11:05 +02:00
// Get tenant ID from tenant store
2025-09-21 22:56:55 +02:00
const tenantId = useTenantId();
2025-08-28 10:41:04 +02:00
2025-09-11 18:21:32 +02:00
// Calculate date range based on selected period
const endDate = new Date();
const startDate = new Date();
startDate.setDate(startDate.getDate() - parseInt(forecastPeriod));
2025-09-20 22:11:05 +02:00
// Fetch existing forecasts
2025-09-11 18:21:32 +02:00
const {
data: forecastsData,
isLoading: forecastsLoading,
error: forecastsError
} = useTenantForecasts(tenantId, {
start_date: startDate.toISOString().split('T')[0],
end_date: endDate.toISOString().split('T')[0],
2025-09-20 22:11:05 +02:00
...(selectedProduct && { inventory_product_id: selectedProduct }),
2025-09-11 18:21:32 +02:00
limit: 100
2025-09-20 22:11:05 +02:00
}, {
enabled: !!tenantId && hasGeneratedForecast && !!selectedProduct
2025-09-11 18:21:32 +02:00
});
// Fetch real inventory data
const {
data: ingredientsData,
isLoading: ingredientsLoading,
error: ingredientsError
} = useIngredients(tenantId);
2025-09-20 22:11:05 +02:00
// 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
2025-09-11 18:21:32 +02:00
const products = useMemo(() => {
2025-09-20 22:11:05 +02:00
if (!ingredientsData || !modelsData?.models) {
return [];
2025-09-11 18:21:32 +02:00
}
2025-09-20 22:11:05 +02:00
// 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]);
2025-08-28 10:41:04 +02:00
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' },
];
2025-09-20 22:11:05 +02:00
// 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
2025-09-11 18:21:32 +02:00
const transformForecastsForTable = (forecasts: ForecastResponse[]) => {
return forecasts.map(forecast => ({
id: forecast.id,
2025-09-20 22:11:05 +02:00
product: forecast.inventory_product_id,
forecastDate: forecast.forecast_date,
2025-09-11 18:21:32 +02:00
forecastDemand: forecast.predicted_demand,
confidence: Math.round(forecast.confidence_level * 100),
2025-09-20 22:11:05 +02:00
confidenceRange: `${forecast.confidence_lower?.toFixed(1) || 'N/A'} - ${forecast.confidence_upper?.toFixed(1) || 'N/A'}`,
algorithm: forecast.algorithm,
2025-09-11 18:21:32 +02:00
}));
};
2025-08-28 10:41:04 +02:00
2025-09-20 22:11:05 +02:00
2025-08-28 18:07:16 +02:00
const forecastColumns: TableColumn[] = [
{
key: 'product',
2025-09-20 22:11:05 +02:00
title: 'Producto ID',
2025-08-28 18:07:16 +02:00
dataIndex: 'product',
},
{
2025-09-20 22:11:05 +02:00
key: 'forecastDate',
title: 'Fecha',
dataIndex: 'forecastDate',
render: (value) => new Date(value).toLocaleDateString('es-ES'),
2025-08-28 18:07:16 +02:00
},
{
key: 'forecastDemand',
title: 'Demanda Prevista',
dataIndex: 'forecastDemand',
render: (value) => (
2025-09-20 22:11:05 +02:00
<span className="font-medium text-[var(--color-info)]">{value?.toFixed(2) || 'N/A'}</span>
2025-08-28 18:07:16 +02:00
),
},
{
key: 'confidence',
title: 'Confianza',
dataIndex: 'confidence',
render: (value) => `${value}%`,
},
{
2025-09-20 22:11:05 +02:00
key: 'confidenceRange',
title: 'Rango de Confianza',
dataIndex: 'confidenceRange',
2025-08-28 18:07:16 +02:00
},
{
2025-09-20 22:11:05 +02:00
key: 'algorithm',
title: 'Algoritmo',
dataIndex: 'algorithm',
2025-08-28 18:07:16 +02:00
},
];
2025-09-20 22:11:05 +02:00
// Use either current forecast data or fetched data
const forecasts = currentForecastData.length > 0 ? currentForecastData : (forecastsData?.forecasts || []);
2025-09-11 18:21:32 +02:00
const transformedForecasts = transformForecastsForTable(forecasts);
2025-09-20 22:11:05 +02:00
const isLoading = forecastsLoading || ingredientsLoading || modelsLoading || isGenerating;
const hasError = forecastsError || ingredientsError || modelsError;
2025-09-11 18:21:32 +02:00
// Calculate metrics from real data
const totalDemand = forecasts.reduce((sum, f) => sum + f.predicted_demand, 0);
2025-09-20 22:11:05 +02:00
const averageConfidence = forecasts.length > 0
2025-09-11 18:21:32 +02:00
? Math.round((forecasts.reduce((sum, f) => sum + f.confidence_level, 0) / forecasts.length) * 100)
: 0;
2025-09-21 22:56:55 +02:00
// Get simplified forecast insights
const getForecastInsights = (forecasts: ForecastResponse[]) => {
if (!forecasts || forecasts.length === 0) return [];
2025-09-20 22:11:05 +02:00
2025-09-21 22:56:55 +02:00
const insights = [];
const avgConfidence = forecasts.reduce((sum, f) => sum + f.confidence_level, 0) / forecasts.length;
2025-09-20 22:11:05 +02:00
2025-09-21 22:56:55 +02:00
// Model confidence
insights.push({
type: 'model',
icon: Target,
title: 'Confianza del Modelo',
description: `${Math.round(avgConfidence * 100)}%`,
impact: avgConfidence > 0.8 ? 'positive' : avgConfidence > 0.6 ? 'moderate' : 'high'
});
2025-09-20 22:11:05 +02:00
2025-09-21 22:56:55 +02:00
// Algorithm used
if (forecasts[0]?.algorithm) {
2025-09-20 22:11:05 +02:00
insights.push({
2025-09-21 22:56:55 +02:00
type: 'algorithm',
icon: Brain,
title: 'Algoritmo',
description: forecasts[0].algorithm,
2025-09-20 22:11:05 +02:00
impact: 'info'
});
}
2025-09-21 22:56:55 +02:00
// Forecast period
2025-09-20 22:11:05 +02:00
insights.push({
2025-09-21 22:56:55 +02:00
type: 'period',
icon: Calendar,
title: 'Período',
description: `${forecasts.length} días predichos`,
impact: 'info'
2025-09-20 22:11:05 +02:00
});
return insights;
};
2025-09-21 22:56:55 +02:00
const currentInsights = getForecastInsights(forecasts);
2025-09-20 22:11:05 +02:00
2025-09-21 22:56:55 +02:00
// Loading and error states - using project patterns
if (isLoading || !tenantId) {
return (
<div className="flex items-center justify-center min-h-64">
<LoadingSpinner text="Cargando datos de predicción..." />
</div>
);
}
if (hasError) {
return (
<div className="text-center py-12">
<TrendingUp className="mx-auto h-12 w-12 text-[var(--color-error)] mb-4" />
<h3 className="text-lg font-medium text-[var(--text-primary)] mb-2">
Error al cargar datos
</h3>
<p className="text-[var(--text-secondary)] mb-4">
{forecastsError?.message || ingredientsError?.message || modelsError?.message || 'Ha ocurrido un error inesperado'}
</p>
<Button onClick={() => window.location.reload()}>
Reintentar
</Button>
</div>
);
}
2025-08-28 10:41:04 +02:00
return (
2025-09-21 22:56:55 +02:00
<div className="space-y-6">
2025-08-28 10:41:04 +02:00
<PageHeader
title="Predicción de Demanda"
2025-09-21 22:56:55 +02:00
description="Sistema inteligente de predicción de demanda basado en IA"
2025-08-28 10:41:04 +02:00
/>
2025-09-21 22:56:55 +02:00
{/* Stats Grid - Similar to POSPage */}
<StatsGrid
stats={[
{
title: 'Ingredientes con Modelos',
value: products.length,
variant: 'default' as const,
icon: Brain,
},
{
title: 'Predicciones Generadas',
value: forecasts.length,
variant: 'info' as const,
icon: TrendingUp,
},
{
title: 'Confianza Promedio',
value: `${averageConfidence}%`,
variant: 'success' as const,
icon: Target,
},
{
title: 'Demanda Total',
value: formatters.number(Math.round(totalDemand)),
variant: 'warning' as const,
icon: BarChart3,
},
]}
columns={4}
/>
2025-09-20 22:11:05 +02:00
2025-09-21 22:56:55 +02:00
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Ingredient Selection Section */}
<div className="lg:col-span-2 space-y-6">
{/* Ingredients Grid - Similar to POSPage products */}
<Card className="p-4">
<h3 className="text-lg font-semibold mb-4 flex items-center">
<Brain className="w-5 h-5 mr-2" />
Ingredientes Disponibles ({products.length})
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{products.map(product => {
const isSelected = selectedProduct === product.id;
const getStatusConfig = () => {
if (isSelected) {
return {
color: getStatusColor('completed'),
text: 'Seleccionado',
icon: Target,
isCritical: false,
isHighlight: true
};
} else {
return {
color: getStatusColor('pending'),
text: 'Disponible',
icon: Brain,
isCritical: false,
isHighlight: false
};
}
};
return (
<StatusCard
key={product.id}
id={product.id}
statusIndicator={getStatusConfig()}
title={product.name}
subtitle={product.category}
primaryValue="Modelo IA"
primaryValueLabel="entrenado"
actions={[
{
label: isSelected ? 'Seleccionado' : 'Seleccionar',
icon: isSelected ? Target : Zap,
variant: isSelected ? 'success' : 'primary',
priority: 'primary',
disabled: isGenerating,
onClick: () => setSelectedProduct(product.id)
}
]}
/>
);
})}
</div>
2025-08-28 10:41:04 +02:00
2025-09-21 22:56:55 +02:00
{/* Empty State */}
2025-09-20 22:11:05 +02:00
{products.length === 0 && (
2025-09-21 22:56:55 +02:00
<div className="text-center py-12">
<Brain className="mx-auto h-12 w-12 text-[var(--text-tertiary)] mb-4" />
<h3 className="text-lg font-medium text-[var(--text-primary)] mb-2">
No hay modelos entrenados
</h3>
<p className="text-[var(--text-secondary)] mb-4">
Para generar predicciones, necesitas modelos IA entrenados para tus ingredientes
</p>
<Button
onClick={() => window.location.href = '/app/database/models'}
variant="primary"
>
<Settings className="w-4 h-4 mr-2" />
Entrenar Modelos
</Button>
</div>
2025-09-20 22:11:05 +02:00
)}
2025-09-21 22:56:55 +02:00
</Card>
</div>
2025-08-28 10:41:04 +02:00
2025-09-21 22:56:55 +02:00
{/* Configuration and Generation Section */}
<div className="space-y-6">
{/* Period Selection */}
<Card className="p-6">
<h3 className="text-lg font-semibold mb-4 flex items-center">
<Calendar className="w-5 h-5 mr-2" />
Configuración
</h3>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
Período de Predicción
</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={isGenerating}
>
{periods.map(period => (
<option key={period.value} value={period.value}>{period.label}</option>
))}
</select>
</div>
2025-08-28 10:41:04 +02:00
2025-09-21 22:56:55 +02:00
{selectedProduct && (
<div className="p-3 bg-[var(--bg-secondary)] rounded-lg">
<p className="text-sm font-medium text-[var(--text-primary)]">
{products.find(p => p.id === selectedProduct)?.name}
</p>
<p className="text-xs text-[var(--text-secondary)]">
Predicción para {forecastPeriod} días
</p>
</div>
)}
</div>
</Card>
{/* Generate Forecast */}
<Card className="p-6">
<h3 className="text-lg font-semibold mb-4 flex items-center">
<Zap className="w-5 h-5 mr-2" />
Generar Predicción
</h3>
2025-09-20 22:11:05 +02:00
<Button
onClick={handleGenerateForecast}
disabled={!selectedProduct || !forecastPeriod || isGenerating}
2025-09-21 22:56:55 +02:00
className="w-full"
size="lg"
2025-09-20 22:11:05 +02:00
>
{isGenerating ? (
<>
2025-09-21 22:56:55 +02:00
<Loader className="w-5 h-5 mr-2 animate-spin" />
Generando Predicción...
2025-09-20 22:11:05 +02:00
</>
) : (
<>
2025-09-21 22:56:55 +02:00
<Zap className="w-5 h-5 mr-2" />
Generar Predicción IA
2025-09-20 22:11:05 +02:00
</>
)}
</Button>
2025-09-21 22:56:55 +02:00
{!selectedProduct && (
<p className="text-xs text-[var(--text-tertiary)] mt-2 text-center">
Selecciona un ingrediente para continuar
</p>
)}
</Card>
</div>
</div>
2025-08-28 10:41:04 +02:00
2025-09-21 22:56:55 +02:00
{/* Results Section */}
2025-09-20 22:11:05 +02:00
{hasGeneratedForecast && forecasts.length > 0 && (
<>
2025-09-21 22:56:55 +02:00
{/* Results Header */}
<Card className="p-6">
<div className="flex items-center justify-between mb-4">
<div>
<h3 className="text-xl font-bold text-[var(--text-primary)]">Resultados de Predicción</h3>
<p className="text-[var(--text-secondary)]">
{products.find(p => p.id === selectedProduct)?.name} {forecastPeriod} días
</p>
</div>
<div className="flex items-center space-x-3">
<div className="flex rounded-lg border border-[var(--border-secondary)] overflow-hidden">
<Button
variant={viewMode === 'chart' ? 'primary' : 'outline'}
onClick={() => setViewMode('chart')}
size="sm"
>
<BarChart3 className="w-4 h-4 mr-1" />
Gráfico
</Button>
<Button
variant={viewMode === 'table' ? 'primary' : 'outline'}
onClick={() => setViewMode('table')}
size="sm"
>
<Package className="w-4 h-4 mr-1" />
Tabla
</Button>
</div>
<Button variant="outline" size="sm">
<Download className="w-4 h-4 mr-2" />
Exportar
</Button>
2025-09-20 22:11:05 +02:00
</div>
2025-09-21 22:56:55 +02:00
</div>
2025-09-20 22:11:05 +02:00
2025-09-21 22:56:55 +02:00
{/* Chart or Table */}
<div className="min-h-96">
{viewMode === 'chart' ? (
<DemandChart
data={forecasts}
product={selectedProduct}
period={forecastPeriod}
loading={isLoading}
error={hasError ? 'Error al cargar las predicciones' : null}
height={400}
title=""
/>
) : (
<ForecastTable forecasts={transformedForecasts} />
2025-09-20 22:11:05 +02:00
)}
2025-09-21 22:56:55 +02:00
</div>
</Card>
{/* Insights */}
{currentInsights.length > 0 && (
<Card className="p-6">
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4 flex items-center">
<Activity className="w-5 h-5 mr-2" />
Factores de Influencia
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{currentInsights.map((insight, index) => {
const IconComponent = insight.icon;
return (
<div key={index} className="flex items-start space-x-3 p-4 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" />
2025-09-20 22:11:05 +02:00
</div>
2025-09-21 22:56:55 +02:00
<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>
2025-09-20 22:11:05 +02:00
</div>
</div>
2025-09-21 22:56:55 +02:00
);
})}
2025-09-11 18:21:32 +02:00
</div>
2025-09-21 22:56:55 +02:00
</Card>
)}
2025-09-20 22:11:05 +02:00
</>
)}
2025-08-28 10:41:04 +02:00
2025-09-21 22:56:55 +02:00
{/* Help Section - Only when no models available */}
2025-09-20 22:11:05 +02:00
{!isLoading && !hasError && products.length === 0 && (
<Card className="p-6 text-center">
<div className="py-8">
2025-09-21 22:56:55 +02:00
<Brain className="h-12 w-12 text-[var(--color-info)] mx-auto mb-4" />
<h3 className="text-lg font-medium text-[var(--text-primary)] mb-2">
No hay modelos entrenados
</h3>
<p className="text-[var(--text-secondary)] mb-6">
Para generar predicciones, necesitas entrenar modelos de IA para tus ingredientes.
2025-09-11 18:21:32 +02:00
</p>
2025-09-21 22:56:55 +02:00
<Button
onClick={() => window.location.href = '/app/database/models'}
variant="primary"
>
<Settings className="w-4 h-4 mr-2" />
Entrenar Modelos IA
</Button>
2025-09-11 18:21:32 +02:00
</div>
</Card>
)}
2025-08-28 10:41:04 +02:00
</div>
);
};
export default ForecastingPage;