feat(events): reliable holiday skipping for recurrences + UI badge; clean logs
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
This commit is contained in:
31
dashboard/src/apiEventExceptions.ts
Normal file
31
dashboard/src/apiEventExceptions.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
export interface EventException {
|
||||
id?: number;
|
||||
event_id: number;
|
||||
exception_date: string; // YYYY-MM-DD
|
||||
is_skipped: boolean;
|
||||
override_title?: string;
|
||||
override_description?: string;
|
||||
override_start?: string;
|
||||
override_end?: string;
|
||||
}
|
||||
|
||||
export async function createEventException(exception: Omit<EventException, 'id'>) {
|
||||
const res = await fetch('/api/event_exceptions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(exception),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok || data.error) throw new Error(data.error || 'Fehler beim Erstellen der Ausnahme');
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function listEventExceptions(eventId?: number) {
|
||||
const params = new URLSearchParams();
|
||||
if (eventId) params.set('event_id', eventId.toString());
|
||||
|
||||
const res = await fetch(`/api/event_exceptions?${params.toString()}`);
|
||||
const data = await res.json();
|
||||
if (!res.ok || data.error) throw new Error(data.error || 'Fehler beim Laden der Ausnahmen');
|
||||
return data as EventException[];
|
||||
}
|
||||
@@ -8,10 +8,18 @@ export interface Event {
|
||||
extendedProps: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export async function fetchEvents(groupId: string, showInactive = false) {
|
||||
const res = await fetch(
|
||||
`/api/events?group_id=${encodeURIComponent(groupId)}&show_inactive=${showInactive ? '1' : '0'}`
|
||||
);
|
||||
export async function fetchEvents(
|
||||
groupId: string,
|
||||
showInactive = false,
|
||||
options?: { start?: Date; end?: Date; expand?: boolean }
|
||||
) {
|
||||
const params = new URLSearchParams();
|
||||
params.set('group_id', groupId);
|
||||
params.set('show_inactive', showInactive ? '1' : '0');
|
||||
if (options?.start) params.set('start', options.start.toISOString());
|
||||
if (options?.end) params.set('end', options.end.toISOString());
|
||||
if (options?.expand) params.set('expand', options.expand ? '1' : '0');
|
||||
const res = await fetch(`/api/events?${params.toString()}`);
|
||||
const data = await res.json();
|
||||
if (!res.ok || data.error) throw new Error(data.error || 'Fehler beim Laden der Termine');
|
||||
return data;
|
||||
|
||||
@@ -37,8 +37,8 @@ import {
|
||||
School,
|
||||
CheckCircle,
|
||||
AlertCircle,
|
||||
TentTree,
|
||||
} from 'lucide-react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import caGregorian from './cldr/ca-gregorian.json';
|
||||
import numbers from './cldr/numbers.json';
|
||||
import timeZoneNames from './cldr/timeZoneNames.json';
|
||||
@@ -64,6 +64,12 @@ type Event = {
|
||||
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 = {
|
||||
@@ -75,6 +81,11 @@ type RawEvent = {
|
||||
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)
|
||||
@@ -137,6 +148,8 @@ const eventTemplate = (event: Event) => {
|
||||
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 }}>
|
||||
@@ -145,17 +158,36 @@ const eventTemplate = (event: Event) => {
|
||||
<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 = () => {
|
||||
const [groups, setGroups] = useState<Group[]>([]);
|
||||
const [selectedGroupId, setSelectedGroupId] = useState<string | null>(null);
|
||||
const [events, setEvents] = useState<Event[]>([]);
|
||||
|
||||
// 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({});
|
||||
@@ -164,6 +196,8 @@ const Appointments: React.FC = () => {
|
||||
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>('');
|
||||
@@ -208,16 +242,70 @@ const Appointments: React.FC = () => {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const data = await fetchEvents(selectedGroupId, showInactive); // selectedGroupId ist jetzt garantiert string
|
||||
// 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: new Date(e.StartTime.endsWith('Z') ? e.StartTime : e.StartTime + 'Z'),
|
||||
EndTime: new Date(e.EndTime.endsWith('Z') ? e.EndTime : e.EndTime + 'Z'),
|
||||
StartTime: parseEventDate(e.StartTime),
|
||||
EndTime: parseEventDate(e.EndTime),
|
||||
IsAllDay: e.IsAllDay,
|
||||
MediaId: e.MediaId,
|
||||
Icon: e.Icon, // <--- Icon übernehmen!
|
||||
Type: e.Type, // <--- Typ übernehmen!
|
||||
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) {
|
||||
@@ -296,8 +384,30 @@ const Appointments: React.FC = () => {
|
||||
}, [holidays, allowScheduleOnHolidays]);
|
||||
|
||||
const dataSource = useMemo(() => {
|
||||
return [...events, ...holidayDisplayEvents, ...holidayBlockEvents];
|
||||
}, [events, holidayDisplayEvents, holidayBlockEvents]);
|
||||
// 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(
|
||||
@@ -529,6 +639,7 @@ const Appointments: React.FC = () => {
|
||||
/>
|
||||
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 */}
|
||||
@@ -546,6 +657,7 @@ const Appointments: React.FC = () => {
|
||||
{holidaysInView > 0 ? `Ferien im Blick: ${holidaysInView}` : 'Keine Ferien in Ansicht'}
|
||||
</span>
|
||||
</div>
|
||||
{/* Removed expandRecurrences indicator: Syncfusion now always handles recurrence */}
|
||||
</div>
|
||||
<CustomEventModal
|
||||
open={modalOpen}
|
||||
@@ -556,19 +668,9 @@ const Appointments: React.FC = () => {
|
||||
onSave={async () => {
|
||||
setModalOpen(false);
|
||||
setEditMode(false);
|
||||
if (selectedGroupId) {
|
||||
const data = await fetchEvents(selectedGroupId, showInactive);
|
||||
const mapped: Event[] = data.map((e: RawEvent) => ({
|
||||
Id: e.Id,
|
||||
Subject: e.Subject,
|
||||
StartTime: new Date(e.StartTime.endsWith('Z') ? e.StartTime : e.StartTime + 'Z'),
|
||||
EndTime: new Date(e.EndTime.endsWith('Z') ? e.EndTime : e.EndTime + 'Z'),
|
||||
IsAllDay: e.IsAllDay,
|
||||
MediaId: e.MediaId,
|
||||
}));
|
||||
setEvents(mapped);
|
||||
setSchedulerKey(prev => prev + 1); // <-- Key erhöhen
|
||||
}
|
||||
// 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: '' }}
|
||||
@@ -585,10 +687,22 @@ const Appointments: React.FC = () => {
|
||||
currentView="Week"
|
||||
eventSettings={{
|
||||
dataSource: dataSource,
|
||||
fields: { isBlock: 'IsBlock' },
|
||||
fields: {
|
||||
id: 'Id',
|
||||
subject: 'Subject',
|
||||
startTime: 'StartTime',
|
||||
endTime: 'EndTime',
|
||||
isBlock: 'IsBlock',
|
||||
},
|
||||
template: eventTemplate, // <--- Hier das Template setzen!
|
||||
}}
|
||||
actionComplete={() => updateHolidaysInView()}
|
||||
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;
|
||||
@@ -608,7 +722,7 @@ const Appointments: React.FC = () => {
|
||||
if (args.type === 'Editor') {
|
||||
args.cancel = true;
|
||||
const event = args.data;
|
||||
console.log('Event zum Bearbeiten:', event);
|
||||
// Removed event logging
|
||||
let media = null;
|
||||
if (event.MediaId) {
|
||||
try {
|
||||
@@ -622,6 +736,29 @@ const Appointments: React.FC = () => {
|
||||
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,
|
||||
@@ -630,36 +767,39 @@ const Appointments: React.FC = () => {
|
||||
endTime: event.EndTime,
|
||||
description: event.Description ?? '',
|
||||
type: event.Type ?? 'presentation',
|
||||
repeat: event.Repeat ?? false,
|
||||
weekdays: event.Weekdays ?? [],
|
||||
repeatUntil: event.RepeatUntil ?? null,
|
||||
skipHolidays: event.SkipHolidays ?? false,
|
||||
media, // Metadaten werden nur bei Bedarf geladen!
|
||||
slideshowInterval: event.SlideshowInterval ?? 10,
|
||||
websiteUrl: event.WebsiteUrl ?? '',
|
||||
});
|
||||
console.log('Modal initial data:', {
|
||||
Id: event.Id,
|
||||
title: event.Subject,
|
||||
startDate: event.StartTime,
|
||||
startTime: event.StartTime,
|
||||
endTime: event.EndTime,
|
||||
description: event.Description ?? '',
|
||||
type: event.Type ?? 'presentation',
|
||||
repeat: event.Repeat ?? false,
|
||||
weekdays: event.Weekdays ?? [],
|
||||
repeatUntil: event.RepeatUntil ?? null,
|
||||
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
|
||||
@@ -677,38 +817,6 @@ const Appointments: React.FC = () => {
|
||||
const groupColor = getGroupColor(selectedGroupId, groups);
|
||||
const now = new Date();
|
||||
|
||||
let IconComponent: React.ElementType | null = null;
|
||||
switch (args.data.Type) {
|
||||
case 'presentation':
|
||||
IconComponent = Presentation;
|
||||
break;
|
||||
case 'website':
|
||||
IconComponent = Globe;
|
||||
break;
|
||||
case 'video':
|
||||
IconComponent = Video;
|
||||
break;
|
||||
case 'message':
|
||||
IconComponent = MessageSquare;
|
||||
break;
|
||||
case 'webuntis':
|
||||
IconComponent = School;
|
||||
break;
|
||||
default:
|
||||
IconComponent = null;
|
||||
}
|
||||
|
||||
// Nur .e-subject verwenden!
|
||||
const titleElement = args.element.querySelector('.e-subject');
|
||||
if (titleElement && IconComponent) {
|
||||
const svgString = renderToStaticMarkup(<IconComponent size={18} color="#78591c" />);
|
||||
// Immer nur den reinen Text nehmen, kein vorhandenes Icon!
|
||||
const subjectText = (titleElement as HTMLElement).textContent ?? '';
|
||||
(titleElement as HTMLElement).innerHTML =
|
||||
`<span style="vertical-align:middle;display:inline-block;margin-right:6px;">${svgString}</span>` +
|
||||
subjectText;
|
||||
}
|
||||
|
||||
// Vergangene Termine: Raumgruppenfarbe
|
||||
if (args.data.EndTime && args.data.EndTime < now) {
|
||||
args.element.style.backgroundColor = groupColor ? `${groupColor}` : '#f3f3f3';
|
||||
@@ -751,10 +859,11 @@ const Appointments: React.FC = () => {
|
||||
const mapped: Event[] = data.map((e: RawEvent) => ({
|
||||
Id: e.Id,
|
||||
Subject: e.Subject,
|
||||
StartTime: new Date(e.StartTime),
|
||||
EndTime: new Date(e.EndTime),
|
||||
StartTime: parseEventDate(e.StartTime),
|
||||
EndTime: parseEventDate(e.EndTime),
|
||||
IsAllDay: e.IsAllDay,
|
||||
MediaId: e.MediaId,
|
||||
SkipHolidays: e.SkipHolidays ?? false,
|
||||
}));
|
||||
setEvents(mapped);
|
||||
})
|
||||
|
||||
@@ -6,6 +6,7 @@ import { DropDownListComponent, MultiSelectComponent } from '@syncfusion/ej2-rea
|
||||
import { CheckBoxComponent } from '@syncfusion/ej2-react-buttons';
|
||||
import CustomSelectUploadEventModal from './CustomSelectUploadEventModal';
|
||||
import { updateEvent } from '../apiEvents';
|
||||
// Holiday exceptions are now created in the backend
|
||||
|
||||
type CustomEventData = {
|
||||
title: string;
|
||||
@@ -76,7 +77,10 @@ const CustomEventModal: React.FC<CustomEventModalProps> = ({
|
||||
const [repeat, setRepeat] = React.useState(initialData.repeat || false);
|
||||
const [weekdays, setWeekdays] = React.useState<number[]>(initialData.weekdays || []);
|
||||
const [repeatUntil, setRepeatUntil] = React.useState(initialData.repeatUntil || null);
|
||||
const [skipHolidays, setSkipHolidays] = React.useState(initialData.skipHolidays || false);
|
||||
// Default to true so recurrences skip holidays by default
|
||||
const [skipHolidays, setSkipHolidays] = React.useState(
|
||||
initialData.skipHolidays !== undefined ? initialData.skipHolidays : true
|
||||
);
|
||||
const [errors, setErrors] = React.useState<{ [key: string]: string }>({});
|
||||
// --- KORREKTUR: Media, SlideshowInterval, WebsiteUrl aus initialData übernehmen ---
|
||||
const [media, setMedia] = React.useState<{ id: string; path: string; name: string } | null>(
|
||||
@@ -104,7 +108,7 @@ const CustomEventModal: React.FC<CustomEventModalProps> = ({
|
||||
setRepeat(initialData.repeat || false);
|
||||
setWeekdays(initialData.weekdays || []);
|
||||
setRepeatUntil(initialData.repeatUntil || null);
|
||||
setSkipHolidays(initialData.skipHolidays || false);
|
||||
setSkipHolidays(initialData.skipHolidays !== undefined ? initialData.skipHolidays : true);
|
||||
// --- KORREKTUR: Media, SlideshowInterval, WebsiteUrl aus initialData übernehmen ---
|
||||
setMedia(initialData.media ?? null);
|
||||
setSlideshowInterval(initialData.slideshowInterval ?? 10);
|
||||
@@ -190,6 +194,27 @@ const CustomEventModal: React.FC<CustomEventModalProps> = ({
|
||||
|
||||
const group_id = typeof groupName === 'object' && groupName !== null ? groupName.id : groupName;
|
||||
|
||||
// Build recurrence rule if repeat is enabled
|
||||
let recurrenceRule = null;
|
||||
let recurrenceEnd = null;
|
||||
if (repeat && weekdays.length > 0) {
|
||||
// Convert weekdays to RRULE format (0=Monday -> MO)
|
||||
const rruleDays = weekdays.map(day => {
|
||||
const dayNames = ['MO', 'TU', 'WE', 'TH', 'FR', 'SA', 'SU'];
|
||||
return dayNames[day];
|
||||
}).join(',');
|
||||
|
||||
recurrenceRule = `FREQ=WEEKLY;BYDAY=${rruleDays}`;
|
||||
if (repeatUntil) {
|
||||
const untilDate = new Date(repeatUntil);
|
||||
untilDate.setHours(23, 59, 59);
|
||||
recurrenceEnd = untilDate.toISOString();
|
||||
// Note: RRULE UNTIL should be in UTC format for consistency
|
||||
const untilUTC = untilDate.toISOString().replace(/[-:]/g, '').split('.')[0] + 'Z';
|
||||
recurrenceRule += `;UNTIL=${untilUTC}`;
|
||||
}
|
||||
}
|
||||
|
||||
const payload: CustomEventData & { [key: string]: unknown } = {
|
||||
group_id,
|
||||
title,
|
||||
@@ -225,6 +250,8 @@ const CustomEventModal: React.FC<CustomEventModalProps> = ({
|
||||
event_type: type,
|
||||
is_active: 1,
|
||||
created_by: 1,
|
||||
recurrence_rule: recurrenceRule,
|
||||
recurrence_end: recurrenceEnd,
|
||||
};
|
||||
|
||||
if (type === 'presentation') {
|
||||
@@ -250,6 +277,7 @@ const CustomEventModal: React.FC<CustomEventModalProps> = ({
|
||||
});
|
||||
res = await res.json();
|
||||
}
|
||||
|
||||
if (res.success) {
|
||||
onSave(payload);
|
||||
onClose(); // <--- Box nach erfolgreichem Speichern schließen
|
||||
|
||||
Reference in New Issue
Block a user