Add alerts ssytems to the frontend

This commit is contained in:
Urtzi Alfaro
2025-09-21 17:35:36 +02:00
parent 57fd2f22f0
commit f08667150d
17 changed files with 2086 additions and 786 deletions

View File

@@ -1,7 +1,8 @@
import React, { useState, useEffect } from 'react';
import { Card, CardHeader, CardBody } from '../../ui/Card';
import { StatusCard } from '../../ui/StatusCard/StatusCard';
import { Badge } from '../../ui/Badge';
import { Button } from '../../ui/Button';
import { useNotifications } from '../../../hooks/useNotifications';
import {
AlertTriangle,
AlertCircle,
@@ -11,140 +12,93 @@ import {
X,
Wifi,
WifiOff,
Bell
Bell,
ChevronDown,
ChevronUp,
Check,
Trash2
} from 'lucide-react';
export interface Alert {
id: string;
type: 'critical' | 'warning' | 'info' | 'success';
item_type: 'alert' | 'recommendation';
type: string;
severity: 'urgent' | 'high' | 'medium' | 'low';
title: string;
message: string;
timestamp: string;
source: string;
actionRequired?: boolean;
resolved?: boolean;
actions?: string[];
metadata?: any;
status?: 'active' | 'resolved' | 'acknowledged';
}
export interface RealTimeAlertsProps {
className?: string;
maxAlerts?: number;
enableSSE?: boolean;
sseEndpoint?: string;
}
const RealTimeAlerts: React.FC<RealTimeAlertsProps> = ({
className,
maxAlerts = 10,
enableSSE = true,
sseEndpoint = '/api/alerts/stream'
maxAlerts = 10
}) => {
const [alerts, setAlerts] = useState<Alert[]>([
{
id: '1',
type: 'critical',
title: 'Stock Crítico',
message: 'Levadura fresca: Solo quedan 2 unidades (mínimo: 5)',
timestamp: new Date().toISOString(),
source: 'Inventario',
actionRequired: true
},
{
id: '2',
type: 'warning',
title: 'Temperatura Horno',
message: 'Horno principal: Temperatura fuera del rango óptimo (185°C)',
timestamp: new Date(Date.now() - 5 * 60 * 1000).toISOString(),
source: 'Producción',
actionRequired: true
},
{
id: '3',
type: 'info',
title: 'Nueva Orden',
message: 'Orden #1247: 50 croissants para las 14:00',
timestamp: new Date(Date.now() - 10 * 60 * 1000).toISOString(),
source: 'Ventas'
}
]);
const [expandedAlert, setExpandedAlert] = useState<string | null>(null);
const [isConnected, setIsConnected] = useState(false);
const { notifications, isConnected, markAsRead, removeNotification } = useNotifications();
useEffect(() => {
let eventSource: EventSource | null = null;
// Convert notifications to alerts format and limit them
const alerts = notifications.slice(0, maxAlerts).map(notification => ({
id: notification.id,
item_type: notification.item_type,
type: notification.item_type, // Use item_type as type
severity: notification.severity,
title: notification.title,
message: notification.message,
timestamp: notification.timestamp,
status: notification.read ? 'acknowledged' as const : 'active' as const,
}));
if (enableSSE) {
eventSource = new EventSource(sseEndpoint);
eventSource.onopen = () => {
setIsConnected(true);
};
eventSource.onmessage = (event) => {
try {
const newAlert: Alert = JSON.parse(event.data);
setAlerts(prev => {
const updated = [newAlert, ...prev];
return updated.slice(0, maxAlerts);
});
} catch (error) {
console.error('Error parsing alert data:', error);
}
};
eventSource.onerror = () => {
setIsConnected(false);
};
}
return () => {
if (eventSource) {
eventSource.close();
}
};
}, [enableSSE, sseEndpoint, maxAlerts]);
const getAlertStatusConfig = (alert: Alert) => {
const baseConfig = {
isCritical: alert.type === 'critical',
isHighlight: alert.type === 'warning' || alert.actionRequired,
};
switch (alert.type) {
case 'critical':
return {
...baseConfig,
color: 'var(--color-error)',
text: 'Crítico',
icon: AlertTriangle
};
case 'warning':
return {
...baseConfig,
color: 'var(--color-warning)',
text: 'Advertencia',
icon: AlertCircle
};
case 'info':
return {
...baseConfig,
color: 'var(--color-info)',
text: 'Información',
icon: Info
};
case 'success':
return {
...baseConfig,
color: 'var(--color-success)',
text: 'Éxito',
icon: CheckCircle
};
const getSeverityIcon = (severity: string) => {
switch (severity) {
case 'urgent':
return AlertTriangle;
case 'high':
return AlertCircle;
case 'medium':
return Info;
case 'low':
return CheckCircle;
default:
return {
...baseConfig,
color: 'var(--color-info)',
text: 'Información',
icon: Info
};
return Info;
}
};
const getSeverityColor = (severity: string) => {
switch (severity) {
case 'urgent':
return 'var(--color-error)';
case 'high':
return 'var(--color-warning)';
case 'medium':
return 'var(--color-info)';
case 'low':
return 'var(--color-success)';
default:
return 'var(--color-info)';
}
};
const getSeverityBadge = (severity: string) => {
switch (severity) {
case 'urgent':
return 'error';
case 'high':
return 'warning';
case 'medium':
return 'info';
case 'low':
return 'success';
default:
return 'info';
}
};
@@ -155,68 +109,68 @@ const RealTimeAlerts: React.FC<RealTimeAlertsProps> = ({
const diffMins = Math.floor(diffMs / (1000 * 60));
if (diffMins < 1) return 'Ahora';
if (diffMins < 60) return `Hace ${diffMins}m`;
if (diffMins < 60) return `${diffMins}m`;
const diffHours = Math.floor(diffMins / 60);
if (diffHours < 24) return `Hace ${diffHours}h`;
return date.toLocaleDateString('es-ES', {
day: '2-digit',
month: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
if (diffHours < 24) return `${diffHours}h`;
return date.toLocaleDateString('es-ES', { day: '2-digit', month: '2-digit' });
};
const dismissAlert = (alertId: string) => {
setAlerts(prev => prev.filter(alert => alert.id !== alertId));
const toggleExpanded = (alertId: string) => {
setExpandedAlert(prev => prev === alertId ? null : alertId);
};
const handleViewAlert = (alertId: string) => {
console.log('Viewing alert details:', alertId);
const handleMarkAsRead = (alertId: string) => {
markAsRead(alertId);
};
const unresolvedAlerts = alerts.filter(alert => !alert.resolved);
const criticalCount = unresolvedAlerts.filter(alert => alert.type === 'critical').length;
const warningCount = unresolvedAlerts.filter(alert => alert.type === 'warning').length;
const handleRemoveAlert = (alertId: string) => {
removeNotification(alertId);
if (expandedAlert === alertId) {
setExpandedAlert(null);
}
};
const activeAlerts = alerts.filter(alert => alert.status === 'active');
const urgentCount = activeAlerts.filter(alert => alert.severity === 'urgent').length;
const highCount = activeAlerts.filter(alert => alert.severity === 'high').length;
return (
<Card className={className} variant="elevated" padding="none">
<CardHeader padding="lg" divider>
<CardHeader padding="md" divider>
<div className="flex items-center justify-between w-full">
<div className="flex items-center gap-3">
<div
className="p-2 rounded-lg"
style={{ backgroundColor: 'var(--color-primary)20' }}
>
<Bell className="w-5 h-5" style={{ color: 'var(--color-primary)' }} />
<Bell className="w-4 h-4" style={{ color: 'var(--color-primary)' }} />
</div>
<div>
<h3 className="text-lg font-semibold" style={{ color: 'var(--text-primary)' }}>
Alertas en Tiempo Real
<h3 className="text-base font-semibold" style={{ color: 'var(--text-primary)' }}>
Alertas
</h3>
<div className="flex items-center gap-2 mt-1">
<div className="flex items-center gap-2">
{isConnected ? (
<Wifi className="w-4 h-4" style={{ color: 'var(--color-success)' }} />
<Wifi className="w-3 h-3" style={{ color: 'var(--color-success)' }} />
) : (
<WifiOff className="w-4 h-4" style={{ color: 'var(--color-error)' }} />
<WifiOff className="w-3 h-3" style={{ color: 'var(--color-error)' }} />
)}
<span className="text-sm" style={{ color: 'var(--text-secondary)' }}>
{isConnected ? 'Conectado' : 'Desconectado'}
<span className="text-xs" style={{ color: 'var(--text-secondary)' }}>
{isConnected ? 'En vivo' : 'Desconectado'}
</span>
</div>
</div>
</div>
<div className="flex items-center gap-2">
{criticalCount > 0 && (
<div className="flex items-center gap-1">
{urgentCount > 0 && (
<Badge variant="error" size="sm">
{criticalCount} críticas
{urgentCount}
</Badge>
)}
{warningCount > 0 && (
{highCount > 0 && (
<Badge variant="warning" size="sm">
{warningCount} advertencias
{highCount}
</Badge>
)}
</div>
@@ -224,78 +178,184 @@ const RealTimeAlerts: React.FC<RealTimeAlertsProps> = ({
</CardHeader>
<CardBody padding="none">
{unresolvedAlerts.length === 0 ? (
<div className="p-8 text-center">
<div
className="p-4 rounded-full mx-auto mb-4 w-16 h-16 flex items-center justify-center"
style={{ backgroundColor: 'var(--color-success)20' }}
>
<CheckCircle className="w-8 h-8" style={{ color: 'var(--color-success)' }} />
</div>
<h4 className="text-lg font-medium mb-2" style={{ color: 'var(--text-primary)' }}>
No hay alertas activas
</h4>
{activeAlerts.length === 0 ? (
<div className="p-6 text-center">
<CheckCircle className="w-6 h-6 mx-auto mb-2" style={{ color: 'var(--color-success)' }} />
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
Todo funciona correctamente en tu panadería
No hay alertas activas
</p>
</div>
) : (
<div className="space-y-2 p-4">
{unresolvedAlerts.map((alert) => {
const statusConfig = getAlertStatusConfig(alert);
<div className="space-y-2 p-2">
{activeAlerts.map((alert) => {
const isExpanded = expandedAlert === alert.id;
const SeverityIcon = getSeverityIcon(alert.severity);
return (
<StatusCard
<div
key={alert.id}
id={alert.id}
statusIndicator={statusConfig}
title={alert.title}
subtitle={alert.message}
primaryValue={formatTimestamp(alert.timestamp)}
primaryValueLabel="TIEMPO"
secondaryInfo={{
label: 'Fuente',
value: alert.source
className={`
rounded-lg border transition-all duration-200
${isExpanded ? 'ring-2 ring-opacity-20' : 'hover:shadow-sm'}
`}
style={{
borderColor: getSeverityColor(alert.severity) + '40',
backgroundColor: 'var(--bg-primary)',
...(isExpanded && {
ringColor: getSeverityColor(alert.severity),
backgroundColor: 'var(--bg-secondary)'
})
}}
metadata={alert.actionRequired ? ['🔥 Acción requerida'] : []}
actions={[
{
label: 'Ver Detalles',
icon: Info,
variant: 'outline',
onClick: () => handleViewAlert(alert.id),
priority: 'primary'
},
{
label: 'Descartar',
icon: X,
variant: 'outline',
onClick: () => dismissAlert(alert.id),
priority: 'secondary',
destructive: true
}
]}
compact={true}
className="border-l-4"
/>
>
{/* Compact Card Header */}
<div
className="flex items-center gap-3 p-3 cursor-pointer hover:bg-[var(--bg-secondary)] transition-colors rounded-lg"
onClick={() => toggleExpanded(alert.id)}
>
{/* Severity Icon */}
<div
className="flex-shrink-0 p-2 rounded-full"
style={{ backgroundColor: getSeverityColor(alert.severity) + '15' }}
>
<SeverityIcon
className="w-4 h-4"
style={{ color: getSeverityColor(alert.severity) }}
/>
</div>
{/* Alert Content */}
<div className="flex-1 min-w-0">
{/* Title and Timestamp Row */}
<div className="flex items-start justify-between gap-3 mb-2">
<h4 className="text-sm font-semibold leading-tight flex-1" style={{ color: 'var(--text-primary)' }}>
{alert.title}
</h4>
<span className="text-xs font-medium flex-shrink-0" style={{ color: 'var(--text-secondary)' }}>
{formatTimestamp(alert.timestamp)}
</span>
</div>
{/* Badges Row */}
<div className="flex items-center gap-2 mb-2">
<Badge variant={getSeverityBadge(alert.severity)} size="sm">
{alert.severity.toUpperCase()}
</Badge>
<Badge variant="secondary" size="sm">
{alert.item_type === 'alert' ? '🚨 Alerta' : '💡 Recomendación'}
</Badge>
</div>
{/* Preview message when collapsed */}
{!isExpanded && (
<p className="text-xs leading-relaxed truncate" style={{ color: 'var(--text-secondary)' }}>
{alert.message}
</p>
)}
</div>
{/* Expand/Collapse Button */}
<div className="flex-shrink-0 p-1 rounded-full hover:bg-black/5 transition-colors">
{isExpanded ? (
<ChevronUp className="w-4 h-4" style={{ color: 'var(--text-secondary)' }} />
) : (
<ChevronDown className="w-4 h-4" style={{ color: 'var(--text-secondary)' }} />
)}
</div>
</div>
{/* Expanded Details */}
{isExpanded && (
<div className="px-3 pb-3 border-t mt-3 pt-3" style={{ borderColor: getSeverityColor(alert.severity) + '20' }}>
{/* Full Message */}
<div className="mb-4">
<p className="text-sm leading-relaxed" style={{ color: 'var(--text-primary)' }}>
{alert.message}
</p>
</div>
{/* Actions Section */}
{alert.actions && alert.actions.length > 0 && (
<div className="mb-4">
<p className="text-xs font-semibold mb-2 uppercase tracking-wide" style={{ color: 'var(--text-primary)' }}>
Acciones Recomendadas
</p>
<div className="space-y-1">
{alert.actions.map((action, index) => (
<div key={index} className="flex items-start gap-2">
<span className="text-xs mt-0.5" style={{ color: getSeverityColor(alert.severity) }}>
</span>
<span className="text-xs leading-relaxed" style={{ color: 'var(--text-secondary)' }}>
{action}
</span>
</div>
))}
</div>
</div>
)}
{/* Metadata */}
{alert.metadata && Object.keys(alert.metadata).length > 0 && (
<div className="mb-4 p-2 rounded-md" style={{ backgroundColor: 'var(--bg-secondary)' }}>
<p className="text-xs font-semibold mb-1 uppercase tracking-wide" style={{ color: 'var(--text-primary)' }}>
Detalles Adicionales
</p>
<div className="text-xs space-y-1" style={{ color: 'var(--text-secondary)' }}>
{Object.entries(alert.metadata).map(([key, value]) => (
<div key={key} className="flex gap-2">
<span className="font-medium capitalize">{key.replace(/_/g, ' ')}:</span>
<span>{String(value)}</span>
</div>
))}
</div>
</div>
)}
{/* Action Buttons */}
<div className="flex items-center gap-2 pt-2">
<Button
variant="outline"
size="sm"
onClick={(e) => {
e.stopPropagation();
handleMarkAsRead(alert.id);
}}
className="h-8 px-3 text-xs font-medium"
>
<Check className="w-3 h-3 mr-1" />
Marcar como leído
</Button>
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
handleRemoveAlert(alert.id);
}}
className="h-8 px-3 text-xs font-medium text-red-600 hover:text-red-700 hover:bg-red-50"
>
<Trash2 className="w-3 h-3 mr-1" />
Eliminar
</Button>
</div>
</div>
)}
</div>
);
})}
</div>
)}
{unresolvedAlerts.length > 0 && (
{activeAlerts.length > 0 && (
<div
className="p-4 border-t text-center"
className="p-3 border-t text-center"
style={{
borderColor: 'var(--border-primary)',
backgroundColor: 'var(--bg-secondary)'
}}
>
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
{unresolvedAlerts.length} alertas activas
<span className="ml-1" style={{ color: 'var(--color-primary)' }}>
Monitoreo automático habilitado
</span>
<p className="text-xs" style={{ color: 'var(--text-secondary)' }}>
{activeAlerts.length} alertas activas
</p>
</div>
)}