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) <noreply@anthropic.com>
This commit is contained in:
2026-04-10 21:09:11 +02:00
parent 212db0d3cb
commit 78cc19373e
4 changed files with 110 additions and 7 deletions

View File

@@ -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,

View File

@@ -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

View File

@@ -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

View File

@@ -29,6 +29,15 @@ const assignError = ref<string | null>(null)
const showSuccess = ref(false)
const successName = ref('')
// Overbooking confirmation
const pendingPerson = ref<AssignablePerson | null>(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) {
<VDivider class="mb-4" />
<!-- Overbooking warning -->
<VAlert
v-if="isShiftFull"
type="warning"
variant="tonal"
density="compact"
class="mb-3"
>
<strong>Shift is vol</strong> {{ shift.filled_slots }}/{{ shift.slots_total }}
plekken bezet. Je kunt nog steeds iemand toewijzen, maar de shift wordt overbezet.
</VAlert>
<!-- Error alert -->
<VAlert
v-if="assignError"
@@ -352,6 +393,40 @@ async function handleAssign(person: AssignablePerson) {
</VCard>
</VDialog>
<!-- Overbook confirmation -->
<VDialog
v-model="showOverbookConfirm"
max-width="420"
>
<VCard>
<VCardTitle class="text-h6 pt-5 px-5">
Shift overbezetten?
</VCardTitle>
<VCardText class="px-5">
Deze shift heeft {{ shift?.slots_total }} plekken en
{{ shift?.filled_slots }} zijn bezet. Wil je
<strong>{{ pendingPerson?.name }}</strong> toch toewijzen?
</VCardText>
<VCardActions class="px-5 pb-5">
<VSpacer />
<VBtn
variant="tonal"
@click="showOverbookConfirm = false"
>
Annuleren
</VBtn>
<VBtn
color="warning"
variant="flat"
:loading="isAssigning"
@click="confirmOverbook"
>
Toch toewijzen
</VBtn>
</VCardActions>
</VCard>
</VDialog>
<VSnackbar
v-model="showSuccess"
color="success"