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

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