Add new API in the frontend

This commit is contained in:
Urtzi Alfaro
2025-09-11 18:21:32 +02:00
parent 523b926854
commit 55f31a3630
16 changed files with 2719 additions and 1806 deletions

View File

@@ -5,189 +5,64 @@ import { Button } from '../../ui';
import { Badge } from '../../ui';
import { Select } from '../../ui';
import { Input } from '../../ui';
import {
ForecastAlert,
ForecastAlertType,
AlertSeverity,
} from '../../../types/forecasting.types';
// Simple alert interface for component
interface SimpleAlert {
id: string;
type: string;
product: string;
message: string;
severity: 'high' | 'medium' | 'low' | 'info';
recommendation?: string;
}
export interface AlertsPanelProps {
className?: string;
title?: string;
alerts?: ForecastAlert[];
alerts?: SimpleAlert[];
loading?: boolean;
error?: string | null;
onAlertAction?: (alertId: string, action: 'acknowledge' | 'resolve' | 'snooze' | 'production_adjust' | 'inventory_check') => void;
onAlertAction?: (alertId: string, action: string) => 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';
severity: 'high' | 'medium' | 'low' | 'info' | 'all';
type: string;
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<string, string> = {
'high': 'Alta',
'medium': 'Media',
'low': 'Baja',
'info': 'Info',
};
const SPANISH_SEVERITIES: Record<AlertSeverity, string> = {
[AlertSeverity.CRITICAL]: 'Crítica',
[AlertSeverity.HIGH]: 'Alta',
[AlertSeverity.MEDIUM]: 'Media',
[AlertSeverity.LOW]: 'Baja',
const SEVERITY_COLORS: Record<string, string> = {
'high': 'text-[var(--color-error)] bg-red-50 border-red-200',
'medium': 'text-yellow-600 bg-yellow-50 border-yellow-200',
'low': 'text-[var(--color-info)] bg-[var(--color-info)]/5 border-[var(--color-info)]/20',
'info': 'text-[var(--color-primary)] bg-blue-50 border-blue-200',
};
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<string, 'danger' | 'warning' | 'success' | 'info'> = {
'high': 'danger',
'medium': 'warning',
'low': 'info',
'info': 'info',
};
const SEVERITY_BADGE_VARIANTS: Record<AlertSeverity, 'danger' | 'warning' | 'success' | 'info'> = {
[AlertSeverity.CRITICAL]: 'danger',
[AlertSeverity.HIGH]: 'warning',
[AlertSeverity.MEDIUM]: 'warning',
[AlertSeverity.LOW]: 'info',
const ALERT_ICONS: Record<string, string> = {
'high-demand': '📈',
'low-demand': '📉',
'low-confidence': '🎯',
'weather': '🌦️',
'default': '⚠️',
};
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,
@@ -197,90 +72,35 @@ const AlertsPanel: React.FC<AlertsPanelProps> = ({
error = null,
onAlertAction,
onAlertDismiss,
onBulkAction,
showFilters = true,
showFilters = false,
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
// Filter and limit 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())
alert.product.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();
});
// Sort by severity priority
const severityOrder = { 'high': 3, 'medium': 2, 'low': 1, 'info': 0 };
filtered.sort((a, b) => severityOrder[b.severity] - severityOrder[a.severity]);
// Limit items if specified
if (maxItems) {
@@ -290,73 +110,6 @@ const AlertsPanel: React.FC<AlertsPanelProps> = ({
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 (
@@ -403,110 +156,13 @@ const AlertsPanel: React.FC<AlertsPanelProps> = ({
<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>
<h3 className="text-lg font-semibold text-text-primary">{title}</h3>
<Badge variant="ghost">
{filteredAlerts.length} alertas
</Badge>
</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">
@@ -521,200 +177,68 @@ const AlertsPanel: React.FC<AlertsPanelProps> = ({
</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>
)}
{filteredAlerts.map((alert) => (
<div
key={alert.id}
className={clsx(
'rounded-lg border transition-all duration-200 p-4',
SEVERITY_COLORS[alert.severity]
)}
>
<div className="flex items-start gap-3">
<div className="text-2xl">
{ALERT_ICONS[alert.type] || ALERT_ICONS['default']}
</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)}
/>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-2">
<Badge
variant={SEVERITY_BADGE_VARIANTS[alert.severity]}
size="sm"
>
{SPANISH_SEVERITIES[alert.severity]}
</Badge>
{alert.product && (
<Badge variant="ghost" size="sm">
{alert.product}
</Badge>
)}
{/* 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>
<p className="text-text-primary text-sm mb-2">
{alert.message}
</p>
{alert.recommendation && (
<p className="text-text-secondary text-sm mb-3">
💡 {alert.recommendation}
</p>
)}
{/* 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 className="flex items-center gap-2">
{onAlertAction && (
<Button
variant="ghost"
size="sm"
onClick={() => onAlertAction(alert.id, 'acknowledge')}
>
Confirmar
</Button>
)}
{onAlertDismiss && (
<Button
variant="ghost"
size="sm"
onClick={() => onAlertDismiss(alert.id)}
>
Descartar
</Button>
)}
</div>
</div>
</div>
);
})}
</div>
))}
</div>
)}
</CardBody>

View File

@@ -21,35 +21,17 @@ import { Select } from '../../ui';
import { Badge } from '../../ui';
import {
ForecastResponse,
DemandTrend,
TrendDirection,
WeatherCondition,
EventType,
} from '../../../types/forecasting.types';
} from '../../../api/types/forecasting';
export interface DemandChartProps {
className?: string;
title?: string;
data?: DemandTrend[];
products?: string[];
selectedProducts?: string[];
onProductSelectionChange?: (products: string[]) => void;
data?: ForecastResponse[];
product?: string;
period?: string;
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;
@@ -62,15 +44,6 @@ interface ChartDataPoint {
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;
}
@@ -85,52 +58,25 @@ const PRODUCT_COLORS = [
'#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 WEATHER_ICONS: Record<string, string> = {
'sunny': '☀️',
'cloudy': '☁️',
'rainy': '🌧️',
'stormy': '',
'snowy': '❄️',
'foggy': '🌫️',
'windy': '🌪️',
};
const DemandChart: React.FC<DemandChartProps> = ({
className,
title = 'Predicción de Demanda',
data = [],
products = [],
selectedProducts = [],
onProductSelectionChange,
product = '',
period = '7',
timeframe = 'weekly',
onTimeframeChange,
showConfidenceInterval = true,
showEvents = true,
showWeatherOverlay = false,
events = [],
weatherData = [],
loading = false,
error = null,
height = 400,
@@ -140,34 +86,21 @@ const DemandChart: React.FC<DemandChartProps> = ({
const [zoomedData, setZoomedData] = useState<ChartDataPoint[]>([]);
const [hoveredPoint, setHoveredPoint] = useState<ChartDataPoint | null>(null);
// Process and merge data with events and weather
// Process forecast data for chart
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);
const processedData: ChartDataPoint[] = data.map(forecast => {
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,
date: forecast.forecast_date,
actualDemand: undefined, // Not available in current forecast response
predictedDemand: forecast.predicted_demand,
confidenceLower: forecast.confidence_lower,
confidenceUpper: forecast.confidence_upper,
temperature: forecast.weather_temperature,
};
});
return processedData;
}, [data, events, weatherData]);
return processedData.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime());
}, [data]);
// Filter data based on selected period
const filteredData = useMemo(() => {
@@ -218,13 +151,6 @@ const DemandChart: React.FC<DemandChartProps> = ({
<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>
@@ -241,88 +167,17 @@ const DemandChart: React.FC<DemandChartProps> = ({
</div>
)}
{data.accuracy !== undefined && (
{data.temperature && (
<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>
<span className="text-text-secondary text-sm">Temperatura:</span>
<span className="text-sm text-text-tertiary">{data.temperature}°C</span>
</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) {
@@ -489,30 +344,17 @@ const DemandChart: React.FC<DemandChartProps> = ({
/>
)}
{/* 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 }}
strokeWidth={3}
dot={true}
dotSize={6}
activeDot={{ r: 8, stroke: '#10b981', strokeWidth: 2 }}
name="Demanda Predicha"
/>
{renderEventDots()}
</ComposedChart>
</ResponsiveContainer>
</div>
@@ -547,11 +389,7 @@ const DemandChart: React.FC<DemandChartProps> = ({
{/* 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>
<div className="w-4 h-0.5 bg-green-500"></div>
<span className="text-text-secondary">Demanda Predicha</span>
</div>
{showConfidenceInterval && (
@@ -560,12 +398,6 @@ const DemandChart: React.FC<DemandChartProps> = ({
<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>

View File

@@ -8,9 +8,7 @@ import { Input } from '../../ui';
import { Table, TableColumn } from '../../ui';
import {
ForecastResponse,
TrendDirection,
ModelType,
} from '../../../types/forecasting.types';
} from '../../../api/types/forecasting';
export interface ForecastTableProps {
className?: string;
@@ -31,7 +29,7 @@ interface FilterState {
productName: string;
category: string;
accuracyRange: 'all' | 'high' | 'medium' | 'low';
trendDirection: TrendDirection | 'all';
trendDirection: 'increasing' | 'decreasing' | 'stable' | 'all';
confidenceLevel: number | 'all';
dateRange: 'today' | 'week' | 'month' | 'quarter' | 'all';
}
@@ -41,28 +39,28 @@ interface SortState {
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 SPANISH_TRENDS: Record<string, string> = {
'increasing': 'Creciente',
'decreasing': 'Decreciente',
'stable': 'Estable',
'volatile': 'Volátil',
'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_COLORS: Record<string, string> = {
'increasing': 'text-[var(--color-success)]',
'decreasing': 'text-[var(--color-error)]',
'stable': 'text-[var(--color-info)]',
'volatile': 'text-yellow-600',
'seasonal': 'text-purple-600',
};
const TREND_ICONS: Record<TrendDirection, string> = {
[TrendDirection.INCREASING]: '↗️',
[TrendDirection.DECREASING]: '↘️',
[TrendDirection.STABLE]: '➡️',
[TrendDirection.VOLATILE]: '📈',
[TrendDirection.SEASONAL]: '🔄',
const TREND_ICONS: Record<string, string> = {
'increasing': '↗️',
'decreasing': '↘️',
'stable': '➡️',
'volatile': '📈',
'seasonal': '🔄',
};
const BAKERY_CATEGORIES = [
@@ -140,17 +138,17 @@ const ForecastTable: React.FC<ForecastTableProps> = ({
// Apply filters
if (filters.productName) {
filtered = filtered.filter(item =>
item.product_name.toLowerCase().includes(filters.productName.toLowerCase())
item.inventory_product_id.toLowerCase().includes(filters.productName.toLowerCase())
);
}
if (filters.category !== 'all') {
filtered = filtered.filter(item => getProductCategory(item.product_name) === filters.category);
filtered = filtered.filter(item => getProductCategory(item.inventory_product_id) === filters.category);
}
if (filters.accuracyRange !== 'all') {
filtered = filtered.filter(item => {
const accuracy = item.accuracy_score || 0;
const accuracy = item.confidence_level || 0;
switch (filters.accuracyRange) {
case 'high': return accuracy >= 0.8;
case 'medium': return accuracy >= 0.6 && accuracy < 0.8;
@@ -162,8 +160,8 @@ const ForecastTable: React.FC<ForecastTableProps> = ({
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;
// Determine trend from predicted demand (simplified)
const trend = item.predicted_demand > 50 ? 'increasing' : item.predicted_demand < 20 ? 'decreasing' : 'stable';
return trend === filters.trendDirection;
});
}
@@ -200,21 +198,17 @@ const ForecastTable: React.FC<ForecastTableProps> = ({
let bValue: any;
switch (sort.field) {
case 'product_name':
aValue = a.product_name;
bValue = b.product_name;
case 'inventory_product_id':
aValue = a.inventory_product_id;
bValue = b.inventory_product_id;
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;
aValue = a.confidence_level || 0;
bValue = b.confidence_level || 0;
break;
case 'forecast_date':
aValue = new Date(a.forecast_date);
@@ -263,7 +257,7 @@ const ForecastTable: React.FC<ForecastTableProps> = ({
// Calculate accuracy percentage and trend
const getAccuracyInfo = useCallback((item: ForecastResponse) => {
const accuracy = item.accuracy_score || 0;
const accuracy = item.confidence_level || 0;
const percentage = (accuracy * 100).toFixed(1);
let variant: 'success' | 'warning' | 'danger' = 'success';
@@ -276,8 +270,7 @@ const ForecastTable: React.FC<ForecastTableProps> = ({
// Get trend info
const getTrendInfo = useCallback((item: ForecastResponse) => {
// Simplified trend calculation
const trend = item.predicted_demand > (item.actual_demand || 0) ?
TrendDirection.INCREASING : TrendDirection.DECREASING;
const trend = item.predicted_demand > 50 ? 'increasing' : item.predicted_demand < 20 ? 'decreasing' : 'stable';
return {
direction: trend,
@@ -291,9 +284,9 @@ const ForecastTable: React.FC<ForecastTableProps> = ({
const columns: TableColumn<ForecastResponse>[] = useMemo(() => {
const baseColumns: TableColumn<ForecastResponse>[] = [
{
key: 'product_name',
key: 'inventory_product_id',
title: 'Producto',
dataIndex: 'product_name',
dataIndex: 'inventory_product_id',
sortable: true,
width: compact ? 120 : 160,
render: (value: string, record: ForecastResponse) => (
@@ -324,21 +317,9 @@ const ForecastTable: React.FC<ForecastTableProps> = ({
),
},
{
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',
key: 'confidence_level',
title: 'Confianza',
dataIndex: 'confidence_level',
sortable: true,
align: 'center' as const,
width: 80,

View File

@@ -22,56 +22,25 @@ 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';
// Simplified interfaces for seasonality data
interface SimpleSeasonalData {
month: string;
value: number;
season: string;
}
interface SimpleWeeklyData {
day: string;
value: number;
}
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'
@@ -85,18 +54,18 @@ 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 SPANISH_SEASONS: Record<string, string> = {
'spring': 'Primavera',
'summer': 'Verano',
'fall': 'Otoño',
'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 SEASON_COLORS: Record<string, string> = {
'spring': '#22c55e', // Green
'summer': '#f59e0b', // Amber
'fall': '#ea580c', // Orange
'winter': '#3b82f6', // Blue
};
const SPANISH_HOLIDAYS = [
@@ -125,308 +94,9 @@ const INTENSITY_COLORS = [
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 (
@@ -469,161 +139,24 @@ const SeasonalityIndicator: React.FC<SeasonalityIndicatorProps> = ({
);
}
// 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>
);
}
// Placeholder view - no complex seasonality data available yet
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>
<h3 className="text-lg font-semibold text-text-primary">{title}</h3>
</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 className="flex items-center justify-center h-64">
<div className="text-center">
<div className="text-text-tertiary mb-4">
<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 mb-2">Análisis de patrones estacionales</p>
<p className="text-text-tertiary text-sm">
Los datos de estacionalidad estarán disponibles próximamente
</p>
</div>
</div>
</CardBody>