feat: festival/series model with sub-events, cross-event sections, tab navigation, SectionsShiftsPanel extraction

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-10 11:15:19 +02:00
parent 11b9f1d399
commit 10bd55b8ae
40 changed files with 3087 additions and 1080 deletions

View File

@@ -41,6 +41,7 @@ const eventTypeOptions: { title: string; value: EventTypeEnum }[] = [
const subEventLabelOptions = [
'Dag',
'Evenement',
'Programmaonderdeel',
'Editie',
'Locatie',
@@ -134,11 +135,11 @@ function onSubmit() {
@after-leave="resetForm"
>
<VCard title="Nieuw evenement">
<VCardText>
<VForm
ref="refVForm"
@submit.prevent="onSubmit"
>
<VForm
ref="refVForm"
@submit.prevent="onSubmit"
>
<VCardText>
<VRow>
<VCol cols="12">
<AppTextField
@@ -147,6 +148,7 @@ function onSubmit() {
:rules="[requiredValidator]"
:error-messages="errors.name"
autofocus
autocomplete="one-time-code"
/>
</VCol>
@@ -158,6 +160,7 @@ function onSubmit() {
:error-messages="errors.slug"
hint="Wordt gebruikt in de URL"
persistent-hint
autocomplete="one-time-code"
/>
</VCol>
@@ -203,6 +206,7 @@ function onSubmit() {
:error-messages="errors.sub_event_label"
hint="Kies uit de lijst of typ een eigen naam"
persistent-hint
autocomplete="one-time-code"
/>
</VCol>
@@ -214,6 +218,7 @@ function onSubmit() {
:error-messages="errors.event_type_label"
maxlength="50"
counter
autocomplete="one-time-code"
/>
</VCol>
@@ -250,24 +255,24 @@ function onSubmit() {
/>
</VCol>
</VRow>
</VForm>
</VCardText>
<VCardActions>
<VSpacer />
<VBtn
variant="text"
@click="modelValue = false"
>
Annuleren
</VBtn>
<VBtn
color="primary"
:loading="isPending"
@click="onSubmit"
>
Aanmaken
</VBtn>
</VCardActions>
</VCardText>
<VCardActions>
<VSpacer />
<VBtn
variant="text"
@click="modelValue = false"
>
Annuleren
</VBtn>
<VBtn
type="submit"
color="primary"
:loading="isPending"
>
Aanmaken
</VBtn>
</VCardActions>
</VForm>
</VCard>
</VDialog>

View File

@@ -12,6 +12,7 @@ const props = defineProps<{
const modelValue = defineModel<boolean>({ required: true })
const orgIdRef = computed(() => props.orgId)
const parentEventIdRef = computed(() => props.parentEvent.id)
const subEventLabel = computed(() =>
props.parentEvent.sub_event_label ?? 'Programmaonderdeel',
@@ -29,7 +30,7 @@ const errors = ref<Record<string, string>>({})
const refVForm = ref<VForm>()
const showSuccess = ref(false)
const { mutate: createSubEvent, isPending } = useCreateSubEvent(orgIdRef)
const { mutate: createSubEvent, isPending } = useCreateSubEvent(orgIdRef, parentEventIdRef)
const statusOptions: { title: string; value: EventStatus }[] = [
{ title: 'Draft', value: 'draft' },
@@ -72,7 +73,6 @@ function onSubmit() {
onSuccess: () => {
modelValue.value = false
showSuccess.value = true
resetForm()
},
onError: (err: any) => {
const data = err.response?.data
@@ -97,15 +97,15 @@ function onSubmit() {
@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"
>
<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
@@ -115,6 +115,7 @@ function onSubmit() {
:error-messages="errors.name"
:placeholder="`Dag 1, ${subEventLabel} 1...`"
autofocus
autocomplete="one-time-code"
/>
</VCol>
<VCol cols="12">
@@ -159,24 +160,24 @@ function onSubmit() {
/>
</VCol>
</VRow>
</VForm>
</VCardText>
<VCardActions>
<VSpacer />
<VBtn
variant="text"
@click="modelValue = false"
>
Annuleren
</VBtn>
<VBtn
color="primary"
:loading="isPending"
@click="onSubmit"
>
Toevoegen
</VBtn>
</VCardActions>
</VCardText>
<VCardActions>
<VSpacer />
<VBtn
variant="text"
@click="modelValue = false"
>
Annuleren
</VBtn>
<VBtn
type="submit"
color="primary"
:loading="isPending"
>
Toevoegen
</VBtn>
</VCardActions>
</VForm>
</VCard>
</VDialog>

View File

@@ -0,0 +1,65 @@
<script setup lang="ts">
import { useDeleteEvent } from '@/composables/api/useEvents'
import type { EventItem } from '@/types/event'
const props = defineProps<{
event: EventItem | null
orgId: string
parentEventId: string
}>()
const modelValue = defineModel<boolean>({ required: true })
const emit = defineEmits<{
deleted: [name: string]
}>()
const orgIdRef = computed(() => props.orgId)
const parentEventIdRef = computed(() => props.parentEventId)
const { mutate: deleteEvent, isPending } = useDeleteEvent(orgIdRef, parentEventIdRef)
function onConfirm() {
if (!props.event) return
const name = props.event.name
deleteEvent(props.event.id, {
onSuccess: () => {
modelValue.value = false
emit('deleted', name)
},
})
}
</script>
<template>
<VDialog
v-model="modelValue"
max-width="450"
>
<VCard title="Programmaonderdeel verwijderen">
<VCardText>
<p class="text-body-1 mb-0">
Weet je zeker dat je <strong>'{{ event?.name }}'</strong> wilt verwijderen?
Dit programmaonderdeel en alle bijbehorende secties, shifts en toewijzingen worden gearchiveerd.
</p>
</VCardText>
<VCardActions>
<VSpacer />
<VBtn
variant="text"
@click="modelValue = false"
>
Annuleren
</VBtn>
<VBtn
color="error"
:loading="isPending"
@click="onConfirm"
>
Verwijderen
</VBtn>
</VCardActions>
</VCard>
</VDialog>
</template>

View File

@@ -21,6 +21,7 @@ const form = ref({
end_date: '',
timezone: '',
status: '' as EventStatus,
sub_event_label: '' as string | null,
})
const errors = ref<Record<string, string>>({})
@@ -29,6 +30,19 @@ const showSuccess = ref(false)
const { mutate: updateEvent, isPending } = useUpdateEvent(orgIdRef, eventIdRef)
const isFestivalOrSeries = computed(() =>
props.event.event_type === 'festival' || props.event.event_type === 'series',
)
const subEventLabelOptions = [
'Dag',
'Evenement',
'Programmaonderdeel',
'Editie',
'Locatie',
'Ronde',
]
const timezoneOptions = [
{ title: 'Europe/Amsterdam', value: 'Europe/Amsterdam' },
{ title: 'Europe/London', value: 'Europe/London' },
@@ -62,6 +76,7 @@ watch(() => props.event, (ev) => {
end_date: ev.end_date,
timezone: ev.timezone,
status: ev.status,
sub_event_label: ev.sub_event_label ?? '',
}
}, { immediate: true })
@@ -71,7 +86,14 @@ function onSubmit() {
errors.value = {}
updateEvent(form.value, {
const payload = {
...form.value,
sub_event_label: isFestivalOrSeries.value && form.value.sub_event_label
? form.value.sub_event_label
: null,
}
updateEvent(payload, {
onSuccess: () => {
modelValue.value = false
showSuccess.value = true
@@ -98,11 +120,11 @@ function onSubmit() {
max-width="550"
>
<VCard title="Evenement bewerken">
<VCardText>
<VForm
ref="refVForm"
@submit.prevent="onSubmit"
>
<VForm
ref="refVForm"
@submit.prevent="onSubmit"
>
<VCardText>
<VRow>
<VCol cols="12">
<AppTextField
@@ -111,6 +133,7 @@ function onSubmit() {
:rules="[requiredValidator]"
:error-messages="errors.name"
autofocus
autocomplete="one-time-code"
/>
</VCol>
<VCol cols="12">
@@ -119,6 +142,21 @@ function onSubmit() {
label="Slug"
:rules="[requiredValidator]"
:error-messages="errors.slug"
autocomplete="one-time-code"
/>
</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
autocomplete="one-time-code"
/>
</VCol>
<VCol
@@ -168,24 +206,24 @@ function onSubmit() {
/>
</VCol>
</VRow>
</VForm>
</VCardText>
<VCardActions>
<VSpacer />
<VBtn
variant="text"
@click="modelValue = false"
>
Annuleren
</VBtn>
<VBtn
color="primary"
:loading="isPending"
@click="onSubmit"
>
Opslaan
</VBtn>
</VCardActions>
</VCardText>
<VCardActions>
<VSpacer />
<VBtn
variant="text"
@click="modelValue = false"
>
Annuleren
</VBtn>
<VBtn
type="submit"
color="primary"
:loading="isPending"
>
Opslaan
</VBtn>
</VCardActions>
</VForm>
</VCard>
</VDialog>

View File

@@ -1,16 +1,11 @@
<script setup lang="ts">
import { useEventDetail } from '@/composables/api/useEvents'
import { useEventDetail, useEventChildren } from '@/composables/api/useEvents'
import { dutchPlural } from '@/lib/dutch-plural'
import { useAuthStore } from '@/stores/useAuthStore'
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()
@@ -20,6 +15,9 @@ const eventId = computed(() => String((route.params as { id: string }).id))
const { data: event, isLoading, isError, refetch } = useEventDetail(orgId, eventId)
// Children count for programmaonderdelen badge — only for festivals
const { data: children } = useEventChildren(orgId, eventId)
// Set active event in store
watch(eventId, (id) => {
if (id) orgStore.setActiveEvent(id)
@@ -52,7 +50,7 @@ function formatDate(iso: string) {
return dateFormatter.format(new Date(iso))
}
const tabs = [
const baseTabs = [
{ label: 'Overzicht', icon: 'tabler-layout-dashboard', route: 'events-id' },
{ label: 'Personen', icon: 'tabler-users', route: 'events-id-persons' },
{ label: 'Secties & Shifts', icon: 'tabler-layout-grid', route: 'events-id-sections' },
@@ -61,9 +59,38 @@ const tabs = [
{ label: 'Instellingen', icon: 'tabler-settings', route: 'events-id-settings' },
]
const programmaonderdelenLabel = computed(() => {
const label = event.value?.sub_event_label
? dutchPlural(event.value.sub_event_label)
: 'Programmaonderdelen'
const count = children.value?.length ?? event.value?.children_count ?? 0
return `${label} (${count})`
})
const tabs = computed(() => {
if (!event.value?.is_festival) return baseTabs
// Festival tab order: Overzicht | Programmaonderdelen | Secties & Shifts | Personen | Artiesten | Briefings | Instellingen
const festivalTab = {
label: programmaonderdelenLabel.value,
icon: 'tabler-calendar-event',
route: 'events-id-programmaonderdelen',
}
return [
baseTabs[0], // Overzicht
festivalTab,
baseTabs[2], // Secties & Shifts
baseTabs[1], // Personen
baseTabs[3], // Artiesten
baseTabs[4], // Briefings
baseTabs[5], // Instellingen
]
})
const activeTab = computed(() => {
const name = route.name as string
return tabs.find(t => name === t.route || name?.startsWith(`${t.route}-`))?.route ?? 'events-id'
return tabs.value.find(t => name === t.route || name?.startsWith(`${t.route}-`))?.route ?? 'events-id'
})
const backRoute = computed(() => {
@@ -100,22 +127,6 @@ const backRoute = 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">
@@ -125,6 +136,15 @@ const backRoute = computed(() => {
:to="backRoute"
/>
<h4 class="text-h4">
<template v-if="event.is_sub_event && event.parent && event.parent_event_id">
<RouterLink
:to="{ name: 'events-id', params: { id: event.parent_event_id } }"
class="text-medium-emphasis text-decoration-none"
>
{{ event.parent.name }}
</RouterLink>
<span class="text-medium-emphasis mx-1">&raquo;</span>
</template>
{{ event.name }}
</h4>
<VChip
@@ -153,9 +173,8 @@ const backRoute = computed(() => {
</VBtn>
</div>
<!-- Horizontal tabs (hidden for festival containers) -->
<!-- Horizontal tabs -->
<VTabs
v-if="!hideTabs"
:model-value="activeTab"
class="mb-6"
>

View File

@@ -1,19 +1,34 @@
<script setup lang="ts">
import { VForm } from 'vuetify/components/VForm'
import { useCreateSection } from '@/composables/api/useSections'
import { useCreateSection, useSectionCategories } from '@/composables/api/useSections'
import { useAuthStore } from '@/stores/useAuthStore'
import { requiredValidator } from '@core/utils/validators'
import type { SectionType } from '@/types/section'
const props = defineProps<{
const props = withDefaults(defineProps<{
eventId: string
isSubEvent?: boolean
nextSortOrder?: number
}>(), {
isSubEvent: false,
nextSortOrder: 0,
})
const emit = defineEmits<{
created: [payload: { name: string; redirectedToParent: boolean; parentEventName?: string }]
}>()
const modelValue = defineModel<boolean>({ required: true })
const eventIdRef = computed(() => props.eventId)
const authStore = useAuthStore()
const orgId = computed(() => authStore.currentOrganisation?.id ?? '')
const { data: categorySuggestions } = useSectionCategories(orgId)
const form = ref({
name: '',
category: null as string | null,
icon: null as string | null,
type: 'standard' as SectionType,
crew_auto_accepts: false,
responder_self_checkin: true,
@@ -29,9 +44,15 @@ const typeOptions = [
{ title: 'Overkoepelend', value: 'cross_event' },
]
const showCrossEventHint = computed(() =>
props.isSubEvent && form.value.type === 'cross_event',
)
function resetForm() {
form.value = {
name: '',
category: null,
icon: null,
type: 'standard',
crew_auto_accepts: false,
responder_self_checkin: true,
@@ -49,13 +70,21 @@ function onSubmit() {
createSection(
{
name: form.value.name,
category: form.value.category || null,
icon: form.value.icon || null,
type: form.value.type,
sort_order: props.nextSortOrder,
crew_auto_accepts: form.value.crew_auto_accepts,
responder_self_checkin: form.value.responder_self_checkin,
},
{
onSuccess: () => {
onSuccess: (result) => {
modelValue.value = false
emit('created', {
name: result.section.name,
redirectedToParent: result.redirectedToParent,
parentEventName: result.parentEventName,
})
resetForm()
},
onError: (err: any) => {
@@ -65,6 +94,9 @@ function onSubmit() {
Object.entries(data.errors).map(([k, v]) => [k, (v as string[])[0]]),
)
}
else if (data?.message) {
errors.value = { type: data.message }
}
},
},
)
@@ -79,11 +111,11 @@ function onSubmit() {
@after-leave="resetForm"
>
<VCard title="Sectie aanmaken">
<VCardText>
<VForm
ref="refVForm"
@submit.prevent="onSubmit"
>
<VForm
ref="refVForm"
@submit.prevent="onSubmit"
>
<VCardText>
<VRow>
<VCol cols="12">
<AppTextField
@@ -92,8 +124,38 @@ function onSubmit() {
:rules="[requiredValidator]"
:error-messages="errors.name"
autofocus
autocomplete="one-time-code"
/>
</VCol>
<VCol cols="12">
<VCombobox
v-model="form.category"
label="Categorie"
:items="categorySuggestions ?? []"
placeholder="Bijv. Bar, Podium, Operationeel..."
clearable
:error-messages="errors.category"
autocomplete="one-time-code"
/>
</VCol>
<VCol cols="12">
<div class="d-flex align-center gap-x-2">
<AppTextField
v-model="form.icon"
label="Icoon"
placeholder="tabler-beer"
clearable
:error-messages="errors.icon"
class="flex-grow-1"
autocomplete="one-time-code"
/>
<VIcon
v-if="form.icon"
:icon="form.icon"
size="24"
/>
</div>
</VCol>
<VCol cols="12">
<AppSelect
v-model="form.type"
@@ -101,6 +163,15 @@ function onSubmit() {
:items="typeOptions"
:error-messages="errors.type"
/>
<VAlert
v-if="showCrossEventHint"
type="info"
variant="tonal"
density="compact"
class="mt-2"
>
Deze sectie wordt automatisch aangemaakt op festival-niveau en is zichtbaar in alle programmaonderdelen.
</VAlert>
</VCol>
<VCol cols="12">
<VSwitch
@@ -119,24 +190,24 @@ function onSubmit() {
/>
</VCol>
</VRow>
</VForm>
</VCardText>
<VCardActions>
<VSpacer />
<VBtn
variant="text"
@click="modelValue = false"
>
Annuleren
</VBtn>
<VBtn
color="primary"
:loading="isPending"
@click="onSubmit"
>
Aanmaken
</VBtn>
</VCardActions>
</VCardText>
<VCardActions>
<VSpacer />
<VBtn
variant="text"
@click="modelValue = false"
>
Annuleren
</VBtn>
<VBtn
type="submit"
color="primary"
:loading="isPending"
>
Aanmaken
</VBtn>
</VCardActions>
</VForm>
</VCard>
</VDialog>
</template>

File diff suppressed because it is too large Load Diff