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