feat: enrich assignable-persons with tags, preferences, availability and cascading filters

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-10 22:05:02 +02:00
parent 9e4e0c3d4b
commit 04ceecc51d
5 changed files with 540 additions and 141 deletions

View File

@@ -18,12 +18,13 @@ const modelValue = defineModel<boolean>({ required: true })
const eventIdRef = computed(() => props.eventId)
const shiftIdRef = computed(() => props.shift?.id ?? '')
const { data: assignablePersons, isLoading } = useAssignablePersons(eventIdRef, shiftIdRef)
const { data: assignableData, isLoading } = useAssignablePersons(eventIdRef, shiftIdRef)
const { mutateAsync: assignPerson, isPending: isAssigning } = useAssignPersonToShift(eventIdRef)
// Search and filters
const searchQuery = ref('')
const showOnlyAvailable = ref(true)
const showRecommendedOnly = ref(false)
const selectedCrowdType = ref<string | null>(null)
const assignError = ref<string | null>(null)
const showSuccess = ref(false)
@@ -39,16 +40,43 @@ const isShiftFull = computed(() => {
return props.shift.filled_slots >= props.shift.slots_total
})
// Clear error on filter changes
watch([searchQuery, showOnlyAvailable, selectedCrowdType], () => {
// Clear error on any filter change
watch([searchQuery, showOnlyAvailable, showRecommendedOnly, selectedCrowdType], () => {
assignError.value = null
})
// Cascading auto-filter when data loads
watch(() => assignableData.value, (data) => {
if (!data) return
const { persons, meta } = data
const recommended = persons.filter(p =>
p.is_available && !p.already_assigned
&& p.section_preferences.some(sp => sp.section_name === meta.section_name),
)
const available = persons.filter(p =>
p.is_available && !p.already_assigned,
)
if (recommended.length > 0) {
showRecommendedOnly.value = true
showOnlyAvailable.value = true
}
else if (available.length > 0) {
showRecommendedOnly.value = false
showOnlyAvailable.value = true
}
else {
showRecommendedOnly.value = false
showOnlyAvailable.value = false
}
}, { immediate: true })
// Reset state when dialog opens
watch(modelValue, (open) => {
if (open) {
searchQuery.value = ''
showOnlyAvailable.value = true
selectedCrowdType.value = null
assignError.value = null
}
@@ -56,9 +84,9 @@ watch(modelValue, (open) => {
// Crowd type filter options (derived from data)
const crowdTypeOptions = computed(() => {
if (!assignablePersons.value) return []
if (!assignableData.value) return []
const seen = new Map<string, string>()
for (const p of assignablePersons.value) {
for (const p of assignableData.value.persons) {
if (p.crowd_type && !seen.has(p.crowd_type.system_type)) {
seen.set(p.crowd_type.system_type, p.crowd_type.name)
}
@@ -69,9 +97,10 @@ const crowdTypeOptions = computed(() => {
// Filtered persons
const filteredPersons = computed(() => {
if (!assignablePersons.value) return []
if (!assignableData.value) return []
const sectionName = assignableData.value.meta.section_name
return assignablePersons.value.filter((person) => {
return assignableData.value.persons.filter((person) => {
if (searchQuery.value) {
const q = searchQuery.value.toLowerCase()
if (!person.name.toLowerCase().includes(q) && !person.email.toLowerCase().includes(q)) {
@@ -79,24 +108,54 @@ const filteredPersons = computed(() => {
}
}
if (selectedCrowdType.value) {
if (person.crowd_type?.system_type !== selectedCrowdType.value) return false
}
if (showOnlyAvailable.value) {
if (!person.is_available || person.already_assigned) return false
}
if (selectedCrowdType.value) {
if (person.crowd_type?.system_type !== selectedCrowdType.value) return false
if (showRecommendedOnly.value) {
const hasPreference = person.section_preferences.some(
sp => sp.section_name === sectionName,
)
if (!hasPreference && !person.has_availability) return false
}
return true
})
})
// Smart sorting
const sortedPersons = computed(() => {
const sectionName = assignableData.value?.meta?.section_name || ''
return [...filteredPersons.value].sort((a, b) => {
if (a.already_assigned !== b.already_assigned) return a.already_assigned ? 1 : -1
if (a.is_available !== b.is_available) return a.is_available ? -1 : 1
const aMatch = a.section_preferences.find(p => p.section_name === sectionName)
const bMatch = b.section_preferences.find(p => p.section_name === sectionName)
if (!!aMatch !== !!bMatch) return aMatch ? -1 : 1
if (aMatch && bMatch) return aMatch.priority - bMatch.priority
if (a.has_availability !== b.has_availability) return a.has_availability ? -1 : 1
if (a.tags.length !== b.tags.length) return b.tags.length - a.tags.length
return a.name.localeCompare(b.name)
})
})
// Empty state reason
const emptyReason = computed(() => {
if (!assignablePersons.value?.length) {
if (!assignableData.value?.persons?.length) {
return 'Er zijn geen goedgekeurde personen voor dit evenement.'
}
if (showOnlyAvailable.value && !filteredPersons.value.length && assignablePersons.value.length) {
if (showRecommendedOnly.value && !filteredPersons.value.length) {
return 'Geen aanbevolen personen gevonden. Zet \'Aanbevolen\' uit om alle personen te zien.'
}
if (showOnlyAvailable.value && !filteredPersons.value.length) {
return 'Alle personen zijn al ingepland voor dit tijdslot. Zet \'Alleen beschikbaar\' uit om alle personen te zien.'
}
@@ -112,6 +171,12 @@ function getInitials(name: string) {
.slice(0, 2)
}
function getPreferenceMatch(person: AssignablePerson) {
const sectionName = assignableData.value?.meta?.section_name
if (!sectionName) return null
return person.section_preferences.find(sp => sp.section_name === sectionName)
}
function handleAssign(person: AssignablePerson) {
if (!props.shift) return
assignError.value = null
@@ -228,7 +293,7 @@ async function executeAssign(person: AssignablePerson) {
density="compact"
class="mb-3"
>
<strong>Shift is vol</strong> {{ shift.filled_slots }}/{{ shift.slots_total }}
<strong>Shift is vol</strong> {{ shift?.filled_slots }}/{{ shift?.slots_total }}
plekken bezet. Je kunt nog steeds iemand toewijzen, maar de shift wordt overbezet.
</VAlert>
@@ -257,7 +322,14 @@ async function executeAssign(person: AssignablePerson) {
/>
<!-- Filters -->
<div class="d-flex align-center ga-3 mb-3">
<div class="d-flex align-center flex-wrap ga-3 mb-3">
<VSwitch
v-model="showRecommendedOnly"
label="Aanbevolen"
density="compact"
hide-details
color="warning"
/>
<VSwitch
v-model="showOnlyAvailable"
label="Alleen beschikbaar"
@@ -291,145 +363,187 @@ async function executeAssign(person: AssignablePerson) {
<!-- Person list -->
<VList
v-else-if="filteredPersons.length"
v-else-if="sortedPersons.length"
density="compact"
class="person-list overflow-y-auto"
style="max-block-size: 400px;"
>
<template
v-for="person in filteredPersons"
<VListItem
v-for="person in sortedPersons"
:key="person.id"
:disabled="!person.is_available || person.already_assigned"
:class="{
'opacity-50': !person.is_available || person.already_assigned,
'cursor-pointer': person.is_available && !person.already_assigned,
}"
@click="person.is_available && !person.already_assigned && handleAssign(person)"
>
<!-- Already assigned -->
<VListItem
v-if="person.already_assigned"
disabled
class="opacity-40"
>
<template #prepend>
<VAvatar
size="36"
color="grey"
variant="tonal"
>
<span class="text-caption">{{ getInitials(person.name) }}</span>
</VAvatar>
</template>
<VListItemTitle class="text-decoration-line-through">
{{ person.name }}
</VListItemTitle>
<VListItemSubtitle>
<span class="text-success text-caption">Al toegewezen aan deze shift</span>
</VListItemSubtitle>
</VListItem>
<template #prepend>
<VAvatar
size="36"
:color="person.is_available ? 'primary' : 'grey'"
variant="tonal"
>
<span class="text-caption">{{ getInitials(person.name) }}</span>
</VAvatar>
</template>
<!-- Conflict (unavailable) -->
<VListItem
v-else-if="!person.is_available && person.conflict"
disabled
class="opacity-50"
>
<template #prepend>
<VAvatar
size="36"
color="grey"
<VListItemTitle class="d-flex align-center ga-2">
{{ person.name }}
<VChip
v-if="person.crowd_type"
size="x-small"
variant="tonal"
class="ml-auto"
>
{{ person.crowd_type.name }}
</VChip>
</VListItemTitle>
<VListItemSubtitle>
<div>{{ person.email }}</div>
<!-- Tags -->
<div
v-if="person.tags.length"
class="d-flex flex-wrap ga-1 mt-1"
>
<VChip
v-for="tag in person.tags"
:key="tag.name"
size="x-small"
variant="tonal"
:color="tag.color || 'default'"
:prepend-icon="tag.icon || undefined"
>
<span class="text-caption">{{ getInitials(person.name) }}</span>
</VAvatar>
</template>
<VListItemTitle>{{ person.name }}</VListItemTitle>
<VListItemSubtitle>
<span>{{ person.email }}</span>
<br>
<span class="text-warning text-caption">
Ingepland bij "{{ person.conflict.section_name }}" {{ person.conflict.time_slot_name }}
</span>
</VListItemSubtitle>
<template #append>
{{ tag.name }}
<span
v-if="tag.proficiency"
class="ml-1 font-italic"
>
({{ { beginner: 'beg.', experienced: 'erv.', expert: 'exp.' }[tag.proficiency] }})
</span>
</VChip>
</div>
<!-- Section preference match -->
<div
v-if="getPreferenceMatch(person)"
class="mt-1"
>
<VIcon
size="14"
color="warning"
>
tabler-star-filled
</VIcon>
<span class="text-warning text-caption">
Voorkeur: {{ assignableData?.meta?.section_name }} (#{{ getPreferenceMatch(person)!.priority }})
</span>
</div>
<!-- Availability -->
<div
v-if="person.is_available"
class="mt-1"
>
<VIcon
size="14"
:color="person.has_availability ? 'success' : 'grey'"
>
{{ person.has_availability ? 'tabler-check' : 'tabler-clock-question' }}
</VIcon>
<span
:class="person.has_availability ? 'text-success' : 'text-medium-emphasis'"
class="text-caption"
>
{{ person.has_availability ? 'Beschikbaar voor dit tijdslot' : 'Geen beschikbaarheid opgegeven' }}
</span>
</div>
<!-- Conflict (unavailable) -->
<div
v-if="person.conflict"
class="mt-1"
>
<VIcon
size="14"
color="warning"
size="18"
>
tabler-alert-triangle
</VIcon>
</template>
</VListItem>
<span class="text-warning text-caption">
Ingepland bij "{{ person.conflict.section_name }}" {{ person.conflict.time_slot_name }}
</span>
</div>
<!-- Available -->
<VListItem
v-else
:disabled="isAssigning"
class="cursor-pointer"
@click="handleAssign(person)"
>
<template #prepend>
<VAvatar
size="36"
color="primary"
variant="tonal"
<!-- Already assigned -->
<div
v-if="person.already_assigned"
class="mt-1"
>
<VIcon
size="14"
color="success"
>
<span class="text-caption">{{ getInitials(person.name) }}</span>
</VAvatar>
</template>
<VListItemTitle>{{ person.name }}</VListItemTitle>
<VListItemSubtitle>
<span>{{ person.email }}</span>
<!-- Previous assignment indicator -->
<template v-if="person.previous_assignment">
<br>
<template v-if="person.previous_assignment.status === 'cancelled' && person.previous_assignment.cancellation_source === 'organiser'">
<VIcon
size="14"
color="info"
class="me-1"
>
tabler-history
</VIcon>
<span class="text-info text-caption">
Eerder toegewezen, geannuleerd door organisator
</span>
</template>
<template v-else-if="person.previous_assignment.status === 'cancelled' && person.previous_assignment.cancellation_source === 'volunteer'">
<VIcon
size="14"
color="warning"
class="me-1"
>
tabler-alert-triangle
</VIcon>
<span class="text-warning text-caption">
Heeft zichzelf afgemeld voor deze shift
</span>
</template>
<template v-else-if="person.previous_assignment.status === 'rejected'">
<VIcon
size="14"
color="error"
class="me-1"
>
tabler-x
</VIcon>
<span class="text-error text-caption">
Eerder afgewezen
<span v-if="person.previous_assignment.rejection_reason">
({{ person.previous_assignment.rejection_reason }})
</span>
</span>
</template>
</template>
</VListItemSubtitle>
<template #append>
<VChip
v-if="person.crowd_type"
size="x-small"
variant="tonal"
tabler-check
</VIcon>
<span class="text-success text-caption">Al toegewezen aan deze shift</span>
</div>
<!-- Previous assignment indicator -->
<template v-if="person.previous_assignment && person.is_available">
<div
v-if="person.previous_assignment.status === 'cancelled' && person.previous_assignment.cancellation_source === 'organiser'"
class="mt-1"
>
{{ person.crowd_type.name }}
</VChip>
<VIcon
size="14"
color="info"
class="me-1"
>
tabler-history
</VIcon>
<span class="text-info text-caption">
Eerder toegewezen, geannuleerd door organisator
</span>
</div>
<div
v-else-if="person.previous_assignment.status === 'cancelled' && person.previous_assignment.cancellation_source === 'volunteer'"
class="mt-1"
>
<VIcon
size="14"
color="warning"
class="me-1"
>
tabler-alert-triangle
</VIcon>
<span class="text-warning text-caption">
Heeft zichzelf afgemeld voor deze shift
</span>
</div>
<div
v-else-if="person.previous_assignment.status === 'rejected'"
class="mt-1"
>
<VIcon
size="14"
color="error"
class="me-1"
>
tabler-x
</VIcon>
<span class="text-error text-caption">
Eerder afgewezen
<span v-if="person.previous_assignment.rejection_reason">
({{ person.previous_assignment.rejection_reason }})
</span>
</span>
</div>
</template>
</VListItem>
</template>
</VListItemSubtitle>
</VListItem>
</VList>
<!-- Empty state -->

View File

@@ -2,7 +2,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
import type { MaybeRef, Ref } from 'vue'
import { unref } from 'vue'
import { apiClient } from '@/lib/axios'
import type { AssignablePerson, ShiftAssignment } from '@/types/shiftAssignment'
import type { AssignablePerson, AssignablePersonsMeta, ShiftAssignment } from '@/types/shiftAssignment'
interface ApiResponse<T> {
success: boolean
@@ -151,11 +151,14 @@ export function useAssignablePersons(eventId: MaybeRef<string>, shiftId: MaybeRe
return useQuery({
queryKey: ['assignable-persons', eventId, shiftId],
queryFn: async () => {
const { data } = await apiClient.get<{ data: AssignablePerson[] }>(
const { data } = await apiClient.get<{ data: AssignablePerson[]; meta: AssignablePersonsMeta }>(
`/events/${unref(eventId)}/shifts/${unref(shiftId)}/assignable-persons`,
)
return data.data
return {
persons: data.data,
meta: data.meta,
}
},
enabled: () => !!unref(eventId) && !!unref(shiftId),
})

View File

@@ -58,6 +58,18 @@ export interface PreviousAssignment {
rejection_reason: string | null
}
export interface PersonTag {
name: string
icon: string | null
color: string | null
proficiency: 'beginner' | 'experienced' | 'expert' | null
}
export interface SectionPreference {
section_name: string
priority: number
}
export interface AssignablePerson {
id: string
name: string
@@ -77,4 +89,15 @@ export interface AssignablePerson {
time: string
} | null
previous_assignment: PreviousAssignment | null
tags: PersonTag[]
section_preferences: SectionPreference[]
has_availability: boolean
}
export interface AssignablePersonsMeta {
section_name: string
time_slot_name: string
slots_total: number
filled_slots: number
is_overbooked: boolean
}