diff --git a/api/app/Http/Controllers/Api/V1/EventController.php b/api/app/Http/Controllers/Api/V1/EventController.php index c8307741..977c233b 100644 --- a/api/app/Http/Controllers/Api/V1/EventController.php +++ b/api/app/Http/Controllers/Api/V1/EventController.php @@ -10,6 +10,7 @@ use App\Http\Requests\Api\V1\UpdateEventRequest; use App\Http\Resources\Api\V1\EventResource; use App\Models\Event; use App\Models\Organisation; +use App\Models\PersonIdentityMatch; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\AnonymousResourceCollection; @@ -124,4 +125,57 @@ final class EventController extends Controller return EventResource::collection($children); } + + public function stats(Event $event): JsonResponse + { + Gate::authorize('view', $event); + + $personCounts = $event->persons() + ->selectRaw(" + COUNT(*) as total, + SUM(CASE WHEN status = 'approved' THEN 1 ELSE 0 END) as approved, + SUM(CASE WHEN status IN ('pending', 'applied') THEN 1 ELSE 0 END) as pending, + SUM(CASE WHEN status = 'rejected' THEN 1 ELSE 0 END) as rejected + ") + ->first(); + + $approvedWithoutShift = $event->persons() + ->where('status', 'approved') + ->whereDoesntHave('shiftAssignments') + ->count(); + + $pendingMatches = PersonIdentityMatch::pending() + ->whereHas('person', fn ($q) => $q->where('event_id', $event->id)) + ->count(); + + $shifts = $event->festivalSections() + ->with(['shifts' => fn ($q) => $q->withCount([ + 'shiftAssignments' => fn ($q) => $q->where('status', 'approved'), + ])]) + ->get() + ->flatMap->shifts; + + $shiftsTotal = $shifts->count(); + $shiftsFilled = $shifts->filter( + fn ($s) => $s->shift_assignments_count >= $s->slots_total + )->count(); + + $total = (int) $personCounts->total; + $approved = (int) $personCounts->approved; + $pending = (int) $personCounts->pending; + $rejected = (int) $personCounts->rejected; + + return response()->json(['data' => [ + 'persons_total' => $total, + 'persons_approved' => $approved, + 'persons_pending' => $pending, + 'persons_rejected' => $rejected, + 'persons_other' => $total - $approved - $pending - $rejected, + 'persons_approved_without_shift' => $approvedWithoutShift, + 'pending_identity_matches' => $pendingMatches, + 'shifts_total' => $shiftsTotal, + 'shifts_filled' => $shiftsFilled, + 'shifts_understaffed' => $shiftsTotal - $shiftsFilled, + ]]); + } } diff --git a/api/routes/api.php b/api/routes/api.php index 3d861f06..bbdd6ad1 100644 --- a/api/routes/api.php +++ b/api/routes/api.php @@ -111,6 +111,7 @@ Route::middleware('auth:sanctum')->group(function () { }); // Event-scoped resources + Route::get('events/{event}/stats', [EventController::class, 'stats']); Route::prefix('events/{event}')->group(function () { Route::apiResource('locations', LocationController::class) ->except(['show']); diff --git a/api/tests/Feature/Event/EventStatsTest.php b/api/tests/Feature/Event/EventStatsTest.php new file mode 100644 index 00000000..aeea0493 --- /dev/null +++ b/api/tests/Feature/Event/EventStatsTest.php @@ -0,0 +1,208 @@ +seed(RoleSeeder::class); + + $this->organisation = Organisation::factory()->create(); + $this->otherOrganisation = Organisation::factory()->create(); + + $this->orgAdmin = User::factory()->create(); + $this->organisation->users()->attach($this->orgAdmin, ['role' => 'org_admin']); + + $this->outsider = User::factory()->create(); + $this->otherOrganisation->users()->attach($this->outsider, ['role' => 'org_admin']); + + $this->event = Event::factory()->create(['organisation_id' => $this->organisation->id]); + + $this->crowdType = CrowdType::factory()->systemType('VOLUNTEER')->create([ + 'organisation_id' => $this->organisation->id, + ]); + } + + public function test_event_stats_returns_correct_counts(): void + { + // Create persons with various statuses + Person::factory()->count(5)->create([ + 'event_id' => $this->event->id, + 'crowd_type_id' => $this->crowdType->id, + 'status' => 'approved', + ]); + Person::factory()->count(3)->create([ + 'event_id' => $this->event->id, + 'crowd_type_id' => $this->crowdType->id, + 'status' => 'pending', + ]); + Person::factory()->count(2)->create([ + 'event_id' => $this->event->id, + 'crowd_type_id' => $this->crowdType->id, + 'status' => 'rejected', + ]); + + // Create a section, time slot, and shifts + $section = FestivalSection::factory()->create([ + 'event_id' => $this->event->id, + ]); + $timeSlot = TimeSlot::factory()->create([ + 'event_id' => $this->event->id, + ]); + $shiftFull = Shift::factory()->create([ + 'festival_section_id' => $section->id, + 'time_slot_id' => $timeSlot->id, + 'slots_total' => 2, + ]); + $shiftPartial = Shift::factory()->create([ + 'festival_section_id' => $section->id, + 'time_slot_id' => $timeSlot->id, + 'slots_total' => 3, + ]); + + // Assign 2 approved persons to shiftFull (fills it) + $approvedPersons = $this->event->persons()->where('status', 'approved')->get(); + ShiftAssignment::factory()->approved()->create([ + 'shift_id' => $shiftFull->id, + 'person_id' => $approvedPersons[0]->id, + 'time_slot_id' => $timeSlot->id, + ]); + ShiftAssignment::factory()->approved()->create([ + 'shift_id' => $shiftFull->id, + 'person_id' => $approvedPersons[1]->id, + 'time_slot_id' => $timeSlot->id, + ]); + + // Assign 1 approved person to shiftPartial (understaffed: 1/3) + ShiftAssignment::factory()->approved()->create([ + 'shift_id' => $shiftPartial->id, + 'person_id' => $approvedPersons[2]->id, + 'time_slot_id' => $timeSlot->id, + ]); + + // Create a pending identity match + PersonIdentityMatch::factory()->create([ + 'person_id' => $approvedPersons[3]->id, + ]); + + Sanctum::actingAs($this->orgAdmin); + + $response = $this->getJson("/api/v1/events/{$this->event->id}/stats"); + + $response->assertOk(); + $data = $response->json('data'); + + $this->assertEquals(10, $data['persons_total']); + $this->assertEquals(5, $data['persons_approved']); + $this->assertEquals(3, $data['persons_pending']); + $this->assertEquals(2, $data['persons_rejected']); + $this->assertEquals(0, $data['persons_other']); + // 5 approved - 3 with assignments = 2 without shift + $this->assertEquals(2, $data['persons_approved_without_shift']); + $this->assertEquals(1, $data['pending_identity_matches']); + $this->assertEquals(2, $data['shifts_total']); + $this->assertEquals(1, $data['shifts_filled']); + $this->assertEquals(1, $data['shifts_understaffed']); + } + + public function test_event_stats_scoped_to_event(): void + { + $otherEvent = Event::factory()->create([ + 'organisation_id' => $this->organisation->id, + ]); + + // Create persons on both events + Person::factory()->count(3)->create([ + 'event_id' => $this->event->id, + 'crowd_type_id' => $this->crowdType->id, + 'status' => 'approved', + ]); + Person::factory()->count(5)->create([ + 'event_id' => $otherEvent->id, + 'crowd_type_id' => $this->crowdType->id, + 'status' => 'approved', + ]); + + // Create identity matches on other event (should not count) + $otherPerson = $otherEvent->persons()->first(); + PersonIdentityMatch::factory()->create([ + 'person_id' => $otherPerson->id, + ]); + + Sanctum::actingAs($this->orgAdmin); + + $response = $this->getJson("/api/v1/events/{$this->event->id}/stats"); + + $response->assertOk(); + $data = $response->json('data'); + + $this->assertEquals(3, $data['persons_total']); + $this->assertEquals(3, $data['persons_approved']); + $this->assertEquals(0, $data['pending_identity_matches']); + } + + public function test_unauthenticated_cannot_access_stats(): void + { + $response = $this->getJson("/api/v1/events/{$this->event->id}/stats"); + + $response->assertUnauthorized(); + } + + public function test_cross_org_cannot_access_stats(): void + { + Sanctum::actingAs($this->outsider); + + $response = $this->getJson("/api/v1/events/{$this->event->id}/stats"); + + $response->assertForbidden(); + } + + public function test_event_stats_returns_zeros_when_empty(): void + { + Sanctum::actingAs($this->orgAdmin); + + $response = $this->getJson("/api/v1/events/{$this->event->id}/stats"); + + $response->assertOk(); + $data = $response->json('data'); + + $this->assertEquals(0, $data['persons_total']); + $this->assertEquals(0, $data['persons_approved']); + $this->assertEquals(0, $data['persons_pending']); + $this->assertEquals(0, $data['persons_rejected']); + $this->assertEquals(0, $data['persons_other']); + $this->assertEquals(0, $data['persons_approved_without_shift']); + $this->assertEquals(0, $data['pending_identity_matches']); + $this->assertEquals(0, $data['shifts_total']); + $this->assertEquals(0, $data['shifts_filled']); + $this->assertEquals(0, $data['shifts_understaffed']); + } +} diff --git a/apps/app/components.d.ts b/apps/app/components.d.ts index 468101ce..65797d4c 100644 --- a/apps/app/components.d.ts +++ b/apps/app/components.d.ts @@ -58,6 +58,7 @@ declare module 'vue' { EditSectionDialog: typeof import('./src/components/sections/EditSectionDialog.vue')['default'] EnableOneTimePasswordDialog: typeof import('./src/components/dialogs/EnableOneTimePasswordDialog.vue')['default'] ErrorHeader: typeof import('./src/components/ErrorHeader.vue')['default'] + EventMetricCards: typeof import('./src/components/events/EventMetricCards.vue')['default'] EventTabsNav: typeof import('./src/components/events/EventTabsNav.vue')['default'] I18n: typeof import('./src/@core/components/I18n.vue')['default'] InviteMemberDialog: typeof import('./src/components/members/InviteMemberDialog.vue')['default'] diff --git a/apps/app/src/components/events/EventMetricCards.vue b/apps/app/src/components/events/EventMetricCards.vue new file mode 100644 index 00000000..6a48f232 --- /dev/null +++ b/apps/app/src/components/events/EventMetricCards.vue @@ -0,0 +1,225 @@ + + + + + + + + + + + + + + + + + Kon statistieken niet laden. + + + Opnieuw proberen + + + + + + + + + + + + + + + + {{ stats.persons_approved_without_shift }} + + + goedgekeurd zonder shift + + + van {{ stats.persons_approved }} goedgekeurde personen + + + + + + + + + + + + + + + + {{ stats.persons_pending }} + + + wachtende goedkeuringen + + + + + + + + + + + + + + + + {{ stats.pending_identity_matches }} + + + onopgeloste matches + + + + + + + + + + + + + + + + {{ stats.shifts_filled }}/{{ stats.shifts_total }} + + + shifts gevuld + + + + + + + + diff --git a/apps/app/src/composables/api/useEvents.ts b/apps/app/src/composables/api/useEvents.ts index def6cabb..1ee5f35a 100644 --- a/apps/app/src/composables/api/useEvents.ts +++ b/apps/app/src/composables/api/useEvents.ts @@ -4,6 +4,7 @@ import { apiClient } from '@/lib/axios' import type { CreateEventPayload, EventItem, + EventStats, UpdateEventPayload, } from '@/types/event' @@ -130,3 +131,16 @@ export function useUpdateEvent(orgId: Ref, id: Ref) { }, }) } + +export function useEventStats(eventId: Ref) { + return useQuery({ + queryKey: ['events', eventId, 'stats'], + queryFn: async () => { + const { data } = await apiClient.get<{ data: EventStats }>( + `/events/${eventId.value}/stats`, + ) + return data.data + }, + enabled: () => !!eventId.value, + }) +} diff --git a/apps/app/src/pages/events/[id]/index.vue b/apps/app/src/pages/events/[id]/index.vue index 019fafd9..55879a1c 100644 --- a/apps/app/src/pages/events/[id]/index.vue +++ b/apps/app/src/pages/events/[id]/index.vue @@ -1,4 +1,5 @@
+ goedgekeurd zonder shift +
+ van {{ stats.persons_approved }} goedgekeurde personen +
+ wachtende goedkeuringen +
+ onopgeloste matches +
+ shifts gevuld +