feat: festival/event model frontend + topbar activeren

- Events lijst: card grid met festival/serie chips
- Festival detail: programmaonderdelen grid
- CreateSubEventDialog voor sub-events binnen festival
- EventTabsNav: breadcrumb terug naar festival
- Sessie A: festival-bewuste EventResource + children endpoint
- Topbar: zoekbalk, theme switcher, shortcuts, notificaties
- Schema v1.7 + BACKLOG.md toegevoegd
- 121 tests groen
This commit is contained in:
2026-04-08 10:06:47 +02:00
parent 6848bc2c49
commit c776331cf8
21 changed files with 1087 additions and 190 deletions

View File

@@ -1,5 +1,9 @@
<script setup lang="ts">
import EventTabsNav from '@/components/events/EventTabsNav.vue'
import CreateSubEventDialog from '@/components/events/CreateSubEventDialog.vue'
import { useEventDetail, useEventChildren } from '@/composables/api/useEvents'
import { useAuthStore } from '@/stores/useAuthStore'
import type { EventStatus, EventItem } from '@/types/event'
definePage({
meta: {
@@ -9,9 +13,52 @@ definePage({
const route = useRoute()
const router = useRouter()
const authStore = useAuthStore()
const orgId = computed(() => authStore.currentOrganisation?.id ?? '')
const eventId = computed(() => String((route.params as { id: string }).id))
const { data: event } = useEventDetail(orgId, eventId)
const isFestival = computed(() => event.value?.is_festival ?? false)
const isFlatEvent = computed(() => event.value?.is_flat_event ?? false)
// Children query — only enabled for festivals
const { data: children, isLoading: childrenLoading } = useEventChildren(orgId, eventId)
const isCreateSubEventOpen = ref(false)
const statusColor: Record<EventStatus, string> = {
draft: 'default',
published: 'info',
registration_open: 'cyan',
buildup: 'warning',
showday: 'success',
teardown: 'warning',
closed: 'error',
}
const dateFormatter = new Intl.DateTimeFormat('nl-NL', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
})
function formatDate(iso: string) {
return dateFormatter.format(new Date(iso))
}
const subEventLabel = computed(() =>
event.value?.sub_event_label ?? 'Programmaonderdeel',
)
const subEventLabelPlural = computed(() =>
event.value?.sub_event_label
? `${event.value.sub_event_label}en`
: 'Programmaonderdelen',
)
// --- Flat event tiles (existing behaviour) ---
const tiles = [
{ title: 'Personen', value: 0, icon: 'tabler-users', color: 'success', route: 'events-id-persons', enabled: true },
{ title: 'Secties & Shifts', value: 0, icon: 'tabler-layout-grid', color: 'primary', route: null, enabled: false },
@@ -23,10 +70,163 @@ function onTileClick(tile: typeof tiles[number]) {
if (tile.enabled && tile.route)
router.push({ name: tile.route as 'events-id-persons', params: { id: eventId.value } })
}
function navigateToChild(child: EventItem) {
router.push({ name: 'events-id', params: { id: child.id } })
}
</script>
<template>
<EventTabsNav>
<!-- Festival / Series view -->
<template v-if="isFestival && event">
<EventTabsNav :hide-tabs="true">
<!-- Sub-events subtitle bar -->
<div class="d-flex justify-space-between align-center mb-6">
<div class="text-body-1 text-medium-emphasis">
{{ children?.length ?? event.children_count ?? 0 }} {{ subEventLabelPlural.toLowerCase() }}
</div>
<VBtn
prepend-icon="tabler-plus"
size="small"
@click="isCreateSubEventOpen = true"
>
{{ subEventLabel }} toevoegen
</VBtn>
</div>
<!-- Children loading -->
<VSkeletonLoader
v-if="childrenLoading"
type="card@3"
/>
<!-- Children grid -->
<VRow v-else-if="children?.length">
<VCol
v-for="child in children"
:key="child.id"
cols="12"
md="6"
>
<VCard
class="cursor-pointer"
hover
@click="navigateToChild(child)"
>
<VCardText>
<div class="d-flex justify-space-between align-start mb-1">
<h6 class="text-h6">
{{ child.name }}
</h6>
<VChip
:color="statusColor[child.status]"
size="small"
>
{{ child.status }}
</VChip>
</div>
<p class="text-body-2 text-medium-emphasis mb-0">
{{ formatDate(child.start_date) }}
</p>
</VCardText>
</VCard>
</VCol>
</VRow>
<!-- Children empty -->
<VCard
v-else
class="text-center pa-6"
>
<p class="text-body-1 text-disabled mb-0">
Nog geen {{ subEventLabelPlural.toLowerCase() }}. Voeg je eerste {{ subEventLabel.toLowerCase() }} toe.
</p>
</VCard>
<!-- Bottom info cards -->
<VRow class="mt-6">
<VCol
cols="12"
md="6"
>
<VCard>
<VCardText>
<div class="d-flex justify-space-between align-center">
<div class="d-flex align-center gap-x-3">
<VAvatar
color="success"
variant="tonal"
size="44"
rounded
>
<VIcon
icon="tabler-users"
size="28"
/>
</VAvatar>
<div>
<p class="text-body-1 mb-0">
Vrijwilligers
</p>
<h4 class="text-h4">
0
</h4>
</div>
</div>
<VBtn
variant="tonal"
size="small"
:to="{ name: 'events-id-persons', params: { id: eventId } }"
>
Beheren
</VBtn>
</div>
</VCardText>
</VCard>
</VCol>
<VCol
cols="12"
md="6"
>
<VCard>
<VCardText>
<div class="d-flex align-center gap-x-3">
<VAvatar
color="info"
variant="tonal"
size="44"
rounded
>
<VIcon
icon="tabler-chart-bar"
size="28"
/>
</VAvatar>
<div>
<p class="text-body-1 mb-0">
Capaciteitsoverzicht
</p>
<p class="text-body-2 text-disabled mb-0">
Volledige capaciteitsplanning zichtbaar zodra {{ subEventLabelPlural.toLowerCase() }} zijn aangemaakt
</p>
</div>
</div>
</VCardText>
</VCard>
</VCol>
</VRow>
<CreateSubEventDialog
v-if="event"
v-model="isCreateSubEventOpen"
:parent-event="event"
:org-id="orgId"
/>
</EventTabsNav>
</template>
<!-- Flat event / Sub-event view (existing behaviour) -->
<EventTabsNav v-else>
<VRow class="mb-6">
<VCol
v-for="tile in tiles"

View File

@@ -2,7 +2,7 @@
import { useEventList } from '@/composables/api/useEvents'
import { useAuthStore } from '@/stores/useAuthStore'
import CreateEventDialog from '@/components/events/CreateEventDialog.vue'
import type { EventStatus, EventType } from '@/types/event'
import type { EventStatus, EventItem } from '@/types/event'
const router = useRouter()
const authStore = useAuthStore()
@@ -13,13 +13,10 @@ const { data: events, isLoading, isError, refetch } = useEventList(orgId)
const isCreateDialogOpen = ref(false)
const headers = [
{ title: 'Naam', key: 'name' },
{ title: 'Status', key: 'status' },
{ title: 'Startdatum', key: 'start_date' },
{ title: 'Einddatum', key: 'end_date' },
{ title: 'Acties', key: 'actions', sortable: false, align: 'end' as const },
]
// Filter: only top-level events (no sub-events)
const topLevelEvents = computed(() =>
events.value?.filter(e => !e.is_sub_event) ?? [],
)
const statusColor: Record<EventStatus, string> = {
draft: 'default',
@@ -31,6 +28,11 @@ const statusColor: Record<EventStatus, string> = {
closed: 'error',
}
const eventTypeColor: Record<string, string> = {
festival: 'purple',
series: 'info',
}
const dateFormatter = new Intl.DateTimeFormat('nl-NL', {
day: '2-digit',
month: '2-digit',
@@ -41,8 +43,8 @@ function formatDate(iso: string) {
return dateFormatter.format(new Date(iso))
}
function navigateToDetail(_event: Event, row: { item: EventType }) {
router.push({ name: 'events-id', params: { id: row.item.id } })
function navigateToEvent(event: EventItem) {
router.push({ name: 'events-id', params: { id: event.id } })
}
</script>
@@ -74,14 +76,14 @@ function navigateToDetail(_event: Event, row: { item: EventType }) {
prepend-icon="tabler-plus"
@click="isCreateDialogOpen = true"
>
Nieuw evenement
Nieuw aanmaken
</VBtn>
</div>
<!-- Loading -->
<VSkeletonLoader
v-if="isLoading"
type="table"
type="card@4"
/>
<!-- Error -->
@@ -103,7 +105,7 @@ function navigateToDetail(_event: Event, row: { item: EventType }) {
<!-- Empty -->
<VCard
v-else-if="!events?.length"
v-else-if="!topLevelEvents.length"
class="text-center pa-8"
>
<VIcon
@@ -112,47 +114,72 @@ function navigateToDetail(_event: Event, row: { item: EventType }) {
class="mb-4 text-disabled"
/>
<p class="text-body-1 text-disabled">
Nog geen evenementen
Nog geen evenementen. Maak je eerste evenement aan.
</p>
</VCard>
<!-- Data table -->
<VCard v-else>
<VDataTable
:headers="headers"
:items="events"
item-value="id"
hover
@click:row="navigateToDetail"
<!-- Event cards grid -->
<VRow v-else>
<VCol
v-for="event in topLevelEvents"
:key="event.id"
cols="12"
md="6"
>
<template #item.status="{ item }">
<VChip
:color="statusColor[item.status]"
size="small"
>
{{ item.status }}
</VChip>
</template>
<VCard
class="cursor-pointer"
hover
@click="navigateToEvent(event)"
>
<VCardText>
<div class="d-flex justify-space-between align-start mb-2">
<h5 class="text-h5">
{{ event.name }}
</h5>
<div class="d-flex gap-x-2">
<VChip
v-if="event.event_type === 'festival'"
color="purple"
size="small"
variant="tonal"
>
Festival
</VChip>
<VChip
v-else-if="event.event_type === 'series'"
color="info"
size="small"
variant="tonal"
>
Serie
</VChip>
<VChip
:color="statusColor[event.status]"
size="small"
>
{{ event.status }}
</VChip>
</div>
</div>
<template #item.start_date="{ item }">
{{ formatDate(item.start_date) }}
</template>
<p class="text-body-2 text-medium-emphasis mb-0">
{{ formatDate(event.start_date) }} {{ formatDate(event.end_date) }}
</p>
<template #item.end_date="{ item }">
{{ formatDate(item.end_date) }}
</template>
<template #item.actions="{ item }">
<VBtn
icon="tabler-eye"
variant="text"
size="small"
:to="{ name: 'events-id', params: { id: item.id } }"
@click.stop
/>
</template>
</VDataTable>
</VCard>
<p
v-if="event.children_count && event.children_count > 0"
class="text-body-2 text-medium-emphasis d-flex align-center gap-x-1 mt-1 mb-0"
>
<VIcon
icon="tabler-layout-grid"
size="16"
/>
{{ event.children_count }} {{ event.sub_event_label?.toLowerCase() ?? 'programmaonderdelen' }}
</p>
</VCardText>
</VCard>
</VCol>
</VRow>
<CreateEventDialog
v-model="isCreateDialogOpen"