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

@@ -168,18 +168,26 @@ final class ShiftAssignmentService
$this->validateStatusTransition($assignment, ShiftAssignmentStatus::APPROVED);
$shift = $assignment->shift;
$oldStatus = $assignment->status;
// Log overbooking for audit trail (organisers may intentionally overbook)
$approvedCount = $shift->shiftAssignments()
->where('status', ShiftAssignmentStatus::APPROVED)
->count();
if ($approvedCount >= $shift->slots_total) {
throw ValidationException::withMessages([
'shift' => ['Shift is vol — alle slots zijn bezet.'],
]);
activity('shift_assignment')
->causedBy($approvedBy)
->performedOn($shift)
->withProperties([
'filled_slots' => $approvedCount,
'slots_total' => $shift->slots_total,
'assignment_id' => $assignment->id,
'person_id' => $assignment->person_id,
])
->log('shift.overbooked_approval');
}
$oldStatus = $assignment->status;
$assignment->status = ShiftAssignmentStatus::APPROVED;
$assignment->approved_by = $approvedBy->id;
$assignment->approved_at = now();
@@ -280,16 +288,22 @@ final class ShiftAssignmentService
}
$shift = $assignment->shift;
// Log overbooking for audit trail
$approvedCount = $shift->shiftAssignments()
->where('status', ShiftAssignmentStatus::APPROVED)
->count();
if ($approvedCount >= $shift->slots_total) {
return [
'assignment_id' => $assignment->id,
'status' => 'skipped',
'reason' => 'Shift is vol.',
];
activity('shift_assignment')
->causedBy($approvedBy)
->performedOn($shift)
->withProperties([
'filled_slots' => $approvedCount,
'slots_total' => $shift->slots_total,
'assignment_id' => $assignment->id,
])
->log('shift.overbooked_approval');
}
$assignment->status = ShiftAssignmentStatus::APPROVED;

View File

@@ -406,6 +406,44 @@ class ShiftAssignmentWorkflowTest extends TestCase
->assertJsonPath('data.rejection_reason', 'Onvoldoende ervaring voor deze rol.');
}
public function test_approve_allows_overbooking_when_shift_is_full(): void
{
$shift = $this->createOpenShift(['slots_total' => 1]);
// Fill the slot
ShiftAssignment::factory()->approved()->create([
'shift_id' => $shift->id,
'person_id' => Person::factory()->approved()->create([
'event_id' => $this->event->id,
'crowd_type_id' => $this->crowdType->id,
])->id,
'time_slot_id' => $this->timeSlot->id,
]);
// Pending assignment to approve
$assignment = ShiftAssignment::factory()->create([
'shift_id' => $shift->id,
'person_id' => $this->person->id,
'time_slot_id' => $this->timeSlot->id,
'status' => ShiftAssignmentStatus::PENDING_APPROVAL,
]);
Sanctum::actingAs($this->orgAdmin);
$response = $this->postJson(
"/api/v1/organisations/{$this->organisation->id}/events/{$this->event->id}/shift-assignments/{$assignment->id}/approve",
);
$response->assertOk()
->assertJsonPath('data.status', 'approved');
$this->assertDatabaseHas('shift_assignments', [
'id' => $assignment->id,
'status' => ShiftAssignmentStatus::APPROVED->value,
'approved_by' => $this->orgAdmin->id,
]);
}
public function test_cannot_approve_already_approved(): void
{
$shift = $this->createOpenShift();