487 lines
17 KiB
TypeScript
487 lines
17 KiB
TypeScript
import React, { useState, useMemo } from 'react';
|
|
import { Calendar, TrendingUp, AlertTriangle, BarChart3, Settings, Loader, Zap, Brain, Target, Activity } from 'lucide-react';
|
|
import { Button, Card, Badge, StatsGrid, StatusCard, getStatusColor } from '../../../../components/ui';
|
|
import { PageHeader } from '../../../../components/layout';
|
|
import { LoadingSpinner } from '../../../../components/ui';
|
|
import { DemandChart } from '../../../../components/domain/forecasting';
|
|
import { useTenantForecasts, useCreateSingleForecast } from '../../../../api/hooks/forecasting';
|
|
import { useIngredients } from '../../../../api/hooks/inventory';
|
|
import { useModels } from '../../../../api/hooks/training';
|
|
import { useTenantId } from '../../../../hooks/useTenantId';
|
|
import { ForecastResponse } from '../../../../api/types/forecasting';
|
|
import { forecastingService } from '../../../../api/services/forecasting';
|
|
import { formatters } from '../../../../components/ui/Stats/StatsPresets';
|
|
|
|
const ForecastingPage: React.FC = () => {
|
|
const [selectedProduct, setSelectedProduct] = useState('');
|
|
const [forecastPeriod, setForecastPeriod] = useState('7');
|
|
const [isGenerating, setIsGenerating] = useState(false);
|
|
const [hasGeneratedForecast, setHasGeneratedForecast] = useState(false);
|
|
const [currentForecastData, setCurrentForecastData] = useState<ForecastResponse[]>([]);
|
|
|
|
// Get tenant ID from tenant store
|
|
const tenantId = useTenantId();
|
|
|
|
// 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);
|
|
}
|
|
};
|
|
|
|
// Use either current forecast data or fetched data
|
|
const forecasts = currentForecastData.length > 0 ? currentForecastData : (forecastsData?.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 simplified forecast insights
|
|
const getForecastInsights = (forecasts: ForecastResponse[]) => {
|
|
if (!forecasts || forecasts.length === 0) return [];
|
|
|
|
const insights = [];
|
|
const avgConfidence = forecasts.reduce((sum, f) => sum + f.confidence_level, 0) / forecasts.length;
|
|
|
|
// 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'
|
|
});
|
|
|
|
// Algorithm used
|
|
if (forecasts[0]?.algorithm) {
|
|
insights.push({
|
|
type: 'algorithm',
|
|
icon: Brain,
|
|
title: 'Algoritmo',
|
|
description: forecasts[0].algorithm,
|
|
impact: 'info'
|
|
});
|
|
}
|
|
|
|
// Forecast period
|
|
insights.push({
|
|
type: 'period',
|
|
icon: Calendar,
|
|
title: 'Período',
|
|
description: `${forecasts.length} días predichos`,
|
|
impact: 'info'
|
|
});
|
|
|
|
return insights;
|
|
};
|
|
|
|
const currentInsights = getForecastInsights(forecasts);
|
|
|
|
|
|
// 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>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<PageHeader
|
|
title="Predicción de Demanda"
|
|
description="Sistema inteligente de predicción de demanda basado en IA"
|
|
/>
|
|
|
|
{/* 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}
|
|
/>
|
|
|
|
<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>
|
|
|
|
{/* Empty State */}
|
|
{products.length === 0 && (
|
|
<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>
|
|
)}
|
|
</Card>
|
|
</div>
|
|
|
|
{/* 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>
|
|
|
|
{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>
|
|
<Button
|
|
onClick={handleGenerateForecast}
|
|
disabled={!selectedProduct || !forecastPeriod || isGenerating}
|
|
className="w-full"
|
|
size="lg"
|
|
>
|
|
{isGenerating ? (
|
|
<>
|
|
<Loader className="w-5 h-5 mr-2 animate-spin" />
|
|
Generando Predicción...
|
|
</>
|
|
) : (
|
|
<>
|
|
<Zap className="w-5 h-5 mr-2" />
|
|
Generar Predicción IA
|
|
</>
|
|
)}
|
|
</Button>
|
|
|
|
{!selectedProduct && (
|
|
<p className="text-xs text-[var(--text-tertiary)] mt-2 text-center">
|
|
Selecciona un ingrediente para continuar
|
|
</p>
|
|
)}
|
|
</Card>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Results Section */}
|
|
{hasGeneratedForecast && forecasts.length > 0 && (
|
|
<>
|
|
{/* 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>
|
|
|
|
{/* Chart View */}
|
|
<div className="min-h-96">
|
|
<DemandChart
|
|
data={forecasts}
|
|
product={selectedProduct}
|
|
period={forecastPeriod}
|
|
loading={isLoading}
|
|
error={hasError ? 'Error al cargar las predicciones' : null}
|
|
height={400}
|
|
title=""
|
|
/>
|
|
</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" />
|
|
</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>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{/* Help Section - Only when no models available */}
|
|
{!isLoading && !hasError && products.length === 0 && (
|
|
<Card className="p-6 text-center">
|
|
<div className="py-8">
|
|
<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.
|
|
</p>
|
|
<Button
|
|
onClick={() => window.location.href = '/app/database/models'}
|
|
variant="primary"
|
|
>
|
|
<Settings className="w-4 h-4 mr-2" />
|
|
Entrenar Modelos IA
|
|
</Button>
|
|
</div>
|
|
</Card>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default ForecastingPage; |