Files
crewli/apps/app/src/components/shifts/AssignPersonDialog.vue

653 lines
19 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: 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)
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 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 = ''
selectedCrowdType.value = null
assignError.value = null
}
})
// Crowd type filter options (derived from data)
const crowdTypeOptions = computed(() => {
if (!assignableData.value) return []
const seen = new Map<string, string>()
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)
}
}
return Array.from(seen, ([value, title]) => ({ title, value }))
})
// Filtered persons
const filteredPersons = computed(() => {
if (!assignableData.value) return []
const sectionName = assignableData.value.meta.section_name
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)) {
return false
}
}
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 (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 (!assignableData.value?.persons?.length) {
return 'Er zijn geen goedgekeurde personen voor dit evenement.'
}
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.'
}
return 'Geen personen gevonden voor deze zoekopdracht.'
})
function getInitials(name: string) {
return name
.split(' ')
.map(p => p[0])
.join('')
.toUpperCase()
.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
// 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 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"
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="sortedPersons.length"
density="compact"
class="person-list overflow-y-auto"
style="max-block-size: 400px;"
>
<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)"
>
<template #prepend>
<VAvatar
size="36"
:color="person.is_available ? 'primary' : 'grey'"
variant="tonal"
>
<span class="text-caption">{{ getInitials(person.name) }}</span>
</VAvatar>
</template>
<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"
>
{{ 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"
>
tabler-alert-triangle
</VIcon>
<span class="text-warning text-caption">
Ingepland bij "{{ person.conflict.section_name }}" {{ person.conflict.time_slot_name }}
</span>
</div>
<!-- Already assigned -->
<div
v-if="person.already_assigned"
class="mt-1"
>
<VIcon
size="14"
color="success"
>
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"
>
<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>
</VListItemSubtitle>
</VListItem>
</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>