feat: implement comprehensive recurring event single occurrence editing
- 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.
This commit is contained in:
6
.github/copilot-instructions.md
vendored
6
.github/copilot-instructions.md
vendored
@@ -71,7 +71,8 @@ Use this as your shared context when proposing changes. Keep edits minimal and m
|
|||||||
|
|
||||||
- Recurrence & holidays (latest):
|
- Recurrence & holidays (latest):
|
||||||
- Backend stores holiday skips in `EventException` and emits `RecurrenceException` (EXDATE) for master events in `GET /api/events`. EXDATE timestamps match each occurrence start time (UTC) so Syncfusion excludes instances on holidays reliably.
|
- Backend stores holiday skips in `EventException` and emits `RecurrenceException` (EXDATE) for master events in `GET /api/events`. EXDATE timestamps match each occurrence start time (UTC) so Syncfusion excludes instances on holidays reliably.
|
||||||
- Frontend maps `RecurrenceException` to `recurrenceException` and relies on Syncfusion to expand recurrences. Additionally, `eventRendered` cancels rendering for any instance with `SkipHolidays=true` falling within holiday ranges, independent of the “Termine an Ferientagen erlauben” toggle.
|
- Frontend manually expands recurring events due to Syncfusion EXDATE handling bugs. Daily/Weekly recurrence patterns are expanded client-side with proper EXDATE filtering and DST timezone tolerance (2-hour window).
|
||||||
|
- Single occurrence editing: Users can detach individual occurrences from recurring series via confirmation dialog. The detach operation creates `EventException` records, generates EXDATE entries, and creates standalone events without affecting the master series.
|
||||||
- UI: Events with `SkipHolidays` render a TentTree icon directly after the main event icon in the scheduler event template. Icon color: black.
|
- UI: Events with `SkipHolidays` render a TentTree icon directly after the main event icon in the scheduler event template. Icon color: black.
|
||||||
|
|
||||||
- Program info page (`dashboard/src/programminfo.tsx`):
|
- Program info page (`dashboard/src/programminfo.tsx`):
|
||||||
@@ -124,8 +125,9 @@ Note: Syncfusion usage in the dashboard is already documented above; if a UI for
|
|||||||
|
|
||||||
### Recurrence & holidays: conventions
|
### Recurrence & holidays: conventions
|
||||||
- Do not pre-expand recurrences on the backend. Always send master event with `RecurrenceRule` + `RecurrenceException`.
|
- Do not pre-expand recurrences on the backend. Always send master event with `RecurrenceRule` + `RecurrenceException`.
|
||||||
- Ensure EXDATE tokens include the occurrence start time (HH:mm:ss) in UTC to match Syncfusion’s expansion.
|
- Ensure EXDATE tokens include the occurrence start time (HH:mm:ss) in UTC to match manual expansion logic.
|
||||||
- When `skip_holidays` or recurrence changes, regenerate `EventException` rows so `RecurrenceException` stays in sync.
|
- When `skip_holidays` or recurrence changes, regenerate `EventException` rows so `RecurrenceException` stays in sync.
|
||||||
|
- Single occurrence detach: Use `POST /api/events/<id>/occurrences/<date>/detach` to create standalone events and add EXDATE entries without modifying master events.
|
||||||
|
|
||||||
## Quick examples
|
## Quick examples
|
||||||
- Add client description persists to DB and publishes group via MQTT: see `PUT /api/clients/<uuid>/description` in `routes/clients.py`.
|
- Add client description persists to DB and publishes group via MQTT: see `PUT /api/clients/<uuid>/description` in `routes/clients.py`.
|
||||||
|
|||||||
10
README.md
10
README.md
@@ -54,6 +54,7 @@ A comprehensive multi-service digital signage solution for educational instituti
|
|||||||
- **Messages**: Text announcements
|
- **Messages**: Text announcements
|
||||||
- **WebUntis**: Educational schedule integration
|
- **WebUntis**: Educational schedule integration
|
||||||
- **Recurrence & Holidays**: Recurring events can be configured to skip holidays. The backend generates EXDATEs (RecurrenceException) for holiday occurrences so the calendar never shows those instances. The "Termine an Ferientagen erlauben" toggle does not affect these events.
|
- **Recurrence & Holidays**: Recurring events can be configured to skip holidays. The backend generates EXDATEs (RecurrenceException) for holiday occurrences so the calendar never shows those instances. The "Termine an Ferientagen erlauben" toggle does not affect these events.
|
||||||
|
- **Single Occurrence Editing**: Users can edit individual occurrences of recurring events without affecting the master series. The system provides a confirmation dialog to choose between editing a single occurrence or the entire series.
|
||||||
|
|
||||||
### 🏫 **Academic Period Management**
|
### 🏫 **Academic Period Management**
|
||||||
- Support for school years, semesters, and trimesters
|
- Support for school years, semesters, and trimesters
|
||||||
@@ -144,6 +145,7 @@ For detailed deployment instructions, see:
|
|||||||
- **Database**: MariaDB with timezone-aware timestamps
|
- **Database**: MariaDB with timezone-aware timestamps
|
||||||
- **Features**: RESTful API, file uploads, MQTT integration
|
- **Features**: RESTful API, file uploads, MQTT integration
|
||||||
- Recurrence/holidays: returns only master events with `RecurrenceRule` and `RecurrenceException` (EXDATEs) so clients render recurrences and skip holiday instances reliably.
|
- Recurrence/holidays: returns only master events with `RecurrenceRule` and `RecurrenceException` (EXDATEs) so clients render recurrences and skip holiday instances reliably.
|
||||||
|
- Single occurrence detach: `POST /api/events/<id>/occurrences/<date>/detach` creates standalone events from recurring series without modifying the master event.
|
||||||
- **Port**: 8000
|
- **Port**: 8000
|
||||||
- **Health Check**: `/health`
|
- **Health Check**: `/health`
|
||||||
|
|
||||||
@@ -258,6 +260,7 @@ mosquitto_sub -h localhost -t "infoscreen/+/heartbeat" -v
|
|||||||
- `GET /api/groups` - List client groups with alive status
|
- `GET /api/groups` - List client groups with alive status
|
||||||
- `GET /api/events` - List events with filtering
|
- `GET /api/events` - List events with filtering
|
||||||
- `POST /api/events` - Create new event
|
- `POST /api/events` - Create new event
|
||||||
|
- `POST /api/events/{id}/occurrences/{date}/detach` - Detach single occurrence from recurring series
|
||||||
- `GET /api/academic_periods` - List academic periods
|
- `GET /api/academic_periods` - List academic periods
|
||||||
- `POST /api/academic_periods/active` - Set active period
|
- `POST /api/academic_periods/active` - Set active period
|
||||||
|
|
||||||
@@ -275,9 +278,10 @@ mosquitto_sub -h localhost -t "infoscreen/+/heartbeat" -v
|
|||||||
## 🎨 Frontend Features
|
## 🎨 Frontend Features
|
||||||
|
|
||||||
### Recurrence & holidays
|
### Recurrence & holidays
|
||||||
- The Scheduler (Syncfusion) expands master events via `RecurrenceRule`.
|
- The frontend manually expands recurring events due to Syncfusion EXDATE handling limitations.
|
||||||
- The API supplies `recurrenceException` (EXDATE) with exact occurrence start times (UTC) so holiday instances are excluded.
|
- The API supplies `RecurrenceException` (EXDATE) with exact occurrence start times (UTC) so holiday instances are excluded.
|
||||||
- Events with “skip holidays” display a TentTree icon next to the main event icon.
|
- Events with "skip holidays" display a TentTree icon next to the main event icon.
|
||||||
|
- Single occurrence editing: Users can detach individual occurrences via confirmation dialog, creating standalone events while preserving the master series.
|
||||||
|
|
||||||
### Syncfusion Components Used (Material 3)
|
### Syncfusion Components Used (Material 3)
|
||||||
- **Schedule**: Event calendar with drag-drop support
|
- **Schedule**: Event calendar with drag-drop support
|
||||||
|
|||||||
@@ -25,6 +25,13 @@ export async function fetchEvents(
|
|||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchEventById(eventId: string) {
|
||||||
|
const res = await fetch(`/api/events/${encodeURIComponent(eventId)}`);
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok || data.error) throw new Error(data.error || 'Fehler beim Laden des Termins');
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
export async function deleteEvent(eventId: string) {
|
export async function deleteEvent(eventId: string) {
|
||||||
const res = await fetch(`/api/events/${encodeURIComponent(eventId)}`, {
|
const res = await fetch(`/api/events/${encodeURIComponent(eventId)}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
@@ -34,6 +41,26 @@ export async function deleteEvent(eventId: string) {
|
|||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function deleteEventOccurrence(eventId: string, occurrenceDate: string) {
|
||||||
|
const res = await fetch(`/api/events/${encodeURIComponent(eventId)}/occurrences/${encodeURIComponent(occurrenceDate)}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok || data.error) throw new Error(data.error || 'Fehler beim Löschen des Einzeltermins');
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateEventOccurrence(eventId: string, occurrenceDate: string, payload: UpdateEventPayload) {
|
||||||
|
const res = await fetch(`/api/events/${encodeURIComponent(eventId)}/occurrences/${encodeURIComponent(occurrenceDate)}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok || data.error) throw new Error(data.error || 'Fehler beim Aktualisieren des Einzeltermins');
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
export interface UpdateEventPayload {
|
export interface UpdateEventPayload {
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
@@ -48,3 +75,23 @@ export async function updateEvent(eventId: string, payload: UpdateEventPayload)
|
|||||||
if (!res.ok || data.error) throw new Error(data.error || 'Fehler beim Aktualisieren des Termins');
|
if (!res.ok || data.error) throw new Error(data.error || 'Fehler beim Aktualisieren des Termins');
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const detachEventOccurrence = async (masterId: number, occurrenceDate: string, eventData: object) => {
|
||||||
|
const url = `/api/events/${masterId}/occurrences/${occurrenceDate}/detach`;
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(eventData),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(data.error || `HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|||||||
@@ -11,16 +11,17 @@ import {
|
|||||||
ViewDirective,
|
ViewDirective,
|
||||||
} from '@syncfusion/ej2-react-schedule';
|
} from '@syncfusion/ej2-react-schedule';
|
||||||
import { DropDownListComponent } from '@syncfusion/ej2-react-dropdowns';
|
import { DropDownListComponent } from '@syncfusion/ej2-react-dropdowns';
|
||||||
|
import { DialogComponent } from '@syncfusion/ej2-react-popups';
|
||||||
import { L10n, loadCldr, setCulture } from '@syncfusion/ej2-base';
|
import { L10n, loadCldr, setCulture } from '@syncfusion/ej2-base';
|
||||||
import type {
|
import type {
|
||||||
EventRenderedArgs,
|
EventRenderedArgs,
|
||||||
ActionEventArgs,
|
ActionEventArgs,
|
||||||
RenderCellEventArgs,
|
RenderCellEventArgs,
|
||||||
} from '@syncfusion/ej2-react-schedule';
|
} from '@syncfusion/ej2-react-schedule';
|
||||||
import { fetchEvents } from './apiEvents';
|
import { fetchEvents, fetchEventById } from './apiEvents';
|
||||||
import { fetchGroups } from './apiGroups';
|
import { fetchGroups } from './apiGroups';
|
||||||
import { getGroupColor } from './groupColors';
|
import { getGroupColor } from './groupColors';
|
||||||
import { deleteEvent } from './apiEvents';
|
import { deleteEvent, deleteEventOccurrence } from './apiEvents';
|
||||||
import CustomEventModal from './components/CustomEventModal';
|
import CustomEventModal from './components/CustomEventModal';
|
||||||
import { fetchMediaById } from './apiClients';
|
import { fetchMediaById } from './apiClients';
|
||||||
import { listHolidays, type Holiday } from './apiHolidays';
|
import { listHolidays, type Holiday } from './apiHolidays';
|
||||||
@@ -204,6 +205,34 @@ const Appointments: React.FC = () => {
|
|||||||
const [hasSchoolYearPlan, setHasSchoolYearPlan] = React.useState<boolean>(false);
|
const [hasSchoolYearPlan, setHasSchoolYearPlan] = React.useState<boolean>(false);
|
||||||
const [periods, setPeriods] = React.useState<{ id: number; label: string }[]>([]);
|
const [periods, setPeriods] = React.useState<{ id: number; label: string }[]>([]);
|
||||||
const [activePeriodId, setActivePeriodId] = React.useState<number | null>(null);
|
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
|
// Gruppen laden
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -290,24 +319,139 @@ const Appointments: React.FC = () => {
|
|||||||
end: endRange!,
|
end: endRange!,
|
||||||
expand: false,
|
expand: false,
|
||||||
});
|
});
|
||||||
const mapped: Event[] = data.map((e: RawEvent) => ({
|
// Manually expand recurring events and filter out EXDATE occurrences
|
||||||
Id: e.Id,
|
const expandedEvents: Event[] = [];
|
||||||
Subject: e.Subject,
|
|
||||||
StartTime: parseEventDate(e.StartTime),
|
for (const e of data) {
|
||||||
EndTime: parseEventDate(e.EndTime),
|
if (e.RecurrenceRule) {
|
||||||
IsAllDay: e.IsAllDay,
|
// Parse EXDATE list
|
||||||
MediaId: e.MediaId,
|
const exdates = new Set<string>();
|
||||||
Icon: e.Icon,
|
if (e.RecurrenceException) {
|
||||||
Type: e.Type,
|
e.RecurrenceException.split(',').forEach((dateStr: string) => {
|
||||||
OccurrenceOfId: e.OccurrenceOfId,
|
const trimmed = dateStr.trim();
|
||||||
Recurrence: !!e.RecurrenceRule,
|
exdates.add(trimmed);
|
||||||
RecurrenceRule: e.RecurrenceRule ?? null,
|
});
|
||||||
RecurrenceEnd: e.RecurrenceEnd ?? null,
|
}
|
||||||
SkipHolidays: e.SkipHolidays ?? false,
|
|
||||||
RecurrenceException: e.RecurrenceException ?? undefined,
|
// Manual expansion for DAILY and WEEKLY recurrence
|
||||||
recurrenceException: e.RecurrenceException ?? undefined, // for Syncfusion
|
if (e.RecurrenceRule.includes('FREQ=DAILY') || e.RecurrenceRule.includes('FREQ=WEEKLY')) {
|
||||||
}));
|
const startTime = parseEventDate(e.StartTime);
|
||||||
setEvents(mapped);
|
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) {
|
} catch (err) {
|
||||||
console.error('Fehler beim Laden der Termine:', err);
|
console.error('Fehler beim Laden der Termine:', err);
|
||||||
}
|
}
|
||||||
@@ -668,9 +812,16 @@ const Appointments: React.FC = () => {
|
|||||||
onSave={async () => {
|
onSave={async () => {
|
||||||
setModalOpen(false);
|
setModalOpen(false);
|
||||||
setEditMode(false);
|
setEditMode(false);
|
||||||
// Reload events using the same logic as fetchAndSetEvents
|
|
||||||
|
// Force immediate data refresh
|
||||||
await fetchAndSetEvents();
|
await fetchAndSetEvents();
|
||||||
setSchedulerKey(prev => prev + 1); // <-- Key erhöhen
|
|
||||||
|
// Force Syncfusion scheduler to refresh its internal cache
|
||||||
|
if (scheduleRef.current) {
|
||||||
|
scheduleRef.current.dataBind?.();
|
||||||
|
scheduleRef.current.refreshEvents?.();
|
||||||
|
setSchedulerKey(prev => prev + 1);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
initialData={modalInitialData}
|
initialData={modalInitialData}
|
||||||
groupName={groups.find(g => g.id === selectedGroupId) ?? { id: selectedGroupId, name: '' }}
|
groupName={groups.find(g => g.id === selectedGroupId) ?? { id: selectedGroupId, name: '' }}
|
||||||
@@ -722,11 +873,50 @@ const Appointments: React.FC = () => {
|
|||||||
if (args.type === 'Editor') {
|
if (args.type === 'Editor') {
|
||||||
args.cancel = true;
|
args.cancel = true;
|
||||||
const event = args.data;
|
const event = args.data;
|
||||||
// Removed event logging
|
|
||||||
let media = null;
|
|
||||||
if (event.MediaId) {
|
// 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 {
|
try {
|
||||||
const mediaData = await fetchMediaById(event.MediaId);
|
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 = {
|
media = {
|
||||||
id: mediaData.id,
|
id: mediaData.id,
|
||||||
path: mediaData.file_path,
|
path: mediaData.file_path,
|
||||||
@@ -736,46 +926,54 @@ const Appointments: React.FC = () => {
|
|||||||
console.error('Fehler beim Laden der Mediainfos:', err);
|
console.error('Fehler beim Laden der Mediainfos:', err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Parse recurrence info if present (supports FREQ=WEEKLY;BYDAY=...;[UNTIL=...])
|
|
||||||
let repeat = false;
|
let repeat = false;
|
||||||
let weekdays: number[] = [];
|
let weekdays: number[] = [];
|
||||||
let repeatUntil: Date | null = null;
|
let repeatUntil: Date | null = null;
|
||||||
const rr = (event.RecurrenceRule as string) || '';
|
|
||||||
if (rr && rr.includes('FREQ=WEEKLY')) {
|
// Only parse recurrence info if editing the entire series (not a single occurrence)
|
||||||
repeat = true;
|
if (!isSingleOccurrence) {
|
||||||
const m = rr.match(/BYDAY=([^;]+)/);
|
const rr = (eventDataToUse.RecurrenceRule as string) || '';
|
||||||
if (m && m[1]) {
|
if (rr && rr.includes('FREQ=WEEKLY')) {
|
||||||
const map: Record<string, number> = { MO: 0, TU: 1, WE: 2, TH: 3, FR: 4, SA: 5, SU: 6 };
|
repeat = true;
|
||||||
weekdays = m[1].split(',').map(s => map[s]).filter(v => v !== undefined);
|
const m = rr.match(/BYDAY=([^;]+)/);
|
||||||
}
|
if (m && m[1]) {
|
||||||
const mu = rr.match(/UNTIL=([0-9TZ]+)/);
|
const map: Record<string, number> = { MO: 0, TU: 1, WE: 2, TH: 3, FR: 4, SA: 5, SU: 6 };
|
||||||
if (mu && mu[1]) {
|
weekdays = m[1].split(',').map(s => map[s]).filter(v => v !== undefined);
|
||||||
// UNTIL is UTC in yyyymmddThhmmssZ form; we take date part
|
}
|
||||||
const untilIso = mu[1]
|
const mu = rr.match(/UNTIL=([0-9TZ]+)/);
|
||||||
.replace(/^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z$/, '$1-$2-$3T$4:$5:$6Z');
|
if (mu && mu[1]) {
|
||||||
repeatUntil = new Date(untilIso);
|
// UNTIL is UTC in yyyymmddThhmmssZ form; we take date part
|
||||||
} else if (event.RecurrenceEnd) {
|
const untilIso = mu[1]
|
||||||
repeatUntil = new Date(event.RecurrenceEnd);
|
.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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setModalInitialData({
|
const modalData = {
|
||||||
Id: event.Id,
|
Id: (event.OccurrenceOfId && !isSingleOccurrence) ? event.OccurrenceOfId : event.Id, // Use master ID for series edit, occurrence ID for single edit
|
||||||
title: event.Subject,
|
OccurrenceOfId: event.OccurrenceOfId, // Master event ID if this is an occurrence
|
||||||
startDate: event.StartTime,
|
occurrenceDate: isSingleOccurrence ? event.StartTime : null, // Store occurrence date for single occurrence editing
|
||||||
startTime: event.StartTime,
|
isSingleOccurrence,
|
||||||
endTime: event.EndTime,
|
title: eventDataToUse.Subject,
|
||||||
description: event.Description ?? '',
|
startDate: eventDataToUse.StartTime,
|
||||||
type: event.Type ?? 'presentation',
|
startTime: eventDataToUse.StartTime,
|
||||||
repeat,
|
endTime: eventDataToUse.EndTime,
|
||||||
weekdays,
|
description: eventDataToUse.Description ?? '',
|
||||||
repeatUntil,
|
type: eventDataToUse.Type ?? 'presentation',
|
||||||
skipHolidays: event.SkipHolidays ?? false,
|
repeat: isSingleOccurrence ? false : repeat, // Disable recurrence for single occurrence
|
||||||
media, // Metadaten werden nur bei Bedarf geladen!
|
weekdays: isSingleOccurrence ? [] : weekdays,
|
||||||
slideshowInterval: event.SlideshowInterval ?? 10,
|
repeatUntil: isSingleOccurrence ? null : repeatUntil,
|
||||||
websiteUrl: event.WebsiteUrl ?? '',
|
skipHolidays: isSingleOccurrence ? false : (eventDataToUse.SkipHolidays ?? false),
|
||||||
});
|
media,
|
||||||
// Removed modal initial data logging
|
slideshowInterval: eventDataToUse.SlideshowInterval ?? 10,
|
||||||
|
websiteUrl: eventDataToUse.WebsiteUrl ?? '',
|
||||||
|
};
|
||||||
|
|
||||||
|
setModalInitialData(modalData);
|
||||||
setEditMode(true);
|
setEditMode(true);
|
||||||
setModalOpen(true);
|
setModalOpen(true);
|
||||||
}
|
}
|
||||||
@@ -846,9 +1044,26 @@ const Appointments: React.FC = () => {
|
|||||||
const toDelete = Array.isArray(args.data) ? args.data : [args.data];
|
const toDelete = Array.isArray(args.data) ? args.data : [args.data];
|
||||||
for (const ev of toDelete) {
|
for (const ev of toDelete) {
|
||||||
try {
|
try {
|
||||||
await deleteEvent(ev.Id); // Deine API-Funktion
|
// 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) {
|
} catch (err) {
|
||||||
// Optional: Fehlerbehandlung
|
|
||||||
console.error('Fehler beim Löschen:', err);
|
console.error('Fehler beim Löschen:', err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -915,6 +1130,42 @@ const Appointments: React.FC = () => {
|
|||||||
</ViewsDirective>
|
</ViewsDirective>
|
||||||
<Inject services={[Day, Week, WorkWeek, Month, Agenda]} />
|
<Inject services={[Day, Week, WorkWeek, Month, Agenda]} />
|
||||||
</ScheduleComponent>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { DatePickerComponent, TimePickerComponent } from '@syncfusion/ej2-react-
|
|||||||
import { DropDownListComponent, MultiSelectComponent } from '@syncfusion/ej2-react-dropdowns';
|
import { DropDownListComponent, MultiSelectComponent } from '@syncfusion/ej2-react-dropdowns';
|
||||||
import { CheckBoxComponent } from '@syncfusion/ej2-react-buttons';
|
import { CheckBoxComponent } from '@syncfusion/ej2-react-buttons';
|
||||||
import CustomSelectUploadEventModal from './CustomSelectUploadEventModal';
|
import CustomSelectUploadEventModal from './CustomSelectUploadEventModal';
|
||||||
import { updateEvent } from '../apiEvents';
|
import { updateEvent, detachEventOccurrence } from '../apiEvents';
|
||||||
// Holiday exceptions are now created in the backend
|
// Holiday exceptions are now created in the backend
|
||||||
|
|
||||||
type CustomEventData = {
|
type CustomEventData = {
|
||||||
@@ -29,7 +29,12 @@ type CustomEventModalProps = {
|
|||||||
open: boolean;
|
open: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onSave: (eventData: CustomEventData) => void;
|
onSave: (eventData: CustomEventData) => void;
|
||||||
initialData?: Partial<CustomEventData> & { Id?: string }; // <--- Id ergänzen
|
initialData?: Partial<CustomEventData> & {
|
||||||
|
Id?: string;
|
||||||
|
OccurrenceOfId?: string;
|
||||||
|
isSingleOccurrence?: boolean;
|
||||||
|
occurrenceDate?: Date;
|
||||||
|
};
|
||||||
groupName: string | { id: string | null; name: string };
|
groupName: string | { id: string | null; name: string };
|
||||||
groupColor?: string;
|
groupColor?: string;
|
||||||
editMode?: boolean;
|
editMode?: boolean;
|
||||||
@@ -74,12 +79,14 @@ const CustomEventModal: React.FC<CustomEventModalProps> = ({
|
|||||||
const [endTime, setEndTime] = React.useState(initialData.endTime || new Date(0, 0, 0, 9, 30));
|
const [endTime, setEndTime] = React.useState(initialData.endTime || new Date(0, 0, 0, 9, 30));
|
||||||
const [type, setType] = React.useState(initialData.type ?? 'presentation');
|
const [type, setType] = React.useState(initialData.type ?? 'presentation');
|
||||||
const [description, setDescription] = React.useState(initialData.description || '');
|
const [description, setDescription] = React.useState(initialData.description || '');
|
||||||
const [repeat, setRepeat] = React.useState(initialData.repeat || false);
|
// Initialize recurrence state - force to false/empty for single occurrence editing
|
||||||
const [weekdays, setWeekdays] = React.useState<number[]>(initialData.weekdays || []);
|
const isSingleOccurrence = initialData.isSingleOccurrence || false;
|
||||||
const [repeatUntil, setRepeatUntil] = React.useState(initialData.repeatUntil || null);
|
const [repeat, setRepeat] = React.useState(isSingleOccurrence ? false : (initialData.repeat || false));
|
||||||
// Default to true so recurrences skip holidays by default
|
const [weekdays, setWeekdays] = React.useState<number[]>(isSingleOccurrence ? [] : (initialData.weekdays || []));
|
||||||
|
const [repeatUntil, setRepeatUntil] = React.useState(isSingleOccurrence ? null : (initialData.repeatUntil || null));
|
||||||
|
// Default to true so recurrences skip holidays by default, but false for single occurrences
|
||||||
const [skipHolidays, setSkipHolidays] = React.useState(
|
const [skipHolidays, setSkipHolidays] = React.useState(
|
||||||
initialData.skipHolidays !== undefined ? initialData.skipHolidays : true
|
isSingleOccurrence ? false : (initialData.skipHolidays !== undefined ? initialData.skipHolidays : true)
|
||||||
);
|
);
|
||||||
const [errors, setErrors] = React.useState<{ [key: string]: string }>({});
|
const [errors, setErrors] = React.useState<{ [key: string]: string }>({});
|
||||||
// --- KORREKTUR: Media, SlideshowInterval, WebsiteUrl aus initialData übernehmen ---
|
// --- KORREKTUR: Media, SlideshowInterval, WebsiteUrl aus initialData übernehmen ---
|
||||||
@@ -99,16 +106,28 @@ const CustomEventModal: React.FC<CustomEventModalProps> = ({
|
|||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (open) {
|
if (open) {
|
||||||
|
const isSingleOccurrence = initialData.isSingleOccurrence || false;
|
||||||
|
|
||||||
setTitle(initialData.title || '');
|
setTitle(initialData.title || '');
|
||||||
setStartDate(initialData.startDate || null);
|
setStartDate(initialData.startDate || null);
|
||||||
setStartTime(initialData.startTime || new Date(0, 0, 0, 9, 0));
|
setStartTime(initialData.startTime || new Date(0, 0, 0, 9, 0));
|
||||||
setEndTime(initialData.endTime || new Date(0, 0, 0, 9, 30));
|
setEndTime(initialData.endTime || new Date(0, 0, 0, 9, 30));
|
||||||
setType(initialData.type ?? 'presentation');
|
setType(initialData.type ?? 'presentation');
|
||||||
setDescription(initialData.description || '');
|
setDescription(initialData.description || '');
|
||||||
setRepeat(initialData.repeat || false);
|
|
||||||
setWeekdays(initialData.weekdays || []);
|
// For single occurrence editing, force recurrence settings to be disabled
|
||||||
setRepeatUntil(initialData.repeatUntil || null);
|
if (isSingleOccurrence) {
|
||||||
setSkipHolidays(initialData.skipHolidays !== undefined ? initialData.skipHolidays : true);
|
setRepeat(false);
|
||||||
|
setWeekdays([]);
|
||||||
|
setRepeatUntil(null);
|
||||||
|
setSkipHolidays(false);
|
||||||
|
} else {
|
||||||
|
setRepeat(initialData.repeat || false);
|
||||||
|
setWeekdays(initialData.weekdays || []);
|
||||||
|
setRepeatUntil(initialData.repeatUntil || null);
|
||||||
|
setSkipHolidays(initialData.skipHolidays !== undefined ? initialData.skipHolidays : true);
|
||||||
|
}
|
||||||
|
|
||||||
// --- KORREKTUR: Media, SlideshowInterval, WebsiteUrl aus initialData übernehmen ---
|
// --- KORREKTUR: Media, SlideshowInterval, WebsiteUrl aus initialData übernehmen ---
|
||||||
setMedia(initialData.media ?? null);
|
setMedia(initialData.media ?? null);
|
||||||
setSlideshowInterval(initialData.slideshowInterval ?? 10);
|
setSlideshowInterval(initialData.slideshowInterval ?? 10);
|
||||||
@@ -131,7 +150,7 @@ const CustomEventModal: React.FC<CustomEventModalProps> = ({
|
|||||||
if (!endTime) newErrors.endTime = 'Endzeit ist erforderlich';
|
if (!endTime) newErrors.endTime = 'Endzeit ist erforderlich';
|
||||||
if (!type) newErrors.type = 'Termintyp ist erforderlich';
|
if (!type) newErrors.type = 'Termintyp ist erforderlich';
|
||||||
|
|
||||||
// Vergangenheitsprüfung
|
// Vergangenheitsprüfung - für Einzeltermine strikt, für Serien flexibler
|
||||||
const startDateTime =
|
const startDateTime =
|
||||||
startDate && startTime
|
startDate && startTime
|
||||||
? new Date(
|
? new Date(
|
||||||
@@ -145,7 +164,14 @@ const CustomEventModal: React.FC<CustomEventModalProps> = ({
|
|||||||
const isPast = startDateTime && startDateTime < new Date();
|
const isPast = startDateTime && startDateTime < new Date();
|
||||||
|
|
||||||
if (isPast) {
|
if (isPast) {
|
||||||
newErrors.startDate = 'Termine in der Vergangenheit sind nicht erlaubt!';
|
if (isSingleOccurrence || !repeat) {
|
||||||
|
// Einzeltermine oder nicht-wiederkehrende Events dürfen nicht in der Vergangenheit liegen
|
||||||
|
newErrors.startDate = 'Termine in der Vergangenheit sind nicht erlaubt!';
|
||||||
|
} else if (repeat && repeatUntil && repeatUntil < new Date()) {
|
||||||
|
// Wiederkehrende Events sind erlaubt, wenn das End-Datum in der Zukunft liegt
|
||||||
|
newErrors.repeatUntil = 'Terminserien mit End-Datum in der Vergangenheit sind nicht erlaubt!';
|
||||||
|
}
|
||||||
|
// Andernfalls: Wiederkehrende Serie ohne End-Datum oder mit End-Datum in der Zukunft ist erlaubt
|
||||||
}
|
}
|
||||||
|
|
||||||
if (type === 'presentation') {
|
if (type === 'presentation') {
|
||||||
@@ -243,15 +269,16 @@ const CustomEventModal: React.FC<CustomEventModalProps> = ({
|
|||||||
startDate,
|
startDate,
|
||||||
startTime,
|
startTime,
|
||||||
endTime,
|
endTime,
|
||||||
repeat,
|
// Initialize required fields
|
||||||
weekdays,
|
repeat: isSingleOccurrence ? false : repeat,
|
||||||
repeatUntil,
|
weekdays: isSingleOccurrence ? [] : weekdays,
|
||||||
skipHolidays,
|
repeatUntil: isSingleOccurrence ? null : repeatUntil,
|
||||||
|
skipHolidays: isSingleOccurrence ? false : skipHolidays,
|
||||||
event_type: type,
|
event_type: type,
|
||||||
is_active: 1,
|
is_active: 1,
|
||||||
created_by: 1,
|
created_by: 1,
|
||||||
recurrence_rule: recurrenceRule,
|
recurrence_rule: isSingleOccurrence ? null : recurrenceRule,
|
||||||
recurrence_end: recurrenceEnd,
|
recurrence_end: isSingleOccurrence ? null : recurrenceEnd,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (type === 'presentation') {
|
if (type === 'presentation') {
|
||||||
@@ -266,8 +293,38 @@ const CustomEventModal: React.FC<CustomEventModalProps> = ({
|
|||||||
try {
|
try {
|
||||||
let res;
|
let res;
|
||||||
if (editMode && initialData && typeof initialData.Id === 'string') {
|
if (editMode && initialData && typeof initialData.Id === 'string') {
|
||||||
// UPDATE statt CREATE
|
// Check if this is a recurring event occurrence that should be detached
|
||||||
res = await updateEvent(initialData.Id, payload);
|
const shouldDetach = isSingleOccurrence &&
|
||||||
|
initialData.OccurrenceOfId &&
|
||||||
|
!isNaN(Number(initialData.OccurrenceOfId));
|
||||||
|
|
||||||
|
if (shouldDetach) {
|
||||||
|
// DETACH single occurrence from recurring series
|
||||||
|
|
||||||
|
// Use occurrenceDate from initialData, or fall back to startDate
|
||||||
|
const sourceDate = initialData.occurrenceDate || startDate;
|
||||||
|
if (!sourceDate) {
|
||||||
|
setErrors({ api: 'Fehler: Kein Datum für Einzeltermin verfügbar' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const occurrenceDate = sourceDate instanceof Date
|
||||||
|
? sourceDate.toISOString().split('T')[0]
|
||||||
|
: new Date(sourceDate).toISOString().split('T')[0];
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Use the master event ID (OccurrenceOfId) for detaching
|
||||||
|
const masterId = Number(initialData.OccurrenceOfId);
|
||||||
|
res = await detachEventOccurrence(masterId, occurrenceDate, payload);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Detach operation failed:', error);
|
||||||
|
setErrors({ api: `API Fehler: ${error instanceof Error ? error.message : String(error)}` });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// UPDATE entire event/series OR standalone event
|
||||||
|
res = await updateEvent(initialData.Id, payload);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// CREATE
|
// CREATE
|
||||||
res = await fetch('/api/events', {
|
res = await fetch('/api/events', {
|
||||||
@@ -287,7 +344,7 @@ const CustomEventModal: React.FC<CustomEventModalProps> = ({
|
|||||||
} catch {
|
} catch {
|
||||||
setErrors({ api: 'Netzwerkfehler beim Speichern' });
|
setErrors({ api: 'Netzwerkfehler beim Speichern' });
|
||||||
}
|
}
|
||||||
console.log('handleSave called');
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Vergangenheitsprüfung (außerhalb von handleSave, damit im Render verfügbar)
|
// Vergangenheitsprüfung (außerhalb von handleSave, damit im Render verfügbar)
|
||||||
@@ -302,6 +359,10 @@ const CustomEventModal: React.FC<CustomEventModalProps> = ({
|
|||||||
)
|
)
|
||||||
: null;
|
: null;
|
||||||
const isPast = !!(startDateTime && startDateTime < new Date());
|
const isPast = !!(startDateTime && startDateTime < new Date());
|
||||||
|
|
||||||
|
// Button sollte nur für Einzeltermine in der Vergangenheit deaktiviert werden
|
||||||
|
// Wiederkehrende Serien können bearbeitet werden, auch wenn der Starttermin vergangen ist
|
||||||
|
const shouldDisableButton = isPast && (isSingleOccurrence || !repeat);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DialogComponent
|
<DialogComponent
|
||||||
@@ -321,7 +382,9 @@ const CustomEventModal: React.FC<CustomEventModalProps> = ({
|
|||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{editMode ? 'Termin bearbeiten' : 'Neuen Termin anlegen'}
|
{editMode
|
||||||
|
? (isSingleOccurrence ? 'Einzeltermin bearbeiten' : 'Terminserie bearbeiten')
|
||||||
|
: 'Neuen Termin anlegen'}
|
||||||
{groupName && (
|
{groupName && (
|
||||||
<span style={{ fontWeight: 400, fontSize: 16, marginLeft: 16, color: '#fff' }}>
|
<span style={{ fontWeight: 400, fontSize: 16, marginLeft: 16, color: '#fff' }}>
|
||||||
für Raumgruppe: <b>{typeof groupName === 'object' ? groupName.name : groupName}</b>
|
für Raumgruppe: <b>{typeof groupName === 'object' ? groupName.name : groupName}</b>
|
||||||
@@ -340,7 +403,7 @@ const CustomEventModal: React.FC<CustomEventModalProps> = ({
|
|||||||
<button
|
<button
|
||||||
className="e-btn e-success"
|
className="e-btn e-success"
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
disabled={isPast} // <--- Button deaktivieren, wenn Termin in Vergangenheit
|
disabled={shouldDisableButton} // <--- Button nur für Einzeltermine in Vergangenheit deaktivieren
|
||||||
>
|
>
|
||||||
Termin(e) speichern
|
Termin(e) speichern
|
||||||
</button>
|
</button>
|
||||||
@@ -379,7 +442,7 @@ const CustomEventModal: React.FC<CustomEventModalProps> = ({
|
|||||||
{errors.startDate && (
|
{errors.startDate && (
|
||||||
<div style={{ color: 'red', fontSize: 12 }}>{errors.startDate}</div>
|
<div style={{ color: 'red', fontSize: 12 }}>{errors.startDate}</div>
|
||||||
)}
|
)}
|
||||||
{isPast && (
|
{isPast && (isSingleOccurrence || !repeat) && (
|
||||||
<span
|
<span
|
||||||
style={{
|
style={{
|
||||||
color: 'orange',
|
color: 'orange',
|
||||||
@@ -395,6 +458,22 @@ const CustomEventModal: React.FC<CustomEventModalProps> = ({
|
|||||||
⚠️ Termin liegt in der Vergangenheit!
|
⚠️ Termin liegt in der Vergangenheit!
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{isPast && repeat && !isSingleOccurrence && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
color: 'blue',
|
||||||
|
fontWeight: 600,
|
||||||
|
marginLeft: 8,
|
||||||
|
display: 'inline-block',
|
||||||
|
background: '#e3f2fd',
|
||||||
|
borderRadius: 4,
|
||||||
|
padding: '2px 8px',
|
||||||
|
border: '1px solid #90caf9',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
ℹ️ Bearbeitung einer Terminserie (Startdatum kann in Vergangenheit liegen)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', gap: 8, marginBottom: 12 }}>
|
<div style={{ display: 'flex', gap: 8, marginBottom: 12 }}>
|
||||||
<div style={{ flex: 1 }}>
|
<div style={{ flex: 1 }}>
|
||||||
@@ -425,11 +504,19 @@ const CustomEventModal: React.FC<CustomEventModalProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
<div style={{ flex: 1, minWidth: 260 }}>
|
<div style={{ flex: 1, minWidth: 260 }}>
|
||||||
{/* ...Wiederholung, MultiSelect, Wiederholung bis, Ferien... */}
|
{/* ...Wiederholung, MultiSelect, Wiederholung bis, Ferien... */}
|
||||||
|
{isSingleOccurrence && (
|
||||||
|
<div style={{ marginBottom: 12, padding: '8px 12px', backgroundColor: '#e8f4fd', borderRadius: 4, border: '1px solid #bee5eb' }}>
|
||||||
|
<span style={{ fontSize: '14px', color: '#0c5460', fontWeight: 500 }}>
|
||||||
|
ℹ️ Bearbeitung eines Einzeltermins - Wiederholungsoptionen nicht verfügbar
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div style={{ marginBottom: 12 }}>
|
<div style={{ marginBottom: 12 }}>
|
||||||
<CheckBoxComponent
|
<CheckBoxComponent
|
||||||
label="Wiederholender Termin"
|
label="Wiederholender Termin"
|
||||||
checked={repeat}
|
checked={repeat}
|
||||||
change={e => setRepeat(e.checked)}
|
change={e => setRepeat(e.checked)}
|
||||||
|
disabled={isSingleOccurrence}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ marginBottom: 12 }}>
|
<div style={{ marginBottom: 12 }}>
|
||||||
@@ -440,7 +527,7 @@ const CustomEventModal: React.FC<CustomEventModalProps> = ({
|
|||||||
placeholder="Wochentage"
|
placeholder="Wochentage"
|
||||||
value={weekdays}
|
value={weekdays}
|
||||||
change={e => setWeekdays(e.value as number[])}
|
change={e => setWeekdays(e.value as number[])}
|
||||||
disabled={!repeat}
|
disabled={!repeat || isSingleOccurrence}
|
||||||
showDropDownIcon={true}
|
showDropDownIcon={true}
|
||||||
closePopupOnSelect={false}
|
closePopupOnSelect={false}
|
||||||
/>
|
/>
|
||||||
@@ -452,7 +539,7 @@ const CustomEventModal: React.FC<CustomEventModalProps> = ({
|
|||||||
floatLabelType="Auto"
|
floatLabelType="Auto"
|
||||||
value={repeatUntil ?? undefined}
|
value={repeatUntil ?? undefined}
|
||||||
change={e => setRepeatUntil(e.value)}
|
change={e => setRepeatUntil(e.value)}
|
||||||
disabled={!repeat}
|
disabled={!repeat || isSingleOccurrence}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ marginBottom: 12 }}>
|
<div style={{ marginBottom: 12 }}>
|
||||||
@@ -460,7 +547,7 @@ const CustomEventModal: React.FC<CustomEventModalProps> = ({
|
|||||||
label="Ferientage berücksichtigen"
|
label="Ferientage berücksichtigen"
|
||||||
checked={skipHolidays}
|
checked={skipHolidays}
|
||||||
change={e => setSkipHolidays(e.checked)}
|
change={e => setSkipHolidays(e.checked)}
|
||||||
disabled={!repeat}
|
disabled={!repeat || isSingleOccurrence}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -54,32 +54,37 @@ def get_events():
|
|||||||
if not (show_inactive or e.is_active):
|
if not (show_inactive or e.is_active):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Gather exception dates for this event (for recurrenceException/EXDATE)
|
# Gather exceptions for this event
|
||||||
exception_dates = session.query(EventException).filter(
|
all_exceptions = session.query(EventException).filter(
|
||||||
EventException.event_id == e.id,
|
EventException.event_id == e.id
|
||||||
EventException.is_skipped == True
|
|
||||||
).all()
|
).all()
|
||||||
# Syncfusion expects recurrenceException as comma-separated ISO strings (yyyy-MM-ddTHH:mm:ssZ)
|
|
||||||
# IMPORTANT: The time must match the event's occurrence start time. Use the event's start time-of-day.
|
# Build RecurrenceException (EXDATE) tokens for skipped occurrences only
|
||||||
|
# (detached occurrences are now real Event rows, not synthetic)
|
||||||
recurrence_exception = None
|
recurrence_exception = None
|
||||||
if exception_dates:
|
if all_exceptions:
|
||||||
# Use event start time in UTC as baseline
|
|
||||||
base_start = e.start.astimezone(UTC) if e.start.tzinfo else e.start.replace(tzinfo=UTC)
|
base_start = e.start.astimezone(UTC) if e.start.tzinfo else e.start.replace(tzinfo=UTC)
|
||||||
tokens = []
|
tokens = []
|
||||||
for d in exception_dates:
|
for ex in all_exceptions:
|
||||||
exd = d.exception_date # date
|
if ex.is_skipped:
|
||||||
occ_dt = datetime(
|
exd = ex.exception_date
|
||||||
exd.year, exd.month, exd.day,
|
# Create the EXDATE timestamp using the master event's original start time
|
||||||
base_start.hour, base_start.minute, base_start.second,
|
# This should match the time when the original occurrence would have happened
|
||||||
tzinfo=UTC
|
occ_dt = datetime(
|
||||||
)
|
exd.year, exd.month, exd.day,
|
||||||
tokens.append(occ_dt.strftime('%Y-%m-%dT%H:%M:%SZ'))
|
base_start.hour, base_start.minute, base_start.second,
|
||||||
recurrence_exception = ','.join(tokens)
|
tzinfo=UTC
|
||||||
|
)
|
||||||
|
token = occ_dt.strftime('%Y-%m-%dT%H:%M:%SZ')
|
||||||
|
tokens.append(token)
|
||||||
|
if tokens:
|
||||||
|
recurrence_exception = ','.join(tokens)
|
||||||
|
|
||||||
base_payload = {
|
base_payload = {
|
||||||
"Id": str(e.id),
|
"Id": str(e.id),
|
||||||
"GroupId": e.group_id,
|
"GroupId": e.group_id,
|
||||||
"Subject": e.title,
|
"Subject": e.title,
|
||||||
|
"Description": getattr(e, 'description', None),
|
||||||
"StartTime": e.start.isoformat() if e.start else None,
|
"StartTime": e.start.isoformat() if e.start else None,
|
||||||
"EndTime": e.end.isoformat() if e.end else None,
|
"EndTime": e.end.isoformat() if e.end else None,
|
||||||
"IsAllDay": False,
|
"IsAllDay": False,
|
||||||
@@ -93,23 +98,228 @@ def get_events():
|
|||||||
"SkipHolidays": bool(getattr(e, 'skip_holidays', False)),
|
"SkipHolidays": bool(getattr(e, 'skip_holidays', False)),
|
||||||
}
|
}
|
||||||
result.append(base_payload)
|
result.append(base_payload)
|
||||||
|
|
||||||
|
# No need to emit synthetic override events anymore since detached occurrences
|
||||||
|
# are now real Event rows that will be returned in the main query
|
||||||
session.close()
|
session.close()
|
||||||
return jsonify(result)
|
return jsonify(result)
|
||||||
|
|
||||||
|
|
||||||
@events_bp.route("/<event_id>", methods=["DELETE"])
|
@events_bp.route("/<event_id>", methods=["GET"]) # get single event
|
||||||
|
def get_event(event_id):
|
||||||
|
session = Session()
|
||||||
|
try:
|
||||||
|
event = session.query(Event).filter_by(id=event_id).first()
|
||||||
|
if not event:
|
||||||
|
return jsonify({"error": "Termin nicht gefunden"}), 404
|
||||||
|
|
||||||
|
# Convert event to dictionary with all necessary fields
|
||||||
|
event_dict = {
|
||||||
|
"Id": str(event.id),
|
||||||
|
"Subject": event.title,
|
||||||
|
"StartTime": event.start.isoformat() if event.start else None,
|
||||||
|
"EndTime": event.end.isoformat() if event.end else None,
|
||||||
|
"Description": event.description,
|
||||||
|
"Type": event.event_type.value if event.event_type else "presentation",
|
||||||
|
"IsAllDay": False, # Assuming events are not all-day by default
|
||||||
|
"MediaId": str(event.event_media_id) if event.event_media_id else None,
|
||||||
|
"SlideshowInterval": event.slideshow_interval,
|
||||||
|
"WebsiteUrl": event.event_media.url if event.event_media and hasattr(event.event_media, 'url') else None,
|
||||||
|
"RecurrenceRule": event.recurrence_rule,
|
||||||
|
"RecurrenceEnd": event.recurrence_end.isoformat() if event.recurrence_end else None,
|
||||||
|
"SkipHolidays": event.skip_holidays,
|
||||||
|
"Icon": get_icon_for_type(event.event_type.value if event.event_type else "presentation"),
|
||||||
|
}
|
||||||
|
|
||||||
|
return jsonify(event_dict)
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"error": f"Fehler beim Laden des Termins: {str(e)}"}), 500
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
|
||||||
|
@events_bp.route("/<event_id>", methods=["DELETE"]) # delete series or single event
|
||||||
def delete_event(event_id):
|
def delete_event(event_id):
|
||||||
session = Session()
|
session = Session()
|
||||||
event = session.query(Event).filter_by(id=event_id).first()
|
event = session.query(Event).filter_by(id=event_id).first()
|
||||||
if not event:
|
if not event:
|
||||||
session.close()
|
session.close()
|
||||||
return jsonify({"error": "Termin nicht gefunden"}), 404
|
return jsonify({"error": "Termin nicht gefunden"}), 404
|
||||||
|
# Safety: do not allow accidental deletion of a recurring master without explicit force flag
|
||||||
|
force = request.args.get('force') == '1'
|
||||||
|
if event.recurrence_rule and not force:
|
||||||
|
session.close()
|
||||||
|
return jsonify({
|
||||||
|
"error": "Löschen der Terminserie erfordert Bestätigung",
|
||||||
|
"hint": "Fügen Sie ?force=1 zur Anfrage hinzu, um die Serie zu löschen.",
|
||||||
|
"event_id": event_id
|
||||||
|
}), 400
|
||||||
session.delete(event)
|
session.delete(event)
|
||||||
session.commit()
|
session.commit()
|
||||||
session.close()
|
session.close()
|
||||||
return jsonify({"success": True})
|
return jsonify({"success": True})
|
||||||
|
|
||||||
|
|
||||||
|
@events_bp.route("/<event_id>/occurrences/<occurrence_date>", methods=["DELETE"]) # skip single occurrence
|
||||||
|
|
||||||
|
def delete_event_occurrence(event_id, occurrence_date):
|
||||||
|
"""Delete a single occurrence of a recurring event by creating an EventException."""
|
||||||
|
session = Session()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Validate event exists
|
||||||
|
event = session.query(Event).filter_by(id=event_id).first()
|
||||||
|
if not event:
|
||||||
|
session.close()
|
||||||
|
return jsonify({"error": "Termin nicht gefunden"}), 404
|
||||||
|
|
||||||
|
# Validate that this is a recurring event
|
||||||
|
if not event.recurrence_rule:
|
||||||
|
session.close()
|
||||||
|
return jsonify({"error": "Termin ist keine Wiederholungsserie"}), 400
|
||||||
|
|
||||||
|
# Parse the occurrence date
|
||||||
|
try:
|
||||||
|
occ_date = datetime.fromisoformat(occurrence_date).date()
|
||||||
|
except ValueError:
|
||||||
|
session.close()
|
||||||
|
return jsonify({"error": "Ungültiges Datumsformat"}), 400
|
||||||
|
|
||||||
|
# Check if an exception for this date already exists
|
||||||
|
existing_exception = session.query(EventException).filter_by(
|
||||||
|
event_id=event.id,
|
||||||
|
exception_date=occ_date
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if existing_exception:
|
||||||
|
# Update existing exception to be skipped
|
||||||
|
existing_exception.is_skipped = True
|
||||||
|
existing_exception.updated_at = datetime.now()
|
||||||
|
else:
|
||||||
|
# Create new exception to skip this occurrence
|
||||||
|
exception = EventException(
|
||||||
|
event_id=event.id,
|
||||||
|
exception_date=occ_date,
|
||||||
|
is_skipped=True
|
||||||
|
)
|
||||||
|
session.add(exception)
|
||||||
|
|
||||||
|
session.commit()
|
||||||
|
session.close()
|
||||||
|
return jsonify({"success": True, "message": "Einzeltermin wurde gelöscht"})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
session.rollback()
|
||||||
|
session.close()
|
||||||
|
return jsonify({"error": f"Fehler beim Löschen des Einzeltermins: {str(e)}"}), 500
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@events_bp.route("/<event_id>/occurrences/<occurrence_date>/detach", methods=["POST"]) # detach single occurrence into standalone event
|
||||||
|
def detach_event_occurrence(event_id, occurrence_date):
|
||||||
|
"""BULLETPROOF: Detach single occurrence without touching master event."""
|
||||||
|
session = Session()
|
||||||
|
try:
|
||||||
|
data = request.json or {}
|
||||||
|
|
||||||
|
# Step 0: Get master event and NEVER modify it
|
||||||
|
master = session.query(Event).filter_by(id=event_id).first()
|
||||||
|
if not master:
|
||||||
|
session.close()
|
||||||
|
return jsonify({"error": "Termin nicht gefunden"}), 404
|
||||||
|
if not master.recurrence_rule:
|
||||||
|
session.close()
|
||||||
|
return jsonify({"error": "Termin ist keine Wiederholungsserie"}), 400
|
||||||
|
|
||||||
|
# Store master data (read-only copy)
|
||||||
|
master_data = {
|
||||||
|
'id': master.id,
|
||||||
|
'group_id': master.group_id,
|
||||||
|
'title': master.title,
|
||||||
|
'description': master.description,
|
||||||
|
'start': master.start,
|
||||||
|
'end': master.end,
|
||||||
|
'event_type': master.event_type,
|
||||||
|
'event_media_id': master.event_media_id,
|
||||||
|
'slideshow_interval': getattr(master, 'slideshow_interval', None),
|
||||||
|
'created_by': master.created_by,
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
occ_date = datetime.fromisoformat(occurrence_date).date()
|
||||||
|
except ValueError:
|
||||||
|
session.close()
|
||||||
|
return jsonify({"error": "Ungültiges Datumsformat"}), 400
|
||||||
|
|
||||||
|
# Step 1: Create exception entry (using master ID, not master object)
|
||||||
|
existing_exception = session.query(EventException).filter_by(
|
||||||
|
event_id=master_data['id'],
|
||||||
|
exception_date=occ_date
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not existing_exception:
|
||||||
|
exception = EventException(
|
||||||
|
event_id=master_data['id'],
|
||||||
|
exception_date=occ_date,
|
||||||
|
is_skipped=True
|
||||||
|
)
|
||||||
|
session.add(exception)
|
||||||
|
else:
|
||||||
|
existing_exception.is_skipped = True
|
||||||
|
|
||||||
|
# Step 2: Create new standalone event (using copied data, not master object)
|
||||||
|
new_title = data.get("title", master_data['title'])
|
||||||
|
new_description = data.get("description", master_data['description'])
|
||||||
|
|
||||||
|
if data.get("start"):
|
||||||
|
new_start = datetime.fromisoformat(data["start"])
|
||||||
|
else:
|
||||||
|
base_start_utc = master_data['start'].astimezone(UTC) if master_data['start'].tzinfo else master_data['start'].replace(tzinfo=UTC)
|
||||||
|
new_start = datetime(occ_date.year, occ_date.month, occ_date.day,
|
||||||
|
base_start_utc.hour, base_start_utc.minute, base_start_utc.second, tzinfo=UTC)
|
||||||
|
|
||||||
|
if data.get("end"):
|
||||||
|
new_end = datetime.fromisoformat(data["end"])
|
||||||
|
else:
|
||||||
|
duration = (master_data['end'] - master_data['start']) if (master_data['end'] and master_data['start']) else timedelta(minutes=30)
|
||||||
|
new_end = new_start + duration
|
||||||
|
|
||||||
|
new_event = Event(
|
||||||
|
group_id=master_data['group_id'],
|
||||||
|
title=new_title,
|
||||||
|
description=new_description,
|
||||||
|
start=new_start,
|
||||||
|
end=new_end,
|
||||||
|
event_type=master_data['event_type'],
|
||||||
|
event_media_id=master_data['event_media_id'],
|
||||||
|
slideshow_interval=master_data['slideshow_interval'],
|
||||||
|
recurrence_rule=None,
|
||||||
|
recurrence_end=None,
|
||||||
|
skip_holidays=False,
|
||||||
|
created_by=master_data['created_by'],
|
||||||
|
updated_by=master_data['created_by'],
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
session.add(new_event)
|
||||||
|
|
||||||
|
# Commit both changes at once
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
new_event_id = new_event.id
|
||||||
|
session.close()
|
||||||
|
return jsonify({
|
||||||
|
"success": True,
|
||||||
|
"new_event_id": new_event_id,
|
||||||
|
"master_event_id": master_data['id'],
|
||||||
|
"message": f"Einzeltermin erstellt, Master-Event {master_data['id']} unberührt"
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
session.rollback()
|
||||||
|
session.close()
|
||||||
|
return jsonify({"error": f"Fehler beim Erstellen des Einzeltermins: {str(e)}"}), 500
|
||||||
|
|
||||||
|
|
||||||
@events_bp.route("", methods=["POST"])
|
@events_bp.route("", methods=["POST"])
|
||||||
def create_event():
|
def create_event():
|
||||||
data = request.json
|
data = request.json
|
||||||
@@ -187,8 +397,15 @@ def create_event():
|
|||||||
from models.models import SchoolHoliday, EventException
|
from models.models import SchoolHoliday, EventException
|
||||||
from dateutil.rrule import rrulestr
|
from dateutil.rrule import rrulestr
|
||||||
from dateutil.tz import UTC
|
from dateutil.tz import UTC
|
||||||
# Clear existing exceptions for this event
|
# Clear only auto-generated holiday skip exceptions, keep user overrides
|
||||||
session.query(EventException).filter_by(event_id=ev.id).delete()
|
session.query(EventException).filter(
|
||||||
|
EventException.event_id == ev.id,
|
||||||
|
EventException.is_skipped == True,
|
||||||
|
EventException.override_title.is_(None),
|
||||||
|
EventException.override_description.is_(None),
|
||||||
|
EventException.override_start.is_(None),
|
||||||
|
EventException.override_end.is_(None),
|
||||||
|
).delete(synchronize_session=False)
|
||||||
session.commit()
|
session.commit()
|
||||||
if not (ev.skip_holidays and ev.recurrence_rule):
|
if not (ev.skip_holidays and ev.recurrence_rule):
|
||||||
return
|
return
|
||||||
@@ -219,7 +436,7 @@ def create_event():
|
|||||||
return jsonify({"success": True, "event_id": event.id})
|
return jsonify({"success": True, "event_id": event.id})
|
||||||
|
|
||||||
|
|
||||||
@events_bp.route("/<event_id>", methods=["PUT"])
|
@events_bp.route("/<event_id>", methods=["PUT"]) # update series or single event
|
||||||
def update_event(event_id):
|
def update_event(event_id):
|
||||||
data = request.json
|
data = request.json
|
||||||
session = Session()
|
session = Session()
|
||||||
@@ -262,21 +479,30 @@ def update_event(event_id):
|
|||||||
prev_skip != bool(getattr(event, 'skip_holidays', False))
|
prev_skip != bool(getattr(event, 'skip_holidays', False))
|
||||||
)
|
)
|
||||||
if need_regen:
|
if need_regen:
|
||||||
# Re-use helper from create route
|
# Re-use helper from create route (preserve user overrides)
|
||||||
def regenerate_event_exceptions(ev: Event):
|
def regenerate_event_exceptions(ev: Event):
|
||||||
from models.models import SchoolHoliday, EventException
|
from models.models import SchoolHoliday, EventException
|
||||||
from dateutil.rrule import rrulestr
|
from dateutil.rrule import rrulestr
|
||||||
from dateutil.tz import UTC
|
from dateutil.tz import UTC
|
||||||
# Clear existing exceptions
|
# Clear only auto-generated holiday skip exceptions, keep user overrides
|
||||||
session.query(EventException).filter_by(event_id=ev.id).delete()
|
session.query(EventException).filter(
|
||||||
|
EventException.event_id == ev.id,
|
||||||
|
EventException.is_skipped == True,
|
||||||
|
EventException.override_title.is_(None),
|
||||||
|
EventException.override_description.is_(None),
|
||||||
|
EventException.override_start.is_(None),
|
||||||
|
EventException.override_end.is_(None),
|
||||||
|
).delete(synchronize_session=False)
|
||||||
session.commit()
|
session.commit()
|
||||||
if not (ev.skip_holidays and ev.recurrence_rule):
|
if not (ev.skip_holidays and ev.recurrence_rule):
|
||||||
return
|
return
|
||||||
|
# Get holidays
|
||||||
holidays = session.query(SchoolHoliday).all()
|
holidays = session.query(SchoolHoliday).all()
|
||||||
dtstart = ev.start.astimezone(UTC)
|
dtstart = ev.start.astimezone(UTC)
|
||||||
r = rrulestr(ev.recurrence_rule, dtstart=dtstart)
|
r = rrulestr(ev.recurrence_rule, dtstart=dtstart)
|
||||||
window_start = dtstart
|
window_start = dtstart
|
||||||
window_end = ev.recurrence_end.astimezone(UTC) if ev.recurrence_end else dtstart.replace(year=dtstart.year + 1)
|
window_end = ev.recurrence_end.astimezone(UTC) if ev.recurrence_end else dtstart.replace(year=dtstart.year + 1)
|
||||||
|
# Build set of all holiday dates (inclusive)
|
||||||
holiday_dates = set()
|
holiday_dates = set()
|
||||||
for h in holidays:
|
for h in holidays:
|
||||||
hs = h.start_date
|
hs = h.start_date
|
||||||
@@ -285,6 +511,7 @@ def update_event(event_id):
|
|||||||
while d <= he:
|
while d <= he:
|
||||||
holiday_dates.add(d)
|
holiday_dates.add(d)
|
||||||
d = d + timedelta(days=1)
|
d = d + timedelta(days=1)
|
||||||
|
# Create exceptions for occurrences on holiday dates
|
||||||
for occ_start in r.between(window_start, window_end, inc=True):
|
for occ_start in r.between(window_start, window_end, inc=True):
|
||||||
occ_date = occ_start.date()
|
occ_date = occ_start.date()
|
||||||
if occ_date in holiday_dates:
|
if occ_date in holiday_dates:
|
||||||
@@ -293,6 +520,7 @@ def update_event(event_id):
|
|||||||
|
|
||||||
regenerate_event_exceptions(event)
|
regenerate_event_exceptions(event)
|
||||||
|
|
||||||
event_id_return = event.id # <-- ID vor session.close() speichern!
|
# Return success with event id
|
||||||
|
event_id_return = event.id
|
||||||
session.close()
|
session.close()
|
||||||
return jsonify({"success": True, "event_id": event_id_return})
|
return jsonify({"success": True, "event_id": event_id_return})
|
||||||
|
|||||||
Reference in New Issue
Block a user