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:
@@ -1,9 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
useCrowdListPersons,
|
||||
useRemovePersonFromCrowdList,
|
||||
useApproveAllPending,
|
||||
} from '@/composables/api/useCrowdLists'
|
||||
import { useCrowdTypeList } from '@/composables/api/useCrowdTypes'
|
||||
import { useCompanies } from '@/composables/api/useCompanies'
|
||||
import { useApprovePerson } from '@/composables/api/usePersons'
|
||||
import AddPersonToCrowdListDialog from '@/components/crowd-lists/AddPersonToCrowdListDialog.vue'
|
||||
import { CrowdListType } from '@/types/crowdList'
|
||||
import type { CrowdList } from '@/types/crowdList'
|
||||
import { PersonStatus } from '@/types/person'
|
||||
import type { Person } from '@/types/person'
|
||||
|
||||
const props = defineProps<{
|
||||
eventId: string
|
||||
@@ -19,13 +27,27 @@ const modelValue = defineModel<boolean>({ required: true })
|
||||
|
||||
const eventIdRef = computed(() => props.eventId)
|
||||
const orgIdRef = computed(() => props.orgId)
|
||||
const crowdListIdRef = computed(() => props.crowdList?.id ?? '')
|
||||
|
||||
// Data fetching
|
||||
const { data: crowdTypes } = useCrowdTypeList(orgIdRef)
|
||||
const { data: companies } = useCompanies(orgIdRef)
|
||||
const {
|
||||
data: personsResponse,
|
||||
isLoading: personsLoading,
|
||||
isError: personsError,
|
||||
refetch: refetchPersons,
|
||||
} = useCrowdListPersons(eventIdRef, crowdListIdRef)
|
||||
|
||||
const crowdTypeName = computed(() => {
|
||||
if (!props.crowdList) return '-'
|
||||
return crowdTypes.value?.find(ct => ct.id === props.crowdList!.crowd_type_id)?.name ?? '-'
|
||||
// Mutations
|
||||
const { mutate: removePerson, isPending: isRemoving } = useRemovePersonFromCrowdList(eventIdRef)
|
||||
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(() => {
|
||||
@@ -33,6 +55,49 @@ const companyName = computed(() => {
|
||||
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(() => {
|
||||
if (!props.crowdList?.max_persons) return null
|
||||
return Math.round((props.crowdList.persons_count / props.crowdList.max_persons) * 100)
|
||||
@@ -41,23 +106,122 @@ const capacityPercentage = computed(() => {
|
||||
const capacityColor = computed(() => {
|
||||
const pct = capacityPercentage.value
|
||||
if (pct === null) return 'primary'
|
||||
if (pct > 100) return 'purple'
|
||||
if (pct >= 80) return 'success'
|
||||
if (pct >= 50) return 'warning'
|
||||
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 isRemoveDialogOpen = ref(false)
|
||||
const removingPerson = ref<Person | null>(null)
|
||||
const isApproveAllDialogOpen = ref(false)
|
||||
const showSuccess = ref(false)
|
||||
const successMessage = ref('')
|
||||
|
||||
const dateFormatter = new Intl.DateTimeFormat('nl-NL', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
// Date formatting
|
||||
const longDateFormatter = new Intl.DateTimeFormat('nl-NL', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
})
|
||||
|
||||
function formatDate(iso: string) {
|
||||
return dateFormatter.format(new Date(iso))
|
||||
const shortDateFormatter = new Intl.DateTimeFormat('nl-NL', {
|
||||
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>
|
||||
|
||||
@@ -66,40 +230,15 @@ function formatDate(iso: string) {
|
||||
v-model="modelValue"
|
||||
location="end"
|
||||
temporary
|
||||
:width="520"
|
||||
:width="560"
|
||||
>
|
||||
<template v-if="crowdList">
|
||||
<!-- Header -->
|
||||
<div class="pa-6">
|
||||
<div class="d-flex justify-space-between align-start mb-4">
|
||||
<div>
|
||||
<h5 class="text-h5 mb-1">
|
||||
{{ crowdList.name }}
|
||||
</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>
|
||||
<!-- ═══ Section 1: Header ═══ -->
|
||||
<div class="pa-6 pb-4">
|
||||
<div class="d-flex justify-space-between align-start mb-3">
|
||||
<h5 class="text-h5">
|
||||
{{ crowdList.name }}
|
||||
</h5>
|
||||
<div class="d-flex gap-x-1">
|
||||
<VBtn
|
||||
icon="tabler-edit"
|
||||
@@ -117,86 +256,317 @@ function formatDate(iso: string) {
|
||||
/>
|
||||
</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 -->
|
||||
<div class="pa-6">
|
||||
<VList class="pa-0">
|
||||
<VListItem>
|
||||
<VList
|
||||
density="compact"
|
||||
class="pa-0"
|
||||
>
|
||||
<VListItem density="compact">
|
||||
<template #prepend>
|
||||
<VIcon
|
||||
icon="tabler-users-group"
|
||||
size="18"
|
||||
class="me-3"
|
||||
/>
|
||||
</template>
|
||||
<VListItemTitle>Crowd Type</VListItemTitle>
|
||||
<VListItemSubtitle>{{ crowdTypeName }}</VListItemSubtitle>
|
||||
<VListItemTitle class="text-body-2">
|
||||
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 v-if="companyName">
|
||||
<VListItem
|
||||
v-if="companyName"
|
||||
density="compact"
|
||||
>
|
||||
<template #prepend>
|
||||
<VIcon
|
||||
icon="tabler-building"
|
||||
size="18"
|
||||
class="me-3"
|
||||
/>
|
||||
</template>
|
||||
<VListItemTitle>Ontvangende organisatie</VListItemTitle>
|
||||
<VListItemSubtitle>{{ companyName }}</VListItemSubtitle>
|
||||
<VListItemTitle class="text-body-2">
|
||||
Bedrijf
|
||||
</VListItemTitle>
|
||||
<template #append>
|
||||
<span class="text-body-2">{{ companyName }}</span>
|
||||
</template>
|
||||
</VListItem>
|
||||
|
||||
<VListItem>
|
||||
<VListItem density="compact">
|
||||
<template #prepend>
|
||||
<VIcon
|
||||
icon="tabler-calendar"
|
||||
size="18"
|
||||
class="me-3"
|
||||
/>
|
||||
</template>
|
||||
<VListItemTitle>Aangemaakt op</VListItemTitle>
|
||||
<VListItemSubtitle>{{ formatDate(crowdList.created_at) }}</VListItemSubtitle>
|
||||
<VListItemTitle class="text-body-2">
|
||||
Aangemaakt
|
||||
</VListItemTitle>
|
||||
<template #append>
|
||||
<span class="text-body-2">{{ formatLongDate(crowdList.created_at) }}</span>
|
||||
</template>
|
||||
</VListItem>
|
||||
</VList>
|
||||
|
||||
<!-- Capacity progress -->
|
||||
<template v-if="crowdList.max_persons">
|
||||
<VDivider class="my-4" />
|
||||
<div class="d-flex justify-space-between align-center mb-2">
|
||||
<span class="text-body-2 font-weight-medium">Capaciteit</span>
|
||||
<span class="text-body-2">
|
||||
{{ crowdList.persons_count }} / {{ crowdList.max_persons }} personen
|
||||
</span>
|
||||
</div>
|
||||
<VProgressLinear
|
||||
:model-value="capacityPercentage ?? 0"
|
||||
:color="capacityColor"
|
||||
rounded
|
||||
height="8"
|
||||
/>
|
||||
</template>
|
||||
<VRow class="mt-2">
|
||||
<VCol
|
||||
cols="6"
|
||||
>
|
||||
<VCard
|
||||
variant="outlined"
|
||||
class="pa-3 text-center"
|
||||
>
|
||||
<VIcon
|
||||
:icon="crowdList.auto_approve ? 'tabler-circle-check' : 'tabler-circle-x'"
|
||||
:color="crowdList.auto_approve ? 'success' : 'default'"
|
||||
size="20"
|
||||
class="mb-1"
|
||||
/>
|
||||
<p class="text-caption text-disabled mb-0">
|
||||
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>
|
||||
|
||||
<VDivider />
|
||||
|
||||
<!-- Persons section -->
|
||||
<div class="pa-6">
|
||||
<div class="d-flex justify-space-between align-center mb-4">
|
||||
<!-- ═══ Section 2: Capacity Overview ═══ -->
|
||||
<div class="pa-6 py-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">
|
||||
Personen ({{ crowdList.persons_count }})
|
||||
</h6>
|
||||
<VBtn
|
||||
size="small"
|
||||
prepend-icon="tabler-plus"
|
||||
:disabled="crowdList.is_full"
|
||||
@click="isAddPersonDialogOpen = true"
|
||||
>
|
||||
Toevoegen
|
||||
</VBtn>
|
||||
</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 -->
|
||||
<VCard
|
||||
v-if="crowdList.persons_count === 0"
|
||||
v-else-if="!persons.length"
|
||||
variant="outlined"
|
||||
class="text-center pa-6"
|
||||
>
|
||||
@@ -207,41 +577,126 @@ function formatDate(iso: string) {
|
||||
/>
|
||||
<p class="text-body-2 text-disabled mb-0">
|
||||
Nog geen personen op deze lijst.
|
||||
Voeg personen toe via de knop hierboven.
|
||||
Voeg personen toe om te beginnen.
|
||||
</p>
|
||||
</VCard>
|
||||
|
||||
<!-- Has persons (count only — no list endpoint yet) -->
|
||||
<!-- No results (search/filter active) -->
|
||||
<VCard
|
||||
v-else
|
||||
v-else-if="!filteredPersons.length"
|
||||
variant="outlined"
|
||||
class="pa-4"
|
||||
class="text-center pa-6"
|
||||
>
|
||||
<div class="d-flex align-center gap-x-3">
|
||||
<VAvatar
|
||||
color="primary"
|
||||
variant="tonal"
|
||||
size="44"
|
||||
rounded
|
||||
>
|
||||
<VIcon
|
||||
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>
|
||||
<VIcon
|
||||
icon="tabler-search"
|
||||
size="36"
|
||||
class="mb-2 text-disabled"
|
||||
/>
|
||||
<p class="text-body-2 text-disabled mb-0">
|
||||
Geen personen gevonden voor deze zoekopdracht.
|
||||
</p>
|
||||
</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>
|
||||
</template>
|
||||
|
||||
@@ -253,6 +708,68 @@ function formatDate(iso: string) {
|
||||
: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 -->
|
||||
<VSnackbar
|
||||
v-model="showSuccess"
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
|
||||
import type { Ref } from 'vue'
|
||||
import { apiClient } from '@/lib/axios'
|
||||
import type { CrowdList, CreateCrowdListDto, UpdateCrowdListDto } from '@/types/crowdList'
|
||||
import type { Person } from '@/types/person'
|
||||
|
||||
interface ApiResponse<T> {
|
||||
success: boolean
|
||||
@@ -9,6 +10,17 @@ interface ApiResponse<T> {
|
||||
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>) {
|
||||
return useQuery({
|
||||
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>) {
|
||||
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] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,12 +1,45 @@
|
||||
import type { Company, CrowdType } from '@/types/organisation'
|
||||
|
||||
export type PersonStatus =
|
||||
| 'invited'
|
||||
| 'applied'
|
||||
| 'pending'
|
||||
| 'approved'
|
||||
| 'rejected'
|
||||
| 'no_show'
|
||||
export const PersonStatus = {
|
||||
INVITED: 'invited',
|
||||
APPLIED: 'applied',
|
||||
PENDING: 'pending',
|
||||
APPROVED: 'approved',
|
||||
REJECTED: 'rejected',
|
||||
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 {
|
||||
id: string
|
||||
@@ -21,6 +54,9 @@ export interface Person {
|
||||
created_at: string
|
||||
crowd_type: CrowdType | null
|
||||
company: Company | null
|
||||
pending_identity_match?: PendingIdentityMatch
|
||||
crowd_list_pivot?: CrowdListPivot
|
||||
tags?: PersonTag[]
|
||||
}
|
||||
|
||||
export interface CreatePersonPayload {
|
||||
|
||||
Reference in New Issue
Block a user