347 lines
13 KiB
TypeScript
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>
|
|
);
|
|
}; |