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,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>