diff --git a/api/app/Services/ShiftAssignmentService.php b/api/app/Services/ShiftAssignmentService.php index 47281a80..ef463c8c 100644 --- a/api/app/Services/ShiftAssignmentService.php +++ b/api/app/Services/ShiftAssignmentService.php @@ -70,10 +70,26 @@ final class ShiftAssignmentService public function assign(Shift $shift, Person $person, User $assignedBy): ShiftAssignment { return DB::transaction(function () use ($shift, $person, $assignedBy): ShiftAssignment { - $this->validateShiftIsOpen($shift); - $this->validateAssignCapacity($shift); $this->validateNoConflict($shift, $person); + // Log overbooking for audit trail (organizers may intentionally overbook) + $filledSlots = $shift->shiftAssignments() + ->where('status', ShiftAssignmentStatus::APPROVED) + ->count(); + + if ($filledSlots >= $shift->slots_total) { + activity('shift_assignment') + ->causedBy($assignedBy) + ->performedOn($shift) + ->withProperties([ + 'filled_slots' => $filledSlots, + 'slots_total' => $shift->slots_total, + 'person_id' => $person->id, + 'person_name' => $person->name, + ]) + ->log('shift.overbooked_assignment'); + } + $assignment = $shift->shiftAssignments()->create([ 'person_id' => $person->id, 'time_slot_id' => $shift->time_slot_id, diff --git a/api/tests/Feature/Api/V1/ShiftAssignmentWorkflowTest.php b/api/tests/Feature/Api/V1/ShiftAssignmentWorkflowTest.php index da303d24..7649fc7c 100644 --- a/api/tests/Feature/Api/V1/ShiftAssignmentWorkflowTest.php +++ b/api/tests/Feature/Api/V1/ShiftAssignmentWorkflowTest.php @@ -289,7 +289,7 @@ class ShiftAssignmentWorkflowTest extends TestCase $response->assertCreated(); } - public function test_assign_rejected_when_capacity_full(): void + public function test_assign_allows_overbooking_when_capacity_full(): void { $shift = $this->createOpenShift(['slots_total' => 1]); @@ -310,7 +310,13 @@ class ShiftAssignmentWorkflowTest extends TestCase ['person_id' => $this->person->id], ); - $response->assertUnprocessable(); + $response->assertCreated(); + + $this->assertDatabaseHas('shift_assignments', [ + 'shift_id' => $shift->id, + 'person_id' => $this->person->id, + 'status' => 'approved', + ]); } public function test_assign_rejected_with_conflict(): void diff --git a/api/tests/Feature/Shift/ShiftTest.php b/api/tests/Feature/Shift/ShiftTest.php index 77da9dfa..7202bfad 100644 --- a/api/tests/Feature/Shift/ShiftTest.php +++ b/api/tests/Feature/Shift/ShiftTest.php @@ -284,7 +284,7 @@ class ShiftTest extends TestCase $response->assertCreated(); } - public function test_assign_full_shift_returns_422(): void + public function test_assign_full_shift_allows_overbooking(): void { $shift = Shift::factory()->create([ 'festival_section_id' => $this->section->id, @@ -317,7 +317,13 @@ class ShiftTest extends TestCase 'person_id' => $person2->id, ]); - $response->assertUnprocessable(); + $response->assertCreated(); + + $this->assertDatabaseHas('shift_assignments', [ + 'shift_id' => $shift->id, + 'person_id' => $person2->id, + 'status' => 'approved', + ]); } public function test_claim_no_claimable_slots_returns_422(): void diff --git a/apps/app/src/components/shifts/AssignPersonDialog.vue b/apps/app/src/components/shifts/AssignPersonDialog.vue index 6c30b5a4..c4fb0bd0 100644 --- a/apps/app/src/components/shifts/AssignPersonDialog.vue +++ b/apps/app/src/components/shifts/AssignPersonDialog.vue @@ -29,6 +29,15 @@ const assignError = ref(null) const showSuccess = ref(false) const successName = ref('') +// Overbooking confirmation +const pendingPerson = ref(null) +const showOverbookConfirm = ref(false) + +const isShiftFull = computed(() => { + if (!props.shift) return false + return props.shift.filled_slots >= props.shift.slots_total +}) + // Clear error on filter changes watch([searchQuery, showOnlyAvailable, selectedCrowdType], () => { assignError.value = null @@ -102,10 +111,30 @@ function getInitials(name: string) { .slice(0, 2) } -async function handleAssign(person: AssignablePerson) { +function handleAssign(person: AssignablePerson) { if (!props.shift) return assignError.value = null + if (isShiftFull.value) { + pendingPerson.value = person + showOverbookConfirm.value = true + return + } + + executeAssign(person) +} + +function confirmOverbook() { + if (pendingPerson.value) { + executeAssign(pendingPerson.value) + } + showOverbookConfirm.value = false + pendingPerson.value = null +} + +async function executeAssign(person: AssignablePerson) { + if (!props.shift) return + try { await assignPerson({ sectionId: props.sectionId, @@ -168,6 +197,18 @@ async function handleAssign(person: AssignablePerson) { + + + Shift is vol — {{ shift.filled_slots }}/{{ shift.slots_total }} + plekken bezet. Je kunt nog steeds iemand toewijzen, maar de shift wordt overbezet. + + + + + + + Shift overbezetten? + + + Deze shift heeft {{ shift?.slots_total }} plekken en + {{ shift?.filled_slots }} zijn bezet. Wil je + {{ pendingPerson?.name }} toch toewijzen? + + + + + Annuleren + + + Toch toewijzen + + + + +