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:
@@ -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"
|
||||
|
||||
441
apps/app/src/pages/events/[id]/settings/registration-fields.vue
Normal file
441
apps/app/src/pages/events/[id]/settings/registration-fields.vue
Normal 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>
|
||||
Reference in New Issue
Block a user