docs: clarify event deletion flows and dialog handling for all event types

- Documented unified deletion process for single, single-in-series, and recurring series events
- Explained custom dialog interception of Syncfusion RecurrenceAlert and DeleteAlert
- Updated both README.md and .github/copilot-instructions.md to match current frontend logic
This commit is contained in:
RobbStarkAustria
2025-10-14 19:10:38 +00:00
parent 5f0972c79c
commit 8676370fe2
6 changed files with 443 additions and 663 deletions

View File

@@ -32,8 +32,12 @@ export async function fetchEventById(eventId: string) {
return data;
}
export async function deleteEvent(eventId: string) {
const res = await fetch(`/api/events/${encodeURIComponent(eventId)}`, {
export async function deleteEvent(eventId: string, force: boolean = false) {
const url = force
? `/api/events/${encodeURIComponent(eventId)}?force=1`
: `/api/events/${encodeURIComponent(eventId)}`;
const res = await fetch(url, {
method: 'DELETE',
});
const data = await res.json();

View File

@@ -75,22 +75,6 @@ type Event = {
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,
@@ -208,6 +192,7 @@ const Appointments: React.FC = () => {
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<{
@@ -217,6 +202,44 @@ const Appointments: React.FC = () => {
onCancel: () => void;
} | null>(null);
// Recurring deletion dialog state
const [recurringDeleteDialogOpen, setRecurringDeleteDialogOpen] = React.useState(false);
const [recurringDeleteData, setRecurringDeleteData] = React.useState<{
event: Event;
onChoice: (choice: 'series' | 'occurrence' | 'cancel') => void;
} | null>(null);
// Series deletion final confirmation dialog (after choosing 'series')
const [seriesConfirmDialogOpen, setSeriesConfirmDialogOpen] = React.useState(false);
const [seriesConfirmData, setSeriesConfirmData] = React.useState<{
event: Event;
onConfirm: () => void;
onCancel: () => void;
} | null>(null);
const showSeriesConfirmDialog = (event: Event): Promise<boolean> => {
return new Promise(resolve => {
console.log('[Delete] showSeriesConfirmDialog invoked for event', event.Id);
// Defer open to next tick to avoid race with closing previous dialog
setSeriesConfirmData({
event,
onConfirm: () => {
console.log('[Delete] Series confirm dialog: confirmed');
setSeriesConfirmDialogOpen(false);
resolve(true);
},
onCancel: () => {
console.log('[Delete] Series confirm dialog: cancelled');
setSeriesConfirmDialogOpen(false);
resolve(false);
}
});
setTimeout(() => {
setSeriesConfirmDialogOpen(true);
}, 0);
});
};
// Helper function to show confirmation dialog
const showConfirmDialog = (title: string, message: string): Promise<boolean> => {
return new Promise((resolve) => {
@@ -236,6 +259,20 @@ const Appointments: React.FC = () => {
});
};
// Helper function to show recurring event deletion dialog
const showRecurringDeleteDialog = (event: Event): Promise<'series' | 'occurrence' | 'cancel'> => {
return new Promise((resolve) => {
setRecurringDeleteData({
event,
onChoice: (choice: 'series' | 'occurrence' | 'cancel') => {
setRecurringDeleteDialogOpen(false);
resolve(choice);
}
});
setRecurringDeleteDialogOpen(true);
});
};
// Gruppen laden
useEffect(() => {
fetchGroups()
@@ -563,6 +600,22 @@ const Appointments: React.FC = () => {
updateHolidaysInView();
}, [holidays, updateHolidaysInView]);
// Inject global z-index fixes for dialogs (only once)
React.useEffect(() => {
if (typeof document !== 'undefined' && !document.getElementById('series-dialog-zfix')) {
const style = document.createElement('style');
style.id = 'series-dialog-zfix';
style.textContent = `\n .final-series-dialog.e-dialog { z-index: 25000 !important; }\n .final-series-dialog + .e-dlg-overlay { z-index: 24990 !important; }\n .recurring-delete-dialog.e-dialog { z-index: 24000 !important; }\n .recurring-delete-dialog + .e-dlg-overlay { z-index: 23990 !important; }\n `;
document.head.appendChild(style);
}
}, []);
React.useEffect(() => {
if (seriesConfirmDialogOpen) {
console.log('[Delete] Series confirm dialog now visible');
}
}, [seriesConfirmDialogOpen]);
return (
<div>
<h1 style={{ fontSize: '1.5rem', fontWeight: 700, marginBottom: 16 }}>Terminmanagement</h1>
@@ -733,17 +786,25 @@ const Appointments: React.FC = () => {
setModalOpen(false);
setEditMode(false); // Editiermodus zurücksetzen
}}
onSave={async () => {
onSave={async (eventData) => {
console.log('Modal saved event data:', eventData);
// The CustomEventModal already handled the API calls internally
// For now, just refresh the data (the recurring event logic is handled in the modal itself)
console.log('Modal operation completed, refreshing data');
setModalOpen(false);
setEditMode(false);
// Force immediate data refresh
// Refresh the data and scheduler
await fetchAndSetEvents();
// Defer refresh to avoid interfering with current React commit
setTimeout(() => {
scheduleRef.current?.refreshEvents?.();
}, 0);
console.log('Modal save cycle completed - data refreshed');
}}
initialData={modalInitialData}
groupName={groups.find(g => g.id === selectedGroupId) ?? { id: selectedGroupId, name: '' }}
@@ -781,6 +842,7 @@ const Appointments: React.FC = () => {
// Persist UI-driven changes (drag/resize/editor fallbacks)
if (args && args.requestType === 'eventChanged') {
console.log('actionComplete: Processing eventChanged from direct UI interaction (drag/resize)');
try {
type SchedulerEvent = Partial<Event> & {
Id?: string | number;
@@ -810,18 +872,64 @@ const Appointments: React.FC = () => {
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
// Check if this is a single occurrence edit by looking at the original master event
const eventId = String(changed.Id);
// Debug logging to understand what Syncfusion sends
console.log('actionComplete eventChanged - Debug info:', {
eventId,
changedRecurrenceRule: changed.RecurrenceRule,
changedRecurrenceID: changed.RecurrenceID,
changedStartTime: changed.StartTime,
changedSubject: changed.Subject,
payload,
fullChangedObject: JSON.stringify(changed, null, 2)
});
// First, fetch the master event to check if it has a RecurrenceRule
let masterEvent = null;
let isMasterRecurring = false;
try {
masterEvent = await fetchEventById(eventId);
isMasterRecurring = !!masterEvent.RecurrenceRule;
console.log('Master event info:', {
masterRecurrenceRule: masterEvent.RecurrenceRule,
masterStartTime: masterEvent.StartTime,
isMasterRecurring
});
} catch (err) {
console.error('Failed to fetch master event:', err);
}
// KEY DETECTION: Syncfusion sets RecurrenceID when editing a single occurrence
const hasRecurrenceID = 'RecurrenceID' in changed && !!(changed as Record<string, unknown>).RecurrenceID;
// When dragging a single occurrence, Syncfusion may not provide RecurrenceID
// but it won't provide RecurrenceRule on the changed object
const isRecurrenceRuleStripped = isMasterRecurring && !changed.RecurrenceRule;
console.log('FINAL Edit detection:', {
isMasterRecurring,
hasRecurrenceID,
isRecurrenceRuleStripped,
masterHasRule: masterEvent?.RecurrenceRule ? 'YES' : 'NO',
changedHasRule: changed.RecurrenceRule ? 'YES' : 'NO',
decision: (hasRecurrenceID || isRecurrenceRuleStripped) ? 'DETACH' : 'UPDATE'
});
// SINGLE OCCURRENCE EDIT detection:
// 1. RecurrenceID is set (explicit single occurrence marker)
// 2. OR master has RecurrenceRule but changed object doesn't (stripped during single edit)
if (isMasterRecurring && (hasRecurrenceID || isRecurrenceRuleStripped) && changed.StartTime) {
// This is a single occurrence edit - detach it
console.log('Detaching single occurrence...');
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);
await detachEventOccurrence(Number(eventId), occDate, payload);
} else {
// This is a series edit or regular single event
console.log('Updating event directly...');
await updateEvent(eventId, payload);
}
// Refresh events and scheduler cache after persisting
@@ -860,6 +968,96 @@ const Appointments: React.FC = () => {
setModalOpen(true);
}}
popupOpen={async args => {
// Intercept Syncfusion's recurrence choice dialog (RecurrenceAlert) and replace with custom
if (args.type === 'RecurrenceAlert') {
// Prevent default Syncfusion dialog
args.cancel = true;
const event = args.data;
console.log('[RecurrenceAlert] Intercepted for event', event?.Id);
if (!event) return;
// Show our custom recurring delete dialog
const choice = await showRecurringDeleteDialog(event);
let didDelete = false;
try {
if (choice === 'series') {
const confirmed = await showSeriesConfirmDialog(event);
if (confirmed) {
await deleteEvent(event.Id, true);
didDelete = true;
}
} else if (choice === 'occurrence') {
const occurrenceDate = event.StartTime instanceof Date
? event.StartTime.toISOString().split('T')[0]
: new Date(event.StartTime).toISOString().split('T')[0];
// If this is the master being edited for a single occurrence, treat as occurrence delete
if (event.OccurrenceOfId) {
await deleteEventOccurrence(event.OccurrenceOfId, occurrenceDate);
} else {
await deleteEventOccurrence(event.Id, occurrenceDate);
}
didDelete = true;
}
} catch (e) {
console.error('Fehler bei RecurrenceAlert Löschung:', e);
}
if (didDelete) {
await fetchAndSetEvents();
setTimeout(() => scheduleRef.current?.refreshEvents?.(), 0);
}
return; // handled
}
if (args.type === 'DeleteAlert') {
// Handle delete confirmation directly here to avoid multiple dialogs
args.cancel = true;
const event = args.data;
let didDelete = false;
try {
// 1) Single occurrence of a recurring event → delete occurrence only
if (event.OccurrenceOfId && event.StartTime) {
console.log('[Delete] Deleting single occurrence via OccurrenceOfId path', {
eventId: event.Id,
masterId: event.OccurrenceOfId,
start: event.StartTime
});
const occurrenceDate = event.StartTime instanceof Date
? event.StartTime.toISOString().split('T')[0]
: new Date(event.StartTime).toISOString().split('T')[0];
await deleteEventOccurrence(event.OccurrenceOfId, occurrenceDate);
didDelete = true;
}
// 2) Recurring master event deletion → show deletion choice dialog
else if (event.RecurrenceRule) {
// For recurring events the RecurrenceAlert should have been intercepted.
console.log('[DeleteAlert] Recurring event delete without RecurrenceAlert (fallback)');
const confirmed = await showSeriesConfirmDialog(event);
if (confirmed) {
await deleteEvent(event.Id, true);
didDelete = true;
}
}
// 3) Single non-recurring event → delete normally with simple confirmation
else {
console.log('Deleting single non-recurring event:', event.Id);
await deleteEvent(event.Id, false);
didDelete = true;
}
// Refresh events only if a deletion actually occurred
if (didDelete) {
await fetchAndSetEvents();
setTimeout(() => {
scheduleRef.current?.refreshEvents?.();
}, 0);
}
} catch (err) {
console.error('Fehler beim Löschen:', err);
}
return; // Exit early for delete operations
}
if (args.type === 'Editor') {
args.cancel = true;
const event = args.data;
@@ -943,9 +1141,11 @@ const Appointments: React.FC = () => {
}
}
// Fixed: Ensure OccurrenceOfId is set for recurring events in native recurrence mode
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
OccurrenceOfId: event.OccurrenceOfId || (event.RecurrenceRule ? event.Id : undefined), // Master event ID - use current ID if it's a recurring master
occurrenceDate: isSingleOccurrence ? event.StartTime : null, // Store occurrence date for single occurrence editing
isSingleOccurrence,
title: eventDataToUse.Subject,
@@ -1029,54 +1229,14 @@ const Appointments: React.FC = () => {
}
}}
actionBegin={async (args: ActionEventArgs) => {
// Delete operations are now handled in popupOpen to avoid multiple dialogs
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
// Cancel all delete operations here - they're handled in popupOpen
args.cancel = true;
} else if (
return;
}
if (
(args.requestType === 'eventCreate' || args.requestType === 'eventChange') &&
!allowScheduleOnHolidays
) {
@@ -1156,6 +1316,167 @@ const Appointments: React.FC = () => {
</div>
</DialogComponent>
)}
{/* Recurring Event Deletion Dialog */}
{recurringDeleteDialogOpen && recurringDeleteData && (
<DialogComponent
target="#root"
visible={recurringDeleteDialogOpen}
width="500px"
zIndex={18000}
cssClass="recurring-delete-dialog"
header={() => (
<div style={{
padding: '12px 20px',
background: '#dc3545',
color: 'white',
fontWeight: 600,
borderRadius: '6px 6px 0 0'
}}>
🗑 Wiederkehrenden Termin löschen
</div>
)}
showCloseIcon={true}
close={() => recurringDeleteData.onChoice('cancel')}
isModal={true}
footerTemplate={() => (
<div style={{ padding: '12px 20px', display: 'flex', gap: '12px', justifyContent: 'flex-end' }}>
<button
className="e-btn e-outline"
onClick={() => recurringDeleteData.onChoice('cancel')}
style={{ minWidth: '100px' }}
>
Abbrechen
</button>
<button
className="e-btn e-warning"
onClick={() => recurringDeleteData.onChoice('occurrence')}
style={{ minWidth: '140px' }}
>
Nur diesen Termin
</button>
<button
className="e-btn e-danger"
onClick={() => recurringDeleteData.onChoice('series')}
style={{ minWidth: '140px' }}
>
Gesamte Serie
</button>
</div>
)}
>
<div style={{ padding: '24px', fontSize: '14px', lineHeight: 1.5 }}>
<div style={{ marginBottom: '16px', fontSize: '16px', fontWeight: 500 }}>
Sie möchten einen wiederkehrenden Termin löschen:
</div>
<div style={{
background: '#f8f9fa',
border: '1px solid #e9ecef',
borderRadius: '6px',
padding: '12px',
marginBottom: '20px',
fontWeight: 500
}}>
📅 {recurringDeleteData.event.Subject}
</div>
<div style={{ marginBottom: '16px' }}>
<strong>Was möchten Sie löschen?</strong>
</div>
<div style={{ marginBottom: '12px' }}>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '8px' }}>
<span style={{ color: '#fd7e14', fontSize: '16px' }}>📝</span>
<div>
<strong>Nur diesen Termin:</strong> Löscht nur den ausgewählten Termin. Die anderen Termine der Serie bleiben bestehen.
</div>
</div>
</div>
<div style={{ marginBottom: '20px' }}>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '8px' }}>
<span style={{ color: '#dc3545', fontSize: '16px' }}></span>
<div>
<strong>Gesamte Serie:</strong> Löscht <u>alle Termine</u> dieser Wiederholungsserie. Diese Aktion kann nicht rückgängig gemacht werden!
</div>
</div>
</div>
</div>
</DialogComponent>
)}
{/* Final Series Deletion Confirmation Dialog */}
{seriesConfirmDialogOpen && seriesConfirmData && (
<DialogComponent
target="#root"
visible={seriesConfirmDialogOpen}
width="520px"
zIndex={19000}
cssClass="final-series-dialog"
header={() => (
<div style={{
padding: '12px 20px',
background: '#b91c1c',
color: 'white',
fontWeight: 600,
borderRadius: '6px 6px 0 0'
}}>
Serie endgültig löschen
</div>
)}
showCloseIcon={true}
close={() => seriesConfirmData.onCancel()}
isModal={true}
footerTemplate={() => (
<div style={{ padding: '12px 20px', display: 'flex', gap: '12px', justifyContent: 'flex-end' }}>
<button
className="e-btn e-outline"
onClick={seriesConfirmData.onCancel}
style={{ minWidth: '110px' }}
>
Abbrechen
</button>
<button
className="e-btn e-danger"
onClick={seriesConfirmData.onConfirm}
style={{ minWidth: '180px' }}
>
Serie löschen
</button>
</div>
)}
>
<div style={{ padding: '24px', fontSize: '14px', lineHeight: 1.55 }}>
<div style={{ marginBottom: '14px' }}>
Sie sind dabei die <strong>gesamte Terminserie</strong> zu löschen:
</div>
<div style={{
background: '#fef2f2',
border: '1px solid #fecaca',
borderRadius: 6,
padding: '10px 14px',
marginBottom: 18,
fontWeight: 500
}}>
📅 {seriesConfirmData.event.Subject}
</div>
<ul style={{ margin: '0 0 18px 18px', padding: 0 }}>
<li>Alle zukünftigen und vergangenen Vorkommen werden entfernt.</li>
<li>Dieser Vorgang kann nicht rückgängig gemacht werden.</li>
<li>Einzelne bereits abgetrennte Einzeltermine bleiben bestehen.</li>
</ul>
<div style={{
background: '#fff7ed',
border: '1px solid #ffedd5',
borderRadius: 6,
padding: '10px 14px',
fontSize: 13
}}>
Wenn Sie nur einen einzelnen Termin entfernen möchten, schließen Sie diesen Dialog und wählen Sie im vorherigen Dialog "Nur diesen Termin".
</div>
</div>
</DialogComponent>
)}
</div>
);
};