feat: registration field polish, multi-category tags, file uploads, Partner icon

- Restructure field editor dialog: move Options section to bottom with
  divider and subheader, fix delete button with flex layout
- Change tag_category (single string) to tag_categories (JSON array)
  supporting multiple category selection in tag picker fields
- Portal tag picker now groups tags by category with subheaders
- Add generic file upload endpoint (FileUploadService + UploadController)
- Replace email branding logo URL text field with ImageUploadField
- Update Partner crowd type default icon to tabler-affiliate
- Apply changes consistently to both field and template dialogs

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-16 18:03:49 +02:00
parent d57dcdb616
commit 6a8d21a5b6
31 changed files with 813 additions and 239 deletions

View File

@@ -0,0 +1,131 @@
<script setup lang="ts">
import { apiClient } from '@/lib/axios'
interface Props {
modelValue: string | null
label?: string
purpose: 'logo' | 'banner' | 'icon' | 'avatar'
hint?: string
previewHeight?: number
}
const props = withDefaults(defineProps<Props>(), {
label: undefined,
hint: undefined,
previewHeight: 120,
})
const emit = defineEmits<{
'update:modelValue': [url: string | null]
}>()
const fileInput = ref<HTMLInputElement | null>(null)
const uploading = ref(false)
const error = ref('')
function triggerFileInput() {
fileInput.value?.click()
}
async function onFileSelected(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
if (!file) return
uploading.value = true
error.value = ''
try {
const formData = new FormData()
formData.append('file', file)
formData.append('purpose', props.purpose)
const { data } = await apiClient.post<{ data: { url: string } }>('/upload/image', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
})
emit('update:modelValue', data.data.url)
}
catch (e: unknown) {
const axiosError = e as { response?: { data?: { message?: string } } }
error.value = axiosError.response?.data?.message ?? 'Upload mislukt'
}
finally {
uploading.value = false
if (input) input.value = ''
}
}
function remove() {
emit('update:modelValue', null)
error.value = ''
}
</script>
<template>
<div>
<label
v-if="label"
class="text-body-2 text-medium-emphasis d-block mb-1"
>
{{ label }}
</label>
<div
v-if="modelValue"
class="mb-2"
>
<VImg
:src="modelValue"
:max-height="previewHeight"
max-width="300"
class="rounded border"
cover
/>
</div>
<div class="d-flex align-center gap-2">
<VBtn
:variant="modelValue ? 'tonal' : 'elevated'"
color="primary"
size="small"
prepend-icon="tabler-upload"
:loading="uploading"
@click="triggerFileInput"
>
{{ modelValue ? 'Vervangen' : 'Uploaden' }}
</VBtn>
<VBtn
v-if="modelValue"
variant="text"
color="error"
size="small"
prepend-icon="tabler-trash"
@click="remove"
>
Verwijderen
</VBtn>
</div>
<input
ref="fileInput"
type="file"
accept="image/*"
style="display: none;"
@change="onFileSelected"
>
<div
v-if="error"
class="text-caption text-error mt-1"
>
{{ error }}
</div>
<div
v-else-if="hint"
class="text-caption text-medium-emphasis mt-1"
>
{{ hint }}
</div>
</div>
</template>

View File

