verifyEventBelongsToOrganisation($organisation, $event); Gate::authorize('viewAny', [ShiftAssignment::class, $event]); $query = ShiftAssignment::query() ->whereHas('shift.festivalSection', fn ($q) => $q->where('event_id', $event->id)) ->with(['person', 'shift.festivalSection', 'shift.timeSlot']); if ($request->filled('status')) { $status = ShiftAssignmentStatus::tryFrom($request->string('status')->toString()); if ($status !== null) { $query->where('status', $status); } } if ($request->filled('shift_id')) { $query->where('shift_id', $request->string('shift_id')->toString()); } if ($request->filled('person_id')) { $query->where('person_id', $request->string('person_id')->toString()); } if ($request->filled('section_id')) { $query->whereHas('shift', fn ($q) => $q->where('festival_section_id', $request->string('section_id')->toString())); } $assignments = $query->orderByDesc('created_at')->paginate(50); return ShiftAssignmentResource::collection($assignments); } public function approve(Organisation $organisation, Event $event, ShiftAssignment $shiftAssignment): JsonResponse { $this->verifyEventBelongsToOrganisation($organisation, $event); Gate::authorize('approve', [$shiftAssignment, $event]); $shiftAssignment = $this->service->approve($shiftAssignment, request()->user()); return $this->success(new ShiftAssignmentResource($shiftAssignment->load(['person', 'shift.festivalSection', 'shift.timeSlot']))); } public function reject(RejectShiftAssignmentRequest $request, Organisation $organisation, Event $event, ShiftAssignment $shiftAssignment): JsonResponse { $this->verifyEventBelongsToOrganisation($organisation, $event); Gate::authorize('reject', [$shiftAssignment, $event]); $shiftAssignment = $this->service->reject( $shiftAssignment, $request->user(), $request->validated('reason'), ); return $this->success(new ShiftAssignmentResource($shiftAssignment->load(['person', 'shift.festivalSection', 'shift.timeSlot']))); } public function cancel(Organisation $organisation, Event $event, ShiftAssignment $shiftAssignment): JsonResponse { $this->verifyEventBelongsToOrganisation($organisation, $event); Gate::authorize('cancel', [$shiftAssignment, $event]); $shiftAssignment = $this->service->cancel($shiftAssignment, request()->user()); return $this->success(new ShiftAssignmentResource($shiftAssignment->load(['person', 'shift.festivalSection', 'shift.timeSlot']))); } public function bulkApprove(BulkApproveShiftAssignmentRequest $request, Organisation $organisation, Event $event): JsonResponse { $this->verifyEventBelongsToOrganisation($organisation, $event); Gate::authorize('bulkApprove', [ShiftAssignment::class, $event]); $assignments = ShiftAssignment::whereIn('id', $request->validated('assignment_ids')) ->whereHas('shift.festivalSection', fn ($q) => $q->where('event_id', $event->id)) ->with('shift') ->get(); $results = $this->service->bulkApprove($assignments, $request->user()); return $this->success($results); } public function assignablePersons(Organisation $organisation, Event $event, Shift $shift): JsonResponse { $this->verifyEventBelongsToOrganisation($organisation, $event); Gate::authorize('viewAny', [ShiftAssignment::class, $event]); $shift->load(['festivalSection', 'timeSlot']); $festivalEventId = $event->parent_event_id ?? $event->id; $timeSlotId = $shift->time_slot_id; $shiftId = $shift->id; // Get all conflict assignments for this time slot in one query $conflicts = ShiftAssignment::where('time_slot_id', $timeSlotId) ->whereIn('status', [ ShiftAssignmentStatus::PENDING_APPROVAL, ShiftAssignmentStatus::APPROVED, ]) ->with(['shift.festivalSection', 'shift.timeSlot']) ->get() ->keyBy('person_id'); // Get active (non-cancelled/rejected) assignments for THIS shift $alreadyAssigned = ShiftAssignment::where('shift_id', $shiftId) ->whereNotIn('status', [ ShiftAssignmentStatus::REJECTED, ShiftAssignmentStatus::CANCELLED, ]) ->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('first_name') ->orderBy('last_name') ->get(); // Batch: tags for all persons with user_id $userIds = $persons->pluck('user_id')->filter()->unique(); $allTags = collect(); if ($userIds->isNotEmpty()) { $allTags = DB::table('user_organisation_tags') ->join('person_tags', 'user_organisation_tags.person_tag_id', '=', 'person_tags.id') ->whereIn('user_organisation_tags.user_id', $userIds) ->where('user_organisation_tags.organisation_id', $event->organisation_id) ->where('person_tags.is_active', true) ->select( 'user_organisation_tags.user_id', 'person_tags.name', 'person_tags.icon', 'person_tags.color', 'user_organisation_tags.proficiency', ) ->get() ->groupBy('user_id'); } // Batch: availability for this shift's time slot $personIds = $persons->pluck('id'); $availablePersonIds = VolunteerAvailability::where('time_slot_id', $shift->time_slot_id) ->whereIn('person_id', $personIds) ->pluck('person_id') ->flip(); $mappedPersons = $persons ->map(function (Person $person) use ($conflicts, $alreadyAssigned, $previousAssignments, $shiftId, $allTags, $availablePersonIds) { $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, 'first_name' => $person->first_name, 'last_name' => $person->last_name, 'full_name' => $person->full_name, 'email' => $person->email, 'status' => $person->status, 'crowd_type' => $person->crowdType ? [ 'id' => $person->crowdType->id, 'name' => $person->crowdType->name, 'system_type' => $person->crowdType->system_type, ] : null, 'is_available' => ! $hasConflict && ! $isAlreadyAssigned, 'already_assigned' => $isAlreadyAssigned, 'conflict' => $hasConflict ? [ 'section_name' => $conflict->shift->festivalSection->name, 'shift_title' => $conflict->shift->title ?? $conflict->shift->festivalSection->name, 'time_slot_name' => $conflict->shift->timeSlot->name, '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, 'tags' => $person->user_id ? ($allTags->get($person->user_id) ?? collect())->map(fn ($t) => [ 'name' => $t->name, 'icon' => $t->icon, 'color' => $t->color, 'proficiency' => $t->proficiency, ])->values()->toArray() : [], 'section_preferences' => $person->custom_fields['section_preferences'] ?? [], 'has_availability' => $availablePersonIds->has($person->id), ]; }) ->sortBy([ ['already_assigned', 'asc'], ['is_available', 'desc'], ['first_name', 'asc'], ]) ->values(); return response()->json([ 'data' => $mappedPersons, 'meta' => [ 'section_name' => $shift->festivalSection->name, 'time_slot_name' => $shift->timeSlot->name, 'slots_total' => $shift->slots_total, 'filled_slots' => $shift->filled_slots, 'is_overbooked' => $shift->filled_slots >= $shift->slots_total, ], ]); } }