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>
182 lines
5.9 KiB
PHP
182 lines
5.9 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Http\Controllers\Api\V1;
|
|
|
|
use App\Http\Controllers\Controller;
|
|
use App\Http\Requests\Api\V1\StoreEventRequest;
|
|
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;
|
|
use Illuminate\Support\Facades\Gate;
|
|
|
|
final class EventController extends Controller
|
|
{
|
|
public function index(Request $request, Organisation $organisation): AnonymousResourceCollection
|
|
{
|
|
Gate::authorize('viewAny', [Event::class, $organisation]);
|
|
|
|
$query = $organisation->events()
|
|
->topLevel()
|
|
->latest('start_date');
|
|
|
|
if ($request->query('type')) {
|
|
$query->where('event_type', $request->query('type'));
|
|
}
|
|
|
|
if ($request->boolean('include_children')) {
|
|
$query->with('children');
|
|
}
|
|
|
|
return EventResource::collection($query->paginate());
|
|
}
|
|
|
|
public function show(Organisation $organisation, Event $event): JsonResponse
|
|
{
|
|
Gate::authorize('view', [$event, $organisation]);
|
|
|
|
$event->load(['organisation', 'children', 'parent'])
|
|
->loadCount('children');
|
|
|
|
return $this->success(new EventResource($event));
|
|
}
|
|
|
|
public function store(StoreEventRequest $request, Organisation $organisation): JsonResponse
|
|
{
|
|
Gate::authorize('create', [Event::class, $organisation]);
|
|
|
|
$data = $request->validated();
|
|
|
|
if (!empty($data['parent_event_id'])) {
|
|
$parentEvent = Event::where('id', $data['parent_event_id'])
|
|
->where('organisation_id', $organisation->id)
|
|
->first();
|
|
|
|
if (!$parentEvent) {
|
|
return $this->error('Parent event does not belong to this organisation.', 422);
|
|
}
|
|
}
|
|
|
|
if (!isset($data['event_type'])) {
|
|
$data['event_type'] = 'event';
|
|
}
|
|
|
|
$event = $organisation->events()->create($data);
|
|
|
|
return $this->created(new EventResource($event));
|
|
}
|
|
|
|
public function update(UpdateEventRequest $request, Organisation $organisation, Event $event): JsonResponse
|
|
{
|
|
Gate::authorize('update', [$event, $organisation]);
|
|
|
|
$event->update($request->validated());
|
|
|
|
return $this->success(new EventResource($event->fresh()));
|
|
}
|
|
|
|
public function destroy(Organisation $organisation, Event $event): JsonResponse
|
|
{
|
|
Gate::authorize('delete', [$event, $organisation]);
|
|
|
|
$event->delete();
|
|
|
|
return $this->success(null, 'Event deleted');
|
|
}
|
|
|
|
public function transition(Request $request, Organisation $organisation, Event $event): JsonResponse
|
|
{
|
|
Gate::authorize('update', [$event, $organisation]);
|
|
|
|
$request->validate(['status' => 'required|string']);
|
|
$newStatus = $request->status;
|
|
|
|
$result = $event->canTransitionToWithPrerequisites($newStatus);
|
|
|
|
if (! empty($result['errors'])) {
|
|
return response()->json([
|
|
'message' => 'Status transition not possible.',
|
|
'errors' => $result['errors'],
|
|
'current_status' => $event->status,
|
|
'requested_status' => $newStatus,
|
|
'allowed_transitions' => Event::STATUS_TRANSITIONS[$event->status] ?? [],
|
|
], 422);
|
|
}
|
|
|
|
$event->transitionTo($newStatus);
|
|
|
|
return $this->success(new EventResource($event->fresh()));
|
|
}
|
|
|
|
public function children(Organisation $organisation, Event $event): AnonymousResourceCollection
|
|
{
|
|
Gate::authorize('view', [$event, $organisation]);
|
|
|
|
$children = $event->children()
|
|
->orderBy('start_date')
|
|
->orderBy('name')
|
|
->paginate();
|
|
|
|
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,
|
|
]]);
|
|
}
|
|
}
|