feat: add "Lid toevoegen als deelnemer" shortcut for org members

Adds two new API endpoints to quickly add organisation members as event
persons with user_id pre-linked and status approved:
- GET /organisations/{org}/members/available-for-event/{event}
- POST /organisations/{org}/events/{event}/persons/from-member

Includes frontend dialog with member search, crowd type selection, and
click-to-add behavior in the Personen tab.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-14 18:38:53 +02:00
parent 624756e505
commit a29fa32ac6
11 changed files with 699 additions and 6 deletions

View File

@@ -10,6 +10,7 @@ declare module 'vue' {
AddEditAddressDialog: typeof import('./src/components/dialogs/AddEditAddressDialog.vue')['default']
AddEditPermissionDialog: typeof import('./src/components/dialogs/AddEditPermissionDialog.vue')['default']
AddEditRoleDialog: typeof import('./src/components/dialogs/AddEditRoleDialog.vue')['default']
AddMemberAsPersonDialog: typeof import('./src/components/persons/AddMemberAsPersonDialog.vue')['default']
AddPersonToCrowdListDialog: typeof import('./src/components/crowd-lists/AddPersonToCrowdListDialog.vue')['default']
AppAutocomplete: typeof import('./src/@core/components/app-form-elements/AppAutocomplete.vue')['default']
AppBarSearch: typeof import('./src/@core/components/AppBarSearch.vue')['default']

View File

@@ -0,0 +1,231 @@
<script setup lang="ts">
import { useAvailableMembers, useCreatePersonFromMember } from '@/composables/api/usePersons'
import { useCrowdTypeList } from '@/composables/api/useCrowdTypes'
import { requiredValidator } from '@core/utils/validators'
import type { AvailableMember } from '@/types/member'
const props = defineProps<{
eventId: string
orgId: string
}>()
const modelValue = defineModel<boolean>({ required: true })
const eventIdRef = computed(() => props.eventId)
const orgIdRef = computed(() => props.orgId)
const { data: crowdTypes } = useCrowdTypeList(orgIdRef)
const { data: availableMembers, isLoading, isError, refetch } = useAvailableMembers(orgIdRef, eventIdRef, modelValue)
const { mutate: createFromMember, isPending: isCreating } = useCreatePersonFromMember(orgIdRef, eventIdRef)
const searchQuery = ref('')
const selectedCrowdTypeId = ref('')
const showSuccess = ref(false)
const successName = ref('')
const creatingMemberId = ref<string | null>(null)
const errorMessage = ref('')
const crowdTypeItems = computed(() =>
crowdTypes.value
?.filter(ct => ct.is_active && (ct.system_type === 'CREW' || ct.system_type === 'VOLUNTEER'))
.map(ct => ({
title: ct.name,
value: ct.id,
})) ?? [],
)
// Default to first CREW type
watch(crowdTypeItems, items => {
if (!selectedCrowdTypeId.value && items.length > 0) {
const crewType = crowdTypes.value?.find(ct => ct.is_active && ct.system_type === 'CREW')
selectedCrowdTypeId.value = crewType?.id ?? items[0].value
}
}, { immediate: true })
const filteredMembers = computed(() => {
if (!availableMembers.value) return []
if (!searchQuery.value) return availableMembers.value
const q = searchQuery.value.toLowerCase()
return availableMembers.value.filter(
m => m.full_name.toLowerCase().includes(q) || m.email.toLowerCase().includes(q),
)
})
function onAddMember(member: AvailableMember) {
if (!selectedCrowdTypeId.value) return
creatingMemberId.value = member.id
errorMessage.value = ''
createFromMember(
{ user_id: member.id, crowd_type_id: selectedCrowdTypeId.value },
{
onSuccess: () => {
successName.value = member.full_name
showSuccess.value = true
creatingMemberId.value = null
},
onError: (err: unknown) => {
creatingMemberId.value = null
const axiosError = err as { response?: { data?: { message?: string; errors?: Record<string, string[]> } } }
const data = axiosError.response?.data
if (data?.errors?.user_id) {
errorMessage.value = data.errors.user_id[0]
}
else if (data?.message) {
errorMessage.value = data.message
}
else {
errorMessage.value = 'Er ging iets mis bij het toevoegen.'
}
},
},
)
}
function onClose() {
searchQuery.value = ''
errorMessage.value = ''
}
</script>
<template>
<VDialog
v-model="modelValue"
max-width="550"
@after-leave="onClose"
>
<VCard title="Lid toevoegen als deelnemer">
<VCardText>
<VRow>
<VCol cols="12">
<AppSelect
v-model="selectedCrowdTypeId"
label="Crowd Type"
:items="crowdTypeItems"
:rules="[requiredValidator]"
/>
</VCol>
<VCol cols="12">
<AppTextField
v-model="searchQuery"
label="Zoek op naam of e-mail"
prepend-inner-icon="tabler-search"
clearable
/>
</VCol>
</VRow>
<VAlert
v-if="errorMessage"
type="error"
class="mt-4 mb-2"
closable
@click:close="errorMessage = ''"
>
{{ errorMessage }}
</VAlert>
<!-- Loading -->
<VSkeletonLoader
v-if="isLoading"
type="list-item-two-line, list-item-two-line, list-item-two-line"
class="mt-4"
/>
<!-- Error -->
<VAlert
v-else-if="isError"
type="error"
class="mt-4"
>
Kon beschikbare leden niet laden.
<template #append>
<VBtn
variant="text"
@click="refetch()"
>
Opnieuw proberen
</VBtn>
</template>
</VAlert>
<!-- Empty state -->
<div
v-else-if="!filteredMembers.length"
class="text-center pa-8"
>
<VIcon
icon="tabler-users-check"
size="48"
class="mb-4 text-disabled"
/>
<p class="text-body-1 text-disabled mb-0">
{{ searchQuery
? 'Geen leden gevonden voor deze zoekopdracht'
: 'Alle organisatieleden zijn al toegevoegd aan dit evenement'
}}
</p>
</div>
<!-- Member list -->
<VList
v-else
class="mt-4"
>
<VListItem
v-for="member in filteredMembers"
:key="member.id"
:disabled="isCreating && creatingMemberId === member.id"
@click="onAddMember(member)"
>
<template #prepend>
<VAvatar
size="36"
color="primary"
variant="tonal"
>
<span class="text-caption">
{{ member.first_name[0] }}{{ member.last_name[0] }}
</span>
</VAvatar>
</template>
<VListItemTitle>{{ member.full_name }}</VListItemTitle>
<VListItemSubtitle>{{ member.email }}</VListItemSubtitle>
<template #append>
<VProgressCircular
v-if="isCreating && creatingMemberId === member.id"
size="20"
width="2"
indeterminate
/>
<VIcon
v-else
icon="tabler-plus"
size="20"
/>
</template>
</VListItem>
</VList>
</VCardText>
<VCardActions>
<VSpacer />
<VBtn
variant="text"
@click="modelValue = false"
>
Sluiten
</VBtn>
</VCardActions>
</VCard>
</VDialog>
<VSnackbar
v-model="showSuccess"
color="success"
:timeout="3000"
>
{{ successName }} toegevoegd als deelnemer
</VSnackbar>
</template>

View File

@@ -1,6 +1,7 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
import type { Ref } from 'vue'
import { apiClient } from '@/lib/axios'
import type { AvailableMember } from '@/types/member'
import type { CreatePersonPayload, Person, UpdatePersonPayload } from '@/types/person'
interface ApiResponse<T> {
@@ -128,3 +129,36 @@ export function useDeletePerson(orgId: Ref<string>, eventId: Ref<string>) {
},
})
}
export function useAvailableMembers(orgId: Ref<string>, eventId: Ref<string>, enabled: Ref<boolean>) {
return useQuery({
queryKey: ['available-members', orgId, eventId],
queryFn: async () => {
const { data } = await apiClient.get<{ data: AvailableMember[] }>(
`/organisations/${orgId.value}/members/available-for-event/${eventId.value}`,
)
return data.data
},
enabled: () => !!orgId.value && !!eventId.value && enabled.value,
})
}
export function useCreatePersonFromMember(orgId: Ref<string>, eventId: Ref<string>) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (payload: { user_id: string; crowd_type_id: string }) => {
const { data } = await apiClient.post<ApiResponse<Person>>(
`/organisations/${orgId.value}/events/${eventId.value}/persons/from-member`,
payload,
)
return data.data
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['persons', eventId.value] })
queryClient.invalidateQueries({ queryKey: ['available-members', orgId.value, eventId.value] })
},
})
}

