Imporve the predicciones page

This commit is contained in:
Urtzi Alfaro
2025-09-20 22:11:05 +02:00
parent abe7cf2444
commit 38d314e28d
14 changed files with 1659 additions and 364 deletions

View File

@@ -15,10 +15,11 @@ import {
DeleteForecastResponse, DeleteForecastResponse,
GetForecastsParams, GetForecastsParams,
ForecastingHealthResponse, ForecastingHealthResponse,
MultiDayForecastResponse,
} from '../types/forecasting'; } from '../types/forecasting';
export class ForecastingService { export class ForecastingService {
private readonly baseUrl = '/forecasts'; private readonly baseUrl = '/tenants';
/** /**
* Generate a single product forecast * Generate a single product forecast
@@ -29,7 +30,7 @@ export class ForecastingService {
request: ForecastRequest request: ForecastRequest
): Promise<ForecastResponse> { ): Promise<ForecastResponse> {
return apiClient.post<ForecastResponse, ForecastRequest>( return apiClient.post<ForecastResponse, ForecastRequest>(
`/tenants/${tenantId}${this.baseUrl}/single`, `${this.baseUrl}/${tenantId}/forecasts/single`,
request request
); );
} }
@@ -43,7 +44,7 @@ export class ForecastingService {
request: BatchForecastRequest request: BatchForecastRequest
): Promise<BatchForecastResponse> { ): Promise<BatchForecastResponse> {
return apiClient.post<BatchForecastResponse, BatchForecastRequest>( return apiClient.post<BatchForecastResponse, BatchForecastRequest>(
`/tenants/${tenantId}${this.baseUrl}/batch`, `${this.baseUrl}/${tenantId}/forecasts/batch`,
request request
); );
} }
@@ -75,7 +76,7 @@ export class ForecastingService {
} }
const queryString = searchParams.toString(); const queryString = searchParams.toString();
const url = `/tenants/${tenantId}${this.baseUrl}${queryString ? `?${queryString}` : ''}`; const url = `${this.baseUrl}/${tenantId}/forecasts${queryString ? `?${queryString}` : ''}`;
return apiClient.get<ForecastListResponse>(url); return apiClient.get<ForecastListResponse>(url);
} }
@@ -89,7 +90,7 @@ export class ForecastingService {
forecastId: string forecastId: string
): Promise<ForecastByIdResponse> { ): Promise<ForecastByIdResponse> {
return apiClient.get<ForecastByIdResponse>( return apiClient.get<ForecastByIdResponse>(
`/tenants/${tenantId}${this.baseUrl}/${forecastId}` `${this.baseUrl}/${tenantId}/forecasts/${forecastId}`
); );
} }
@@ -102,7 +103,7 @@ export class ForecastingService {
forecastId: string forecastId: string
): Promise<DeleteForecastResponse> { ): Promise<DeleteForecastResponse> {
return apiClient.delete<DeleteForecastResponse>( return apiClient.delete<DeleteForecastResponse>(
`/tenants/${tenantId}${this.baseUrl}/${forecastId}` `${this.baseUrl}/${tenantId}/forecasts/${forecastId}`
); );
} }
@@ -114,7 +115,21 @@ export class ForecastingService {
tenantId: string tenantId: string
): Promise<ForecastStatistics> { ): Promise<ForecastStatistics> {
return apiClient.get<ForecastStatistics>( return apiClient.get<ForecastStatistics>(
`/tenants/${tenantId}${this.baseUrl}/statistics` `${this.baseUrl}/${tenantId}/forecasts/statistics`
);
}
/**
* Generate multi-day forecasts for a single product
* POST /tenants/{tenant_id}/forecasts/multi-day
*/
async createMultiDayForecast(
tenantId: string,
request: ForecastRequest
): Promise<MultiDayForecastResponse> {
return apiClient.post<MultiDayForecastResponse, ForecastRequest>(
`${this.baseUrl}/${tenantId}/forecasts/multi-day`,
request
); );
} }

View File

@@ -83,7 +83,7 @@ class TrainingService {
const queryString = params.toString() ? `?${params.toString()}` : ''; const queryString = params.toString() ? `?${params.toString()}` : '';
return apiClient.get<PaginatedResponse<TrainedModelResponse>>( return apiClient.get<PaginatedResponse<TrainedModelResponse>>(
`${this.baseUrl}/${tenantId}/models${queryString}` `${this.baseUrl}/${tenantId}/models/${queryString}`
); );
} }

View File

@@ -158,3 +158,14 @@ export interface ForecastingHealthResponse {
features: string[]; features: string[];
timestamp: string; timestamp: string;
} }
export interface MultiDayForecastResponse {
tenant_id: string;
inventory_product_id: string;
forecast_start_date: string; // ISO date string
forecast_days: number;
forecasts: ForecastResponse[];
total_predicted_demand: number;
average_confidence_level: number;
processing_time_ms: number;
}

View File

