Backend: generate EventException on create/update when skip_holidays or recurrence changes; emit RecurrenceException (EXDATE) with exact occurrence start time (UTC) API: return master events with RecurrenceRule + RecurrenceException Frontend: map RecurrenceException → recurrenceException; ensure SkipHolidays instances never render on holidays; place TentTree icon (black) next to main event icon via template Docs: update README and Copilot instructions for recurrence/holiday behavior Cleanup: remove dataSource and debug console logs
923 lines
33 KiB
TypeScript
923 lines
33 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 { L10n, loadCldr, setCulture } from '@syncfusion/ej2-base';
|
||
import type {
|
||
EventRenderedArgs,
|
||
ActionEventArgs,
|
||
RenderCellEventArgs,
|
||
} from '@syncfusion/ej2-react-schedule';
|
||
import { fetchEvents } from './apiEvents';
|
||
import { fetchGroups } from './apiGroups';
|
||
import { getGroupColor } from './groupColors';
|
||
import { deleteEvent } 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);
|
||
|
||
// 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,
|
||
});
|
||
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,
|
||
Icon: e.Icon,
|
||
Type: e.Type,
|
||
OccurrenceOfId: e.OccurrenceOfId,
|
||
Recurrence: !!e.RecurrenceRule,
|
||
RecurrenceRule: e.RecurrenceRule ?? null,
|
||
RecurrenceEnd: e.RecurrenceEnd ?? null,
|
||
SkipHolidays: e.SkipHolidays ?? false,
|
||
RecurrenceException: e.RecurrenceException ?? undefined,
|
||
recurrenceException: e.RecurrenceException ?? undefined, // for Syncfusion
|
||
}));
|
||
setEvents(mapped);
|
||
} 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);
|
||
// Reload events using the same logic as fetchAndSetEvents
|
||
await fetchAndSetEvents();
|
||
setSchedulerKey(prev => prev + 1); // <-- Key erhöhen
|
||
}}
|
||
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;
|
||
// Removed event logging
|
||
let media = null;
|
||
if (event.MediaId) {
|
||
try {
|
||
const mediaData = await fetchMediaById(event.MediaId);
|
||
media = {
|
||
id: mediaData.id,
|
||
path: mediaData.file_path,
|
||
name: mediaData.name || mediaData.url,
|
||
};
|
||
} catch (err) {
|
||
console.error('Fehler beim Laden der Mediainfos:', err);
|
||
}
|
||
}
|
||
// Parse recurrence info if present (supports FREQ=WEEKLY;BYDAY=...;[UNTIL=...])
|
||
let repeat = false;
|
||
let weekdays: number[] = [];
|
||
let repeatUntil: Date | null = null;
|
||
const rr = (event.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 (event.RecurrenceEnd) {
|
||
repeatUntil = new Date(event.RecurrenceEnd);
|
||
}
|
||
}
|
||
|
||
setModalInitialData({
|
||
Id: event.Id,
|
||
title: event.Subject,
|
||
startDate: event.StartTime,
|
||
startTime: event.StartTime,
|
||
endTime: event.EndTime,
|
||
description: event.Description ?? '',
|
||
type: event.Type ?? 'presentation',
|
||
repeat,
|
||
weekdays,
|
||
repeatUntil,
|
||
skipHolidays: event.SkipHolidays ?? false,
|
||
media, // Metadaten werden nur bei Bedarf geladen!
|
||
slideshowInterval: event.SlideshowInterval ?? 10,
|
||
websiteUrl: event.WebsiteUrl ?? '',
|
||
});
|
||
// Removed modal initial data logging
|
||
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 {
|
||
await deleteEvent(ev.Id); // Deine API-Funktion
|
||
} catch (err) {
|
||
// Optional: Fehlerbehandlung
|
||
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>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default Appointments;
|