feat: period-scoped holiday management, archive lifecycle, and docs/release sync
- add period-scoped holiday architecture end-to-end - model: scope `SchoolHoliday` to `academic_period_id` - migrations: add holiday-period scoping, academic-period archive lifecycle, and merge migration head - API: extend holidays with manual CRUD, period validation, duplicate prevention, and overlap merge/conflict handling - recurrence: regenerate holiday exceptions using period-scoped holiday sets - improve frontend settings and holiday workflows - bind holiday import/list/manual CRUD to selected academic period - show detailed import outcomes (inserted/updated/merged/skipped/conflicts) - fix file-picker UX (visible selected filename) - align settings controls/dialogs with defined frontend design rules - scope appointments/dashboard holiday loading to active period - add shared date formatting utility - strengthen academic period lifecycle handling - add archive/restore/delete flow and backend validations/blocker checks - extend API client support for lifecycle operations - release/docs updates and cleanup - bump user-facing version to `2026.1.0-alpha.15` with new changelog entry - add tech changelog entry for alpha.15 backend changes - refactor README to concise index and archive historical implementation docs - fix Copilot instruction link diagnostics via local `.github` design-rules reference
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"appName": "Infoscreen-Management",
|
||||
"version": "2026.1.0-alpha.14",
|
||||
"version": "2026.1.0-alpha.15",
|
||||
"copyright": "© 2026 Third-Age-Applications",
|
||||
"supportContact": "support@third-age-applications.com",
|
||||
"description": "Eine zentrale Verwaltungsoberfläche für digitale Informationsbildschirme.",
|
||||
@@ -30,6 +30,19 @@
|
||||
"commitId": "9f2ae8b44c3a"
|
||||
},
|
||||
"changelog": [
|
||||
{
|
||||
"version": "2026.1.0-alpha.15",
|
||||
"date": "2026-03-31",
|
||||
"changes": [
|
||||
"✨ Einstellungen: Ferienverwaltung pro akademischer Periode verbessert (Import/Anzeige an ausgewählte Periode gebunden).",
|
||||
"➕ Ferienkalender: Manuelle Ferienpflege mit Erstellen, Bearbeiten und Löschen direkt im gleichen Bereich.",
|
||||
"✅ Validierung: Ferien-Datumsbereiche werden bei Import und manueller Erfassung gegen die gewählte Periode geprüft.",
|
||||
"🧠 Ferienlogik: Doppelte Einträge werden verhindert; identische Überschneidungen (Name+Region) werden automatisch zusammengeführt.",
|
||||
"⚠️ Import: Konfliktfälle bei überlappenden, unterschiedlichen Feiertags-Identitäten werden übersichtlich ausgewiesen.",
|
||||
"🎯 UX: Dateiauswahl im Ferien-Import zeigt den gewählten Dateinamen zuverlässig an.",
|
||||
"🎨 UI: Ferien-Tab und Dialoge an die definierten Syncfusion-Designregeln angeglichen."
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "2026.1.0-alpha.14",
|
||||
"date": "2026-01-28",
|
||||
|
||||
@@ -1,16 +1,35 @@
|
||||
export type AcademicPeriod = {
|
||||
id: number;
|
||||
name: string;
|
||||
display_name?: string | null;
|
||||
start_date: string; // YYYY-MM-DD
|
||||
end_date: string; // YYYY-MM-DD
|
||||
period_type: 'schuljahr' | 'semester' | 'trimester';
|
||||
is_active: boolean;
|
||||
displayName?: string | null;
|
||||
startDate: string; // YYYY-MM-DD
|
||||
endDate: string; // YYYY-MM-DD
|
||||
periodType: 'schuljahr' | 'semester' | 'trimester';
|
||||
isActive: boolean;
|
||||
isArchived: boolean;
|
||||
archivedAt?: string | null;
|
||||
archivedBy?: number | null;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
};
|
||||
|
||||
export type PeriodUsage = {
|
||||
linked_events: number;
|
||||
has_active_recurrence: boolean;
|
||||
blockers: string[];
|
||||
};
|
||||
|
||||
async function api<T>(url: string, init?: RequestInit): Promise<T> {
|
||||
const res = await fetch(url, { credentials: 'include', ...init });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
try {
|
||||
const err = JSON.parse(text);
|
||||
throw new Error(err.error || `HTTP ${res.status}`);
|
||||
} catch {
|
||||
throw new Error(`HTTP ${res.status}: ${text}`);
|
||||
}
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
@@ -22,21 +41,99 @@ export async function getAcademicPeriodForDate(date: Date): Promise<AcademicPeri
|
||||
return period ?? null;
|
||||
}
|
||||
|
||||
export async function listAcademicPeriods(): Promise<AcademicPeriod[]> {
|
||||
const { periods } = await api<{ periods: AcademicPeriod[] }>(`/api/academic_periods`);
|
||||
export async function listAcademicPeriods(options?: {
|
||||
includeArchived?: boolean;
|
||||
archivedOnly?: boolean;
|
||||
}): Promise<AcademicPeriod[]> {
|
||||
const params = new URLSearchParams();
|
||||
if (options?.includeArchived) {
|
||||
params.set('includeArchived', '1');
|
||||
}
|
||||
if (options?.archivedOnly) {
|
||||
params.set('archivedOnly', '1');
|
||||
}
|
||||
const query = params.toString();
|
||||
const { periods } = await api<{ periods: AcademicPeriod[] }>(
|
||||
`/api/academic_periods${query ? `?${query}` : ''}`
|
||||
);
|
||||
return Array.isArray(periods) ? periods : [];
|
||||
}
|
||||
|
||||
export async function getAcademicPeriod(id: number): Promise<AcademicPeriod> {
|
||||
const { period } = await api<{ period: AcademicPeriod }>(`/api/academic_periods/${id}`);
|
||||
return period;
|
||||
}
|
||||
|
||||
export async function getActiveAcademicPeriod(): Promise<AcademicPeriod | null> {
|
||||
const { period } = await api<{ period: AcademicPeriod | null }>(`/api/academic_periods/active`);
|
||||
return period ?? null;
|
||||
}
|
||||
|
||||
export async function setActiveAcademicPeriod(id: number): Promise<AcademicPeriod> {
|
||||
const { period } = await api<{ period: AcademicPeriod }>(`/api/academic_periods/active`, {
|
||||
export async function createAcademicPeriod(payload: {
|
||||
name: string;
|
||||
displayName?: string;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
periodType: 'schuljahr' | 'semester' | 'trimester';
|
||||
}): Promise<AcademicPeriod> {
|
||||
const { period } = await api<{ period: AcademicPeriod }>(`/api/academic_periods`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ id }),
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
return period;
|
||||
}
|
||||
|
||||
export async function updateAcademicPeriod(
|
||||
id: number,
|
||||
payload: Partial<{
|
||||
name: string;
|
||||
displayName: string | null;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
periodType: 'schuljahr' | 'semester' | 'trimester';
|
||||
}>
|
||||
): Promise<AcademicPeriod> {
|
||||
const { period } = await api<{ period: AcademicPeriod }>(`/api/academic_periods/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
return period;
|
||||
}
|
||||
|
||||
export async function setActiveAcademicPeriod(id: number): Promise<AcademicPeriod> {
|
||||
const { period } = await api<{ period: AcademicPeriod }>(`/api/academic_periods/${id}/activate`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
return period;
|
||||
}
|
||||
|
||||
export async function archiveAcademicPeriod(id: number): Promise<AcademicPeriod> {
|
||||
const { period } = await api<{ period: AcademicPeriod }>(`/api/academic_periods/${id}/archive`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
return period;
|
||||
}
|
||||
|
||||
export async function restoreAcademicPeriod(id: number): Promise<AcademicPeriod> {
|
||||
const { period } = await api<{ period: AcademicPeriod }>(`/api/academic_periods/${id}/restore`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
return period;
|
||||
}
|
||||
|
||||
export async function getAcademicPeriodUsage(id: number): Promise<PeriodUsage> {
|
||||
const { usage } = await api<{ usage: PeriodUsage }>(`/api/academic_periods/${id}/usage`);
|
||||
return usage;
|
||||
}
|
||||
|
||||
export async function deleteAcademicPeriod(id: number): Promise<void> {
|
||||
await api(`/api/academic_periods/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export type Holiday = {
|
||||
id: number;
|
||||
academic_period_id?: number | null;
|
||||
name: string;
|
||||
start_date: string;
|
||||
end_date: string;
|
||||
@@ -8,19 +9,80 @@ export type Holiday = {
|
||||
imported_at?: string | null;
|
||||
};
|
||||
|
||||
export async function listHolidays(region?: string) {
|
||||
const url = region ? `/api/holidays?region=${encodeURIComponent(region)}` : '/api/holidays';
|
||||
export async function listHolidays(region?: string, academicPeriodId?: number | null) {
|
||||
const params = new URLSearchParams();
|
||||
if (region) {
|
||||
params.set('region', region);
|
||||
}
|
||||
if (academicPeriodId != null) {
|
||||
params.set('academicPeriodId', String(academicPeriodId));
|
||||
}
|
||||
const query = params.toString();
|
||||
const url = query ? `/api/holidays?${query}` : '/api/holidays';
|
||||
const res = await fetch(url);
|
||||
const data = await res.json();
|
||||
if (!res.ok || data.error) throw new Error(data.error || 'Fehler beim Laden der Ferien');
|
||||
return data as { holidays: Holiday[] };
|
||||
}
|
||||
|
||||
export async function uploadHolidaysCsv(file: File) {
|
||||
export async function uploadHolidaysCsv(file: File, academicPeriodId: number) {
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
form.append('academicPeriodId', String(academicPeriodId));
|
||||
const res = await fetch('/api/holidays/upload', { method: 'POST', body: form });
|
||||
const data = await res.json();
|
||||
if (!res.ok || data.error) throw new Error(data.error || 'Fehler beim Import der Ferien');
|
||||
return data as { success: boolean; inserted: number; updated: number };
|
||||
return data as {
|
||||
success: boolean;
|
||||
inserted: number;
|
||||
updated: number;
|
||||
merged_overlaps?: number;
|
||||
skipped_duplicates?: number;
|
||||
conflicts?: string[];
|
||||
academic_period_id?: number | null;
|
||||
};
|
||||
}
|
||||
|
||||
export type HolidayInput = {
|
||||
name: string;
|
||||
start_date: string;
|
||||
end_date: string;
|
||||
region?: string | null;
|
||||
academic_period_id?: number | null;
|
||||
};
|
||||
|
||||
export type HolidayMutationResult = {
|
||||
success: boolean;
|
||||
holiday?: Holiday;
|
||||
regenerated_events: number;
|
||||
merged?: boolean;
|
||||
};
|
||||
|
||||
export async function createHoliday(data: HolidayInput): Promise<HolidayMutationResult> {
|
||||
const res = await fetch('/api/holidays', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
const json = await res.json();
|
||||
if (!res.ok || json.error) throw new Error(json.error || 'Fehler beim Erstellen');
|
||||
return json;
|
||||
}
|
||||
|
||||
export async function updateHoliday(id: number, data: Partial<HolidayInput>): Promise<HolidayMutationResult> {
|
||||
const res = await fetch(`/api/holidays/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
const json = await res.json();
|
||||
if (!res.ok || json.error) throw new Error(json.error || 'Fehler beim Aktualisieren');
|
||||
return json;
|
||||
}
|
||||
|
||||
export async function deleteHoliday(id: number): Promise<{ success: boolean; regenerated_events: number }> {
|
||||
const res = await fetch(`/api/holidays/${id}`, { method: 'DELETE' });
|
||||
const json = await res.json();
|
||||
if (!res.ok || json.error) throw new Error(json.error || 'Fehler beim Löschen');
|
||||
return json;
|
||||
}
|
||||
|
||||
@@ -303,24 +303,29 @@ const Appointments: React.FC = () => {
|
||||
.catch(console.error);
|
||||
}, []);
|
||||
|
||||
// Holidays laden
|
||||
useEffect(() => {
|
||||
listHolidays()
|
||||
.then(res => setHolidays(res.holidays || []))
|
||||
.catch(err => console.error('Ferien laden fehlgeschlagen:', err));
|
||||
}, []);
|
||||
|
||||
// Perioden laden (Dropdown)
|
||||
useEffect(() => {
|
||||
listAcademicPeriods()
|
||||
.then(all => {
|
||||
setPeriods(all.map(p => ({ id: p.id, label: p.display_name || p.name })));
|
||||
const active = all.find(p => p.is_active);
|
||||
setPeriods(all.map(p => ({ id: p.id, label: p.displayName || p.name })));
|
||||
const active = all.find(p => p.isActive);
|
||||
setActivePeriodId(active ? active.id : null);
|
||||
})
|
||||
.catch(err => console.error('Akademische Perioden laden fehlgeschlagen:', err));
|
||||
}, []);
|
||||
|
||||
// Holidays passend zur aktiven akademischen Periode laden
|
||||
useEffect(() => {
|
||||
if (!activePeriodId) {
|
||||
setHolidays([]);
|
||||
return;
|
||||
}
|
||||
|
||||
listHolidays(undefined, activePeriodId)
|
||||
.then(res => setHolidays(res.holidays || []))
|
||||
.catch(err => console.error('Ferien laden fehlgeschlagen:', err));
|
||||
}, [activePeriodId]);
|
||||
|
||||
// fetchAndSetEvents als useCallback definieren, damit die Dependency korrekt ist:
|
||||
const fetchAndSetEvents = React.useCallback(async () => {
|
||||
if (!selectedGroupId) {
|
||||
@@ -540,12 +545,12 @@ const Appointments: React.FC = () => {
|
||||
setHasSchoolYearPlan(false);
|
||||
return;
|
||||
}
|
||||
// Anzeige: bevorzugt display_name, sonst name
|
||||
const label = p.display_name ? p.display_name : p.name;
|
||||
// Anzeige: bevorzugt displayName, sonst name
|
||||
const label = p.displayName ? p.displayName : p.name;
|
||||
setSchoolYearLabel(label);
|
||||
// Existiert ein Ferienplan innerhalb der Periode?
|
||||
const start = new Date(p.start_date + 'T00:00:00');
|
||||
const end = new Date(p.end_date + 'T23:59:59');
|
||||
const start = new Date(p.startDate + 'T00:00:00');
|
||||
const end = new Date(p.endDate + 'T23:59:59');
|
||||
let exists = false;
|
||||
for (const h of holidays) {
|
||||
const hs = new Date(h.start_date + 'T00:00:00');
|
||||
@@ -680,7 +685,7 @@ const Appointments: React.FC = () => {
|
||||
setActivePeriodId(updated.id);
|
||||
// Zum gleichen Tag/Monat (heute) innerhalb der gewählten Periode springen
|
||||
const today = new Date();
|
||||
const targetYear = new Date(updated.start_date).getFullYear();
|
||||
const targetYear = new Date(updated.startDate).getFullYear();
|
||||
const target = new Date(targetYear, today.getMonth(), today.getDate(), 12, 0, 0);
|
||||
if (scheduleRef.current) {
|
||||
scheduleRef.current.selectedDate = target;
|
||||
|
||||
@@ -7,6 +7,7 @@ import { ToastComponent, MessageComponent } from '@syncfusion/ej2-react-notifica
|
||||
import { listHolidays } from './apiHolidays';
|
||||
import { getActiveAcademicPeriod, type AcademicPeriod } from './apiAcademicPeriods';
|
||||
import { getHolidayBannerSetting } from './apiSystemSettings';
|
||||
import { formatIsoDateForDisplay } from './dateFormatting';
|
||||
|
||||
const REFRESH_INTERVAL = 15000; // 15 Sekunden
|
||||
|
||||
@@ -104,13 +105,13 @@ const Dashboard: React.FC = () => {
|
||||
try {
|
||||
const period = await getActiveAcademicPeriod();
|
||||
setActivePeriod(period || null);
|
||||
const holidayData = await listHolidays();
|
||||
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.start_date + 'T00:00:00');
|
||||
const pe = new Date(period.end_date + 'T23:59:59');
|
||||
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');
|
||||
@@ -413,13 +414,7 @@ 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; }
|
||||
};
|
||||
const formatDate = (iso: string | null) => formatIsoDateForDisplay(iso);
|
||||
|
||||
// Holiday Status Banner Component
|
||||
const HolidayStatusBanner = () => {
|
||||
@@ -447,7 +442,7 @@ const Dashboard: React.FC = () => {
|
||||
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'}
|
||||
✅ Ferienplan vorhanden für <strong>{activePeriod.displayName || activePeriod.name}</strong>: {holidayOverlapCount} Zeitraum{holidayOverlapCount === 1 ? '' : 'e'}
|
||||
{holidayFirst && holidayLast && (
|
||||
<> ({formatDate(holidayFirst)} – {formatDate(holidayLast)})</>
|
||||
)}
|
||||
@@ -456,7 +451,7 @@ const Dashboard: React.FC = () => {
|
||||
}
|
||||
return (
|
||||
<MessageComponent severity="Warning" variant="Filled">
|
||||
⚠️ Kein Ferienplan für <strong>{activePeriod.display_name || activePeriod.name}</strong> importiert. Jetzt unter Einstellungen → 📅 Kalender hochladen.
|
||||
⚠️ Kein Ferienplan für <strong>{activePeriod.displayName || activePeriod.name}</strong> importiert. Jetzt unter Einstellungen → 📥 Ferienkalender: Import/Anzeige.
|
||||
</MessageComponent>
|
||||
);
|
||||
};
|
||||
|
||||
15
dashboard/src/dateFormatting.ts
Normal file
15
dashboard/src/dateFormatting.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
export function formatIsoDateForDisplay(isoDate: string | null | undefined): string {
|
||||
if (!isoDate) {
|
||||
return '-';
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = new Date(`${isoDate}T00:00:00`);
|
||||
if (Number.isNaN(parsed.getTime())) {
|
||||
return isoDate;
|
||||
}
|
||||
return parsed.toLocaleDateString('de-DE');
|
||||
} catch {
|
||||
return isoDate;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user