From 78cc19373eeaeb7514a55c7730892c05ee0a5206 Mon Sep 17 00:00:00 2001 From: "bert.hausmans" Date: Fri, 10 Apr 2026 21:09:11 +0200 Subject: [PATCH] feat: allow organizer overbooking with confirmation dialog Remove capacity and status validation from organizer assign flow so organizers can intentionally overbook shifts. Log overbooked assignments for audit trail. Volunteer claims still enforce hard limits. Frontend shows a warning banner when a shift is full and requires confirmation before overbooking. Co-Authored-By: Claude Opus 4.6 (1M context) --- api/app/Services/ShiftAssignmentService.php | 20 ++++- .../Api/V1/ShiftAssignmentWorkflowTest.php | 10 ++- api/tests/Feature/Shift/ShiftTest.php | 10 ++- .../components/shifts/AssignPersonDialog.vue | 77 ++++++++++++++++++- 4 files changed, 110 insertions(+), 7 deletions(-) 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 + + + + +