New alert system and panel de control page

This commit is contained in:
Urtzi Alfaro
2025-11-27 15:52:40 +01:00
parent 1a2f4602f3
commit e902419b6e
178 changed files with 20982 additions and 6944 deletions

View File

@@ -1,2 +1,2 @@
export { KeyValueEditor } from './KeyValueEditor';
export default from './KeyValueEditor';
export default KeyValueEditor;

View File

@@ -7,14 +7,23 @@ import {
Check,
Trash2,
AlertTriangle,
AlertCircle,
Info,
CheckCircle,
X
X,
Bot,
TrendingUp,
Clock,
DollarSign,
Phone,
ExternalLink
} from 'lucide-react';
import { EnrichedAlert, AlertTypeClass } from '../../../types/alerts';
import { useSmartActionHandler } from '../../../utils/smartActionHandlers';
import { getPriorityColor, getTypeClassBadgeVariant, formatTimeUntilConsequence } from '../../../types/alerts';
import { useAuthUser } from '../../../stores/auth.store';
export interface NotificationPanelProps {
notifications: NotificationData[];
enrichedAlerts?: EnrichedAlert[];
isOpen: boolean;
onClose: () => void;
onMarkAsRead: (id: string) => void;
@@ -24,50 +33,7 @@ export interface NotificationPanelProps {
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';
}
};
// Legacy severity functions removed - now using enriched priority_level and type_class
const formatTimestamp = (timestamp: string) => {
const date = new Date(timestamp);
@@ -82,8 +48,176 @@ const formatTimestamp = (timestamp: string) => {
return date.toLocaleDateString('es-ES', { day: '2-digit', month: '2-digit' });
};
// Enriched Alert Item Component
const EnrichedAlertItem: React.FC<{
alert: EnrichedAlert;
isMobile: boolean;
onMarkAsRead: (id: string) => void;
onRemove: (id: string) => void;
actionHandler: any;
}> = ({ alert, isMobile, onMarkAsRead, onRemove, actionHandler }) => {
const isUnread = alert.status === 'active';
const priorityColor = getPriorityColor(alert.priority_level);
return (
<div
className={clsx(
"transition-colors hover:bg-[var(--bg-secondary)] active:bg-[var(--bg-tertiary)]",
isMobile ? 'px-4 py-4 mx-2 my-1 rounded-lg' : 'p-3',
isUnread && "bg-[var(--color-info)]/5 border-l-4"
)}
style={isUnread ? { borderLeftColor: priorityColor } : {}}
>
<div className={`flex gap-${isMobile ? '4' : '3'}`}>
{/* Priority Icon */}
<div
className={`flex-shrink-0 rounded-full mt-0.5 ${isMobile ? 'p-2' : 'p-1'}`}
style={{ backgroundColor: priorityColor + '15' }}
>
{alert.type_class === AlertTypeClass.PREVENTED_ISSUE ? (
<CheckCircle className={`${isMobile ? 'w-5 h-5' : 'w-3 h-3'}`} style={{ color: 'var(--color-success)' }} />
) : alert.type_class === AlertTypeClass.ESCALATION ? (
<Clock className={`${isMobile ? 'w-5 h-5' : 'w-3 h-3'}`} style={{ color: priorityColor }} />
) : (
<AlertTriangle className={`${isMobile ? 'w-5 h-5' : 'w-3 h-3'}`} style={{ color: priorityColor }} />
)}
</div>
{/* Content */}
<div className="flex-1 min-w-0">
{/* Header */}
<div className={`flex items-start justify-between gap-2 ${isMobile ? 'mb-2' : 'mb-1'}`}>
<div className={`flex items-center gap-2 ${isMobile ? 'flex-wrap' : ''}`}>
<Badge variant={getTypeClassBadgeVariant(alert.type_class)} size={isMobile ? "md" : "sm"}>
{alert.priority_level.toUpperCase()}
</Badge>
<Badge variant="secondary" size={isMobile ? "md" : "sm"}>
{alert.priority_score}
</Badge>
{alert.is_group_summary && (
<Badge variant="info" size={isMobile ? "md" : "sm"}>
{alert.grouped_alert_count} agrupadas
</Badge>
)}
</div>
<span className={`font-medium text-[var(--text-secondary)] ${isMobile ? 'text-sm' : 'text-xs'}`}>
{formatTimestamp(alert.created_at)}
</span>
</div>
{/* Title */}
<p className={`font-medium leading-tight text-[var(--text-primary)] ${
isMobile ? 'text-base mb-2' : 'text-sm mb-1'
}`}>
{alert.title}
</p>
{/* Message */}
<p className={`leading-relaxed text-[var(--text-secondary)] ${
isMobile ? 'text-sm mb-3' : 'text-xs mb-2'
}`}>
{alert.message}
</p>
{/* Context Badges */}
<div className="flex flex-wrap gap-2 mb-3">
{alert.orchestrator_context?.already_addressed && (
<div className="flex items-center gap-1 px-2 py-1 rounded-md bg-success/10 text-success text-xs">
<Bot className="w-3 h-3" />
<span>AI ya gestionó esto</span>
</div>
)}
{alert.business_impact?.financial_impact_eur && (
<div className="flex items-center gap-1 px-2 py-1 rounded-md bg-warning/10 text-warning text-xs">
<DollarSign className="w-3 h-3" />
<span>{alert.business_impact.financial_impact_eur.toFixed(0)} en riesgo</span>
</div>
)}
{alert.urgency_context?.time_until_consequence_hours && (
<div className="flex items-center gap-1 px-2 py-1 rounded-md bg-error/10 text-error text-xs">
<Clock className="w-3 h-3" />
<span>{formatTimeUntilConsequence(alert.urgency_context.time_until_consequence_hours)}</span>
</div>
)}
{alert.trend_context && (
<div className="flex items-center gap-1 px-2 py-1 rounded-md bg-info/10 text-info text-xs">
<TrendingUp className="w-3 h-3" />
<span>{alert.trend_context.change_percentage > 0 ? '+' : ''}{alert.trend_context.change_percentage.toFixed(1)}%</span>
</div>
)}
</div>
{/* AI Reasoning Summary */}
{alert.ai_reasoning_summary && (
<div className="mb-3 p-2 rounded-md bg-primary/5 border border-primary/20">
<div className="flex items-start gap-2">
<Bot className="w-4 h-4 text-primary mt-0.5 flex-shrink-0" />
<p className="text-xs text-[var(--text-secondary)] italic">
{alert.ai_reasoning_summary}
</p>
</div>
</div>
)}
{/* Smart Actions */}
{alert.actions && alert.actions.length > 0 && (
<div className={`flex flex-wrap gap-2 ${isMobile ? 'mb-3' : 'mb-2'}`}>
{alert.actions.slice(0, 3).map((action, idx) => (
<Button
key={idx}
size={isMobile ? "sm" : "xs"}
variant={action.variant === 'primary' ? 'default' : 'ghost'}
onClick={() => actionHandler.handleAction(action)}
disabled={action.disabled}
className={`${isMobile ? 'text-xs' : 'text-[10px]'} ${
action.variant === 'danger' ? 'text-error hover:text-error-dark' : ''
}`}
>
{action.type === 'call_supplier' && <Phone className="w-3 h-3 mr-1" />}
{action.type === 'navigate' && <ExternalLink className="w-3 h-3 mr-1" />}
{action.label}
{action.estimated_time_minutes && (
<span className="ml-1 opacity-60">({action.estimated_time_minutes}m)</span>
)}
</Button>
))}
</div>
)}
{/* Standard Actions */}
<div className={`flex items-center gap-2 ${isMobile ? 'flex-col sm:flex-row' : ''}`}>
{isUnread && (
<Button
variant="ghost"
size={isMobile ? "md" : "sm"}
onClick={() => onMarkAsRead(alert.id)}
className={`${isMobile ? 'w-full sm:w-auto px-4 py-2 text-sm' : 'h-6 px-2 text-xs'}`}
>
<Check className={`${isMobile ? 'w-4 h-4 mr-2' : 'w-3 h-3 mr-1'}`} />
Marcar como leído
</Button>
)}
<Button
variant="ghost"
size={isMobile ? "md" : "sm"}
onClick={() => onRemove(alert.id)}
className={`text-[var(--color-error)] hover:text-[var(--color-error-dark)] ${
isMobile ? 'w-full sm:w-auto px-4 py-2 text-sm' : 'h-6 px-2 text-xs'
}`}
>
<Trash2 className={`${isMobile ? 'w-4 h-4 mr-2' : 'w-3 h-3 mr-1'}`} />
Eliminar
</Button>
</div>
</div>
</div>
</div>
);
};
export const NotificationPanel: React.FC<NotificationPanelProps> = ({
notifications,
enrichedAlerts = [],
isOpen,
onClose,
onMarkAsRead,
@@ -94,9 +228,20 @@ export const NotificationPanel: React.FC<NotificationPanelProps> = ({
}) => {
if (!isOpen) return null;
const unreadNotifications = notifications.filter(n => !n.read);
const actionHandler = useSmartActionHandler();
const user = useAuthUser();
// Memoize unread notifications to prevent recalculation on every render
const unreadNotifications = React.useMemo(() =>
notifications.filter(n => !n.read),
[notifications]
);
const isMobile = window.innerWidth < 768;
// Use enriched alerts if available, otherwise fallback to legacy notifications
const useEnrichedAlerts = enrichedAlerts.length > 0;
return (
<>
{/* Backdrop - Only on mobile */}
@@ -167,7 +312,7 @@ export const NotificationPanel: React.FC<NotificationPanelProps> = ({
{/* Notifications List */}
<div className={`flex-1 overflow-y-auto ${isMobile ? 'px-2 py-2' : ''}`}>
{notifications.length === 0 ? (
{(useEnrichedAlerts ? enrichedAlerts.length === 0 : notifications.length === 0) ? (
<div className={`text-center ${isMobile ? 'py-12 px-6' : 'p-8'}`}>
<CheckCircle className={`mx-auto mb-3 ${isMobile ? 'w-12 h-12' : 'w-8 h-8'}`} style={{ color: 'var(--color-success)' }} />
<p className={`text-[var(--text-secondary)] ${isMobile ? 'text-base' : 'text-sm'}`}>
@@ -176,91 +321,47 @@ export const NotificationPanel: React.FC<NotificationPanelProps> = ({
</div>
) : (
<div className="divide-y divide-[var(--border-primary)]">
{notifications.map((notification) => {
const SeverityIcon = getSeverityIcon(notification.severity);
return (
<div
key={notification.id}
className={clsx(
"transition-colors hover:bg-[var(--bg-secondary)] active:bg-[var(--bg-tertiary)]",
isMobile ? 'px-4 py-4 mx-2 my-1 rounded-lg' : 'p-3',
!notification.read && "bg-[var(--color-info)]/5"
)}
>
<div className={`flex gap-${isMobile ? '4' : '3'}`}>
{/* Icon */}
<div
className={`flex-shrink-0 rounded-full mt-0.5 ${isMobile ? 'p-2' : 'p-1'}`}
style={{ backgroundColor: getSeverityColor(notification.severity) + '15' }}
>
<SeverityIcon
className={`${isMobile ? 'w-5 h-5' : '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 ${isMobile ? 'mb-2' : 'mb-1'}`}>
<div className={`flex items-center gap-2 ${isMobile ? 'flex-wrap' : ''}`}>
<Badge variant={getSeverityBadge(notification.severity)} size={isMobile ? "md" : "sm"}>
{notification.severity.toUpperCase()}
</Badge>
<Badge variant="secondary" size={isMobile ? "md" : "sm"}>
{notification.item_type === 'alert' ? 'Alerta' : 'Recomendación'}
</Badge>
</div>
<span className={`font-medium text-[var(--text-secondary)] ${isMobile ? 'text-sm' : 'text-xs'}`}>
{formatTimestamp(notification.timestamp)}
</span>
</div>
{/* Title */}
<p className={`font-medium leading-tight text-[var(--text-primary)] ${
isMobile ? 'text-base mb-2' : 'text-sm mb-1'
}`}>
{notification.title}
</p>
{/* Message */}
<p className={`leading-relaxed text-[var(--text-secondary)] ${
isMobile ? 'text-sm mb-4' : 'text-xs mb-2'
}`}>
{notification.message}
</p>
{/* Actions */}
<div className={`flex items-center gap-2 ${isMobile ? 'flex-col sm:flex-row' : ''}`}>
{!notification.read && (
<Button
variant="ghost"
size={isMobile ? "md" : "sm"}
onClick={() => onMarkAsRead(notification.id)}
className={`${isMobile ? 'w-full sm:w-auto px-4 py-2 text-sm' : 'h-6 px-2 text-xs'}`}
>
<Check className={`${isMobile ? 'w-4 h-4 mr-2' : 'w-3 h-3 mr-1'}`} />
Marcar como leído
</Button>
)}
<Button
variant="ghost"
size={isMobile ? "md" : "sm"}
onClick={() => onRemoveNotification(notification.id)}
className={`text-[var(--color-error)] hover:text-[var(--color-error-dark)] ${
isMobile ? 'w-full sm:w-auto px-4 py-2 text-sm' : 'h-6 px-2 text-xs'
}`}
>
<Trash2 className={`${isMobile ? 'w-4 h-4 mr-2' : 'w-3 h-3 mr-1'}`} />
Eliminar
</Button>
</div>
</div>
</div>
</div>
);
})}
{useEnrichedAlerts ? (
// Render enriched alerts
enrichedAlerts
.sort((a, b) => b.priority_score - a.priority_score)
.map((alert) => (
<EnrichedAlertItem
key={alert.id}
alert={alert}
isMobile={isMobile}
onMarkAsRead={onMarkAsRead}
onRemove={onRemoveNotification}
actionHandler={actionHandler}
/>
))
) : (
// Render notifications as enriched alerts (NotificationData now has enriched fields)
notifications
.sort((a, b) => b.priority_score - a.priority_score)
.map((notification) => (
<EnrichedAlertItem
key={notification.id}
alert={{
...notification,
tenant_id: user?.tenant_id || '',
status: notification.read ? 'acknowledged' : 'active',
created_at: notification.timestamp,
enriched_at: notification.timestamp,
alert_metadata: notification.metadata || {},
service: 'notification-service',
alert_type: notification.item_type,
actions: notification.actions || [],
is_group_summary: false,
placement: notification.placement || ['notification_panel']
} as EnrichedAlert}
isMobile={isMobile}
onMarkAsRead={onMarkAsRead}
onRemove={onRemoveNotification}
actionHandler={actionHandler}
/>
))
)}
</div>
)}
</div>