New alert system and panel de control page

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

View File

@@ -1,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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

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

View File

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

View 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;

View File

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

View File

@@ -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;

View File

@@ -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>
</>
);
}

View File

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