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:
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>
|
||||
Reference in New Issue
Block a user