Files
bakery-ia/frontend/src/components/alerts/AlertDashboard.tsx
2025-08-23 10:19:58 +02:00

347 lines
13 KiB
TypeScript

// frontend/src/components/alerts/AlertDashboard.tsx
/**
* Main dashboard component for alerts and recommendations
* Provides filtering, bulk actions, and real-time updates
*/
import React, { useState, useEffect, useMemo } from 'react';
import { AlertItem, ItemFilters, ItemType, ItemSeverity, ItemStatus } from '../../types/alerts';
import { useAlertStream } from '../../hooks/useAlertStream';
import { AlertCard } from './AlertCard';
import { AlertFilters } from './AlertFilters';
import { AlertStats } from './AlertStats';
import { ConnectionStatus } from './ConnectionStatus';
import { useTenantId } from '../../hooks/useTenantId';
interface AlertDashboardProps {
className?: string;
maxItems?: number;
autoRequestNotifications?: boolean;
}
export const AlertDashboard: React.FC<AlertDashboardProps> = ({
className = '',
maxItems = 50,
autoRequestNotifications = true
}) => {
const tenantId = useTenantId();
const {
items,
connectionState,
urgentCount,
highCount,
recCount,
acknowledgeItem,
resolveItem,
notificationPermission,
requestNotificationPermission
} = useAlertStream({ tenantId });
const [filters, setFilters] = useState<ItemFilters>({
item_type: 'all',
severity: 'all',
status: 'all',
service: 'all',
search: ''
});
const [selectedItems, setSelectedItems] = useState<string[]>([]);
const [bulkActionsOpen, setBulkActionsOpen] = useState(false);
const [viewMode, setViewMode] = useState<'list' | 'compact'>('list');
// Request notification permission on mount if needed
useEffect(() => {
if (autoRequestNotifications && notificationPermission === 'default') {
// Delay request to avoid immediate popup
const timer = setTimeout(() => {
requestNotificationPermission();
}, 2000);
return () => clearTimeout(timer);
}
}, [autoRequestNotifications, notificationPermission, requestNotificationPermission]);
// Filter items based on current filters
const filteredItems = useMemo(() => {
let filtered = items;
// Filter by type
if (filters.item_type !== 'all') {
filtered = filtered.filter(item => item.item_type === filters.item_type);
}
// Filter by severity
if (filters.severity !== 'all') {
filtered = filtered.filter(item => item.severity === filters.severity);
}
// Filter by status
if (filters.status !== 'all') {
filtered = filtered.filter(item => item.status === filters.status);
}
// Filter by service
if (filters.service !== 'all') {
filtered = filtered.filter(item => item.service === filters.service);
}
// Filter by search text
if (filters.search.trim()) {
const searchLower = filters.search.toLowerCase();
filtered = filtered.filter(item =>
item.title.toLowerCase().includes(searchLower) ||
item.message.toLowerCase().includes(searchLower) ||
item.type.toLowerCase().includes(searchLower)
);
}
return filtered.slice(0, maxItems);
}, [items, filters, maxItems]);
// Get unique services for filter dropdown
const availableServices = useMemo(() => {
const services = [...new Set(items.map(item => item.service))].sort();
return services;
}, [items]);
// Handle bulk actions
const handleBulkAcknowledge = async () => {
await Promise.all(selectedItems.map(id => acknowledgeItem(id)));
setSelectedItems([]);
setBulkActionsOpen(false);
};
const handleBulkResolve = async () => {
await Promise.all(selectedItems.map(id => resolveItem(id)));
setSelectedItems([]);
setBulkActionsOpen(false);
};
const handleSelectAll = () => {
const selectableItems = filteredItems
.filter(item => item.status === 'active')
.map(item => item.id);
setSelectedItems(selectableItems);
};
const handleClearSelection = () => {
setSelectedItems([]);
setBulkActionsOpen(false);
};
const toggleItemSelection = (itemId: string) => {
setSelectedItems(prev =>
prev.includes(itemId)
? prev.filter(id => id !== itemId)
: [...prev, itemId]
);
};
const activeItems = filteredItems.filter(item => item.status === 'active');
const hasSelection = selectedItems.length > 0;
return (
<div className={`max-w-7xl mx-auto ${className}`}>
{/* Header */}
<div className="bg-white shadow-sm border-b border-gray-200 px-6 py-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900">
Sistema de Alertas y Recomendaciones
</h1>
<p className="text-sm text-gray-600 mt-1">
Monitoreo en tiempo real de operaciones de panadería
</p>
</div>
{/* Connection Status */}
<ConnectionStatus connectionState={connectionState} />
</div>
</div>
{/* Stats */}
<AlertStats
urgentCount={urgentCount}
highCount={highCount}
recCount={recCount}
totalItems={items.length}
activeItems={activeItems.length}
/>
{/* Notification Permission Banner */}
{notificationPermission === 'denied' && (
<div className="bg-yellow-50 border border-yellow-200 rounded-md p-4 mx-6 mt-4">
<div className="flex">
<div className="flex-shrink-0">
<svg className="h-5 w-5 text-yellow-400" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
</svg>
</div>
<div className="ml-3">
<h3 className="text-sm font-medium text-yellow-800">
Notificaciones bloqueadas
</h3>
<p className="text-sm text-yellow-700 mt-1">
Las notificaciones del navegador están deshabilitadas. No recibirás alertas urgentes en tiempo real.
</p>
</div>
</div>
</div>
)}
{/* Filters and View Controls */}
<div className="bg-white border-b border-gray-200 px-6 py-4">
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between space-y-4 lg:space-y-0">
<AlertFilters
filters={filters}
onFiltersChange={setFilters}
availableServices={availableServices}
/>
<div className="flex items-center space-x-4">
{/* View Mode Toggle */}
<div className="flex rounded-md shadow-sm">
<button
onClick={() => setViewMode('list')}
className={`px-4 py-2 text-sm font-medium rounded-l-md border ${
viewMode === 'list'
? 'bg-blue-50 border-blue-200 text-blue-700'
: 'bg-white border-gray-300 text-gray-700 hover:bg-gray-50'
}`}
>
Lista
</button>
<button
onClick={() => setViewMode('compact')}
className={`px-4 py-2 text-sm font-medium rounded-r-md border-l-0 border ${
viewMode === 'compact'
? 'bg-blue-50 border-blue-200 text-blue-700'
: 'bg-white border-gray-300 text-gray-700 hover:bg-gray-50'
}`}
>
Compacto
</button>
</div>
{/* Bulk Actions */}
{activeItems.length > 0 && (
<div className="flex items-center space-x-2">
<button
onClick={() => setBulkActionsOpen(!bulkActionsOpen)}
className="inline-flex items-center px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50"
>
Acciones masivas
<svg className="ml-2 h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
</div>
)}
</div>
</div>
{/* Bulk Actions Panel */}
{bulkActionsOpen && activeItems.length > 0 && (
<div className="mt-4 p-4 bg-gray-50 rounded-lg border border-gray-200">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-4">
<span className="text-sm text-gray-600">
{selectedItems.length} elementos seleccionados
</span>
<button
onClick={handleSelectAll}
className="text-sm text-blue-600 hover:text-blue-800"
>
Seleccionar todos los activos
</button>
<button
onClick={handleClearSelection}
className="text-sm text-gray-600 hover:text-gray-800"
>
Limpiar selección
</button>
</div>
{hasSelection && (
<div className="flex items-center space-x-2">
<button
onClick={handleBulkAcknowledge}
className="inline-flex items-center px-3 py-1 border border-transparent text-sm font-medium rounded-md text-blue-700 bg-blue-100 hover:bg-blue-200"
>
Reconocer seleccionados
</button>
<button
onClick={handleBulkResolve}
className="inline-flex items-center px-3 py-1 border border-transparent text-sm font-medium rounded-md text-green-700 bg-green-100 hover:bg-green-200"
>
Resolver seleccionados
</button>
</div>
)}
</div>
</div>
)}
</div>
{/* Items List */}
<div className="px-6 py-4">
{filteredItems.length === 0 ? (
<div className="text-center py-12">
{items.length === 0 ? (
<div>
<svg className="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<h3 className="mt-2 text-sm font-medium text-gray-900">
Sistema operativo
</h3>
<p className="mt-1 text-sm text-gray-500">
No hay alertas activas en este momento. Todas las operaciones funcionan correctamente.
</p>
</div>
) : (
<div>
<svg className="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<h3 className="mt-2 text-sm font-medium text-gray-900">
No se encontraron elementos
</h3>
<p className="mt-1 text-sm text-gray-500">
Intenta ajustar los filtros para ver más elementos.
</p>
</div>
)}
</div>
) : (
<div className={`space-y-4 ${viewMode === 'compact' ? 'space-y-2' : ''}`}>
{filteredItems.map((item) => (
<div key={item.id} className="relative">
{/* Selection Checkbox */}
{bulkActionsOpen && item.status === 'active' && (
<div className="absolute left-2 top-4 z-10">
<input
type="checkbox"
checked={selectedItems.includes(item.id)}
onChange={() => toggleItemSelection(item.id)}
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
/>
</div>
)}
<div className={bulkActionsOpen && item.status === 'active' ? 'ml-8' : ''}>
<AlertCard
item={item}
onAcknowledge={acknowledgeItem}
onResolve={resolveItem}
compact={viewMode === 'compact'}
showActions={!bulkActionsOpen}
/>
</div>
</div>
))}
</div>
)}
</div>
</div>
);
};