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) <noreply@anthropic.com>
This commit is contained in:
2026-04-10 21:50:24 +02:00
parent dfe7a63ad3
commit 3e292567c3
11 changed files with 554 additions and 7 deletions

View File

@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace App\Enums;
enum CancellationSource: string
{
case ORGANISER = 'organiser';
case VOLUNTEER = 'volunteer';
case SYSTEM = 'system';
}

View File

@@ -120,7 +120,7 @@ final class ShiftAssignmentController extends Controller
->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([

View File

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

View File

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

View File

@@ -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');

View File

@@ -0,0 +1,27 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('shift_assignments', function (Blueprint $table) {
$table->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']);
});
}
};

View File

@@ -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));
}
}