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

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