New alert system and panel de control page
This commit is contained in:
@@ -1,126 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Check, Trash2, Clock, X } from 'lucide-react';
|
||||
import { Button } from '../../ui/Button';
|
||||
import AlertSnoozeMenu from './AlertSnoozeMenu';
|
||||
|
||||
export interface AlertBulkActionsProps {
|
||||
selectedCount: number;
|
||||
onMarkAsRead: () => void;
|
||||
onRemove: () => void;
|
||||
onSnooze: (duration: '15min' | '1hr' | '4hr' | 'tomorrow' | number) => void;
|
||||
onDeselectAll: () => void;
|
||||
onSelectAll: () => void;
|
||||
totalCount: number;
|
||||
}
|
||||
|
||||
const AlertBulkActions: React.FC<AlertBulkActionsProps> = ({
|
||||
selectedCount,
|
||||
onMarkAsRead,
|
||||
onRemove,
|
||||
onSnooze,
|
||||
onDeselectAll,
|
||||
onSelectAll,
|
||||
totalCount,
|
||||
}) => {
|
||||
const [showSnoozeMenu, setShowSnoozeMenu] = useState(false);
|
||||
|
||||
if (selectedCount === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleSnooze = (duration: '15min' | '1hr' | '4hr' | 'tomorrow' | number) => {
|
||||
onSnooze(duration);
|
||||
setShowSnoozeMenu(false);
|
||||
};
|
||||
|
||||
const allSelected = selectedCount === totalCount;
|
||||
|
||||
return (
|
||||
<div className="sticky top-0 z-20 bg-gradient-to-r from-[var(--color-primary)] to-[var(--color-primary-dark)] text-white px-4 py-3 rounded-xl shadow-xl flex items-center justify-between gap-3 animate-in slide-in-from-top-2 duration-300">
|
||||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 bg-white/20 backdrop-blur-sm rounded-lg">
|
||||
<span className="text-sm font-bold">{selectedCount}</span>
|
||||
<span className="text-xs font-medium opacity-90">
|
||||
{selectedCount === 1 ? 'seleccionado' : 'seleccionados'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{!allSelected && totalCount > selectedCount && (
|
||||
<button
|
||||
onClick={onSelectAll}
|
||||
className="text-sm font-medium hover:underline opacity-90 hover:opacity-100 transition-opacity whitespace-nowrap"
|
||||
aria-label={`Select all ${totalCount} alerts`}
|
||||
>
|
||||
Seleccionar todos ({totalCount})
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 relative flex-shrink-0">
|
||||
{/* Quick Actions */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onMarkAsRead}
|
||||
className="bg-white/15 text-white border-white/30 hover:bg-white/25 backdrop-blur-sm h-9 px-3"
|
||||
aria-label="Mark all selected as read"
|
||||
>
|
||||
<Check className="w-4 h-4 sm:mr-2" />
|
||||
<span className="hidden sm:inline">Marcar leídos</span>
|
||||
</Button>
|
||||
|
||||
<div className="relative">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowSnoozeMenu(!showSnoozeMenu)}
|
||||
className="bg-white/15 text-white border-white/30 hover:bg-white/25 backdrop-blur-sm h-9 px-3"
|
||||
aria-label="Snooze selected alerts"
|
||||
>
|
||||
<Clock className="w-4 h-4 sm:mr-2" />
|
||||
<span className="hidden sm:inline">Posponer</span>
|
||||
</Button>
|
||||
|
||||
{showSnoozeMenu && (
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 z-20"
|
||||
onClick={() => setShowSnoozeMenu(false)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div className="absolute right-0 top-full mt-2 z-30">
|
||||
<AlertSnoozeMenu
|
||||
onSnooze={handleSnooze}
|
||||
onCancel={() => setShowSnoozeMenu(false)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onRemove}
|
||||
className="bg-red-500/25 text-white border-red-300/40 hover:bg-red-500/40 backdrop-blur-sm h-9 px-3"
|
||||
aria-label="Delete selected alerts"
|
||||
>
|
||||
<Trash2 className="w-4 h-4 sm:mr-2" />
|
||||
<span className="hidden sm:inline">Eliminar</span>
|
||||
</Button>
|
||||
|
||||
{/* Close button */}
|
||||
<button
|
||||
onClick={onDeselectAll}
|
||||
className="ml-1 p-2 hover:bg-white/15 rounded-lg transition-colors"
|
||||
aria-label="Deselect all"
|
||||
title="Cerrar selección"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AlertBulkActions;
|
||||
@@ -1,446 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
AlertTriangle,
|
||||
AlertCircle,
|
||||
Info,
|
||||
CheckCircle,
|
||||
Check,
|
||||
Trash2,
|
||||
Clock,
|
||||
MoreVertical,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
} from 'lucide-react';
|
||||
import { Badge } from '../../ui/Badge';
|
||||
import { Button } from '../../ui/Button';
|
||||
import type { NotificationData } from '../../../hooks/useNotifications';
|
||||
import { getSnoozedTimeRemaining, categorizeAlert } from '../../../utils/alertHelpers';
|
||||
import AlertContextActions from './AlertContextActions';
|
||||
import AlertSnoozeMenu from './AlertSnoozeMenu';
|
||||
|
||||
export interface AlertCardProps {
|
||||
alert: NotificationData;
|
||||
isExpanded: boolean;
|
||||
isSelected: boolean;
|
||||
isSnoozed: boolean;
|
||||
snoozedUntil?: number;
|
||||
onToggleExpand: () => void;
|
||||
onToggleSelect: () => void;
|
||||
onMarkAsRead: () => void;
|
||||
onRemove: () => void;
|
||||
onSnooze: (duration: '15min' | '1hr' | '4hr' | 'tomorrow' | number) => void;
|
||||
onUnsnooze: () => void;
|
||||
showCheckbox?: boolean;
|
||||
}
|
||||
|
||||
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): 'error' | 'warning' | 'info' | 'success' => {
|
||||
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, t: any) => {
|
||||
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 t('dashboard:alerts.time.now', 'Ahora');
|
||||
if (diffMins < 60) return t('dashboard:alerts.time.minutes_ago', 'hace {{count}} min', { count: diffMins });
|
||||
const diffHours = Math.floor(diffMins / 60);
|
||||
if (diffHours < 24) return t('dashboard:alerts.time.hours_ago', 'hace {{count}} h', { count: diffHours });
|
||||
return date.toLocaleDateString() === new Date(now.getTime() - 24 * 60 * 60 * 1000).toLocaleDateString()
|
||||
? t('dashboard:alerts.time.yesterday', 'Ayer')
|
||||
: date.toLocaleDateString('es-ES', { day: '2-digit', month: '2-digit' });
|
||||
};
|
||||
|
||||
const AlertCard: React.FC<AlertCardProps> = ({
|
||||
alert,
|
||||
isExpanded,
|
||||
isSelected,
|
||||
isSnoozed,
|
||||
snoozedUntil,
|
||||
onToggleExpand,
|
||||
onToggleSelect,
|
||||
onMarkAsRead,
|
||||
onRemove,
|
||||
onSnooze,
|
||||
onUnsnooze,
|
||||
showCheckbox = false,
|
||||
}) => {
|
||||
const { t } = useTranslation(['dashboard']);
|
||||
const [showSnoozeMenu, setShowSnoozeMenu] = useState(false);
|
||||
const [showActions, setShowActions] = useState(false);
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
|
||||
const SeverityIcon = getSeverityIcon(alert.severity);
|
||||
const severityColor = getSeverityColor(alert.severity);
|
||||
|
||||
const handleSnooze = (duration: '15min' | '1hr' | '4hr' | 'tomorrow' | number) => {
|
||||
onSnooze(duration);
|
||||
setShowSnoozeMenu(false);
|
||||
};
|
||||
|
||||
const category = categorizeAlert(alert);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
rounded-lg transition-all duration-200 relative overflow-hidden
|
||||
${isExpanded ? 'shadow-md' : 'hover:shadow-md'}
|
||||
${isSelected ? 'ring-2 ring-[var(--color-primary)] ring-offset-2' : ''}
|
||||
${isSnoozed ? 'opacity-75' : ''}
|
||||
`}
|
||||
style={{
|
||||
backgroundColor: 'var(--bg-primary)',
|
||||
border: '1px solid var(--border-primary)',
|
||||
...(isExpanded && {
|
||||
backgroundColor: 'var(--bg-secondary)',
|
||||
}),
|
||||
}}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
{/* Left severity accent border */}
|
||||
<div
|
||||
className="absolute left-0 top-0 bottom-0 w-1"
|
||||
style={{ backgroundColor: severityColor }}
|
||||
/>
|
||||
|
||||
{/* Compact Card Header */}
|
||||
<div className="flex items-start gap-3 p-4 pl-5">
|
||||
{/* Checkbox for selection */}
|
||||
{showCheckbox && (
|
||||
<div className="flex-shrink-0 pt-0.5">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
onChange={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggleSelect();
|
||||
}}
|
||||
className="w-4 h-4 rounded border-[var(--border-primary)] text-[var(--color-primary)] focus:ring-[var(--color-primary)] focus:ring-offset-0 cursor-pointer"
|
||||
aria-label={`Select alert: ${alert.title}`}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Severity Icon */}
|
||||
<div
|
||||
className="flex-shrink-0 p-2 rounded-lg cursor-pointer hover:scale-105 transition-transform"
|
||||
style={{ backgroundColor: severityColor + '15' }}
|
||||
onClick={onToggleExpand}
|
||||
aria-label="Toggle alert details"
|
||||
>
|
||||
<SeverityIcon
|
||||
className="w-5 h-5"
|
||||
style={{ color: severityColor }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Alert Content */}
|
||||
<div className="flex-1 min-w-0 cursor-pointer" onClick={onToggleExpand}>
|
||||
{/* Title and Status */}
|
||||
<div className="flex items-start justify-between gap-3 mb-1.5">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h4 className="text-base font-semibold leading-snug mb-1" style={{ color: 'var(--text-primary)' }}>
|
||||
{alert.title}
|
||||
</h4>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{/* Single primary severity badge */}
|
||||
<Badge variant={getSeverityBadge(alert.severity)} size="sm" className="font-semibold px-2.5 py-1 min-h-[1.375rem]">
|
||||
{t(`dashboard:alerts.severity.${alert.severity}`, alert.severity.toUpperCase())}
|
||||
</Badge>
|
||||
|
||||
{/* Unread indicator */}
|
||||
{!alert.read && (
|
||||
<span className="inline-flex items-center gap-1.5 text-xs font-medium px-2 py-1 rounded-md bg-blue-50 dark:bg-blue-900/20 min-h-[1.375rem]" style={{ color: 'var(--color-info)' }}>
|
||||
<span className="w-2 h-2 rounded-full bg-[var(--color-info)] animate-pulse flex-shrink-0" />
|
||||
{t('dashboard:alerts.status.new', 'Nuevo')}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Snoozed indicator */}
|
||||
{isSnoozed && snoozedUntil && (
|
||||
<span className="inline-flex items-center gap-1.5 text-xs font-medium px-2 py-1 rounded-md bg-gray-50 dark:bg-gray-800 min-h-[1.375rem]" style={{ color: 'var(--text-secondary)' }}>
|
||||
<Clock className="w-3 h-3 flex-shrink-0" />
|
||||
<span className="whitespace-nowrap">{getSnoozedTimeRemaining(alert.id, new Map([[alert.id, { alertId: alert.id, until: snoozedUntil }]]))}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Timestamp */}
|
||||
<span className="text-xs font-medium flex-shrink-0 pt-0.5" style={{ color: 'var(--text-secondary)' }}>
|
||||
{formatTimestamp(alert.timestamp, t)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Preview message when collapsed */}
|
||||
{!isExpanded && alert.message && (
|
||||
<p
|
||||
className="text-sm leading-relaxed mt-2 overflow-hidden"
|
||||
style={{
|
||||
color: 'var(--text-secondary)',
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
}}
|
||||
>
|
||||
{alert.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions - shown on hover or when expanded */}
|
||||
<div className={`flex-shrink-0 flex items-center gap-1 transition-opacity ${isHovered || isExpanded || showActions ? 'opacity-100' : 'opacity-0'}`}>
|
||||
{/* Quick action buttons */}
|
||||
{!alert.read && !isExpanded && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onMarkAsRead();
|
||||
}}
|
||||
className="p-1.5 rounded-md hover:bg-[var(--bg-tertiary)] transition-colors"
|
||||
title={t('dashboard:alerts.mark_as_read', 'Marcar como leído')}
|
||||
aria-label="Mark as read"
|
||||
>
|
||||
<Check className="w-4 h-4" style={{ color: 'var(--color-success)' }} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowActions(!showActions);
|
||||
}}
|
||||
className="p-1.5 rounded-md hover:bg-[var(--bg-tertiary)] transition-colors"
|
||||
aria-label="More actions"
|
||||
title="More actions"
|
||||
>
|
||||
<MoreVertical className="w-4 h-4" style={{ color: 'var(--text-secondary)' }} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggleExpand();
|
||||
}}
|
||||
className="p-1.5 rounded-md hover:bg-[var(--bg-tertiary)] transition-colors"
|
||||
aria-label={isExpanded ? "Collapse" : "Expand"}
|
||||
title={isExpanded ? "Collapse" : "Expand"}
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronUp className="w-4 h-4" style={{ color: 'var(--text-secondary)' }} />
|
||||
) : (
|
||||
<ChevronDown className="w-4 h-4" style={{ color: 'var(--text-secondary)' }} />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Actions Menu - Better positioning */}
|
||||
{showActions && (
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 z-20"
|
||||
onClick={() => setShowActions(false)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div className="absolute right-3 top-16 z-30 bg-[var(--bg-primary)] border border-[var(--border-primary)] rounded-lg shadow-xl py-1 min-w-[180px]">
|
||||
{!alert.read && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onMarkAsRead();
|
||||
setShowActions(false);
|
||||
}}
|
||||
className="w-full px-4 py-2 text-left text-sm hover:bg-[var(--bg-secondary)] transition-colors flex items-center gap-2"
|
||||
style={{ color: 'var(--text-primary)' }}
|
||||
>
|
||||
<Check className="w-4 h-4" style={{ color: 'var(--color-success)' }} />
|
||||
Marcar como leído
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowSnoozeMenu(true);
|
||||
setShowActions(false);
|
||||
}}
|
||||
className="w-full px-4 py-2 text-left text-sm hover:bg-[var(--bg-secondary)] transition-colors flex items-center gap-2"
|
||||
style={{ color: 'var(--text-primary)' }}
|
||||
>
|
||||
<Clock className="w-4 h-4" />
|
||||
{isSnoozed ? 'Cambiar tiempo' : 'Posponer'}
|
||||
</button>
|
||||
{isSnoozed && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onUnsnooze();
|
||||
setShowActions(false);
|
||||
}}
|
||||
className="w-full px-4 py-2 text-left text-sm hover:bg-[var(--bg-secondary)] transition-colors flex items-center gap-2"
|
||||
style={{ color: 'var(--text-primary)' }}
|
||||
>
|
||||
<Clock className="w-4 h-4" />
|
||||
Reactivar ahora
|
||||
</button>
|
||||
)}
|
||||
<div className="my-1 border-t border-[var(--border-primary)]" />
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemove();
|
||||
setShowActions(false);
|
||||
}}
|
||||
className="w-full px-4 py-2 text-left text-sm hover:bg-red-50 dark:hover:bg-red-900/20 text-red-600 transition-colors flex items-center gap-2"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
Eliminar
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Snooze Menu - Better positioning */}
|
||||
{showSnoozeMenu && (
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 z-20"
|
||||
onClick={() => setShowSnoozeMenu(false)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div className="absolute right-3 top-16 z-30">
|
||||
<AlertSnoozeMenu
|
||||
onSnooze={handleSnooze}
|
||||
onCancel={() => setShowSnoozeMenu(false)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Expanded Details */}
|
||||
{isExpanded && (
|
||||
<div className="px-5 pb-4 border-t pt-4" style={{ borderColor: 'var(--border-primary)' }}>
|
||||
{/* Full Message */}
|
||||
<div className="mb-4">
|
||||
<p className="text-sm leading-relaxed" style={{ color: 'var(--text-primary)' }}>
|
||||
{alert.message}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Metadata */}
|
||||
{alert.metadata && Object.keys(alert.metadata).length > 0 && (
|
||||
<div className="mb-4 p-3 rounded-lg border" style={{
|
||||
backgroundColor: 'var(--bg-tertiary)',
|
||||
borderColor: 'var(--border-primary)'
|
||||
}}>
|
||||
<p className="text-xs font-semibold mb-2 uppercase tracking-wide" style={{ color: 'var(--text-primary)' }}>
|
||||
{t('dashboard:alerts.additional_details', 'Detalles Adicionales')}
|
||||
</p>
|
||||
<div className="text-sm space-y-2" style={{ color: 'var(--text-secondary)' }}>
|
||||
{Object.entries(alert.metadata).map(([key, value]) => (
|
||||
<div key={key} className="flex justify-between gap-4">
|
||||
<span className="font-medium capitalize text-[var(--text-primary)]">{key.replace(/_/g, ' ')}:</span>
|
||||
<span className="text-right">{String(value)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Contextual Actions */}
|
||||
<div className="mb-4">
|
||||
<AlertContextActions alert={alert} />
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{!alert.read && (
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onMarkAsRead();
|
||||
}}
|
||||
className="h-9 px-4 text-sm font-medium"
|
||||
>
|
||||
<Check className="w-4 h-4 mr-2" />
|
||||
{t('dashboard:alerts.mark_as_read', 'Marcar como leído')}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowSnoozeMenu(true);
|
||||
}}
|
||||
className="h-9 px-4 text-sm font-medium"
|
||||
>
|
||||
<Clock className="w-4 h-4 mr-2" />
|
||||
{isSnoozed ? 'Cambiar' : 'Posponer'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemove();
|
||||
}}
|
||||
className="h-9 px-4 text-sm font-medium text-red-600 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-900/20"
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
{t('dashboard:alerts.remove', 'Eliminar')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AlertCard;
|
||||
@@ -1,49 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Button } from '../../ui/Button';
|
||||
import type { NotificationData } from '../../../hooks/useNotifications';
|
||||
import { useAlertActions } from '../../../hooks/useAlertActions';
|
||||
|
||||
export interface AlertContextActionsProps {
|
||||
alert: NotificationData;
|
||||
}
|
||||
|
||||
const AlertContextActions: React.FC<AlertContextActionsProps> = ({ alert }) => {
|
||||
const { getActions, executeAction } = useAlertActions();
|
||||
const actions = getActions(alert);
|
||||
|
||||
if (actions.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mb-4">
|
||||
<p className="text-xs font-semibold mb-2 uppercase tracking-wide text-[var(--text-primary)]">
|
||||
Acciones Recomendadas
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{actions.map((action, index) => {
|
||||
const variantMap: Record<string, 'primary' | 'secondary' | 'outline'> = {
|
||||
primary: 'primary',
|
||||
secondary: 'secondary',
|
||||
outline: 'outline',
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={index}
|
||||
variant={variantMap[action.variant] || 'outline'}
|
||||
size="sm"
|
||||
onClick={() => executeAction(alert, action)}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<span>{action.icon}</span>
|
||||
<span>{action.label}</span>
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AlertContextActions;
|
||||
@@ -1,306 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Search, X, Filter, ChevronDown } from 'lucide-react';
|
||||
import { Badge } from '../../ui/Badge';
|
||||
import { Button } from '../../ui/Button';
|
||||
import { Input } from '../../ui/Input';
|
||||
import type { AlertSeverity, AlertCategory, TimeGroup } from '../../../utils/alertHelpers';
|
||||
import { getCategoryName, getCategoryIcon } from '../../../utils/alertHelpers';
|
||||
|
||||
export interface AlertFiltersProps {
|
||||
selectedSeverities: AlertSeverity[];
|
||||
selectedCategories: AlertCategory[];
|
||||
selectedTimeRange: TimeGroup | 'all';
|
||||
searchQuery: string;
|
||||
showSnoozed: boolean;
|
||||
onToggleSeverity: (severity: AlertSeverity) => void;
|
||||
onToggleCategory: (category: AlertCategory) => void;
|
||||
onSetTimeRange: (range: TimeGroup | 'all') => void;
|
||||
onSearchChange: (query: string) => void;
|
||||
onToggleShowSnoozed: () => void;
|
||||
onClearFilters: () => void;
|
||||
hasActiveFilters: boolean;
|
||||
activeFilterCount: number;
|
||||
}
|
||||
|
||||
const SEVERITY_CONFIG: Record<AlertSeverity, { label: string; color: string; variant: 'error' | 'warning' | 'info' | 'success' }> = {
|
||||
urgent: { label: 'Urgente', color: 'bg-red-500', variant: 'error' },
|
||||
high: { label: 'Alta', color: 'bg-orange-500', variant: 'warning' },
|
||||
medium: { label: 'Media', color: 'bg-blue-500', variant: 'info' },
|
||||
low: { label: 'Baja', color: 'bg-green-500', variant: 'success' },
|
||||
};
|
||||
|
||||
const TIME_RANGES: Array<{ value: TimeGroup | 'all'; label: string }> = [
|
||||
{ value: 'all', label: 'Todos' },
|
||||
{ value: 'today', label: 'Hoy' },
|
||||
{ value: 'yesterday', label: 'Ayer' },
|
||||
{ value: 'this_week', label: 'Esta semana' },
|
||||
{ value: 'older', label: 'Anteriores' },
|
||||
];
|
||||
|
||||
const CATEGORIES: AlertCategory[] = ['inventory', 'production', 'orders', 'equipment', 'quality', 'suppliers'];
|
||||
|
||||
const AlertFilters: React.FC<AlertFiltersProps> = ({
|
||||
selectedSeverities,
|
||||
selectedCategories,
|
||||
selectedTimeRange,
|
||||
searchQuery,
|
||||
showSnoozed,
|
||||
onToggleSeverity,
|
||||
onToggleCategory,
|
||||
onSetTimeRange,
|
||||
onSearchChange,
|
||||
onToggleShowSnoozed,
|
||||
onClearFilters,
|
||||
hasActiveFilters,
|
||||
activeFilterCount,
|
||||
}) => {
|
||||
const { t, i18n } = useTranslation(['dashboard']);
|
||||
// Start collapsed by default for cleaner UI
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* Search and Filter Toggle */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 relative">
|
||||
<Input
|
||||
type="text"
|
||||
placeholder={t('dashboard:alerts.filters.search_placeholder', 'Buscar alertas...')}
|
||||
value={searchQuery}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
leftIcon={<Search className="w-4 h-4" />}
|
||||
rightIcon={
|
||||
searchQuery ? (
|
||||
<button
|
||||
onClick={() => onSearchChange('')}
|
||||
className="p-1 hover:bg-[var(--bg-secondary)] rounded-full transition-colors"
|
||||
aria-label="Clear search"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
) : undefined
|
||||
}
|
||||
className="pr-8 h-10"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant={showFilters || hasActiveFilters ? 'primary' : 'outline'}
|
||||
size="md"
|
||||
onClick={() => setShowFilters(!showFilters)}
|
||||
className="flex items-center gap-2 relative h-10 px-4"
|
||||
aria-expanded={showFilters}
|
||||
aria-label="Toggle filters"
|
||||
>
|
||||
<Filter className="w-4 h-4" />
|
||||
<span className="hidden sm:inline font-medium">Filtros</span>
|
||||
{activeFilterCount > 0 && (
|
||||
<span className="absolute -top-1.5 -right-1.5 w-5 h-5 bg-[var(--color-error)] text-white text-xs font-bold rounded-full flex items-center justify-center">
|
||||
{activeFilterCount}
|
||||
</span>
|
||||
)}
|
||||
<ChevronDown className={`w-4 h-4 transition-transform duration-200 ${showFilters ? 'rotate-180' : ''}`} />
|
||||
</Button>
|
||||
|
||||
{hasActiveFilters && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onClearFilters}
|
||||
className="text-red-500 hover:text-red-600 h-10 px-3"
|
||||
title="Clear all filters"
|
||||
aria-label="Clear all filters"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
<span className="sr-only">Limpiar filtros</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Expandable Filters Panel - Animated */}
|
||||
{showFilters && (
|
||||
<div className="p-4 bg-[var(--bg-secondary)] rounded-lg border border-[var(--border-primary)] space-y-5 animate-in fade-in slide-in-from-top-2 duration-200">
|
||||
{/* Severity Filters */}
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-[var(--text-primary)] mb-3">
|
||||
{t('dashboard:alerts.filters.severity', 'Severidad')}
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(Object.keys(SEVERITY_CONFIG) as AlertSeverity[]).map((severity) => {
|
||||
const config = SEVERITY_CONFIG[severity];
|
||||
const isSelected = selectedSeverities.includes(severity);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={severity}
|
||||
onClick={() => onToggleSeverity(severity)}
|
||||
className={`
|
||||
px-4 py-2 rounded-lg text-sm font-medium transition-all
|
||||
${isSelected
|
||||
? 'ring-2 ring-[var(--color-primary)] ring-offset-2 ring-offset-[var(--bg-secondary)] scale-105'
|
||||
: 'opacity-70 hover:opacity-100 hover:scale-105'
|
||||
}
|
||||
`}
|
||||
aria-pressed={isSelected}
|
||||
>
|
||||
<Badge variant={config.variant} size="sm" className="pointer-events-none">
|
||||
{config.label}
|
||||
</Badge>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Category Filters */}
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-[var(--text-primary)] mb-3">
|
||||
{t('dashboard:alerts.filters.category', 'Categoría')}
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{CATEGORIES.map((category) => {
|
||||
const isSelected = selectedCategories.includes(category);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={category}
|
||||
onClick={() => onToggleCategory(category)}
|
||||
className={`
|
||||
px-4 py-2 rounded-lg text-sm font-medium transition-all border-2
|
||||
${isSelected
|
||||
? 'bg-[var(--color-primary)] text-white border-[var(--color-primary)] scale-105'
|
||||
: 'bg-[var(--bg-primary)] text-[var(--text-secondary)] border-[var(--border-primary)] hover:border-[var(--color-primary)] hover:text-[var(--text-primary)] hover:scale-105'
|
||||
}
|
||||
`}
|
||||
aria-pressed={isSelected}
|
||||
>
|
||||
<span className="mr-1.5">{getCategoryIcon(category)}</span>
|
||||
{getCategoryName(category, i18n.language)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Time Range Filters */}
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-[var(--text-primary)] mb-3">
|
||||
{t('dashboard:alerts.filters.time_range', 'Periodo')}
|
||||
</label>
|
||||
<div className="grid grid-cols-3 sm:grid-cols-5 gap-2">
|
||||
{TIME_RANGES.map((range) => {
|
||||
const isSelected = selectedTimeRange === range.value;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={range.value}
|
||||
onClick={() => onSetTimeRange(range.value)}
|
||||
className={`
|
||||
px-4 py-2 rounded-lg text-sm font-medium transition-all
|
||||
${isSelected
|
||||
? 'bg-[var(--color-primary)] text-white shadow-md scale-105'
|
||||
: 'bg-[var(--bg-primary)] text-[var(--text-secondary)] hover:bg-[var(--bg-tertiary)] hover:text-[var(--text-primary)] hover:scale-105'
|
||||
}
|
||||
`}
|
||||
aria-pressed={isSelected}
|
||||
>
|
||||
{range.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Show Snoozed Toggle */}
|
||||
<div className="flex items-center justify-between pt-3 border-t border-[var(--border-primary)]">
|
||||
<label htmlFor="show-snoozed-toggle" className="text-sm font-medium text-[var(--text-primary)] cursor-pointer">
|
||||
{t('dashboard:alerts.filters.show_snoozed', 'Mostrar pospuestos')}
|
||||
</label>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
id="show-snoozed-toggle"
|
||||
type="checkbox"
|
||||
checked={showSnoozed}
|
||||
onChange={onToggleShowSnoozed}
|
||||
className="sr-only peer"
|
||||
aria-label="Toggle show snoozed alerts"
|
||||
/>
|
||||
<div className="w-11 h-6 bg-gray-300 dark:bg-gray-600 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-[var(--color-primary)]/20 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-[var(--color-primary)]"></div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Active Filters Summary - Chips */}
|
||||
{hasActiveFilters && !showFilters && (
|
||||
<div className="flex flex-wrap gap-2 animate-in fade-in slide-in-from-top-1 duration-200">
|
||||
<span className="text-xs font-medium text-[var(--text-secondary)] self-center">
|
||||
Filtros activos:
|
||||
</span>
|
||||
|
||||
{selectedSeverities.map((severity) => (
|
||||
<button
|
||||
key={severity}
|
||||
onClick={() => onToggleSeverity(severity)}
|
||||
className="group inline-flex items-center gap-1.5 transition-all hover:scale-105"
|
||||
aria-label={`Remove ${severity} filter`}
|
||||
>
|
||||
<Badge
|
||||
variant={SEVERITY_CONFIG[severity].variant}
|
||||
size="sm"
|
||||
className="flex items-center gap-1.5 pr-1"
|
||||
>
|
||||
{SEVERITY_CONFIG[severity].label}
|
||||
<span className="p-0.5 rounded-full hover:bg-black/10 transition-colors">
|
||||
<X className="w-3 h-3" />
|
||||
</span>
|
||||
</Badge>
|
||||
</button>
|
||||
))}
|
||||
|
||||
{selectedCategories.map((category) => (
|
||||
<button
|
||||
key={category}
|
||||
onClick={() => onToggleCategory(category)}
|
||||
className="group inline-flex items-center gap-1.5 transition-all hover:scale-105"
|
||||
aria-label={`Remove ${category} filter`}
|
||||
>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="flex items-center gap-1.5 pr-1"
|
||||
>
|
||||
{getCategoryIcon(category)} {getCategoryName(category, i18n.language)}
|
||||
<span className="p-0.5 rounded-full hover:bg-black/10 transition-colors">
|
||||
<X className="w-3 h-3" />
|
||||
</span>
|
||||
</Badge>
|
||||
</button>
|
||||
))}
|
||||
|
||||
{selectedTimeRange !== 'all' && (
|
||||
<button
|
||||
onClick={() => onSetTimeRange('all')}
|
||||
className="group inline-flex items-center gap-1.5 transition-all hover:scale-105"
|
||||
aria-label="Remove time range filter"
|
||||
>
|
||||
<Badge
|
||||
variant="info"
|
||||
size="sm"
|
||||
className="flex items-center gap-1.5 pr-1"
|
||||
>
|
||||
{TIME_RANGES.find(r => r.value === selectedTimeRange)?.label}
|
||||
<span className="p-0.5 rounded-full hover:bg-black/10 transition-colors">
|
||||
<X className="w-3 h-3" />
|
||||
</span>
|
||||
</Badge>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AlertFilters;
|
||||
@@ -1,84 +0,0 @@
|
||||
import React from 'react';
|
||||
import { ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import { Badge } from '../../ui/Badge';
|
||||
import type { AlertGroup } from '../../../utils/alertHelpers';
|
||||
|
||||
export interface AlertGroupHeaderProps {
|
||||
group: AlertGroup;
|
||||
isCollapsed: boolean;
|
||||
onToggleCollapse: () => void;
|
||||
}
|
||||
|
||||
const SEVERITY_COLORS: Record<string, string> = {
|
||||
urgent: 'text-red-600 bg-red-50 border-red-200',
|
||||
high: 'text-orange-600 bg-orange-50 border-orange-200',
|
||||
medium: 'text-blue-600 bg-blue-50 border-blue-200',
|
||||
low: 'text-green-600 bg-green-50 border-green-200',
|
||||
};
|
||||
|
||||
const SEVERITY_BADGE_VARIANTS: Record<string, 'error' | 'warning' | 'info' | 'success'> = {
|
||||
urgent: 'error',
|
||||
high: 'warning',
|
||||
medium: 'info',
|
||||
low: 'success',
|
||||
};
|
||||
|
||||
const AlertGroupHeader: React.FC<AlertGroupHeaderProps> = ({
|
||||
group,
|
||||
isCollapsed,
|
||||
onToggleCollapse,
|
||||
}) => {
|
||||
const severityConfig = SEVERITY_COLORS[group.severity] || SEVERITY_COLORS.low;
|
||||
const badgeVariant = SEVERITY_BADGE_VARIANTS[group.severity] || 'info';
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={onToggleCollapse}
|
||||
className={`
|
||||
w-full flex items-center justify-between p-3.5 rounded-lg border-2 transition-all
|
||||
${severityConfig}
|
||||
hover:shadow-md cursor-pointer hover:scale-[1.01]
|
||||
`}
|
||||
aria-expanded={!isCollapsed}
|
||||
aria-label={`${isCollapsed ? 'Expand' : 'Collapse'} ${group.title}`}
|
||||
>
|
||||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||
<div className="flex-shrink-0">
|
||||
{isCollapsed ? (
|
||||
<ChevronDown className="w-5 h-5" />
|
||||
) : (
|
||||
<ChevronUp className="w-5 h-5" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-left flex-1 min-w-0">
|
||||
<h3 className="font-bold text-sm truncate">
|
||||
{group.title}
|
||||
</h3>
|
||||
{group.type === 'similarity' && group.count > 1 && (
|
||||
<p className="text-xs opacity-75 mt-0.5">
|
||||
{group.alerts.length} alertas similares
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 flex-shrink-0 ml-3">
|
||||
{group.count > 1 && (
|
||||
<div className="flex items-center gap-1.5 px-2.5 py-1 bg-white/60 backdrop-blur-sm rounded-lg border border-current/20 min-h-[1.625rem]">
|
||||
<span className="text-xs font-bold leading-none">{group.count}</span>
|
||||
<span className="text-xs opacity-75 leading-none">alertas</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{group.severity && (
|
||||
<Badge variant={badgeVariant} size="sm" className="font-bold px-2.5 py-1 min-h-[1.625rem]">
|
||||
{group.severity.toUpperCase()}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export default AlertGroupHeader;
|
||||
@@ -1,118 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Clock, X } from 'lucide-react';
|
||||
import { Button } from '../../ui/Button';
|
||||
|
||||
export interface AlertSnoozeMenuProps {
|
||||
onSnooze: (duration: '15min' | '1hr' | '4hr' | 'tomorrow' | number) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
const PRESET_DURATIONS = [
|
||||
{ value: '15min' as const, label: '15 minutos', icon: '⏰' },
|
||||
{ value: '1hr' as const, label: '1 hora', icon: '🕐' },
|
||||
{ value: '4hr' as const, label: '4 horas', icon: '🕓' },
|
||||
{ value: 'tomorrow' as const, label: 'Mañana (9 AM)', icon: '☀️' },
|
||||
];
|
||||
|
||||
const AlertSnoozeMenu: React.FC<AlertSnoozeMenuProps> = ({
|
||||
onSnooze,
|
||||
onCancel,
|
||||
}) => {
|
||||
const [showCustom, setShowCustom] = useState(false);
|
||||
const [customHours, setCustomHours] = useState(1);
|
||||
|
||||
const handleCustomSnooze = () => {
|
||||
const milliseconds = customHours * 60 * 60 * 1000;
|
||||
onSnooze(milliseconds);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-[var(--bg-primary)] border border-[var(--border-primary)] rounded-lg shadow-lg p-3 min-w-[240px]">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-3 pb-2 border-b border-[var(--border-primary)]">
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="w-4 h-4 text-[var(--color-primary)]" />
|
||||
<span className="text-sm font-semibold text-[var(--text-primary)]">
|
||||
Posponer hasta
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="p-1 hover:bg-[var(--bg-secondary)] rounded transition-colors"
|
||||
>
|
||||
<X className="w-4 h-4 text-[var(--text-secondary)]" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!showCustom ? (
|
||||
<>
|
||||
{/* Preset Options */}
|
||||
<div className="space-y-1 mb-2">
|
||||
{PRESET_DURATIONS.map((preset) => (
|
||||
<button
|
||||
key={preset.value}
|
||||
onClick={() => onSnooze(preset.value)}
|
||||
className="w-full px-3 py-2 text-left text-sm rounded-lg hover:bg-[var(--bg-secondary)] transition-colors flex items-center gap-2 text-[var(--text-primary)]"
|
||||
>
|
||||
<span className="text-lg">{preset.icon}</span>
|
||||
<span>{preset.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Custom Option */}
|
||||
<button
|
||||
onClick={() => setShowCustom(true)}
|
||||
className="w-full px-3 py-2 text-left text-sm rounded-lg border border-dashed border-[var(--border-primary)] hover:bg-[var(--bg-secondary)] transition-colors text-[var(--text-secondary)]"
|
||||
>
|
||||
⚙️ Personalizado...
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* Custom Time Input */}
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-[var(--text-secondary)] mb-1">
|
||||
Número de horas
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="168"
|
||||
value={customHours}
|
||||
onChange={(e) => setCustomHours(parseInt(e.target.value) || 1)}
|
||||
className="w-full px-3 py-2 border border-[var(--border-primary)] rounded-lg text-sm bg-[var(--bg-primary)] text-[var(--text-primary)]"
|
||||
autoFocus
|
||||
/>
|
||||
<p className="text-xs text-[var(--text-tertiary)] mt-1">
|
||||
Máximo 168 horas (7 días)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowCustom(false)}
|
||||
className="flex-1"
|
||||
>
|
||||
Atrás
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={handleCustomSnooze}
|
||||
className="flex-1"
|
||||
>
|
||||
Confirmar
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AlertSnoozeMenu;
|
||||
@@ -1,179 +0,0 @@
|
||||
import React from 'react';
|
||||
import { TrendingUp, Clock, AlertTriangle, BarChart3 } from 'lucide-react';
|
||||
import { Badge } from '../../ui/Badge';
|
||||
import type { AlertAnalytics } from '../../../hooks/useAlertAnalytics';
|
||||
|
||||
export interface AlertTrendsProps {
|
||||
analytics: AlertAnalytics | undefined;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const AlertTrends: React.FC<AlertTrendsProps> = ({ analytics, className }) => {
|
||||
// Debug logging
|
||||
console.log('[AlertTrends] Received analytics:', analytics);
|
||||
console.log('[AlertTrends] Has trends?', analytics?.trends);
|
||||
console.log('[AlertTrends] Is array?', Array.isArray(analytics?.trends));
|
||||
|
||||
// Safety check: handle undefined or missing analytics data
|
||||
if (!analytics || !analytics.trends || !Array.isArray(analytics.trends)) {
|
||||
console.log('[AlertTrends] Showing loading state');
|
||||
return (
|
||||
<div className={`bg-[var(--bg-secondary)] rounded-lg p-4 border border-[var(--border-primary)] ${className}`}>
|
||||
<div className="flex items-center justify-center h-32 text-[var(--text-secondary)]">
|
||||
Cargando analíticas...
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
console.log('[AlertTrends] Rendering analytics with', analytics.trends.length, 'trends');
|
||||
|
||||
// Ensure we have valid trend data
|
||||
const validTrends = analytics.trends.filter(t => t && typeof t.count === 'number');
|
||||
const maxCount = validTrends.length > 0 ? Math.max(...validTrends.map(t => t.count), 1) : 1;
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
const date = new Date(dateStr);
|
||||
const today = new Date();
|
||||
const yesterday = new Date(today);
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
|
||||
if (date.toDateString() === today.toDateString()) {
|
||||
return 'Hoy';
|
||||
}
|
||||
if (date.toDateString() === yesterday.toDateString()) {
|
||||
return 'Ayer';
|
||||
}
|
||||
return date.toLocaleDateString('es-ES', { weekday: 'short', day: 'numeric' });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`bg-[var(--bg-secondary)] rounded-lg p-4 border border-[var(--border-primary)] ${className}`}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<BarChart3 className="w-5 h-5 text-[var(--color-primary)]" />
|
||||
<h3 className="font-semibold text-[var(--text-primary)]">
|
||||
Tendencias (7 días)
|
||||
</h3>
|
||||
</div>
|
||||
<Badge variant="secondary" size="sm">
|
||||
{analytics.totalAlerts} total
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Chart */}
|
||||
<div className="mb-4">
|
||||
<div className="flex items-end justify-between gap-1 h-32">
|
||||
{analytics.trends.map((trend, index) => {
|
||||
const heightPercentage = maxCount > 0 ? (trend.count / maxCount) * 100 : 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={trend.date}
|
||||
className="flex-1 flex flex-col items-center gap-1"
|
||||
>
|
||||
<div className="w-full flex flex-col justify-end" style={{ height: '100px' }}>
|
||||
{/* Bar */}
|
||||
<div
|
||||
className="w-full bg-gradient-to-t from-[var(--color-primary)] to-[var(--color-primary)]/60 rounded-t transition-all hover:opacity-80 cursor-pointer relative group"
|
||||
style={{ height: `${heightPercentage}%`, minHeight: trend.count > 0 ? '4px' : '0' }}
|
||||
title={`${trend.count} alertas`}
|
||||
>
|
||||
{/* Tooltip on hover */}
|
||||
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none">
|
||||
<div className="bg-[var(--text-primary)] text-white text-xs rounded px-2 py-1 whitespace-nowrap">
|
||||
{trend.count} alertas
|
||||
<div className="text-[10px] opacity-75">
|
||||
🔴 {trend.urgentCount} • 🟠 {trend.highCount} • 🔵 {trend.mediumCount} • 🟢 {trend.lowCount}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Label */}
|
||||
<span className="text-[10px] text-[var(--text-secondary)] text-center">
|
||||
{formatDate(trend.date)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Grid */}
|
||||
<div className="grid grid-cols-2 gap-3 pt-3 border-t border-[var(--border-primary)]">
|
||||
{/* Average Response Time */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="p-2 bg-blue-500/10 rounded">
|
||||
<Clock className="w-4 h-4 text-blue-500" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-[var(--text-secondary)]">Respuesta promedio</div>
|
||||
<div className="text-sm font-semibold text-[var(--text-primary)]">
|
||||
{analytics.averageResponseTime > 0 ? `${analytics.averageResponseTime} min` : 'N/A'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Daily Average */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="p-2 bg-purple-500/10 rounded">
|
||||
<TrendingUp className="w-4 h-4 text-purple-500" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-[var(--text-secondary)]">Promedio diario</div>
|
||||
<div className="text-sm font-semibold text-[var(--text-primary)]">
|
||||
{analytics.predictedDailyAverage} alertas
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Resolution Rate */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="p-2 bg-green-500/10 rounded">
|
||||
<BarChart3 className="w-4 h-4 text-green-500" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-[var(--text-secondary)]">Tasa de resolución</div>
|
||||
<div className="text-sm font-semibold text-[var(--text-primary)]">
|
||||
{analytics.resolutionRate}%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Busiest Day */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="p-2 bg-orange-500/10 rounded">
|
||||
<AlertTriangle className="w-4 h-4 text-orange-500" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-[var(--text-secondary)]">Día más activo</div>
|
||||
<div className="text-sm font-semibold text-[var(--text-primary)]">
|
||||
{analytics.busiestDay}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Top Categories */}
|
||||
{analytics.topCategories.length > 0 && (
|
||||
<div className="mt-3 pt-3 border-t border-[var(--border-primary)]">
|
||||
<div className="text-xs font-semibold text-[var(--text-secondary)] mb-2">
|
||||
Categorías principales
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{analytics.topCategories.map((cat) => (
|
||||
<Badge key={cat.category} variant="secondary" size="sm">
|
||||
{cat.count} ({cat.percentage}%)
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AlertTrends;
|
||||
@@ -0,0 +1,286 @@
|
||||
// ================================================================
|
||||
// frontend/src/components/domain/dashboard/AutoActionCountdownComponent.tsx
|
||||
// ================================================================
|
||||
/**
|
||||
* Auto Action Countdown Component
|
||||
*
|
||||
* Displays countdown timer for ESCALATION type alerts where AI will
|
||||
* automatically take action unless user intervenes.
|
||||
*
|
||||
* Design Philosophy:
|
||||
* - High urgency visual design
|
||||
* - Clear countdown timer
|
||||
* - One-click cancel button
|
||||
* - Shows what action will be taken
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { AlertTriangle, Clock, XCircle, CheckCircle } from 'lucide-react';
|
||||
import { Button } from '../../ui/Button';
|
||||
import { Badge } from '../../ui/Badge';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export interface AutoActionCountdownProps {
|
||||
actionDescription: string;
|
||||
countdownSeconds: number;
|
||||
onCancel: () => Promise<void>;
|
||||
financialImpactEur?: number;
|
||||
alertId: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function AutoActionCountdownComponent({
|
||||
actionDescription,
|
||||
countdownSeconds,
|
||||
onCancel,
|
||||
financialImpactEur,
|
||||
alertId,
|
||||
className = '',
|
||||
}: AutoActionCountdownProps) {
|
||||
const { t } = useTranslation('alerts');
|
||||
const [timeRemaining, setTimeRemaining] = useState(countdownSeconds);
|
||||
const [isCancelling, setIsCancelling] = useState(false);
|
||||
const [isCancelled, setIsCancelled] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (timeRemaining <= 0 || isCancelled) return;
|
||||
|
||||
const timer = setInterval(() => {
|
||||
setTimeRemaining((prev) => {
|
||||
if (prev <= 1) {
|
||||
clearInterval(timer);
|
||||
return 0;
|
||||
}
|
||||
return prev - 1;
|
||||
});
|
||||
}, 1000);
|
||||
|
||||
return () => clearInterval(timer);
|
||||
}, [timeRemaining, isCancelled]);
|
||||
|
||||
const handleCancel = async () => {
|
||||
setIsCancelling(true);
|
||||
try {
|
||||
await onCancel();
|
||||
setIsCancelled(true);
|
||||
} catch (error) {
|
||||
console.error('Failed to cancel auto-action:', error);
|
||||
} finally {
|
||||
setIsCancelling(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatTime = (seconds: number): string => {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = seconds % 60;
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
const getUrgencyLevel = (seconds: number): 'critical' | 'warning' | 'info' => {
|
||||
if (seconds <= 60) return 'critical';
|
||||
if (seconds <= 300) return 'warning';
|
||||
return 'info';
|
||||
};
|
||||
|
||||
const urgency = getUrgencyLevel(timeRemaining);
|
||||
const progressPercentage = ((countdownSeconds - timeRemaining) / countdownSeconds) * 100;
|
||||
|
||||
// Cancelled state
|
||||
if (isCancelled) {
|
||||
return (
|
||||
<div
|
||||
className={`rounded-lg p-4 border ${className}`}
|
||||
style={{
|
||||
backgroundColor: 'var(--color-success-50)',
|
||||
borderColor: 'var(--color-success-300)',
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<CheckCircle className="w-6 h-6 flex-shrink-0" style={{ color: 'var(--color-success-600)' }} />
|
||||
<div className="flex-1">
|
||||
<h4 className="font-semibold" style={{ color: 'var(--color-success-700)' }}>
|
||||
{t('auto_action.cancelled_title', 'Auto-action Cancelled')}
|
||||
</h4>
|
||||
<p className="text-sm mt-1" style={{ color: 'var(--color-success-600)' }}>
|
||||
{t('auto_action.cancelled_message', 'The automatic action has been prevented. You can now handle this manually.')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Action completed (countdown reached 0)
|
||||
if (timeRemaining <= 0) {
|
||||
return (
|
||||
<div
|
||||
className={`rounded-lg p-4 border ${className}`}
|
||||
style={{
|
||||
backgroundColor: 'var(--bg-secondary)',
|
||||
borderColor: 'var(--border-primary)',
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Clock className="w-6 h-6 flex-shrink-0" style={{ color: 'var(--text-tertiary)' }} />
|
||||
<div className="flex-1">
|
||||
<h4 className="font-semibold" style={{ color: 'var(--text-primary)' }}>
|
||||
{t('auto_action.completed_title', 'Auto-action Executed')}
|
||||
</h4>
|
||||
<p className="text-sm mt-1" style={{ color: 'var(--text-secondary)' }}>
|
||||
{actionDescription}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Active countdown
|
||||
return (
|
||||
<div
|
||||
className={`rounded-lg p-4 border-2 shadow-lg ${className}`}
|
||||
style={{
|
||||
backgroundColor:
|
||||
urgency === 'critical'
|
||||
? 'var(--color-error-50)'
|
||||
: urgency === 'warning'
|
||||
? 'var(--color-warning-50)'
|
||||
: 'var(--color-info-50)',
|
||||
borderColor:
|
||||
urgency === 'critical'
|
||||
? 'var(--color-error-400)'
|
||||
: urgency === 'warning'
|
||||
? 'var(--color-warning-400)'
|
||||
: 'var(--color-info-400)',
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between gap-3 mb-3">
|
||||
<div className="flex items-center gap-3 flex-1">
|
||||
<div
|
||||
className={`p-2 rounded-full ${
|
||||
urgency === 'critical' ? 'animate-pulse' : ''
|
||||
}`}
|
||||
style={{
|
||||
backgroundColor:
|
||||
urgency === 'critical'
|
||||
? 'var(--color-error-100)'
|
||||
: urgency === 'warning'
|
||||
? 'var(--color-warning-100)'
|
||||
: 'var(--color-info-100)',
|
||||
}}
|
||||
>
|
||||
<AlertTriangle
|
||||
className="w-5 h-5"
|
||||
style={{
|
||||
color:
|
||||
urgency === 'critical'
|
||||
? 'var(--color-error-600)'
|
||||
: urgency === 'warning'
|
||||
? 'var(--color-warning-600)'
|
||||
: 'var(--color-info-600)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h4 className="font-bold" style={{ color: 'var(--text-primary)' }}>
|
||||
{t('auto_action.title', 'Auto-action Pending')}
|
||||
</h4>
|
||||
<Badge
|
||||
variant={urgency === 'critical' ? 'error' : urgency === 'warning' ? 'warning' : 'info'}
|
||||
size="sm"
|
||||
>
|
||||
{urgency === 'critical'
|
||||
? t('auto_action.urgency.critical', 'URGENT')
|
||||
: urgency === 'warning'
|
||||
? t('auto_action.urgency.warning', 'SOON')
|
||||
: t('auto_action.urgency.info', 'SCHEDULED')}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>
|
||||
{actionDescription}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Countdown Timer */}
|
||||
<div className="text-center flex-shrink-0">
|
||||
<div
|
||||
className="text-2xl font-bold tabular-nums"
|
||||
style={{
|
||||
color:
|
||||
urgency === 'critical'
|
||||
? 'var(--color-error-600)'
|
||||
: urgency === 'warning'
|
||||
? 'var(--color-warning-600)'
|
||||
: 'var(--color-info-600)',
|
||||
}}
|
||||
>
|
||||
{formatTime(timeRemaining)}
|
||||
</div>
|
||||
<div className="text-xs font-medium" style={{ color: 'var(--text-tertiary)' }}>
|
||||
{t('auto_action.remaining', 'remaining')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress Bar */}
|
||||
<div className="mb-3 rounded-full overflow-hidden" style={{ backgroundColor: 'var(--bg-tertiary)', height: '6px' }}>
|
||||
<div
|
||||
className="h-full transition-all duration-1000 ease-linear"
|
||||
style={{
|
||||
width: `${progressPercentage}%`,
|
||||
backgroundColor:
|
||||
urgency === 'critical'
|
||||
? 'var(--color-error-500)'
|
||||
: urgency === 'warning'
|
||||
? 'var(--color-warning-500)'
|
||||
: 'var(--color-info-500)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Financial Impact & Cancel Button */}
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
{financialImpactEur !== undefined && financialImpactEur > 0 && (
|
||||
<div className="text-sm">
|
||||
<span className="font-medium" style={{ color: 'var(--text-secondary)' }}>
|
||||
{t('auto_action.financial_impact', 'Impact:')}
|
||||
</span>{' '}
|
||||
<span className="font-bold" style={{ color: 'var(--text-primary)' }}>
|
||||
€{financialImpactEur.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleCancel}
|
||||
disabled={isCancelling}
|
||||
className="ml-auto bg-white/80 hover:bg-white flex items-center gap-2"
|
||||
>
|
||||
<XCircle className="w-4 h-4" />
|
||||
{isCancelling
|
||||
? t('auto_action.cancelling', 'Cancelling...')
|
||||
: t('auto_action.cancel_button', 'Cancel Auto-action')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Help Text */}
|
||||
<div
|
||||
className="mt-3 text-xs p-2 rounded"
|
||||
style={{
|
||||
backgroundColor: 'var(--bg-primary)',
|
||||
color: 'var(--text-tertiary)',
|
||||
}}
|
||||
>
|
||||
{t(
|
||||
'auto_action.help_text',
|
||||
'AI will automatically execute this action when the timer expires. Click "Cancel" to prevent it and handle manually.'
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,298 +0,0 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useCurrentTenant } from '../../../stores/tenant.store';
|
||||
import { useIngredients } from '../../../api/hooks/inventory';
|
||||
import { useSuppliers } from '../../../api/hooks/suppliers';
|
||||
import { useRecipes } from '../../../api/hooks/recipes';
|
||||
import { useQualityTemplates } from '../../../api/hooks/qualityTemplates';
|
||||
import { CheckCircle2, Circle, AlertCircle, ChevronRight, Package, Users, BookOpen, Shield } from 'lucide-react';
|
||||
|
||||
interface ConfigurationSection {
|
||||
id: string;
|
||||
title: string;
|
||||
icon: React.ElementType;
|
||||
path: string;
|
||||
count: number;
|
||||
minimum: number;
|
||||
recommended: number;
|
||||
isOptional?: boolean;
|
||||
isComplete: boolean;
|
||||
nextAction?: string;
|
||||
}
|
||||
|
||||
export const ConfigurationProgressWidget: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const currentTenant = useCurrentTenant();
|
||||
const tenantId = currentTenant?.id || '';
|
||||
|
||||
// Fetch configuration data
|
||||
const { data: ingredients = [], isLoading: loadingIngredients } = useIngredients(tenantId, {}, { enabled: !!tenantId });
|
||||
const { data: suppliersData, isLoading: loadingSuppliers } = useSuppliers(tenantId, { enabled: !!tenantId });
|
||||
const suppliers = suppliersData?.suppliers || [];
|
||||
const { data: recipesData, isLoading: loadingRecipes } = useRecipes(tenantId, { enabled: !!tenantId });
|
||||
const recipes = recipesData?.recipes || [];
|
||||
const { data: qualityData, isLoading: loadingQuality } = useQualityTemplates(tenantId, { enabled: !!tenantId });
|
||||
const qualityTemplates = qualityData?.templates || [];
|
||||
|
||||
const isLoading = loadingIngredients || loadingSuppliers || loadingRecipes || loadingQuality;
|
||||
|
||||
// Calculate configuration sections
|
||||
const sections: ConfigurationSection[] = useMemo(() => [
|
||||
{
|
||||
id: 'inventory',
|
||||
title: t('dashboard:config.inventory', 'Inventory'),
|
||||
icon: Package,
|
||||
path: '/app/operations/inventory',
|
||||
count: ingredients.length,
|
||||
minimum: 3,
|
||||
recommended: 10,
|
||||
isComplete: ingredients.length >= 3,
|
||||
nextAction: ingredients.length < 3 ? t('dashboard:config.add_ingredients', 'Add at least {{count}} ingredients', { count: 3 - ingredients.length }) : undefined
|
||||
},
|
||||
{
|
||||
id: 'suppliers',
|
||||
title: t('dashboard:config.suppliers', 'Suppliers'),
|
||||
icon: Users,
|
||||
path: '/app/operations/suppliers',
|
||||
count: suppliers.length,
|
||||
minimum: 1,
|
||||
recommended: 3,
|
||||
isComplete: suppliers.length >= 1,
|
||||
nextAction: suppliers.length < 1 ? t('dashboard:config.add_supplier', 'Add your first supplier') : undefined
|
||||
},
|
||||
{
|
||||
id: 'recipes',
|
||||
title: t('dashboard:config.recipes', 'Recipes'),
|
||||
icon: BookOpen,
|
||||
path: '/app/operations/recipes',
|
||||
count: recipes.length,
|
||||
minimum: 1,
|
||||
recommended: 3,
|
||||
isComplete: recipes.length >= 1,
|
||||
nextAction: recipes.length < 1 ? t('dashboard:config.add_recipe', 'Create your first recipe') : undefined
|
||||
},
|
||||
{
|
||||
id: 'quality',
|
||||
title: t('dashboard:config.quality', 'Quality Standards'),
|
||||
icon: Shield,
|
||||
path: '/app/operations/production/quality',
|
||||
count: qualityTemplates.length,
|
||||
minimum: 0,
|
||||
recommended: 2,
|
||||
isOptional: true,
|
||||
isComplete: true, // Optional, so always "complete"
|
||||
nextAction: qualityTemplates.length < 2 ? t('dashboard:config.add_quality', 'Add quality checks (optional)') : undefined
|
||||
}
|
||||
], [ingredients.length, suppliers.length, recipes.length, qualityTemplates.length, t]);
|
||||
|
||||
// Calculate overall progress
|
||||
const { completedSections, totalSections, progressPercentage, nextIncompleteSection } = useMemo(() => {
|
||||
const requiredSections = sections.filter(s => !s.isOptional);
|
||||
const completed = requiredSections.filter(s => s.isComplete).length;
|
||||
const total = requiredSections.length;
|
||||
const percentage = Math.round((completed / total) * 100);
|
||||
const nextIncomplete = sections.find(s => !s.isComplete && !s.isOptional);
|
||||
|
||||
return {
|
||||
completedSections: completed,
|
||||
totalSections: total,
|
||||
progressPercentage: percentage,
|
||||
nextIncompleteSection: nextIncomplete
|
||||
};
|
||||
}, [sections]);
|
||||
|
||||
const isFullyConfigured = progressPercentage === 100;
|
||||
|
||||
// Determine unlocked features
|
||||
const unlockedFeatures = useMemo(() => {
|
||||
const features: string[] = [];
|
||||
if (ingredients.length >= 3) features.push(t('dashboard:config.features.inventory_tracking', 'Inventory Tracking'));
|
||||
if (suppliers.length >= 1 && ingredients.length >= 3) features.push(t('dashboard:config.features.purchase_orders', 'Purchase Orders'));
|
||||
if (recipes.length >= 1 && ingredients.length >= 3) features.push(t('dashboard:config.features.production_planning', 'Production Planning'));
|
||||
if (recipes.length >= 1 && ingredients.length >= 3 && suppliers.length >= 1) features.push(t('dashboard:config.features.cost_analysis', 'Cost Analysis'));
|
||||
return features;
|
||||
}, [ingredients.length, suppliers.length, recipes.length, t]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="bg-[var(--bg-primary)] border border-[var(--border-secondary)] rounded-lg p-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-[var(--color-primary)]"></div>
|
||||
<span className="text-sm text-[var(--text-secondary)]">{t('common:loading', 'Loading configuration...')}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Don't show widget if fully configured
|
||||
if (isFullyConfigured) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-gradient-to-br from-[var(--bg-primary)] to-[var(--bg-secondary)] border-2 border-[var(--color-primary)]/20 rounded-xl shadow-lg overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="p-6 pb-4 border-b border-[var(--border-secondary)]">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-full bg-[var(--color-primary)]/10 flex items-center justify-center">
|
||||
<AlertCircle className="w-5 h-5 text-[var(--color-primary)]" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)]">
|
||||
🏗️ {t('dashboard:config.title', 'Complete Your Bakery Setup')}
|
||||
</h3>
|
||||
<p className="text-sm text-[var(--text-secondary)] mt-0.5">
|
||||
{t('dashboard:config.subtitle', 'Configure essential features to get started')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress Bar */}
|
||||
<div className="mt-4">
|
||||
<div className="flex items-center justify-between text-sm mb-2">
|
||||
<span className="font-medium text-[var(--text-primary)]">
|
||||
{completedSections}/{totalSections} {t('dashboard:config.sections_complete', 'sections complete')}
|
||||
</span>
|
||||
<span className="text-[var(--color-primary)] font-bold">{progressPercentage}%</span>
|
||||
</div>
|
||||
<div className="w-full h-2.5 bg-[var(--bg-tertiary)] rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-[var(--color-primary)] to-[var(--color-success)] transition-all duration-500 ease-out"
|
||||
style={{ width: `${progressPercentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sections List */}
|
||||
<div className="p-6 pt-4 space-y-3">
|
||||
{sections.map((section) => {
|
||||
const Icon = section.icon;
|
||||
const meetsRecommended = section.count >= section.recommended;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={section.id}
|
||||
onClick={() => navigate(section.path)}
|
||||
className={`w-full p-4 rounded-lg border-2 transition-all duration-200 text-left group ${
|
||||
section.isComplete
|
||||
? 'border-[var(--color-success)]/30 bg-[var(--color-success)]/5 hover:bg-[var(--color-success)]/10'
|
||||
: 'border-[var(--border-secondary)] hover:border-[var(--color-primary)] hover:bg-[var(--bg-secondary)]'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
{/* Status Icon */}
|
||||
<div className={`flex-shrink-0 ${
|
||||
section.isComplete
|
||||
? 'text-[var(--color-success)]'
|
||||
: 'text-[var(--text-tertiary)]'
|
||||
}`}>
|
||||
{section.isComplete ? (
|
||||
<CheckCircle2 className="w-5 h-5" />
|
||||
) : (
|
||||
<Circle className="w-5 h-5" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Section Icon */}
|
||||
<div className={`w-10 h-10 rounded-full flex items-center justify-center ${
|
||||
section.isComplete
|
||||
? 'bg-[var(--color-success)]/10 text-[var(--color-success)]'
|
||||
: 'bg-[var(--bg-tertiary)] text-[var(--text-secondary)]'
|
||||
}`}>
|
||||
<Icon className="w-5 h-5" />
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h4 className="font-semibold text-[var(--text-primary)]">{section.title}</h4>
|
||||
{section.isOptional && (
|
||||
<span className="text-xs px-2 py-0.5 bg-[var(--bg-tertiary)] text-[var(--text-tertiary)] rounded-full">
|
||||
{t('common:optional', 'Optional')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<span className={`font-medium ${
|
||||
section.isComplete
|
||||
? 'text-[var(--color-success)]'
|
||||
: 'text-[var(--text-secondary)]'
|
||||
}`}>
|
||||
{section.count} {t('dashboard:config.added', 'added')}
|
||||
</span>
|
||||
|
||||
{!section.isComplete && section.nextAction && (
|
||||
<span className="text-[var(--text-tertiary)]">
|
||||
• {section.nextAction}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{section.isComplete && !meetsRecommended && (
|
||||
<span className="text-[var(--text-tertiary)]">
|
||||
• {section.recommended} {t('dashboard:config.recommended', 'recommended')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Chevron */}
|
||||
<ChevronRight className="w-5 h-5 text-[var(--text-tertiary)] group-hover:text-[var(--color-primary)] transition-colors flex-shrink-0" />
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Next Action / Unlocked Features */}
|
||||
<div className="px-6 pb-6">
|
||||
{nextIncompleteSection ? (
|
||||
<div className="p-4 bg-[var(--color-warning)]/10 border border-[var(--color-warning)]/30 rounded-lg">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertCircle className="w-5 h-5 text-[var(--color-warning)] flex-shrink-0 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-[var(--text-primary)] mb-1">
|
||||
👉 {t('dashboard:config.next_step', 'Next Step')}
|
||||
</p>
|
||||
<p className="text-sm text-[var(--text-secondary)] mb-3">
|
||||
{nextIncompleteSection.nextAction}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => navigate(nextIncompleteSection.path)}
|
||||
className="px-4 py-2 bg-[var(--color-primary)] text-white rounded-lg hover:bg-[var(--color-primary-dark)] transition-colors text-sm font-medium inline-flex items-center gap-2"
|
||||
>
|
||||
{t('dashboard:config.configure', 'Configure')} {nextIncompleteSection.title}
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : unlockedFeatures.length > 0 && (
|
||||
<div className="p-4 bg-[var(--color-success)]/10 border border-[var(--color-success)]/30 rounded-lg">
|
||||
<div className="flex items-start gap-3">
|
||||
<CheckCircle2 className="w-5 h-5 text-[var(--color-success)] flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--text-primary)] mb-2">
|
||||
🎉 {t('dashboard:config.features_unlocked', 'Features Unlocked!')}
|
||||
</p>
|
||||
<ul className="space-y-1">
|
||||
{unlockedFeatures.map((feature, idx) => (
|
||||
<li key={idx} className="text-sm text-[var(--text-secondary)] flex items-center gap-2">
|
||||
<CheckCircle2 className="w-3 h-3 text-[var(--color-success)]" />
|
||||
{feature}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
83
frontend/src/components/domain/dashboard/PriorityBadge.tsx
Normal file
83
frontend/src/components/domain/dashboard/PriorityBadge.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import React from 'react';
|
||||
import { cn } from '../../../lib/utils';
|
||||
|
||||
interface PriorityBadgeProps {
|
||||
score: number; // 0-100
|
||||
level: 'critical' | 'important' | 'standard' | 'info';
|
||||
className?: string;
|
||||
showScore?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* PriorityBadge - Visual indicator for alert priority
|
||||
*
|
||||
* Priority Levels:
|
||||
* - Critical (90-100): Red with pulse animation
|
||||
* - Important (70-89): Orange/Yellow
|
||||
* - Standard (50-69): Blue
|
||||
* - Info (0-49): Gray
|
||||
*/
|
||||
export const PriorityBadge: React.FC<PriorityBadgeProps> = ({
|
||||
score,
|
||||
level,
|
||||
className,
|
||||
showScore = true,
|
||||
}) => {
|
||||
const getStyles = () => {
|
||||
switch (level) {
|
||||
case 'critical':
|
||||
return {
|
||||
bg: 'bg-red-100 dark:bg-red-900/30',
|
||||
text: 'text-red-800 dark:text-red-200',
|
||||
border: 'border-red-300 dark:border-red-700',
|
||||
pulse: 'animate-pulse',
|
||||
};
|
||||
case 'important':
|
||||
return {
|
||||
bg: 'bg-orange-100 dark:bg-orange-900/30',
|
||||
text: 'text-orange-800 dark:text-orange-200',
|
||||
border: 'border-orange-300 dark:border-orange-700',
|
||||
pulse: '',
|
||||
};
|
||||
case 'standard':
|
||||
return {
|
||||
bg: 'bg-blue-100 dark:bg-blue-900/30',
|
||||
text: 'text-blue-800 dark:text-blue-200',
|
||||
border: 'border-blue-300 dark:border-blue-700',
|
||||
pulse: '',
|
||||
};
|
||||
case 'info':
|
||||
default:
|
||||
return {
|
||||
bg: 'bg-gray-100 dark:bg-gray-800',
|
||||
text: 'text-gray-700 dark:text-gray-300',
|
||||
border: 'border-gray-300 dark:border-gray-600',
|
||||
pulse: '',
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const styles = getStyles();
|
||||
const displayText = level.charAt(0).toUpperCase() + level.slice(1);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium border',
|
||||
styles.bg,
|
||||
styles.text,
|
||||
styles.border,
|
||||
styles.pulse,
|
||||
className
|
||||
)}
|
||||
title={`Puntuación de prioridad: ${score}/100`}
|
||||
>
|
||||
<span>{displayText}</span>
|
||||
{showScore && (
|
||||
<span className="font-bold">{score}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PriorityBadge;
|
||||
@@ -0,0 +1,358 @@
|
||||
// ================================================================
|
||||
// frontend/src/components/domain/dashboard/PriorityScoreExplainerModal.tsx
|
||||
// ================================================================
|
||||
/**
|
||||
* Priority Score Explainer Modal
|
||||
*
|
||||
* Educational modal explaining the 0-100 priority scoring algorithm.
|
||||
* Builds trust by showing transparency into AI decision-making.
|
||||
*
|
||||
* Shows:
|
||||
* - Overall priority score breakdown
|
||||
* - 4 weighted components (Business Impact 40%, Urgency 30%, Agency 20%, Confidence 10%)
|
||||
* - Example calculations
|
||||
* - Visual progress bars for each component
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { X, TrendingUp, DollarSign, Clock, Target, Brain, Info } from 'lucide-react';
|
||||
import { Button } from '../../ui/Button';
|
||||
import { Badge } from '../../ui/Badge';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export interface PriorityScoreExplainerModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
exampleScore?: number;
|
||||
exampleBreakdown?: {
|
||||
businessImpact: number;
|
||||
urgency: number;
|
||||
agency: number;
|
||||
confidence: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface ScoreComponent {
|
||||
name: string;
|
||||
icon: typeof DollarSign;
|
||||
weight: number;
|
||||
description: string;
|
||||
examples: string[];
|
||||
color: string;
|
||||
}
|
||||
|
||||
export function PriorityScoreExplainerModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
exampleScore,
|
||||
exampleBreakdown,
|
||||
}: PriorityScoreExplainerModalProps) {
|
||||
const { t } = useTranslation('alerts');
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const components: ScoreComponent[] = [
|
||||
{
|
||||
name: t('priority_explainer.business_impact.name', 'Business Impact'),
|
||||
icon: DollarSign,
|
||||
weight: 40,
|
||||
description: t(
|
||||
'priority_explainer.business_impact.description',
|
||||
'Financial consequences, affected orders, customer satisfaction'
|
||||
),
|
||||
examples: [
|
||||
t('priority_explainer.business_impact.example1', '€500 in potential revenue at risk'),
|
||||
t('priority_explainer.business_impact.example2', '10 customer orders affected'),
|
||||
t('priority_explainer.business_impact.example3', 'High customer satisfaction impact'),
|
||||
],
|
||||
color: 'var(--color-error-500)',
|
||||
},
|
||||
{
|
||||
name: t('priority_explainer.urgency.name', 'Urgency'),
|
||||
icon: Clock,
|
||||
weight: 30,
|
||||
description: t(
|
||||
'priority_explainer.urgency.description',
|
||||
'Time sensitivity, deadlines, escalation potential'
|
||||
),
|
||||
examples: [
|
||||
t('priority_explainer.urgency.example1', 'Deadline in 2 hours'),
|
||||
t('priority_explainer.urgency.example2', 'Stockout imminent (4 hours)'),
|
||||
t('priority_explainer.urgency.example3', 'Production window closing soon'),
|
||||
],
|
||||
color: 'var(--color-warning-500)',
|
||||
},
|
||||
{
|
||||
name: t('priority_explainer.agency.name', 'User Agency'),
|
||||
icon: Target,
|
||||
weight: 20,
|
||||
description: t(
|
||||
'priority_explainer.agency.description',
|
||||
'Can you take action? Do you have control over the outcome?'
|
||||
),
|
||||
examples: [
|
||||
t('priority_explainer.agency.example1', 'Requires approval from you'),
|
||||
t('priority_explainer.agency.example2', 'One-click action available'),
|
||||
t('priority_explainer.agency.example3', 'Decision needed within your authority'),
|
||||
],
|
||||
color: 'var(--color-primary-500)',
|
||||
},
|
||||
{
|
||||
name: t('priority_explainer.confidence.name', 'AI Confidence'),
|
||||
icon: Brain,
|
||||
weight: 10,
|
||||
description: t(
|
||||
'priority_explainer.confidence.description',
|
||||
"How certain is the AI about this alert's validity?"
|
||||
),
|
||||
examples: [
|
||||
t('priority_explainer.confidence.example1', 'Based on historical patterns (95% match)'),
|
||||
t('priority_explainer.confidence.example2', 'Data quality: High'),
|
||||
t('priority_explainer.confidence.example3', 'Prediction accuracy validated'),
|
||||
],
|
||||
color: 'var(--color-info-500)',
|
||||
},
|
||||
];
|
||||
|
||||
const calculateExampleScore = (breakdown?: typeof exampleBreakdown): number => {
|
||||
if (!breakdown) return 75; // Default example
|
||||
|
||||
return (
|
||||
(breakdown.businessImpact * 0.4) +
|
||||
(breakdown.urgency * 0.3) +
|
||||
(breakdown.agency * 0.2) +
|
||||
(breakdown.confidence * 0.1)
|
||||
);
|
||||
};
|
||||
|
||||
const displayScore = exampleScore ?? calculateExampleScore(exampleBreakdown);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm animate-in fade-in duration-200"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="bg-[var(--bg-primary)] rounded-xl shadow-2xl max-w-3xl w-full max-h-[90vh] overflow-y-auto"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
className="sticky top-0 z-10 flex items-center justify-between p-6 border-b"
|
||||
style={{
|
||||
backgroundColor: 'var(--bg-primary)',
|
||||
borderColor: 'var(--border-primary)',
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className="p-2 rounded-lg"
|
||||
style={{ backgroundColor: 'var(--color-primary-100)' }}
|
||||
>
|
||||
<TrendingUp className="w-6 h-6" style={{ color: 'var(--color-primary-600)' }} />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold" style={{ color: 'var(--text-primary)' }}>
|
||||
{t('priority_explainer.title', 'Understanding Priority Scores')}
|
||||
</h2>
|
||||
<p className="text-sm mt-1" style={{ color: 'var(--text-secondary)' }}>
|
||||
{t('priority_explainer.subtitle', 'How AI calculates what needs your attention first')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 rounded-lg hover:bg-[var(--bg-secondary)] transition-colors"
|
||||
aria-label="Close"
|
||||
>
|
||||
<X className="w-5 h-5" style={{ color: 'var(--text-secondary)' }} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Overview */}
|
||||
<div
|
||||
className="rounded-lg p-5 border"
|
||||
style={{
|
||||
backgroundColor: 'var(--bg-secondary)',
|
||||
borderColor: 'var(--border-primary)',
|
||||
}}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<Info className="w-5 h-5 mt-0.5 flex-shrink-0" style={{ color: 'var(--color-info)' }} />
|
||||
<div>
|
||||
<h3 className="font-semibold mb-2" style={{ color: 'var(--text-primary)' }}>
|
||||
{t('priority_explainer.overview.title', 'The Priority Score (0-100)')}
|
||||
</h3>
|
||||
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||
{t(
|
||||
'priority_explainer.overview.description',
|
||||
'Every alert receives a priority score from 0-100 based on four weighted components. This helps you focus on what truly matters for your bakery.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Example Score */}
|
||||
{exampleScore !== undefined && (
|
||||
<div
|
||||
className="rounded-lg p-5 border-2"
|
||||
style={{
|
||||
backgroundColor: 'var(--color-primary-50)',
|
||||
borderColor: 'var(--color-primary-300)',
|
||||
}}
|
||||
>
|
||||
<div className="text-center">
|
||||
<div className="text-sm font-medium mb-2" style={{ color: 'var(--text-secondary)' }}>
|
||||
{t('priority_explainer.example_alert', 'Example Alert Priority')}
|
||||
</div>
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
<div className="text-5xl font-bold" style={{ color: 'var(--color-primary-600)' }}>
|
||||
{displayScore.toFixed(0)}
|
||||
</div>
|
||||
<Badge variant="primary" size="lg">
|
||||
{displayScore >= 90
|
||||
? t('priority_explainer.level.critical', 'CRITICAL')
|
||||
: displayScore >= 70
|
||||
? t('priority_explainer.level.important', 'IMPORTANT')
|
||||
: displayScore >= 50
|
||||
? t('priority_explainer.level.standard', 'STANDARD')
|
||||
: t('priority_explainer.level.info', 'INFO')}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Components Breakdown */}
|
||||
<div>
|
||||
<h3 className="text-lg font-bold mb-4" style={{ color: 'var(--text-primary)' }}>
|
||||
{t('priority_explainer.components_title', 'Score Components')}
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
{components.map((component) => {
|
||||
const Icon = component.icon;
|
||||
const componentValue = exampleBreakdown
|
||||
? exampleBreakdown[
|
||||
component.name.toLowerCase().replace(/\s+/g, '') as keyof typeof exampleBreakdown
|
||||
] || 75
|
||||
: 75;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={component.name}
|
||||
className="rounded-lg p-4 border"
|
||||
style={{
|
||||
backgroundColor: 'var(--bg-secondary)',
|
||||
borderColor: 'var(--border-primary)',
|
||||
}}
|
||||
>
|
||||
{/* Component Header */}
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className="p-2 rounded-lg"
|
||||
style={{ backgroundColor: component.color + '20' }}
|
||||
>
|
||||
<Icon className="w-5 h-5" style={{ color: component.color }} />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-semibold" style={{ color: 'var(--text-primary)' }}>
|
||||
{component.name}
|
||||
</h4>
|
||||
<p className="text-xs" style={{ color: 'var(--text-tertiary)' }}>
|
||||
{component.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant="secondary" size="sm">
|
||||
{component.weight}% {t('priority_explainer.weight', 'weight')}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Progress Bar */}
|
||||
<div className="mb-3">
|
||||
<div
|
||||
className="h-2 rounded-full overflow-hidden"
|
||||
style={{ backgroundColor: 'var(--bg-tertiary)' }}
|
||||
>
|
||||
<div
|
||||
className="h-full transition-all duration-500"
|
||||
style={{
|
||||
width: `${componentValue}%`,
|
||||
backgroundColor: component.color,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-between text-xs mt-1">
|
||||
<span style={{ color: 'var(--text-tertiary)' }}>0</span>
|
||||
<span className="font-semibold" style={{ color: component.color }}>
|
||||
{componentValue}/100
|
||||
</span>
|
||||
<span style={{ color: 'var(--text-tertiary)' }}>100</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Examples */}
|
||||
<div className="space-y-1">
|
||||
{component.examples.map((example, idx) => (
|
||||
<div key={idx} className="flex items-start gap-2 text-xs">
|
||||
<span style={{ color: component.color }}>•</span>
|
||||
<span style={{ color: 'var(--text-secondary)' }}>{example}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Formula */}
|
||||
<div
|
||||
className="rounded-lg p-5 border"
|
||||
style={{
|
||||
backgroundColor: 'var(--bg-tertiary)',
|
||||
borderColor: 'var(--border-secondary)',
|
||||
}}
|
||||
>
|
||||
<h4 className="font-semibold mb-3" style={{ color: 'var(--text-primary)' }}>
|
||||
{t('priority_explainer.formula_title', 'The Formula')}
|
||||
</h4>
|
||||
<div
|
||||
className="font-mono text-sm p-3 rounded"
|
||||
style={{
|
||||
backgroundColor: 'var(--bg-primary)',
|
||||
color: 'var(--text-primary)',
|
||||
}}
|
||||
>
|
||||
Priority Score = (Business Impact × 0.40) + (Urgency × 0.30) + (User Agency × 0.20) + (AI Confidence ×
|
||||
0.10)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div
|
||||
className="sticky bottom-0 flex items-center justify-between p-6 border-t"
|
||||
style={{
|
||||
backgroundColor: 'var(--bg-primary)',
|
||||
borderColor: 'var(--border-primary)',
|
||||
}}
|
||||
>
|
||||
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||
{t(
|
||||
'priority_explainer.footer',
|
||||
'This scoring helps AI prioritize alerts, ensuring you see the most important issues first.'
|
||||
)}
|
||||
</p>
|
||||
<Button onClick={onClose} variant="primary">
|
||||
{t('priority_explainer.got_it', 'Got it!')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,539 +0,0 @@
|
||||
import React, { useState, useMemo, useCallback, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Card, CardHeader, CardBody } from '../../ui/Card';
|
||||
import { SeverityBadge } from '../../ui/Badge';
|
||||
import { Button } from '../../ui/Button';
|
||||
import { useNotifications } from '../../../hooks/useNotifications';
|
||||
import { useAlertFilters } from '../../../hooks/useAlertFilters';
|
||||
import { useAlertGrouping, type GroupingMode } from '../../../hooks/useAlertGrouping';
|
||||
import { useAlertAnalytics, useAlertAnalyticsTracking } from '../../../hooks/useAlertAnalytics';
|
||||
import { useKeyboardNavigation } from '../../../hooks/useKeyboardNavigation';
|
||||
import { filterAlerts, getAlertStatistics, getTimeGroup } from '../../../utils/alertHelpers';
|
||||
import {
|
||||
Bell,
|
||||
Wifi,
|
||||
WifiOff,
|
||||
CheckCircle,
|
||||
BarChart3,
|
||||
AlertTriangle,
|
||||
AlertCircle,
|
||||
Clock,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
} from 'lucide-react';
|
||||
import AlertFilters from './AlertFilters';
|
||||
import AlertGroupHeader from './AlertGroupHeader';
|
||||
import AlertCard from './AlertCard';
|
||||
import AlertTrends from './AlertTrends';
|
||||
import AlertBulkActions from './AlertBulkActions';
|
||||
|
||||
export interface RealTimeAlertsProps {
|
||||
className?: string;
|
||||
maxAlerts?: number;
|
||||
showAnalytics?: boolean;
|
||||
showGrouping?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* RealTimeAlerts - Dashboard component for displaying today's active alerts
|
||||
*
|
||||
* IMPORTANT: This component shows ONLY TODAY'S alerts (from 00:00 UTC today onwards)
|
||||
* to prevent flooding the dashboard with historical data.
|
||||
*
|
||||
* For historical alert data, use the Analytics panel or API endpoints:
|
||||
* - showAnalytics=true: Shows AlertTrends component with historical data (7 days, 30 days, etc.)
|
||||
* - API: /api/v1/tenants/{tenant_id}/alerts/analytics for historical analytics
|
||||
*
|
||||
* Alert scopes across the application:
|
||||
* - Dashboard (this component): TODAY'S alerts only
|
||||
* - Notification Bell: Last 24 hours
|
||||
* - Analytics Panel: Historical data (configurable: 7 days, 30 days, etc.)
|
||||
* - localStorage: Auto-cleanup of alerts >24h old on load
|
||||
* - Redis cache (initial_items): TODAY'S alerts only
|
||||
*/
|
||||
const RealTimeAlerts: React.FC<RealTimeAlertsProps> = ({
|
||||
className,
|
||||
maxAlerts = 50,
|
||||
showAnalytics = true,
|
||||
showGrouping = true,
|
||||
}) => {
|
||||
const { t } = useTranslation(['dashboard']);
|
||||
const [expandedAlerts, setExpandedAlerts] = useState<Set<string>>(new Set());
|
||||
const [selectedAlerts, setSelectedAlerts] = useState<Set<string>>(new Set());
|
||||
const [showBulkActions, setShowBulkActions] = useState(false);
|
||||
const [showAnalyticsPanel, setShowAnalyticsPanel] = useState(false);
|
||||
|
||||
// Pagination state
|
||||
const ALERTS_PER_PAGE = 3;
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
|
||||
const {
|
||||
notifications,
|
||||
isConnected,
|
||||
markAsRead,
|
||||
removeNotification,
|
||||
snoozeAlert,
|
||||
unsnoozeAlert,
|
||||
isAlertSnoozed,
|
||||
snoozedAlerts,
|
||||
markMultipleAsRead,
|
||||
removeMultiple,
|
||||
snoozeMultiple,
|
||||
} = useNotifications();
|
||||
|
||||
const {
|
||||
filters,
|
||||
toggleSeverity,
|
||||
toggleCategory,
|
||||
setTimeRange,
|
||||
setSearch,
|
||||
toggleShowSnoozed,
|
||||
clearFilters,
|
||||
hasActiveFilters,
|
||||
activeFilterCount,
|
||||
} = useAlertFilters();
|
||||
|
||||
// Dashboard shows only TODAY's alerts
|
||||
// Analytics panel shows historical data (configured separately)
|
||||
const filteredNotifications = useMemo(() => {
|
||||
// Filter to today's alerts only for dashboard display
|
||||
// This prevents showing yesterday's or older alerts on the main dashboard
|
||||
const todayAlerts = notifications.filter(alert => {
|
||||
const timeGroup = getTimeGroup(alert.timestamp);
|
||||
return timeGroup === 'today';
|
||||
});
|
||||
|
||||
return filterAlerts(todayAlerts, filters, snoozedAlerts).slice(0, maxAlerts);
|
||||
}, [notifications, filters, snoozedAlerts, maxAlerts]);
|
||||
|
||||
const {
|
||||
groupedAlerts,
|
||||
groupingMode,
|
||||
setGroupingMode,
|
||||
toggleGroupCollapse,
|
||||
isGroupCollapsed,
|
||||
} = useAlertGrouping(filteredNotifications, 'time');
|
||||
|
||||
const analytics = useAlertAnalytics(notifications);
|
||||
const { trackAcknowledgment, trackResolution } = useAlertAnalyticsTracking();
|
||||
|
||||
const stats = useMemo(() => {
|
||||
return getAlertStatistics(filteredNotifications, snoozedAlerts);
|
||||
}, [filteredNotifications, snoozedAlerts]);
|
||||
|
||||
const flatAlerts = useMemo(() => {
|
||||
return groupedAlerts.flatMap(group =>
|
||||
isGroupCollapsed(group.id) ? [] : group.alerts
|
||||
);
|
||||
}, [groupedAlerts, isGroupCollapsed]);
|
||||
|
||||
// Reset pagination when filters change
|
||||
useEffect(() => {
|
||||
setCurrentPage(1);
|
||||
}, [filters, groupingMode]);
|
||||
|
||||
// Pagination calculations
|
||||
const totalAlerts = flatAlerts.length;
|
||||
const totalPages = Math.ceil(totalAlerts / ALERTS_PER_PAGE);
|
||||
const startIndex = (currentPage - 1) * ALERTS_PER_PAGE;
|
||||
const endIndex = startIndex + ALERTS_PER_PAGE;
|
||||
|
||||
// Paginated alerts - slice the flat alerts for current page
|
||||
const paginatedAlerts = useMemo(() => {
|
||||
const alertsToShow = flatAlerts.slice(startIndex, endIndex);
|
||||
const alertIds = new Set(alertsToShow.map(a => a.id));
|
||||
|
||||
// Filter groups to only show alerts on current page
|
||||
return groupedAlerts
|
||||
.map(group => ({
|
||||
...group,
|
||||
alerts: group.alerts.filter(alert => alertIds.has(alert.id)),
|
||||
count: group.alerts.filter(alert => alertIds.has(alert.id)).length,
|
||||
}))
|
||||
.filter(group => group.alerts.length > 0);
|
||||
}, [groupedAlerts, flatAlerts, startIndex, endIndex]);
|
||||
|
||||
const { focusedIndex } = useKeyboardNavigation(
|
||||
flatAlerts.length,
|
||||
{
|
||||
onMoveUp: () => {},
|
||||
onMoveDown: () => {},
|
||||
onSelect: () => {
|
||||
if (flatAlerts[focusedIndex]) {
|
||||
toggleAlertSelection(flatAlerts[focusedIndex].id);
|
||||
}
|
||||
},
|
||||
onExpand: () => {
|
||||
if (flatAlerts[focusedIndex]) {
|
||||
toggleAlertExpansion(flatAlerts[focusedIndex].id);
|
||||
}
|
||||
},
|
||||
onMarkAsRead: () => {
|
||||
if (flatAlerts[focusedIndex]) {
|
||||
handleMarkAsRead(flatAlerts[focusedIndex].id);
|
||||
}
|
||||
},
|
||||
onDismiss: () => {
|
||||
if (flatAlerts[focusedIndex]) {
|
||||
handleRemoveAlert(flatAlerts[focusedIndex].id);
|
||||
}
|
||||
},
|
||||
onSnooze: () => {
|
||||
if (flatAlerts[focusedIndex]) {
|
||||
handleSnoozeAlert(flatAlerts[focusedIndex].id, '1hr');
|
||||
}
|
||||
},
|
||||
onEscape: () => {
|
||||
setExpandedAlerts(new Set());
|
||||
setSelectedAlerts(new Set());
|
||||
},
|
||||
onSelectAll: () => {
|
||||
handleSelectAll();
|
||||
},
|
||||
onSearch: () => {},
|
||||
},
|
||||
true
|
||||
);
|
||||
|
||||
const toggleAlertExpansion = useCallback((alertId: string) => {
|
||||
setExpandedAlerts(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(alertId)) {
|
||||
next.delete(alertId);
|
||||
} else {
|
||||
next.add(alertId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const toggleAlertSelection = useCallback((alertId: string) => {
|
||||
setSelectedAlerts(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(alertId)) {
|
||||
next.delete(alertId);
|
||||
} else {
|
||||
next.add(alertId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleMarkAsRead = useCallback((alertId: string) => {
|
||||
markAsRead(alertId);
|
||||
trackAcknowledgment(alertId).catch(err =>
|
||||
console.error('Failed to track acknowledgment:', err)
|
||||
);
|
||||
}, [markAsRead, trackAcknowledgment]);
|
||||
|
||||
const handleRemoveAlert = useCallback((alertId: string) => {
|
||||
removeNotification(alertId);
|
||||
trackResolution(alertId).catch(err =>
|
||||
console.error('Failed to track resolution:', err)
|
||||
);
|
||||
setExpandedAlerts(prev => {
|
||||
const next = new Set(prev);
|
||||
next.delete(alertId);
|
||||
return next;
|
||||
});
|
||||
setSelectedAlerts(prev => {
|
||||
const next = new Set(prev);
|
||||
next.delete(alertId);
|
||||
return next;
|
||||
});
|
||||
}, [removeNotification, trackResolution]);
|
||||
|
||||
const handleSnoozeAlert = useCallback((alertId: string, duration: '15min' | '1hr' | '4hr' | 'tomorrow' | number) => {
|
||||
snoozeAlert(alertId, duration);
|
||||
}, [snoozeAlert]);
|
||||
|
||||
const handleUnsnoozeAlert = useCallback((alertId: string) => {
|
||||
unsnoozeAlert(alertId);
|
||||
}, [unsnoozeAlert]);
|
||||
|
||||
const handleSelectAll = useCallback(() => {
|
||||
setSelectedAlerts(new Set(flatAlerts.map(a => a.id)));
|
||||
setShowBulkActions(true);
|
||||
}, [flatAlerts]);
|
||||
|
||||
const handleDeselectAll = useCallback(() => {
|
||||
setSelectedAlerts(new Set());
|
||||
setShowBulkActions(false);
|
||||
}, []);
|
||||
|
||||
const handleBulkMarkAsRead = useCallback(() => {
|
||||
const ids = Array.from(selectedAlerts);
|
||||
markMultipleAsRead(ids);
|
||||
ids.forEach(id =>
|
||||
trackAcknowledgment(id).catch(err =>
|
||||
console.error('Failed to track acknowledgment:', err)
|
||||
)
|
||||
);
|
||||
handleDeselectAll();
|
||||
}, [selectedAlerts, markMultipleAsRead, trackAcknowledgment, handleDeselectAll]);
|
||||
|
||||
const handleBulkRemove = useCallback(() => {
|
||||
const ids = Array.from(selectedAlerts);
|
||||
removeMultiple(ids);
|
||||
ids.forEach(id =>
|
||||
trackResolution(id).catch(err =>
|
||||
console.error('Failed to track resolution:', err)
|
||||
)
|
||||
);
|
||||
handleDeselectAll();
|
||||
}, [selectedAlerts, removeMultiple, trackResolution, handleDeselectAll]);
|
||||
|
||||
const handleBulkSnooze = useCallback((duration: '15min' | '1hr' | '4hr' | 'tomorrow' | number) => {
|
||||
const ids = Array.from(selectedAlerts);
|
||||
snoozeMultiple(ids, duration);
|
||||
handleDeselectAll();
|
||||
}, [selectedAlerts, snoozeMultiple, handleDeselectAll]);
|
||||
|
||||
const activeAlerts = filteredNotifications.filter(a => a.status !== 'acknowledged' && !isAlertSnoozed(a.id));
|
||||
const urgentCount = activeAlerts.filter(a => a.severity === 'urgent').length;
|
||||
const highCount = activeAlerts.filter(a => a.severity === 'high').length;
|
||||
|
||||
return (
|
||||
<Card className={className} variant="elevated" padding="none">
|
||||
<CardHeader padding="lg" divider>
|
||||
<div className="flex items-start sm:items-center justify-between w-full gap-4 flex-col sm:flex-row">
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className="p-2.5 rounded-xl shadow-sm flex-shrink-0"
|
||||
style={{ backgroundColor: 'var(--color-primary)15' }}
|
||||
>
|
||||
<Bell className="w-5 h-5" style={{ color: 'var(--color-primary)' }} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-bold mb-0.5" style={{ color: 'var(--text-primary)' }}>
|
||||
{t('dashboard:alerts.title', 'Alertas')}
|
||||
</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
{isConnected ? (
|
||||
<Wifi className="w-3.5 h-3.5" style={{ color: 'var(--color-success)' }} />
|
||||
) : (
|
||||
<WifiOff className="w-3.5 h-3.5" style={{ color: 'var(--color-error)' }} />
|
||||
)}
|
||||
<span className="text-xs font-medium whitespace-nowrap" style={{ color: 'var(--text-secondary)' }}>
|
||||
{isConnected
|
||||
? t('dashboard:alerts.live', 'En vivo')
|
||||
: t('dashboard:alerts.offline', 'Desconectado')
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 flex-wrap w-full sm:w-auto">
|
||||
{/* Alert count badges */}
|
||||
<div className="flex items-center gap-2">
|
||||
{urgentCount > 0 && (
|
||||
<SeverityBadge
|
||||
severity="high"
|
||||
count={urgentCount}
|
||||
size="sm"
|
||||
/>
|
||||
)}
|
||||
{highCount > 0 && (
|
||||
<SeverityBadge
|
||||
severity="medium"
|
||||
count={highCount}
|
||||
size="sm"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
{showAnalytics && (
|
||||
<Button
|
||||
variant={showAnalyticsPanel ? 'primary' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => setShowAnalyticsPanel(!showAnalyticsPanel)}
|
||||
className="h-9"
|
||||
title="Toggle analytics"
|
||||
aria-label="Toggle analytics panel"
|
||||
>
|
||||
<BarChart3 className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{showGrouping && (
|
||||
<select
|
||||
value={groupingMode}
|
||||
onChange={(e) => setGroupingMode(e.target.value as GroupingMode)}
|
||||
className="px-3 py-2 text-sm font-medium border-2 border-[var(--border-primary)] rounded-lg bg-[var(--bg-primary)] text-[var(--text-primary)] focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] focus:border-transparent transition-all cursor-pointer hover:border-[var(--color-primary)]"
|
||||
aria-label="Group alerts by"
|
||||
>
|
||||
<option value="time">⏰ Por tiempo</option>
|
||||
<option value="category">📁 Por categoría</option>
|
||||
<option value="similarity">🔗 Similares</option>
|
||||
<option value="none">📋 Sin agrupar</option>
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardBody padding="none">
|
||||
{showAnalyticsPanel && (
|
||||
<div className="p-4 border-b border-[var(--border-primary)]">
|
||||
<AlertTrends analytics={analytics} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="px-4 py-4 border-b border-[var(--border-primary)] bg-[var(--bg-secondary)]/30">
|
||||
<AlertFilters
|
||||
selectedSeverities={filters.severities}
|
||||
selectedCategories={filters.categories}
|
||||
selectedTimeRange={filters.timeRange}
|
||||
searchQuery={filters.search}
|
||||
showSnoozed={filters.showSnoozed}
|
||||
onToggleSeverity={toggleSeverity}
|
||||
onToggleCategory={toggleCategory}
|
||||
onSetTimeRange={setTimeRange}
|
||||
onSearchChange={setSearch}
|
||||
onToggleShowSnoozed={toggleShowSnoozed}
|
||||
onClearFilters={clearFilters}
|
||||
hasActiveFilters={hasActiveFilters}
|
||||
activeFilterCount={activeFilterCount}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{selectedAlerts.size > 0 && (
|
||||
<div className="p-4 border-b border-[var(--border-primary)]">
|
||||
<AlertBulkActions
|
||||
selectedCount={selectedAlerts.size}
|
||||
totalCount={flatAlerts.length}
|
||||
onMarkAsRead={handleBulkMarkAsRead}
|
||||
onRemove={handleBulkRemove}
|
||||
onSnooze={handleBulkSnooze}
|
||||
onDeselectAll={handleDeselectAll}
|
||||
onSelectAll={handleSelectAll}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filteredNotifications.length === 0 ? (
|
||||
<div className="p-12 text-center">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-[var(--color-success)]/10 mb-4">
|
||||
<CheckCircle className="w-8 h-8" style={{ color: 'var(--color-success)' }} />
|
||||
</div>
|
||||
<h4 className="text-base font-semibold mb-2" style={{ color: 'var(--text-primary)' }}>
|
||||
{hasActiveFilters ? 'Sin resultados' : 'Todo despejado'}
|
||||
</h4>
|
||||
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||
{hasActiveFilters
|
||||
? 'No hay alertas que coincidan con los filtros seleccionados'
|
||||
: t('dashboard:alerts.no_alerts', 'No hay alertas activas en este momento')
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3 p-4">
|
||||
{paginatedAlerts.map((group) => (
|
||||
<div key={group.id}>
|
||||
{(group.count > 1 || groupingMode !== 'none') && (
|
||||
<div className="mb-3">
|
||||
<AlertGroupHeader
|
||||
group={group}
|
||||
isCollapsed={isGroupCollapsed(group.id)}
|
||||
onToggleCollapse={() => toggleGroupCollapse(group.id)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isGroupCollapsed(group.id) && (
|
||||
<div className="space-y-3 ml-0">
|
||||
{group.alerts.map((alert) => (
|
||||
<AlertCard
|
||||
key={alert.id}
|
||||
alert={alert}
|
||||
isExpanded={expandedAlerts.has(alert.id)}
|
||||
isSelected={selectedAlerts.has(alert.id)}
|
||||
isSnoozed={isAlertSnoozed(alert.id)}
|
||||
snoozedUntil={snoozedAlerts.get(alert.id)?.until}
|
||||
onToggleExpand={() => toggleAlertExpansion(alert.id)}
|
||||
onToggleSelect={() => toggleAlertSelection(alert.id)}
|
||||
onMarkAsRead={() => handleMarkAsRead(alert.id)}
|
||||
onRemove={() => handleRemoveAlert(alert.id)}
|
||||
onSnooze={(duration) => handleSnoozeAlert(alert.id, duration)}
|
||||
onUnsnooze={() => handleUnsnoozeAlert(alert.id)}
|
||||
showCheckbox={showBulkActions || selectedAlerts.size > 0}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filteredNotifications.length > 0 && (
|
||||
<div
|
||||
className="px-4 py-3 border-t"
|
||||
style={{
|
||||
borderColor: 'var(--border-primary)',
|
||||
backgroundColor: 'var(--bg-secondary)/50',
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* Stats row */}
|
||||
<div className="flex items-center justify-between text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||
<span className="font-medium">
|
||||
Mostrando <span className="font-bold text-[var(--text-primary)]">{startIndex + 1}-{Math.min(endIndex, totalAlerts)}</span> de <span className="font-bold text-[var(--text-primary)]">{totalAlerts}</span> alertas
|
||||
</span>
|
||||
<div className="flex items-center gap-4">
|
||||
{stats.unread > 0 && (
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="w-2 h-2 rounded-full bg-[var(--color-info)] animate-pulse" />
|
||||
<span className="font-semibold text-[var(--text-primary)]">{stats.unread}</span> sin leer
|
||||
</span>
|
||||
)}
|
||||
{stats.snoozed > 0 && (
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Clock className="w-3.5 h-3.5" />
|
||||
<span className="font-semibold text-[var(--text-primary)]">{stats.snoozed}</span> pospuestas
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pagination controls */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage(prev => Math.max(1, prev - 1))}
|
||||
disabled={currentPage === 1}
|
||||
className="h-8 px-3"
|
||||
aria-label="Previous page"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</Button>
|
||||
|
||||
<span className="text-sm font-medium px-3" style={{ color: 'var(--text-primary)' }}>
|
||||
Página <span className="font-bold">{currentPage}</span> de <span className="font-bold">{totalPages}</span>
|
||||
</span>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage(prev => Math.min(totalPages, prev + 1))}
|
||||
disabled={currentPage === totalPages}
|
||||
className="h-8 px-3"
|
||||
aria-label="Next page"
|
||||
>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default RealTimeAlerts;
|
||||
@@ -0,0 +1,284 @@
|
||||
// ================================================================
|
||||
// frontend/src/components/domain/dashboard/SmartActionConsequencePreview.tsx
|
||||
// ================================================================
|
||||
/**
|
||||
* Smart Action Consequence Preview
|
||||
*
|
||||
* Shows "What happens if I click this?" before user takes action.
|
||||
* Displays expected outcomes, affected systems, and financial impact.
|
||||
*
|
||||
* Design: Tooltip/popover style preview with clear consequences
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { Info, CheckCircle, AlertTriangle, DollarSign, Clock, ArrowRight } from 'lucide-react';
|
||||
import { Badge } from '../../ui/Badge';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export interface ActionConsequence {
|
||||
outcome: string;
|
||||
affectedSystems?: string[];
|
||||
financialImpact?: {
|
||||
amount: number;
|
||||
currency: string;
|
||||
type: 'cost' | 'savings' | 'revenue';
|
||||
};
|
||||
timeImpact?: string;
|
||||
reversible: boolean;
|
||||
confidence?: number;
|
||||
warnings?: string[];
|
||||
}
|
||||
|
||||
export interface SmartActionConsequencePreviewProps {
|
||||
action: {
|
||||
label: string;
|
||||
actionType: string;
|
||||
};
|
||||
consequences: ActionConsequence;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
isOpen: boolean;
|
||||
triggerElement?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function SmartActionConsequencePreview({
|
||||
action,
|
||||
consequences,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
isOpen,
|
||||
triggerElement,
|
||||
}: SmartActionConsequencePreviewProps) {
|
||||
const { t } = useTranslation('alerts');
|
||||
const [showPreview, setShowPreview] = useState(isOpen);
|
||||
|
||||
const handleConfirm = () => {
|
||||
setShowPreview(false);
|
||||
onConfirm();
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setShowPreview(false);
|
||||
onCancel();
|
||||
};
|
||||
|
||||
if (!showPreview) {
|
||||
return (
|
||||
<div onClick={() => setShowPreview(true)}>
|
||||
{triggerElement}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="fixed inset-0 z-40 bg-black/20 backdrop-blur-sm animate-in fade-in duration-200"
|
||||
onClick={handleCancel}
|
||||
/>
|
||||
|
||||
{/* Preview Card */}
|
||||
<div
|
||||
className="fixed z-50 top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 w-full max-w-md animate-in zoom-in-95 duration-200"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div
|
||||
className="rounded-xl shadow-2xl border-2"
|
||||
style={{
|
||||
backgroundColor: 'var(--bg-primary)',
|
||||
borderColor: 'var(--color-primary-300)',
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
className="p-4 border-b"
|
||||
style={{
|
||||
backgroundColor: 'var(--color-primary-50)',
|
||||
borderColor: 'var(--border-primary)',
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className="p-2 rounded-lg"
|
||||
style={{ backgroundColor: 'var(--color-primary-100)' }}
|
||||
>
|
||||
<Info className="w-5 h-5" style={{ color: 'var(--color-primary-600)' }} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-bold text-lg" style={{ color: 'var(--text-primary)' }}>
|
||||
{t('action_preview.title', 'Action Preview')}
|
||||
</h3>
|
||||
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||
{action.label}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-4 space-y-4">
|
||||
{/* Expected Outcome */}
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<ArrowRight className="w-4 h-4" style={{ color: 'var(--color-primary)' }} />
|
||||
<h4 className="font-semibold text-sm" style={{ color: 'var(--text-primary)' }}>
|
||||
{t('action_preview.outcome', 'What will happen')}
|
||||
</h4>
|
||||
</div>
|
||||
<p className="text-sm pl-6" style={{ color: 'var(--text-secondary)' }}>
|
||||
{consequences.outcome}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Financial Impact */}
|
||||
{consequences.financialImpact && (
|
||||
<div
|
||||
className="rounded-lg p-3 border"
|
||||
style={{
|
||||
backgroundColor:
|
||||
consequences.financialImpact.type === 'cost'
|
||||
? 'var(--color-error-50)'
|
||||
: 'var(--color-success-50)',
|
||||
borderColor:
|
||||
consequences.financialImpact.type === 'cost'
|
||||
? 'var(--color-error-200)'
|
||||
: 'var(--color-success-200)',
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<DollarSign
|
||||
className="w-4 h-4"
|
||||
style={{
|
||||
color:
|
||||
consequences.financialImpact.type === 'cost'
|
||||
? 'var(--color-error-600)'
|
||||
: 'var(--color-success-600)',
|
||||
}}
|
||||
/>
|
||||
<span className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>
|
||||
{t('action_preview.financial_impact', 'Financial Impact')}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
className="text-lg font-bold"
|
||||
style={{
|
||||
color:
|
||||
consequences.financialImpact.type === 'cost'
|
||||
? 'var(--color-error-600)'
|
||||
: 'var(--color-success-600)',
|
||||
}}
|
||||
>
|
||||
{consequences.financialImpact.type === 'cost' ? '-' : '+'}
|
||||
{consequences.financialImpact.currency}
|
||||
{consequences.financialImpact.amount.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Affected Systems */}
|
||||
{consequences.affectedSystems && consequences.affectedSystems.length > 0 && (
|
||||
<div>
|
||||
<h4 className="font-semibold text-sm mb-2" style={{ color: 'var(--text-primary)' }}>
|
||||
{t('action_preview.affected_systems', 'Affected Systems')}
|
||||
</h4>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{consequences.affectedSystems.map((system, idx) => (
|
||||
<Badge key={idx} variant="secondary" size="sm">
|
||||
{system}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Time Impact */}
|
||||
{consequences.timeImpact && (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Clock className="w-4 h-4" style={{ color: 'var(--text-tertiary)' }} />
|
||||
<span style={{ color: 'var(--text-secondary)' }}>{consequences.timeImpact}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Reversibility */}
|
||||
<div
|
||||
className="flex items-center gap-2 text-sm p-2 rounded"
|
||||
style={{ backgroundColor: 'var(--bg-secondary)' }}
|
||||
>
|
||||
<CheckCircle
|
||||
className="w-4 h-4"
|
||||
style={{ color: consequences.reversible ? 'var(--color-success-500)' : 'var(--color-warning-500)' }}
|
||||
/>
|
||||
<span style={{ color: 'var(--text-secondary)' }}>
|
||||
{consequences.reversible
|
||||
? t('action_preview.reversible', 'This action can be undone')
|
||||
: t('action_preview.not_reversible', 'This action cannot be undone')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Warnings */}
|
||||
{consequences.warnings && consequences.warnings.length > 0 && (
|
||||
<div
|
||||
className="rounded-lg p-3 border"
|
||||
style={{
|
||||
backgroundColor: 'var(--color-warning-50)',
|
||||
borderColor: 'var(--color-warning-300)',
|
||||
}}
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertTriangle className="w-4 h-4 mt-0.5 flex-shrink-0" style={{ color: 'var(--color-warning-600)' }} />
|
||||
<div className="space-y-1">
|
||||
{consequences.warnings.map((warning, idx) => (
|
||||
<p key={idx} className="text-sm" style={{ color: 'var(--color-warning-700)' }}>
|
||||
{warning}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Confidence Score */}
|
||||
{consequences.confidence !== undefined && (
|
||||
<div className="text-xs text-center" style={{ color: 'var(--text-tertiary)' }}>
|
||||
{t('action_preview.confidence', 'AI Confidence: {{confidence}}%', {
|
||||
confidence: consequences.confidence,
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div
|
||||
className="flex items-center gap-3 p-4 border-t"
|
||||
style={{ borderColor: 'var(--border-primary)' }}
|
||||
>
|
||||
<button
|
||||
onClick={handleCancel}
|
||||
className="flex-1 px-4 py-2 rounded-lg font-medium transition-colors"
|
||||
style={{
|
||||
backgroundColor: 'var(--bg-secondary)',
|
||||
color: 'var(--text-primary)',
|
||||
border: '1px solid var(--border-primary)',
|
||||
}}
|
||||
>
|
||||
{t('action_preview.cancel', 'Cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleConfirm}
|
||||
className="flex-1 px-4 py-2 rounded-lg font-semibold transition-colors"
|
||||
style={{
|
||||
backgroundColor: 'var(--color-primary)',
|
||||
color: 'white',
|
||||
}}
|
||||
>
|
||||
{t('action_preview.confirm', 'Confirm Action')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
// ================================================================
|
||||
// frontend/src/components/domain/dashboard/TrendVisualizationComponent.tsx
|
||||
// ================================================================
|
||||
/**
|
||||
* Trend Visualization Component
|
||||
*
|
||||
* Displays visual trend indicators for TREND_WARNING type alerts.
|
||||
* Shows historical comparison with simple sparkline/arrow indicators.
|
||||
*
|
||||
* Design: Lightweight, inline trend indicators with color coding
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { TrendingUp, TrendingDown, Minus, AlertTriangle } from 'lucide-react';
|
||||
import { Badge } from '../../ui/Badge';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export interface TrendData {
|
||||
direction: 'increasing' | 'decreasing' | 'stable';
|
||||
percentageChange?: number;
|
||||
historicalComparison?: string;
|
||||
dataPoints?: number[];
|
||||
threshold?: number;
|
||||
current?: number;
|
||||
}
|
||||
|
||||
export interface TrendVisualizationProps {
|
||||
trend: TrendData;
|
||||
label?: string;
|
||||
showSparkline?: boolean;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function TrendVisualizationComponent({
|
||||
trend,
|
||||
label,
|
||||
showSparkline = false,
|
||||
size = 'md',
|
||||
className = '',
|
||||
}: TrendVisualizationProps) {
|
||||
const { t } = useTranslation('alerts');
|
||||
|
||||
const getTrendIcon = () => {
|
||||
switch (trend.direction) {
|
||||
case 'increasing':
|
||||
return TrendingUp;
|
||||
case 'decreasing':
|
||||
return TrendingDown;
|
||||
case 'stable':
|
||||
return Minus;
|
||||
}
|
||||
};
|
||||
|
||||
const getTrendColor = () => {
|
||||
switch (trend.direction) {
|
||||
case 'increasing':
|
||||
return 'var(--color-error-500)';
|
||||
case 'decreasing':
|
||||
return 'var(--color-success-500)';
|
||||
case 'stable':
|
||||
return 'var(--text-tertiary)';
|
||||
}
|
||||
};
|
||||
|
||||
const getTrendBadgeVariant = (): 'error' | 'success' | 'secondary' => {
|
||||
switch (trend.direction) {
|
||||
case 'increasing':
|
||||
return 'error';
|
||||
case 'decreasing':
|
||||
return 'success';
|
||||
case 'stable':
|
||||
return 'secondary';
|
||||
}
|
||||
};
|
||||
|
||||
const getTrendLabel = () => {
|
||||
if (trend.percentageChange !== undefined) {
|
||||
const sign = trend.percentageChange > 0 ? '+' : '';
|
||||
return `${sign}${trend.percentageChange.toFixed(1)}%`;
|
||||
}
|
||||
return trend.direction.charAt(0).toUpperCase() + trend.direction.slice(1);
|
||||
};
|
||||
|
||||
const isNearThreshold = trend.threshold !== undefined && trend.current !== undefined
|
||||
? Math.abs(trend.current - trend.threshold) / trend.threshold < 0.1
|
||||
: false;
|
||||
|
||||
const TrendIcon = getTrendIcon();
|
||||
const trendColor = getTrendColor();
|
||||
|
||||
const iconSizes = {
|
||||
sm: 'w-3 h-3',
|
||||
md: 'w-4 h-4',
|
||||
lg: 'w-5 h-5',
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`inline-flex items-center gap-2 ${className}`}>
|
||||
{/* Trend Indicator */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<TrendIcon
|
||||
className={iconSizes[size]}
|
||||
style={{ color: trendColor }}
|
||||
/>
|
||||
<Badge variant={getTrendBadgeVariant()} size={size === 'sm' ? 'sm' : 'md'}>
|
||||
{getTrendLabel()}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Optional Label */}
|
||||
{label && (
|
||||
<span
|
||||
className={`font-medium ${size === 'sm' ? 'text-xs' : size === 'lg' ? 'text-base' : 'text-sm'}`}
|
||||
style={{ color: 'var(--text-secondary)' }}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Threshold Warning */}
|
||||
{isNearThreshold && (
|
||||
<div className="flex items-center gap-1">
|
||||
<AlertTriangle className="w-3 h-3" style={{ color: 'var(--color-warning-500)' }} />
|
||||
<span className="text-xs font-medium" style={{ color: 'var(--color-warning-600)' }}>
|
||||
{t('trend.near_threshold', 'Near threshold')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sparkline (Simple Mini Chart) */}
|
||||
{showSparkline && trend.dataPoints && trend.dataPoints.length > 0 && (
|
||||
<div className="flex items-end gap-0.5 h-6">
|
||||
{trend.dataPoints.slice(-8).map((value, idx) => {
|
||||
const maxValue = Math.max(...trend.dataPoints!);
|
||||
const heightPercent = (value / maxValue) * 100;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={idx}
|
||||
className="w-1 rounded-t transition-all"
|
||||
style={{
|
||||
height: `${heightPercent}%`,
|
||||
backgroundColor: trendColor,
|
||||
opacity: 0.3 + (idx / trend.dataPoints!.length) * 0.7,
|
||||
}}
|
||||
title={`${value}`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Historical Comparison Text */}
|
||||
{trend.historicalComparison && (
|
||||
<span
|
||||
className={`${size === 'sm' ? 'text-xs' : 'text-sm'}`}
|
||||
style={{ color: 'var(--text-tertiary)' }}
|
||||
>
|
||||
{trend.historicalComparison}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact version for inline use in alert cards
|
||||
*/
|
||||
export function TrendVisualizationInline({ trend }: { trend: TrendData }) {
|
||||
const TrendIcon = trend.direction === 'increasing' ? TrendingUp
|
||||
: trend.direction === 'decreasing' ? TrendingDown
|
||||
: Minus;
|
||||
|
||||
const color = trend.direction === 'increasing' ? 'var(--color-error-500)'
|
||||
: trend.direction === 'decreasing' ? 'var(--color-success-500)'
|
||||
: 'var(--text-tertiary)';
|
||||
|
||||
return (
|
||||
<div className="inline-flex items-center gap-1">
|
||||
<TrendIcon className="w-3 h-3" style={{ color }} />
|
||||
{trend.percentageChange !== undefined && (
|
||||
<span className="text-xs font-semibold" style={{ color }}>
|
||||
{trend.percentageChange > 0 ? '+' : ''}{trend.percentageChange.toFixed(1)}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user