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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user