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>
1058 lines
32 KiB
Vue
1058 lines
32 KiB
Vue
<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>
|