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:
@@ -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');
|
||||
|
||||
|
||||
Reference in New Issue
Block a user