feat(video, settings, docs): add muted playback, nested Settings tabs, merge holidays tab; bump 2025.1.0-alpha.11

API/DB: add Event.muted with full CRUD wiring (Alembic migration), persist/return with autoplay/loop/volume
Dashboard: per‑event video options (autoplay/loop/volume/muted) with system defaults; Settings → Events → Videos defaults
Settings UX: nested tabs with controlled selection; Academic Calendar: merge “Schulferien Import”+“Liste” into “📥 Import & Liste”
Docs: update README and copilot-instructions (video payload, streaming 206, defaults keys); update program-info.json changelog; bump version to 2025.1.0‑alpha.11
This commit is contained in:
RobbStarkAustria
2025-11-05 19:30:10 +00:00
parent 38800cec68
commit 452ba3033b
12 changed files with 793 additions and 381 deletions

View File

@@ -1,11 +1,11 @@
{
"appName": "Infoscreen-Management",
"version": "2025.1.0-alpha.10",
"version": "2025.1.0-alpha.11",
"copyright": "© 2025 Third-Age-Applications",
"supportContact": "support@third-age-applications.com",
"description": "Eine zentrale Verwaltungsoberfläche für digitale Informationsbildschirme.",
"techStack": {
"Frontend": "React, Vite, TypeScript",
"Frontend": "React, Vite, TypeScript, Syncfusion UI Components (Material 3)",
"Backend": "Python (Flask), SQLAlchemy",
"Database": "MariaDB",
"Realtime": "Mosquitto (MQTT)",
@@ -30,6 +30,17 @@
"commitId": "9f2ae8b44c3a"
},
"changelog": [
{
"version": "2025.1.0-alpha.11",
"date": "2025-11-05",
"changes": [
"🎬 Client: Clients können jetzt Video-Events aus dem Terminplaner abspielen (Streaming mit Seek via Byte-Range).",
"🧭 Einstellungen: Neues verschachteltes Tab-Layout mit kontrollierter Tab-Auswahl (keine Sprünge in Unter-Tabs).",
"📅 Einstellungen Akademischer Kalender: Schulferien Import und Liste zusammengeführt in ‘📥 Import & Liste.",
"🗓️ Events-Modal: Video-Optionen erweitert (Autoplay, Loop, Lautstärke, Ton aus). Werte werden bei neuen Terminen aus System-Defaults initialisiert.",
"⚙️ Einstellungen Events Videos: Globale Defaults für Autoplay, Loop, Lautstärke und Mute (Keys: video_autoplay, video_loop, video_volume, video_muted)."
]
},
{
"version": "2025.1.0-alpha.10",
"date": "2025-10-25",

View File

@@ -63,7 +63,14 @@ type Event = {
isHoliday?: boolean; // marker for styling/logic
MediaId?: string | number;
SlideshowInterval?: number;
PageProgress?: boolean;
AutoProgress?: boolean;
WebsiteUrl?: string;
// Video-specific fields
Autoplay?: boolean;
Loop?: boolean;
Volume?: number;
Muted?: boolean;
Icon?: string; // <--- Icon ergänzen!
Type?: string; // <--- Typ ergänzen, falls benötigt
OccurrenceOfId?: string; // Serieninstanz
@@ -380,6 +387,14 @@ const Appointments: React.FC = () => {
EndTime: parseEventDate(e.EndTime),
IsAllDay: e.IsAllDay,
MediaId: e.MediaId,
SlideshowInterval: e.SlideshowInterval,
PageProgress: e.PageProgress,
AutoProgress: e.AutoProgress,
WebsiteUrl: e.WebsiteUrl,
Autoplay: e.Autoplay,
Loop: e.Loop,
Volume: e.Volume,
Muted: e.Muted,
Icon: e.Icon,
Type: e.Type,
OccurrenceOfId: e.OccurrenceOfId,
@@ -398,6 +413,14 @@ const Appointments: React.FC = () => {
EndTime: parseEventDate(e.EndTime),
IsAllDay: e.IsAllDay,
MediaId: e.MediaId,
SlideshowInterval: e.SlideshowInterval,
PageProgress: e.PageProgress,
AutoProgress: e.AutoProgress,
WebsiteUrl: e.WebsiteUrl,
Autoplay: e.Autoplay,
Loop: e.Loop,
Volume: e.Volume,
Muted: e.Muted,
Icon: e.Icon,
Type: e.Type,
OccurrenceOfId: e.OccurrenceOfId,
@@ -810,8 +833,6 @@ const Appointments: React.FC = () => {
groupName={groups.find(g => g.id === selectedGroupId) ?? { id: selectedGroupId, name: '' }}
groupColor={selectedGroupId ? getGroupColor(selectedGroupId, groups) : undefined}
editMode={editMode} // NEU: Prop für Editiermodus
blockHolidays={!allowScheduleOnHolidays}
isHolidayRange={(s, e) => isWithinHolidayRange(s, e)}
/>
<ScheduleComponent
ref={scheduleRef}
@@ -1160,7 +1181,13 @@ const Appointments: React.FC = () => {
skipHolidays: isSingleOccurrence ? false : (eventDataToUse.SkipHolidays ?? false),
media,
slideshowInterval: eventDataToUse.SlideshowInterval ?? 10,
pageProgress: eventDataToUse.PageProgress ?? true,
autoProgress: eventDataToUse.AutoProgress ?? true,
websiteUrl: eventDataToUse.WebsiteUrl ?? '',
autoplay: eventDataToUse.Autoplay ?? true,
loop: eventDataToUse.Loop ?? true,
volume: eventDataToUse.Volume ?? 0.8,
muted: eventDataToUse.Muted ?? false,
};
setModalInitialData(modalData);

View File

@@ -28,6 +28,7 @@ type CustomEventData = {
autoplay?: boolean;
loop?: boolean;
volume?: number;
muted?: boolean;
};
// Typ für initialData erweitern, damit Id unterstützt wird
@@ -117,13 +118,50 @@ const CustomEventModal: React.FC<CustomEventModalProps> = ({
);
const [websiteUrl, setWebsiteUrl] = React.useState<string>(initialData.websiteUrl ?? '');
// Video-specific state
// Video-specific state with system defaults loading
const [autoplay, setAutoplay] = React.useState<boolean>(initialData.autoplay ?? true);
const [loop, setLoop] = React.useState<boolean>(initialData.loop ?? false);
const [loop, setLoop] = React.useState<boolean>(initialData.loop ?? true);
const [volume, setVolume] = React.useState<number>(initialData.volume ?? 0.8);
const [muted, setMuted] = React.useState<boolean>(initialData.muted ?? false);
const [videoDefaultsLoaded, setVideoDefaultsLoaded] = React.useState<boolean>(false);
const [mediaModalOpen, setMediaModalOpen] = React.useState(false);
// Load system video defaults once when opening for a new video event
React.useEffect(() => {
if (open && !editMode && !videoDefaultsLoaded) {
(async () => {
try {
const api = await import('../apiSystemSettings');
const keys = ['video_autoplay', 'video_loop', 'video_volume', 'video_muted'] as const;
const [autoplayRes, loopRes, volumeRes, mutedRes] = await Promise.all(
keys.map(k => api.getSetting(k).catch(() => ({ value: null } as { value: string | null })))
);
// Only apply defaults if not already set from initialData
if (initialData.autoplay === undefined) {
setAutoplay(autoplayRes.value == null ? true : autoplayRes.value === 'true');
}
if (initialData.loop === undefined) {
setLoop(loopRes.value == null ? true : loopRes.value === 'true');
}
if (initialData.volume === undefined) {
const volParsed = volumeRes.value == null ? 0.8 : parseFloat(String(volumeRes.value));
setVolume(Number.isFinite(volParsed) ? volParsed : 0.8);
}
if (initialData.muted === undefined) {
setMuted(mutedRes.value == null ? false : mutedRes.value === 'true');
}
setVideoDefaultsLoaded(true);
} catch {
// Silently fall back to hard-coded defaults
setVideoDefaultsLoaded(true);
}
})();
}
}, [open, editMode, videoDefaultsLoaded, initialData]);
React.useEffect(() => {
if (open) {
const isSingleOccurrence = initialData.isSingleOccurrence || false;
@@ -154,12 +192,16 @@ const CustomEventModal: React.FC<CustomEventModalProps> = ({
setPageProgress(initialData.pageProgress ?? true);
setAutoProgress(initialData.autoProgress ?? true);
setWebsiteUrl(initialData.websiteUrl ?? '');
// Video fields
setAutoplay(initialData.autoplay ?? true);
setLoop(initialData.loop ?? false);
setVolume(initialData.volume ?? 0.8);
// Video fields - use initialData values when editing
if (editMode) {
setAutoplay(initialData.autoplay ?? true);
setLoop(initialData.loop ?? true);
setVolume(initialData.volume ?? 0.8);
setMuted(initialData.muted ?? false);
}
}
}, [open, initialData]);
}, [open, initialData, editMode]);
React.useEffect(() => {
if (!mediaModalOpen && pendingMedia) {
@@ -296,6 +338,7 @@ const CustomEventModal: React.FC<CustomEventModalProps> = ({
payload.autoplay = autoplay;
payload.loop = loop;
payload.volume = volume;
payload.muted = muted;
}
try {
@@ -664,13 +707,24 @@ const CustomEventModal: React.FC<CustomEventModalProps> = ({
/>
</div>
<div style={{ marginTop: 8 }}>
<TextBoxComponent
placeholder="Lautstärke (0.0 - 1.0)"
floatLabelType="Auto"
type="number"
value={String(volume)}
change={e => setVolume(Math.max(0, Math.min(1, Number(e.value))))}
/>
<label style={{ display: 'block', marginBottom: 4, fontWeight: 500, fontSize: '14px' }}>
Lautstärke
</label>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<TextBoxComponent
placeholder="0.0 - 1.0"
floatLabelType="Never"
type="number"
value={String(volume)}
change={e => setVolume(Math.max(0, Math.min(1, Number(e.value))))}
style={{ flex: 1 }}
/>
<CheckBoxComponent
label="Ton aus"
checked={muted}
change={e => setMuted(e.checked || false)}
/>
</div>
</div>
</div>
)}

View File

@@ -1,9 +1,7 @@
import React from 'react';
import { TabComponent, TabItemDirective, TabItemsDirective } from '@syncfusion/ej2-react-navigations';
import { NumericTextBoxComponent } from '@syncfusion/ej2-react-inputs';
import { TextBoxComponent } from '@syncfusion/ej2-react-inputs';
import { ButtonComponent } from '@syncfusion/ej2-react-buttons';
import { CheckBoxComponent } from '@syncfusion/ej2-react-buttons';
import { NumericTextBoxComponent, TextBoxComponent } from '@syncfusion/ej2-react-inputs';
import { ButtonComponent, CheckBoxComponent } from '@syncfusion/ej2-react-buttons';
import { ToastComponent } from '@syncfusion/ej2-react-notifications';
import { listHolidays, uploadHolidaysCsv, type Holiday } from './apiHolidays';
import { getSupplementTableSettings, updateSupplementTableSettings } from './apiSystemSettings';
@@ -12,6 +10,9 @@ import { DropDownListComponent } from '@syncfusion/ej2-react-dropdowns';
import { listAcademicPeriods, getActiveAcademicPeriod, setActiveAcademicPeriod, type AcademicPeriod } from './apiAcademicPeriods';
import { Link } from 'react-router-dom';
// Minimal event type for Syncfusion Tab 'selected' callback
type TabSelectedEvent = { selectedIndex?: number };
const Einstellungen: React.FC = () => {
// Presentation settings state
const [presentationInterval, setPresentationInterval] = React.useState(10);
@@ -76,6 +77,13 @@ const Einstellungen: React.FC = () => {
const [supplementEnabled, setSupplementEnabled] = React.useState(false);
const [supplementBusy, setSupplementBusy] = React.useState(false);
// Video defaults state (Admin+)
const [videoAutoplay, setVideoAutoplay] = React.useState<boolean>(true);
const [videoLoop, setVideoLoop] = React.useState<boolean>(true);
const [videoVolume, setVideoVolume] = React.useState<number>(0.8);
const [videoMuted, setVideoMuted] = React.useState<boolean>(false);
const [videoBusy, setVideoBusy] = React.useState<boolean>(false);
const showToast = (content: string, cssClass: string = 'e-toast-success') => {
if (toastRef.current) {
toastRef.current.show({
@@ -108,6 +116,30 @@ const Einstellungen: React.FC = () => {
}
}, []);
// Load video default settings (with fallbacks)
const loadVideoSettings = React.useCallback(async () => {
try {
const api = await import('./apiSystemSettings');
const keys = ['video_autoplay', 'video_loop', 'video_volume', 'video_muted'] as const;
const [autoplay, loop, volume, muted] = await Promise.all(
keys.map(k => api.getSetting(k).catch(() => ({ value: null } as { value: string | null })))
);
setVideoAutoplay(autoplay.value == null ? true : autoplay.value === 'true');
setVideoLoop(loop.value == null ? true : loop.value === 'true');
const volParsed = volume.value == null ? 0.8 : parseFloat(String(volume.value));
setVideoVolume(Number.isFinite(volParsed) ? volParsed : 0.8);
setVideoMuted(muted.value == null ? false : muted.value === 'true');
} catch (e) {
// Fallback to defaults on any error
setVideoAutoplay(true);
setVideoLoop(true);
setVideoVolume(0.8);
setVideoMuted(false);
const msg = e instanceof Error ? e.message : 'Fehler beim Laden der Video-Standards';
showToast(msg, 'e-toast-warning');
}
}, []);
const loadAcademicPeriods = React.useCallback(async () => {
try {
const [list, active] = await Promise.all([
@@ -130,9 +162,11 @@ const Einstellungen: React.FC = () => {
// System settings only for admin/superadmin (will render only if allowed)
if (['admin', 'superadmin'].includes(user.role)) {
loadSupplementSettings();
loadPresentationSettings();
loadVideoSettings();
}
}
}, [refresh, loadSupplementSettings, loadAcademicPeriods, user]);
}, [refresh, loadSupplementSettings, loadAcademicPeriods, loadPresentationSettings, loadVideoSettings, user]);
const onUpload = async () => {
if (!file) return;
@@ -174,374 +208,571 @@ const Einstellungen: React.FC = () => {
}
};
const onSaveVideoSettings = async () => {
setVideoBusy(true);
try {
const api = await import('./apiSystemSettings');
await Promise.all([
api.updateSetting('video_autoplay', videoAutoplay ? 'true' : 'false'),
api.updateSetting('video_loop', videoLoop ? 'true' : 'false'),
api.updateSetting('video_volume', String(videoVolume)),
api.updateSetting('video_muted', videoMuted ? 'true' : 'false'),
]);
showToast('Video-Standardeinstellungen gespeichert', 'e-toast-success');
} catch (e) {
const msg = e instanceof Error ? e.message : 'Fehler beim Speichern der Video-Standards';
showToast(msg, 'e-toast-danger');
} finally {
setVideoBusy(false);
}
};
// Determine which tabs to show based on role
const isAdmin = !!(user && ['admin', 'superadmin'].includes(user.role));
const isSuperadmin = !!(user && user.role === 'superadmin');
// Preserve selected nested-tab indices to avoid resets on parent re-render
const [academicTabIndex, setAcademicTabIndex] = React.useState(0);
const [displayTabIndex, setDisplayTabIndex] = React.useState(0);
const [mediaTabIndex, setMediaTabIndex] = React.useState(0);
const [eventsTabIndex, setEventsTabIndex] = React.useState(0);
const [usersTabIndex, setUsersTabIndex] = React.useState(0);
const [systemTabIndex, setSystemTabIndex] = React.useState(0);
// ---------- Leaf content functions (second-level tabs) ----------
// Academic Calendar
// (Old separate Import/List tab contents removed in favor of combined tab)
// Combined Import + List tab content
const HolidaysImportAndListContent = () => (
<div style={{ padding: 20 }}>
{/* Import Card */}
<div className="e-card" style={{ marginBottom: 20 }}>
<div className="e-card-header">
<div className="e-card-header-caption">
<div className="e-card-header-title">Schulferien importieren</div>
</div>
</div>
<div className="e-card-content">
<p style={{ marginBottom: 12, fontSize: '14px', color: '#666' }}>
Unterstützte Formate:
<br /> CSV mit Kopfzeile: <code>name</code>, <code>start_date</code>, <code>end_date</code>, optional <code>region</code>
<br /> TXT/CSV ohne Kopfzeile mit Spalten: interner Name, <strong>Name</strong>, <strong>Start (YYYYMMDD)</strong>, <strong>Ende (YYYYMMDD)</strong>, optional interne Info (ignoriert)
</p>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 12 }}>
<input type="file" accept=".csv,text/csv,.txt,text/plain" onChange={e => setFile(e.target.files?.[0] ?? null)} />
<ButtonComponent cssClass="e-primary" onClick={onUpload} disabled={!file || busy}>
{busy ? 'Importiere…' : 'CSV/TXT importieren'}
</ButtonComponent>
</div>
{message && <div style={{ marginTop: 8, fontSize: '14px' }}>{message}</div>}
</div>
</div>
{/* List Card */}
<div className="e-card">
<div className="e-card-header">
<div className="e-card-header-caption">
<div className="e-card-header-title">Importierte Ferien</div>
</div>
</div>
<div className="e-card-content">
{holidays.length === 0 ? (
<div style={{ fontSize: '14px', color: '#666' }}>Keine Einträge vorhanden.</div>
) : (
<ul style={{ fontSize: '14px', listStyle: 'disc', paddingLeft: 24 }}>
{holidays.slice(0, 20).map(h => (
<li key={h.id}>
{h.name}: {h.start_date} {h.end_date}
{h.region ? ` (${h.region})` : ''}
</li>
))}
</ul>
)}
</div>
</div>
</div>
);
const AcademicPeriodsContent = () => (
<div style={{ padding: 20 }}>
<div className="e-card">
<div className="e-card-header">
<div className="e-card-header-caption">
<div className="e-card-header-title">Akademische Perioden</div>
</div>
</div>
<div className="e-card-content">
{periods.length === 0 ? (
<div style={{ fontSize: '14px', color: '#666' }}>Keine Perioden gefunden.</div>
) : (
<div style={{ display: 'flex', gap: 12, alignItems: 'center', flexWrap: 'wrap' }}>
<div style={{ minWidth: 260 }}>
<DropDownListComponent
dataSource={periodOptions}
fields={{ text: 'name', value: 'id' }}
value={activePeriodId ?? undefined}
change={(e) => setActivePeriodId(Number(e.value))}
placeholder="Aktive Periode wählen"
popupHeight="250px"
/>
</div>
<ButtonComponent
cssClass="e-primary"
disabled={activePeriodId == null}
onClick={async () => {
if (activePeriodId == null) return;
try {
const p = await setActiveAcademicPeriod(activePeriodId);
showToast(`Aktive Periode gesetzt: ${p.display_name || p.name}`, 'e-toast-success');
await loadAcademicPeriods();
} catch (e) {
const msg = e instanceof Error ? e.message : 'Fehler beim Setzen der aktiven Periode';
showToast(msg, 'e-toast-danger');
}
}}
>
Als aktiv setzen
</ButtonComponent>
</div>
)}
</div>
</div>
</div>
);
// Display & Clients
const DisplayDefaultsContent = () => (
<div style={{ padding: 20 }}>
<div className="e-card">
<div className="e-card-header">
<div className="e-card-header-caption">
<div className="e-card-header-title">Standard-Einstellungen</div>
</div>
</div>
<div className="e-card-content" style={{ color: '#666', fontSize: 14 }}>
Platzhalter für Anzeige-Defaults (z. B. Heartbeat-Timeout, Screenshot-Aufbewahrung, Standard-Gruppe).
</div>
</div>
</div>
);
const ClientsConfigContent = () => (
<div style={{ padding: 20 }}>
<div className="e-card">
<div className="e-card-header">
<div className="e-card-header-caption">
<div className="e-card-header-title">Client-Konfiguration</div>
</div>
</div>
<div className="e-card-content">
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
<Link to="/clients"><ButtonComponent>Infoscreen-Clients öffnen</ButtonComponent></Link>
<Link to="/infoscr_groups"><ButtonComponent>Raumgruppen öffnen</ButtonComponent></Link>
</div>
</div>
</div>
</div>
);
const UploadSettingsContent = () => (
<div style={{ padding: 20 }}>
<div className="e-card">
<div className="e-card-header">
<div className="e-card-header-caption">
<div className="e-card-header-title">Upload-Einstellungen</div>
</div>
</div>
<div className="e-card-content" style={{ color: '#666', fontSize: 14 }}>
Platzhalter für Upload-Limits, erlaubte Dateitypen und Standardspeicherorte.
</div>
</div>
</div>
);
const ConversionStatusContent = () => (
<div style={{ padding: 20 }}>
<div className="e-card">
<div className="e-card-header">
<div className="e-card-header-caption">
<div className="e-card-header-title">Konvertierungsstatus</div>
</div>
</div>
<div className="e-card-content" style={{ color: '#666', fontSize: 14 }}>
Platzhalter Konversionsstatus-Übersicht kann hier integriert werden (siehe API /api/conversions/...).
</div>
</div>
</div>
);
// Events
const WebUntisSettingsContent = () => (
<div style={{ padding: 20 }}>
<div className="e-card">
<div className="e-card-header">
<div className="e-card-header-caption">
<div className="e-card-header-title">WebUntis / Vertretungsplan</div>
</div>
</div>
<div className="e-card-content">
<div style={{ marginBottom: 16 }}>
<label style={{ display: 'block', marginBottom: 8, fontWeight: 500 }}>
Vertretungsplan URL
</label>
<TextBoxComponent
placeholder="https://example.com/vertretungsplan"
value={supplementUrl}
change={(e) => setSupplementUrl(e.value || '')}
cssClass="e-outline"
width="100%"
/>
<div style={{ fontSize: '12px', color: '#666', marginTop: 4 }}>
Diese URL wird verwendet, um die Stundenplan-Änderungstabelle (Vertretungsplan) anzuzeigen.
</div>
</div>
<div style={{ marginBottom: 16 }}>
<CheckBoxComponent
label="Vertretungsplan aktiviert"
checked={supplementEnabled}
change={(e) => setSupplementEnabled(e.checked || false)}
/>
</div>
<div style={{ display: 'flex', gap: 12 }}>
<ButtonComponent
cssClass="e-primary"
onClick={onSaveSupplementSettings}
disabled={supplementBusy}
>
{supplementBusy ? 'Speichere…' : 'Einstellungen speichern'}
</ButtonComponent>
<ButtonComponent
cssClass="e-outline"
onClick={onTestSupplementUrl}
disabled={!supplementUrl}
>
Vorschau öffnen
</ButtonComponent>
</div>
</div>
</div>
</div>
);
const PresentationsDefaultsContent = () => (
<div style={{ padding: 20 }}>
<div className="e-card">
<div className="e-card-header">
<div className="e-card-header-caption">
<div className="e-card-header-title">Präsentationen</div>
</div>
</div>
<div className="e-card-content" style={{ fontSize: 14 }}>
<div style={{ marginBottom: 16 }}>
<label style={{ display: 'block', marginBottom: 8, fontWeight: 500 }}>
Slide-Show Intervall (Sekunden)
</label>
<NumericTextBoxComponent
min={1}
max={600}
step={1}
value={presentationInterval}
format="n0"
change={(e: { value: number }) => setPresentationInterval(Number(e.value) || 10)}
placeholder="Intervall in Sekunden"
width="120px"
/>
</div>
<div style={{ marginBottom: 16 }}>
<CheckBoxComponent
label="Seitenfortschritt anzeigen (Fortschrittsbalken)"
checked={presentationPageProgress}
change={e => setPresentationPageProgress(e.checked || false)}
/>
</div>
<div style={{ marginBottom: 16 }}>
<CheckBoxComponent
label="Präsentationsfortschritt anzeigen (Fortschrittsbalken)"
checked={presentationAutoProgress}
change={e => setPresentationAutoProgress(e.checked || false)}
/>
</div>
<ButtonComponent
cssClass="e-primary"
onClick={onSavePresentationSettings}
style={{ marginTop: 8 }}
disabled={presentationBusy}
>
{presentationBusy ? 'Speichere…' : 'Einstellungen speichern'}
</ButtonComponent>
</div>
</div>
</div>
);
const WebsitesDefaultsContent = () => (
<div style={{ padding: 20 }}>
<div className="e-card">
<div className="e-card-header">
<div className="e-card-header-caption">
<div className="e-card-header-title">Webseiten</div>
</div>
</div>
<div className="e-card-content" style={{ color: '#666', fontSize: 14 }}>
Platzhalter für Standardwerte (Lade-Timeout, Intervall, Sandbox/Embedding) für Webseiten.
</div>
</div>
</div>
);
const VideosDefaultsContent = () => (
<div style={{ padding: 20 }}>
<div className="e-card">
<div className="e-card-header">
<div className="e-card-header-caption">
<div className="e-card-header-title">Videos</div>
</div>
</div>
<div className="e-card-content" style={{ fontSize: 14 }}>
<div style={{ marginBottom: 16 }}>
<CheckBoxComponent
label="Automatisch abspielen"
checked={videoAutoplay}
change={e => setVideoAutoplay(!!e.checked)}
/>
</div>
<div style={{ marginBottom: 16 }}>
<CheckBoxComponent
label="In Schleife abspielen"
checked={videoLoop}
change={e => setVideoLoop(!!e.checked)}
/>
</div>
<div style={{ marginBottom: 16 }}>
<label style={{ display: 'block', marginBottom: 8, fontWeight: 500 }}>Lautstärke</label>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<NumericTextBoxComponent
min={0}
max={1}
step={0.05}
value={videoVolume}
format="n2"
change={(e: { value: number }) => {
const v = typeof e.value === 'number' ? e.value : Number(e.value);
if (Number.isFinite(v)) setVideoVolume(Math.max(0, Math.min(1, v)));
}}
placeholder="0.01.0"
width="140px"
/>
<CheckBoxComponent
label="Ton aus"
checked={videoMuted}
change={e => setVideoMuted(!!e.checked)}
/>
</div>
</div>
<ButtonComponent
cssClass="e-primary"
onClick={onSaveVideoSettings}
disabled={videoBusy}
>
{videoBusy ? 'Speichere…' : 'Einstellungen speichern'}
</ButtonComponent>
</div>
</div>
</div>
);
const MessagesDefaultsContent = () => (
<div style={{ padding: 20 }}>
<div className="e-card">
<div className="e-card-header">
<div className="e-card-header-caption">
<div className="e-card-header-title">Mitteilungen</div>
</div>
</div>
<div className="e-card-content" style={{ color: '#666', fontSize: 14 }}>
Platzhalter für Standardwerte (Dauer, Layout/Styling) für Mitteilungen.
</div>
</div>
</div>
);
const OtherDefaultsContent = () => (
<div style={{ padding: 20 }}>
<div className="e-card">
<div className="e-card-header">
<div className="e-card-header-caption">
<div className="e-card-header-title">Sonstige</div>
</div>
</div>
<div className="e-card-content" style={{ color: '#666', fontSize: 14 }}>
Platzhalter für sonstige Eventtypen.
</div>
</div>
</div>
);
// Users
const UsersQuickActionsContent = () => (
<div style={{ padding: 20 }}>
<div className="e-card">
<div className="e-card-header">
<div className="e-card-header-caption">
<div className="e-card-header-title">Schnellaktionen</div>
</div>
</div>
<div className="e-card-content">
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
<Link to="/benutzer"><ButtonComponent cssClass="e-primary">Benutzerverwaltung öffnen</ButtonComponent></Link>
<ButtonComponent disabled title="Demnächst">Benutzer einladen</ButtonComponent>
</div>
</div>
</div>
</div>
);
// System
const OrganizationInfoContent = () => (
<div style={{ padding: 20 }}>
<div className="e-card">
<div className="e-card-header">
<div className="e-card-header-caption">
<div className="e-card-header-title">Organisationsinformationen</div>
</div>
</div>
<div className="e-card-content" style={{ color: '#666', fontSize: 14 }}>
Platzhalter für Organisationsname, Branding, Standard-Lokalisierung.
</div>
</div>
</div>
);
const AdvancedConfigContent = () => (
<div style={{ padding: 20 }}>
<div className="e-card">
<div className="e-card-header">
<div className="e-card-header-caption">
<div className="e-card-header-title">Erweiterte Konfiguration</div>
</div>
</div>
<div className="e-card-content" style={{ color: '#666', fontSize: 14 }}>
Platzhalter für System-weit fortgeschrittene Optionen.
</div>
</div>
</div>
);
// ---------- Nested Tab wrappers (first-level tabs -> second-level content) ----------
const AcademicCalendarTabs = () => (
<TabComponent
heightAdjustMode="Auto"
selectedItem={academicTabIndex}
selected={(e: TabSelectedEvent) => setAcademicTabIndex(e.selectedIndex ?? 0)}
>
<TabItemsDirective>
<TabItemDirective header={{ text: '📥 Import & Liste' }} content={HolidaysImportAndListContent} />
<TabItemDirective header={{ text: '🗂️ Perioden' }} content={AcademicPeriodsContent} />
</TabItemsDirective>
</TabComponent>
);
const DisplayClientsTabs = () => (
<TabComponent
heightAdjustMode="Auto"
selectedItem={displayTabIndex}
selected={(e: TabSelectedEvent) => setDisplayTabIndex(e.selectedIndex ?? 0)}
>
<TabItemsDirective>
<TabItemDirective header={{ text: '⚙️ Defaults' }} content={DisplayDefaultsContent} />
<TabItemDirective header={{ text: '🖥️ Clients' }} content={ClientsConfigContent} />
</TabItemsDirective>
</TabComponent>
);
const MediaFilesTabs = () => (
<TabComponent
heightAdjustMode="Auto"
selectedItem={mediaTabIndex}
selected={(e: TabSelectedEvent) => setMediaTabIndex(e.selectedIndex ?? 0)}
>
<TabItemsDirective>
<TabItemDirective header={{ text: '⬆️ Upload' }} content={UploadSettingsContent} />
<TabItemDirective header={{ text: '🔄 Konvertierung' }} content={ConversionStatusContent} />
</TabItemsDirective>
</TabComponent>
);
const EventsTabs = () => (
<TabComponent
heightAdjustMode="Auto"
selectedItem={eventsTabIndex}
selected={(e: TabSelectedEvent) => setEventsTabIndex(e.selectedIndex ?? 0)}
>
<TabItemsDirective>
<TabItemDirective header={{ text: '📘 WebUntis' }} content={WebUntisSettingsContent} />
<TabItemDirective header={{ text: '🖼️ Präsentation' }} content={PresentationsDefaultsContent} />
<TabItemDirective header={{ text: '🌐 Webseiten' }} content={WebsitesDefaultsContent} />
<TabItemDirective header={{ text: '🎬 Videos' }} content={VideosDefaultsContent} />
<TabItemDirective header={{ text: '💬 Mitteilungen' }} content={MessagesDefaultsContent} />
<TabItemDirective header={{ text: '🔧 Sonstige' }} content={OtherDefaultsContent} />
</TabItemsDirective>
</TabComponent>
);
const UsersTabs = () => (
<TabComponent
heightAdjustMode="Auto"
selectedItem={usersTabIndex}
selected={(e: TabSelectedEvent) => setUsersTabIndex(e.selectedIndex ?? 0)}
>
<TabItemsDirective>
<TabItemDirective header={{ text: '⚡ Schnellaktionen' }} content={UsersQuickActionsContent} />
</TabItemsDirective>
</TabComponent>
);
const SystemTabs = () => (
<TabComponent
heightAdjustMode="Auto"
selectedItem={systemTabIndex}
selected={(e: TabSelectedEvent) => setSystemTabIndex(e.selectedIndex ?? 0)}
>
<TabItemsDirective>
<TabItemDirective header={{ text: '🏢 Organisation' }} content={OrganizationInfoContent} />
<TabItemDirective header={{ text: '🧩 Erweiterte Optionen' }} content={AdvancedConfigContent} />
</TabItemsDirective>
</TabComponent>
);
// ---------- Top-level (root) tabs ----------
return (
<div style={{ padding: 20 }}>
<ToastComponent ref={toastRef} position={{ X: 'Right', Y: 'Top' }} />
<h2 style={{ marginBottom: 20, fontSize: '24px', fontWeight: 600 }}>Einstellungen</h2>
<TabComponent heightAdjustMode="Auto">
<TabItemsDirective>
{/* 📅 Academic Calendar */}
<TabItemDirective header={{ text: '📅 Akademischer Kalender' }} content={() => (
<div style={{ padding: 20 }}>
{/* Holidays Import */}
<div className="e-card" style={{ marginBottom: 20 }}>
<div className="e-card-header">
<div className="e-card-header-caption">
<div className="e-card-header-title">Schulferien importieren</div>
</div>
</div>
<div className="e-card-content">
<p style={{ marginBottom: 12, fontSize: '14px', color: '#666' }}>
Unterstützte Formate:
<br /> CSV mit Kopfzeile: <code>name</code>, <code>start_date</code>, <code>end_date</code>, optional <code>region</code>
<br /> TXT/CSV ohne Kopfzeile mit Spalten: interner Name, <strong>Name</strong>, <strong>Start (YYYYMMDD)</strong>, <strong>Ende (YYYYMMDD)</strong>, optional interne Info (ignoriert)
</p>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 12 }}>
<input type="file" accept=".csv,text/csv,.txt,text/plain" onChange={e => setFile(e.target.files?.[0] ?? null)} />
<ButtonComponent cssClass="e-primary" onClick={onUpload} disabled={!file || busy}>
{busy ? 'Importiere…' : 'CSV/TXT importieren'}
</ButtonComponent>
</div>
{message && <div style={{ marginTop: 8, fontSize: '14px' }}>{message}</div>}
</div>
</div>
{/* Imported Holidays List */}
<div className="e-card" style={{ marginBottom: 20 }}>
<div className="e-card-header">
<div className="e-card-header-caption">
<div className="e-card-header-title">Importierte Ferien</div>
</div>
</div>
<div className="e-card-content">
{holidays.length === 0 ? (
<div style={{ fontSize: '14px', color: '#666' }}>Keine Einträge vorhanden.</div>
) : (
<ul style={{ fontSize: '14px', listStyle: 'disc', paddingLeft: 24 }}>
{holidays.slice(0, 20).map(h => (
<li key={h.id}>
{h.name}: {h.start_date} {h.end_date}
{h.region ? ` (${h.region})` : ''}
</li>
))}
</ul>
)}
</div>
</div>
{/* Academic Periods */}
<div className="e-card">
<div className="e-card-header">
<div className="e-card-header-caption">
<div className="e-card-header-title">Akademische Perioden</div>
</div>
</div>
<div className="e-card-content">
{periods.length === 0 ? (
<div style={{ fontSize: '14px', color: '#666' }}>Keine Perioden gefunden.</div>
) : (
<div style={{ display: 'flex', gap: 12, alignItems: 'center', flexWrap: 'wrap' }}>
<div style={{ minWidth: 260 }}>
<DropDownListComponent
dataSource={periodOptions}
fields={{ text: 'name', value: 'id' }}
value={activePeriodId ?? undefined}
change={(e) => setActivePeriodId(Number(e.value))}
placeholder="Aktive Periode wählen"
popupHeight="250px"
/>
</div>
<ButtonComponent
cssClass="e-primary"
disabled={activePeriodId == null}
onClick={async () => {
if (activePeriodId == null) return;
try {
const p = await setActiveAcademicPeriod(activePeriodId);
showToast(`Aktive Periode gesetzt: ${p.display_name || p.name}`, 'e-toast-success');
await loadAcademicPeriods();
} catch (e) {
const msg = e instanceof Error ? e.message : 'Fehler beim Setzen der aktiven Periode';
showToast(msg, 'e-toast-danger');
}
}}
>
Als aktiv setzen
</ButtonComponent>
</div>
)}
</div>
</div>
</div>
)} />
{/* 🖥️ Display & Clients (Admin+) */}
<TabItemDirective header={{ text: '📅 Akademischer Kalender' }} content={AcademicCalendarTabs} />
{isAdmin && (
<TabItemDirective header={{ text: '🖥️ Anzeige & Clients' }} content={() => (
<div style={{ padding: 20 }}>
<div className="e-card" style={{ marginBottom: 20 }}>
<div className="e-card-header">
<div className="e-card-header-caption">
<div className="e-card-header-title">Standard-Einstellungen</div>
</div>
</div>
<div className="e-card-content" style={{ color: '#666', fontSize: 14 }}>
Platzhalter für Anzeige-Defaults (z. B. Heartbeat-Timeout, Screenshot-Aufbewahrung, Standard-Gruppe).
</div>
</div>
<div className="e-card">
<div className="e-card-header">
<div className="e-card-header-caption">
<div className="e-card-header-title">Client-Konfiguration</div>
</div>
</div>
<div className="e-card-content">
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
<Link to="/clients"><ButtonComponent>Infoscreen-Clients öffnen</ButtonComponent></Link>
<Link to="/infoscr_groups"><ButtonComponent>Raumgruppen öffnen</ButtonComponent></Link>
</div>
</div>
</div>
</div>
)} />
<TabItemDirective header={{ text: '🖥️ Anzeige & Clients' }} content={DisplayClientsTabs} />
)}
{/* 🎬 Media & Files (Admin+) */}
{isAdmin && (
<TabItemDirective header={{ text: '🎬 Medien & Dateien' }} content={() => (
<div style={{ padding: 20 }}>
<div className="e-card" style={{ marginBottom: 20 }}>
<div className="e-card-header">
<div className="e-card-header-caption">
<div className="e-card-header-title">Upload-Einstellungen</div>
</div>
</div>
<div className="e-card-content" style={{ color: '#666', fontSize: 14 }}>
Platzhalter für Upload-Limits, erlaubte Dateitypen und Standardspeicherorte.
</div>
</div>
<div className="e-card">
<div className="e-card-header">
<div className="e-card-header-caption">
<div className="e-card-header-title">Konvertierungsstatus</div>
</div>
</div>
<div className="e-card-content" style={{ color: '#666', fontSize: 14 }}>
Platzhalter Konversionsstatus-Übersicht kann hier integriert werden (siehe API /api/conversions/...).
</div>
</div>
</div>
)} />
<TabItemDirective header={{ text: '🎬 Medien & Dateien' }} content={MediaFilesTabs} />
)}
{/* <20> Events (Admin+): per-event-type defaults and WebUntis link settings */}
{isAdmin && (
<TabItemDirective header={{ text: '<EFBFBD> Events' }} content={() => (
<div style={{ padding: 20 }}>
{/* WebUntis / Supplement table URL */}
<div className="e-card" style={{ marginBottom: 20 }}>
<div className="e-card-header">
<div className="e-card-header-caption">
<div className="e-card-header-title">WebUntis / Vertretungsplan</div>
</div>
</div>
<div className="e-card-content">
<div style={{ marginBottom: 16 }}>
<label style={{ display: 'block', marginBottom: 8, fontWeight: 500 }}>
Vertretungsplan URL
</label>
<TextBoxComponent
placeholder="https://example.com/vertretungsplan"
value={supplementUrl}
change={(e) => setSupplementUrl(e.value || '')}
cssClass="e-outline"
width="100%"
/>
<div style={{ fontSize: '12px', color: '#666', marginTop: 4 }}>
Diese URL wird verwendet, um die Stundenplan-Änderungstabelle (Vertretungsplan) anzuzeigen.
</div>
</div>
<div style={{ marginBottom: 16 }}>
<CheckBoxComponent
label="Vertretungsplan aktiviert"
checked={supplementEnabled}
change={(e) => setSupplementEnabled(e.checked || false)}
/>
</div>
<div style={{ display: 'flex', gap: 12 }}>
<ButtonComponent
cssClass="e-primary"
onClick={onSaveSupplementSettings}
disabled={supplementBusy}
>
{supplementBusy ? 'Speichere…' : 'Einstellungen speichern'}
</ButtonComponent>
<ButtonComponent
cssClass="e-outline"
onClick={onTestSupplementUrl}
disabled={!supplementUrl}
>
Vorschau öffnen
</ButtonComponent>
</div>
</div>
</div>
{/* Presentation defaults */}
<div className="e-card" style={{ marginBottom: 20 }}>
<div className="e-card-header">
<div className="e-card-header-caption">
<div className="e-card-header-title">Präsentationen</div>
</div>
</div>
<div className="e-card-content" style={{ fontSize: 14 }}>
<div style={{ marginBottom: 16 }}>
<label style={{ display: 'block', marginBottom: 8, fontWeight: 500 }}>
Slide-Show Intervall (Sekunden)
</label>
<NumericTextBoxComponent
min={1}
max={600}
step={1}
value={presentationInterval}
format="n0"
change={(e: { value: number }) => setPresentationInterval(Number(e.value) || 10)}
placeholder="Intervall in Sekunden"
width="120px"
/>
</div>
<div style={{ marginBottom: 16 }}>
<CheckBoxComponent
label="Seitenfortschritt anzeigen (Fortschrittsbalken)"
checked={presentationPageProgress}
change={e => setPresentationPageProgress(e.checked || false)}
/>
</div>
<div style={{ marginBottom: 16 }}>
<CheckBoxComponent
label="Präsentationsfortschritt anzeigen (Fortschrittsbalken)"
checked={presentationAutoProgress}
change={e => setPresentationAutoProgress(e.checked || false)}
/>
</div>
<ButtonComponent
cssClass="e-primary"
onClick={onSavePresentationSettings}
style={{ marginTop: 8 }}
disabled={presentationBusy}
>
{presentationBusy ? 'Speichere…' : 'Einstellungen speichern'}
</ButtonComponent>
</div>
</div>
{/* Website defaults */}
<div className="e-card" style={{ marginBottom: 20 }}>
<div className="e-card-header">
<div className="e-card-header-caption">
<div className="e-card-header-title">Webseiten</div>
</div>
</div>
<div className="e-card-content" style={{ color: '#666', fontSize: 14 }}>
Platzhalter für Standardwerte (Lade-Timeout, Intervall, Sandbox/Embedding) für Webseiten.
</div>
</div>
{/* Video defaults */}
<div className="e-card" style={{ marginBottom: 20 }}>
<div className="e-card-header">
<div className="e-card-header-caption">
<div className="e-card-header-title">Videos</div>
</div>
</div>
<div className="e-card-content" style={{ color: '#666', fontSize: 14 }}>
Platzhalter für Standardwerte (Autoplay, Loop, Lautstärke) für Videos.
</div>
</div>
{/* Message defaults */}
<div className="e-card" style={{ marginBottom: 20 }}>
<div className="e-card-header">
<div className="e-card-header-caption">
<div className="e-card-header-title">Mitteilungen</div>
</div>
</div>
<div className="e-card-content" style={{ color: '#666', fontSize: 14 }}>
Platzhalter für Standardwerte (Dauer, Layout/Styling) für Mitteilungen.
</div>
</div>
{/* Other defaults */}
<div className="e-card">
<div className="e-card-header">
<div className="e-card-header-caption">
<div className="e-card-header-title">Sonstige</div>
</div>
</div>
<div className="e-card-content" style={{ color: '#666', fontSize: 14 }}>
Platzhalter für sonstige Eventtypen.
</div>
</div>
</div>
)} />
<TabItemDirective header={{ text: '🗓 Events' }} content={EventsTabs} />
)}
{/* 👥 Users (Admin+) */}
{isAdmin && (
<TabItemDirective header={{ text: '👥 Benutzer' }} content={() => (
<div style={{ padding: 20 }}>
<div className="e-card">
<div className="e-card-header">
<div className="e-card-header-caption">
<div className="e-card-header-title">Schnellaktionen</div>
</div>
</div>
<div className="e-card-content">
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
<Link to="/benutzer"><ButtonComponent cssClass="e-primary">Benutzerverwaltung öffnen</ButtonComponent></Link>
<ButtonComponent disabled title="Demnächst">Benutzer einladen</ButtonComponent>
</div>
</div>
</div>
</div>
)} />
<TabItemDirective header={{ text: '👥 Benutzer' }} content={UsersTabs} />
)}
{/* ⚙️ System (Superadmin) */}
{isSuperadmin && (
<TabItemDirective header={{ text: '⚙️ System' }} content={() => (
<div style={{ padding: 20 }}>
<div className="e-card" style={{ marginBottom: 20 }}>
<div className="e-card-header">
<div className="e-card-header-caption">
<div className="e-card-header-title">Organisationsinformationen</div>
</div>
</div>
<div className="e-card-content" style={{ color: '#666', fontSize: 14 }}>
Platzhalter für Organisationsname, Branding, Standard-Lokalisierung.
</div>
</div>
<div className="e-card">
<div className="e-card-header">
<div className="e-card-header-caption">
<div className="e-card-header-title">Erweiterte Konfiguration</div>
</div>
</div>
<div className="e-card-content" style={{ color: '#666', fontSize: 14 }}>
Platzhalter für System-weit fortgeschrittene Optionen.
</div>
</div>
</div>
)} />
<TabItemDirective header={{ text: '⚙️ System' }} content={SystemTabs} />
)}
</TabItemsDirective>
</TabComponent>

View File

@@ -20,6 +20,9 @@ export default defineConfig({
'@syncfusion/ej2-react-navigations',
'@syncfusion/ej2-react-buttons',
'@syncfusion/ej2-react-splitbuttons',
'@syncfusion/ej2-react-grids',
'@syncfusion/ej2-react-schedule',
'@syncfusion/ej2-react-filemanager',
'@syncfusion/ej2-base',
'@syncfusion/ej2-navigations',
'@syncfusion/ej2-buttons',