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