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"
>