Imporve the AI models page
This commit is contained in:
2
frontend/package-lock.json
generated
2
frontend/package-lock.json
generated
@@ -72,7 +72,7 @@
|
|||||||
"prettier": "^3.1.0",
|
"prettier": "^3.1.0",
|
||||||
"prettier-plugin-tailwindcss": "^0.5.0",
|
"prettier-plugin-tailwindcss": "^0.5.0",
|
||||||
"tailwindcss": "^3.3.0",
|
"tailwindcss": "^3.3.0",
|
||||||
"typescript": "^5.3.0",
|
"typescript": "^5.9.2",
|
||||||
"vite": "^5.0.0",
|
"vite": "^5.0.0",
|
||||||
"vite-plugin-pwa": "^0.17.0",
|
"vite-plugin-pwa": "^0.17.0",
|
||||||
"vitest": "^1.0.0"
|
"vitest": "^1.0.0"
|
||||||
|
|||||||
@@ -82,7 +82,7 @@
|
|||||||
"prettier": "^3.1.0",
|
"prettier": "^3.1.0",
|
||||||
"prettier-plugin-tailwindcss": "^0.5.0",
|
"prettier-plugin-tailwindcss": "^0.5.0",
|
||||||
"tailwindcss": "^3.3.0",
|
"tailwindcss": "^3.3.0",
|
||||||
"typescript": "^5.3.0",
|
"typescript": "^5.9.2",
|
||||||
"vite": "^5.0.0",
|
"vite": "^5.0.0",
|
||||||
"vite-plugin-pwa": "^0.17.0",
|
"vite-plugin-pwa": "^0.17.0",
|
||||||
"vitest": "^1.0.0"
|
"vitest": "^1.0.0"
|
||||||
|
|||||||
404
frontend/src/components/domain/forecasting/ModelDetailsModal.tsx
Normal file
404
frontend/src/components/domain/forecasting/ModelDetailsModal.tsx
Normal file
@@ -0,0 +1,404 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { StatusModal, StatusModalSection } from '@/components/ui/StatusModal/StatusModal';
|
||||||
|
import { Badge } from '@/components/ui/Badge';
|
||||||
|
import { Tooltip } from '@/components/ui/Tooltip';
|
||||||
|
import { TrainedModelResponse, TrainingMetrics } from '@/types/training';
|
||||||
|
import { Activity, TrendingUp, Calendar, Settings, Lightbulb, AlertTriangle, CheckCircle, Target } from 'lucide-react';
|
||||||
|
|
||||||
|
interface ModelDetailsModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
model: TrainedModelResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to determine performance color based on accuracy
|
||||||
|
const getPerformanceColor = (accuracy: number): string => {
|
||||||
|
if (accuracy >= 90) return 'var(--color-success)';
|
||||||
|
if (accuracy >= 80) return 'var(--color-info)';
|
||||||
|
if (accuracy >= 70) return 'var(--color-warning)';
|
||||||
|
if (accuracy >= 60) return 'var(--color-warning-dark)';
|
||||||
|
return 'var(--color-error)';
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
// Helper function to determine performance message for bakery owners
|
||||||
|
const getPerformanceMessage = (accuracy: number): string => {
|
||||||
|
if (accuracy >= 90) return 'Excelente: Las predicciones son muy precisas, ideales para planificar producción';
|
||||||
|
if (accuracy >= 80) return 'Bueno: Las predicciones son confiables para planificación diaria';
|
||||||
|
if (accuracy >= 70) return 'Aceptable: Predicciones útiles pero considera monitorear de cerca';
|
||||||
|
if (accuracy >= 60) return 'Necesita mejora: Predicciones menos precisas, considera reentrenar';
|
||||||
|
return 'Poco confiable: El modelo necesita ser reentrenado con más datos';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper function to format date
|
||||||
|
const formatDate = (dateString: string): string => {
|
||||||
|
const date = new Date(dateString);
|
||||||
|
return date.toLocaleDateString('en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric'
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper function to calculate accuracy from MAPE
|
||||||
|
const calculateAccuracy = (mape: number | undefined): number => {
|
||||||
|
if (mape === undefined || mape === null || isNaN(mape)) return 0;
|
||||||
|
// MAPE in the API response is already in percentage form (e.g., 8.5 = 8.5%)
|
||||||
|
const accuracy = Math.max(0, Math.min(100, 100 - mape));
|
||||||
|
return accuracy;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Feature tag component
|
||||||
|
const FeatureTag: React.FC<{ feature: string }> = ({ feature }) => {
|
||||||
|
const getIcon = (feature: string) => {
|
||||||
|
const featureLower = feature.toLowerCase();
|
||||||
|
if (featureLower.includes('temperature')) return '☀️';
|
||||||
|
if (featureLower.includes('weekend')) return '📅';
|
||||||
|
if (featureLower.includes('holiday')) return '🎉';
|
||||||
|
if (featureLower.includes('precipitation')) return '🌧️';
|
||||||
|
if (featureLower.includes('traffic')) return '🚗';
|
||||||
|
return '📊';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTooltip = (feature: string) => {
|
||||||
|
const featureLower = feature.toLowerCase();
|
||||||
|
if (featureLower.includes('temperature')) return 'El clima afecta lo que los clientes quieren comprar - días calurosos significan menos productos horneados calientes';
|
||||||
|
if (featureLower.includes('weekend')) return 'Tus patrones de ventas son diferentes los fines de semana vs días de semana';
|
||||||
|
if (featureLower.includes('holiday')) return 'Días especiales y festivos cambian cuánto compra la gente';
|
||||||
|
if (featureLower.includes('precipitation')) return 'La lluvia o nieve afecta cuántos clientes visitan tu panadería';
|
||||||
|
if (featureLower.includes('traffic')) return 'Calles transitadas usualmente significan más clientes';
|
||||||
|
return `${feature}: Esto ayuda a predecir cuánto venderás`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip content={getTooltip(feature)}>
|
||||||
|
<Badge
|
||||||
|
variant="secondary"
|
||||||
|
className="flex items-center gap-1 text-xs md:text-sm px-2 py-1"
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
aria-label={`Característica: ${feature}. ${getTooltip(feature)}`}
|
||||||
|
>
|
||||||
|
<span className="text-xs" aria-hidden="true">{getIcon(feature)}</span>
|
||||||
|
<span className="truncate max-w-[120px]">{feature}</span>
|
||||||
|
</Badge>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ModelDetailsModal: React.FC<ModelDetailsModalProps> = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
model
|
||||||
|
}) => {
|
||||||
|
// Early return if model is not provided
|
||||||
|
if (!model) {
|
||||||
|
return (
|
||||||
|
<StatusModal
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={onClose}
|
||||||
|
mode="view"
|
||||||
|
title="Model Details"
|
||||||
|
sections={[]}
|
||||||
|
actions={[]}
|
||||||
|
size="xl"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate business metrics from technical metrics
|
||||||
|
// The API response has metrics directly on the model object, not nested in training_metrics
|
||||||
|
const accuracy = calculateAccuracy((model as any).mape || model.training_metrics?.mape);
|
||||||
|
const performanceColor = getPerformanceColor(accuracy);
|
||||||
|
const performanceMessage = getPerformanceMessage(accuracy);
|
||||||
|
|
||||||
|
// Prepare sections for StatusModal
|
||||||
|
const sections: StatusModalSection[] = [
|
||||||
|
{
|
||||||
|
title: "Resumen",
|
||||||
|
icon: Activity,
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
label: "Rendimiento del Modelo",
|
||||||
|
value: (
|
||||||
|
<div className="w-full">
|
||||||
|
<div className="flex justify-between text-sm mb-1">
|
||||||
|
<span className="font-medium">Precisión</span>
|
||||||
|
<span className="font-bold" style={{ color: performanceColor }} aria-label={`Precisión del modelo: ${accuracy.toFixed(0)} porciento`}>
|
||||||
|
{accuracy.toFixed(0)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full bg-[var(--bg-secondary)] rounded-full h-2.5" role="progressbar" aria-valuenow={accuracy} aria-valuemin={0} aria-valuemax={100} aria-label="Progreso de rendimiento del modelo">
|
||||||
|
<div
|
||||||
|
className="h-2.5 rounded-full transition-all duration-300 ease-out"
|
||||||
|
style={{
|
||||||
|
width: `${accuracy}%`,
|
||||||
|
backgroundColor: performanceColor
|
||||||
|
}}
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-[var(--text-secondary)] mt-2">
|
||||||
|
{performanceMessage}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
span: 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Estado",
|
||||||
|
value: (
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium"
|
||||||
|
style={{
|
||||||
|
backgroundColor: (model as any).is_active
|
||||||
|
? 'color-mix(in srgb, var(--color-success) 20%, transparent)'
|
||||||
|
: 'color-mix(in srgb, var(--color-error) 20%, transparent)',
|
||||||
|
color: (model as any).is_active ? 'var(--color-success)' : 'var(--color-error)'
|
||||||
|
}}
|
||||||
|
aria-label={`Estado del modelo: ${(model as any).is_active ? 'Activo' : 'Inactivo'}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="inline-block w-2 h-2 rounded-full mr-2"
|
||||||
|
style={{
|
||||||
|
backgroundColor: (model as any).is_active ? 'var(--color-success)' : 'var(--color-error)'
|
||||||
|
}}
|
||||||
|
></span>
|
||||||
|
{(model as any).is_active ? 'Activo' : 'Inactivo'}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Última Actualización",
|
||||||
|
value: formatDate(model.created_at || new Date().toISOString())
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Período de Entrenamiento",
|
||||||
|
value: `${formatDate((model as any).training_start_date || model.training_period?.start_date || new Date().toISOString())} - ${formatDate((model as any).training_end_date || model.training_period?.end_date || new Date().toISOString())}`
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Métricas de Rendimiento",
|
||||||
|
icon: TrendingUp,
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
label: "Tasa de Precisión",
|
||||||
|
value: `${accuracy.toFixed(0)}%`,
|
||||||
|
highlight: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Diferencia Típica",
|
||||||
|
value: (() => {
|
||||||
|
const maeValue = ((model as any).mae || model.training_metrics?.mae || 0).toFixed(0);
|
||||||
|
return (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="font-medium">±{maeValue} productos</div>
|
||||||
|
<div className="text-xs text-[var(--text-secondary)]">
|
||||||
|
En promedio, las predicciones se desvían por {maeValue} productos
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Confiabilidad General",
|
||||||
|
value: `${(((model as any).r2_score || model.training_metrics?.r2_score || 0) * 100).toFixed(0)}% confiable`
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Qué Significa Esto para Tu Panadería",
|
||||||
|
icon: Target,
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
label: "Precisión de las Predicciones",
|
||||||
|
value: (() => {
|
||||||
|
const mapeValue = Math.round((model as any).mape || model.training_metrics?.mape || 0);
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="text-sm font-medium text-[var(--text-primary)]">
|
||||||
|
Error promedio: {mapeValue}%
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-[var(--text-secondary)] leading-relaxed">
|
||||||
|
<strong>Ejemplo práctico:</strong> Si el modelo predice que venderás 100 panes,
|
||||||
|
es muy probable que vendas entre {100 - mapeValue} y {100 + mapeValue} panes.
|
||||||
|
Mientras menor sea este porcentaje, más precisas son las predicciones.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})(),
|
||||||
|
span: 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Potencial de Reducción de Desperdicio",
|
||||||
|
value: accuracy >= 80
|
||||||
|
? `Podría ayudar a reducir el desperdicio diario en ~${Math.round(accuracy / 8)}% mediante mejor planificación`
|
||||||
|
: accuracy >= 60
|
||||||
|
? 'Alguna reducción de desperdicio posible con monitoreo cuidadoso'
|
||||||
|
: 'Precisión del modelo demasiado baja para reducción confiable de desperdicio',
|
||||||
|
highlight: true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Factores que Considera el Modelo",
|
||||||
|
icon: Settings,
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
label: "Información que Analiza",
|
||||||
|
value: (() => {
|
||||||
|
const features = ((model as any).features_used || model.features_used || []);
|
||||||
|
const featureCount = features.length;
|
||||||
|
|
||||||
|
if (featureCount === 0) {
|
||||||
|
return (
|
||||||
|
<div className="text-sm text-[var(--text-secondary)] italic">
|
||||||
|
Usando datos básicos de ventas e historial
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="text-sm text-[var(--text-secondary)]">
|
||||||
|
El modelo analiza <strong>{featureCount} factores diferentes</strong> para hacer predicciones más precisas.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3 text-xs">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="font-medium text-[var(--text-primary)]">📅 Temporales:</div>
|
||||||
|
<div className="text-[var(--text-secondary)]">Días de semana, festivos, estaciones</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="font-medium text-[var(--text-primary)]">🌤️ Clima:</div>
|
||||||
|
<div className="text-[var(--text-secondary)]">Temperatura, lluvia, humedad</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="font-medium text-[var(--text-primary)]">🚶 Tráfico:</div>
|
||||||
|
<div className="text-[var(--text-secondary)]">Peatones, congestión, velocidad</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="font-medium text-[var(--text-primary)]">📊 Ventas:</div>
|
||||||
|
<div className="text-[var(--text-secondary)]">Historial, patrones, tendencias</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-xs text-[var(--text-secondary)] bg-[var(--bg-secondary)] rounded-md p-3 border-l-4 border-[var(--color-info)]">
|
||||||
|
<strong>💡 En resumen:</strong> Cuantos más factores analiza el modelo, más precisas son las predicciones para tu panadería.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})(),
|
||||||
|
span: 2
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Detalles Técnicos",
|
||||||
|
icon: Calendar,
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
label: "Método de Predicción",
|
||||||
|
value: model.model_type || 'Pronóstico con IA'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Última Actualización",
|
||||||
|
value: formatDate(model.created_at || new Date().toISOString())
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Período de Entrenamiento",
|
||||||
|
value: `${formatDate((model as any).training_start_date || model.training_period?.start_date || new Date().toISOString())} a ${formatDate((model as any).training_end_date || model.training_period?.end_date || new Date().toISOString())}`,
|
||||||
|
span: 2
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Perspectivas de Negocio",
|
||||||
|
icon: Lightbulb,
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
label: "Qué funciona mejor",
|
||||||
|
value: (
|
||||||
|
<div className="flex items-start">
|
||||||
|
<CheckCircle className="text-[var(--color-success)] mr-2 mt-0.5 flex-shrink-0" size={16} />
|
||||||
|
<span>Este modelo te da las predicciones más precisas para días de semana regulares</span>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
span: 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Ten cuidado con",
|
||||||
|
value: (
|
||||||
|
<div className="flex items-start">
|
||||||
|
<AlertTriangle className="text-[var(--color-warning)] mr-2 mt-0.5 flex-shrink-0" size={16} />
|
||||||
|
<span>Las predicciones pueden ser menos confiables durante festivos y eventos especiales</span>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
span: 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Patrones descubiertos",
|
||||||
|
value: ((model as any).features_used || model.features_used || []).some((f: string) => f.toLowerCase().includes('weekend'))
|
||||||
|
? "Tu negocio muestra patrones diferentes entre días de semana y fines de semana"
|
||||||
|
: "Este modelo ha aprendido tus patrones regulares de ventas",
|
||||||
|
span: 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Próximos pasos",
|
||||||
|
value: accuracy < 80
|
||||||
|
? "Considera reentrenar con datos más recientes para mejorar la precisión"
|
||||||
|
: "Tu modelo está funcionando bien. Continúa monitoreando las predicciones",
|
||||||
|
span: 2
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
// Actions for the modal - removed console.log placeholders for production readiness
|
||||||
|
const actions = [
|
||||||
|
{
|
||||||
|
label: 'Actualizar Modelo',
|
||||||
|
variant: 'primary' as const,
|
||||||
|
onClick: () => {
|
||||||
|
// TODO: Implement model retraining functionality
|
||||||
|
// This should trigger a new training job for the product
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Ver Predicciones',
|
||||||
|
variant: 'secondary' as const,
|
||||||
|
onClick: () => {
|
||||||
|
// TODO: Navigate to forecast history or predictions view
|
||||||
|
// This should show historical predictions vs actual sales
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
// Only show deactivate if model is active
|
||||||
|
if ((model as any).is_active) {
|
||||||
|
actions.push({
|
||||||
|
label: 'Desactivar',
|
||||||
|
variant: 'secondary' as const,
|
||||||
|
onClick: () => {
|
||||||
|
// TODO: Implement model deactivation
|
||||||
|
// This should set model status to inactive
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<StatusModal
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={onClose}
|
||||||
|
mode="view"
|
||||||
|
title="Detalles de Predicción de Ventas"
|
||||||
|
subtitle={`Para ${model.model_type || 'tu producto'} • Actualizado ${model.created_at ? formatDate(model.created_at) : 'recientemente'}`}
|
||||||
|
sections={sections}
|
||||||
|
actions={actions}
|
||||||
|
size="xl"
|
||||||
|
actionsPosition="footer"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ModelDetailsModal;
|
||||||
@@ -3,29 +3,11 @@ export { default as DemandChart } from './DemandChart';
|
|||||||
export { default as ForecastTable } from './ForecastTable';
|
export { default as ForecastTable } from './ForecastTable';
|
||||||
export { default as SeasonalityIndicator } from './SeasonalityIndicator';
|
export { default as SeasonalityIndicator } from './SeasonalityIndicator';
|
||||||
export { default as AlertsPanel } from './AlertsPanel';
|
export { default as AlertsPanel } from './AlertsPanel';
|
||||||
|
export { default as ModelDetailsModal } from './ModelDetailsModal';
|
||||||
|
|
||||||
// Export component props for type checking
|
// Export component props for type checking
|
||||||
export type { DemandChartProps } from './DemandChart';
|
export type { DemandChartProps } from './DemandChart';
|
||||||
export type { ForecastTableProps } from './ForecastTable';
|
export type { ForecastTableProps } from './ForecastTable';
|
||||||
export type { SeasonalityIndicatorProps } from './SeasonalityIndicator';
|
export type { SeasonalityIndicatorProps } from './SeasonalityIndicator';
|
||||||
export type { AlertsPanelProps } from './AlertsPanel';
|
export type { AlertsPanelProps } from './AlertsPanel';
|
||||||
|
export type { ModelDetailsModalProps } from './ModelDetailsModal';
|
||||||
// Re-export related types from forecasting types
|
|
||||||
export type {
|
|
||||||
ForecastResponse,
|
|
||||||
DemandTrend,
|
|
||||||
SeasonalPattern,
|
|
||||||
ForecastAlert,
|
|
||||||
TrendDirection,
|
|
||||||
AlertSeverity,
|
|
||||||
ForecastAlertType,
|
|
||||||
SeasonalComponent,
|
|
||||||
HolidayEffect,
|
|
||||||
WeeklyPattern,
|
|
||||||
YearlyTrend,
|
|
||||||
Season,
|
|
||||||
SeasonalPeriod,
|
|
||||||
DayOfWeek,
|
|
||||||
WeatherCondition,
|
|
||||||
EventType,
|
|
||||||
} from '../../../types/forecasting.types';
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useState, useMemo } from 'react';
|
import React, { useState, useMemo } from 'react';
|
||||||
import { Brain, TrendingUp, AlertCircle, Play, RotateCcw, Eye, Loader, CheckCircle } from 'lucide-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 { PageHeader } from '../../../../components/layout';
|
||||||
import { useToast } from '../../../../hooks/ui/useToast';
|
import { useToast } from '../../../../hooks/ui/useToast';
|
||||||
import { useCurrentTenant } from '../../../../stores/tenant.store';
|
import { useCurrentTenant } from '../../../../stores/tenant.store';
|
||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
useModelPerformance,
|
useModelPerformance,
|
||||||
useTenantTrainingStatistics
|
useTenantTrainingStatistics
|
||||||
} from '../../../../api/hooks/training';
|
} from '../../../../api/hooks/training';
|
||||||
|
import { ModelDetailsModal } from '../../../../components/domain/forecasting';
|
||||||
import type { IngredientResponse } from '../../../../api/types/inventory';
|
import type { IngredientResponse } from '../../../../api/types/inventory';
|
||||||
import type { TrainedModelResponse, SingleProductTrainingRequest } from '../../../../api/types/training';
|
import type { TrainedModelResponse, SingleProductTrainingRequest } from '../../../../api/types/training';
|
||||||
|
|
||||||
@@ -44,6 +45,7 @@ const ModelsConfigPage: React.FC = () => {
|
|||||||
const tenantId = currentTenant?.id || '';
|
const tenantId = currentTenant?.id || '';
|
||||||
|
|
||||||
const [selectedIngredient, setSelectedIngredient] = useState<IngredientResponse | null>(null);
|
const [selectedIngredient, setSelectedIngredient] = useState<IngredientResponse | null>(null);
|
||||||
|
const [selectedModel, setSelectedModel] = useState<TrainedModelResponse | null>(null);
|
||||||
const [showTrainingModal, setShowTrainingModal] = useState(false);
|
const [showTrainingModal, setShowTrainingModal] = useState(false);
|
||||||
const [showModelDetailsModal, setShowModelDetailsModal] = useState(false);
|
const [showModelDetailsModal, setShowModelDetailsModal] = useState(false);
|
||||||
const [trainingSettings, setTrainingSettings] = useState<Partial<SingleProductTrainingRequest>>({
|
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);
|
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);
|
setShowModelDetailsModal(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -171,95 +180,9 @@ const ModelsConfigPage: React.FC = () => {
|
|||||||
setShowTrainingModal(true);
|
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) {
|
if (ingredientsLoading || modelsLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -291,55 +214,35 @@ const ModelsConfigPage: React.FC = () => {
|
|||||||
|
|
||||||
|
|
||||||
{/* Statistics Cards */}
|
{/* Statistics Cards */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
|
<StatsGrid
|
||||||
<Card className="p-6">
|
stats={[
|
||||||
<div className="flex items-center justify-between">
|
{
|
||||||
<div>
|
title: 'Ingredientes con Modelo',
|
||||||
<div className="text-2xl font-bold text-[var(--text-primary)]">
|
value: modelStatuses.filter(s => s.hasModel).length,
|
||||||
{modelStatuses.filter(s => s.hasModel).length}
|
icon: Brain,
|
||||||
</div>
|
variant: 'default',
|
||||||
<div className="text-sm text-[var(--text-secondary)]">Ingredientes con Modelo</div>
|
},
|
||||||
</div>
|
{
|
||||||
<Brain className="w-8 h-8 text-[var(--color-primary)]" />
|
title: 'Sin Modelo',
|
||||||
</div>
|
value: modelStatuses.filter(s => s.status === 'no_model').length,
|
||||||
</Card>
|
icon: AlertCircle,
|
||||||
|
variant: 'warning',
|
||||||
<Card className="p-6">
|
},
|
||||||
<div className="flex items-center justify-between">
|
{
|
||||||
<div>
|
title: 'Modelos Huérfanos',
|
||||||
<div className="text-2xl font-bold text-[var(--text-primary)]">
|
value: orphanedModels.length,
|
||||||
{modelStatuses.filter(s => s.status === 'no_model').length}
|
icon: AlertCircle,
|
||||||
</div>
|
variant: 'info',
|
||||||
<div className="text-sm text-[var(--text-secondary)]">Sin Modelo</div>
|
},
|
||||||
</div>
|
{
|
||||||
<AlertCircle className="w-8 h-8 text-[var(--color-warning)]" />
|
title: 'Precisión Promedio',
|
||||||
</div>
|
value: statsError ? 'N/A' : (statistics?.average_accuracy ? `${(100 - statistics.average_accuracy).toFixed(1)}%` : 'N/A'),
|
||||||
</Card>
|
icon: TrendingUp,
|
||||||
|
variant: 'success',
|
||||||
<Card className="p-6">
|
},
|
||||||
<div className="flex items-center justify-between">
|
]}
|
||||||
<div>
|
columns={4}
|
||||||
<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 */}
|
{/* Orphaned Models Warning */}
|
||||||
{orphanedModels.length > 0 && (
|
{orphanedModels.length > 0 && (
|
||||||
@@ -385,10 +288,10 @@ const ModelsConfigPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Models Table */}
|
{/* Models Grid */}
|
||||||
<Card>
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
{filteredStatuses.length === 0 ? (
|
{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" />
|
<Brain className="w-12 h-12 text-[var(--color-secondary)] mb-4" />
|
||||||
<h3 className="text-lg font-medium text-[var(--text-primary)] mb-2">
|
<h3 className="text-lg font-medium text-[var(--text-primary)] mb-2">
|
||||||
No se encontraron ingredientes
|
No se encontraron ingredientes
|
||||||
@@ -398,12 +301,70 @@ const ModelsConfigPage: React.FC = () => {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Table
|
filteredStatuses.map((status) => {
|
||||||
data={filteredStatuses}
|
// Get status configuration for the StatusCard
|
||||||
columns={tableColumns}
|
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 */}
|
{/* Training Modal */}
|
||||||
<Modal
|
<Modal
|
||||||
@@ -484,194 +445,12 @@ const ModelsConfigPage: React.FC = () => {
|
|||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
{/* Model Details Modal */}
|
{/* Model Details Modal */}
|
||||||
<Modal
|
{selectedModel && (
|
||||||
isOpen={showModelDetailsModal}
|
<ModelDetailsModal
|
||||||
onClose={() => setShowModelDetailsModal(false)}
|
isOpen={showModelDetailsModal}
|
||||||
title={`Detalles del Modelo - ${selectedIngredient?.name}`}
|
onClose={() => setShowModelDetailsModal(false)}
|
||||||
size="lg"
|
model={selectedModel}
|
||||||
>
|
/>
|
||||||
<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>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
import React, { useState } from 'react';
|
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 { Button, Input, Card, StatsGrid, StatusCard, getStatusColor, StatusModal } from '../../../../components/ui';
|
||||||
import { formatters } from '../../../../components/ui/Stats/StatsPresets';
|
import { formatters } from '../../../../components/ui/Stats/StatsPresets';
|
||||||
import { PageHeader } from '../../../../components/layout';
|
import { PageHeader } from '../../../../components/layout';
|
||||||
import {
|
import {
|
||||||
useProcurementDashboard,
|
useProcurementDashboard,
|
||||||
useProcurementPlans,
|
useProcurementPlans,
|
||||||
useCurrentProcurementPlan,
|
|
||||||
useCriticalRequirements,
|
|
||||||
usePlanRequirements,
|
usePlanRequirements,
|
||||||
useGenerateProcurementPlan,
|
useGenerateProcurementPlan,
|
||||||
useUpdateProcurementPlanStatus,
|
useUpdateProcurementPlanStatus,
|
||||||
@@ -16,7 +14,6 @@ import {
|
|||||||
import { useTenantStore } from '../../../../stores/tenant.store';
|
import { useTenantStore } from '../../../../stores/tenant.store';
|
||||||
|
|
||||||
const ProcurementPage: React.FC = () => {
|
const ProcurementPage: React.FC = () => {
|
||||||
const [activeTab, setActiveTab] = useState('plans');
|
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
const [showForm, setShowForm] = useState(false);
|
const [showForm, setShowForm] = useState(false);
|
||||||
const [modalMode, setModalMode] = useState<'view' | 'edit'>('view');
|
const [modalMode, setModalMode] = useState<'view' | 'edit'>('view');
|
||||||
@@ -36,18 +33,36 @@ const ProcurementPage: React.FC = () => {
|
|||||||
limit: 50,
|
limit: 50,
|
||||||
offset: 0
|
offset: 0
|
||||||
});
|
});
|
||||||
const { data: currentPlan, isLoading: isCurrentPlanLoading } = useCurrentProcurementPlan(tenantId);
|
|
||||||
const { data: criticalRequirements, isLoading: isCriticalLoading } = useCriticalRequirements(tenantId);
|
|
||||||
|
|
||||||
// Get plan requirements for selected plan
|
// Get plan requirements for selected plan
|
||||||
const { data: planRequirements, isLoading: isPlanRequirementsLoading } = usePlanRequirements({
|
const { data: allPlanRequirements, isLoading: isPlanRequirementsLoading } = usePlanRequirements({
|
||||||
tenant_id: tenantId,
|
tenant_id: tenantId,
|
||||||
plan_id: selectedPlanForRequirements || '',
|
plan_id: selectedPlanForRequirements || ''
|
||||||
status: 'critical' // Only get critical requirements
|
// Remove status filter to get all requirements
|
||||||
}, {
|
}, {
|
||||||
enabled: !!selectedPlanForRequirements && !!tenantId
|
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 generatePlanMutation = useGenerateProcurementPlan();
|
||||||
const updatePlanStatusMutation = useUpdateProcurementPlanStatus();
|
const updatePlanStatusMutation = useUpdateProcurementPlanStatus();
|
||||||
const triggerSchedulerMutation = useTriggerDailyScheduler();
|
const triggerSchedulerMutation = useTriggerDailyScheduler();
|
||||||
@@ -120,6 +135,7 @@ const ProcurementPage: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleShowCriticalRequirements = (planId: string) => {
|
const handleShowCriticalRequirements = (planId: string) => {
|
||||||
|
console.log('🔍 Opening critical requirements for plan:', planId);
|
||||||
setSelectedPlanForRequirements(planId);
|
setSelectedPlanForRequirements(planId);
|
||||||
setShowCriticalRequirements(true);
|
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">
|
||||||
<Card className="p-4">
|
<div className="flex flex-col sm:flex-row gap-4">
|
||||||
<div className="flex flex-col sm:flex-row gap-4">
|
<div className="flex-1">
|
||||||
<div className="flex-1">
|
<Input
|
||||||
<Input
|
placeholder="Buscar planes por número, estado o notas..."
|
||||||
placeholder="Buscar planes por número, estado o notas..."
|
value={searchTerm}
|
||||||
value={searchTerm}
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
onChange={(e) => setSearchTerm(e.target.value)}
|
className="w-full"
|
||||||
className="w-full"
|
/>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Button variant="outline" onClick={() => console.log('Export filtered')}>
|
|
||||||
<Download className="w-4 h-4 mr-2" />
|
|
||||||
Exportar
|
|
||||||
</Button>
|
|
||||||
</div>
|
</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 */}
|
{/* 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">
|
||||||
<div className="grid gap-4 sm:grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-3">
|
{isPlansLoading ? (
|
||||||
{isPlansLoading ? (
|
<div className="col-span-full flex justify-center items-center h-32">
|
||||||
<div className="col-span-full flex justify-center items-center h-32">
|
<Loader className="w-8 h-8 animate-spin text-[var(--color-primary)]" />
|
||||||
<Loader className="w-8 h-8 animate-spin text-[var(--color-primary)]" />
|
</div>
|
||||||
</div>
|
) : (
|
||||||
) : (
|
filteredPlans.map((plan) => {
|
||||||
filteredPlans.map((plan) => {
|
|
||||||
const statusConfig = getPlanStatusConfig(plan.status);
|
const statusConfig = getPlanStatusConfig(plan.status);
|
||||||
const nextStageConfig = getStageActionConfig(plan.status);
|
const nextStageConfig = getStageActionConfig(plan.status);
|
||||||
const isEditing = editingPlan?.id === plan.id;
|
const isEditing = editingPlan?.id === plan.id;
|
||||||
@@ -522,12 +500,12 @@ const ProcurementPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
)}
|
)
|
||||||
</div>
|
}
|
||||||
)}
|
</div>
|
||||||
|
|
||||||
{/* Empty State for Procurement Plans */}
|
{/* Empty State for Procurement Plans */}
|
||||||
{activeTab === 'plans' && !isPlansLoading && filteredPlans.length === 0 && (
|
{!isPlansLoading && filteredPlans.length === 0 && (
|
||||||
<div className="text-center py-12">
|
<div className="text-center py-12">
|
||||||
<Package className="mx-auto h-12 w-12 text-[var(--text-tertiary)] mb-4" />
|
<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">
|
<h3 className="text-lg font-medium text-[var(--text-primary)] mb-2">
|
||||||
@@ -558,152 +536,123 @@ const ProcurementPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 'requirements' && (
|
{/* Critical Requirements Modal */}
|
||||||
<div className="space-y-4">
|
{showCriticalRequirements && selectedPlanForRequirements && (
|
||||||
{isCriticalLoading ? (
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||||
<div className="flex justify-center items-center h-32">
|
<div className="bg-[var(--bg-primary)] rounded-lg shadow-xl max-w-4xl w-full max-h-[90vh] overflow-hidden mx-4">
|
||||||
<Loader className="w-8 h-8 animate-spin text-[var(--color-primary)]" />
|
{/* Header */}
|
||||||
</div>
|
<div className="flex items-center justify-between p-6 border-b border-[var(--border-primary)]">
|
||||||
) : criticalRequirements && criticalRequirements.length > 0 ? (
|
<div className="flex items-center space-x-3">
|
||||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
<div className="flex items-center justify-center w-8 h-8 rounded-full bg-red-100">
|
||||||
{criticalRequirements.map((requirement) => (
|
<AlertCircle className="w-5 h-5 text-red-600" />
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between items-center">
|
<div>
|
||||||
<span className="text-sm text-[var(--text-secondary)]">Costo Aprobado</span>
|
<h2 className="text-lg font-semibold text-[var(--text-primary)]">
|
||||||
<span className="text-lg font-semibold text-green-600">
|
Requerimientos Críticos
|
||||||
{formatters.currency(stats.totalApprovedCost)}
|
</h2>
|
||||||
</span>
|
<p className="text-sm text-[var(--text-secondary)]">
|
||||||
</div>
|
Plan {filteredPlans.find(p => p.id === selectedPlanForRequirements)?.plan_number || selectedPlanForRequirements}
|
||||||
<div className="flex justify-between items-center">
|
</p>
|
||||||
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleCloseCriticalRequirements}
|
||||||
|
className="p-2"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Card className="p-6">
|
{/* Content */}
|
||||||
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4">Alertas Críticas</h3>
|
<div className="p-6 overflow-y-auto max-h-[calc(90vh-120px)]">
|
||||||
<div className="space-y-3">
|
{isPlanRequirementsLoading ? (
|
||||||
{dashboardData?.low_stock_alerts?.slice(0, 5).map((alert: any, index: number) => (
|
<div className="flex justify-center items-center h-32">
|
||||||
<div key={index} className="flex items-center justify-between p-3 bg-red-50 rounded-lg">
|
<Loader className="w-8 h-8 animate-spin text-[var(--color-primary)]" />
|
||||||
<div className="flex items-center">
|
</div>
|
||||||
<AlertCircle className="w-4 h-4 text-red-500 mr-2" />
|
) : planRequirements && planRequirements.length > 0 ? (
|
||||||
<span className="text-sm text-[var(--text-primary)]">{alert.product_name || `Alerta ${index + 1}`}</span>
|
<div className="grid gap-4">
|
||||||
</div>
|
{planRequirements.map((requirement) => (
|
||||||
<span className="text-xs text-red-600 font-medium">
|
<StatusCard
|
||||||
Stock Bajo
|
key={requirement.id}
|
||||||
</span>
|
id={requirement.requirement_number}
|
||||||
</div>
|
statusIndicator={{
|
||||||
)) || (
|
color: getStatusColor('danger'),
|
||||||
<div className="text-center py-8">
|
text: 'Crítico',
|
||||||
<CheckCircle className="mx-auto h-8 w-8 text-green-500 mb-2" />
|
icon: AlertCircle,
|
||||||
<p className="text-sm text-[var(--text-secondary)]">No hay alertas críticas</p>
|
isCritical: true
|
||||||
</div>
|
}}
|
||||||
)}
|
title={requirement.product_name}
|
||||||
</div>
|
subtitle={`${requirement.requirement_number} • ${requirement.supplier_name || 'Sin proveedor'} • Plan: ${filteredPlans.find(p => p.id === selectedPlanForRequirements)?.plan_number || 'N/A'}`}
|
||||||
</Card>
|
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>
|
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user