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:
231
apps/app/src/components/persons/AddMemberAsPersonDialog.vue
Normal file
231
apps/app/src/components/persons/AddMemberAsPersonDialog.vue
Normal 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>
|
||||
Reference in New Issue
Block a user