Files
crewli/apps/app/src/components/shifts/AssignPersonDialog.vue
bert.hausmans d1ad0e1f89 fix: refresh assignable persons list after assignment and keep dialog open
Invalidate assignable-persons query cache in useAssignPersonToShift
onSuccess so the list reflects the new assignment immediately. Keep the
dialog open after assigning a person to allow sequential assignments,
showing a brief success snackbar instead of closing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 20:37:38 +02:00

363 lines
10 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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)
const showSuccess = ref(false)
const successName = ref('')
// 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')
successName.value = person.name
showSuccess.value = true
}
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>
<VSnackbar
v-model="showSuccess"
color="success"
:timeout="2000"
>
{{ successName }} toegewezen
</VSnackbar>
</template>