feat: smart assign person dialog with conflict details and assignable-persons endpoint

Add GET /events/{event}/shifts/{shift}/assignable-persons endpoint that
returns approved persons with availability status, conflict details, and
already-assigned flags. Improve ShiftAssignmentService conflict errors to
include section name, time slot, and time range. Replace both assign
dialogs with a new AssignPersonDialog featuring search, crowd type
filtering, availability toggle, and inline conflict warnings.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-10 20:32:31 +02:00
parent c220446920
commit 968e17c6d6
10 changed files with 1872 additions and 13 deletions

View File

@@ -5,9 +5,13 @@ import { useShiftList, useDeleteShift } from '@/composables/api/useShifts'
import CreateSectionDialog from '@/components/sections/CreateSectionDialog.vue'
import EditSectionDialog from '@/components/sections/EditSectionDialog.vue'
import CreateShiftDialog from '@/components/sections/CreateShiftDialog.vue'
import AssignShiftDialog from '@/components/sections/AssignShiftDialog.vue'
import AssignPersonDialog from '@/components/shifts/AssignPersonDialog.vue'
import ShiftDetailPanel from '@/components/shifts/ShiftDetailPanel.vue'
import { useShiftDetailStore } from '@/stores/useShiftDetailStore'
import type { FestivalSection, Shift, ShiftStatus } from '@/types/section'
const shiftDetailStore = useShiftDetailStore()
const props = defineProps<{
eventId: string
isSubEvent?: boolean
@@ -179,9 +183,10 @@ const statusLabel: Record<ShiftStatus, string> = {
cancelled: 'Geannuleerd',
}
function fillRateColor(rate: number): string {
if (rate >= 80) return 'success'
if (rate >= 40) return 'warning'
function fillRateColor(shift: Shift): string {
if (shift.is_overbooked) return 'warning'
if (shift.fill_rate >= 80) return 'success'
if (shift.fill_rate >= 40) return 'warning'
return 'error'
}
@@ -197,6 +202,12 @@ function formatDate(iso: string) {
return dateFormatter.format(new Date(iso))
}
// Selected shift for detail panel (resolved from store ID)
const selectedShift = computed(() => {
if (!shiftDetailStore.selectedShiftId || !shifts.value) return null
return shifts.value.find(s => s.id === shiftDetailStore.selectedShiftId) ?? null
})
// Success snackbar
const showSuccess = ref(false)
const successMessage = ref('')
@@ -481,8 +492,8 @@ function onSectionCreated(payload: { name: string; redirectedToParent: boolean;
<!-- Fill rate -->
<div class="d-flex align-center gap-x-2" style="min-inline-size: 160px;">
<VProgressLinear
:model-value="shift.fill_rate"
:color="fillRateColor(shift.fill_rate)"
:model-value="shift.is_overbooked ? 100 : shift.fill_rate"
:color="fillRateColor(shift)"
height="8"
rounded
style="inline-size: 80px;"
@@ -490,6 +501,12 @@ function onSectionCreated(payload: { name: string; redirectedToParent: boolean;
<span class="text-body-2 text-no-wrap">
{{ shift.filled_slots }}/{{ shift.slots_total }}
</span>
<VIcon
v-if="shift.is_overbooked"
icon="tabler-alert-triangle"
size="16"
color="warning"
/>
</div>
<!-- Status -->
@@ -499,11 +516,32 @@ function onSectionCreated(payload: { name: string; redirectedToParent: boolean;
>
{{ statusLabel[shift.status] }}
</VChip>
<VChip
v-if="shift.is_overbooked"
color="warning"
size="small"
prepend-icon="tabler-alert-triangle"
>
Overbezet
</VChip>
<VSpacer />
<!-- Actions -->
<div class="d-flex gap-x-1">
<VBtn
icon
variant="text"
size="small"
@click="shiftDetailStore.openPanel(shift.id, activeSection!.id)"
>
<VIcon size="18">
tabler-eye
</VIcon>
<VTooltip activator="parent">
Details bekijken
</VTooltip>
</VBtn>
<VBtn
icon="tabler-user-plus"
variant="text"
@@ -563,7 +601,7 @@ function onSectionCreated(payload: { name: string; redirectedToParent: boolean;
:is-sub-event="isSubEvent"
/>
<AssignShiftDialog
<AssignPersonDialog
v-if="activeSection"
v-model="isAssignShiftOpen"
:event-id="activeSectionEventId"
@@ -626,6 +664,13 @@ function onSectionCreated(payload: { name: string; redirectedToParent: boolean;
</VCard>
</VDialog>
<!-- Shift detail side panel -->
<ShiftDetailPanel
v-model="shiftDetailStore.isOpen"
:event-id="activeSectionEventId"
:shift="selectedShift"
/>
<!-- Success snackbar -->
<VSnackbar
v-model="showSuccess"

View File

@@ -0,0 +1,351 @@
<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)
// 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')
modelValue.value = false
}
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>
</template>

View File

@@ -0,0 +1,817 @@
<script setup lang="ts">
import {
useShiftAssignmentList,
useApproveAssignment,
useRejectAssignment,
useCancelAssignment,
useBulkApproveAssignments,
} from '@/composables/api/useShiftAssignments'
import AssignPersonDialog from '@/components/shifts/AssignPersonDialog.vue'
import { useShiftDetailStore } from '@/stores/useShiftDetailStore'
import { ShiftAssignmentStatus } from '@/types/shiftAssignment'
import type { ShiftAssignment } from '@/types/shiftAssignment'
import type { Shift } from '@/types/section'
const props = defineProps<{
eventId: string
shift: Shift | null
}>()
const modelValue = defineModel<boolean>({ required: true })
const store = useShiftDetailStore()
const eventIdRef = computed(() => props.eventId)
// Fetch assignments filtered by this shift
const filters = computed(() => ({
shift_id: props.shift?.id ?? '',
}))
const {
data: assignmentsResponse,
isLoading: assignmentsLoading,
isError: assignmentsError,
refetch: refetchAssignments,
} = useShiftAssignmentList(eventIdRef, filters)
const assignments = computed(() => assignmentsResponse.value?.data ?? [])
// Mutations
const { mutate: approveAssignment, isPending: isApproving } = useApproveAssignment(eventIdRef)
const { mutate: rejectAssignment, isPending: isRejecting } = useRejectAssignment(eventIdRef)
const { mutate: cancelAssignment, isPending: isCancelling } = useCancelAssignment(eventIdRef)
const { mutate: bulkApprove, isPending: isBulkApproving } = useBulkApproveAssignments(eventIdRef)
// Status counts
const pendingAssignments = computed(() =>
assignments.value.filter(a => a.status === ShiftAssignmentStatus.PENDING_APPROVAL),
)
const statusCounts = computed(() => {
const counts = { pending: 0, approved: 0, rejected: 0, cancelled: 0, completed: 0 }
for (const a of assignments.value) {
if (a.status === ShiftAssignmentStatus.PENDING_APPROVAL) counts.pending++
else if (a.status === ShiftAssignmentStatus.APPROVED) counts.approved++
else if (a.status === ShiftAssignmentStatus.REJECTED) counts.rejected++
else if (a.status === ShiftAssignmentStatus.CANCELLED) counts.cancelled++
else if (a.status === ShiftAssignmentStatus.COMPLETED) counts.completed++
}
return counts
})
// Status UI maps
const statusColor: Record<ShiftAssignmentStatus, string> = {
[ShiftAssignmentStatus.PENDING_APPROVAL]: 'warning',
[ShiftAssignmentStatus.APPROVED]: 'success',
[ShiftAssignmentStatus.REJECTED]: 'error',
[ShiftAssignmentStatus.CANCELLED]: 'default',
[ShiftAssignmentStatus.COMPLETED]: 'info',
}
const statusLabel: Record<ShiftAssignmentStatus, string> = {
[ShiftAssignmentStatus.PENDING_APPROVAL]: 'Wachtend',
[ShiftAssignmentStatus.APPROVED]: 'Goedgekeurd',
[ShiftAssignmentStatus.REJECTED]: 'Afgewezen',
[ShiftAssignmentStatus.CANCELLED]: 'Geannuleerd',
[ShiftAssignmentStatus.COMPLETED]: 'Voltooid',
}
// Status filter
const statusFilter = ref<ShiftAssignmentStatus | ''>('')
const filteredAssignments = computed(() => {
if (!statusFilter.value) return assignments.value
return assignments.value.filter(a => a.status === statusFilter.value)
})
const statusFilterOptions = [
{ title: 'Alle', value: '' },
{ title: 'Wachtend', value: ShiftAssignmentStatus.PENDING_APPROVAL },
{ title: 'Goedgekeurd', value: ShiftAssignmentStatus.APPROVED },
{ title: 'Afgewezen', value: ShiftAssignmentStatus.REJECTED },
{ title: 'Geannuleerd', value: ShiftAssignmentStatus.CANCELLED },
{ title: 'Voltooid', value: ShiftAssignmentStatus.COMPLETED },
]
// Date formatting
const dateTimeFormatter = new Intl.DateTimeFormat('nl-NL', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
function formatDateTime(iso: string) {
return dateTimeFormatter.format(new Date(iso))
}
function getInitials(name: string) {
return name
.split(' ')
.map(p => p[0])
.join('')
.toUpperCase()
.slice(0, 2)
}
// Bulk selection
const isAllPendingSelected = computed(() => {
if (!pendingAssignments.value.length) return false
return pendingAssignments.value.every(a =>
store.selectedAssignmentIds.includes(a.id),
)
})
function onToggleSelectAll() {
if (isAllPendingSelected.value) {
store.clearSelection()
}
else {
store.selectAllPending(assignments.value)
}
}
// Snackbar
const showSuccess = ref(false)
const successMessage = ref('')
// --- Actions ---
function onApprove(assignment: ShiftAssignment) {
approveAssignment(assignment.id, {
onSuccess: () => {
successMessage.value = `${assignment.person?.name ?? 'Toewijzing'} goedgekeurd`
showSuccess.value = true
},
})
}
function onCancel(assignment: ShiftAssignment) {
cancellingAssignment.value = assignment
isCancelDialogOpen.value = true
}
function onCancelExecute() {
if (!cancellingAssignment.value) return
const name = cancellingAssignment.value.person?.name ?? 'Toewijzing'
cancelAssignment(cancellingAssignment.value.id, {
onSuccess: () => {
isCancelDialogOpen.value = false
cancellingAssignment.value = null
successMessage.value = `${name} geannuleerd`
showSuccess.value = true
},
})
}
// Reject dialog
const isRejectDialogOpen = ref(false)
const rejectingAssignment = ref<ShiftAssignment | null>(null)
const rejectReason = ref('')
function onReject(assignment: ShiftAssignment) {
rejectingAssignment.value = assignment
rejectReason.value = ''
isRejectDialogOpen.value = true
}
function onRejectExecute() {
if (!rejectingAssignment.value) return
const name = rejectingAssignment.value.person?.name ?? 'Toewijzing'
rejectAssignment(
{
assignmentId: rejectingAssignment.value.id,
reason: rejectReason.value || undefined,
},
{
onSuccess: () => {
isRejectDialogOpen.value = false
rejectingAssignment.value = null
rejectReason.value = ''
successMessage.value = `${name} afgewezen`
showSuccess.value = true
},
},
)
}
// Cancel dialog
const isCancelDialogOpen = ref(false)
const cancellingAssignment = ref<ShiftAssignment | null>(null)
// Bulk approve dialog
const isBulkApproveDialogOpen = ref(false)
function onBulkApprove() {
isBulkApproveDialogOpen.value = true
}
function onBulkApproveExecute() {
if (!store.selectedAssignmentIds.length) return
bulkApprove(store.selectedAssignmentIds, {
onSuccess: () => {
isBulkApproveDialogOpen.value = false
successMessage.value = `${store.selectedAssignmentIds.length} toewijzingen goedgekeurd`
showSuccess.value = true
store.clearSelection()
},
})
}
// Assign person dialog
const isAssignDialogOpen = ref(false)
function onPersonAssigned() {
successMessage.value = 'Persoon toegewezen'
showSuccess.value = true
}
// Fill rate color
function fillRateColor(): string {
if (props.shift?.is_overbooked) return 'warning'
const rate = props.shift?.fill_rate ?? 0
if (rate >= 80) return 'success'
if (rate >= 40) return 'warning'
return 'error'
}
</script>
<template>
<VNavigationDrawer
v-model="modelValue"
class="shift-detail-drawer"
location="end"
temporary
:width="560"
>
<template v-if="shift">
<div
class="d-flex flex-column h-100 overflow-hidden"
style="min-height: 0;"
>
<div class="flex-shrink-0">
<!-- Header -->
<div class="pa-6 pb-4">
<div class="d-flex justify-space-between align-start mb-3">
<div>
<h5 class="text-h5 mb-1">
{{ shift.title ?? 'Shift' }}
</h5>
<div class="d-flex gap-x-2 flex-wrap">
<VChip
v-if="shift.is_lead_role"
color="warning"
size="small"
>
Hoofdrol
</VChip>
<VChip
:color="shift.status === 'open' ? 'info' : shift.status === 'full' ? 'success' : 'default'"
size="small"
>
{{ shift.status }}
</VChip>
</div>
</div>
<VBtn
icon="tabler-x"
variant="text"
size="small"
title="Sluiten"
@click="modelValue = false"
/>
</div>
<!-- Shift info -->
<VList
density="compact"
class="pa-0"
>
<VListItem density="compact">
<template #prepend>
<VIcon
icon="tabler-clock"
size="18"
class="me-3"
/>
</template>
<VListItemTitle class="text-body-2">
Tijdslot
</VListItemTitle>
<template #append>
<span class="text-body-2">
{{ shift.time_slot?.name ?? '-' }}
</span>
</template>
</VListItem>
<VListItem density="compact">
<template #prepend>
<VIcon
icon="tabler-calendar-time"
size="18"
class="me-3"
/>
</template>
<VListItemTitle class="text-body-2">
Tijd
</VListItemTitle>
<template #append>
<span class="text-body-2">
{{ shift.effective_start_time }}{{ shift.effective_end_time }}
</span>
</template>
</VListItem>
<VListItem density="compact">
<template #prepend>
<VIcon
icon="tabler-users"
size="18"
class="me-3"
/>
</template>
<VListItemTitle class="text-body-2">
Bezetting
</VListItemTitle>
<template #append>
<div class="d-flex align-center gap-x-2">
<VProgressLinear
:model-value="shift.is_overbooked ? 100 : shift.fill_rate"
:color="fillRateColor()"
height="6"
rounded
style="inline-size: 60px;"
/>
<span class="text-body-2">
{{ shift.filled_slots }}/{{ shift.slots_total }}
</span>
<VChip
v-if="shift.is_overbooked"
color="warning"
size="x-small"
prepend-icon="tabler-alert-triangle"
>
Overbezet
</VChip>
</div>
</template>
</VListItem>
<VListItem
v-if="shift.report_time"
density="compact"
>
<template #prepend>
<VIcon
icon="tabler-alarm"
size="18"
class="me-3"
/>
</template>
<VListItemTitle class="text-body-2">
Aanwezig
</VListItemTitle>
<template #append>
<span class="text-body-2">{{ shift.report_time }}</span>
</template>
</VListItem>
</VList>
</div>
<VDivider />
<!-- Status breakdown -->
<div class="pa-6 py-4">
<div class="d-flex gap-2">
<VCard
variant="tonal"
color="warning"
class="pa-2 text-center flex-fill"
>
<p class="text-h6 mb-0">
{{ statusCounts.pending }}
</p>
<p class="text-caption mb-0">
Wachtend
</p>
</VCard>
<VCard
variant="tonal"
color="success"
class="pa-2 text-center flex-fill"
>
<p class="text-h6 mb-0">
{{ statusCounts.approved }}
</p>
<p class="text-caption mb-0">
Goedg.
</p>
</VCard>
<VCard
variant="tonal"
color="info"
class="pa-2 text-center flex-fill"
>
<p class="text-h6 mb-0">
{{ statusCounts.completed }}
</p>
<p class="text-caption mb-0">
Voltooid
</p>
</VCard>
<VCard
variant="tonal"
color="error"
class="pa-2 text-center flex-fill"
>
<p class="text-h6 mb-0">
{{ statusCounts.rejected }}
</p>
<p class="text-caption mb-0">
Afgew.
</p>
</VCard>
<VCard
variant="tonal"
class="pa-2 text-center flex-fill"
>
<p class="text-h6 mb-0">
{{ statusCounts.cancelled }}
</p>
<p class="text-caption mb-0">
Geann.
</p>
</VCard>
</div>
</div>
<VDivider />
<!-- Quick actions -->
<div class="pa-6 py-3 d-flex gap-x-2 flex-wrap">
<VBtn
color="primary"
variant="flat"
size="small"
prepend-icon="tabler-user-plus"
@click="isAssignDialogOpen = true"
>
Toewijzen
</VBtn>
<VBtn
v-if="store.selectedAssignmentIds.length > 0"
color="success"
variant="outlined"
size="small"
prepend-icon="tabler-circle-check"
:loading="isBulkApproving"
@click="onBulkApprove"
>
Goedkeuren ({{ store.selectedAssignmentIds.length }})
</VBtn>
</div>
<VDivider />
</div>
<!-- Assignment list (scrollable) -->
<div
class="pa-6 pt-4 flex-grow-1 overflow-y-auto"
style="min-height: 0;"
>
<div class="d-flex justify-space-between align-center mb-3">
<h6 class="text-h6">
Toewijzingen ({{ assignments.length }})
</h6>
<VCheckbox
v-if="pendingAssignments.length > 0"
:model-value="isAllPendingSelected"
label="Alle wachtend"
density="compact"
hide-details
@update:model-value="onToggleSelectAll"
/>
</div>
<!-- Status filter -->
<div class="mb-4">
<AppSelect
v-model="statusFilter"
:items="statusFilterOptions"
density="compact"
hide-details
style="max-inline-size: 200px;"
/>
</div>
<!-- Loading -->
<div v-if="assignmentsLoading">
<VSkeletonLoader
type="list-item-three-line"
class="mb-2"
/>
<VSkeletonLoader
type="list-item-three-line"
class="mb-2"
/>
<VSkeletonLoader type="list-item-three-line" />
</div>
<!-- Error -->
<VAlert
v-else-if="assignmentsError"
type="error"
variant="tonal"
class="mb-4"
>
Kon toewijzingen niet laden.
<template #append>
<VBtn
variant="text"
size="small"
@click="refetchAssignments()"
>
Opnieuw proberen
</VBtn>
</template>
</VAlert>
<!-- Empty -->
<VCard
v-else-if="!assignments.length"
variant="outlined"
class="text-center pa-6"
>
<VIcon
icon="tabler-users"
size="36"
class="mb-2 text-disabled"
/>
<p class="text-body-2 text-disabled mb-0">
Nog geen toewijzingen voor deze shift.
</p>
</VCard>
<!-- No filter results -->
<VCard
v-else-if="!filteredAssignments.length"
variant="outlined"
class="text-center pa-6"
>
<VIcon
icon="tabler-filter-off"
size="36"
class="mb-2 text-disabled"
/>
<p class="text-body-2 text-disabled mb-0">
Geen toewijzingen gevonden voor dit filter.
</p>
</VCard>
<!-- Assignment cards -->
<div v-else>
<VCard
v-for="assignment in filteredAssignments"
:key="assignment.id"
variant="outlined"
class="mb-2"
>
<VCardText class="pa-3">
<div class="d-flex justify-space-between align-start">
<div class="d-flex gap-x-3 align-center flex-grow-1" style="min-width: 0;">
<!-- Checkbox for pending items -->
<VCheckbox
v-if="assignment.is_approvable"
:model-value="store.selectedAssignmentIds.includes(assignment.id)"
density="compact"
hide-details
@update:model-value="store.toggleAssignmentSelection(assignment.id)"
/>
<VAvatar
size="32"
color="primary"
variant="tonal"
>
<span class="text-caption">
{{ assignment.person ? getInitials(assignment.person.name) : '?' }}
</span>
</VAvatar>
<div style="min-width: 0;" class="flex-grow-1">
<div class="d-flex align-center gap-x-2 flex-wrap">
<span class="text-body-2 font-weight-medium text-truncate">
{{ assignment.person?.name ?? 'Onbekend' }}
</span>
<VChip
v-if="assignment.status === ShiftAssignmentStatus.REJECTED"
color="error"
variant="tonal"
size="x-small"
label
>
{{ statusLabel[assignment.status] }}
<VTooltip
v-if="assignment.rejection_reason"
activator="parent"
location="top"
>
{{ assignment.rejection_reason }}
</VTooltip>
</VChip>
<VChip
v-else
:color="statusColor[assignment.status]"
variant="tonal"
size="x-small"
label
>
{{ statusLabel[assignment.status] }}
</VChip>
<VChip
v-if="assignment.auto_approved"
size="x-small"
color="info"
variant="tonal"
>
Auto
</VChip>
</div>
<p class="text-caption text-disabled mb-0">
{{ formatDateTime(assignment.created_at) }}
</p>
</div>
</div>
<!-- Actions menu -->
<VMenu v-if="assignment.is_approvable || assignment.is_cancellable">
<template #activator="{ props: menuProps }">
<VBtn
icon="tabler-dots-vertical"
variant="text"
size="x-small"
v-bind="menuProps"
/>
</template>
<VList density="compact">
<VListItem
v-if="assignment.is_approvable"
prepend-icon="tabler-circle-check"
title="Goedkeuren"
@click="onApprove(assignment)"
/>
<VListItem
v-if="assignment.is_approvable"
prepend-icon="tabler-circle-x"
title="Afwijzen"
base-color="error"
@click="onReject(assignment)"
/>
<VListItem
v-if="assignment.is_cancellable"
prepend-icon="tabler-ban"
title="Annuleren"
base-color="error"
@click="onCancel(assignment)"
/>
</VList>
</VMenu>
</div>
</VCardText>
</VCard>
</div>
</div>
</div>
</template>
<!-- Reject dialog -->
<VDialog
v-model="isRejectDialogOpen"
max-width="440"
>
<VCard title="Toewijzing afwijzen">
<VCardText>
Weet je zeker dat je de toewijzing van
<strong>{{ rejectingAssignment?.person?.name ?? 'deze persoon' }}</strong>
wilt afwijzen?
<VTextarea
v-model="rejectReason"
label="Reden (optioneel)"
variant="outlined"
rows="3"
class="mt-4"
placeholder="Bijv. onvoldoende ervaring voor deze rol..."
/>
</VCardText>
<VCardActions>
<VSpacer />
<VBtn
variant="text"
@click="isRejectDialogOpen = false"
>
Annuleren
</VBtn>
<VBtn
color="error"
:loading="isRejecting"
@click="onRejectExecute"
>
Afwijzen
</VBtn>
</VCardActions>
</VCard>
</VDialog>
<!-- Cancel dialog -->
<VDialog
v-model="isCancelDialogOpen"
max-width="440"
>
<VCard title="Toewijzing annuleren">
<VCardText>
Weet je zeker dat je de toewijzing van
<strong>{{ cancellingAssignment?.person?.name ?? 'deze persoon' }}</strong>
wilt annuleren?
</VCardText>
<VCardActions>
<VSpacer />
<VBtn
variant="text"
@click="isCancelDialogOpen = false"
>
Annuleren
</VBtn>
<VBtn
color="error"
:loading="isCancelling"
@click="onCancelExecute"
>
Annuleren
</VBtn>
</VCardActions>
</VCard>
</VDialog>
<!-- Bulk approve dialog -->
<VDialog
v-model="isBulkApproveDialogOpen"
max-width="440"
>
<VCard title="Toewijzingen goedkeuren">
<VCardText>
Weet je zeker dat je
<strong>{{ store.selectedAssignmentIds.length }}</strong>
{{ store.selectedAssignmentIds.length === 1 ? 'toewijzing' : 'toewijzingen' }}
wilt goedkeuren?
</VCardText>
<VCardActions>
<VSpacer />
<VBtn
variant="text"
@click="isBulkApproveDialogOpen = false"
>
Annuleren
</VBtn>
<VBtn
color="success"
:loading="isBulkApproving"
@click="onBulkApproveExecute"
>
Goedkeuren
</VBtn>
</VCardActions>
</VCard>
</VDialog>
<!-- Assign person dialog -->
<AssignPersonDialog
v-if="shift"
v-model="isAssignDialogOpen"
:event-id="eventId"
:section-id="store.selectedSectionId ?? ''"
:shift="shift"
@assigned="onPersonAssigned"
/>
<!-- Success snackbar -->
<VSnackbar
v-model="showSuccess"
color="success"
:timeout="3000"
>
{{ successMessage }}
</VSnackbar>
</VNavigationDrawer>
</template>
<style scoped>
.shift-detail-drawer :deep(.v-navigation-drawer__content) {
min-height: 0;
}
</style>

View File

@@ -0,0 +1,161 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
import type { MaybeRef, Ref } from 'vue'
import { unref } from 'vue'
import { apiClient } from '@/lib/axios'
import type { AssignablePerson, ShiftAssignment } from '@/types/shiftAssignment'
interface ApiResponse<T> {
success: boolean
data: T
message?: string
}
interface PaginatedResponse<T> {
data: T[]
links: Record<string, string | null>
meta: {
current_page: number
per_page: number
total: number
last_page: number
}
}
export interface ShiftAssignmentFilters {
shift_id?: string
person_id?: string
section_id?: string
status?: string
}
export function useShiftAssignmentList(
eventId: Ref<string>,
filters?: Ref<ShiftAssignmentFilters>,
) {
return useQuery({
queryKey: ['shift-assignments', eventId, filters],
queryFn: async () => {
const params: Record<string, string> = {}
if (filters?.value?.shift_id) params.shift_id = filters.value.shift_id
if (filters?.value?.person_id) params.person_id = filters.value.person_id
if (filters?.value?.section_id) params.section_id = filters.value.section_id
if (filters?.value?.status) params.status = filters.value.status
const { data } = await apiClient.get<PaginatedResponse<ShiftAssignment>>(
`/events/${eventId.value}/shift-assignments`,
{ params },
)
return data
},
enabled: () => !!eventId.value,
})
}
export function useApproveAssignment(eventId: Ref<string>) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (assignmentId: string) => {
const { data } = await apiClient.post<ApiResponse<ShiftAssignment>>(
`/events/${eventId.value}/shift-assignments/${assignmentId}/approve`,
)
return data.data
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['shift-assignments', eventId.value] })
queryClient.invalidateQueries({ queryKey: ['shifts'] })
},
})
}
export function useRejectAssignment(eventId: Ref<string>) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async ({ assignmentId, reason }: { assignmentId: string; reason?: string }) => {
const { data } = await apiClient.post<ApiResponse<ShiftAssignment>>(
`/events/${eventId.value}/shift-assignments/${assignmentId}/reject`,
{ reason },
)
return data.data
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['shift-assignments', eventId.value] })
queryClient.invalidateQueries({ queryKey: ['shifts'] })
},
})
}
export function useCancelAssignment(eventId: Ref<string>) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (assignmentId: string) => {
const { data } = await apiClient.post<ApiResponse<ShiftAssignment>>(
`/events/${eventId.value}/shift-assignments/${assignmentId}/cancel`,
)
return data.data
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['shift-assignments', eventId.value] })
queryClient.invalidateQueries({ queryKey: ['shifts'] })
},
})
}
export function useBulkApproveAssignments(eventId: Ref<string>) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (assignmentIds: string[]) => {
const { data } = await apiClient.post<ApiResponse<unknown>>(
`/events/${eventId.value}/shift-assignments/bulk-approve`,
{ assignment_ids: assignmentIds },
)
return data.data
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['shift-assignments', eventId.value] })
queryClient.invalidateQueries({ queryKey: ['shifts'] })
},
})
}
export function useAssignPersonToShift(eventId: Ref<string>) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async ({ sectionId, shiftId, personId }: { sectionId: string; shiftId: string; personId: string }) => {
const { data } = await apiClient.post<ApiResponse<ShiftAssignment>>(
`/events/${eventId.value}/sections/${sectionId}/shifts/${shiftId}/assign`,
{ person_id: personId },
)
return data.data
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['shift-assignments', eventId.value] })
queryClient.invalidateQueries({ queryKey: ['shifts'] })
queryClient.invalidateQueries({ queryKey: ['persons', eventId.value] })
},
})
}
export function useAssignablePersons(eventId: MaybeRef<string>, shiftId: MaybeRef<string>) {
return useQuery({
queryKey: ['assignable-persons', eventId, shiftId],
queryFn: async () => {
const { data } = await apiClient.get<{ data: AssignablePerson[] }>(
`/events/${unref(eventId)}/shifts/${unref(shiftId)}/assignable-persons`,
)
return data.data
},
enabled: () => !!unref(eventId) && !!unref(shiftId),
})
}

