2025-09-20 22:11:05 +02:00
|
|
|
import React, { useState, useMemo } from 'react';
|
|
|
|
|
import { Brain, TrendingUp, AlertCircle, Play, RotateCcw, Eye, Loader, CheckCircle } from 'lucide-react';
|
2025-09-20 23:30:54 +02:00
|
|
|
import { Button, Card, Badge, Modal, Table, Select, Input, StatsGrid, StatusCard } from '../../../../components/ui';
|
2025-09-20 22:11:05 +02:00
|
|
|
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';
|
2025-09-20 23:30:54 +02:00
|
|
|
import { ModelDetailsModal } from '../../../../components/domain/forecasting';
|
2025-09-20 22:11:05 +02:00
|
|
|
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);
|
2025-09-20 23:30:54 +02:00
|
|
|
const [selectedModel, setSelectedModel] = useState<TrainedModelResponse | null>(null);
|
2025-09-20 22:11:05 +02:00
|
|
|
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' });
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2025-09-20 23:30:54 +02:00
|
|
|
const handleViewModelDetails = async (ingredient: IngredientResponse) => {
|
2025-09-20 22:11:05 +02:00
|
|
|
setSelectedIngredient(ingredient);
|
2025-09-20 23:30:54 +02:00
|
|
|
|
|
|
|
|
// Find the model for this ingredient
|
|
|
|
|
const model = modelStatuses.find(status => status.ingredient.id === ingredient.id)?.model;
|
|
|
|
|
if (model) {
|
|
|
|
|
setSelectedModel(model);
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-20 22:11:05 +02:00
|
|
|
setShowModelDetailsModal(true);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleStartTraining = (ingredient: IngredientResponse) => {
|
|
|
|
|
setSelectedIngredient(ingredient);
|
|
|
|
|
setShowTrainingModal(true);
|
|
|
|
|
};
|
|
|
|
|
|
2025-09-20 23:30:54 +02:00
|
|
|
|
2025-09-20 22:11:05 +02:00
|
|
|
|
2025-09-20 23:30:54 +02:00
|
|
|
|
2025-09-20 22:11:05 +02:00
|
|
|
|
|
|
|
|
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 */}
|
2025-09-20 23:30:54 +02:00
|
|
|
<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 Huérfanos',
|
|
|
|
|
value: orphanedModels.length,
|
|
|
|
|
icon: AlertCircle,
|
|
|
|
|
variant: 'info',
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
title: 'Precisión Promedio',
|
|
|
|
|
value: statsError ? 'N/A' : (statistics?.average_accuracy ? `${(100 - statistics.average_accuracy).toFixed(1)}%` : 'N/A'),
|
|
|
|
|
icon: TrendingUp,
|
|
|
|
|
variant: 'success',
|
|
|
|
|
},
|
|
|
|
|
]}
|
|
|
|
|
columns={4}
|
|
|
|
|
/>
|
2025-09-20 22:11:05 +02:00
|
|
|
|
|
|
|
|
{/* 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>
|
|
|
|
|
|
2025-09-20 23:30:54 +02:00
|
|
|
{/* Models Grid */}
|
|
|
|
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
2025-09-20 22:11:05 +02:00
|
|
|
{filteredStatuses.length === 0 ? (
|
2025-09-20 23:30:54 +02:00
|
|
|
<div className="flex flex-col items-center justify-center py-12 col-span-full">
|
2025-09-20 22:11:05 +02:00
|
|
|
<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>
|
|
|
|
|
) : (
|
2025-09-20 23:30:54 +02:00
|
|
|
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}
|
|
|
|
|
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}
|
|
|
|
|
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: () => handleStartTraining(status.ingredient),
|
|
|
|
|
priority: 'secondary' as const
|
|
|
|
|
}] : [])
|
|
|
|
|
]}
|
|
|
|
|
/>
|
|
|
|
|
);
|
|
|
|
|
})
|
2025-09-20 22:11:05 +02:00
|
|
|
)}
|
2025-09-20 23:30:54 +02:00
|
|
|
</div>
|
2025-09-20 22:11:05 +02:00
|
|
|
|
|
|
|
|
{/* 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 */}
|
2025-09-20 23:30:54 +02:00
|
|
|
{selectedModel && (
|
|
|
|
|
<ModelDetailsModal
|
|
|
|
|
isOpen={showModelDetailsModal}
|
|
|
|
|
onClose={() => setShowModelDetailsModal(false)}
|
|
|
|
|
model={selectedModel}
|
|
|
|
|
/>
|
2025-09-20 22:11:05 +02:00
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export default ModelsConfigPage;
|