@@ -107,12 +107,12 @@ function formatOptions(options: NormalizedOption[] | null): string {
Opties: {{ formatOptions(field.normalized_options) }}
</div>
<!-- Tag category -->
<!-- Tag categories -->
<div
v-if="field.field_type === 'tag_picker' && field.tag_category"
v-if="field.field_type === 'tag_picker' && field.tag_categories?.length"
class="text-body-2 text-medium-emphasis mb-1"
>
Categorie: {{ field.tag_category }}
Categorieën: {{ field.tag_categories.join(', ') }}
</div>
<!-- Help text -->

View File

@@ -52,7 +52,7 @@ const defaultForm = () => ({
label: '',
field_type: 'text' as string,
options: [] as OptionEntry[],
tag_category: null as string | null,
tag_categories: [] as string[],
is_required: false,
is_filterable: false,
is_portal_visible: true,
@@ -85,7 +85,7 @@ watch(modelValue, (open) => {
options: props.field.normalized_options
? props.field.normalized_options.map(o => ({ label: o.label, description: o.description ?? '' }))
: [],
tag_category: props.field.tag_category,
tag_categories: props.field.tag_categories ?? [],
is_required: props.field.is_required,
is_filterable: props.field.is_filterable,
is_portal_visible: props.field.is_portal_visible,
@@ -142,10 +142,10 @@ function onSubmit() {
}
if (showTagCategory.value) {
payload.tag_category = form.value.tag_category || null
payload.tag_categories = form.value.tag_categories.length > 0 ? form.value.tag_categories : null
}
else {
payload.tag_category = null
payload.tag_categories = null
}
}
@@ -242,81 +242,13 @@ defineExpose({ setErrors })
/>
</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 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>
@@ -332,15 +264,6 @@ defineExpose({ setErrors })
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">
@@ -371,6 +294,118 @@ defineExpose({ setErrors })
/>
</div>
</VCol>
<!-- Options section at bottom with visual separation -->
<template v-if="showOptions">
<VCol cols="12">
<VDivider class="mb-1" />
</VCol>
<VCol cols="12">
<div class="d-flex align-center justify-space-between mb-3">
<div>
<div class="text-subtitle-2">
Opties
</div>
<div class="text-caption text-medium-emphasis">
Waaruit kan de deelnemer kiezen?
</div>
</div>
<VBtn
variant="tonal"
size="small"
prepend-icon="tabler-plus"
@click="addOption"
>
Optie toevoegen
</VBtn>
</div>
<div
v-for="(option, index) in form.options"
:key="index"
class="mb-3"
>
<VCard
variant="outlined"
class="pa-3"
>
<div class="d-flex align-start gap-2">
<VIcon
icon="tabler-grip-vertical"
class="mt-2 text-medium-emphasis"
style="cursor: grab;"
/>
<div class="flex-grow-1">
<AppTextField
v-model="option.label"
:placeholder="`Optie ${index + 1}`"
density="compact"
hide-details="auto"
class="mb-2"
/>
<AppTextField
v-model="option.description"
label="Beschrijving (optioneel)"
density="compact"
placeholder="Korte toelichting die onder de optie verschijnt"
counter="200"
maxlength="200"
hide-details="auto"
/>
</div>
<VBtn
icon="tabler-trash"
variant="text"
color="error"
size="small"
class="mt-1"
@click="removeOption(index)"
/>
</div>
</VCard>
</div>
<div
v-if="form.options.length === 0"
class="text-center text-medium-emphasis py-4"
>
Nog geen opties. Klik op "Optie toevoegen" om te beginnen.
</div>
<p
v-if="errors.options"
class="text-error text-caption mt-1"
>
{{ errors.options }}
</p>
</VCol>
</template>
<!-- Tag categories (conditional) -->
<template v-if="showTagCategory">
<VCol cols="12">
<VDivider class="mb-1" />
</VCol>
<VCol cols="12">
<div class="text-subtitle-2 mb-1">
Tag-categorieën
</div>
<div class="text-caption text-medium-emphasis mb-3">
Kies één of meerdere categorieën. De deelnemer ziet alle tags uit deze categorieën.
</div>
<AppAutocomplete
v-model="form.tag_categories"
label="Categorieën"
:items="tagCategories ?? []"
multiple
chips
closable-chips
clearable
:error-messages="errors.tag_categories"
placeholder="Alle tags (laat leeg voor alle categorieën)"
/>
</VCol>
</template>
</template>
</VRow>
</VCardText>

View File

@@ -2,6 +2,7 @@
import { VForm } from 'vuetify/components/VForm'
import { useEmailSettings, useUpdateEmailSettings } from '@/composables/api/useEmail'
import { emailValidator } from '@core/utils/validators'
import ImageUploadField from '@/components/common/ImageUploadField.vue'
import type { AxiosError } from 'axios'
import type { ApiErrorResponse } from '@/types/auth'
@@ -116,34 +117,23 @@ function fieldErrors(field: string): string | undefined {
@submit.prevent="onSubmit"
>
<VRow>
<!-- Logo URL -->
<!-- Logo -->
<VCol
cols="12"
md="8"
>
<VTextField
<ImageUploadField
v-model="logoUrl"
label="Logo URL"
hint="Gebruik een URL naar je logo (PNG of SVG, max 200px hoog)"
persistent-hint
:error-messages="fieldErrors('logo_url')"
label="Logo"
purpose="logo"
hint="PNG, JPG, SVG of WebP. Max 5MB."
:preview-height="80"
/>
<div
v-if="logoUrl"
class="mt-2"
>
<img
:src="logoUrl"
style="max-height: 48px"
@error="($event.target as HTMLImageElement).style.display = 'none'"
@load="($event.target as HTMLImageElement).style.display = 'block'"
>
</div>
<p
v-else
class="text-caption text-disabled mt-2"
v-if="fieldErrors('logo_url')"
class="text-error text-caption mt-1"
>
Geen logo ingesteld
{{ fieldErrors('logo_url') }}
</p>
</VCol>

View File

@@ -68,7 +68,7 @@ const defaultForm = () => ({
label: '',
field_type: 'text' as string,
options: [] as OptionEntry[],
tag_category: null as string | null,
tag_categories: [] as string[],
is_required: false,
is_filterable: false,
is_portal_visible: true,
@@ -113,7 +113,7 @@ function openEditDialog(template: RegistrationFieldTemplate) {
options: template.normalized_options
? template.normalized_options.map(o => ({ label: o.label, description: o.description ?? '' }))
: [],
tag_category: template.tag_category,
tag_categories: template.tag_categories ?? [],
is_required: template.is_required,
is_filterable: template.is_filterable,
is_portal_visible: template.is_portal_visible,
@@ -169,7 +169,7 @@ function onSubmit() {
}
if (showTagCategory.value) {
payload.tag_category = form.value.tag_category || null
payload.tag_categories = form.value.tag_categories.length > 0 ? form.value.tag_categories : null
}
}
@@ -467,81 +467,13 @@ function activate(template: RegistrationFieldTemplate) {
/>
</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 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>
@@ -571,15 +503,6 @@ function activate(template: RegistrationFieldTemplate) {
:error-messages="errors.sort_order"
/>
</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">
@@ -610,6 +533,118 @@ function activate(template: RegistrationFieldTemplate) {
/>
</div>
</VCol>
<!-- Options section at bottom with visual separation -->
<template v-if="showOptions">
<VCol cols="12">
<VDivider class="mb-1" />
</VCol>
<VCol cols="12">
<div class="d-flex align-center justify-space-between mb-3">
<div>
<div class="text-subtitle-2">
Opties
</div>
<div class="text-caption text-medium-emphasis">
Waaruit kan de deelnemer kiezen?
</div>
</div>
<VBtn
variant="tonal"
size="small"
prepend-icon="tabler-plus"
@click="addOption"
>
Optie toevoegen
</VBtn>
</div>
<div
v-for="(option, index) in form.options"
:key="index"
class="mb-3"
>
<VCard
variant="outlined"
class="pa-3"
>
<div class="d-flex align-start gap-2">
<VIcon
icon="tabler-grip-vertical"
class="mt-2 text-medium-emphasis"
style="cursor: grab;"
/>
<div class="flex-grow-1">
<AppTextField
v-model="option.label"
:placeholder="`Optie ${index + 1}`"
density="compact"
hide-details="auto"
class="mb-2"
/>
<AppTextField
v-model="option.description"
label="Beschrijving (optioneel)"
density="compact"
placeholder="Korte toelichting die onder de optie verschijnt"
counter="200"
maxlength="200"
hide-details="auto"
/>
</div>
<VBtn
icon="tabler-trash"
variant="text"
color="error"
size="small"
class="mt-1"
@click="removeOption(index)"
/>
</div>
</VCard>
</div>
<div
v-if="form.options.length === 0"
class="text-center text-medium-emphasis py-4"
>
Nog geen opties. Klik op "Optie toevoegen" om te beginnen.
</div>
<p
v-if="errors.options"
class="text-error text-caption mt-1"
>
{{ errors.options }}
</p>
</VCol>
</template>
<!-- Tag categories (conditional) -->
<template v-if="showTagCategory">
<VCol cols="12">
<VDivider class="mb-1" />
</VCol>
<VCol cols="12">
<div class="text-subtitle-2 mb-1">
Tag-categorieën
</div>
<div class="text-caption text-medium-emphasis mb-3">
Kies één of meerdere categorieën. De deelnemer ziet alle tags uit deze categorieën.
</div>
<AppAutocomplete
v-model="form.tag_categories"
label="Categorieën"
:items="tagCategories ?? []"
multiple
chips
closable-chips
clearable
:error-messages="errors.tag_categories"
placeholder="Alle tags (laat leeg voor alle categorieën)"
/>
</VCol>
</template>
</template>
</VRow>
</VCardText>

View File

@@ -49,7 +49,7 @@ export interface RegistrationFieldTemplate {
field_type: RegistrationFieldType
options: FieldOption[] | null
normalized_options: NormalizedOption[] | null
tag_category: string | null
tag_categories: string[] | null
is_required: boolean
is_filterable: boolean
is_portal_visible: boolean
@@ -65,7 +65,7 @@ export interface RegistrationFieldTemplateCreateDTO {
label: string
field_type: RegistrationFieldType
options?: FieldOption[] | null
tag_category?: string | null
tag_categories?: string[] | null
is_required?: boolean
is_filterable?: boolean
is_portal_visible?: boolean

View File

@@ -17,7 +17,7 @@ export interface RegistrationFormField {
field_type: RegistrationFieldType
options: FieldOption[] | null
normalized_options: NormalizedOption[] | null
tag_category: string | null
tag_categories: string[] | null
is_required: boolean
is_portal_visible: boolean
is_admin_only: boolean
@@ -34,7 +34,7 @@ export interface RegistrationFormFieldCreateDTO {
label: string
field_type: RegistrationFieldType
options?: FieldOption[] | null
tag_category?: string | null
tag_categories?: string[] | null
is_required?: boolean
is_portal_visible?: boolean
is_admin_only?: boolean