feat: Serientermin-Bearbeitung: Nur diesen Termin oder Serie

Klick auf einen wiederkehrenden Termin oeffnet zuerst einen Dialog:
"Nur diesen Termin" oder "Ganze Serie".

* Serie: bearbeitet den Master wie bisher
* Nur dieser: fuegt EXDATE fuer das geklickte Datum zum Master
  hinzu und legt einen eigenstaendigen Ersatz-Termin mit den
  bearbeiteten Daten an

Backend:
* CalendarEvent.exdates speichert Ausnahmedaten kommasepariert
* POST /events/<id>/exception fuegt EXDATE hinzu, erstellt
  optional das Replacement-Event mit frischer UID
* _build_vevent schreibt jetzt EXDATE-Zeilen in die ical_data,
  sodass CalDAV-Clients die Ausnahmen auch sehen werden

Frontend:
* FullCalendar rrule-Plugin bekommt die exdate-Liste und blendet
  die uebersprungenen Tage aus
* Drag & Drop verschiebt weiterhin die ganze Serie (Shortcut -
  fuer Einzelverschiebung Termin anklicken und bearbeiten)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Stefan Hacker
2026-04-12 12:41:35 +02:00
parent ddd8f57e69
commit c1b05e2525
3 changed files with 165 additions and 7 deletions
+73 -2
View File
@@ -186,6 +186,16 @@
</template>
</Dialog>
<!-- Scope picker for recurring events -->
<Dialog v-model:visible="showScopeDialog" header="Serientermin bearbeiten" modal :style="{ width: '440px' }">
<p>Dieser Termin gehoert zu einer Serie.<br>Was moechtest du bearbeiten?</p>
<template #footer>
<Button label="Abbrechen" text @click="showScopeDialog = false" />
<Button label="Nur diesen Termin" outlined @click="openEditScope('occurrence')" />
<Button label="Ganze Serie" @click="openEditScope('series')" />
</template>
</Dialog>
<Dialog v-model:visible="confirmDeleteCal" header="Kalender loeschen" modal :style="{ width: '400px' }">
<p>Moechtest du den Kalender <strong>{{ selectedCal?.name }}</strong> mit allen Terminen loeschen?</p>
<template #footer>
@@ -271,6 +281,11 @@ const sharePermission = ref('read')
const icalPassword = ref('')
const confirmDeleteEvent = ref(false)
const confirmDeleteCal = ref(false)
const showScopeDialog = ref(false)
const pendingScopeEvent = ref(null)
const pendingScopeOccurrence = ref(null)
// 'series' (edit master), 'occurrence' (split off single date), or null (non-recurring)
const currentEditScope = ref(null)
const permOptions = [{ label: 'Lesen', value: 'read' }, { label: 'Lesen+Schreiben', value: 'readwrite' }]
const ownCalendars = computed(() => calendars.value.filter(c => c.permission === 'owner'))
@@ -354,12 +369,14 @@ function toFcEvent(e, cal) {
extendedProps: { ...e, _cal: cal },
}
if (e.recurrence_rule) {
// FullCalendar rrule plugin consumes the RRULE together with dtstart
fc.rrule = {
freq: extractFreq(e.recurrence_rule),
dtstart: e.dtstart,
...parseRRule(e.recurrence_rule),
}
if (e.exdates && e.exdates.length) {
fc.exdate = e.exdates
}
if (e.dtend && e.dtstart) {
const ms = new Date(e.dtend).getTime() - new Date(e.dtstart).getTime()
if (ms > 0) fc.duration = msToDuration(ms)
@@ -443,9 +460,47 @@ function loadRRuleIntoForm(rrule) {
function onEventClick(info) {
const e = info.event.extendedProps
if (e.recurrence_rule) {
pendingScopeEvent.value = e
pendingScopeOccurrence.value = info.event.start
showScopeDialog.value = true
return
}
currentEditScope.value = null
openEditEvent(e)
}
function openEditScope(scope) {
showScopeDialog.value = false
const e = pendingScopeEvent.value
const occ = pendingScopeOccurrence.value
if (scope === 'series') {
currentEditScope.value = 'series'
openEditEvent(e)
} else {
// "Nur diesen Termin" - form starts from the clicked occurrence's
// start/end, non-recurring. Save will add an EXDATE on the master
// plus create a standalone event.
currentEditScope.value = 'occurrence'
editingEvent.value = e
const durationMs = e.dtend ? new Date(e.dtend) - new Date(e.dtstart) : 3600000
const occEnd = new Date(occ.getTime() + durationMs)
eventForm.value = {
summary: e.summary,
description: e.description || '',
location: e.location || '',
calendar_id: e.calendar_id,
dtstart: toLocalISO(occ, e.all_day),
dtend: toLocalISO(occEnd, e.all_day),
all_day: e.all_day,
}
loadRRuleIntoForm('')
// Freeze recurrence editor - this is a single instance
recurFreq.value = 'none'
showEventDialog.value = true
}
}
function onDateSelect(info) {
openNewEvent(info.start, info.end, info.allDay)
}
@@ -534,12 +589,28 @@ async function saveEvent() {
calendar_id: eventForm.value.calendar_id,
}
try {
if (editingEvent.value) {
if (currentEditScope.value === 'occurrence' && editingEvent.value) {
// Split single occurrence off the series: add EXDATE + create replacement
await apiClient.post(`/events/${editingEvent.value.id}/exception`, {
occurrence_date: toLocalISO(pendingScopeOccurrence.value, editingEvent.value.all_day),
replacement: {
summary: payload.summary,
description: payload.description,
location: payload.location,
dtstart: payload.dtstart,
dtend: payload.dtend,
all_day: payload.all_day,
},
})
} else if (editingEvent.value) {
await apiClient.put(`/events/${editingEvent.value.id}`, payload)
} else {
await apiClient.post(`/calendars/${payload.calendar_id}/events`, payload)
}
showEventDialog.value = false
currentEditScope.value = null
pendingScopeEvent.value = null
pendingScopeOccurrence.value = null
refreshEvents()
} catch (err) {
toast.add({ severity: 'error', summary: 'Fehler', detail: err.response?.data?.error, life: 5000 })