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:
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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) }}
|
||||
· 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"
|
||||
|
||||
Reference in New Issue
Block a user