diff --git a/backend/app/dav/caldav.py b/backend/app/dav/caldav.py
index 6c5358e..7857288 100644
--- a/backend/app/dav/caldav.py
+++ b/backend/app/dav/caldav.py
@@ -26,8 +26,14 @@ from functools import wraps
from flask import Response, request
from app.extensions import db
-from app.models.calendar import Calendar, CalendarEvent
+from app.models.calendar import Calendar, CalendarEvent, CalendarShare
from app.models.user import User
+from app.services.events import notify_calendar_change
+
+
+def _cal_recipients(cal: 'Calendar'):
+ return [s.shared_with_id for s in
+ CalendarShare.query.filter_by(calendar_id=cal.id).all()]
from . import dav_bp
@@ -513,6 +519,8 @@ def put_event(username, cal_part, filename):
existing.ical_data = _extract_vevent_block(raw)
existing.updated_at = datetime.now(timezone.utc)
db.session.commit()
+ notify_calendar_change(cal.owner_id, cal.id, 'event',
+ shared_with=_cal_recipients(cal))
status = 201 if request.method == 'PUT' and not if_match else 204
return Response('', status, {'ETag': _etag_for_event(existing)})
@@ -541,6 +549,8 @@ def delete_event(username, cal_part, filename):
return Response('', 404)
db.session.delete(ev)
db.session.commit()
+ notify_calendar_change(cal.owner_id, cal.id, 'event',
+ shared_with=_cal_recipients(cal))
return Response('', 204)
@@ -558,8 +568,12 @@ def delete_calendar(username, cal_part):
cal = _calendar_for(user, cal_id) if cal_id else None
if not cal:
return Response('', 404)
+ recipients = _cal_recipients(cal)
+ owner_id = cal.owner_id
+ cid = cal.id
db.session.delete(cal)
db.session.commit()
+ notify_calendar_change(owner_id, cid, 'deleted', shared_with=recipients)
return Response('', 204)
diff --git a/frontend/src/views/CalendarView.vue b/frontend/src/views/CalendarView.vue
index c01cab6..47a5a7b 100644
--- a/frontend/src/views/CalendarView.vue
+++ b/frontend/src/views/CalendarView.vue
@@ -3,6 +3,7 @@
@@ -282,6 +336,7 @@ import Dialog from 'primevue/dialog'
import InputText from 'primevue/inputtext'
import Textarea from 'primevue/textarea'
import Select from 'primevue/select'
+import SelectButton from 'primevue/selectbutton'
import FullCalendar from '@fullcalendar/vue3'
import dayGridPlugin from '@fullcalendar/daygrid'
import timeGridPlugin from '@fullcalendar/timegrid'
@@ -301,6 +356,88 @@ const showNewCalendar = ref(false)
const newCalName = ref('')
const newCalColor = ref('#3788d8')
+const viewMode = ref('calendar')
+const viewModeOptions = [
+ { label: 'Kalender', value: 'calendar' },
+ { label: 'Liste', value: 'list' },
+]
+const listEvents = ref([])
+const listSearch = ref('')
+const listFrom = ref('')
+const listTo = ref('')
+const listCalFilter = ref(null)
+const listSort = ref('dtstart')
+const listSortDir = ref('asc')
+
+const listCalOptions = computed(() => calendars.value.map(c => ({ label: c.name, value: c.id })))
+
+const filteredListEvents = computed(() => {
+ const q = listSearch.value.trim().toLowerCase()
+ const fromDt = listFrom.value ? new Date(listFrom.value + 'T00:00:00') : null
+ const toDt = listTo.value ? new Date(listTo.value + 'T23:59:59') : null
+ let arr = listEvents.value.filter(e => {
+ if (listCalFilter.value && e.calendar_id !== listCalFilter.value) return false
+ if (fromDt && new Date(e.dtstart) < fromDt) return false
+ if (toDt && new Date(e.dtstart) > toDt) return false
+ if (q) {
+ const hay = `${e.summary || ''} ${e.location || ''} ${e.description || ''}`.toLowerCase()
+ if (!hay.includes(q)) return false
+ }
+ return true
+ })
+ const dir = listSortDir.value === 'asc' ? 1 : -1
+ const key = listSort.value
+ arr = arr.slice().sort((a, b) => {
+ const av = key === 'dtstart' ? new Date(a.dtstart).getTime() : (a.summary || '').toLowerCase()
+ const bv = key === 'dtstart' ? new Date(b.dtstart).getTime() : (b.summary || '').toLowerCase()
+ return av < bv ? -dir : av > bv ? dir : 0
+ })
+ return arr
+})
+
+function toggleListSort(col) {
+ if (listSort.value === col) listSortDir.value = listSortDir.value === 'asc' ? 'desc' : 'asc'
+ else { listSort.value = col; listSortDir.value = 'asc' }
+}
+
+function formatListDate(ev) {
+ const d = new Date(ev.dtstart)
+ return d.toLocaleDateString('de-DE', { weekday: 'short', day: '2-digit', month: '2-digit', year: 'numeric' })
+}
+function formatListTime(ev) {
+ const fmt = d => new Date(d).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })
+ return ev.dtend ? `${fmt(ev.dtstart)}–${fmt(ev.dtend)}` : fmt(ev.dtstart)
+}
+
+async function loadListEvents() {
+ // Lade alle Termine ueber die letzten 90 Tage und naechsten 365 Tage hinweg.
+ const start = new Date(); start.setDate(start.getDate() - 90)
+ const end = new Date(); end.setDate(end.getDate() + 365)
+ const all = []
+ for (const cal of calendars.value) {
+ if (visibleCalendars[cal.id] === false) continue
+ try {
+ const res = await apiClient.get(`/calendars/${cal.id}/events`, {
+ params: { start: start.toISOString(), end: end.toISOString() },
+ })
+ for (const e of res.data) all.push({ ...e, _cal: cal })
+ } catch { /* ignore */ }
+ }
+ listEvents.value = all
+}
+
+async function confirmDeleteListEvent(ev) {
+ if (!confirm(`Termin "${ev.summary || '(ohne Titel)'}" wirklich loeschen?`)) return
+ try {
+ await apiClient.delete(`/events/${ev.id}`)
+ toast.add({ severity: 'success', summary: 'Geloescht', life: 2000 })
+ await loadListEvents()
+ refreshEvents()
+ } catch (err) {
+ toast.add({ severity: 'error', summary: 'Fehler', detail: err.response?.data?.error, life: 5000 })
+ }
+}
+
const showEventDialog = ref(false)
const editingEvent = ref(null)
const eventForm = ref({
@@ -377,6 +514,8 @@ const calendarOptions = computed(() => ({
center: 'title',
right: 'dayGridMonth,timeGridWeek,timeGridDay',
},
+ navLinks: true,
+ navLinkDayClick: (date) => fcRef.value?.getApi().changeView('timeGridDay', date),
locale: deLocale,
firstDay: 1,
nowIndicator: true,
@@ -600,6 +739,7 @@ async function onEventDrop(info) {
function refreshEvents() {
fcRef.value?.getApi().refetchEvents()
+ if (viewMode.value === 'list') loadListEvents()
}
async function loadCalendars() {
@@ -896,9 +1036,14 @@ function scheduleReload() {
reloadTimer = null
await loadCalendars()
refreshEvents()
+ if (viewMode.value === 'list') await loadListEvents()
}, 300)
}
+watch(viewMode, async (mode) => {
+ if (mode === 'list') await loadListEvents()
+})
+
onMounted(async () => {
await loadCalendars()
refreshEvents()
@@ -968,4 +1113,21 @@ onUnmounted(() => {
.fc-event-content-inner .fc-icon { flex-shrink: 0; font-size: 0.85em; }
.fc-event-content-inner .fc-time { flex-shrink: 0; font-weight: 600; font-size: 0.8em; opacity: 0.9; }
.fc-event-content-inner .fc-title { overflow: hidden; text-overflow: ellipsis; }
+
+.list-view { display: flex; flex-direction: column; gap: 0.75rem; }
+.list-filters { display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap; }
+.list-meta { font-size: 0.8rem; color: var(--p-text-muted-color); }
+.list-table { width: 100%; border-collapse: collapse; font-size: 0.875rem; }
+.list-table th { text-align: left; padding: 0.5rem; border-bottom: 2px solid var(--p-surface-200); font-weight: 600; user-select: none; }
+.list-table th.sortable { cursor: pointer; }
+.list-table th.sortable:hover { background: var(--p-surface-50); }
+.list-table td { padding: 0.5rem; border-bottom: 1px solid var(--p-surface-100); vertical-align: top; }
+.list-row { cursor: pointer; }
+.list-row:hover { background: var(--p-surface-50); }
+.col-date { white-space: nowrap; min-width: 140px; }
+.col-title { font-weight: 500; }
+.col-actions { width: 60px; text-align: right; }
+.cal-dot { display: inline-block; width: 10px; height: 10px; border-radius: 50%; margin-right: 0.4rem; vertical-align: middle; }
+.meta-time { font-size: 0.75rem; color: var(--p-text-muted-color); }
+.empty-row { text-align: center; padding: 2rem !important; color: var(--p-text-muted-color); }