diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 06e811b..2e85676 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -16,6 +16,8 @@ It is not a changelog and not a full architecture handbook. - `scheduler/scheduler.py` - scheduler loop, MQTT event publishing, TV power intent publishing - `scheduler/db_utils.py` - event formatting and power-intent helper logic - `listener/listener.py` - discovery/heartbeat/log/screenshot MQTT consumption +- `server/init_academic_periods.py` - idempotent academic-period seeding + auto-activation for current date +- `server/initialize_database.py` - migration + bootstrap orchestration for local/manual setup - `server/routes/events.py` - event CRUD, recurrence handling, UTC normalization - `server/routes/eventmedia.py` - file manager, media upload/stream endpoints - `server/routes/groups.py` - group lifecycle, alive status, order persistence @@ -32,6 +34,7 @@ It is not a changelog and not a full architecture handbook. - Listener: MQTT consumer that updates server-side state - Scheduler: publishes active events and group-level TV power intents - Nginx: routes `/api/*` and `/screenshots/*` to API, dashboard otherwise +- Prod bootstrap: `docker-compose.prod.yml` server command runs migrations, defaults init, and academic-period init before Gunicorn start ## Non-negotiable conventions - Datetime: @@ -66,12 +69,14 @@ TV power intent Phase 1 rules: - Keep enum/datetime serialization JSON-safe. - Maintain UTC-safe comparisons in scheduler and routes. - Keep recurrence handling backend-driven and consistent with exceptions. +- Academic periods bootstrap is idempotent and should auto-activate period covering `date.today()` when available. ## Frontend patterns - Use Syncfusion-based patterns already present in dashboard. - Keep API requests relative (`/api/...`) to use Vite proxy in dev. - Respect `FRONTEND_DESIGN_RULES.md` for component and styling conventions. - Keep role-gated UI behavior aligned with backend authorization. +- Holiday status banner in dashboard should render from computed state and avoid stale message reuse in 3rd-party UI components. ## Environment variables (high-value) - Scheduler: `POLL_INTERVAL_SECONDS`, `REFRESH_SECONDS` diff --git a/DEV-CHANGELOG.md b/DEV-CHANGELOG.md index 76aaecc..9580c37 100644 --- a/DEV-CHANGELOG.md +++ b/DEV-CHANGELOG.md @@ -5,6 +5,10 @@ This changelog tracks all changes made in the development workspace, including i --- ## Unreleased (development workspace) +- Programminfo GUI regression/fix: `dashboard/public/program-info.json` could not be loaded in Programminfo menu due to invalid JSON in the new alpha.16 changelog line (malformed quote in a text entry). Fixed JSON entry and verified file parses correctly again. +- Dashboard holiday banner fix: `dashboard/src/dashboard.tsx` — `loadHolidayStatus` now uses a stable `useCallback` with empty deps, preventing repeated re-creation on render. `useEffect` depends only on the stable callback reference. +- Dashboard Syncfusion stale-render fix: `MessageComponent` in the holiday banner now receives `key={`${severity}:${text}`}` to force remount when severity or text changes; without this Syncfusion cached stale DOM and the banner did not update reactively. +- Dashboard German text: Replaced transliterated forms (ae/oe/ue) with correct Umlauts throughout visible dashboard UI strings — `Präsentation`, `für`, `prüfen`, `Ferienüberschneidungen`, `verfügbar`, `Vorfälle`, `Ausfälle`. - TV power intent (Phase 1): Scheduler publishes retained QoS1 group-level intents to `infoscreen/groups/{group_id}/power/intent` with transition+heartbeat semantics, startup/reconnect republish, and poll-based expiry (`max(3 × poll_interval_sec, 90s)`). - TV power validation: Added unit/integration/canary coverage in `scheduler/test_power_intent_utils.py`, `scheduler/test_power_intent_scheduler.py`, and `test_power_intent_canary.py`. - Monitoring system completion: End-to-end monitoring pipeline is active (MQTT logs/health → listener persistence → monitoring APIs → superadmin dashboard). diff --git a/README.md b/README.md index 1f85e1b..4dcbf48 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,13 @@ Core stack: - Messaging: MQTT (Mosquitto) - Background jobs: Redis + RQ + Gotenberg +## Latest Release Highlights (2026.1.0-alpha.16) + +- Dashboard holiday status banner now updates reliably after hard refresh and after switching between settings and dashboard. +- Production startup now auto-initializes and auto-activates the academic period for the current date. +- Dashboard German UI wording was polished with proper Umlauts. +- User-facing changelog source: [dashboard/public/program-info.json](dashboard/public/program-info.json) + ## Architecture (Short) - Dashboard talks only to API (`/api/...` via Vite proxy in dev). diff --git a/TECH-CHANGELOG.md b/TECH-CHANGELOG.md index b3b0e93..0c80098 100644 --- a/TECH-CHANGELOG.md +++ b/TECH-CHANGELOG.md @@ -5,6 +5,29 @@ This changelog documents technical and developer-relevant changes included in public releases. For development workspace changes, see DEV-CHANGELOG.md. Not all changes here are reflected in the user-facing changelog (`program-info.json`), and not all UI/feature changes are repeated here. Some changes (e.g., backend refactoring, API adjustments, infrastructure, developer tooling, or internal logic) may only appear in TECH-CHANGELOG.md. For UI/feature changes, see `dashboard/public/program-info.json`. +## 2026.1.0-alpha.16 (2026-04-02) +- 🐛 **Dashboard holiday banner refactoring and state fix (`dashboard/src/dashboard.tsx`)**: + - **Motivation — unstable fetch function:** `loadHolidayStatus` had `location.pathname` in its `useCallback` dependency array, causing a new function reference to be created on every navigation event. The `useEffect` depending on that reference then re-fired, producing overlapping API calls at mount that cancelled each other via the request-sequence guard, leaving the banner unresolved. + - **Refactoring:** Removed `location.pathname` from `useCallback` deps (it was unused inside the function body). The callback now has an empty dependency array, making its reference stable across the component lifetime. The `useEffect` is keyed only to the stable callback reference — no spurious re-fires. + - **Motivation — Syncfusion stale render:** Syncfusion's `MessageComponent` caches its rendered DOM internally and does not reactively update when React passes new children or props. Even after React state changed, the component displayed whatever text was rendered on first mount. + - **Fix:** Added a `key` prop derived from `${severity}:${text}` to `MessageComponent`. React unmounts and remounts the component whenever the key changes, bypassing Syncfusion's internal caching and ensuring the correct message is always visible. + - **Result:** Active-period name and holiday overlap details now render correctly on hard refresh, initial load, and route transitions without additional API calls. +- 🗓️ **Academic period bootstrap hardening (`server/init_academic_periods.py`)**: + - Refactored initialization into idempotent flow: + - seed default periods only when table is empty, + - on every run, activate exactly the non-archived period covering `date.today()`. + - Enforces single-active behavior by deactivating all previously active periods before setting the period for today. + - Emits explicit warning if no period covers current date (all remain inactive), improving operational diagnostics. +- 🚀 **Production startup alignment (`docker-compose.prod.yml`)**: + - Server startup command now runs `python /app/server/init_academic_periods.py` after migrations and default settings bootstrap. + - Removes manual post-deploy step to set an active academic period. +- 🌐 **Dashboard UX/text refinement (`dashboard/src/dashboard.tsx`)**: + - Converted user-facing transliterated German strings to proper Umlauts in the dashboard (for example: "für", "prüfen", "Ferienüberschneidungen", "Vorfälle", "Ausfälle"). + +Notes for integrators: +- On production boot, the active period is now derived from current date coverage automatically. +- If customer calendars do not include today, startup logs a warning and dashboard banner will still guide admins to configure periods. + ## 2026.1.0-alpha.15 (2026-03-31) - 🔌 **TV Power Intent Phase 1 (server-side)**: - Scheduler now publishes retained QoS1 group-level intents to `infoscreen/groups/{group_id}/power/intent`. diff --git a/dashboard/public/program-info.json b/dashboard/public/program-info.json index 2d8ffea..64a84b8 100644 --- a/dashboard/public/program-info.json +++ b/dashboard/public/program-info.json @@ -1,6 +1,6 @@ { "appName": "Infoscreen-Management", - "version": "2026.1.0-alpha.15", + "version": "2026.1.0-alpha.16", "copyright": "© 2026 Third-Age-Applications", "supportContact": "support@third-age-applications.com", "description": "Eine zentrale Verwaltungsoberfläche für digitale Informationsbildschirme.", @@ -30,6 +30,16 @@ "commitId": "9f2ae8b44c3a" }, "changelog": [ + { + "version": "2026.1.0-alpha.16", + "date": "2026-04-02", + "changes": [ + "✅ Dashboard: Der Ferienstatus-Banner zeigt die aktive akademische Periode jetzt zuverlässig nach Hard-Refresh und beim Wechsel zwischen Dashboard und Einstellungen.", + "🧭 Navigation: Der Link vom Ferienstatus-Banner zu den Einstellungen bleibt stabil und funktioniert konsistent für Admin-Rollen.", + "🚀 Deployment: Akademische Perioden werden nach Initialisierung automatisch für das aktuelle Datum aktiviert (kein manueller Aktivierungsschritt direkt nach Rollout mehr nötig).", + "🔤 Sprache: Mehrere deutsche UI-Texte im Dashboard wurden auf korrekte Umlaute umgestellt (zum Beispiel für, prüfen, Vorfälle und Ausfälle)." + ] + }, { "version": "2026.1.0-alpha.15", "date": "2026-03-31", diff --git a/dashboard/src/apiAcademicPeriods.ts b/dashboard/src/apiAcademicPeriods.ts index f2b1ed7..e02e894 100644 --- a/dashboard/src/apiAcademicPeriods.ts +++ b/dashboard/src/apiAcademicPeriods.ts @@ -19,8 +19,25 @@ export type PeriodUsage = { blockers: string[]; }; +function normalizeAcademicPeriod(period: any): AcademicPeriod { + return { + id: Number(period.id), + name: period.name, + displayName: period.displayName ?? period.display_name ?? null, + startDate: period.startDate ?? period.start_date, + endDate: period.endDate ?? period.end_date, + periodType: period.periodType ?? period.period_type, + isActive: Boolean(period.isActive ?? period.is_active), + isArchived: Boolean(period.isArchived ?? period.is_archived), + archivedAt: period.archivedAt ?? period.archived_at ?? null, + archivedBy: period.archivedBy ?? period.archived_by ?? null, + createdAt: period.createdAt ?? period.created_at, + updatedAt: period.updatedAt ?? period.updated_at, + }; +} + async function api(url: string, init?: RequestInit): Promise { - const res = await fetch(url, { credentials: 'include', ...init }); + const res = await fetch(url, { credentials: 'include', cache: 'no-store', ...init }); if (!res.ok) { const text = await res.text(); try { @@ -35,10 +52,10 @@ async function api(url: string, init?: RequestInit): Promise { export async function getAcademicPeriodForDate(date: Date): Promise { const iso = date.toISOString().slice(0, 10); - const { period } = await api<{ period: AcademicPeriod | null }>( + const { period } = await api<{ period: any | null }>( `/api/academic_periods/for_date?date=${iso}` ); - return period ?? null; + return period ? normalizeAcademicPeriod(period) : null; } export async function listAcademicPeriods(options?: { @@ -53,20 +70,20 @@ export async function listAcademicPeriods(options?: { params.set('archivedOnly', '1'); } const query = params.toString(); - const { periods } = await api<{ periods: AcademicPeriod[] }>( + const { periods } = await api<{ periods: any[] }>( `/api/academic_periods${query ? `?${query}` : ''}` ); - return Array.isArray(periods) ? periods : []; + return Array.isArray(periods) ? periods.map(normalizeAcademicPeriod) : []; } export async function getAcademicPeriod(id: number): Promise { - const { period } = await api<{ period: AcademicPeriod }>(`/api/academic_periods/${id}`); - return period; + const { period } = await api<{ period: any }>(`/api/academic_periods/${id}`); + return normalizeAcademicPeriod(period); } export async function getActiveAcademicPeriod(): Promise { - const { period } = await api<{ period: AcademicPeriod | null }>(`/api/academic_periods/active`); - return period ?? null; + const { period } = await api<{ period: any | null }>(`/api/academic_periods/active`); + return period ? normalizeAcademicPeriod(period) : null; } export async function createAcademicPeriod(payload: { @@ -76,12 +93,12 @@ export async function createAcademicPeriod(payload: { endDate: string; periodType: 'schuljahr' | 'semester' | 'trimester'; }): Promise { - const { period } = await api<{ period: AcademicPeriod }>(`/api/academic_periods`, { + const { period } = await api<{ period: any }>(`/api/academic_periods`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); - return period; + return normalizeAcademicPeriod(period); } export async function updateAcademicPeriod( @@ -94,36 +111,36 @@ export async function updateAcademicPeriod( periodType: 'schuljahr' | 'semester' | 'trimester'; }> ): Promise { - const { period } = await api<{ period: AcademicPeriod }>(`/api/academic_periods/${id}`, { + const { period } = await api<{ period: any }>(`/api/academic_periods/${id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); - return period; + return normalizeAcademicPeriod(period); } export async function setActiveAcademicPeriod(id: number): Promise { - const { period } = await api<{ period: AcademicPeriod }>(`/api/academic_periods/${id}/activate`, { + const { period } = await api<{ period: any }>(`/api/academic_periods/${id}/activate`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, }); - return period; + return normalizeAcademicPeriod(period); } export async function archiveAcademicPeriod(id: number): Promise { - const { period } = await api<{ period: AcademicPeriod }>(`/api/academic_periods/${id}/archive`, { + const { period } = await api<{ period: any }>(`/api/academic_periods/${id}/archive`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, }); - return period; + return normalizeAcademicPeriod(period); } export async function restoreAcademicPeriod(id: number): Promise { - const { period } = await api<{ period: AcademicPeriod }>(`/api/academic_periods/${id}/restore`, { + const { period } = await api<{ period: any }>(`/api/academic_periods/${id}/restore`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, }); - return period; + return normalizeAcademicPeriod(period); } export async function getAcademicPeriodUsage(id: number): Promise { diff --git a/dashboard/src/dashboard.tsx b/dashboard/src/dashboard.tsx index 0e931bb..e490ad9 100644 --- a/dashboard/src/dashboard.tsx +++ b/dashboard/src/dashboard.tsx @@ -1,41 +1,175 @@ -import React, { useEffect, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { ButtonComponent } from '@syncfusion/ej2-react-buttons'; +import { + GridComponent, + ColumnsDirective, + ColumnDirective, + Inject, + Page, + Search, + Sort, + Toolbar, +} from '@syncfusion/ej2-react-grids'; +import { DialogComponent } from '@syncfusion/ej2-react-popups'; +import { MessageComponent, ToastComponent } from '@syncfusion/ej2-react-notifications'; 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 { + getAcademicPeriodForDate, + getActiveAcademicPeriod, + listAcademicPeriods, + type AcademicPeriod, +} from './apiAcademicPeriods'; import { getHolidayBannerSetting } from './apiSystemSettings'; import { formatIsoDateForDisplay } from './dateFormatting'; +import { useAuth } from './useAuth'; +import { useNavigate } from 'react-router-dom'; -const REFRESH_INTERVAL = 15000; // 15 Sekunden +const REFRESH_INTERVAL_MS = 15000; +const UNASSIGNED_GROUP_NAME = 'Nicht zugeordnet'; -type FilterType = 'all' | 'online' | 'offline' | 'warning'; +type DashboardFilter = 'all' | 'online' | 'offline' | 'warning'; interface ActiveEvent { id: string; title: string; - event_type: string; + eventType: string; start: string; end: string; - recurrenceRule?: string; isRecurring: boolean; } -interface GroupEvents { - [groupId: number]: ActiveEvent | null; +type GroupEvents = Record; + +interface GroupRow { + id: number; + name: string; + totalClients: number; + onlineClients: number; + offlineClients: number; + healthLabel: string; +} + +interface ClientRow { + uuid: string; + description: string; + groupName: string; + hostname: string; + ip: string; + isAlive: boolean; + aliveLabel: string; + lastAlive: string; +} + +function parseUtcDate(value?: string | null): Date | null { + if (!value) return null; + const hasTimezone = /[zZ]$|[+-]\d{2}:?\d{2}$/.test(value); + const normalized = hasTimezone ? value : `${value}Z`; + const parsed = new Date(normalized); + if (Number.isNaN(parsed.getTime())) return null; + return parsed; +} + +function formatRelative(value?: string | null): string { + const date = parseUtcDate(value); + if (!date) return 'Keine Daten'; + + const diffMs = Date.now() - date.getTime(); + const diffMinutes = Math.floor(diffMs / 60000); + const diffHours = Math.floor(diffMinutes / 60); + const diffDays = Math.floor(diffHours / 24); + + if (diffMinutes < 1) return 'gerade eben'; + if (diffMinutes < 60) return `vor ${diffMinutes} Min.`; + if (diffHours < 24) return `vor ${diffHours} Std.`; + return `vor ${diffDays} Tag${diffDays === 1 ? '' : 'en'}`; +} + +function eventTypeLabel(type?: string): string { + switch ((type || '').toLowerCase()) { + case 'presentation': + return 'Präsentation'; + case 'website': + return 'Website'; + case 'video': + return 'Video'; + case 'webuntis': + return 'WebUntis'; + case 'message': + return 'Mitteilung'; + default: + return 'Inhalt'; + } +} + +function healthBadge(online: number, total: number) { + const ratio = total === 0 ? 0 : online / total; + let bg = '#fee2e2'; + let color = '#991b1b'; + let label = 'Kritisch'; + + if (ratio === 1) { + bg = '#dcfce7'; + color = '#166534'; + label = 'Optimal'; + } else if (ratio >= 0.5) { + bg = '#fef3c7'; + color = '#92400e'; + label = 'Teilweise'; + } + + return ( + + {label} + + ); +} + +function aliveBadge(isAlive: boolean) { + return ( + + {isAlive ? 'Online' : 'Offline'} + + ); } const Dashboard: React.FC = () => { + const navigate = useNavigate(); + const { user } = useAuth(); + const toastRef = useRef(null); 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); + const [filter, setFilter] = useState('all'); + const [selectedGroupId, setSelectedGroupId] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [lastUpdate, setLastUpdate] = useState(new Date()); + + const [restartDialogGroup, setRestartDialogGroup] = useState(null); + const [restartBusy, setRestartBusy] = useState(false); - // Holiday status state const [holidayBannerEnabled, setHolidayBannerEnabled] = useState(true); const [activePeriod, setActivePeriod] = useState(null); const [holidayOverlapCount, setHolidayOverlapCount] = useState(0); @@ -43,864 +177,761 @@ const Dashboard: React.FC = () => { const [holidayLast, setHolidayLast] = useState(null); const [holidayLoading, setHolidayLoading] = useState(false); const [holidayError, setHolidayError] = useState(null); + const holidayRequestSeqRef = useRef(0); + const lastValidActivePeriodRef = useRef(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); - } - }; + const isAdminOrHigher = user?.role === 'admin' || user?.role === 'superadmin'; - fetchAndUpdate(); - const interval = setInterval(fetchAndUpdate, REFRESH_INTERVAL); - return () => clearInterval(interval); + const openHolidaySettings = useCallback(() => { + navigate('/einstellungen?focus=holidays'); + }, [navigate]); + + const isUnassignedGroup = useCallback((groupName: string) => { + return groupName.trim().toLowerCase() === UNASSIGNED_GROUP_NAME.toLowerCase(); }, []); - // 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 + const loadActiveEvents = useCallback(async (groupsList: Group[]) => { + const now = new Date(); + const start = new Date(now.getTime() - 60000); + const end = new Date(now.getTime() + 60000); + + const entries = await Promise.all( + groupsList.map(async group => { + try { + const events = await fetchEvents(String(group.id), false, { start, end, expand: true }); + if (events && events.length > 0) { + const event = events[0]; + return [ + group.id, + { + id: event.id, + title: event.subject || 'Unbenannter Event', + eventType: event.type || 'unknown', + start: event.startTime, + end: event.endTime, + isRecurring: Boolean(event.recurrenceRule), + } as ActiveEvent, + ] as const; + } + return [group.id, null] as const; + } catch { + return [group.id, null] as const; } - } catch (e) { - console.error('Fehler beim Laden der Banner-Einstellung:', e); - // Continue with default (enabled) + }) + ); + + setActiveEvents(Object.fromEntries(entries)); + }, []); + + const loadDashboard = useCallback(async () => { + setError(null); + try { + const groupsData = await fetchGroupsWithClients(); + setGroups(groupsData); + setLastUpdate(new Date()); + const selectableGroups = groupsData.filter(group => !isUnassignedGroup(group.name)); + + setSelectedGroupId(prevSelectedGroupId => { + if (prevSelectedGroupId === null) { + return selectableGroups[0]?.id ?? null; + } + + const selectedGroup = groupsData.find(group => group.id === prevSelectedGroupId); + if (!selectedGroup || isUnassignedGroup(selectedGroup.name)) { + return selectableGroups[0]?.id ?? null; + } + + return prevSelectedGroupId; + }); + + void loadActiveEvents(groupsData); + } catch (loadError) { + setError(loadError instanceof Error ? loadError.message : 'Dashboard-Daten konnten nicht geladen werden'); + } finally { + setLoading(false); + } + }, [isUnassignedGroup, loadActiveEvents]); + + useEffect(() => { + void loadDashboard(); + const interval = window.setInterval(() => { + void loadDashboard(); + }, REFRESH_INTERVAL_MS); + + return () => window.clearInterval(interval); + }, [loadDashboard]); + + const loadHolidayStatus = useCallback(async () => { + const requestSeq = ++holidayRequestSeqRef.current; + + try { + const bannerSetting = await getHolidayBannerSetting(); + if (requestSeq !== holidayRequestSeqRef.current) return; + setHolidayBannerEnabled(bannerSetting.enabled); + if (!bannerSetting.enabled) return; + } catch { + if (requestSeq !== holidayRequestSeqRef.current) return; + setHolidayBannerEnabled(true); + } + + if (requestSeq !== holidayRequestSeqRef.current) return; + setHolidayLoading(true); + setHolidayError(null); + try { + let period: AcademicPeriod | null = null; + let lookupFailed = false; + + // 1) Prefer explicit active-period endpoint. + try { + period = await getActiveAcademicPeriod(); + } catch { + lookupFailed = true; } - 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); + // 2) Fall back to list-based active flag (same logic as settings page). + if (!period) { + try { + const periods = await listAcademicPeriods({ includeArchived: true }); + period = periods.find(candidate => candidate.isActive && !candidate.isArchived) || null; + } catch { + lookupFailed = true; } - } catch (e) { - const msg = e instanceof Error ? e.message : 'Ferienstatus konnte nicht geladen werden'; - setHolidayError(msg); - } finally { + } + + // 3) Last resort: period covering today's date. + if (!period) { + try { + period = await getAcademicPeriodForDate(new Date()); + } catch { + lookupFailed = true; + } + } + + if (requestSeq !== holidayRequestSeqRef.current) return; + + if (!period && lookupFailed) { + // Keep previously known period on transient lookup failures to avoid false "no period" UI. + if (lastValidActivePeriodRef.current) { + period = lastValidActivePeriodRef.current; + } else { + setHolidayError('Aktive Periode konnte derzeit nicht verifiziert werden. Bitte erneut aktualisieren.'); + return; + } + } + + if (period) { + lastValidActivePeriodRef.current = period; + } + + setActivePeriod(prevPeriod => { + if (!period && !prevPeriod) return prevPeriod; + if (period && prevPeriod && prevPeriod.id === period.id) return prevPeriod; + return period || null; + }); + + if (!period) { + setHolidayOverlapCount(0); + setHolidayFirst(null); + setHolidayLast(null); + return; + } + + const holidayData = await listHolidays(undefined, period.id); + const list = holidayData.holidays || []; + const periodStart = new Date(`${period.startDate}T00:00:00`); + const periodEnd = new Date(`${period.endDate}T23:59:59`); + + const overlapping = list.filter(holiday => { + const holidayStart = new Date(`${holiday.start_date}T00:00:00`); + const holidayEnd = new Date(`${holiday.end_date}T23:59:59`); + return holidayStart <= periodEnd && holidayEnd >= periodStart; + }); + + 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); + } + } catch (holidayLoadError) { + if (requestSeq !== holidayRequestSeqRef.current) return; + setHolidayError( + holidayLoadError instanceof Error ? holidayLoadError.message : 'Ferienstatus konnte nicht geladen werden' + ); + } finally { + if (requestSeq === holidayRequestSeqRef.current) { 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); - }; + useEffect(() => { + void loadHolidayStatus(); + }, [loadHolidayStatus]); - // 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; + const filteredGroups = useMemo(() => { + return groups.filter(group => { + const total = group.clients.length; + const online = group.clients.filter(client => client.is_alive).length; + const offline = total - online; + if (filter === 'online') return total > 0 && online === total; + if (filter === 'offline') return offline > 0; + if (filter === 'warning') return online > 0 && offline > 0; + return true; }); - }; + }, [filter, groups]); - // 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'; + const unassignedGroup = useMemo(() => { + return groups.find(group => isUnassignedGroup(group.name)) || null; + }, [groups, isUnassignedGroup]); + + const unassignedClientCount = unassignedGroup?.clients.length || 0; + const hasUnassignedClients = unassignedClientCount > 0; + + const visibleFilteredGroups = useMemo(() => { + return filteredGroups.filter(group => !isUnassignedGroup(group.name)); + }, [filteredGroups, isUnassignedGroup]); + + const globalStats = useMemo(() => { + const allClients = groups.flatMap(group => group.clients); + const total = allClients.length; + const online = allClients.filter(client => client.is_alive).length; + const offline = total - online; + const warningGroups = groups.filter(group => { + if (isUnassignedGroup(group.name)) return false; + const totalClients = group.clients.length; + if (totalClients === 0) return false; + const onlineClients = group.clients.filter(client => client.is_alive).length; + return onlineClients > 0 && onlineClients < totalClients; + }).length; + + const activeIncidents = groups.filter(group => { + if (isUnassignedGroup(group.name)) return false; + return group.clients.some(client => !client.is_alive); + }).length; + const onlineRatio = total === 0 ? 0 : Math.round((online / total) * 100); + + return { + total, + online, + offline, + warningGroups, + activeIncidents, + onlineRatio, + }; + }, [groups, isUnassignedGroup]); + + const groupRows = useMemo(() => { + return visibleFilteredGroups.map(group => { + const totalClients = group.clients.length; + const onlineClients = group.clients.filter(client => client.is_alive).length; + const offlineClients = totalClients - onlineClients; + const healthPercent = totalClients === 0 ? 0 : Math.round((onlineClients / totalClients) * 100); + const healthLabel = + healthPercent === 100 ? 'Optimal' : healthPercent >= 50 ? 'Teilweise' : 'Kritisch'; + + return { + id: group.id, + name: group.name, + totalClients, + onlineClients, + offlineClients, + healthLabel, + }; + }); + }, [visibleFilteredGroups]); + + const clientRows = useMemo(() => { + const byGroup = new Map(groups.map(group => [group.id, group])); + + if (selectedGroupId && byGroup.has(selectedGroupId)) { + const selected = byGroup.get(selectedGroupId)!; + return selected.clients.map(client => ({ + uuid: client.uuid, + description: client.description || 'Ohne Bezeichnung', + groupName: selected.name, + hostname: client.hostname || '-', + ip: client.ip || '-', + isAlive: Boolean(client.is_alive), + aliveLabel: client.is_alive ? 'Online' : 'Offline', + lastAlive: formatRelative(client.last_alive), + })); } - - 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', - }); - } - }; + return visibleFilteredGroups.flatMap(group => + group.clients.map(client => ({ + uuid: client.uuid, + description: client.description || 'Ohne Bezeichnung', + groupName: group.name, + hostname: client.hostname || '-', + ip: client.ip || '-', + isAlive: Boolean(client.is_alive), + aliveLabel: client.is_alive ? 'Online' : 'Offline', + lastAlive: formatRelative(client.last_alive), + })) + ); + }, [groups, selectedGroupId, visibleFilteredGroups]); - // 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) { + const incidentGroups = useMemo(() => { + return visibleFilteredGroups + .map(group => { + const offlineClients = group.clients.filter(client => !client.is_alive); + return { + group, + offlineClients, + }; + }) + .filter(item => item.offlineClients.length > 0) + .sort((a, b) => b.offlineClients.length - a.offlineClients.length); + }, [visibleFilteredGroups]); + + const contentRows = useMemo(() => { + return visibleFilteredGroups.map(group => { + const activeEvent = activeEvents[group.id]; + const online = group.clients.filter(client => client.is_alive).length; + return { + id: group.id, + groupName: group.name, + online, + total: group.clients.length, + title: activeEvent?.title || 'Kein aktiver Inhalt', + contentType: activeEvent ? eventTypeLabel(activeEvent.eventType) : '-', + recurring: activeEvent?.isRecurring ? 'Ja' : 'Nein', + }; + }); + }, [activeEvents, visibleFilteredGroups]); + + const handleManualRefresh = useCallback(async () => { + setLoading(true); + await Promise.all([loadDashboard(), loadHolidayStatus()]); + }, [loadDashboard, loadHolidayStatus]); + + const openRestartDialog = useCallback((groupId: number) => { + const group = groups.find(item => item.id === groupId) || null; + if (!group) return; + + const offlineCount = group.clients.filter(client => !client.is_alive).length; + if (offlineCount === 0) { toastRef.current?.show({ - title: 'Keine Offline-Geräte', - content: `Alle Infoscreens in "${group.name}" sind online.`, + title: 'Keine Offline-Clients', + 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; + setRestartDialogGroup(group); + }, [groups]); - let successCount = 0; - let failCount = 0; - - for (const client of offlineClients) { - try { - await restartClient(client.uuid); - successCount++; - } catch { - failCount++; - } + const handleRestartGroupOffline = useCallback(async () => { + if (!restartDialogGroup) return; + const offlineClients = restartDialogGroup.clients.filter(client => !client.is_alive); + if (offlineClients.length === 0) { + setRestartDialogGroup(null); + return; } + setRestartBusy(true); + const results = await Promise.allSettled(offlineClients.map(client => restartClient(client.uuid))); + const successCount = results.filter(result => result.status === 'fulfilled').length; + const failCount = results.length - successCount; + toastRef.current?.show({ title: 'Bulk-Neustart abgeschlossen', - content: `✓ ${successCount} erfolgreich, ✗ ${failCount} fehlgeschlagen`, + content: `Gruppe ${restartDialogGroup.name}: ${successCount} erfolgreich, ${failCount} fehlgeschlagen`, cssClass: failCount > 0 ? 'e-toast-warning' : 'e-toast-success', icon: failCount > 0 ? 'e-warning toast-icons' : 'e-success toast-icons', + timeOut: 4000, }); - }; - // 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; + setRestartBusy(false); + setRestartDialogGroup(null); + }, [restartDialogGroup]); - 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. - - ); - }; + const headerButtons = ( +
+ setFilter('all')}> + Alle ({groups.length}) + + setFilter('online')}> + Stabil + + setFilter('warning')}> + Warnung + + setFilter('offline')}> + Offline + +
+ ); return ( -
- - -
-

- Dashboard -

-

- Übersicht aller Raumgruppen und deren Infoscreens -

-
+
+ - {/* Holiday Status Banner */} - {holidayBannerEnabled && ( -
- +
+
+

Dashboard

+

+ Systemstatus und aktive Inhalte für Benutzer, Redaktion und Administration +

+

+ Letzte Aktualisierung: {lastUpdate.toLocaleTimeString('de-DE')} +

+
+
+ {headerButtons} + + {loading ? 'Aktualisiere...' : 'Aktualisieren'} + +
+
+ + {error && ( +
+ {error}
)} - {/* Global Statistics Summary */} - {(() => { - const globalStats = getGlobalStats(); - return ( -
-
-
-
- Gesamt Infoscreens -
-
- {globalStats.total} -
-
- -
-
- 🟢 Online -
-
- {globalStats.online} - - ({globalStats.total > 0 ? Math.round(globalStats.ratio * 100) : 0}%) - -
-
+ {holidayBannerEnabled && ( +
+ {(() => { + let severity: 'Info' | 'Warning' = 'Info'; + let text = 'Ferienstatus wird geladen...'; + let actionLabel: string | null = null; -
-
- 🔴 Offline -
-
- {globalStats.offline} -
-
+ if (!holidayLoading && holidayError) { + severity = 'Warning'; + text = holidayError; + actionLabel = 'In Einstellungen prüfen'; + } else if (!holidayLoading && !holidayError && !activePeriod) { + severity = 'Warning'; + text = 'Keine aktive akademische Periode gesetzt.'; + actionLabel = 'In Einstellungen setzen'; + } else if (!holidayLoading && !holidayError && activePeriod && holidayOverlapCount === 0) { + severity = 'Warning'; + text = `Aktive Periode: ${activePeriod.name}. Für diese Periode wurden noch keine Ferien importiert.`; + actionLabel = 'Ferien importieren'; + } else if (!holidayLoading && !holidayError && activePeriod) { + severity = 'Info'; + text = `Aktive Periode: ${activePeriod.name} (${formatIsoDateForDisplay(activePeriod.startDate)} bis ${formatIsoDateForDisplay(activePeriod.endDate)}), Ferienüberschneidungen: ${holidayOverlapCount}${ + holidayFirst && holidayLast + ? ` (von ${formatIsoDateForDisplay(holidayFirst)} bis ${formatIsoDateForDisplay(holidayLast)})` + : '' + }`; + actionLabel = 'In Einstellungen prüfen'; + } -
-
- ⚠️ Gruppen mit Warnungen -
-
- {globalStats.warningGroups} -
+ return ( +
+ {text} + {isAdminOrHigher && actionLabel && !holidayLoading && ( +
+ + {actionLabel} + +
+ )}
+ ); + })()} +
+ )} -
-
- Zuletzt aktualisiert: {getTimeSinceLastAlive(lastUpdate.toISOString())} -
- - Aktualisieren - -
-
+ {isAdminOrHigher && hasUnassignedClients && ( +
+ + Es gibt {unassignedClientCount} Client{unassignedClientCount === 1 ? '' : 's'} in der Gruppe + {' '} + {UNASSIGNED_GROUP_NAME}. Bitte den Client nach Registrierung einer Standard-Gruppe zuweisen. + +
+ )} + +
+
+
+
Clients gesamt
+
{globalStats.total}
- ); - })()} - - {/* 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}) - +
+
+
+
Online
+
{globalStats.online}
+
{globalStats.onlineRatio}% verfügbar
+
+
+
+
+
Warnungsgruppen
+
{globalStats.warningGroups}
+
+
+
+
+
Offline
+
{globalStats.offline}
+
{globalStats.activeIncidents} Gruppen betroffen
+
+
- {/* Group Cards */} - {(() => { - const filteredGroups = getFilteredGroups(); - - if (filteredGroups.length === 0) { - return ( -
-
- {filter === 'all' - ? 'Keine Raumgruppen gefunden' - : `Keine Gruppen mit Filter "${filter}" gefunden` - } -
+
+
+
+
+
Aktive Inhalte je Gruppe
- ); - } - - 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" - /> -
-
- )) - )} -
- )} -
-
- ); - })}
- ); - })()} +
+ + + + + + + + + +
+
+ +
+
+
+
Aktive Vorfälle
+
+
+
+ {incidentGroups.length === 0 && ( + Keine aktiven Ausfälle in den gefilterten Gruppen. + )} + {incidentGroups.map(item => ( +
+
+
{item.group.name}
+
+ {item.offlineClients.length} von {item.group.clients.length} Clients offline +
+
+ {isAdminOrHigher && ( + openRestartDialog(item.group.id)} + > + Offline neu starten + + )} +
+ ))} +
+
+
+ +
+
+
+
Gruppenstatus
+
+
+
+ { + const data = args.data as GroupRow; + setSelectedGroupId(data.id); + }} + > + + + + + + healthBadge(props.onlineClients, props.totalClients)} + /> + {isAdminOrHigher && ( + ( + openRestartDialog(props.id)} + disabled={props.offlineClients === 0} + > + Offline neu starten + + )} + /> + )} + + + +
+
+ +
+
+
+
Client-Status
+
+ setSelectedGroupId(null)} + > + Alle Gruppen + + {groupRows.map(group => ( + setSelectedGroupId(group.id)} + > + {group.name} + + ))} +
+
+
+
+ + + + + + + aliveBadge(props.isAlive)} + /> + + + + +
+
+ + { + if (!restartBusy) setRestartDialogGroup(null); + }} + footerTemplate={() => ( +
+ setRestartDialogGroup(null)}> + Abbrechen + + void handleRestartGroupOffline()}> + {restartBusy ? 'Starte neu...' : 'Neu starten'} + +
+ )} + > +
+ {restartDialogGroup && ( + <> +

+ Es werden alle aktuell offline gemeldeten Clients dieser Gruppe neu gestartet. +

+
+ Betroffene Clients:{' '} + {restartDialogGroup.clients.filter((client: Client) => !client.is_alive).length} +
+
+ {restartDialogGroup.clients + .filter((client: Client) => !client.is_alive) + .map((client: Client) => ( +
+ {client.description || client.uuid} + {client.ip || '-'} +
+ ))} +
+ + )} +
+
); }; diff --git a/dashboard/src/settings.tsx b/dashboard/src/settings.tsx index 036873a..de344d5 100644 --- a/dashboard/src/settings.tsx +++ b/dashboard/src/settings.tsx @@ -21,12 +21,14 @@ import { type PeriodUsage } from './apiAcademicPeriods'; import { formatIsoDateForDisplay } from './dateFormatting'; -import { Link } from 'react-router-dom'; +import { Link, useLocation } from 'react-router-dom'; // Minimal event type for Syncfusion Tab 'selected' callback type TabSelectedEvent = { selectedIndex?: number }; const Einstellungen: React.FC = () => { + const location = useLocation(); + // Presentation settings state const [presentationInterval, setPresentationInterval] = React.useState(10); const [presentationPageProgress, setPresentationPageProgress] = React.useState(true); @@ -670,6 +672,8 @@ const Einstellungen: React.FC = () => { const isAdmin = !!(user && ['admin', 'superadmin'].includes(user.role)); const isSuperadmin = !!(user && user.role === 'superadmin'); + const [rootTabIndex, setRootTabIndex] = React.useState(0); + // Preserve selected nested-tab indices to avoid resets on parent re-render const [academicTabIndex, setAcademicTabIndex] = React.useState(0); const [displayTabIndex, setDisplayTabIndex] = React.useState(0); @@ -678,6 +682,22 @@ const Einstellungen: React.FC = () => { const [usersTabIndex, setUsersTabIndex] = React.useState(0); const [systemTabIndex, setSystemTabIndex] = React.useState(0); + React.useEffect(() => { + const params = new URLSearchParams(location.search); + const focus = params.get('focus'); + + if (focus === 'holidays') { + setRootTabIndex(0); + setAcademicTabIndex(1); + return; + } + + if (focus === 'academic-periods') { + setRootTabIndex(0); + setAcademicTabIndex(0); + } + }, [location.search]); + // ---------- Leaf content functions (second-level tabs) ---------- // Academic Calendar // (Old separate Import/List tab contents removed in favor of combined tab) @@ -1695,7 +1715,11 @@ const Einstellungen: React.FC = () => {

