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>
363 lines
10 KiB
Vue
363 lines
10 KiB
Vue
<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>
|