Add GET /events/{event}/shifts/{shift}/assignable-persons endpoint that
returns approved persons with availability status, conflict details, and
already-assigned flags. Improve ShiftAssignmentService conflict errors to
include section name, time slot, and time range. Replace both assign
dialogs with a new AssignPersonDialog featuring search, crowd type
filtering, availability toggle, and inline conflict warnings.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
388 lines
13 KiB
PHP
388 lines
13 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace App\Services;
|
||
|
||
use App\Enums\ShiftAssignmentStatus;
|
||
use App\Models\Person;
|
||
use App\Models\Shift;
|
||
use App\Models\ShiftAssignment;
|
||
use App\Models\User;
|
||
use Illuminate\Support\Collection;
|
||
use Illuminate\Support\Facades\DB;
|
||
use Illuminate\Validation\ValidationException;
|
||
|
||
final class ShiftAssignmentService
|
||
{
|
||
/**
|
||
* @throws ValidationException
|
||
*/
|
||
public function claim(Shift $shift, Person $person): ShiftAssignment
|
||
{
|
||
return DB::transaction(function () use ($shift, $person): ShiftAssignment {
|
||
$this->validateShiftIsOpen($shift);
|
||
$this->validatePersonApproved($person);
|
||
$this->validateClaimCapacity($shift);
|
||
$this->validateNoConflict($shift, $person, isClaim: true);
|
||
|
||
$autoApprove = $shift->festivalSection->crew_auto_accepts;
|
||
$status = $autoApprove
|
||
? ShiftAssignmentStatus::APPROVED
|
||
: ShiftAssignmentStatus::PENDING_APPROVAL;
|
||
|
||
$assignment = $shift->shiftAssignments()->create([
|
||
'person_id' => $person->id,
|
||
'time_slot_id' => $shift->time_slot_id,
|
||
'status' => $status,
|
||
'auto_approved' => $autoApprove,
|
||
'assigned_at' => now(),
|
||
'approved_at' => $autoApprove ? now() : null,
|
||
]);
|
||
|
||
$this->updateShiftStatusIfFull($shift);
|
||
|
||
activity('shift_assignment')
|
||
->causedBy(auth()->user())
|
||
->performedOn($assignment)
|
||
->withProperties([
|
||
'shift_id' => $shift->id,
|
||
'person_id' => $person->id,
|
||
'auto_approved' => $autoApprove,
|
||
])
|
||
->log('shift_assignment.claimed');
|
||
|
||
if ($autoApprove) {
|
||
activity('shift_assignment')
|
||
->causedBy(auth()->user())
|
||
->performedOn($assignment)
|
||
->withProperties(['shift_id' => $shift->id])
|
||
->log('shift_assignment.auto_approved');
|
||
}
|
||
|
||
return $assignment;
|
||
});
|
||
}
|
||
|
||
/**
|
||
* @throws ValidationException
|
||
*/
|
||
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);
|
||
|
||
$assignment = $shift->shiftAssignments()->create([
|
||
'person_id' => $person->id,
|
||
'time_slot_id' => $shift->time_slot_id,
|
||
'status' => ShiftAssignmentStatus::APPROVED,
|
||
'auto_approved' => false,
|
||
'assigned_by' => $assignedBy->id,
|
||
'assigned_at' => now(),
|
||
'approved_by' => $assignedBy->id,
|
||
'approved_at' => now(),
|
||
]);
|
||
|
||
$this->updateShiftStatusIfFull($shift);
|
||
|
||
activity('shift_assignment')
|
||
->causedBy($assignedBy)
|
||
->performedOn($assignment)
|
||
->withProperties([
|
||
'shift_id' => $shift->id,
|
||
'person_id' => $person->id,
|
||
'assigned_by' => $assignedBy->id,
|
||
])
|
||
->log('shift_assignment.assigned');
|
||
|
||
return $assignment;
|
||
});
|
||
}
|
||
|
||
/**
|
||
* @throws ValidationException
|
||
*/
|
||
public function approve(ShiftAssignment $assignment, User $approvedBy): ShiftAssignment
|
||
{
|
||
return DB::transaction(function () use ($assignment, $approvedBy): ShiftAssignment {
|
||
$this->validateStatusTransition($assignment, ShiftAssignmentStatus::APPROVED);
|
||
|
||
$shift = $assignment->shift;
|
||
$approvedCount = $shift->shiftAssignments()
|
||
->where('status', ShiftAssignmentStatus::APPROVED)
|
||
->count();
|
||
|
||
if ($approvedCount >= $shift->slots_total) {
|
||
throw ValidationException::withMessages([
|
||
'shift' => ['Shift is vol — alle slots zijn bezet.'],
|
||
]);
|
||
}
|
||
|
||
$oldStatus = $assignment->status;
|
||
|
||
$assignment->update([
|
||
'status' => ShiftAssignmentStatus::APPROVED,
|
||
'approved_by' => $approvedBy->id,
|
||
'approved_at' => now(),
|
||
]);
|
||
|
||
$this->updateShiftStatusIfFull($shift);
|
||
|
||
activity('shift_assignment')
|
||
->causedBy($approvedBy)
|
||
->performedOn($assignment)
|
||
->withProperties([
|
||
'old_status' => $oldStatus->value,
|
||
'new_status' => ShiftAssignmentStatus::APPROVED->value,
|
||
])
|
||
->log('shift_assignment.approved');
|
||
|
||
return $assignment->fresh();
|
||
});
|
||
}
|
||
|
||
/**
|
||
* @throws ValidationException
|
||
*/
|
||
public function reject(ShiftAssignment $assignment, User $rejectedBy, ?string $reason = null): ShiftAssignment
|
||
{
|
||
$this->validateStatusTransition($assignment, ShiftAssignmentStatus::REJECTED);
|
||
|
||
$oldStatus = $assignment->status;
|
||
|
||
$assignment->update([
|
||
'status' => ShiftAssignmentStatus::REJECTED,
|
||
'rejection_reason' => $reason,
|
||
]);
|
||
|
||
activity('shift_assignment')
|
||
->causedBy($rejectedBy)
|
||
->performedOn($assignment)
|
||
->withProperties([
|
||
'old_status' => $oldStatus->value,
|
||
'new_status' => ShiftAssignmentStatus::REJECTED->value,
|
||
'reason' => $reason,
|
||
])
|
||
->log('shift_assignment.rejected');
|
||
|
||
return $assignment->fresh();
|
||
}
|
||
|
||
/**
|
||
* @throws ValidationException
|
||
*/
|
||
public function cancel(ShiftAssignment $assignment, User $cancelledBy): ShiftAssignment
|
||
{
|
||
return DB::transaction(function () use ($assignment, $cancelledBy): ShiftAssignment {
|
||
$this->validateStatusTransition($assignment, ShiftAssignmentStatus::CANCELLED);
|
||
|
||
$wasApproved = $assignment->status === ShiftAssignmentStatus::APPROVED;
|
||
$oldStatus = $assignment->status;
|
||
|
||
$assignment->update([
|
||
'status' => ShiftAssignmentStatus::CANCELLED,
|
||
]);
|
||
|
||
if ($wasApproved) {
|
||
$this->updateShiftStatusAfterCancellation($assignment->shift);
|
||
}
|
||
|
||
activity('shift_assignment')
|
||
->causedBy($cancelledBy)
|
||
->performedOn($assignment)
|
||
->withProperties([
|
||
'old_status' => $oldStatus->value,
|
||
'new_status' => ShiftAssignmentStatus::CANCELLED->value,
|
||
])
|
||
->log('shift_assignment.cancelled');
|
||
|
||
return $assignment->fresh();
|
||
});
|
||
}
|
||
|
||
/**
|
||
* @return Collection<int, array{assignment_id: string, status: string}>
|
||
*/
|
||
public function bulkApprove(Collection $assignments, User $approvedBy): Collection
|
||
{
|
||
return DB::transaction(function () use ($assignments, $approvedBy): Collection {
|
||
return $assignments->map(function (ShiftAssignment $assignment) use ($approvedBy): array {
|
||
if ($assignment->status !== ShiftAssignmentStatus::PENDING_APPROVAL) {
|
||
return [
|
||
'assignment_id' => $assignment->id,
|
||
'status' => 'skipped',
|
||
'reason' => "Status is {$assignment->status->value}, not pending_approval.",
|
||
];
|
||
}
|
||
|
||
$shift = $assignment->shift;
|
||
$approvedCount = $shift->shiftAssignments()
|
||
->where('status', ShiftAssignmentStatus::APPROVED)
|
||
->count();
|
||
|
||
if ($approvedCount >= $shift->slots_total) {
|
||
return [
|
||
'assignment_id' => $assignment->id,
|
||
'status' => 'skipped',
|
||
'reason' => 'Shift is vol.',
|
||
];
|
||
}
|
||
|
||
$assignment->update([
|
||
'status' => ShiftAssignmentStatus::APPROVED,
|
||
'approved_by' => $approvedBy->id,
|
||
'approved_at' => now(),
|
||
]);
|
||
|
||
$this->updateShiftStatusIfFull($shift);
|
||
|
||
activity('shift_assignment')
|
||
->causedBy($approvedBy)
|
||
->performedOn($assignment)
|
||
->withProperties([
|
||
'old_status' => ShiftAssignmentStatus::PENDING_APPROVAL->value,
|
||
'new_status' => ShiftAssignmentStatus::APPROVED->value,
|
||
])
|
||
->log('shift_assignment.approved');
|
||
|
||
return [
|
||
'assignment_id' => $assignment->id,
|
||
'status' => 'approved',
|
||
];
|
||
});
|
||
});
|
||
}
|
||
|
||
/**
|
||
* @throws ValidationException
|
||
*/
|
||
private function validateShiftIsOpen(Shift $shift): void
|
||
{
|
||
if ($shift->status !== 'open') {
|
||
throw ValidationException::withMessages([
|
||
'shift' => ['Shift is niet open voor inschrijvingen.'],
|
||
]);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* @throws ValidationException
|
||
*/
|
||
private function validatePersonApproved(Person $person): void
|
||
{
|
||
if ($person->status !== 'approved') {
|
||
throw ValidationException::withMessages([
|
||
'person' => ['Persoon is nog niet goedgekeurd.'],
|
||
]);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* @throws ValidationException
|
||
*/
|
||
private function validateClaimCapacity(Shift $shift): void
|
||
{
|
||
if ($shift->slots_open_for_claiming <= 0) {
|
||
throw ValidationException::withMessages([
|
||
'shift' => ['Geen claimbare slots beschikbaar voor deze shift.'],
|
||
]);
|
||
}
|
||
|
||
$activeCount = $shift->shiftAssignments()
|
||
->whereNotIn('status', [
|
||
ShiftAssignmentStatus::REJECTED,
|
||
ShiftAssignmentStatus::CANCELLED,
|
||
])
|
||
->count();
|
||
|
||
if ($activeCount >= $shift->slots_open_for_claiming) {
|
||
throw ValidationException::withMessages([
|
||
'shift' => ['Geen claimbare slots beschikbaar voor deze shift.'],
|
||
]);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* @throws ValidationException
|
||
*/
|
||
private function validateAssignCapacity(Shift $shift): void
|
||
{
|
||
$approvedCount = $shift->shiftAssignments()
|
||
->where('status', ShiftAssignmentStatus::APPROVED)
|
||
->count();
|
||
|
||
if ($approvedCount >= $shift->slots_total) {
|
||
throw ValidationException::withMessages([
|
||
'shift' => ['Shift is vol — alle slots zijn bezet.'],
|
||
]);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* @throws ValidationException
|
||
*/
|
||
private function validateNoConflict(Shift $shift, Person $person, bool $isClaim = false): void
|
||
{
|
||
if ($shift->allow_overlap) {
|
||
return;
|
||
}
|
||
|
||
$existingAssignment = ShiftAssignment::where('person_id', $person->id)
|
||
->where('time_slot_id', $shift->time_slot_id)
|
||
->active()
|
||
->with(['shift.festivalSection', 'shift.timeSlot'])
|
||
->first();
|
||
|
||
if ($existingAssignment) {
|
||
$section = $existingAssignment->shift->festivalSection->name;
|
||
$timeSlot = $existingAssignment->shift->timeSlot->name;
|
||
$time = $existingAssignment->shift->timeSlot->start_time
|
||
. '–' . $existingAssignment->shift->timeSlot->end_time;
|
||
|
||
$message = $isClaim
|
||
? "Je bent al ingepland bij \"{$section}\" voor {$timeSlot} ({$time})."
|
||
: "Deze persoon is al ingepland bij \"{$section}\" voor {$timeSlot} ({$time}).";
|
||
|
||
throw ValidationException::withMessages([
|
||
'person_id' => [$message],
|
||
]);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* @throws ValidationException
|
||
*/
|
||
private function validateStatusTransition(ShiftAssignment $assignment, ShiftAssignmentStatus $target): void
|
||
{
|
||
if (! $assignment->status->canTransitionTo($target)) {
|
||
throw ValidationException::withMessages([
|
||
'status' => ["Statusovergang van '{$assignment->status->value}' naar '{$target->value}' is niet toegestaan."],
|
||
]);
|
||
}
|
||
}
|
||
|
||
private function updateShiftStatusIfFull(Shift $shift): void
|
||
{
|
||
$approvedCount = $shift->shiftAssignments()
|
||
->where('status', ShiftAssignmentStatus::APPROVED)
|
||
->count();
|
||
|
||
if ($approvedCount >= $shift->slots_total && $shift->status === 'open') {
|
||
$shift->update(['status' => 'full']);
|
||
}
|
||
}
|
||
|
||
private function updateShiftStatusAfterCancellation(Shift $shift): void
|
||
{
|
||
$approvedCount = $shift->shiftAssignments()
|
||
->where('status', ShiftAssignmentStatus::APPROVED)
|
||
->count();
|
||
|
||
if ($approvedCount < $shift->slots_total && $shift->status === 'full') {
|
||
$shift->update(['status' => 'open']);
|
||
}
|
||
}
|
||
}
|