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 = { 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 (
{IconComponent && ( )} {showTentTree && ( )} {event.Subject}
{timeString}
); } // 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([]); const [selectedGroupId, setSelectedGroupId] = useState(null); const [events, setEvents] = useState([]); const [holidays, setHolidays] = useState([]); 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(null); const [holidaysInView, setHolidaysInView] = React.useState(0); const [schoolYearLabel, setSchoolYearLabel] = React.useState(''); const [hasSchoolYearPlan, setHasSchoolYearPlan] = React.useState(false); const [periods, setPeriods] = React.useState<{ id: number; label: string }[]>([]); const [activePeriodId, setActivePeriodId] = React.useState(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 => { 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(); 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 (

Terminmanagement

{ // <--- Typ für e ergänzt setEvents([]); // Events sofort leeren setSelectedGroupId(e.value); }} style={{}} /> {/* Akademische Periode Selector + Plan-Badge */} Periode: { 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) */} {hasSchoolYearPlan ? ( ) : ( )} {schoolYearLabel || 'Periode'}
{/* Removed expandRecurrences toggle: Syncfusion now always handles recurrence */} {/* Right-aligned indicators */}
{/* Holidays-in-view badge */} 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'}
{/* Removed expandRecurrences indicator: Syncfusion now always handles recurrence */}
{ 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)} /> { 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 = { 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; 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'; } } }} > {/* Confirmation Dialog */} {confirmDialogData && ( { setConfirmDialogOpen(false); confirmDialogData.onCancel(); }} isModal={true} footerTemplate={() => (
)} >
{confirmDialogData.message}
)}
); }; export default Appointments;