import React, { useEffect, useState } from 'react'; import { fetchGroupsWithClients, restartClient } from './apiClients'; import type { Group, Client } from './apiClients'; import { fetchEvents } from './apiEvents'; import { ButtonComponent } from '@syncfusion/ej2-react-buttons'; import { ToastComponent, MessageComponent } from '@syncfusion/ej2-react-notifications'; import { listHolidays } from './apiHolidays'; import { getActiveAcademicPeriod, type AcademicPeriod } from './apiAcademicPeriods'; import { getHolidayBannerSetting } from './apiSystemSettings'; import { formatIsoDateForDisplay } from './dateFormatting'; const REFRESH_INTERVAL = 15000; // 15 Sekunden type FilterType = 'all' | 'online' | 'offline' | 'warning'; interface ActiveEvent { id: string; title: string; event_type: string; start: string; end: string; recurrenceRule?: string; isRecurring: boolean; } interface GroupEvents { [groupId: number]: ActiveEvent | null; } const Dashboard: React.FC = () => { const [groups, setGroups] = useState([]); const [expandedCards, setExpandedCards] = useState>(new Set()); const [filter, setFilter] = useState('all'); const [lastUpdate, setLastUpdate] = useState(new Date()); const [activeEvents, setActiveEvents] = useState({}); const toastRef = React.useRef(null); // Holiday status state const [holidayBannerEnabled, setHolidayBannerEnabled] = useState(true); const [activePeriod, setActivePeriod] = useState(null); const [holidayOverlapCount, setHolidayOverlapCount] = useState(0); const [holidayFirst, setHolidayFirst] = useState(null); const [holidayLast, setHolidayLast] = useState(null); const [holidayLoading, setHolidayLoading] = useState(false); const [holidayError, setHolidayError] = useState(null); // Optimiertes Update: Nur bei echten Datenänderungen wird das Grid aktualisiert useEffect(() => { let lastGroups: Group[] = []; const fetchAndUpdate = async () => { try { const newGroups = await fetchGroupsWithClients(); // Vergleiche nur die relevanten Felder (id, clients, is_alive) const changed = lastGroups.length !== newGroups.length || lastGroups.some((g, i) => { const ng = newGroups[i]; if (!ng || g.id !== ng.id || g.clients.length !== ng.clients.length) return true; // Optional: Vergleiche tiefer, z.B. Alive-Status for (let j = 0; j < g.clients.length; j++) { if ( g.clients[j].uuid !== ng.clients[j].uuid || g.clients[j].is_alive !== ng.clients[j].is_alive ) { return true; } } return false; }); if (changed) { setGroups(newGroups); lastGroups = newGroups; setLastUpdate(new Date()); // Fetch active events for all groups fetchActiveEventsForGroups(newGroups); } } catch (error) { console.error('Fehler beim Laden der Gruppen:', error); } }; fetchAndUpdate(); const interval = setInterval(fetchAndUpdate, REFRESH_INTERVAL); return () => clearInterval(interval); }, []); // Load academic period & holidays status useEffect(() => { const loadHolidayStatus = async () => { // Check if banner is enabled first try { const bannerSetting = await getHolidayBannerSetting(); setHolidayBannerEnabled(bannerSetting.enabled); if (!bannerSetting.enabled) { return; // Skip loading if disabled } } catch (e) { console.error('Fehler beim Laden der Banner-Einstellung:', e); // Continue with default (enabled) } setHolidayLoading(true); setHolidayError(null); try { const period = await getActiveAcademicPeriod(); setActivePeriod(period || null); const holidayData = period ? await listHolidays(undefined, period.id) : { holidays: [] }; const list = holidayData.holidays || []; if (period) { // Check for holidays overlapping with active period const ps = new Date(period.startDate + 'T00:00:00'); const pe = new Date(period.endDate + 'T23:59:59'); const overlapping = list.filter(h => { const hs = new Date(h.start_date + 'T00:00:00'); const he = new Date(h.end_date + 'T23:59:59'); return hs <= pe && he >= ps; }); setHolidayOverlapCount(overlapping.length); if (overlapping.length > 0) { const sorted = overlapping.slice().sort((a, b) => a.start_date.localeCompare(b.start_date)); setHolidayFirst(sorted[0].start_date); setHolidayLast(sorted[sorted.length - 1].end_date); } else { setHolidayFirst(null); setHolidayLast(null); } } else { setHolidayOverlapCount(0); setHolidayFirst(null); setHolidayLast(null); } } catch (e) { const msg = e instanceof Error ? e.message : 'Ferienstatus konnte nicht geladen werden'; setHolidayError(msg); } finally { setHolidayLoading(false); } }; loadHolidayStatus(); }, []); // Fetch currently active events for all groups const fetchActiveEventsForGroups = async (groupsList: Group[]) => { const now = new Date(); const eventsMap: GroupEvents = {}; for (const group of groupsList) { try { const events = await fetchEvents(String(group.id), false, { start: new Date(now.getTime() - 60000), // 1 minute ago end: new Date(now.getTime() + 60000), // 1 minute ahead expand: true }); // Find the first active event if (events && events.length > 0) { const activeEvent = events[0]; eventsMap[group.id] = { id: activeEvent.id, title: activeEvent.subject || 'Unbenannter Event', event_type: activeEvent.type || 'unknown', start: activeEvent.startTime, // Keep as string, will be parsed in format functions end: activeEvent.endTime, recurrenceRule: activeEvent.recurrenceRule, isRecurring: !!activeEvent.recurrenceRule }; } else { eventsMap[group.id] = null; } } catch { console.error(`Fehler beim Laden der Events für Gruppe ${group.id}`); eventsMap[group.id] = null; } } setActiveEvents(eventsMap); }; // Toggle card expansion const toggleCard = (groupId: number) => { setExpandedCards(prev => { const newSet = new Set(prev); if (newSet.has(groupId)) { newSet.delete(groupId); } else { newSet.add(groupId); } return newSet; }); }; // Health-Statistik berechnen const getHealthStats = (group: Group) => { const total = group.clients.length; const alive = group.clients.filter((c: Client) => c.is_alive).length; const offline = total - alive; const ratio = total === 0 ? 0 : alive / total; let statusColor = '#e74c3c'; // danger red let statusText = 'Kritisch'; if (ratio === 1) { statusColor = '#27ae60'; // success green statusText = 'Optimal'; } else if (ratio >= 0.5) { statusColor = '#f39c12'; // warning orange statusText = 'Teilweise'; } return { total, alive, offline, ratio, statusColor, statusText }; }; // Neustart-Logik const handleRestartClient = async (uuid: string, description: string) => { try { const result = await restartClient(uuid); toastRef.current?.show({ title: 'Neustart erfolgreich', content: `${description || uuid}: ${result.message}`, cssClass: 'e-toast-success', icon: 'e-success toast-icons', }); } catch (error: unknown) { const message = error && typeof error === 'object' && 'message' in error ? (error as { message: string }).message : 'Unbekannter Fehler beim Neustart'; toastRef.current?.show({ title: 'Fehler beim Neustart', content: message, cssClass: 'e-toast-danger', icon: 'e-error toast-icons', }); } }; // Bulk restart für offline Clients einer Gruppe const handleRestartAllOffline = async (group: Group) => { const offlineClients = group.clients.filter(c => !c.is_alive); if (offlineClients.length === 0) { toastRef.current?.show({ title: 'Keine Offline-Geräte', content: `Alle Infoscreens in "${group.name}" sind online.`, cssClass: 'e-toast-info', icon: 'e-info toast-icons', }); return; } const confirmed = window.confirm( `Möchten Sie ${offlineClients.length} offline Infoscreen(s) in "${group.name}" neu starten?` ); if (!confirmed) return; let successCount = 0; let failCount = 0; for (const client of offlineClients) { try { await restartClient(client.uuid); successCount++; } catch { failCount++; } } toastRef.current?.show({ title: 'Bulk-Neustart abgeschlossen', content: `✓ ${successCount} erfolgreich, ✗ ${failCount} fehlgeschlagen`, cssClass: failCount > 0 ? 'e-toast-warning' : 'e-toast-success', icon: failCount > 0 ? 'e-warning toast-icons' : 'e-success toast-icons', }); }; // Berechne Gesamtstatistiken const getGlobalStats = () => { const allClients = groups.flatMap(g => g.clients); const total = allClients.length; const online = allClients.filter(c => c.is_alive).length; const offline = total - online; const ratio = total === 0 ? 0 : online / total; // Warnungen: Gruppen mit teilweise offline Clients const warningGroups = groups.filter(g => { const groupStats = getHealthStats(g); return groupStats.ratio > 0 && groupStats.ratio < 1; }).length; return { total, online, offline, ratio, warningGroups }; }; // Berechne "Zeit seit letztem Lebenszeichen" const getTimeSinceLastAlive = (lastAlive: string | null | undefined): string => { if (!lastAlive) return 'Nie'; try { const dateStr = lastAlive.endsWith('Z') ? lastAlive : lastAlive + 'Z'; const date = new Date(dateStr); if (isNaN(date.getTime())) return 'Unbekannt'; const now = new Date(); const diffMs = now.getTime() - date.getTime(); const diffMins = Math.floor(diffMs / 60000); const diffHours = Math.floor(diffMins / 60); const diffDays = Math.floor(diffHours / 24); if (diffMins < 1) return 'Gerade eben'; if (diffMins < 60) return `vor ${diffMins} Min.`; if (diffHours < 24) return `vor ${diffHours} Std.`; return `vor ${diffDays} Tag${diffDays > 1 ? 'en' : ''}`; } catch { return 'Unbekannt'; } }; // Manuelle Aktualisierung const handleManualRefresh = async () => { try { const newGroups = await fetchGroupsWithClients(); setGroups(newGroups); setLastUpdate(new Date()); await fetchActiveEventsForGroups(newGroups); toastRef.current?.show({ title: 'Aktualisiert', content: 'Daten wurden erfolgreich aktualisiert', cssClass: 'e-toast-success', icon: 'e-success toast-icons', timeOut: 2000, }); } catch { toastRef.current?.show({ title: 'Fehler', content: 'Daten konnten nicht aktualisiert werden', cssClass: 'e-toast-danger', icon: 'e-error toast-icons', }); } }; // Get event type icon const getEventTypeIcon = (eventType: string): string => { switch (eventType) { case 'presentation': return '📊'; case 'website': return '🌐'; case 'webuntis': return '📅'; case 'video': return '🎬'; case 'message': return '💬'; default: return '📄'; } }; // Get event type label const getEventTypeLabel = (eventType: string): string => { switch (eventType) { case 'presentation': return 'Präsentation'; case 'website': return 'Website'; case 'webuntis': return 'WebUntis'; case 'video': return 'Video'; case 'message': return 'Nachricht'; default: return 'Inhalt'; } }; // Format time for display const formatEventTime = (dateStr: string): string => { try { // API returns UTC ISO strings without 'Z', so append 'Z' to parse as UTC const utcString = dateStr.endsWith('Z') ? dateStr : dateStr + 'Z'; const date = new Date(utcString); if (isNaN(date.getTime())) return '—'; return date.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' }); } catch { return '—'; } }; // Format date for display const formatEventDate = (dateStr: string): string => { try { // API returns UTC ISO strings without 'Z', so append 'Z' to parse as UTC const utcString = dateStr.endsWith('Z') ? dateStr : dateStr + 'Z'; const date = new Date(utcString); if (isNaN(date.getTime())) return '—'; const today = new Date(); const isToday = date.toDateString() === today.toDateString(); if (isToday) { return 'Heute'; } return date.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' }); } catch { return '—'; } }; // Filter Gruppen basierend auf Status const getFilteredGroups = () => { return groups.filter(group => { const stats = getHealthStats(group); switch (filter) { case 'online': return stats.ratio === 1; case 'offline': return stats.ratio === 0; case 'warning': return stats.ratio > 0 && stats.ratio < 1; default: return true; } }); }; // Format date for holiday display const formatDate = (iso: string | null) => formatIsoDateForDisplay(iso); // Holiday Status Banner Component const HolidayStatusBanner = () => { if (holidayLoading) { return ( Lade Ferienstatus ... ); } if (holidayError) { return ( Fehler beim Laden des Ferienstatus: {holidayError} ); } if (!activePeriod) { return ( ⚠️ Keine aktive akademische Periode gesetzt – Ferienplan nicht verknüpft. ); } if (holidayOverlapCount > 0) { return ( ✅ Ferienplan vorhanden für {activePeriod.displayName || activePeriod.name}: {holidayOverlapCount} Zeitraum{holidayOverlapCount === 1 ? '' : 'e'} {holidayFirst && holidayLast && ( <> ({formatDate(holidayFirst)} – {formatDate(holidayLast)}) )} ); } return ( ⚠️ Kein Ferienplan für {activePeriod.displayName || activePeriod.name} importiert. Jetzt unter Einstellungen → 📥 Ferienkalender: Import/Anzeige. ); }; return (

