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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user