Files
bakery-ia/frontend/src/pages/app/database/models/ModelsConfigPage.tsx
2025-10-29 06:58:05 +01:00

505 lines
18 KiB
TypeScript

import React, { useState, useMemo } from 'react';
import { Brain, TrendingUp, AlertCircle, Play, RotateCcw, Eye, Loader, CheckCircle } from 'lucide-react';
import { Button, Badge, Modal, Table, Select, StatsGrid, StatusCard, SearchAndFilter, type FilterConfig, Card, EmptyState } 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 { ModelDetailsModal, RetrainModelModal } from '../../../../components/domain/forecasting';
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 [selectedModel, setSelectedModel] = useState<TrainedModelResponse | null>(null);
const [showTrainingModal, setShowTrainingModal] = useState(false);
const [showRetrainModal, setShowRetrainModal] = 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 || undefined,
accuracy: model ?
(model.training_metrics?.mape !== undefined ? (100 - model.training_metrics.mape) :
(model as any).mape !== undefined ? (100 - (model as any).mape) :
undefined) : 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 = async (ingredient: IngredientResponse) => {
setSelectedIngredient(ingredient);
// Find the model for this ingredient
const model = modelStatuses.find(status => status.ingredient.id === ingredient.id)?.model;
if (model) {
setSelectedModel(model);
}
setShowModelDetailsModal(true);
};
const handleStartTraining = (ingredient: IngredientResponse) => {
setSelectedIngredient(ingredient);
setShowTrainingModal(true);
};
const handleStartRetraining = (ingredient: IngredientResponse) => {
setSelectedIngredient(ingredient);
// Find and set the model for this ingredient
const model = modelStatuses.find(status => status.ingredient.id === ingredient.id)?.model;
if (model) {
setSelectedModel(model);
}
setShowRetrainModal(true);
};
const handleRetrain = async (settings: SingleProductTrainingRequest) => {
if (!selectedIngredient) return;
try {
await trainMutation.mutateAsync({
tenantId,
inventoryProductId: selectedIngredient.id,
request: settings
});
addToast(`Reentrenamiento iniciado para ${selectedIngredient.name}`, { type: 'success' });
setShowRetrainModal(false);
setSelectedIngredient(null);
setSelectedModel(null);
} catch (error) {
addToast('Error al reentrenar el modelo', { type: 'error' });
}
};
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="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 */}
<StatsGrid
stats={[
{
title: 'Ingredientes con Modelo',
value: modelStatuses.filter(s => s.hasModel).length,
icon: Brain,
variant: 'default',
},
{
title: 'Sin Modelo',
value: modelStatuses.filter(s => s.status === 'no_model').length,
icon: AlertCircle,
variant: 'warning',
},
{
title: 'Modelos Activos',
value: modelStatuses.filter(s => s.status === 'active').length,
icon: CheckCircle,
variant: 'success',
},
{
title: 'Precisión Promedio',
value: statsError ? 'N/A' : (statistics?.models?.average_accuracy !== undefined && statistics?.models?.average_accuracy !== null ? `${Number(statistics.models.average_accuracy).toFixed(1)}%` : 'N/A'),
icon: TrendingUp,
variant: 'success',
},
{
title: 'Total Modelos',
value: modelStatuses.length,
icon: Brain,
variant: 'info',
},
{
title: 'Modelos Huérfanos',
value: orphanedModels.length,
icon: AlertCircle,
variant: 'error',
},
]}
columns={3}
/>
{/* Search and Filter Controls */}
<SearchAndFilter
searchValue={searchTerm}
onSearchChange={setSearchTerm}
searchPlaceholder="Buscar ingrediente..."
filters={[
{
key: 'status',
label: 'Estado',
type: 'dropdown',
value: statusFilter,
onChange: (value) => setStatusFilter(value as string),
placeholder: 'Todos los estados',
options: [
{ value: 'no_model', label: 'Sin modelo' },
{ value: 'active', label: 'Activo' },
{ value: 'training', label: 'Entrenando' },
{ value: 'error', label: 'Error' }
]
}
] as FilterConfig[]}
/>
{/* Models Grid */}
{filteredStatuses.length === 0 ? (
<EmptyState
icon={Brain}
title="No se encontraron ingredientes"
description="No hay ingredientes que coincidan con los filtros aplicados."
className="col-span-full"
/>
) : (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{(
filteredStatuses.map((status) => {
// Get status configuration for the StatusCard
const statusConfig = {
color: status.status === 'active'
? '#10B981' // green for active
: status.status === 'no_model'
? '#6B7280' // gray for no model
: status.status === 'training'
? '#F59E0B' // amber for training
: '#EF4444', // red for error
text: status.status === 'active'
? 'Activo'
: status.status === 'no_model'
? 'Sin Modelo'
: status.status === 'training'
? 'Entrenando'
: 'Error',
icon: status.status === 'active'
? CheckCircle
: status.status === 'no_model'
? AlertCircle
: status.status === 'training'
? Loader
: AlertCircle,
isCritical: status.status === 'error',
isHighlight: status.status === 'training'
};
return (
<StatusCard
key={status.ingredient.id}
id={status.ingredient.id}
statusIndicator={statusConfig}
title={status.ingredient.name}
subtitle={status.ingredient.category || undefined}
primaryValue={status.accuracy ? status.accuracy.toFixed(1) : 'N/A'}
primaryValueLabel="Precisión"
secondaryInfo={status.lastTrainingDate ? {
label: 'Último entrenamiento',
value: new Date(status.lastTrainingDate).toLocaleDateString('es-ES')
} : undefined}
onClick={() => {
if (status.hasModel) {
handleViewModelDetails(status.ingredient);
} else {
handleStartTraining(status.ingredient);
}
}}
actions={[
// Primary action - View details or train model
{
label: status.hasModel ? 'Ver Detalles' : 'Entrenar',
icon: status.hasModel ? Eye : Play,
onClick: () => status.hasModel
? handleViewModelDetails(status.ingredient)
: handleStartTraining(status.ingredient),
priority: 'primary' as const
},
// Secondary action - Retrain if model exists
...(status.hasModel ? [{
label: 'Reentrenar',
icon: RotateCcw,
onClick: () => handleStartRetraining(status.ingredient),
priority: 'secondary' as const
}] : [])
]}
/>
);
})
)}
</div>
)}
{/* 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 */}
{selectedModel && (
<ModelDetailsModal
isOpen={showModelDetailsModal}
onClose={() => setShowModelDetailsModal(false)}
model={selectedModel}
/>
)}
{/* Retrain Model Modal */}
{selectedIngredient && (
<RetrainModelModal
isOpen={showRetrainModal}
onClose={() => {
setShowRetrainModal(false);
setSelectedIngredient(null);
setSelectedModel(null);
}}
ingredient={selectedIngredient}
currentModel={selectedModel}
onRetrain={handleRetrain}
isLoading={trainMutation.isPending}
/>
)}
</div>
);
};
export default ModelsConfigPage;