New alert system and panel de control page
This commit is contained in:
@@ -1,2 +1,2 @@
|
||||
export { KeyValueEditor } from './KeyValueEditor';
|
||||
export default from './KeyValueEditor';
|
||||
export default KeyValueEditor;
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user