feat(calendar): Live-Refresh ueber CalDAV, Tagklick-Navigation, Listen-Ansicht

- caldav.py sendet SSE-Notifications bei Event-PUT/DELETE und Kalender-Loeschung,
  damit das Web-UI auch auf Aenderungen aus DAVx5 sofort reagiert.
- FullCalendar navLinks: Klick auf Tagesnummer im Monatsraster wechselt in
  die Tagesansicht.
- Neue Listen-Ansicht mit Volltext-Suche, Datumsbereich, Kalender-Filter,
  Sortierung nach Datum/Titel und Loeschen-Button pro Zeile.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Stefan Hacker
2026-04-13 09:28:44 +02:00
parent 10a1dec448
commit e02c4f97c1
2 changed files with 178 additions and 2 deletions
+163 -1
View File
@@ -3,6 +3,7 @@
<div class="view-header">
<h2>Kalender</h2>
<div class="header-actions">
<SelectButton v-model="viewMode" :options="viewModeOptions" optionLabel="label" optionValue="value" size="small" />
<Button icon="pi pi-plus" label="Neuer Kalender" size="small" outlined @click="showNewCalendar = true" />
<Button icon="pi pi-plus" label="Neuer Termin" size="small" @click="openNewEvent()" />
</div>
@@ -21,7 +22,7 @@
</aside>
<div class="calendar-main">
<FullCalendar ref="fcRef" :options="calendarOptions">
<FullCalendar v-show="viewMode === 'calendar'" ref="fcRef" :options="calendarOptions">
<template #eventContent="arg">
<div class="fc-event-content-inner" :title="eventTooltip(arg.event)">
<span v-if="arg.event.extendedProps.all_day" class="fc-icon">📅</span>
@@ -31,6 +32,59 @@
</div>
</template>
</FullCalendar>
<div v-if="viewMode === 'list'" class="list-view">
<div class="list-filters">
<span class="p-input-icon-left" style="flex: 1;">
<i class="pi pi-search"></i>
<InputText v-model="listSearch" placeholder="Suchen (Titel, Ort, Beschreibung)..." fluid />
</span>
<InputText v-model="listFrom" type="date" title="Von" />
<InputText v-model="listTo" type="date" title="Bis" />
<Select v-model="listCalFilter" :options="listCalOptions"
optionLabel="label" optionValue="value" placeholder="Alle Kalender"
showClear style="min-width: 180px" />
</div>
<div class="list-meta">{{ filteredListEvents.length }} Termin(e)</div>
<table class="list-table">
<thead>
<tr>
<th @click="toggleListSort('dtstart')" class="sortable">
Datum <i v-if="listSort === 'dtstart'" :class="listSortDir === 'asc' ? 'pi pi-arrow-up' : 'pi pi-arrow-down'"></i>
</th>
<th @click="toggleListSort('summary')" class="sortable">
Titel <i v-if="listSort === 'summary'" :class="listSortDir === 'asc' ? 'pi pi-arrow-up' : 'pi pi-arrow-down'"></i>
</th>
<th>Kalender</th>
<th>Ort</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="ev in filteredListEvents" :key="ev.id" class="list-row" @click="openEditEvent(ev)">
<td class="col-date">
<div>{{ formatListDate(ev) }}</div>
<div v-if="!ev.all_day" class="meta-time">{{ formatListTime(ev) }}</div>
</td>
<td class="col-title">
<span class="cal-dot" :style="{ background: ev._cal?.color }"></span>
{{ ev.summary || '(ohne Titel)' }}
<i v-if="ev.recurrence_rule" class="pi pi-replay" title="Wiederholung" style="margin-left: 0.25rem; font-size: 0.75rem;"></i>
</td>
<td>{{ ev._cal?.name }}</td>
<td>{{ ev.location || '' }}</td>
<td class="col-actions" @click.stop>
<Button icon="pi pi-trash" text severity="danger" size="small"
:disabled="ev._cal?.permission === 'read'"
@click="confirmDeleteListEvent(ev)" title="Loeschen" />
</td>
</tr>
<tr v-if="!filteredListEvents.length">
<td colspan="5" class="empty-row">Keine Termine gefunden.</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
@@ -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); }
</style>