feat(app): registration fields management page in event settings

Adds a new settings sub-page for managing dynamic registration form fields
per event. Includes sortable field list, create/edit dialog, template picker,
and import-from-event functionality.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-12 23:44:14 +02:00
parent c43a922641
commit a9dcee0fc7
10 changed files with 1376 additions and 0 deletions

View File

@@ -17,6 +17,12 @@ const authStore = useAuthStore()
const orgId = computed(() => authStore.currentOrganisation?.id ?? '')
const eventId = computed(() => String((route.params as { id: string }).id))
const settingsTab = computed(() => {
const name = route.name as string
if (name?.includes('registration-fields')) return 'registration-fields'
return 'branding'
})
const { mutate: updateEvent, isPending: isUpdating } = useUpdateEvent(orgId, eventId)
const { mutate: uploadImage, isPending: isUploading } = useUploadEventImage(orgId, eventId)
@@ -56,6 +62,27 @@ function onClearImage(event: EventItem, type: 'banner' | 'logo') {
<template>
<EventTabsNav v-slot="{ event }">
<div @vue:mounted="initForm(event)">
<!-- Settings sub-navigation -->
<VTabs
:model-value="settingsTab"
class="mb-6"
>
<VTab
value="branding"
prepend-icon="tabler-palette"
:to="{ name: 'events-id-settings', params: { id: eventId } }"
>
Registratie-uiterlijk
</VTab>
<VTab
value="registration-fields"
prepend-icon="tabler-forms"
:to="{ name: 'events-id-settings-registration-fields', params: { id: eventId } }"
>
Registratievelden
</VTab>
</VTabs>
<VRow>
<VCol
cols="12"

View File

@@ -0,0 +1,441 @@
<script setup lang="ts">
import draggable from 'vuedraggable'
import EventTabsNav from '@/components/events/EventTabsNav.vue'
import RegistrationFieldCard from '@/components/event/RegistrationFieldCard.vue'
import RegistrationFieldFormDialog from '@/components/event/RegistrationFieldFormDialog.vue'
import TemplatePickerDialog from '@/components/event/TemplatePickerDialog.vue'
import ImportFromEventDialog from '@/components/event/ImportFromEventDialog.vue'
import { useUpdateEvent } from '@/composables/api/useEvents'
import {
useRegistrationFormFields,
useCreateRegistrationFormField,
useUpdateRegistrationFormField,
useDeleteRegistrationFormField,
useReorderRegistrationFormFields,
} from '@/composables/api/useRegistrationFormFields'
import { useAuthStore } from '@/stores/useAuthStore'
import { useNotificationStore } from '@/stores/useNotificationStore'
import type { RegistrationFormField } from '@/types/registration-form-field'
import type { EventItem } from '@/types/event'
definePage({
meta: {
navActiveLink: 'events',
},
})
const route = useRoute()
const authStore = useAuthStore()
const notificationStore = useNotificationStore()
const orgId = computed(() => authStore.currentOrganisation?.id ?? '')
const eventId = computed(() => String((route.params as { id: string }).id))
// Event update for form settings toggles
const { mutate: updateEvent, isPending: isUpdatingEvent } = useUpdateEvent(orgId, eventId)
// Registration form fields
const { data: fields, isLoading } = useRegistrationFormFields(eventId)
const { mutate: createField, isPending: isCreating } = useCreateRegistrationFormField(eventId)
const { mutate: updateField, isPending: isUpdating } = useUpdateRegistrationFormField(eventId)
const { mutate: deleteField, isPending: isDeleting } = useDeleteRegistrationFormField(eventId)
const { mutate: reorderFields } = useReorderRegistrationFormFields(eventId)
// Local draggable list synced from query
const localFields = ref<RegistrationFormField[]>([])
watch(fields, (newFields) => {
if (newFields) {
localFields.value = [...newFields]
}
}, { immediate: true })
// Form settings toggles (initialised from event)
const showSectionPreferences = ref(false)
const showAvailability = ref(false)
let initialised = false
function initFormSettings(event: EventItem) {
if (initialised) return
showSectionPreferences.value = event.registration_show_section_preferences ?? false
showAvailability.value = event.registration_show_availability ?? false
initialised = true
}
function onToggleSectionPreferences(val: boolean | null) {
updateEvent(
{ registration_show_section_preferences: !!val },
{
onSuccess: () => notificationStore.show('Instelling opgeslagen', 'success'),
},
)
}
function onToggleAvailability(val: boolean | null) {
updateEvent(
{ registration_show_availability: !!val },
{
onSuccess: () => notificationStore.show('Instelling opgeslagen', 'success'),
},
)
}
// Create / Edit dialog
const isFieldDialogOpen = ref(false)
const editingField = ref<RegistrationFormField | null>(null)
const fieldFormRef = ref<InstanceType<typeof RegistrationFieldFormDialog> | null>(null)
const isSaving = computed(() => isCreating.value || isUpdating.value)
function openCreateDialog() {
editingField.value = null
isFieldDialogOpen.value = true
}
function openEditDialog(field: RegistrationFormField) {
editingField.value = field
isFieldDialogOpen.value = true
}
function onSaveField(payload: Record<string, any>) {
if (editingField.value) {
updateField(
{ id: editingField.value.id, ...payload },
{
onSuccess: () => {
isFieldDialogOpen.value = false
notificationStore.show(`${payload.label} bijgewerkt`, 'success')
},
onError: (err) => fieldFormRef.value?.setErrors(err),
},
)
}
else {
createField(payload as any, {
onSuccess: () => {
isFieldDialogOpen.value = false
notificationStore.show(`${payload.label} aangemaakt`, 'success')
},
onError: (err) => fieldFormRef.value?.setErrors(err),
})
}
}
// Delete confirmation
const isDeleteDialogOpen = ref(false)
const deletingField = ref<RegistrationFormField | null>(null)
function onDeleteConfirm(field: RegistrationFormField) {
deletingField.value = field
isDeleteDialogOpen.value = true
}
function onDeleteExecute() {
if (!deletingField.value) return
const label = deletingField.value.label
deleteField(deletingField.value.id, {
onSuccess: () => {
isDeleteDialogOpen.value = false
deletingField.value = null
notificationStore.show(`${label} verwijderd`, 'success')
},
})
}
// Drag reorder
function onDragEnd() {
reorderFields(localFields.value.map(f => f.id))
}
// Template picker dialog
const isTemplatePickerOpen = ref(false)
function onTemplateAdded() {
notificationStore.show('Veld uit template toegevoegd', 'success')
}
// Import from event dialog
const isImportDialogOpen = ref(false)
function onImported(count: number, eventName: string) {
notificationStore.show(`${count} velden geïmporteerd van ${eventName}`, 'success')
}
// Field count
const fieldCount = computed(() => localFields.value.length)
// Settings sub-navigation tabs
const settingsTab = computed(() => {
const name = route.name as string
if (name?.includes('registration-fields')) return 'registration-fields'
return 'branding'
})
</script>
<template>
<EventTabsNav v-slot="{ event }">
<div @vue:mounted="initFormSettings(event)">
<!-- Settings sub-navigation -->
<VTabs
:model-value="settingsTab"
class="mb-6"
>
<VTab
value="branding"
prepend-icon="tabler-palette"
:to="{ name: 'events-id-settings', params: { id: eventId } }"
>
Registratie-uiterlijk
</VTab>
<VTab
value="registration-fields"
prepend-icon="tabler-forms"
:to="{ name: 'events-id-settings-registration-fields', params: { id: eventId } }"
>
Registratievelden
</VTab>
</VTabs>
<VRow>
<VCol
cols="12"
md="8"
>
<!-- Section 1: Formulierinstellingen -->
<VCard class="mb-6">
<VCardTitle class="d-flex align-center gap-2">
<VIcon
icon="tabler-adjustments"
size="20"
/>
Formulierinstellingen
</VCardTitle>
<VCardSubtitle>
Bepaal welke vaste onderdelen in het registratieformulier worden getoond
</VCardSubtitle>
<VCardText>
<div class="d-flex flex-column gap-4">
<div class="d-flex align-center justify-space-between">
<div>
<p class="text-body-1 font-weight-medium mb-0">
Voorkeurssecties tonen
</p>
<p class="text-body-2 text-medium-emphasis mb-0">
Vrijwilligers kunnen secties kiezen en op prioriteit rangschikken
</p>
</div>
<VSwitch
v-model="showSectionPreferences"
hide-details
:loading="isUpdatingEvent"
@update:model-value="onToggleSectionPreferences"
/>
</div>
<VDivider />
<div class="d-flex align-center justify-space-between">
<div>
<p class="text-body-1 font-weight-medium mb-0">
Beschikbaarheid tonen
</p>
<p class="text-body-2 text-medium-emphasis mb-0">
Vrijwilligers kunnen hun beschikbare tijdsloten selecteren
</p>
</div>
<VSwitch
v-model="showAvailability"
hide-details
:loading="isUpdatingEvent"
@update:model-value="onToggleAvailability"
/>
</div>
</div>
</VCardText>
</VCard>
<!-- Section 2: Registratievelden -->
<div class="d-flex flex-wrap justify-space-between align-center gap-y-3 mb-4">
<div class="d-flex align-center gap-2">
<h5 class="text-h5">
Registratievelden
</h5>
<VChip
v-if="fieldCount > 0"
size="small"
variant="tonal"
>
{{ fieldCount }} {{ fieldCount === 1 ? 'veld' : 'velden' }}
</VChip>
</div>
<div class="d-flex flex-wrap gap-2">
<VBtn
variant="outlined"
prepend-icon="tabler-template"
@click="isTemplatePickerOpen = true"
>
Uit template
</VBtn>
<VBtn
prepend-icon="tabler-plus"
@click="openCreateDialog"
>
Nieuw veld
</VBtn>
<VBtn
variant="outlined"
color="secondary"
prepend-icon="tabler-download"
@click="isImportDialogOpen = true"
>
Importeer van event
</VBtn>
</div>
</div>
<!-- Loading -->
<VSkeletonLoader
v-if="isLoading"
type="card@3"
/>
<!-- Empty state -->
<VCard
v-else-if="!localFields.length"
class="text-center pa-8"
>
<VIcon
icon="tabler-forms"
size="48"
class="mb-4 text-disabled"
/>
<p class="text-body-1 text-disabled mb-2">
Nog geen registratievelden geconfigureerd.
</p>
<p class="text-body-2 text-disabled mb-0">
Voeg velden toe uit templates of maak een nieuw veld aan.
</p>
</VCard>
<!-- Sortable field list -->
<draggable
v-else
v-model="localFields"
item-key="id"
ghost-class="field-ghost"
chosen-class="field-chosen"
drag-class="field-drag"
handle=".drag-handle"
:animation="200"
:delay="100"
:delay-on-touch-only="true"
direction="vertical"
@end="onDragEnd"
>
<template #item="{ element }">
<RegistrationFieldCard
:field="element"
@edit="openEditDialog"
@delete="onDeleteConfirm"
/>
</template>
</draggable>
</VCol>
<VCol
cols="12"
md="4"
>
<!-- Info card -->
<VCard variant="tonal">
<VCardText>
<div class="d-flex align-center gap-2 mb-2">
<VIcon
icon="tabler-info-circle"
size="18"
/>
<span class="text-subtitle-2 font-weight-medium">Over registratievelden</span>
</div>
<p class="text-body-2 text-medium-emphasis mb-2">
Registratievelden bepalen welke informatie vrijwilligers invullen bij hun aanmelding.
</p>
<p class="text-body-2 text-medium-emphasis mb-0">
Versleep velden om de volgorde aan te passen. De volgorde bepaalt hoe het formulier wordt getoond.
</p>
</VCardText>
</VCard>
</VCol>
</VRow>
</div>
<!-- Create / Edit dialog -->
<RegistrationFieldFormDialog
ref="fieldFormRef"
v-model="isFieldDialogOpen"
:org-id="orgId"
:field="editingField"
:is-saving="isSaving"
@save="onSaveField"
/>
<!-- Template picker dialog -->
<TemplatePickerDialog
v-model="isTemplatePickerOpen"
:org-id="orgId"
:event-id="eventId"
:existing-fields="localFields"
@added="onTemplateAdded"
/>
<!-- Import from event dialog -->
<ImportFromEventDialog
v-model="isImportDialogOpen"
:org-id="orgId"
:event-id="eventId"
@imported="onImported"
/>
<!-- Delete confirmation -->
<VDialog
v-model="isDeleteDialogOpen"
max-width="400"
>
<VCard title="Veld verwijderen">
<VCardText>
Weet je zeker dat je <strong>{{ deletingField?.label }}</strong> wilt verwijderen?
Bestaande antwoorden blijven bewaard.
</VCardText>
<VCardActions>
<VSpacer />
<VBtn
variant="text"
@click="isDeleteDialogOpen = false"
>
Annuleren
</VBtn>
<VBtn
color="error"
:loading="isDeleting"
@click="onDeleteExecute"
>
Verwijderen
</VBtn>
</VCardActions>
</VCard>
</VDialog>
</EventTabsNav>
</template>
<style scoped>
.field-ghost {
opacity: 0.5;
background: rgb(var(--v-theme-primary), 0.05);
border-radius: 8px;
}
.field-chosen {
box-shadow: 0 4px 12px rgb(var(--v-theme-on-surface), 0.12);
}
</style>