Imporve the AI models page
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
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 { Button, Card, Badge, Modal, Table, Select, Input, StatsGrid, StatusCard } from '../../../../components/ui';
|
||||
import { PageHeader } from '../../../../components/layout';
|
||||
import { useToast } from '../../../../hooks/ui/useToast';
|
||||
import { useCurrentTenant } from '../../../../stores/tenant.store';
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
useModelPerformance,
|
||||
useTenantTrainingStatistics
|
||||
} from '../../../../api/hooks/training';
|
||||
import { ModelDetailsModal } from '../../../../components/domain/forecasting';
|
||||
import type { IngredientResponse } from '../../../../api/types/inventory';
|
||||
import type { TrainedModelResponse, SingleProductTrainingRequest } from '../../../../api/types/training';
|
||||
|
||||
@@ -44,6 +45,7 @@ const ModelsConfigPage: React.FC = () => {
|
||||
const tenantId = currentTenant?.id || '';
|
||||
|
||||
const [selectedIngredient, setSelectedIngredient] = useState<IngredientResponse | null>(null);
|
||||
const [selectedModel, setSelectedModel] = useState<TrainedModelResponse | null>(null);
|
||||
const [showTrainingModal, setShowTrainingModal] = useState(false);
|
||||
const [showModelDetailsModal, setShowModelDetailsModal] = useState(false);
|
||||
const [trainingSettings, setTrainingSettings] = useState<Partial<SingleProductTrainingRequest>>({
|
||||
@@ -161,8 +163,15 @@ const ModelsConfigPage: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleViewModelDetails = (ingredient: IngredientResponse) => {
|
||||
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);
|
||||
};
|
||||
|
||||
@@ -171,95 +180,9 @@ const ModelsConfigPage: React.FC = () => {
|
||||
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 (
|
||||
@@ -291,55 +214,35 @@ const ModelsConfigPage: React.FC = () => {
|
||||
|
||||
|
||||
{/* 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>
|
||||
<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}
|
||||
/>
|
||||
|
||||
{/* Orphaned Models Warning */}
|
||||
{orphanedModels.length > 0 && (
|
||||
@@ -385,10 +288,10 @@ const ModelsConfigPage: React.FC = () => {
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Models Table */}
|
||||
<Card>
|
||||
{/* Models Grid */}
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{filteredStatuses.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12">
|
||||
<div className="flex flex-col items-center justify-center py-12 col-span-full">
|
||||
<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
|
||||
@@ -398,12 +301,70 @@ const ModelsConfigPage: React.FC = () => {
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<Table
|
||||
data={filteredStatuses}
|
||||
columns={tableColumns}
|
||||
/>
|
||||
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
|
||||
}] : [])
|
||||
]}
|
||||
/>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Training Modal */}
|
||||
<Modal
|
||||
@@ -484,194 +445,12 @@ const ModelsConfigPage: React.FC = () => {
|
||||
</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>
|
||||
{selectedModel && (
|
||||
<ModelDetailsModal
|
||||
isOpen={showModelDetailsModal}
|
||||
onClose={() => setShowModelDetailsModal(false)}
|
||||
model={selectedModel}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user