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>
)}

View File

@@ -3,11 +3,13 @@ import { clsx } from 'clsx';
import { useNavigate } from 'react-router-dom';
import { useAuthUser, useIsAuthenticated, useAuthActions } from '../../../stores';
import { useTheme } from '../../../contexts/ThemeContext';
import { useNotifications } from '../../../hooks/useNotifications';
import { Button } from '../../ui';
import { Avatar } from '../../ui';
import { Badge } from '../../ui';
import { Modal } from '../../ui';
import { TenantSwitcher } from '../../ui/TenantSwitcher';
import { NotificationPanel } from '../../ui/NotificationPanel/NotificationPanel';
import {
Menu,
Search,
@@ -101,11 +103,21 @@ export const Header = forwardRef<HeaderRef, HeaderProps>(({
const isAuthenticated = useIsAuthenticated();
const { logout } = useAuthActions();
const { theme, resolvedTheme, setTheme } = useTheme();
const {
notifications,
unreadCount,
isConnected,
markAsRead,
markAllAsRead,
removeNotification,
clearAll
} = useNotifications();
const [isUserMenuOpen, setIsUserMenuOpen] = useState(false);
const [isSearchFocused, setIsSearchFocused] = useState(false);
const [searchValue, setSearchValue] = useState('');
const [isThemeMenuOpen, setIsThemeMenuOpen] = useState(false);
const [isNotificationPanelOpen, setIsNotificationPanelOpen] = useState(false);
const searchInputRef = React.useRef<HTMLInputElement>(null);
@@ -168,6 +180,7 @@ export const Header = forwardRef<HeaderRef, HeaderProps>(({
if (e.key === 'Escape') {
setIsUserMenuOpen(false);
setIsThemeMenuOpen(false);
setIsNotificationPanelOpen(false);
if (isSearchFocused) {
searchInputRef.current?.blur();
}
@@ -188,6 +201,9 @@ export const Header = forwardRef<HeaderRef, HeaderProps>(({
if (!target.closest('[data-theme-menu]')) {
setIsThemeMenuOpen(false);
}
if (!target.closest('[data-notification-panel]')) {
setIsNotificationPanelOpen(false);
}
};
document.addEventListener('click', handleClickOutside);
@@ -379,25 +395,45 @@ export const Header = forwardRef<HeaderRef, HeaderProps>(({
{/* Notifications */}
{showNotifications && (
<div className="relative">
<div className="relative" data-notification-panel>
<Button
variant="ghost"
size="sm"
onClick={onNotificationClick}
className="w-10 h-10 p-0 flex items-center justify-center relative"
aria-label={`Notificaciones${notificationCount > 0 ? ` (${notificationCount})` : ''}`}
onClick={() => setIsNotificationPanelOpen(!isNotificationPanelOpen)}
className={clsx(
"w-10 h-10 p-0 flex items-center justify-center relative",
!isConnected && "opacity-50",
isNotificationPanelOpen && "bg-[var(--bg-secondary)]"
)}
aria-label={`Notificaciones${unreadCount > 0 ? ` (${unreadCount})` : ''}${!isConnected ? ' - Desconectado' : ''}`}
title={!isConnected ? 'Sin conexión en tiempo real' : undefined}
aria-expanded={isNotificationPanelOpen}
aria-haspopup="true"
>
<Bell className="h-5 w-5" />
{notificationCount > 0 && (
<Bell className={clsx(
"h-5 w-5 transition-colors",
unreadCount > 0 && "text-[var(--color-warning)]"
)} />
{unreadCount > 0 && (
<Badge
variant="error"
size="sm"
className="absolute -top-1 -right-1 min-w-[18px] h-[18px] text-xs flex items-center justify-center"
>
{notificationCount > 99 ? '99+' : notificationCount}
{unreadCount > 99 ? '99+' : unreadCount}
</Badge>
)}
</Button>
<NotificationPanel
notifications={notifications}
isOpen={isNotificationPanelOpen}
onClose={() => setIsNotificationPanelOpen(false)}
onMarkAsRead={markAsRead}
onMarkAllAsRead={markAllAsRead}
onRemoveNotification={removeNotification}
onClearAll={clearAll}
/>
</div>
)}

View File

@@ -0,0 +1,259 @@
import React from 'react';
import { clsx } from 'clsx';
import { Button } from '../Button';
import { Badge } from '../Badge';
import { NotificationData } from '../../../hooks/useNotifications';
import {
Check,
Trash2,
AlertTriangle,
AlertCircle,
Info,
CheckCircle,
X
} from 'lucide-react';
export interface NotificationPanelProps {
notifications: NotificationData[];
isOpen: boolean;
onClose: () => void;
onMarkAsRead: (id: string) => void;
onMarkAllAsRead: () => void;
onRemoveNotification: (id: string) => void;
onClearAll: () => void;
className?: string;
}
const getSeverityIcon = (severity: string) => {
switch (severity) {
case 'urgent':
return AlertTriangle;
case 'high':
return AlertCircle;
case 'medium':
return Info;
case 'low':
return CheckCircle;
default:
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';
}
};
const formatTimestamp = (timestamp: string) => {
const date = new Date(timestamp);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / (1000 * 60));
if (diffMins < 1) return 'Ahora';
if (diffMins < 60) return `${diffMins}m`;
const diffHours = Math.floor(diffMins / 60);
if (diffHours < 24) return `${diffHours}h`;
return date.toLocaleDateString('es-ES', { day: '2-digit', month: '2-digit' });
};
export const NotificationPanel: React.FC<NotificationPanelProps> = ({
notifications,
isOpen,
onClose,
onMarkAsRead,
onMarkAllAsRead,
onRemoveNotification,
onClearAll,
className
}) => {
if (!isOpen) return null;
const unreadNotifications = notifications.filter(n => !n.read);
return (
<>
{/* Backdrop */}
<div
className="fixed inset-0 z-40 bg-black/20"
onClick={onClose}
aria-hidden="true"
/>
{/* Panel */}
<div
className={clsx(
"absolute right-0 top-full mt-2 w-96 max-w-sm bg-[var(--bg-primary)] border border-[var(--border-primary)] rounded-lg shadow-xl z-50 max-h-96 flex flex-col",
className
)}
>
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-[var(--border-primary)]">
<div className="flex items-center gap-2">
<h3 className="font-semibold text-sm" style={{ color: 'var(--text-primary)' }}>
Notificaciones
</h3>
{unreadNotifications.length > 0 && (
<Badge variant="info" size="sm">
{unreadNotifications.length} nuevas
</Badge>
)}
</div>
<div className="flex items-center gap-1">
{unreadNotifications.length > 0 && (
<Button
variant="ghost"
size="sm"
onClick={onMarkAllAsRead}
className="h-6 px-2 text-xs"
>
Marcar todas
</Button>
)}
<Button
variant="ghost"
size="sm"
onClick={onClose}
className="h-6 w-6 p-0"
>
<X className="h-3 w-3" />
</Button>
</div>
</div>
{/* Notifications List */}
<div className="flex-1 overflow-y-auto">
{notifications.length === 0 ? (
<div className="p-8 text-center">
<CheckCircle className="w-8 h-8 mx-auto mb-2" style={{ color: 'var(--color-success)' }} />
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
No hay notificaciones
</p>
</div>
) : (
<div className="divide-y divide-[var(--border-primary)]">
{notifications.map((notification) => {
const SeverityIcon = getSeverityIcon(notification.severity);
return (
<div
key={notification.id}
className={clsx(
"p-3 hover:bg-[var(--bg-secondary)] transition-colors",
!notification.read && "bg-[var(--color-info)]/5"
)}
>
<div className="flex gap-3">
{/* Icon */}
<div
className="flex-shrink-0 p-1 rounded-full mt-0.5"
style={{ backgroundColor: getSeverityColor(notification.severity) + '15' }}
>
<SeverityIcon
className="w-3 h-3"
style={{ color: getSeverityColor(notification.severity) }}
/>
</div>
{/* Content */}
<div className="flex-1 min-w-0">
{/* Header */}
<div className="flex items-start justify-between gap-2 mb-1">
<div className="flex items-center gap-2">
<Badge variant={getSeverityBadge(notification.severity)} size="sm">
{notification.severity.toUpperCase()}
</Badge>
<Badge variant="secondary" size="sm">
{notification.item_type === 'alert' ? 'Alerta' : 'Recomendación'}
</Badge>
</div>
<span className="text-xs font-medium" style={{ color: 'var(--text-secondary)' }}>
{formatTimestamp(notification.timestamp)}
</span>
</div>
{/* Title */}
<p className="text-sm font-medium mb-1 leading-tight" style={{ color: 'var(--text-primary)' }}>
{notification.title}
</p>
{/* Message */}
<p className="text-xs leading-relaxed mb-2" style={{ color: 'var(--text-secondary)' }}>
{notification.message}
</p>
{/* Actions */}
<div className="flex items-center gap-1">
{!notification.read && (
<Button
variant="ghost"
size="sm"
onClick={() => onMarkAsRead(notification.id)}
className="h-6 px-2 text-xs"
>
<Check className="w-3 h-3 mr-1" />
Marcar como leído
</Button>
)}
<Button
variant="ghost"
size="sm"
onClick={() => onRemoveNotification(notification.id)}
className="h-6 px-2 text-xs text-red-600 hover:text-red-700"
>
<Trash2 className="w-3 h-3 mr-1" />
Eliminar
</Button>
</div>
</div>
</div>
</div>
);
})}
</div>
)}
</div>
{/* Footer */}
{notifications.length > 0 && (
<div className="p-3 border-t border-[var(--border-primary)] bg-[var(--bg-secondary)]">
<Button
variant="ghost"
size="sm"
onClick={onClearAll}
className="w-full text-xs text-red-600 hover:text-red-700"
>
Limpiar todas las notificaciones
</Button>
</div>
)}
</div>
</>
);
};