Merge branch 'recurring_events_scheduler'
This commit is contained in:
@@ -22,6 +22,7 @@ import { fetchEvents, fetchEventById } from './apiEvents';
|
|||||||
import { fetchGroups } from './apiGroups';
|
import { fetchGroups } from './apiGroups';
|
||||||
import { getGroupColor } from './groupColors';
|
import { getGroupColor } from './groupColors';
|
||||||
import { deleteEvent, deleteEventOccurrence } from './apiEvents';
|
import { deleteEvent, deleteEventOccurrence } from './apiEvents';
|
||||||
|
import { detachEventOccurrence, updateEvent } from './apiEvents';
|
||||||
import CustomEventModal from './components/CustomEventModal';
|
import CustomEventModal from './components/CustomEventModal';
|
||||||
import { fetchMediaById } from './apiClients';
|
import { fetchMediaById } from './apiClients';
|
||||||
import { listHolidays, type Holiday } from './apiHolidays';
|
import { listHolidays, type Holiday } from './apiHolidays';
|
||||||
@@ -66,6 +67,7 @@ type Event = {
|
|||||||
Icon?: string; // <--- Icon ergänzen!
|
Icon?: string; // <--- Icon ergänzen!
|
||||||
Type?: string; // <--- Typ ergänzen, falls benötigt
|
Type?: string; // <--- Typ ergänzen, falls benötigt
|
||||||
OccurrenceOfId?: string; // Serieninstanz
|
OccurrenceOfId?: string; // Serieninstanz
|
||||||
|
RecurrenceID?: string; // Syncfusion linkage to master series
|
||||||
Recurrence?: boolean; // Marker für Serientermin
|
Recurrence?: boolean; // Marker für Serientermin
|
||||||
RecurrenceRule?: string | null;
|
RecurrenceRule?: string | null;
|
||||||
RecurrenceEnd?: string | null;
|
RecurrenceEnd?: string | null;
|
||||||
@@ -192,7 +194,7 @@ const Appointments: React.FC = () => {
|
|||||||
const [holidays, setHolidays] = useState<Holiday[]>([]);
|
const [holidays, setHolidays] = useState<Holiday[]>([]);
|
||||||
const [modalOpen, setModalOpen] = useState(false);
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
const [modalInitialData, setModalInitialData] = useState({});
|
const [modalInitialData, setModalInitialData] = useState({});
|
||||||
const [schedulerKey, setSchedulerKey] = useState(0);
|
// Removed schedulerKey remount mechanism; rely on refreshEvents()
|
||||||
const [editMode, setEditMode] = useState(false); // NEU: Editiermodus
|
const [editMode, setEditMode] = useState(false); // NEU: Editiermodus
|
||||||
const [showInactive, setShowInactive] = React.useState(true);
|
const [showInactive, setShowInactive] = React.useState(true);
|
||||||
const [allowScheduleOnHolidays, setAllowScheduleOnHolidays] = React.useState(false);
|
const [allowScheduleOnHolidays, setAllowScheduleOnHolidays] = React.useState(false);
|
||||||
@@ -323,7 +325,7 @@ const Appointments: React.FC = () => {
|
|||||||
const expandedEvents: Event[] = [];
|
const expandedEvents: Event[] = [];
|
||||||
|
|
||||||
for (const e of data) {
|
for (const e of data) {
|
||||||
if (e.RecurrenceRule) {
|
if (e.RecurrenceRule) {
|
||||||
// Parse EXDATE list
|
// Parse EXDATE list
|
||||||
const exdates = new Set<string>();
|
const exdates = new Set<string>();
|
||||||
if (e.RecurrenceException) {
|
if (e.RecurrenceException) {
|
||||||
@@ -333,101 +335,23 @@ const Appointments: React.FC = () => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Manual expansion for DAILY and WEEKLY recurrence
|
// Let Syncfusion handle ALL recurrence patterns natively for proper badge display
|
||||||
if (e.RecurrenceRule.includes('FREQ=DAILY') || e.RecurrenceRule.includes('FREQ=WEEKLY')) {
|
expandedEvents.push({
|
||||||
const startTime = parseEventDate(e.StartTime);
|
Id: e.Id,
|
||||||
const endTime = parseEventDate(e.EndTime);
|
Subject: e.Subject,
|
||||||
const duration = endTime.getTime() - startTime.getTime();
|
StartTime: parseEventDate(e.StartTime),
|
||||||
|
EndTime: parseEventDate(e.EndTime),
|
||||||
// Extract INTERVAL if present
|
IsAllDay: e.IsAllDay,
|
||||||
let interval = 1;
|
MediaId: e.MediaId,
|
||||||
const intervalMatch = e.RecurrenceRule.match(/INTERVAL=(\d+)/);
|
Icon: e.Icon,
|
||||||
if (intervalMatch) {
|
Type: e.Type,
|
||||||
interval = parseInt(intervalMatch[1], 10);
|
OccurrenceOfId: e.OccurrenceOfId,
|
||||||
}
|
Recurrence: true,
|
||||||
|
RecurrenceRule: e.RecurrenceRule,
|
||||||
// Extract end date from UNTIL if present
|
RecurrenceEnd: e.RecurrenceEnd ?? null,
|
||||||
let endDate: Date | null = null;
|
SkipHolidays: e.SkipHolidays ?? false,
|
||||||
const untilMatch = e.RecurrenceRule.match(/UNTIL=(\d{8}T\d{6}Z)/);
|
RecurrenceException: e.RecurrenceException || undefined,
|
||||||
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 {
|
} else {
|
||||||
// Non-recurring event - add as-is
|
// Non-recurring event - add as-is
|
||||||
expandedEvents.push({
|
expandedEvents.push({
|
||||||
@@ -637,7 +561,7 @@ const Appointments: React.FC = () => {
|
|||||||
// Aktualisiere Indikator wenn Ferien oder Ansicht (Key) wechseln
|
// Aktualisiere Indikator wenn Ferien oder Ansicht (Key) wechseln
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
updateHolidaysInView();
|
updateHolidaysInView();
|
||||||
}, [holidays, updateHolidaysInView, schedulerKey]);
|
}, [holidays, updateHolidaysInView]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -816,12 +740,10 @@ const Appointments: React.FC = () => {
|
|||||||
// Force immediate data refresh
|
// Force immediate data refresh
|
||||||
await fetchAndSetEvents();
|
await fetchAndSetEvents();
|
||||||
|
|
||||||
// Force Syncfusion scheduler to refresh its internal cache
|
// Defer refresh to avoid interfering with current React commit
|
||||||
if (scheduleRef.current) {
|
setTimeout(() => {
|
||||||
scheduleRef.current.dataBind?.();
|
scheduleRef.current?.refreshEvents?.();
|
||||||
scheduleRef.current.refreshEvents?.();
|
}, 0);
|
||||||
setSchedulerKey(prev => prev + 1);
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
initialData={modalInitialData}
|
initialData={modalInitialData}
|
||||||
groupName={groups.find(g => g.id === selectedGroupId) ?? { id: selectedGroupId, name: '' }}
|
groupName={groups.find(g => g.id === selectedGroupId) ?? { id: selectedGroupId, name: '' }}
|
||||||
@@ -832,7 +754,6 @@ const Appointments: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
<ScheduleComponent
|
<ScheduleComponent
|
||||||
ref={scheduleRef}
|
ref={scheduleRef}
|
||||||
key={schedulerKey} // <-- dynamischer Key
|
|
||||||
height="750px"
|
height="750px"
|
||||||
locale="de"
|
locale="de"
|
||||||
currentView="Week"
|
currentView="Week"
|
||||||
@@ -844,14 +765,83 @@ const Appointments: React.FC = () => {
|
|||||||
startTime: 'StartTime',
|
startTime: 'StartTime',
|
||||||
endTime: 'EndTime',
|
endTime: 'EndTime',
|
||||||
isBlock: 'IsBlock',
|
isBlock: 'IsBlock',
|
||||||
|
recurrenceID: 'RecurrenceID', // Map for proper series recognition
|
||||||
|
recurrenceRule: 'RecurrenceRule',
|
||||||
|
recurrenceException: 'RecurrenceException',
|
||||||
},
|
},
|
||||||
template: eventTemplate, // <--- Hier das Template setzen!
|
template: eventTemplate, // <--- Hier das Template setzen!
|
||||||
}}
|
}}
|
||||||
actionComplete={(args) => {
|
actionComplete={async (args: ActionEventArgs) => {
|
||||||
updateHolidaysInView();
|
updateHolidaysInView();
|
||||||
// Bei Navigation oder Viewwechsel Events erneut laden (für Range-basierte Expansion)
|
// Bei Navigation oder Viewwechsel Events erneut laden (für Range-basierte Expansion)
|
||||||
if (args && (args.requestType === 'dateNavigate' || args.requestType === 'viewNavigate')) {
|
if (args && (args.requestType === 'dateNavigate' || args.requestType === 'viewNavigate')) {
|
||||||
fetchAndSetEvents();
|
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 => {
|
cellClick={args => {
|
||||||
|
|||||||
@@ -68,14 +68,15 @@ def get_events():
|
|||||||
for ex in all_exceptions:
|
for ex in all_exceptions:
|
||||||
if ex.is_skipped:
|
if ex.is_skipped:
|
||||||
exd = ex.exception_date
|
exd = ex.exception_date
|
||||||
# Create the EXDATE timestamp using the master event's original start time
|
# Create the EXDATE timestamp in Syncfusion's expected format
|
||||||
# This should match the time when the original occurrence would have happened
|
# Use the exact time of the occurrence that would have happened
|
||||||
occ_dt = datetime(
|
occ_dt = datetime(
|
||||||
exd.year, exd.month, exd.day,
|
exd.year, exd.month, exd.day,
|
||||||
base_start.hour, base_start.minute, base_start.second,
|
base_start.hour, base_start.minute, base_start.second,
|
||||||
tzinfo=UTC
|
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)
|
tokens.append(token)
|
||||||
if tokens:
|
if tokens:
|
||||||
recurrence_exception = ','.join(tokens)
|
recurrence_exception = ','.join(tokens)
|
||||||
|
|||||||
Reference in New Issue
Block a user