@@ -88,9 +88,15 @@ const DemandChart: React.FC<DemandChartProps> = ({
// Process forecast data for chart // Process forecast data for chart
const chartData = useMemo(() => { const chartData = useMemo(() => {
console.log('🔍 Processing forecast data for chart:', data);
const processedData: ChartDataPoint[] = data.map(forecast => { const processedData: ChartDataPoint[] = data.map(forecast => {
// Convert forecast_date to a proper date format for the chart
const forecastDate = new Date(forecast.forecast_date);
const dateString = forecastDate.toISOString().split('T')[0];
return { return {
date: forecast.forecast_date, date: dateString,
actualDemand: undefined, // Not available in current forecast response actualDemand: undefined, // Not available in current forecast response
predictedDemand: forecast.predicted_demand, predictedDemand: forecast.predicted_demand,
confidenceLower: forecast.confidence_lower, confidenceLower: forecast.confidence_lower,
@@ -99,23 +105,32 @@ const DemandChart: React.FC<DemandChartProps> = ({
}; };
}); });
console.log('📊 Processed chart data:', processedData);
return processedData.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()); return processedData.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime());
}, [data]); }, [data]);
// Filter data based on selected period // Filter data based on selected period
const filteredData = useMemo(() => { const filteredData = useMemo(() => {
console.log('🔍 Filtering data - selected period:', selectedPeriod);
console.log('🔍 Chart data before filtering:', chartData);
if (!selectedPeriod.start || !selectedPeriod.end) { if (!selectedPeriod.start || !selectedPeriod.end) {
console.log('📊 No period filter, returning all chart data');
return chartData; return chartData;
} }
return chartData.filter(point => { const filtered = chartData.filter(point => {
const pointDate = new Date(point.date); const pointDate = new Date(point.date);
return pointDate >= selectedPeriod.start! && pointDate <= selectedPeriod.end!; return pointDate >= selectedPeriod.start! && pointDate <= selectedPeriod.end!;
}); });
console.log('📊 Filtered data:', filtered);
return filtered;
}, [chartData, selectedPeriod]); }, [chartData, selectedPeriod]);
// Update zoomed data when filtered data changes // Update zoomed data when filtered data changes
useEffect(() => { useEffect(() => {
console.log('🔍 Setting zoomed data from filtered data:', filteredData);
setZoomedData(filteredData); setZoomedData(filteredData);
}, [filteredData]); }, [filteredData]);
@@ -221,8 +236,11 @@ const DemandChart: React.FC<DemandChartProps> = ({
); );
} }
// Empty state // Use filteredData if zoomedData is empty but we have data
if (zoomedData.length === 0) { const displayData = zoomedData.length > 0 ? zoomedData : filteredData;
// Empty state - only show if we truly have no data
if (displayData.length === 0 && chartData.length === 0) {
return ( return (
<Card className={className}> <Card className={className}>
<CardHeader> <CardHeader>
@@ -286,7 +304,7 @@ const DemandChart: React.FC<DemandChartProps> = ({
)} )}
{/* Reset zoom */} {/* Reset zoom */}
{zoomedData.length !== filteredData.length && ( {displayData.length !== filteredData.length && (
<Button variant="ghost" size="sm" onClick={handleResetZoom}> <Button variant="ghost" size="sm" onClick={handleResetZoom}>
Restablecer Zoom Restablecer Zoom
</Button> </Button>
@@ -298,28 +316,57 @@ const DemandChart: React.FC<DemandChartProps> = ({
<CardBody padding="lg"> <CardBody padding="lg">
<div style={{ width: '100%', height }}> <div style={{ width: '100%', height }}>
<ResponsiveContainer> <ResponsiveContainer>
<ComposedChart data={zoomedData} margin={{ top: 20, right: 30, left: 20, bottom: 5 }}> <ComposedChart data={displayData} margin={{ top: 20, right: 30, left: 20, bottom: 60 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" /> <defs>
<linearGradient id="demandGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#10b981" stopOpacity={0.3}/>
<stop offset="95%" stopColor="#10b981" stopOpacity={0.05}/>
</linearGradient>
<linearGradient id="confidenceGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#10b981" stopOpacity={0.1}/>
<stop offset="95%" stopColor="#10b981" stopOpacity={0.02}/>
</linearGradient>
</defs>
<CartesianGrid
strokeDasharray="2 2"
stroke="#e5e7eb"
strokeOpacity={0.5}
horizontal={true}
vertical={false}
/>
<XAxis <XAxis
dataKey="date" dataKey="date"
stroke="#6b7280" stroke="#6b7280"
fontSize={12} fontSize={11}
tickMargin={8}
angle={-45}
textAnchor="end"
height={80}
interval={0}
tickFormatter={(value) => { tickFormatter={(value) => {
const date = new Date(value); const date = new Date(value);
return timeframe === 'weekly' return date.toLocaleDateString('es-ES', {
? date.toLocaleDateString('es-ES', { month: 'short', day: 'numeric' }) month: 'short',
: timeframe === 'monthly' day: 'numeric',
? date.toLocaleDateString('es-ES', { month: 'short', year: '2-digit' }) weekday: displayData.length <= 7 ? 'short' : undefined
: date.getFullYear().toString(); });
}} }}
/> />
<YAxis <YAxis
stroke="#6b7280" stroke="#6b7280"
fontSize={12} fontSize={11}
tickFormatter={(value) => `${value}`} width={60}
tickFormatter={(value) => value.toFixed(0)}
domain={['dataMin - 5', 'dataMax + 5']}
/>
<Tooltip
content={<CustomTooltip />}
cursor={{ stroke: '#10b981', strokeWidth: 1, strokeOpacity: 0.5 }}
/>
<Legend
wrapperStyle={{ paddingTop: '20px' }}
iconType="line"
/> />
<Tooltip content={<CustomTooltip />} />
<Legend />
{/* Confidence interval area */} {/* Confidence interval area */}
{showConfidenceInterval && ( {showConfidenceInterval && (
@@ -328,9 +375,9 @@ const DemandChart: React.FC<DemandChartProps> = ({
dataKey="confidenceUpper" dataKey="confidenceUpper"
stackId={1} stackId={1}
stroke="none" stroke="none"
fill="#10b98120" fill="url(#confidenceGradient)"
fillOpacity={0.3} fillOpacity={0.4}
name="Intervalo de Confianza Superior" name="Límite Superior"
/> />
)} )}
{showConfidenceInterval && ( {showConfidenceInterval && (
@@ -340,20 +387,40 @@ const DemandChart: React.FC<DemandChartProps> = ({
stackId={1} stackId={1}
stroke="none" stroke="none"
fill="#ffffff" fill="#ffffff"
name="Intervalo de Confianza Inferior" name="Límite Inferior"
/> />
)} )}
{/* Background area for main prediction */}
<Area
type="monotone"
dataKey="predictedDemand"
stroke="none"
fill="url(#demandGradient)"
fillOpacity={0.2}
name="Área de Demanda"
/>
{/* Predicted demand line */} {/* Predicted demand line */}
<Line <Line
type="monotone" type="monotone"
dataKey="predictedDemand" dataKey="predictedDemand"
stroke="#10b981" stroke="#10b981"
strokeWidth={3} strokeWidth={3}
dot={true} dot={{
dotSize={6} fill: '#10b981',
activeDot={{ r: 8, stroke: '#10b981', strokeWidth: 2 }} strokeWidth: 2,
stroke: '#ffffff',
r: 4
}}
activeDot={{
r: 6,
stroke: '#10b981',
strokeWidth: 3,
fill: '#ffffff'
}}
name="Demanda Predicha" name="Demanda Predicha"
connectNulls={false}
/> />
</ComposedChart> </ComposedChart>
</ResponsiveContainer> </ResponsiveContainer>
@@ -386,18 +453,46 @@ const DemandChart: React.FC<DemandChartProps> = ({
</div> </div>
)} )}
{/* Chart legend */} {/* Enhanced Chart legend and insights */}
<div className="flex items-center justify-center gap-6 mt-4 text-sm"> <div className="mt-6 space-y-4">
<div className="flex items-center justify-center gap-6 text-sm">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="w-4 h-0.5 bg-green-500"></div> <div className="w-4 h-0.5 bg-green-500 rounded"></div>
<span className="text-text-secondary">Demanda Predicha</span> <span className="text-text-secondary">Demanda Predicha</span>
</div> </div>
{showConfidenceInterval && ( {showConfidenceInterval && (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="w-4 h-2 bg-green-500 bg-opacity-20"></div> <div className="w-4 h-2 bg-green-500 bg-opacity-20 rounded"></div>
<span className="text-text-secondary">Intervalo de Confianza</span> <span className="text-text-secondary">Intervalo de Confianza</span>
</div> </div>
)} )}
<div className="flex items-center gap-2">
<div className="w-3 h-3 bg-green-500 rounded-full"></div>
<span className="text-text-secondary">Puntos de Datos</span>
</div>
</div>
{/* Quick stats */}
<div className="grid grid-cols-3 gap-4 p-4 bg-gray-50 rounded-lg">
<div className="text-center">
<div className="text-lg font-bold text-green-600">
{Math.min(...displayData.map(d => d.predictedDemand || 0)).toFixed(1)}
</div>
<div className="text-xs text-gray-500">Mínimo</div>
</div>
<div className="text-center">
<div className="text-lg font-bold text-blue-600">
{(displayData.reduce((sum, d) => sum + (d.predictedDemand || 0), 0) / displayData.length).toFixed(1)}
</div>
<div className="text-xs text-gray-500">Promedio</div>
</div>
<div className="text-center">
<div className="text-lg font-bold text-orange-600">
{Math.max(...displayData.map(d => d.predictedDemand || 0)).toFixed(1)}
</div>
<div className="text-xs text-gray-500">Máximo</div>
</div>
</div>
</div> </div>
</CardBody> </CardBody>
</Card> </Card>

View File

@@ -1,29 +1,34 @@
import React, { useState, useMemo } from 'react'; import React, { useState, useMemo } from 'react';
import { Calendar, TrendingUp, AlertTriangle, BarChart3, Download, Settings, Loader } from 'lucide-react'; import { Calendar, TrendingUp, AlertTriangle, BarChart3, Download, Settings, Loader, Zap, Brain, Target, CloudRain, Sun, Thermometer } from 'lucide-react';
import { Button, Card, Badge, Select, Table } from '../../../../components/ui'; import { Button, Card, Badge, Select, Table, StatsGrid } from '../../../../components/ui';
import type { TableColumn } from '../../../../components/ui'; import type { TableColumn } from '../../../../components/ui';
import { PageHeader } from '../../../../components/layout'; import { PageHeader } from '../../../../components/layout';
import { DemandChart, ForecastTable, SeasonalityIndicator, AlertsPanel } from '../../../../components/domain/forecasting'; import { DemandChart, ForecastTable } from '../../../../components/domain/forecasting';
import { useTenantForecasts, useForecastStatistics } from '../../../../api/hooks/forecasting'; import { useTenantForecasts, useCreateSingleForecast } from '../../../../api/hooks/forecasting';
import { useIngredients } from '../../../../api/hooks/inventory'; import { useIngredients } from '../../../../api/hooks/inventory';
import { useAuthUser } from '../../../../stores/auth.store'; import { useModels } from '../../../../api/hooks/training';
import { useCurrentTenant } from '../../../../stores/tenant.store';
import { ForecastResponse } from '../../../../api/types/forecasting'; import { ForecastResponse } from '../../../../api/types/forecasting';
import { forecastingService } from '../../../../api/services/forecasting';
const ForecastingPage: React.FC = () => { const ForecastingPage: React.FC = () => {
const [selectedProduct, setSelectedProduct] = useState('all'); const [selectedProduct, setSelectedProduct] = useState('');
const [forecastPeriod, setForecastPeriod] = useState('7'); const [forecastPeriod, setForecastPeriod] = useState('7');
const [viewMode, setViewMode] = useState<'chart' | 'table'>('chart'); 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 auth user // Get tenant ID from tenant store
const user = useAuthUser(); const currentTenant = useCurrentTenant();
const tenantId = user?.tenant_id || ''; const tenantId = currentTenant?.id || '';
// Calculate date range based on selected period // Calculate date range based on selected period
const endDate = new Date(); const endDate = new Date();
const startDate = new Date(); const startDate = new Date();
startDate.setDate(startDate.getDate() - parseInt(forecastPeriod)); startDate.setDate(startDate.getDate() - parseInt(forecastPeriod));
// API hooks // Fetch existing forecasts
const { const {
data: forecastsData, data: forecastsData,
isLoading: forecastsLoading, isLoading: forecastsLoading,
@@ -31,15 +36,12 @@ const ForecastingPage: React.FC = () => {
} = useTenantForecasts(tenantId, { } = useTenantForecasts(tenantId, {
start_date: startDate.toISOString().split('T')[0], start_date: startDate.toISOString().split('T')[0],
end_date: endDate.toISOString().split('T')[0], end_date: endDate.toISOString().split('T')[0],
...(selectedProduct !== 'all' && { inventory_product_id: selectedProduct }), ...(selectedProduct && { inventory_product_id: selectedProduct }),
limit: 100 limit: 100
}, {
enabled: !!tenantId && hasGeneratedForecast && !!selectedProduct
}); });
const {
data: statisticsData,
isLoading: statisticsLoading,
error: statisticsError
} = useForecastStatistics(tenantId);
// Fetch real inventory data // Fetch real inventory data
const { const {
@@ -48,21 +50,48 @@ const ForecastingPage: React.FC = () => {
error: ingredientsError error: ingredientsError
} = useIngredients(tenantId); } = useIngredients(tenantId);
// Build products list from real inventory data // Fetch trained models to filter products
const products = useMemo(() => { const {
const productList = [{ id: 'all', name: 'Todos los productos' }]; data: modelsData,
isLoading: modelsLoading,
error: modelsError
} = useModels(tenantId, { active_only: true });
if (ingredientsData && ingredientsData.length > 0) { // Forecast generation mutation
const inventoryProducts = ingredientsData.map(ingredient => ({ 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, id: ingredient.id,
name: ingredient.name, name: ingredient.name,
category: ingredient.category, category: ingredient.category,
hasModel: true
})); }));
productList.push(...inventoryProducts); }, [ingredientsData, modelsData]);
}
return productList;
}, [ingredientsData]);
const periods = [ const periods = [
{ value: '7', label: '7 días' }, { value: '7', label: '7 días' },
@@ -71,99 +100,107 @@ const ForecastingPage: React.FC = () => {
{ value: '90', label: '3 meses' }, { value: '90', label: '3 meses' },
]; ];
// Transform forecast data for table display // Handle forecast generation
const transformForecastsForTable = (forecasts: ForecastResponse[]) => { const handleGenerateForecast = async () => {
return forecasts.map(forecast => ({ if (!tenantId || !selectedProduct) {
id: forecast.id, alert('Por favor, selecciona un ingrediente para generar predicciones.');
product: forecast.inventory_product_id, // Will need to map to product name return;
currentStock: 'N/A', // Not available in forecast data }
forecastDemand: forecast.predicted_demand,
recommendedProduction: Math.ceil(forecast.predicted_demand * 1.1), // Simple calculation setIsGenerating(true);
confidence: Math.round(forecast.confidence_level * 100),
trend: forecast.predicted_demand > 0 ? 'up' : 'stable', try {
stockoutRisk: forecast.confidence_level > 0.8 ? 'low' : forecast.confidence_level > 0.6 ? 'medium' : 'high', 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,
}; };
// Generate alerts based on forecast data // Use the new multi-day endpoint for all forecasts
const generateAlertsFromForecasts = (forecasts: ForecastResponse[]) => { const multiDayResult = await forecastingService.createMultiDayForecast(tenantId, forecastRequest);
return forecasts setIsGenerating(false);
.filter(forecast => forecast.confidence_level < 0.7 || forecast.predicted_demand > 50) setHasGeneratedForecast(true);
.slice(0, 3) // Limit to 3 alerts // Use the forecasts from the multi-day response
.map((forecast, index) => ({ setCurrentForecastData(multiDayResult.forecasts);
id: (index + 1).toString(), } catch (error) {
type: forecast.confidence_level < 0.7 ? 'low-confidence' : 'high-demand', console.error('Failed to generate forecast:', error);
product: forecast.inventory_product_id, setIsGenerating(false);
message: forecast.confidence_level < 0.7
? `Baja confianza en predicción (${Math.round(forecast.confidence_level * 100)}%)`
: `Alta demanda prevista: ${forecast.predicted_demand} unidades`,
severity: forecast.confidence_level < 0.5 ? 'high' : 'medium',
recommendation: forecast.confidence_level < 0.7
? 'Revisar datos históricos y factores externos'
: `Considerar aumentar producción a ${Math.ceil(forecast.predicted_demand * 1.2)} unidades`
}));
};
// Extract weather data from first forecast (if available)
const getWeatherImpact = (forecasts: ForecastResponse[]) => {
const firstForecast = forecasts?.[0];
if (!firstForecast) return null;
return {
today: firstForecast.weather_description || 'N/A',
temperature: firstForecast.weather_temperature || 0,
demandFactor: 1.0, // Could be calculated based on weather
affectedCategories: [], // Could be derived from business logic
};
};
const getTrendIcon = (trend: string) => {
switch (trend) {
case 'up':
return <TrendingUp className="h-4 w-4 text-[var(--color-success)]" />;
case 'down':
return <TrendingUp className="h-4 w-4 text-[var(--color-error)] rotate-180" />;
default:
return <div className="h-4 w-4 bg-gray-400 rounded-full" />;
} }
}; };
const getRiskBadge = (risk: string) => { // Transform forecast data for table display - only real data
const riskConfig = { const transformForecastsForTable = (forecasts: ForecastResponse[]) => {
low: { color: 'green', text: 'Bajo' }, return forecasts.map(forecast => ({
medium: { color: 'yellow', text: 'Medio' }, id: forecast.id,
high: { color: 'red', text: 'Alto' }, 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,
}));
}; };
const config = riskConfig[risk as keyof typeof riskConfig];
return <Badge variant={config?.color as any}>{config?.text}</Badge>; // 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[] = [ const forecastColumns: TableColumn[] = [
{ {
key: 'product', key: 'product',
title: 'Producto', title: 'Producto ID',
dataIndex: 'product', dataIndex: 'product',
}, },
{ {
key: 'currentStock', key: 'forecastDate',
title: 'Stock Actual', title: 'Fecha',
dataIndex: 'currentStock', dataIndex: 'forecastDate',
render: (value) => new Date(value).toLocaleDateString('es-ES'),
}, },
{ {
key: 'forecastDemand', key: 'forecastDemand',
title: 'Demanda Prevista', title: 'Demanda Prevista',
dataIndex: 'forecastDemand', dataIndex: 'forecastDemand',
render: (value) => ( render: (value) => (
<span className="font-medium text-[var(--color-info)]">{value}</span> <span className="font-medium text-[var(--color-info)]">{value?.toFixed(2) || 'N/A'}</span>
),
},
{
key: 'recommendedProduction',
title: 'Producción Recomendada',
dataIndex: 'recommendedProduction',
render: (value) => (
<span className="font-medium text-[var(--color-success)]">{value}</span>
), ),
}, },
{ {
@@ -173,30 +210,23 @@ const ForecastingPage: React.FC = () => {
render: (value) => `${value}%`, render: (value) => `${value}%`,
}, },
{ {
key: 'trend', key: 'confidenceRange',
title: 'Tendencia', title: 'Rango de Confianza',
dataIndex: 'trend', dataIndex: 'confidenceRange',
render: (value) => (
<div className="flex items-center">
{getTrendIcon(value)}
</div>
),
}, },
{ {
key: 'stockoutRisk', key: 'algorithm',
title: 'Riesgo Agotamiento', title: 'Algoritmo',
dataIndex: 'stockoutRisk', dataIndex: 'algorithm',
render: (value) => getRiskBadge(value),
}, },
]; ];
// Derived data from API responses // Use either current forecast data or fetched data
const forecasts = forecastsData?.forecasts || []; const forecasts = currentForecastData.length > 0 ? currentForecastData : (forecastsData?.forecasts || []);
const transformedForecasts = transformForecastsForTable(forecasts); const transformedForecasts = transformForecastsForTable(forecasts);
const alerts = generateAlertsFromForecasts(forecasts);
const weatherImpact = getWeatherImpact(forecasts); const weatherImpact = getWeatherImpact(forecasts);
const isLoading = forecastsLoading || statisticsLoading || ingredientsLoading; const isLoading = forecastsLoading || ingredientsLoading || modelsLoading || isGenerating;
const hasError = forecastsError || statisticsError || ingredientsError; const hasError = forecastsError || ingredientsError || modelsError;
// Calculate metrics from real data // Calculate metrics from real data
const totalDemand = forecasts.reduce((sum, f) => sum + f.predicted_demand, 0); const totalDemand = forecasts.reduce((sum, f) => sum + f.predicted_demand, 0);
@@ -204,6 +234,67 @@ const ForecastingPage: React.FC = () => {
? Math.round((forecasts.reduce((sum, f) => sum + f.confidence_level, 0) / forecasts.length) * 100) ? Math.round((forecasts.reduce((sum, f) => sum + f.confidence_level, 0) / forecasts.length) * 100)
: 0; : 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 ( return (
<div className="p-6 space-y-6"> <div className="p-6 space-y-6">
<PageHeader <PageHeader
@@ -226,108 +317,74 @@ const ForecastingPage: React.FC = () => {
{isLoading && ( {isLoading && (
<Card className="p-6 flex items-center justify-center"> <Card className="p-6 flex items-center justify-center">
<Loader className="h-6 w-6 animate-spin mr-2" /> <Loader className="h-6 w-6 animate-spin mr-2" />
<span>Cargando predicciones...</span> <span>
{isGenerating ? 'Generando nuevas predicciones...' : 'Cargando predicciones...'}
</span>
</Card> </Card>
)} )}
{hasError && ( {hasError && (
<Card className="p-6 bg-red-50 border-red-200"> <Card className="p-6 bg-[var(--color-error-50)] border-[var(--color-error-200)]">
<div className="flex items-center"> <div className="flex items-center">
<AlertTriangle className="h-5 w-5 text-red-600 mr-2" /> <AlertTriangle className="h-5 w-5 text-[var(--color-error-600)] mr-2" />
<span className="text-red-800">Error al cargar las predicciones. Por favor, inténtalo de nuevo.</span> <span className="text-[var(--color-error-800)]">Error al cargar las predicciones. Por favor, inténtalo de nuevo.</span>
</div> </div>
</Card> </Card>
)} )}
{!isLoading && !hasError && ( {!isLoading && !hasError && (
<> <>
{/* Key Metrics */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<Card className="p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-[var(--text-secondary)]">Precisión del Modelo</p>
<p className="text-3xl font-bold text-[var(--color-success)]">
{statisticsData?.accuracy_metrics?.average_accuracy
? Math.round(statisticsData.accuracy_metrics.average_accuracy * 100)
: averageConfidence}%
</p>
</div>
<div className="h-12 w-12 bg-[var(--color-success)]/10 rounded-full flex items-center justify-center">
<BarChart3 className="h-6 w-6 text-[var(--color-success)]" />
</div>
</div>
</Card>
<Card className="p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-[var(--text-secondary)]">Demanda Prevista</p>
<p className="text-3xl font-bold text-[var(--color-info)]">{Math.round(totalDemand)}</p>
<p className="text-xs text-[var(--text-tertiary)]">próximos {forecastPeriod} días</p>
</div>
<Calendar className="h-12 w-12 text-[var(--color-info)]" />
</div>
</Card>
<Card className="p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-[var(--text-secondary)]">Tendencia</p>
<p className="text-3xl font-bold text-purple-600">
+{statisticsData?.accuracy_metrics?.accuracy_trend
? Math.round(statisticsData.accuracy_metrics.accuracy_trend * 100)
: 5}%
</p>
<p className="text-xs text-[var(--text-tertiary)]">vs período anterior</p>
</div>
<TrendingUp className="h-12 w-12 text-purple-600" />
</div>
</Card>
<Card className="p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-[var(--text-secondary)]">Total Predicciones</p>
<p className="text-3xl font-bold text-[var(--color-primary)]">
{statisticsData?.total_forecasts || forecasts.length}
</p>
<p className="text-xs text-[var(--text-tertiary)]">generadas</p>
</div>
<div className="h-12 w-12 bg-[var(--color-primary)]/10 rounded-full flex items-center justify-center">
<svg className="h-6 w-6 text-[var(--color-primary)]" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707" />
</svg>
</div>
</div>
</Card>
</div>
</> </>
)} )}
{/* Controls */} {/* Forecast Configuration */}
<Card className="p-6"> <Card className="p-6">
<div className="flex flex-col sm:flex-row gap-4"> <h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4">Configurar Predicción</h3>
<div className="flex-1 grid grid-cols-1 sm:grid-cols-3 gap-4"> <div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{/* Step 1: Select Ingredient */}
<div> <div>
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">Producto</label> <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 <select
value={selectedProduct} value={selectedProduct}
onChange={(e) => setSelectedProduct(e.target.value)} onChange={(e) => setSelectedProduct(e.target.value)}
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md" 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 => ( {products.map(product => (
<option key={product.id} value={product.id}>{product.name}</option> <option key={product.id} value={product.id}>
🤖 {product.name}
</option>
))} ))}
</select> </select>
{products.length === 0 && (
<p className="text-xs text-[var(--text-tertiary)] mt-1">
No hay ingredientes con modelos entrenados
</p>
)}
</div> </div>
{/* Step 2: Select Period */}
<div> <div>
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">Período</label> <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 <select
value={forecastPeriod} value={forecastPeriod}
onChange={(e) => setForecastPeriod(e.target.value)} onChange={(e) => setForecastPeriod(e.target.value)}
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md" className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md"
disabled={!selectedProduct || isGenerating}
> >
{periods.map(period => ( {periods.map(period => (
<option key={period.value} value={period.value}>{period.label}</option> <option key={period.value} value={period.value}>{period.label}</option>
@@ -335,96 +392,259 @@ const ForecastingPage: React.FC = () => {
</select> </select>
</div> </div>
{/* Step 3: Generate */}
<div> <div>
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">Vista</label> <label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
<div className="flex rounded-md border border-[var(--border-secondary)]"> <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 <button
onClick={() => setViewMode('chart')} onClick={() => setViewMode('chart')}
className={`px-3 py-2 text-sm ${viewMode === 'chart' ? 'bg-blue-600 text-white' : 'bg-white text-[var(--text-secondary)]'} rounded-l-md`} 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 📊 Gráfico
</button> </button>
<button <button
onClick={() => setViewMode('table')} onClick={() => setViewMode('table')}
className={`px-3 py-2 text-sm ${viewMode === 'table' ? 'bg-blue-600 text-white' : 'bg-white text-[var(--text-secondary)]'} rounded-r-md border-l`} 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 📋 Tabla
</button> </button>
</div> </div>
<Button variant="outline" size="sm">
<Download className="w-4 h-4 mr-2" />
Exportar
</Button>
</div> </div>
</div> </div>
</div> </div>
</Card>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6"> <div className="p-6">
{/* Main Forecast Display */}
<div className="lg:col-span-2">
{viewMode === 'chart' ? ( {viewMode === 'chart' ? (
<DemandChart <DemandChart
data={forecasts}
product={selectedProduct} product={selectedProduct}
period={forecastPeriod} period={forecastPeriod}
loading={isLoading}
error={hasError ? 'Error al cargar las predicciones' : null}
height={450}
title=""
/> />
) : ( ) : (
<ForecastTable forecasts={transformedForecasts} /> <ForecastTable forecasts={transformedForecasts} />
)} )}
</div> </div>
</Card>
</div>
{/* Alerts Panel */} {/* Right Sidebar - Insights */}
<div className="col-span-12 lg:col-span-4 space-y-6">
{/* Weather & External Factors */}
<div className="space-y-6"> <div className="space-y-6">
<AlertsPanel alerts={alerts} /> {/* 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 */} {/* Weather Impact */}
{weatherImpact && ( {weatherImpact && (
<Card className="p-6"> <Card className="overflow-hidden">
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4">Impacto Meteorológico</h3> <div className="bg-gradient-to-r from-[var(--color-warning-500)] to-[var(--color-error-500)] p-4">
<div className="space-y-3"> <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"> <div className="flex items-center justify-between">
<span className="text-sm text-[var(--text-secondary)]">Hoy:</span> <Thermometer className="w-4 h-4 text-[var(--color-warning-600)]" />
<div className="flex items-center"> <span className="text-lg font-bold text-[var(--color-warning-800)]">{weatherImpact.avgTemperature}°C</span>
<span className="text-sm font-medium">{weatherImpact.temperature}°C</span> </div>
<div className="ml-2 w-6 h-6 bg-yellow-400 rounded-full"></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>
</div> </div>
<div className="flex items-center justify-between"> {/* Daily forecast - compact */}
<span className="text-sm text-[var(--text-secondary)]">Condiciones:</span> <div className="space-y-1">
<span className="text-sm font-medium text-[var(--color-info)]">{weatherImpact.today}</span> <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 className="flex items-center justify-between">
<span className="text-sm text-[var(--text-secondary)]">Factor de demanda:</span>
<span className="text-sm font-medium text-[var(--color-info)]">{weatherImpact.demandFactor}x</span>
</div> </div>
{weatherImpact.affectedCategories.length > 0 && (
<div className="mt-4">
<p className="text-xs text-[var(--text-tertiary)] mb-2">Categorías afectadas:</p>
<div className="flex flex-wrap gap-1">
{weatherImpact.affectedCategories.map((category, index) => (
<Badge key={index} variant="blue">{category}</Badge>
))} ))}
</div> </div>
</div> </div>
)}
</div> </div>
</Card> </Card>
)} )}
{/* Model Performance */}
{statisticsData?.model_performance && ( {/* Model Information */}
<Card className="p-6"> {forecasts.length > 0 && (
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4">Rendimiento del Modelo</h3> <Card className="overflow-hidden">
<div className="space-y-3"> <div className="bg-gradient-to-r from-[var(--chart-quinary)] to-[var(--color-info-700)] p-4">
<div className="p-3 bg-[var(--bg-secondary)] rounded-lg"> <h3 className="text-lg font-bold text-white flex items-center">
<div className="flex items-center justify-between mb-2"> <Brain className="w-5 h-5 mr-2" />
<span className="text-sm font-medium">Algoritmo principal</span> Modelo IA
<Badge variant="purple">{statisticsData.model_performance.most_used_algorithm}</Badge> </h3>
<p className="text-purple-100 text-sm">Información técnica del algoritmo</p>
</div> </div>
<div className="flex items-center justify-between"> <div className="p-4 space-y-3">
<span className="text-xs text-[var(--text-tertiary)]">Tiempo de procesamiento promedio</span> <div className="grid grid-cols-1 gap-2">
<span className="text-xs text-[var(--text-secondary)]"> <div className="flex items-center justify-between p-2 bg-[var(--color-info-50)] rounded border border-[var(--color-info-200)]">
{Math.round(statisticsData.model_performance.average_processing_time)}ms <span className="text-sm font-medium text-[var(--color-info-800)]">Algoritmo</span>
</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> </div>
</div> </div>
@@ -432,6 +652,10 @@ const ForecastingPage: React.FC = () => {
)} )}
</div> </div>
</div> </div>
</div>
</div>
</>
)}
{/* Detailed Forecasts Table */} {/* Detailed Forecasts Table */}
{!isLoading && !hasError && transformedForecasts.length > 0 && ( {!isLoading && !hasError && transformedForecasts.length > 0 && (
@@ -448,15 +672,57 @@ const ForecastingPage: React.FC = () => {
</Card> </Card>
)} )}
{!isLoading && !hasError && transformedForecasts.length === 0 && ( {/* Empty States */}
{!isLoading && !hasError && products.length === 0 && (
<Card className="p-6 text-center"> <Card className="p-6 text-center">
<div className="py-8"> <div className="py-8">
<BarChart3 className="h-12 w-12 text-gray-400 mx-auto mb-4" /> <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 predicciones disponibles</h3> <h3 className="text-lg font-medium text-gray-600 mb-2">No hay ingredientes con modelos entrenados</h3>
<p className="text-gray-500"> <p className="text-gray-500 mb-6">
No se encontraron predicciones para el período seleccionado. Para generar predicciones, primero necesitas entrenar modelos de IA para tus ingredientes.
Prueba ajustando los filtros o genera nuevas predicciones. Ve a la página de Modelos IA para entrenar modelos para tus ingredientes.
</p> </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> </div>
</Card> </Card>
)} )}

View File

@@ -0,0 +1,680 @@
import React, { useState, useMemo } from 'react';
import { Brain, TrendingUp, AlertCircle, Play, RotateCcw, Eye, Loader, CheckCircle } from 'lucide-react';
import { Button, Card, Badge, Modal, Table, Select, Input } from '../../../../components/ui';
import { PageHeader } from '../../../../components/layout';
import { useToast } from '../../../../hooks/ui/useToast';
import { useCurrentTenant } from '../../../../stores/tenant.store';
import { useIngredients } from '../../../../api/hooks/inventory';
import {
useModels,
useActiveModel,
useTrainSingleProduct,
useModelMetrics,
useModelPerformance,
useTenantTrainingStatistics
} from '../../../../api/hooks/training';
import type { IngredientResponse } from '../../../../api/types/inventory';
import type { TrainedModelResponse, SingleProductTrainingRequest } from '../../../../api/types/training';
// Actual API response structure (different from expected paginated response)
interface ModelsApiResponse {
tenant_id: string;
models: TrainedModelResponse[];
total_returned: number;
active_only: boolean;
pagination: any;
enhanced_features: boolean;
repository_integration: boolean;
}
interface ModelStatus {
ingredient: IngredientResponse;
hasModel: boolean;
model?: TrainedModelResponse;
isTraining: boolean;
trainingJobId?: string;
lastTrainingDate?: string;
accuracy?: number;
status: 'no_model' | 'active' | 'training' | 'error';
}
const ModelsConfigPage: React.FC = () => {
const { addToast } = useToast();
const currentTenant = useCurrentTenant();
const tenantId = currentTenant?.id || '';
const [selectedIngredient, setSelectedIngredient] = useState<IngredientResponse | null>(null);
const [showTrainingModal, setShowTrainingModal] = useState(false);
const [showModelDetailsModal, setShowModelDetailsModal] = useState(false);
const [trainingSettings, setTrainingSettings] = useState<Partial<SingleProductTrainingRequest>>({
seasonality_mode: 'additive',
daily_seasonality: true,
weekly_seasonality: true,
yearly_seasonality: false,
});
// API hooks
const { data: ingredients = [], isLoading: ingredientsLoading } = useIngredients(tenantId);
const { data: modelsData, isLoading: modelsLoading, error: modelsError } = useModels(tenantId);
const { data: statistics, error: statsError } = useTenantTrainingStatistics(tenantId);
const trainMutation = useTrainSingleProduct();
// Debug: Log the models data structure
React.useEffect(() => {
console.log('Models data structure:', {
modelsData,
modelsLoading,
modelsError,
ingredients: ingredients.length
});
}, [modelsData, modelsLoading, modelsError, ingredients]);
// Build model status for each ingredient
const modelStatuses = useMemo<ModelStatus[]>(() => {
// Handle different possible data structures from the API response
let models: TrainedModelResponse[] = [];
// The API actually returns { models: [...], tenant_id: ..., total_returned: ... }
const apiResponse = modelsData as any as ModelsApiResponse;
if (apiResponse?.models && Array.isArray(apiResponse.models)) {
models = apiResponse.models;
} else if (modelsData?.data && Array.isArray(modelsData.data)) {
models = modelsData.data;
} else if (modelsData && Array.isArray(modelsData)) {
models = modelsData as any;
}
console.log('Processing models:', {
modelsData,
extractedModels: models,
modelsCount: models.length,
ingredientsCount: ingredients.length
});
return ingredients.map((ingredient: IngredientResponse) => {
const model = models.find((m: any) => {
// Focus only on ID-based matching
return m.inventory_product_id === ingredient.id ||
String(m.inventory_product_id) === String(ingredient.id);
});
const isTraining = false; // We'll track this separately for active training jobs
console.log(`Ingredient ${ingredient.name} (${ingredient.id}):`, {
hasModel: !!model,
model: model ? {
id: model.model_id,
created: model.created_at,
inventory_product_id: model.inventory_product_id
} : null
});
return {
ingredient,
hasModel: !!model,
model,
isTraining,
lastTrainingDate: model?.created_at,
accuracy: model?.training_metrics?.mape ? (100 - model.training_metrics.mape) : undefined,
status: model
? (isTraining ? 'training' : 'active')
: 'no_model'
};
});
}, [ingredients, modelsData]);
// Calculate orphaned models (models for ingredients that no longer exist)
const orphanedModels = useMemo(() => {
const apiResponse = modelsData as any as ModelsApiResponse;
const models = apiResponse?.models || [];
const ingredientIds = new Set(ingredients.map(ing => ing.id));
return models.filter((model: any) => !ingredientIds.has(model.inventory_product_id));
}, [modelsData, ingredients]);
// Filter and search
const [searchTerm, setSearchTerm] = useState('');
const [statusFilter, setStatusFilter] = useState<string>('all');
const filteredStatuses = useMemo(() => {
return modelStatuses.filter(status => {
const matchesSearch = status.ingredient.name.toLowerCase().includes(searchTerm.toLowerCase());
const matchesStatus = statusFilter === 'all' || status.status === statusFilter;
return matchesSearch && matchesStatus;
});
}, [modelStatuses, searchTerm, statusFilter]);
const handleTrainModel = async () => {
if (!selectedIngredient) return;
try {
await trainMutation.mutateAsync({
tenantId,
inventoryProductId: selectedIngredient.id,
request: trainingSettings
});
addToast(`Entrenamiento iniciado para ${selectedIngredient.name}`, { type: 'success' });
setShowTrainingModal(false);
} catch (error) {
addToast('Error al iniciar el entrenamiento', { type: 'error' });
}
};
const handleViewModelDetails = (ingredient: IngredientResponse) => {
setSelectedIngredient(ingredient);
setShowModelDetailsModal(true);
};
const handleStartTraining = (ingredient: IngredientResponse) => {
setSelectedIngredient(ingredient);
setShowTrainingModal(true);
};
const getStatusBadge = (status: ModelStatus['status']) => {
switch (status) {
case 'no_model':
return <Badge variant="secondary">Sin modelo</Badge>;
case 'active':
return <Badge variant="success">Activo</Badge>;
case 'training':
return <Badge variant="warning">Entrenando</Badge>;
case 'error':
return <Badge variant="error">Error</Badge>;
default:
return <Badge variant="secondary">Desconocido</Badge>;
}
};
const getAccuracyBadge = (accuracy?: number) => {
if (!accuracy) return null;
const variant = accuracy >= 90 ? 'success' : accuracy >= 75 ? 'warning' : 'error';
return <Badge variant={variant} size="sm">{accuracy.toFixed(1)}%</Badge>;
};
// Table columns configuration
const tableColumns = [
{
key: 'ingredient',
title: 'Ingrediente',
render: (_: any, status: ModelStatus) => (
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-gradient-to-br from-[var(--color-primary)] to-[var(--color-primary-dark)] rounded-lg flex items-center justify-center text-white font-bold">
{status.ingredient.name.charAt(0).toUpperCase()}
</div>
<div>
<div className="font-medium text-[var(--text-primary)]">{status.ingredient.name}</div>
<div className="text-sm text-[var(--text-secondary)]">{status.ingredient.category}</div>
</div>
</div>
),
},
{
key: 'status',
title: 'Estado del Modelo',
render: (_: any, status: ModelStatus) => (
<div className="flex items-center gap-2">
{getStatusBadge(status.status)}
{status.accuracy && getAccuracyBadge(status.accuracy)}
</div>
),
},
{
key: 'lastTrained',
title: 'Último Entrenamiento',
render: (_: any, status: ModelStatus) => (
<div className="text-sm text-[var(--text-secondary)]">
{status.lastTrainingDate
? new Date(status.lastTrainingDate).toLocaleDateString('es-ES')
: 'Nunca'
}
</div>
),
},
{
key: 'actions',
title: 'Acciones',
render: (_: any, status: ModelStatus) => (
<div className="flex items-center gap-2">
{status.hasModel && (
<Button
variant="ghost"
size="sm"
onClick={() => handleViewModelDetails(status.ingredient)}
leftIcon={<Eye className="w-4 h-4" />}
>
Ver detalles
</Button>
)}
<Button
variant={status.hasModel ? "outline" : "primary"}
size="sm"
onClick={() => handleStartTraining(status.ingredient)}
leftIcon={status.hasModel ? <RotateCcw className="w-4 h-4" /> : <Play className="w-4 h-4" />}
disabled={status.isTraining}
>
{status.hasModel ? 'Reentrenar' : 'Entrenar'}
</Button>
</div>
),
},
];
if (ingredientsLoading || modelsLoading) {
return (
<div className="p-6 space-y-6">
<PageHeader
title="Configuración de Modelos IA"
description="Gestiona el entrenamiento y configuración de modelos de predicción para cada ingrediente"
/>
<div className="flex items-center justify-center h-64">
<Loader className="w-8 h-8 animate-spin" />
<span className="ml-2">
{ingredientsLoading ? 'Cargando ingredientes...' : 'Cargando modelos...'}
</span>
</div>
</div>
);
}
if (modelsError) {
console.error('Error loading models:', modelsError);
}
return (
<div className="p-6 space-y-6">
<PageHeader
title="Configuración de Modelos IA"
description="Gestiona el entrenamiento y configuración de modelos de predicción para cada ingrediente"
/>
{/* Statistics Cards */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
<Card className="p-6">
<div className="flex items-center justify-between">
<div>
<div className="text-2xl font-bold text-[var(--text-primary)]">
{modelStatuses.filter(s => s.hasModel).length}
</div>
<div className="text-sm text-[var(--text-secondary)]">Ingredientes con Modelo</div>
</div>
<Brain className="w-8 h-8 text-[var(--color-primary)]" />
</div>
</Card>
<Card className="p-6">
<div className="flex items-center justify-between">
<div>
<div className="text-2xl font-bold text-[var(--text-primary)]">
{modelStatuses.filter(s => s.status === 'no_model').length}
</div>
<div className="text-sm text-[var(--text-secondary)]">Sin Modelo</div>
</div>
<AlertCircle className="w-8 h-8 text-[var(--color-warning)]" />
</div>
</Card>
<Card className="p-6">
<div className="flex items-center justify-between">
<div>
<div className="text-2xl font-bold text-[var(--text-primary)]">
{orphanedModels.length}
</div>
<div className="text-sm text-[var(--text-secondary)]">Modelos Huérfanos</div>
</div>
<AlertCircle className="w-8 h-8 text-[var(--color-secondary)]" />
</div>
</Card>
<Card className="p-6">
<div className="flex items-center justify-between">
<div>
<div className="text-2xl font-bold text-[var(--text-primary)]">
{statsError ? 'N/A' : (statistics?.average_accuracy ? `${(100 - statistics.average_accuracy).toFixed(1)}%` : 'N/A')}
</div>
<div className="text-sm text-[var(--text-secondary)]">Precisión Promedio</div>
</div>
<TrendingUp className="w-8 h-8 text-[var(--color-primary)]" />
</div>
</Card>
</div>
{/* Orphaned Models Warning */}
{orphanedModels.length > 0 && (
<Card className="p-4 bg-orange-50 border-orange-200">
<div className="flex items-start gap-3">
<AlertCircle className="w-5 h-5 text-orange-600 mt-0.5" />
<div>
<h4 className="font-medium text-orange-900 mb-1">
Modelos Huérfanos Detectados
</h4>
<p className="text-sm text-orange-700">
Se encontraron {orphanedModels.length} modelos entrenados para ingredientes que ya no existen en el inventario.
Estos modelos pueden ser eliminados para optimizar el espacio de almacenamiento.
</p>
</div>
</div>
</Card>
)}
{/* Filters */}
<Card className="p-6">
<div className="flex flex-col sm:flex-row gap-4">
<div className="flex-1">
<Input
placeholder="Buscar ingrediente..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
<div className="w-full sm:w-48">
<Select
value={statusFilter}
onChange={(value) => setStatusFilter(value as string)}
options={[
{ value: 'all', label: 'Todos los estados' },
{ value: 'no_model', label: 'Sin modelo' },
{ value: 'active', label: 'Activo' },
{ value: 'training', label: 'Entrenando' },
{ value: 'error', label: 'Error' },
]}
/>
</div>
</div>
</Card>
{/* Models Table */}
<Card>
{filteredStatuses.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12">
<Brain className="w-12 h-12 text-[var(--color-secondary)] mb-4" />
<h3 className="text-lg font-medium text-[var(--text-primary)] mb-2">
No se encontraron ingredientes
</h3>
<p className="text-[var(--text-secondary)] text-center">
No hay ingredientes que coincidan con los filtros aplicados.
</p>
</div>
) : (
<Table
data={filteredStatuses}
columns={tableColumns}
/>
)}
</Card>
{/* Training Modal */}
<Modal
isOpen={showTrainingModal}
onClose={() => setShowTrainingModal(false)}
title={`Entrenar Modelo - ${selectedIngredient?.name}`}
size="lg"
>
<div className="space-y-6">
<div className="p-4 bg-blue-50 rounded-lg">
<h4 className="font-medium text-blue-900 mb-2">Configuración de Entrenamiento</h4>
<p className="text-sm text-blue-700">
Configure los parámetros para el entrenamiento del modelo de predicción de demanda.
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label className="block text-sm font-medium mb-2">Modo de Estacionalidad</label>
<Select
value={trainingSettings.seasonality_mode || 'additive'}
onChange={(value) => setTrainingSettings(prev => ({ ...prev, seasonality_mode: value as any }))}
options={[
{ value: 'additive', label: 'Aditivo' },
{ value: 'multiplicative', label: 'Multiplicativo' }
]}
/>
</div>
<div className="space-y-4">
<h4 className="font-medium text-sm">Patrones Estacionales</h4>
<label className="flex items-center space-x-2">
<input
type="checkbox"
checked={trainingSettings.daily_seasonality || false}
onChange={(e) => setTrainingSettings(prev => ({ ...prev, daily_seasonality: e.target.checked }))}
className="rounded border-[var(--border-primary)]"
/>
<span className="text-sm">Estacionalidad diaria</span>
</label>
<label className="flex items-center space-x-2">
<input
type="checkbox"
checked={trainingSettings.weekly_seasonality || false}
onChange={(e) => setTrainingSettings(prev => ({ ...prev, weekly_seasonality: e.target.checked }))}
className="rounded border-[var(--border-primary)]"
/>
<span className="text-sm">Estacionalidad semanal</span>
</label>
<label className="flex items-center space-x-2">
<input
type="checkbox"
checked={trainingSettings.yearly_seasonality || false}
onChange={(e) => setTrainingSettings(prev => ({ ...prev, yearly_seasonality: e.target.checked }))}
className="rounded border-[var(--border-primary)]"
/>
<span className="text-sm">Estacionalidad anual</span>
</label>
</div>
</div>
<div className="flex justify-end space-x-3 pt-4 border-t">
<Button variant="outline" onClick={() => setShowTrainingModal(false)}>
Cancelar
</Button>
<Button
onClick={handleTrainModel}
isLoading={trainMutation.isPending}
leftIcon={<Play className="w-4 h-4" />}
>
Iniciar Entrenamiento
</Button>
</div>
</div>
</Modal>
{/* Model Details Modal */}
<Modal
isOpen={showModelDetailsModal}
onClose={() => setShowModelDetailsModal(false)}
title={`Detalles del Modelo - ${selectedIngredient?.name}`}
size="lg"
>
<div className="space-y-6">
{selectedIngredient && (
<ModelDetailsContent
tenantId={tenantId}
ingredientId={selectedIngredient.id}
/>
)}
</div>
</Modal>
</div>
);
};
// Component for model details content
const ModelDetailsContent: React.FC<{
tenantId: string;
ingredientId: string;
}> = ({ tenantId, ingredientId }) => {
const { data: activeModel } = useActiveModel(tenantId, ingredientId);
if (!activeModel) {
return (
<div className="text-center py-12">
<AlertCircle className="w-16 h-16 text-[var(--color-warning)] mx-auto mb-4" />
<h3 className="text-xl font-semibold mb-2 text-[var(--text-primary)]">No hay modelo disponible</h3>
<p className="text-[var(--text-secondary)] max-w-md mx-auto">
Este ingrediente no tiene un modelo entrenado disponible. Puedes entrenar uno nuevo usando el botón "Entrenar".
</p>
</div>
);
}
const precision = activeModel.training_metrics?.mape
? (100 - activeModel.training_metrics.mape).toFixed(1)
: 'N/A';
return (
<div className="space-y-6">
{/* Model Overview */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="bg-gradient-to-br from-green-50 to-green-100 p-6 rounded-xl border border-green-200">
<div className="text-center">
<div className="text-3xl font-bold text-green-700 mb-1">
{precision}%
</div>
<div className="text-sm font-medium text-green-600">Precisión</div>
</div>
</div>
<div className="bg-gradient-to-br from-blue-50 to-blue-100 p-6 rounded-xl border border-blue-200">
<div className="text-center">
<div className="text-3xl font-bold text-blue-700 mb-1">
{activeModel.training_metrics?.mae?.toFixed(2) || 'N/A'}
</div>
<div className="text-sm font-medium text-blue-600">MAE</div>
</div>
</div>
<div className="bg-gradient-to-br from-purple-50 to-purple-100 p-6 rounded-xl border border-purple-200">
<div className="text-center">
<div className="text-3xl font-bold text-purple-700 mb-1">
{activeModel.training_metrics?.rmse?.toFixed(2) || 'N/A'}
</div>
<div className="text-sm font-medium text-purple-600">RMSE</div>
</div>
</div>
</div>
{/* Model Information */}
<Card className="p-6 bg-[var(--bg-primary)]">
<h4 className="text-lg font-semibold mb-6 text-[var(--text-primary)] flex items-center">
<Brain className="w-5 h-5 mr-2 text-[var(--color-primary)]" />
Información del Modelo
</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-4">
<div className="flex flex-col">
<span className="text-xs font-semibold text-[var(--text-tertiary)] uppercase tracking-wide mb-1">
Creado
</span>
<span className="text-sm text-[var(--text-primary)]">
{new Date(activeModel.created_at).toLocaleString('es-ES', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})}
</span>
</div>
<div className="flex flex-col">
<span className="text-xs font-semibold text-[var(--text-tertiary)] uppercase tracking-wide mb-1">
Características usadas
</span>
<span className="text-sm text-[var(--text-primary)]">
{activeModel.features_used?.length || 0} variables
</span>
</div>
</div>
<div className="space-y-4">
<div className="flex flex-col">
<span className="text-xs font-semibold text-[var(--text-tertiary)] uppercase tracking-wide mb-1">
Período de entrenamiento
</span>
<span className="text-sm text-[var(--text-primary)]">
{activeModel.training_period?.start_date && activeModel.training_period?.end_date
? `${new Date(activeModel.training_period.start_date).toLocaleDateString('es-ES')} - ${new Date(activeModel.training_period.end_date).toLocaleDateString('es-ES')}`
: 'No disponible'
}
</span>
</div>
<div className="flex flex-col">
<span className="text-xs font-semibold text-[var(--text-tertiary)] uppercase tracking-wide mb-1">
Hiperparámetros
</span>
<span className="text-sm text-[var(--text-primary)]">
{Object.keys(activeModel.hyperparameters || {}).length} configurados
</span>
</div>
</div>
</div>
</Card>
{/* Features Used */}
{activeModel.features_used && activeModel.features_used.length > 0 && (
<Card className="p-6 bg-[var(--bg-primary)]">
<h4 className="text-lg font-semibold mb-4 text-[var(--text-primary)] flex items-center">
<TrendingUp className="w-5 h-5 mr-2 text-[var(--color-primary)]" />
Características del Modelo
</h4>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3">
{activeModel.features_used.map((feature: string, index: number) => (
<div
key={index}
className="bg-[var(--bg-secondary)] border border-[var(--border-primary)] rounded-lg px-3 py-2 text-center"
>
<span className="text-sm font-medium text-[var(--text-primary)]">
{feature.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase())}
</span>
</div>
))}
</div>
</Card>
)}
{/* Training Performance */}
{activeModel.training_metrics && (
<Card className="p-6 bg-[var(--bg-primary)]">
<h4 className="text-lg font-semibold mb-4 text-[var(--text-primary)] flex items-center">
<CheckCircle className="w-5 h-5 mr-2 text-[var(--color-success)]" />
Métricas de Rendimiento
</h4>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="text-center p-4 bg-[var(--bg-secondary)] rounded-lg border border-[var(--border-primary)]">
<div className="text-2xl font-bold text-[var(--color-success)] mb-1">
{precision}%
</div>
<div className="text-xs text-[var(--text-tertiary)] uppercase tracking-wide">Precisión</div>
</div>
<div className="text-center p-4 bg-[var(--bg-secondary)] rounded-lg border border-[var(--border-primary)]">
<div className="text-2xl font-bold text-[var(--text-primary)] mb-1">
{activeModel.training_metrics.mae?.toFixed(2) || 'N/A'}
</div>
<div className="text-xs text-[var(--text-tertiary)] uppercase tracking-wide">MAE</div>
</div>
<div className="text-center p-4 bg-[var(--bg-secondary)] rounded-lg border border-[var(--border-primary)]">
<div className="text-2xl font-bold text-[var(--text-primary)] mb-1">
{activeModel.training_metrics.rmse?.toFixed(2) || 'N/A'}
</div>
<div className="text-xs text-[var(--text-tertiary)] uppercase tracking-wide">RMSE</div>
</div>
<div className="text-center p-4 bg-[var(--bg-secondary)] rounded-lg border border-[var(--border-primary)]">
<div className="text-2xl font-bold text-[var(--text-primary)] mb-1">
{activeModel.training_metrics.r2_score?.toFixed(3) || 'N/A'}
</div>
<div className="text-xs text-[var(--text-tertiary)] uppercase tracking-wide">R²</div>
</div>
</div>
</Card>
)}
</div>
);
};
export default ModelsConfigPage;

View File

@@ -0,0 +1 @@
export { default as ModelsConfigPage } from './ModelsConfigPage';

View File

@@ -8,6 +8,7 @@ import {
useProcurementPlans, useProcurementPlans,
useCurrentProcurementPlan, useCurrentProcurementPlan,
useCriticalRequirements, useCriticalRequirements,
usePlanRequirements,
useGenerateProcurementPlan, useGenerateProcurementPlan,
useUpdateProcurementPlanStatus, useUpdateProcurementPlanStatus,
useTriggerDailyScheduler useTriggerDailyScheduler
@@ -22,6 +23,8 @@ const ProcurementPage: React.FC = () => {
const [selectedPlan, setSelectedPlan] = useState<any>(null); const [selectedPlan, setSelectedPlan] = useState<any>(null);
const [editingPlan, setEditingPlan] = useState<any>(null); const [editingPlan, setEditingPlan] = useState<any>(null);
const [editFormData, setEditFormData] = useState<any>({}); const [editFormData, setEditFormData] = useState<any>({});
const [selectedPlanForRequirements, setSelectedPlanForRequirements] = useState<string | null>(null);
const [showCriticalRequirements, setShowCriticalRequirements] = useState(false);
const { currentTenant } = useTenantStore(); const { currentTenant } = useTenantStore();
const tenantId = currentTenant?.id || ''; const tenantId = currentTenant?.id || '';
@@ -36,6 +39,15 @@ const ProcurementPage: React.FC = () => {
const { data: currentPlan, isLoading: isCurrentPlanLoading } = useCurrentProcurementPlan(tenantId); const { data: currentPlan, isLoading: isCurrentPlanLoading } = useCurrentProcurementPlan(tenantId);
const { data: criticalRequirements, isLoading: isCriticalLoading } = useCriticalRequirements(tenantId); const { data: criticalRequirements, isLoading: isCriticalLoading } = useCriticalRequirements(tenantId);
// Get plan requirements for selected plan
const { data: planRequirements, isLoading: isPlanRequirementsLoading } = usePlanRequirements({
tenant_id: tenantId,
plan_id: selectedPlanForRequirements || '',
status: 'critical' // Only get critical requirements
}, {
enabled: !!selectedPlanForRequirements && !!tenantId
});
const generatePlanMutation = useGenerateProcurementPlan(); const generatePlanMutation = useGenerateProcurementPlan();
const updatePlanStatusMutation = useUpdateProcurementPlanStatus(); const updatePlanStatusMutation = useUpdateProcurementPlanStatus();
const triggerSchedulerMutation = useTriggerDailyScheduler(); const triggerSchedulerMutation = useTriggerDailyScheduler();
@@ -107,6 +119,16 @@ const ProcurementPage: React.FC = () => {
setEditFormData({}); setEditFormData({});
}; };
const handleShowCriticalRequirements = (planId: string) => {
setSelectedPlanForRequirements(planId);
setShowCriticalRequirements(true);
};
const handleCloseCriticalRequirements = () => {
setShowCriticalRequirements(false);
setSelectedPlanForRequirements(null);
};
if (!tenantId) { if (!tenantId) {
return ( return (
<div className="flex justify-center items-center h-64"> <div className="flex justify-center items-center h-64">
@@ -391,6 +413,15 @@ const ProcurementPage: React.FC = () => {
} }
}); });
// Show Critical Requirements button
actions.push({
label: 'Req. Críticos',
icon: AlertCircle,
variant: 'outline' as const,
priority: 'secondary' as const,
onClick: () => handleShowCriticalRequirements(plan.id)
});
// Tertiary action: Cancel (least prominent, destructive) // Tertiary action: Cancel (least prominent, destructive)
if (!['completed', 'cancelled'].includes(plan.status)) { if (!['completed', 'cancelled'].includes(plan.status)) {
actions.push({ actions.push({

View File

@@ -33,6 +33,7 @@ const TeamPage = React.lazy(() => import('../pages/app/settings/team/TeamPage'))
// Database pages // Database pages
const DatabasePage = React.lazy(() => import('../pages/app/database/DatabasePage')); const DatabasePage = React.lazy(() => import('../pages/app/database/DatabasePage'));
const ModelsConfigPage = React.lazy(() => import('../pages/app/database/models/ModelsConfigPage'));
// Data pages // Data pages
const WeatherPage = React.lazy(() => import('../pages/app/data/weather/WeatherPage')); const WeatherPage = React.lazy(() => import('../pages/app/data/weather/WeatherPage'));
@@ -176,6 +177,16 @@ export const AppRouter: React.FC = () => {
</ProtectedRoute> </ProtectedRoute>
} }
/> />
<Route
path="/app/database/models"
element={
<ProtectedRoute>
<AppShell>
<ModelsConfigPage />
</AppShell>
</ProtectedRoute>
}
/>
{/* Analytics Routes */} {/* Analytics Routes */}
<Route <Route

View File

@@ -330,6 +330,16 @@ export const routesConfig: RouteConfig[] = [
showInNavigation: true, showInNavigation: true,
showInBreadcrumbs: true, showInBreadcrumbs: true,
}, },
{
path: '/app/database/models',
name: 'ModelsConfig',
component: 'ModelsConfigPage',
title: 'Modelos IA',
icon: 'training',
requiresAuth: true,
showInNavigation: true,
showInBreadcrumbs: true,
},
], ],
}, },

View File

@@ -113,6 +113,12 @@ async def proxy_tenant_models(request: Request, tenant_id: str = Path(...), path
target_path = f"/api/v1/tenants/{tenant_id}/models/{path}".rstrip("/") target_path = f"/api/v1/tenants/{tenant_id}/models/{path}".rstrip("/")
return await _proxy_to_training_service(request, target_path, tenant_id=tenant_id) return await _proxy_to_training_service(request, target_path, tenant_id=tenant_id)
@router.api_route("/{tenant_id}/statistics", methods=["GET", "OPTIONS"])
async def proxy_tenant_statistics(request: Request, tenant_id: str = Path(...)):
"""Proxy tenant statistics requests to training service"""
target_path = f"/api/v1/tenants/{tenant_id}/statistics"
return await _proxy_to_training_service(request, target_path, tenant_id=tenant_id)
# ================================================================ # ================================================================
# TENANT-SCOPED FORECASTING SERVICE ENDPOINTS # TENANT-SCOPED FORECASTING SERVICE ENDPOINTS
# ================================================================ # ================================================================

View File

@@ -12,7 +12,7 @@ import uuid
from app.services.forecasting_service import EnhancedForecastingService from app.services.forecasting_service import EnhancedForecastingService
from app.schemas.forecasts import ( from app.schemas.forecasts import (
ForecastRequest, ForecastResponse, BatchForecastRequest, ForecastRequest, ForecastResponse, BatchForecastRequest,
BatchForecastResponse BatchForecastResponse, MultiDayForecastResponse
) )
from shared.auth.decorators import ( from shared.auth.decorators import (
get_current_user_dep, get_current_user_dep,
@@ -89,6 +89,70 @@ async def create_enhanced_single_forecast(
) )
@router.post("/tenants/{tenant_id}/forecasts/multi-day", response_model=MultiDayForecastResponse)
@track_execution_time("enhanced_multi_day_forecast_duration_seconds", "forecasting-service")
async def create_enhanced_multi_day_forecast(
request: ForecastRequest,
tenant_id: str = Path(..., description="Tenant ID"),
request_obj: Request = None,
enhanced_forecasting_service: EnhancedForecastingService = Depends(get_enhanced_forecasting_service)
):
"""Generate multiple daily forecasts for the specified period using enhanced repository pattern"""
metrics = get_metrics_collector(request_obj)
try:
logger.info("Generating enhanced multi-day forecast",
tenant_id=tenant_id,
inventory_product_id=request.inventory_product_id,
forecast_days=request.forecast_days,
forecast_date=request.forecast_date.isoformat())
# Record metrics
if metrics:
metrics.increment_counter("enhanced_multi_day_forecasts_total")
# Validate forecast_days parameter
if request.forecast_days <= 0 or request.forecast_days > 30:
raise ValueError("forecast_days must be between 1 and 30")
# Generate multi-day forecast using enhanced service
forecast_result = await enhanced_forecasting_service.generate_multi_day_forecast(
tenant_id=tenant_id,
request=request
)
if metrics:
metrics.increment_counter("enhanced_multi_day_forecasts_success_total")
logger.info("Enhanced multi-day forecast generated successfully",
tenant_id=tenant_id,
inventory_product_id=request.inventory_product_id,
forecast_days=len(forecast_result.get("forecasts", [])))
return MultiDayForecastResponse(**forecast_result)
except ValueError as e:
if metrics:
metrics.increment_counter("enhanced_multi_day_forecast_validation_errors_total")
logger.error("Enhanced multi-day forecast validation error",
error=str(e),
tenant_id=tenant_id)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e)
)
except Exception as e:
if metrics:
metrics.increment_counter("enhanced_multi_day_forecasts_errors_total")
logger.error("Enhanced multi-day forecast generation failed",
error=str(e),
tenant_id=tenant_id)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Enhanced multi-day forecast generation failed"
)
@router.post("/tenants/{tenant_id}/forecasts/batch", response_model=BatchForecastResponse) @router.post("/tenants/{tenant_id}/forecasts/batch", response_model=BatchForecastResponse)
@track_execution_time("enhanced_batch_forecast_duration_seconds", "forecasting-service") @track_execution_time("enhanced_batch_forecast_duration_seconds", "forecasting-service")
async def create_enhanced_batch_forecast( async def create_enhanced_batch_forecast(

View File

@@ -95,4 +95,15 @@ class BatchForecastResponse(BaseModel):
forecasts: Optional[List[ForecastResponse]] forecasts: Optional[List[ForecastResponse]]
error_message: Optional[str] error_message: Optional[str]
class MultiDayForecastResponse(BaseModel):
"""Response schema for multi-day forecast results"""
tenant_id: str = Field(..., description="Tenant ID")
inventory_product_id: str = Field(..., description="Inventory product ID")
forecast_start_date: date = Field(..., description="Start date of forecast period")
forecast_days: int = Field(..., description="Number of forecasted days")
forecasts: List[ForecastResponse] = Field(..., description="Daily forecasts")
total_predicted_demand: float = Field(..., description="Total demand across all days")
average_confidence_level: float = Field(..., description="Average confidence across all days")
processing_time_ms: int = Field(..., description="Total processing time")

View File

@@ -346,6 +346,100 @@ class EnhancedForecastingService:
processing_time=processing_time) processing_time=processing_time)
raise raise
async def generate_multi_day_forecast(
self,
tenant_id: str,
request: ForecastRequest
) -> Dict[str, Any]:
"""
Generate multiple daily forecasts for the specified period.
"""
start_time = datetime.utcnow()
forecasts = []
try:
logger.info("Generating multi-day forecast",
tenant_id=tenant_id,
inventory_product_id=request.inventory_product_id,
forecast_days=request.forecast_days,
start_date=request.forecast_date.isoformat())
# Generate a forecast for each day
for day_offset in range(request.forecast_days):
# Calculate the forecast date for this day
current_date = request.forecast_date
if isinstance(current_date, str):
from dateutil.parser import parse
current_date = parse(current_date).date()
if day_offset > 0:
from datetime import timedelta
current_date = current_date + timedelta(days=day_offset)
# Create a new request for this specific day
daily_request = ForecastRequest(
inventory_product_id=request.inventory_product_id,
forecast_date=current_date,
forecast_days=1, # Single day for each iteration
location=request.location,
confidence_level=request.confidence_level
)
# Generate forecast for this day
daily_forecast = await self.generate_forecast(tenant_id, daily_request)
forecasts.append(daily_forecast)
# Calculate summary statistics
total_demand = sum(f.predicted_demand for f in forecasts)
avg_confidence = sum(f.confidence_level for f in forecasts) / len(forecasts)
processing_time = int((datetime.utcnow() - start_time).total_seconds() * 1000)
# Convert forecasts to dictionary format for the response
forecast_dicts = []
for forecast in forecasts:
forecast_dicts.append({
"id": forecast.id,
"tenant_id": forecast.tenant_id,
"inventory_product_id": forecast.inventory_product_id,
"location": forecast.location,
"forecast_date": forecast.forecast_date.isoformat() if hasattr(forecast.forecast_date, 'isoformat') else str(forecast.forecast_date),
"predicted_demand": forecast.predicted_demand,
"confidence_lower": forecast.confidence_lower,
"confidence_upper": forecast.confidence_upper,
"confidence_level": forecast.confidence_level,
"model_id": forecast.model_id,
"model_version": forecast.model_version,
"algorithm": forecast.algorithm,
"business_type": forecast.business_type,
"is_holiday": forecast.is_holiday,
"is_weekend": forecast.is_weekend,
"day_of_week": forecast.day_of_week,
"weather_temperature": forecast.weather_temperature,
"weather_precipitation": forecast.weather_precipitation,
"weather_description": forecast.weather_description,
"traffic_volume": forecast.traffic_volume,
"created_at": forecast.created_at.isoformat() if hasattr(forecast.created_at, 'isoformat') else str(forecast.created_at),
"processing_time_ms": forecast.processing_time_ms,
"features_used": forecast.features_used
})
return {
"tenant_id": tenant_id,
"inventory_product_id": request.inventory_product_id,
"forecast_start_date": request.forecast_date.isoformat() if hasattr(request.forecast_date, 'isoformat') else str(request.forecast_date),
"forecast_days": request.forecast_days,
"forecasts": forecast_dicts,
"total_predicted_demand": total_demand,
"average_confidence_level": avg_confidence,
"processing_time_ms": processing_time
}
except Exception as e:
logger.error("Multi-day forecast generation failed",
tenant_id=tenant_id,
error=str(e))
raise
async def get_forecast_history( async def get_forecast_history(
self, self,
tenant_id: str, tenant_id: str,