diff --git a/dashboard/src/appointments.tsx b/dashboard/src/appointments.tsx index 32ed5c6..2de6e4c 100644 --- a/dashboard/src/appointments.tsx +++ b/dashboard/src/appointments.tsx @@ -22,6 +22,7 @@ import { fetchEvents, fetchEventById } from './apiEvents'; import { fetchGroups } from './apiGroups'; import { getGroupColor } from './groupColors'; import { deleteEvent, deleteEventOccurrence } from './apiEvents'; +import { detachEventOccurrence, updateEvent } from './apiEvents'; import CustomEventModal from './components/CustomEventModal'; import { fetchMediaById } from './apiClients'; import { listHolidays, type Holiday } from './apiHolidays'; @@ -66,6 +67,7 @@ type Event = { Icon?: string; // <--- Icon ergänzen! Type?: string; // <--- Typ ergänzen, falls benötigt OccurrenceOfId?: string; // Serieninstanz + RecurrenceID?: string; // Syncfusion linkage to master series Recurrence?: boolean; // Marker für Serientermin RecurrenceRule?: string | null; RecurrenceEnd?: string | null; @@ -192,7 +194,7 @@ const Appointments: React.FC = () => { const [holidays, setHolidays] = useState([]); const [modalOpen, setModalOpen] = useState(false); const [modalInitialData, setModalInitialData] = useState({}); - const [schedulerKey, setSchedulerKey] = useState(0); + // Removed schedulerKey remount mechanism; rely on refreshEvents() const [editMode, setEditMode] = useState(false); // NEU: Editiermodus const [showInactive, setShowInactive] = React.useState(true); const [allowScheduleOnHolidays, setAllowScheduleOnHolidays] = React.useState(false); @@ -323,7 +325,7 @@ const Appointments: React.FC = () => { const expandedEvents: Event[] = []; for (const e of data) { - if (e.RecurrenceRule) { + if (e.RecurrenceRule) { // Parse EXDATE list const exdates = new Set(); if (e.RecurrenceException) { @@ -333,101 +335,23 @@ const Appointments: React.FC = () => { }); } - // 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 - }); - } + // Let Syncfusion handle ALL recurrence patterns natively for proper badge display + 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: e.RecurrenceException || undefined, + }); } else { // Non-recurring event - add as-is expandedEvents.push({ @@ -637,7 +561,7 @@ const Appointments: React.FC = () => { // Aktualisiere Indikator wenn Ferien oder Ansicht (Key) wechseln React.useEffect(() => { updateHolidaysInView(); - }, [holidays, updateHolidaysInView, schedulerKey]); + }, [holidays, updateHolidaysInView]); return (
@@ -816,12 +740,10 @@ const Appointments: React.FC = () => { // 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); - } + // Defer refresh to avoid interfering with current React commit + setTimeout(() => { + scheduleRef.current?.refreshEvents?.(); + }, 0); }} initialData={modalInitialData} groupName={groups.find(g => g.id === selectedGroupId) ?? { id: selectedGroupId, name: '' }} @@ -832,7 +754,6 @@ const Appointments: React.FC = () => { /> { startTime: 'StartTime', endTime: 'EndTime', isBlock: 'IsBlock', + recurrenceID: 'RecurrenceID', // Map for proper series recognition + recurrenceRule: 'RecurrenceRule', + recurrenceException: 'RecurrenceException', }, template: eventTemplate, // <--- Hier das Template setzen! }} - actionComplete={(args) => { + actionComplete={async (args: ActionEventArgs) => { updateHolidaysInView(); // Bei Navigation oder Viewwechsel Events erneut laden (für Range-basierte Expansion) if (args && (args.requestType === 'dateNavigate' || args.requestType === 'viewNavigate')) { fetchAndSetEvents(); + return; + } + + // Persist UI-driven changes (drag/resize/editor fallbacks) + if (args && args.requestType === 'eventChanged') { + try { + type SchedulerEvent = Partial & { + Id?: string | number; + OccurrenceOfId?: string | number; + RecurrenceRule?: string | null; + isHoliday?: boolean; + IsBlock?: boolean; + StartTime?: Date | string; + EndTime?: Date | string; + Subject?: string; + }; + const raw = args.data as unknown; + const changed = (Array.isArray(raw) ? raw[0] : raw) as SchedulerEvent | undefined; + if (!changed) return; + // Ignore holiday visuals and blocks + if (changed.isHoliday || changed.IsBlock) return; + + // Build payload (only fields we can safely update here) + const payload: Record = {}; + if (changed.Subject) payload.title = changed.Subject; + if (changed.StartTime) { + const s = changed.StartTime instanceof Date ? changed.StartTime : new Date(changed.StartTime); + payload.start = s.toISOString(); + } + if (changed.EndTime) { + const e = changed.EndTime instanceof Date ? changed.EndTime : new Date(changed.EndTime); + payload.end = e.toISOString(); + } + + // Single occurrence change from a recurring master (our manual expansion marks OccurrenceOfId) + if (changed.OccurrenceOfId) { + if (!changed.StartTime) return; // cannot determine occurrence date + const occStart = changed.StartTime instanceof Date ? changed.StartTime : new Date(changed.StartTime as string); + const occDate = occStart.toISOString().split('T')[0]; + await detachEventOccurrence(Number(changed.OccurrenceOfId), occDate, payload); + } else if (changed.RecurrenceRule) { + // Change to master series (non-manually expanded recurrences) + await updateEvent(String(changed.Id), payload); + } else if (changed.Id) { + // Regular single event + await updateEvent(String(changed.Id), payload); + } + + // Refresh events and scheduler cache after persisting + await fetchAndSetEvents(); + // Defer refresh to avoid React/Syncfusion DOM race + setTimeout(() => { + scheduleRef.current?.refreshEvents?.(); + }, 0); + } catch (err) { + console.error('Persisting geänderten Termin fehlgeschlagen:', err); + } + return; + } + + // If events were created via built-in UI (rare, as we use custom modal), reload as fallback + if (args && args.requestType === 'eventCreated') { + await fetchAndSetEvents(); + setTimeout(() => { + scheduleRef.current?.refreshEvents?.(); + }, 0); } }} cellClick={args => { diff --git a/server/routes/events.py b/server/routes/events.py index aa07b30..a4b8bd8 100644 --- a/server/routes/events.py +++ b/server/routes/events.py @@ -68,14 +68,15 @@ def get_events(): for ex in all_exceptions: if ex.is_skipped: exd = ex.exception_date - # Create the EXDATE timestamp using the master event's original start time - # This should match the time when the original occurrence would have happened + # Create the EXDATE timestamp in Syncfusion's expected format + # Use the exact time of the occurrence that would have happened occ_dt = datetime( exd.year, exd.month, exd.day, base_start.hour, base_start.minute, base_start.second, tzinfo=UTC ) - token = occ_dt.strftime('%Y-%m-%dT%H:%M:%SZ') + # Format as compact ISO without separators (yyyyMMddThhmmssZ) - RFC 5545 format + token = occ_dt.strftime('%Y%m%dT%H%M%SZ') tokens.append(token) if tokens: recurrence_exception = ','.join(tokens)