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:
parent
10a1dec448
commit
e02c4f97c1
|
|
@ -26,8 +26,14 @@ from functools import wraps
|
||||||
from flask import Response, request
|
from flask import Response, request
|
||||||
|
|
||||||
from app.extensions import db
|
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.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
|
from . import dav_bp
|
||||||
|
|
||||||
|
|
@ -513,6 +519,8 @@ def put_event(username, cal_part, filename):
|
||||||
existing.ical_data = _extract_vevent_block(raw)
|
existing.ical_data = _extract_vevent_block(raw)
|
||||||
existing.updated_at = datetime.now(timezone.utc)
|
existing.updated_at = datetime.now(timezone.utc)
|
||||||
db.session.commit()
|
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
|
status = 201 if request.method == 'PUT' and not if_match else 204
|
||||||
return Response('', status, {'ETag': _etag_for_event(existing)})
|
return Response('', status, {'ETag': _etag_for_event(existing)})
|
||||||
|
|
@ -541,6 +549,8 @@ def delete_event(username, cal_part, filename):
|
||||||
return Response('', 404)
|
return Response('', 404)
|
||||||
db.session.delete(ev)
|
db.session.delete(ev)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
notify_calendar_change(cal.owner_id, cal.id, 'event',
|
||||||
|
shared_with=_cal_recipients(cal))
|
||||||
return Response('', 204)
|
return Response('', 204)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -558,8 +568,12 @@ def delete_calendar(username, cal_part):
|
||||||
cal = _calendar_for(user, cal_id) if cal_id else None
|
cal = _calendar_for(user, cal_id) if cal_id else None
|
||||||
if not cal:
|
if not cal:
|
||||||
return Response('', 404)
|
return Response('', 404)
|
||||||
|
recipients = _cal_recipients(cal)
|
||||||
|
owner_id = cal.owner_id
|
||||||
|
cid = cal.id
|
||||||
db.session.delete(cal)
|
db.session.delete(cal)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
notify_calendar_change(owner_id, cid, 'deleted', shared_with=recipients)
|
||||||
return Response('', 204)
|
return Response('', 204)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
<div class="view-header">
|
<div class="view-header">
|
||||||
<h2>Kalender</h2>
|
<h2>Kalender</h2>
|
||||||
<div class="header-actions">
|
<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 Kalender" size="small" outlined @click="showNewCalendar = true" />
|
||||||
<Button icon="pi pi-plus" label="Neuer Termin" size="small" @click="openNewEvent()" />
|
<Button icon="pi pi-plus" label="Neuer Termin" size="small" @click="openNewEvent()" />
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -21,7 +22,7 @@
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<div class="calendar-main">
|
<div class="calendar-main">
|
||||||
<FullCalendar ref="fcRef" :options="calendarOptions">
|
<FullCalendar v-show="viewMode === 'calendar'" ref="fcRef" :options="calendarOptions">
|
||||||
<template #eventContent="arg">
|
<template #eventContent="arg">
|
||||||
<div class="fc-event-content-inner" :title="eventTooltip(arg.event)">
|
<div class="fc-event-content-inner" :title="eventTooltip(arg.event)">
|
||||||
<span v-if="arg.event.extendedProps.all_day" class="fc-icon">📅</span>
|
<span v-if="arg.event.extendedProps.all_day" class="fc-icon">📅</span>
|
||||||
|
|
@ -31,6 +32,59 @@
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</FullCalendar>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -282,6 +336,7 @@ import Dialog from 'primevue/dialog'
|
||||||
import InputText from 'primevue/inputtext'
|
import InputText from 'primevue/inputtext'
|
||||||
import Textarea from 'primevue/textarea'
|
import Textarea from 'primevue/textarea'
|
||||||
import Select from 'primevue/select'
|
import Select from 'primevue/select'
|
||||||
|
import SelectButton from 'primevue/selectbutton'
|
||||||
import FullCalendar from '@fullcalendar/vue3'
|
import FullCalendar from '@fullcalendar/vue3'
|
||||||
import dayGridPlugin from '@fullcalendar/daygrid'
|
import dayGridPlugin from '@fullcalendar/daygrid'
|
||||||
import timeGridPlugin from '@fullcalendar/timegrid'
|
import timeGridPlugin from '@fullcalendar/timegrid'
|
||||||
|
|
@ -301,6 +356,88 @@ const showNewCalendar = ref(false)
|
||||||
const newCalName = ref('')
|
const newCalName = ref('')
|
||||||
const newCalColor = ref('#3788d8')
|
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 showEventDialog = ref(false)
|
||||||
const editingEvent = ref(null)
|
const editingEvent = ref(null)
|
||||||
const eventForm = ref({
|
const eventForm = ref({
|
||||||
|
|
@ -377,6 +514,8 @@ const calendarOptions = computed(() => ({
|
||||||
center: 'title',
|
center: 'title',
|
||||||
right: 'dayGridMonth,timeGridWeek,timeGridDay',
|
right: 'dayGridMonth,timeGridWeek,timeGridDay',
|
||||||
},
|
},
|
||||||
|
navLinks: true,
|
||||||
|
navLinkDayClick: (date) => fcRef.value?.getApi().changeView('timeGridDay', date),
|
||||||
locale: deLocale,
|
locale: deLocale,
|
||||||
firstDay: 1,
|
firstDay: 1,
|
||||||
nowIndicator: true,
|
nowIndicator: true,
|
||||||
|
|
@ -600,6 +739,7 @@ async function onEventDrop(info) {
|
||||||
|
|
||||||
function refreshEvents() {
|
function refreshEvents() {
|
||||||
fcRef.value?.getApi().refetchEvents()
|
fcRef.value?.getApi().refetchEvents()
|
||||||
|
if (viewMode.value === 'list') loadListEvents()
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadCalendars() {
|
async function loadCalendars() {
|
||||||
|
|
@ -896,9 +1036,14 @@ function scheduleReload() {
|
||||||
reloadTimer = null
|
reloadTimer = null
|
||||||
await loadCalendars()
|
await loadCalendars()
|
||||||
refreshEvents()
|
refreshEvents()
|
||||||
|
if (viewMode.value === 'list') await loadListEvents()
|
||||||
}, 300)
|
}, 300)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
watch(viewMode, async (mode) => {
|
||||||
|
if (mode === 'list') await loadListEvents()
|
||||||
|
})
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await loadCalendars()
|
await loadCalendars()
|
||||||
refreshEvents()
|
refreshEvents()
|
||||||
|
|
@ -968,4 +1113,21 @@ onUnmounted(() => {
|
||||||
.fc-event-content-inner .fc-icon { flex-shrink: 0; font-size: 0.85em; }
|
.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-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; }
|
.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>
|
</style>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue