feat(organisation): expand /organisation page to full dashboard

Replace the minimal placeholder with a dashboard: header + edit action,
drie stat-tegels (Leden / Evenementen / Personen — de eerste twee
clickable), organisatiegegevens + leden-top-5 infokaarten en een recente-
activiteit lijst. Nieuwe TypeScript-types en useOrganisationDashboardStats
composable sluiten aan op de nieuwe backend-endpoint.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-17 10:27:51 +02:00
parent 80f0b535f5
commit 027c5dac4e
2 changed files with 500 additions and 50 deletions

View File

@@ -4,6 +4,7 @@ import { apiClient } from '@/lib/axios'
import { useAuthStore } from '@/stores/useAuthStore'
import type {
Organisation,
OrganisationDashboardStats,
UpdateOrganisationPayload,
} from '@/types/organisation'
@@ -70,6 +71,18 @@ export function useUpdateOrganisation() {
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({ queryKey: ['organisations'] })
queryClient.invalidateQueries({ queryKey: ['organisations', variables.id] })
queryClient.invalidateQueries({ queryKey: ['organisation-dashboard-stats', variables.id] })
},
})
}
export function useOrganisationDashboardStats(id: Ref<string>) {
return useQuery({
queryKey: ['organisation-dashboard-stats', id],
queryFn: async () => {
const { data } = await apiClient.get<ApiResponse<OrganisationDashboardStats>>(`/organisations/${id.value}/dashboard-stats`)
return data.data
},
enabled: () => !!id.value,
})
}

View File

