feat(app): enhanced crowd list detail panel with person management

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-10 15:04:36 +02:00
parent 69306206b1
commit ee1ee6f41d
3 changed files with 720 additions and 117 deletions

View File

@@ -1,9 +1,17 @@
<script setup lang="ts"> <script setup lang="ts">
import {
useCrowdListPersons,
useRemovePersonFromCrowdList,
useApproveAllPending,
} from '@/composables/api/useCrowdLists'
import { useCrowdTypeList } from '@/composables/api/useCrowdTypes' import { useCrowdTypeList } from '@/composables/api/useCrowdTypes'
import { useCompanies } from '@/composables/api/useCompanies' import { useCompanies } from '@/composables/api/useCompanies'
import { useApprovePerson } from '@/composables/api/usePersons'
import AddPersonToCrowdListDialog from '@/components/crowd-lists/AddPersonToCrowdListDialog.vue' import AddPersonToCrowdListDialog from '@/components/crowd-lists/AddPersonToCrowdListDialog.vue'
import { CrowdListType } from '@/types/crowdList' import { CrowdListType } from '@/types/crowdList'
import type { CrowdList } from '@/types/crowdList' import type { CrowdList } from '@/types/crowdList'
import { PersonStatus } from '@/types/person'
import type { Person } from '@/types/person'
const props = defineProps<{ const props = defineProps<{
eventId: string eventId: string
@@ -19,13 +27,27 @@ const modelValue = defineModel<boolean>({ required: true })
const eventIdRef = computed(() => props.eventId) const eventIdRef = computed(() => props.eventId)
const orgIdRef = computed(() => props.orgId) const orgIdRef = computed(() => props.orgId)
const crowdListIdRef = computed(() => props.crowdList?.id ?? '')
// Data fetching
const { data: crowdTypes } = useCrowdTypeList(orgIdRef) const { data: crowdTypes } = useCrowdTypeList(orgIdRef)
const { data: companies } = useCompanies(orgIdRef) const { data: companies } = useCompanies(orgIdRef)
const {
data: personsResponse,
isLoading: personsLoading,
isError: personsError,
refetch: refetchPersons,
} = useCrowdListPersons(eventIdRef, crowdListIdRef)
const crowdTypeName = computed(() => { // Mutations
if (!props.crowdList) return '-' const { mutate: removePerson, isPending: isRemoving } = useRemovePersonFromCrowdList(eventIdRef)
return crowdTypes.value?.find(ct => ct.id === props.crowdList!.crowd_type_id)?.name ?? '-' const { mutate: approvePerson } = useApprovePerson(eventIdRef)
const { mutate: approveAllPending, isPending: isApprovingAll } = useApproveAllPending(eventIdRef)
// Resolved lookups
const crowdTypeObj = computed(() => {
if (!props.crowdList) return null
return crowdTypes.value?.find(ct => ct.id === props.crowdList!.crowd_type_id) ?? null
}) })
const companyName = computed(() => { const companyName = computed(() => {
@@ -33,6 +55,49 @@ const companyName = computed(() => {
return companies.value?.find(c => c.id === props.crowdList!.recipient_company_id)?.name ?? '-' return companies.value?.find(c => c.id === props.crowdList!.recipient_company_id)?.name ?? '-'
}) })
// Persons list
const persons = computed(() => personsResponse.value?.data ?? [])
// Search & filter
const searchQuery = ref('')
const statusFilter = ref<PersonStatus | ''>('')
const filteredPersons = computed(() => {
let result = persons.value
if (searchQuery.value) {
const q = searchQuery.value.toLowerCase()
result = result.filter(
(p: Person) => p.name.toLowerCase().includes(q) || p.email.toLowerCase().includes(q),
)
}
if (statusFilter.value) {
result = result.filter((p: Person) => p.status === statusFilter.value)
}
return result
})
// Status breakdown
const statusCounts = computed(() => {
const counts = { approved: 0, pending: 0, rejected: 0, other: 0 }
for (const p of persons.value) {
if (p.status === PersonStatus.APPROVED) counts.approved++
else if (p.status === PersonStatus.PENDING || p.status === PersonStatus.APPLIED) counts.pending++
else if (p.status === PersonStatus.REJECTED) counts.rejected++
else counts.other++
}
return counts
})
const pendingPersonIds = computed(() =>
persons.value
.filter((p: Person) => p.status === PersonStatus.PENDING || p.status === PersonStatus.APPLIED)
.map((p: Person) => p.id),
)
// Capacity
const capacityPercentage = computed(() => { const capacityPercentage = computed(() => {
if (!props.crowdList?.max_persons) return null if (!props.crowdList?.max_persons) return null
return Math.round((props.crowdList.persons_count / props.crowdList.max_persons) * 100) return Math.round((props.crowdList.persons_count / props.crowdList.max_persons) * 100)
@@ -41,23 +106,122 @@ const capacityPercentage = computed(() => {
const capacityColor = computed(() => { const capacityColor = computed(() => {
const pct = capacityPercentage.value const pct = capacityPercentage.value
if (pct === null) return 'primary' if (pct === null) return 'primary'
if (pct > 100) return 'purple'
if (pct >= 80) return 'success' if (pct >= 80) return 'success'
if (pct >= 50) return 'warning' if (pct >= 50) return 'warning'
return 'error' return 'error'
}) })
// Status color map
const statusColor: Record<PersonStatus, string> = {
[PersonStatus.APPROVED]: 'success',
[PersonStatus.PENDING]: 'warning',
[PersonStatus.APPLIED]: 'info',
[PersonStatus.INVITED]: 'cyan',
[PersonStatus.REJECTED]: 'error',
[PersonStatus.NO_SHOW]: 'default',
}
const statusLabel: Record<PersonStatus, string> = {
[PersonStatus.APPROVED]: 'Goedgekeurd',
[PersonStatus.PENDING]: 'Pending',
[PersonStatus.APPLIED]: 'Aangemeld',
[PersonStatus.INVITED]: 'Uitgenodigd',
[PersonStatus.REJECTED]: 'Afgewezen',
[PersonStatus.NO_SHOW]: 'No-show',
}
// Filter options
const statusFilterOptions = [
{ title: 'Alle', value: '' },
{ title: 'Goedgekeurd', value: PersonStatus.APPROVED },
{ title: 'Pending', value: PersonStatus.PENDING },
{ title: 'Afgewezen', value: PersonStatus.REJECTED },
]
// Dialogs & state
const isAddPersonDialogOpen = ref(false) const isAddPersonDialogOpen = ref(false)
const isRemoveDialogOpen = ref(false)
const removingPerson = ref<Person | null>(null)
const isApproveAllDialogOpen = ref(false)
const showSuccess = ref(false) const showSuccess = ref(false)
const successMessage = ref('') const successMessage = ref('')
const dateFormatter = new Intl.DateTimeFormat('nl-NL', { // Date formatting
day: '2-digit', const longDateFormatter = new Intl.DateTimeFormat('nl-NL', {
month: '2-digit', day: 'numeric',
month: 'long',
year: 'numeric', year: 'numeric',
}) })
function formatDate(iso: string) { const shortDateFormatter = new Intl.DateTimeFormat('nl-NL', {
return dateFormatter.format(new Date(iso)) day: 'numeric',
month: 'short',
year: 'numeric',
})
function formatLongDate(iso: string) {
return longDateFormatter.format(new Date(iso))
}
function formatShortDate(iso: string) {
return shortDateFormatter.format(new Date(iso))
}
function getInitials(name: string) {
return name
.split(' ')
.map(p => p[0])
.join('')
.toUpperCase()
.slice(0, 2)
}
// Actions
function onApprove(person: Person) {
approvePerson(person.id, {
onSuccess: () => {
successMessage.value = `${person.name} goedgekeurd`
showSuccess.value = true
refetchPersons()
},
})
}
function onRemoveConfirm(person: Person) {
removingPerson.value = person
isRemoveDialogOpen.value = true
}
function onRemoveExecute() {
if (!removingPerson.value || !props.crowdList) return
const name = removingPerson.value.name
removePerson(
{ listId: props.crowdList.id, personId: removingPerson.value.id },
{
onSuccess: () => {
isRemoveDialogOpen.value = false
removingPerson.value = null
successMessage.value = `${name} verwijderd van lijst`
showSuccess.value = true
refetchPersons()
},
},
)
}
function onApproveAllExecute() {
if (!pendingPersonIds.value.length) return
approveAllPending(pendingPersonIds.value, {
onSuccess: (result) => {
isApproveAllDialogOpen.value = false
successMessage.value = `${result.approved} ${result.approved === 1 ? 'persoon' : 'personen'} goedgekeurd`
showSuccess.value = true
refetchPersons()
},
})
} }
</script> </script>
@@ -66,40 +230,15 @@ function formatDate(iso: string) {
v-model="modelValue" v-model="modelValue"
location="end" location="end"
temporary temporary
:width="520" :width="560"
> >
<template v-if="crowdList"> <template v-if="crowdList">
<!-- Header --> <!-- Section 1: Header -->
<div class="pa-6"> <div class="pa-6 pb-4">
<div class="d-flex justify-space-between align-start mb-4"> <div class="d-flex justify-space-between align-start mb-3">
<div> <h5 class="text-h5">
<h5 class="text-h5 mb-1"> {{ crowdList.name }}
{{ crowdList.name }} </h5>
</h5>
<div class="d-flex gap-x-2 flex-wrap">
<VChip
:color="crowdList.type === CrowdListType.INTERNAL ? 'primary' : 'info'"
size="small"
>
{{ crowdList.type === CrowdListType.INTERNAL ? 'Intern' : 'Extern' }}
</VChip>
<VChip
v-if="crowdList.is_full"
color="error"
size="small"
>
Vol
</VChip>
<VChip
v-if="crowdList.auto_approve"
color="success"
size="small"
variant="tonal"
>
Auto-approve
</VChip>
</div>
</div>
<div class="d-flex gap-x-1"> <div class="d-flex gap-x-1">
<VBtn <VBtn
icon="tabler-edit" icon="tabler-edit"
@@ -117,86 +256,317 @@ function formatDate(iso: string) {
/> />
</div> </div>
</div> </div>
</div>
<VDivider /> <div class="d-flex gap-x-2 flex-wrap mb-4">
<VChip
:color="crowdList.type === CrowdListType.INTERNAL ? 'primary' : 'info'"
size="small"
>
{{ crowdList.type === CrowdListType.INTERNAL ? 'Intern' : 'Extern' }}
</VChip>
<VChip
v-if="crowdList.is_full"
color="error"
size="small"
>
Vol
</VChip>
</div>
<!-- Info section --> <VList
<div class="pa-6"> density="compact"
<VList class="pa-0"> class="pa-0"
<VListItem> >
<VListItem density="compact">
<template #prepend> <template #prepend>
<VIcon <VIcon
icon="tabler-users-group" icon="tabler-users-group"
size="18"
class="me-3" class="me-3"
/> />
</template> </template>
<VListItemTitle>Crowd Type</VListItemTitle> <VListItemTitle class="text-body-2">
<VListItemSubtitle>{{ crowdTypeName }}</VListItemSubtitle> Crowd Type
</VListItemTitle>
<template #append>
<div class="d-flex align-center gap-x-2">
<VIcon
v-if="crowdTypeObj?.color"
icon="tabler-circle-filled"
:color="crowdTypeObj.color"
size="10"
/>
<span class="text-body-2">{{ crowdTypeObj?.name ?? '-' }}</span>
</div>
</template>
</VListItem> </VListItem>
<VListItem v-if="companyName"> <VListItem
v-if="companyName"
density="compact"
>
<template #prepend> <template #prepend>
<VIcon <VIcon
icon="tabler-building" icon="tabler-building"
size="18"
class="me-3" class="me-3"
/> />
</template> </template>
<VListItemTitle>Ontvangende organisatie</VListItemTitle> <VListItemTitle class="text-body-2">
<VListItemSubtitle>{{ companyName }}</VListItemSubtitle> Bedrijf
</VListItemTitle>
<template #append>
<span class="text-body-2">{{ companyName }}</span>
</template>
</VListItem> </VListItem>
<VListItem> <VListItem density="compact">
<template #prepend> <template #prepend>
<VIcon <VIcon
icon="tabler-calendar" icon="tabler-calendar"
size="18"
class="me-3" class="me-3"
/> />
</template> </template>
<VListItemTitle>Aangemaakt op</VListItemTitle> <VListItemTitle class="text-body-2">
<VListItemSubtitle>{{ formatDate(crowdList.created_at) }}</VListItemSubtitle> Aangemaakt
</VListItemTitle>
<template #append>
<span class="text-body-2">{{ formatLongDate(crowdList.created_at) }}</span>
</template>
</VListItem> </VListItem>
</VList> </VList>
<!-- Capacity progress --> <VRow class="mt-2">
<template v-if="crowdList.max_persons"> <VCol
<VDivider class="my-4" /> cols="6"
<div class="d-flex justify-space-between align-center mb-2"> >
<span class="text-body-2 font-weight-medium">Capaciteit</span> <VCard
<span class="text-body-2"> variant="outlined"
{{ crowdList.persons_count }} / {{ crowdList.max_persons }} personen class="pa-3 text-center"
</span> >
</div> <VIcon
<VProgressLinear :icon="crowdList.auto_approve ? 'tabler-circle-check' : 'tabler-circle-x'"
:model-value="capacityPercentage ?? 0" :color="crowdList.auto_approve ? 'success' : 'default'"
:color="capacityColor" size="20"
rounded class="mb-1"
height="8" />
/> <p class="text-caption text-disabled mb-0">
</template> Auto-approve
</p>
<p class="text-body-2 font-weight-medium mb-0">
{{ crowdList.auto_approve ? 'Aan' : 'Uit' }}
</p>
</VCard>
</VCol>
<VCol
cols="6"
>
<VCard
variant="outlined"
class="pa-3 text-center"
>
<VIcon
icon="tabler-users"
size="20"
class="mb-1"
/>
<p class="text-caption text-disabled mb-0">
Max personen
</p>
<p class="text-body-2 font-weight-medium mb-0">
{{ crowdList.max_persons ?? 'Geen limiet' }}
</p>
</VCard>
</VCol>
</VRow>
</div> </div>
<VDivider /> <VDivider />
<!-- Persons section --> <!-- Section 2: Capacity Overview -->
<div class="pa-6"> <div class="pa-6 py-4">
<div class="d-flex justify-space-between align-center mb-4"> <template v-if="crowdList.max_persons">
<div class="d-flex justify-space-between align-center mb-2">
<span class="text-body-2 font-weight-medium">Bezetting</span>
<span class="text-body-2">
{{ crowdList.persons_count }} / {{ crowdList.max_persons }}
({{ capacityPercentage }}%)
</span>
</div>
<VProgressLinear
:model-value="Math.min(capacityPercentage ?? 0, 100)"
:color="capacityColor"
rounded
height="8"
class="mb-3"
/>
<!-- Status breakdown tiles -->
<VRow dense>
<VCol cols="3">
<VCard
variant="tonal"
color="success"
class="pa-2 text-center"
>
<p class="text-h6 mb-0">
{{ statusCounts.approved }}
</p>
<p class="text-caption mb-0">
Goedg.
</p>
</VCard>
</VCol>
<VCol cols="3">
<VCard
variant="tonal"
color="warning"
class="pa-2 text-center"
>
<p class="text-h6 mb-0">
{{ statusCounts.pending }}
</p>
<p class="text-caption mb-0">
Wacht
</p>
</VCard>
</VCol>
<VCol cols="3">
<VCard
variant="tonal"
color="error"
class="pa-2 text-center"
>
<p class="text-h6 mb-0">
{{ statusCounts.rejected }}
</p>
<p class="text-caption mb-0">
Afgew.
</p>
</VCard>
</VCol>
<VCol cols="3">
<VCard
variant="tonal"
class="pa-2 text-center"
>
<p class="text-h6 mb-0">
{{ statusCounts.other }}
</p>
<p class="text-caption mb-0">
Overig
</p>
</VCard>
</VCol>
</VRow>
</template>
<div
v-else
class="d-flex align-center gap-x-2"
>
<VIcon
icon="tabler-users"
size="20"
/>
<span class="text-body-2">
{{ crowdList.persons_count }} {{ crowdList.persons_count === 1 ? 'persoon' : 'personen' }}
· Geen limiet ingesteld
</span>
</div>
</div>
<VDivider />
<!-- Section 3: Quick Actions -->
<div class="pa-6 py-3 d-flex gap-x-2">
<VBtn
size="small"
prepend-icon="tabler-plus"
:disabled="crowdList.is_full"
@click="isAddPersonDialogOpen = true"
>
Persoon toevoegen
</VBtn>
<VBtn
v-if="pendingPersonIds.length > 0"
size="small"
variant="tonal"
color="success"
prepend-icon="tabler-circle-check"
:loading="isApprovingAll"
@click="isApproveAllDialogOpen = true"
>
Alle pending goedkeuren ({{ pendingPersonIds.length }})
</VBtn>
</div>
<VDivider />
<!-- Section 4: Person List -->
<div class="pa-6 pt-4">
<div class="d-flex justify-space-between align-center mb-3">
<h6 class="text-h6"> <h6 class="text-h6">
Personen ({{ crowdList.persons_count }}) Personen ({{ crowdList.persons_count }})
</h6> </h6>
<VBtn
size="small"
prepend-icon="tabler-plus"
:disabled="crowdList.is_full"
@click="isAddPersonDialogOpen = true"
>
Toevoegen
</VBtn>
</div> </div>
<!-- Search & filter -->
<div class="d-flex gap-x-3 mb-4">
<AppTextField
v-model="searchQuery"
placeholder="Zoeken op naam of e-mail..."
prepend-inner-icon="tabler-search"
density="compact"
clearable
hide-details
style="flex: 1;"
/>
<AppSelect
v-model="statusFilter"
:items="statusFilterOptions"
density="compact"
hide-details
style="min-inline-size: 140px;"
/>
</div>
<!-- Loading -->
<div v-if="personsLoading">
<VSkeletonLoader
type="list-item-three-line"
class="mb-2"
/>
<VSkeletonLoader
type="list-item-three-line"
class="mb-2"
/>
<VSkeletonLoader type="list-item-three-line" />
</div>
<!-- Error -->
<VAlert
v-else-if="personsError"
type="error"
variant="tonal"
class="mb-4"
>
Kon personen niet laden.
<template #append>
<VBtn
variant="text"
size="small"
@click="refetchPersons()"
>
Opnieuw proberen
</VBtn>
</template>
</VAlert>
<!-- Empty --> <!-- Empty -->
<VCard <VCard
v-if="crowdList.persons_count === 0" v-else-if="!persons.length"
variant="outlined" variant="outlined"
class="text-center pa-6" class="text-center pa-6"
> >
@@ -207,41 +577,126 @@ function formatDate(iso: string) {
/> />
<p class="text-body-2 text-disabled mb-0"> <p class="text-body-2 text-disabled mb-0">
Nog geen personen op deze lijst. Nog geen personen op deze lijst.
Voeg personen toe via de knop hierboven. Voeg personen toe om te beginnen.
</p> </p>
</VCard> </VCard>
<!-- Has persons (count only no list endpoint yet) --> <!-- No results (search/filter active) -->
<VCard <VCard
v-else v-else-if="!filteredPersons.length"
variant="outlined" variant="outlined"
class="pa-4" class="text-center pa-6"
> >
<div class="d-flex align-center gap-x-3"> <VIcon
<VAvatar icon="tabler-search"
color="primary" size="36"
variant="tonal" class="mb-2 text-disabled"
size="44" />
rounded <p class="text-body-2 text-disabled mb-0">
> Geen personen gevonden voor deze zoekopdracht.
<VIcon </p>
icon="tabler-users"
size="24"
/>
</VAvatar>
<div>
<p class="text-body-1 font-weight-medium mb-0">
{{ crowdList.persons_count }} {{ crowdList.persons_count === 1 ? 'persoon' : 'personen' }}
</p>
<p
v-if="!crowdList.max_persons"
class="text-body-2 text-disabled mb-0"
>
Geen limiet ingesteld
</p>
</div>
</div>
</VCard> </VCard>
<!-- Person list -->
<div v-else>
<VCard
v-for="person in filteredPersons"
:key="person.id"
variant="outlined"
class="mb-2"
>
<VCardText class="pa-3">
<div class="d-flex justify-space-between align-start">
<div class="d-flex gap-x-3 align-start flex-grow-1" style="min-width: 0;">
<VAvatar
size="36"
color="primary"
variant="tonal"
>
<span class="text-caption">{{ getInitials(person.name) }}</span>
</VAvatar>
<div style="min-width: 0;">
<p class="text-body-1 font-weight-medium mb-0 text-truncate">
{{ person.name }}
</p>
<p class="text-body-2 text-medium-emphasis mb-1 text-truncate">
{{ person.email }}
<template v-if="person.phone">
· {{ person.phone }}
</template>
</p>
<div class="d-flex gap-x-1 flex-wrap mb-1">
<VChip
:color="statusColor[person.status]"
size="x-small"
>
{{ statusLabel[person.status] ?? person.status }}
</VChip>
<VChip
v-if="person.crowd_type"
:color="person.crowd_type.color"
size="x-small"
variant="tonal"
>
{{ person.crowd_type.name }}
</VChip>
<VChip
v-for="tag in (person.tags ?? []).slice(0, 3)"
:key="tag.id"
size="x-small"
variant="outlined"
:color="tag.person_tag.color ?? undefined"
>
{{ tag.person_tag.name }}
</VChip>
<VChip
v-if="person.pending_identity_match"
size="x-small"
color="warning"
variant="tonal"
prepend-icon="tabler-alert-triangle"
>
Mogelijk account gevonden
</VChip>
</div>
<p
v-if="person.crowd_list_pivot"
class="text-caption text-disabled mb-0"
>
Toegevoegd: {{ formatShortDate(person.crowd_list_pivot.added_at) }}
</p>
</div>
</div>
<!-- Three-dot menu -->
<VMenu>
<template #activator="{ props: menuProps }">
<VBtn
icon="tabler-dots-vertical"
variant="text"
size="x-small"
v-bind="menuProps"
/>
</template>
<VList density="compact">
<VListItem
v-if="person.status === PersonStatus.PENDING || person.status === PersonStatus.APPLIED"
prepend-icon="tabler-circle-check"
title="Goedkeuren"
@click="onApprove(person)"
/>
<VListItem
prepend-icon="tabler-user-minus"
title="Verwijderen van lijst"
base-color="error"
@click="onRemoveConfirm(person)"
/>
</VList>
</VMenu>
</div>
</VCardText>
</VCard>
</div>
</div> </div>
</template> </template>
@@ -253,6 +708,68 @@ function formatDate(iso: string) {
:crowd-list="crowdList" :crowd-list="crowdList"
/> />
<!-- Remove person confirmation -->
<VDialog
v-model="isRemoveDialogOpen"
max-width="440"
>
<VCard title="Persoon verwijderen van lijst">
<VCardText>
Weet je zeker dat je <strong>{{ removingPerson?.name }}</strong> van de lijst
<strong>{{ crowdList?.name }}</strong> wilt verwijderen?
<br><br>
<span class="text-medium-emphasis">
De persoon wordt niet verwijderd uit het evenement alleen van deze lijst.
</span>
</VCardText>
<VCardActions>
<VSpacer />
<VBtn
variant="text"
@click="isRemoveDialogOpen = false"
>
Annuleren
</VBtn>
<VBtn
color="error"
:loading="isRemoving"
@click="onRemoveExecute"
>
Verwijderen
</VBtn>
</VCardActions>
</VCard>
</VDialog>
<!-- Approve all confirmation -->
<VDialog
v-model="isApproveAllDialogOpen"
max-width="440"
>
<VCard title="Alle pending goedkeuren">
<VCardText>
Weet je zeker dat je <strong>{{ pendingPersonIds.length }}</strong>
{{ pendingPersonIds.length === 1 ? 'persoon' : 'personen' }} wilt goedkeuren?
</VCardText>
<VCardActions>
<VSpacer />
<VBtn
variant="text"
@click="isApproveAllDialogOpen = false"
>
Annuleren
</VBtn>
<VBtn
color="success"
:loading="isApprovingAll"
@click="onApproveAllExecute"
>
Goedkeuren
</VBtn>
</VCardActions>
</VCard>
</VDialog>
<!-- Success snackbar --> <!-- Success snackbar -->
<VSnackbar <VSnackbar
v-model="showSuccess" v-model="showSuccess"

View File

@@ -2,6 +2,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
import type { Ref } from 'vue' import type { Ref } from 'vue'
import { apiClient } from '@/lib/axios' import { apiClient } from '@/lib/axios'
import type { CrowdList, CreateCrowdListDto, UpdateCrowdListDto } from '@/types/crowdList' import type { CrowdList, CreateCrowdListDto, UpdateCrowdListDto } from '@/types/crowdList'
import type { Person } from '@/types/person'
interface ApiResponse<T> { interface ApiResponse<T> {
success: boolean success: boolean
@@ -9,6 +10,17 @@ interface ApiResponse<T> {
message?: string message?: string
} }
interface PaginatedResponse<T> {
data: T[]
links: Record<string, string | null>
meta: {
current_page: number
per_page: number
total: number
last_page: number
}
}
export function useCrowdLists(eventId: Ref<string>) { export function useCrowdLists(eventId: Ref<string>) {
return useQuery({ return useQuery({
queryKey: ['crowd-lists', eventId], queryKey: ['crowd-lists', eventId],
@@ -23,6 +35,20 @@ export function useCrowdLists(eventId: Ref<string>) {
}) })
} }
export function useCrowdListPersons(eventId: Ref<string>, crowdListId: Ref<string>) {
return useQuery({
queryKey: ['crowd-lists', eventId, 'persons', crowdListId],
queryFn: async () => {
const { data } = await apiClient.get<PaginatedResponse<Person>>(
`/events/${eventId.value}/crowd-lists/${crowdListId.value}/persons`,
)
return data
},
enabled: () => !!eventId.value && !!crowdListId.value,
})
}
export function useCreateCrowdList(eventId: Ref<string>) { export function useCreateCrowdList(eventId: Ref<string>) {
const queryClient = useQueryClient() const queryClient = useQueryClient()
@@ -104,3 +130,27 @@ export function useRemovePersonFromCrowdList(eventId: Ref<string>) {
}, },
}) })
} }
export function useApproveAllPending(eventId: Ref<string>) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (personIds: string[]) => {
const results = await Promise.allSettled(
personIds.map(id =>
apiClient.post<ApiResponse<Person>>(
`/events/${eventId.value}/persons/${id}/approve`,
),
),
)
const approved = results.filter(r => r.status === 'fulfilled').length
return { approved, total: personIds.length }
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['crowd-lists', eventId.value] })
queryClient.invalidateQueries({ queryKey: ['persons', eventId.value] })
},
})
}

View File

@@ -1,12 +1,45 @@
import type { Company, CrowdType } from '@/types/organisation' import type { Company, CrowdType } from '@/types/organisation'
export type PersonStatus = export const PersonStatus = {
| 'invited' INVITED: 'invited',
| 'applied' APPLIED: 'applied',
| 'pending' PENDING: 'pending',
| 'approved' APPROVED: 'approved',
| 'rejected' REJECTED: 'rejected',
| 'no_show' NO_SHOW: 'no_show',
} as const
export type PersonStatus = (typeof PersonStatus)[keyof typeof PersonStatus]
export interface PersonTag {
id: string
person_tag: {
id: string
name: string
category: string | null
icon: string | null
color: string | null
}
source: string
proficiency: string | null
notes: string | null
assigned_at: string
}
export interface CrowdListPivot {
added_at: string
added_by_user_id: string | null
}
export interface PendingIdentityMatch {
match_id: string
matched_user: {
id: string
name: string
email: string
}
matched_on: string
confidence: string
}
export interface Person { export interface Person {
id: string id: string
@@ -21,6 +54,9 @@ export interface Person {
created_at: string created_at: string
crowd_type: CrowdType | null crowd_type: CrowdType | null
company: Company | null company: Company | null
pending_identity_match?: PendingIdentityMatch
crowd_list_pivot?: CrowdListPivot
tags?: PersonTag[]
} }
export interface CreatePersonPayload { export interface CreatePersonPayload {