Files
crewli/apps/app/src/components/shifts/ShiftDetailPanel.vue
bert.hausmans d407cd17de fix(app): resolve Bucket B (type safety) lint items
WS-3 session 1b-ii Task 3 (audit Bucket B — 34 items: 21 absorbed
via ignorePatterns + 14 real fixes; the count of 21 is the actual
non-Tier-3 lint-count drop from the .eslintrc edit, slightly above
the audit's predicted 20 because additional vendored-Vuexy items
beyond the 23 no-explicit-any landed in those paths too).

Config:
- .eslintrc.cjs: add src/@core/** and src/@layouts/** to ignorePatterns.
  Vendored Vuexy code, precedent: src/plugins/iconify/*.js. The
  CLAUDE.md no-any rule remains in force for our own code under src/.

Real type-safety fixes:
- B.1 ref<any> in our code (3 occurrences):
  * blank.vue / default.vue: AppLoadingIndicator template ref now
    typed as InstanceType<typeof AppLoadingIndicator> | null. Picks
    up the defineExpose'd fallbackHandle / resolveHandle methods.
  * NavSearchBar.vue:109: useApi<any>(...) → useApi<SearchResults[]>(...)
    matching the existing searchResult ref type.
- B.2 ShiftDetailPanel.vue: moved the Cancel-dialog ref declarations
  (isCancelDialogOpen, cancellingAssignment) from line 305-307 to
  line 248 — directly above the onCancel handler that uses them.
  Resolves all 7 no-use-before-define items in one move. Same-file,
  no logic change.
- B.3 useImpersonationStore.ts:119: renamed inner 'stored' to
  'storedSnapshot' to resolve shadowing of the outer 'stored' on
  line 18.
- B.4 useFormSchemas.ts:97-99: renamed local mutationFn parameter
  'confirmed_name' to camelCase 'confirmedName'. Wire-format key
  stays snake_case via destructure-alias:
    params: confirmedName ? { confirmed_name: confirmedName } : undefined
  No callers found in apps/app/src — safe rename.

Tests + typecheck verified green.

Lint baseline: 97 → 62.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 14:11:05 +02:00

1058 lines
32 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 {
useApproveAssignment,
useAssignPersonToShift,
useBulkApproveAssignments,
useCancelAssignment,
useRejectAssignment,
useShiftAssignmentList,
} from '@/composables/api/useShiftAssignments'
import AssignPersonDialog from '@/components/shifts/AssignPersonDialog.vue'
import { getApiErrorMessage } from '@/lib/apiErrors'
import { useAuthStore } from '@/stores/useAuthStore'
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 authStore = useAuthStore()
const store = useShiftDetailStore()
const orgIdRef = computed(() => authStore.currentOrganisation?.id ?? '')
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(orgIdRef, eventIdRef, filters)
const assignments = computed(() => assignmentsResponse.value?.data ?? [])
// Mutations
const { mutate: approveAssignment, isPending: isApproving } = useApproveAssignment(orgIdRef, eventIdRef)
const { mutate: rejectAssignment, isPending: isRejecting } = useRejectAssignment(orgIdRef, eventIdRef)
const { mutate: cancelAssignment, isPending: isCancelling } = useCancelAssignment(orgIdRef, eventIdRef)
const { mutate: bulkApprove, isPending: isBulkApproving } = useBulkApproveAssignments(orgIdRef, eventIdRef)
const { mutateAsync: assignPersonMutation } = useAssignPersonToShift(orgIdRef, eventIdRef)
// Re-assign
const reassigning = ref<string | null>(null)
const showVolunteerReassignConfirm = ref(false)
const reassigningAssignment = ref<ShiftAssignment | null>(null)
function onReassign(assignment: ShiftAssignment) {
if (assignment.cancellation_source === 'volunteer') {
reassigningAssignment.value = assignment
showVolunteerReassignConfirm.value = true
return
}
executeReassign(assignment)
}
function confirmReassign() {
if (reassigningAssignment.value)
executeReassign(reassigningAssignment.value)
showVolunteerReassignConfirm.value = false
reassigningAssignment.value = null
}
async function executeReassign(assignment: ShiftAssignment) {
if (!store.selectedSectionId)
return
reassigning.value = assignment.id
try {
await assignPersonMutation({
sectionId: store.selectedSectionId,
shiftId: assignment.shift_id,
personId: assignment.person_id,
})
flashFeedback(`${assignment.person?.full_name ?? 'Persoon'} opnieuw toegewezen`, 'success')
}
catch (err: unknown) {
flashFeedback(
getApiErrorMessage(err, 'Fout bij opnieuw toewijzen'),
'error',
)
}
finally {
reassigning.value = null
}
}
// 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 (success + server errors)
const showSuccess = ref(false)
const successMessage = ref('')
const snackbarColor = ref<'success' | 'error'>('success')
const snackbarTimeout = ref(3000)
function flashFeedback(message: string, variant: 'success' | 'error' = 'success') {
successMessage.value = message
snackbarColor.value = variant
snackbarTimeout.value = variant === 'error' ? 12_000 : 3000
showSuccess.value = true
}
// --- Actions ---
// Overbook confirmation
const isOverbookDialogOpen = ref(false)
const overbookingAssignment = ref<ShiftAssignment | null>(null)
function onApprove(assignment: ShiftAssignment) {
const filledSlots = props.shift?.filled_slots ?? 0
const totalSlots = props.shift?.slots_total ?? 0
if (filledSlots >= totalSlots) {
overbookingAssignment.value = assignment
isOverbookDialogOpen.value = true
return
}
executeApprove(assignment)
}
function onOverbookConfirm() {
if (!overbookingAssignment.value)
return
executeApprove(overbookingAssignment.value)
isOverbookDialogOpen.value = false
overbookingAssignment.value = null
}
function executeApprove(assignment: ShiftAssignment) {
approveAssignment(assignment.id, {
onSuccess: () => {
flashFeedback(`${assignment.person?.full_name ?? 'Toewijzing'} goedgekeurd`, 'success')
},
onError: (err: unknown) => {
flashFeedback(getApiErrorMessage(err, 'Fout bij goedkeuren'), 'error')
},
})
}
// Cancel dialog
const isCancelDialogOpen = ref(false)
const cancellingAssignment = ref<ShiftAssignment | null>(null)
function onCancel(assignment: ShiftAssignment) {
cancellingAssignment.value = assignment
isCancelDialogOpen.value = true
}
function onCancelExecute() {
if (!cancellingAssignment.value)
return
const name = cancellingAssignment.value.person?.full_name ?? 'Toewijzing'
cancelAssignment(cancellingAssignment.value.id, {
onSuccess: () => {
isCancelDialogOpen.value = false
cancellingAssignment.value = null
flashFeedback(`${name} geannuleerd`, 'success')
},
onError: (err: unknown) => {
flashFeedback(getApiErrorMessage(err, 'Fout bij annuleren'), 'error')
},
})
}
// 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?.full_name ?? 'Toewijzing'
rejectAssignment(
{
assignmentId: rejectingAssignment.value.id,
reason: rejectReason.value || undefined,
},
{
onSuccess: () => {
isRejectDialogOpen.value = false
rejectingAssignment.value = null
rejectReason.value = ''
flashFeedback(`${name} afgewezen`, 'success')
},
onError: (err: unknown) => {
flashFeedback(getApiErrorMessage(err, 'Fout bij afwijzen'), 'error')
},
},
)
}
// Bulk approve dialog
const isBulkApproveDialogOpen = ref(false)
const bulkApproveWillOverbook = computed(() => {
const filledSlots = props.shift?.filled_slots ?? 0
const totalSlots = props.shift?.slots_total ?? 0
const selectedCount = store.selectedAssignmentIds.length
return filledSlots + selectedCount > totalSlots
})
function onBulkApprove() {
isBulkApproveDialogOpen.value = true
}
function onBulkApproveExecute() {
if (!store.selectedAssignmentIds.length)
return
bulkApprove(store.selectedAssignmentIds, {
onSuccess: () => {
isBulkApproveDialogOpen.value = false
flashFeedback(`${store.selectedAssignmentIds.length} toewijzingen goedgekeurd`, 'success')
store.clearSelection()
},
onError: (err: unknown) => {
flashFeedback(getApiErrorMessage(err, 'Fout bij bulk goedkeuren'), 'error')
},
})
}
// Assign person dialog
const isAssignDialogOpen = ref(false)
function onPersonAssigned() {
flashFeedback('Persoon toegewezen', 'success')
}
// 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.full_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?.full_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>
<!-- Cancellation source -->
<span
v-if="assignment.status === ShiftAssignmentStatus.CANCELLED && assignment.cancellation_source === 'volunteer'"
class="text-caption text-warning"
>
(afgemeld door vrijwilliger)
</span>
<span
v-else-if="assignment.status === ShiftAssignmentStatus.CANCELLED && assignment.cancellation_source === 'organiser'"
class="text-caption text-medium-emphasis"
>
(geannuleerd door organisator)
</span>
<p class="text-caption text-disabled mb-0">
{{ formatDateTime(assignment.created_at) }}
</p>
</div>
</div>
<!-- Re-assign button for cancelled/rejected -->
<VBtn
v-if="assignment.status === ShiftAssignmentStatus.CANCELLED || assignment.status === ShiftAssignmentStatus.REJECTED"
size="x-small"
variant="tonal"
color="primary"
:loading="reassigning === assignment.id"
class="me-1"
@click="onReassign(assignment)"
>
<VIcon
start
size="14"
>
tabler-refresh
</VIcon>
Opnieuw toewijzen
</VBtn>
<!-- 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?.full_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?.full_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?
<VAlert
v-if="bulkApproveWillOverbook"
type="warning"
variant="tonal"
class="mt-3"
density="compact"
>
Deze actie zal de shift overbezetten
({{ shift?.filled_slots }}/{{ shift?.slots_total }} plekken bezet).
</VAlert>
</VCardText>
<VCardActions>
<VSpacer />
<VBtn
variant="text"
@click="isBulkApproveDialogOpen = false"
>
Annuleren
</VBtn>
<VBtn
:color="bulkApproveWillOverbook ? 'warning' : 'success'"
:loading="isBulkApproving"
@click="onBulkApproveExecute"
>
{{ bulkApproveWillOverbook ? 'Toch goedkeuren' : 'Goedkeuren' }}
</VBtn>
</VCardActions>
</VCard>
</VDialog>
<!-- Overbook confirmation dialog (single approve) -->
<VDialog
v-model="isOverbookDialogOpen"
max-width="440"
>
<VCard>
<VCardTitle class="text-h6 pt-5 px-5">
Shift overbezetten?
</VCardTitle>
<VCardText class="px-5">
Deze shift is al vol
({{ shift?.filled_slots }}/{{ shift?.slots_total }} plekken bezet).
Wil je de aanmelding van
<strong>{{ overbookingAssignment?.person?.full_name ?? 'deze persoon' }}</strong>
toch goedkeuren?
</VCardText>
<VCardActions class="px-5 pb-5">
<VSpacer />
<VBtn
variant="tonal"
@click="isOverbookDialogOpen = false"
>
Annuleren
</VBtn>
<VBtn
color="warning"
variant="flat"
:loading="isApproving"
@click="onOverbookConfirm"
>
Toch goedkeuren
</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>{{ reassigningAssignment?.person?.full_name ?? 'Deze persoon' }}</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="reassigning === reassigningAssignment?.id"
@click="confirmReassign"
>
Toch toewijzen
</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"
/>
<!-- Feedback snackbar (success + API errors) -->
<VSnackbar
v-model="showSuccess"
:color="snackbarColor"
:timeout="snackbarTimeout"
location="bottom end"
multi-line
>
{{ successMessage }}
</VSnackbar>
</VNavigationDrawer>
</template>
<style scoped>
.shift-detail-drawer :deep(.v-navigation-drawer__content) {
min-height: 0;
}
</style>