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:
@@ -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,
|
||||
]]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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']);
|
||||
|
||||
208
api/tests/Feature/Event/EventStatsTest.php
Normal file
208
api/tests/Feature/Event/EventStatsTest.php
Normal 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']);
|
||||
}
|
||||
}
|
||||
1
apps/app/components.d.ts
vendored
1
apps/app/components.d.ts
vendored
@@ -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']
|
||||
|
||||
225
apps/app/src/components/events/EventMetricCards.vue
Normal file
225
apps/app/src/components/events/EventMetricCards.vue
Normal 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>
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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) -->
|
||||
<!-- ═══════════════════════════════════════════ -->
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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`
|
||||
|
||||
Reference in New Issue
Block a user