653 lines
19 KiB
Vue
653 lines
19 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: 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>
|