@@ -1,12 +1,21 @@
<script setup lang="ts">
import { useMyOrganisation } from '@/composables/api/useOrganisations'
import { useMyOrganisation, useOrganisationDashboardStats } from '@/composables/api/useOrganisations'
import { useAuthStore } from '@/stores/useAuthStore'
import EditOrganisationDialog from '@/components/organisations/EditOrganisationDialog.vue'
import type { Organisation } from '@/types/organisation'
import type { ActivityLogEntry, Organisation } from '@/types/organisation'
const authStore = useAuthStore()
const router = useRouter()
const { data: organisation, isLoading, isError, refetch } = useMyOrganisation()
const { data: organisation, isLoading: orgLoading, isError: orgError, refetch: refetchOrg } = useMyOrganisation()
const orgId = computed(() => organisation.value?.id ?? '')
const {
data: stats,
isLoading: statsLoading,
isError: statsError,
refetch: refetchStats,
} = useOrganisationDashboardStats(orgId)
const isOrgAdmin = computed(() => {
const role = authStore.currentOrganisation?.role
@@ -22,11 +31,68 @@ const statusColor: Record<Organisation['billing_status'], string> = {
const isEditDialogOpen = ref(false)
function formatDate(iso: string) {
const d = new Date(iso)
const date = d.toLocaleDateString('nl-NL', { day: 'numeric', month: 'long', year: 'numeric' })
const time = d.toLocaleTimeString('nl-NL', { hour: '2-digit', minute: '2-digit' })
return `${date} om ${time} uur`
const activeSubtitle = computed(() => {
const active = stats.value?.active_events_count ?? 0
if (active > 0) return `${active} actief`
return 'Nog geen actieve evenementen'
})
const roleColor: Record<string, string> = {
org_admin: 'primary',
org_member: 'secondary',
}
const roleLabel: Record<string, string> = {
org_admin: 'Admin',
org_member: 'Lid',
}
function goToMembers() {
router.push('/members')
}
function goToEvents() {
router.push('/events')
}
function formatRelativeTime(iso: string | null): string {
if (!iso) return ''
const date = new Date(iso)
const diffMs = Date.now() - date.getTime()
const diffSec = Math.round(diffMs / 1000)
if (diffSec < 60) return 'zojuist'
const diffMin = Math.round(diffSec / 60)
if (diffMin < 60) return `${diffMin} min geleden`
const diffHr = Math.round(diffMin / 60)
if (diffHr < 24) return `${diffHr} uur geleden`
const diffDays = Math.round(diffHr / 24)
if (diffDays < 7) return `${diffDays} dag${diffDays === 1 ? '' : 'en'} geleden`
return date.toLocaleDateString('nl-NL', { day: 'numeric', month: 'short', year: 'numeric' })
}
function avatarInitials(name: string | null | undefined): string {
if (!name) return '?'
return name
.split(' ')
.filter(Boolean)
.slice(0, 2)
.map(part => part[0]?.toUpperCase() ?? '')
.join('')
}
function describeActivity(entry: ActivityLogEntry): string {
const changed = Object.keys(entry.properties?.attributes ?? {})
const labelMap: Record<string, string> = {
name: 'naam',
slug: 'slug',
contact_name: 'contactpersoon',
contact_email: 'contact e-mail',
phone: 'telefoonnummer',
website: 'website',
}
if (changed.length === 0) return entry.description
const fields = changed.map(f => labelMap[f] ?? f).join(', ')
return `Wijzigde ${fields}`
}
</script>
@@ -34,13 +100,13 @@ function formatDate(iso: string) {
<div>
<!-- Loading -->
<VSkeletonLoader
v-if="isLoading"
type="card"
v-if="orgLoading"
type="article, article"
/>
<!-- Error -->
<VAlert
v-else-if="isError"
v-else-if="orgError"
type="error"
class="mb-4"
>
@@ -48,7 +114,7 @@ function formatDate(iso: string) {
<template #append>
<VBtn
variant="text"
@click="refetch()"
@click="refetchOrg()"
>
Opnieuw proberen
</VBtn>
@@ -57,12 +123,11 @@ function formatDate(iso: string) {
<template v-else-if="organisation">
<!-- Header -->
<div class="d-flex align-center justify-space-between mb-2">
<div class="d-flex align-center justify-space-between flex-wrap gap-4 mb-6">
<div>
<div class="d-flex align-center gap-x-2">
<h4 class="text-h4">
<div class="d-flex align-center gap-x-2 flex-wrap">
<h4 class="text-h4 mb-0">
{{ organisation.name }}
<span class="text-body-2 text-medium-emphasis font-weight-regular ms-2">({{ organisation.slug }})</span>
</h4>
<VChip
:color="statusColor[organisation.billing_status]"
@@ -71,51 +136,423 @@ function formatDate(iso: string) {
{{ organisation.billing_status.charAt(0).toUpperCase() + organisation.billing_status.slice(1) }}
</VChip>
</div>
<p class="text-body-1 text-disabled mb-0">
Aangemaakt op {{ formatDate(organisation.created_at) }}
&middot; Gewijzigd op {{ formatDate(organisation.updated_at) }}
</p>
<code class="text-caption text-primary d-block mt-1">{{ organisation.slug }}</code>
</div>
<VBtn
v-if="isOrgAdmin"
prepend-icon="tabler-edit"
@click="isEditDialogOpen = true"
>
Bewerken
Organisatie bewerken
</VBtn>
</div>
<VCard>
<VCardText>
<VRow>
<VCol
cols="12"
md="4"
>
<h6 class="text-h6 mb-1">
Slug
</h6>
<p class="text-body-1 text-disabled mb-0">
{{ organisation.slug }}
<!-- Stat cards -->
<VRow
v-if="statsLoading"
class="mb-6"
>
<VCol
v-for="n in 3"
:key="n"
cols="12"
md="4"
>
<VCard>
<VCardText>
<VSkeletonLoader type="heading" />
</VCardText>
</VCard>
</VCol>
</VRow>
<VAlert
v-else-if="statsError"
type="error"
variant="tonal"
class="mb-6"
>
Kon statistieken niet laden.
<template #append>
<VBtn
variant="text"
size="small"
@click="refetchStats()"
>
Opnieuw proberen
</VBtn>
</template>
</VAlert>
<VRow
v-else-if="stats"
class="mb-2"
>
<VCol
cols="12"
md="4"
>
<VCard
class="cursor-pointer h-100 card-border-shadow-primary"
@click="goToMembers"
>
<VCardText>
<div class="d-flex align-center mb-1">
<VAvatar
color="primary"
variant="tonal"
size="44"
rounded
class="me-4"
>
<VIcon
icon="tabler-users"
size="28"
/>
</VAvatar>
<h4 class="text-h4 mb-0">
{{ stats.members_count }}
</h4>
</div>
<p class="mb-1">
Leden
</p>
</VCol>
<VCol
cols="12"
md="4"
>
<h6 class="text-h6 mb-1">
Status
</h6>
<VChip
:color="statusColor[organisation.billing_status]"
size="small"
<p class="mb-0 text-body-secondary text-sm">
Actieve leden in je organisatie
</p>
</VCardText>
</VCard>
</VCol>
<VCol
cols="12"
md="4"
>
<VCard
class="cursor-pointer h-100 card-border-shadow-info"
@click="goToEvents"
>
<VCardText>
<div class="d-flex align-center mb-1">
<VAvatar
color="info"
variant="tonal"
size="44"
rounded
class="me-4"
>
<VIcon
icon="tabler-calendar-event"
size="28"
/>
</VAvatar>
<h4 class="text-h4 mb-0">
{{ stats.events_count }}
</h4>
</div>
<p class="mb-1">
Evenementen
</p>
<p class="mb-0 text-body-secondary text-sm">
{{ activeSubtitle }}
</p>
</VCardText>
</VCard>
</VCol>
<VCol
cols="12"
md="4"
>
<VCard class="h-100 card-border-shadow-success">
<VCardText>
<div class="d-flex align-center mb-1">
<VAvatar
color="success"
variant="tonal"
size="44"
rounded
class="me-4"
>
<VIcon
icon="tabler-user-circle"
size="28"
/>
</VAvatar>
<h4 class="text-h4 mb-0">
{{ stats.persons_count }}
</h4>
</div>
<p class="mb-1">
Personen
</p>
<p class="mb-0 text-body-secondary text-sm">
Over alle evenementen heen
</p>
</VCardText>
</VCard>
</VCol>
</VRow>
<!-- Info cards row -->
<VRow>
<!-- Organisation details -->
<VCol
cols="12"
md="6"
>
<VCard>
<VCardItem>
<template #title>
Organisatiegegevens
</template>
<template
v-if="isOrgAdmin"
#append
>
{{ organisation.billing_status }}
</VChip>
</VCol>
</VRow>
</VCardText>
</VCard>
<VBtn
variant="text"
size="small"
icon="tabler-edit"
@click="isEditDialogOpen = true"
/>
</template>
</VCardItem>
<VDivider />
<VList
lines="two"
density="comfortable"
>
<VListItem>
<template #title>
<span class="text-medium-emphasis text-caption">Naam</span>
</template>
<template #subtitle>
<span class="text-body-1">{{ organisation.name }}</span>
</template>
</VListItem>
<VListItem>
<template #title>
<span class="text-medium-emphasis text-caption">Slug</span>
</template>
<template #subtitle>
<code class="text-body-2 text-primary">{{ organisation.slug }}</code>
</template>
</VListItem>
<VListItem>
<template #title>
<span class="text-medium-emphasis text-caption">Contactpersoon</span>
</template>
<template #subtitle>
<span class="text-body-1">{{ organisation.contact_name || '—' }}</span>
</template>
</VListItem>
<VListItem>
<template #title>
<span class="text-medium-emphasis text-caption">Contact e-mail</span>
</template>
<template #subtitle>
<a
v-if="organisation.contact_email"
:href="`mailto:${organisation.contact_email}`"
class="text-body-1 text-primary"
>{{ organisation.contact_email }}</a>
<span
v-else
class="text-body-1"
></span>
</template>
</VListItem>
<VListItem>
<template #title>
<span class="text-medium-emphasis text-caption">Telefoon</span>
</template>
<template #subtitle>
<span class="text-body-1">{{ organisation.phone || '—' }}</span>
</template>
</VListItem>
<VListItem>
<template #title>
<span class="text-medium-emphasis text-caption">Website</span>
</template>
<template #subtitle>
<a
v-if="organisation.website"
:href="organisation.website"
target="_blank"
rel="noopener"
class="text-body-1 text-primary"
>{{ organisation.website }}</a>
<span
v-else
class="text-body-1"
></span>
</template>
</VListItem>
</VList>
</VCard>
</VCol>
<!-- Members card -->
<VCol
cols="12"
md="6"
>
<VCard class="h-100">
<VCardItem>
<template #title>
<span>Leden</span>
<span
v-if="stats"
class="text-medium-emphasis text-body-2 ms-2"
>({{ stats.members_count }})</span>
</template>
<template #append>
<VBtn
variant="text"
size="small"
append-icon="tabler-arrow-right"
@click="goToMembers"
>
Alle leden
</VBtn>
</template>
</VCardItem>
<VDivider />
<VSkeletonLoader
v-if="statsLoading"
type="list-item-avatar@5"
/>
<VAlert
v-else-if="statsError"
type="error"
variant="tonal"
class="ma-4"
>
Kon leden niet laden.
</VAlert>
<div
v-else-if="stats && stats.top_members.length === 0"
class="pa-6 text-center text-medium-emphasis"
>
Nog geen leden
</div>
<VList
v-else-if="stats"
lines="two"
density="comfortable"
>
<VListItem
v-for="member in stats.top_members"
:key="member.id"
>
<template #prepend>
<VAvatar
:image="member.avatar_url ?? undefined"
color="primary"
variant="tonal"
size="36"
>
<span
v-if="!member.avatar_url"
class="text-sm"
>{{ avatarInitials(member.name) }}</span>
</VAvatar>
</template>
<template #title>
{{ member.name }}
</template>
<template #subtitle>
<span class="text-body-2 text-medium-emphasis">{{ member.email }}</span>
</template>
<template #append>
<VChip
:color="roleColor[member.role] ?? 'secondary'"
size="small"
>
{{ roleLabel[member.role] ?? member.role }}
</VChip>
</template>
</VListItem>
</VList>
</VCard>
</VCol>
</VRow>
<!-- Activity log -->
<VRow class="mt-2">
<VCol cols="12">
<VCard>
<VCardItem>
<template #title>
Recente activiteit
</template>
</VCardItem>
<VDivider />
<VSkeletonLoader
v-if="statsLoading"
type="list-item-avatar@5"
/>
<VAlert
v-else-if="statsError"
type="error"
variant="tonal"
class="ma-4"
>
Kon activiteiten niet laden.
<template #append>
<VBtn
variant="text"
size="small"
@click="refetchStats()"
>
Opnieuw proberen
</VBtn>
</template>
</VAlert>
<div
v-else-if="stats && stats.recent_activity.length === 0"
class="pa-6 text-center text-medium-emphasis"
>
Nog geen recente activiteit
</div>
<VList
v-else-if="stats"
lines="two"
density="comfortable"
>
<VListItem
v-for="entry in stats.recent_activity"
:key="entry.id"
>
<template #prepend>
<VAvatar
:image="entry.causer_avatar_url ?? undefined"
color="primary"
variant="tonal"
size="36"
>
<span
v-if="!entry.causer_avatar_url"
class="text-sm"
>{{ avatarInitials(entry.causer_name) }}</span>
</VAvatar>
</template>
<template #title>
<span class="text-body-1">{{ describeActivity(entry) }}</span>
</template>
<template #subtitle>
<span class="text-body-2 text-medium-emphasis">
{{ entry.causer_name ?? 'Systeem' }} · {{ formatRelativeTime(entry.created_at) }}
</span>
</template>
</VListItem>
</VList>
</VCard>
</VCol>
</VRow>
<EditOrganisationDialog
v-model="isEditDialogOpen"