refactor(app): unify settings tab design for tags, templates & crowd types

Move crowd types management to organisation settings as a new tab and
align all three settings tabs (Tags, Registration Field Templates, Crowd
Types) to the same layout pattern: header with title/subtitle, VDataTable
for active items, and a separate inactive section with VList. Also fix
the API to return inactive records for person tags and registration field
templates so the frontend can display them.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-12 23:18:10 +02:00
parent 1c0ac488b0
commit 63bc351c59
8 changed files with 268 additions and 234 deletions

View File

@@ -20,7 +20,7 @@ final class PersonTagController extends Controller
{ {
Gate::authorize('viewAny', [PersonTag::class, $organisation]); Gate::authorize('viewAny', [PersonTag::class, $organisation]);
$tags = $organisation->personTags()->active()->ordered()->get(); $tags = $organisation->personTags()->ordered()->get();
return PersonTagResource::collection($tags); return PersonTagResource::collection($tags);
} }

View File

@@ -16,7 +16,6 @@ final class RegistrationFieldTemplateService
public function listForOrganisation(Organisation $organisation): Collection public function listForOrganisation(Organisation $organisation): Collection
{ {
return $organisation->registrationFieldTemplates() return $organisation->registrationFieldTemplates()
->active()
->ordered() ->ordered()
->get(); ->get();
} }

View File

@@ -51,7 +51,7 @@ class PersonTagTest extends TestCase
$response = $this->getJson("/api/v1/organisations/{$this->organisation->id}/person-tags"); $response = $this->getJson("/api/v1/organisations/{$this->organisation->id}/person-tags");
$response->assertOk(); $response->assertOk();
$this->assertCount(3, $response->json('data')); $this->assertCount(4, $response->json('data'));
} }
public function test_store_creates_tag(): void public function test_store_creates_tag(): void

View File

