docs/dev: sync backend rework, MQTT, and devcontainer hygiene
README: add Versioning (unified SemVer, pre-releases, build metadata); emphasize UTC handling and streaming endpoint; add Dev Container notes (UI-only Remote Containers, npm ci, idempotent aliases) TECH-CHANGELOG: backend rework notes (serialization camelCase, UTC normalization, streaming metadata); add component build metadata template (image tags/SHAs) Copilot instructions: integrate maintenance guardrails; reinforce UTC and camelCase conventions; document MQTT topics and scheduler retained payload behavior Devcontainer: map Remote Containers to UI; remove in-container install; switch to npm ci; make aliases idempotent
This commit is contained in:
@@ -106,3 +106,34 @@ export async function updateSupplementTableSettings(
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get holiday banner setting
|
||||
*/
|
||||
export async function getHolidayBannerSetting(): Promise<{ enabled: boolean }> {
|
||||
const response = await fetch(`/api/system-settings/holiday-banner`, {
|
||||
credentials: 'include',
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch holiday banner setting: ${response.statusText}`);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update holiday banner setting
|
||||
*/
|
||||
export async function updateHolidayBannerSetting(
|
||||
enabled: boolean
|
||||
): Promise<{ enabled: boolean; message: string }> {
|
||||
const response = await fetch(`/api/system-settings/holiday-banner`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ enabled }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to update holiday banner setting: ${response.statusText}`);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
@@ -3,7 +3,10 @@ import { fetchGroupsWithClients, restartClient } from './apiClients';
|
||||
import type { Group, Client } from './apiClients';
|
||||
import { fetchEvents } from './apiEvents';
|
||||
import { ButtonComponent } from '@syncfusion/ej2-react-buttons';
|
||||
import { ToastComponent } from '@syncfusion/ej2-react-notifications';
|
||||
import { ToastComponent, MessageComponent } from '@syncfusion/ej2-react-notifications';
|
||||
import { listHolidays } from './apiHolidays';
|
||||
import { getActiveAcademicPeriod, type AcademicPeriod } from './apiAcademicPeriods';
|
||||
import { getHolidayBannerSetting } from './apiSystemSettings';
|
||||
|
||||
const REFRESH_INTERVAL = 15000; // 15 Sekunden
|
||||
|
||||
@@ -31,6 +34,15 @@ const Dashboard: React.FC = () => {
|
||||
const [activeEvents, setActiveEvents] = useState<GroupEvents>({});
|
||||
const toastRef = React.useRef<ToastComponent>(null);
|
||||
|
||||
// Holiday status state
|
||||
const [holidayBannerEnabled, setHolidayBannerEnabled] = useState<boolean>(true);
|
||||
const [activePeriod, setActivePeriod] = useState<AcademicPeriod | null>(null);
|
||||
const [holidayOverlapCount, setHolidayOverlapCount] = useState<number>(0);
|
||||
const [holidayFirst, setHolidayFirst] = useState<string | null>(null);
|
||||
const [holidayLast, setHolidayLast] = useState<string | null>(null);
|
||||
const [holidayLoading, setHolidayLoading] = useState<boolean>(false);
|
||||
const [holidayError, setHolidayError] = useState<string | null>(null);
|
||||
|
||||
// Optimiertes Update: Nur bei echten Datenänderungen wird das Grid aktualisiert
|
||||
useEffect(() => {
|
||||
let lastGroups: Group[] = [];
|
||||
@@ -72,6 +84,62 @@ const Dashboard: React.FC = () => {
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
// Load academic period & holidays status
|
||||
useEffect(() => {
|
||||
const loadHolidayStatus = async () => {
|
||||
// Check if banner is enabled first
|
||||
try {
|
||||
const bannerSetting = await getHolidayBannerSetting();
|
||||
setHolidayBannerEnabled(bannerSetting.enabled);
|
||||
if (!bannerSetting.enabled) {
|
||||
return; // Skip loading if disabled
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Fehler beim Laden der Banner-Einstellung:', e);
|
||||
// Continue with default (enabled)
|
||||
}
|
||||
|
||||
setHolidayLoading(true);
|
||||
setHolidayError(null);
|
||||
try {
|
||||
const period = await getActiveAcademicPeriod();
|
||||
setActivePeriod(period || null);
|
||||
const holidayData = await listHolidays();
|
||||
const list = holidayData.holidays || [];
|
||||
|
||||
if (period) {
|
||||
// Check for holidays overlapping with active period
|
||||
const ps = new Date(period.start_date + 'T00:00:00');
|
||||
const pe = new Date(period.end_date + 'T23:59:59');
|
||||
const overlapping = list.filter(h => {
|
||||
const hs = new Date(h.start_date + 'T00:00:00');
|
||||
const he = new Date(h.end_date + 'T23:59:59');
|
||||
return hs <= pe && he >= ps;
|
||||
});
|
||||
setHolidayOverlapCount(overlapping.length);
|
||||
if (overlapping.length > 0) {
|
||||
const sorted = overlapping.slice().sort((a, b) => a.start_date.localeCompare(b.start_date));
|
||||
setHolidayFirst(sorted[0].start_date);
|
||||
setHolidayLast(sorted[sorted.length - 1].end_date);
|
||||
} else {
|
||||
setHolidayFirst(null);
|
||||
setHolidayLast(null);
|
||||
}
|
||||
} else {
|
||||
setHolidayOverlapCount(0);
|
||||
setHolidayFirst(null);
|
||||
setHolidayLast(null);
|
||||
}
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : 'Ferienstatus konnte nicht geladen werden';
|
||||
setHolidayError(msg);
|
||||
} finally {
|
||||
setHolidayLoading(false);
|
||||
}
|
||||
};
|
||||
loadHolidayStatus();
|
||||
}, []);
|
||||
|
||||
// Fetch currently active events for all groups
|
||||
const fetchActiveEventsForGroups = async (groupsList: Group[]) => {
|
||||
const now = new Date();
|
||||
@@ -344,6 +412,55 @@ const Dashboard: React.FC = () => {
|
||||
});
|
||||
};
|
||||
|
||||
// Format date for holiday display
|
||||
const formatDate = (iso: string | null) => {
|
||||
if (!iso) return '-';
|
||||
try {
|
||||
const d = new Date(iso + 'T00:00:00');
|
||||
return d.toLocaleDateString('de-DE');
|
||||
} catch { return iso; }
|
||||
};
|
||||
|
||||
// Holiday Status Banner Component
|
||||
const HolidayStatusBanner = () => {
|
||||
if (holidayLoading) {
|
||||
return (
|
||||
<MessageComponent severity="Info" variant="Filled">
|
||||
Lade Ferienstatus ...
|
||||
</MessageComponent>
|
||||
);
|
||||
}
|
||||
if (holidayError) {
|
||||
return (
|
||||
<MessageComponent severity="Error" variant="Filled">
|
||||
Fehler beim Laden des Ferienstatus: {holidayError}
|
||||
</MessageComponent>
|
||||
);
|
||||
}
|
||||
if (!activePeriod) {
|
||||
return (
|
||||
<MessageComponent severity="Warning" variant="Outlined">
|
||||
⚠️ Keine aktive akademische Periode gesetzt – Ferienplan nicht verknüpft.
|
||||
</MessageComponent>
|
||||
);
|
||||
}
|
||||
if (holidayOverlapCount > 0) {
|
||||
return (
|
||||
<MessageComponent severity="Success" variant="Filled">
|
||||
✅ Ferienplan vorhanden für <strong>{activePeriod.display_name || activePeriod.name}</strong>: {holidayOverlapCount} Zeitraum{holidayOverlapCount === 1 ? '' : 'e'}
|
||||
{holidayFirst && holidayLast && (
|
||||
<> ({formatDate(holidayFirst)} – {formatDate(holidayLast)})</>
|
||||
)}
|
||||
</MessageComponent>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<MessageComponent severity="Warning" variant="Filled">
|
||||
⚠️ Kein Ferienplan für <strong>{activePeriod.display_name || activePeriod.name}</strong> importiert. Jetzt unter Einstellungen → 📅 Kalender hochladen.
|
||||
</MessageComponent>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<ToastComponent
|
||||
@@ -361,6 +478,13 @@ const Dashboard: React.FC = () => {
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{/* Holiday Status Banner */}
|
||||
{holidayBannerEnabled && (
|
||||
<div style={{ marginBottom: '20px' }}>
|
||||
<HolidayStatusBanner />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Global Statistics Summary */}
|
||||
{(() => {
|
||||
const globalStats = getGlobalStats();
|
||||
|
||||
@@ -4,7 +4,7 @@ import { NumericTextBoxComponent, TextBoxComponent } from '@syncfusion/ej2-react
|
||||
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';
|
||||
import { getSupplementTableSettings, updateSupplementTableSettings, getHolidayBannerSetting, updateHolidayBannerSetting } from './apiSystemSettings';
|
||||
import { useAuth } from './useAuth';
|
||||
import { DropDownListComponent } from '@syncfusion/ej2-react-dropdowns';
|
||||
import { listAcademicPeriods, getActiveAcademicPeriod, setActiveAcademicPeriod, type AcademicPeriod } from './apiAcademicPeriods';
|
||||
@@ -77,6 +77,10 @@ const Einstellungen: React.FC = () => {
|
||||
const [supplementEnabled, setSupplementEnabled] = React.useState(false);
|
||||
const [supplementBusy, setSupplementBusy] = React.useState(false);
|
||||
|
||||
// Holiday banner state
|
||||
const [holidayBannerEnabled, setHolidayBannerEnabled] = React.useState(true);
|
||||
const [holidayBannerBusy, setHolidayBannerBusy] = React.useState(false);
|
||||
|
||||
// Video defaults state (Admin+)
|
||||
const [videoAutoplay, setVideoAutoplay] = React.useState<boolean>(true);
|
||||
const [videoLoop, setVideoLoop] = React.useState<boolean>(true);
|
||||
@@ -116,6 +120,15 @@ const Einstellungen: React.FC = () => {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadHolidayBannerSetting = React.useCallback(async () => {
|
||||
try {
|
||||
const data = await getHolidayBannerSetting();
|
||||
setHolidayBannerEnabled(data.enabled);
|
||||
} catch (e) {
|
||||
console.error('Fehler beim Laden der Ferienbanner-Einstellung:', e);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Load video default settings (with fallbacks)
|
||||
const loadVideoSettings = React.useCallback(async () => {
|
||||
try {
|
||||
@@ -156,6 +169,7 @@ const Einstellungen: React.FC = () => {
|
||||
|
||||
React.useEffect(() => {
|
||||
refresh();
|
||||
loadHolidayBannerSetting(); // Everyone can see this
|
||||
if (user) {
|
||||
// Academic periods for all users
|
||||
loadAcademicPeriods();
|
||||
@@ -166,7 +180,7 @@ const Einstellungen: React.FC = () => {
|
||||
loadVideoSettings();
|
||||
}
|
||||
}
|
||||
}, [refresh, loadSupplementSettings, loadAcademicPeriods, loadPresentationSettings, loadVideoSettings, user]);
|
||||
}, [refresh, loadSupplementSettings, loadAcademicPeriods, loadPresentationSettings, loadVideoSettings, loadHolidayBannerSetting, user]);
|
||||
|
||||
const onUpload = async () => {
|
||||
if (!file) return;
|
||||
@@ -208,6 +222,19 @@ const Einstellungen: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const onSaveHolidayBannerSetting = async () => {
|
||||
setHolidayBannerBusy(true);
|
||||
try {
|
||||
await updateHolidayBannerSetting(holidayBannerEnabled);
|
||||
showToast('Ferienbanner-Einstellung gespeichert', 'e-toast-success');
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : 'Fehler beim Speichern';
|
||||
showToast(msg, 'e-toast-danger');
|
||||
} finally {
|
||||
setHolidayBannerBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onSaveVideoSettings = async () => {
|
||||
setVideoBusy(true);
|
||||
try {
|
||||
@@ -291,6 +318,34 @@ const Einstellungen: React.FC = () => {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dashboard Display Settings Card */}
|
||||
<div className="e-card" style={{ marginTop: 20 }}>
|
||||
<div className="e-card-header">
|
||||
<div className="e-card-header-caption">
|
||||
<div className="e-card-header-title">Dashboard-Anzeige</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="e-card-content">
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<CheckBoxComponent
|
||||
label="Ferienstatus-Banner auf Dashboard anzeigen"
|
||||
checked={holidayBannerEnabled}
|
||||
change={(e) => setHolidayBannerEnabled(e.checked || false)}
|
||||
/>
|
||||
<div style={{ fontSize: '12px', color: '#666', marginTop: 4, marginLeft: 24 }}>
|
||||
Zeigt eine Information an, ob ein Ferienplan für die aktive Periode importiert wurde.
|
||||
</div>
|
||||
</div>
|
||||
<ButtonComponent
|
||||
cssClass="e-primary"
|
||||
onClick={onSaveHolidayBannerSetting}
|
||||
disabled={holidayBannerBusy}
|
||||
>
|
||||
{holidayBannerBusy ? 'Speichere…' : 'Einstellung speichern'}
|
||||
</ButtonComponent>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user