feat: platform admin frontend — pages, composables, navigation, impersonation

Build the frontend for platform admin in apps/app/:
- TypeScript types (admin.ts) and API composable (useAdmin.ts) with
  TanStack Query for all admin endpoints
- ImpersonationStore (Pinia) + ImpersonationBanner component integrated
  in the main layout, with token-based session management
- Platform navigation section (conditionally shown for super_admin users)
- Route guard blocking /platform/* for non-super_admin users
- 6 pages: dashboard with stats cards, organisations list/detail,
  users list/detail with impersonation, activity log with expandable rows
- All pages implement loading/error/empty states per conventions
- Vite build passes cleanly

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-14 23:49:36 +02:00
parent 07ba791405
commit 9e7f28420c
15 changed files with 2262 additions and 2 deletions

View File

@@ -0,0 +1,221 @@
<script setup lang="ts">
import { useAdminActivityLog } from '@/composables/api/useAdmin'
definePage({
meta: {
navActiveLink: 'platform-activity-log',
},
})
const search = ref('')
const searchDebounced = refDebounced(search, 400)
const logNameFilter = ref('')
const page = ref(1)
const params = computed(() => ({
page: page.value,
causer_id: undefined,
log_name: logNameFilter.value || undefined,
from: undefined,
to: undefined,
}))
const { data, isLoading, isError, refetch } = useAdminActivityLog(params)
const activities = computed(() => data.value?.data ?? [])
const totalItems = computed(() => data.value?.meta?.total ?? 0)
const expandedRows = ref<Set<number>>(new Set())
function toggleRow(id: number) {
if (expandedRows.value.has(id)) {
expandedRows.value.delete(id)
}
else {
expandedRows.value.add(id)
}
}
const logNameOptions = [
{ title: 'Alle logs', value: '' },
{ title: 'Admin', value: 'admin' },
{ title: 'Default', value: 'default' },
]
const headers = [
{ title: 'Tijd', key: 'created_at', width: '180px' },
{ title: 'Actie', key: 'description' },
{ title: 'Gebruiker', key: 'causer', sortable: false },
{ title: 'Type', key: 'subject_type', sortable: false },
{ title: '', key: 'expand', sortable: false, width: '50px' },
]
function formatDateTime(iso: string): string {
return new Date(iso).toLocaleString('nl-NL', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
}
function shortenSubjectType(type: string | null): string {
if (!type) return '-'
const parts = type.split('\\')
return parts[parts.length - 1]
}
function formatProperties(props: Record<string, unknown>): Array<{ key: string; value: string }> {
return Object.entries(props).map(([key, value]) => ({
key,
value: typeof value === 'object' ? JSON.stringify(value, null, 2) : String(value),
}))
}
function onUpdateOptions(options: { page: number }) {
page.value = options.page
}
</script>
<template>
<div>
<div class="d-flex align-center justify-space-between mb-6">
<div>
<h4 class="text-h4">
Activity Log
</h4>
<p class="text-body-1 text-disabled mb-0">
Alle activiteiten op het platform
</p>
</div>
</div>
<!-- Error -->
<VAlert
v-if="isError"
type="error"
class="mb-4"
>
Kon activity log niet laden.
<template #append>
<VBtn
variant="text"
@click="refetch()"
>
Opnieuw proberen
</VBtn>
</template>
</VAlert>
<VCard>
<!-- Filters -->
<VCardText>
<VRow>
<VCol
cols="12"
md="4"
>
<AppSelect
v-model="logNameFilter"
:items="logNameOptions"
placeholder="Log type"
clearable
/>
</VCol>
</VRow>
</VCardText>
<VDataTableServer
:headers="headers"
:items="activities"
:items-length="totalItems"
:loading="isLoading"
:items-per-page="25"
:page="page"
@update:options="onUpdateOptions"
>
<template #item.created_at="{ item }">
<span class="text-body-2">
{{ formatDateTime(item.created_at) }}
</span>
</template>
<template #item.causer="{ item }">
<span v-if="item.causer">
{{ item.causer.name }}
</span>
<span
v-else
class="text-disabled"
>Systeem</span>
</template>
<template #item.subject_type="{ item }">
<VChip
v-if="item.subject_type"
size="x-small"
>
{{ shortenSubjectType(item.subject_type) }}
</VChip>
<span
v-else
class="text-disabled"
>-</span>
</template>
<template #item.expand="{ item }">
<VBtn
v-if="item.properties && Object.keys(item.properties).length > 0"
:icon="expandedRows.has(item.id) ? 'tabler-chevron-up' : 'tabler-chevron-down'"
variant="text"
size="x-small"
@click="toggleRow(item.id)"
/>
</template>
<template #expanded-row="{ item }">
<tr v-if="expandedRows.has(item.id)">
<td :colspan="headers.length">
<div class="pa-4">
<VTable density="compact">
<thead>
<tr>
<th>Eigenschap</th>
<th>Waarde</th>
</tr>
</thead>
<tbody>
<tr
v-for="prop in formatProperties(item.properties)"
:key="prop.key"
>
<td class="text-body-2 font-weight-medium">
{{ prop.key }}
</td>
<td>
<code class="text-body-2">{{ prop.value }}</code>
</td>
</tr>
</tbody>
</VTable>
</div>
</td>
</tr>
</template>
<!-- Empty -->
<template #no-data>
<div class="text-center pa-4 text-disabled">
<VIcon
icon="tabler-list-details"
size="48"
class="mb-2"
/>
<p>Geen activiteiten gevonden</p>
</div>
</template>
</VDataTableServer>
</VCard>
</div>
</template>

View File

@@ -0,0 +1,239 @@
<script setup lang="ts">
import { usePlatformStats, useAdminActivityLog } from '@/composables/api/useAdmin'
import type { BillingStatus } from '@/types/admin'
definePage({
meta: {
navActiveLink: 'platform',
},
})
const { data: stats, isLoading: statsLoading, isError: statsError, refetch: refetchStats } = usePlatformStats()
const activityParams = computed(() => ({ page: 1, per_page: 10 }))
const { data: activityData, isLoading: activityLoading } = useAdminActivityLog(activityParams)
const recentActivities = computed(() => activityData.value?.data ?? [])
const billingStatusColor: Record<BillingStatus, string> = {
trial: 'info',
active: 'success',
suspended: 'warning',
cancelled: 'error',
}
const statCards = computed(() => {
if (!stats.value) return []
return [
{
title: 'Organisaties',
value: stats.value.organisations.total,
icon: 'tabler-buildings',
color: 'primary',
to: { name: 'platform-organisations' },
},
{
title: 'Evenementen',
value: stats.value.events.total,
icon: 'tabler-calendar-event',
color: 'success',
extra: stats.value.events.by_status,
},
{
title: 'Gebruikers',
value: `${stats.value.users.verified} / ${stats.value.users.total}`,
icon: 'tabler-users-group',
color: 'warning',
to: { name: 'platform-users' },
subtitle: 'geverifieerd',
},
{
title: 'Personen',
value: stats.value.persons.total,
icon: 'tabler-user',
color: 'info',
},
]
})
function formatDateTime(iso: string): string {
return new Date(iso).toLocaleString('nl-NL', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
}
function shortenSubjectType(type: string | null): string {
if (!type) return '-'
const parts = type.split('\\')
return parts[parts.length - 1]
}
</script>
<template>
<div>
<div class="d-flex align-center mb-6">
<div>
<h4 class="text-h4">
Platform Admin
</h4>
<p class="text-body-1 text-disabled mb-0">
Overzicht van het hele platform
</p>
</div>
</div>
<!-- Loading -->
<VSkeletonLoader
v-if="statsLoading"
type="card, card"
/>
<!-- Error -->
<VAlert
v-else-if="statsError"
type="error"
class="mb-4"
>
Kon statistieken niet laden.
<template #append>
<VBtn
variant="text"
@click="refetchStats()"
>
Opnieuw proberen
</VBtn>
</template>
</VAlert>
<template v-else>
<!-- Stats Cards -->
<VRow class="mb-6">
<VCol
v-for="card in statCards"
:key="card.title"
cols="12"
sm="6"
md="3"
>
<VCard
:to="card.to"
:class="{ 'cursor-pointer': !!card.to }"
>
<VCardText class="d-flex align-center gap-x-4">
<VAvatar
:color="card.color"
variant="tonal"
size="44"
rounded
>
<VIcon
:icon="card.icon"
size="28"
/>
</VAvatar>
<div>
<p class="text-body-1 mb-0">
{{ card.title }}
</p>
<h4 class="text-h4">
{{ card.value }}
</h4>
<p
v-if="card.subtitle"
class="text-caption text-disabled mb-0"
>
{{ card.subtitle }}
</p>
</div>
</VCardText>
<!-- Billing status breakdown for organisations -->
<VCardText
v-if="card.extra"
class="pt-0"
>
<div class="d-flex flex-wrap gap-1">
<VChip
v-for="(count, status) in card.extra"
:key="String(status)"
size="x-small"
:color="billingStatusColor[status as BillingStatus] ?? 'default'"
>
{{ status }}: {{ count }}
</VChip>
</div>
</VCardText>
</VCard>
</VCol>
</VRow>
<!-- Recent Activity -->
<VCard>
<VCardTitle class="d-flex align-center justify-space-between">
<span>Recente Activiteit</span>
<VBtn
variant="text"
size="small"
:to="{ name: 'platform-activity-log' }"
>
Alles bekijken
</VBtn>
</VCardTitle>
<VSkeletonLoader
v-if="activityLoading"
type="list-item@5"
/>
<VList
v-else-if="recentActivities.length > 0"
lines="two"
>
<VListItem
v-for="entry in recentActivities"
:key="entry.id"
>
<template #prepend>
<VAvatar
color="secondary"
variant="tonal"
size="34"
>
<VIcon
icon="tabler-activity"
size="18"
/>
</VAvatar>
</template>
<VListItemTitle>{{ entry.description }}</VListItemTitle>
<VListItemSubtitle>
{{ entry.causer?.name ?? 'Systeem' }}
&middot;
{{ formatDateTime(entry.created_at) }}
<VChip
v-if="entry.subject_type"
size="x-small"
class="ms-1"
>
{{ shortenSubjectType(entry.subject_type) }}
</VChip>
</VListItemSubtitle>
</VListItem>
</VList>
<VCardText
v-else
class="text-center text-disabled"
>
Geen activiteit gevonden
</VCardText>
</VCard>
</template>
</div>
</template>

View File

@@ -0,0 +1,384 @@
<script setup lang="ts">
import { useAdminOrganisation, useUpdateAdminOrganisation, useDeleteAdminOrganisation } from '@/composables/api/useAdmin'
import { useOrganisationStore } from '@/stores/useOrganisationStore'
import type { BillingStatus, UpdateAdminOrganisationPayload } from '@/types/admin'
definePage({
meta: {
navActiveLink: 'platform-organisations',
},
})
const route = useRoute()
const router = useRouter()
const orgStore = useOrganisationStore()
const orgId = computed(() => String((route.params as { id: string }).id))
const { data: org, isLoading, isError, refetch } = useAdminOrganisation(orgId)
const billingStatusColor: Record<BillingStatus, string> = {
trial: 'info',
active: 'success',
suspended: 'warning',
cancelled: 'error',
}
const billingStatusOptions = [
{ title: 'Trial', value: 'trial' },
{ title: 'Active', value: 'active' },
{ title: 'Suspended', value: 'suspended' },
{ title: 'Cancelled', value: 'cancelled' },
]
// Edit dialog
const isEditDialogOpen = ref(false)
const editForm = ref<UpdateAdminOrganisationPayload>({})
const { mutate: updateOrg, isPending: isUpdating } = useUpdateAdminOrganisation()
function openEditDialog() {
if (!org.value) return
editForm.value = {
name: org.value.name,
slug: org.value.slug,
billing_status: org.value.billing_status,
}
isEditDialogOpen.value = true
}
function submitEdit() {
updateOrg(
{ id: orgId.value, payload: editForm.value },
{
onSuccess: () => {
isEditDialogOpen.value = false
showEditSuccess.value = true
},
},
)
}
// Delete
const isDeleteDialogOpen = ref(false)
const { mutate: deleteOrg, isPending: isDeleting } = useDeleteAdminOrganisation()
function confirmDelete() {
deleteOrg(orgId.value, {
onSuccess: () => {
router.push({ name: 'platform-organisations' })
},
})
}
// Open as organiser
function openAsOrganiser() {
if (!org.value) return
orgStore.setActiveOrganisation(org.value.id)
router.push({ name: 'dashboard' })
}
const showEditSuccess = ref(false)
function formatDate(iso: string): string {
return new Date(iso).toLocaleDateString('nl-NL', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
}
</script>
<template>
<div>
<!-- Loading -->
<VSkeletonLoader
v-if="isLoading"
type="card, card"
/>
<!-- Error -->
<VAlert
v-else-if="isError"
type="error"
class="mb-4"
>
Kon organisatie niet laden.
<template #append>
<VBtn
variant="text"
@click="refetch()"
>
Opnieuw proberen
</VBtn>
</template>
</VAlert>
<template v-else-if="org">
<!-- Header -->
<div class="d-flex align-center justify-space-between mb-6">
<div class="d-flex align-center gap-x-3">
<VBtn
icon="tabler-arrow-left"
variant="text"
size="small"
:to="{ name: 'platform-organisations' }"
/>
<div>
<h4 class="text-h4">
{{ org.name }}
<VChip
:color="billingStatusColor[org.billing_status]"
size="small"
class="ms-2"
>
{{ org.billing_status_label ?? org.billing_status }}
</VChip>
</h4>
<p class="text-body-2 text-disabled mb-0">
{{ org.slug }}
</p>
</div>
</div>
<div class="d-flex gap-x-2">
<VBtn
variant="tonal"
prepend-icon="tabler-external-link"
@click="openAsOrganiser"
>
Open als organisator
</VBtn>
<VBtn
prepend-icon="tabler-edit"
@click="openEditDialog"
>
Bewerken
</VBtn>
</div>
</div>
<!-- Info Cards -->
<VRow class="mb-6">
<VCol
cols="12"
md="4"
>
<VCard>
<VCardText class="d-flex align-center gap-x-4">
<VAvatar
color="primary"
variant="tonal"
size="44"
rounded
>
<VIcon
icon="tabler-calendar-event"
size="28"
/>
</VAvatar>
<div>
<p class="text-body-1 mb-0">
Events
</p>
<h4 class="text-h4">
{{ org.events_count }}
</h4>
</div>
</VCardText>
</VCard>
</VCol>
<VCol
cols="12"
md="4"
>
<VCard>
<VCardText class="d-flex align-center gap-x-4">
<VAvatar
color="success"
variant="tonal"
size="44"
rounded
>
<VIcon
icon="tabler-users-group"
size="28"
/>
</VAvatar>
<div>
<p class="text-body-1 mb-0">
Gebruikers
</p>
<h4 class="text-h4">
{{ org.users_count }}
</h4>
</div>
</VCardText>
</VCard>
</VCol>
<VCol
cols="12"
md="4"
>
<VCard>
<VCardText class="d-flex align-center gap-x-4">
<VAvatar
color="info"
variant="tonal"
size="44"
rounded
>
<VIcon
icon="tabler-user"
size="28"
/>
</VAvatar>
<div>
<p class="text-body-1 mb-0">
Totaal personen
</p>
<h4 class="text-h4">
{{ org.total_persons }}
</h4>
</div>
</VCardText>
</VCard>
</VCol>
</VRow>
<!-- Details Card -->
<VCard class="mb-6">
<VCardTitle>Details</VCardTitle>
<VCardText>
<VRow>
<VCol
cols="12"
sm="6"
>
<p class="text-body-2 text-disabled mb-1">
Aangemaakt
</p>
<p class="text-body-1">
{{ formatDate(org.created_at) }}
</p>
</VCol>
<VCol
cols="12"
sm="6"
>
<p class="text-body-2 text-disabled mb-1">
Laatste wijziging
</p>
<p class="text-body-1">
{{ formatDate(org.updated_at) }}
</p>
</VCol>
</VRow>
</VCardText>
</VCard>
<!-- Danger Zone -->
<VCard>
<VCardTitle class="text-error">
Gevarenzone
</VCardTitle>
<VCardText>
<VBtn
color="error"
variant="tonal"
prepend-icon="tabler-trash"
@click="isDeleteDialogOpen = true"
>
Organisatie verwijderen
</VBtn>
</VCardText>
</VCard>
</template>
<!-- Edit Dialog -->
<VDialog
v-model="isEditDialogOpen"
max-width="500"
>
<VCard title="Organisatie bewerken">
<VCardText>
<VRow>
<VCol cols="12">
<AppTextField
v-model="editForm.name"
label="Naam"
/>
</VCol>
<VCol cols="12">
<AppTextField
v-model="editForm.slug"
label="Slug"
/>
</VCol>
<VCol cols="12">
<AppSelect
v-model="editForm.billing_status"
:items="billingStatusOptions"
label="Billing Status"
/>
</VCol>
</VRow>
</VCardText>
<VCardActions>
<VSpacer />
<VBtn
variant="tonal"
@click="isEditDialogOpen = false"
>
Annuleren
</VBtn>
<VBtn
color="primary"
:loading="isUpdating"
@click="submitEdit"
>
Opslaan
</VBtn>
</VCardActions>
</VCard>
</VDialog>
<!-- Delete Dialog -->
<VDialog
v-model="isDeleteDialogOpen"
max-width="400"
>
<VCard title="Organisatie verwijderen">
<VCardText>
Weet je zeker dat je <strong>{{ org?.name }}</strong> wilt verwijderen?
Dit kan niet ongedaan worden gemaakt.
</VCardText>
<VCardActions>
<VSpacer />
<VBtn
variant="text"
@click="isDeleteDialogOpen = false"
>
Annuleren
</VBtn>
<VBtn
color="error"
:loading="isDeleting"
@click="confirmDelete"
>
Verwijderen
</VBtn>
</VCardActions>
</VCard>
</VDialog>
<!-- Success snackbar -->
<VSnackbar
v-model="showEditSuccess"
color="success"
:timeout="3000"
>
Organisatie bijgewerkt
</VSnackbar>
</div>
</template>

View File

@@ -0,0 +1,178 @@
<script setup lang="ts">
import { useAdminOrganisations } from '@/composables/api/useAdmin'
import type { AdminOrganisation, BillingStatus } from '@/types/admin'
definePage({
meta: {
navActiveLink: 'platform-organisations',
},
})
const router = useRouter()
const search = ref('')
const searchDebounced = refDebounced(search, 400)
const billingStatusFilter = ref<string>('')
const page = ref(1)
const itemsPerPage = ref(15)
const sortBy = ref('name')
const sortDirection = ref<'asc' | 'desc'>('asc')
const params = computed(() => ({
page: page.value,
per_page: itemsPerPage.value,
search: searchDebounced.value || undefined,
billing_status: billingStatusFilter.value || undefined,
sort: sortBy.value,
direction: sortDirection.value,
}))
const { data, isLoading, isError, refetch } = useAdminOrganisations(params)
const organisations = computed(() => data.value?.data ?? [])
const totalItems = computed(() => data.value?.meta?.total ?? 0)
const billingStatusColor: Record<BillingStatus, string> = {
trial: 'info',
active: 'success',
suspended: 'warning',
cancelled: 'error',
}
const billingStatusOptions = [
{ title: 'Alle statussen', value: '' },
{ title: 'Trial', value: 'trial' },
{ title: 'Active', value: 'active' },
{ title: 'Suspended', value: 'suspended' },
{ title: 'Cancelled', value: 'cancelled' },
]
const headers = [
{ title: 'Naam', key: 'name', sortable: true },
{ title: 'Slug', key: 'slug', sortable: false },
{ title: 'Status', key: 'billing_status', sortable: false },
{ title: 'Events', key: 'events_count', sortable: false, align: 'center' as const },
{ title: 'Gebruikers', key: 'users_count', sortable: false, align: 'center' as const },
{ title: 'Aangemaakt', key: 'created_at', sortable: true },
]
function formatDate(iso: string): string {
return new Date(iso).toLocaleDateString('nl-NL', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
})
}
function onRowClick(_event: Event, { item }: { item: AdminOrganisation }) {
router.push({ name: 'platform-organisations-id', params: { id: item.id } })
}
function onUpdateOptions(options: { page: number; itemsPerPage: number; sortBy: Array<{ key: string; order: string }> }) {
page.value = options.page
itemsPerPage.value = options.itemsPerPage
if (options.sortBy.length > 0) {
sortBy.value = options.sortBy[0].key
sortDirection.value = options.sortBy[0].order as 'asc' | 'desc'
}
}
</script>
<template>
<div>
<div class="d-flex align-center justify-space-between mb-6">
<div>
<h4 class="text-h4">
Organisaties
</h4>
<p class="text-body-1 text-disabled mb-0">
Alle organisaties op het platform
</p>
</div>
</div>
<!-- Error -->
<VAlert
v-if="isError"
type="error"
class="mb-4"
>
Kon organisaties niet laden.
<template #append>
<VBtn
variant="text"
@click="refetch()"
>
Opnieuw proberen
</VBtn>
</template>
</VAlert>
<VCard>
<!-- Filters -->
<VCardText>
<VRow>
<VCol
cols="12"
md="6"
>
<AppTextField
v-model="search"
placeholder="Zoek op naam of slug..."
prepend-inner-icon="tabler-search"
clearable
/>
</VCol>
<VCol
cols="12"
md="3"
>
<AppSelect
v-model="billingStatusFilter"
:items="billingStatusOptions"
placeholder="Status"
clearable
/>
</VCol>
</VRow>
</VCardText>
<VDataTableServer
:headers="headers"
:items="organisations"
:items-length="totalItems"
:loading="isLoading"
:items-per-page="itemsPerPage"
:page="page"
hover
@update:options="onUpdateOptions"
@click:row="onRowClick"
>
<template #item.billing_status="{ item }">
<VChip
:color="billingStatusColor[item.billing_status]"
size="small"
>
{{ item.billing_status_label ?? item.billing_status }}
</VChip>
</template>
<template #item.created_at="{ item }">
{{ formatDate(item.created_at) }}
</template>
<!-- Empty -->
<template #no-data>
<div class="text-center pa-4 text-disabled">
<VIcon
icon="tabler-building-off"
size="48"
class="mb-2"
/>
<p>Geen organisaties gevonden</p>
</div>
</template>
</VDataTableServer>
</VCard>
</div>
</template>

View File

@@ -0,0 +1,404 @@
<script setup lang="ts">
import {
useAdminUser,
useUpdateAdminUser,
useStartImpersonation,
} from '@/composables/api/useAdmin'
import { useImpersonationStore } from '@/stores/useImpersonationStore'
import type { AdminUser, UpdateAdminUserPayload } from '@/types/admin'
definePage({
meta: {
navActiveLink: 'platform-users',
},
})
const route = useRoute()
const impersonationStore = useImpersonationStore()
const userId = computed(() => String((route.params as { id: string }).id))
const { data: user, isLoading, isError, refetch } = useAdminUser(userId)
const roleColorMap: Record<string, string> = {
super_admin: 'error',
org_admin: 'purple',
org_member: 'info',
event_manager: 'cyan',
}
const platformRoleOptions = [
{ title: 'Super Admin', value: 'super_admin' },
]
// Edit dialog
const isEditDialogOpen = ref(false)
const editForm = ref<UpdateAdminUserPayload>({})
const { mutate: updateUser, isPending: isUpdating } = useUpdateAdminUser()
const showEditSuccess = ref(false)
function openEditDialog() {
if (!user.value) return
editForm.value = {
first_name: user.value.first_name,
last_name: user.value.last_name,
email: user.value.email,
timezone: user.value.timezone,
locale: user.value.locale,
roles: user.value.roles.filter(r => ['super_admin', 'support_agent'].includes(r)),
}
isEditDialogOpen.value = true
}
function submitEdit() {
updateUser(
{ id: userId.value, payload: editForm.value },
{
onSuccess: () => {
isEditDialogOpen.value = false
showEditSuccess.value = true
},
},
)
}
// Impersonation
const isImpersonateDialogOpen = ref(false)
const { mutate: startImpersonation, isPending: isImpersonating } = useStartImpersonation()
function confirmImpersonate() {
startImpersonation(userId.value, {
onSuccess: (result) => {
isImpersonateDialogOpen.value = false
impersonationStore.startImpersonation(result.token, result.user, result.admin_id)
},
})
}
function formatDate(iso: string): string {
return new Date(iso).toLocaleDateString('nl-NL', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
}
function getInitials(name: string): string {
return name
.split(' ')
.map(p => p[0])
.filter(Boolean)
.slice(0, 2)
.join('')
.toUpperCase()
}
</script>
<template>
<div>
<!-- Loading -->
<VSkeletonLoader
v-if="isLoading"
type="card, card"
/>
<!-- Error -->
<VAlert
v-else-if="isError"
type="error"
class="mb-4"
>
Kon gebruiker niet laden.
<template #append>
<VBtn
variant="text"
@click="refetch()"
>
Opnieuw proberen
</VBtn>
</template>
</VAlert>
<template v-else-if="user">
<!-- Header -->
<div class="d-flex align-center justify-space-between mb-6">
<div class="d-flex align-center gap-x-3">
<VBtn
icon="tabler-arrow-left"
variant="text"
size="small"
:to="{ name: 'platform-users' }"
/>
<VAvatar
v-if="user.avatar"
size="44"
:image="user.avatar"
/>
<VAvatar
v-else
size="44"
color="primary"
variant="tonal"
>
<span>{{ getInitials(user.full_name) }}</span>
</VAvatar>
<div>
<h4 class="text-h4">
{{ user.full_name }}
<VChip
v-for="role in user.roles"
:key="role"
:color="roleColorMap[role] ?? 'default'"
size="small"
class="ms-1"
>
{{ role }}
</VChip>
</h4>
<p class="text-body-2 text-disabled mb-0">
{{ user.email }}
</p>
</div>
</div>
<div class="d-flex gap-x-2">
<VBtn
v-if="!user.is_super_admin"
variant="tonal"
color="warning"
prepend-icon="tabler-user-share"
@click="isImpersonateDialogOpen = true"
>
Inloggen als
</VBtn>
<VBtn
prepend-icon="tabler-edit"
@click="openEditDialog"
>
Bewerken
</VBtn>
</div>
</div>
<!-- User Info -->
<VRow class="mb-6">
<VCol
cols="12"
md="6"
>
<VCard>
<VCardTitle>Profiel</VCardTitle>
<VCardText>
<VRow>
<VCol
cols="6"
>
<p class="text-body-2 text-disabled mb-1">
Tijdzone
</p>
<p class="text-body-1">
{{ user.timezone }}
</p>
</VCol>
<VCol
cols="6"
>
<p class="text-body-2 text-disabled mb-1">
Taal
</p>
<p class="text-body-1">
{{ user.locale === 'nl' ? 'Nederlands' : 'English' }}
</p>
</VCol>
<VCol
cols="6"
>
<p class="text-body-2 text-disabled mb-1">
E-mail geverifieerd
</p>
<p class="text-body-1">
<VIcon
:icon="user.email_verified_at ? 'tabler-circle-check' : 'tabler-circle-x'"
:color="user.email_verified_at ? 'success' : 'error'"
size="18"
class="me-1"
/>
{{ user.email_verified_at ? formatDate(user.email_verified_at) : 'Niet geverifieerd' }}
</p>
</VCol>
<VCol
cols="6"
>
<p class="text-body-2 text-disabled mb-1">
Aangemaakt
</p>
<p class="text-body-1">
{{ formatDate(user.created_at) }}
</p>
</VCol>
</VRow>
</VCardText>
</VCard>
</VCol>
<!-- Organisations -->
<VCol
cols="12"
md="6"
>
<VCard>
<VCardTitle>Organisaties</VCardTitle>
<VCardText
v-if="user.organisations.length === 0"
class="text-disabled"
>
Geen organisaties
</VCardText>
<VList
v-else
lines="one"
>
<VListItem
v-for="org in user.organisations"
:key="org.id"
:to="{ name: 'platform-organisations-id', params: { id: org.id } }"
>
<VListItemTitle>{{ org.name }}</VListItemTitle>
<template #append>
<VChip
:color="roleColorMap[org.role] ?? 'default'"
size="x-small"
>
{{ org.role }}
</VChip>
</template>
</VListItem>
</VList>
</VCard>
</VCol>
</VRow>
</template>
<!-- Edit Dialog -->
<VDialog
v-model="isEditDialogOpen"
max-width="500"
>
<VCard title="Gebruiker bewerken">
<VCardText>
<VRow>
<VCol
cols="12"
md="6"
>
<AppTextField
v-model="editForm.first_name"
label="Voornaam"
/>
</VCol>
<VCol
cols="12"
md="6"
>
<AppTextField
v-model="editForm.last_name"
label="Achternaam"
/>
</VCol>
<VCol cols="12">
<AppTextField
v-model="editForm.email"
label="E-mail"
type="email"
/>
</VCol>
<VCol
cols="12"
md="6"
>
<AppTextField
v-model="editForm.timezone"
label="Tijdzone"
/>
</VCol>
<VCol
cols="12"
md="6"
>
<AppSelect
v-model="editForm.locale"
:items="[{ title: 'Nederlands', value: 'nl' }, { title: 'English', value: 'en' }]"
label="Taal"
/>
</VCol>
<VCol cols="12">
<AppSelect
v-model="editForm.roles"
:items="platformRoleOptions"
label="Platform rollen"
multiple
chips
closable-chips
/>
</VCol>
</VRow>
</VCardText>
<VCardActions>
<VSpacer />
<VBtn
variant="tonal"
@click="isEditDialogOpen = false"
>
Annuleren
</VBtn>
<VBtn
color="primary"
:loading="isUpdating"
@click="submitEdit"
>
Opslaan
</VBtn>
</VCardActions>
</VCard>
</VDialog>
<!-- Impersonate Dialog -->
<VDialog
v-model="isImpersonateDialogOpen"
max-width="400"
>
<VCard title="Inloggen als gebruiker">
<VCardText>
Je gaat inloggen als <strong>{{ user?.full_name }}</strong>
({{ user?.email }}). Wil je doorgaan?
</VCardText>
<VCardActions>
<VSpacer />
<VBtn
variant="text"
@click="isImpersonateDialogOpen = false"
>
Annuleren
</VBtn>
<VBtn
color="warning"
:loading="isImpersonating"
@click="confirmImpersonate"
>
Doorgaan
</VBtn>
</VCardActions>
</VCard>
</VDialog>
<!-- Success snackbar -->
<VSnackbar
v-model="showEditSuccess"
color="success"
:timeout="3000"
>
Gebruiker bijgewerkt
</VSnackbar>
</div>
</template>

View File

@@ -0,0 +1,364 @@
<script setup lang="ts">
import {
useAdminUsers,
useStartImpersonation,
useDeleteAdminUser,
} from '@/composables/api/useAdmin'
import { useImpersonationStore } from '@/stores/useImpersonationStore'
import type { AdminUser } from '@/types/admin'
definePage({
meta: {
navActiveLink: 'platform-users',
},
})
const router = useRouter()
const impersonationStore = useImpersonationStore()
const search = ref('')
const searchDebounced = refDebounced(search, 400)
const organisationFilter = ref('')
const roleFilter = ref('')
const page = ref(1)
const itemsPerPage = ref(15)
const params = computed(() => ({
page: page.value,
per_page: itemsPerPage.value,
search: searchDebounced.value || undefined,
organisation_id: organisationFilter.value || undefined,
role: roleFilter.value || undefined,
}))
const { data, isLoading, isError, refetch } = useAdminUsers(params)
const users = computed(() => data.value?.data ?? [])
const totalItems = computed(() => data.value?.meta?.total ?? 0)
const roleOptions = [
{ title: 'Alle rollen', value: '' },
{ title: 'Super Admin', value: 'super_admin' },
{ title: 'Org Admin', value: 'org_admin' },
{ title: 'Org Member', value: 'org_member' },
{ title: 'Event Manager', value: 'event_manager' },
]
const headers = [
{ title: 'Naam', key: 'full_name' },
{ title: 'E-mail', key: 'email' },
{ title: 'Organisaties', key: 'organisations', sortable: false },
{ title: 'Rollen', key: 'roles', sortable: false },
{ title: 'Geverifieerd', key: 'email_verified_at', sortable: false, align: 'center' as const },
{ title: 'Aangemaakt', key: 'created_at', sortable: false },
{ title: '', key: 'actions', sortable: false, align: 'end' as const },
]
// Impersonation
const isImpersonateDialogOpen = ref(false)
const userToImpersonate = ref<AdminUser | null>(null)
const { mutate: startImpersonation, isPending: isImpersonating } = useStartImpersonation()
function openImpersonateDialog(user: AdminUser) {
userToImpersonate.value = user
isImpersonateDialogOpen.value = true
}
function confirmImpersonate() {
if (!userToImpersonate.value) return
startImpersonation(userToImpersonate.value.id, {
onSuccess: (result) => {
isImpersonateDialogOpen.value = false
impersonationStore.startImpersonation(result.token, result.user, result.admin_id)
},
})
}
// Delete
const isDeleteDialogOpen = ref(false)
const userToDelete = ref<AdminUser | null>(null)
const { mutate: deleteUser, isPending: isDeleting } = useDeleteAdminUser()
const showDeleteSuccess = ref(false)
function openDeleteDialog(user: AdminUser) {
userToDelete.value = user
isDeleteDialogOpen.value = true
}
function confirmDelete() {
if (!userToDelete.value) return
deleteUser(userToDelete.value.id, {
onSuccess: () => {
isDeleteDialogOpen.value = false
userToDelete.value = null
showDeleteSuccess.value = true
},
})
}
function formatDate(iso: string): string {
return new Date(iso).toLocaleDateString('nl-NL', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
})
}
function getInitials(name: string): string {
return name
.split(' ')
.map(p => p[0])
.filter(Boolean)
.slice(0, 2)
.join('')
.toUpperCase()
}
function onRowClick(_event: Event, { item }: { item: AdminUser }) {
router.push({ name: 'platform-users-id', params: { id: item.id } })
}
function onUpdateOptions(options: { page: number; itemsPerPage: number }) {
page.value = options.page
itemsPerPage.value = options.itemsPerPage
}
</script>
<template>
<div>
<div class="d-flex align-center justify-space-between mb-6">
<div>
<h4 class="text-h4">
Gebruikers
</h4>
<p class="text-body-1 text-disabled mb-0">
Alle gebruikers op het platform
</p>
</div>
</div>
<!-- Error -->
<VAlert
v-if="isError"
type="error"
class="mb-4"
>
Kon gebruikers niet laden.
<template #append>
<VBtn
variant="text"
@click="refetch()"
>
Opnieuw proberen
</VBtn>
</template>
</VAlert>
<VCard>
<!-- Filters -->
<VCardText>
<VRow>
<VCol
cols="12"
md="6"
>
<AppTextField
v-model="search"
placeholder="Zoek op naam of e-mail..."
prepend-inner-icon="tabler-search"
clearable
/>
</VCol>
<VCol
cols="12"
md="3"
>
<AppSelect
v-model="roleFilter"
:items="roleOptions"
placeholder="Rol"
clearable
/>
</VCol>
</VRow>
</VCardText>
<VDataTableServer
:headers="headers"
:items="users"
:items-length="totalItems"
:loading="isLoading"
:items-per-page="itemsPerPage"
:page="page"
hover
@update:options="onUpdateOptions"
@click:row="onRowClick"
>
<template #item.full_name="{ item }">
<div class="d-flex align-center gap-x-3">
<VAvatar
v-if="item.avatar"
size="34"
:image="item.avatar"
/>
<VAvatar
v-else
size="34"
color="primary"
variant="tonal"
>
<span class="text-sm">{{ getInitials(item.full_name) }}</span>
</VAvatar>
<span>{{ item.full_name }}</span>
</div>
</template>
<template #item.organisations="{ item }">
<div class="d-flex flex-wrap gap-1">
<VChip
v-for="org in item.organisations.slice(0, 3)"
:key="org.id"
size="x-small"
>
{{ org.name }}
</VChip>
<VChip
v-if="item.organisations.length > 3"
size="x-small"
color="secondary"
>
+{{ item.organisations.length - 3 }}
</VChip>
</div>
</template>
<template #item.roles="{ item }">
<div class="d-flex flex-wrap gap-1">
<VChip
v-for="role in item.roles"
:key="role"
size="x-small"
:color="role === 'super_admin' ? 'error' : 'primary'"
>
{{ role }}
</VChip>
</div>
</template>
<template #item.email_verified_at="{ item }">
<VIcon
:icon="item.email_verified_at ? 'tabler-circle-check' : 'tabler-circle-x'"
:color="item.email_verified_at ? 'success' : 'error'"
size="20"
/>
</template>
<template #item.created_at="{ item }">
{{ formatDate(item.created_at) }}
</template>
<template #item.actions="{ item }">
<div
class="d-flex gap-x-1 justify-end"
@click.stop
>
<VBtn
icon="tabler-user-share"
variant="text"
size="small"
:disabled="item.is_super_admin"
@click="openImpersonateDialog(item)"
/>
<VBtn
icon="tabler-trash"
variant="text"
size="small"
color="error"
@click="openDeleteDialog(item)"
/>
</div>
</template>
<!-- Empty -->
<template #no-data>
<div class="text-center pa-4 text-disabled">
<VIcon
icon="tabler-users-minus"
size="48"
class="mb-2"
/>
<p>Geen gebruikers gevonden</p>
</div>
</template>
</VDataTableServer>
</VCard>
<!-- Impersonate Dialog -->
<VDialog
v-model="isImpersonateDialogOpen"
max-width="400"
>
<VCard title="Inloggen als gebruiker">
<VCardText>
Je gaat inloggen als <strong>{{ userToImpersonate?.full_name }}</strong>
({{ userToImpersonate?.email }}). Wil je doorgaan?
</VCardText>
<VCardActions>
<VSpacer />
<VBtn
variant="text"
@click="isImpersonateDialogOpen = false"
>
Annuleren
</VBtn>
<VBtn
color="warning"
:loading="isImpersonating"
@click="confirmImpersonate"
>
Doorgaan
</VBtn>
</VCardActions>
</VCard>
</VDialog>
<!-- Delete Dialog -->
<VDialog
v-model="isDeleteDialogOpen"
max-width="400"
>
<VCard title="Gebruiker verwijderen">
<VCardText>
Weet je zeker dat je <strong>{{ userToDelete?.full_name }}</strong> wilt verwijderen?
</VCardText>
<VCardActions>
<VSpacer />
<VBtn
variant="text"
@click="isDeleteDialogOpen = false"
>
Annuleren
</VBtn>
<VBtn
color="error"
:loading="isDeleting"
@click="confirmDelete"
>
Verwijderen
</VBtn>
</VCardActions>
</VCard>
</VDialog>
<!-- Snackbars -->
<VSnackbar
v-model="showDeleteSuccess"
color="success"
:timeout="3000"
>
Gebruiker verwijderd
</VSnackbar>
</div>
</template>