feat: crowd types management UI with create/edit/deactivate

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-10 11:15:51 +02:00
parent d37a45b028
commit 169a078a92
6 changed files with 511 additions and 10 deletions

View File

@@ -0,0 +1,393 @@
<script setup lang="ts">
import { VForm } from 'vuetify/components/VForm'
import { useCrowdTypeList, useCreateCrowdType, useUpdateCrowdType, useDeleteCrowdType } from '@/composables/api/useCrowdTypes'
import { requiredValidator } from '@core/utils/validators'
import type { CrowdType } from '@/types/organisation'
const props = defineProps<{
orgId: string
}>()
const orgIdRef = computed(() => props.orgId)
const { data: crowdTypes, isLoading } = useCrowdTypeList(orgIdRef)
const { mutate: createCrowdType, isPending: isCreating } = useCreateCrowdType(orgIdRef)
const { mutate: updateCrowdType, isPending: isUpdating } = useUpdateCrowdType(orgIdRef)
const { mutate: deleteCrowdType } = useDeleteCrowdType(orgIdRef)
const isDialogOpen = ref(false)
const editingCrowdType = ref<CrowdType | null>(null)
const errors = ref<Record<string, string>>({})
const refVForm = ref<VForm>()
const showSuccess = ref(false)
const successMessage = ref('')
const systemTypeOptions = [
{ title: 'Crew', value: 'CREW' },
{ title: 'Gast', value: 'GUEST' },
{ title: 'Artiest', value: 'ARTIST' },
{ title: 'Vrijwilliger', value: 'VOLUNTEER' },
{ title: 'Pers', value: 'PRESS' },
{ title: 'Partner', value: 'PARTNER' },
{ title: 'Leverancier', value: 'SUPPLIER' },
]
const systemTypeLabels: Record<string, string> = {
CREW: 'Crew',
GUEST: 'Gast',
ARTIST: 'Artiest',
VOLUNTEER: 'Vrijwilliger',
PRESS: 'Pers',
PARTNER: 'Partner',
SUPPLIER: 'Leverancier',
}
const form = ref({
name: '',
system_type: '',
color: '#3b82f6',
icon: '',
})
const activeCrowdTypes = computed(() =>
crowdTypes.value?.filter(ct => ct.is_active) ?? [],
)
const inactiveCrowdTypes = computed(() =>
crowdTypes.value?.filter(ct => !ct.is_active) ?? [],
)
const dialogTitle = computed(() =>
editingCrowdType.value ? 'Crowd type bewerken' : 'Crowd type aanmaken',
)
const isSaving = computed(() => isCreating.value || isUpdating.value)
function openCreateDialog() {
editingCrowdType.value = null
form.value = { name: '', system_type: '', color: '#3b82f6', icon: '' }
errors.value = {}
isDialogOpen.value = true
}
function openEditDialog(ct: CrowdType) {
editingCrowdType.value = ct
form.value = {
name: ct.name,
system_type: ct.system_type,
color: ct.color,
icon: ct.icon ?? '',
}
errors.value = {}
isDialogOpen.value = true
}
function onSubmit() {
refVForm.value?.validate().then(({ valid }) => {
if (!valid) return
errors.value = {}
const payload = {
name: form.value.name,
system_type: form.value.system_type,
color: form.value.color,
...(form.value.icon ? { icon: form.value.icon } : { icon: null }),
}
if (editingCrowdType.value) {
updateCrowdType(
{ id: editingCrowdType.value.id, ...payload },
{
onSuccess: () => {
isDialogOpen.value = false
successMessage.value = `${form.value.name} bijgewerkt`
showSuccess.value = true
},
onError: handleError,
},
)
}
else {
createCrowdType(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(ct: CrowdType) {
deleteCrowdType(ct.id, {
onSuccess: () => {
successMessage.value = `${ct.name} gedeactiveerd`
showSuccess.value = true
},
})
}
function activate(ct: CrowdType) {
updateCrowdType(
{ id: ct.id, is_active: true },
{
onSuccess: () => {
successMessage.value = `${ct.name} geactiveerd`
showSuccess.value = true
},
},
)
}
</script>
<template>
<VCard>
<VCardItem>
<VCardTitle>Crowd types</VCardTitle>
<VCardSubtitle>Definieer de deelnemertypes voor je organisatie</VCardSubtitle>
<template #append>
<VBtn
prepend-icon="tabler-plus"
@click="openCreateDialog"
>
Crowd type toevoegen
</VBtn>
</template>
</VCardItem>
<VCardText>
<!-- Loading -->
<VSkeletonLoader
v-if="isLoading"
type="list-item@3"
/>
<template v-else>
<!-- Empty state -->
<VAlert
v-if="!crowdTypes?.length"
type="info"
variant="tonal"
>
Nog geen crowd types aangemaakt. Maak er een aan om personen te categoriseren.
</VAlert>
<!-- Active crowd types -->
<VList
v-if="activeCrowdTypes.length"
lines="one"
>
<VListItem
v-for="ct in activeCrowdTypes"
:key="ct.id"
>
<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>
<VListItemSubtitle>
<VChip
size="x-small"
variant="tonal"
class="mt-1"
>
{{ systemTypeLabels[ct.system_type] ?? ct.system_type }}
</VChip>
</VListItemSubtitle>
<template #append>
<VBtn
icon="tabler-edit"
variant="text"
size="small"
@click="openEditDialog(ct)"
/>
<VBtn
icon="tabler-eye-off"
variant="text"
size="small"
color="warning"
@click="deactivate(ct)"
/>
</template>
</VListItem>
</VList>
<!-- Inactive crowd types -->
<template v-if="inactiveCrowdTypes.length">
<VDivider class="my-4" />
<p class="text-body-2 text-disabled mb-2">
Inactief
</p>
<VList
lines="one"
class="opacity-60"
>
<VListItem
v-for="ct in inactiveCrowdTypes"
:key="ct.id"
>
<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 class="text-disabled">
{{ ct.name }}
</VListItemTitle>
<VListItemSubtitle>
<VChip
size="x-small"
variant="tonal"
class="mt-1"
>
{{ systemTypeLabels[ct.system_type] ?? ct.system_type }}
</VChip>
</VListItemSubtitle>
<template #append>
<VBtn
variant="tonal"
size="small"
color="success"
@click="activate(ct)"
>
Activeren
</VBtn>
</template>
</VListItem>
</VList>
</template>
</template>
</VCardText>
</VCard>
<!-- 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">
<AppSelect
v-model="form.system_type"
label="Systeemtype"
:items="systemTypeOptions"
:rules="[requiredValidator]"
:error-messages="errors.system_type"
:disabled="!!editingCrowdType"
hint="Bepaalt hoe dit type wordt gebruikt in het systeem"
persistent-hint
/>
</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"
md="6"
>
<AppTextField
v-model="form.icon"
label="Icoon"
placeholder="tabler-users"
:error-messages="errors.icon"
/>
</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>
</template>

View File

@@ -1,4 +1,4 @@
import { useQuery } from '@tanstack/vue-query'
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
import type { Ref } from 'vue'
import { apiClient } from '@/lib/axios'
import type { CrowdType } from '@/types/organisation'
@@ -14,6 +14,12 @@ interface PaginatedResponse<T> {
}
}
interface ApiResponse<T> {
success: boolean
data: T
message?: string
}
export function useCrowdTypeList(orgId: Ref<string>) {
return useQuery({
queryKey: ['crowd-types', orgId],
@@ -28,3 +34,50 @@ export function useCrowdTypeList(orgId: Ref<string>) {
staleTime: Infinity,
})
}
export function useCreateCrowdType(orgId: Ref<string>) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (payload: { name: string; system_type: string; color: string; icon?: string | null }) => {
const { data } = await apiClient.post<ApiResponse<CrowdType>>(
`/organisations/${orgId.value}/crowd-types`,
payload,
)
return data.data
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['crowd-types', orgId] })
},
})
}
export function useUpdateCrowdType(orgId: Ref<string>) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async ({ id, ...payload }: { id: string; name?: string; color?: string; icon?: string | null; is_active?: boolean }) => {
const { data } = await apiClient.put<ApiResponse<CrowdType>>(
`/organisations/${orgId.value}/crowd-types/${id}`,
payload,
)
return data.data
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['crowd-types', orgId] })
},
})
}
export function useDeleteCrowdType(orgId: Ref<string>) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (id: string) => {
await apiClient.delete(`/organisations/${orgId.value}/crowd-types/${id}`)
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['crowd-types', orgId] })
},
})
}

View File

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