Scheduler: native recurrence handling, onActionCompleted persistence, EXDATE RFC5545, UI icon cleanup
This commit is contained in:
@@ -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<Holiday[]>([]);
|
||||
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<string>();
|
||||
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 (
|
||||
<div>
|
||||
@@ -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 = () => {
|
||||
/>
|
||||
<ScheduleComponent
|
||||
ref={scheduleRef}
|
||||
key={schedulerKey} // <-- dynamischer Key
|
||||
height="750px"
|
||||
locale="de"
|
||||
currentView="Week"
|
||||
@@ -844,14 +765,83 @@ 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<Event> & {
|
||||
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<string, unknown> = {};
|
||||
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 => {
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user