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

@@ -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
}