feat: smart assign person dialog with conflict details and assignable-persons endpoint

Add GET /events/{event}/shifts/{shift}/assignable-persons endpoint that
returns approved persons with availability status, conflict details, and
already-assigned flags. Improve ShiftAssignmentService conflict errors to
include section name, time slot, and time range. Replace both assign
dialogs with a new AssignPersonDialog featuring search, crowd type
filtering, availability toggle, and inline conflict warnings.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-10 20:32:31 +02:00
parent c220446920
commit 968e17c6d6
10 changed files with 1872 additions and 13 deletions

View File

@@ -0,0 +1,351 @@
<script setup lang="ts">
import { useAssignablePersons, useAssignPersonToShift } from '@/composables/api/useShiftAssignments'
import type { AssignablePerson } from '@/types/shiftAssignment'
import type { Shift } from '@/types/section'
const props = defineProps<{
eventId: string
sectionId: string
shift: Shift | null
}>()
const emit = defineEmits<{
assigned: []
}>()
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 { mutateAsync: assignPerson, isPending: isAssigning } = useAssignPersonToShift(eventIdRef)
// Search and filters
const searchQuery = ref('')
const showOnlyAvailable = ref(true)
const selectedCrowdType = ref<string | null>(null)
const assignError = ref<string | null>(null)
// Clear error on filter changes
watch([searchQuery, showOnlyAvailable, selectedCrowdType], () => {
assignError.value = null
})
// Reset state when dialog opens
watch(modelValue, (open) => {
if (open) {
searchQuery.value = ''
showOnlyAvailable.value = true
selectedCrowdType.value = null
assignError.value = null
}
})
// Crowd type filter options (derived from data)
const crowdTypeOptions = computed(() => {
if (!assignablePersons.value) return []
const seen = new Map<string, string>()
for (const p of assignablePersons.value) {
if (p.crowd_type && !seen.has(p.crowd_type.system_type)) {
seen.set(p.crowd_type.system_type, p.crowd_type.name)
}
}
return Array.from(seen, ([value, title]) => ({ title, value }))
})
// Filtered persons
const filteredPersons = computed(() => {
if (!assignablePersons.value) return []
return assignablePersons.value.filter((person) => {
if (searchQuery.value) {
const q = searchQuery.value.toLowerCase()
if (!person.name.toLowerCase().includes(q) && !person.email.toLowerCase().includes(q)) {
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
}
return true
})
})
// Empty state reason
const emptyReason = computed(() => {
if (!assignablePersons.value?.length) {
return 'Er zijn geen goedgekeurde personen voor dit evenement.'
}
if (showOnlyAvailable.value && !filteredPersons.value.length && assignablePersons.value.length) {
return 'Alle personen zijn al ingepland voor dit tijdslot. Zet \'Alleen beschikbaar\' uit om alle personen te zien.'
}
return 'Geen personen gevonden voor deze zoekopdracht.'
})
function getInitials(name: string) {
return name
.split(' ')
.map(p => p[0])
.join('')
.toUpperCase()
.slice(0, 2)
}
async function handleAssign(person: AssignablePerson) {
if (!props.shift) return
assignError.value = null
try {
await assignPerson({
sectionId: props.sectionId,
shiftId: props.shift.id,
personId: person.id,
})
emit('assigned')
modelValue.value = false
}
catch (error: any) {
const message = error.response?.data?.errors?.person_id?.[0]
?? error.response?.data?.message
?? 'Er is een fout opgetreden bij het toewijzen.'
assignError.value = message
}
}
</script>
<template>
<VDialog
v-model="modelValue"
max-width="600"
:fullscreen="$vuetify.display.smAndDown"
>
<VCard>
<VCardTitle class="d-flex align-center justify-space-between">
<span>Persoon toewijzen</span>
<VBtn
icon="tabler-x"
variant="text"
size="small"
@click="modelValue = false"
/>
</VCardTitle>
<VCardText class="pb-0">
<!-- Shift info -->
<div
v-if="shift"
class="mb-4"
>
<div class="d-flex align-center gap-x-2 mb-1">
<span class="text-h6">{{ shift.title ?? 'Shift' }}</span>
<VChip
v-if="shift.is_lead_role"
color="warning"
size="small"
>
Hoofdrol
</VChip>
</div>
<div class="text-body-2 text-disabled">
{{ shift.time_slot?.name }} {{ shift.effective_start_time }}{{ shift.effective_end_time }}
</div>
<div class="text-body-2 text-disabled">
Capaciteit: {{ shift.filled_slots }}/{{ shift.slots_total }}
</div>
</div>
<VDivider class="mb-4" />
<!-- Error alert -->
<VAlert
v-if="assignError"
type="error"
variant="tonal"
density="compact"
closable
class="mb-3"
@click:close="assignError = null"
>
{{ assignError }}
</VAlert>
<!-- Search -->
<VTextField
v-model="searchQuery"
prepend-inner-icon="tabler-search"
placeholder="Zoek op naam of e-mail..."
density="compact"
hide-details
clearable
class="mb-3"
/>
<!-- Filters -->
<div class="d-flex align-center ga-3 mb-3">
<VSwitch
v-model="showOnlyAvailable"
label="Alleen beschikbaar"
density="compact"
hide-details
color="primary"
/>
<VSelect
v-model="selectedCrowdType"
:items="crowdTypeOptions"
label="Type"
density="compact"
hide-details
clearable
style="max-inline-size: 200px;"
/>
</div>
<!-- Loading -->
<div v-if="isLoading">
<VSkeletonLoader
type="list-item-two-line"
class="mb-1"
/>
<VSkeletonLoader
type="list-item-two-line"
class="mb-1"
/>
<VSkeletonLoader type="list-item-two-line" />
</div>
<!-- Person list -->
<VList
v-else-if="filteredPersons.length"
density="compact"
class="person-list overflow-y-auto"
style="max-block-size: 400px;"
>
<template
v-for="person in filteredPersons"
:key="person.id"
>
<!-- 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>
<!-- Conflict (unavailable) -->
<VListItem
v-else-if="!person.is_available && person.conflict"
disabled
class="opacity-50"
>
<template #prepend>
<VAvatar
size="36"
color="grey"
variant="tonal"
>
<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>
<VIcon
color="warning"
size="18"
>
tabler-alert-triangle
</VIcon>
</template>
</VListItem>
<!-- Available -->
<VListItem
v-else
:disabled="isAssigning"
class="cursor-pointer"
@click="handleAssign(person)"
>
<template #prepend>
<VAvatar
size="36"
color="primary"
variant="tonal"
>
<span class="text-caption">{{ getInitials(person.name) }}</span>
</VAvatar>
</template>
<VListItemTitle>{{ person.name }}</VListItemTitle>
<VListItemSubtitle>{{ person.email }}</VListItemSubtitle>
<template #append>
<VChip
v-if="person.crowd_type"
size="x-small"
variant="tonal"
>
{{ person.crowd_type.name }}
</VChip>
</template>
</VListItem>
</template>
</VList>
<!-- Empty state -->
<VCard
v-else
variant="outlined"
class="text-center pa-6"
>
<VIcon
icon="tabler-users-minus"
size="36"
class="mb-2 text-disabled"
/>
<p class="text-body-2 text-disabled mb-0">
{{ emptyReason }}
</p>
</VCard>
</VCardText>
<VCardActions>
<VSpacer />
<VBtn
variant="text"
@click="modelValue = false"
>
Sluiten
</VBtn>
</VCardActions>
</VCard>
</VDialog>
</template>