Files
crewli/apps/app/src/components/organisation/RegistrationFieldTemplatesTab.vue
bert.hausmans c43a922641 fix(app): don't send tag_category when field type is not tag_picker
The backend validates tag_category as 'prohibited' for non-tag_picker
field types. Sending null triggered a 422 validation error.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 23:19:15 +02:00

584 lines
17 KiB
Vue

<script setup lang="ts">
import { VForm } from 'vuetify/components/VForm'
import {
useRegistrationFieldTemplates,
useCreateRegistrationFieldTemplate,
useUpdateRegistrationFieldTemplate,
useDeleteRegistrationFieldTemplate,
} from '@/composables/api/useRegistrationFieldTemplates'
import { usePersonTagCategories } from '@/composables/api/usePersonTags'
import { requiredValidator } from '@core/utils/validators'
import type { RegistrationFieldTemplate } from '@/types/registration-field-template'
import { FIELD_TYPE_LABELS, FIELD_TYPES_WITH_OPTIONS } from '@/types/registration-field-template'
const props = defineProps<{
orgId: string
}>()
const orgIdRef = computed(() => props.orgId)
const { data: templates, isLoading } = useRegistrationFieldTemplates(orgIdRef)
const { data: tagCategories } = usePersonTagCategories(orgIdRef)
const { mutate: createTemplate, isPending: isCreating } = useCreateRegistrationFieldTemplate(orgIdRef)
const { mutate: updateTemplate, isPending: isUpdating } = useUpdateRegistrationFieldTemplate(orgIdRef)
const { mutate: deleteTemplate, isPending: isDeleting } = useDeleteRegistrationFieldTemplate(orgIdRef)
const fieldTypeOptions = Object.entries(FIELD_TYPE_LABELS).map(([value, title]) => ({ title, value }))
const activeTemplates = computed(() => templates.value?.filter(t => t.is_active) ?? [])
const inactiveTemplates = computed(() => templates.value?.filter(t => !t.is_active) ?? [])
const headers = [
{ title: 'Label', key: 'label' },
{ title: 'Type', key: 'field_type' },
{ title: 'Sectie', key: 'section' },
{ title: 'Systeem', key: 'is_system', sortable: false },
{ title: 'Acties', key: 'actions', sortable: false, align: 'end' as const },
]
// Dialog state
const isDialogOpen = ref(false)
const editingTemplate = ref<RegistrationFieldTemplate | null>(null)
const errors = ref<Record<string, string>>({})
const refVForm = ref<VForm>()
const showSuccess = ref(false)
const successMessage = ref('')
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: '',
sort_order: 0,
})
const form = ref(defaultForm())
const dialogTitle = computed(() =>
editingTemplate.value ? 'Template bewerken' : 'Nieuw template',
)
const isSaving = computed(() => isCreating.value || isUpdating.value)
const showOptions = computed(() =>
FIELD_TYPES_WITH_OPTIONS.includes(form.value.field_type as any),
)
const showTagCategory = computed(() => form.value.field_type === 'tag_picker')
// Delete confirmation dialog
const isDeleteDialogOpen = ref(false)
const deletingTemplate = ref<RegistrationFieldTemplate | null>(null)
function openCreateDialog() {
editingTemplate.value = null
form.value = defaultForm()
errors.value = {}
isDialogOpen.value = true
}
function openEditDialog(template: RegistrationFieldTemplate) {
editingTemplate.value = template
form.value = {
label: template.label,
field_type: template.field_type,
options: template.options ? [...template.options] : [],
tag_category: template.tag_category,
is_required: template.is_required,
is_filterable: template.is_filterable,
is_portal_visible: template.is_portal_visible,
is_admin_only: template.is_admin_only,
section: template.section ?? '',
help_text: template.help_text ?? '',
sort_order: template.sort_order,
}
errors.value = {}
isDialogOpen.value = true
}
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,
sort_order: form.value.sort_order,
}
if (!editingTemplate.value) {
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
}
if (editingTemplate.value) {
updateTemplate(
{ id: editingTemplate.value.id, ...payload },
{
onSuccess: () => {
isDialogOpen.value = false
successMessage.value = `${form.value.label} bijgewerkt`
showSuccess.value = true
},
onError: handleError,
},
)
}
else {
createTemplate(payload as any, {
onSuccess: () => {
isDialogOpen.value = false
successMessage.value = `${form.value.label} aangemaakt`
showSuccess.value = true
},
onError: handleError,
})
}
})
}
function handleError(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 }
}
}
function onDeleteConfirm(template: RegistrationFieldTemplate) {
deletingTemplate.value = template
isDeleteDialogOpen.value = true
}
function onDeleteExecute() {
if (!deletingTemplate.value) return
const label = deletingTemplate.value.label
deleteTemplate(deletingTemplate.value.id, {
onSuccess: () => {
isDeleteDialogOpen.value = false
deletingTemplate.value = null
successMessage.value = `${label} verwijderd`
showSuccess.value = true
},
})
}
function deactivate(template: RegistrationFieldTemplate) {
deleteTemplate(template.id, {
onSuccess: () => {
successMessage.value = `${template.label} gedeactiveerd`
showSuccess.value = true
},
})
}
function activate(template: RegistrationFieldTemplate) {
updateTemplate(
{ id: template.id, is_active: true },
{
onSuccess: () => {
successMessage.value = `${template.label} geactiveerd`
showSuccess.value = true
},
},
)
}
</script>
<template>
<div>
<!-- Loading -->
<VSkeletonLoader
v-if="isLoading"
type="card"
/>
<template v-else>
<!-- Header -->
<div class="d-flex justify-space-between align-center mb-6">
<div>
<h5 class="text-h5">
Registratieveld-templates
</h5>
<p class="text-body-2 text-disabled mb-0">
Herbruikbare velddefinities voor aanmeldformulieren
</p>
</div>
<VBtn
prepend-icon="tabler-plus"
@click="openCreateDialog"
>
Nieuw template
</VBtn>
</div>
<!-- Empty state -->
<VCard
v-if="!templates?.length"
class="text-center pa-8"
>
<VIcon
icon="tabler-forms"
size="48"
class="mb-4 text-disabled"
/>
<p class="text-body-1 text-disabled">
Nog geen templates aangemaakt. Maak je eerste template aan.
</p>
</VCard>
<!-- Active templates table -->
<VCard v-else-if="activeTemplates.length">
<VDataTable
:headers="headers"
:items="activeTemplates"
item-value="id"
:items-per-page="-1"
hide-default-footer
hover
>
<template #item.label="{ item }">
<span class="font-weight-medium">{{ item.label }}</span>
<span
v-if="item.is_required"
class="text-error ms-1"
>*</span>
</template>
<template #item.field_type="{ item }">
{{ FIELD_TYPE_LABELS[item.field_type] ?? item.field_type }}
</template>
<template #item.section="{ item }">
<span v-if="item.section">{{ item.section }}</span>
<span
v-else
class="text-disabled"
>-</span>
</template>
<template #item.is_system="{ item }">
<VChip
v-if="item.is_system"
size="small"
variant="tonal"
color="info"
>
Systeem
</VChip>
</template>
<template #item.actions="{ item }">
<div class="d-flex justify-end gap-x-1">
<VBtn
icon="tabler-edit"
variant="text"
size="small"
title="Bewerken"
@click="openEditDialog(item)"
/>
<VBtn
v-if="item.is_system"
icon="tabler-eye-off"
variant="text"
size="small"
color="warning"
title="Deactiveren"
@click="deactivate(item)"
/>
<VBtn
v-else
icon="tabler-trash"
variant="text"
size="small"
color="error"
title="Verwijderen"
@click="onDeleteConfirm(item)"
/>
</div>
</template>
</VDataTable>
</VCard>
<!-- Inactive templates -->
<template v-if="inactiveTemplates.length">
<p class="text-body-2 text-disabled mt-6 mb-2">
Inactief
</p>
<VCard class="opacity-60">
<VList lines="one">
<VListItem
v-for="template in inactiveTemplates"
:key="template.id"
>
<VListItemTitle class="text-disabled">
{{ template.label }}
</VListItemTitle>
<VListItemSubtitle>
{{ FIELD_TYPE_LABELS[template.field_type] ?? template.field_type }}
</VListItemSubtitle>
<template #append>
<VBtn
variant="tonal"
size="small"
color="success"
@click="activate(template)"
>
Activeren
</VBtn>
</template>
</VListItem>
</VList>
</VCard>
</template>
</template>
<!-- Create / Edit dialog -->
<VDialog
v-model="isDialogOpen"
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="!!editingTemplate"
:hint="editingTemplate ? 'Type kan niet gewijzigd worden na aanmaken' : ''"
:persistent-hint="!!editingTemplate"
/>
</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. Vergoeding"
:error-messages="errors.section"
/>
</VCol>
<VCol
cols="12"
md="6"
>
<AppTextField
v-model.number="form.sort_order"
label="Volgorde"
type="number"
: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">
<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="isDialogOpen = false"
>
Annuleren
</VBtn>
<VBtn
type="submit"
color="primary"
:loading="isSaving"
>
Opslaan
</VBtn>
</VCardActions>
</VForm>
</VCard>
</VDialog>
<!-- Delete confirmation -->
<VDialog
v-model="isDeleteDialogOpen"
max-width="400"
>
<VCard title="Template verwijderen">
<VCardText>
Weet je zeker dat je <strong>{{ deletingTemplate?.label }}</strong> wilt verwijderen?
</VCardText>
<VCardActions>
<VSpacer />
<VBtn
variant="text"
@click="isDeleteDialogOpen = false"
>
Annuleren
</VBtn>
<VBtn
color="error"
:loading="isDeleting"
@click="onDeleteExecute"
>
Verwijderen
</VBtn>
</VCardActions>
</VCard>
</VDialog>
<VSnackbar
v-model="showSuccess"
color="success"
:timeout="3000"
>
{{ successMessage }}
</VSnackbar>
</div>
</template>