- 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.
1174 lines
44 KiB
TypeScript
1174 lines
44 KiB
TypeScript
import React, { useEffect, useMemo, useState } from 'react';
|
||
import {
|
||
ScheduleComponent,
|
||
Day,
|
||
Week,
|
||
WorkWeek,
|
||
Month,
|
||
Agenda,
|
||
Inject,
|
||
ViewsDirective,
|
||
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, fetchEventById } from './apiEvents';
|
||
import { fetchGroups } from './apiGroups';
|
||
import { getGroupColor } from './groupColors';
|
||
import { deleteEvent, deleteEventOccurrence } from './apiEvents';
|
||
import CustomEventModal from './components/CustomEventModal';
|
||
import { fetchMediaById } from './apiClients';
|
||
import { listHolidays, type Holiday } from './apiHolidays';
|
||
import {
|
||
getAcademicPeriodForDate,
|
||
listAcademicPeriods,
|
||
setActiveAcademicPeriod,
|
||
} from './apiAcademicPeriods';
|
||
import {
|
||
Presentation,
|
||
Globe,
|
||
Video,
|
||
MessageSquare,
|
||
School,
|
||
CheckCircle,
|
||
AlertCircle,
|
||
TentTree,
|
||
} from 'lucide-react';
|
||
import caGregorian from './cldr/ca-gregorian.json';
|
||
import numbers from './cldr/numbers.json';
|
||
import timeZoneNames from './cldr/timeZoneNames.json';
|
||
import numberingSystems from './cldr/numberingSystems.json';
|
||
|
||
// Typ für Gruppe ergänzen
|
||
type Group = {
|
||
id: string;
|
||
name: string;
|
||
};
|
||
|
||
// Typ für Event ergänzen
|
||
type Event = {
|
||
Id: string;
|
||
Subject: string;
|
||
StartTime: Date;
|
||
EndTime: Date;
|
||
IsAllDay: boolean;
|
||
IsBlock?: boolean; // Syncfusion block appointment
|
||
isHoliday?: boolean; // marker for styling/logic
|
||
MediaId?: string | number;
|
||
SlideshowInterval?: number;
|
||
WebsiteUrl?: string;
|
||
Icon?: string; // <--- Icon ergänzen!
|
||
Type?: string; // <--- Typ ergänzen, falls benötigt
|
||
OccurrenceOfId?: string; // Serieninstanz
|
||
Recurrence?: boolean; // Marker für Serientermin
|
||
RecurrenceRule?: string | null;
|
||
RecurrenceEnd?: string | null;
|
||
SkipHolidays?: boolean;
|
||
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,
|
||
numbers as object,
|
||
timeZoneNames as object,
|
||
numberingSystems as object
|
||
);
|
||
|
||
// Deutsche Lokalisierung für den Scheduler
|
||
L10n.load({
|
||
de: {
|
||
schedule: {
|
||
day: 'Tag',
|
||
week: 'Woche',
|
||
workWeek: 'Arbeitswoche',
|
||
month: 'Monat',
|
||
agenda: 'Agenda',
|
||
today: 'Heute',
|
||
noEvents: 'Keine Termine',
|
||
allDay: 'Ganztägig',
|
||
start: 'Start',
|
||
end: 'Ende',
|
||
event: 'Termin',
|
||
save: 'Speichern',
|
||
cancel: 'Abbrechen',
|
||
delete: 'Löschen',
|
||
edit: 'Bearbeiten',
|
||
newEvent: 'Neuer Termin',
|
||
title: 'Titel',
|
||
description: 'Beschreibung',
|
||
location: 'Ort',
|
||
recurrence: 'Wiederholung',
|
||
repeat: 'Wiederholen',
|
||
deleteEvent: 'Termin löschen',
|
||
deleteContent: 'Möchten Sie diesen Termin wirklich löschen?',
|
||
moreDetails: 'Mehr Details',
|
||
addTitle: 'Termintitel',
|
||
},
|
||
},
|
||
});
|
||
|
||
// Kultur setzen
|
||
setCulture('de');
|
||
|
||
// Mapping für Lucide-Icons
|
||
const iconMap: Record<string, React.ElementType> = {
|
||
Presentation,
|
||
Globe,
|
||
Video,
|
||
MessageSquare,
|
||
School,
|
||
};
|
||
|
||
const eventTemplate = (event: Event) => {
|
||
const IconComponent = iconMap[event.Icon ?? ''] || null;
|
||
// Zeitangabe formatieren
|
||
const start = event.StartTime instanceof Date ? event.StartTime : new Date(event.StartTime);
|
||
const end = event.EndTime instanceof Date ? event.EndTime : new Date(event.EndTime);
|
||
const timeString = `${start.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} - ${end.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`;
|
||
|
||
// Render TentTree icon if SkipHolidays is true
|
||
const showTentTree = !!event.SkipHolidays;
|
||
return (
|
||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start' }}>
|
||
<div style={{ display: 'flex', alignItems: 'center', color: '#000', marginBottom: 2 }}>
|
||
{IconComponent && (
|
||
<span style={{ verticalAlign: 'middle', display: 'inline-block', marginRight: 6 }}>
|
||
<IconComponent size={18} color="#000" />
|
||
</span>
|
||
)}
|
||
{showTentTree && (
|
||
<span
|
||
style={{ verticalAlign: 'middle', display: 'inline-block', marginRight: 6 }}
|
||
title="Ferientage werden berücksichtigt"
|
||
>
|
||
<TentTree size={16} color="#000" />
|
||
</span>
|
||
)}
|
||
<span style={{ marginTop: 3 }}>{event.Subject}</span>
|
||
</div>
|
||
<div style={{ fontSize: '0.95em', color: '#000', marginTop: -2 }}>{timeString}</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// Robust ISO date parsing: append 'Z' only if no timezone offset present
|
||
function parseEventDate(iso: string): Date {
|
||
if (!iso) return new Date(NaN);
|
||
// If string already contains 'Z' or a timezone offset like +00:00/-02:00, don't modify
|
||
const hasTZ = /Z$/i.test(iso) || /[+-]\d{2}:\d{2}$/.test(iso);
|
||
const s = hasTZ ? iso : `${iso}Z`;
|
||
return new Date(s);
|
||
}
|
||
|
||
const Appointments: React.FC = () => {
|
||
|
||
// Debug logging removed
|
||
const [groups, setGroups] = useState<Group[]>([]);
|
||
const [selectedGroupId, setSelectedGroupId] = useState<string | null>(null);
|
||
const [events, setEvents] = useState<Event[]>([]);
|
||
const [holidays, setHolidays] = useState<Holiday[]>([]);
|
||
const [modalOpen, setModalOpen] = useState(false);
|
||
const [modalInitialData, setModalInitialData] = useState({});
|
||
const [schedulerKey, setSchedulerKey] = useState(0);
|
||
const [editMode, setEditMode] = useState(false); // NEU: Editiermodus
|
||
const [showInactive, setShowInactive] = React.useState(true);
|
||
const [allowScheduleOnHolidays, setAllowScheduleOnHolidays] = React.useState(false);
|
||
const [showHolidayList, setShowHolidayList] = React.useState(true);
|
||
// Always let Syncfusion handle recurrence; do not expand on backend
|
||
// Remove expandRecurrences toggle and state
|
||
const scheduleRef = React.useRef<ScheduleComponent | null>(null);
|
||
const [holidaysInView, setHolidaysInView] = React.useState<number>(0);
|
||
const [schoolYearLabel, setSchoolYearLabel] = React.useState<string>('');
|
||
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(() => {
|
||
fetchGroups()
|
||
.then(data => {
|
||
// Nur Gruppen mit id != 1 berücksichtigen (nicht zugeordnet ignorieren)
|
||
const filtered = Array.isArray(data) ? data.filter(g => g.id && g.name && g.id !== 1) : [];
|
||
setGroups(filtered);
|
||
if (filtered.length > 0) setSelectedGroupId(filtered[0].id);
|
||
})
|
||
.catch(console.error);
|
||
}, []);
|
||
|
||
// Holidays laden
|
||
useEffect(() => {
|
||
listHolidays()
|
||
.then(res => setHolidays(res.holidays || []))
|
||
.catch(err => console.error('Ferien laden fehlgeschlagen:', err));
|
||
}, []);
|
||
|
||
// Perioden laden (Dropdown)
|
||
useEffect(() => {
|
||
listAcademicPeriods()
|
||
.then(all => {
|
||
setPeriods(all.map(p => ({ id: p.id, label: p.display_name || p.name })));
|
||
const active = all.find(p => p.is_active);
|
||
setActivePeriodId(active ? active.id : null);
|
||
})
|
||
.catch(err => console.error('Akademische Perioden laden fehlgeschlagen:', err));
|
||
}, []);
|
||
|
||
// fetchAndSetEvents als useCallback definieren, damit die Dependency korrekt ist:
|
||
const fetchAndSetEvents = React.useCallback(async () => {
|
||
if (!selectedGroupId) {
|
||
setEvents([]);
|
||
return;
|
||
}
|
||
try {
|
||
// Sichtbaren Bereich bestimmen (optional, for future filtering)
|
||
const inst = scheduleRef.current;
|
||
let startRange: Date | undefined;
|
||
let endRange: Date | undefined;
|
||
// Fallback to current week if scheduler is not initialized yet
|
||
const today = new Date();
|
||
const fallbackStart = new Date(today);
|
||
const day = today.getDay();
|
||
const diffToMonday = (day + 6) % 7; // Monday=0
|
||
fallbackStart.setDate(today.getDate() - diffToMonday);
|
||
fallbackStart.setHours(0, 0, 0, 0);
|
||
const fallbackEnd = new Date(fallbackStart);
|
||
fallbackEnd.setDate(fallbackStart.getDate() + 6);
|
||
fallbackEnd.setHours(23, 59, 59, 999);
|
||
if (inst) {
|
||
const view = inst.currentView as 'Day' | 'Week' | 'WorkWeek' | 'Month' | 'Agenda';
|
||
const baseDate = inst.selectedDate as Date;
|
||
if (baseDate) {
|
||
if (view === 'Day' || view === 'Agenda') {
|
||
startRange = new Date(baseDate);
|
||
startRange.setHours(0, 0, 0, 0);
|
||
endRange = new Date(baseDate);
|
||
endRange.setHours(23, 59, 59, 999);
|
||
} else if (view === 'Week' || view === 'WorkWeek') {
|
||
const day = baseDate.getDay();
|
||
const diffToMonday = (day + 6) % 7; // Monday=0
|
||
startRange = new Date(baseDate);
|
||
startRange.setDate(baseDate.getDate() - diffToMonday);
|
||
startRange.setHours(0, 0, 0, 0);
|
||
endRange = new Date(startRange);
|
||
endRange.setDate(startRange.getDate() + 6);
|
||
endRange.setHours(23, 59, 59, 999);
|
||
} else if (view === 'Month') {
|
||
startRange = new Date(baseDate.getFullYear(), baseDate.getMonth(), 1, 0, 0, 0, 0);
|
||
endRange = new Date(baseDate.getFullYear(), baseDate.getMonth() + 1, 0, 23, 59, 59, 999);
|
||
}
|
||
}
|
||
}
|
||
if (!startRange || !endRange) {
|
||
startRange = fallbackStart;
|
||
endRange = fallbackEnd;
|
||
}
|
||
// Always request non-expanded events (expand=0)
|
||
const data = await fetchEvents(selectedGroupId, showInactive, {
|
||
start: startRange!,
|
||
end: endRange!,
|
||
expand: false,
|
||
});
|
||
// 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);
|
||
}
|
||
}, [selectedGroupId, showInactive]);
|
||
|
||
React.useEffect(() => {
|
||
if (selectedGroupId) {
|
||
// selectedGroupId kann null sein, fetchEvents erwartet aber string
|
||
fetchAndSetEvents();
|
||
} else {
|
||
setEvents([]);
|
||
}
|
||
}, [selectedGroupId, showInactive, fetchAndSetEvents]);
|
||
|
||
// Helper: prüfe, ob Zeitraum einen Feiertag/Ferienbereich schneidet
|
||
const isWithinHolidayRange = React.useCallback(
|
||
(start: Date, end: Date) => {
|
||
// normalisiere Endzeit minimal (Syncfusion nutzt exklusive Enden in einigen Fällen)
|
||
const adjEnd = new Date(end);
|
||
// keine Änderung nötig – unsere eigenen Events sind präzise
|
||
for (const h of holidays) {
|
||
// Holiday dates are strings YYYY-MM-DD (local date)
|
||
const hs = new Date(h.start_date + 'T00:00:00');
|
||
const he = new Date(h.end_date + 'T23:59:59');
|
||
if (
|
||
(start >= hs && start <= he) ||
|
||
(adjEnd >= hs && adjEnd <= he) ||
|
||
(start <= hs && adjEnd >= he)
|
||
) {
|
||
return true;
|
||
}
|
||
}
|
||
return false;
|
||
},
|
||
[holidays]
|
||
);
|
||
|
||
// Baue Holiday-Anzeige-Events und Block-Events
|
||
const holidayDisplayEvents: Event[] = useMemo(() => {
|
||
if (!showHolidayList) return [];
|
||
const out: Event[] = [];
|
||
for (const h of holidays) {
|
||
const start = new Date(h.start_date + 'T00:00:00');
|
||
const end = new Date(h.end_date + 'T23:59:59');
|
||
out.push({
|
||
Id: `holiday-${h.id}-display`,
|
||
Subject: h.name,
|
||
StartTime: start,
|
||
EndTime: end,
|
||
IsAllDay: true,
|
||
isHoliday: true,
|
||
});
|
||
}
|
||
return out;
|
||
}, [holidays, showHolidayList]);
|
||
|
||
const holidayBlockEvents: Event[] = useMemo(() => {
|
||
if (allowScheduleOnHolidays) return [];
|
||
const out: Event[] = [];
|
||
for (const h of holidays) {
|
||
const start = new Date(h.start_date + 'T00:00:00');
|
||
const end = new Date(h.end_date + 'T23:59:59');
|
||
out.push({
|
||
Id: `holiday-${h.id}-block`,
|
||
Subject: h.name,
|
||
StartTime: start,
|
||
EndTime: end,
|
||
IsAllDay: true,
|
||
IsBlock: true,
|
||
isHoliday: true,
|
||
});
|
||
}
|
||
return out;
|
||
}, [holidays, allowScheduleOnHolidays]);
|
||
|
||
const dataSource = useMemo(() => {
|
||
// Filter: Events with SkipHolidays=true are never shown on holidays, regardless of toggle
|
||
const filteredEvents = events.filter(ev => {
|
||
if (ev.SkipHolidays) {
|
||
// If event falls within a holiday, hide it
|
||
const s = ev.StartTime instanceof Date ? ev.StartTime : new Date(ev.StartTime);
|
||
const e = ev.EndTime instanceof Date ? ev.EndTime : new Date(ev.EndTime);
|
||
for (const h of holidays) {
|
||
const hs = new Date(h.start_date + 'T00:00:00');
|
||
const he = new Date(h.end_date + 'T23:59:59');
|
||
if (
|
||
(s >= hs && s <= he) ||
|
||
(e >= hs && e <= he) ||
|
||
(s <= hs && e >= he)
|
||
) {
|
||
return false;
|
||
}
|
||
}
|
||
}
|
||
return true;
|
||
});
|
||
return [...filteredEvents, ...holidayDisplayEvents, ...holidayBlockEvents];
|
||
}, [events, holidayDisplayEvents, holidayBlockEvents, holidays]);
|
||
|
||
// Removed dataSource logging
|
||
|
||
// Aktive akademische Periode für Datum aus dem Backend ermitteln
|
||
const refreshAcademicPeriodFor = React.useCallback(
|
||
async (baseDate: Date) => {
|
||
try {
|
||
const p = await getAcademicPeriodForDate(baseDate);
|
||
if (!p) {
|
||
setSchoolYearLabel('');
|
||
setHasSchoolYearPlan(false);
|
||
return;
|
||
}
|
||
// Anzeige: bevorzugt display_name, sonst name
|
||
const label = p.display_name ? p.display_name : p.name;
|
||
setSchoolYearLabel(label);
|
||
// Existiert ein Ferienplan innerhalb der Periode?
|
||
const start = new Date(p.start_date + 'T00:00:00');
|
||
const end = new Date(p.end_date + 'T23:59:59');
|
||
let exists = false;
|
||
for (const h of holidays) {
|
||
const hs = new Date(h.start_date + 'T00:00:00');
|
||
const he = new Date(h.end_date + 'T23:59:59');
|
||
if (hs <= end && he >= start) {
|
||
exists = true;
|
||
break;
|
||
}
|
||
}
|
||
setHasSchoolYearPlan(exists);
|
||
} catch (e) {
|
||
console.error('Akademische Periode laden fehlgeschlagen:', e);
|
||
setSchoolYearLabel('');
|
||
setHasSchoolYearPlan(false);
|
||
}
|
||
},
|
||
[holidays]
|
||
);
|
||
|
||
// Anzahl an Ferienzeiträumen in aktueller Ansicht ermitteln + Perioden-Indikator setzen
|
||
const updateHolidaysInView = React.useCallback(() => {
|
||
const inst = scheduleRef.current;
|
||
if (!inst) {
|
||
setHolidaysInView(0);
|
||
return;
|
||
}
|
||
const view = inst.currentView as 'Day' | 'Week' | 'WorkWeek' | 'Month' | 'Agenda';
|
||
const baseDate = inst.selectedDate as Date;
|
||
if (!baseDate) {
|
||
setHolidaysInView(0);
|
||
return;
|
||
}
|
||
let rangeStart = new Date(baseDate);
|
||
let rangeEnd = new Date(baseDate);
|
||
if (view === 'Day' || view === 'Agenda') {
|
||
rangeStart.setHours(0, 0, 0, 0);
|
||
rangeEnd.setHours(23, 59, 59, 999);
|
||
} else if (view === 'Week' || view === 'WorkWeek') {
|
||
const day = baseDate.getDay();
|
||
const diffToMonday = (day + 6) % 7; // Monday=0
|
||
rangeStart = new Date(baseDate);
|
||
rangeStart.setDate(baseDate.getDate() - diffToMonday);
|
||
rangeStart.setHours(0, 0, 0, 0);
|
||
rangeEnd = new Date(rangeStart);
|
||
rangeEnd.setDate(rangeStart.getDate() + 6);
|
||
rangeEnd.setHours(23, 59, 59, 999);
|
||
} else if (view === 'Month') {
|
||
rangeStart = new Date(baseDate.getFullYear(), baseDate.getMonth(), 1, 0, 0, 0, 0);
|
||
rangeEnd = new Date(baseDate.getFullYear(), baseDate.getMonth() + 1, 0, 23, 59, 59, 999);
|
||
}
|
||
let count = 0;
|
||
for (const h of holidays) {
|
||
const hs = new Date(h.start_date + 'T00:00:00');
|
||
const he = new Date(h.end_date + 'T23:59:59');
|
||
const overlaps =
|
||
(hs >= rangeStart && hs <= rangeEnd) ||
|
||
(he >= rangeStart && he <= rangeEnd) ||
|
||
(hs <= rangeStart && he >= rangeEnd);
|
||
if (overlaps) count += 1;
|
||
}
|
||
setHolidaysInView(count);
|
||
// Perioden-Indikator über Backend prüfen
|
||
refreshAcademicPeriodFor(baseDate);
|
||
}, [holidays, refreshAcademicPeriodFor]);
|
||
|
||
// Aktualisiere Indikator wenn Ferien oder Ansicht (Key) wechseln
|
||
React.useEffect(() => {
|
||
updateHolidaysInView();
|
||
}, [holidays, updateHolidaysInView, schedulerKey]);
|
||
|
||
return (
|
||
<div>
|
||
<h1 style={{ fontSize: '1.5rem', fontWeight: 700, marginBottom: 16 }}>Terminmanagement</h1>
|
||
<div
|
||
style={{
|
||
marginBottom: 16,
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
gap: 16,
|
||
flexWrap: 'wrap',
|
||
}}
|
||
>
|
||
<label htmlFor="groupDropdown" className="mb-0 mr-2" style={{ whiteSpace: 'nowrap' }}>
|
||
Raumgruppe auswählen:
|
||
</label>
|
||
<DropDownListComponent
|
||
id="groupDropdown"
|
||
dataSource={groups}
|
||
fields={{ text: 'name', value: 'id' }}
|
||
placeholder="Gruppe auswählen"
|
||
value={selectedGroupId}
|
||
width="240px"
|
||
change={(e: { value: string }) => {
|
||
// <--- Typ für e ergänzt
|
||
setEvents([]); // Events sofort leeren
|
||
setSelectedGroupId(e.value);
|
||
}}
|
||
style={{}}
|
||
/>
|
||
|
||
{/* Akademische Periode Selector + Plan-Badge */}
|
||
<span style={{ marginLeft: 8, whiteSpace: 'nowrap' }}>Periode:</span>
|
||
<DropDownListComponent
|
||
id="periodDropdown"
|
||
dataSource={periods}
|
||
fields={{ text: 'label', value: 'id' }}
|
||
placeholder="Periode wählen"
|
||
value={activePeriodId ?? undefined}
|
||
width="260px"
|
||
change={async (e: { value: number }) => {
|
||
const id = Number(e.value);
|
||
if (!id) return;
|
||
try {
|
||
const updated = await setActiveAcademicPeriod(id);
|
||
setActivePeriodId(updated.id);
|
||
// Zum gleichen Tag/Monat (heute) innerhalb der gewählten Periode springen
|
||
const today = new Date();
|
||
const targetYear = new Date(updated.start_date).getFullYear();
|
||
const target = new Date(targetYear, today.getMonth(), today.getDate(), 12, 0, 0);
|
||
if (scheduleRef.current) {
|
||
scheduleRef.current.selectedDate = target;
|
||
scheduleRef.current.dataBind?.();
|
||
}
|
||
updateHolidaysInView();
|
||
} catch (err) {
|
||
console.error('Aktive Periode setzen fehlgeschlagen:', err);
|
||
}
|
||
}}
|
||
style={{}}
|
||
/>
|
||
{/* School-year/period plan badge (adjacent) */}
|
||
<span
|
||
title={hasSchoolYearPlan ? 'Ferienplan ist hinterlegt' : 'Kein Ferienplan hinterlegt'}
|
||
style={{
|
||
background: hasSchoolYearPlan ? '#dcfce7' : '#f3f4f6',
|
||
border: hasSchoolYearPlan ? '1px solid #86efac' : '1px solid #e5e7eb',
|
||
color: '#000',
|
||
padding: '4px 10px',
|
||
borderRadius: 16,
|
||
fontSize: 12,
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
gap: 6,
|
||
}}
|
||
>
|
||
{hasSchoolYearPlan ? (
|
||
<CheckCircle size={14} color="#166534" />
|
||
) : (
|
||
<AlertCircle size={14} color="#6b7280" />
|
||
)}
|
||
{schoolYearLabel || 'Periode'}
|
||
</span>
|
||
</div>
|
||
<button
|
||
className="e-btn e-success mb-4"
|
||
onClick={() => {
|
||
const now = new Date();
|
||
// Runde auf die nächste halbe Stunde
|
||
const minutes = now.getMinutes();
|
||
const roundedMinutes = minutes < 30 ? 30 : 0;
|
||
const startTime = new Date(now);
|
||
startTime.setMinutes(roundedMinutes, 0, 0);
|
||
if (roundedMinutes === 0) startTime.setHours(startTime.getHours() + 1);
|
||
|
||
const endTime = new Date(startTime);
|
||
endTime.setMinutes(endTime.getMinutes() + 30);
|
||
|
||
setModalInitialData({
|
||
startDate: startTime,
|
||
startTime: startTime,
|
||
endTime: endTime,
|
||
});
|
||
setEditMode(false);
|
||
setModalOpen(true);
|
||
}}
|
||
>
|
||
Neuen Termin anlegen
|
||
</button>
|
||
<div
|
||
style={{
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
gap: 24,
|
||
marginBottom: 16,
|
||
flexWrap: 'wrap',
|
||
}}
|
||
>
|
||
<label>
|
||
<input
|
||
type="checkbox"
|
||
checked={showInactive}
|
||
onChange={e => setShowInactive(e.target.checked)}
|
||
style={{ marginRight: 8 }}
|
||
/>
|
||
Vergangene Termine anzeigen
|
||
</label>
|
||
<label>
|
||
<input
|
||
type="checkbox"
|
||
checked={allowScheduleOnHolidays}
|
||
onChange={e => setAllowScheduleOnHolidays(e.target.checked)}
|
||
style={{ marginRight: 8 }}
|
||
/>
|
||
Termine an Ferientagen erlauben
|
||
</label>
|
||
<label>
|
||
<input
|
||
type="checkbox"
|
||
checked={showHolidayList}
|
||
onChange={e => setShowHolidayList(e.target.checked)}
|
||
style={{ marginRight: 8 }}
|
||
/>
|
||
Ferien im Kalender anzeigen
|
||
</label>
|
||
{/* Removed expandRecurrences toggle: Syncfusion now always handles recurrence */}
|
||
{/* Right-aligned indicators */}
|
||
<div style={{ marginLeft: 'auto', display: 'flex', gap: 8, alignItems: 'center' }}>
|
||
{/* Holidays-in-view badge */}
|
||
<span
|
||
title="Anzahl der Ferientage/-zeiträume in der aktuellen Ansicht"
|
||
style={{
|
||
background: holidaysInView > 0 ? '#ffe8cc' : '#f3f4f6',
|
||
border: holidaysInView > 0 ? '1px solid #ffcf99' : '1px solid #e5e7eb',
|
||
color: '#000',
|
||
padding: '4px 10px',
|
||
borderRadius: 16,
|
||
fontSize: 12,
|
||
}}
|
||
>
|
||
{holidaysInView > 0 ? `Ferien im Blick: ${holidaysInView}` : 'Keine Ferien in Ansicht'}
|
||
</span>
|
||
</div>
|
||
{/* Removed expandRecurrences indicator: Syncfusion now always handles recurrence */}
|
||
</div>
|
||
<CustomEventModal
|
||
open={modalOpen}
|
||
onClose={() => {
|
||
setModalOpen(false);
|
||
setEditMode(false); // Editiermodus zurücksetzen
|
||
}}
|
||
onSave={async () => {
|
||
setModalOpen(false);
|
||
setEditMode(false);
|
||
|
||
// 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);
|
||
}
|
||
}}
|
||
initialData={modalInitialData}
|
||
groupName={groups.find(g => g.id === selectedGroupId) ?? { id: selectedGroupId, name: '' }}
|
||
groupColor={selectedGroupId ? getGroupColor(selectedGroupId, groups) : undefined}
|
||
editMode={editMode} // NEU: Prop für Editiermodus
|
||
blockHolidays={!allowScheduleOnHolidays}
|
||
isHolidayRange={(s, e) => isWithinHolidayRange(s, e)}
|
||
/>
|
||
<ScheduleComponent
|
||
ref={scheduleRef}
|
||
key={schedulerKey} // <-- dynamischer Key
|
||
height="750px"
|
||
locale="de"
|
||
currentView="Week"
|
||
eventSettings={{
|
||
dataSource: dataSource,
|
||
fields: {
|
||
id: 'Id',
|
||
subject: 'Subject',
|
||
startTime: 'StartTime',
|
||
endTime: 'EndTime',
|
||
isBlock: 'IsBlock',
|
||
},
|
||
template: eventTemplate, // <--- Hier das Template setzen!
|
||
}}
|
||
actionComplete={(args) => {
|
||
updateHolidaysInView();
|
||
// Bei Navigation oder Viewwechsel Events erneut laden (für Range-basierte Expansion)
|
||
if (args && (args.requestType === 'dateNavigate' || args.requestType === 'viewNavigate')) {
|
||
fetchAndSetEvents();
|
||
}
|
||
}}
|
||
cellClick={args => {
|
||
if (!allowScheduleOnHolidays && isWithinHolidayRange(args.startTime, args.endTime)) {
|
||
args.cancel = true;
|
||
return; // block creation on holidays
|
||
}
|
||
// args.startTime und args.endTime sind Date-Objekte
|
||
args.cancel = true; // Verhindert die Standardaktion
|
||
setModalInitialData({
|
||
startDate: args.startTime,
|
||
startTime: args.startTime,
|
||
endTime: args.endTime,
|
||
});
|
||
setEditMode(false); // NEU: kein Editiermodus
|
||
setModalOpen(true);
|
||
}}
|
||
popupOpen={async args => {
|
||
if (args.type === 'Editor') {
|
||
args.cancel = true;
|
||
const event = args.data;
|
||
|
||
|
||
// 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 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,
|
||
name: mediaData.name || mediaData.url,
|
||
};
|
||
} catch (err) {
|
||
console.error('Fehler beim Laden der Mediainfos:', err);
|
||
}
|
||
}
|
||
|
||
let repeat = false;
|
||
let weekdays: number[] = [];
|
||
let repeatUntil: Date | null = null;
|
||
|
||
// 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);
|
||
}
|
||
}
|
||
}
|
||
|
||
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);
|
||
}
|
||
}}
|
||
eventRendered={(args: EventRenderedArgs) => {
|
||
// Always hide events that skip holidays when they fall on holidays, regardless of toggle
|
||
if (args.data) {
|
||
const ev = args.data as unknown as Partial<Event>;
|
||
if (ev.SkipHolidays && !args.data.isHoliday) {
|
||
const s =
|
||
args.data.StartTime instanceof Date
|
||
? args.data.StartTime
|
||
: new Date(args.data.StartTime);
|
||
const e =
|
||
args.data.EndTime instanceof Date ? args.data.EndTime : new Date(args.data.EndTime);
|
||
if (isWithinHolidayRange(s, e)) {
|
||
args.cancel = true;
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Blende Nicht-Ferien-Events aus, falls sie in Ferien fallen und Terminieren nicht erlaubt ist
|
||
// Hide events on holidays if not allowed
|
||
if (!allowScheduleOnHolidays && args.data && !args.data.isHoliday) {
|
||
const s =
|
||
args.data.StartTime instanceof Date
|
||
? args.data.StartTime
|
||
: new Date(args.data.StartTime);
|
||
const e =
|
||
args.data.EndTime instanceof Date ? args.data.EndTime : new Date(args.data.EndTime);
|
||
if (isWithinHolidayRange(s, e)) {
|
||
args.cancel = true;
|
||
return;
|
||
}
|
||
}
|
||
|
||
if (selectedGroupId && args.data && args.data.Id) {
|
||
const groupColor = getGroupColor(selectedGroupId, groups);
|
||
const now = new Date();
|
||
|
||
// Vergangene Termine: Raumgruppenfarbe
|
||
if (args.data.EndTime && args.data.EndTime < now) {
|
||
args.element.style.backgroundColor = groupColor ? `${groupColor}` : '#f3f3f3';
|
||
args.element.style.color = '#000';
|
||
} else if (groupColor) {
|
||
args.element.style.backgroundColor = groupColor;
|
||
args.element.style.color = '#000';
|
||
}
|
||
|
||
// Spezielle Darstellung für Ferienanzeige-Events
|
||
if (args.data.isHoliday && !args.data.IsBlock) {
|
||
args.element.style.backgroundColor = '#ffe8cc'; // sanftes Orange
|
||
args.element.style.border = '1px solid #ffcf99';
|
||
args.element.style.color = '#000';
|
||
}
|
||
// Gleiche Darstellung für Ferien-Block-Events
|
||
if (args.data.isHoliday && args.data.IsBlock) {
|
||
args.element.style.backgroundColor = '#ffe8cc';
|
||
args.element.style.border = '1px solid #ffcf99';
|
||
args.element.style.color = '#000';
|
||
}
|
||
}
|
||
}}
|
||
actionBegin={async (args: ActionEventArgs) => {
|
||
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
|
||
args.cancel = true;
|
||
} else if (
|
||
(args.requestType === 'eventCreate' || args.requestType === 'eventChange') &&
|
||
!allowScheduleOnHolidays
|
||
) {
|
||
// Verhindere Erstellen/Ändern in Ferienbereichen (Failsafe, falls eingebauter Editor genutzt wird)
|
||
type PartialEventLike = { StartTime?: Date | string; EndTime?: Date | string };
|
||
const raw = (args as ActionEventArgs).data as
|
||
| PartialEventLike
|
||
| PartialEventLike[]
|
||
| undefined;
|
||
const data = Array.isArray(raw) ? raw[0] : raw;
|
||
if (data && data.StartTime && data.EndTime) {
|
||
const s = data.StartTime instanceof Date ? data.StartTime : new Date(data.StartTime);
|
||
const e = data.EndTime instanceof Date ? data.EndTime : new Date(data.EndTime);
|
||
if (isWithinHolidayRange(s, e)) {
|
||
args.cancel = true;
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
}}
|
||
firstDayOfWeek={1}
|
||
renderCell={(args: RenderCellEventArgs) => {
|
||
// Nur für Arbeitszellen (Stunden-/Tageszellen)
|
||
if (args.elementType === 'workCells') {
|
||
const now = new Date();
|
||
// args.element ist vom Typ Element, daher als HTMLElement casten:
|
||
const cell = args.element as HTMLElement;
|
||
if (args.date && args.date < now) {
|
||
cell.style.backgroundColor = '#fff9e3'; // Hellgelb für Vergangenheit
|
||
cell.style.opacity = '0.7';
|
||
}
|
||
}
|
||
}}
|
||
>
|
||
<ViewsDirective>
|
||
<ViewDirective option="Day" />
|
||
<ViewDirective option="Week" />
|
||
<ViewDirective option="WorkWeek" />
|
||
<ViewDirective option="Month" />
|
||
<ViewDirective option="Agenda" />
|
||
</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>
|
||
);
|
||
};
|
||
|
||
export default Appointments;
|