Dashboard

Übersicht aller Raumgruppen und deren Infoscreens

{/* Holiday Status Banner */} {holidayBannerEnabled && (
)} {/* Global Statistics Summary */} {(() => { const globalStats = getGlobalStats(); return (
Gesamt Infoscreens
{globalStats.total}
🟢 Online
{globalStats.online} ({globalStats.total > 0 ? Math.round(globalStats.ratio * 100) : 0}%)
🔴 Offline
{globalStats.offline}
⚠️ Gruppen mit Warnungen
{globalStats.warningGroups}
Zuletzt aktualisiert: {getTimeSinceLastAlive(lastUpdate.toISOString())}
Aktualisieren
); })()} {/* Filter Buttons */}
setFilter('all')} > Alle anzeigen ({groups.length}) setFilter('online')} > 🟢 Nur Online ({groups.filter(g => getHealthStats(g).ratio === 1).length}) setFilter('offline')} > 🔴 Nur Offline ({groups.filter(g => getHealthStats(g).ratio === 0).length}) setFilter('warning')} > ⚠️ Mit Warnungen ({groups.filter(g => { const stats = getHealthStats(g); return stats.ratio > 0 && stats.ratio < 1; }).length})
{/* Group Cards */} {(() => { const filteredGroups = getFilteredGroups(); if (filteredGroups.length === 0) { return (
{filter === 'all' ? 'Keine Raumgruppen gefunden' : `Keine Gruppen mit Filter "${filter}" gefunden` }
); } return (
{filteredGroups .sort((a, b) => { // 'Nicht zugeordnet' always comes last if (a.name === 'Nicht zugeordnet') return 1; if (b.name === 'Nicht zugeordnet') return -1; // Otherwise, sort alphabetically return a.name.localeCompare(b.name); }) .map((group) => { const stats = getHealthStats(group); const isExpanded = expandedCards.has(group.id); return (
{/* Card Header */}
{group.name}
= 0.5 ? 'warning' : 'danger'}`}> {stats.statusText} {stats.total} {stats.total === 1 ? 'Infoscreen' : 'Infoscreens'}
{/* Card Content - Statistics */}
{/* Currently Active Event */} {activeEvents[group.id] ? (
🎯 Aktuell angezeigt
{activeEvents[group.id]?.isRecurring && ( 🔄 Wiederkehrend )}
{getEventTypeIcon(activeEvents[group.id]?.event_type || 'unknown')} {activeEvents[group.id]?.title || 'Unbenannter Event'}
{getEventTypeLabel(activeEvents[group.id]?.event_type || 'unknown')}
📅 {activeEvents[group.id]?.start ? formatEventDate(activeEvents[group.id]!.start) : 'Kein Datum'} 🕐 {activeEvents[group.id]?.start && activeEvents[group.id]?.end ? `${formatEventTime(activeEvents[group.id]!.start)} - ${formatEventTime(activeEvents[group.id]!.end)}` : 'Keine Zeit'}
) : (
📭 Kein aktiver Event
)} {/* Health Bar */}
🟢 Online: {stats.alive} 🔴 Offline: {stats.offline}
{/* Action Buttons */}
toggleCard(group.id)} style={{ flex: 1 }} > {isExpanded ? 'Details ausblenden' : 'Details anzeigen'} {stats.offline > 0 && ( handleRestartAllOffline(group)} title={`${stats.offline} offline Gerät(e) neu starten`} style={{ flexShrink: 0 }} > Offline neustarten )}
{/* Expanded Client List */} {isExpanded && (
{group.clients.length === 0 ? (
Keine Infoscreens in dieser Gruppe
) : ( group.clients.map((client, index) => (
{client.description || client.hostname || 'Unbenannt'}
📍 {client.ip || 'Keine IP'} {client.hostname && ( 🖥️ {client.hostname} )}
{client.is_alive ? `✓ Aktiv ${getTimeSinceLastAlive(client.last_alive)}` : `⚠ Offline seit ${getTimeSinceLastAlive(client.last_alive)}` }
{client.is_alive ? 'Online' : 'Offline'} handleRestartClient(client.uuid, client.description || client.hostname || 'Infoscreen')} title="Neustart" />
)) )}
)}
); })}
); })()}
); }; export default Dashboard;