feat(app): organisation settings page with tags & registration field templates
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
477
apps/app/src/components/organisation/PersonTagsTab.vue
Normal file
477
apps/app/src/components/organisation/PersonTagsTab.vue
Normal file
@@ -0,0 +1,477 @@
|
||||
<script setup lang="ts">
|
||||
import { VForm } from 'vuetify/components/VForm'
|
||||
import {
|
||||
usePersonTags,
|
||||
usePersonTagCategories,
|
||||
useCreatePersonTag,
|
||||
useUpdatePersonTag,
|
||||
useDeletePersonTag,
|
||||
} from '@/composables/api/usePersonTags'
|
||||
import { requiredValidator } from '@core/utils/validators'
|
||||
import type { PersonTag } from '@/types/person-tag'
|
||||
|
||||
const props = defineProps<{
|
||||
orgId: string
|
||||
}>()
|
||||
|
||||
const orgIdRef = computed(() => props.orgId)
|
||||
|
||||
const { data: tags, isLoading } = usePersonTags(orgIdRef)
|
||||
const { data: categories } = usePersonTagCategories(orgIdRef)
|
||||
const { mutate: createTag, isPending: isCreating } = useCreatePersonTag(orgIdRef)
|
||||
const { mutate: updateTag, isPending: isUpdating } = useUpdatePersonTag(orgIdRef)
|
||||
const { mutate: deleteTag } = useDeletePersonTag(orgIdRef)
|
||||
|
||||
// Search & filter
|
||||
const search = ref('')
|
||||
const filterCategory = ref('')
|
||||
|
||||
const categoryOptions = computed(() => {
|
||||
const cats = categories.value ?? []
|
||||
|
||||
return [{ title: 'Alle categorieën', value: '' }, ...cats.map(c => ({ title: c, value: c }))]
|
||||
})
|
||||
|
||||
const activeTags = computed(() => tags.value?.filter(t => t.is_active) ?? [])
|
||||
const inactiveTags = computed(() => tags.value?.filter(t => !t.is_active) ?? [])
|
||||
|
||||
const filteredActiveTags = computed(() => {
|
||||
let result = activeTags.value
|
||||
|
||||
if (filterCategory.value) {
|
||||
result = result.filter(t => t.category === filterCategory.value)
|
||||
}
|
||||
|
||||
if (search.value) {
|
||||
const q = search.value.toLowerCase()
|
||||
result = result.filter(t => t.name.toLowerCase().includes(q))
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
const headers = [
|
||||
{ title: 'Naam', key: 'name' },
|
||||
{ title: 'Categorie', key: 'category' },
|
||||
{ title: 'Icoon', key: 'icon', sortable: false },
|
||||
{ title: 'Kleur', key: 'color', sortable: false },
|
||||
{ title: 'Acties', key: 'actions', sortable: false, align: 'end' as const },
|
||||
]
|
||||
|
||||
// Dialog state
|
||||
const isDialogOpen = ref(false)
|
||||
const editingTag = ref<PersonTag | null>(null)
|
||||
const errors = ref<Record<string, string>>({})
|
||||
const refVForm = ref<VForm>()
|
||||
const showSuccess = ref(false)
|
||||
const successMessage = ref('')
|
||||
|
||||
const form = ref({
|
||||
name: '',
|
||||
category: null as string | null,
|
||||
icon: '',
|
||||
color: '',
|
||||
sort_order: 0,
|
||||
})
|
||||
|
||||
const dialogTitle = computed(() =>
|
||||
editingTag.value ? 'Tag bewerken' : 'Nieuwe tag',
|
||||
)
|
||||
|
||||
const isSaving = computed(() => isCreating.value || isUpdating.value)
|
||||
|
||||
function openCreateDialog() {
|
||||
editingTag.value = null
|
||||
form.value = { name: '', category: null, icon: '', color: '', sort_order: 0 }
|
||||
errors.value = {}
|
||||
isDialogOpen.value = true
|
||||
}
|
||||
|
||||
function openEditDialog(tag: PersonTag) {
|
||||
editingTag.value = tag
|
||||
form.value = {
|
||||
name: tag.name,
|
||||
category: tag.category,
|
||||
icon: tag.icon ?? '',
|
||||
color: tag.color ?? '',
|
||||
sort_order: tag.sort_order,
|
||||
}
|
||||
errors.value = {}
|
||||
isDialogOpen.value = true
|
||||
}
|
||||
|
||||
function onSubmit() {
|
||||
refVForm.value?.validate().then(({ valid }) => {
|
||||
if (!valid) return
|
||||
|
||||
errors.value = {}
|
||||
const payload = {
|
||||
name: form.value.name,
|
||||
category: form.value.category || null,
|
||||
icon: form.value.icon || null,
|
||||
color: form.value.color || null,
|
||||
sort_order: form.value.sort_order,
|
||||
}
|
||||
|
||||
if (editingTag.value) {
|
||||
updateTag(
|
||||
{ id: editingTag.value.id, ...payload },
|
||||
{
|
||||
onSuccess: () => {
|
||||
isDialogOpen.value = false
|
||||
successMessage.value = `${form.value.name} bijgewerkt`
|
||||
showSuccess.value = true
|
||||
},
|
||||
onError: handleError,
|
||||
},
|
||||
)
|
||||
}
|
||||
else {
|
||||
createTag(payload, {
|
||||
onSuccess: () => {
|
||||
isDialogOpen.value = false
|
||||
successMessage.value = `${form.value.name} 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 = { name: data.message }
|
||||
}
|
||||
}
|
||||
|
||||
function deactivate(tag: PersonTag) {
|
||||
deleteTag(tag.id, {
|
||||
onSuccess: () => {
|
||||
successMessage.value = `${tag.name} gedeactiveerd`
|
||||
showSuccess.value = true
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function activate(tag: PersonTag) {
|
||||
updateTag(
|
||||
{ id: tag.id, is_active: true },
|
||||
{
|
||||
onSuccess: () => {
|
||||
successMessage.value = `${tag.name} 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">
|
||||
Tags & Vaardigheden
|
||||
</h5>
|
||||
<p class="text-body-2 text-disabled mb-0">
|
||||
Definieer tags, vaardigheden en certificaten voor je organisatie
|
||||
</p>
|
||||
</div>
|
||||
<VBtn
|
||||
prepend-icon="tabler-plus"
|
||||
@click="openCreateDialog"
|
||||
>
|
||||
Nieuwe tag
|
||||
</VBtn>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="d-flex gap-x-4 mb-4">
|
||||
<AppTextField
|
||||
v-model="search"
|
||||
prepend-inner-icon="tabler-search"
|
||||
placeholder="Zoek op naam..."
|
||||
clearable
|
||||
style="max-inline-size: 300px;"
|
||||
/>
|
||||
<AppSelect
|
||||
v-model="filterCategory"
|
||||
:items="categoryOptions"
|
||||
label="Categorie"
|
||||
clearable
|
||||
style="min-inline-size: 200px;"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Empty state -->
|
||||
<VCard
|
||||
v-if="!tags?.length"
|
||||
class="text-center pa-8"
|
||||
>
|
||||
<VIcon
|
||||
icon="tabler-tag"
|
||||
size="48"
|
||||
class="mb-4 text-disabled"
|
||||
/>
|
||||
<p class="text-body-1 text-disabled">
|
||||
Nog geen tags aangemaakt. Maak je eerste tag aan.
|
||||
</p>
|
||||
</VCard>
|
||||
|
||||
<!-- No results after filter -->
|
||||
<VCard
|
||||
v-else-if="!filteredActiveTags.length && (search || filterCategory)"
|
||||
class="text-center pa-8"
|
||||
>
|
||||
<p class="text-body-1 text-disabled">
|
||||
Geen tags gevonden voor deze zoekopdracht.
|
||||
</p>
|
||||
</VCard>
|
||||
|
||||
<!-- Active tags table -->
|
||||
<VCard v-else-if="filteredActiveTags.length">
|
||||
<VDataTable
|
||||
:headers="headers"
|
||||
:items="filteredActiveTags"
|
||||
item-value="id"
|
||||
:items-per-page="-1"
|
||||
hide-default-footer
|
||||
hover
|
||||
>
|
||||
<template #item.name="{ item }">
|
||||
<span class="font-weight-medium">{{ item.name }}</span>
|
||||
</template>
|
||||
|
||||
<template #item.category="{ item }">
|
||||
<VChip
|
||||
v-if="item.category"
|
||||
size="small"
|
||||
variant="tonal"
|
||||
>
|
||||
{{ item.category }}
|
||||
</VChip>
|
||||
<span
|
||||
v-else
|
||||
class="text-disabled"
|
||||
>-</span>
|
||||
</template>
|
||||
|
||||
<template #item.icon="{ item }">
|
||||
<VIcon
|
||||
v-if="item.icon"
|
||||
:icon="item.icon"
|
||||
size="20"
|
||||
/>
|
||||
<span
|
||||
v-else
|
||||
class="text-disabled"
|
||||
>-</span>
|
||||
</template>
|
||||
|
||||
<template #item.color="{ item }">
|
||||
<div
|
||||
v-if="item.color"
|
||||
class="d-flex align-center gap-x-2"
|
||||
>
|
||||
<div
|
||||
class="rounded-circle"
|
||||
:style="{ backgroundColor: item.color, width: '20px', height: '20px' }"
|
||||
/>
|
||||
<span class="text-body-2 text-disabled">{{ item.color }}</span>
|
||||
</div>
|
||||
<span
|
||||
v-else
|
||||
class="text-disabled"
|
||||
>-</span>
|
||||
</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
|
||||
icon="tabler-eye-off"
|
||||
variant="text"
|
||||
size="small"
|
||||
color="warning"
|
||||
title="Deactiveren"
|
||||
@click="deactivate(item)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</VDataTable>
|
||||
</VCard>
|
||||
|
||||
<!-- Inactive tags -->
|
||||
<template v-if="inactiveTags.length">
|
||||
<p class="text-body-2 text-disabled mt-6 mb-2">
|
||||
Inactief
|
||||
</p>
|
||||
<VCard class="opacity-60">
|
||||
<VList lines="one">
|
||||
<VListItem
|
||||
v-for="tag in inactiveTags"
|
||||
:key="tag.id"
|
||||
>
|
||||
<template #prepend>
|
||||
<VIcon
|
||||
v-if="tag.icon"
|
||||
:icon="tag.icon"
|
||||
size="20"
|
||||
class="me-2"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<VListItemTitle class="text-disabled">
|
||||
{{ tag.name }}
|
||||
</VListItemTitle>
|
||||
<VListItemSubtitle v-if="tag.category">
|
||||
{{ tag.category }}
|
||||
</VListItemSubtitle>
|
||||
|
||||
<template #append>
|
||||
<VBtn
|
||||
variant="tonal"
|
||||
size="small"
|
||||
color="success"
|
||||
@click="activate(tag)"
|
||||
>
|
||||
Activeren
|
||||
</VBtn>
|
||||
</template>
|
||||
</VListItem>
|
||||
</VList>
|
||||
</VCard>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<!-- Create / Edit dialog -->
|
||||
<VDialog
|
||||
v-model="isDialogOpen"
|
||||
max-width="500"
|
||||
>
|
||||
<VCard :title="dialogTitle">
|
||||
<VForm
|
||||
ref="refVForm"
|
||||
@submit.prevent="onSubmit"
|
||||
>
|
||||
<VCardText>
|
||||
<VRow>
|
||||
<VCol cols="12">
|
||||
<AppTextField
|
||||
v-model="form.name"
|
||||
label="Naam"
|
||||
:rules="[requiredValidator]"
|
||||
:error-messages="errors.name"
|
||||
autofocus
|
||||
autocomplete="one-time-code"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12">
|
||||
<AppAutocomplete
|
||||
v-model="form.category"
|
||||
label="Categorie"
|
||||
:items="categories ?? []"
|
||||
clearable
|
||||
:error-messages="errors.category"
|
||||
placeholder="Vaardigheid, Certificaat, Taal..."
|
||||
/>
|
||||
</VCol>
|
||||
<VCol
|
||||
cols="12"
|
||||
md="6"
|
||||
>
|
||||
<AppTextField
|
||||
v-model="form.icon"
|
||||
label="Icoon"
|
||||
placeholder="tabler-star"
|
||||
:error-messages="errors.icon"
|
||||
>
|
||||
<template
|
||||
v-if="form.icon"
|
||||
#prepend-inner
|
||||
>
|
||||
<VIcon
|
||||
:icon="form.icon"
|
||||
size="20"
|
||||
/>
|
||||
</template>
|
||||
</AppTextField>
|
||||
</VCol>
|
||||
<VCol
|
||||
cols="12"
|
||||
md="6"
|
||||
>
|
||||
<label class="text-body-2 mb-1 d-block">Kleur</label>
|
||||
<input
|
||||
v-model="form.color"
|
||||
type="color"
|
||||
class="w-100 rounded cursor-pointer"
|
||||
style="block-size: 40px; border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));"
|
||||
>
|
||||
<p
|
||||
v-if="errors.color"
|
||||
class="text-error text-caption mt-1"
|
||||
>
|
||||
{{ errors.color }}
|
||||
</p>
|
||||
</VCol>
|
||||
<VCol cols="12">
|
||||
<AppTextField
|
||||
v-model.number="form.sort_order"
|
||||
label="Volgorde"
|
||||
type="number"
|
||||
:error-messages="errors.sort_order"
|
||||
/>
|
||||
</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>
|
||||
|
||||
<VSnackbar
|
||||
v-model="showSuccess"
|
||||
color="success"
|
||||
:timeout="3000"
|
||||
>
|
||||
{{ successMessage }}
|
||||
</VSnackbar>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,565 @@
|
||||
<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 headers = [
|
||||
{ title: 'Label', key: 'label' },
|
||||
{ title: 'Type', key: 'field_type' },
|
||||
{ title: 'Sectie', key: 'section' },
|
||||
{ title: 'Systeem', key: 'is_system', sortable: false },
|
||||
{ title: 'Status', key: 'is_active', 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
|
||||
}
|
||||
else {
|
||||
payload.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 toggleActive(template: RegistrationFieldTemplate) {
|
||||
if (template.is_system) {
|
||||
// System templates: toggle via DELETE (deactivate) or UPDATE (activate)
|
||||
if (template.is_active) {
|
||||
deleteTemplate(template.id, {
|
||||
onSuccess: () => {
|
||||
successMessage.value = `${template.label} gedeactiveerd`
|
||||
showSuccess.value = true
|
||||
},
|
||||
})
|
||||
}
|
||||
else {
|
||||
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>
|
||||
|
||||
<!-- Data table -->
|
||||
<VCard v-else>
|
||||
<VDataTable
|
||||
:headers="headers"
|
||||
:items="templates"
|
||||
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.is_active="{ item }">
|
||||
<VChip
|
||||
:color="item.is_active ? 'success' : 'default'"
|
||||
size="small"
|
||||
variant="tonal"
|
||||
>
|
||||
{{ item.is_active ? 'Actief' : 'Inactief' }}
|
||||
</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="item.is_active ? 'tabler-eye-off' : 'tabler-eye'"
|
||||
variant="text"
|
||||
size="small"
|
||||
:color="item.is_active ? 'warning' : 'success'"
|
||||
:title="item.is_active ? 'Deactiveren' : 'Activeren'"
|
||||
@click="toggleActive(item)"
|
||||
/>
|
||||
<VBtn
|
||||
v-else
|
||||
icon="tabler-trash"
|
||||
variant="text"
|
||||
size="small"
|
||||
color="error"
|
||||
title="Verwijderen"
|
||||
@click="onDeleteConfirm(item)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</VDataTable>
|
||||
</VCard>
|
||||
</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>
|
||||
Reference in New Issue
Block a user