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

@@ -2,6 +2,7 @@
import { VForm } from 'vuetify/components/VForm'
import { useCreateEvent } from '@/composables/api/useEvents'
import { requiredValidator } from '@core/utils/validators'
import type { EventTypeEnum } from '@/types/event'
const props = defineProps<{
orgId: string
@@ -17,6 +18,9 @@ const form = ref({
start_date: '',
end_date: '',
timezone: 'Europe/Amsterdam',
event_type: 'event' as EventTypeEnum,
event_type_label: '',
sub_event_label: '',
})
const errors = ref<Record<string, string>>({})
@@ -25,6 +29,24 @@ const showSuccess = ref(false)
const { mutate: createEvent, isPending } = useCreateEvent(orgIdRef)
const isFestivalOrSeries = computed(() =>
form.value.event_type === 'festival' || form.value.event_type === 'series',
)
const eventTypeOptions: { title: string; value: EventTypeEnum }[] = [
{ title: 'Evenement', value: 'event' },
{ title: 'Festival', value: 'festival' },
{ title: 'Serie', value: 'series' },
]
const subEventLabelOptions = [
'Dag',
'Programmaonderdeel',
'Editie',
'Locatie',
'Ronde',
]
const timezoneOptions = [
{ title: 'Europe/Amsterdam', value: 'Europe/Amsterdam' },
{ title: 'Europe/London', value: 'Europe/London' },
@@ -50,7 +72,16 @@ const endDateRule = (v: string) => {
}
function resetForm() {
form.value = { name: '', slug: '', start_date: '', end_date: '', timezone: 'Europe/Amsterdam' }
form.value = {
name: '',
slug: '',
start_date: '',
end_date: '',
timezone: 'Europe/Amsterdam',
event_type: 'event',
event_type_label: '',
sub_event_label: '',
}
errors.value = {}
refVForm.value?.resetValidation()
}
@@ -61,7 +92,20 @@ function onSubmit() {
errors.value = {}
createEvent(form.value, {
const payload = {
name: form.value.name,
slug: form.value.slug,
start_date: form.value.start_date,
end_date: form.value.end_date,
timezone: form.value.timezone,
event_type: form.value.event_type,
event_type_label: form.value.event_type_label || null,
sub_event_label: isFestivalOrSeries.value && form.value.sub_event_label
? form.value.sub_event_label
: null,
}
createEvent(payload, {
onSuccess: () => {
modelValue.value = false
showSuccess.value = true
@@ -105,6 +149,7 @@ function onSubmit() {
autofocus
/>
</VCol>
<VCol cols="12">
<AppTextField
v-model="form.slug"
@@ -115,6 +160,51 @@ function onSubmit() {
persistent-hint
/>
</VCol>
<VCol cols="12">
<VBtnToggle
v-model="form.event_type"
mandatory
color="primary"
variant="outlined"
divided
density="comfortable"
>
<VBtn
v-for="opt in eventTypeOptions"
:key="opt.value"
:value="opt.value"
>
{{ opt.title }}
</VBtn>
</VBtnToggle>
</VCol>
<VCol
v-if="isFestivalOrSeries"
cols="12"
>
<AppCombobox
v-model="form.sub_event_label"
label="Hoe noem jij de onderdelen?"
:items="subEventLabelOptions"
:error-messages="errors.sub_event_label"
hint="Kies uit de lijst of typ een eigen naam"
persistent-hint
/>
</VCol>
<VCol cols="12">
<AppTextField
v-model="form.event_type_label"
label="Naam van het type (optioneel)"
placeholder="Festival, Evenement, Schaatsbaan..."
:error-messages="errors.event_type_label"
maxlength="50"
counter
/>
</VCol>
<VCol
cols="12"
md="6"

View File

@@ -0,0 +1,190 @@
<script setup lang="ts">
import { VForm } from 'vuetify/components/VForm'
import { useCreateSubEvent } from '@/composables/api/useEvents'
import { requiredValidator } from '@core/utils/validators'
import type { EventItem, EventStatus } from '@/types/event'
const props = defineProps<{
parentEvent: EventItem
orgId: string
}>()
const modelValue = defineModel<boolean>({ required: true })
const orgIdRef = computed(() => props.orgId)
const subEventLabel = computed(() =>
props.parentEvent.sub_event_label ?? 'Programmaonderdeel',
)
const form = ref({
name: '',
date: '',
start_time: '',
end_time: '',
status: 'draft' as EventStatus,
})
const errors = ref<Record<string, string>>({})
const refVForm = ref<VForm>()
const showSuccess = ref(false)
const { mutate: createSubEvent, isPending } = useCreateSubEvent(orgIdRef)
const statusOptions: { title: string; value: EventStatus }[] = [
{ title: 'Draft', value: 'draft' },
{ title: 'Published', value: 'published' },
{ title: 'Registration Open', value: 'registration_open' },
{ title: 'Build-up', value: 'buildup' },
{ title: 'Show Day', value: 'showday' },
{ title: 'Tear-down', value: 'teardown' },
{ title: 'Closed', value: 'closed' },
]
function resetForm() {
form.value = { name: '', date: '', start_time: '', end_time: '', status: 'draft' }
errors.value = {}
refVForm.value?.resetValidation()
}
function onSubmit() {
refVForm.value?.validate().then(({ valid }) => {
if (!valid) return
errors.value = {}
const slug = form.value.name
.toLowerCase()
.replace(/[^a-z0-9\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '')
createSubEvent({
name: form.value.name,
slug,
start_date: form.value.date,
end_date: form.value.date,
timezone: props.parentEvent.timezone,
event_type: 'event',
parent_event_id: props.parentEvent.id,
}, {
onSuccess: () => {
modelValue.value = false
showSuccess.value = true
resetForm()
},
onError: (err: any) => {
const data = err.response?.data
if (data?.errors) {
errors.value = Object.fromEntries(
Object.entries(data.errors).map(([k, v]) => [k, (v as string[])[0]]),
)
}
else if (data?.message) {
errors.value = { name: data.message }
}
},
})
})
}
</script>
<template>
<VDialog
v-model="modelValue"
max-width="500"
@after-leave="resetForm"
>
<VCard :title="`${subEventLabel} toevoegen`">
<VCardText>
<div class="text-body-2 text-medium-emphasis mb-4">
Onderdeel van: <strong>{{ parentEvent.name }}</strong>
</div>
<VForm
ref="refVForm"
@submit.prevent="onSubmit"
>
<VRow>
<VCol cols="12">
<AppTextField
v-model="form.name"
label="Naam"
:rules="[requiredValidator]"
:error-messages="errors.name"
:placeholder="`Dag 1, ${subEventLabel} 1...`"
autofocus
/>
</VCol>
<VCol cols="12">
<AppTextField
v-model="form.date"
label="Datum"
type="date"
:rules="[requiredValidator]"
:error-messages="errors.date ?? errors.start_date"
/>
</VCol>
<VCol
cols="12"
md="6"
>
<AppTextField
v-model="form.start_time"
label="Starttijd"
type="time"
:rules="[requiredValidator]"
:error-messages="errors.start_time"
/>
</VCol>
<VCol
cols="12"
md="6"
>
<AppTextField
v-model="form.end_time"
label="Eindtijd"
type="time"
:rules="[requiredValidator]"
:error-messages="errors.end_time"
/>
</VCol>
<VCol cols="12">
<AppSelect
v-model="form.status"
label="Status"
:items="statusOptions"
:error-messages="errors.status"
/>
</VCol>
</VRow>
</VForm>
</VCardText>
<VCardActions>
<VSpacer />
<VBtn
variant="text"
@click="modelValue = false"
>
Annuleren
</VBtn>
<VBtn
color="primary"
:loading="isPending"
@click="onSubmit"
>
Toevoegen
</VBtn>
</VCardActions>
</VCard>
</VDialog>
<VSnackbar
v-model="showSuccess"
color="success"
:timeout="3000"
>
{{ subEventLabel }} aangemaakt
</VSnackbar>
</template>

View File

@@ -2,10 +2,10 @@
import { VForm } from 'vuetify/components/VForm'
import { useUpdateEvent } from '@/composables/api/useEvents'
import { requiredValidator } from '@core/utils/validators'
import type { EventStatus, EventType } from '@/types/event'
import type { EventStatus, EventItem } from '@/types/event'
const props = defineProps<{
event: EventType
event: EventItem
orgId: string
}>()

View File

@@ -5,6 +5,12 @@ import { useOrganisationStore } from '@/stores/useOrganisationStore'
import EditEventDialog from '@/components/events/EditEventDialog.vue'
import type { EventStatus } from '@/types/event'
const props = withDefaults(defineProps<{
hideTabs?: boolean
}>(), {
hideTabs: false,
})
const route = useRoute()
const authStore = useAuthStore()
const orgStore = useOrganisationStore()
@@ -31,6 +37,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',
@@ -54,6 +65,13 @@ const activeTab = computed(() => {
const name = route.name as string
return tabs.find(t => name === t.route || name?.startsWith(`${t.route}-`))?.route ?? 'events-id'
})
const backRoute = computed(() => {
if (event.value?.is_sub_event && event.value.parent_event_id) {
return { name: 'events-id' as const, params: { id: event.value.parent_event_id } }
}
return { name: 'events' as const }
})
</script>
<template>
@@ -82,17 +100,41 @@ const activeTab = computed(() => {
</VAlert>
<template v-else-if="event">
<!-- Sub-event breadcrumb -->
<div
v-if="event.is_sub_event && event.parent && event.parent_event_id"
class="text-caption text-medium-emphasis mb-1"
>
<VIcon size="12">
tabler-arrow-left
</VIcon>
<RouterLink
:to="{ name: 'events-id', params: { id: event.parent_event_id } }"
class="text-medium-emphasis"
>
{{ event.parent.name }}
</RouterLink>
</div>
<!-- Header -->
<div class="d-flex justify-space-between align-center mb-6">
<div class="d-flex align-center gap-x-3">
<VBtn
icon="tabler-arrow-left"
variant="text"
:to="{ name: 'events' }"
:to="backRoute"
/>
<h4 class="text-h4">
{{ event.name }}
</h4>
<VChip
v-if="event.event_type === 'festival' || event.event_type === 'series'"
:color="eventTypeColor[event.event_type]"
size="small"
variant="tonal"
>
{{ event.event_type_label ?? (event.event_type === 'festival' ? 'Festival' : 'Serie') }}
</VChip>
<VChip
:color="statusColor[event.status]"
size="small"
@@ -111,8 +153,9 @@ const activeTab = computed(() => {
</VBtn>
</div>
<!-- Horizontal tabs -->
<!-- Horizontal tabs (hidden for festival containers) -->
<VTabs
v-if="!hideTabs"
:model-value="activeTab"
class="mb-6"
>

View File

@@ -3,7 +3,7 @@ import type { Ref } from 'vue'
import { apiClient } from '@/lib/axios'
import type {
CreateEventPayload,
EventType,
EventItem,
UpdateEventPayload,
} from '@/types/event'
@@ -28,8 +28,9 @@ export function useEventList(orgId: Ref<string>) {
return useQuery({
queryKey: ['events', orgId],
queryFn: async () => {
const { data } = await apiClient.get<PaginatedResponse<EventType>>(
const { data } = await apiClient.get<PaginatedResponse<EventItem>>(
`/organisations/${orgId.value}/events`,
{ params: { include_children: true } },
)
return data.data
},
@@ -41,7 +42,7 @@ export function useEventDetail(orgId: Ref<string>, id: Ref<string>) {
return useQuery({
queryKey: ['events', orgId, id],
queryFn: async () => {
const { data } = await apiClient.get<ApiResponse<EventType>>(
const { data } = await apiClient.get<ApiResponse<EventItem>>(
`/organisations/${orgId.value}/events/${id.value}`,
)
return data.data
@@ -50,12 +51,42 @@ export function useEventDetail(orgId: Ref<string>, id: Ref<string>) {
})
}
export function useEventChildren(orgId: Ref<string>, eventId: Ref<string>) {
return useQuery({
queryKey: ['event-children', eventId],
queryFn: async () => {
const { data } = await apiClient.get<PaginatedResponse<EventItem>>(
`/organisations/${orgId.value}/events/${eventId.value}/children`,
)
return data.data
},
enabled: () => !!orgId.value && !!eventId.value,
})
}
export function useCreateEvent(orgId: Ref<string>) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (payload: CreateEventPayload) => {
const { data } = await apiClient.post<ApiResponse<EventType>>(
const { data } = await apiClient.post<ApiResponse<EventItem>>(
`/organisations/${orgId.value}/events`,
payload,
)
return data.data
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['events', orgId.value] })
},
})
}
export function useCreateSubEvent(orgId: Ref<string>) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (payload: CreateEventPayload) => {
const { data } = await apiClient.post<ApiResponse<EventItem>>(
`/organisations/${orgId.value}/events`,
payload,
)
@@ -72,7 +103,7 @@ export function useUpdateEvent(orgId: Ref<string>, id: Ref<string>) {
return useMutation({
mutationFn: async (payload: UpdateEventPayload) => {
const { data } = await apiClient.put<ApiResponse<EventType>>(
const { data } = await apiClient.put<ApiResponse<EventItem>>(
`/organisations/${orgId.value}/events/${id.value}`,
payload,
)

View File

@@ -7,7 +7,6 @@ import { themeConfig } from '@themeConfig'
import Footer from '@/layouts/components/Footer.vue'
import NavbarThemeSwitcher from '@/layouts/components/NavbarThemeSwitcher.vue'
import UserProfile from '@/layouts/components/UserProfile.vue'
import NavBarI18n from '@core/components/I18n.vue'
import { HorizontalNavLayout } from '@layouts'
import { VNodeRenderer } from '@layouts/components/VNodeRenderer'
</script>
@@ -28,11 +27,6 @@ import { VNodeRenderer } from '@layouts/components/VNodeRenderer'
</RouterLink>
<VSpacer />
<NavBarI18n
v-if="themeConfig.app.i18n.enable && themeConfig.app.i18n.langConfig?.length"
:languages="themeConfig.app.i18n.langConfig"
/>
<NavbarThemeSwitcher class="me-2" />
<UserProfile />
</template>

View File

@@ -1,13 +1,14 @@
<script lang="ts" setup>
import navItems from '@/navigation/vertical'
import { themeConfig } from '@themeConfig'
// Components
import Footer from '@/layouts/components/Footer.vue'
import NavBarNotifications from '@/layouts/components/NavBarNotifications.vue'
import NavSearchBar from '@/layouts/components/NavSearchBar.vue'
import NavbarShortcuts from '@/layouts/components/NavbarShortcuts.vue'
import NavbarThemeSwitcher from '@/layouts/components/NavbarThemeSwitcher.vue'
import UserProfile from '@/layouts/components/UserProfile.vue'
import OrganisationSwitcher from '@/components/layout/OrganisationSwitcher.vue'
import NavBarI18n from '@core/components/I18n.vue'
// @layouts plugin
import { VerticalNavLayout } from '@layouts'
@@ -21,12 +22,15 @@ import { VerticalNavLayout } from '@layouts'
<div class="vertical-nav-items-shadow" />
</template>
<!-- 👉 navbar -->
<!-- 👉 navbar (match Vuexy full-version: search + actions; search flex-grows) -->
<template #navbar="{ toggleVerticalOverlayNavActive }">
<div class="d-flex h-100 align-center">
<div
class="d-flex h-100 align-center w-100"
style="min-inline-size: 0;"
>
<IconBtn
id="vertical-nav-toggle-btn"
class="ms-n3 d-lg-none"
class="ms-n3 d-lg-none flex-shrink-0"
@click="toggleVerticalOverlayNavActive(true)"
>
<VIcon
@@ -35,15 +39,12 @@ import { VerticalNavLayout } from '@layouts'
/>
</IconBtn>
<NavbarThemeSwitcher />
<NavSearchBar class="flex-grow-1 ms-lg-n3 min-w-0" />
<VSpacer />
<NavBarI18n
v-if="themeConfig.app.i18n.enable && themeConfig.app.i18n.langConfig?.length"
:languages="themeConfig.app.i18n.langConfig"
/>
<UserProfile />
<NavbarThemeSwitcher class="flex-shrink-0 me-2" />
<NavbarShortcuts class="flex-shrink-0" />
<NavBarNotifications class="flex-shrink-0 me-1" />
<UserProfile class="flex-shrink-0" />
</div>
</template>

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import avatar1 from '@images/avatars/avatar-1.png'
import { computed } from 'vue'
import { useLogout } from '@/composables/api/useAuth'
import { useAuthStore } from '@/stores/useAuthStore'
@@ -7,6 +7,28 @@ const router = useRouter()
const authStore = useAuthStore()
const { mutate: logout, isPending: isLoggingOut } = useLogout()
const user = computed(() => authStore.user)
const initials = computed(() => {
const name = user.value?.name?.trim() ?? ''
if (!name)
return '?'
const parts = name.split(/\s+/).filter(Boolean)
if (parts.length >= 2)
return (parts[0]![0]! + parts[parts.length - 1]![0]!).toUpperCase()
return name.slice(0, 2).toUpperCase()
})
const avatarSrc = computed((): string | null => {
const raw = user.value?.avatar
if (!raw)
return null
if (raw.startsWith('http://') || raw.startsWith('https://'))
return raw
const base = (import.meta.env.VITE_API_URL as string | undefined)?.replace(/\/api\/v1\/?$/, '') ?? ''
return raw.startsWith('/') ? `${base}${raw}` : `${base}/${raw}`
})
function handleLogout() {
logout(undefined, {
onSettled: () => {
@@ -17,105 +39,57 @@ function handleLogout() {
</script>
<template>
<VBadge
dot
location="bottom right"
offset-x="3"
offset-y="3"
bordered
color="success"
<VAvatar
class="cursor-pointer"
color="primary"
variant="tonal"
size="38"
>
<VAvatar
class="cursor-pointer"
color="primary"
variant="tonal"
<VImg
v-if="avatarSrc"
:src="avatarSrc"
:alt="user?.name ?? ''"
cover
/>
<span
v-else
class="text-caption font-weight-bold"
>
<VImg :src="avatar1" />
{{ initials }}
</span>
<!-- SECTION Menu -->
<VMenu
activator="parent"
width="230"
location="bottom end"
offset="14px"
>
<VList>
<!-- 👉 User Avatar & Name -->
<VListItem>
<template #prepend>
<VListItemAction start>
<VBadge
dot
location="bottom right"
offset-x="3"
offset-y="3"
color="success"
>
<VAvatar
color="primary"
variant="tonal"
>
<VImg :src="avatar1" />
</VAvatar>
</VBadge>
</VListItemAction>
</template>
<VMenu
activator="parent"
width="260"
location="bottom end"
offset="14px"
>
<VList>
<VListItem class="text-high-emphasis">
<VListItemTitle class="font-weight-semibold">
{{ user?.name ?? '—' }}
</VListItemTitle>
<VListItemSubtitle class="text-wrap">
{{ user?.email ?? '' }}
</VListItemSubtitle>
</VListItem>
<VListItemTitle class="font-weight-semibold">
{{ authStore.user?.name ?? 'User' }}
</VListItemTitle>
<VListItemSubtitle>{{ authStore.currentOrganisation?.role ?? '' }}</VListItemSubtitle>
</VListItem>
<VDivider class="my-2" />
<VDivider class="my-2" />
<!-- 👉 Profile -->
<VListItem link>
<template #prepend>
<VIcon
class="me-2"
icon="tabler-user"
size="22"
/>
</template>
<VListItemTitle>Profile</VListItemTitle>
</VListItem>
<!-- 👉 Settings -->
<VListItem link>
<template #prepend>
<VIcon
class="me-2"
icon="tabler-settings"
size="22"
/>
</template>
<VListItemTitle>Settings</VListItemTitle>
</VListItem>
<!-- Divider -->
<VDivider class="my-2" />
<!-- 👉 Logout -->
<VListItem
:disabled="isLoggingOut"
@click="handleLogout"
>
<template #prepend>
<VIcon
class="me-2"
icon="tabler-logout"
size="22"
/>
</template>
<VListItemTitle>Logout</VListItemTitle>
</VListItem>
</VList>
</VMenu>
<!-- !SECTION -->
</VAvatar>
</VBadge>
<VListItem
:disabled="isLoggingOut"
@click="handleLogout"
>
<template #prepend>
<VIcon
class="me-2"
icon="tabler-logout"
size="22"
/>
</template>
<VListItemTitle>Uitloggen</VListItemTitle>
</VListItem>
</VList>
</VMenu>
</VAvatar>
</template>

View File

@@ -5,7 +5,7 @@ export default [
icon: { icon: 'tabler-smart-home' },
},
{
title: 'Events',
title: 'Evenementen',
to: { name: 'events' },
icon: { icon: 'tabler-calendar-event' },
},

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"

View File

@@ -7,16 +7,30 @@ export type EventStatus =
| 'teardown'
| 'closed'
export interface EventType {
export type EventTypeEnum = 'event' | 'festival' | 'series'
export interface EventItem {
id: string
organisation_id: string
parent_event_id: string | null
name: string
slug: string
status: EventStatus
event_type: EventTypeEnum
event_type_label: string | null
sub_event_label: string | null
is_recurring: boolean
is_festival: boolean
is_sub_event: boolean
is_flat_event: boolean
has_children: boolean
start_date: string
end_date: string
timezone: string
created_at: string
children?: EventItem[]
parent?: EventItem | null
children_count?: number
}
export interface CreateEventPayload {
@@ -25,6 +39,10 @@ export interface CreateEventPayload {
start_date: string
end_date: string
timezone: string
event_type?: EventTypeEnum
event_type_label?: string | null
sub_event_label?: string | null
parent_event_id?: string | null
}
export interface UpdateEventPayload extends Partial<CreateEventPayload> {