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:
120
apps/app/src/components/event/ImportFromEventDialog.vue
Normal file
120
apps/app/src/components/event/ImportFromEventDialog.vue
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useEventList } from '@/composables/api/useEvents'
|
||||||
|
import { useImportFieldsFromEvent } from '@/composables/api/useRegistrationFormFields'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
orgId: string
|
||||||
|
eventId: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
imported: [count: number, eventName: string]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const modelValue = defineModel<boolean>({ default: false })
|
||||||
|
|
||||||
|
const orgIdRef = computed(() => props.orgId)
|
||||||
|
const eventIdRef = computed(() => props.eventId)
|
||||||
|
|
||||||
|
const { data: events, isLoading: isLoadingEvents } = useEventList(orgIdRef)
|
||||||
|
const { mutate: importFields, isPending } = useImportFieldsFromEvent(eventIdRef)
|
||||||
|
|
||||||
|
const selectedEventId = ref<string | null>(null)
|
||||||
|
|
||||||
|
const eventOptions = computed(() => {
|
||||||
|
if (!events.value) return []
|
||||||
|
|
||||||
|
const options: Array<{ title: string; value: string }> = []
|
||||||
|
|
||||||
|
for (const event of events.value) {
|
||||||
|
if (event.id !== props.eventId) {
|
||||||
|
options.push({ title: event.name, value: event.id })
|
||||||
|
}
|
||||||
|
// Also include children for festivals
|
||||||
|
if (event.children?.length) {
|
||||||
|
for (const child of event.children) {
|
||||||
|
if (child.id !== props.eventId) {
|
||||||
|
options.push({ title: `${event.name} > ${child.name}`, value: child.id })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return options
|
||||||
|
})
|
||||||
|
|
||||||
|
const selectedEventName = computed(() =>
|
||||||
|
eventOptions.value.find(e => e.value === selectedEventId.value)?.title ?? '',
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(modelValue, (open) => {
|
||||||
|
if (open) {
|
||||||
|
selectedEventId.value = null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function onImport() {
|
||||||
|
if (!selectedEventId.value) return
|
||||||
|
|
||||||
|
const name = selectedEventName.value
|
||||||
|
|
||||||
|
importFields(selectedEventId.value, {
|
||||||
|
onSuccess: (data) => {
|
||||||
|
const count = Array.isArray(data) ? data.length : 0
|
||||||
|
modelValue.value = false
|
||||||
|
emit('imported', count, name)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<VDialog
|
||||||
|
v-model="modelValue"
|
||||||
|
max-width="500"
|
||||||
|
>
|
||||||
|
<VCard title="Velden importeren van ander event">
|
||||||
|
<VCardText>
|
||||||
|
<p class="text-body-2 text-medium-emphasis mb-4">
|
||||||
|
Kopieer alle registratievelden van een ander event naar dit event. Bestaande velden blijven behouden.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<AppAutocomplete
|
||||||
|
v-model="selectedEventId"
|
||||||
|
label="Selecteer een event"
|
||||||
|
:items="eventOptions"
|
||||||
|
:loading="isLoadingEvents"
|
||||||
|
placeholder="Zoek een event..."
|
||||||
|
clearable
|
||||||
|
/>
|
||||||
|
|
||||||
|
<VAlert
|
||||||
|
v-if="selectedEventId"
|
||||||
|
type="info"
|
||||||
|
variant="tonal"
|
||||||
|
density="compact"
|
||||||
|
class="mt-4"
|
||||||
|
>
|
||||||
|
De registratievelden van <strong>{{ selectedEventName }}</strong> worden gekopieerd naar het huidige event.
|
||||||
|
</VAlert>
|
||||||
|
</VCardText>
|
||||||
|
<VCardActions>
|
||||||
|
<VSpacer />
|
||||||
|
<VBtn
|
||||||
|
variant="text"
|
||||||
|
@click="modelValue = false"
|
||||||
|
>
|
||||||
|
Annuleren
|
||||||
|
</VBtn>
|
||||||
|
<VBtn
|
||||||
|
color="primary"
|
||||||
|
:loading="isPending"
|
||||||
|
:disabled="!selectedEventId"
|
||||||
|
@click="onImport"
|
||||||
|
>
|
||||||
|
Importeren
|
||||||
|
</VBtn>
|
||||||
|
</VCardActions>
|
||||||
|
</VCard>
|
||||||
|
</VDialog>
|
||||||
|
</template>
|
||||||
156
apps/app/src/components/event/RegistrationFieldCard.vue
Normal file
156
apps/app/src/components/event/RegistrationFieldCard.vue
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { RegistrationFormField } from '@/types/registration-form-field'
|
||||||
|
import { FIELD_TYPE_LABELS } from '@/types/registration-field-template'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
field: RegistrationFormField
|
||||||
|
}>()
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
edit: [field: RegistrationFormField]
|
||||||
|
delete: [field: RegistrationFormField]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
function formatOptions(options: string[] | null): string {
|
||||||
|
if (!options?.length) return ''
|
||||||
|
if (options.length <= 5) return options.join(', ')
|
||||||
|
return `${options.slice(0, 5).join(', ')}, +${options.length - 5} meer`
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<VCard
|
||||||
|
variant="outlined"
|
||||||
|
class="mb-2"
|
||||||
|
>
|
||||||
|
<VCardText class="pa-4">
|
||||||
|
<div class="d-flex align-start gap-3">
|
||||||
|
<!-- Drag handle -->
|
||||||
|
<div class="drag-handle d-flex align-center pt-1 cursor-grab">
|
||||||
|
<VIcon
|
||||||
|
icon="tabler-grip-vertical"
|
||||||
|
size="20"
|
||||||
|
class="text-disabled"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<div class="flex-grow-1 min-width-0">
|
||||||
|
<div class="d-flex align-center justify-space-between gap-2 mb-1">
|
||||||
|
<span class="text-body-1 font-weight-medium">
|
||||||
|
{{ field.label }}
|
||||||
|
</span>
|
||||||
|
<VChip
|
||||||
|
size="small"
|
||||||
|
variant="tonal"
|
||||||
|
color="default"
|
||||||
|
>
|
||||||
|
{{ FIELD_TYPE_LABELS[field.field_type] ?? field.field_type }}
|
||||||
|
</VChip>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Section -->
|
||||||
|
<div
|
||||||
|
v-if="field.section"
|
||||||
|
class="text-body-2 text-medium-emphasis mb-1"
|
||||||
|
>
|
||||||
|
Sectie: {{ field.section }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Options preview -->
|
||||||
|
<div
|
||||||
|
v-if="field.options?.length"
|
||||||
|
class="text-body-2 text-medium-emphasis mb-1"
|
||||||
|
>
|
||||||
|
Opties: {{ formatOptions(field.options) }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tag category -->
|
||||||
|
<div
|
||||||
|
v-if="field.field_type === 'tag_picker' && field.tag_category"
|
||||||
|
class="text-body-2 text-medium-emphasis mb-1"
|
||||||
|
>
|
||||||
|
Categorie: {{ field.tag_category }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Help text -->
|
||||||
|
<div
|
||||||
|
v-if="field.help_text"
|
||||||
|
class="text-body-2 text-disabled mb-1 text-truncate"
|
||||||
|
>
|
||||||
|
{{ field.help_text }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Status badges -->
|
||||||
|
<div class="d-flex flex-wrap gap-1 mt-2">
|
||||||
|
<VChip
|
||||||
|
v-if="field.is_required"
|
||||||
|
size="x-small"
|
||||||
|
variant="tonal"
|
||||||
|
color="error"
|
||||||
|
>
|
||||||
|
Verplicht
|
||||||
|
</VChip>
|
||||||
|
<VChip
|
||||||
|
v-if="field.is_filterable"
|
||||||
|
size="x-small"
|
||||||
|
variant="tonal"
|
||||||
|
color="info"
|
||||||
|
>
|
||||||
|
Filterbaar
|
||||||
|
</VChip>
|
||||||
|
<VChip
|
||||||
|
v-if="field.is_portal_visible"
|
||||||
|
size="x-small"
|
||||||
|
variant="tonal"
|
||||||
|
color="success"
|
||||||
|
>
|
||||||
|
Zichtbaar
|
||||||
|
</VChip>
|
||||||
|
<VChip
|
||||||
|
v-if="field.is_admin_only"
|
||||||
|
size="x-small"
|
||||||
|
variant="tonal"
|
||||||
|
color="warning"
|
||||||
|
>
|
||||||
|
Admin-only
|
||||||
|
</VChip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
|
<div class="d-flex align-center gap-x-1 pt-1">
|
||||||
|
<VBtn
|
||||||
|
icon="tabler-edit"
|
||||||
|
variant="text"
|
||||||
|
size="small"
|
||||||
|
title="Bewerken"
|
||||||
|
@click="$emit('edit', field)"
|
||||||
|
/>
|
||||||
|
<VBtn
|
||||||
|
icon="tabler-trash"
|
||||||
|
variant="text"
|
||||||
|
size="small"
|
||||||
|
color="error"
|
||||||
|
title="Verwijderen"
|
||||||
|
@click="$emit('delete', field)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</VCardText>
|
||||||
|
</VCard>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.min-width-0 {
|
||||||
|
min-inline-size: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cursor-grab {
|
||||||
|
cursor: grab;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cursor-grab:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
304
apps/app/src/components/event/RegistrationFieldFormDialog.vue
Normal file
304
apps/app/src/components/event/RegistrationFieldFormDialog.vue
Normal file
@@ -0,0 +1,304 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { VForm } from 'vuetify/components/VForm'
|
||||||
|
import { usePersonTagCategories } from '@/composables/api/usePersonTags'
|
||||||
|
import { requiredValidator } from '@core/utils/validators'
|
||||||
|
import type { RegistrationFormField } from '@/types/registration-form-field'
|
||||||
|
import { FIELD_TYPE_LABELS, FIELD_TYPES_WITH_OPTIONS } from '@/types/registration-field-template'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
orgId: string
|
||||||
|
field?: RegistrationFormField | null
|
||||||
|
isSaving: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
save: [payload: Record<string, any>]
|
||||||
|
'update:modelValue': [value: boolean]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const modelValue = defineModel<boolean>({ default: false })
|
||||||
|
|
||||||
|
const orgIdRef = computed(() => props.orgId)
|
||||||
|
const { data: tagCategories } = usePersonTagCategories(orgIdRef)
|
||||||
|
|
||||||
|
const fieldTypeOptions = Object.entries(FIELD_TYPE_LABELS).map(([value, title]) => ({ title, value }))
|
||||||
|
|
||||||
|
const errors = ref<Record<string, string>>({})
|
||||||
|
const refVForm = ref<VForm>()
|
||||||
|
|
||||||
|
const defaultForm = () => ({
|
||||||
|
label: '',
|
||||||
|
field_type: 'text' as string,
|
||||||
|
options: [] as string[],
|
||||||
|
tag_category: null as string | null,
|
||||||
|
is_required: false,
|
||||||
|
is_filterable: false,
|
||||||
|
is_portal_visible: true,
|
||||||
|
is_admin_only: false,
|
||||||
|
section: '',
|
||||||
|
help_text: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const form = ref(defaultForm())
|
||||||
|
|
||||||
|
const dialogTitle = computed(() =>
|
||||||
|
props.field ? 'Veld bewerken' : 'Nieuw veld',
|
||||||
|
)
|
||||||
|
|
||||||
|
const showOptions = computed(() =>
|
||||||
|
FIELD_TYPES_WITH_OPTIONS.includes(form.value.field_type as any),
|
||||||
|
)
|
||||||
|
|
||||||
|
const showTagCategory = computed(() => form.value.field_type === 'tag_picker')
|
||||||
|
|
||||||
|
watch(modelValue, (open) => {
|
||||||
|
if (open) {
|
||||||
|
errors.value = {}
|
||||||
|
if (props.field) {
|
||||||
|
form.value = {
|
||||||
|
label: props.field.label,
|
||||||
|
field_type: props.field.field_type,
|
||||||
|
options: props.field.options ? [...props.field.options] : [],
|
||||||
|
tag_category: props.field.tag_category,
|
||||||
|
is_required: props.field.is_required,
|
||||||
|
is_filterable: props.field.is_filterable,
|
||||||
|
is_portal_visible: props.field.is_portal_visible,
|
||||||
|
is_admin_only: props.field.is_admin_only,
|
||||||
|
section: props.field.section ?? '',
|
||||||
|
help_text: props.field.help_text ?? '',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
form.value = defaultForm()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function addOption() {
|
||||||
|
form.value.options.push('')
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeOption(index: number) {
|
||||||
|
form.value.options.splice(index, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSubmit() {
|
||||||
|
refVForm.value?.validate().then(({ valid }) => {
|
||||||
|
if (!valid) return
|
||||||
|
|
||||||
|
errors.value = {}
|
||||||
|
const payload: Record<string, any> = {
|
||||||
|
label: form.value.label,
|
||||||
|
is_required: form.value.is_required,
|
||||||
|
is_filterable: form.value.is_filterable,
|
||||||
|
is_portal_visible: form.value.is_portal_visible,
|
||||||
|
is_admin_only: form.value.is_admin_only,
|
||||||
|
section: form.value.section || null,
|
||||||
|
help_text: form.value.help_text || null,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!props.field) {
|
||||||
|
payload.field_type = form.value.field_type
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showOptions.value) {
|
||||||
|
payload.options = form.value.options.filter(o => o.trim() !== '')
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
payload.options = null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showTagCategory.value) {
|
||||||
|
payload.tag_category = form.value.tag_category || null
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
payload.tag_category = null
|
||||||
|
}
|
||||||
|
|
||||||
|
emit('save', payload)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function setErrors(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 = { label: data.message }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ setErrors })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<VDialog
|
||||||
|
v-model="modelValue"
|
||||||
|
max-width="650"
|
||||||
|
>
|
||||||
|
<VCard :title="dialogTitle">
|
||||||
|
<VForm
|
||||||
|
ref="refVForm"
|
||||||
|
@submit.prevent="onSubmit"
|
||||||
|
>
|
||||||
|
<VCardText>
|
||||||
|
<VRow>
|
||||||
|
<VCol cols="12">
|
||||||
|
<AppTextField
|
||||||
|
v-model="form.label"
|
||||||
|
label="Label"
|
||||||
|
placeholder="Hoe heet dit veld?"
|
||||||
|
:rules="[requiredValidator]"
|
||||||
|
:error-messages="errors.label"
|
||||||
|
autofocus
|
||||||
|
autocomplete="one-time-code"
|
||||||
|
/>
|
||||||
|
</VCol>
|
||||||
|
<VCol cols="12">
|
||||||
|
<AppSelect
|
||||||
|
v-model="form.field_type"
|
||||||
|
label="Type"
|
||||||
|
:items="fieldTypeOptions"
|
||||||
|
:rules="[requiredValidator]"
|
||||||
|
:error-messages="errors.field_type"
|
||||||
|
:disabled="!!field"
|
||||||
|
:hint="field ? 'Type kan niet gewijzigd worden na aanmaken' : ''"
|
||||||
|
:persistent-hint="!!field"
|
||||||
|
/>
|
||||||
|
</VCol>
|
||||||
|
|
||||||
|
<!-- Options (conditional) -->
|
||||||
|
<VCol
|
||||||
|
v-if="showOptions"
|
||||||
|
cols="12"
|
||||||
|
>
|
||||||
|
<label class="text-body-2 mb-2 d-block">Opties</label>
|
||||||
|
<div
|
||||||
|
v-for="(_, index) in form.options"
|
||||||
|
:key="index"
|
||||||
|
class="d-flex align-center gap-x-2 mb-2"
|
||||||
|
>
|
||||||
|
<AppTextField
|
||||||
|
v-model="form.options[index]"
|
||||||
|
:placeholder="`Optie ${index + 1}`"
|
||||||
|
density="compact"
|
||||||
|
hide-details
|
||||||
|
/>
|
||||||
|
<VBtn
|
||||||
|
icon="tabler-x"
|
||||||
|
variant="text"
|
||||||
|
size="small"
|
||||||
|
color="error"
|
||||||
|
@click="removeOption(index)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<VBtn
|
||||||
|
variant="tonal"
|
||||||
|
size="small"
|
||||||
|
prepend-icon="tabler-plus"
|
||||||
|
@click="addOption"
|
||||||
|
>
|
||||||
|
Optie toevoegen
|
||||||
|
</VBtn>
|
||||||
|
<p
|
||||||
|
v-if="errors.options"
|
||||||
|
class="text-error text-caption mt-1"
|
||||||
|
>
|
||||||
|
{{ errors.options }}
|
||||||
|
</p>
|
||||||
|
</VCol>
|
||||||
|
|
||||||
|
<!-- Tag category (conditional) -->
|
||||||
|
<VCol
|
||||||
|
v-if="showTagCategory"
|
||||||
|
cols="12"
|
||||||
|
>
|
||||||
|
<AppAutocomplete
|
||||||
|
v-model="form.tag_category"
|
||||||
|
label="Tag-categorie"
|
||||||
|
:items="tagCategories ?? []"
|
||||||
|
clearable
|
||||||
|
:error-messages="errors.tag_category"
|
||||||
|
placeholder="Alle tags (laat leeg voor alle categorieën)"
|
||||||
|
/>
|
||||||
|
</VCol>
|
||||||
|
|
||||||
|
<VCol
|
||||||
|
cols="12"
|
||||||
|
md="6"
|
||||||
|
>
|
||||||
|
<AppTextField
|
||||||
|
v-model="form.section"
|
||||||
|
label="Sectie"
|
||||||
|
placeholder="bijv. Over jou"
|
||||||
|
:error-messages="errors.section"
|
||||||
|
/>
|
||||||
|
</VCol>
|
||||||
|
<VCol
|
||||||
|
cols="12"
|
||||||
|
md="6"
|
||||||
|
/>
|
||||||
|
<VCol cols="12">
|
||||||
|
<AppTextarea
|
||||||
|
v-model="form.help_text"
|
||||||
|
label="Helptekst"
|
||||||
|
placeholder="Toelichting die onder het veld wordt getoond"
|
||||||
|
:error-messages="errors.help_text"
|
||||||
|
rows="2"
|
||||||
|
/>
|
||||||
|
</VCol>
|
||||||
|
|
||||||
|
<!-- Toggles -->
|
||||||
|
<VCol cols="12">
|
||||||
|
<div class="d-flex flex-wrap gap-x-6 gap-y-2">
|
||||||
|
<VSwitch
|
||||||
|
v-model="form.is_required"
|
||||||
|
label="Verplicht"
|
||||||
|
hide-details
|
||||||
|
density="compact"
|
||||||
|
/>
|
||||||
|
<VSwitch
|
||||||
|
v-model="form.is_filterable"
|
||||||
|
label="Filterbaar bij inplannen"
|
||||||
|
hide-details
|
||||||
|
density="compact"
|
||||||
|
/>
|
||||||
|
<VSwitch
|
||||||
|
v-model="form.is_portal_visible"
|
||||||
|
label="Zichtbaar voor deelnemer"
|
||||||
|
hide-details
|
||||||
|
density="compact"
|
||||||
|
/>
|
||||||
|
<VSwitch
|
||||||
|
v-model="form.is_admin_only"
|
||||||
|
label="Alleen voor organisator"
|
||||||
|
hide-details
|
||||||
|
density="compact"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</VCol>
|
||||||
|
</VRow>
|
||||||
|
</VCardText>
|
||||||
|
<VCardActions>
|
||||||
|
<VSpacer />
|
||||||
|
<VBtn
|
||||||
|
variant="text"
|
||||||
|
@click="modelValue = false"
|
||||||
|
>
|
||||||
|
Annuleren
|
||||||
|
</VBtn>
|
||||||
|
<VBtn
|
||||||
|
type="submit"
|
||||||
|
color="primary"
|
||||||
|
:loading="isSaving"
|
||||||
|
>
|
||||||
|
Opslaan
|
||||||
|
</VBtn>
|
||||||
|
</VCardActions>
|
||||||
|
</VForm>
|
||||||
|
</VCard>
|
||||||
|
</VDialog>
|
||||||
|
</template>
|
||||||
141
apps/app/src/components/event/TemplatePickerDialog.vue
Normal file
141
apps/app/src/components/event/TemplatePickerDialog.vue
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useRegistrationFieldTemplates } from '@/composables/api/useRegistrationFieldTemplates'
|
||||||
|
import { useCreateFieldFromTemplate } from '@/composables/api/useRegistrationFormFields'
|
||||||
|
import { FIELD_TYPE_LABELS } from '@/types/registration-field-template'
|
||||||
|
import type { RegistrationFormField } from '@/types/registration-form-field'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
orgId: string
|
||||||
|
eventId: string
|
||||||
|
existingFields: RegistrationFormField[]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
added: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const modelValue = defineModel<boolean>({ default: false })
|
||||||
|
|
||||||
|
const orgIdRef = computed(() => props.orgId)
|
||||||
|
const eventIdRef = computed(() => props.eventId)
|
||||||
|
|
||||||
|
const { data: templates, isLoading } = useRegistrationFieldTemplates(orgIdRef)
|
||||||
|
const { mutate: createFromTemplate, isPending } = useCreateFieldFromTemplate(eventIdRef)
|
||||||
|
|
||||||
|
const existingSlugs = computed(() =>
|
||||||
|
new Set(props.existingFields.map(f => f.slug)),
|
||||||
|
)
|
||||||
|
|
||||||
|
const activeTemplates = computed(() =>
|
||||||
|
(templates.value ?? []).filter(t => t.is_active),
|
||||||
|
)
|
||||||
|
|
||||||
|
const addingTemplateId = ref<string | null>(null)
|
||||||
|
|
||||||
|
function isAlreadyAdded(slug: string): boolean {
|
||||||
|
return existingSlugs.value.has(slug)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onAdd(templateId: string) {
|
||||||
|
addingTemplateId.value = templateId
|
||||||
|
createFromTemplate(templateId, {
|
||||||
|
onSuccess: () => {
|
||||||
|
addingTemplateId.value = null
|
||||||
|
emit('added')
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
addingTemplateId.value = null
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<VDialog
|
||||||
|
v-model="modelValue"
|
||||||
|
max-width="600"
|
||||||
|
>
|
||||||
|
<VCard title="Veld uit template toevoegen">
|
||||||
|
<VCardText>
|
||||||
|
<VSkeletonLoader
|
||||||
|
v-if="isLoading"
|
||||||
|
type="list-item@3"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<template v-else-if="activeTemplates.length">
|
||||||
|
<VList density="compact">
|
||||||
|
<VListItem
|
||||||
|
v-for="template in activeTemplates"
|
||||||
|
:key="template.id"
|
||||||
|
:disabled="isAlreadyAdded(template.slug)"
|
||||||
|
class="rounded mb-1"
|
||||||
|
:class="{ 'opacity-50': isAlreadyAdded(template.slug) }"
|
||||||
|
>
|
||||||
|
<template #prepend>
|
||||||
|
<VIcon
|
||||||
|
:icon="isAlreadyAdded(template.slug) ? 'tabler-check' : 'tabler-forms'"
|
||||||
|
size="20"
|
||||||
|
:color="isAlreadyAdded(template.slug) ? 'success' : undefined"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<VListItemTitle>
|
||||||
|
{{ template.label }}
|
||||||
|
<span
|
||||||
|
v-if="isAlreadyAdded(template.slug)"
|
||||||
|
class="text-caption text-disabled ms-2"
|
||||||
|
>(al toegevoegd)</span>
|
||||||
|
</VListItemTitle>
|
||||||
|
<VListItemSubtitle>
|
||||||
|
{{ FIELD_TYPE_LABELS[template.field_type] ?? template.field_type }}
|
||||||
|
<template v-if="template.section">
|
||||||
|
· {{ template.section }}
|
||||||
|
</template>
|
||||||
|
<template v-if="template.is_required">
|
||||||
|
· Verplicht
|
||||||
|
</template>
|
||||||
|
</VListItemSubtitle>
|
||||||
|
|
||||||
|
<template #append>
|
||||||
|
<VBtn
|
||||||
|
v-if="!isAlreadyAdded(template.slug)"
|
||||||
|
size="small"
|
||||||
|
variant="tonal"
|
||||||
|
color="primary"
|
||||||
|
:loading="isPending && addingTemplateId === template.id"
|
||||||
|
:disabled="isPending"
|
||||||
|
@click="onAdd(template.id)"
|
||||||
|
>
|
||||||
|
Toevoegen
|
||||||
|
</VBtn>
|
||||||
|
</template>
|
||||||
|
</VListItem>
|
||||||
|
</VList>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="text-center pa-4"
|
||||||
|
>
|
||||||
|
<VIcon
|
||||||
|
icon="tabler-forms"
|
||||||
|
size="40"
|
||||||
|
class="mb-3 text-disabled"
|
||||||
|
/>
|
||||||
|
<p class="text-body-2 text-disabled mb-0">
|
||||||
|
Geen actieve templates beschikbaar. Maak eerst templates aan in de organisatie-instellingen.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</VCardText>
|
||||||
|
<VCardActions>
|
||||||
|
<VSpacer />
|
||||||
|
<VBtn
|
||||||
|
variant="text"
|
||||||
|
@click="modelValue = false"
|
||||||
|
>
|
||||||
|
Sluiten
|
||||||
|
</VBtn>
|
||||||
|
</VCardActions>
|
||||||
|
</VCard>
|
||||||
|
</VDialog>
|
||||||
|
</template>
|
||||||
144
apps/app/src/composables/api/useRegistrationFormFields.ts
Normal file
144
apps/app/src/composables/api/useRegistrationFormFields.ts
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
|
||||||
|
import type { Ref } from 'vue'
|
||||||
|
import { apiClient } from '@/lib/axios'
|
||||||
|
import type {
|
||||||
|
RegistrationFormField,
|
||||||
|
RegistrationFormFieldCreateDTO,
|
||||||
|
RegistrationFormFieldUpdateDTO,
|
||||||
|
} from '@/types/registration-form-field'
|
||||||
|
|
||||||
|
interface ApiResponse<T> {
|
||||||
|
success: boolean
|
||||||
|
data: T
|
||||||
|
message?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRegistrationFormFields(eventId: Ref<string>) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['registration-form-fields', eventId],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data } = await apiClient.get<{ data: RegistrationFormField[] }>(
|
||||||
|
`/events/${eventId.value}/registration-fields`,
|
||||||
|
)
|
||||||
|
|
||||||
|
return data.data
|
||||||
|
},
|
||||||
|
enabled: () => !!eventId.value,
|
||||||
|
staleTime: Infinity,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreateRegistrationFormField(eventId: Ref<string>) {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (payload: RegistrationFormFieldCreateDTO) => {
|
||||||
|
const { data } = await apiClient.post<ApiResponse<RegistrationFormField>>(
|
||||||
|
`/events/${eventId.value}/registration-fields`,
|
||||||
|
payload,
|
||||||
|
)
|
||||||
|
|
||||||
|
return data.data
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['registration-form-fields', eventId] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateRegistrationFormField(eventId: Ref<string>) {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async ({ id, ...payload }: RegistrationFormFieldUpdateDTO & { id: string }) => {
|
||||||
|
const { data } = await apiClient.put<ApiResponse<RegistrationFormField>>(
|
||||||
|
`/events/${eventId.value}/registration-fields/${id}`,
|
||||||
|
payload,
|
||||||
|
)
|
||||||
|
|
||||||
|
return data.data
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['registration-form-fields', eventId] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeleteRegistrationFormField(eventId: Ref<string>) {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (id: string) => {
|
||||||
|
await apiClient.delete(`/events/${eventId.value}/registration-fields/${id}`)
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['registration-form-fields', eventId] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useReorderRegistrationFormFields(eventId: Ref<string>) {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
let previousFields: RegistrationFormField[] | undefined
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (orderedIds: string[]) => {
|
||||||
|
await apiClient.post(`/events/${eventId.value}/registration-fields/reorder`, {
|
||||||
|
ids: orderedIds,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
onMutate: async (orderedIds) => {
|
||||||
|
await queryClient.cancelQueries({ queryKey: ['registration-form-fields', eventId.value] })
|
||||||
|
previousFields = queryClient.getQueryData<RegistrationFormField[]>(['registration-form-fields', eventId.value])
|
||||||
|
|
||||||
|
if (previousFields) {
|
||||||
|
const byId = new Map(previousFields.map(f => [f.id, f]))
|
||||||
|
const reordered = orderedIds
|
||||||
|
.map(id => byId.get(id))
|
||||||
|
.filter((f): f is RegistrationFormField => !!f)
|
||||||
|
queryClient.setQueryData(['registration-form-fields', eventId.value], reordered)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
if (previousFields) {
|
||||||
|
queryClient.setQueryData(['registration-form-fields', eventId.value], previousFields)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreateFieldFromTemplate(eventId: Ref<string>) {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (templateId: string) => {
|
||||||
|
const { data } = await apiClient.post<ApiResponse<RegistrationFormField>>(
|
||||||
|
`/events/${eventId.value}/registration-fields/from-template`,
|
||||||
|
{ template_id: templateId },
|
||||||
|
)
|
||||||
|
|
||||||
|
return data.data
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['registration-form-fields', eventId] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useImportFieldsFromEvent(eventId: Ref<string>) {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (sourceEventId: string) => {
|
||||||
|
const { data } = await apiClient.post<ApiResponse<RegistrationFormField[]>>(
|
||||||
|
`/events/${eventId.value}/registration-fields/import-from-event`,
|
||||||
|
{ source_event_id: sourceEventId },
|
||||||
|
)
|
||||||
|
|
||||||
|
return data.data
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['registration-form-fields', eventId] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -17,6 +17,12 @@ const authStore = useAuthStore()
|
|||||||
const orgId = computed(() => authStore.currentOrganisation?.id ?? '')
|
const orgId = computed(() => authStore.currentOrganisation?.id ?? '')
|
||||||
const eventId = computed(() => String((route.params as { id: string }).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: updateEvent, isPending: isUpdating } = useUpdateEvent(orgId, eventId)
|
||||||
const { mutate: uploadImage, isPending: isUploading } = useUploadEventImage(orgId, eventId)
|
const { mutate: uploadImage, isPending: isUploading } = useUploadEventImage(orgId, eventId)
|
||||||
|
|
||||||
@@ -56,6 +62,27 @@ function onClearImage(event: EventItem, type: 'banner' | 'logo') {
|
|||||||
<template>
|
<template>
|
||||||
<EventTabsNav v-slot="{ event }">
|
<EventTabsNav v-slot="{ event }">
|
||||||
<div @vue:mounted="initForm(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>
|
<VRow>
|
||||||
<VCol
|
<VCol
|
||||||
cols="12"
|
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>
|
||||||
@@ -25,6 +25,8 @@ export interface EventItem {
|
|||||||
registration_banner_url: string | null
|
registration_banner_url: string | null
|
||||||
registration_welcome_text: string | null
|
registration_welcome_text: string | null
|
||||||
registration_logo_url: string | null
|
registration_logo_url: string | null
|
||||||
|
registration_show_section_preferences: boolean
|
||||||
|
registration_show_availability: boolean
|
||||||
is_festival: boolean
|
is_festival: boolean
|
||||||
is_sub_event: boolean
|
is_sub_event: boolean
|
||||||
is_flat_event: boolean
|
is_flat_event: boolean
|
||||||
@@ -53,6 +55,8 @@ export interface CreateEventPayload {
|
|||||||
export interface UpdateEventPayload extends Partial<CreateEventPayload> {
|
export interface UpdateEventPayload extends Partial<CreateEventPayload> {
|
||||||
status?: EventStatus
|
status?: EventStatus
|
||||||
registration_welcome_text?: string | null
|
registration_welcome_text?: string | null
|
||||||
|
registration_show_section_preferences?: boolean
|
||||||
|
registration_show_availability?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EventStats {
|
export interface EventStats {
|
||||||
|
|||||||
37
apps/app/src/types/registration-form-field.ts
Normal file
37
apps/app/src/types/registration-form-field.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import type { RegistrationFieldType } from './registration-field-template'
|
||||||
|
|
||||||
|
export interface RegistrationFormField {
|
||||||
|
id: string
|
||||||
|
event_id: string
|
||||||
|
label: string
|
||||||
|
slug: string
|
||||||
|
field_type: RegistrationFieldType
|
||||||
|
options: string[] | null
|
||||||
|
tag_category: string | null
|
||||||
|
is_required: boolean
|
||||||
|
is_portal_visible: boolean
|
||||||
|
is_admin_only: boolean
|
||||||
|
is_filterable: boolean
|
||||||
|
section: string | null
|
||||||
|
help_text: string | null
|
||||||
|
sort_order: number
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
available_tags?: Array<{ id: string; name: string; category: string | null }>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RegistrationFormFieldCreateDTO {
|
||||||
|
label: string
|
||||||
|
field_type: RegistrationFieldType
|
||||||
|
options?: string[] | null
|
||||||
|
tag_category?: string | null
|
||||||
|
is_required?: boolean
|
||||||
|
is_portal_visible?: boolean
|
||||||
|
is_admin_only?: boolean
|
||||||
|
is_filterable?: boolean
|
||||||
|
section?: string | null
|
||||||
|
help_text?: string | null
|
||||||
|
sort_order?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RegistrationFormFieldUpdateDTO extends Partial<Omit<RegistrationFormFieldCreateDTO, 'field_type'>> {}
|
||||||
2
apps/app/typed-router.d.ts
vendored
2
apps/app/typed-router.d.ts
vendored
@@ -30,12 +30,14 @@ declare module 'vue-router/auto-routes' {
|
|||||||
'events-id-programmaonderdelen': RouteRecordInfo<'events-id-programmaonderdelen', '/events/:id/programmaonderdelen', { id: ParamValue<true> }, { id: ParamValue<false> }>,
|
'events-id-programmaonderdelen': RouteRecordInfo<'events-id-programmaonderdelen', '/events/:id/programmaonderdelen', { id: ParamValue<true> }, { id: ParamValue<false> }>,
|
||||||
'events-id-sections': RouteRecordInfo<'events-id-sections', '/events/:id/sections', { id: ParamValue<true> }, { id: ParamValue<false> }>,
|
'events-id-sections': RouteRecordInfo<'events-id-sections', '/events/:id/sections', { id: ParamValue<true> }, { id: ParamValue<false> }>,
|
||||||
'events-id-settings': RouteRecordInfo<'events-id-settings', '/events/:id/settings', { id: ParamValue<true> }, { id: ParamValue<false> }>,
|
'events-id-settings': RouteRecordInfo<'events-id-settings', '/events/:id/settings', { id: ParamValue<true> }, { id: ParamValue<false> }>,
|
||||||
|
'events-id-settings-registration-fields': RouteRecordInfo<'events-id-settings-registration-fields', '/events/:id/settings/registration-fields', { id: ParamValue<true> }, { id: ParamValue<false> }>,
|
||||||
'events-id-time-slots': RouteRecordInfo<'events-id-time-slots', '/events/:id/time-slots', { id: ParamValue<true> }, { id: ParamValue<false> }>,
|
'events-id-time-slots': RouteRecordInfo<'events-id-time-slots', '/events/:id/time-slots', { id: ParamValue<true> }, { id: ParamValue<false> }>,
|
||||||
'invitations-token': RouteRecordInfo<'invitations-token', '/invitations/:token', { token: ParamValue<true> }, { token: ParamValue<false> }>,
|
'invitations-token': RouteRecordInfo<'invitations-token', '/invitations/:token', { token: ParamValue<true> }, { token: ParamValue<false> }>,
|
||||||
'login': RouteRecordInfo<'login', '/login', Record<never, never>, Record<never, never>>,
|
'login': RouteRecordInfo<'login', '/login', Record<never, never>, Record<never, never>>,
|
||||||
'organisation': RouteRecordInfo<'organisation', '/organisation', Record<never, never>, Record<never, never>>,
|
'organisation': RouteRecordInfo<'organisation', '/organisation', Record<never, never>, Record<never, never>>,
|
||||||
'organisation-companies': RouteRecordInfo<'organisation-companies', '/organisation/companies', Record<never, never>, Record<never, never>>,
|
'organisation-companies': RouteRecordInfo<'organisation-companies', '/organisation/companies', Record<never, never>, Record<never, never>>,
|
||||||
'organisation-members': RouteRecordInfo<'organisation-members', '/organisation/members', Record<never, never>, Record<never, never>>,
|
'organisation-members': RouteRecordInfo<'organisation-members', '/organisation/members', Record<never, never>, Record<never, never>>,
|
||||||
|
'organisation-settings': RouteRecordInfo<'organisation-settings', '/organisation/settings', Record<never, never>, Record<never, never>>,
|
||||||
'select-organisation': RouteRecordInfo<'select-organisation', '/select-organisation', Record<never, never>, Record<never, never>>,
|
'select-organisation': RouteRecordInfo<'select-organisation', '/select-organisation', Record<never, never>, Record<never, never>>,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user