View File

@@ -3,6 +3,7 @@ import { usePersonList, useApprovePerson, useDeletePerson } from '@/composables/
import { useCrowdTypeList } from '@/composables/api/useCrowdTypes'
import { useAuthStore } from '@/stores/useAuthStore'
import EventTabsNav from '@/components/events/EventTabsNav.vue'
import AddMemberAsPersonDialog from '@/components/persons/AddMemberAsPersonDialog.vue'
import CreatePersonDialog from '@/components/persons/CreatePersonDialog.vue'
import EditPersonDialog from '@/components/persons/EditPersonDialog.vue'
import PersonDetailPanel from '@/components/persons/PersonDetailPanel.vue'
@@ -112,6 +113,7 @@ function getInitials(name: string) {
}
// Dialogs & panel
const isAddMemberDialogOpen = ref(false)
const isCreateDialogOpen = ref(false)
const isEditDialogOpen = ref(false)
const editingPerson = ref<Person | null>(null)
@@ -194,12 +196,21 @@ const crowdTypeOptions = computed(() => [
style="min-inline-size: 180px;"
/>
</div>
<VBtn
prepend-icon="tabler-plus"
@click="isCreateDialogOpen = true"
>
Persoon toevoegen
</VBtn>
<div class="d-flex gap-x-2">
<VBtn
prepend-icon="tabler-user-plus"
variant="tonal"
@click="isAddMemberDialogOpen = true"
>
Lid toevoegen
</VBtn>
<VBtn
prepend-icon="tabler-plus"
@click="isCreateDialogOpen = true"
>
Persoon toevoegen
</VBtn>
</div>
</div>
<!-- KPI Tiles -->
@@ -378,6 +389,13 @@ const crowdTypeOptions = computed(() => [
</VDataTable>
</VCard>
<!-- Add member dialog -->
<AddMemberAsPersonDialog
v-model="isAddMemberDialogOpen"
:event-id="eventId"
:org-id="orgId"
/>
<!-- Create dialog -->
<CreatePersonDialog
v-model="isCreateDialogOpen"

View File

@@ -24,6 +24,14 @@ export type OrganisationRole =
| 'staff_coordinator'
| 'volunteer_coordinator'
export interface AvailableMember {
id: string
first_name: string
last_name: string
full_name: string
email: string
}
export interface MemberListResponse {
data: Member[]
meta: {