From 874eeee7705de55d317c645b6489d3942ccdf53a Mon Sep 17 00:00:00 2001 From: "bert.hausmans" Date: Fri, 10 Apr 2026 16:19:31 +0200 Subject: [PATCH] feat: event dashboard metric cards with stats endpoint (UX-02) Add GET /events/{event}/stats endpoint returning aggregate counts for persons (by status, approved without shift), pending identity matches, and shift fill rates. Frontend metric cards component shows four actionable KPIs on the event overview tab. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Controllers/Api/V1/EventController.php | 54 +++++ api/routes/api.php | 1 + api/tests/Feature/Event/EventStatsTest.php | 208 ++++++++++++++++ apps/app/components.d.ts | 1 + .../components/events/EventMetricCards.vue | 225 ++++++++++++++++++ apps/app/src/composables/api/useEvents.ts | 14 ++ apps/app/src/pages/events/[id]/index.vue | 7 + apps/app/src/types/event.ts | 13 + dev-docs/API.md | 23 ++ 9 files changed, 546 insertions(+) create mode 100644 api/tests/Feature/Event/EventStatsTest.php create mode 100644 apps/app/src/components/events/EventMetricCards.vue 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 @@ + + + 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 @@