@@ -39,7 +39,7 @@ class RegistrationFieldTemplateTest extends TestCase
$this->otherOrganisation->users()->attach($this->outsider, ['role' => 'org_admin']); $this->otherOrganisation->users()->attach($this->outsider, ['role' => 'org_admin']);
} }
public function test_index_returns_active_templates(): void public function test_index_returns_all_templates(): void
{ {
RegistrationFieldTemplate::factory()->count(3)->create([ RegistrationFieldTemplate::factory()->count(3)->create([
'organisation_id' => $this->organisation->id, 'organisation_id' => $this->organisation->id,
@@ -53,7 +53,7 @@ class RegistrationFieldTemplateTest extends TestCase
$response = $this->getJson("/api/v1/organisations/{$this->organisation->id}/registration-field-templates"); $response = $this->getJson("/api/v1/organisations/{$this->organisation->id}/registration-field-templates");
$response->assertOk(); $response->assertOk();
$this->assertCount(3, $response->json('data')); $this->assertCount(4, $response->json('data'));
} }
public function test_store_creates_template(): void public function test_store_creates_template(): void

View File

@@ -25,12 +25,14 @@ const { mutate: deleteTemplate, isPending: isDeleting } = useDeleteRegistrationF
const fieldTypeOptions = Object.entries(FIELD_TYPE_LABELS).map(([value, title]) => ({ title, value })) 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 = [ const headers = [
{ title: 'Label', key: 'label' }, { title: 'Label', key: 'label' },
{ title: 'Type', key: 'field_type' }, { title: 'Type', key: 'field_type' },
{ title: 'Sectie', key: 'section' }, { title: 'Sectie', key: 'section' },
{ title: 'Systeem', key: 'is_system', sortable: false }, { title: 'Systeem', key: 'is_system', sortable: false },
{ title: 'Status', key: 'is_active', sortable: false },
{ title: 'Acties', key: 'actions', sortable: false, align: 'end' as const }, { title: 'Acties', key: 'actions', sortable: false, align: 'end' as const },
] ]
@@ -199,29 +201,25 @@ function onDeleteExecute() {
}) })
} }
function toggleActive(template: RegistrationFieldTemplate) { function deactivate(template: RegistrationFieldTemplate) {
if (template.is_system) { deleteTemplate(template.id, {
// System templates: toggle via DELETE (deactivate) or UPDATE (activate) onSuccess: () => {
if (template.is_active) { successMessage.value = `${template.label} gedeactiveerd`
deleteTemplate(template.id, { showSuccess.value = true
onSuccess: () => { },
successMessage.value = `${template.label} gedeactiveerd` })
showSuccess.value = true }
},
}) function activate(template: RegistrationFieldTemplate) {
} updateTemplate(
else { { id: template.id, is_active: true },
updateTemplate( {
{ id: template.id, is_active: true }, onSuccess: () => {
{ successMessage.value = `${template.label} geactiveerd`
onSuccess: () => { showSuccess.value = true
successMessage.value = `${template.label} geactiveerd` },
showSuccess.value = true },
}, )
},
)
}
}
} }
</script> </script>
@@ -267,11 +265,11 @@ function toggleActive(template: RegistrationFieldTemplate) {
</p> </p>
</VCard> </VCard>
<!-- Data table --> <!-- Active templates table -->
<VCard v-else> <VCard v-else-if="activeTemplates.length">
<VDataTable <VDataTable
:headers="headers" :headers="headers"
:items="templates" :items="activeTemplates"
item-value="id" item-value="id"
:items-per-page="-1" :items-per-page="-1"
hide-default-footer hide-default-footer
@@ -308,16 +306,6 @@ function toggleActive(template: RegistrationFieldTemplate) {
</VChip> </VChip>
</template> </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 }"> <template #item.actions="{ item }">
<div class="d-flex justify-end gap-x-1"> <div class="d-flex justify-end gap-x-1">
<VBtn <VBtn
@@ -329,12 +317,12 @@ function toggleActive(template: RegistrationFieldTemplate) {
/> />
<VBtn <VBtn
v-if="item.is_system" v-if="item.is_system"
:icon="item.is_active ? 'tabler-eye-off' : 'tabler-eye'" icon="tabler-eye-off"
variant="text" variant="text"
size="small" size="small"
:color="item.is_active ? 'warning' : 'success'" color="warning"
:title="item.is_active ? 'Deactiveren' : 'Activeren'" title="Deactiveren"
@click="toggleActive(item)" @click="deactivate(item)"
/> />
<VBtn <VBtn
v-else v-else
@@ -349,6 +337,39 @@ function toggleActive(template: RegistrationFieldTemplate) {
</template> </template>
</VDataTable> </VDataTable>
</VCard> </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> </template>
<!-- Create / Edit dialog --> <!-- Create / Edit dialog -->

View File

@@ -57,6 +57,14 @@ const inactiveCrowdTypes = computed(() =>
crowdTypes.value?.filter(ct => !ct.is_active) ?? [], crowdTypes.value?.filter(ct => !ct.is_active) ?? [],
) )
const headers = [
{ title: 'Naam', key: 'name' },
{ title: 'Systeemtype', key: 'system_type' },
{ title: 'Icoon', key: 'icon', sortable: false },
{ title: 'Kleur', key: 'color', sortable: false },
{ title: 'Acties', key: 'actions', sortable: false, align: 'end' as const },
]
const dialogTitle = computed(() => const dialogTitle = computed(() =>
editingCrowdType.value ? 'Crowd type bewerken' : 'Crowd type aanmaken', editingCrowdType.value ? 'Crowd type bewerken' : 'Crowd type aanmaken',
) )
@@ -155,130 +163,139 @@ function activate(ct: CrowdType) {
</script> </script>
<template> <template>
<VCard> <div>
<VCardItem> <!-- Loading -->
<VCardTitle>Crowd types</VCardTitle> <VSkeletonLoader
<VCardSubtitle>Definieer de deelnemertypes voor je organisatie</VCardSubtitle> v-if="isLoading"
<template #append> type="card"
/>
<template v-else>
<!-- Header -->
<div class="d-flex justify-space-between align-center mb-6">
<div>
<h5 class="text-h5">
Crowd types
</h5>
<p class="text-body-2 text-disabled mb-0">
Definieer de deelnemertypes voor je organisatie
</p>
</div>
<VBtn <VBtn
prepend-icon="tabler-plus" prepend-icon="tabler-plus"
@click="openCreateDialog" @click="openCreateDialog"
> >
Crowd type toevoegen Crowd type toevoegen
</VBtn> </VBtn>
</template> </div>
</VCardItem>
<VCardText> <!-- Empty state -->
<!-- Loading --> <VCard
<VSkeletonLoader v-if="!crowdTypes?.length"
v-if="isLoading" class="text-center pa-8"
type="list-item@3" >
/> <VIcon
icon="tabler-users-group"
<template v-else> size="48"
<!-- Empty state --> class="mb-4 text-disabled"
<VAlert />
v-if="!crowdTypes?.length" <p class="text-body-1 text-disabled">
type="info"
variant="tonal"
>
Nog geen crowd types aangemaakt. Maak er een aan om personen te categoriseren. Nog geen crowd types aangemaakt. Maak er een aan om personen te categoriseren.
</VAlert> </p>
</VCard>
<!-- Active crowd types --> <!-- Active crowd types table -->
<VList <VCard v-else-if="activeCrowdTypes.length">
v-if="activeCrowdTypes.length" <VDataTable
lines="one" :headers="headers"
:items="activeCrowdTypes"
item-value="id"
:items-per-page="-1"
hide-default-footer
hover
> >
<VListItem <template #item.name="{ item }">
v-for="ct in activeCrowdTypes" <span class="font-weight-medium">{{ item.name }}</span>
:key="ct.id" </template>
>
<template #prepend>
<VAvatar
:color="ct.color"
size="32"
variant="flat"
>
<VIcon
v-if="ct.icon"
:icon="ct.icon"
size="18"
color="white"
/>
</VAvatar>
</template>
<VListItemTitle>{{ ct.name }}</VListItemTitle> <template #item.system_type="{ item }">
<VListItemSubtitle> <VChip
<VChip size="small"
size="x-small" variant="tonal"
variant="tonal" >
class="mt-1" {{ systemTypeLabels[item.system_type] ?? item.system_type }}
> </VChip>
{{ systemTypeLabels[ct.system_type] ?? ct.system_type }} </template>
</VChip>
</VListItemSubtitle>
<template #append> <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 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>
</template>
<template #item.actions="{ item }">
<div class="d-flex justify-end gap-x-1">
<VBtn <VBtn
icon="tabler-edit" icon="tabler-edit"
variant="text" variant="text"
size="small" size="small"
@click="openEditDialog(ct)" title="Bewerken"
@click="openEditDialog(item)"
/> />
<VBtn <VBtn
icon="tabler-eye-off" icon="tabler-eye-off"
variant="text" variant="text"
size="small" size="small"
color="warning" color="warning"
@click="deactivate(ct)" title="Deactiveren"
@click="deactivate(item)"
/> />
</template> </div>
</VListItem> </template>
</VList> </VDataTable>
</VCard>
<!-- Inactive crowd types --> <!-- Inactive crowd types -->
<template v-if="inactiveCrowdTypes.length"> <template v-if="inactiveCrowdTypes.length">
<VDivider class="my-4" /> <p class="text-body-2 text-disabled mt-6 mb-2">
<p class="text-body-2 text-disabled mb-2"> Inactief
Inactief </p>
</p> <VCard class="opacity-60">
<VList <VList lines="one">
lines="one"
class="opacity-60"
>
<VListItem <VListItem
v-for="ct in inactiveCrowdTypes" v-for="ct in inactiveCrowdTypes"
:key="ct.id" :key="ct.id"
> >
<template #prepend> <template #prepend>
<VAvatar <VIcon
:color="ct.color" v-if="ct.icon"
size="32" :icon="ct.icon"
variant="flat" size="20"
> class="me-2"
<VIcon />
v-if="ct.icon"
:icon="ct.icon"
size="18"
color="white"
/>
</VAvatar>
</template> </template>
<VListItemTitle class="text-disabled"> <VListItemTitle class="text-disabled">
{{ ct.name }} {{ ct.name }}
</VListItemTitle> </VListItemTitle>
<VListItemSubtitle> <VListItemSubtitle>
<VChip {{ systemTypeLabels[ct.system_type] ?? ct.system_type }}
size="x-small"
variant="tonal"
class="mt-1"
>
{{ systemTypeLabels[ct.system_type] ?? ct.system_type }}
</VChip>
</VListItemSubtitle> </VListItemSubtitle>
<template #append> <template #append>
@@ -293,101 +310,101 @@ function activate(ct: CrowdType) {
</template> </template>
</VListItem> </VListItem>
</VList> </VList>
</template> </VCard>
</template> </template>
</VCardText> </template>
</VCard>
<!-- Create / Edit dialog --> <!-- Create / Edit dialog -->
<VDialog <VDialog
v-model="isDialogOpen" v-model="isDialogOpen"
max-width="500" max-width="500"
> >
<VCard :title="dialogTitle"> <VCard :title="dialogTitle">
<VForm <VForm
ref="refVForm" ref="refVForm"
@submit.prevent="onSubmit" @submit.prevent="onSubmit"
> >
<VCardText> <VCardText>
<VRow> <VRow>
<VCol cols="12"> <VCol cols="12">
<AppTextField <AppTextField
v-model="form.name" v-model="form.name"
label="Naam" label="Naam"
:rules="[requiredValidator]" :rules="[requiredValidator]"
:error-messages="errors.name" :error-messages="errors.name"
autofocus autofocus
autocomplete="one-time-code" autocomplete="one-time-code"
/> />
</VCol> </VCol>
<VCol cols="12"> <VCol cols="12">
<AppSelect <AppSelect
v-model="form.system_type" v-model="form.system_type"
label="Systeemtype" label="Systeemtype"
:items="systemTypeOptions" :items="systemTypeOptions"
:rules="[requiredValidator]" :rules="[requiredValidator]"
:error-messages="errors.system_type" :error-messages="errors.system_type"
:disabled="!!editingCrowdType" :disabled="!!editingCrowdType"
hint="Bepaalt hoe dit type wordt gebruikt in het systeem" hint="Bepaalt hoe dit type wordt gebruikt in het systeem"
persistent-hint persistent-hint
/> />
</VCol> </VCol>
<VCol <VCol
cols="12" cols="12"
md="6" 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 <label class="text-body-2 mb-1 d-block">Kleur</label>
v-if="errors.color" <input
class="text-error text-caption mt-1" 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"
md="6"
> >
{{ errors.color }} <AppTextField
</p> v-model="form.icon"
</VCol> label="Icoon"
<VCol placeholder="tabler-users"
cols="12" :error-messages="errors.icon"
md="6" />
</VCol>
</VRow>
</VCardText>
<VCardActions>
<VSpacer />
<VBtn
variant="text"
@click="isDialogOpen = false"
> >
<AppTextField Annuleren
v-model="form.icon" </VBtn>
label="Icoon" <VBtn
placeholder="tabler-users" type="submit"
:error-messages="errors.icon" color="primary"
/> :loading="isSaving"
</VCol> >
</VRow> Opslaan
</VCardText> </VBtn>
<VCardActions> </VCardActions>
<VSpacer /> </VForm>
<VBtn </VCard>
variant="text" </VDialog>
@click="isDialogOpen = false"
>
Annuleren
</VBtn>
<VBtn
type="submit"
color="primary"
:loading="isSaving"
>
Opslaan
</VBtn>
</VCardActions>
</VForm>
</VCard>
</VDialog>
<VSnackbar <VSnackbar
v-model="showSuccess" v-model="showSuccess"
color="success" color="success"
:timeout="3000" :timeout="3000"
> >
{{ successMessage }} {{ successMessage }}
</VSnackbar> </VSnackbar>
</div>
</template> </template>

View File

@@ -2,7 +2,6 @@
import { useMyOrganisation } from '@/composables/api/useOrganisations' import { useMyOrganisation } from '@/composables/api/useOrganisations'
import { useAuthStore } from '@/stores/useAuthStore' import { useAuthStore } from '@/stores/useAuthStore'
import EditOrganisationDialog from '@/components/organisations/EditOrganisationDialog.vue' import EditOrganisationDialog from '@/components/organisations/EditOrganisationDialog.vue'
import CrowdTypesManager from '@/components/organisations/CrowdTypesManager.vue'
import type { Organisation } from '@/types/organisation' import type { Organisation } from '@/types/organisation'
const authStore = useAuthStore() const authStore = useAuthStore()
@@ -124,13 +123,6 @@ function formatDate(iso: string) {
</VCardText> </VCardText>
</VCard> </VCard>
<!-- Crowd Types -->
<CrowdTypesManager
v-if="isOrgAdmin"
:org-id="organisation.id"
class="mt-6"
/>
<EditOrganisationDialog <EditOrganisationDialog
v-model="isEditDialogOpen" v-model="isEditDialogOpen"
:organisation="organisation" :organisation="organisation"

View File

@@ -2,6 +2,7 @@
import { useOrganisationStore } from '@/stores/useOrganisationStore' import { useOrganisationStore } from '@/stores/useOrganisationStore'
import PersonTagsTab from '@/components/organisation/PersonTagsTab.vue' import PersonTagsTab from '@/components/organisation/PersonTagsTab.vue'
import RegistrationFieldTemplatesTab from '@/components/organisation/RegistrationFieldTemplatesTab.vue' import RegistrationFieldTemplatesTab from '@/components/organisation/RegistrationFieldTemplatesTab.vue'
import CrowdTypesManager from '@/components/organisations/CrowdTypesManager.vue'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
@@ -12,6 +13,7 @@ const orgId = computed(() => orgStore.activeOrganisationId ?? '')
const tabs = [ const tabs = [
{ value: 'tags', label: 'Tags & Vaardigheden', icon: 'tabler-tag' }, { value: 'tags', label: 'Tags & Vaardigheden', icon: 'tabler-tag' },
{ value: 'templates', label: 'Registratieveld-templates', icon: 'tabler-forms' }, { value: 'templates', label: 'Registratieveld-templates', icon: 'tabler-forms' },
{ value: 'crowd-types', label: 'Crowd types', icon: 'tabler-users-group' },
] ]
const activeTab = computed({ const activeTab = computed({
@@ -62,6 +64,9 @@ const activeTab = computed({
<VWindowItem value="templates"> <VWindowItem value="templates">
<RegistrationFieldTemplatesTab :org-id="orgId" /> <RegistrationFieldTemplatesTab :org-id="orgId" />
</VWindowItem> </VWindowItem>
<VWindowItem value="crowd-types">
<CrowdTypesManager :org-id="orgId" />
</VWindowItem>
</VWindow> </VWindow>
</div> </div>
</template> </template>