ADD new frontend
This commit is contained in:
725
frontend/src/components/domain/forecasting/AlertsPanel.tsx
Normal file
725
frontend/src/components/domain/forecasting/AlertsPanel.tsx
Normal file
@@ -0,0 +1,725 @@
|
||||
import React, { useState, useMemo, useCallback } from 'react';
|
||||
import { clsx } from 'clsx';
|
||||
import { Card, CardHeader, CardBody } from '../../ui';
|
||||
import { Button } from '../../ui';
|
||||
import { Badge } from '../../ui';
|
||||
import { Select } from '../../ui';
|
||||
import { Input } from '../../ui';
|
||||
import {
|
||||
ForecastAlert,
|
||||
ForecastAlertType,
|
||||
AlertSeverity,
|
||||
} from '../../../types/forecasting.types';
|
||||
|
||||
export interface AlertsPanelProps {
|
||||
className?: string;
|
||||
title?: string;
|
||||
alerts?: ForecastAlert[];
|
||||
loading?: boolean;
|
||||
error?: string | null;
|
||||
onAlertAction?: (alertId: string, action: 'acknowledge' | 'resolve' | 'snooze' | 'production_adjust' | 'inventory_check') => void;
|
||||
onAlertDismiss?: (alertId: string) => void;
|
||||
onBulkAction?: (alertIds: string[], action: string) => void;
|
||||
showFilters?: boolean;
|
||||
compact?: boolean;
|
||||
maxItems?: number;
|
||||
autoRefresh?: boolean;
|
||||
refreshInterval?: number;
|
||||
}
|
||||
|
||||
interface AlertFilter {
|
||||
severity: AlertSeverity | 'all';
|
||||
type: ForecastAlertType | 'all';
|
||||
status: 'active' | 'acknowledged' | 'resolved' | 'all';
|
||||
product: string;
|
||||
dateRange: 'today' | 'week' | 'month' | 'all';
|
||||
}
|
||||
|
||||
interface AlertActionGroup {
|
||||
critical: AlertAction[];
|
||||
high: AlertAction[];
|
||||
medium: AlertAction[];
|
||||
low: AlertAction[];
|
||||
}
|
||||
|
||||
interface AlertAction {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: string;
|
||||
variant: 'primary' | 'secondary' | 'success' | 'warning' | 'danger';
|
||||
description: string;
|
||||
}
|
||||
|
||||
const SPANISH_ALERT_TYPES: Record<ForecastAlertType, string> = {
|
||||
[ForecastAlertType.HIGH_DEMAND_PREDICTED]: 'Alta Demanda Predicha',
|
||||
[ForecastAlertType.LOW_DEMAND_PREDICTED]: 'Baja Demanda Predicha',
|
||||
[ForecastAlertType.ACCURACY_DROP]: 'Caída de Precisión',
|
||||
[ForecastAlertType.MODEL_DRIFT]: 'Deriva del Modelo',
|
||||
[ForecastAlertType.DATA_ANOMALY]: 'Anomalía de Datos',
|
||||
[ForecastAlertType.MISSING_DATA]: 'Datos Faltantes',
|
||||
[ForecastAlertType.SEASONAL_SHIFT]: 'Cambio Estacional',
|
||||
};
|
||||
|
||||
const SPANISH_SEVERITIES: Record<AlertSeverity, string> = {
|
||||
[AlertSeverity.CRITICAL]: 'Crítica',
|
||||
[AlertSeverity.HIGH]: 'Alta',
|
||||
[AlertSeverity.MEDIUM]: 'Media',
|
||||
[AlertSeverity.LOW]: 'Baja',
|
||||
};
|
||||
|
||||
const SEVERITY_COLORS: Record<AlertSeverity, string> = {
|
||||
[AlertSeverity.CRITICAL]: 'text-[var(--color-error)] bg-red-50 border-red-200',
|
||||
[AlertSeverity.HIGH]: 'text-[var(--color-primary)] bg-orange-50 border-orange-200',
|
||||
[AlertSeverity.MEDIUM]: 'text-yellow-600 bg-yellow-50 border-yellow-200',
|
||||
[AlertSeverity.LOW]: 'text-[var(--color-info)] bg-[var(--color-info)]/5 border-[var(--color-info)]/20',
|
||||
};
|
||||
|
||||
const SEVERITY_BADGE_VARIANTS: Record<AlertSeverity, 'danger' | 'warning' | 'success' | 'info'> = {
|
||||
[AlertSeverity.CRITICAL]: 'danger',
|
||||
[AlertSeverity.HIGH]: 'warning',
|
||||
[AlertSeverity.MEDIUM]: 'warning',
|
||||
[AlertSeverity.LOW]: 'info',
|
||||
};
|
||||
|
||||
const ALERT_TYPE_ICONS: Record<ForecastAlertType, string> = {
|
||||
[ForecastAlertType.HIGH_DEMAND_PREDICTED]: '📈',
|
||||
[ForecastAlertType.LOW_DEMAND_PREDICTED]: '📉',
|
||||
[ForecastAlertType.ACCURACY_DROP]: '🎯',
|
||||
[ForecastAlertType.MODEL_DRIFT]: '🔄',
|
||||
[ForecastAlertType.DATA_ANOMALY]: '⚠️',
|
||||
[ForecastAlertType.MISSING_DATA]: '📊',
|
||||
[ForecastAlertType.SEASONAL_SHIFT]: '🍂',
|
||||
};
|
||||
|
||||
const ALERT_ACTIONS: AlertActionGroup = {
|
||||
critical: [
|
||||
{
|
||||
id: 'production_adjust',
|
||||
label: 'Ajustar Producción',
|
||||
icon: '🏭',
|
||||
variant: 'primary',
|
||||
description: 'Modificar inmediatamente el plan de producción',
|
||||
},
|
||||
{
|
||||
id: 'inventory_check',
|
||||
label: 'Verificar Inventario',
|
||||
icon: '📦',
|
||||
variant: 'warning',
|
||||
description: 'Revisar niveles de stock y materias primas',
|
||||
},
|
||||
{
|
||||
id: 'emergency_order',
|
||||
label: 'Pedido Urgente',
|
||||
icon: '🚚',
|
||||
variant: 'danger',
|
||||
description: 'Realizar pedido urgente a proveedores',
|
||||
},
|
||||
],
|
||||
high: [
|
||||
{
|
||||
id: 'production_adjust',
|
||||
label: 'Ajustar Producción',
|
||||
icon: '🏭',
|
||||
variant: 'primary',
|
||||
description: 'Ajustar plan de producción para mañana',
|
||||
},
|
||||
{
|
||||
id: 'inventory_alert',
|
||||
label: 'Alerta Inventario',
|
||||
icon: '📋',
|
||||
variant: 'warning',
|
||||
description: 'Crear alerta de inventario preventiva',
|
||||
},
|
||||
{
|
||||
id: 'team_notify',
|
||||
label: 'Notificar Equipo',
|
||||
icon: '👥',
|
||||
variant: 'secondary',
|
||||
description: 'Informar al equipo de producción',
|
||||
},
|
||||
],
|
||||
medium: [
|
||||
{
|
||||
id: 'production_review',
|
||||
label: 'Revisar Producción',
|
||||
icon: '📊',
|
||||
variant: 'secondary',
|
||||
description: 'Revisar plan de producción esta semana',
|
||||
},
|
||||
{
|
||||
id: 'monitor',
|
||||
label: 'Monitorear',
|
||||
icon: '👁️',
|
||||
variant: 'secondary',
|
||||
description: 'Mantener bajo observación',
|
||||
},
|
||||
],
|
||||
low: [
|
||||
{
|
||||
id: 'monitor',
|
||||
label: 'Monitorear',
|
||||
icon: '👁️',
|
||||
variant: 'secondary',
|
||||
description: 'Mantener bajo observación',
|
||||
},
|
||||
{
|
||||
id: 'data_review',
|
||||
label: 'Revisar Datos',
|
||||
icon: '🔍',
|
||||
variant: 'secondary',
|
||||
description: 'Revisar calidad de los datos',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const RECOMMENDATION_MESSAGES: Record<ForecastAlertType, (product: string, value: number) => string> = {
|
||||
[ForecastAlertType.HIGH_DEMAND_PREDICTED]: (product, value) =>
|
||||
`Se predice un aumento del ${value}% en la demanda de ${product}. Considera aumentar la producción.`,
|
||||
[ForecastAlertType.LOW_DEMAND_PREDICTED]: (product, value) =>
|
||||
`Se predice una disminución del ${Math.abs(value)}% en la demanda de ${product}. Considera reducir la producción para evitar desperdicios.`,
|
||||
[ForecastAlertType.ACCURACY_DROP]: (product, value) =>
|
||||
`La precisión del modelo para ${product} ha disminuido al ${value}%. Es recomendable reentrenar el modelo.`,
|
||||
[ForecastAlertType.MODEL_DRIFT]: (product, value) =>
|
||||
`Detectada deriva en el modelo de ${product}. Los patrones de demanda han cambiado significativamente.`,
|
||||
[ForecastAlertType.DATA_ANOMALY]: (product, value) =>
|
||||
`Anomalía detectada en los datos de ${product}. Verifica la calidad de los datos de entrada.`,
|
||||
[ForecastAlertType.MISSING_DATA]: (product, value) =>
|
||||
`Faltan ${value} días de datos para ${product}. Esto puede afectar la precisión de las predicciones.`,
|
||||
[ForecastAlertType.SEASONAL_SHIFT]: (product, value) =>
|
||||
`Detectado cambio en el patrón estacional de ${product}. El pico de demanda se ha adelantado/retrasado.`,
|
||||
};
|
||||
|
||||
const AlertsPanel: React.FC<AlertsPanelProps> = ({
|
||||
className,
|
||||
title = 'Alertas y Recomendaciones',
|
||||
alerts = [],
|
||||
loading = false,
|
||||
error = null,
|
||||
onAlertAction,
|
||||
onAlertDismiss,
|
||||
onBulkAction,
|
||||
showFilters = true,
|
||||
compact = false,
|
||||
maxItems,
|
||||
autoRefresh = false,
|
||||
refreshInterval = 30000,
|
||||
}) => {
|
||||
const [filters, setFilters] = useState<AlertFilter>({
|
||||
severity: 'all',
|
||||
type: 'all',
|
||||
status: 'active',
|
||||
product: '',
|
||||
dateRange: 'all',
|
||||
});
|
||||
|
||||
const [selectedAlerts, setSelectedAlerts] = useState<string[]>([]);
|
||||
const [expandedAlerts, setExpandedAlerts] = useState<string[]>([]);
|
||||
|
||||
// Filter alerts
|
||||
const filteredAlerts = useMemo(() => {
|
||||
let filtered = [...alerts];
|
||||
|
||||
// Filter by status
|
||||
if (filters.status !== 'all') {
|
||||
filtered = filtered.filter(alert => {
|
||||
if (filters.status === 'active') return alert.is_active && !alert.acknowledged_at && !alert.resolved_at;
|
||||
if (filters.status === 'acknowledged') return !!alert.acknowledged_at && !alert.resolved_at;
|
||||
if (filters.status === 'resolved') return !!alert.resolved_at;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
// Filter by severity
|
||||
if (filters.severity !== 'all') {
|
||||
filtered = filtered.filter(alert => alert.severity === filters.severity);
|
||||
}
|
||||
|
||||
// Filter by type
|
||||
if (filters.type !== 'all') {
|
||||
filtered = filtered.filter(alert => alert.alert_type === filters.type);
|
||||
}
|
||||
|
||||
// Filter by product
|
||||
if (filters.product) {
|
||||
filtered = filtered.filter(alert =>
|
||||
alert.product_name?.toLowerCase().includes(filters.product.toLowerCase())
|
||||
);
|
||||
}
|
||||
|
||||
// Filter by date range
|
||||
if (filters.dateRange !== 'all') {
|
||||
const now = new Date();
|
||||
const filterDate = new Date();
|
||||
|
||||
switch (filters.dateRange) {
|
||||
case 'today':
|
||||
filterDate.setHours(0, 0, 0, 0);
|
||||
break;
|
||||
case 'week':
|
||||
filterDate.setDate(now.getDate() - 7);
|
||||
break;
|
||||
case 'month':
|
||||
filterDate.setMonth(now.getMonth() - 1);
|
||||
break;
|
||||
}
|
||||
|
||||
filtered = filtered.filter(alert => new Date(alert.created_at) >= filterDate);
|
||||
}
|
||||
|
||||
// Sort by severity and creation date
|
||||
filtered.sort((a, b) => {
|
||||
const severityOrder: Record<AlertSeverity, number> = {
|
||||
[AlertSeverity.CRITICAL]: 4,
|
||||
[AlertSeverity.HIGH]: 3,
|
||||
[AlertSeverity.MEDIUM]: 2,
|
||||
[AlertSeverity.LOW]: 1,
|
||||
};
|
||||
|
||||
if (a.severity !== b.severity) {
|
||||
return severityOrder[b.severity] - severityOrder[a.severity];
|
||||
}
|
||||
|
||||
return new Date(b.created_at).getTime() - new Date(a.created_at).getTime();
|
||||
});
|
||||
|
||||
// Limit items if specified
|
||||
if (maxItems) {
|
||||
filtered = filtered.slice(0, maxItems);
|
||||
}
|
||||
|
||||
return filtered;
|
||||
}, [alerts, filters, maxItems]);
|
||||
|
||||
// Get alert statistics
|
||||
const alertStats = useMemo(() => {
|
||||
const stats = {
|
||||
total: alerts.length,
|
||||
active: alerts.filter(a => a.is_active && !a.acknowledged_at && !a.resolved_at).length,
|
||||
critical: alerts.filter(a => a.severity === AlertSeverity.CRITICAL && a.is_active).length,
|
||||
high: alerts.filter(a => a.severity === AlertSeverity.HIGH && a.is_active).length,
|
||||
};
|
||||
return stats;
|
||||
}, [alerts]);
|
||||
|
||||
// Handle alert expansion
|
||||
const toggleAlertExpansion = useCallback((alertId: string) => {
|
||||
setExpandedAlerts(prev =>
|
||||
prev.includes(alertId)
|
||||
? prev.filter(id => id !== alertId)
|
||||
: [...prev, alertId]
|
||||
);
|
||||
}, []);
|
||||
|
||||
// Handle alert selection
|
||||
const toggleAlertSelection = useCallback((alertId: string) => {
|
||||
setSelectedAlerts(prev =>
|
||||
prev.includes(alertId)
|
||||
? prev.filter(id => id !== alertId)
|
||||
: [...prev, alertId]
|
||||
);
|
||||
}, []);
|
||||
|
||||
// Handle bulk selection
|
||||
const handleSelectAll = useCallback(() => {
|
||||
const activeAlerts = filteredAlerts.filter(a => a.is_active).map(a => a.id);
|
||||
setSelectedAlerts(prev =>
|
||||
prev.length === activeAlerts.length ? [] : activeAlerts
|
||||
);
|
||||
}, [filteredAlerts]);
|
||||
|
||||
// Get available actions for alert
|
||||
const getAlertActions = useCallback((alert: ForecastAlert): AlertAction[] => {
|
||||
return ALERT_ACTIONS[alert.severity] || [];
|
||||
}, []);
|
||||
|
||||
// Get recommendation message
|
||||
const getRecommendation = useCallback((alert: ForecastAlert): string => {
|
||||
const generator = RECOMMENDATION_MESSAGES[alert.alert_type];
|
||||
if (!generator) return alert.message;
|
||||
|
||||
const value = alert.predicted_value || alert.threshold_value || 0;
|
||||
return generator(alert.product_name || 'Producto', value);
|
||||
}, []);
|
||||
|
||||
// Format time ago
|
||||
const formatTimeAgo = useCallback((dateString: string): string => {
|
||||
const date = new Date(dateString);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMins = Math.floor(diffMs / (1000 * 60));
|
||||
const diffHours = Math.floor(diffMins / 60);
|
||||
const diffDays = Math.floor(diffHours / 24);
|
||||
|
||||
if (diffMins < 1) return 'Ahora mismo';
|
||||
if (diffMins < 60) return `Hace ${diffMins} min`;
|
||||
if (diffHours < 24) return `Hace ${diffHours}h`;
|
||||
if (diffDays < 7) return `Hace ${diffDays}d`;
|
||||
return date.toLocaleDateString('es-ES');
|
||||
}, []);
|
||||
|
||||
// Loading state
|
||||
if (loading) {
|
||||
return (
|
||||
<Card className={className}>
|
||||
<CardHeader>
|
||||
<h3 className="text-lg font-semibold text-text-primary">{title}</h3>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<div className="flex items-center justify-center h-32">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-color-primary"></div>
|
||||
<span className="text-text-secondary">Cargando alertas...</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// Error state
|
||||
if (error) {
|
||||
return (
|
||||
<Card className={className} variant="outlined">
|
||||
<CardHeader>
|
||||
<h3 className="text-lg font-semibold text-text-primary">{title}</h3>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<div className="flex items-center justify-center h-32">
|
||||
<div className="text-center">
|
||||
<div className="text-red-500 mb-2">
|
||||
<svg className="w-8 h-8 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-text-secondary text-sm">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className={className}>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between flex-wrap gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<h3 className="text-lg font-semibold text-text-primary">{title}</h3>
|
||||
{alertStats.critical > 0 && (
|
||||
<Badge variant="danger" className="animate-pulse">
|
||||
{alertStats.critical} críticas
|
||||
</Badge>
|
||||
)}
|
||||
{alertStats.high > 0 && (
|
||||
<Badge variant="warning">
|
||||
{alertStats.high} altas
|
||||
</Badge>
|
||||
)}
|
||||
<Badge variant="ghost">
|
||||
{alertStats.active}/{alertStats.total} activas
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{selectedAlerts.length > 0 && onBulkAction && (
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onBulkAction(selectedAlerts, 'acknowledge')}
|
||||
>
|
||||
✓ Confirmar Todas
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onBulkAction(selectedAlerts, 'production_plan')}
|
||||
>
|
||||
📋 Plan Producción
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{autoRefresh && (
|
||||
<div className="flex items-center gap-1 text-xs text-text-tertiary">
|
||||
<div className="animate-spin w-3 h-3 border border-color-primary border-t-transparent rounded-full"></div>
|
||||
Auto-actualización
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
{showFilters && (
|
||||
<div className="px-6 py-3 border-b border-border-primary bg-bg-secondary">
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-3">
|
||||
<Select
|
||||
value={filters.status}
|
||||
onChange={(value) => setFilters(prev => ({ ...prev, status: value as any }))}
|
||||
className="text-sm"
|
||||
>
|
||||
<option value="all">Todos los estados</option>
|
||||
<option value="active">Activas</option>
|
||||
<option value="acknowledged">Confirmadas</option>
|
||||
<option value="resolved">Resueltas</option>
|
||||
</Select>
|
||||
|
||||
<Select
|
||||
value={filters.severity}
|
||||
onChange={(value) => setFilters(prev => ({ ...prev, severity: value as any }))}
|
||||
className="text-sm"
|
||||
>
|
||||
<option value="all">Toda severidad</option>
|
||||
{Object.entries(SPANISH_SEVERITIES).map(([key, label]) => (
|
||||
<option key={key} value={key}>{label}</option>
|
||||
))}
|
||||
</Select>
|
||||
|
||||
<Select
|
||||
value={filters.type}
|
||||
onChange={(value) => setFilters(prev => ({ ...prev, type: value as any }))}
|
||||
className="text-sm"
|
||||
>
|
||||
<option value="all">Todos los tipos</option>
|
||||
{Object.entries(SPANISH_ALERT_TYPES).map(([key, label]) => (
|
||||
<option key={key} value={key}>{label}</option>
|
||||
))}
|
||||
</Select>
|
||||
|
||||
<Input
|
||||
placeholder="Buscar producto..."
|
||||
value={filters.product}
|
||||
onChange={(e) => setFilters(prev => ({ ...prev, product: e.target.value }))}
|
||||
className="text-sm"
|
||||
/>
|
||||
|
||||
<Select
|
||||
value={filters.dateRange}
|
||||
onChange={(value) => setFilters(prev => ({ ...prev, dateRange: value as any }))}
|
||||
className="text-sm"
|
||||
>
|
||||
<option value="all">Todo el tiempo</option>
|
||||
<option value="today">Hoy</option>
|
||||
<option value="week">Última semana</option>
|
||||
<option value="month">Último mes</option>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<CardBody padding={compact ? 'sm' : 'md'}>
|
||||
{filteredAlerts.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-32">
|
||||
<div className="text-center">
|
||||
<div className="text-green-500 mb-2">
|
||||
<svg className="w-8 h-8 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-text-secondary text-sm">No hay alertas que mostrar</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{/* Bulk actions bar */}
|
||||
{filteredAlerts.some(a => a.is_active) && (
|
||||
<div className="flex items-center gap-3 pb-2 border-b border-border-secondary">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="rounded border-input-border focus:ring-color-primary"
|
||||
checked={selectedAlerts.length === filteredAlerts.filter(a => a.is_active).length}
|
||||
onChange={handleSelectAll}
|
||||
/>
|
||||
<span className="text-sm text-text-secondary">
|
||||
{selectedAlerts.length > 0
|
||||
? `${selectedAlerts.length} alertas seleccionadas`
|
||||
: 'Seleccionar todas'
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Alerts list */}
|
||||
{filteredAlerts.map((alert) => {
|
||||
const isExpanded = expandedAlerts.includes(alert.id);
|
||||
const isSelected = selectedAlerts.includes(alert.id);
|
||||
const availableActions = getAlertActions(alert);
|
||||
const recommendation = getRecommendation(alert);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={alert.id}
|
||||
className={clsx(
|
||||
'rounded-lg border transition-all duration-200',
|
||||
SEVERITY_COLORS[alert.severity],
|
||||
{
|
||||
'opacity-60': !alert.is_active || alert.resolved_at,
|
||||
'ring-2 ring-color-primary': isSelected,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<div className="p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
{/* Selection checkbox */}
|
||||
{alert.is_active && !alert.resolved_at && (
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-1 rounded border-input-border focus:ring-color-primary"
|
||||
checked={isSelected}
|
||||
onChange={() => toggleAlertSelection(alert.id)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Alert icon */}
|
||||
<div className="text-2xl mt-1">
|
||||
{ALERT_TYPE_ICONS[alert.alert_type]}
|
||||
</div>
|
||||
|
||||
{/* Alert content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<h4 className="font-semibold text-text-primary text-sm">
|
||||
{alert.title}
|
||||
</h4>
|
||||
<Badge
|
||||
variant={SEVERITY_BADGE_VARIANTS[alert.severity]}
|
||||
size="sm"
|
||||
>
|
||||
{SPANISH_SEVERITIES[alert.severity]}
|
||||
</Badge>
|
||||
{alert.product_name && (
|
||||
<Badge variant="ghost" size="sm">
|
||||
{alert.product_name}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-text-secondary text-sm mb-2">
|
||||
{compact ? alert.message.slice(0, 100) + '...' : alert.message}
|
||||
</p>
|
||||
|
||||
{!compact && (
|
||||
<p className="text-text-primary text-sm mb-3 font-medium">
|
||||
💡 {recommendation}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3 text-xs text-text-tertiary">
|
||||
<span>{formatTimeAgo(alert.created_at)}</span>
|
||||
{alert.acknowledged_at && (
|
||||
<span>• Confirmada</span>
|
||||
)}
|
||||
{alert.resolved_at && (
|
||||
<span>• Resuelta</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
{/* Quick actions */}
|
||||
{alert.is_active && !alert.resolved_at && (
|
||||
<div className="flex gap-1">
|
||||
{availableActions.slice(0, compact ? 1 : 2).map((action) => (
|
||||
<Button
|
||||
key={action.id}
|
||||
variant={action.variant as any}
|
||||
size="sm"
|
||||
onClick={() => onAlertAction?.(alert.id, action.id as any)}
|
||||
title={action.description}
|
||||
>
|
||||
{action.icon} {compact ? '' : action.label}
|
||||
</Button>
|
||||
))}
|
||||
|
||||
{availableActions.length > (compact ? 1 : 2) && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => toggleAlertExpansion(alert.id)}
|
||||
>
|
||||
{isExpanded ? '▼' : '▶'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Dismiss button */}
|
||||
{onAlertDismiss && alert.is_active && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onAlertDismiss(alert.id)}
|
||||
title="Descartar alerta"
|
||||
>
|
||||
✕
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expanded actions */}
|
||||
{isExpanded && availableActions.length > (compact ? 1 : 2) && (
|
||||
<div className="mt-3 pt-3 border-t border-border-secondary">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
|
||||
{availableActions.slice(compact ? 1 : 2).map((action) => (
|
||||
<Button
|
||||
key={action.id}
|
||||
variant={action.variant as any}
|
||||
size="sm"
|
||||
onClick={() => onAlertAction?.(alert.id, action.id as any)}
|
||||
className="justify-start"
|
||||
>
|
||||
<span className="mr-2">{action.icon}</span>
|
||||
<div className="text-left">
|
||||
<div className="font-medium">{action.label}</div>
|
||||
<div className="text-xs opacity-75">{action.description}</div>
|
||||
</div>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Alert details */}
|
||||
{!compact && isExpanded && (
|
||||
<div className="mt-3 pt-3 border-t border-border-secondary">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 text-xs">
|
||||
<div>
|
||||
<span className="text-text-secondary">Tipo:</span>
|
||||
<div className="font-medium">{SPANISH_ALERT_TYPES[alert.alert_type]}</div>
|
||||
</div>
|
||||
{alert.predicted_value && (
|
||||
<div>
|
||||
<span className="text-text-secondary">Valor Predicho:</span>
|
||||
<div className="font-medium">{alert.predicted_value}</div>
|
||||
</div>
|
||||
)}
|
||||
{alert.threshold_value && (
|
||||
<div>
|
||||
<span className="text-text-secondary">Umbral:</span>
|
||||
<div className="font-medium">{alert.threshold_value}</div>
|
||||
</div>
|
||||
)}
|
||||
{alert.model_accuracy && (
|
||||
<div>
|
||||
<span className="text-text-secondary">Precisión Modelo:</span>
|
||||
<div className="font-medium">{(alert.model_accuracy * 100).toFixed(1)}%</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default AlertsPanel;
|
||||
@@ -0,0 +1,725 @@
|
||||
import React, { useState, useMemo, useCallback } from 'react';
|
||||
import { clsx } from 'clsx';
|
||||
import { Card, CardHeader, CardBody } from '../../ui';
|
||||
import { Button } from '../../ui';
|
||||
import { Badge } from '../../ui';
|
||||
import { Select } from '../../ui';
|
||||
import { Input } from '../../ui';
|
||||
import {
|
||||
ForecastAlert,
|
||||
ForecastAlertType,
|
||||
AlertSeverity,
|
||||
} from '../../../types/forecasting.types';
|
||||
|
||||
export interface AlertsPanelProps {
|
||||
className?: string;
|
||||
title?: string;
|
||||
alerts?: ForecastAlert[];
|
||||
loading?: boolean;
|
||||
error?: string | null;
|
||||
onAlertAction?: (alertId: string, action: 'acknowledge' | 'resolve' | 'snooze' | 'production_adjust' | 'inventory_check') => void;
|
||||
onAlertDismiss?: (alertId: string) => void;
|
||||
onBulkAction?: (alertIds: string[], action: string) => void;
|
||||
showFilters?: boolean;
|
||||
compact?: boolean;
|
||||
maxItems?: number;
|
||||
autoRefresh?: boolean;
|
||||
refreshInterval?: number;
|
||||
}
|
||||
|
||||
interface AlertFilter {
|
||||
severity: AlertSeverity | 'all';
|
||||
type: ForecastAlertType | 'all';
|
||||
status: 'active' | 'acknowledged' | 'resolved' | 'all';
|
||||
product: string;
|
||||
dateRange: 'today' | 'week' | 'month' | 'all';
|
||||
}
|
||||
|
||||
interface AlertActionGroup {
|
||||
critical: AlertAction[];
|
||||
high: AlertAction[];
|
||||
medium: AlertAction[];
|
||||
low: AlertAction[];
|
||||
}
|
||||
|
||||
interface AlertAction {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: string;
|
||||
variant: 'primary' | 'secondary' | 'success' | 'warning' | 'danger';
|
||||
description: string;
|
||||
}
|
||||
|
||||
const SPANISH_ALERT_TYPES: Record<ForecastAlertType, string> = {
|
||||
[ForecastAlertType.HIGH_DEMAND_PREDICTED]: 'Alta Demanda Predicha',
|
||||
[ForecastAlertType.LOW_DEMAND_PREDICTED]: 'Baja Demanda Predicha',
|
||||
[ForecastAlertType.ACCURACY_DROP]: 'Caída de Precisión',
|
||||
[ForecastAlertType.MODEL_DRIFT]: 'Deriva del Modelo',
|
||||
[ForecastAlertType.DATA_ANOMALY]: 'Anomalía de Datos',
|
||||
[ForecastAlertType.MISSING_DATA]: 'Datos Faltantes',
|
||||
[ForecastAlertType.SEASONAL_SHIFT]: 'Cambio Estacional',
|
||||
};
|
||||
|
||||
const SPANISH_SEVERITIES: Record<AlertSeverity, string> = {
|
||||
[AlertSeverity.CRITICAL]: 'Crítica',
|
||||
[AlertSeverity.HIGH]: 'Alta',
|
||||
[AlertSeverity.MEDIUM]: 'Media',
|
||||
[AlertSeverity.LOW]: 'Baja',
|
||||
};
|
||||
|
||||
const SEVERITY_COLORS: Record<AlertSeverity, string> = {
|
||||
[AlertSeverity.CRITICAL]: 'text-red-600 bg-red-50 border-red-200',
|
||||
[AlertSeverity.HIGH]: 'text-orange-600 bg-orange-50 border-orange-200',
|
||||
[AlertSeverity.MEDIUM]: 'text-yellow-600 bg-yellow-50 border-yellow-200',
|
||||
[AlertSeverity.LOW]: 'text-blue-600 bg-blue-50 border-blue-200',
|
||||
};
|
||||
|
||||
const SEVERITY_BADGE_VARIANTS: Record<AlertSeverity, 'danger' | 'warning' | 'success' | 'info'> = {
|
||||
[AlertSeverity.CRITICAL]: 'danger',
|
||||
[AlertSeverity.HIGH]: 'warning',
|
||||
[AlertSeverity.MEDIUM]: 'warning',
|
||||
[AlertSeverity.LOW]: 'info',
|
||||
};
|
||||
|
||||
const ALERT_TYPE_ICONS: Record<ForecastAlertType, string> = {
|
||||
[ForecastAlertType.HIGH_DEMAND_PREDICTED]: '📈',
|
||||
[ForecastAlertType.LOW_DEMAND_PREDICTED]: '📉',
|
||||
[ForecastAlertType.ACCURACY_DROP]: '🎯',
|
||||
[ForecastAlertType.MODEL_DRIFT]: '🔄',
|
||||
[ForecastAlertType.DATA_ANOMALY]: '⚠️',
|
||||
[ForecastAlertType.MISSING_DATA]: '📊',
|
||||
[ForecastAlertType.SEASONAL_SHIFT]: '🍂',
|
||||
};
|
||||
|
||||
const ALERT_ACTIONS: AlertActionGroup = {
|
||||
critical: [
|
||||
{
|
||||
id: 'production_adjust',
|
||||
label: 'Ajustar Producción',
|
||||
icon: '🏭',
|
||||
variant: 'primary',
|
||||
description: 'Modificar inmediatamente el plan de producción',
|
||||
},
|
||||
{
|
||||
id: 'inventory_check',
|
||||
label: 'Verificar Inventario',
|
||||
icon: '📦',
|
||||
variant: 'warning',
|
||||
description: 'Revisar niveles de stock y materias primas',
|
||||
},
|
||||
{
|
||||
id: 'emergency_order',
|
||||
label: 'Pedido Urgente',
|
||||
icon: '🚚',
|
||||
variant: 'danger',
|
||||
description: 'Realizar pedido urgente a proveedores',
|
||||
},
|
||||
],
|
||||
high: [
|
||||
{
|
||||
id: 'production_adjust',
|
||||
label: 'Ajustar Producción',
|
||||
icon: '🏭',
|
||||
variant: 'primary',
|
||||
description: 'Ajustar plan de producción para mañana',
|
||||
},
|
||||
{
|
||||
id: 'inventory_alert',
|
||||
label: 'Alerta Inventario',
|
||||
icon: '📋',
|
||||
variant: 'warning',
|
||||
description: 'Crear alerta de inventario preventiva',
|
||||
},
|
||||
{
|
||||
id: 'team_notify',
|
||||
label: 'Notificar Equipo',
|
||||
icon: '👥',
|
||||
variant: 'secondary',
|
||||
description: 'Informar al equipo de producción',
|
||||
},
|
||||
],
|
||||
medium: [
|
||||
{
|
||||
id: 'production_review',
|
||||
label: 'Revisar Producción',
|
||||
icon: '📊',
|
||||
variant: 'secondary',
|
||||
description: 'Revisar plan de producción esta semana',
|
||||
},
|
||||
{
|
||||
id: 'monitor',
|
||||
label: 'Monitorear',
|
||||
icon: '👁️',
|
||||
variant: 'secondary',
|
||||
description: 'Mantener bajo observación',
|
||||
},
|
||||
],
|
||||
low: [
|
||||
{
|
||||
id: 'monitor',
|
||||
label: 'Monitorear',
|
||||
icon: '👁️',
|
||||
variant: 'secondary',
|
||||
description: 'Mantener bajo observación',
|
||||
},
|
||||
{
|
||||
id: 'data_review',
|
||||
label: 'Revisar Datos',
|
||||
icon: '🔍',
|
||||
variant: 'secondary',
|
||||
description: 'Revisar calidad de los datos',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const RECOMMENDATION_MESSAGES: Record<ForecastAlertType, (product: string, value: number) => string> = {
|
||||
[ForecastAlertType.HIGH_DEMAND_PREDICTED]: (product, value) =>
|
||||
`Se predice un aumento del ${value}% en la demanda de ${product}. Considera aumentar la producción.`,
|
||||
[ForecastAlertType.LOW_DEMAND_PREDICTED]: (product, value) =>
|
||||
`Se predice una disminución del ${Math.abs(value)}% en la demanda de ${product}. Considera reducir la producción para evitar desperdicios.`,
|
||||
[ForecastAlertType.ACCURACY_DROP]: (product, value) =>
|
||||
`La precisión del modelo para ${product} ha disminuido al ${value}%. Es recomendable reentrenar el modelo.`,
|
||||
[ForecastAlertType.MODEL_DRIFT]: (product, value) =>
|
||||
`Detectada deriva en el modelo de ${product}. Los patrones de demanda han cambiado significativamente.`,
|
||||
[ForecastAlertType.DATA_ANOMALY]: (product, value) =>
|
||||
`Anomalía detectada en los datos de ${product}. Verifica la calidad de los datos de entrada.`,
|
||||
[ForecastAlertType.MISSING_DATA]: (product, value) =>
|
||||
`Faltan ${value} días de datos para ${product}. Esto puede afectar la precisión de las predicciones.`,
|
||||
[ForecastAlertType.SEASONAL_SHIFT]: (product, value) =>
|
||||
`Detectado cambio en el patrón estacional de ${product}. El pico de demanda se ha adelantado/retrasado.`,
|
||||
};
|
||||
|
||||
const AlertsPanel: React.FC<AlertsPanelProps> = ({
|
||||
className,
|
||||
title = 'Alertas y Recomendaciones',
|
||||
alerts = [],
|
||||
loading = false,
|
||||
error = null,
|
||||
onAlertAction,
|
||||
onAlertDismiss,
|
||||
onBulkAction,
|
||||
showFilters = true,
|
||||
compact = false,
|
||||
maxItems,
|
||||
autoRefresh = false,
|
||||
refreshInterval = 30000,
|
||||
}) => {
|
||||
const [filters, setFilters] = useState<AlertFilter>({
|
||||
severity: 'all',
|
||||
type: 'all',
|
||||
status: 'active',
|
||||
product: '',
|
||||
dateRange: 'all',
|
||||
});
|
||||
|
||||
const [selectedAlerts, setSelectedAlerts] = useState<string[]>([]);
|
||||
const [expandedAlerts, setExpandedAlerts] = useState<string[]>([]);
|
||||
|
||||
// Filter alerts
|
||||
const filteredAlerts = useMemo(() => {
|
||||
let filtered = [...alerts];
|
||||
|
||||
// Filter by status
|
||||
if (filters.status !== 'all') {
|
||||
filtered = filtered.filter(alert => {
|
||||
if (filters.status === 'active') return alert.is_active && !alert.acknowledged_at && !alert.resolved_at;
|
||||
if (filters.status === 'acknowledged') return !!alert.acknowledged_at && !alert.resolved_at;
|
||||
if (filters.status === 'resolved') return !!alert.resolved_at;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
// Filter by severity
|
||||
if (filters.severity !== 'all') {
|
||||
filtered = filtered.filter(alert => alert.severity === filters.severity);
|
||||
}
|
||||
|
||||
// Filter by type
|
||||
if (filters.type !== 'all') {
|
||||
filtered = filtered.filter(alert => alert.alert_type === filters.type);
|
||||
}
|
||||
|
||||
// Filter by product
|
||||
if (filters.product) {
|
||||
filtered = filtered.filter(alert =>
|
||||
alert.product_name?.toLowerCase().includes(filters.product.toLowerCase())
|
||||
);
|
||||
}
|
||||
|
||||
// Filter by date range
|
||||
if (filters.dateRange !== 'all') {
|
||||
const now = new Date();
|
||||
const filterDate = new Date();
|
||||
|
||||
switch (filters.dateRange) {
|
||||
case 'today':
|
||||
filterDate.setHours(0, 0, 0, 0);
|
||||
break;
|
||||
case 'week':
|
||||
filterDate.setDate(now.getDate() - 7);
|
||||
break;
|
||||
case 'month':
|
||||
filterDate.setMonth(now.getMonth() - 1);
|
||||
break;
|
||||
}
|
||||
|
||||
filtered = filtered.filter(alert => new Date(alert.created_at) >= filterDate);
|
||||
}
|
||||
|
||||
// Sort by severity and creation date
|
||||
filtered.sort((a, b) => {
|
||||
const severityOrder: Record<AlertSeverity, number> = {
|
||||
[AlertSeverity.CRITICAL]: 4,
|
||||
[AlertSeverity.HIGH]: 3,
|
||||
[AlertSeverity.MEDIUM]: 2,
|
||||
[AlertSeverity.LOW]: 1,
|
||||
};
|
||||
|
||||
if (a.severity !== b.severity) {
|
||||
return severityOrder[b.severity] - severityOrder[a.severity];
|
||||
}
|
||||
|
||||
return new Date(b.created_at).getTime() - new Date(a.created_at).getTime();
|
||||
});
|
||||
|
||||
// Limit items if specified
|
||||
if (maxItems) {
|
||||
filtered = filtered.slice(0, maxItems);
|
||||
}
|
||||
|
||||
return filtered;
|
||||
}, [alerts, filters, maxItems]);
|
||||
|
||||
// Get alert statistics
|
||||
const alertStats = useMemo(() => {
|
||||
const stats = {
|
||||
total: alerts.length,
|
||||
active: alerts.filter(a => a.is_active && !a.acknowledged_at && !a.resolved_at).length,
|
||||
critical: alerts.filter(a => a.severity === AlertSeverity.CRITICAL && a.is_active).length,
|
||||
high: alerts.filter(a => a.severity === AlertSeverity.HIGH && a.is_active).length,
|
||||
};
|
||||
return stats;
|
||||
}, [alerts]);
|
||||
|
||||
// Handle alert expansion
|
||||
const toggleAlertExpansion = useCallback((alertId: string) => {
|
||||
setExpandedAlerts(prev =>
|
||||
prev.includes(alertId)
|
||||
? prev.filter(id => id !== alertId)
|
||||
: [...prev, alertId]
|
||||
);
|
||||
}, []);
|
||||
|
||||
// Handle alert selection
|
||||
const toggleAlertSelection = useCallback((alertId: string) => {
|
||||
setSelectedAlerts(prev =>
|
||||
prev.includes(alertId)
|
||||
? prev.filter(id => id !== alertId)
|
||||
: [...prev, alertId]
|
||||
);
|
||||
}, []);
|
||||
|
||||
// Handle bulk selection
|
||||
const handleSelectAll = useCallback(() => {
|
||||
const activeAlerts = filteredAlerts.filter(a => a.is_active).map(a => a.id);
|
||||
setSelectedAlerts(prev =>
|
||||
prev.length === activeAlerts.length ? [] : activeAlerts
|
||||
);
|
||||
}, [filteredAlerts]);
|
||||
|
||||
// Get available actions for alert
|
||||
const getAlertActions = useCallback((alert: ForecastAlert): AlertAction[] => {
|
||||
return ALERT_ACTIONS[alert.severity] || [];
|
||||
}, []);
|
||||
|
||||
// Get recommendation message
|
||||
const getRecommendation = useCallback((alert: ForecastAlert): string => {
|
||||
const generator = RECOMMENDATION_MESSAGES[alert.alert_type];
|
||||
if (!generator) return alert.message;
|
||||
|
||||
const value = alert.predicted_value || alert.threshold_value || 0;
|
||||
return generator(alert.product_name || 'Producto', value);
|
||||
}, []);
|
||||
|
||||
// Format time ago
|
||||
const formatTimeAgo = useCallback((dateString: string): string => {
|
||||
const date = new Date(dateString);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMins = Math.floor(diffMs / (1000 * 60));
|
||||
const diffHours = Math.floor(diffMins / 60);
|
||||
const diffDays = Math.floor(diffHours / 24);
|
||||
|
||||
if (diffMins < 1) return 'Ahora mismo';
|
||||
if (diffMins < 60) return `Hace ${diffMins} min`;
|
||||
if (diffHours < 24) return `Hace ${diffHours}h`;
|
||||
if (diffDays < 7) return `Hace ${diffDays}d`;
|
||||
return date.toLocaleDateString('es-ES');
|
||||
}, []);
|
||||
|
||||
// Loading state
|
||||
if (loading) {
|
||||
return (
|
||||
<Card className={className}>
|
||||
<CardHeader>
|
||||
<h3 className="text-lg font-semibold text-text-primary">{title}</h3>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<div className="flex items-center justify-center h-32">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-color-primary"></div>
|
||||
<span className="text-text-secondary">Cargando alertas...</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// Error state
|
||||
if (error) {
|
||||
return (
|
||||
<Card className={className} variant="outlined">
|
||||
<CardHeader>
|
||||
<h3 className="text-lg font-semibold text-text-primary">{title}</h3>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<div className="flex items-center justify-center h-32">
|
||||
<div className="text-center">
|
||||
<div className="text-red-500 mb-2">
|
||||
<svg className="w-8 h-8 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-text-secondary text-sm">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className={className}>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between flex-wrap gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<h3 className="text-lg font-semibold text-text-primary">{title}</h3>
|
||||
{alertStats.critical > 0 && (
|
||||
<Badge variant="danger" className="animate-pulse">
|
||||
{alertStats.critical} críticas
|
||||
</Badge>
|
||||
)}
|
||||
{alertStats.high > 0 && (
|
||||
<Badge variant="warning">
|
||||
{alertStats.high} altas
|
||||
</Badge>
|
||||
)}
|
||||
<Badge variant="ghost">
|
||||
{alertStats.active}/{alertStats.total} activas
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{selectedAlerts.length > 0 && onBulkAction && (
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onBulkAction(selectedAlerts, 'acknowledge')}
|
||||
>
|
||||
✓ Confirmar Todas
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onBulkAction(selectedAlerts, 'production_plan')}
|
||||
>
|
||||
📋 Plan Producción
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{autoRefresh && (
|
||||
<div className="flex items-center gap-1 text-xs text-text-tertiary">
|
||||
<div className="animate-spin w-3 h-3 border border-color-primary border-t-transparent rounded-full"></div>
|
||||
Auto-actualización
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
{showFilters && (
|
||||
<div className="px-6 py-3 border-b border-border-primary bg-bg-secondary">
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-3">
|
||||
<Select
|
||||
value={filters.status}
|
||||
onChange={(value) => setFilters(prev => ({ ...prev, status: value as any }))}
|
||||
className="text-sm"
|
||||
>
|
||||
<option value="all">Todos los estados</option>
|
||||
<option value="active">Activas</option>
|
||||
<option value="acknowledged">Confirmadas</option>
|
||||
<option value="resolved">Resueltas</option>
|
||||
</Select>
|
||||
|
||||
<Select
|
||||
value={filters.severity}
|
||||
onChange={(value) => setFilters(prev => ({ ...prev, severity: value as any }))}
|
||||
className="text-sm"
|
||||
>
|
||||
<option value="all">Toda severidad</option>
|
||||
{Object.entries(SPANISH_SEVERITIES).map(([key, label]) => (
|
||||
<option key={key} value={key}>{label}</option>
|
||||
))}
|
||||
</Select>
|
||||
|
||||
<Select
|
||||
value={filters.type}
|
||||
onChange={(value) => setFilters(prev => ({ ...prev, type: value as any }))}
|
||||
className="text-sm"
|
||||
>
|
||||
<option value="all">Todos los tipos</option>
|
||||
{Object.entries(SPANISH_ALERT_TYPES).map(([key, label]) => (
|
||||
<option key={key} value={key}>{label}</option>
|
||||
))}
|
||||
</Select>
|
||||
|
||||
<Input
|
||||
placeholder="Buscar producto..."
|
||||
value={filters.product}
|
||||
onChange={(e) => setFilters(prev => ({ ...prev, product: e.target.value }))}
|
||||
className="text-sm"
|
||||
/>
|
||||
|
||||
<Select
|
||||
value={filters.dateRange}
|
||||
onChange={(value) => setFilters(prev => ({ ...prev, dateRange: value as any }))}
|
||||
className="text-sm"
|
||||
>
|
||||
<option value="all">Todo el tiempo</option>
|
||||
<option value="today">Hoy</option>
|
||||
<option value="week">Última semana</option>
|
||||
<option value="month">Último mes</option>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<CardBody padding={compact ? 'sm' : 'md'}>
|
||||
{filteredAlerts.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-32">
|
||||
<div className="text-center">
|
||||
<div className="text-green-500 mb-2">
|
||||
<svg className="w-8 h-8 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-text-secondary text-sm">No hay alertas que mostrar</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{/* Bulk actions bar */}
|
||||
{filteredAlerts.some(a => a.is_active) && (
|
||||
<div className="flex items-center gap-3 pb-2 border-b border-border-secondary">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="rounded border-input-border focus:ring-color-primary"
|
||||
checked={selectedAlerts.length === filteredAlerts.filter(a => a.is_active).length}
|
||||
onChange={handleSelectAll}
|
||||
/>
|
||||
<span className="text-sm text-text-secondary">
|
||||
{selectedAlerts.length > 0
|
||||
? `${selectedAlerts.length} alertas seleccionadas`
|
||||
: 'Seleccionar todas'
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Alerts list */}
|
||||
{filteredAlerts.map((alert) => {
|
||||
const isExpanded = expandedAlerts.includes(alert.id);
|
||||
const isSelected = selectedAlerts.includes(alert.id);
|
||||
const availableActions = getAlertActions(alert);
|
||||
const recommendation = getRecommendation(alert);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={alert.id}
|
||||
className={clsx(
|
||||
'rounded-lg border transition-all duration-200',
|
||||
SEVERITY_COLORS[alert.severity],
|
||||
{
|
||||
'opacity-60': !alert.is_active || alert.resolved_at,
|
||||
'ring-2 ring-color-primary': isSelected,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<div className="p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
{/* Selection checkbox */}
|
||||
{alert.is_active && !alert.resolved_at && (
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-1 rounded border-input-border focus:ring-color-primary"
|
||||
checked={isSelected}
|
||||
onChange={() => toggleAlertSelection(alert.id)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Alert icon */}
|
||||
<div className="text-2xl mt-1">
|
||||
{ALERT_TYPE_ICONS[alert.alert_type]}
|
||||
</div>
|
||||
|
||||
{/* Alert content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<h4 className="font-semibold text-text-primary text-sm">
|
||||
{alert.title}
|
||||
</h4>
|
||||
<Badge
|
||||
variant={SEVERITY_BADGE_VARIANTS[alert.severity]}
|
||||
size="sm"
|
||||
>
|
||||
{SPANISH_SEVERITIES[alert.severity]}
|
||||
</Badge>
|
||||
{alert.product_name && (
|
||||
<Badge variant="ghost" size="sm">
|
||||
{alert.product_name}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-text-secondary text-sm mb-2">
|
||||
{compact ? alert.message.slice(0, 100) + '...' : alert.message}
|
||||
</p>
|
||||
|
||||
{!compact && (
|
||||
<p className="text-text-primary text-sm mb-3 font-medium">
|
||||
💡 {recommendation}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3 text-xs text-text-tertiary">
|
||||
<span>{formatTimeAgo(alert.created_at)}</span>
|
||||
{alert.acknowledged_at && (
|
||||
<span>• Confirmada</span>
|
||||
)}
|
||||
{alert.resolved_at && (
|
||||
<span>• Resuelta</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
{/* Quick actions */}
|
||||
{alert.is_active && !alert.resolved_at && (
|
||||
<div className="flex gap-1">
|
||||
{availableActions.slice(0, compact ? 1 : 2).map((action) => (
|
||||
<Button
|
||||
key={action.id}
|
||||
variant={action.variant as any}
|
||||
size="sm"
|
||||
onClick={() => onAlertAction?.(alert.id, action.id as any)}
|
||||
title={action.description}
|
||||
>
|
||||
{action.icon} {compact ? '' : action.label}
|
||||
</Button>
|
||||
))}
|
||||
|
||||
{availableActions.length > (compact ? 1 : 2) && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => toggleAlertExpansion(alert.id)}
|
||||
>
|
||||
{isExpanded ? '▼' : '▶'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Dismiss button */}
|
||||
{onAlertDismiss && alert.is_active && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onAlertDismiss(alert.id)}
|
||||
title="Descartar alerta"
|
||||
>
|
||||
✕
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expanded actions */}
|
||||
{isExpanded && availableActions.length > (compact ? 1 : 2) && (
|
||||
<div className="mt-3 pt-3 border-t border-border-secondary">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
|
||||
{availableActions.slice(compact ? 1 : 2).map((action) => (
|
||||
<Button
|
||||
key={action.id}
|
||||
variant={action.variant as any}
|
||||
size="sm"
|
||||
onClick={() => onAlertAction?.(alert.id, action.id as any)}
|
||||
className="justify-start"
|
||||
>
|
||||
<span className="mr-2">{action.icon}</span>
|
||||
<div className="text-left">
|
||||
<div className="font-medium">{action.label}</div>
|
||||
<div className="text-xs opacity-75">{action.description}</div>
|
||||
</div>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Alert details */}
|
||||
{!compact && isExpanded && (
|
||||
<div className="mt-3 pt-3 border-t border-border-secondary">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 text-xs">
|
||||
<div>
|
||||
<span className="text-text-secondary">Tipo:</span>
|
||||
<div className="font-medium">{SPANISH_ALERT_TYPES[alert.alert_type]}</div>
|
||||
</div>
|
||||
{alert.predicted_value && (
|
||||
<div>
|
||||
<span className="text-text-secondary">Valor Predicho:</span>
|
||||
<div className="font-medium">{alert.predicted_value}</div>
|
||||
</div>
|
||||
)}
|
||||
{alert.threshold_value && (
|
||||
<div>
|
||||
<span className="text-text-secondary">Umbral:</span>
|
||||
<div className="font-medium">{alert.threshold_value}</div>
|
||||
</div>
|
||||
)}
|
||||
{alert.model_accuracy && (
|
||||
<div>
|
||||
<span className="text-text-secondary">Precisión Modelo:</span>
|
||||
<div className="font-medium">{(alert.model_accuracy * 100).toFixed(1)}%</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default AlertsPanel;
|
||||
575
frontend/src/components/domain/forecasting/DemandChart.tsx
Normal file
575
frontend/src/components/domain/forecasting/DemandChart.tsx
Normal file
@@ -0,0 +1,575 @@
|
||||
import React, { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import { clsx } from 'clsx';
|
||||
import {
|
||||
LineChart,
|
||||
Line,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
ReferenceLine,
|
||||
Area,
|
||||
ComposedChart,
|
||||
Legend,
|
||||
Brush,
|
||||
ReferenceDot,
|
||||
} from 'recharts';
|
||||
import { Card, CardHeader, CardBody } from '../../ui';
|
||||
import { Button } from '../../ui';
|
||||
import { Select } from '../../ui';
|
||||
import { Badge } from '../../ui';
|
||||
import {
|
||||
ForecastResponse,
|
||||
DemandTrend,
|
||||
TrendDirection,
|
||||
WeatherCondition,
|
||||
EventType,
|
||||
} from '../../../types/forecasting.types';
|
||||
|
||||
export interface DemandChartProps {
|
||||
className?: string;
|
||||
title?: string;
|
||||
data?: DemandTrend[];
|
||||
products?: string[];
|
||||
selectedProducts?: string[];
|
||||
onProductSelectionChange?: (products: string[]) => void;
|
||||
timeframe?: 'weekly' | 'monthly' | 'quarterly' | 'yearly';
|
||||
onTimeframeChange?: (timeframe: 'weekly' | 'monthly' | 'quarterly' | 'yearly') => void;
|
||||
showConfidenceInterval?: boolean;
|
||||
showEvents?: boolean;
|
||||
showWeatherOverlay?: boolean;
|
||||
events?: Array<{
|
||||
date: string;
|
||||
type: EventType;
|
||||
name: string;
|
||||
impact: 'high' | 'medium' | 'low';
|
||||
}>;
|
||||
weatherData?: Array<{
|
||||
date: string;
|
||||
condition: WeatherCondition;
|
||||
temperature: number;
|
||||
}>;
|
||||
loading?: boolean;
|
||||
error?: string | null;
|
||||
height?: number;
|
||||
onExport?: (format: 'png' | 'pdf' | 'csv') => void;
|
||||
}
|
||||
|
||||
interface ChartDataPoint {
|
||||
date: string;
|
||||
actualDemand?: number;
|
||||
predictedDemand?: number;
|
||||
confidenceLower?: number;
|
||||
confidenceUpper?: number;
|
||||
accuracy?: number;
|
||||
trendDirection?: TrendDirection;
|
||||
seasonalFactor?: number;
|
||||
anomalyScore?: number;
|
||||
hasEvent?: boolean;
|
||||
eventType?: EventType;
|
||||
eventName?: string;
|
||||
eventImpact?: 'high' | 'medium' | 'low';
|
||||
weather?: WeatherCondition;
|
||||
temperature?: number;
|
||||
}
|
||||
|
||||
const PRODUCT_COLORS = [
|
||||
'#8884d8', // Croissants
|
||||
'#82ca9d', // Pan de molde
|
||||
'#ffc658', // Roscón de Reyes
|
||||
'#ff7c7c', // Torrijas
|
||||
'#8dd1e1', // Magdalenas
|
||||
'#d084d0', // Empanadas
|
||||
'#ffb347', // Tarta de Santiago
|
||||
'#87ceeb', // Mazapán
|
||||
];
|
||||
|
||||
const EVENT_ICONS: Record<EventType, string> = {
|
||||
[EventType.HOLIDAY]: '🎉',
|
||||
[EventType.FESTIVAL]: '🎪',
|
||||
[EventType.SPORTS_EVENT]: '⚽',
|
||||
[EventType.WEATHER_EVENT]: '🌧️',
|
||||
[EventType.SCHOOL_EVENT]: '🎒',
|
||||
[EventType.CONCERT]: '🎵',
|
||||
[EventType.CONFERENCE]: '📊',
|
||||
[EventType.CONSTRUCTION]: '🚧',
|
||||
};
|
||||
|
||||
const WEATHER_ICONS: Record<WeatherCondition, string> = {
|
||||
[WeatherCondition.SUNNY]: '☀️',
|
||||
[WeatherCondition.CLOUDY]: '☁️',
|
||||
[WeatherCondition.RAINY]: '🌧️',
|
||||
[WeatherCondition.STORMY]: '⛈️',
|
||||
[WeatherCondition.SNOWY]: '❄️',
|
||||
[WeatherCondition.FOGGY]: '🌫️',
|
||||
[WeatherCondition.WINDY]: '🌪️',
|
||||
};
|
||||
|
||||
const SPANISH_EVENT_NAMES: Record<EventType, string> = {
|
||||
[EventType.HOLIDAY]: 'Festividad',
|
||||
[EventType.FESTIVAL]: 'Festival',
|
||||
[EventType.SPORTS_EVENT]: 'Evento Deportivo',
|
||||
[EventType.WEATHER_EVENT]: 'Evento Climático',
|
||||
[EventType.SCHOOL_EVENT]: 'Evento Escolar',
|
||||
[EventType.CONCERT]: 'Concierto',
|
||||
[EventType.CONFERENCE]: 'Conferencia',
|
||||
[EventType.CONSTRUCTION]: 'Construcción',
|
||||
};
|
||||
|
||||
const DemandChart: React.FC<DemandChartProps> = ({
|
||||
className,
|
||||
title = 'Predicción de Demanda',
|
||||
data = [],
|
||||
products = [],
|
||||
selectedProducts = [],
|
||||
onProductSelectionChange,
|
||||
timeframe = 'weekly',
|
||||
onTimeframeChange,
|
||||
showConfidenceInterval = true,
|
||||
showEvents = true,
|
||||
showWeatherOverlay = false,
|
||||
events = [],
|
||||
weatherData = [],
|
||||
loading = false,
|
||||
error = null,
|
||||
height = 400,
|
||||
onExport,
|
||||
}) => {
|
||||
const [selectedPeriod, setSelectedPeriod] = useState<{ start?: Date; end?: Date }>({});
|
||||
const [zoomedData, setZoomedData] = useState<ChartDataPoint[]>([]);
|
||||
const [hoveredPoint, setHoveredPoint] = useState<ChartDataPoint | null>(null);
|
||||
|
||||
// Process and merge data with events and weather
|
||||
const chartData = useMemo(() => {
|
||||
const processedData: ChartDataPoint[] = data.map(point => {
|
||||
const dateStr = point.date;
|
||||
const event = events.find(e => e.date === dateStr);
|
||||
const weather = weatherData.find(w => w.date === dateStr);
|
||||
|
||||
return {
|
||||
date: dateStr,
|
||||
actualDemand: point.actual_demand,
|
||||
predictedDemand: point.predicted_demand,
|
||||
confidenceLower: point.confidence_lower,
|
||||
confidenceUpper: point.confidence_upper,
|
||||
accuracy: point.accuracy,
|
||||
trendDirection: point.trend_direction,
|
||||
seasonalFactor: point.seasonal_factor,
|
||||
anomalyScore: point.anomaly_score,
|
||||
hasEvent: !!event,
|
||||
eventType: event?.type,
|
||||
eventName: event?.name,
|
||||
eventImpact: event?.impact,
|
||||
weather: weather?.condition,
|
||||
temperature: weather?.temperature,
|
||||
};
|
||||
});
|
||||
|
||||
return processedData;
|
||||
}, [data, events, weatherData]);
|
||||
|
||||
// Filter data based on selected period
|
||||
const filteredData = useMemo(() => {
|
||||
if (!selectedPeriod.start || !selectedPeriod.end) {
|
||||
return chartData;
|
||||
}
|
||||
|
||||
return chartData.filter(point => {
|
||||
const pointDate = new Date(point.date);
|
||||
return pointDate >= selectedPeriod.start! && pointDate <= selectedPeriod.end!;
|
||||
});
|
||||
}, [chartData, selectedPeriod]);
|
||||
|
||||
// Update zoomed data when filtered data changes
|
||||
useEffect(() => {
|
||||
setZoomedData(filteredData);
|
||||
}, [filteredData]);
|
||||
|
||||
// Handle brush selection
|
||||
const handleBrushChange = useCallback((brushData: any) => {
|
||||
if (brushData && brushData.startIndex !== undefined && brushData.endIndex !== undefined) {
|
||||
const newData = filteredData.slice(brushData.startIndex, brushData.endIndex + 1);
|
||||
setZoomedData(newData);
|
||||
} else {
|
||||
setZoomedData(filteredData);
|
||||
}
|
||||
}, [filteredData]);
|
||||
|
||||
// Reset zoom
|
||||
const handleResetZoom = useCallback(() => {
|
||||
setZoomedData(filteredData);
|
||||
setSelectedPeriod({});
|
||||
}, [filteredData]);
|
||||
|
||||
// Custom tooltip
|
||||
const CustomTooltip = ({ active, payload, label }: any) => {
|
||||
if (!active || !payload || !payload.length) return null;
|
||||
|
||||
const data = payload[0].payload as ChartDataPoint;
|
||||
const formattedDate = new Date(label).toLocaleDateString('es-ES', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="bg-card-bg border border-border-primary rounded-lg shadow-lg p-4 max-w-sm">
|
||||
<h4 className="font-semibold text-text-primary mb-2">{formattedDate}</h4>
|
||||
|
||||
<div className="space-y-1">
|
||||
{data.actualDemand !== undefined && (
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-text-secondary text-sm">Demanda Real:</span>
|
||||
<span className="font-medium text-[var(--color-info)]">{data.actualDemand}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data.predictedDemand !== undefined && (
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-text-secondary text-sm">Demanda Predicha:</span>
|
||||
<span className="font-medium text-[var(--color-success)]">{data.predictedDemand.toFixed(1)}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showConfidenceInterval && data.confidenceLower !== undefined && data.confidenceUpper !== undefined && (
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-text-secondary text-sm">Intervalo de Confianza:</span>
|
||||
<span className="text-sm text-text-tertiary">
|
||||
{data.confidenceLower.toFixed(1)} - {data.confidenceUpper.toFixed(1)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data.accuracy !== undefined && (
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-text-secondary text-sm">Precisión:</span>
|
||||
<Badge
|
||||
variant={data.accuracy > 0.8 ? 'success' : data.accuracy > 0.6 ? 'warning' : 'danger'}
|
||||
size="sm"
|
||||
>
|
||||
{(data.accuracy * 100).toFixed(1)}%
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showEvents && data.hasEvent && (
|
||||
<div className="mt-3 pt-2 border-t border-border-secondary">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-lg">{EVENT_ICONS[data.eventType!]}</span>
|
||||
<div className="flex-1">
|
||||
<div className="font-medium text-text-primary text-sm">{data.eventName}</div>
|
||||
<div className="text-xs text-text-tertiary">
|
||||
{SPANISH_EVENT_NAMES[data.eventType!]}
|
||||
</div>
|
||||
</div>
|
||||
<Badge
|
||||
variant={data.eventImpact === 'high' ? 'danger' : data.eventImpact === 'medium' ? 'warning' : 'success'}
|
||||
size="sm"
|
||||
>
|
||||
{data.eventImpact === 'high' ? 'Alto' : data.eventImpact === 'medium' ? 'Medio' : 'Bajo'}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showWeatherOverlay && data.weather && (
|
||||
<div className="mt-3 pt-2 border-t border-border-secondary">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-lg">{WEATHER_ICONS[data.weather]}</span>
|
||||
<div className="flex-1">
|
||||
<div className="text-sm text-text-primary capitalize">
|
||||
{data.weather.replace('_', ' ')}
|
||||
</div>
|
||||
{data.temperature && (
|
||||
<div className="text-xs text-text-tertiary">
|
||||
{data.temperature}°C
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Event reference dots
|
||||
const renderEventDots = () => {
|
||||
if (!showEvents) return null;
|
||||
|
||||
return events
|
||||
.filter(event => {
|
||||
const eventData = zoomedData.find(d => d.date === event.date);
|
||||
return eventData;
|
||||
})
|
||||
.map((event, index) => {
|
||||
const eventData = zoomedData.find(d => d.date === event.date);
|
||||
if (!eventData) return null;
|
||||
|
||||
const yValue = eventData.predictedDemand || eventData.actualDemand || 0;
|
||||
|
||||
return (
|
||||
<ReferenceDot
|
||||
key={`event-${index}`}
|
||||
x={event.date}
|
||||
y={yValue}
|
||||
r={8}
|
||||
fill={event.impact === 'high' ? '#ef4444' : event.impact === 'medium' ? '#f59e0b' : '#10b981'}
|
||||
stroke="#ffffff"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
// Loading state
|
||||
if (loading) {
|
||||
return (
|
||||
<Card className={className}>
|
||||
<CardHeader>
|
||||
<h3 className="text-lg font-semibold text-text-primary">{title}</h3>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-color-primary"></div>
|
||||
<span className="text-text-secondary">Cargando gráfico de demanda...</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// Error state
|
||||
if (error) {
|
||||
return (
|
||||
<Card className={className} variant="outlined">
|
||||
<CardHeader>
|
||||
<h3 className="text-lg font-semibold text-text-primary">{title}</h3>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-center">
|
||||
<div className="text-red-500 mb-2">
|
||||
<svg className="w-12 h-12 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-text-secondary">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// Empty state
|
||||
if (zoomedData.length === 0) {
|
||||
return (
|
||||
<Card className={className}>
|
||||
<CardHeader>
|
||||
<h3 className="text-lg font-semibold text-text-primary">{title}</h3>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-center">
|
||||
<div className="text-text-tertiary mb-2">
|
||||
<svg className="w-12 h-12 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-text-secondary">No hay datos de demanda disponibles</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className={className}>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between flex-wrap gap-4">
|
||||
<h3 className="text-lg font-semibold text-text-primary">{title}</h3>
|
||||
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
{/* Time period selector */}
|
||||
<Select
|
||||
value={timeframe}
|
||||
onChange={(value) => onTimeframeChange?.(value as 'weekly' | 'monthly' | 'quarterly' | 'yearly')}
|
||||
className="w-32"
|
||||
>
|
||||
<option value="weekly">Semanal</option>
|
||||
<option value="monthly">Mensual</option>
|
||||
<option value="quarterly">Trimestral</option>
|
||||
<option value="yearly">Anual</option>
|
||||
</Select>
|
||||
|
||||
{/* Export options */}
|
||||
{onExport && (
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onExport('png')}
|
||||
title="Exportar como PNG"
|
||||
>
|
||||
📊
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onExport('csv')}
|
||||
title="Exportar datos CSV"
|
||||
>
|
||||
📋
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Reset zoom */}
|
||||
{zoomedData.length !== filteredData.length && (
|
||||
<Button variant="ghost" size="sm" onClick={handleResetZoom}>
|
||||
Restablecer Zoom
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardBody padding="lg">
|
||||
<div style={{ width: '100%', height }}>
|
||||
<ResponsiveContainer>
|
||||
<ComposedChart data={zoomedData} margin={{ top: 20, right: 30, left: 20, bottom: 5 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
stroke="#6b7280"
|
||||
fontSize={12}
|
||||
tickFormatter={(value) => {
|
||||
const date = new Date(value);
|
||||
return timeframe === 'weekly'
|
||||
? date.toLocaleDateString('es-ES', { month: 'short', day: 'numeric' })
|
||||
: timeframe === 'monthly'
|
||||
? date.toLocaleDateString('es-ES', { month: 'short', year: '2-digit' })
|
||||
: date.getFullYear().toString();
|
||||
}}
|
||||
/>
|
||||
<YAxis
|
||||
stroke="#6b7280"
|
||||
fontSize={12}
|
||||
tickFormatter={(value) => `${value}`}
|
||||
/>
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
<Legend />
|
||||
|
||||
{/* Confidence interval area */}
|
||||
{showConfidenceInterval && (
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="confidenceUpper"
|
||||
stackId={1}
|
||||
stroke="none"
|
||||
fill="#10b98120"
|
||||
fillOpacity={0.3}
|
||||
name="Intervalo de Confianza Superior"
|
||||
/>
|
||||
)}
|
||||
{showConfidenceInterval && (
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="confidenceLower"
|
||||
stackId={1}
|
||||
stroke="none"
|
||||
fill="#ffffff"
|
||||
name="Intervalo de Confianza Inferior"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Actual demand line */}
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="actualDemand"
|
||||
stroke="#3b82f6"
|
||||
strokeWidth={3}
|
||||
dot={false}
|
||||
activeDot={{ r: 6, stroke: '#3b82f6', strokeWidth: 2 }}
|
||||
name="Demanda Real"
|
||||
/>
|
||||
|
||||
{/* Predicted demand line */}
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="predictedDemand"
|
||||
stroke="#10b981"
|
||||
strokeWidth={2}
|
||||
strokeDasharray="5 5"
|
||||
dot={false}
|
||||
activeDot={{ r: 4, stroke: '#10b981', strokeWidth: 2 }}
|
||||
name="Demanda Predicha"
|
||||
/>
|
||||
|
||||
{renderEventDots()}
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
{/* Brush for zooming */}
|
||||
{filteredData.length > 20 && (
|
||||
<div className="mt-4" style={{ width: '100%', height: 80 }}>
|
||||
<ResponsiveContainer>
|
||||
<LineChart data={filteredData}>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="predictedDemand"
|
||||
stroke="#10b981"
|
||||
strokeWidth={1}
|
||||
dot={false}
|
||||
/>
|
||||
<Brush
|
||||
dataKey="date"
|
||||
height={30}
|
||||
stroke="#8884d8"
|
||||
onChange={handleBrushChange}
|
||||
tickFormatter={(value) => {
|
||||
const date = new Date(value);
|
||||
return date.toLocaleDateString('es-ES', { month: 'short', day: 'numeric' });
|
||||
}}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Chart legend */}
|
||||
<div className="flex items-center justify-center gap-6 mt-4 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-0.5 bg-[var(--color-info)]/50"></div>
|
||||
<span className="text-text-secondary">Demanda Real</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-0.5 bg-green-500 border-dashed border-t-2 border-green-500"></div>
|
||||
<span className="text-text-secondary">Demanda Predicha</span>
|
||||
</div>
|
||||
{showConfidenceInterval && (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-2 bg-green-500 bg-opacity-20"></div>
|
||||
<span className="text-text-secondary">Intervalo de Confianza</span>
|
||||
</div>
|
||||
)}
|
||||
{showEvents && events.length > 0 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 bg-red-500 rounded-full"></div>
|
||||
<span className="text-text-secondary">Eventos</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default DemandChart;
|
||||
@@ -0,0 +1,575 @@
|
||||
import React, { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import { clsx } from 'clsx';
|
||||
import {
|
||||
LineChart,
|
||||
Line,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
ReferenceLine,
|
||||
Area,
|
||||
ComposedChart,
|
||||
Legend,
|
||||
Brush,
|
||||
ReferenceDot,
|
||||
} from 'recharts';
|
||||
import { Card, CardHeader, CardBody } from '../../ui';
|
||||
import { Button } from '../../ui';
|
||||
import { Select } from '../../ui';
|
||||
import { Badge } from '../../ui';
|
||||
import {
|
||||
ForecastResponse,
|
||||
DemandTrend,
|
||||
TrendDirection,
|
||||
WeatherCondition,
|
||||
EventType,
|
||||
} from '../../../types/forecasting.types';
|
||||
|
||||
export interface DemandChartProps {
|
||||
className?: string;
|
||||
title?: string;
|
||||
data?: DemandTrend[];
|
||||
products?: string[];
|
||||
selectedProducts?: string[];
|
||||
onProductSelectionChange?: (products: string[]) => void;
|
||||
timeframe?: 'weekly' | 'monthly' | 'quarterly' | 'yearly';
|
||||
onTimeframeChange?: (timeframe: 'weekly' | 'monthly' | 'quarterly' | 'yearly') => void;
|
||||
showConfidenceInterval?: boolean;
|
||||
showEvents?: boolean;
|
||||
showWeatherOverlay?: boolean;
|
||||
events?: Array<{
|
||||
date: string;
|
||||
type: EventType;
|
||||
name: string;
|
||||
impact: 'high' | 'medium' | 'low';
|
||||
}>;
|
||||
weatherData?: Array<{
|
||||
date: string;
|
||||
condition: WeatherCondition;
|
||||
temperature: number;
|
||||
}>;
|
||||
loading?: boolean;
|
||||
error?: string | null;
|
||||
height?: number;
|
||||
onExport?: (format: 'png' | 'pdf' | 'csv') => void;
|
||||
}
|
||||
|
||||
interface ChartDataPoint {
|
||||
date: string;
|
||||
actualDemand?: number;
|
||||
predictedDemand?: number;
|
||||
confidenceLower?: number;
|
||||
confidenceUpper?: number;
|
||||
accuracy?: number;
|
||||
trendDirection?: TrendDirection;
|
||||
seasonalFactor?: number;
|
||||
anomalyScore?: number;
|
||||
hasEvent?: boolean;
|
||||
eventType?: EventType;
|
||||
eventName?: string;
|
||||
eventImpact?: 'high' | 'medium' | 'low';
|
||||
weather?: WeatherCondition;
|
||||
temperature?: number;
|
||||
}
|
||||
|
||||
const PRODUCT_COLORS = [
|
||||
'#8884d8', // Croissants
|
||||
'#82ca9d', // Pan de molde
|
||||
'#ffc658', // Roscón de Reyes
|
||||
'#ff7c7c', // Torrijas
|
||||
'#8dd1e1', // Magdalenas
|
||||
'#d084d0', // Empanadas
|
||||
'#ffb347', // Tarta de Santiago
|
||||
'#87ceeb', // Mazapán
|
||||
];
|
||||
|
||||
const EVENT_ICONS: Record<EventType, string> = {
|
||||
[EventType.HOLIDAY]: '🎉',
|
||||
[EventType.FESTIVAL]: '🎪',
|
||||
[EventType.SPORTS_EVENT]: '⚽',
|
||||
[EventType.WEATHER_EVENT]: '🌧️',
|
||||
[EventType.SCHOOL_EVENT]: '🎒',
|
||||
[EventType.CONCERT]: '🎵',
|
||||
[EventType.CONFERENCE]: '📊',
|
||||
[EventType.CONSTRUCTION]: '🚧',
|
||||
};
|
||||
|
||||
const WEATHER_ICONS: Record<WeatherCondition, string> = {
|
||||
[WeatherCondition.SUNNY]: '☀️',
|
||||
[WeatherCondition.CLOUDY]: '☁️',
|
||||
[WeatherCondition.RAINY]: '🌧️',
|
||||
[WeatherCondition.STORMY]: '⛈️',
|
||||
[WeatherCondition.SNOWY]: '❄️',
|
||||
[WeatherCondition.FOGGY]: '🌫️',
|
||||
[WeatherCondition.WINDY]: '🌪️',
|
||||
};
|
||||
|
||||
const SPANISH_EVENT_NAMES: Record<EventType, string> = {
|
||||
[EventType.HOLIDAY]: 'Festividad',
|
||||
[EventType.FESTIVAL]: 'Festival',
|
||||
[EventType.SPORTS_EVENT]: 'Evento Deportivo',
|
||||
[EventType.WEATHER_EVENT]: 'Evento Climático',
|
||||
[EventType.SCHOOL_EVENT]: 'Evento Escolar',
|
||||
[EventType.CONCERT]: 'Concierto',
|
||||
[EventType.CONFERENCE]: 'Conferencia',
|
||||
[EventType.CONSTRUCTION]: 'Construcción',
|
||||
};
|
||||
|
||||
const DemandChart: React.FC<DemandChartProps> = ({
|
||||
className,
|
||||
title = 'Predicción de Demanda',
|
||||
data = [],
|
||||
products = [],
|
||||
selectedProducts = [],
|
||||
onProductSelectionChange,
|
||||
timeframe = 'weekly',
|
||||
onTimeframeChange,
|
||||
showConfidenceInterval = true,
|
||||
showEvents = true,
|
||||
showWeatherOverlay = false,
|
||||
events = [],
|
||||
weatherData = [],
|
||||
loading = false,
|
||||
error = null,
|
||||
height = 400,
|
||||
onExport,
|
||||
}) => {
|
||||
const [selectedPeriod, setSelectedPeriod] = useState<{ start?: Date; end?: Date }>({});
|
||||
const [zoomedData, setZoomedData] = useState<ChartDataPoint[]>([]);
|
||||
const [hoveredPoint, setHoveredPoint] = useState<ChartDataPoint | null>(null);
|
||||
|
||||
// Process and merge data with events and weather
|
||||
const chartData = useMemo(() => {
|
||||
const processedData: ChartDataPoint[] = data.map(point => {
|
||||
const dateStr = point.date;
|
||||
const event = events.find(e => e.date === dateStr);
|
||||
const weather = weatherData.find(w => w.date === dateStr);
|
||||
|
||||
return {
|
||||
date: dateStr,
|
||||
actualDemand: point.actual_demand,
|
||||
predictedDemand: point.predicted_demand,
|
||||
confidenceLower: point.confidence_lower,
|
||||
confidenceUpper: point.confidence_upper,
|
||||
accuracy: point.accuracy,
|
||||
trendDirection: point.trend_direction,
|
||||
seasonalFactor: point.seasonal_factor,
|
||||
anomalyScore: point.anomaly_score,
|
||||
hasEvent: !!event,
|
||||
eventType: event?.type,
|
||||
eventName: event?.name,
|
||||
eventImpact: event?.impact,
|
||||
weather: weather?.condition,
|
||||
temperature: weather?.temperature,
|
||||
};
|
||||
});
|
||||
|
||||
return processedData;
|
||||
}, [data, events, weatherData]);
|
||||
|
||||
// Filter data based on selected period
|
||||
const filteredData = useMemo(() => {
|
||||
if (!selectedPeriod.start || !selectedPeriod.end) {
|
||||
return chartData;
|
||||
}
|
||||
|
||||
return chartData.filter(point => {
|
||||
const pointDate = new Date(point.date);
|
||||
return pointDate >= selectedPeriod.start! && pointDate <= selectedPeriod.end!;
|
||||
});
|
||||
}, [chartData, selectedPeriod]);
|
||||
|
||||
// Update zoomed data when filtered data changes
|
||||
useEffect(() => {
|
||||
setZoomedData(filteredData);
|
||||
}, [filteredData]);
|
||||
|
||||
// Handle brush selection
|
||||
const handleBrushChange = useCallback((brushData: any) => {
|
||||
if (brushData && brushData.startIndex !== undefined && brushData.endIndex !== undefined) {
|
||||
const newData = filteredData.slice(brushData.startIndex, brushData.endIndex + 1);
|
||||
setZoomedData(newData);
|
||||
} else {
|
||||
setZoomedData(filteredData);
|
||||
}
|
||||
}, [filteredData]);
|
||||
|
||||
// Reset zoom
|
||||
const handleResetZoom = useCallback(() => {
|
||||
setZoomedData(filteredData);
|
||||
setSelectedPeriod({});
|
||||
}, [filteredData]);
|
||||
|
||||
// Custom tooltip
|
||||
const CustomTooltip = ({ active, payload, label }: any) => {
|
||||
if (!active || !payload || !payload.length) return null;
|
||||
|
||||
const data = payload[0].payload as ChartDataPoint;
|
||||
const formattedDate = new Date(label).toLocaleDateString('es-ES', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="bg-card-bg border border-border-primary rounded-lg shadow-lg p-4 max-w-sm">
|
||||
<h4 className="font-semibold text-text-primary mb-2">{formattedDate}</h4>
|
||||
|
||||
<div className="space-y-1">
|
||||
{data.actualDemand !== undefined && (
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-text-secondary text-sm">Demanda Real:</span>
|
||||
<span className="font-medium text-blue-600">{data.actualDemand}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data.predictedDemand !== undefined && (
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-text-secondary text-sm">Demanda Predicha:</span>
|
||||
<span className="font-medium text-green-600">{data.predictedDemand.toFixed(1)}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showConfidenceInterval && data.confidenceLower !== undefined && data.confidenceUpper !== undefined && (
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-text-secondary text-sm">Intervalo de Confianza:</span>
|
||||
<span className="text-sm text-text-tertiary">
|
||||
{data.confidenceLower.toFixed(1)} - {data.confidenceUpper.toFixed(1)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data.accuracy !== undefined && (
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-text-secondary text-sm">Precisión:</span>
|
||||
<Badge
|
||||
variant={data.accuracy > 0.8 ? 'success' : data.accuracy > 0.6 ? 'warning' : 'danger'}
|
||||
size="sm"
|
||||
>
|
||||
{(data.accuracy * 100).toFixed(1)}%
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showEvents && data.hasEvent && (
|
||||
<div className="mt-3 pt-2 border-t border-border-secondary">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-lg">{EVENT_ICONS[data.eventType!]}</span>
|
||||
<div className="flex-1">
|
||||
<div className="font-medium text-text-primary text-sm">{data.eventName}</div>
|
||||
<div className="text-xs text-text-tertiary">
|
||||
{SPANISH_EVENT_NAMES[data.eventType!]}
|
||||
</div>
|
||||
</div>
|
||||
<Badge
|
||||
variant={data.eventImpact === 'high' ? 'danger' : data.eventImpact === 'medium' ? 'warning' : 'success'}
|
||||
size="sm"
|
||||
>
|
||||
{data.eventImpact === 'high' ? 'Alto' : data.eventImpact === 'medium' ? 'Medio' : 'Bajo'}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showWeatherOverlay && data.weather && (
|
||||
<div className="mt-3 pt-2 border-t border-border-secondary">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-lg">{WEATHER_ICONS[data.weather]}</span>
|
||||
<div className="flex-1">
|
||||
<div className="text-sm text-text-primary capitalize">
|
||||
{data.weather.replace('_', ' ')}
|
||||
</div>
|
||||
{data.temperature && (
|
||||
<div className="text-xs text-text-tertiary">
|
||||
{data.temperature}°C
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Event reference dots
|
||||
const renderEventDots = () => {
|
||||
if (!showEvents) return null;
|
||||
|
||||
return events
|
||||
.filter(event => {
|
||||
const eventData = zoomedData.find(d => d.date === event.date);
|
||||
return eventData;
|
||||
})
|
||||
.map((event, index) => {
|
||||
const eventData = zoomedData.find(d => d.date === event.date);
|
||||
if (!eventData) return null;
|
||||
|
||||
const yValue = eventData.predictedDemand || eventData.actualDemand || 0;
|
||||
|
||||
return (
|
||||
<ReferenceDot
|
||||
key={`event-${index}`}
|
||||
x={event.date}
|
||||
y={yValue}
|
||||
r={8}
|
||||
fill={event.impact === 'high' ? '#ef4444' : event.impact === 'medium' ? '#f59e0b' : '#10b981'}
|
||||
stroke="#ffffff"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
// Loading state
|
||||
if (loading) {
|
||||
return (
|
||||
<Card className={className}>
|
||||
<CardHeader>
|
||||
<h3 className="text-lg font-semibold text-text-primary">{title}</h3>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-color-primary"></div>
|
||||
<span className="text-text-secondary">Cargando gráfico de demanda...</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// Error state
|
||||
if (error) {
|
||||
return (
|
||||
<Card className={className} variant="outlined">
|
||||
<CardHeader>
|
||||
<h3 className="text-lg font-semibold text-text-primary">{title}</h3>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-center">
|
||||
<div className="text-red-500 mb-2">
|
||||
<svg className="w-12 h-12 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-text-secondary">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// Empty state
|
||||
if (zoomedData.length === 0) {
|
||||
return (
|
||||
<Card className={className}>
|
||||
<CardHeader>
|
||||
<h3 className="text-lg font-semibold text-text-primary">{title}</h3>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-center">
|
||||
<div className="text-text-tertiary mb-2">
|
||||
<svg className="w-12 h-12 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-text-secondary">No hay datos de demanda disponibles</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className={className}>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between flex-wrap gap-4">
|
||||
<h3 className="text-lg font-semibold text-text-primary">{title}</h3>
|
||||
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
{/* Time period selector */}
|
||||
<Select
|
||||
value={timeframe}
|
||||
onChange={(value) => onTimeframeChange?.(value as 'weekly' | 'monthly' | 'quarterly' | 'yearly')}
|
||||
className="w-32"
|
||||
>
|
||||
<option value="weekly">Semanal</option>
|
||||
<option value="monthly">Mensual</option>
|
||||
<option value="quarterly">Trimestral</option>
|
||||
<option value="yearly">Anual</option>
|
||||
</Select>
|
||||
|
||||
{/* Export options */}
|
||||
{onExport && (
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onExport('png')}
|
||||
title="Exportar como PNG"
|
||||
>
|
||||
📊
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onExport('csv')}
|
||||
title="Exportar datos CSV"
|
||||
>
|
||||
📋
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Reset zoom */}
|
||||
{zoomedData.length !== filteredData.length && (
|
||||
<Button variant="ghost" size="sm" onClick={handleResetZoom}>
|
||||
Restablecer Zoom
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardBody padding="lg">
|
||||
<div style={{ width: '100%', height }}>
|
||||
<ResponsiveContainer>
|
||||
<ComposedChart data={zoomedData} margin={{ top: 20, right: 30, left: 20, bottom: 5 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
stroke="#6b7280"
|
||||
fontSize={12}
|
||||
tickFormatter={(value) => {
|
||||
const date = new Date(value);
|
||||
return timeframe === 'weekly'
|
||||
? date.toLocaleDateString('es-ES', { month: 'short', day: 'numeric' })
|
||||
: timeframe === 'monthly'
|
||||
? date.toLocaleDateString('es-ES', { month: 'short', year: '2-digit' })
|
||||
: date.getFullYear().toString();
|
||||
}}
|
||||
/>
|
||||
<YAxis
|
||||
stroke="#6b7280"
|
||||
fontSize={12}
|
||||
tickFormatter={(value) => `${value}`}
|
||||
/>
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
<Legend />
|
||||
|
||||
{/* Confidence interval area */}
|
||||
{showConfidenceInterval && (
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="confidenceUpper"
|
||||
stackId={1}
|
||||
stroke="none"
|
||||
fill="#10b98120"
|
||||
fillOpacity={0.3}
|
||||
name="Intervalo de Confianza Superior"
|
||||
/>
|
||||
)}
|
||||
{showConfidenceInterval && (
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="confidenceLower"
|
||||
stackId={1}
|
||||
stroke="none"
|
||||
fill="#ffffff"
|
||||
name="Intervalo de Confianza Inferior"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Actual demand line */}
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="actualDemand"
|
||||
stroke="#3b82f6"
|
||||
strokeWidth={3}
|
||||
dot={false}
|
||||
activeDot={{ r: 6, stroke: '#3b82f6', strokeWidth: 2 }}
|
||||
name="Demanda Real"
|
||||
/>
|
||||
|
||||
{/* Predicted demand line */}
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="predictedDemand"
|
||||
stroke="#10b981"
|
||||
strokeWidth={2}
|
||||
strokeDasharray="5 5"
|
||||
dot={false}
|
||||
activeDot={{ r: 4, stroke: '#10b981', strokeWidth: 2 }}
|
||||
name="Demanda Predicha"
|
||||
/>
|
||||
|
||||
{renderEventDots()}
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
{/* Brush for zooming */}
|
||||
{filteredData.length > 20 && (
|
||||
<div className="mt-4" style={{ width: '100%', height: 80 }}>
|
||||
<ResponsiveContainer>
|
||||
<LineChart data={filteredData}>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="predictedDemand"
|
||||
stroke="#10b981"
|
||||
strokeWidth={1}
|
||||
dot={false}
|
||||
/>
|
||||
<Brush
|
||||
dataKey="date"
|
||||
height={30}
|
||||
stroke="#8884d8"
|
||||
onChange={handleBrushChange}
|
||||
tickFormatter={(value) => {
|
||||
const date = new Date(value);
|
||||
return date.toLocaleDateString('es-ES', { month: 'short', day: 'numeric' });
|
||||
}}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Chart legend */}
|
||||
<div className="flex items-center justify-center gap-6 mt-4 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-0.5 bg-blue-500"></div>
|
||||
<span className="text-text-secondary">Demanda Real</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-0.5 bg-green-500 border-dashed border-t-2 border-green-500"></div>
|
||||
<span className="text-text-secondary">Demanda Predicha</span>
|
||||
</div>
|
||||
{showConfidenceInterval && (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-2 bg-green-500 bg-opacity-20"></div>
|
||||
<span className="text-text-secondary">Intervalo de Confianza</span>
|
||||
</div>
|
||||
)}
|
||||
{showEvents && events.length > 0 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 bg-red-500 rounded-full"></div>
|
||||
<span className="text-text-secondary">Eventos</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default DemandChart;
|
||||
653
frontend/src/components/domain/forecasting/ForecastTable.tsx
Normal file
653
frontend/src/components/domain/forecasting/ForecastTable.tsx
Normal file
@@ -0,0 +1,653 @@
|
||||
import React, { useState, useMemo, useCallback } from 'react';
|
||||
import { clsx } from 'clsx';
|
||||
import { Card, CardHeader, CardBody } from '../../ui';
|
||||
import { Button } from '../../ui';
|
||||
import { Badge } from '../../ui';
|
||||
import { Select } from '../../ui';
|
||||
import { Input } from '../../ui';
|
||||
import { Table, TableColumn } from '../../ui';
|
||||
import {
|
||||
ForecastResponse,
|
||||
TrendDirection,
|
||||
ModelType,
|
||||
} from '../../../types/forecasting.types';
|
||||
|
||||
export interface ForecastTableProps {
|
||||
className?: string;
|
||||
title?: string;
|
||||
data?: ForecastResponse[];
|
||||
loading?: boolean;
|
||||
error?: string | null;
|
||||
pageSize?: number;
|
||||
onExportCSV?: (data: ForecastResponse[]) => void;
|
||||
onBulkAction?: (action: string, selectedItems: ForecastResponse[]) => void;
|
||||
onProductClick?: (productName: string) => void;
|
||||
showBulkActions?: boolean;
|
||||
showFilters?: boolean;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
interface FilterState {
|
||||
productName: string;
|
||||
category: string;
|
||||
accuracyRange: 'all' | 'high' | 'medium' | 'low';
|
||||
trendDirection: TrendDirection | 'all';
|
||||
confidenceLevel: number | 'all';
|
||||
dateRange: 'today' | 'week' | 'month' | 'quarter' | 'all';
|
||||
}
|
||||
|
||||
interface SortState {
|
||||
field: string;
|
||||
order: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
const SPANISH_TRENDS: Record<TrendDirection, string> = {
|
||||
[TrendDirection.INCREASING]: 'Creciente',
|
||||
[TrendDirection.DECREASING]: 'Decreciente',
|
||||
[TrendDirection.STABLE]: 'Estable',
|
||||
[TrendDirection.VOLATILE]: 'Volátil',
|
||||
[TrendDirection.SEASONAL]: 'Estacional',
|
||||
};
|
||||
|
||||
const TREND_COLORS: Record<TrendDirection, string> = {
|
||||
[TrendDirection.INCREASING]: 'text-[var(--color-success)]',
|
||||
[TrendDirection.DECREASING]: 'text-[var(--color-error)]',
|
||||
[TrendDirection.STABLE]: 'text-[var(--color-info)]',
|
||||
[TrendDirection.VOLATILE]: 'text-yellow-600',
|
||||
[TrendDirection.SEASONAL]: 'text-purple-600',
|
||||
};
|
||||
|
||||
const TREND_ICONS: Record<TrendDirection, string> = {
|
||||
[TrendDirection.INCREASING]: '↗️',
|
||||
[TrendDirection.DECREASING]: '↘️',
|
||||
[TrendDirection.STABLE]: '➡️',
|
||||
[TrendDirection.VOLATILE]: '📈',
|
||||
[TrendDirection.SEASONAL]: '🔄',
|
||||
};
|
||||
|
||||
const BAKERY_CATEGORIES = [
|
||||
{ value: 'all', label: 'Todas las categorías' },
|
||||
{ value: 'pan', label: 'Pan y Bollería' },
|
||||
{ value: 'dulces', label: 'Dulces y Postres' },
|
||||
{ value: 'salados', label: 'Salados' },
|
||||
{ value: 'estacional', label: 'Productos Estacionales' },
|
||||
{ value: 'bebidas', label: 'Bebidas' },
|
||||
];
|
||||
|
||||
const SPANISH_PRODUCTS = [
|
||||
'Croissants',
|
||||
'Pan de molde',
|
||||
'Roscón de Reyes',
|
||||
'Torrijas',
|
||||
'Magdalenas',
|
||||
'Empanadas',
|
||||
'Tarta de Santiago',
|
||||
'Mazapán',
|
||||
'Pan tostado',
|
||||
'Palmeras',
|
||||
'Napolitanas',
|
||||
'Ensaimadas',
|
||||
'Churros',
|
||||
'Polvorones',
|
||||
'Turrones',
|
||||
];
|
||||
|
||||
const ForecastTable: React.FC<ForecastTableProps> = ({
|
||||
className,
|
||||
title = 'Predicciones de Demanda',
|
||||
data = [],
|
||||
loading = false,
|
||||
error = null,
|
||||
pageSize = 20,
|
||||
onExportCSV,
|
||||
onBulkAction,
|
||||
onProductClick,
|
||||
showBulkActions = true,
|
||||
showFilters = true,
|
||||
compact = false,
|
||||
}) => {
|
||||
const [filters, setFilters] = useState<FilterState>({
|
||||
productName: '',
|
||||
category: 'all',
|
||||
accuracyRange: 'all',
|
||||
trendDirection: 'all',
|
||||
confidenceLevel: 'all',
|
||||
dateRange: 'all',
|
||||
});
|
||||
|
||||
const [sort, setSort] = useState<SortState>({
|
||||
field: 'forecast_date',
|
||||
order: 'desc',
|
||||
});
|
||||
|
||||
const [selectedRows, setSelectedRows] = useState<React.Key[]>([]);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
|
||||
// Get product category (simplified mapping)
|
||||
const getProductCategory = useCallback((productName: string): string => {
|
||||
const name = productName.toLowerCase();
|
||||
if (name.includes('pan') || name.includes('croissant') || name.includes('magdalena')) return 'pan';
|
||||
if (name.includes('roscón') || name.includes('tarta') || name.includes('mazapán') || name.includes('polvorón')) return 'dulces';
|
||||
if (name.includes('empanada') || name.includes('churro')) return 'salados';
|
||||
if (name.includes('torrija') || name.includes('turrón')) return 'estacional';
|
||||
return 'pan';
|
||||
}, []);
|
||||
|
||||
// Filter and sort data
|
||||
const filteredAndSortedData = useMemo(() => {
|
||||
let filtered = [...data];
|
||||
|
||||
// Apply filters
|
||||
if (filters.productName) {
|
||||
filtered = filtered.filter(item =>
|
||||
item.product_name.toLowerCase().includes(filters.productName.toLowerCase())
|
||||
);
|
||||
}
|
||||
|
||||
if (filters.category !== 'all') {
|
||||
filtered = filtered.filter(item => getProductCategory(item.product_name) === filters.category);
|
||||
}
|
||||
|
||||
if (filters.accuracyRange !== 'all') {
|
||||
filtered = filtered.filter(item => {
|
||||
const accuracy = item.accuracy_score || 0;
|
||||
switch (filters.accuracyRange) {
|
||||
case 'high': return accuracy >= 0.8;
|
||||
case 'medium': return accuracy >= 0.6 && accuracy < 0.8;
|
||||
case 'low': return accuracy < 0.6;
|
||||
default: return true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (filters.trendDirection !== 'all') {
|
||||
filtered = filtered.filter(item => {
|
||||
// Determine trend from predicted vs historical (simplified)
|
||||
const trend = item.predicted_demand > (item.actual_demand || 0) ? TrendDirection.INCREASING : TrendDirection.DECREASING;
|
||||
return trend === filters.trendDirection;
|
||||
});
|
||||
}
|
||||
|
||||
if (filters.confidenceLevel !== 'all') {
|
||||
filtered = filtered.filter(item => item.confidence_level >= filters.confidenceLevel);
|
||||
}
|
||||
|
||||
if (filters.dateRange !== 'all') {
|
||||
const now = new Date();
|
||||
const filterDate = new Date();
|
||||
|
||||
switch (filters.dateRange) {
|
||||
case 'today':
|
||||
filterDate.setHours(0, 0, 0, 0);
|
||||
break;
|
||||
case 'week':
|
||||
filterDate.setDate(now.getDate() - 7);
|
||||
break;
|
||||
case 'month':
|
||||
filterDate.setMonth(now.getMonth() - 1);
|
||||
break;
|
||||
case 'quarter':
|
||||
filterDate.setMonth(now.getMonth() - 3);
|
||||
break;
|
||||
}
|
||||
|
||||
filtered = filtered.filter(item => new Date(item.forecast_date) >= filterDate);
|
||||
}
|
||||
|
||||
// Apply sorting
|
||||
filtered.sort((a, b) => {
|
||||
let aValue: any;
|
||||
let bValue: any;
|
||||
|
||||
switch (sort.field) {
|
||||
case 'product_name':
|
||||
aValue = a.product_name;
|
||||
bValue = b.product_name;
|
||||
break;
|
||||
case 'predicted_demand':
|
||||
aValue = a.predicted_demand;
|
||||
bValue = b.predicted_demand;
|
||||
break;
|
||||
case 'accuracy_score':
|
||||
aValue = a.accuracy_score || 0;
|
||||
bValue = b.accuracy_score || 0;
|
||||
break;
|
||||
case 'confidence_level':
|
||||
aValue = a.confidence_level;
|
||||
bValue = b.confidence_level;
|
||||
break;
|
||||
case 'forecast_date':
|
||||
aValue = new Date(a.forecast_date);
|
||||
bValue = new Date(b.forecast_date);
|
||||
break;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (aValue < bValue) return sort.order === 'asc' ? -1 : 1;
|
||||
if (aValue > bValue) return sort.order === 'asc' ? 1 : -1;
|
||||
return 0;
|
||||
});
|
||||
|
||||
return filtered;
|
||||
}, [data, filters, sort, getProductCategory]);
|
||||
|
||||
// Paginated data
|
||||
const paginatedData = useMemo(() => {
|
||||
const startIndex = (currentPage - 1) * pageSize;
|
||||
return filteredAndSortedData.slice(startIndex, startIndex + pageSize);
|
||||
}, [filteredAndSortedData, currentPage, pageSize]);
|
||||
|
||||
// Handle sort change
|
||||
const handleSort = useCallback((field: string, order: 'asc' | 'desc' | null) => {
|
||||
if (order === null) {
|
||||
setSort({ field: 'forecast_date', order: 'desc' });
|
||||
} else {
|
||||
setSort({ field, order });
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Handle filter change
|
||||
const handleFilterChange = useCallback((key: keyof FilterState, value: any) => {
|
||||
setFilters(prev => ({ ...prev, [key]: value }));
|
||||
setCurrentPage(1); // Reset to first page when filtering
|
||||
}, []);
|
||||
|
||||
// Handle bulk actions
|
||||
const handleBulkAction = useCallback((action: string) => {
|
||||
const selectedData = filteredAndSortedData.filter(item =>
|
||||
selectedRows.includes(item.id)
|
||||
);
|
||||
onBulkAction?.(action, selectedData);
|
||||
}, [filteredAndSortedData, selectedRows, onBulkAction]);
|
||||
|
||||
// Calculate accuracy percentage and trend
|
||||
const getAccuracyInfo = useCallback((item: ForecastResponse) => {
|
||||
const accuracy = item.accuracy_score || 0;
|
||||
const percentage = (accuracy * 100).toFixed(1);
|
||||
|
||||
let variant: 'success' | 'warning' | 'danger' = 'success';
|
||||
if (accuracy < 0.6) variant = 'danger';
|
||||
else if (accuracy < 0.8) variant = 'warning';
|
||||
|
||||
return { percentage, variant };
|
||||
}, []);
|
||||
|
||||
// Get trend info
|
||||
const getTrendInfo = useCallback((item: ForecastResponse) => {
|
||||
// Simplified trend calculation
|
||||
const trend = item.predicted_demand > (item.actual_demand || 0) ?
|
||||
TrendDirection.INCREASING : TrendDirection.DECREASING;
|
||||
|
||||
return {
|
||||
direction: trend,
|
||||
label: SPANISH_TRENDS[trend],
|
||||
color: TREND_COLORS[trend],
|
||||
icon: TREND_ICONS[trend],
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Table columns
|
||||
const columns: TableColumn<ForecastResponse>[] = useMemo(() => {
|
||||
const baseColumns: TableColumn<ForecastResponse>[] = [
|
||||
{
|
||||
key: 'product_name',
|
||||
title: 'Producto',
|
||||
dataIndex: 'product_name',
|
||||
sortable: true,
|
||||
width: compact ? 120 : 160,
|
||||
render: (value: string, record: ForecastResponse) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => onProductClick?.(value)}
|
||||
className="font-medium text-text-primary hover:text-color-primary transition-colors duration-150"
|
||||
>
|
||||
{value}
|
||||
</button>
|
||||
<Badge variant="ghost" size="sm">
|
||||
{getProductCategory(value)}
|
||||
</Badge>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'predicted_demand',
|
||||
title: 'Demanda Predicha',
|
||||
dataIndex: 'predicted_demand',
|
||||
sortable: true,
|
||||
align: 'right' as const,
|
||||
width: compact ? 100 : 120,
|
||||
render: (value: number) => (
|
||||
<div className="font-semibold text-[var(--color-success)]">
|
||||
{value.toFixed(0)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'actual_demand',
|
||||
title: 'Demanda Real',
|
||||
dataIndex: 'actual_demand',
|
||||
align: 'right' as const,
|
||||
width: compact ? 80 : 100,
|
||||
render: (value?: number) => (
|
||||
<div className="font-medium text-[var(--color-info)]">
|
||||
{value ? value.toFixed(0) : '-'}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'accuracy_score',
|
||||
title: 'Precisión',
|
||||
dataIndex: 'accuracy_score',
|
||||
sortable: true,
|
||||
align: 'center' as const,
|
||||
width: 80,
|
||||
render: (value: number | undefined, record: ForecastResponse) => {
|
||||
const { percentage, variant } = getAccuracyInfo(record);
|
||||
return (
|
||||
<Badge variant={variant} size="sm">
|
||||
{percentage}%
|
||||
</Badge>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'trend',
|
||||
title: 'Tendencia',
|
||||
align: 'center' as const,
|
||||
width: compact ? 80 : 100,
|
||||
render: (_, record: ForecastResponse) => {
|
||||
const trend = getTrendInfo(record);
|
||||
return (
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<span className="text-lg">{trend.icon}</span>
|
||||
<span className={clsx('text-xs font-medium', trend.color)}>
|
||||
{compact ? '' : trend.label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'confidence_interval',
|
||||
title: 'Intervalo de Confianza',
|
||||
align: 'center' as const,
|
||||
width: compact ? 120 : 140,
|
||||
render: (_, record: ForecastResponse) => (
|
||||
<div className="text-sm text-text-secondary">
|
||||
{record.confidence_lower.toFixed(0)} - {record.confidence_upper.toFixed(0)}
|
||||
<div className="text-xs text-text-tertiary">
|
||||
({(record.confidence_level * 100).toFixed(0)}%)
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
if (!compact) {
|
||||
baseColumns.push(
|
||||
{
|
||||
key: 'forecast_date',
|
||||
title: 'Fecha de Predicción',
|
||||
dataIndex: 'forecast_date',
|
||||
sortable: true,
|
||||
width: 120,
|
||||
render: (value: string) => (
|
||||
<div className="text-sm">
|
||||
{new Date(value).toLocaleDateString('es-ES', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: '2-digit',
|
||||
})}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'model_version',
|
||||
title: 'Modelo',
|
||||
dataIndex: 'model_version',
|
||||
width: 80,
|
||||
render: (value: string) => (
|
||||
<Badge variant="outlined" size="sm">
|
||||
v{value}
|
||||
</Badge>
|
||||
),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return baseColumns;
|
||||
}, [compact, onProductClick, getProductCategory, getAccuracyInfo, getTrendInfo]);
|
||||
|
||||
// Loading state
|
||||
if (loading) {
|
||||
return (
|
||||
<Card className={className}>
|
||||
<CardHeader>
|
||||
<h3 className="text-lg font-semibold text-text-primary">{title}</h3>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-color-primary"></div>
|
||||
<span className="text-text-secondary">Cargando predicciones...</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// Error state
|
||||
if (error) {
|
||||
return (
|
||||
<Card className={className} variant="outlined">
|
||||
<CardHeader>
|
||||
<h3 className="text-lg font-semibold text-text-primary">{title}</h3>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-center">
|
||||
<div className="text-red-500 mb-2">
|
||||
<svg className="w-12 h-12 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-text-secondary">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className={className}>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between flex-wrap gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<h3 className="text-lg font-semibold text-text-primary">{title}</h3>
|
||||
<Badge variant="ghost">
|
||||
{filteredAndSortedData.length} predicciones
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{onExportCSV && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onExportCSV(filteredAndSortedData)}
|
||||
>
|
||||
📊 Exportar CSV
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{showBulkActions && selectedRows.length > 0 && (
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleBulkAction('production_plan')}
|
||||
>
|
||||
📋 Plan de Producción
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleBulkAction('inventory_alert')}
|
||||
>
|
||||
🚨 Alertas de Inventario
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
{showFilters && (
|
||||
<div className="px-6 py-4 border-b border-border-primary bg-bg-secondary">
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4">
|
||||
<Input
|
||||
placeholder="Buscar producto..."
|
||||
value={filters.productName}
|
||||
onChange={(e) => handleFilterChange('productName', e.target.value)}
|
||||
className="text-sm"
|
||||
/>
|
||||
|
||||
<Select
|
||||
value={filters.category}
|
||||
onChange={(value) => handleFilterChange('category', value)}
|
||||
className="text-sm"
|
||||
>
|
||||
{BAKERY_CATEGORIES.map(cat => (
|
||||
<option key={cat.value} value={cat.value}>{cat.label}</option>
|
||||
))}
|
||||
</Select>
|
||||
|
||||
<Select
|
||||
value={filters.accuracyRange}
|
||||
onChange={(value) => handleFilterChange('accuracyRange', value)}
|
||||
className="text-sm"
|
||||
>
|
||||
<option value="all">Toda precisión</option>
|
||||
<option value="high">Alta (>80%)</option>
|
||||
<option value="medium">Media (60-80%)</option>
|
||||
<option value="low">Baja (<60%)</option>
|
||||
</Select>
|
||||
|
||||
<Select
|
||||
value={filters.trendDirection}
|
||||
onChange={(value) => handleFilterChange('trendDirection', value)}
|
||||
className="text-sm"
|
||||
>
|
||||
<option value="all">Todas las tendencias</option>
|
||||
{Object.entries(SPANISH_TRENDS).map(([key, label]) => (
|
||||
<option key={key} value={key}>{label}</option>
|
||||
))}
|
||||
</Select>
|
||||
|
||||
<Select
|
||||
value={filters.confidenceLevel}
|
||||
onChange={(value) => handleFilterChange('confidenceLevel', value === 'all' ? 'all' : parseFloat(value))}
|
||||
className="text-sm"
|
||||
>
|
||||
<option value="all">Toda confianza</option>
|
||||
<option value="0.9">Alta (>90%)</option>
|
||||
<option value="0.8">Media (>80%)</option>
|
||||
<option value="0.7">Baja (>70%)</option>
|
||||
</Select>
|
||||
|
||||
<Select
|
||||
value={filters.dateRange}
|
||||
onChange={(value) => handleFilterChange('dateRange', value)}
|
||||
className="text-sm"
|
||||
>
|
||||
<option value="all">Todo el tiempo</option>
|
||||
<option value="today">Hoy</option>
|
||||
<option value="week">Última semana</option>
|
||||
<option value="month">Último mes</option>
|
||||
<option value="quarter">Último trimestre</option>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<CardBody padding="none">
|
||||
<Table
|
||||
columns={columns}
|
||||
data={paginatedData}
|
||||
rowSelection={showBulkActions ? {
|
||||
selectedRowKeys: selectedRows,
|
||||
onSelectAll: (selected, selectedRows, changeRows) => {
|
||||
setSelectedRows(selected ? selectedRows.map(row => row.id) : []);
|
||||
},
|
||||
onSelect: (record, selected) => {
|
||||
setSelectedRows(prev =>
|
||||
selected
|
||||
? [...prev, record.id]
|
||||
: prev.filter(key => key !== record.id)
|
||||
);
|
||||
},
|
||||
} : undefined}
|
||||
pagination={{
|
||||
current: currentPage,
|
||||
pageSize,
|
||||
total: filteredAndSortedData.length,
|
||||
showSizeChanger: true,
|
||||
showTotal: (total, range) =>
|
||||
`${range[0]}-${range[1]} de ${total} predicciones`,
|
||||
onChange: (page, size) => {
|
||||
setCurrentPage(page);
|
||||
},
|
||||
}}
|
||||
sort={{
|
||||
field: sort.field,
|
||||
order: sort.order,
|
||||
}}
|
||||
onSort={handleSort}
|
||||
size={compact ? 'sm' : 'md'}
|
||||
hover={true}
|
||||
expandable={!compact ? {
|
||||
expandedRowRender: (record) => (
|
||||
<div className="p-4 bg-bg-tertiary">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
|
||||
<div>
|
||||
<div className="font-medium text-text-primary mb-1">ID de Predicción</div>
|
||||
<div className="text-text-secondary font-mono">{record.id}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-text-primary mb-1">Fecha de Creación</div>
|
||||
<div className="text-text-secondary">
|
||||
{new Date(record.created_at).toLocaleString('es-ES')}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-text-primary mb-1">Factores Externos</div>
|
||||
<div className="text-text-secondary">
|
||||
{Object.entries(record.external_factors_impact).length > 0
|
||||
? Object.keys(record.external_factors_impact).join(', ')
|
||||
: 'Ninguno'
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-text-primary mb-1">Componente Estacional</div>
|
||||
<div className="text-text-secondary">
|
||||
{record.seasonal_component
|
||||
? (record.seasonal_component * 100).toFixed(1) + '%'
|
||||
: 'N/A'
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
} : undefined}
|
||||
/>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default ForecastTable;
|
||||
@@ -0,0 +1,653 @@
|
||||
import React, { useState, useMemo, useCallback } from 'react';
|
||||
import { clsx } from 'clsx';
|
||||
import { Card, CardHeader, CardBody } from '../../ui';
|
||||
import { Button } from '../../ui';
|
||||
import { Badge } from '../../ui';
|
||||
import { Select } from '../../ui';
|
||||
import { Input } from '../../ui';
|
||||
import { Table, TableColumn } from '../../ui';
|
||||
import {
|
||||
ForecastResponse,
|
||||
TrendDirection,
|
||||
ModelType,
|
||||
} from '../../../types/forecasting.types';
|
||||
|
||||
export interface ForecastTableProps {
|
||||
className?: string;
|
||||
title?: string;
|
||||
data?: ForecastResponse[];
|
||||
loading?: boolean;
|
||||
error?: string | null;
|
||||
pageSize?: number;
|
||||
onExportCSV?: (data: ForecastResponse[]) => void;
|
||||
onBulkAction?: (action: string, selectedItems: ForecastResponse[]) => void;
|
||||
onProductClick?: (productName: string) => void;
|
||||
showBulkActions?: boolean;
|
||||
showFilters?: boolean;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
interface FilterState {
|
||||
productName: string;
|
||||
category: string;
|
||||
accuracyRange: 'all' | 'high' | 'medium' | 'low';
|
||||
trendDirection: TrendDirection | 'all';
|
||||
confidenceLevel: number | 'all';
|
||||
dateRange: 'today' | 'week' | 'month' | 'quarter' | 'all';
|
||||
}
|
||||
|
||||
interface SortState {
|
||||
field: string;
|
||||
order: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
const SPANISH_TRENDS: Record<TrendDirection, string> = {
|
||||
[TrendDirection.INCREASING]: 'Creciente',
|
||||
[TrendDirection.DECREASING]: 'Decreciente',
|
||||
[TrendDirection.STABLE]: 'Estable',
|
||||
[TrendDirection.VOLATILE]: 'Volátil',
|
||||
[TrendDirection.SEASONAL]: 'Estacional',
|
||||
};
|
||||
|
||||
const TREND_COLORS: Record<TrendDirection, string> = {
|
||||
[TrendDirection.INCREASING]: 'text-green-600',
|
||||
[TrendDirection.DECREASING]: 'text-red-600',
|
||||
[TrendDirection.STABLE]: 'text-blue-600',
|
||||
[TrendDirection.VOLATILE]: 'text-yellow-600',
|
||||
[TrendDirection.SEASONAL]: 'text-purple-600',
|
||||
};
|
||||
|
||||
const TREND_ICONS: Record<TrendDirection, string> = {
|
||||
[TrendDirection.INCREASING]: '↗️',
|
||||
[TrendDirection.DECREASING]: '↘️',
|
||||
[TrendDirection.STABLE]: '➡️',
|
||||
[TrendDirection.VOLATILE]: '📈',
|
||||
[TrendDirection.SEASONAL]: '🔄',
|
||||
};
|
||||
|
||||
const BAKERY_CATEGORIES = [
|
||||
{ value: 'all', label: 'Todas las categorías' },
|
||||
{ value: 'pan', label: 'Pan y Bollería' },
|
||||
{ value: 'dulces', label: 'Dulces y Postres' },
|
||||
{ value: 'salados', label: 'Salados' },
|
||||
{ value: 'estacional', label: 'Productos Estacionales' },
|
||||
{ value: 'bebidas', label: 'Bebidas' },
|
||||
];
|
||||
|
||||
const SPANISH_PRODUCTS = [
|
||||
'Croissants',
|
||||
'Pan de molde',
|
||||
'Roscón de Reyes',
|
||||
'Torrijas',
|
||||
'Magdalenas',
|
||||
'Empanadas',
|
||||
'Tarta de Santiago',
|
||||
'Mazapán',
|
||||
'Pan tostado',
|
||||
'Palmeras',
|
||||
'Napolitanas',
|
||||
'Ensaimadas',
|
||||
'Churros',
|
||||
'Polvorones',
|
||||
'Turrones',
|
||||
];
|
||||
|
||||
const ForecastTable: React.FC<ForecastTableProps> = ({
|
||||
className,
|
||||
title = 'Predicciones de Demanda',
|
||||
data = [],
|
||||
loading = false,
|
||||
error = null,
|
||||
pageSize = 20,
|
||||
onExportCSV,
|
||||
onBulkAction,
|
||||
onProductClick,
|
||||
showBulkActions = true,
|
||||
showFilters = true,
|
||||
compact = false,
|
||||
}) => {
|
||||
const [filters, setFilters] = useState<FilterState>({
|
||||
productName: '',
|
||||
category: 'all',
|
||||
accuracyRange: 'all',
|
||||
trendDirection: 'all',
|
||||
confidenceLevel: 'all',
|
||||
dateRange: 'all',
|
||||
});
|
||||
|
||||
const [sort, setSort] = useState<SortState>({
|
||||
field: 'forecast_date',
|
||||
order: 'desc',
|
||||
});
|
||||
|
||||
const [selectedRows, setSelectedRows] = useState<React.Key[]>([]);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
|
||||
// Get product category (simplified mapping)
|
||||
const getProductCategory = useCallback((productName: string): string => {
|
||||
const name = productName.toLowerCase();
|
||||
if (name.includes('pan') || name.includes('croissant') || name.includes('magdalena')) return 'pan';
|
||||
if (name.includes('roscón') || name.includes('tarta') || name.includes('mazapán') || name.includes('polvorón')) return 'dulces';
|
||||
if (name.includes('empanada') || name.includes('churro')) return 'salados';
|
||||
if (name.includes('torrija') || name.includes('turrón')) return 'estacional';
|
||||
return 'pan';
|
||||
}, []);
|
||||
|
||||
// Filter and sort data
|
||||
const filteredAndSortedData = useMemo(() => {
|
||||
let filtered = [...data];
|
||||
|
||||
// Apply filters
|
||||
if (filters.productName) {
|
||||
filtered = filtered.filter(item =>
|
||||
item.product_name.toLowerCase().includes(filters.productName.toLowerCase())
|
||||
);
|
||||
}
|
||||
|
||||
if (filters.category !== 'all') {
|
||||
filtered = filtered.filter(item => getProductCategory(item.product_name) === filters.category);
|
||||
}
|
||||
|
||||
if (filters.accuracyRange !== 'all') {
|
||||
filtered = filtered.filter(item => {
|
||||
const accuracy = item.accuracy_score || 0;
|
||||
switch (filters.accuracyRange) {
|
||||
case 'high': return accuracy >= 0.8;
|
||||
case 'medium': return accuracy >= 0.6 && accuracy < 0.8;
|
||||
case 'low': return accuracy < 0.6;
|
||||
default: return true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (filters.trendDirection !== 'all') {
|
||||
filtered = filtered.filter(item => {
|
||||
// Determine trend from predicted vs historical (simplified)
|
||||
const trend = item.predicted_demand > (item.actual_demand || 0) ? TrendDirection.INCREASING : TrendDirection.DECREASING;
|
||||
return trend === filters.trendDirection;
|
||||
});
|
||||
}
|
||||
|
||||
if (filters.confidenceLevel !== 'all') {
|
||||
filtered = filtered.filter(item => item.confidence_level >= filters.confidenceLevel);
|
||||
}
|
||||
|
||||
if (filters.dateRange !== 'all') {
|
||||
const now = new Date();
|
||||
const filterDate = new Date();
|
||||
|
||||
switch (filters.dateRange) {
|
||||
case 'today':
|
||||
filterDate.setHours(0, 0, 0, 0);
|
||||
break;
|
||||
case 'week':
|
||||
filterDate.setDate(now.getDate() - 7);
|
||||
break;
|
||||
case 'month':
|
||||
filterDate.setMonth(now.getMonth() - 1);
|
||||
break;
|
||||
case 'quarter':
|
||||
filterDate.setMonth(now.getMonth() - 3);
|
||||
break;
|
||||
}
|
||||
|
||||
filtered = filtered.filter(item => new Date(item.forecast_date) >= filterDate);
|
||||
}
|
||||
|
||||
// Apply sorting
|
||||
filtered.sort((a, b) => {
|
||||
let aValue: any;
|
||||
let bValue: any;
|
||||
|
||||
switch (sort.field) {
|
||||
case 'product_name':
|
||||
aValue = a.product_name;
|
||||
bValue = b.product_name;
|
||||
break;
|
||||
case 'predicted_demand':
|
||||
aValue = a.predicted_demand;
|
||||
bValue = b.predicted_demand;
|
||||
break;
|
||||
case 'accuracy_score':
|
||||
aValue = a.accuracy_score || 0;
|
||||
bValue = b.accuracy_score || 0;
|
||||
break;
|
||||
case 'confidence_level':
|
||||
aValue = a.confidence_level;
|
||||
bValue = b.confidence_level;
|
||||
break;
|
||||
case 'forecast_date':
|
||||
aValue = new Date(a.forecast_date);
|
||||
bValue = new Date(b.forecast_date);
|
||||
break;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (aValue < bValue) return sort.order === 'asc' ? -1 : 1;
|
||||
if (aValue > bValue) return sort.order === 'asc' ? 1 : -1;
|
||||
return 0;
|
||||
});
|
||||
|
||||
return filtered;
|
||||
}, [data, filters, sort, getProductCategory]);
|
||||
|
||||
// Paginated data
|
||||
const paginatedData = useMemo(() => {
|
||||
const startIndex = (currentPage - 1) * pageSize;
|
||||
return filteredAndSortedData.slice(startIndex, startIndex + pageSize);
|
||||
}, [filteredAndSortedData, currentPage, pageSize]);
|
||||
|
||||
// Handle sort change
|
||||
const handleSort = useCallback((field: string, order: 'asc' | 'desc' | null) => {
|
||||
if (order === null) {
|
||||
setSort({ field: 'forecast_date', order: 'desc' });
|
||||
} else {
|
||||
setSort({ field, order });
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Handle filter change
|
||||
const handleFilterChange = useCallback((key: keyof FilterState, value: any) => {
|
||||
setFilters(prev => ({ ...prev, [key]: value }));
|
||||
setCurrentPage(1); // Reset to first page when filtering
|
||||
}, []);
|
||||
|
||||
// Handle bulk actions
|
||||
const handleBulkAction = useCallback((action: string) => {
|
||||
const selectedData = filteredAndSortedData.filter(item =>
|
||||
selectedRows.includes(item.id)
|
||||
);
|
||||
onBulkAction?.(action, selectedData);
|
||||
}, [filteredAndSortedData, selectedRows, onBulkAction]);
|
||||
|
||||
// Calculate accuracy percentage and trend
|
||||
const getAccuracyInfo = useCallback((item: ForecastResponse) => {
|
||||
const accuracy = item.accuracy_score || 0;
|
||||
const percentage = (accuracy * 100).toFixed(1);
|
||||
|
||||
let variant: 'success' | 'warning' | 'danger' = 'success';
|
||||
if (accuracy < 0.6) variant = 'danger';
|
||||
else if (accuracy < 0.8) variant = 'warning';
|
||||
|
||||
return { percentage, variant };
|
||||
}, []);
|
||||
|
||||
// Get trend info
|
||||
const getTrendInfo = useCallback((item: ForecastResponse) => {
|
||||
// Simplified trend calculation
|
||||
const trend = item.predicted_demand > (item.actual_demand || 0) ?
|
||||
TrendDirection.INCREASING : TrendDirection.DECREASING;
|
||||
|
||||
return {
|
||||
direction: trend,
|
||||
label: SPANISH_TRENDS[trend],
|
||||
color: TREND_COLORS[trend],
|
||||
icon: TREND_ICONS[trend],
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Table columns
|
||||
const columns: TableColumn<ForecastResponse>[] = useMemo(() => {
|
||||
const baseColumns: TableColumn<ForecastResponse>[] = [
|
||||
{
|
||||
key: 'product_name',
|
||||
title: 'Producto',
|
||||
dataIndex: 'product_name',
|
||||
sortable: true,
|
||||
width: compact ? 120 : 160,
|
||||
render: (value: string, record: ForecastResponse) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => onProductClick?.(value)}
|
||||
className="font-medium text-text-primary hover:text-color-primary transition-colors duration-150"
|
||||
>
|
||||
{value}
|
||||
</button>
|
||||
<Badge variant="ghost" size="sm">
|
||||
{getProductCategory(value)}
|
||||
</Badge>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'predicted_demand',
|
||||
title: 'Demanda Predicha',
|
||||
dataIndex: 'predicted_demand',
|
||||
sortable: true,
|
||||
align: 'right' as const,
|
||||
width: compact ? 100 : 120,
|
||||
render: (value: number) => (
|
||||
<div className="font-semibold text-green-600">
|
||||
{value.toFixed(0)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'actual_demand',
|
||||
title: 'Demanda Real',
|
||||
dataIndex: 'actual_demand',
|
||||
align: 'right' as const,
|
||||
width: compact ? 80 : 100,
|
||||
render: (value?: number) => (
|
||||
<div className="font-medium text-blue-600">
|
||||
{value ? value.toFixed(0) : '-'}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'accuracy_score',
|
||||
title: 'Precisión',
|
||||
dataIndex: 'accuracy_score',
|
||||
sortable: true,
|
||||
align: 'center' as const,
|
||||
width: 80,
|
||||
render: (value: number | undefined, record: ForecastResponse) => {
|
||||
const { percentage, variant } = getAccuracyInfo(record);
|
||||
return (
|
||||
<Badge variant={variant} size="sm">
|
||||
{percentage}%
|
||||
</Badge>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'trend',
|
||||
title: 'Tendencia',
|
||||
align: 'center' as const,
|
||||
width: compact ? 80 : 100,
|
||||
render: (_, record: ForecastResponse) => {
|
||||
const trend = getTrendInfo(record);
|
||||
return (
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<span className="text-lg">{trend.icon}</span>
|
||||
<span className={clsx('text-xs font-medium', trend.color)}>
|
||||
{compact ? '' : trend.label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'confidence_interval',
|
||||
title: 'Intervalo de Confianza',
|
||||
align: 'center' as const,
|
||||
width: compact ? 120 : 140,
|
||||
render: (_, record: ForecastResponse) => (
|
||||
<div className="text-sm text-text-secondary">
|
||||
{record.confidence_lower.toFixed(0)} - {record.confidence_upper.toFixed(0)}
|
||||
<div className="text-xs text-text-tertiary">
|
||||
({(record.confidence_level * 100).toFixed(0)}%)
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
if (!compact) {
|
||||
baseColumns.push(
|
||||
{
|
||||
key: 'forecast_date',
|
||||
title: 'Fecha de Predicción',
|
||||
dataIndex: 'forecast_date',
|
||||
sortable: true,
|
||||
width: 120,
|
||||
render: (value: string) => (
|
||||
<div className="text-sm">
|
||||
{new Date(value).toLocaleDateString('es-ES', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: '2-digit',
|
||||
})}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'model_version',
|
||||
title: 'Modelo',
|
||||
dataIndex: 'model_version',
|
||||
width: 80,
|
||||
render: (value: string) => (
|
||||
<Badge variant="outlined" size="sm">
|
||||
v{value}
|
||||
</Badge>
|
||||
),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return baseColumns;
|
||||
}, [compact, onProductClick, getProductCategory, getAccuracyInfo, getTrendInfo]);
|
||||
|
||||
// Loading state
|
||||
if (loading) {
|
||||
return (
|
||||
<Card className={className}>
|
||||
<CardHeader>
|
||||
<h3 className="text-lg font-semibold text-text-primary">{title}</h3>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-color-primary"></div>
|
||||
<span className="text-text-secondary">Cargando predicciones...</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// Error state
|
||||
if (error) {
|
||||
return (
|
||||
<Card className={className} variant="outlined">
|
||||
<CardHeader>
|
||||
<h3 className="text-lg font-semibold text-text-primary">{title}</h3>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-center">
|
||||
<div className="text-red-500 mb-2">
|
||||
<svg className="w-12 h-12 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-text-secondary">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className={className}>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between flex-wrap gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<h3 className="text-lg font-semibold text-text-primary">{title}</h3>
|
||||
<Badge variant="ghost">
|
||||
{filteredAndSortedData.length} predicciones
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{onExportCSV && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onExportCSV(filteredAndSortedData)}
|
||||
>
|
||||
📊 Exportar CSV
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{showBulkActions && selectedRows.length > 0 && (
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleBulkAction('production_plan')}
|
||||
>
|
||||
📋 Plan de Producción
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleBulkAction('inventory_alert')}
|
||||
>
|
||||
🚨 Alertas de Inventario
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
{showFilters && (
|
||||
<div className="px-6 py-4 border-b border-border-primary bg-bg-secondary">
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4">
|
||||
<Input
|
||||
placeholder="Buscar producto..."
|
||||
value={filters.productName}
|
||||
onChange={(e) => handleFilterChange('productName', e.target.value)}
|
||||
className="text-sm"
|
||||
/>
|
||||
|
||||
<Select
|
||||
value={filters.category}
|
||||
onChange={(value) => handleFilterChange('category', value)}
|
||||
className="text-sm"
|
||||
>
|
||||
{BAKERY_CATEGORIES.map(cat => (
|
||||
<option key={cat.value} value={cat.value}>{cat.label}</option>
|
||||
))}
|
||||
</Select>
|
||||
|
||||
<Select
|
||||
value={filters.accuracyRange}
|
||||
onChange={(value) => handleFilterChange('accuracyRange', value)}
|
||||
className="text-sm"
|
||||
>
|
||||
<option value="all">Toda precisión</option>
|
||||
<option value="high">Alta (>80%)</option>
|
||||
<option value="medium">Media (60-80%)</option>
|
||||
<option value="low">Baja (<60%)</option>
|
||||
</Select>
|
||||
|
||||
<Select
|
||||
value={filters.trendDirection}
|
||||
onChange={(value) => handleFilterChange('trendDirection', value)}
|
||||
className="text-sm"
|
||||
>
|
||||
<option value="all">Todas las tendencias</option>
|
||||
{Object.entries(SPANISH_TRENDS).map(([key, label]) => (
|
||||
<option key={key} value={key}>{label}</option>
|
||||
))}
|
||||
</Select>
|
||||
|
||||
<Select
|
||||
value={filters.confidenceLevel}
|
||||
onChange={(value) => handleFilterChange('confidenceLevel', value === 'all' ? 'all' : parseFloat(value))}
|
||||
className="text-sm"
|
||||
>
|
||||
<option value="all">Toda confianza</option>
|
||||
<option value="0.9">Alta (>90%)</option>
|
||||
<option value="0.8">Media (>80%)</option>
|
||||
<option value="0.7">Baja (>70%)</option>
|
||||
</Select>
|
||||
|
||||
<Select
|
||||
value={filters.dateRange}
|
||||
onChange={(value) => handleFilterChange('dateRange', value)}
|
||||
className="text-sm"
|
||||
>
|
||||
<option value="all">Todo el tiempo</option>
|
||||
<option value="today">Hoy</option>
|
||||
<option value="week">Última semana</option>
|
||||
<option value="month">Último mes</option>
|
||||
<option value="quarter">Último trimestre</option>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<CardBody padding="none">
|
||||
<Table
|
||||
columns={columns}
|
||||
data={paginatedData}
|
||||
rowSelection={showBulkActions ? {
|
||||
selectedRowKeys: selectedRows,
|
||||
onSelectAll: (selected, selectedRows, changeRows) => {
|
||||
setSelectedRows(selected ? selectedRows.map(row => row.id) : []);
|
||||
},
|
||||
onSelect: (record, selected) => {
|
||||
setSelectedRows(prev =>
|
||||
selected
|
||||
? [...prev, record.id]
|
||||
: prev.filter(key => key !== record.id)
|
||||
);
|
||||
},
|
||||
} : undefined}
|
||||
pagination={{
|
||||
current: currentPage,
|
||||
pageSize,
|
||||
total: filteredAndSortedData.length,
|
||||
showSizeChanger: true,
|
||||
showTotal: (total, range) =>
|
||||
`${range[0]}-${range[1]} de ${total} predicciones`,
|
||||
onChange: (page, size) => {
|
||||
setCurrentPage(page);
|
||||
},
|
||||
}}
|
||||
sort={{
|
||||
field: sort.field,
|
||||
order: sort.order,
|
||||
}}
|
||||
onSort={handleSort}
|
||||
size={compact ? 'sm' : 'md'}
|
||||
hover={true}
|
||||
expandable={!compact ? {
|
||||
expandedRowRender: (record) => (
|
||||
<div className="p-4 bg-bg-tertiary">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
|
||||
<div>
|
||||
<div className="font-medium text-text-primary mb-1">ID de Predicción</div>
|
||||
<div className="text-text-secondary font-mono">{record.id}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-text-primary mb-1">Fecha de Creación</div>
|
||||
<div className="text-text-secondary">
|
||||
{new Date(record.created_at).toLocaleString('es-ES')}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-text-primary mb-1">Factores Externos</div>
|
||||
<div className="text-text-secondary">
|
||||
{Object.entries(record.external_factors_impact).length > 0
|
||||
? Object.keys(record.external_factors_impact).join(', ')
|
||||
: 'Ninguno'
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-text-primary mb-1">Componente Estacional</div>
|
||||
<div className="text-text-secondary">
|
||||
{record.seasonal_component
|
||||
? (record.seasonal_component * 100).toFixed(1) + '%'
|
||||
: 'N/A'
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
} : undefined}
|
||||
/>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default ForecastTable;
|
||||
@@ -0,0 +1,634 @@
|
||||
import React, { useState, useMemo, useCallback } from 'react';
|
||||
import { clsx } from 'clsx';
|
||||
import {
|
||||
RadialBarChart,
|
||||
RadialBar,
|
||||
ResponsiveContainer,
|
||||
PieChart,
|
||||
Pie,
|
||||
Cell,
|
||||
Tooltip,
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
ComposedChart,
|
||||
Line,
|
||||
LineChart,
|
||||
ReferenceLine,
|
||||
} from 'recharts';
|
||||
import { Card, CardHeader, CardBody } from '../../ui';
|
||||
import { Button } from '../../ui';
|
||||
import { Badge } from '../../ui';
|
||||
import { Select } from '../../ui';
|
||||
import {
|
||||
SeasonalPattern,
|
||||
SeasonalComponent,
|
||||
HolidayEffect,
|
||||
WeeklyPattern,
|
||||
YearlyTrend,
|
||||
Season,
|
||||
SeasonalPeriod,
|
||||
DayOfWeek,
|
||||
} from '../../../types/forecasting.types';
|
||||
|
||||
export interface SeasonalityIndicatorProps {
|
||||
className?: string;
|
||||
title?: string;
|
||||
seasonalPatterns?: SeasonalPattern[];
|
||||
selectedProduct?: string;
|
||||
onProductChange?: (product: string) => void;
|
||||
viewMode?: 'circular' | 'calendar' | 'heatmap' | 'trends';
|
||||
onViewModeChange?: (mode: 'circular' | 'calendar' | 'heatmap' | 'trends') => void;
|
||||
showComparison?: boolean;
|
||||
comparisonYear?: number;
|
||||
loading?: boolean;
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
interface MonthlyData {
|
||||
month: string;
|
||||
value: number;
|
||||
strength: number;
|
||||
color: string;
|
||||
season: Season;
|
||||
holidays: string[];
|
||||
}
|
||||
|
||||
interface WeeklyData {
|
||||
day: string;
|
||||
dayShort: string;
|
||||
value: number;
|
||||
variance: number;
|
||||
peakHours?: number[];
|
||||
}
|
||||
|
||||
interface HeatmapData {
|
||||
month: number;
|
||||
week: number;
|
||||
intensity: number;
|
||||
value: number;
|
||||
holiday?: string;
|
||||
}
|
||||
|
||||
const SPANISH_MONTHS = [
|
||||
'Enero', 'Febrero', 'Marzo', 'Abril', 'Mayo', 'Junio',
|
||||
'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre'
|
||||
];
|
||||
|
||||
const SPANISH_DAYS = [
|
||||
'Lunes', 'Martes', 'Miércoles', 'Jueves', 'Viernes', 'Sábado', 'Domingo'
|
||||
];
|
||||
|
||||
const SPANISH_DAYS_SHORT = [
|
||||
'L', 'M', 'X', 'J', 'V', 'S', 'D'
|
||||
];
|
||||
|
||||
const SPANISH_SEASONS: Record<Season, string> = {
|
||||
[Season.SPRING]: 'Primavera',
|
||||
[Season.SUMMER]: 'Verano',
|
||||
[Season.FALL]: 'Otoño',
|
||||
[Season.WINTER]: 'Invierno',
|
||||
};
|
||||
|
||||
const SEASON_COLORS: Record<Season, string> = {
|
||||
[Season.SPRING]: '#22c55e', // Green
|
||||
[Season.SUMMER]: '#f59e0b', // Amber
|
||||
[Season.FALL]: '#ea580c', // Orange
|
||||
[Season.WINTER]: '#3b82f6', // Blue
|
||||
};
|
||||
|
||||
const SPANISH_HOLIDAYS = [
|
||||
{ month: 0, name: 'Año Nuevo' },
|
||||
{ month: 0, name: 'Día de Reyes' },
|
||||
{ month: 2, name: 'Semana Santa' }, // March/April
|
||||
{ month: 4, name: 'Día del Trabajador' },
|
||||
{ month: 7, name: 'Asunción' },
|
||||
{ month: 9, name: 'Día Nacional' },
|
||||
{ month: 10, name: 'Todos los Santos' },
|
||||
{ month: 11, name: 'Constitución' },
|
||||
{ month: 11, name: 'Navidad' },
|
||||
];
|
||||
|
||||
const INTENSITY_COLORS = [
|
||||
'#f3f4f6', // Very low
|
||||
'#dbeafe', // Low
|
||||
'#bfdbfe', // Medium-low
|
||||
'#93c5fd', // Medium
|
||||
'#60a5fa', // Medium-high
|
||||
'#3b82f6', // High
|
||||
'#2563eb', // Very high
|
||||
'#1d4ed8', // Extremely high
|
||||
];
|
||||
|
||||
const SeasonalityIndicator: React.FC<SeasonalityIndicatorProps> = ({
|
||||
className,
|
||||
title = 'Patrones Estacionales',
|
||||
seasonalPatterns = [],
|
||||
selectedProduct = '',
|
||||
onProductChange,
|
||||
viewMode = 'circular',
|
||||
onViewModeChange,
|
||||
showComparison = false,
|
||||
comparisonYear = new Date().getFullYear() - 1,
|
||||
loading = false,
|
||||
error = null,
|
||||
}) => {
|
||||
const [hoveredElement, setHoveredElement] = useState<any>(null);
|
||||
|
||||
// Get current pattern data
|
||||
const currentPattern = useMemo(() => {
|
||||
if (!selectedProduct || seasonalPatterns.length === 0) {
|
||||
return seasonalPatterns[0] || null;
|
||||
}
|
||||
return seasonalPatterns.find(p => p.product_name === selectedProduct) || seasonalPatterns[0];
|
||||
}, [seasonalPatterns, selectedProduct]);
|
||||
|
||||
// Process monthly seasonal data
|
||||
const monthlyData = useMemo((): MonthlyData[] => {
|
||||
if (!currentPattern) return [];
|
||||
|
||||
const monthlyComponent = currentPattern.seasonal_components.find(
|
||||
c => c.period === SeasonalPeriod.MONTHLY
|
||||
);
|
||||
|
||||
if (!monthlyComponent) return [];
|
||||
|
||||
return SPANISH_MONTHS.map((month, index) => {
|
||||
const value = monthlyComponent.pattern[index] || 0;
|
||||
const strength = Math.abs(value);
|
||||
|
||||
// Determine season
|
||||
let season: Season;
|
||||
if (index >= 2 && index <= 4) season = Season.SPRING;
|
||||
else if (index >= 5 && index <= 7) season = Season.SUMMER;
|
||||
else if (index >= 8 && index <= 10) season = Season.FALL;
|
||||
else season = Season.WINTER;
|
||||
|
||||
// Get holidays for this month
|
||||
const holidays = SPANISH_HOLIDAYS
|
||||
.filter(h => h.month === index)
|
||||
.map(h => h.name);
|
||||
|
||||
return {
|
||||
month,
|
||||
value: value * 100, // Convert to percentage
|
||||
strength: strength * 100,
|
||||
color: SEASON_COLORS[season],
|
||||
season,
|
||||
holidays,
|
||||
};
|
||||
});
|
||||
}, [currentPattern]);
|
||||
|
||||
// Process weekly data
|
||||
const weeklyData = useMemo((): WeeklyData[] => {
|
||||
if (!currentPattern) return [];
|
||||
|
||||
return currentPattern.weekly_patterns.map((pattern, index) => ({
|
||||
day: SPANISH_DAYS[index] || `Día ${index + 1}`,
|
||||
dayShort: SPANISH_DAYS_SHORT[index] || `D${index + 1}`,
|
||||
value: pattern.average_multiplier * 100,
|
||||
variance: pattern.variance * 100,
|
||||
peakHours: pattern.peak_hours,
|
||||
}));
|
||||
}, [currentPattern]);
|
||||
|
||||
// Process heatmap data
|
||||
const heatmapData = useMemo((): HeatmapData[] => {
|
||||
if (!currentPattern) return [];
|
||||
|
||||
const data: HeatmapData[] = [];
|
||||
const monthlyComponent = currentPattern.seasonal_components.find(
|
||||
c => c.period === SeasonalPeriod.MONTHLY
|
||||
);
|
||||
|
||||
if (monthlyComponent) {
|
||||
for (let month = 0; month < 12; month++) {
|
||||
for (let week = 0; week < 4; week++) {
|
||||
const value = monthlyComponent.pattern[month] || 0;
|
||||
const intensity = Math.min(Math.max(Math.abs(value) * 8, 0), 7); // 0-7 scale
|
||||
|
||||
const holiday = SPANISH_HOLIDAYS.find(h => h.month === month);
|
||||
|
||||
data.push({
|
||||
month,
|
||||
week,
|
||||
intensity: Math.floor(intensity),
|
||||
value: value * 100,
|
||||
holiday: holiday?.name,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return data;
|
||||
}, [currentPattern]);
|
||||
|
||||
// Custom tooltip for radial chart
|
||||
const RadialTooltip = ({ active, payload }: any) => {
|
||||
if (!active || !payload || !payload.length) return null;
|
||||
|
||||
const data = payload[0].payload as MonthlyData;
|
||||
|
||||
return (
|
||||
<div className="bg-card-bg border border-border-primary rounded-lg shadow-lg p-3 max-w-sm">
|
||||
<h4 className="font-semibold text-text-primary mb-2">{data.month}</h4>
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-text-secondary text-sm">Estación:</span>
|
||||
<Badge variant="ghost" size="sm" style={{ color: data.color }}>
|
||||
{SPANISH_SEASONS[data.season]}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-text-secondary text-sm">Variación:</span>
|
||||
<span className="font-medium">{data.value.toFixed(1)}%</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-text-secondary text-sm">Intensidad:</span>
|
||||
<span className="font-medium">{data.strength.toFixed(1)}%</span>
|
||||
</div>
|
||||
{data.holidays.length > 0 && (
|
||||
<div className="mt-2 pt-2 border-t border-border-secondary">
|
||||
<div className="text-text-secondary text-sm mb-1">Festividades:</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{data.holidays.map(holiday => (
|
||||
<Badge key={holiday} variant="outlined" size="sm">
|
||||
{holiday}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Circular view (Radial chart)
|
||||
const renderCircularView = () => (
|
||||
<div style={{ width: '100%', height: 400 }}>
|
||||
<ResponsiveContainer>
|
||||
<RadialBarChart
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius="60%"
|
||||
outerRadius="90%"
|
||||
data={monthlyData}
|
||||
startAngle={90}
|
||||
endAngle={450}
|
||||
>
|
||||
<RadialBar
|
||||
dataKey="strength"
|
||||
cornerRadius={4}
|
||||
fill={(entry) => entry.color}
|
||||
/>
|
||||
<Tooltip content={<RadialTooltip />} />
|
||||
</RadialBarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Calendar view (Bar chart by month)
|
||||
const renderCalendarView = () => (
|
||||
<div style={{ width: '100%', height: 400 }}>
|
||||
<ResponsiveContainer>
|
||||
<ComposedChart data={monthlyData} margin={{ top: 20, right: 30, left: 20, bottom: 5 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis
|
||||
dataKey="month"
|
||||
tick={{ fontSize: 12 }}
|
||||
angle={-45}
|
||||
textAnchor="end"
|
||||
height={80}
|
||||
/>
|
||||
<YAxis tick={{ fontSize: 12 }} />
|
||||
<Tooltip
|
||||
content={({ active, payload, label }) => {
|
||||
if (!active || !payload || !payload.length) return null;
|
||||
const data = payload[0].payload as MonthlyData;
|
||||
return <RadialTooltip active={true} payload={[{ payload: data }]} />;
|
||||
}}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="value"
|
||||
fill={(entry, index) => monthlyData[index]?.color || '#8884d8'}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="strength"
|
||||
stroke="#ff7300"
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
/>
|
||||
<ReferenceLine y={0} stroke="#666" strokeDasharray="2 2" />
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Heatmap view
|
||||
const renderHeatmapView = () => (
|
||||
<div className="grid grid-cols-12 gap-1 p-4">
|
||||
{/* Month labels */}
|
||||
<div className="col-span-12 grid grid-cols-12 gap-1 mb-2">
|
||||
{SPANISH_MONTHS.map(month => (
|
||||
<div key={month} className="text-xs text-center text-text-tertiary font-medium">
|
||||
{month.slice(0, 3)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Heatmap grid */}
|
||||
{[0, 1, 2, 3].map(week => (
|
||||
<div key={week} className="col-span-12 grid grid-cols-12 gap-1">
|
||||
{heatmapData
|
||||
.filter(d => d.week === week)
|
||||
.map((cell, monthIndex) => (
|
||||
<div
|
||||
key={`${cell.month}-${cell.week}`}
|
||||
className="aspect-square rounded cursor-pointer transition-all duration-200 hover:scale-110 flex items-center justify-center"
|
||||
style={{
|
||||
backgroundColor: INTENSITY_COLORS[cell.intensity],
|
||||
border: hoveredElement?.month === cell.month && hoveredElement?.week === cell.week
|
||||
? '2px solid #3b82f6' : '1px solid #e5e7eb'
|
||||
}}
|
||||
onMouseEnter={() => setHoveredElement(cell)}
|
||||
onMouseLeave={() => setHoveredElement(null)}
|
||||
title={`${SPANISH_MONTHS[cell.month]} S${cell.week + 1}: ${cell.value.toFixed(1)}%`}
|
||||
>
|
||||
{cell.holiday && (
|
||||
<div className="text-xs">🎉</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Legend */}
|
||||
<div className="col-span-12 flex items-center justify-center gap-4 mt-4">
|
||||
<span className="text-sm text-text-secondary">Baja</span>
|
||||
<div className="flex gap-1">
|
||||
{INTENSITY_COLORS.map((color, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="w-4 h-4 rounded"
|
||||
style={{ backgroundColor: color }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<span className="text-sm text-text-secondary">Alta</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Trends view (Weekly patterns)
|
||||
const renderTrendsView = () => (
|
||||
<div style={{ width: '100%', height: 400 }}>
|
||||
<ResponsiveContainer>
|
||||
<BarChart data={weeklyData} margin={{ top: 20, right: 30, left: 20, bottom: 5 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="dayShort" tick={{ fontSize: 12 }} />
|
||||
<YAxis tick={{ fontSize: 12 }} />
|
||||
<Tooltip
|
||||
content={({ active, payload, label }) => {
|
||||
if (!active || !payload || !payload.length) return null;
|
||||
const data = payload[0].payload as WeeklyData;
|
||||
return (
|
||||
<div className="bg-card-bg border border-border-primary rounded-lg shadow-lg p-3">
|
||||
<h4 className="font-semibold text-text-primary mb-2">{data.day}</h4>
|
||||
<div className="space-y-1 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span>Multiplicador Promedio:</span>
|
||||
<span className="font-medium">{data.value.toFixed(1)}%</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>Varianza:</span>
|
||||
<span className="font-medium">{data.variance.toFixed(1)}%</span>
|
||||
</div>
|
||||
{data.peakHours && data.peakHours.length > 0 && (
|
||||
<div className="flex justify-between">
|
||||
<span>Horas Pico:</span>
|
||||
<span className="font-medium">
|
||||
{data.peakHours.map(h => `${h}:00`).join(', ')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Bar dataKey="value" fill="#8884d8" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Loading state
|
||||
if (loading) {
|
||||
return (
|
||||
<Card className={className}>
|
||||
<CardHeader>
|
||||
<h3 className="text-lg font-semibold text-text-primary">{title}</h3>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-color-primary"></div>
|
||||
<span className="text-text-secondary">Cargando patrones estacionales...</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// Error state
|
||||
if (error) {
|
||||
return (
|
||||
<Card className={className} variant="outlined">
|
||||
<CardHeader>
|
||||
<h3 className="text-lg font-semibold text-text-primary">{title}</h3>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-center">
|
||||
<div className="text-red-500 mb-2">
|
||||
<svg className="w-12 h-12 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-text-secondary">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// Empty state
|
||||
if (!currentPattern) {
|
||||
return (
|
||||
<Card className={className}>
|
||||
<CardHeader>
|
||||
<h3 className="text-lg font-semibold text-text-primary">{title}</h3>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-center">
|
||||
<div className="text-text-tertiary mb-2">
|
||||
<svg className="w-12 h-12 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1} d="M8 7V3a2 2 0 012-2h4a2 2 0 012 2v4m-6 4v10a2 2 0 002 2h4a2 2 0 002-2V11m-6 0a2 2 0 012-2h4a2 2 0 012 2m-6 0h8" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-text-secondary">No hay datos de estacionalidad disponibles</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className={className}>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between flex-wrap gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<h3 className="text-lg font-semibold text-text-primary">{title}</h3>
|
||||
{currentPattern && (
|
||||
<Badge variant="ghost">
|
||||
{currentPattern.product_name}
|
||||
</Badge>
|
||||
)}
|
||||
<Badge
|
||||
variant="success"
|
||||
size="sm"
|
||||
title="Nivel de confianza del patrón estacional"
|
||||
>
|
||||
{(currentPattern.confidence_score * 100).toFixed(0)}% confianza
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Product selector */}
|
||||
{onProductChange && seasonalPatterns.length > 1 && (
|
||||
<Select
|
||||
value={selectedProduct || seasonalPatterns[0]?.product_name || ''}
|
||||
onChange={(value) => onProductChange(value)}
|
||||
className="w-40"
|
||||
>
|
||||
{seasonalPatterns.map(pattern => (
|
||||
<option key={pattern.product_name} value={pattern.product_name}>
|
||||
{pattern.product_name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
)}
|
||||
|
||||
{/* View mode selector */}
|
||||
<div className="flex bg-bg-secondary rounded-lg p-1 gap-1">
|
||||
{(['circular', 'calendar', 'heatmap', 'trends'] as const).map((mode) => (
|
||||
<Button
|
||||
key={mode}
|
||||
variant={viewMode === mode ? 'filled' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => onViewModeChange?.(mode)}
|
||||
title={
|
||||
mode === 'circular' ? 'Vista Circular' :
|
||||
mode === 'calendar' ? 'Vista Calendario' :
|
||||
mode === 'heatmap' ? 'Mapa de Calor' :
|
||||
'Vista Tendencias'
|
||||
}
|
||||
>
|
||||
{mode === 'circular' && '🔄'}
|
||||
{mode === 'calendar' && '📅'}
|
||||
{mode === 'heatmap' && '🔥'}
|
||||
{mode === 'trends' && '📊'}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardBody>
|
||||
<div className="space-y-4">
|
||||
{/* Main visualization */}
|
||||
<div className="min-h-[400px]">
|
||||
{viewMode === 'circular' && renderCircularView()}
|
||||
{viewMode === 'calendar' && renderCalendarView()}
|
||||
{viewMode === 'heatmap' && renderHeatmapView()}
|
||||
{viewMode === 'trends' && renderTrendsView()}
|
||||
</div>
|
||||
|
||||
{/* Holiday effects summary */}
|
||||
{currentPattern.holiday_effects && currentPattern.holiday_effects.length > 0 && (
|
||||
<div className="border-t border-border-primary pt-4">
|
||||
<h4 className="font-medium text-text-primary mb-3">Efectos de Festividades</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{currentPattern.holiday_effects.map((holiday, index) => (
|
||||
<div key={index} className="bg-bg-secondary rounded-lg p-3">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="font-medium text-text-primary text-sm">
|
||||
{holiday.holiday_name}
|
||||
</span>
|
||||
<Badge
|
||||
variant={holiday.impact_factor > 1.2 ? 'success' : holiday.impact_factor < 0.8 ? 'danger' : 'warning'}
|
||||
size="sm"
|
||||
>
|
||||
{((holiday.impact_factor - 1) * 100).toFixed(0)}%
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="text-xs text-text-secondary space-y-1">
|
||||
<div>Duración: {holiday.duration_days} días</div>
|
||||
<div>Confianza: {(holiday.confidence * 100).toFixed(0)}%</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pattern strength indicators */}
|
||||
<div className="border-t border-border-primary pt-4">
|
||||
<h4 className="font-medium text-text-primary mb-3">Intensidad de Patrones</h4>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
{currentPattern.seasonal_components.map((component, index) => {
|
||||
const periodLabel = {
|
||||
[SeasonalPeriod.WEEKLY]: 'Semanal',
|
||||
[SeasonalPeriod.MONTHLY]: 'Mensual',
|
||||
[SeasonalPeriod.QUARTERLY]: 'Trimestral',
|
||||
[SeasonalPeriod.YEARLY]: 'Anual',
|
||||
}[component.period] || component.period;
|
||||
|
||||
return (
|
||||
<div key={index} className="bg-bg-secondary rounded-lg p-3">
|
||||
<div className="text-sm font-medium text-text-primary mb-1">
|
||||
{periodLabel}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 bg-bg-tertiary rounded-full h-2">
|
||||
<div
|
||||
className="bg-color-primary h-2 rounded-full transition-all duration-300"
|
||||
style={{ width: `${Math.min(component.strength * 100, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-text-secondary">
|
||||
{(component.strength * 100).toFixed(0)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default SeasonalityIndicator;
|
||||
@@ -0,0 +1,634 @@
|
||||
import React, { useState, useMemo, useCallback } from 'react';
|
||||
import { clsx } from 'clsx';
|
||||
import {
|
||||
RadialBarChart,
|
||||
RadialBar,
|
||||
ResponsiveContainer,
|
||||
PieChart,
|
||||
Pie,
|
||||
Cell,
|
||||
Tooltip,
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
ComposedChart,
|
||||
Line,
|
||||
LineChart,
|
||||
ReferenceLine,
|
||||
} from 'recharts';
|
||||
import { Card, CardHeader, CardBody } from '../../ui';
|
||||
import { Button } from '../../ui';
|
||||
import { Badge } from '../../ui';
|
||||
import { Select } from '../../ui';
|
||||
import {
|
||||
SeasonalPattern,
|
||||
SeasonalComponent,
|
||||
HolidayEffect,
|
||||
WeeklyPattern,
|
||||
YearlyTrend,
|
||||
Season,
|
||||
SeasonalPeriod,
|
||||
DayOfWeek,
|
||||
} from '../../../types/forecasting.types';
|
||||
|
||||
export interface SeasonalityIndicatorProps {
|
||||
className?: string;
|
||||
title?: string;
|
||||
seasonalPatterns?: SeasonalPattern[];
|
||||
selectedProduct?: string;
|
||||
onProductChange?: (product: string) => void;
|
||||
viewMode?: 'circular' | 'calendar' | 'heatmap' | 'trends';
|
||||
onViewModeChange?: (mode: 'circular' | 'calendar' | 'heatmap' | 'trends') => void;
|
||||
showComparison?: boolean;
|
||||
comparisonYear?: number;
|
||||
loading?: boolean;
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
interface MonthlyData {
|
||||
month: string;
|
||||
value: number;
|
||||
strength: number;
|
||||
color: string;
|
||||
season: Season;
|
||||
holidays: string[];
|
||||
}
|
||||
|
||||
interface WeeklyData {
|
||||
day: string;
|
||||
dayShort: string;
|
||||
value: number;
|
||||
variance: number;
|
||||
peakHours?: number[];
|
||||
}
|
||||
|
||||
interface HeatmapData {
|
||||
month: number;
|
||||
week: number;
|
||||
intensity: number;
|
||||
value: number;
|
||||
holiday?: string;
|
||||
}
|
||||
|
||||
const SPANISH_MONTHS = [
|
||||
'Enero', 'Febrero', 'Marzo', 'Abril', 'Mayo', 'Junio',
|
||||
'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre'
|
||||
];
|
||||
|
||||
const SPANISH_DAYS = [
|
||||
'Lunes', 'Martes', 'Miércoles', 'Jueves', 'Viernes', 'Sábado', 'Domingo'
|
||||
];
|
||||
|
||||
const SPANISH_DAYS_SHORT = [
|
||||
'L', 'M', 'X', 'J', 'V', 'S', 'D'
|
||||
];
|
||||
|
||||
const SPANISH_SEASONS: Record<Season, string> = {
|
||||
[Season.SPRING]: 'Primavera',
|
||||
[Season.SUMMER]: 'Verano',
|
||||
[Season.FALL]: 'Otoño',
|
||||
[Season.WINTER]: 'Invierno',
|
||||
};
|
||||
|
||||
const SEASON_COLORS: Record<Season, string> = {
|
||||
[Season.SPRING]: '#22c55e', // Green
|
||||
[Season.SUMMER]: '#f59e0b', // Amber
|
||||
[Season.FALL]: '#ea580c', // Orange
|
||||
[Season.WINTER]: '#3b82f6', // Blue
|
||||
};
|
||||
|
||||
const SPANISH_HOLIDAYS = [
|
||||
{ month: 0, name: 'Año Nuevo' },
|
||||
{ month: 0, name: 'Día de Reyes' },
|
||||
{ month: 2, name: 'Semana Santa' }, // March/April
|
||||
{ month: 4, name: 'Día del Trabajador' },
|
||||
{ month: 7, name: 'Asunción' },
|
||||
{ month: 9, name: 'Día Nacional' },
|
||||
{ month: 10, name: 'Todos los Santos' },
|
||||
{ month: 11, name: 'Constitución' },
|
||||
{ month: 11, name: 'Navidad' },
|
||||
];
|
||||
|
||||
const INTENSITY_COLORS = [
|
||||
'#f3f4f6', // Very low
|
||||
'#dbeafe', // Low
|
||||
'#bfdbfe', // Medium-low
|
||||
'#93c5fd', // Medium
|
||||
'#60a5fa', // Medium-high
|
||||
'#3b82f6', // High
|
||||
'#2563eb', // Very high
|
||||
'#1d4ed8', // Extremely high
|
||||
];
|
||||
|
||||
const SeasonalityIndicator: React.FC<SeasonalityIndicatorProps> = ({
|
||||
className,
|
||||
title = 'Patrones Estacionales',
|
||||
seasonalPatterns = [],
|
||||
selectedProduct = '',
|
||||
onProductChange,
|
||||
viewMode = 'circular',
|
||||
onViewModeChange,
|
||||
showComparison = false,
|
||||
comparisonYear = new Date().getFullYear() - 1,
|
||||
loading = false,
|
||||
error = null,
|
||||
}) => {
|
||||
const [hoveredElement, setHoveredElement] = useState<any>(null);
|
||||
|
||||
// Get current pattern data
|
||||
const currentPattern = useMemo(() => {
|
||||
if (!selectedProduct || seasonalPatterns.length === 0) {
|
||||
return seasonalPatterns[0] || null;
|
||||
}
|
||||
return seasonalPatterns.find(p => p.product_name === selectedProduct) || seasonalPatterns[0];
|
||||
}, [seasonalPatterns, selectedProduct]);
|
||||
|
||||
// Process monthly seasonal data
|
||||
const monthlyData = useMemo((): MonthlyData[] => {
|
||||
if (!currentPattern) return [];
|
||||
|
||||
const monthlyComponent = currentPattern.seasonal_components.find(
|
||||
c => c.period === SeasonalPeriod.MONTHLY
|
||||
);
|
||||
|
||||
if (!monthlyComponent) return [];
|
||||
|
||||
return SPANISH_MONTHS.map((month, index) => {
|
||||
const value = monthlyComponent.pattern[index] || 0;
|
||||
const strength = Math.abs(value);
|
||||
|
||||
// Determine season
|
||||
let season: Season;
|
||||
if (index >= 2 && index <= 4) season = Season.SPRING;
|
||||
else if (index >= 5 && index <= 7) season = Season.SUMMER;
|
||||
else if (index >= 8 && index <= 10) season = Season.FALL;
|
||||
else season = Season.WINTER;
|
||||
|
||||
// Get holidays for this month
|
||||
const holidays = SPANISH_HOLIDAYS
|
||||
.filter(h => h.month === index)
|
||||
.map(h => h.name);
|
||||
|
||||
return {
|
||||
month,
|
||||
value: value * 100, // Convert to percentage
|
||||
strength: strength * 100,
|
||||
color: SEASON_COLORS[season],
|
||||
season,
|
||||
holidays,
|
||||
};
|
||||
});
|
||||
}, [currentPattern]);
|
||||
|
||||
// Process weekly data
|
||||
const weeklyData = useMemo((): WeeklyData[] => {
|
||||
if (!currentPattern) return [];
|
||||
|
||||
return currentPattern.weekly_patterns.map((pattern, index) => ({
|
||||
day: SPANISH_DAYS[index] || `Día ${index + 1}`,
|
||||
dayShort: SPANISH_DAYS_SHORT[index] || `D${index + 1}`,
|
||||
value: pattern.average_multiplier * 100,
|
||||
variance: pattern.variance * 100,
|
||||
peakHours: pattern.peak_hours,
|
||||
}));
|
||||
}, [currentPattern]);
|
||||
|
||||
// Process heatmap data
|
||||
const heatmapData = useMemo((): HeatmapData[] => {
|
||||
if (!currentPattern) return [];
|
||||
|
||||
const data: HeatmapData[] = [];
|
||||
const monthlyComponent = currentPattern.seasonal_components.find(
|
||||
c => c.period === SeasonalPeriod.MONTHLY
|
||||
);
|
||||
|
||||
if (monthlyComponent) {
|
||||
for (let month = 0; month < 12; month++) {
|
||||
for (let week = 0; week < 4; week++) {
|
||||
const value = monthlyComponent.pattern[month] || 0;
|
||||
const intensity = Math.min(Math.max(Math.abs(value) * 8, 0), 7); // 0-7 scale
|
||||
|
||||
const holiday = SPANISH_HOLIDAYS.find(h => h.month === month);
|
||||
|
||||
data.push({
|
||||
month,
|
||||
week,
|
||||
intensity: Math.floor(intensity),
|
||||
value: value * 100,
|
||||
holiday: holiday?.name,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return data;
|
||||
}, [currentPattern]);
|
||||
|
||||
// Custom tooltip for radial chart
|
||||
const RadialTooltip = ({ active, payload }: any) => {
|
||||
if (!active || !payload || !payload.length) return null;
|
||||
|
||||
const data = payload[0].payload as MonthlyData;
|
||||
|
||||
return (
|
||||
<div className="bg-card-bg border border-border-primary rounded-lg shadow-lg p-3 max-w-sm">
|
||||
<h4 className="font-semibold text-text-primary mb-2">{data.month}</h4>
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-text-secondary text-sm">Estación:</span>
|
||||
<Badge variant="ghost" size="sm" style={{ color: data.color }}>
|
||||
{SPANISH_SEASONS[data.season]}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-text-secondary text-sm">Variación:</span>
|
||||
<span className="font-medium">{data.value.toFixed(1)}%</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-text-secondary text-sm">Intensidad:</span>
|
||||
<span className="font-medium">{data.strength.toFixed(1)}%</span>
|
||||
</div>
|
||||
{data.holidays.length > 0 && (
|
||||
<div className="mt-2 pt-2 border-t border-border-secondary">
|
||||
<div className="text-text-secondary text-sm mb-1">Festividades:</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{data.holidays.map(holiday => (
|
||||
<Badge key={holiday} variant="outlined" size="sm">
|
||||
{holiday}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Circular view (Radial chart)
|
||||
const renderCircularView = () => (
|
||||
<div style={{ width: '100%', height: 400 }}>
|
||||
<ResponsiveContainer>
|
||||
<RadialBarChart
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius="60%"
|
||||
outerRadius="90%"
|
||||
data={monthlyData}
|
||||
startAngle={90}
|
||||
endAngle={450}
|
||||
>
|
||||
<RadialBar
|
||||
dataKey="strength"
|
||||
cornerRadius={4}
|
||||
fill={(entry) => entry.color}
|
||||
/>
|
||||
<Tooltip content={<RadialTooltip />} />
|
||||
</RadialBarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Calendar view (Bar chart by month)
|
||||
const renderCalendarView = () => (
|
||||
<div style={{ width: '100%', height: 400 }}>
|
||||
<ResponsiveContainer>
|
||||
<ComposedChart data={monthlyData} margin={{ top: 20, right: 30, left: 20, bottom: 5 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis
|
||||
dataKey="month"
|
||||
tick={{ fontSize: 12 }}
|
||||
angle={-45}
|
||||
textAnchor="end"
|
||||
height={80}
|
||||
/>
|
||||
<YAxis tick={{ fontSize: 12 }} />
|
||||
<Tooltip
|
||||
content={({ active, payload, label }) => {
|
||||
if (!active || !payload || !payload.length) return null;
|
||||
const data = payload[0].payload as MonthlyData;
|
||||
return <RadialTooltip active={true} payload={[{ payload: data }]} />;
|
||||
}}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="value"
|
||||
fill={(entry, index) => monthlyData[index]?.color || '#8884d8'}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="strength"
|
||||
stroke="#ff7300"
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
/>
|
||||
<ReferenceLine y={0} stroke="#666" strokeDasharray="2 2" />
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Heatmap view
|
||||
const renderHeatmapView = () => (
|
||||
<div className="grid grid-cols-12 gap-1 p-4">
|
||||
{/* Month labels */}
|
||||
<div className="col-span-12 grid grid-cols-12 gap-1 mb-2">
|
||||
{SPANISH_MONTHS.map(month => (
|
||||
<div key={month} className="text-xs text-center text-text-tertiary font-medium">
|
||||
{month.slice(0, 3)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Heatmap grid */}
|
||||
{[0, 1, 2, 3].map(week => (
|
||||
<div key={week} className="col-span-12 grid grid-cols-12 gap-1">
|
||||
{heatmapData
|
||||
.filter(d => d.week === week)
|
||||
.map((cell, monthIndex) => (
|
||||
<div
|
||||
key={`${cell.month}-${cell.week}`}
|
||||
className="aspect-square rounded cursor-pointer transition-all duration-200 hover:scale-110 flex items-center justify-center"
|
||||
style={{
|
||||
backgroundColor: INTENSITY_COLORS[cell.intensity],
|
||||
border: hoveredElement?.month === cell.month && hoveredElement?.week === cell.week
|
||||
? '2px solid #3b82f6' : '1px solid #e5e7eb'
|
||||
}}
|
||||
onMouseEnter={() => setHoveredElement(cell)}
|
||||
onMouseLeave={() => setHoveredElement(null)}
|
||||
title={`${SPANISH_MONTHS[cell.month]} S${cell.week + 1}: ${cell.value.toFixed(1)}%`}
|
||||
>
|
||||
{cell.holiday && (
|
||||
<div className="text-xs">🎉</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Legend */}
|
||||
<div className="col-span-12 flex items-center justify-center gap-4 mt-4">
|
||||
<span className="text-sm text-text-secondary">Baja</span>
|
||||
<div className="flex gap-1">
|
||||
{INTENSITY_COLORS.map((color, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="w-4 h-4 rounded"
|
||||
style={{ backgroundColor: color }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<span className="text-sm text-text-secondary">Alta</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Trends view (Weekly patterns)
|
||||
const renderTrendsView = () => (
|
||||
<div style={{ width: '100%', height: 400 }}>
|
||||
<ResponsiveContainer>
|
||||
<BarChart data={weeklyData} margin={{ top: 20, right: 30, left: 20, bottom: 5 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="dayShort" tick={{ fontSize: 12 }} />
|
||||
<YAxis tick={{ fontSize: 12 }} />
|
||||
<Tooltip
|
||||
content={({ active, payload, label }) => {
|
||||
if (!active || !payload || !payload.length) return null;
|
||||
const data = payload[0].payload as WeeklyData;
|
||||
return (
|
||||
<div className="bg-card-bg border border-border-primary rounded-lg shadow-lg p-3">
|
||||
<h4 className="font-semibold text-text-primary mb-2">{data.day}</h4>
|
||||
<div className="space-y-1 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span>Multiplicador Promedio:</span>
|
||||
<span className="font-medium">{data.value.toFixed(1)}%</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>Varianza:</span>
|
||||
<span className="font-medium">{data.variance.toFixed(1)}%</span>
|
||||
</div>
|
||||
{data.peakHours && data.peakHours.length > 0 && (
|
||||
<div className="flex justify-between">
|
||||
<span>Horas Pico:</span>
|
||||
<span className="font-medium">
|
||||
{data.peakHours.map(h => `${h}:00`).join(', ')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Bar dataKey="value" fill="#8884d8" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Loading state
|
||||
if (loading) {
|
||||
return (
|
||||
<Card className={className}>
|
||||
<CardHeader>
|
||||
<h3 className="text-lg font-semibold text-text-primary">{title}</h3>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-color-primary"></div>
|
||||
<span className="text-text-secondary">Cargando patrones estacionales...</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// Error state
|
||||
if (error) {
|
||||
return (
|
||||
<Card className={className} variant="outlined">
|
||||
<CardHeader>
|
||||
<h3 className="text-lg font-semibold text-text-primary">{title}</h3>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-center">
|
||||
<div className="text-red-500 mb-2">
|
||||
<svg className="w-12 h-12 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-text-secondary">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// Empty state
|
||||
if (!currentPattern) {
|
||||
return (
|
||||
<Card className={className}>
|
||||
<CardHeader>
|
||||
<h3 className="text-lg font-semibold text-text-primary">{title}</h3>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-center">
|
||||
<div className="text-text-tertiary mb-2">
|
||||
<svg className="w-12 h-12 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1} d="M8 7V3a2 2 0 012-2h4a2 2 0 012 2v4m-6 4v10a2 2 0 002 2h4a2 2 0 002-2V11m-6 0a2 2 0 012-2h4a2 2 0 012 2m-6 0h8" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-text-secondary">No hay datos de estacionalidad disponibles</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className={className}>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between flex-wrap gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<h3 className="text-lg font-semibold text-text-primary">{title}</h3>
|
||||
{currentPattern && (
|
||||
<Badge variant="ghost">
|
||||
{currentPattern.product_name}
|
||||
</Badge>
|
||||
)}
|
||||
<Badge
|
||||
variant="success"
|
||||
size="sm"
|
||||
title="Nivel de confianza del patrón estacional"
|
||||
>
|
||||
{(currentPattern.confidence_score * 100).toFixed(0)}% confianza
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Product selector */}
|
||||
{onProductChange && seasonalPatterns.length > 1 && (
|
||||
<Select
|
||||
value={selectedProduct || seasonalPatterns[0]?.product_name || ''}
|
||||
onChange={(value) => onProductChange(value)}
|
||||
className="w-40"
|
||||
>
|
||||
{seasonalPatterns.map(pattern => (
|
||||
<option key={pattern.product_name} value={pattern.product_name}>
|
||||
{pattern.product_name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
)}
|
||||
|
||||
{/* View mode selector */}
|
||||
<div className="flex bg-bg-secondary rounded-lg p-1 gap-1">
|
||||
{(['circular', 'calendar', 'heatmap', 'trends'] as const).map((mode) => (
|
||||
<Button
|
||||
key={mode}
|
||||
variant={viewMode === mode ? 'filled' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => onViewModeChange?.(mode)}
|
||||
title={
|
||||
mode === 'circular' ? 'Vista Circular' :
|
||||
mode === 'calendar' ? 'Vista Calendario' :
|
||||
mode === 'heatmap' ? 'Mapa de Calor' :
|
||||
'Vista Tendencias'
|
||||
}
|
||||
>
|
||||
{mode === 'circular' && '🔄'}
|
||||
{mode === 'calendar' && '📅'}
|
||||
{mode === 'heatmap' && '🔥'}
|
||||
{mode === 'trends' && '📊'}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardBody>
|
||||
<div className="space-y-4">
|
||||
{/* Main visualization */}
|
||||
<div className="min-h-[400px]">
|
||||
{viewMode === 'circular' && renderCircularView()}
|
||||
{viewMode === 'calendar' && renderCalendarView()}
|
||||
{viewMode === 'heatmap' && renderHeatmapView()}
|
||||
{viewMode === 'trends' && renderTrendsView()}
|
||||
</div>
|
||||
|
||||
{/* Holiday effects summary */}
|
||||
{currentPattern.holiday_effects && currentPattern.holiday_effects.length > 0 && (
|
||||
<div className="border-t border-border-primary pt-4">
|
||||
<h4 className="font-medium text-text-primary mb-3">Efectos de Festividades</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{currentPattern.holiday_effects.map((holiday, index) => (
|
||||
<div key={index} className="bg-bg-secondary rounded-lg p-3">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="font-medium text-text-primary text-sm">
|
||||
{holiday.holiday_name}
|
||||
</span>
|
||||
<Badge
|
||||
variant={holiday.impact_factor > 1.2 ? 'success' : holiday.impact_factor < 0.8 ? 'danger' : 'warning'}
|
||||
size="sm"
|
||||
>
|
||||
{((holiday.impact_factor - 1) * 100).toFixed(0)}%
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="text-xs text-text-secondary space-y-1">
|
||||
<div>Duración: {holiday.duration_days} días</div>
|
||||
<div>Confianza: {(holiday.confidence * 100).toFixed(0)}%</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pattern strength indicators */}
|
||||
<div className="border-t border-border-primary pt-4">
|
||||
<h4 className="font-medium text-text-primary mb-3">Intensidad de Patrones</h4>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
{currentPattern.seasonal_components.map((component, index) => {
|
||||
const periodLabel = {
|
||||
[SeasonalPeriod.WEEKLY]: 'Semanal',
|
||||
[SeasonalPeriod.MONTHLY]: 'Mensual',
|
||||
[SeasonalPeriod.QUARTERLY]: 'Trimestral',
|
||||
[SeasonalPeriod.YEARLY]: 'Anual',
|
||||
}[component.period] || component.period;
|
||||
|
||||
return (
|
||||
<div key={index} className="bg-bg-secondary rounded-lg p-3">
|
||||
<div className="text-sm font-medium text-text-primary mb-1">
|
||||
{periodLabel}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 bg-bg-tertiary rounded-full h-2">
|
||||
<div
|
||||
className="bg-color-primary h-2 rounded-full transition-all duration-300"
|
||||
style={{ width: `${Math.min(component.strength * 100, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-text-secondary">
|
||||
{(component.strength * 100).toFixed(0)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default SeasonalityIndicator;
|
||||
31
frontend/src/components/domain/forecasting/index.ts
Normal file
31
frontend/src/components/domain/forecasting/index.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
// Forecasting Domain Components
|
||||
export { default as DemandChart } from './DemandChart';
|
||||
export { default as ForecastTable } from './ForecastTable';
|
||||
export { default as SeasonalityIndicator } from './SeasonalityIndicator';
|
||||
export { default as AlertsPanel } from './AlertsPanel';
|
||||
|
||||
// Export component props for type checking
|
||||
export type { DemandChartProps } from './DemandChart';
|
||||
export type { ForecastTableProps } from './ForecastTable';
|
||||
export type { SeasonalityIndicatorProps } from './SeasonalityIndicator';
|
||||
export type { AlertsPanelProps } from './AlertsPanel';
|
||||
|
||||
// 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';
|
||||
Reference in New Issue
Block a user