feat: registration section preferences with show_in_registration filtering and deduplication

Add show_in_registration and registration_description columns to festival_sections.
Registration form now shows deduplicated sections by name (across sub-events),
filtered by show_in_registration=true, grouped by category with card-based UI.
Section preferences use section_name instead of section_id.
Add GET/PUT registration-settings endpoints for festival-level bulk management.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-10 20:03:54 +02:00
parent 3400e4cc7e
commit c21bc085e9
22 changed files with 1443 additions and 104 deletions

View File

@@ -32,6 +32,8 @@ const form = ref({
type: 'standard' as SectionType,
crew_auto_accepts: false,
responder_self_checkin: true,
show_in_registration: false,
registration_description: null as string | null,
})
const errors = ref<Record<string, string>>({})
@@ -56,6 +58,8 @@ function resetForm() {
type: 'standard',
crew_auto_accepts: false,
responder_self_checkin: true,
show_in_registration: false,
registration_description: null,
}
errors.value = {}
refVForm.value?.resetValidation()
@@ -76,6 +80,8 @@ function onSubmit() {
sort_order: props.nextSortOrder,
crew_auto_accepts: form.value.crew_auto_accepts,
responder_self_checkin: form.value.responder_self_checkin,
show_in_registration: form.value.show_in_registration,
registration_description: form.value.registration_description || null,
},
{
onSuccess: (result) => {
@@ -188,6 +194,29 @@ function onSubmit() {
persistent-hint
/>
</VCol>
<VCol cols="12">
<VSwitch
v-model="form.show_in_registration"
label="Toon in vrijwilligersregistratie"
hint="Dit werkgebied verschijnt in het aanmeldformulier voor vrijwilligers"
persistent-hint
/>
</VCol>
<VCol
v-if="form.show_in_registration"
cols="12"
>
<VTextarea
v-model="form.registration_description"
label="Beschrijving voor vrijwilligers"
:counter="500"
rows="2"
auto-grow
hint="Korte uitleg zodat vrijwilligers weten wat dit werkgebied inhoudt"
persistent-hint
:error-messages="errors.registration_description"
/>
</VCol>
</VRow>
</VCardText>
<VCardActions>

View File

@@ -28,6 +28,8 @@ const form = ref({
type: 'standard' as SectionType,
crew_auto_accepts: false,
responder_self_checkin: true,
show_in_registration: false,
registration_description: null as string | null,
})
const errors = ref<Record<string, string>>({})
@@ -52,6 +54,8 @@ watch(
type: section.type,
crew_auto_accepts: section.crew_auto_accepts,
responder_self_checkin: section.responder_self_checkin,
show_in_registration: section.show_in_registration,
registration_description: section.registration_description,
}
}
},
@@ -79,6 +83,8 @@ function onSubmit() {
icon: form.value.icon || null,
crew_auto_accepts: form.value.crew_auto_accepts,
responder_self_checkin: form.value.responder_self_checkin,
show_in_registration: form.value.show_in_registration,
registration_description: form.value.registration_description || null,
},
{
onSuccess: () => {
@@ -180,6 +186,29 @@ function onSubmit() {
persistent-hint
/>
</VCol>
<VCol cols="12">
<VSwitch
v-model="form.show_in_registration"
label="Toon in vrijwilligersregistratie"
hint="Dit werkgebied verschijnt in het aanmeldformulier voor vrijwilligers"
persistent-hint
/>
</VCol>
<VCol
v-if="form.show_in_registration"
cols="12"
>
<VTextarea
v-model="form.registration_description"
label="Beschrijving voor vrijwilligers"
:counter="500"
rows="2"
auto-grow
hint="Korte uitleg zodat vrijwilligers weten wat dit werkgebied inhoudt"
persistent-hint
:error-messages="errors.registration_description"
/>
</VCol>
</VRow>
</VCardText>
<VCardActions>

View File

@@ -18,6 +18,8 @@ export interface FestivalSection {
crew_need: number | null
crew_auto_accepts: boolean
responder_self_checkin: boolean
show_in_registration: boolean
registration_description: string | null
created_at: string
}
@@ -39,6 +41,8 @@ export interface Shift {
status: ShiftStatus
filled_slots: number
fill_rate: number
is_overbooked: boolean
overbooking_count: number
effective_start_time: string
effective_end_time: string
time_slot: TimeSlot | null
@@ -61,6 +65,8 @@ export interface CreateSectionPayload {
sort_order?: number
crew_auto_accepts?: boolean
responder_self_checkin?: boolean
show_in_registration?: boolean
registration_description?: string | null
}
export interface UpdateSectionPayload extends Partial<CreateSectionPayload> {}

View File

@@ -6,6 +6,7 @@ import { useAuthStore } from '@/stores/useAuthStore'
import { useRegistrationData, useSubmitRegistration } from '@/composables/api/useVolunteerRegistration'
import { fullRegistrationSchema } from '@/schemas/registrationSchema'
import type {
SectionOption,
SectionPreference,
TimeSlotOption,
VolunteerAvailability,
@@ -68,8 +69,8 @@ watch(() => authStore.user, (user) => {
}
}, { immediate: true })
// Step 4: Section preferences
const selectedSectionIds = ref<string[]>([])
// Step 4: Section preferences (by name, not ID)
const selectedSections = ref<string[]>([])
// Step 5: Availability
const selectedTimeSlotIds = ref<string[]>([])
@@ -91,6 +92,27 @@ const motivationItems = [
const stepTitles = ['Over jou', 'Meer over jou', 'Motivatie', 'Secties', 'Beschikbaarheid']
// Section helpers
const sectionsByCategory = computed(() => {
if (!registrationData.value?.sections) return {}
return registrationData.value.sections.reduce((groups, section) => {
const cat = section.category || 'Overig'
if (!groups[cat]) groups[cat] = []
groups[cat].push(section)
return groups
}, {} as Record<string, SectionOption[]>)
})
function isSelected(name: string) {
return selectedSections.value.includes(name)
}
function getSelectionPriority(name: string) {
return selectedSections.value.indexOf(name) + 1
}
const selectedCount = computed(() => selectedSections.value.length)
// Computed
const timeSlotsByDate = computed(() => {
if (!registrationData.value?.time_slots) return []
@@ -140,13 +162,13 @@ function prevStep() {
}
// Section toggle
function toggleSection(sectionId: string) {
const idx = selectedSectionIds.value.indexOf(sectionId)
if (idx >= 0) {
selectedSectionIds.value.splice(idx, 1)
function toggleSection(name: string) {
const idx = selectedSections.value.indexOf(name)
if (idx !== -1) {
selectedSections.value.splice(idx, 1)
}
else if (selectedSectionIds.value.length < 5) {
selectedSectionIds.value.push(sectionId)
else if (selectedSections.value.length < 5) {
selectedSections.value.push(name)
}
}
@@ -194,8 +216,8 @@ async function onSubmit() {
if (!registrationData.value) return
const sectionPreferences: SectionPreference[] = selectedSectionIds.value.map((id, index) => ({
section_id: id,
const sectionPreferences: SectionPreference[] = selectedSections.value.map((name, index) => ({
section_name: name,
priority: index + 1,
}))
@@ -490,69 +512,87 @@ async function onSubmit() {
<!-- Step 4: Voorkeurssecties -->
<VWindowItem :value="4">
<div class="pa-4 pa-sm-6">
<p
v-if="registrationData.sections.length === 0"
class="text-body-1 text-medium-emphasis"
>
Er zijn geen secties beschikbaar voor dit evenement.
<h3 class="text-h6 mb-1">
Bij welke onderdelen wil je het liefst helpen?
</h3>
<p class="text-body-2 text-medium-emphasis mb-4">
Selecteer maximaal 5 onderdelen. Je eerste keuze heeft de hoogste prioriteit.
</p>
<template v-else>
<p class="text-body-2 text-medium-emphasis mb-4">
Selecteer maximaal 5 secties waar je graag wilt werken.
De volgorde van selectie bepaalt je voorkeur.
</p>
<VList lines="two">
<VListItem
v-for="section in registrationData.sections"
:key="section.id"
:disabled="!selectedSectionIds.includes(section.id) && selectedSectionIds.length >= 5"
class="section-item"
@click="toggleSection(section.id)"
>
<template #prepend>
<VCheckboxBtn
:model-value="selectedSectionIds.includes(section.id)"
:disabled="!selectedSectionIds.includes(section.id) && selectedSectionIds.length >= 5"
@click.stop="toggleSection(section.id)"
/>
</template>
<VListItemTitle class="d-flex align-center ga-2">
<VIcon
v-if="section.icon"
:icon="section.icon"
size="18"
/>
{{ section.name }}
</VListItemTitle>
<VListItemSubtitle v-if="section.category">
<VChip
size="x-small"
variant="outlined"
>
{{ section.category }}
</VChip>
</VListItemSubtitle>
<template
v-if="selectedSectionIds.includes(section.id)"
#append
>
<VChip
color="primary"
size="small"
>
Voorkeur {{ selectedSectionIds.indexOf(section.id) + 1 }}
</VChip>
</template>
</VListItem>
</VList>
<p
v-if="selectedSectionIds.length > 0"
class="text-body-2 text-medium-emphasis mt-4"
>
{{ selectedSectionIds.length }} van 5 secties geselecteerd
</p>
<template
v-for="(sections, category) in sectionsByCategory"
:key="category"
>
<div class="text-subtitle-2 text-medium-emphasis mt-4 mb-2">
{{ category }}
</div>
<VRow dense>
<VCol
v-for="section in sections"
:key="section.id"
cols="12"
sm="6"
>
<VCard
:variant="isSelected(section.name) ? 'flat' : 'outlined'"
:color="isSelected(section.name) ? 'primary' : undefined"
class="cursor-pointer"
:disabled="!isSelected(section.name) && selectedCount >= 5"
@click="toggleSection(section.name)"
>
<VCardText class="d-flex align-center ga-3 pa-3">
<VCheckboxBtn
:model-value="isSelected(section.name)"
readonly
density="compact"
hide-details
/>
<VIcon
v-if="section.icon"
size="20"
>
{{ section.icon }}
</VIcon>
<div class="flex-grow-1">
<div class="text-body-1 font-weight-medium">
{{ section.name }}
</div>
<div
v-if="section.registration_description"
class="text-body-2 text-medium-emphasis"
>
{{ section.registration_description }}
</div>
</div>
<VChip
v-if="isSelected(section.name)"
size="x-small"
color="primary"
variant="elevated"
>
#{{ getSelectionPriority(section.name) }}
</VChip>
</VCardText>
</VCard>
</VCol>
</VRow>
</template>
<VAlert
v-if="!Object.keys(sectionsByCategory).length"
type="info"
variant="tonal"
class="mt-4"
>
Er zijn nog geen werkgebieden geconfigureerd voor dit evenement.
</VAlert>
<p
v-if="selectedCount > 0"
class="text-body-2 text-medium-emphasis mt-4"
>
{{ selectedCount }} van 5 onderdelen geselecteerd
</p>
</div>
</VWindowItem>

View File

@@ -15,6 +15,7 @@ export interface SectionOption {
name: string
category: string | null
icon: string | null
registration_description: string | null
}
export interface TimeSlotOption {
@@ -27,7 +28,7 @@ export interface TimeSlotOption {
}
export interface SectionPreference {
section_id: string
section_name: string
priority: number
}