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) <noreply@anthropic.com>
This commit is contained in:
2026-04-10 16:19:31 +02:00
parent b094018eeb
commit 874eeee770
9 changed files with 546 additions and 0 deletions

View File

@@ -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,
]]);
}
}

View File

@@ -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']);

View File

@@ -0,0 +1,208 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Event;
use App\Models\CrowdType;
use App\Models\Event;
use App\Models\FestivalSection;
use App\Models\Organisation;
use App\Models\Person;
use App\Models\PersonIdentityMatch;
use App\Models\Shift;
use App\Models\ShiftAssignment;
use App\Models\TimeSlot;
use App\Models\User;
use Database\Seeders\RoleSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Laravel\Sanctum\Sanctum;
use Tests\TestCase;
class EventStatsTest extends TestCase
{
use RefreshDatabase;
private User $orgAdmin;
private User $outsider;
private Organisation $organisation;
private Organisation $otherOrganisation;
private Event $event;
private CrowdType $crowdType;
protected function setUp(): void
{
parent::setUp();
$this->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']);
}
}

View File

@@ -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']

View File

@@ -0,0 +1,225 @@
<script setup lang="ts">
import { useEventStats } from '@/composables/api/useEvents'
const props = defineProps<{
eventId: string
}>()
const router = useRouter()
const eventIdRef = computed(() => props.eventId)
const { data: stats, isLoading, isError, refetch } = useEventStats(eventIdRef)
function navigateTo(routeName: string) {
router.push({ name: routeName, params: { id: props.eventId } })
}
const shiftFillColor = computed(() => {
if (!stats.value || stats.value.shifts_total === 0) return 'success'
const rate = stats.value.shifts_filled / stats.value.shifts_total
if (rate >= 0.9) return 'success'
if (rate >= 0.6) return 'warning'
return 'error'
})
const shiftFillPercent = computed(() => {
if (!stats.value || stats.value.shifts_total === 0) return 0
return Math.round((stats.value.shifts_filled / stats.value.shifts_total) * 100)
})
</script>
<template>
<!-- Loading state -->
<VRow
v-if="isLoading"
class="mb-6"
>
<VCol
v-for="n in 4"
:key="n"
cols="12"
sm="6"
md="3"
>
<VCard>
<VCardText>
<VSkeletonLoader type="heading" />
</VCardText>
</VCard>
</VCol>
</VRow>
<!-- Error state -->
<VAlert
v-else-if="isError"
type="error"
variant="tonal"
class="mb-6"
>
Kon statistieken niet laden.
<template #append>
<VBtn
variant="text"
size="small"
@click="refetch()"
>
Opnieuw proberen
</VBtn>
</template>
</VAlert>
<!-- Data -->
<VRow
v-else-if="stats"
class="mb-6"
>
<!-- Card 1: Zonder shift -->
<VCol
cols="12"
sm="6"
md="3"
>
<VCard
class="cursor-pointer"
hover
@click="navigateTo('events-id-persons')"
>
<VCardText class="d-flex align-center gap-x-3">
<VAvatar
:color="stats.persons_approved_without_shift > 0 ? 'warning' : 'success'"
variant="tonal"
size="44"
rounded
>
<VIcon
icon="tabler-user-exclamation"
size="28"
/>
</VAvatar>
<div>
<h4 class="text-h4">
{{ stats.persons_approved_without_shift }}
</h4>
<p class="text-body-2 text-medium-emphasis mb-0">
goedgekeurd zonder shift
</p>
<p class="text-caption text-disabled mb-0">
van {{ stats.persons_approved }} goedgekeurde personen
</p>
</div>
</VCardText>
</VCard>
</VCol>
<!-- Card 2: Wachtende goedkeuringen -->
<VCol
cols="12"
sm="6"
md="3"
>
<VCard
class="cursor-pointer"
hover
@click="navigateTo('events-id-persons')"
>
<VCardText class="d-flex align-center gap-x-3">
<VAvatar
:color="stats.persons_pending > 0 ? 'warning' : 'success'"
variant="tonal"
size="44"
rounded
>
<VIcon
icon="tabler-clock"
size="28"
/>
</VAvatar>
<div>
<h4 class="text-h4">
{{ stats.persons_pending }}
</h4>
<p class="text-body-2 text-medium-emphasis mb-0">
wachtende goedkeuringen
</p>
</div>
</VCardText>
</VCard>
</VCol>
<!-- Card 3: Identiteitsmatches -->
<VCol
cols="12"
sm="6"
md="3"
>
<VCard
class="cursor-pointer"
hover
@click="navigateTo('events-id-persons')"
>
<VCardText class="d-flex align-center gap-x-3">
<VAvatar
:color="stats.pending_identity_matches > 0 ? 'info' : 'success'"
variant="tonal"
size="44"
rounded
>
<VIcon
icon="tabler-user-search"
size="28"
/>
</VAvatar>
<div>
<h4 class="text-h4">
{{ stats.pending_identity_matches }}
</h4>
<p class="text-body-2 text-medium-emphasis mb-0">
onopgeloste matches
</p>
</div>
</VCardText>
</VCard>
</VCol>
<!-- Card 4: Shift bezetting -->
<VCol
cols="12"
sm="6"
md="3"
>
<VCard
class="cursor-pointer"
hover
@click="navigateTo('events-id-sections')"
>
<VCardText class="d-flex align-center gap-x-3">
<VAvatar
:color="shiftFillColor"
variant="tonal"
size="44"
rounded
>
<VIcon
icon="tabler-calendar-check"
size="28"
/>
</VAvatar>
<div class="flex-grow-1">
<h4 class="text-h4">
{{ stats.shifts_filled }}/{{ stats.shifts_total }}
</h4>
<p class="text-body-2 text-medium-emphasis mb-1">
shifts gevuld
</p>
<VProgressLinear
:model-value="shiftFillPercent"
:color="shiftFillColor"
height="6"
rounded
/>
</div>
</VCardText>
</VCard>
</VCol>
</VRow>
</template>

