Replace the per-field `section` text property with a dedicated HEADING field type that organizers add as a separate block for visual grouping. Also fixes duplicate heading bug on portal radio fields, replaces cramped VBtnToggle with VSelect for field width, and adds grouped field type dropdown with structure/input categories. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
397 lines
13 KiB
Vue
397 lines
13 KiB
Vue
<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, RegistrationFormFieldCreateDTO } from '@/types/registration-form-field'
|
|
import type { RegistrationFieldType, FieldDisplayWidth } from '@/types/registration-field-template'
|
|
import { FIELD_TYPES_WITH_OPTIONS } from '@/types/registration-field-template'
|
|
import type { AxiosError } from 'axios'
|
|
import type { ApiErrorResponse } from '@/types/auth'
|
|
|
|
const props = defineProps<{
|
|
orgId: string
|
|
field?: RegistrationFormField | null
|
|
isSaving: boolean
|
|
}>()
|
|
|
|
const emit = defineEmits<{
|
|
save: [payload: Partial<RegistrationFormFieldCreateDTO>]
|
|
'update:modelValue': [value: boolean]
|
|
}>()
|
|
|
|
const modelValue = defineModel<boolean>({ default: false })
|
|
|
|
const orgIdRef = computed(() => props.orgId)
|
|
const { data: tagCategories } = usePersonTagCategories(orgIdRef)
|
|
|
|
const fieldTypeItems = [
|
|
{ type: 'subheader', title: 'Structuur' },
|
|
{ title: 'Kop / Sectie-titel', value: 'heading', props: { prependIcon: 'tabler-heading' } },
|
|
{ type: 'divider' },
|
|
{ type: 'subheader', title: 'Invoervelden' },
|
|
{ title: 'Tekstveld', value: 'text', props: { prependIcon: 'tabler-letter-t' } },
|
|
{ title: 'Tekstvak (groot)', value: 'textarea', props: { prependIcon: 'tabler-text-size' } },
|
|
{ title: 'Getal', value: 'number', props: { prependIcon: 'tabler-hash' } },
|
|
{ title: 'Toggle (ja/nee)', value: 'boolean', props: { prependIcon: 'tabler-toggle-left' } },
|
|
{ title: 'Dropdown', value: 'select', props: { prependIcon: 'tabler-list' } },
|
|
{ title: 'Meervoudige keuze', value: 'multiselect', props: { prependIcon: 'tabler-checklist' } },
|
|
{ title: 'Keuzerondjes', value: 'radio', props: { prependIcon: 'tabler-circle-dot' } },
|
|
{ title: 'Checkbox', value: 'checkbox', props: { prependIcon: 'tabler-checkbox' } },
|
|
{ title: 'Tags & Vaardigheden', value: 'tag_picker', props: { prependIcon: 'tabler-tags' } },
|
|
]
|
|
|
|
const errors = ref<Record<string, string>>({})
|
|
const refVForm = ref<VForm>()
|
|
|
|
interface OptionEntry {
|
|
label: string
|
|
description: string
|
|
}
|
|
|
|
const defaultForm = () => ({
|
|
label: '',
|
|
field_type: 'text' as string,
|
|
options: [] as OptionEntry[],
|
|
tag_category: null as string | null,
|
|
is_required: false,
|
|
is_filterable: false,
|
|
is_portal_visible: true,
|
|
is_admin_only: false,
|
|
help_text: '',
|
|
display_width: 'full' as FieldDisplayWidth,
|
|
})
|
|
|
|
const form = ref(defaultForm())
|
|
|
|
const dialogTitle = computed(() =>
|
|
props.field ? 'Veld bewerken' : 'Nieuw veld',
|
|
)
|
|
|
|
const isHeading = computed(() => form.value.field_type === 'heading')
|
|
|
|
const showOptions = computed(() =>
|
|
(FIELD_TYPES_WITH_OPTIONS as readonly string[]).includes(form.value.field_type),
|
|
)
|
|
|
|
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.normalized_options
|
|
? props.field.normalized_options.map(o => ({ label: o.label, description: o.description ?? '' }))
|
|
: [],
|
|
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,
|
|
help_text: props.field.help_text ?? '',
|
|
display_width: props.field.display_width ?? 'full',
|
|
}
|
|
}
|
|
else {
|
|
form.value = defaultForm()
|
|
}
|
|
}
|
|
})
|
|
|
|
function addOption() {
|
|
form.value.options.push({ label: '', description: '' })
|
|
}
|
|
|
|
function removeOption(index: number) {
|
|
form.value.options.splice(index, 1)
|
|
}
|
|
|
|
function onSubmit() {
|
|
refVForm.value?.validate().then(({ valid }) => {
|
|
if (!valid) return
|
|
|
|
errors.value = {}
|
|
const payload: Partial<RegistrationFormFieldCreateDTO> = {
|
|
label: form.value.label,
|
|
help_text: form.value.help_text || null,
|
|
display_width: form.value.display_width,
|
|
}
|
|
|
|
if (!props.field) {
|
|
payload.field_type = form.value.field_type as RegistrationFieldType
|
|
}
|
|
|
|
if (!isHeading.value) {
|
|
payload.is_required = form.value.is_required
|
|
payload.is_filterable = form.value.is_filterable
|
|
payload.is_portal_visible = form.value.is_portal_visible
|
|
payload.is_admin_only = form.value.is_admin_only
|
|
|
|
if (showOptions.value) {
|
|
payload.options = form.value.options
|
|
.filter(o => o.label.trim() !== '')
|
|
.map(o => o.description.trim()
|
|
? { label: o.label, description: o.description }
|
|
: { label: o.label },
|
|
)
|
|
}
|
|
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: Error) {
|
|
const data = (err as AxiosError<ApiErrorResponse>).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">
|
|
<AppSelect
|
|
v-model="form.field_type"
|
|
label="Type"
|
|
:items="fieldTypeItems"
|
|
:rules="[requiredValidator]"
|
|
:error-messages="errors.field_type"
|
|
:disabled="!!field"
|
|
:hint="field ? 'Type kan niet gewijzigd worden na aanmaken' : ''"
|
|
:persistent-hint="!!field"
|
|
/>
|
|
</VCol>
|
|
|
|
<!-- HEADING fields: simplified UI -->
|
|
<template v-if="isHeading">
|
|
<VCol cols="12">
|
|
<AppTextField
|
|
v-model="form.label"
|
|
label="Kop tekst"
|
|
placeholder="bijv. Persoonlijke gegevens"
|
|
:rules="[requiredValidator]"
|
|
:error-messages="errors.label"
|
|
autofocus
|
|
autocomplete="one-time-code"
|
|
/>
|
|
</VCol>
|
|
<VCol cols="12">
|
|
<AppTextField
|
|
v-model="form.help_text"
|
|
label="Omschrijving (optioneel)"
|
|
placeholder="Korte toelichting onder de kop"
|
|
:error-messages="errors.help_text"
|
|
hint="Verschijnt in kleinere tekst onder de kop"
|
|
persistent-hint
|
|
/>
|
|
</VCol>
|
|
<VCol cols="12">
|
|
<VAlert
|
|
type="info"
|
|
variant="tonal"
|
|
density="compact"
|
|
icon="tabler-info-circle"
|
|
>
|
|
Een kop groepeert de velden hieronder visueel. Het verzamelt geen antwoorden.
|
|
</VAlert>
|
|
</VCol>
|
|
</template>
|
|
|
|
<!-- Regular input fields: full UI -->
|
|
<template v-else>
|
|
<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>
|
|
|
|
<!-- Options (conditional) -->
|
|
<VCol
|
|
v-if="showOptions"
|
|
cols="12"
|
|
>
|
|
<label class="text-body-2 mb-2 d-block">Opties</label>
|
|
<div
|
|
v-for="(option, index) in form.options"
|
|
:key="index"
|
|
class="mb-3"
|
|
>
|
|
<VCard
|
|
variant="outlined"
|
|
class="pa-3 position-relative"
|
|
>
|
|
<VRow dense>
|
|
<VCol cols="12">
|
|
<AppTextField
|
|
v-model="option.label"
|
|
:placeholder="`Optie ${index + 1}`"
|
|
density="compact"
|
|
hide-details
|
|
/>
|
|
</VCol>
|
|
<VCol cols="12">
|
|
<AppTextField
|
|
v-model="option.description"
|
|
label="Beschrijving (optioneel)"
|
|
density="compact"
|
|
placeholder="Korte toelichting die onder de optie verschijnt"
|
|
hint="Max 200 tekens"
|
|
counter="200"
|
|
maxlength="200"
|
|
/>
|
|
</VCol>
|
|
</VRow>
|
|
<VBtn
|
|
icon="tabler-x"
|
|
variant="text"
|
|
color="error"
|
|
size="small"
|
|
class="position-absolute"
|
|
style="inset-block-start: 4px; inset-inline-end: 4px"
|
|
@click="removeOption(index)"
|
|
/>
|
|
</VCard>
|
|
</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">
|
|
<AppSelect
|
|
v-model="form.display_width"
|
|
:items="[
|
|
{ title: 'Volledige breedte', value: 'full' },
|
|
{ title: 'Halve breedte', value: 'half' },
|
|
]"
|
|
label="Veldbreedte"
|
|
:hint="form.display_width === 'half' ? 'Wordt naast een ander half-breed veld geplaatst' : ''"
|
|
persistent-hint
|
|
/>
|
|
</VCol>
|
|
<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>
|
|
</template>
|
|
</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>
|