207 lines
9.4 KiB
Vue
207 lines
9.4 KiB
Vue
<script setup>
|
|
import dayjs from 'dayjs'
|
|
|
|
definePageMeta({ alias: ['/standardEntity/events'] })
|
|
useHead({ title: 'Termine · FEDEO' })
|
|
|
|
const auth = useAuthStore()
|
|
const { has } = usePermission()
|
|
const eventEntities = useEntities('events')
|
|
const canView = computed(() => has('events') && auth.activeTenantData?.features?.calendar !== false)
|
|
const canCreate = computed(() => canView.value && has('events-create'))
|
|
const events = ref([])
|
|
const total = ref(0)
|
|
const loading = ref(false)
|
|
const error = ref('')
|
|
const search = ref('')
|
|
const status = ref('all')
|
|
const page = ref(1)
|
|
const pageSize = 25
|
|
const sortDirection = ref('desc')
|
|
const createRoute = { path: '/standardEntity/events/create', query: { returnTo: 'events' } }
|
|
const statusOptions = [
|
|
{ label: 'Alle Status', value: 'all' },
|
|
{ label: 'Entwurf', value: 'Entwurf' },
|
|
{ label: 'Final', value: 'Final' }
|
|
]
|
|
const sortOptions = [
|
|
{ label: 'Beginn: neueste zuerst', value: 'desc' },
|
|
{ label: 'Beginn: älteste zuerst', value: 'asc' }
|
|
]
|
|
const columns = [
|
|
{ accessorKey: 'name', header: 'Termin' },
|
|
{ accessorKey: 'startDate', header: 'Zeitraum' },
|
|
{ accessorKey: 'project', header: 'Projekt' },
|
|
{ accessorKey: 'state', header: 'Status' },
|
|
{ id: 'actions', header: '' }
|
|
]
|
|
let requestVersion = 0
|
|
let searchTimer
|
|
|
|
const editRoute = (event) => ({
|
|
path: `/standardEntity/events/edit/${event.id}`,
|
|
query: { returnTo: 'events' }
|
|
})
|
|
const eventTitle = (event) => event.name || event.project?.name || 'Termin ohne Titel'
|
|
const projectLabel = (event) => {
|
|
const project = event.project
|
|
if (!project) return 'Ohne Projekt'
|
|
if (typeof project !== 'object') return `Projekt ${project}`
|
|
return [project.projectNumber, project.name].filter(Boolean).join(' · ') || 'Ohne Projekt'
|
|
}
|
|
const formatDate = (value) => value && dayjs(value).isValid() ? dayjs(value).format('DD.MM.YYYY HH:mm') : 'Nicht angegeben'
|
|
const eventColor = (event) => event.color
|
|
|| (event.quick ? auth.activeTenantData?.calendarConfig?.quickEntry?.color : null)
|
|
|| auth.activeTenantData?.calendarConfig?.eventTypes?.find((type) => type.label === event.eventtype)?.color
|
|
|| '#2563eb'
|
|
|
|
async function loadEvents() {
|
|
const version = ++requestVersion
|
|
if (!canView.value) {
|
|
events.value = []
|
|
total.value = 0
|
|
loading.value = false
|
|
return
|
|
}
|
|
|
|
loading.value = true
|
|
error.value = ''
|
|
try {
|
|
const result = await eventEntities.selectPaginated({
|
|
select: '*, project(id,name,projectNumber)',
|
|
filters: { archived: false, ...(status.value === 'all' ? {} : { state: status.value }) },
|
|
search: search.value.trim(),
|
|
sort: [{ field: 'startDate', direction: sortDirection.value }],
|
|
page: page.value,
|
|
limit: pageSize
|
|
})
|
|
if (version !== requestVersion) return
|
|
events.value = result.data
|
|
total.value = result.meta?.total || 0
|
|
} catch {
|
|
if (version !== requestVersion) return
|
|
events.value = []
|
|
total.value = 0
|
|
error.value = 'Die Termine konnten nicht geladen werden. Bitte versuche es erneut.'
|
|
} finally {
|
|
if (version === requestVersion) loading.value = false
|
|
}
|
|
}
|
|
|
|
function reloadFromFirstPage() {
|
|
clearTimeout(searchTimer)
|
|
if (page.value !== 1) page.value = 1
|
|
else loadEvents()
|
|
}
|
|
|
|
function resetFilters() {
|
|
search.value = ''
|
|
status.value = 'all'
|
|
}
|
|
|
|
watch(search, () => {
|
|
++requestVersion
|
|
loading.value = true
|
|
clearTimeout(searchTimer)
|
|
searchTimer = setTimeout(reloadFromFirstPage, 250)
|
|
})
|
|
watch([status, sortDirection, canView, () => auth.activeTenant], reloadFromFirstPage)
|
|
watch(page, loadEvents)
|
|
onBeforeUnmount(() => {
|
|
clearTimeout(searchTimer)
|
|
++requestVersion
|
|
})
|
|
loadEvents()
|
|
</script>
|
|
|
|
<template>
|
|
<UDashboardNavbar title="Termine" :badge="total">
|
|
<template #right>
|
|
<UButton v-if="canCreate" :to="createRoute" icon="i-heroicons-plus" label="Neuer Termin" />
|
|
</template>
|
|
</UDashboardNavbar>
|
|
|
|
<UDashboardPanelContent class="space-y-4">
|
|
<UAlert v-if="!canView" color="neutral" variant="subtle" title="Termine sind für deinen Zugang nicht verfügbar." />
|
|
<template v-else>
|
|
<div class="flex flex-col gap-3 lg:flex-row lg:items-center">
|
|
<UInput
|
|
v-model="search"
|
|
icon="i-heroicons-magnifying-glass"
|
|
placeholder="Termin, Projekt oder Projektnummer suchen …"
|
|
aria-label="Termine durchsuchen"
|
|
class="w-full lg:flex-1"
|
|
/>
|
|
<div class="flex flex-wrap gap-2">
|
|
<USelect v-model="status" :items="statusOptions" aria-label="Status filtern" class="min-w-36 flex-1 lg:flex-none" />
|
|
<USelect v-model="sortDirection" :items="sortOptions" aria-label="Termine sortieren" class="min-w-48 flex-1 lg:flex-none" />
|
|
<UButton color="neutral" variant="outline" icon="i-heroicons-arrow-path" aria-label="Termine aktualisieren" :loading="loading" @click="loadEvents" />
|
|
</div>
|
|
</div>
|
|
|
|
<UAlert v-if="error" color="error" variant="subtle" title="Laden fehlgeschlagen" :description="error">
|
|
<template #actions>
|
|
<UButton color="error" variant="outline" label="Erneut versuchen" @click="loadEvents" />
|
|
</template>
|
|
</UAlert>
|
|
<div v-else-if="loading" class="space-y-3 py-4" role="status" aria-label="Termine werden geladen">
|
|
<USkeleton v-for="index in 5" :key="index" class="h-20 w-full rounded-lg" />
|
|
</div>
|
|
<div v-else-if="!events.length" class="rounded-xl border border-dashed border-default px-4 py-14 text-center">
|
|
<UIcon name="i-heroicons-calendar-days" class="mx-auto mb-3 size-10 text-muted" />
|
|
<h2 class="text-lg font-semibold">{{ search || status !== 'all' ? 'Keine passenden Termine' : 'Noch keine Termine' }}</h2>
|
|
<p class="mt-1 text-sm text-muted">{{ search || status !== 'all' ? 'Passe deine Suche oder den Statusfilter an.' : 'Erstelle deinen ersten Termin direkt über diese Übersicht.' }}</p>
|
|
<UButton v-if="search || status !== 'all'" class="mt-4" color="neutral" variant="outline" label="Filter zurücksetzen" @click="resetFilters" />
|
|
<UButton v-else-if="canCreate" class="mt-4" :to="createRoute" icon="i-heroicons-plus" label="Neuer Termin" />
|
|
</div>
|
|
<template v-else>
|
|
<UTable :data="events" :columns="columns" class="hidden rounded-xl border border-default md:block" :ui="{ td: 'align-top' }">
|
|
<template #name-cell="{ row }">
|
|
<div class="flex items-start gap-3">
|
|
<span class="mt-1 size-3 shrink-0 rounded-full" :style="{ backgroundColor: eventColor(row.original) }" aria-hidden="true" />
|
|
<div class="min-w-0">
|
|
<NuxtLink :to="editRoute(row.original)" class="block max-w-sm whitespace-normal break-words font-medium text-highlighted hover:text-primary">{{ eventTitle(row.original) }}</NuxtLink>
|
|
<p v-if="row.original.repeatInterval && row.original.repeatInterval !== 'Keine Wiederholung'" class="mt-1 text-xs text-muted">{{ row.original.repeatInterval }}</p>
|
|
<p v-if="row.original.quick" class="mt-1 text-xs text-muted">Quick-Eintrag</p>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
<template #startDate-cell="{ row }">
|
|
<div>{{ formatDate(row.original.startDate) }}</div>
|
|
<div v-if="row.original.endDate" class="mt-1 text-xs text-muted">bis {{ formatDate(row.original.endDate) }}</div>
|
|
</template>
|
|
<template #project-cell="{ row }">
|
|
<span class="block max-w-xs whitespace-normal break-words">{{ projectLabel(row.original) }}</span>
|
|
</template>
|
|
<template #state-cell="{ row }">
|
|
<UBadge :color="row.original.state === 'Entwurf' ? 'warning' : 'success'" variant="subtle">{{ row.original.state || 'Final' }}</UBadge>
|
|
</template>
|
|
<template #actions-cell="{ row }">
|
|
<UButton :to="editRoute(row.original)" color="neutral" variant="ghost" icon="i-heroicons-pencil-square" :aria-label="`${eventTitle(row.original)} bearbeiten`" />
|
|
</template>
|
|
</UTable>
|
|
|
|
<div class="space-y-3 md:hidden">
|
|
<NuxtLink v-for="event in events" :key="event.id" :to="editRoute(event)" class="block rounded-xl border border-default p-4 hover:border-primary">
|
|
<div class="flex items-start gap-3">
|
|
<span class="mt-1 size-3 shrink-0 rounded-full" :style="{ backgroundColor: eventColor(event) }" aria-hidden="true" />
|
|
<h2 class="min-w-0 flex-1 break-words font-semibold text-highlighted">{{ eventTitle(event) }}</h2>
|
|
<UBadge :color="event.state === 'Entwurf' ? 'warning' : 'success'" variant="subtle" class="shrink-0">{{ event.state || 'Final' }}</UBadge>
|
|
</div>
|
|
<p class="mt-3 text-sm">{{ formatDate(event.startDate) }}</p>
|
|
<p v-if="event.endDate" class="text-sm text-muted">bis {{ formatDate(event.endDate) }}</p>
|
|
<p class="mt-2 break-words text-sm text-muted">{{ projectLabel(event) }}</p>
|
|
<p v-if="event.repeatInterval && event.repeatInterval !== 'Keine Wiederholung'" class="mt-2 text-xs text-muted">{{ event.repeatInterval }}</p>
|
|
<p v-if="event.quick" class="mt-1 text-xs text-muted">Quick-Eintrag</p>
|
|
</NuxtLink>
|
|
</div>
|
|
|
|
<div class="flex flex-wrap items-center justify-between gap-3 border-t border-default pt-4">
|
|
<p class="text-sm text-muted">{{ total }} {{ total === 1 ? 'Termin' : 'Termine' }}</p>
|
|
<UPagination v-model:page="page" :total="total" :items-per-page="pageSize" :sibling-count="0" />
|
|
</div>
|
|
</template>
|
|
</template>
|
|
</UDashboardPanelContent>
|
|
</template>
|