From 3e292567c3625795f51928209e4a68ec87f57b93 Mon Sep 17 00:00:00 2001 From: "bert.hausmans" Date: Fri, 10 Apr 2026 21:50:24 +0200 Subject: [PATCH] feat: smart re-assignment with cancellation source tracking Add cancelled_by, cancellation_source (organiser|volunteer|system), and cancelled_at columns to shift_assignments. Cancel flow now records who cancelled and why. Assign flow reactivates existing cancelled/rejected records instead of creating duplicates, preventing UNIQUE constraint violations. Assignable-persons endpoint returns previous_assignment data for contextual UI indicators. Frontend shows cancellation source labels, previous assignment history in assign dialog, and "Opnieuw toewijzen" buttons with volunteer-cancelled confirmation dialogs. Co-Authored-By: Claude Opus 4.6 (1M context) --- api/app/Enums/CancellationSource.php | 12 ++ .../Api/V1/ShiftAssignmentController.php | 20 +- .../Api/V1/ShiftAssignmentResource.php | 3 + api/app/Models/ShiftAssignment.php | 11 + api/app/Services/ShiftAssignmentService.php | 52 ++++- ...ellation_tracking_to_shift_assignments.php | 27 +++ .../Feature/Api/V1/AssignablePersonsTest.php | 203 ++++++++++++++++++ .../components/shifts/AssignPersonDialog.vue | 105 ++++++++- .../components/shifts/ShiftDetailPanel.vue | 112 ++++++++++ apps/app/src/types/shiftAssignment.ts | 13 ++ dev-docs/SCHEMA.md | 3 + 11 files changed, 554 insertions(+), 7 deletions(-) create mode 100644 api/app/Enums/CancellationSource.php create mode 100644 api/database/migrations/2026_04_10_193837_add_cancellation_tracking_to_shift_assignments.php diff --git a/api/app/Enums/CancellationSource.php b/api/app/Enums/CancellationSource.php new file mode 100644 index 00000000..a15ea611 --- /dev/null +++ b/api/app/Enums/CancellationSource.php @@ -0,0 +1,12 @@ +get() ->keyBy('person_id'); - // Get all assignments for THIS shift in one query + // Get active (non-cancelled/rejected) assignments for THIS shift $alreadyAssigned = ShiftAssignment::where('shift_id', $shiftId) ->whereNotIn('status', [ ShiftAssignmentStatus::REJECTED, @@ -129,15 +129,25 @@ final class ShiftAssignmentController extends Controller ->pluck('person_id') ->flip(); + // Get previous cancelled/rejected assignments on THIS shift + $previousAssignments = ShiftAssignment::where('shift_id', $shiftId) + ->whereIn('status', [ + ShiftAssignmentStatus::CANCELLED, + ShiftAssignmentStatus::REJECTED, + ]) + ->get() + ->keyBy('person_id'); + $persons = Person::where('event_id', $festivalEventId) ->where('status', PersonStatus::APPROVED) ->with('crowdType') ->orderBy('name') ->get() - ->map(function (Person $person) use ($conflicts, $alreadyAssigned, $shiftId) { + ->map(function (Person $person) use ($conflicts, $alreadyAssigned, $previousAssignments, $shiftId) { $isAlreadyAssigned = $alreadyAssigned->has($person->id); $conflict = $conflicts->get($person->id); $hasConflict = $conflict && $conflict->shift_id !== $shiftId; + $previous = $previousAssignments->get($person->id); return [ 'id' => $person->id, @@ -158,6 +168,12 @@ final class ShiftAssignmentController extends Controller 'time' => $conflict->shift->timeSlot->start_time . '–' . $conflict->shift->timeSlot->end_time, ] : null, + 'previous_assignment' => $previous ? [ + 'status' => $previous->status->value, + 'cancellation_source' => $previous->cancellation_source?->value, + 'cancelled_at' => $previous->cancelled_at?->toIso8601String(), + 'rejection_reason' => $previous->rejection_reason, + ] : null, ]; }) ->sortBy([ diff --git a/api/app/Http/Resources/Api/V1/ShiftAssignmentResource.php b/api/app/Http/Resources/Api/V1/ShiftAssignmentResource.php index d654c378..74e04c59 100644 --- a/api/app/Http/Resources/Api/V1/ShiftAssignmentResource.php +++ b/api/app/Http/Resources/Api/V1/ShiftAssignmentResource.php @@ -23,6 +23,9 @@ final class ShiftAssignmentResource extends JsonResource 'approved_by' => $this->approved_by, 'approved_at' => $this->approved_at?->toIso8601String(), 'rejection_reason' => $this->rejection_reason, + 'cancelled_by' => $this->cancelled_by, + 'cancellation_source' => $this->cancellation_source?->value, + 'cancelled_at' => $this->cancelled_at?->toIso8601String(), 'hours_expected' => $this->hours_expected, 'hours_completed' => $this->hours_completed, 'checked_in_at' => $this->checked_in_at?->toIso8601String(), diff --git a/api/app/Models/ShiftAssignment.php b/api/app/Models/ShiftAssignment.php index 832e8324..563c55b8 100644 --- a/api/app/Models/ShiftAssignment.php +++ b/api/app/Models/ShiftAssignment.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Models; +use App\Enums\CancellationSource; use App\Enums\ShiftAssignmentStatus; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Concerns\HasUlids; @@ -29,6 +30,9 @@ final class ShiftAssignment extends Model 'approved_by', 'approved_at', 'rejection_reason', + 'cancelled_by', + 'cancellation_source', + 'cancelled_at', 'hours_expected', 'hours_completed', 'checked_in_at', @@ -39,9 +43,11 @@ final class ShiftAssignment extends Model { return [ 'status' => ShiftAssignmentStatus::class, + 'cancellation_source' => CancellationSource::class, 'auto_approved' => 'boolean', 'assigned_at' => 'datetime', 'approved_at' => 'datetime', + 'cancelled_at' => 'datetime', 'checked_in_at' => 'datetime', 'checked_out_at' => 'datetime', 'hours_expected' => 'decimal:2', @@ -74,6 +80,11 @@ final class ShiftAssignment extends Model return $this->belongsTo(User::class, 'approved_by'); } + public function cancelledByUser(): BelongsTo + { + return $this->belongsTo(User::class, 'cancelled_by'); + } + public function scopeActive(Builder $query): Builder { return $query->whereIn('status', [ diff --git a/api/app/Services/ShiftAssignmentService.php b/api/app/Services/ShiftAssignmentService.php index ef463c8c..da14789b 100644 --- a/api/app/Services/ShiftAssignmentService.php +++ b/api/app/Services/ShiftAssignmentService.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Services; +use App\Enums\CancellationSource; use App\Enums\ShiftAssignmentStatus; use App\Models\Person; use App\Models\Shift; @@ -72,6 +73,44 @@ final class ShiftAssignmentService return DB::transaction(function () use ($shift, $person, $assignedBy): ShiftAssignment { $this->validateNoConflict($shift, $person); + // Check for existing cancelled/rejected assignment on THIS shift + $existing = ShiftAssignment::where('shift_id', $shift->id) + ->where('person_id', $person->id) + ->whereIn('status', [ + ShiftAssignmentStatus::CANCELLED, + ShiftAssignmentStatus::REJECTED, + ]) + ->first(); + + if ($existing) { + $previousStatus = $existing->status->value; + + $existing->update([ + 'status' => ShiftAssignmentStatus::APPROVED, + 'assigned_by' => $assignedBy->id, + 'assigned_at' => now(), + 'approved_by' => $assignedBy->id, + 'approved_at' => now(), + 'rejection_reason' => null, + 'cancelled_by' => null, + 'cancellation_source' => null, + 'cancelled_at' => null, + ]); + + activity('shift_assignment') + ->causedBy($assignedBy) + ->performedOn($existing) + ->withProperties([ + 'previous_status' => $previousStatus, + 'person_name' => $person->name, + ]) + ->log('shift_assignment.reactivated'); + + $this->updateShiftStatusIfFull($shift); + + return $existing->fresh(); + } + // Log overbooking for audit trail (organizers may intentionally overbook) $filledSlots = $shift->shiftAssignments() ->where('status', ShiftAssignmentStatus::APPROVED) @@ -189,9 +228,12 @@ final class ShiftAssignmentService /** * @throws ValidationException */ - public function cancel(ShiftAssignment $assignment, User $cancelledBy): ShiftAssignment - { - return DB::transaction(function () use ($assignment, $cancelledBy): ShiftAssignment { + public function cancel( + ShiftAssignment $assignment, + User $cancelledBy, + CancellationSource $source = CancellationSource::ORGANISER, + ): ShiftAssignment { + return DB::transaction(function () use ($assignment, $cancelledBy, $source): ShiftAssignment { $this->validateStatusTransition($assignment, ShiftAssignmentStatus::CANCELLED); $wasApproved = $assignment->status === ShiftAssignmentStatus::APPROVED; @@ -199,6 +241,9 @@ final class ShiftAssignmentService $assignment->update([ 'status' => ShiftAssignmentStatus::CANCELLED, + 'cancelled_by' => $cancelledBy->id, + 'cancellation_source' => $source, + 'cancelled_at' => now(), ]); if ($wasApproved) { @@ -211,6 +256,7 @@ final class ShiftAssignmentService ->withProperties([ 'old_status' => $oldStatus->value, 'new_status' => ShiftAssignmentStatus::CANCELLED->value, + 'source' => $source->value, ]) ->log('shift_assignment.cancelled'); diff --git a/api/database/migrations/2026_04_10_193837_add_cancellation_tracking_to_shift_assignments.php b/api/database/migrations/2026_04_10_193837_add_cancellation_tracking_to_shift_assignments.php new file mode 100644 index 00000000..57f622ea --- /dev/null +++ b/api/database/migrations/2026_04_10_193837_add_cancellation_tracking_to_shift_assignments.php @@ -0,0 +1,27 @@ +ulid('cancelled_by')->nullable()->after('rejection_reason'); + $table->string('cancellation_source')->nullable()->after('cancelled_by'); + $table->timestamp('cancelled_at')->nullable()->after('cancellation_source'); + + $table->foreign('cancelled_by')->references('id')->on('users')->nullOnDelete(); + }); + } + + public function down(): void + { + Schema::table('shift_assignments', function (Blueprint $table) { + $table->dropForeign(['cancelled_by']); + $table->dropColumn(['cancelled_by', 'cancellation_source', 'cancelled_at']); + }); + } +}; diff --git a/api/tests/Feature/Api/V1/AssignablePersonsTest.php b/api/tests/Feature/Api/V1/AssignablePersonsTest.php index 5b91874d..e389e410 100644 --- a/api/tests/Feature/Api/V1/AssignablePersonsTest.php +++ b/api/tests/Feature/Api/V1/AssignablePersonsTest.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Tests\Feature\Api\V1; +use App\Enums\CancellationSource; use App\Enums\ShiftAssignmentStatus; use App\Models\CrowdType; use App\Models\Event; @@ -293,4 +294,206 @@ class AssignablePersonsTest extends TestCase $message = $response->json('errors.person_id.0'); $this->assertStringContainsString('Je bent al ingepland bij', $message); } + + // ========================================================================= + // Cancellation source tracking + // ========================================================================= + + public function test_cancel_stores_cancellation_source_and_cancelled_by(): void + { + $shift = $this->createOpenShift(); + $person = $this->createPerson(); + + $assignment = ShiftAssignment::factory()->approved()->create([ + 'shift_id' => $shift->id, + 'person_id' => $person->id, + 'time_slot_id' => $this->timeSlot->id, + ]); + + Sanctum::actingAs($this->orgAdmin); + + $response = $this->postJson( + "/api/v1/events/{$this->event->id}/shift-assignments/{$assignment->id}/cancel", + ); + + $response->assertOk() + ->assertJsonPath('data.cancellation_source', 'organiser') + ->assertJsonPath('data.cancelled_by', $this->orgAdmin->id); + + $this->assertNotNull($response->json('data.cancelled_at')); + } + + // ========================================================================= + // Re-assignment (reactivation) + // ========================================================================= + + public function test_assign_after_cancellation_reactivates_existing_record(): void + { + $shift = $this->createOpenShift(); + $person = $this->createPerson(); + + $assignment = ShiftAssignment::factory()->create([ + 'shift_id' => $shift->id, + 'person_id' => $person->id, + 'time_slot_id' => $this->timeSlot->id, + 'status' => ShiftAssignmentStatus::CANCELLED, + 'cancelled_by' => $this->orgAdmin->id, + 'cancellation_source' => CancellationSource::ORGANISER, + 'cancelled_at' => now(), + ]); + + Sanctum::actingAs($this->orgAdmin); + + $response = $this->postJson( + "/api/v1/events/{$this->event->id}/sections/{$this->section->id}/shifts/{$shift->id}/assign", + ['person_id' => $person->id], + ); + + $response->assertCreated() + ->assertJsonPath('data.status', 'approved') + ->assertJsonPath('data.cancelled_by', null) + ->assertJsonPath('data.cancellation_source', null) + ->assertJsonPath('data.cancelled_at', null); + + // Same record reactivated, not a new one + $this->assertJsonPath($response, 'data.id', $assignment->id); + $this->assertDatabaseCount('shift_assignments', 1); + } + + public function test_assign_after_rejection_reactivates_existing_record(): void + { + $shift = $this->createOpenShift(); + $person = $this->createPerson(); + + $assignment = ShiftAssignment::factory()->create([ + 'shift_id' => $shift->id, + 'person_id' => $person->id, + 'time_slot_id' => $this->timeSlot->id, + 'status' => ShiftAssignmentStatus::REJECTED, + 'rejection_reason' => 'Niet geschikt', + ]); + + Sanctum::actingAs($this->orgAdmin); + + $response = $this->postJson( + "/api/v1/events/{$this->event->id}/sections/{$this->section->id}/shifts/{$shift->id}/assign", + ['person_id' => $person->id], + ); + + $response->assertCreated() + ->assertJsonPath('data.status', 'approved') + ->assertJsonPath('data.rejection_reason', null); + + $this->assertEquals($assignment->id, $response->json('data.id')); + $this->assertDatabaseCount('shift_assignments', 1); + } + + public function test_conflict_check_excludes_cancelled_assignments(): void + { + $shift1 = $this->createOpenShift(); + $shift2 = $this->createOpenShift(['festival_section_id' => $this->otherSection->id]); + $person = $this->createPerson(); + + // Cancelled assignment on shift1 should NOT block assignment on shift2 + ShiftAssignment::factory()->create([ + 'shift_id' => $shift1->id, + 'person_id' => $person->id, + 'time_slot_id' => $this->timeSlot->id, + 'status' => ShiftAssignmentStatus::CANCELLED, + ]); + + Sanctum::actingAs($this->orgAdmin); + + $response = $this->postJson( + "/api/v1/events/{$this->event->id}/sections/{$this->otherSection->id}/shifts/{$shift2->id}/assign", + ['person_id' => $person->id], + ); + + $response->assertCreated(); + } + + // ========================================================================= + // Assignable persons — previous assignment data + // ========================================================================= + + public function test_assignable_persons_cancelled_person_has_previous_assignment(): void + { + $shift = $this->createOpenShift(); + $person = $this->createPerson(); + + ShiftAssignment::factory()->create([ + 'shift_id' => $shift->id, + 'person_id' => $person->id, + 'time_slot_id' => $this->timeSlot->id, + 'status' => ShiftAssignmentStatus::CANCELLED, + 'cancellation_source' => CancellationSource::ORGANISER, + 'cancelled_at' => now(), + ]); + + Sanctum::actingAs($this->orgAdmin); + + $response = $this->getJson( + "/api/v1/events/{$this->event->id}/shifts/{$shift->id}/assignable-persons", + ); + + $response->assertOk() + ->assertJsonPath('data.0.already_assigned', false) + ->assertJsonPath('data.0.is_available', true) + ->assertJsonPath('data.0.previous_assignment.status', 'cancelled') + ->assertJsonPath('data.0.previous_assignment.cancellation_source', 'organiser'); + } + + public function test_assignable_persons_volunteer_cancelled_has_volunteer_source(): void + { + $shift = $this->createOpenShift(); + $person = $this->createPerson(); + + ShiftAssignment::factory()->create([ + 'shift_id' => $shift->id, + 'person_id' => $person->id, + 'time_slot_id' => $this->timeSlot->id, + 'status' => ShiftAssignmentStatus::CANCELLED, + 'cancellation_source' => CancellationSource::VOLUNTEER, + 'cancelled_at' => now(), + ]); + + Sanctum::actingAs($this->orgAdmin); + + $response = $this->getJson( + "/api/v1/events/{$this->event->id}/shifts/{$shift->id}/assignable-persons", + ); + + $response->assertOk() + ->assertJsonPath('data.0.previous_assignment.cancellation_source', 'volunteer'); + } + + public function test_assignable_persons_rejected_person_has_previous_assignment(): void + { + $shift = $this->createOpenShift(); + $person = $this->createPerson(); + + ShiftAssignment::factory()->create([ + 'shift_id' => $shift->id, + 'person_id' => $person->id, + 'time_slot_id' => $this->timeSlot->id, + 'status' => ShiftAssignmentStatus::REJECTED, + 'rejection_reason' => 'Geen ervaring', + ]); + + Sanctum::actingAs($this->orgAdmin); + + $response = $this->getJson( + "/api/v1/events/{$this->event->id}/shifts/{$shift->id}/assignable-persons", + ); + + $response->assertOk() + ->assertJsonPath('data.0.already_assigned', false) + ->assertJsonPath('data.0.previous_assignment.status', 'rejected') + ->assertJsonPath('data.0.previous_assignment.rejection_reason', 'Geen ervaring'); + } + + private function assertJsonPath($response, string $path, mixed $expected): void + { + $this->assertEquals($expected, $response->json($path)); + } } diff --git a/apps/app/src/components/shifts/AssignPersonDialog.vue b/apps/app/src/components/shifts/AssignPersonDialog.vue index c4fb0bd0..7486cf90 100644 --- a/apps/app/src/components/shifts/AssignPersonDialog.vue +++ b/apps/app/src/components/shifts/AssignPersonDialog.vue @@ -29,9 +29,10 @@ const assignError = ref(null) const showSuccess = ref(false) const successName = ref('') -// Overbooking confirmation +// Confirmation dialogs const pendingPerson = ref(null) const showOverbookConfirm = ref(false) +const showVolunteerReassignConfirm = ref(false) const isShiftFull = computed(() => { if (!props.shift) return false @@ -115,6 +116,14 @@ function handleAssign(person: AssignablePerson) { if (!props.shift) return assignError.value = null + // Volunteer self-cancelled — extra warning + if (person.previous_assignment?.cancellation_source === 'volunteer') { + pendingPerson.value = person + showVolunteerReassignConfirm.value = true + return + } + + // Shift is full — overbooking warning if (isShiftFull.value) { pendingPerson.value = person showOverbookConfirm.value = true @@ -132,6 +141,20 @@ function confirmOverbook() { pendingPerson.value = null } +function confirmVolunteerReassign() { + if (pendingPerson.value) { + // Still check overbooking after volunteer confirmation + if (isShiftFull.value) { + showVolunteerReassignConfirm.value = false + showOverbookConfirm.value = true + return + } + executeAssign(pendingPerson.value) + } + showVolunteerReassignConfirm.value = false + pendingPerson.value = null +} + async function executeAssign(person: AssignablePerson) { if (!props.shift) return @@ -350,7 +373,52 @@ async function executeAssign(person: AssignablePerson) { {{ person.name }} - {{ person.email }} + + {{ person.email }} + + +