docs(settings): Update README + Copilot instructions; bump Program Info to 2025.1.0-alpha.11

README: Add System Settings API endpoints; describe new tabbed Settings layout with role gating; add Vite dev proxy tip to use relative /api paths.
Copilot instructions: Note SystemSetting key–value store in data model; document system_settings.py (CRUD + supplement-table convenience endpoint); reference apiSystemSettings.ts; note defaults seeding via init_defaults.py.
Program Info: Bump version to 2025.1.0-alpha.11; changelog explicitly tied to the Settings page (Events tab: supplement-table URL moved; Academic Calendar: set active period; proxy note); README docs mention.
No functional changes to API or UI code in this commit; documentation and program info only.
This commit is contained in:
RobbStarkAustria
2025-10-16 19:15:55 +00:00
parent 7b38b49598
commit 150937f2e2
11 changed files with 882 additions and 50 deletions

View File

@@ -0,0 +1,108 @@
/**
* API client for system settings
*/
export interface SystemSetting {
key: string;
value: string | null;
description: string | null;
updated_at: string | null;
}
export interface SupplementTableSettings {
url: string;
enabled: boolean;
}
/**
* Get all system settings
*/
export async function getAllSettings(): Promise<{ settings: SystemSetting[] }> {
const response = await fetch(`/api/system-settings`, {
credentials: 'include',
});
if (!response.ok) {
throw new Error(`Failed to fetch settings: ${response.statusText}`);
}
return response.json();
}
/**
* Get a specific setting by key
*/
export async function getSetting(key: string): Promise<SystemSetting> {
const response = await fetch(`/api/system-settings/${key}`, {
credentials: 'include',
});
if (!response.ok) {
throw new Error(`Failed to fetch setting: ${response.statusText}`);
}
return response.json();
}
/**
* Update or create a setting
*/
export async function updateSetting(
key: string,
value: string,
description?: string
): Promise<SystemSetting> {
const response = await fetch(`/api/system-settings/${key}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ value, description }),
});
if (!response.ok) {
throw new Error(`Failed to update setting: ${response.statusText}`);
}
return response.json();
}
/**
* Delete a setting
*/
export async function deleteSetting(key: string): Promise<{ message: string }> {
const response = await fetch(`/api/system-settings/${key}`, {
method: 'DELETE',
credentials: 'include',
});
if (!response.ok) {
throw new Error(`Failed to delete setting: ${response.statusText}`);
}
return response.json();
}
/**
* Get supplement table settings
*/
export async function getSupplementTableSettings(): Promise<SupplementTableSettings> {
const response = await fetch(`/api/system-settings/supplement-table`, {
credentials: 'include',
});
if (!response.ok) {
throw new Error(`Failed to fetch supplement table settings: ${response.statusText}`);
}
return response.json();
}
/**
* Update supplement table settings
*/
export async function updateSupplementTableSettings(
url: string,
enabled: boolean
): Promise<SupplementTableSettings & { message: string }> {
const response = await fetch(`/api/system-settings/supplement-table`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ url, enabled }),
});
if (!response.ok) {
throw new Error(`Failed to update supplement table settings: ${response.statusText}`);
}
return response.json();
}

View File

@@ -1,11 +1,46 @@
import React from 'react';
import { TabComponent, TabItemDirective, TabItemsDirective } from '@syncfusion/ej2-react-navigations';
import { TextBoxComponent } from '@syncfusion/ej2-react-inputs';
import { ButtonComponent } from '@syncfusion/ej2-react-buttons';
import { 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';
import { useAuth } from './useAuth';
import { DropDownListComponent } from '@syncfusion/ej2-react-dropdowns';
import { listAcademicPeriods, getActiveAcademicPeriod, setActiveAcademicPeriod, type AcademicPeriod } from './apiAcademicPeriods';
import { Link } from 'react-router-dom';
const Einstellungen: React.FC = () => {
const { user } = useAuth();
const toastRef = React.useRef<ToastComponent>(null);
// Holidays state
const [file, setFile] = React.useState<File | null>(null);
const [busy, setBusy] = React.useState(false);
const [message, setMessage] = React.useState<string | null>(null);
const [holidays, setHolidays] = React.useState<Holiday[]>([]);
const [periods, setPeriods] = React.useState<AcademicPeriod[]>([]);
const [activePeriodId, setActivePeriodId] = React.useState<number | null>(null);
const periodOptions = React.useMemo(() =>
periods.map(p => ({ id: p.id, name: p.display_name || p.name })),
[periods]
);
// Supplement table state
const [supplementUrl, setSupplementUrl] = React.useState('');
const [supplementEnabled, setSupplementEnabled] = React.useState(false);
const [supplementBusy, setSupplementBusy] = React.useState(false);
const showToast = (content: string, cssClass: string = 'e-toast-success') => {
if (toastRef.current) {
toastRef.current.show({
content,
cssClass,
timeOut: 3000,
});
}
};
const refresh = React.useCallback(async () => {
try {
@@ -14,12 +49,46 @@ const Einstellungen: React.FC = () => {
} catch (e) {
const msg = e instanceof Error ? e.message : 'Fehler beim Laden der Ferien';
setMessage(msg);
showToast(msg, 'e-toast-danger');
}
}, []);
const loadSupplementSettings = React.useCallback(async () => {
try {
const data = await getSupplementTableSettings();
setSupplementUrl(data.url || '');
setSupplementEnabled(data.enabled || false);
} catch (e) {
const msg = e instanceof Error ? e.message : 'Fehler beim Laden der Vertretungsplan-Einstellungen';
showToast(msg, 'e-toast-danger');
}
}, []);
const loadAcademicPeriods = React.useCallback(async () => {
try {
const [list, active] = await Promise.all([
listAcademicPeriods(),
getActiveAcademicPeriod(),
]);
setPeriods(list);
setActivePeriodId(active ? active.id : null);
} catch (e) {
const msg = e instanceof Error ? e.message : 'Fehler beim Laden der Schuljahre/Perioden';
showToast(msg, 'e-toast-danger');
}
}, []);
React.useEffect(() => {
refresh();
}, [refresh]);
if (user) {
// Academic periods for all users
loadAcademicPeriods();
// System settings only for admin/superadmin (will render only if allowed)
if (['admin', 'superadmin'].includes(user.role)) {
loadSupplementSettings();
}
}
}, [refresh, loadSupplementSettings, loadAcademicPeriods, user]);
const onUpload = async () => {
if (!file) return;
@@ -27,59 +96,375 @@ const Einstellungen: React.FC = () => {
setMessage(null);
try {
const res = await uploadHolidaysCsv(file);
setMessage(`Import erfolgreich: ${res.inserted} neu, ${res.updated} aktualisiert.`);
const msg = `Import erfolgreich: ${res.inserted} neu, ${res.updated} aktualisiert.`;
setMessage(msg);
showToast(msg, 'e-toast-success');
await refresh();
} catch (e) {
const msg = e instanceof Error ? e.message : 'Fehler beim Import.';
setMessage(msg);
showToast(msg, 'e-toast-danger');
} finally {
setBusy(false);
}
};
return (
<div>
<h2 className="text-xl font-bold mb-4">Einstellungen</h2>
<div className="space-y-4">
<section className="p-4 border rounded-md">
<h3 className="font-semibold mb-2">Schulferien importieren</h3>
<p className="text-sm text-gray-600 mb-2">
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 className="flex items-center gap-3">
<input
type="file"
accept=".csv,text/csv,.txt,text/plain"
onChange={e => setFile(e.target.files?.[0] ?? null)}
/>
<button className="e-btn e-primary" onClick={onUpload} disabled={!file || busy}>
{busy ? 'Importiere…' : 'CSV/TXT importieren'}
</button>
</div>
{message && <div className="mt-2 text-sm">{message}</div>}
</section>
const onSaveSupplementSettings = async () => {
setSupplementBusy(true);
try {
await updateSupplementTableSettings(supplementUrl, supplementEnabled);
showToast('Vertretungsplan-Einstellungen erfolgreich gespeichert', 'e-toast-success');
} catch (e) {
const msg = e instanceof Error ? e.message : 'Fehler beim Speichern der Einstellungen';
showToast(msg, 'e-toast-danger');
} finally {
setSupplementBusy(false);
}
};
<section className="p-4 border rounded-md">
<h3 className="font-semibold mb-2">Importierte Ferien</h3>
{holidays.length === 0 ? (
<div className="text-sm text-gray-600">Keine Einträge vorhanden.</div>
) : (
<ul className="text-sm list-disc pl-6">
{holidays.slice(0, 20).map(h => (
<li key={h.id}>
{h.name}: {h.start_date} {h.end_date}
{h.region ? ` (${h.region})` : ''}
</li>
))}
</ul>
const onTestSupplementUrl = () => {
if (supplementUrl) {
window.open(supplementUrl, '_blank');
} else {
showToast('Bitte geben Sie zuerst eine URL ein', 'e-toast-warning');
}
};
// Determine which tabs to show based on role
const isAdmin = !!(user && ['admin', 'superadmin'].includes(user.role));
const isSuperadmin = !!(user && user.role === 'superadmin');
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+) */}
{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>
)} />
)}
</section>
</div>
{/* 🎬 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>
)} />
)}
{/* <20> Events (Admin+): per-event-type defaults and WebUntis link settings */}
{isAdmin && (
<TabItemDirective header={{ text: '<27> 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={{ color: '#666', fontSize: 14 }}>
Platzhalter für Standardwerte (Autoplay, Loop, Intervall) für Präsentationen.
</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>
)} />
)}
{/* 👥 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>
)} />
)}
{/* ⚙️ 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>
)} />
)}
</TabItemsDirective>
</TabComponent>
</div>
);
};