feat: crowd lists frontend with list view, create/edit dialog and person management
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,103 @@
|
||||
<script setup lang="ts">
|
||||
import { usePersonList } from '@/composables/api/usePersons'
|
||||
import { useAddPersonToCrowdList } from '@/composables/api/useCrowdLists'
|
||||
import type { CrowdList } from '@/types/crowdList'
|
||||
import type { Person } from '@/types/person'
|
||||
|
||||
const props = defineProps<{
|
||||
eventId: string
|
||||
crowdList: CrowdList
|
||||
existingPersonIds: string[]
|
||||
}>()
|
||||
|
||||
const modelValue = defineModel<boolean>({ required: true })
|
||||
|
||||
const eventIdRef = computed(() => props.eventId)
|
||||
|
||||
const { data: personsResponse } = usePersonList(eventIdRef)
|
||||
const { mutate: addPerson, isPending } = useAddPersonToCrowdList(eventIdRef)
|
||||
|
||||
const selectedPersonId = ref<string | null>(null)
|
||||
const showSuccess = ref(false)
|
||||
const successName = ref('')
|
||||
|
||||
const availablePersons = computed(() => {
|
||||
const all = personsResponse.value?.data ?? []
|
||||
const excluded = new Set(props.existingPersonIds)
|
||||
|
||||
return all
|
||||
.filter((p: Person) => !excluded.has(p.id))
|
||||
.map((p: Person) => ({
|
||||
title: p.name,
|
||||
value: p.id,
|
||||
subtitle: p.email,
|
||||
}))
|
||||
})
|
||||
|
||||
function onSubmit() {
|
||||
if (!selectedPersonId.value) return
|
||||
|
||||
const personName = availablePersons.value.find(p => p.value === selectedPersonId.value)?.title ?? ''
|
||||
|
||||
addPerson(
|
||||
{ listId: props.crowdList.id, personId: selectedPersonId.value },
|
||||
{
|
||||
onSuccess: () => {
|
||||
successName.value = personName
|
||||
selectedPersonId.value = null
|
||||
modelValue.value = false
|
||||
showSuccess.value = true
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDialog
|
||||
v-model="modelValue"
|
||||
max-width="500"
|
||||
>
|
||||
<VCard title="Persoon toevoegen aan lijst">
|
||||
<VCardText>
|
||||
<p class="text-body-2 text-medium-emphasis mb-4">
|
||||
Voeg een persoon toe aan <strong>{{ crowdList.name }}</strong>
|
||||
</p>
|
||||
|
||||
<VAutocomplete
|
||||
v-model="selectedPersonId"
|
||||
label="Selecteer persoon"
|
||||
:items="availablePersons"
|
||||
:no-data-text="availablePersons.length === 0 ? 'Alle personen zijn al toegevoegd' : 'Geen resultaten'"
|
||||
auto-select-first
|
||||
clearable
|
||||
/>
|
||||
</VCardText>
|
||||
<VCardActions>
|
||||
<VSpacer />
|
||||
<VBtn
|
||||
variant="text"
|
||||
@click="modelValue = false"
|
||||
>
|
||||
Annuleren
|
||||
</VBtn>
|
||||
<VBtn
|
||||
color="primary"
|
||||
:loading="isPending"
|
||||
:disabled="!selectedPersonId"
|
||||
@click="onSubmit"
|
||||
>
|
||||
Toevoegen
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
|
||||
<VSnackbar
|
||||
v-model="showSuccess"
|
||||
color="success"
|
||||
:timeout="3000"
|
||||
>
|
||||
{{ successName }} toegevoegd aan {{ crowdList.name }}
|
||||
</VSnackbar>
|
||||
</template>
|
||||
363
apps/app/src/components/crowd-lists/CrowdListDetailPanel.vue
Normal file
363
apps/app/src/components/crowd-lists/CrowdListDetailPanel.vue
Normal file
@@ -0,0 +1,363 @@
|
||||
<script setup lang="ts">
|
||||
import { useCrowdListPersons, useRemovePersonFromCrowdList } from '@/composables/api/useCrowdLists'
|
||||
import { useCrowdTypeList } from '@/composables/api/useCrowdTypes'
|
||||
import { useCompanies } from '@/composables/api/useCompanies'
|
||||
import AddPersonToCrowdListDialog from '@/components/crowd-lists/AddPersonToCrowdListDialog.vue'
|
||||
import { CrowdListType } from '@/types/crowdList'
|
||||
import type { CrowdList } from '@/types/crowdList'
|
||||
import type { Person } from '@/types/person'
|
||||
|
||||
const props = defineProps<{
|
||||
eventId: string
|
||||
orgId: string
|
||||
crowdList: CrowdList | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
edit: [crowdList: CrowdList]
|
||||
}>()
|
||||
|
||||
const modelValue = defineModel<boolean>({ required: true })
|
||||
|
||||
const eventIdRef = computed(() => props.eventId)
|
||||
const orgIdRef = computed(() => props.orgId)
|
||||
const listIdRef = computed(() => props.crowdList?.id ?? '')
|
||||
|
||||
const { data: crowdTypes } = useCrowdTypeList(orgIdRef)
|
||||
const { data: companies } = useCompanies(orgIdRef)
|
||||
const { data: persons, isLoading: personsLoading, isError: personsError, refetch: refetchPersons } = useCrowdListPersons(eventIdRef, listIdRef)
|
||||
const { mutate: removePerson, isPending: isRemoving } = useRemovePersonFromCrowdList(eventIdRef)
|
||||
|
||||
const crowdTypeName = computed(() => {
|
||||
if (!props.crowdList) return '-'
|
||||
return crowdTypes.value?.find(ct => ct.id === props.crowdList!.crowd_type_id)?.name ?? '-'
|
||||
})
|
||||
|
||||
const companyName = computed(() => {
|
||||
if (!props.crowdList?.recipient_company_id) return null
|
||||
return companies.value?.find(c => c.id === props.crowdList!.recipient_company_id)?.name ?? '-'
|
||||
})
|
||||
|
||||
const capacityPercentage = computed(() => {
|
||||
if (!props.crowdList?.max_persons) return null
|
||||
return Math.round((props.crowdList.persons_count / props.crowdList.max_persons) * 100)
|
||||
})
|
||||
|
||||
const capacityColor = computed(() => {
|
||||
const pct = capacityPercentage.value
|
||||
if (pct === null) return 'primary'
|
||||
if (pct >= 80) return 'success'
|
||||
if (pct >= 50) return 'warning'
|
||||
return 'error'
|
||||
})
|
||||
|
||||
const existingPersonIds = computed(() =>
|
||||
(persons.value ?? []).map((p: Person) => p.id),
|
||||
)
|
||||
|
||||
const isAddPersonDialogOpen = ref(false)
|
||||
const isRemoveDialogOpen = ref(false)
|
||||
const removingPerson = ref<Person | null>(null)
|
||||
const showSuccess = ref(false)
|
||||
const successMessage = ref('')
|
||||
|
||||
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 getInitials(name: string) {
|
||||
return name
|
||||
.split(' ')
|
||||
.map(p => p[0])
|
||||
.join('')
|
||||
.toUpperCase()
|
||||
.slice(0, 2)
|
||||
}
|
||||
|
||||
const dateFormatter = new Intl.DateTimeFormat('nl-NL', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
})
|
||||
|
||||
function formatDate(iso: string) {
|
||||
return dateFormatter.format(new Date(iso))
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VNavigationDrawer
|
||||
v-model="modelValue"
|
||||
location="end"
|
||||
temporary
|
||||
:width="520"
|
||||
>
|
||||
<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">
|
||||
<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">
|
||||
<VBtn
|
||||
icon="tabler-edit"
|
||||
variant="text"
|
||||
size="small"
|
||||
title="Bewerken"
|
||||
@click="emit('edit', crowdList)"
|
||||
/>
|
||||
<VBtn
|
||||
icon="tabler-x"
|
||||
variant="text"
|
||||
size="small"
|
||||
title="Sluiten"
|
||||
@click="modelValue = false"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<VDivider />
|
||||
|
||||
<!-- Info section -->
|
||||
<div class="pa-6">
|
||||
<VList class="pa-0">
|
||||
<VListItem>
|
||||
<template #prepend>
|
||||
<VIcon
|
||||
icon="tabler-users-group"
|
||||
class="me-3"
|
||||
/>
|
||||
</template>
|
||||
<VListItemTitle>Crowd Type</VListItemTitle>
|
||||
<VListItemSubtitle>{{ crowdTypeName }}</VListItemSubtitle>
|
||||
</VListItem>
|
||||
|
||||
<VListItem v-if="companyName">
|
||||
<template #prepend>
|
||||
<VIcon
|
||||
icon="tabler-building"
|
||||
class="me-3"
|
||||
/>
|
||||
</template>
|
||||
<VListItemTitle>Ontvangende organisatie</VListItemTitle>
|
||||
<VListItemSubtitle>{{ companyName }}</VListItemSubtitle>
|
||||
</VListItem>
|
||||
|
||||
<VListItem>
|
||||
<template #prepend>
|
||||
<VIcon
|
||||
icon="tabler-calendar"
|
||||
class="me-3"
|
||||
/>
|
||||
</template>
|
||||
<VListItemTitle>Aangemaakt op</VListItemTitle>
|
||||
<VListItemSubtitle>{{ formatDate(crowdList.created_at) }}</VListItemSubtitle>
|
||||
</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>
|
||||
</div>
|
||||
|
||||
<VDivider />
|
||||
|
||||
<!-- Persons section -->
|
||||
<div class="pa-6">
|
||||
<div class="d-flex justify-space-between align-center mb-4">
|
||||
<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>
|
||||
|
||||
<!-- Loading -->
|
||||
<VSkeletonLoader
|
||||
v-if="personsLoading"
|
||||
type="list-item-avatar@3"
|
||||
/>
|
||||
|
||||
<!-- 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
|
||||
</VBtn>
|
||||
</template>
|
||||
</VAlert>
|
||||
|
||||
<!-- Empty -->
|
||||
<VCard
|
||||
v-else-if="!persons?.length"
|
||||
variant="outlined"
|
||||
class="text-center pa-6"
|
||||
>
|
||||
<VIcon
|
||||
icon="tabler-users"
|
||||
size="36"
|
||||
class="mb-2 text-disabled"
|
||||
/>
|
||||
<p class="text-body-2 text-disabled mb-0">
|
||||
Nog geen personen op deze lijst
|
||||
</p>
|
||||
</VCard>
|
||||
|
||||
<!-- Person list -->
|
||||
<VList
|
||||
v-else
|
||||
class="pa-0"
|
||||
>
|
||||
<VListItem
|
||||
v-for="person in persons"
|
||||
:key="person.id"
|
||||
>
|
||||
<template #prepend>
|
||||
<VAvatar
|
||||
size="32"
|
||||
color="primary"
|
||||
variant="tonal"
|
||||
class="me-3"
|
||||
>
|
||||
<span class="text-caption">{{ getInitials(person.name) }}</span>
|
||||
</VAvatar>
|
||||
</template>
|
||||
<VListItemTitle>{{ person.name }}</VListItemTitle>
|
||||
<VListItemSubtitle>{{ person.email }}</VListItemSubtitle>
|
||||
<template #append>
|
||||
<VBtn
|
||||
icon="tabler-x"
|
||||
variant="text"
|
||||
size="small"
|
||||
color="error"
|
||||
title="Verwijderen van lijst"
|
||||
@click="onRemoveConfirm(person)"
|
||||
/>
|
||||
</template>
|
||||
</VListItem>
|
||||
</VList>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Add person dialog -->
|
||||
<AddPersonToCrowdListDialog
|
||||
v-if="crowdList"
|
||||
v-model="isAddPersonDialogOpen"
|
||||
:event-id="eventId"
|
||||
:crowd-list="crowdList"
|
||||
:existing-person-ids="existingPersonIds"
|
||||
/>
|
||||
|
||||
<!-- Remove confirmation -->
|
||||
<VDialog
|
||||
v-model="isRemoveDialogOpen"
|
||||
max-width="400"
|
||||
>
|
||||
<VCard title="Persoon verwijderen van lijst">
|
||||
<VCardText>
|
||||
Weet je zeker dat je <strong>{{ removingPerson?.name }}</strong> wilt verwijderen van deze lijst?
|
||||
</VCardText>
|
||||
<VCardActions>
|
||||
<VSpacer />
|
||||
<VBtn
|
||||
variant="text"
|
||||
@click="isRemoveDialogOpen = false"
|
||||
>
|
||||
Annuleren
|
||||
</VBtn>
|
||||
<VBtn
|
||||
color="error"
|
||||
:loading="isRemoving"
|
||||
@click="onRemoveExecute"
|
||||
>
|
||||
Verwijderen
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
|
||||
<!-- Success snackbar -->
|
||||
<VSnackbar
|
||||
v-model="showSuccess"
|
||||
color="success"
|
||||
:timeout="3000"
|
||||
>
|
||||
{{ successMessage }}
|
||||
</VSnackbar>
|
||||
</VNavigationDrawer>
|
||||
</template>
|
||||
261
apps/app/src/components/crowd-lists/CrowdListFormDialog.vue
Normal file
261
apps/app/src/components/crowd-lists/CrowdListFormDialog.vue
Normal file
@@ -0,0 +1,261 @@
|
||||
<script setup lang="ts">
|
||||
import { VForm } from 'vuetify/components/VForm'
|
||||
import { useCreateCrowdList, useUpdateCrowdList } from '@/composables/api/useCrowdLists'
|
||||
import { useCrowdTypeList } from '@/composables/api/useCrowdTypes'
|
||||
import { useCompanies } from '@/composables/api/useCompanies'
|
||||
import { requiredValidator, integerValidator } from '@core/utils/validators'
|
||||
import { CrowdListType } from '@/types/crowdList'
|
||||
import type { CrowdList } from '@/types/crowdList'
|
||||
|
||||
const props = defineProps<{
|
||||
eventId: string
|
||||
orgId: string
|
||||
crowdList?: CrowdList | null
|
||||
}>()
|
||||
|
||||
const modelValue = defineModel<boolean>({ required: true })
|
||||
|
||||
const isEditMode = computed(() => !!props.crowdList)
|
||||
|
||||
const eventIdRef = computed(() => props.eventId)
|
||||
const orgIdRef = computed(() => props.orgId)
|
||||
|
||||
const { data: crowdTypes } = useCrowdTypeList(orgIdRef)
|
||||
const { data: companies } = useCompanies(orgIdRef)
|
||||
const { mutate: createCrowdList, isPending: isCreating } = useCreateCrowdList(eventIdRef)
|
||||
const { mutate: updateCrowdList, isPending: isUpdating } = useUpdateCrowdList(eventIdRef)
|
||||
|
||||
const isPending = computed(() => isCreating.value || isUpdating.value)
|
||||
|
||||
const form = ref({
|
||||
name: '',
|
||||
type: CrowdListType.INTERNAL as CrowdListType,
|
||||
crowd_type_id: '',
|
||||
recipient_company_id: '' as string | null,
|
||||
auto_approve: false,
|
||||
max_persons: null as number | null,
|
||||
})
|
||||
|
||||
const errors = ref<Record<string, string>>({})
|
||||
const refVForm = ref<VForm>()
|
||||
const showSuccess = ref(false)
|
||||
const successMessage = ref('')
|
||||
|
||||
watch(() => props.crowdList, (cl) => {
|
||||
if (cl) {
|
||||
form.value = {
|
||||
name: cl.name,
|
||||
type: cl.type,
|
||||
crowd_type_id: cl.crowd_type_id,
|
||||
recipient_company_id: cl.recipient_company_id ?? '',
|
||||
auto_approve: cl.auto_approve,
|
||||
max_persons: cl.max_persons,
|
||||
}
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
const crowdTypeItems = computed(() =>
|
||||
crowdTypes.value
|
||||
?.filter(ct => ct.is_active)
|
||||
.map(ct => ({
|
||||
title: ct.name,
|
||||
value: ct.id,
|
||||
})) ?? [],
|
||||
)
|
||||
|
||||
const typeLabel: Record<string, string> = {
|
||||
supplier: 'Leverancier',
|
||||
partner: 'Partner',
|
||||
agency: 'Bureau',
|
||||
venue: 'Locatie',
|
||||
other: 'Overig',
|
||||
}
|
||||
|
||||
const companyItems = computed(() =>
|
||||
companies.value?.map(c => ({
|
||||
title: c.name,
|
||||
value: c.id,
|
||||
subtitle: typeLabel[c.type] ?? c.type,
|
||||
})) ?? [],
|
||||
)
|
||||
|
||||
const typeOptions: { title: string; value: CrowdListType }[] = [
|
||||
{ title: 'Intern', value: CrowdListType.INTERNAL },
|
||||
{ title: 'Extern', value: CrowdListType.EXTERNAL },
|
||||
]
|
||||
|
||||
const isExternal = computed(() => form.value.type === CrowdListType.EXTERNAL)
|
||||
|
||||
function resetForm() {
|
||||
form.value = {
|
||||
name: '',
|
||||
type: CrowdListType.INTERNAL,
|
||||
crowd_type_id: '',
|
||||
recipient_company_id: '',
|
||||
auto_approve: false,
|
||||
max_persons: null,
|
||||
}
|
||||
errors.value = {}
|
||||
refVForm.value?.resetValidation()
|
||||
}
|
||||
|
||||
function handleError(err: unknown) {
|
||||
const data = (err as { response?: { data?: { errors?: Record<string, string[]>; message?: string } } }).response?.data
|
||||
if (data?.errors) {
|
||||
errors.value = Object.fromEntries(
|
||||
Object.entries(data.errors).map(([k, v]) => [k, v[0]]),
|
||||
)
|
||||
}
|
||||
else if (data?.message) {
|
||||
errors.value = { name: data.message }
|
||||
}
|
||||
}
|
||||
|
||||
function onSubmit() {
|
||||
refVForm.value?.validate().then(({ valid }) => {
|
||||
if (!valid) return
|
||||
|
||||
errors.value = {}
|
||||
|
||||
const payload = {
|
||||
name: form.value.name,
|
||||
type: form.value.type,
|
||||
crowd_type_id: form.value.crowd_type_id,
|
||||
recipient_company_id: isExternal.value ? (form.value.recipient_company_id || null) : null,
|
||||
auto_approve: form.value.auto_approve,
|
||||
max_persons: form.value.max_persons || null,
|
||||
}
|
||||
|
||||
if (isEditMode.value && props.crowdList) {
|
||||
updateCrowdList({ id: props.crowdList.id, ...payload }, {
|
||||
onSuccess: () => {
|
||||
successMessage.value = `${form.value.name} bijgewerkt`
|
||||
modelValue.value = false
|
||||
showSuccess.value = true
|
||||
},
|
||||
onError: handleError,
|
||||
})
|
||||
}
|
||||
else {
|
||||
createCrowdList(payload, {
|
||||
onSuccess: () => {
|
||||
successMessage.value = `${form.value.name} aangemaakt`
|
||||
modelValue.value = false
|
||||
showSuccess.value = true
|
||||
resetForm()
|
||||
},
|
||||
onError: handleError,
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDialog
|
||||
v-model="modelValue"
|
||||
max-width="550"
|
||||
@after-leave="resetForm"
|
||||
>
|
||||
<VCard :title="isEditMode ? 'Publiekslijst bewerken' : 'Publiekslijst aanmaken'">
|
||||
<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
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12">
|
||||
<AppSelect
|
||||
v-model="form.type"
|
||||
label="Type"
|
||||
:items="typeOptions"
|
||||
:rules="[requiredValidator]"
|
||||
:error-messages="errors.type"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12">
|
||||
<AppSelect
|
||||
v-model="form.crowd_type_id"
|
||||
label="Crowd Type"
|
||||
:items="crowdTypeItems"
|
||||
:rules="[requiredValidator]"
|
||||
:error-messages="errors.crowd_type_id"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol
|
||||
v-if="isExternal"
|
||||
cols="12"
|
||||
>
|
||||
<AppSelect
|
||||
v-model="form.recipient_company_id"
|
||||
label="Ontvangende organisatie"
|
||||
:items="companyItems"
|
||||
clearable
|
||||
:error-messages="errors.recipient_company_id"
|
||||
no-data-text="Nog geen bedrijven"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol
|
||||
cols="12"
|
||||
md="6"
|
||||
>
|
||||
<VSwitch
|
||||
v-model="form.auto_approve"
|
||||
label="Automatisch goedkeuren"
|
||||
color="primary"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol
|
||||
cols="12"
|
||||
md="6"
|
||||
>
|
||||
<AppTextField
|
||||
v-model.number="form.max_persons"
|
||||
label="Max. personen"
|
||||
type="number"
|
||||
:rules="[integerValidator]"
|
||||
:error-messages="errors.max_persons"
|
||||
clearable
|
||||
hint="Leeg = onbeperkt"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VCardText>
|
||||
<VCardActions>
|
||||
<VSpacer />
|
||||
<VBtn
|
||||
variant="text"
|
||||
@click="modelValue = false"
|
||||
>
|
||||
Annuleren
|
||||
</VBtn>
|
||||
<VBtn
|
||||
type="submit"
|
||||
color="primary"
|
||||
:loading="isPending"
|
||||
>
|
||||
{{ isEditMode ? 'Opslaan' : 'Aanmaken' }}
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
</VForm>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
|
||||
<VSnackbar
|
||||
v-model="showSuccess"
|
||||
color="success"
|
||||
:timeout="3000"
|
||||
>
|
||||
{{ successMessage }}
|
||||
</VSnackbar>
|
||||
</template>
|
||||
@@ -53,6 +53,7 @@ function formatDate(iso: string) {
|
||||
const baseTabs = [
|
||||
{ label: 'Overzicht', icon: 'tabler-layout-dashboard', route: 'events-id' },
|
||||
{ label: 'Personen', icon: 'tabler-users', route: 'events-id-persons' },
|
||||
{ label: 'Publiekslijsten', icon: 'tabler-list', route: 'events-id-crowd-lists' },
|
||||
{ label: 'Secties & Shifts', icon: 'tabler-layout-grid', route: 'events-id-sections' },
|
||||
{ label: 'Artiesten', icon: 'tabler-music', route: 'events-id-artists' },
|
||||
{ label: 'Briefings', icon: 'tabler-mail', route: 'events-id-briefings' },
|
||||
@@ -70,7 +71,7 @@ const programmaonderdelenLabel = computed(() => {
|
||||
const tabs = computed(() => {
|
||||
if (!event.value?.is_festival) return baseTabs
|
||||
|
||||
// Festival tab order: Overzicht | Programmaonderdelen | Secties & Shifts | Personen | Artiesten | Briefings | Instellingen
|
||||
// Festival tab order: Overzicht | Programmaonderdelen | Secties & Shifts | Personen | Publiekslijsten | Artiesten | Briefings | Instellingen
|
||||
const festivalTab = {
|
||||
label: programmaonderdelenLabel.value,
|
||||
icon: 'tabler-calendar-event',
|
||||
@@ -80,11 +81,12 @@ const tabs = computed(() => {
|
||||
return [
|
||||
baseTabs[0], // Overzicht
|
||||
festivalTab,
|
||||
baseTabs[2], // Secties & Shifts
|
||||
baseTabs[3], // Secties & Shifts
|
||||
baseTabs[1], // Personen
|
||||
baseTabs[3], // Artiesten
|
||||
baseTabs[4], // Briefings
|
||||
baseTabs[5], // Instellingen
|
||||
baseTabs[2], // Publiekslijsten
|
||||
baseTabs[4], // Artiesten
|
||||
baseTabs[5], // Briefings
|
||||
baseTabs[6], // Instellingen
|
||||
]
|
||||
})
|
||||
|
||||
|
||||
121
apps/app/src/composables/api/useCrowdLists.ts
Normal file
121
apps/app/src/composables/api/useCrowdLists.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
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
|
||||
data: T
|
||||
message?: string
|
||||
}
|
||||
|
||||
export function useCrowdLists(eventId: Ref<string>) {
|
||||
return useQuery({
|
||||
queryKey: ['crowd-lists', eventId],
|
||||
queryFn: async () => {
|
||||
const { data } = await apiClient.get<{ data: CrowdList[] }>(
|
||||
`/events/${eventId.value}/crowd-lists`,
|
||||
)
|
||||
|
||||
return data.data
|
||||
},
|
||||
enabled: () => !!eventId.value,
|
||||
})
|
||||
}
|
||||
|
||||
export function useCrowdListPersons(eventId: Ref<string>, listId: Ref<string>) {
|
||||
return useQuery({
|
||||
queryKey: ['crowd-lists', eventId, 'persons', listId],
|
||||
queryFn: async () => {
|
||||
const { data } = await apiClient.get<{ data: Person[] }>(
|
||||
`/events/${eventId.value}/crowd-lists/${listId.value}/persons`,
|
||||
)
|
||||
|
||||
return data.data
|
||||
},
|
||||
enabled: () => !!eventId.value && !!listId.value,
|
||||
})
|
||||
}
|
||||
|
||||
export function useCreateCrowdList(eventId: Ref<string>) {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (payload: CreateCrowdListDto) => {
|
||||
const { data } = await apiClient.post<ApiResponse<CrowdList>>(
|
||||
`/events/${eventId.value}/crowd-lists`,
|
||||
payload,
|
||||
)
|
||||
|
||||
return data.data
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['crowd-lists', eventId.value] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useUpdateCrowdList(eventId: Ref<string>) {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ id, ...payload }: UpdateCrowdListDto & { id: string }) => {
|
||||
const { data } = await apiClient.put<ApiResponse<CrowdList>>(
|
||||
`/events/${eventId.value}/crowd-lists/${id}`,
|
||||
payload,
|
||||
)
|
||||
|
||||
return data.data
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['crowd-lists', eventId.value] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useDeleteCrowdList(eventId: Ref<string>) {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (id: string) => {
|
||||
await apiClient.delete(`/events/${eventId.value}/crowd-lists/${id}`)
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['crowd-lists', eventId.value] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useAddPersonToCrowdList(eventId: Ref<string>) {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ listId, personId }: { listId: string; personId: string }) => {
|
||||
const { data } = await apiClient.post<ApiResponse<CrowdList>>(
|
||||
`/events/${eventId.value}/crowd-lists/${listId}/persons`,
|
||||
{ person_id: personId },
|
||||
)
|
||||
|
||||
return data.data
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['crowd-lists', eventId.value] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useRemovePersonFromCrowdList(eventId: Ref<string>) {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ listId, personId }: { listId: string; personId: string }) => {
|
||||
await apiClient.delete(
|
||||
`/events/${eventId.value}/crowd-lists/${listId}/persons/${personId}`,
|
||||
)
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['crowd-lists', eventId.value] })
|
||||
},
|
||||
})
|
||||
}
|
||||
307
apps/app/src/pages/events/[id]/crowd-lists/index.vue
Normal file
307
apps/app/src/pages/events/[id]/crowd-lists/index.vue
Normal file
@@ -0,0 +1,307 @@
|
||||
<script setup lang="ts">
|
||||
import { useCrowdLists, useDeleteCrowdList } from '@/composables/api/useCrowdLists'
|
||||
import { useCrowdTypeList } from '@/composables/api/useCrowdTypes'
|
||||
import { useCompanies } from '@/composables/api/useCompanies'
|
||||
import { useAuthStore } from '@/stores/useAuthStore'
|
||||
import EventTabsNav from '@/components/events/EventTabsNav.vue'
|
||||
import CrowdListFormDialog from '@/components/crowd-lists/CrowdListFormDialog.vue'
|
||||
import CrowdListDetailPanel from '@/components/crowd-lists/CrowdListDetailPanel.vue'
|
||||
import { CrowdListType } from '@/types/crowdList'
|
||||
import type { CrowdList } from '@/types/crowdList'
|
||||
|
||||
definePage({
|
||||
meta: {
|
||||
navActiveLink: 'events',
|
||||
},
|
||||
})
|
||||
|
||||
const route = useRoute()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const orgId = computed(() => authStore.currentOrganisation?.id ?? '')
|
||||
const eventId = computed(() => String((route.params as { id: string }).id))
|
||||
|
||||
const { data: crowdLists, isLoading, isError, refetch } = useCrowdLists(eventId)
|
||||
const { data: crowdTypes } = useCrowdTypeList(orgId)
|
||||
const { data: companies } = useCompanies(orgId)
|
||||
const { mutate: deleteCrowdList, isPending: isDeleting } = useDeleteCrowdList(eventId)
|
||||
|
||||
// Lookup maps for resolving IDs to names
|
||||
const crowdTypeMap = computed(() => {
|
||||
const map = new Map<string, string>()
|
||||
crowdTypes.value?.forEach(ct => map.set(ct.id, ct.name))
|
||||
return map
|
||||
})
|
||||
|
||||
const companyMap = computed(() => {
|
||||
const map = new Map<string, string>()
|
||||
companies.value?.forEach(c => map.set(c.id, c.name))
|
||||
return map
|
||||
})
|
||||
|
||||
// Table headers
|
||||
const headers = [
|
||||
{ title: 'Naam', key: 'name' },
|
||||
{ title: 'Type', key: 'type', width: 100 },
|
||||
{ title: 'Crowd Type', key: 'crowd_type_id' },
|
||||
{ title: 'Personen', key: 'persons_count', width: 120 },
|
||||
{ title: 'Auto-approve', key: 'auto_approve', width: 130 },
|
||||
{ title: 'Bedrijf', key: 'recipient_company_id' },
|
||||
{ title: 'Acties', key: 'actions', sortable: false, align: 'end' as const, width: 100 },
|
||||
]
|
||||
|
||||
// Dialogs
|
||||
const isFormDialogOpen = ref(false)
|
||||
const editingCrowdList = ref<CrowdList | null>(null)
|
||||
const isDetailPanelOpen = ref(false)
|
||||
const selectedCrowdList = ref<CrowdList | null>(null)
|
||||
|
||||
// Delete confirmation
|
||||
const isDeleteDialogOpen = ref(false)
|
||||
const deletingCrowdList = ref<CrowdList | null>(null)
|
||||
|
||||
const showSuccess = ref(false)
|
||||
const successMessage = ref('')
|
||||
|
||||
function onCreateNew() {
|
||||
editingCrowdList.value = null
|
||||
isFormDialogOpen.value = true
|
||||
}
|
||||
|
||||
function onRowClick(_event: Event, row: { item: CrowdList }) {
|
||||
selectedCrowdList.value = row.item
|
||||
isDetailPanelOpen.value = true
|
||||
}
|
||||
|
||||
function onEdit(crowdList: CrowdList) {
|
||||
editingCrowdList.value = crowdList
|
||||
isFormDialogOpen.value = true
|
||||
}
|
||||
|
||||
function onDeleteConfirm(crowdList: CrowdList) {
|
||||
deletingCrowdList.value = crowdList
|
||||
isDeleteDialogOpen.value = true
|
||||
}
|
||||
|
||||
function onDeleteExecute() {
|
||||
if (!deletingCrowdList.value) return
|
||||
const name = deletingCrowdList.value.name
|
||||
|
||||
deleteCrowdList(deletingCrowdList.value.id, {
|
||||
onSuccess: () => {
|
||||
isDeleteDialogOpen.value = false
|
||||
deletingCrowdList.value = null
|
||||
successMessage.value = `${name} verwijderd`
|
||||
showSuccess.value = true
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function onEditFromPanel(crowdList: CrowdList) {
|
||||
isDetailPanelOpen.value = false
|
||||
onEdit(crowdList)
|
||||
}
|
||||
|
||||
function formatPersonsCount(item: CrowdList): string {
|
||||
if (item.max_persons) {
|
||||
return `${item.persons_count} / ${item.max_persons}`
|
||||
}
|
||||
return String(item.persons_count)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<EventTabsNav>
|
||||
<!-- Action bar -->
|
||||
<div class="d-flex justify-space-between align-center mb-4">
|
||||
<h5 class="text-h5">
|
||||
Publiekslijsten
|
||||
</h5>
|
||||
<VBtn
|
||||
prepend-icon="tabler-plus"
|
||||
@click="onCreateNew"
|
||||
>
|
||||
Nieuwe lijst
|
||||
</VBtn>
|
||||
</div>
|
||||
|
||||
<!-- Loading -->
|
||||
<VSkeletonLoader
|
||||
v-if="isLoading"
|
||||
type="table"
|
||||
/>
|
||||
|
||||
<!-- Error -->
|
||||
<VAlert
|
||||
v-else-if="isError"
|
||||
type="error"
|
||||
class="mb-4"
|
||||
>
|
||||
Kon publiekslijsten niet laden.
|
||||
<template #append>
|
||||
<VBtn
|
||||
variant="text"
|
||||
@click="refetch()"
|
||||
>
|
||||
Opnieuw proberen
|
||||
</VBtn>
|
||||
</template>
|
||||
</VAlert>
|
||||
|
||||
<!-- Empty -->
|
||||
<VCard
|
||||
v-else-if="!crowdLists?.length"
|
||||
class="text-center pa-8"
|
||||
>
|
||||
<VIcon
|
||||
icon="tabler-list"
|
||||
size="48"
|
||||
class="mb-4 text-disabled"
|
||||
/>
|
||||
<p class="text-body-1 text-disabled mb-4">
|
||||
Nog geen publiekslijsten voor dit evenement.
|
||||
Maak je eerste lijst aan om deelnemers te organiseren.
|
||||
</p>
|
||||
<VBtn
|
||||
prepend-icon="tabler-plus"
|
||||
@click="onCreateNew"
|
||||
>
|
||||
Eerste lijst aanmaken
|
||||
</VBtn>
|
||||
</VCard>
|
||||
|
||||
<!-- Data table -->
|
||||
<VCard v-else>
|
||||
<VDataTable
|
||||
:headers="headers"
|
||||
:items="crowdLists"
|
||||
item-value="id"
|
||||
hover
|
||||
@click:row="onRowClick"
|
||||
>
|
||||
<template #item.name="{ item }">
|
||||
<div class="d-flex align-center gap-x-2">
|
||||
<span>{{ item.name }}</span>
|
||||
<VChip
|
||||
v-if="item.is_full"
|
||||
color="error"
|
||||
size="x-small"
|
||||
>
|
||||
Vol
|
||||
</VChip>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #item.type="{ item }">
|
||||
<VChip
|
||||
:color="item.type === CrowdListType.INTERNAL ? 'primary' : 'info'"
|
||||
size="small"
|
||||
>
|
||||
{{ item.type === CrowdListType.INTERNAL ? 'Intern' : 'Extern' }}
|
||||
</VChip>
|
||||
</template>
|
||||
|
||||
<template #item.crowd_type_id="{ item }">
|
||||
{{ crowdTypeMap.get(item.crowd_type_id) ?? '-' }}
|
||||
</template>
|
||||
|
||||
<template #item.persons_count="{ item }">
|
||||
{{ formatPersonsCount(item) }}
|
||||
</template>
|
||||
|
||||
<template #item.auto_approve="{ item }">
|
||||
<VIcon
|
||||
:icon="item.auto_approve ? 'tabler-circle-check' : 'tabler-circle-x'"
|
||||
:color="item.auto_approve ? 'success' : 'default'"
|
||||
size="20"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #item.recipient_company_id="{ item }">
|
||||
<template v-if="item.recipient_company_id">
|
||||
{{ companyMap.get(item.recipient_company_id) ?? '-' }}
|
||||
</template>
|
||||
<span
|
||||
v-else
|
||||
class="text-disabled"
|
||||
>-</span>
|
||||
</template>
|
||||
|
||||
<template #item.actions="{ item }">
|
||||
<div class="d-flex justify-end gap-x-1">
|
||||
<VBtn
|
||||
icon="tabler-edit"
|
||||
variant="text"
|
||||
size="small"
|
||||
title="Bewerken"
|
||||
@click.stop="onEdit(item)"
|
||||
/>
|
||||
<VBtn
|
||||
icon="tabler-trash"
|
||||
variant="text"
|
||||
size="small"
|
||||
color="error"
|
||||
title="Verwijderen"
|
||||
@click.stop="onDeleteConfirm(item)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</VDataTable>
|
||||
</VCard>
|
||||
|
||||
<!-- Create / Edit dialog -->
|
||||
<CrowdListFormDialog
|
||||
v-model="isFormDialogOpen"
|
||||
:event-id="eventId"
|
||||
:org-id="orgId"
|
||||
:crowd-list="editingCrowdList"
|
||||
/>
|
||||
|
||||
<!-- Detail panel -->
|
||||
<CrowdListDetailPanel
|
||||
v-model="isDetailPanelOpen"
|
||||
:event-id="eventId"
|
||||
:org-id="orgId"
|
||||
:crowd-list="selectedCrowdList"
|
||||
@edit="onEditFromPanel"
|
||||
/>
|
||||
|
||||
<!-- Delete confirmation -->
|
||||
<VDialog
|
||||
v-model="isDeleteDialogOpen"
|
||||
max-width="400"
|
||||
>
|
||||
<VCard title="Publiekslijst verwijderen">
|
||||
<VCardText>
|
||||
Weet je zeker dat je <strong>{{ deletingCrowdList?.name }}</strong> wilt verwijderen?
|
||||
Alle personen worden van de lijst verwijderd.
|
||||
</VCardText>
|
||||
<VCardActions>
|
||||
<VSpacer />
|
||||
<VBtn
|
||||
variant="text"
|
||||
@click="isDeleteDialogOpen = false"
|
||||
>
|
||||
Annuleren
|
||||
</VBtn>
|
||||
<VBtn
|
||||
color="error"
|
||||
:loading="isDeleting"
|
||||
@click="onDeleteExecute"
|
||||
>
|
||||
Verwijderen
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
|
||||
<!-- Success snackbar -->
|
||||
<VSnackbar
|
||||
v-model="showSuccess"
|
||||
color="success"
|
||||
:timeout="3000"
|
||||
>
|
||||
{{ successMessage }}
|
||||
</VSnackbar>
|
||||
</EventTabsNav>
|
||||
</template>
|
||||
41
apps/app/src/types/crowdList.ts
Normal file
41
apps/app/src/types/crowdList.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
export const CrowdListType = {
|
||||
INTERNAL: 'internal',
|
||||
EXTERNAL: 'external',
|
||||
} as const
|
||||
export type CrowdListType = (typeof CrowdListType)[keyof typeof CrowdListType]
|
||||
|
||||
export interface CrowdList {
|
||||
id: string
|
||||
event_id: string
|
||||
crowd_type_id: string
|
||||
name: string
|
||||
type: CrowdListType
|
||||
recipient_company_id: string | null
|
||||
auto_approve: boolean
|
||||
max_persons: number | null
|
||||
is_full: boolean
|
||||
created_at: string
|
||||
persons_count: number
|
||||
}
|
||||
|
||||
export interface CreateCrowdListDto {
|
||||
crowd_type_id: string
|
||||
name: string
|
||||
type: CrowdListType
|
||||
recipient_company_id?: string | null
|
||||
auto_approve: boolean
|
||||
max_persons?: number | null
|
||||
}
|
||||
|
||||
export interface UpdateCrowdListDto {
|
||||
crowd_type_id?: string
|
||||
name?: string
|
||||
type?: CrowdListType
|
||||
recipient_company_id?: string | null
|
||||
auto_approve?: boolean
|
||||
max_persons?: number | null
|
||||
}
|
||||
|
||||
export interface AddPersonToCrowdListDto {
|
||||
person_id: string
|
||||
}
|
||||
Reference in New Issue
Block a user