View File

@@ -0,0 +1,67 @@
import type { Person } from './person'
import type { Shift } from './section'
export const ShiftAssignmentStatus = {
PENDING_APPROVAL: 'pending_approval',
APPROVED: 'approved',
REJECTED: 'rejected',
CANCELLED: 'cancelled',
COMPLETED: 'completed',
} as const
export type ShiftAssignmentStatus = (typeof ShiftAssignmentStatus)[keyof typeof ShiftAssignmentStatus]
export interface ShiftAssignment {
id: string
shift_id: string
person_id: string
time_slot_id: string
status: ShiftAssignmentStatus
auto_approved: boolean
assigned_by: string | null
assigned_at: string | null
approved_by: string | null
approved_at: string | null
rejection_reason: string | null
hours_expected: number | null
hours_completed: number | null
checked_in_at: string | null
checked_out_at: string | null
is_cancellable: boolean
is_approvable: boolean
person?: Person
shift?: Shift
created_at: string
}
export interface AssignPersonToShiftDto {
person_id: string
}
export interface RejectAssignmentDto {
reason?: string
}
export interface BulkApproveDto {
assignment_ids: string[]
}
export interface AssignablePerson {
id: string
name: string
email: string
status: string
crowd_type: {
id: string
name: string
system_type: string
} | null
is_available: boolean
already_assigned: boolean
conflict: {
section_name: string
shift_title: string
time_slot_name: string
time: string
} | null
}