- add period-scoped holiday architecture end-to-end - model: scope `SchoolHoliday` to `academic_period_id` - migrations: add holiday-period scoping, academic-period archive lifecycle, and merge migration head - API: extend holidays with manual CRUD, period validation, duplicate prevention, and overlap merge/conflict handling - recurrence: regenerate holiday exceptions using period-scoped holiday sets - improve frontend settings and holiday workflows - bind holiday import/list/manual CRUD to selected academic period - show detailed import outcomes (inserted/updated/merged/skipped/conflicts) - fix file-picker UX (visible selected filename) - align settings controls/dialogs with defined frontend design rules - scope appointments/dashboard holiday loading to active period - add shared date formatting utility - strengthen academic period lifecycle handling - add archive/restore/delete flow and backend validations/blocker checks - extend API client support for lifecycle operations - release/docs updates and cleanup - bump user-facing version to `2026.1.0-alpha.15` with new changelog entry - add tech changelog entry for alpha.15 backend changes - refactor README to concise index and archive historical implementation docs - fix Copilot instruction link diagnostics via local `.github` design-rules reference
909 lines
35 KiB
TypeScript
909 lines
35 KiB
TypeScript
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<Group[]>([]);
|
||
const [expandedCards, setExpandedCards] = useState<Set<number>>(new Set());
|
||
const [filter, setFilter] = useState<FilterType>('all');
|
||
const [lastUpdate, setLastUpdate] = useState<Date>(new Date());
|
||
const [activeEvents, setActiveEvents] = useState<GroupEvents>({});
|
||
const toastRef = React.useRef<ToastComponent>(null);
|
||
|
||
// Holiday status state
|
||
const [holidayBannerEnabled, setHolidayBannerEnabled] = useState<boolean>(true);
|
||
const [activePeriod, setActivePeriod] = useState<AcademicPeriod | null>(null);
|
||
const [holidayOverlapCount, setHolidayOverlapCount] = useState<number>(0);
|
||
const [holidayFirst, setHolidayFirst] = useState<string | null>(null);
|
||
const [holidayLast, setHolidayLast] = useState<string | null>(null);
|
||
const [holidayLoading, setHolidayLoading] = useState<boolean>(false);
|
||
const [holidayError, setHolidayError] = useState<string | null>(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 (
|
||
<MessageComponent severity="Info" variant="Filled">
|
||
Lade Ferienstatus ...
|
||
</MessageComponent>
|
||
);
|
||
}
|
||
if (holidayError) {
|
||
return (
|
||
<MessageComponent severity="Error" variant="Filled">
|
||
Fehler beim Laden des Ferienstatus: {holidayError}
|
||
</MessageComponent>
|
||
);
|
||
}
|
||
if (!activePeriod) {
|
||
return (
|
||
<MessageComponent severity="Warning" variant="Outlined">
|
||
⚠️ Keine aktive akademische Periode gesetzt – Ferienplan nicht verknüpft.
|
||
</MessageComponent>
|
||
);
|
||
}
|
||
if (holidayOverlapCount > 0) {
|
||
return (
|
||
<MessageComponent severity="Success" variant="Filled">
|
||
✅ Ferienplan vorhanden für <strong>{activePeriod.displayName || activePeriod.name}</strong>: {holidayOverlapCount} Zeitraum{holidayOverlapCount === 1 ? '' : 'e'}
|
||
{holidayFirst && holidayLast && (
|
||
<> ({formatDate(holidayFirst)} – {formatDate(holidayLast)})</>
|
||
)}
|
||
</MessageComponent>
|
||
);
|
||
}
|
||
return (
|
||
<MessageComponent severity="Warning" variant="Filled">
|
||
⚠️ Kein Ferienplan für <strong>{activePeriod.displayName || activePeriod.name}</strong> importiert. Jetzt unter Einstellungen → 📥 Ferienkalender: Import/Anzeige.
|
||
</MessageComponent>
|
||
);
|
||
};
|
||
|
||
return (
|
||
<div>
|
||
<ToastComponent
|
||
ref={toastRef}
|
||
position={{ X: 'Right', Y: 'Top' }}
|
||
timeOut={4000}
|
||
/>
|
||
|
||
<header style={{ marginBottom: 24, paddingBottom: 16, borderBottom: '2px solid #d6c3a6' }}>
|
||
<h2 style={{ fontSize: '1.75rem', fontWeight: 800, margin: 0, marginBottom: 8 }}>
|
||
Dashboard
|
||
</h2>
|
||
<p style={{ color: '#666', fontSize: '0.95rem', margin: 0 }}>
|
||
Übersicht aller Raumgruppen und deren Infoscreens
|
||
</p>
|
||
</header>
|
||
|
||
{/* Holiday Status Banner */}
|
||
{holidayBannerEnabled && (
|
||
<div style={{ marginBottom: '20px' }}>
|
||
<HolidayStatusBanner />
|
||
</div>
|
||
)}
|
||
|
||
{/* Global Statistics Summary */}
|
||
{(() => {
|
||
const globalStats = getGlobalStats();
|
||
return (
|
||
<div className="e-card" style={{
|
||
marginBottom: '24px',
|
||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||
color: 'white',
|
||
borderRadius: '8px',
|
||
padding: '24px'
|
||
}}>
|
||
<div style={{
|
||
display: 'grid',
|
||
gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))',
|
||
gap: '20px',
|
||
alignItems: 'center'
|
||
}}>
|
||
<div>
|
||
<div style={{ fontSize: '0.85rem', opacity: 0.9, marginBottom: '4px' }}>
|
||
Gesamt Infoscreens
|
||
</div>
|
||
<div style={{ fontSize: '2rem', fontWeight: 'bold' }}>
|
||
{globalStats.total}
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<div style={{ fontSize: '0.85rem', opacity: 0.9, marginBottom: '4px' }}>
|
||
🟢 Online
|
||
</div>
|
||
<div style={{ fontSize: '2rem', fontWeight: 'bold' }}>
|
||
{globalStats.online}
|
||
<span style={{ fontSize: '1rem', marginLeft: '8px', opacity: 0.8 }}>
|
||
({globalStats.total > 0 ? Math.round(globalStats.ratio * 100) : 0}%)
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<div style={{ fontSize: '0.85rem', opacity: 0.9, marginBottom: '4px' }}>
|
||
🔴 Offline
|
||
</div>
|
||
<div style={{ fontSize: '2rem', fontWeight: 'bold' }}>
|
||
{globalStats.offline}
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<div style={{ fontSize: '0.85rem', opacity: 0.9, marginBottom: '4px' }}>
|
||
⚠️ Gruppen mit Warnungen
|
||
</div>
|
||
<div style={{ fontSize: '2rem', fontWeight: 'bold' }}>
|
||
{globalStats.warningGroups}
|
||
</div>
|
||
</div>
|
||
|
||
<div style={{ textAlign: 'right' }}>
|
||
<div style={{ fontSize: '0.85rem', opacity: 0.9, marginBottom: '8px' }}>
|
||
Zuletzt aktualisiert: {getTimeSinceLastAlive(lastUpdate.toISOString())}
|
||
</div>
|
||
<ButtonComponent
|
||
cssClass="e-small e-info"
|
||
iconCss="e-icons e-refresh"
|
||
onClick={handleManualRefresh}
|
||
>
|
||
Aktualisieren
|
||
</ButtonComponent>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
})()}
|
||
|
||
{/* Filter Buttons */}
|
||
<div style={{
|
||
display: 'flex',
|
||
gap: '12px',
|
||
marginBottom: '24px',
|
||
flexWrap: 'wrap'
|
||
}}>
|
||
<ButtonComponent
|
||
cssClass={filter === 'all' ? 'e-primary' : 'e-outline'}
|
||
onClick={() => setFilter('all')}
|
||
>
|
||
Alle anzeigen ({groups.length})
|
||
</ButtonComponent>
|
||
<ButtonComponent
|
||
cssClass={filter === 'online' ? 'e-success' : 'e-outline'}
|
||
onClick={() => setFilter('online')}
|
||
>
|
||
🟢 Nur Online ({groups.filter(g => getHealthStats(g).ratio === 1).length})
|
||
</ButtonComponent>
|
||
<ButtonComponent
|
||
cssClass={filter === 'offline' ? 'e-danger' : 'e-outline'}
|
||
onClick={() => setFilter('offline')}
|
||
>
|
||
🔴 Nur Offline ({groups.filter(g => getHealthStats(g).ratio === 0).length})
|
||
</ButtonComponent>
|
||
<ButtonComponent
|
||
cssClass={filter === 'warning' ? 'e-warning' : 'e-outline'}
|
||
onClick={() => setFilter('warning')}
|
||
>
|
||
⚠️ Mit Warnungen ({groups.filter(g => {
|
||
const stats = getHealthStats(g);
|
||
return stats.ratio > 0 && stats.ratio < 1;
|
||
}).length})
|
||
</ButtonComponent>
|
||
</div>
|
||
|
||
{/* Group Cards */}
|
||
{(() => {
|
||
const filteredGroups = getFilteredGroups();
|
||
|
||
if (filteredGroups.length === 0) {
|
||
return (
|
||
<div className="e-card" style={{ padding: '40px', textAlign: 'center' }}>
|
||
<div style={{ color: '#999', fontSize: '1.1rem' }}>
|
||
{filter === 'all'
|
||
? 'Keine Raumgruppen gefunden'
|
||
: `Keine Gruppen mit Filter "${filter}" gefunden`
|
||
}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div style={{
|
||
display: 'grid',
|
||
gridTemplateColumns: 'repeat(auto-fill, minmax(380px, 1fr))',
|
||
gap: '24px'
|
||
}}>
|
||
{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 (
|
||
<div key={group.id} className="e-card" style={{
|
||
borderRadius: '8px',
|
||
overflow: 'hidden',
|
||
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
|
||
transition: 'all 0.3s ease',
|
||
border: `2px solid ${stats.statusColor}20`
|
||
}}>
|
||
{/* Card Header */}
|
||
<div className="e-card-header" style={{
|
||
background: `linear-gradient(135deg, ${stats.statusColor}15, ${stats.statusColor}05)`,
|
||
borderBottom: `3px solid ${stats.statusColor}`,
|
||
padding: '16px 20px'
|
||
}}>
|
||
<div className="e-card-header-title" style={{
|
||
fontSize: '1.25rem',
|
||
fontWeight: '700',
|
||
color: '#333',
|
||
marginBottom: '8px'
|
||
}}>
|
||
{group.name}
|
||
</div>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', flexWrap: 'wrap' }}>
|
||
<span className={`e-badge e-badge-${stats.ratio === 1 ? 'success' : stats.ratio >= 0.5 ? 'warning' : 'danger'}`}>
|
||
{stats.statusText}
|
||
</span>
|
||
<span style={{ color: '#666', fontSize: '0.9rem' }}>
|
||
{stats.total} {stats.total === 1 ? 'Infoscreen' : 'Infoscreens'}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Card Content - Statistics */}
|
||
<div className="e-card-content" style={{ padding: '20px' }}>
|
||
{/* Currently Active Event */}
|
||
{activeEvents[group.id] ? (
|
||
<div style={{
|
||
marginBottom: '16px',
|
||
padding: '12px',
|
||
backgroundColor: '#f0f7ff',
|
||
borderLeft: '4px solid #2196F3',
|
||
borderRadius: '4px'
|
||
}}>
|
||
<div style={{
|
||
display: 'flex',
|
||
justifyContent: 'space-between',
|
||
alignItems: 'center',
|
||
marginBottom: '8px'
|
||
}}>
|
||
<div style={{
|
||
fontSize: '0.75rem',
|
||
color: '#666',
|
||
fontWeight: '600',
|
||
textTransform: 'uppercase',
|
||
letterSpacing: '0.5px'
|
||
}}>
|
||
🎯 Aktuell angezeigt
|
||
</div>
|
||
{activeEvents[group.id]?.isRecurring && (
|
||
<span style={{
|
||
fontSize: '0.75rem',
|
||
backgroundColor: '#e3f2fd',
|
||
color: '#1976d2',
|
||
padding: '2px 8px',
|
||
borderRadius: '12px',
|
||
fontWeight: '600'
|
||
}}>
|
||
🔄 Wiederkehrend
|
||
</span>
|
||
)}
|
||
</div>
|
||
<div style={{
|
||
fontSize: '0.95rem',
|
||
fontWeight: '600',
|
||
color: '#333',
|
||
marginBottom: '6px',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
gap: '6px'
|
||
}}>
|
||
<span>{getEventTypeIcon(activeEvents[group.id]?.event_type || 'unknown')}</span>
|
||
<span style={{
|
||
overflow: 'hidden',
|
||
textOverflow: 'ellipsis',
|
||
whiteSpace: 'nowrap',
|
||
flex: 1
|
||
}}>
|
||
{activeEvents[group.id]?.title || 'Unbenannter Event'}
|
||
</span>
|
||
</div>
|
||
<div style={{
|
||
fontSize: '0.8rem',
|
||
color: '#666',
|
||
marginBottom: '6px'
|
||
}}>
|
||
{getEventTypeLabel(activeEvents[group.id]?.event_type || 'unknown')}
|
||
</div>
|
||
<div style={{
|
||
fontSize: '0.8rem',
|
||
color: '#555',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
gap: '8px',
|
||
flexWrap: 'wrap'
|
||
}}>
|
||
<span style={{ fontWeight: '500' }}>
|
||
📅 {activeEvents[group.id]?.start ? formatEventDate(activeEvents[group.id]!.start) : 'Kein Datum'}
|
||
</span>
|
||
<span>•</span>
|
||
<span>
|
||
🕐 {activeEvents[group.id]?.start && activeEvents[group.id]?.end
|
||
? `${formatEventTime(activeEvents[group.id]!.start)} - ${formatEventTime(activeEvents[group.id]!.end)}`
|
||
: 'Keine Zeit'}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div style={{
|
||
marginBottom: '16px',
|
||
padding: '12px',
|
||
backgroundColor: '#f5f5f5',
|
||
borderLeft: '4px solid #999',
|
||
borderRadius: '4px'
|
||
}}>
|
||
<div style={{
|
||
fontSize: '0.85rem',
|
||
color: '#666',
|
||
fontStyle: 'italic'
|
||
}}>
|
||
📭 Kein aktiver Event
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Health Bar */}
|
||
<div style={{ marginBottom: '20px' }}>
|
||
<div style={{
|
||
display: 'flex',
|
||
justifyContent: 'space-between',
|
||
marginBottom: '8px',
|
||
fontSize: '0.85rem',
|
||
color: '#666'
|
||
}}>
|
||
<span>🟢 Online: {stats.alive}</span>
|
||
<span>🔴 Offline: {stats.offline}</span>
|
||
</div>
|
||
<div style={{
|
||
height: '8px',
|
||
backgroundColor: '#e0e0e0',
|
||
borderRadius: '4px',
|
||
overflow: 'hidden'
|
||
}}>
|
||
<div style={{
|
||
height: '100%',
|
||
width: `${stats.ratio * 100}%`,
|
||
backgroundColor: stats.statusColor,
|
||
transition: 'width 0.5s ease'
|
||
}} />
|
||
</div>
|
||
</div>
|
||
|
||
{/* Action Buttons */}
|
||
<div style={{ display: 'flex', gap: '8px', marginBottom: isExpanded ? '16px' : '0' }}>
|
||
<ButtonComponent
|
||
cssClass="e-outline"
|
||
iconCss={isExpanded ? 'e-icons e-chevron-up' : 'e-icons e-chevron-down'}
|
||
onClick={() => toggleCard(group.id)}
|
||
style={{ flex: 1 }}
|
||
>
|
||
{isExpanded ? 'Details ausblenden' : 'Details anzeigen'}
|
||
</ButtonComponent>
|
||
|
||
{stats.offline > 0 && (
|
||
<ButtonComponent
|
||
cssClass="e-danger e-small"
|
||
iconCss="e-icons e-refresh"
|
||
onClick={() => handleRestartAllOffline(group)}
|
||
title={`${stats.offline} offline Gerät(e) neu starten`}
|
||
style={{ flexShrink: 0 }}
|
||
>
|
||
Offline neustarten
|
||
</ButtonComponent>
|
||
)}
|
||
</div>
|
||
|
||
{/* Expanded Client List */}
|
||
{isExpanded && (
|
||
<div style={{
|
||
marginTop: '16px',
|
||
maxHeight: '400px',
|
||
overflowY: 'auto',
|
||
border: '1px solid #e0e0e0',
|
||
borderRadius: '4px'
|
||
}}>
|
||
{group.clients.length === 0 ? (
|
||
<div style={{ padding: '20px', textAlign: 'center', color: '#999' }}>
|
||
Keine Infoscreens in dieser Gruppe
|
||
</div>
|
||
) : (
|
||
group.clients.map((client, index) => (
|
||
<div
|
||
key={client.uuid}
|
||
style={{
|
||
padding: '12px 16px',
|
||
borderBottom: index < group.clients.length - 1 ? '1px solid #f0f0f0' : 'none',
|
||
backgroundColor: client.is_alive ? '#f8fff9' : '#fff5f5',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'space-between',
|
||
gap: '12px'
|
||
}}
|
||
>
|
||
<div style={{ flex: 1, minWidth: 0 }}>
|
||
<div style={{
|
||
fontWeight: '600',
|
||
fontSize: '0.95rem',
|
||
marginBottom: '4px',
|
||
overflow: 'hidden',
|
||
textOverflow: 'ellipsis',
|
||
whiteSpace: 'nowrap'
|
||
}}>
|
||
{client.description || client.hostname || 'Unbenannt'}
|
||
</div>
|
||
<div style={{
|
||
fontSize: '0.85rem',
|
||
color: '#666',
|
||
display: 'flex',
|
||
gap: '12px',
|
||
flexWrap: 'wrap'
|
||
}}>
|
||
<span>📍 {client.ip || 'Keine IP'}</span>
|
||
{client.hostname && (
|
||
<span title={client.hostname} style={{
|
||
overflow: 'hidden',
|
||
textOverflow: 'ellipsis',
|
||
whiteSpace: 'nowrap',
|
||
maxWidth: '150px'
|
||
}}>
|
||
🖥️ {client.hostname}
|
||
</span>
|
||
)}
|
||
</div>
|
||
<div style={{
|
||
fontSize: '0.75rem',
|
||
color: client.is_alive ? '#27ae60' : '#e74c3c',
|
||
marginTop: '4px',
|
||
fontWeight: '500'
|
||
}}>
|
||
{client.is_alive
|
||
? `✓ Aktiv ${getTimeSinceLastAlive(client.last_alive)}`
|
||
: `⚠ Offline seit ${getTimeSinceLastAlive(client.last_alive)}`
|
||
}
|
||
</div>
|
||
</div>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', flexShrink: 0 }}>
|
||
<span className={`e-badge e-badge-${client.is_alive ? 'success' : 'danger'}`}>
|
||
{client.is_alive ? 'Online' : 'Offline'}
|
||
</span>
|
||
<ButtonComponent
|
||
cssClass="e-small e-primary"
|
||
iconCss="e-icons e-refresh"
|
||
onClick={() => handleRestartClient(client.uuid, client.description || client.hostname || 'Infoscreen')}
|
||
title="Neustart"
|
||
/>
|
||
</div>
|
||
</div>
|
||
))
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
);
|
||
})()}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default Dashboard;
|