fix: allow organiser to approve shift assignments when shift is full

The approve() and bulkApprove() methods in ShiftAssignmentService
hard-blocked with a 422 when all slots were filled. This was incorrect
for organiser actions — only volunteer claims (portal self-service)
should enforce capacity limits. Organiser assign() already allowed
overbooking, making the approve block inconsistent.

Changes:
- Remove capacity hard-block from approve() and bulkApprove(), replace
  with audit log entry (shift.overbooked_approval)
- Add overbook confirmation dialog in ShiftDetailPanel before approving
  a full shift (single + bulk approve)
- Add onError handlers to all mutations in ShiftDetailPanel (approve,
  reject, cancel, bulk-approve) so errors display in the snackbar
- Add global 422 validation error display in axios interceptor via
  useNotificationStore as safety net for all components
- Add PHPUnit test for approve-when-full scenario

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-14 17:42:04 +02:00
parent a9ef384515
commit ed1eddd486
4 changed files with 159 additions and 13 deletions

View File

@@ -197,11 +197,38 @@ function flashFeedback(message: string, variant: 'success' | 'error' = 'success'
// --- 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')
},
})
}
@@ -220,6 +247,9 @@ function onCancelExecute() {
cancellingAssignment.value = null
flashFeedback(`${name} geannuleerd`, 'success')
},
onError: (err: unknown) => {
flashFeedback(getApiErrorMessage(err, 'Fout bij annuleren'), 'error')
},
})
}
@@ -250,6 +280,9 @@ function onRejectExecute() {
rejectReason.value = ''
flashFeedback(`${name} afgewezen`, 'success')
},
onError: (err: unknown) => {
flashFeedback(getApiErrorMessage(err, 'Fout bij afwijzen'), 'error')
},
},
)
}
@@ -261,6 +294,13 @@ const cancellingAssignment = ref<ShiftAssignment | null>(null)
// 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
}
@@ -274,6 +314,9 @@ function onBulkApproveExecute() {
flashFeedback(`${store.selectedAssignmentIds.length} toewijzingen goedgekeurd`, 'success')
store.clearSelection()
},
onError: (err: unknown) => {
flashFeedback(getApiErrorMessage(err, 'Fout bij bulk goedkeuren'), 'error')
},
})
}
@@ -856,6 +899,17 @@ function fillRateColor(): string {
<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 />
@@ -866,11 +920,47 @@ function fillRateColor(): string {
Annuleren
</VBtn>
<VBtn
color="success"
:color="bulkApproveWillOverbook ? 'warning' : 'success'"
:loading="isBulkApproving"
@click="onBulkApproveExecute"
>
Goedkeuren
{{ 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>