Files
infoscreen/dashboard/src/appointments.tsx
RobbStarkAustria e53cc619ec feat: implement comprehensive recurring event single occurrence editing
- Add detach functionality for individual occurrences of recurring events
- Create POST /api/events/<id>/occurrences/<date>/detach endpoint
- Implement EventException-based EXDATE generation for master events
- Add user confirmation dialog for single vs series editing choice
- Implement manual recurrence expansion with DST timezone tolerance
- Support FREQ=DAILY and FREQ=WEEKLY with BYDAY patterns and UNTIL dates
- Create standalone events from detached occurrences without affecting master series
- Add GET /api/events/<id> endpoint for fetching master event data
- Allow editing recurring series even when master event date is in the past
- Replace browser confirm dialogs with Syncfusion dialog components
- Remove debug logging while preserving error handling
- Update documentation for recurring event functionality

BREAKING: Frontend now manually expands recurring events instead of relying on Syncfusion's EXDATE handling

This enables users to edit individual occurrences of recurring events (creating standalone events)
or edit the entire series (updating all future occurrences) through an intuitive UI workflow.
The system properly handles timezone transitions, holiday exclusions, and complex recurrence patterns.
2025-10-12 20:04:23 +00:00

1174 lines
44 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useEffect, useMemo, useState } from 'react';
import {
ScheduleComponent,
Day,
Week,
WorkWeek,
Month,
Agenda,
Inject,
ViewsDirective,
ViewDirective,
} from '@syncfusion/ej2-react-schedule';
import { DropDownListComponent } from '@syncfusion/ej2-react-dropdowns';
import { DialogComponent } from '@syncfusion/ej2-react-popups';
import { L10n, loadCldr, setCulture } from '@syncfusion/ej2-base';
import type {
EventRenderedArgs,
ActionEventArgs,
RenderCellEventArgs,
} from '@syncfusion/ej2-react-schedule';
import { fetchEvents, fetchEventById } from './apiEvents';
import { fetchGroups } from './apiGroups';
import { getGroupColor } from './groupColors';
import { deleteEvent, deleteEventOccurrence } from './apiEvents';
import CustomEventModal from './components/CustomEventModal';
import { fetchMediaById } from './apiClients';
import { listHolidays, type Holiday } from './apiHolidays';
import {
getAcademicPeriodForDate,
listAcademicPeriods,
setActiveAcademicPeriod,
} from './apiAcademicPeriods';
import {
Presentation,
Globe,
Video,
MessageSquare,
School,
CheckCircle,
AlertCircle,
TentTree,
} from 'lucide-react';
import caGregorian from './cldr/ca-gregorian.json';
import numbers from './cldr/numbers.json';
import timeZoneNames from './cldr/timeZoneNames.json';
import numberingSystems from './cldr/numberingSystems.json';
// Typ für Gruppe ergänzen
type Group = {
id: string;
name: string;
};
// Typ für Event ergänzen
type Event = {
Id: string;
Subject: string;
StartTime: Date;
EndTime: Date;
IsAllDay: boolean;
IsBlock?: boolean; // Syncfusion block appointment
isHoliday?: boolean; // marker for styling/logic
MediaId?: string | number;
SlideshowInterval?: number;
WebsiteUrl?: string;
Icon?: string; // <--- Icon ergänzen!
Type?: string; // <--- Typ ergänzen, falls benötigt
OccurrenceOfId?: string; // Serieninstanz
Recurrence?: boolean; // Marker für Serientermin
RecurrenceRule?: string | null;
RecurrenceEnd?: string | null;
SkipHolidays?: boolean;
RecurrenceException?: string;
};
type RawEvent = {
Id: string;
Subject: string;
StartTime: string;
EndTime: string;
IsAllDay: boolean;
MediaId?: string | number;
Icon?: string; // <--- Icon ergänzen!
Type?: string;
OccurrenceOfId?: string;
RecurrenceRule?: string | null;
RecurrenceEnd?: string | null;
SkipHolidays?: boolean;
RecurrenceException?: string;
};
// CLDR-Daten laden (direkt die JSON-Objekte übergeben)
loadCldr(
caGregorian as object,
numbers as object,
timeZoneNames as object,
numberingSystems as object
);
// Deutsche Lokalisierung für den Scheduler
L10n.load({
de: {
schedule: {
day: 'Tag',
week: 'Woche',
workWeek: 'Arbeitswoche',
month: 'Monat',
agenda: 'Agenda',
today: 'Heute',
noEvents: 'Keine Termine',
allDay: 'Ganztägig',
start: 'Start',
end: 'Ende',
event: 'Termin',
save: 'Speichern',
cancel: 'Abbrechen',
delete: 'Löschen',
edit: 'Bearbeiten',
newEvent: 'Neuer Termin',
title: 'Titel',
description: 'Beschreibung',
location: 'Ort',
recurrence: 'Wiederholung',
repeat: 'Wiederholen',
deleteEvent: 'Termin löschen',
deleteContent: 'Möchten Sie diesen Termin wirklich löschen?',
moreDetails: 'Mehr Details',
addTitle: 'Termintitel',
},
},
});
// Kultur setzen
setCulture('de');
// Mapping für Lucide-Icons
const iconMap: Record<string, React.ElementType> = {
Presentation,
Globe,
Video,
MessageSquare,
School,
};
const eventTemplate = (event: Event) => {
const IconComponent = iconMap[event.Icon ?? ''] || null;
// Zeitangabe formatieren
const start = event.StartTime instanceof Date ? event.StartTime : new Date(event.StartTime);
const end = event.EndTime instanceof Date ? event.EndTime : new Date(event.EndTime);
const timeString = `${start.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} - ${end.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`;
// Render TentTree icon if SkipHolidays is true
const showTentTree = !!event.SkipHolidays;
return (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start' }}>
<div style={{ display: 'flex', alignItems: 'center', color: '#000', marginBottom: 2 }}>
{IconComponent && (
<span style={{ verticalAlign: 'middle', display: 'inline-block', marginRight: 6 }}>
<IconComponent size={18} color="#000" />
</span>
)}
{showTentTree && (
<span
style={{ verticalAlign: 'middle', display: 'inline-block', marginRight: 6 }}
title="Ferientage werden berücksichtigt"
>
<TentTree size={16} color="#000" />
</span>
)}
<span style={{ marginTop: 3 }}>{event.Subject}</span>
</div>
<div style={{ fontSize: '0.95em', color: '#000', marginTop: -2 }}>{timeString}</div>
</div>
);
}
// Robust ISO date parsing: append 'Z' only if no timezone offset present
function parseEventDate(iso: string): Date {
if (!iso) return new Date(NaN);
// If string already contains 'Z' or a timezone offset like +00:00/-02:00, don't modify
const hasTZ = /Z$/i.test(iso) || /[+-]\d{2}:\d{2}$/.test(iso);
const s = hasTZ ? iso : `${iso}Z`;
return new Date(s);
}
const Appointments: React.FC = () => {
// Debug logging removed
const [groups, setGroups] = useState<Group[]>([]);
const [selectedGroupId, setSelectedGroupId] = useState<string | null>(null);
const [events, setEvents] = useState<Event[]>([]);
const [holidays, setHolidays] = useState<Holiday[]>([]);
const [modalOpen, setModalOpen] = useState(false);
const [modalInitialData, setModalInitialData] = useState({});
const [schedulerKey, setSchedulerKey] = useState(0);
const [editMode, setEditMode] = useState(false); // NEU: Editiermodus
const [showInactive, setShowInactive] = React.useState(true);
const [allowScheduleOnHolidays, setAllowScheduleOnHolidays] = React.useState(false);
const [showHolidayList, setShowHolidayList] = React.useState(true);
// Always let Syncfusion handle recurrence; do not expand on backend
// Remove expandRecurrences toggle and state
const scheduleRef = React.useRef<ScheduleComponent | null>(null);
const [holidaysInView, setHolidaysInView] = React.useState<number>(0);
const [schoolYearLabel, setSchoolYearLabel] = React.useState<string>('');
const [hasSchoolYearPlan, setHasSchoolYearPlan] = React.useState<boolean>(false);
const [periods, setPeriods] = React.useState<{ id: number; label: string }[]>([]);
const [activePeriodId, setActivePeriodId] = React.useState<number | null>(null);
// Confirmation dialog state
const [confirmDialogOpen, setConfirmDialogOpen] = React.useState(false);
const [confirmDialogData, setConfirmDialogData] = React.useState<{
title: string;
message: string;
onConfirm: () => void;
onCancel: () => void;
} | null>(null);
// Helper function to show confirmation dialog
const showConfirmDialog = (title: string, message: string): Promise<boolean> => {
return new Promise((resolve) => {
setConfirmDialogData({
title,
message,
onConfirm: () => {
setConfirmDialogOpen(false);
resolve(true);
},
onCancel: () => {
setConfirmDialogOpen(false);
resolve(false);
}
});
setConfirmDialogOpen(true);
});
};
// Gruppen laden
useEffect(() => {
fetchGroups()
.then(data => {
// Nur Gruppen mit id != 1 berücksichtigen (nicht zugeordnet ignorieren)
const filtered = Array.isArray(data) ? data.filter(g => g.id && g.name && g.id !== 1) : [];
setGroups(filtered);
if (filtered.length > 0) setSelectedGroupId(filtered[0].id);
})
.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);
setActivePeriodId(active ? active.id : null);
})
.catch(err => console.error('Akademische Perioden laden fehlgeschlagen:', err));
}, []);
// fetchAndSetEvents als useCallback definieren, damit die Dependency korrekt ist:
const fetchAndSetEvents = React.useCallback(async () => {
if (!selectedGroupId) {
setEvents([]);
return;
}
try {
// Sichtbaren Bereich bestimmen (optional, for future filtering)
const inst = scheduleRef.current;
let startRange: Date | undefined;
let endRange: Date | undefined;
// Fallback to current week if scheduler is not initialized yet
const today = new Date();
const fallbackStart = new Date(today);
const day = today.getDay();
const diffToMonday = (day + 6) % 7; // Monday=0
fallbackStart.setDate(today.getDate() - diffToMonday);
fallbackStart.setHours(0, 0, 0, 0);
const fallbackEnd = new Date(fallbackStart);
fallbackEnd.setDate(fallbackStart.getDate() + 6);
fallbackEnd.setHours(23, 59, 59, 999);
if (inst) {
const view = inst.currentView as 'Day' | 'Week' | 'WorkWeek' | 'Month' | 'Agenda';
const baseDate = inst.selectedDate as Date;
if (baseDate) {
if (view === 'Day' || view === 'Agenda') {
startRange = new Date(baseDate);
startRange.setHours(0, 0, 0, 0);
endRange = new Date(baseDate);
endRange.setHours(23, 59, 59, 999);
} else if (view === 'Week' || view === 'WorkWeek') {
const day = baseDate.getDay();
const diffToMonday = (day + 6) % 7; // Monday=0
startRange = new Date(baseDate);
startRange.setDate(baseDate.getDate() - diffToMonday);
startRange.setHours(0, 0, 0, 0);
endRange = new Date(startRange);
endRange.setDate(startRange.getDate() + 6);
endRange.setHours(23, 59, 59, 999);
} else if (view === 'Month') {
startRange = new Date(baseDate.getFullYear(), baseDate.getMonth(), 1, 0, 0, 0, 0);
endRange = new Date(baseDate.getFullYear(), baseDate.getMonth() + 1, 0, 23, 59, 59, 999);
}
}
}
if (!startRange || !endRange) {
startRange = fallbackStart;
endRange = fallbackEnd;
}
// Always request non-expanded events (expand=0)
const data = await fetchEvents(selectedGroupId, showInactive, {
start: startRange!,
end: endRange!,
expand: false,
});
// Manually expand recurring events and filter out EXDATE occurrences
const expandedEvents: Event[] = [];
for (const e of data) {
if (e.RecurrenceRule) {
// Parse EXDATE list
const exdates = new Set<string>();
if (e.RecurrenceException) {
e.RecurrenceException.split(',').forEach((dateStr: string) => {
const trimmed = dateStr.trim();
exdates.add(trimmed);
});
}
// Manual expansion for DAILY and WEEKLY recurrence
if (e.RecurrenceRule.includes('FREQ=DAILY') || e.RecurrenceRule.includes('FREQ=WEEKLY')) {
const startTime = parseEventDate(e.StartTime);
const endTime = parseEventDate(e.EndTime);
const duration = endTime.getTime() - startTime.getTime();
// Extract INTERVAL if present
let interval = 1;
const intervalMatch = e.RecurrenceRule.match(/INTERVAL=(\d+)/);
if (intervalMatch) {
interval = parseInt(intervalMatch[1], 10);
}
// Extract end date from UNTIL if present
let endDate: Date | null = null;
const untilMatch = e.RecurrenceRule.match(/UNTIL=(\d{8}T\d{6}Z)/);
if (untilMatch) {
endDate = new Date(untilMatch[1].replace(/(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z/, '$1-$2-$3T$4:$5:$6Z'));
}
// Extract COUNT or calculate from UNTIL
let maxOccurrences = 100; // Safety limit
const countMatch = e.RecurrenceRule.match(/COUNT=(\d+)/);
if (countMatch) {
maxOccurrences = parseInt(countMatch[1], 10);
} else if (endDate) {
// Calculate approximate number of occurrences until end date
const daysDiff = Math.ceil((endDate.getTime() - startTime.getTime()) / (24 * 60 * 60 * 1000));
maxOccurrences = Math.min(daysDiff + 5, 200); // Add buffer for safety
}
// Check if WEEKLY has BYDAY=MO,TU,WE,TH,FR,SA,SU (which means daily)
const isDaily = e.RecurrenceRule.includes('FREQ=DAILY') ||
(e.RecurrenceRule.includes('FREQ=WEEKLY') &&
e.RecurrenceRule.includes('BYDAY=MO,TU,WE,TH,FR,SA,SU'));
const incrementDays = isDaily ? interval : (interval * 7); // WEEKLY = 7 days * interval unless BYDAY=all days
// Generate occurrences
for (let i = 0; i < maxOccurrences; i++) {
const occurrenceStart = new Date(startTime);
occurrenceStart.setDate(startTime.getDate() + (i * incrementDays));
// Stop if we've passed the UNTIL date
if (endDate && occurrenceStart > endDate) {
break;
}
const occurrenceEnd = new Date(occurrenceStart.getTime() + duration);
// Check if this occurrence is in EXDATE list
const isExcluded = Array.from(exdates).some(exdate => {
const exDateTime = new Date(exdate);
const timeDiff = Math.abs(occurrenceStart.getTime() - exDateTime.getTime());
// Allow up to 2 hours difference to handle DST transitions
return timeDiff < 7200000; // Within 2 hours (7200000ms)
});
if (!isExcluded) {
expandedEvents.push({
Id: `${e.Id}-occurrence-${i}`,
Subject: e.Subject,
StartTime: occurrenceStart,
EndTime: occurrenceEnd,
IsAllDay: e.IsAllDay,
MediaId: e.MediaId,
Icon: e.Icon,
Type: e.Type,
OccurrenceOfId: e.Id, // Reference to master event
Recurrence: false, // Individual occurrences are not recurring
RecurrenceRule: null,
RecurrenceEnd: null,
SkipHolidays: e.SkipHolidays ?? false,
RecurrenceException: undefined,
});
}
}
} else {
// For non-DAILY recurrence, fall back to Syncfusion handling (but clear EXDATE to avoid bugs)
expandedEvents.push({
Id: e.Id,
Subject: e.Subject,
StartTime: parseEventDate(e.StartTime),
EndTime: parseEventDate(e.EndTime),
IsAllDay: e.IsAllDay,
MediaId: e.MediaId,
Icon: e.Icon,
Type: e.Type,
OccurrenceOfId: e.OccurrenceOfId,
Recurrence: true,
RecurrenceRule: e.RecurrenceRule,
RecurrenceEnd: e.RecurrenceEnd ?? null,
SkipHolidays: e.SkipHolidays ?? false,
RecurrenceException: undefined, // Clear to avoid Syncfusion bugs
});
}
} else {
// Non-recurring event - add as-is
expandedEvents.push({
Id: e.Id,
Subject: e.Subject,
StartTime: parseEventDate(e.StartTime),
EndTime: parseEventDate(e.EndTime),
IsAllDay: e.IsAllDay,
MediaId: e.MediaId,
Icon: e.Icon,
Type: e.Type,
OccurrenceOfId: e.OccurrenceOfId,
Recurrence: false,
RecurrenceRule: null,
RecurrenceEnd: null,
SkipHolidays: e.SkipHolidays ?? false,
RecurrenceException: undefined,
});
}
}
setEvents(expandedEvents);
} catch (err) {
console.error('Fehler beim Laden der Termine:', err);
}
}, [selectedGroupId, showInactive]);
React.useEffect(() => {
if (selectedGroupId) {
// selectedGroupId kann null sein, fetchEvents erwartet aber string
fetchAndSetEvents();
} else {
setEvents([]);
}
}, [selectedGroupId, showInactive, fetchAndSetEvents]);
// Helper: prüfe, ob Zeitraum einen Feiertag/Ferienbereich schneidet
const isWithinHolidayRange = React.useCallback(
(start: Date, end: Date) => {
// normalisiere Endzeit minimal (Syncfusion nutzt exklusive Enden in einigen Fällen)
const adjEnd = new Date(end);
// keine Änderung nötig unsere eigenen Events sind präzise
for (const h of holidays) {
// Holiday dates are strings YYYY-MM-DD (local date)
const hs = new Date(h.start_date + 'T00:00:00');
const he = new Date(h.end_date + 'T23:59:59');
if (
(start >= hs && start <= he) ||
(adjEnd >= hs && adjEnd <= he) ||
(start <= hs && adjEnd >= he)
) {
return true;
}
}
return false;
},
[holidays]
);
// Baue Holiday-Anzeige-Events und Block-Events
const holidayDisplayEvents: Event[] = useMemo(() => {
if (!showHolidayList) return [];
const out: Event[] = [];
for (const h of holidays) {
const start = new Date(h.start_date + 'T00:00:00');
const end = new Date(h.end_date + 'T23:59:59');
out.push({
Id: `holiday-${h.id}-display`,
Subject: h.name,
StartTime: start,
EndTime: end,
IsAllDay: true,
isHoliday: true,
});
}
return out;
}, [holidays, showHolidayList]);
const holidayBlockEvents: Event[] = useMemo(() => {
if (allowScheduleOnHolidays) return [];
const out: Event[] = [];
for (const h of holidays) {
const start = new Date(h.start_date + 'T00:00:00');
const end = new Date(h.end_date + 'T23:59:59');
out.push({
Id: `holiday-${h.id}-block`,
Subject: h.name,
StartTime: start,
EndTime: end,
IsAllDay: true,
IsBlock: true,
isHoliday: true,
});
}
return out;
}, [holidays, allowScheduleOnHolidays]);
const dataSource = useMemo(() => {
// Filter: Events with SkipHolidays=true are never shown on holidays, regardless of toggle
const filteredEvents = events.filter(ev => {
if (ev.SkipHolidays) {
// If event falls within a holiday, hide it
const s = ev.StartTime instanceof Date ? ev.StartTime : new Date(ev.StartTime);
const e = ev.EndTime instanceof Date ? ev.EndTime : new Date(ev.EndTime);
for (const h of holidays) {
const hs = new Date(h.start_date + 'T00:00:00');
const he = new Date(h.end_date + 'T23:59:59');
if (
(s >= hs && s <= he) ||
(e >= hs && e <= he) ||
(s <= hs && e >= he)
) {
return false;
}
}
}
return true;
});
return [...filteredEvents, ...holidayDisplayEvents, ...holidayBlockEvents];
}, [events, holidayDisplayEvents, holidayBlockEvents, holidays]);
// Removed dataSource logging
// Aktive akademische Periode für Datum aus dem Backend ermitteln
const refreshAcademicPeriodFor = React.useCallback(
async (baseDate: Date) => {
try {
const p = await getAcademicPeriodForDate(baseDate);
if (!p) {
setSchoolYearLabel('');
setHasSchoolYearPlan(false);
return;
}
// Anzeige: bevorzugt display_name, sonst name
const label = p.display_name ? p.display_name : 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');
let exists = false;
for (const h of holidays) {
const hs = new Date(h.start_date + 'T00:00:00');
const he = new Date(h.end_date + 'T23:59:59');
if (hs <= end && he >= start) {
exists = true;
break;
}
}
setHasSchoolYearPlan(exists);
} catch (e) {
console.error('Akademische Periode laden fehlgeschlagen:', e);
setSchoolYearLabel('');
setHasSchoolYearPlan(false);
}
},
[holidays]
);
// Anzahl an Ferienzeiträumen in aktueller Ansicht ermitteln + Perioden-Indikator setzen
const updateHolidaysInView = React.useCallback(() => {
const inst = scheduleRef.current;
if (!inst) {
setHolidaysInView(0);
return;
}
const view = inst.currentView as 'Day' | 'Week' | 'WorkWeek' | 'Month' | 'Agenda';
const baseDate = inst.selectedDate as Date;
if (!baseDate) {
setHolidaysInView(0);
return;
}
let rangeStart = new Date(baseDate);
let rangeEnd = new Date(baseDate);
if (view === 'Day' || view === 'Agenda') {
rangeStart.setHours(0, 0, 0, 0);
rangeEnd.setHours(23, 59, 59, 999);
} else if (view === 'Week' || view === 'WorkWeek') {
const day = baseDate.getDay();
const diffToMonday = (day + 6) % 7; // Monday=0
rangeStart = new Date(baseDate);
rangeStart.setDate(baseDate.getDate() - diffToMonday);
rangeStart.setHours(0, 0, 0, 0);
rangeEnd = new Date(rangeStart);
rangeEnd.setDate(rangeStart.getDate() + 6);
rangeEnd.setHours(23, 59, 59, 999);
} else if (view === 'Month') {
rangeStart = new Date(baseDate.getFullYear(), baseDate.getMonth(), 1, 0, 0, 0, 0);
rangeEnd = new Date(baseDate.getFullYear(), baseDate.getMonth() + 1, 0, 23, 59, 59, 999);
}
let count = 0;
for (const h of holidays) {
const hs = new Date(h.start_date + 'T00:00:00');
const he = new Date(h.end_date + 'T23:59:59');
const overlaps =
(hs >= rangeStart && hs <= rangeEnd) ||
(he >= rangeStart && he <= rangeEnd) ||
(hs <= rangeStart && he >= rangeEnd);
if (overlaps) count += 1;
}
setHolidaysInView(count);
// Perioden-Indikator über Backend prüfen
refreshAcademicPeriodFor(baseDate);
}, [holidays, refreshAcademicPeriodFor]);
// Aktualisiere Indikator wenn Ferien oder Ansicht (Key) wechseln
React.useEffect(() => {
updateHolidaysInView();
}, [holidays, updateHolidaysInView, schedulerKey]);
return (
<div>
<h1 style={{ fontSize: '1.5rem', fontWeight: 700, marginBottom: 16 }}>Terminmanagement</h1>
<div
style={{
marginBottom: 16,
display: 'flex',
alignItems: 'center',
gap: 16,
flexWrap: 'wrap',
}}
>
<label htmlFor="groupDropdown" className="mb-0 mr-2" style={{ whiteSpace: 'nowrap' }}>
Raumgruppe auswählen:
</label>
<DropDownListComponent
id="groupDropdown"
dataSource={groups}
fields={{ text: 'name', value: 'id' }}
placeholder="Gruppe auswählen"
value={selectedGroupId}
width="240px"
change={(e: { value: string }) => {
// <--- Typ für e ergänzt
setEvents([]); // Events sofort leeren
setSelectedGroupId(e.value);
}}
style={{}}
/>
{/* Akademische Periode Selector + Plan-Badge */}
<span style={{ marginLeft: 8, whiteSpace: 'nowrap' }}>Periode:</span>
<DropDownListComponent
id="periodDropdown"
dataSource={periods}
fields={{ text: 'label', value: 'id' }}
placeholder="Periode wählen"
value={activePeriodId ?? undefined}
width="260px"
change={async (e: { value: number }) => {
const id = Number(e.value);
if (!id) return;
try {
const updated = await setActiveAcademicPeriod(id);
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 target = new Date(targetYear, today.getMonth(), today.getDate(), 12, 0, 0);
if (scheduleRef.current) {
scheduleRef.current.selectedDate = target;
scheduleRef.current.dataBind?.();
}
updateHolidaysInView();
} catch (err) {
console.error('Aktive Periode setzen fehlgeschlagen:', err);
}
}}
style={{}}
/>
{/* School-year/period plan badge (adjacent) */}
<span
title={hasSchoolYearPlan ? 'Ferienplan ist hinterlegt' : 'Kein Ferienplan hinterlegt'}
style={{
background: hasSchoolYearPlan ? '#dcfce7' : '#f3f4f6',
border: hasSchoolYearPlan ? '1px solid #86efac' : '1px solid #e5e7eb',
color: '#000',
padding: '4px 10px',
borderRadius: 16,
fontSize: 12,
display: 'flex',
alignItems: 'center',
gap: 6,
}}
>
{hasSchoolYearPlan ? (
<CheckCircle size={14} color="#166534" />
) : (
<AlertCircle size={14} color="#6b7280" />
)}
{schoolYearLabel || 'Periode'}
</span>
</div>
<button
className="e-btn e-success mb-4"
onClick={() => {
const now = new Date();
// Runde auf die nächste halbe Stunde
const minutes = now.getMinutes();
const roundedMinutes = minutes < 30 ? 30 : 0;
const startTime = new Date(now);
startTime.setMinutes(roundedMinutes, 0, 0);
if (roundedMinutes === 0) startTime.setHours(startTime.getHours() + 1);
const endTime = new Date(startTime);
endTime.setMinutes(endTime.getMinutes() + 30);
setModalInitialData({
startDate: startTime,
startTime: startTime,
endTime: endTime,
});
setEditMode(false);
setModalOpen(true);
}}
>
Neuen Termin anlegen
</button>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 24,
marginBottom: 16,
flexWrap: 'wrap',
}}
>
<label>
<input
type="checkbox"
checked={showInactive}
onChange={e => setShowInactive(e.target.checked)}
style={{ marginRight: 8 }}
/>
Vergangene Termine anzeigen
</label>
<label>
<input
type="checkbox"
checked={allowScheduleOnHolidays}
onChange={e => setAllowScheduleOnHolidays(e.target.checked)}
style={{ marginRight: 8 }}
/>
Termine an Ferientagen erlauben
</label>
<label>
<input
type="checkbox"
checked={showHolidayList}
onChange={e => setShowHolidayList(e.target.checked)}
style={{ marginRight: 8 }}
/>
Ferien im Kalender anzeigen
</label>
{/* Removed expandRecurrences toggle: Syncfusion now always handles recurrence */}
{/* Right-aligned indicators */}
<div style={{ marginLeft: 'auto', display: 'flex', gap: 8, alignItems: 'center' }}>
{/* Holidays-in-view badge */}
<span
title="Anzahl der Ferientage/-zeiträume in der aktuellen Ansicht"
style={{
background: holidaysInView > 0 ? '#ffe8cc' : '#f3f4f6',
border: holidaysInView > 0 ? '1px solid #ffcf99' : '1px solid #e5e7eb',
color: '#000',
padding: '4px 10px',
borderRadius: 16,
fontSize: 12,
}}
>
{holidaysInView > 0 ? `Ferien im Blick: ${holidaysInView}` : 'Keine Ferien in Ansicht'}
</span>
</div>
{/* Removed expandRecurrences indicator: Syncfusion now always handles recurrence */}
</div>
<CustomEventModal
open={modalOpen}
onClose={() => {
setModalOpen(false);
setEditMode(false); // Editiermodus zurücksetzen
}}
onSave={async () => {
setModalOpen(false);
setEditMode(false);
// Force immediate data refresh
await fetchAndSetEvents();
// Force Syncfusion scheduler to refresh its internal cache
if (scheduleRef.current) {
scheduleRef.current.dataBind?.();
scheduleRef.current.refreshEvents?.();
setSchedulerKey(prev => prev + 1);
}
}}
initialData={modalInitialData}
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}
key={schedulerKey} // <-- dynamischer Key
height="750px"
locale="de"
currentView="Week"
eventSettings={{
dataSource: dataSource,
fields: {
id: 'Id',
subject: 'Subject',
startTime: 'StartTime',
endTime: 'EndTime',
isBlock: 'IsBlock',
},
template: eventTemplate, // <--- Hier das Template setzen!
}}
actionComplete={(args) => {
updateHolidaysInView();
// Bei Navigation oder Viewwechsel Events erneut laden (für Range-basierte Expansion)
if (args && (args.requestType === 'dateNavigate' || args.requestType === 'viewNavigate')) {
fetchAndSetEvents();
}
}}
cellClick={args => {
if (!allowScheduleOnHolidays && isWithinHolidayRange(args.startTime, args.endTime)) {
args.cancel = true;
return; // block creation on holidays
}
// args.startTime und args.endTime sind Date-Objekte
args.cancel = true; // Verhindert die Standardaktion
setModalInitialData({
startDate: args.startTime,
startTime: args.startTime,
endTime: args.endTime,
});
setEditMode(false); // NEU: kein Editiermodus
setModalOpen(true);
}}
popupOpen={async args => {
if (args.type === 'Editor') {
args.cancel = true;
const event = args.data;
// Determine if this is single occurrence editing
let isSingleOccurrence = false;
if (event.OccurrenceOfId) {
// This is a manually expanded occurrence from a recurring series
// Ask user if they want to edit single occurrence or entire series
isSingleOccurrence = await showConfirmDialog(
'Termin aus wiederholender Serie',
'Dies ist ein Termin aus einer wiederholenden Serie.\n\nMöchten Sie nur diesen Termin bearbeiten oder die ganze Serie?'
);
} else if (event.RecurrenceRule) {
// This is a recurring event - ask user what they want to edit
isSingleOccurrence = await showConfirmDialog(
'Wiederholender Termin',
'Dies ist ein wiederholender Termin.\n\nMöchten Sie nur diesen Termin bearbeiten oder die ganze Serie?'
);
} else {
// Regular single event
isSingleOccurrence = false;
}
// Fetch master event data if editing entire series from an occurrence
let eventDataToUse = event;
if (event.OccurrenceOfId && !isSingleOccurrence) {
try {
const masterEventData = await fetchEventById(event.OccurrenceOfId);
eventDataToUse = {
...masterEventData,
// Keep the scheduler-specific properties from the occurrence
StartTime: parseEventDate(masterEventData.StartTime),
EndTime: parseEventDate(masterEventData.EndTime),
};
} catch (err) {
console.error('Failed to load master event data:', err);
// Fall back to occurrence data if master event can't be loaded
}
}
let media = null;
if (eventDataToUse.MediaId) {
try {
const mediaData = await fetchMediaById(eventDataToUse.MediaId);
media = {
id: mediaData.id,
path: mediaData.file_path,
name: mediaData.name || mediaData.url,
};
} catch (err) {
console.error('Fehler beim Laden der Mediainfos:', err);
}
}
let repeat = false;
let weekdays: number[] = [];
let repeatUntil: Date | null = null;
// Only parse recurrence info if editing the entire series (not a single occurrence)
if (!isSingleOccurrence) {
const rr = (eventDataToUse.RecurrenceRule as string) || '';
if (rr && rr.includes('FREQ=WEEKLY')) {
repeat = true;
const m = rr.match(/BYDAY=([^;]+)/);
if (m && m[1]) {
const map: Record<string, number> = { MO: 0, TU: 1, WE: 2, TH: 3, FR: 4, SA: 5, SU: 6 };
weekdays = m[1].split(',').map(s => map[s]).filter(v => v !== undefined);
}
const mu = rr.match(/UNTIL=([0-9TZ]+)/);
if (mu && mu[1]) {
// UNTIL is UTC in yyyymmddThhmmssZ form; we take date part
const untilIso = mu[1]
.replace(/^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z$/, '$1-$2-$3T$4:$5:$6Z');
repeatUntil = new Date(untilIso);
} else if (eventDataToUse.RecurrenceEnd) {
repeatUntil = new Date(eventDataToUse.RecurrenceEnd);
}
}
}
const modalData = {
Id: (event.OccurrenceOfId && !isSingleOccurrence) ? event.OccurrenceOfId : event.Id, // Use master ID for series edit, occurrence ID for single edit
OccurrenceOfId: event.OccurrenceOfId, // Master event ID if this is an occurrence
occurrenceDate: isSingleOccurrence ? event.StartTime : null, // Store occurrence date for single occurrence editing
isSingleOccurrence,
title: eventDataToUse.Subject,
startDate: eventDataToUse.StartTime,
startTime: eventDataToUse.StartTime,
endTime: eventDataToUse.EndTime,
description: eventDataToUse.Description ?? '',
type: eventDataToUse.Type ?? 'presentation',
repeat: isSingleOccurrence ? false : repeat, // Disable recurrence for single occurrence
weekdays: isSingleOccurrence ? [] : weekdays,
repeatUntil: isSingleOccurrence ? null : repeatUntil,
skipHolidays: isSingleOccurrence ? false : (eventDataToUse.SkipHolidays ?? false),
media,
slideshowInterval: eventDataToUse.SlideshowInterval ?? 10,
websiteUrl: eventDataToUse.WebsiteUrl ?? '',
};
setModalInitialData(modalData);
setEditMode(true);
setModalOpen(true);
}
}}
eventRendered={(args: EventRenderedArgs) => {
// Always hide events that skip holidays when they fall on holidays, regardless of toggle
if (args.data) {
const ev = args.data as unknown as Partial<Event>;
if (ev.SkipHolidays && !args.data.isHoliday) {
const s =
args.data.StartTime instanceof Date
? args.data.StartTime
: new Date(args.data.StartTime);
const e =
args.data.EndTime instanceof Date ? args.data.EndTime : new Date(args.data.EndTime);
if (isWithinHolidayRange(s, e)) {
args.cancel = true;
return;
}
}
}
// Blende Nicht-Ferien-Events aus, falls sie in Ferien fallen und Terminieren nicht erlaubt ist
// Hide events on holidays if not allowed
if (!allowScheduleOnHolidays && args.data && !args.data.isHoliday) {
const s =
args.data.StartTime instanceof Date
? args.data.StartTime
: new Date(args.data.StartTime);
const e =
args.data.EndTime instanceof Date ? args.data.EndTime : new Date(args.data.EndTime);
if (isWithinHolidayRange(s, e)) {
args.cancel = true;
return;
}
}
if (selectedGroupId && args.data && args.data.Id) {
const groupColor = getGroupColor(selectedGroupId, groups);
const now = new Date();
// Vergangene Termine: Raumgruppenfarbe
if (args.data.EndTime && args.data.EndTime < now) {
args.element.style.backgroundColor = groupColor ? `${groupColor}` : '#f3f3f3';
args.element.style.color = '#000';
} else if (groupColor) {
args.element.style.backgroundColor = groupColor;
args.element.style.color = '#000';
}
// Spezielle Darstellung für Ferienanzeige-Events
if (args.data.isHoliday && !args.data.IsBlock) {
args.element.style.backgroundColor = '#ffe8cc'; // sanftes Orange
args.element.style.border = '1px solid #ffcf99';
args.element.style.color = '#000';
}
// Gleiche Darstellung für Ferien-Block-Events
if (args.data.isHoliday && args.data.IsBlock) {
args.element.style.backgroundColor = '#ffe8cc';
args.element.style.border = '1px solid #ffcf99';
args.element.style.color = '#000';
}
}
}}
actionBegin={async (args: ActionEventArgs) => {
if (args.requestType === 'eventRemove') {
// args.data ist ein Array von zu löschenden Events
const toDelete = Array.isArray(args.data) ? args.data : [args.data];
for (const ev of toDelete) {
try {
// 1) Single occurrence of a recurring event → delete occurrence only
if (ev.OccurrenceOfId && ev.StartTime) {
const occurrenceDate = ev.StartTime instanceof Date
? ev.StartTime.toISOString().split('T')[0]
: new Date(ev.StartTime).toISOString().split('T')[0];
await deleteEventOccurrence(ev.OccurrenceOfId, occurrenceDate);
continue;
}
// 2) Recurring master being removed unexpectedly → block deletion (safety)
// Syncfusion can sometimes raise eventRemove during edits; do NOT delete the series here.
if (ev.RecurrenceRule) {
console.warn('Blocked deletion of recurring master event via eventRemove.');
// If the user truly wants to delete the series, provide an explicit UI path.
continue;
}
// 3) Single non-recurring event → delete normally
await deleteEvent(ev.Id);
} catch (err) {
console.error('Fehler beim Löschen:', err);
}
}
// Events nach Löschen neu laden
if (selectedGroupId) {
fetchEvents(selectedGroupId, showInactive)
.then((data: RawEvent[]) => {
const mapped: Event[] = data.map((e: RawEvent) => ({
Id: e.Id,
Subject: e.Subject,
StartTime: parseEventDate(e.StartTime),
EndTime: parseEventDate(e.EndTime),
IsAllDay: e.IsAllDay,
MediaId: e.MediaId,
SkipHolidays: e.SkipHolidays ?? false,
}));
setEvents(mapped);
})
.catch(console.error);
}
// Syncfusion soll das Event nicht selbst löschen
args.cancel = true;
} else if (
(args.requestType === 'eventCreate' || args.requestType === 'eventChange') &&
!allowScheduleOnHolidays
) {
// Verhindere Erstellen/Ändern in Ferienbereichen (Failsafe, falls eingebauter Editor genutzt wird)
type PartialEventLike = { StartTime?: Date | string; EndTime?: Date | string };
const raw = (args as ActionEventArgs).data as
| PartialEventLike
| PartialEventLike[]
| undefined;
const data = Array.isArray(raw) ? raw[0] : raw;
if (data && data.StartTime && data.EndTime) {
const s = data.StartTime instanceof Date ? data.StartTime : new Date(data.StartTime);
const e = data.EndTime instanceof Date ? data.EndTime : new Date(data.EndTime);
if (isWithinHolidayRange(s, e)) {
args.cancel = true;
return;
}
}
}
}}
firstDayOfWeek={1}
renderCell={(args: RenderCellEventArgs) => {
// Nur für Arbeitszellen (Stunden-/Tageszellen)
if (args.elementType === 'workCells') {
const now = new Date();
// args.element ist vom Typ Element, daher als HTMLElement casten:
const cell = args.element as HTMLElement;
if (args.date && args.date < now) {
cell.style.backgroundColor = '#fff9e3'; // Hellgelb für Vergangenheit
cell.style.opacity = '0.7';
}
}
}}
>
<ViewsDirective>
<ViewDirective option="Day" />
<ViewDirective option="Week" />
<ViewDirective option="WorkWeek" />
<ViewDirective option="Month" />
<ViewDirective option="Agenda" />
</ViewsDirective>
<Inject services={[Day, Week, WorkWeek, Month, Agenda]} />
</ScheduleComponent>
{/* Confirmation Dialog */}
{confirmDialogData && (
<DialogComponent
target="#root"
visible={confirmDialogOpen}
width="500px"
header={confirmDialogData.title}
showCloseIcon={true}
close={() => {
setConfirmDialogOpen(false);
confirmDialogData.onCancel();
}}
isModal={true}
footerTemplate={() => (
<div className="flex gap-2 justify-end">
<button
className="e-btn e-primary"
onClick={confirmDialogData.onConfirm}
>
Einzeltermin bearbeiten
</button>
<button
className="e-btn e-normal"
onClick={confirmDialogData.onCancel}
>
Serie bearbeiten
</button>
</div>
)}
>
<div style={{ padding: '20px', whiteSpace: 'pre-line', fontSize: '14px' }}>
{confirmDialogData.message}
</div>
</DialogComponent>
)}
</div>
);
};
export default Appointments;