View File

@@ -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<string>, id: Ref<string>) {
},
})
}
export function useEventStats(eventId: Ref<string>) {
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,
})
}

View File

@@ -1,4 +1,5 @@
<script setup lang="ts">
import EventMetricCards from '@/components/events/EventMetricCards.vue'
import EventTabsNav from '@/components/events/EventTabsNav.vue'
import { useEventChildren } from '@/composables/api/useEvents'
import { dutchPlural } from '@/lib/dutch-plural'
@@ -65,6 +66,12 @@ function onTileClick(tile: typeof tiles[number]) {
<template>
<EventTabsNav>
<template #default="{ event }">
<!-- Metric cards (shown for all event types) -->
<EventMetricCards
v-if="event"
:event-id="event.id"
/>
<!-- -->
<!-- Festival Overzicht (dashboard) -->
<!-- -->

View File

@@ -48,3 +48,16 @@ export interface CreateEventPayload {
export interface UpdateEventPayload extends Partial<CreateEventPayload> {
status?: EventStatus
}
export interface EventStats {
persons_total: number
persons_approved: number
persons_pending: number
persons_rejected: number
persons_other: number
persons_approved_without_shift: number
pending_identity_matches: number
shifts_total: number
shifts_filled: number
shifts_understaffed: number
}

View File

@@ -47,6 +47,29 @@ Returns 422 with `errors`, `current_status`, `requested_status`, and `allowed_tr
**EventResource** includes `allowed_transitions` (array of valid next statuses) so the frontend knows which buttons to show.
## Event Stats
- `GET /events/{event}/stats` — aggregate dashboard counts for the event
### Response
```json
{
"data": {
"persons_total": 142,
"persons_approved": 98,
"persons_pending": 31,
"persons_rejected": 8,
"persons_other": 5,
"persons_approved_without_shift": 23,
"pending_identity_matches": 3,
"shifts_total": 45,
"shifts_filled": 38,
"shifts_understaffed": 7
}
}
```
## Crowd Types
- `GET /organisations/{org}/crowd-types`