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 */ 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']); } } }