diff --git a/api/app/Http/Controllers/Api/V1/ShiftAssignmentController.php b/api/app/Http/Controllers/Api/V1/ShiftAssignmentController.php index 6d2f3fb7..8655c564 100644 --- a/api/app/Http/Controllers/Api/V1/ShiftAssignmentController.php +++ b/api/app/Http/Controllers/Api/V1/ShiftAssignmentController.php @@ -14,10 +14,12 @@ use App\Models\Event; use App\Models\Person; use App\Models\Shift; use App\Models\ShiftAssignment; +use App\Models\VolunteerAvailability; use App\Services\ShiftAssignmentService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\AnonymousResourceCollection; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Gate; final class ShiftAssignmentController extends Controller @@ -106,6 +108,8 @@ final class ShiftAssignmentController extends Controller { 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; @@ -142,8 +146,37 @@ final class ShiftAssignmentController extends Controller ->where('status', PersonStatus::APPROVED) ->with('crowdType') ->orderBy('name') - ->get() - ->map(function (Person $person) use ($conflicts, $alreadyAssigned, $previousAssignments, $shiftId) { + ->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; @@ -174,6 +207,16 @@ final class ShiftAssignmentController extends Controller '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([ @@ -183,6 +226,15 @@ final class ShiftAssignmentController extends Controller ]) ->values(); - return response()->json(['data' => $persons]); + 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, + ], + ]); } } diff --git a/api/tests/Feature/Api/V1/AssignablePersonsTest.php b/api/tests/Feature/Api/V1/AssignablePersonsTest.php index e389e410..cf16c815 100644 --- a/api/tests/Feature/Api/V1/AssignablePersonsTest.php +++ b/api/tests/Feature/Api/V1/AssignablePersonsTest.php @@ -11,10 +11,13 @@ use App\Models\Event; use App\Models\FestivalSection; use App\Models\Organisation; use App\Models\Person; +use App\Models\PersonTag; use App\Models\Shift; use App\Models\ShiftAssignment; use App\Models\TimeSlot; use App\Models\User; +use App\Models\UserOrganisationTag; +use App\Models\VolunteerAvailability; use Database\Seeders\RoleSeeder; use Illuminate\Foundation\Testing\RefreshDatabase; use Laravel\Sanctum\Sanctum; @@ -492,6 +495,210 @@ class AssignablePersonsTest extends TestCase ->assertJsonPath('data.0.previous_assignment.rejection_reason', 'Geen ervaring'); } + // ========================================================================= + // Enriched data: tags, section_preferences, has_availability, meta + // ========================================================================= + + public function test_assignable_persons_includes_tags_for_person_with_user_id(): void + { + $shift = $this->createOpenShift(); + $user = User::factory()->create(); + $person = $this->createPerson(['user_id' => $user->id]); + + $tag = PersonTag::factory()->create([ + 'organisation_id' => $this->organisation->id, + 'name' => 'Tapper', + 'icon' => 'tabler-beer', + 'color' => '#FF9800', + 'is_active' => true, + ]); + + UserOrganisationTag::factory()->create([ + 'user_id' => $user->id, + 'organisation_id' => $this->organisation->id, + 'person_tag_id' => $tag->id, + 'proficiency' => 'experienced', + ]); + + Sanctum::actingAs($this->orgAdmin); + + $response = $this->getJson( + "/api/v1/events/{$this->event->id}/shifts/{$shift->id}/assignable-persons", + ); + + $response->assertOk() + ->assertJsonCount(1, 'data.0.tags') + ->assertJsonPath('data.0.tags.0.name', 'Tapper') + ->assertJsonPath('data.0.tags.0.icon', 'tabler-beer') + ->assertJsonPath('data.0.tags.0.color', '#FF9800') + ->assertJsonPath('data.0.tags.0.proficiency', 'experienced'); + } + + public function test_assignable_persons_tags_empty_for_person_without_user_id(): void + { + $shift = $this->createOpenShift(); + $this->createPerson(['user_id' => null]); + + Sanctum::actingAs($this->orgAdmin); + + $response = $this->getJson( + "/api/v1/events/{$this->event->id}/shifts/{$shift->id}/assignable-persons", + ); + + $response->assertOk() + ->assertJsonPath('data.0.tags', []); + } + + public function test_assignable_persons_excludes_inactive_tags(): void + { + $shift = $this->createOpenShift(); + $user = User::factory()->create(); + $this->createPerson(['user_id' => $user->id]); + + $tag = PersonTag::factory()->inactive()->create([ + 'organisation_id' => $this->organisation->id, + ]); + + UserOrganisationTag::factory()->create([ + 'user_id' => $user->id, + 'organisation_id' => $this->organisation->id, + 'person_tag_id' => $tag->id, + ]); + + Sanctum::actingAs($this->orgAdmin); + + $response = $this->getJson( + "/api/v1/events/{$this->event->id}/shifts/{$shift->id}/assignable-persons", + ); + + $response->assertOk() + ->assertJsonPath('data.0.tags', []); + } + + public function test_assignable_persons_includes_section_preferences_from_custom_fields(): void + { + $shift = $this->createOpenShift(); + $this->createPerson([ + 'custom_fields' => [ + 'section_preferences' => [ + ['section_name' => $this->section->name, 'priority' => 1], + ['section_name' => 'Other Section', 'priority' => 2], + ], + ], + ]); + + Sanctum::actingAs($this->orgAdmin); + + $response = $this->getJson( + "/api/v1/events/{$this->event->id}/shifts/{$shift->id}/assignable-persons", + ); + + $response->assertOk() + ->assertJsonCount(2, 'data.0.section_preferences') + ->assertJsonPath('data.0.section_preferences.0.section_name', $this->section->name) + ->assertJsonPath('data.0.section_preferences.0.priority', 1); + } + + public function test_assignable_persons_section_preferences_empty_when_not_set(): void + { + $shift = $this->createOpenShift(); + $this->createPerson(['custom_fields' => null]); + + Sanctum::actingAs($this->orgAdmin); + + $response = $this->getJson( + "/api/v1/events/{$this->event->id}/shifts/{$shift->id}/assignable-persons", + ); + + $response->assertOk() + ->assertJsonPath('data.0.section_preferences', []); + } + + public function test_assignable_persons_has_availability_true_when_record_exists(): void + { + $shift = $this->createOpenShift(); + $person = $this->createPerson(); + + VolunteerAvailability::factory()->create([ + 'person_id' => $person->id, + 'time_slot_id' => $this->timeSlot->id, + ]); + + Sanctum::actingAs($this->orgAdmin); + + $response = $this->getJson( + "/api/v1/events/{$this->event->id}/shifts/{$shift->id}/assignable-persons", + ); + + $response->assertOk() + ->assertJsonPath('data.0.has_availability', true); + } + + public function test_assignable_persons_has_availability_false_when_no_record(): void + { + $shift = $this->createOpenShift(); + $this->createPerson(); + + Sanctum::actingAs($this->orgAdmin); + + $response = $this->getJson( + "/api/v1/events/{$this->event->id}/shifts/{$shift->id}/assignable-persons", + ); + + $response->assertOk() + ->assertJsonPath('data.0.has_availability', false); + } + + public function test_assignable_persons_meta_includes_shift_context(): void + { + $shift = $this->createOpenShift(); + $this->createPerson(); + + // Create an approved assignment so filled_slots > 0 + $otherPerson = $this->createPerson(); + ShiftAssignment::factory()->create([ + 'shift_id' => $shift->id, + 'person_id' => $otherPerson->id, + 'time_slot_id' => $this->timeSlot->id, + 'status' => ShiftAssignmentStatus::APPROVED, + ]); + + Sanctum::actingAs($this->orgAdmin); + + $response = $this->getJson( + "/api/v1/events/{$this->event->id}/shifts/{$shift->id}/assignable-persons", + ); + + $response->assertOk() + ->assertJsonPath('meta.section_name', $this->section->name) + ->assertJsonPath('meta.time_slot_name', $this->timeSlot->name) + ->assertJsonPath('meta.slots_total', 4) + ->assertJsonPath('meta.filled_slots', 1) + ->assertJsonPath('meta.is_overbooked', false); + } + + public function test_assignable_persons_meta_is_overbooked_when_full(): void + { + $shift = $this->createOpenShift(['slots_total' => 1]); + $person = $this->createPerson(); + + ShiftAssignment::factory()->create([ + 'shift_id' => $shift->id, + 'person_id' => $person->id, + 'time_slot_id' => $this->timeSlot->id, + 'status' => ShiftAssignmentStatus::APPROVED, + ]); + + Sanctum::actingAs($this->orgAdmin); + + $response = $this->getJson( + "/api/v1/events/{$this->event->id}/shifts/{$shift->id}/assignable-persons", + ); + + $response->assertOk() + ->assertJsonPath('meta.is_overbooked', true); + } + private function assertJsonPath($response, string $path, mixed $expected): void { $this->assertEquals($expected, $response->json($path)); diff --git a/apps/app/src/components/shifts/AssignPersonDialog.vue b/apps/app/src/components/shifts/AssignPersonDialog.vue index 7486cf90..36040111 100644 --- a/apps/app/src/components/shifts/AssignPersonDialog.vue +++ b/apps/app/src/components/shifts/AssignPersonDialog.vue @@ -18,12 +18,13 @@ const modelValue = defineModel({ required: true }) const eventIdRef = computed(() => props.eventId) const shiftIdRef = computed(() => props.shift?.id ?? '') -const { data: assignablePersons, isLoading } = useAssignablePersons(eventIdRef, shiftIdRef) +const { data: assignableData, isLoading } = useAssignablePersons(eventIdRef, shiftIdRef) const { mutateAsync: assignPerson, isPending: isAssigning } = useAssignPersonToShift(eventIdRef) // Search and filters const searchQuery = ref('') const showOnlyAvailable = ref(true) +const showRecommendedOnly = ref(false) const selectedCrowdType = ref(null) const assignError = ref(null) const showSuccess = ref(false) @@ -39,16 +40,43 @@ const isShiftFull = computed(() => { return props.shift.filled_slots >= props.shift.slots_total }) -// Clear error on filter changes -watch([searchQuery, showOnlyAvailable, selectedCrowdType], () => { +// Clear error on any filter change +watch([searchQuery, showOnlyAvailable, showRecommendedOnly, selectedCrowdType], () => { assignError.value = null }) +// Cascading auto-filter when data loads +watch(() => assignableData.value, (data) => { + if (!data) return + const { persons, meta } = data + + const recommended = persons.filter(p => + p.is_available && !p.already_assigned + && p.section_preferences.some(sp => sp.section_name === meta.section_name), + ) + + const available = persons.filter(p => + p.is_available && !p.already_assigned, + ) + + if (recommended.length > 0) { + showRecommendedOnly.value = true + showOnlyAvailable.value = true + } + else if (available.length > 0) { + showRecommendedOnly.value = false + showOnlyAvailable.value = true + } + else { + showRecommendedOnly.value = false + showOnlyAvailable.value = false + } +}, { immediate: true }) + // Reset state when dialog opens watch(modelValue, (open) => { if (open) { searchQuery.value = '' - showOnlyAvailable.value = true selectedCrowdType.value = null assignError.value = null } @@ -56,9 +84,9 @@ watch(modelValue, (open) => { // Crowd type filter options (derived from data) const crowdTypeOptions = computed(() => { - if (!assignablePersons.value) return [] + if (!assignableData.value) return [] const seen = new Map() - for (const p of assignablePersons.value) { + for (const p of assignableData.value.persons) { if (p.crowd_type && !seen.has(p.crowd_type.system_type)) { seen.set(p.crowd_type.system_type, p.crowd_type.name) } @@ -69,9 +97,10 @@ const crowdTypeOptions = computed(() => { // Filtered persons const filteredPersons = computed(() => { - if (!assignablePersons.value) return [] + if (!assignableData.value) return [] + const sectionName = assignableData.value.meta.section_name - return assignablePersons.value.filter((person) => { + return assignableData.value.persons.filter((person) => { if (searchQuery.value) { const q = searchQuery.value.toLowerCase() if (!person.name.toLowerCase().includes(q) && !person.email.toLowerCase().includes(q)) { @@ -79,24 +108,54 @@ const filteredPersons = computed(() => { } } + if (selectedCrowdType.value) { + if (person.crowd_type?.system_type !== selectedCrowdType.value) return false + } + if (showOnlyAvailable.value) { if (!person.is_available || person.already_assigned) return false } - if (selectedCrowdType.value) { - if (person.crowd_type?.system_type !== selectedCrowdType.value) return false + if (showRecommendedOnly.value) { + const hasPreference = person.section_preferences.some( + sp => sp.section_name === sectionName, + ) + if (!hasPreference && !person.has_availability) return false } return true }) }) +// Smart sorting +const sortedPersons = computed(() => { + const sectionName = assignableData.value?.meta?.section_name || '' + + return [...filteredPersons.value].sort((a, b) => { + if (a.already_assigned !== b.already_assigned) return a.already_assigned ? 1 : -1 + if (a.is_available !== b.is_available) return a.is_available ? -1 : 1 + + const aMatch = a.section_preferences.find(p => p.section_name === sectionName) + const bMatch = b.section_preferences.find(p => p.section_name === sectionName) + if (!!aMatch !== !!bMatch) return aMatch ? -1 : 1 + if (aMatch && bMatch) return aMatch.priority - bMatch.priority + + if (a.has_availability !== b.has_availability) return a.has_availability ? -1 : 1 + if (a.tags.length !== b.tags.length) return b.tags.length - a.tags.length + + return a.name.localeCompare(b.name) + }) +}) + // Empty state reason const emptyReason = computed(() => { - if (!assignablePersons.value?.length) { + if (!assignableData.value?.persons?.length) { return 'Er zijn geen goedgekeurde personen voor dit evenement.' } - if (showOnlyAvailable.value && !filteredPersons.value.length && assignablePersons.value.length) { + if (showRecommendedOnly.value && !filteredPersons.value.length) { + return 'Geen aanbevolen personen gevonden. Zet \'Aanbevolen\' uit om alle personen te zien.' + } + if (showOnlyAvailable.value && !filteredPersons.value.length) { return 'Alle personen zijn al ingepland voor dit tijdslot. Zet \'Alleen beschikbaar\' uit om alle personen te zien.' } @@ -112,6 +171,12 @@ function getInitials(name: string) { .slice(0, 2) } +function getPreferenceMatch(person: AssignablePerson) { + const sectionName = assignableData.value?.meta?.section_name + if (!sectionName) return null + return person.section_preferences.find(sp => sp.section_name === sectionName) +} + function handleAssign(person: AssignablePerson) { if (!props.shift) return assignError.value = null @@ -228,7 +293,7 @@ async function executeAssign(person: AssignablePerson) { density="compact" class="mb-3" > - Shift is vol — {{ shift.filled_slots }}/{{ shift.slots_total }} + Shift is vol — {{ shift?.filled_slots }}/{{ shift?.slots_total }} plekken bezet. Je kunt nog steeds iemand toewijzen, maar de shift wordt overbezet. @@ -257,7 +322,14 @@ async function executeAssign(person: AssignablePerson) { /> -
+
+ -