@@ -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.85 em ; }
. fc - event - content - inner . fc - time { flex - shrink : 0 ; font - weight : 600 ; font - size : 0.8 em ; opacity : 0.9 ; }
. fc - event - content - inner . fc - title { overflow : hidden ; text - overflow : ellipsis ; }
. list - view { display : flex ; flex - direction : column ; gap : 0.75 rem ; }
. list - filters { display : flex ; gap : 0.5 rem ; align - items : center ; flex - wrap : wrap ; }
. list - meta { font - size : 0.8 rem ; color : var ( -- p - text - muted - color ) ; }
. list - table { width : 100 % ; border - collapse : collapse ; font - size : 0.875 rem ; }
. list - table th { text - align : left ; padding : 0.5 rem ; border - bottom : 2 px 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.5 rem ; border - bottom : 1 px 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 : 140 px ; }
. col - title { font - weight : 500 ; }
. col - actions { width : 60 px ; text - align : right ; }
. cal - dot { display : inline - block ; width : 10 px ; height : 10 px ; border - radius : 50 % ; margin - right : 0.4 rem ; vertical - align : middle ; }
. meta - time { font - size : 0.75 rem ; color : var ( -- p - text - muted - color ) ; }
. empty - row { text - align : center ; padding : 2 rem ! important ; color : var ( -- p - text - muted - color ) ; }
< / style >