feat: smart re-assignment with cancellation source tracking

Add cancelled_by, cancellation_source (organiser|volunteer|system), and
cancelled_at columns to shift_assignments. Cancel flow now records who
cancelled and why. Assign flow reactivates existing cancelled/rejected
records instead of creating duplicates, preventing UNIQUE constraint
violations. Assignable-persons endpoint returns previous_assignment data
for contextual UI indicators. Frontend shows cancellation source labels,
previous assignment history in assign dialog, and "Opnieuw toewijzen"
buttons with volunteer-cancelled confirmation dialogs.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-10 21:50:24 +02:00
parent dfe7a63ad3
commit 3e292567c3
11 changed files with 554 additions and 7 deletions

View File

@@ -29,9 +29,10 @@ const assignError = ref<string | null>(null)
const showSuccess = ref(false)
const successName = ref('')
// Overbooking confirmation
// Confirmation dialogs
const pendingPerson = ref<AssignablePerson | null>(null)
const showOverbookConfirm = ref(false)
const showVolunteerReassignConfirm = ref(false)
const isShiftFull = computed(() => {
if (!props.shift) return false
@@ -115,6 +116,14 @@ 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
@@ -132,6 +141,20 @@ function confirmOverbook() {
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
@@ -350,7 +373,52 @@ async function executeAssign(person: AssignablePerson) {
</VAvatar>
</template>
<VListItemTitle>{{ person.name }}</VListItemTitle>
<VListItemSubtitle>{{ person.email }}</VListItemSubtitle>
<VListItemSubtitle>
<span>{{ person.email }}</span>
<!-- Previous assignment indicator -->
<template v-if="person.previous_assignment">
<br>
<template v-if="person.previous_assignment.status === 'cancelled' && person.previous_assignment.cancellation_source === 'organiser'">
<VIcon
size="14"
color="info"
class="me-1"
>
tabler-history
</VIcon>
<span class="text-info text-caption">
Eerder toegewezen, geannuleerd door organisator
</span>
</template>
<template v-else-if="person.previous_assignment.status === 'cancelled' && person.previous_assignment.cancellation_source === 'volunteer'">
<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>
</template>
<template v-else-if="person.previous_assignment.status === 'rejected'">
<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>
</template>
</template>
</VListItemSubtitle>
<template #append>
<VChip
v-if="person.crowd_type"
@@ -427,6 +495,39 @@ async function executeAssign(person: AssignablePerson) {
</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"

View File

@@ -5,6 +5,7 @@ import {
useRejectAssignment,
useCancelAssignment,
useBulkApproveAssignments,
useAssignPersonToShift,
} from '@/composables/api/useShiftAssignments'
import AssignPersonDialog from '@/components/shifts/AssignPersonDialog.vue'
import { useShiftDetailStore } from '@/stores/useShiftDetailStore'
@@ -41,6 +42,50 @@ const { mutate: approveAssignment, isPending: isApproving } = useApproveAssignme
const { mutate: rejectAssignment, isPending: isRejecting } = useRejectAssignment(eventIdRef)
const { mutate: cancelAssignment, isPending: isCancelling } = useCancelAssignment(eventIdRef)
const { mutate: bulkApprove, isPending: isBulkApproving } = useBulkApproveAssignments(eventIdRef)
const { mutateAsync: assignPersonMutation } = useAssignPersonToShift(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,
})
successMessage.value = `${assignment.person?.name ?? 'Persoon'} opnieuw toegewezen`
showSuccess.value = true
}
catch {
successMessage.value = 'Fout bij opnieuw toewijzen'
showSuccess.value = true
}
finally {
reassigning.value = null
}
}
// Status counts
const pendingAssignments = computed(() =>
@@ -642,12 +687,45 @@ function fillRateColor(): string {
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 }">
@@ -789,6 +867,40 @@ function fillRateColor(): string {
</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?.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"

View File

@@ -11,6 +11,8 @@ export const ShiftAssignmentStatus = {
export type ShiftAssignmentStatus = (typeof ShiftAssignmentStatus)[keyof typeof ShiftAssignmentStatus]
export type CancellationSource = 'organiser' | 'volunteer' | 'system'
export interface ShiftAssignment {
id: string
shift_id: string
@@ -23,6 +25,9 @@ export interface ShiftAssignment {
approved_by: string | null
approved_at: string | null
rejection_reason: string | null
cancelled_by: string | null
cancellation_source: CancellationSource | null
cancelled_at: string | null
hours_expected: number | null
hours_completed: number | null
checked_in_at: string | null
@@ -46,6 +51,13 @@ export interface BulkApproveDto {
assignment_ids: string[]
}
export interface PreviousAssignment {
status: 'cancelled' | 'rejected'
cancellation_source: CancellationSource | null
cancelled_at: string | null
rejection_reason: string | null
}
export interface AssignablePerson {
id: string
name: string
@@ -64,4 +76,5 @@ export interface AssignablePerson {
time_slot_name: string
time: string
} | null
previous_assignment: PreviousAssignment | null
}