Imporve the AI models page

This commit is contained in:
Urtzi Alfaro
2025-09-20 23:30:54 +02:00
parent 38d314e28d
commit 67b8fcdf9f
6 changed files with 685 additions and 571 deletions

View File

@@ -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>
);

View File

@@ -1,13 +1,11 @@
import React, { useState } from 'react';
import { Plus, Search, Download, ShoppingCart, Truck, DollarSign, Calendar, Clock, CheckCircle, AlertCircle, Package, Eye, Loader, Edit, ArrowRight, Play, Pause, X, Save, Building2 } from 'lucide-react';
import { Plus, Download, ShoppingCart, Truck, DollarSign, Calendar, Clock, CheckCircle, AlertCircle, Package, Eye, Loader, Edit, ArrowRight, X, Save, Building2, Play } from 'lucide-react';
import { Button, Input, Card, StatsGrid, StatusCard, getStatusColor, StatusModal } from '../../../../components/ui';
import { formatters } from '../../../../components/ui/Stats/StatsPresets';
import { PageHeader } from '../../../../components/layout';
import {
useProcurementDashboard,
useProcurementPlans,
useCurrentProcurementPlan,
useCriticalRequirements,
usePlanRequirements,
useGenerateProcurementPlan,
useUpdateProcurementPlanStatus,
@@ -16,7 +14,6 @@ import {
import { useTenantStore } from '../../../../stores/tenant.store';
const ProcurementPage: React.FC = () => {
const [activeTab, setActiveTab] = useState('plans');
const [searchTerm, setSearchTerm] = useState('');
const [showForm, setShowForm] = useState(false);
const [modalMode, setModalMode] = useState<'view' | 'edit'>('view');
@@ -36,17 +33,35 @@ const ProcurementPage: React.FC = () => {
limit: 50,
offset: 0
});
const { data: currentPlan, isLoading: isCurrentPlanLoading } = useCurrentProcurementPlan(tenantId);
const { data: criticalRequirements, isLoading: isCriticalLoading } = useCriticalRequirements(tenantId);
// Get plan requirements for selected plan
const { data: planRequirements, isLoading: isPlanRequirementsLoading } = usePlanRequirements({
const { data: allPlanRequirements, isLoading: isPlanRequirementsLoading } = usePlanRequirements({
tenant_id: tenantId,
plan_id: selectedPlanForRequirements || '',
status: 'critical' // Only get critical requirements
plan_id: selectedPlanForRequirements || ''
// Remove status filter to get all requirements
}, {
enabled: !!selectedPlanForRequirements && !!tenantId
});
// Filter critical requirements client-side
const planRequirements = allPlanRequirements?.filter(req => {
// Check various conditions that might make a requirement critical
const isLowStock = req.current_stock_level && req.required_quantity &&
(req.current_stock_level / req.required_quantity) < 0.5;
const isNearDeadline = req.required_by_date &&
(new Date(req.required_by_date).getTime() - Date.now()) / (1000 * 60 * 60 * 24) < 7;
const hasHighPriority = req.priority === 'high';
return isLowStock || isNearDeadline || hasHighPriority;
});
// Debug logging
console.log('📊 Plan Requirements Debug:', {
selectedPlanId: selectedPlanForRequirements,
allRequirements: allPlanRequirements?.length || 0,
criticalRequirements: planRequirements?.length || 0,
sampleRequirement: allPlanRequirements?.[0]
});
const generatePlanMutation = useGenerateProcurementPlan();
const updatePlanStatusMutation = useUpdateProcurementPlanStatus();
@@ -120,6 +135,7 @@ const ProcurementPage: React.FC = () => {
};
const handleShowCriticalRequirements = (planId: string) => {
console.log('🔍 Opening critical requirements for plan:', planId);
setSelectedPlanForRequirements(planId);
setShowCriticalRequirements(true);
};
@@ -287,70 +303,32 @@ const ProcurementPage: React.FC = () => {
/>
)}
{/* Tabs Navigation */}
<div className="border-b border-[var(--border-primary)]">
<nav className="-mb-px flex space-x-8">
<button
onClick={() => setActiveTab('plans')}
className={`py-2 px-1 border-b-2 font-medium text-sm ${
activeTab === 'plans'
? 'border-orange-500 text-[var(--color-primary)]'
: 'border-transparent text-[var(--text-tertiary)] hover:text-[var(--text-secondary)] hover:border-[var(--border-secondary)]'
}`}
>
Planes de Compra
</button>
<button
onClick={() => setActiveTab('requirements')}
className={`py-2 px-1 border-b-2 font-medium text-sm ${
activeTab === 'requirements'
? 'border-orange-500 text-[var(--color-primary)]'
: 'border-transparent text-[var(--text-tertiary)] hover:text-[var(--text-secondary)] hover:border-[var(--border-secondary)]'
}`}
>
Requerimientos Críticos
</button>
<button
onClick={() => setActiveTab('analytics')}
className={`py-2 px-1 border-b-2 font-medium text-sm ${
activeTab === 'analytics'
? 'border-orange-500 text-[var(--color-primary)]'
: 'border-transparent text-[var(--text-tertiary)] hover:text-[var(--text-secondary)] hover:border-[var(--border-secondary)]'
}`}
>
Análisis
</button>
</nav>
</div>
{activeTab === 'plans' && (
<Card className="p-4">
<div className="flex flex-col sm:flex-row gap-4">
<div className="flex-1">
<Input
placeholder="Buscar planes por número, estado o notas..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full"
/>
</div>
<Button variant="outline" onClick={() => console.log('Export filtered')}>
<Download className="w-4 h-4 mr-2" />
Exportar
</Button>
<Card className="p-4">
<div className="flex flex-col sm:flex-row gap-4">
<div className="flex-1">
<Input
placeholder="Buscar planes por número, estado o notas..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full"
/>
</div>
</Card>
)}
<Button variant="outline" onClick={() => console.log('Export filtered')}>
<Download className="w-4 h-4 mr-2" />
Exportar
</Button>
</div>
</Card>
{/* Procurement Plans Grid - Mobile-Optimized */}
{activeTab === 'plans' && (
<div className="grid gap-4 sm:grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-3">
{isPlansLoading ? (
<div className="col-span-full flex justify-center items-center h-32">
<Loader className="w-8 h-8 animate-spin text-[var(--color-primary)]" />
</div>
) : (
filteredPlans.map((plan) => {
<div className="grid gap-4 sm:grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-3">
{isPlansLoading ? (
<div className="col-span-full flex justify-center items-center h-32">
<Loader className="w-8 h-8 animate-spin text-[var(--color-primary)]" />
</div>
) : (
filteredPlans.map((plan) => {
const statusConfig = getPlanStatusConfig(plan.status);
const nextStageConfig = getStageActionConfig(plan.status);
const isEditing = editingPlan?.id === plan.id;
@@ -522,12 +500,12 @@ const ProcurementPage: React.FC = () => {
</div>
);
})
)}
</div>
)}
)
}
</div>
{/* Empty State for Procurement Plans */}
{activeTab === 'plans' && !isPlansLoading && filteredPlans.length === 0 && (
{!isPlansLoading && filteredPlans.length === 0 && (
<div className="text-center py-12">
<Package className="mx-auto h-12 w-12 text-[var(--text-tertiary)] mb-4" />
<h3 className="text-lg font-medium text-[var(--text-primary)] mb-2">
@@ -558,152 +536,123 @@ const ProcurementPage: React.FC = () => {
</div>
)}
{activeTab === 'requirements' && (
<div className="space-y-4">
{isCriticalLoading ? (
<div className="flex justify-center items-center h-32">
<Loader className="w-8 h-8 animate-spin text-[var(--color-primary)]" />
</div>
) : criticalRequirements && criticalRequirements.length > 0 ? (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{criticalRequirements.map((requirement) => (
<StatusCard
key={requirement.id}
id={requirement.requirement_number}
statusIndicator={{
color: getStatusColor('danger'),
text: 'Crítico',
icon: AlertCircle,
isCritical: true
}}
title={requirement.product_name}
subtitle={`${requirement.requirement_number}${requirement.supplier_name || 'Sin proveedor'}`}
primaryValue={requirement.required_quantity}
primaryValueLabel={requirement.unit_of_measure}
secondaryInfo={{
label: 'Límite',
value: new Date(requirement.required_by_date).toLocaleDateString('es-ES', { day: '2-digit', month: '2-digit' })
}}
progress={requirement.current_stock_level && requirement.required_quantity ? {
label: `${Math.round((requirement.current_stock_level / requirement.required_quantity) * 100)}% cubierto`,
percentage: Math.min((requirement.current_stock_level / requirement.required_quantity) * 100, 100),
color: requirement.current_stock_level >= requirement.required_quantity ? '#10b981' : requirement.current_stock_level >= requirement.required_quantity * 0.5 ? '#f59e0b' : '#ef4444'
} : undefined}
metadata={[
`Stock: ${requirement.current_stock_level || 0} ${requirement.unit_of_measure}`,
`Necesario: ${requirement.required_quantity - (requirement.current_stock_level || 0)} ${requirement.unit_of_measure}`,
`Costo: €${formatters.compact(requirement.estimated_total_cost || 0)}`,
`Días restantes: ${Math.ceil((new Date(requirement.required_by_date).getTime() - Date.now()) / (1000 * 60 * 60 * 24))}`
]}
actions={[
{
label: 'Ver Detalles',
icon: Eye,
variant: 'primary',
priority: 'primary',
onClick: () => console.log('View requirement details')
},
{
label: 'Asignar Proveedor',
icon: Building2,
priority: 'secondary',
onClick: () => console.log('Assign supplier')
},
{
label: 'Comprar Ahora',
icon: ShoppingCart,
priority: 'secondary',
onClick: () => console.log('Purchase now')
}
]}
/>
))}
</div>
) : (
<div className="text-center py-12">
<CheckCircle className="mx-auto h-12 w-12 text-green-500 mb-4" />
<h3 className="text-lg font-medium text-[var(--text-primary)] mb-2">
No hay requerimientos críticos
</h3>
<p className="text-[var(--text-secondary)]">
Todos los requerimientos están bajo control
</p>
</div>
)}
</div>
)}
{activeTab === 'analytics' && (
<div className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<Card className="p-6">
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4">Costos de Procurement</h3>
<div className="space-y-4">
<div className="flex justify-between items-center">
<span className="text-sm text-[var(--text-secondary)]">Costo Estimado Total</span>
<span className="text-lg font-semibold text-[var(--text-primary)]">
{formatters.currency(stats.totalEstimatedCost)}
</span>
{/* Critical Requirements Modal */}
{showCriticalRequirements && selectedPlanForRequirements && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-[var(--bg-primary)] rounded-lg shadow-xl max-w-4xl w-full max-h-[90vh] overflow-hidden mx-4">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-[var(--border-primary)]">
<div className="flex items-center space-x-3">
<div className="flex items-center justify-center w-8 h-8 rounded-full bg-red-100">
<AlertCircle className="w-5 h-5 text-red-600" />
</div>
<div className="flex justify-between items-center">
<span className="text-sm text-[var(--text-secondary)]">Costo Aprobado</span>
<span className="text-lg font-semibold text-green-600">
{formatters.currency(stats.totalApprovedCost)}
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-sm text-[var(--text-secondary)]">Varianza</span>
<span className="text-lg font-semibold text-[var(--text-primary)]">
{formatters.currency(stats.totalEstimatedCost - stats.totalApprovedCost)}
</span>
<div>
<h2 className="text-lg font-semibold text-[var(--text-primary)]">
Requerimientos Críticos
</h2>
<p className="text-sm text-[var(--text-secondary)]">
Plan {filteredPlans.find(p => p.id === selectedPlanForRequirements)?.plan_number || selectedPlanForRequirements}
</p>
</div>
</div>
</Card>
<Button
variant="outline"
onClick={handleCloseCriticalRequirements}
className="p-2"
>
<X className="w-4 h-4" />
</Button>
</div>
<Card className="p-6">
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4">Alertas Críticas</h3>
<div className="space-y-3">
{dashboardData?.low_stock_alerts?.slice(0, 5).map((alert: any, index: number) => (
<div key={index} className="flex items-center justify-between p-3 bg-red-50 rounded-lg">
<div className="flex items-center">
<AlertCircle className="w-4 h-4 text-red-500 mr-2" />
<span className="text-sm text-[var(--text-primary)]">{alert.product_name || `Alerta ${index + 1}`}</span>
</div>
<span className="text-xs text-red-600 font-medium">
Stock Bajo
</span>
</div>
)) || (
<div className="text-center py-8">
<CheckCircle className="mx-auto h-8 w-8 text-green-500 mb-2" />
<p className="text-sm text-[var(--text-secondary)]">No hay alertas críticas</p>
</div>
)}
</div>
</Card>
{/* Content */}
<div className="p-6 overflow-y-auto max-h-[calc(90vh-120px)]">
{isPlanRequirementsLoading ? (
<div className="flex justify-center items-center h-32">
<Loader className="w-8 h-8 animate-spin text-[var(--color-primary)]" />
</div>
) : planRequirements && planRequirements.length > 0 ? (
<div className="grid gap-4">
{planRequirements.map((requirement) => (
<StatusCard
key={requirement.id}
id={requirement.requirement_number}
statusIndicator={{
color: getStatusColor('danger'),
text: 'Crítico',
icon: AlertCircle,
isCritical: true
}}
title={requirement.product_name}
subtitle={`${requirement.requirement_number}${requirement.supplier_name || 'Sin proveedor'} • Plan: ${filteredPlans.find(p => p.id === selectedPlanForRequirements)?.plan_number || 'N/A'}`}
primaryValue={requirement.required_quantity}
primaryValueLabel={requirement.unit_of_measure}
secondaryInfo={{
label: 'Límite',
value: new Date(requirement.required_by_date).toLocaleDateString('es-ES', { day: '2-digit', month: '2-digit' })
}}
progress={requirement.current_stock_level && requirement.required_quantity ? {
label: `${Math.round((requirement.current_stock_level / requirement.required_quantity) * 100)}% cubierto`,
percentage: Math.min((requirement.current_stock_level / requirement.required_quantity) * 100, 100),
color: requirement.current_stock_level >= requirement.required_quantity ? '#10b981' : requirement.current_stock_level >= requirement.required_quantity * 0.5 ? '#f59e0b' : '#ef4444'
} : undefined}
metadata={[
`Stock: ${requirement.current_stock_level || 0} ${requirement.unit_of_measure}`,
`Necesario: ${requirement.required_quantity - (requirement.current_stock_level || 0)} ${requirement.unit_of_measure}`,
`Costo: €${formatters.compact(requirement.estimated_total_cost || 0)}`,
`Días restantes: ${Math.ceil((new Date(requirement.required_by_date).getTime() - Date.now()) / (1000 * 60 * 60 * 24))}`,
`Estado: ${requirement.status || 'N/A'}`,
`PO: ${requirement.purchase_order_number || 'Sin PO asignado'}`,
`Prioridad: ${requirement.priority || 'N/A'}`
]}
actions={[
{
label: 'Ver Detalles',
icon: Eye,
variant: 'primary',
priority: 'primary',
onClick: () => console.log('View requirement details', requirement)
},
...(requirement.purchase_order_number ? [
{
label: 'Ver PO',
icon: Eye,
variant: 'outline' as const,
priority: 'secondary' as const,
onClick: () => console.log('View PO', requirement.purchase_order_number)
}
] : [
{
label: 'Crear PO',
icon: Plus,
variant: 'outline' as const,
priority: 'secondary' as const,
onClick: () => console.log('Create PO for', requirement)
}
]),
{
label: requirement.supplier_name ? 'Cambiar Proveedor' : 'Asignar Proveedor',
icon: Building2,
variant: 'outline' as const,
priority: 'secondary' as const,
onClick: () => console.log('Assign supplier')
}
]}
/>
))}
</div>
) : (
<div className="text-center py-12">
<CheckCircle className="mx-auto h-12 w-12 text-green-500 mb-4" />
<h3 className="text-lg font-medium text-[var(--text-primary)] mb-2">
No hay requerimientos críticos
</h3>
<p className="text-[var(--text-secondary)]">
Este plan no tiene requerimientos críticos en este momento
</p>
</div>
)}
</div>
</div>
<Card className="p-6">
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4">Resumen de Performance</h3>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="text-center p-4 bg-[var(--bg-secondary)] rounded-lg">
<p className="text-2xl font-bold text-[var(--color-primary)]">{stats.totalPlans}</p>
<p className="text-sm text-[var(--text-secondary)] mt-1">Planes Totales</p>
</div>
<div className="text-center p-4 bg-[var(--bg-secondary)] rounded-lg">
<p className="text-2xl font-bold text-green-600">{stats.activePlans}</p>
<p className="text-sm text-[var(--text-secondary)] mt-1">Planes Activos</p>
</div>
<div className="text-center p-4 bg-[var(--bg-secondary)] rounded-lg">
<p className="text-2xl font-bold text-yellow-600">{stats.pendingRequirements}</p>
<p className="text-sm text-[var(--text-secondary)] mt-1">Pendientes</p>
</div>
<div className="text-center p-4 bg-[var(--bg-secondary)] rounded-lg">
<p className="text-2xl font-bold text-red-600">{stats.criticalRequirements}</p>
<p className="text-sm text-[var(--text-secondary)] mt-1">Críticos</p>
</div>
</div>
</Card>
</div>
)}