Einstellungen

- + setRootTabIndex(e.selectedIndex ?? 0)} + > {isAdmin && ( diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index d8e8b61..8131fa2 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -94,6 +94,7 @@ services: command: > bash -c "alembic -c /app/server/alembic.ini upgrade head && python /app/server/init_defaults.py && + python /app/server/init_academic_periods.py && exec gunicorn server.wsgi:app --bind 0.0.0.0:8000" dashboard: diff --git a/server/init_academic_periods.py b/server/init_academic_periods.py index 3afa1a8..986db3b 100644 --- a/server/init_academic_periods.py +++ b/server/init_academic_periods.py @@ -1,7 +1,11 @@ #!/usr/bin/env python3 """ -Erstellt Standard-Schuljahre für österreichische Schulen -Führe dieses Skript nach der Migration aus, um Standard-Perioden zu erstellen. +Erstellt Standard-Schuljahre und setzt automatisch die aktive Periode. + +Dieses Skript ist idempotent: +- Wenn keine Perioden existieren, werden Standard-Perioden erstellt. +- Danach wird (bei jedem Lauf) die nicht-archivierte Periode aktiviert, + die das heutige Datum abdeckt. """ from datetime import date @@ -11,54 +15,94 @@ import sys sys.path.append('/workspace') +def _create_default_periods_if_missing(session): + """Erstellt Standard-Schuljahre nur dann, wenn noch keine Perioden existieren.""" + existing = session.query(AcademicPeriod).first() + if existing: + print("Academic periods already exist. Skipping creation.") + return False + + periods = [ + { + 'name': 'Schuljahr 2024/25', + 'display_name': 'SJ 24/25', + 'start_date': date(2024, 9, 2), + 'end_date': date(2025, 7, 4), + 'period_type': AcademicPeriodType.schuljahr, + 'is_active': False + }, + { + 'name': 'Schuljahr 2025/26', + 'display_name': 'SJ 25/26', + 'start_date': date(2025, 9, 1), + 'end_date': date(2026, 7, 3), + 'period_type': AcademicPeriodType.schuljahr, + 'is_active': False + }, + { + 'name': 'Schuljahr 2026/27', + 'display_name': 'SJ 26/27', + 'start_date': date(2026, 9, 7), + 'end_date': date(2027, 7, 2), + 'period_type': AcademicPeriodType.schuljahr, + 'is_active': False + } + ] + + for period_data in periods: + period = AcademicPeriod(**period_data) + session.add(period) + + session.flush() + print(f"Successfully created {len(periods)} academic periods") + return True + + +def _activate_period_for_today(session): + """Aktiviert genau eine Periode: die Periode, die heute abdeckt.""" + today = date.today() + + period_for_today = ( + session.query(AcademicPeriod) + .filter( + AcademicPeriod.is_archived == False, + AcademicPeriod.start_date <= today, + AcademicPeriod.end_date >= today, + ) + .order_by(AcademicPeriod.start_date.desc()) + .first() + ) + + # Immer zunächst alle aktiven Perioden deaktivieren, um den Zustand konsistent zu halten. + session.query(AcademicPeriod).filter(AcademicPeriod.is_active == True).update( + {AcademicPeriod.is_active: False}, + synchronize_session=False, + ) + + if period_for_today: + period_for_today.is_active = True + print( + f"Activated academic period for today ({today}): {period_for_today.name} " + f"[{period_for_today.start_date} - {period_for_today.end_date}]" + ) + else: + print( + f"WARNING: No academic period covers today ({today}). " + "All periods remain inactive." + ) + + def create_default_academic_periods(): - """Erstellt Standard-Schuljahre für österreichische Schulen""" + """Erstellt Standard-Perioden (falls nötig) und setzt aktive Periode für heute.""" session = Session() try: - # Prüfe ob bereits Perioden existieren - existing = session.query(AcademicPeriod).first() - if existing: - print("Academic periods already exist. Skipping creation.") - return - - # Standard Schuljahre erstellen - periods = [ - { - 'name': 'Schuljahr 2024/25', - 'display_name': 'SJ 24/25', - 'start_date': date(2024, 9, 2), - 'end_date': date(2025, 7, 4), - 'period_type': AcademicPeriodType.schuljahr, - 'is_active': True # Aktuelles Schuljahr - }, - { - 'name': 'Schuljahr 2025/26', - 'display_name': 'SJ 25/26', - 'start_date': date(2025, 9, 1), - 'end_date': date(2026, 7, 3), - 'period_type': AcademicPeriodType.schuljahr, - 'is_active': False - }, - { - 'name': 'Schuljahr 2026/27', - 'display_name': 'SJ 26/27', - 'start_date': date(2026, 9, 7), - 'end_date': date(2027, 7, 2), - 'period_type': AcademicPeriodType.schuljahr, - 'is_active': False - } - ] - - for period_data in periods: - period = AcademicPeriod(**period_data) - session.add(period) - + _create_default_periods_if_missing(session) + _activate_period_for_today(session) session.commit() - print(f"Successfully created {len(periods)} academic periods") # Zeige erstellte Perioden - for period in session.query(AcademicPeriod).all(): + for period in session.query(AcademicPeriod).order_by(AcademicPeriod.start_date.asc()).all(): status = "AKTIV" if period.is_active else "inaktiv" print( f" - {period.name} ({period.start_date} - {period.end_date}) [{status}]")