refactor: identical VDataTable for members on both organisation pages

Both org pages now use the same VDataTable with:
- Search field (name/email filter)
- Sortable columns (Naam, E-mail, Rol) with default sort on name
- Pagination (10 per page)
- Avatar with initials, role chips with color mapping
- Consistent empty state with icon

Platform page: replaced VTable with VDataTable, added role chips
(replacing inline AppSelect), role editing via menu on edit button.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-15 01:22:01 +02:00
parent ca275723db
commit 66e4167c03
2 changed files with 215 additions and 112 deletions

View File

@@ -95,18 +95,22 @@ const roleLabelMap: Record<string, string> = {
volunteer_coordinator: 'Vrijwilliger Coördinator',
}
const memberSearch = ref('')
const memberHeaders = computed(() => {
const headers: Array<{ title: string; key: string; sortable?: boolean }> = [
{ title: 'Naam', key: 'full_name' },
{ title: 'E-mailadres', key: 'email' },
{ title: 'Rol', key: 'role' },
const headers: Array<{ title: string; key: string; sortable?: boolean; align?: 'start' | 'end' }> = [
{ title: 'Naam', key: 'full_name', sortable: true },
{ title: 'E-mailadres', key: 'email', sortable: true },
{ title: 'Rol', key: 'role', sortable: true },
]
if (isOrgAdmin.value) {
headers.push({ title: 'Acties', key: 'actions', sortable: false })
headers.push({ title: 'Acties', key: 'actions', sortable: false, align: 'end' })
}
return headers
})
const memberSortBy = [{ key: 'full_name', order: 'asc' as const }]
function getInitials(name: string): string {
return name
.split(' ')
@@ -328,32 +332,43 @@ function confirmRevokeInvitation() {
</VAlert>
<template v-else>
<!-- Invite button -->
<div
v-if="isOrgAdmin"
class="d-flex justify-end mb-4"
>
<VBtn
prepend-icon="tabler-user-plus"
@click="isInviteDialogOpen = true"
>
Lid uitnodigen
</VBtn>
</div>
<!-- Members table -->
<VCard class="mb-6">
<VCardText v-if="members.length === 0">
<p class="text-body-1 text-disabled mb-0">
Nog geen leden
</p>
<!-- Search + Invite -->
<VCardText>
<VRow>
<VCol
cols="12"
md="6"
>
<AppTextField
v-model="memberSearch"
placeholder="Zoek op naam of e-mail..."
prepend-inner-icon="tabler-search"
clearable
/>
</VCol>
<VCol
v-if="isOrgAdmin"
cols="12"
md="6"
class="d-flex justify-end"
>
<VBtn
prepend-icon="tabler-user-plus"
@click="isInviteDialogOpen = true"
>
Lid uitnodigen
</VBtn>
</VCol>
</VRow>
</VCardText>
<VDataTable
v-else
:headers="memberHeaders"
:items="members"
:items-per-page="-1"
hide-default-footer
:search="memberSearch"
:sort-by="memberSortBy"
:items-per-page="10"
>
<template #item.full_name="{ item }">
<div class="d-flex align-center gap-x-3">
@@ -384,7 +399,7 @@ function confirmRevokeInvitation() {
</template>
<template #item.actions="{ item }">
<div class="d-flex gap-x-1">
<div class="d-flex gap-x-1 justify-end">
<VBtn
icon="tabler-edit"
variant="text"
@@ -406,6 +421,17 @@ function confirmRevokeInvitation() {
/>
</div>
</template>
<template #no-data>
<div class="text-center pa-4 text-disabled">
<VIcon
icon="tabler-users-minus"
size="48"
class="mb-2"
/>
<p>Geen leden gevonden</p>
</div>
</template>
</VDataTable>
</VCard>

View File

@@ -48,6 +48,46 @@ const roleOptions = [
{ title: 'Lid', value: 'org_member' },
]
const roleColorMap: Record<string, string> = {
org_admin: 'purple',
org_member: 'info',
event_manager: 'cyan',
staff_coordinator: 'orange',
volunteer_coordinator: 'success',
}
const roleLabelMap: Record<string, string> = {
org_admin: 'Organisatie Beheerder',
org_member: 'Organisatie Lid',
event_manager: 'Evenement Manager',
staff_coordinator: 'Staf Coördinator',
volunteer_coordinator: 'Vrijwilliger Coördinator',
}
const memberSearch = ref('')
const memberHeaders = computed(() => {
const headers: Array<{ title: string; key: string; sortable?: boolean; align?: 'start' | 'end' }> = [
{ title: 'Naam', key: 'full_name', sortable: true },
{ title: 'E-mailadres', key: 'email', sortable: true },
{ title: 'Rol', key: 'role', sortable: true },
{ title: 'Acties', key: 'actions', sortable: false, align: 'end' },
]
return headers
})
const memberSortBy = [{ key: 'full_name', order: 'asc' as const }]
function getInitials(name: string): string {
return name
.split(' ')
.map(p => p[0])
.filter(Boolean)
.slice(0, 2)
.join('')
.toUpperCase()
}
// ─── Tabs ──────────────────────────────────────────────────
const tabs = [
{ value: 'algemeen', label: 'Algemeen', icon: 'tabler-building' },
@@ -424,94 +464,131 @@ function formatDate(iso: string): string {
<!-- Leden tab -->
<VWindowItem value="leden">
<div class="d-flex justify-end mb-4">
<VBtn
prepend-icon="tabler-user-plus"
@click="openInviteDialog"
>
Lid uitnodigen
</VBtn>
</div>
<VCard>
<VTable v-if="members.length > 0">
<thead>
<tr>
<th>Naam</th>
<th>Email</th>
<th>Rol</th>
<th class="text-end">
Acties
</th>
</tr>
</thead>
<tbody>
<tr
v-for="member in members"
:key="member.id"
<!-- Search + Invite -->
<VCardText>
<VRow>
<VCol
cols="12"
md="6"
>
<td>
<div class="d-flex align-center gap-x-3 py-2">
<VAvatar
size="34"
:color="member.avatar ? undefined : 'primary'"
variant="tonal"
>
<VImg
v-if="member.avatar"
:src="member.avatar"
/>
<span v-else>{{ member.first_name.charAt(0) }}{{ member.last_name.charAt(0) }}</span>
</VAvatar>
<span>{{ member.full_name }}</span>
</div>
</td>
<td>{{ member.email }}</td>
<td>
<AppSelect
:model-value="member.role"
:items="roleOptions"
density="compact"
hide-details
style="max-inline-size: 160px;"
@update:model-value="(val: string) => onRoleChange(member.id, val)"
/>
</td>
<td class="text-end">
<VTooltip
v-if="member.id === authStore.user?.id"
location="top"
>
<template #activator="{ props }">
<span v-bind="props">
<IconBtn
disabled
size="small"
>
<VIcon icon="tabler-trash" />
</IconBtn>
</span>
</template>
Je kunt jezelf niet verwijderen
</VTooltip>
<IconBtn
v-else
size="small"
color="error"
@click="openRemoveDialog(member)"
>
<VIcon icon="tabler-trash" />
</IconBtn>
</td>
</tr>
</tbody>
</VTable>
<VCardText
v-else
class="text-center text-disabled"
>
Geen leden gevonden.
<AppTextField
v-model="memberSearch"
placeholder="Zoek op naam of e-mail..."
prepend-inner-icon="tabler-search"
clearable
/>
</VCol>
<VCol
cols="12"
md="6"
class="d-flex justify-end"
>
<VBtn
prepend-icon="tabler-user-plus"
@click="openInviteDialog"
>
Lid uitnodigen
</VBtn>
</VCol>
</VRow>
</VCardText>
<VDataTable
:headers="memberHeaders"
:items="members"
:search="memberSearch"
:sort-by="memberSortBy"
:items-per-page="10"
>
<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.role="{ item }">
<VChip
:color="roleColorMap[item.role] ?? 'default'"
size="small"
>
{{ roleLabelMap[item.role] ?? item.role }}
</VChip>
</template>
<template #item.actions="{ item }">
<div class="d-flex gap-x-1 justify-end">
<VMenu location="bottom end">
<template #activator="{ props: menuProps }">
<VBtn
icon="tabler-edit"
variant="text"
size="small"
v-bind="menuProps"
/>
</template>
<VList density="compact">
<VListItem
v-for="opt in roleOptions"
:key="opt.value"
:active="item.role === opt.value"
@click="onRoleChange(item.id, opt.value)"
>
<VListItemTitle>{{ opt.title }}</VListItemTitle>
</VListItem>
</VList>
</VMenu>
<VTooltip
v-if="item.id === authStore.user?.id"
location="top"
>
<template #activator="{ props }">
<span v-bind="props">
<VBtn
icon="tabler-trash"
variant="text"
size="small"
disabled
/>
</span>
</template>
Je kunt jezelf niet verwijderen
</VTooltip>
<VBtn
v-else
icon="tabler-trash"
variant="text"
size="small"
color="error"
@click="openRemoveDialog(item)"
/>
</div>
</template>
<template #no-data>
<div class="text-center pa-4 text-disabled">
<VIcon
icon="tabler-users-minus"
size="48"
class="mb-2"
/>
<p>Geen leden gevonden</p>
</div>
</template>
</VDataTable>
</VCard>
</VWindowItem>