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:
@@ -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 -->
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user