Files
crewli/apps/app/src/components/shifts/AssignPersonDialog.vue
bert.hausmans 3e292567c3 feat: smart re-assignment with cancellation source tracking
Add cancelled_by, cancellation_source (organiser|volunteer|system), and
cancelled_at columns to shift_assignments. Cancel flow now records who
cancelled and why. Assign flow reactivates existing cancelled/rejected
records instead of creating duplicates, preventing UNIQUE constraint
violations. Assignable-persons endpoint returns previous_assignment data
for contextual UI indicators. Frontend shows cancellation source labels,
previous assignment history in assign dialog, and "Opnieuw toewijzen"
buttons with volunteer-cancelled confirmation dialogs.

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

539 lines
15 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('')
// Confirmation dialogs
const pendingPerson = ref<AssignablePerson | null>(null)
const showOverbookConfirm = ref(false)
const showVolunteerReassignConfirm = ref(false)
const isShiftFull = computed(() => {
if (!props.shift) return false
return props.shift.filled_slots >= props.shift.slots_total
})
// 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)
}
function handleAssign(person: AssignablePerson) {
if (!props.shift) return
assignError.value = null
// Volunteer self-cancelled — extra warning
if (person.previous_assignment?.cancellation_source === 'volunteer') {
pendingPerson.value = person
showVolunteerReassignConfirm.value = true
return
}
// Shift is full — overbooking warning
if (isShiftFull.value) {
pendingPerson.value = person
showOverbookConfirm.value = true
return
}
executeAssign(person)
}
function confirmOverbook() {
if (pendingPerson.value) {
executeAssign(pendingPerson.value)
}
showOverbookConfirm.value = false
pendingPerson.value = null
}
function confirmVolunteerReassign() {
if (pendingPerson.value) {
// Still check overbooking after volunteer confirmation
if (isShiftFull.value) {
showVolunteerReassignConfirm.value = false
showOverbookConfirm.value = true
return
}
executeAssign(pendingPerson.value)
}
showVolunteerReassignConfirm.value = false
pendingPerson.value = null
}
async function executeAssign(person: AssignablePerson) {
if (!props.shift) return
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" />
<!-- Overbooking warning -->
<VAlert
v-if="isShiftFull"
type="warning"
variant="tonal"
density="compact"
class="mb-3"
>
<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>
<!-- 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>
<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"
>
{{ 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>
<!-- Overbook confirmation -->
<VDialog
v-model="showOverbookConfirm"
max-width="420"
>
<VCard>
<VCardTitle class="text-h6 pt-5 px-5">
Shift overbezetten?
</VCardTitle>
<VCardText class="px-5">
Deze shift heeft {{ shift?.slots_total }} plekken en
{{ shift?.filled_slots }} zijn bezet. Wil je
<strong>{{ pendingPerson?.name }}</strong> toch toewijzen?
</VCardText>
<VCardActions class="px-5 pb-5">
<VSpacer />
<VBtn
variant="tonal"
@click="showOverbookConfirm = false"
>
Annuleren
</VBtn>
<VBtn
color="warning"
variant="flat"
:loading="isAssigning"
@click="confirmOverbook"
>
Toch toewijzen
</VBtn>
</VCardActions>
</VCard>
</VDialog>
<!-- Volunteer re-assign confirmation -->
<VDialog
v-model="showVolunteerReassignConfirm"
max-width="420"
>
<VCard>
<VCardTitle class="text-h6 pt-5 px-5">
Vrijwilliger opnieuw toewijzen?
</VCardTitle>
<VCardText class="px-5">
<strong>{{ pendingPerson?.name }}</strong> heeft zichzelf afgemeld
voor deze shift. Weet je zeker dat je deze persoon opnieuw wilt toewijzen?
</VCardText>
<VCardActions class="px-5 pb-5">
<VSpacer />
<VBtn
variant="tonal"
@click="showVolunteerReassignConfirm = false"
>
Annuleren
</VBtn>
<VBtn
color="warning"
variant="flat"
:loading="isAssigning"
@click="confirmVolunteerReassign"
>
Toch toewijzen
</VBtn>
</VCardActions>
</VCard>
</VDialog>
<VSnackbar
v-model="showSuccess"
color="success"
:timeout="2000"
>
{{ successName }} toegewezen
</VSnackbar>
</template>