refactor: organisation settings — vertical sidebar layout with grouped sections

Replace horizontal tabs with VList-based vertical sidebar following the
Vuexy ecommerce settings pattern. Consolidate Tags, Crowd Types, Members,
and Registration Fields pages into the settings page as sidebar tabs.
Add SettingsGeneral panel with org details form and danger zone.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-16 02:10:50 +02:00
parent 50e2c31dd9
commit 47cb6b83d4
16 changed files with 997 additions and 592 deletions

View File

@@ -1,14 +0,0 @@
<script setup lang="ts">
import { useOrganisationStore } from '@/stores/useOrganisationStore'
import CrowdTypesManager from '@/components/organisations/CrowdTypesManager.vue'
const orgStore = useOrganisationStore()
const orgId = computed(() => orgStore.activeOrganisationId ?? '')
</script>
<template>
<div>
<CrowdTypesManager :org-id="orgId" />
</div>
</template>

View File

@@ -1,494 +0,0 @@
<script setup lang="ts">
import { useMemberList, useRemoveMember, useRevokeInvitation } from '@/composables/api/useMembers'
import { useAdminChangeEmail } from '@/composables/api/useAccount'
import { useAuthStore } from '@/stores/useAuthStore'
import { useOrganisationStore } from '@/stores/useOrganisationStore'
import InviteMemberDialog from '@/components/members/InviteMemberDialog.vue'
import EditMemberRoleDialog from '@/components/members/EditMemberRoleDialog.vue'
import type { Member } from '@/types/member'
const authStore = useAuthStore()
const orgStore = useOrganisationStore()
const orgId = computed(() => orgStore.activeOrganisationId ?? '')
const orgName = computed(() => authStore.currentOrganisation?.name ?? '')
const isOrgAdmin = computed(() => {
const role = authStore.currentOrganisation?.role
return role === 'org_admin' || authStore.isSuperAdmin
})
const { data: memberData, isLoading, isError, refetch } = useMemberList(orgId)
const members = computed(() => memberData.value?.data ?? [])
const pendingInvitations = computed(() => memberData.value?.meta?.pending_invitations ?? [])
// Invite dialog
const isInviteDialogOpen = ref(false)
// Edit role dialog
const isEditRoleDialogOpen = ref(false)
const selectedMember = ref<Member | null>(null)
// Remove member
const isRemoveDialogOpen = ref(false)
const memberToRemove = ref<Member | null>(null)
const { mutate: removeMember, isPending: isRemoving } = useRemoveMember(orgId)
// Revoke invitation
const isRevokeDialogOpen = ref(false)
const invitationToRevoke = ref<{ id: string; email: string } | null>(null)
const { mutate: revokeInvitation, isPending: isRevoking } = useRevokeInvitation(orgId)
// Change email
const isEmailChangeDialogOpen = ref(false)
const memberToChangeEmail = ref<Member | null>(null)
const newMemberEmail = ref('')
const adminEmailErrors = ref<Record<string, string>>({})
const showEmailChangeSuccess = ref(false)
const { mutate: adminChangeEmail, isPending: isChangingMemberEmail } = useAdminChangeEmail(orgId)
const showRemoveSuccess = ref(false)
const showRevokeSuccess = ref(false)
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 },
]
if (isOrgAdmin.value) {
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(' ')
.map(p => p[0])
.filter(Boolean)
.slice(0, 2)
.join('')
.toUpperCase()
}
function formatDate(iso: string): 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`
}
function openEditRole(member: Member) {
selectedMember.value = member
isEditRoleDialogOpen.value = true
}
function openRemoveDialog(member: Member) {
memberToRemove.value = member
isRemoveDialogOpen.value = true
}
function confirmRemoveMember() {
if (!memberToRemove.value) return
removeMember(memberToRemove.value.id, {
onSuccess: () => {
isRemoveDialogOpen.value = false
memberToRemove.value = null
showRemoveSuccess.value = true
},
})
}
function openRevokeDialog(invitation: { id: string; email: string }) {
invitationToRevoke.value = invitation
isRevokeDialogOpen.value = true
}
function openEmailChangeDialog(member: Member) {
memberToChangeEmail.value = member
newMemberEmail.value = ''
adminEmailErrors.value = {}
isEmailChangeDialogOpen.value = true
}
function confirmEmailChange() {
if (!memberToChangeEmail.value) return
adminEmailErrors.value = {}
adminChangeEmail(
{ userId: memberToChangeEmail.value.id, newEmail: newMemberEmail.value },
{
onSuccess: () => {
isEmailChangeDialogOpen.value = false
memberToChangeEmail.value = null
newMemberEmail.value = ''
showEmailChangeSuccess.value = true
},
onError: (err: unknown) => {
const ax = err as { response?: { data?: { errors?: Record<string, string[]> } } }
if (ax.response?.data?.errors) {
for (const [key, messages] of Object.entries(ax.response.data.errors)) {
adminEmailErrors.value[key] = messages[0]
}
}
},
},
)
}
function confirmRevokeInvitation() {
if (!invitationToRevoke.value) return
revokeInvitation(invitationToRevoke.value.id, {
onSuccess: () => {
isRevokeDialogOpen.value = false
invitationToRevoke.value = null
showRevokeSuccess.value = true
},
})
}
</script>
<template>
<div>
<!-- Loading -->
<VSkeletonLoader
v-if="isLoading"
type="card"
/>
<!-- Error -->
<VAlert
v-else-if="isError"
type="error"
class="mb-4"
>
Kon leden niet laden.
<template #append>
<VBtn
variant="text"
@click="refetch()"
>
Opnieuw proberen
</VBtn>
</template>
</VAlert>
<template v-else>
<!-- Header -->
<div class="mb-6">
<h4 class="text-h4">
Leden
</h4>
<p class="text-body-1 text-disabled mb-0">
{{ orgName }}
</p>
</div>
<!-- Members table -->
<VCard class="mb-6">
<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 align-center"
>
<VBtn
prepend-icon="tabler-user-plus"
@click="isInviteDialogOpen = true"
>
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">
<VBtn
icon="tabler-edit"
variant="text"
size="small"
@click="openEditRole(item)"
/>
<VBtn
icon="tabler-mail-forward"
variant="text"
size="small"
@click="openEmailChangeDialog(item)"
/>
<VBtn
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>
<!-- Pending invitations -->
<VCard
v-if="isOrgAdmin && pendingInvitations.length > 0"
>
<VCardTitle>Openstaande uitnodigingen</VCardTitle>
<VList>
<VListItem
v-for="invitation in pendingInvitations"
:key="invitation.id"
>
<template #prepend>
<VAvatar
color="warning"
variant="tonal"
size="34"
>
<VIcon icon="tabler-mail" />
</VAvatar>
</template>
<VListItemTitle>
{{ invitation.email }}
<VChip
:color="roleColorMap[invitation.role] ?? 'default'"
size="x-small"
class="ms-2"
>
{{ roleLabelMap[invitation.role] ?? invitation.role }}
</VChip>
</VListItemTitle>
<VListItemSubtitle>
Verloopt op {{ formatDate(invitation.expires_at) }}
</VListItemSubtitle>
<template #append>
<VBtn
variant="text"
color="error"
size="small"
@click="openRevokeDialog(invitation)"
>
Intrekken
</VBtn>
</template>
</VListItem>
</VList>
</VCard>
</template>
<!-- Dialogs -->
<InviteMemberDialog
v-if="isOrgAdmin"
v-model="isInviteDialogOpen"
:org-id="orgId"
/>
<EditMemberRoleDialog
v-if="selectedMember"
v-model="isEditRoleDialogOpen"
:org-id="orgId"
:member="selectedMember"
/>
<!-- Remove member confirmation -->
<VDialog
v-model="isRemoveDialogOpen"
max-width="400"
>
<VCard title="Lid verwijderen">
<VCardText>
Weet je zeker dat je <strong>{{ memberToRemove?.full_name }}</strong> wilt verwijderen uit de organisatie?
</VCardText>
<VCardActions>
<VSpacer />
<VBtn
variant="text"
@click="isRemoveDialogOpen = false"
>
Annuleren
</VBtn>
<VBtn
color="error"
:loading="isRemoving"
@click="confirmRemoveMember"
>
Verwijderen
</VBtn>
</VCardActions>
</VCard>
</VDialog>
<!-- Revoke invitation confirmation -->
<VDialog
v-model="isRevokeDialogOpen"
max-width="400"
>
<VCard title="Uitnodiging intrekken">
<VCardText>
Weet je zeker dat je de uitnodiging voor <strong>{{ invitationToRevoke?.email }}</strong> wilt intrekken?
</VCardText>
<VCardActions>
<VSpacer />
<VBtn
variant="text"
@click="isRevokeDialogOpen = false"
>
Annuleren
</VBtn>
<VBtn
color="error"
:loading="isRevoking"
@click="confirmRevokeInvitation"
>
Intrekken
</VBtn>
</VCardActions>
</VCard>
</VDialog>
<!-- Change email dialog -->
<VDialog
v-model="isEmailChangeDialogOpen"
max-width="420"
>
<VCard title="E-mailadres wijzigen">
<VCardText>
<p class="text-body-2 text-medium-emphasis mb-4">
Wijzig het e-mailadres van <strong>{{ memberToChangeEmail?.full_name }}</strong>.
Er wordt een verificatiemail verstuurd naar het nieuwe adres.
</p>
<AppTextField
v-model="newMemberEmail"
label="Nieuw e-mailadres"
type="email"
:error-messages="adminEmailErrors.new_email"
/>
</VCardText>
<VCardActions>
<VSpacer />
<VBtn
variant="tonal"
@click="isEmailChangeDialogOpen = false"
>
Annuleren
</VBtn>
<VBtn
color="primary"
:loading="isChangingMemberEmail"
:disabled="!newMemberEmail"
@click="confirmEmailChange"
>
Verificatiemail versturen
</VBtn>
</VCardActions>
</VCard>
</VDialog>
<!-- Success snackbars -->
<VSnackbar
v-model="showRemoveSuccess"
color="success"
:timeout="3000"
>
Lid verwijderd
</VSnackbar>
<VSnackbar
v-model="showRevokeSuccess"
color="success"
:timeout="3000"
>
Uitnodiging ingetrokken
</VSnackbar>
<VSnackbar
v-model="showEmailChangeSuccess"
color="success"
:timeout="4000"
>
Verificatiemail verstuurd
</VSnackbar>
</div>
</template>

View File

@@ -1,9 +1,13 @@
<script setup lang="ts">
import { useOrganisationStore } from '@/stores/useOrganisationStore'
import RegistrationFieldTemplatesTab from '@/components/organisation/RegistrationFieldTemplatesTab.vue'
import EmailBrandingTab from '@/components/organisation/EmailBrandingTab.vue'
import EmailTemplatesTab from '@/components/organisation/EmailTemplatesTab.vue'
import EmailLogTab from '@/components/organisation/EmailLogTab.vue'
import SettingsGeneral from '@/components/organisation/settings/SettingsGeneral.vue'
import SettingsMembers from '@/components/organisation/settings/SettingsMembers.vue'
import SettingsCrowdTypes from '@/components/organisation/settings/SettingsCrowdTypes.vue'
import SettingsTags from '@/components/organisation/settings/SettingsTags.vue'
import SettingsRegistrationFields from '@/components/organisation/settings/SettingsRegistrationFields.vue'
import SettingsEmailBranding from '@/components/organisation/settings/SettingsEmailBranding.vue'
import SettingsEmailTemplates from '@/components/organisation/settings/SettingsEmailTemplates.vue'
import SettingsEmailLog from '@/components/organisation/settings/SettingsEmailLog.vue'
const route = useRoute()
const router = useRouter()
@@ -11,67 +15,128 @@ const orgStore = useOrganisationStore()
const orgId = computed(() => orgStore.activeOrganisationId ?? '')
const tabs = [
{ value: 'templates', label: 'Registratieveld-templates', icon: 'tabler-forms' },
{ value: 'email-branding', label: 'E-mail opmaak', icon: 'tabler-mail-cog' },
{ value: 'email-templates', label: 'E-mail templates', icon: 'tabler-template' },
{ value: 'email-log', label: 'E-mail log', icon: 'tabler-mail-search' },
interface SettingsItem {
icon: string
title: string
key: string
}
interface SettingsGroup {
label: string
items: SettingsItem[]
}
const settingsGroups: SettingsGroup[] = [
{
label: 'Organisatie',
items: [
{ icon: 'tabler-building', title: 'Algemeen', key: 'general' },
{ icon: 'tabler-users-group', title: 'Leden', key: 'members' },
],
},
{
label: 'Configuratie',
items: [
{ icon: 'tabler-category', title: 'Crowd Types', key: 'crowd-types' },
{ icon: 'tabler-tags', title: 'Tags & Vaardigheden', key: 'tags' },
{ icon: 'tabler-forms', title: 'Registratievelden', key: 'registration-fields' },
],
},
{
label: 'E-mail',
items: [
{ icon: 'tabler-palette', title: 'Opmaak & Branding', key: 'email-branding' },
{ icon: 'tabler-template', title: 'Templates', key: 'email-templates' },
{ icon: 'tabler-mail-search', title: 'Verzendlog', key: 'email-log' },
],
},
]
const activeTab = computed({
const allKeys = settingsGroups.flatMap(g => g.items.map(i => i.key))
const activeKey = computed({
get: () => {
const tab = route.query.tab as string
return tabs.some(t => t.value === tab) ? tab : 'templates'
return allKeys.includes(tab) ? tab : 'general'
},
set: (value: string) => {
router.replace({ query: { ...route.query, tab: value } })
set: (key: string) => {
router.replace({ query: { tab: key } })
},
})
const componentMap: Record<string, Component> = {
'general': SettingsGeneral,
'members': SettingsMembers,
'crowd-types': SettingsCrowdTypes,
'tags': SettingsTags,
'registration-fields': SettingsRegistrationFields,
'email-branding': SettingsEmailBranding,
'email-templates': SettingsEmailTemplates,
'email-log': SettingsEmailLog,
}
const activeComponent = computed(() => componentMap[activeKey.value] ?? SettingsGeneral)
</script>
<template>
<div>
<div class="d-flex align-center mb-6">
<div>
<h4 class="text-h4">
Instellingen
</h4>
<p class="text-body-1 text-disabled mb-0">
Organisatie-instellingen en configuratie
</p>
</div>
</div>
<h4 class="text-h4 mb-1">
Instellingen
</h4>
<p class="text-body-1 text-medium-emphasis mb-6">
Beheer de configuratie van je organisatie
</p>
<VTabs
v-model="activeTab"
class="mb-6"
>
<VTab
v-for="tab in tabs"
:key="tab.value"
:value="tab.value"
:prepend-icon="tab.icon"
<VRow>
<!-- Sidebar -->
<VCol
cols="12"
md="4"
lg="3"
>
{{ tab.label }}
</VTab>
</VTabs>
<VCard>
<VList
density="compact"
nav
class="py-0"
>
<template
v-for="(group, gi) in settingsGroups"
:key="gi"
>
<div
class="text-caption text-uppercase text-medium-emphasis font-weight-medium px-4 mt-4 mb-1"
:class="{ 'mt-2': gi === 0 }"
>
{{ group.label }}
</div>
<VListItem
v-for="item in group.items"
:key="item.key"
:prepend-icon="item.icon"
:title="item.title"
:active="activeKey === item.key"
:value="item.key"
rounded="pill"
@click="activeKey = item.key"
/>
</template>
<div class="mb-2" />
</VList>
</VCard>
</VCol>
<VWindow
v-model="activeTab"
class="disable-tab-transition"
>
<VWindowItem value="templates">
<RegistrationFieldTemplatesTab :org-id="orgId" />
</VWindowItem>
<VWindowItem value="email-branding">
<EmailBrandingTab :org-id="orgId" />
</VWindowItem>
<VWindowItem value="email-templates">
<EmailTemplatesTab :org-id="orgId" />
</VWindowItem>
<VWindowItem value="email-log">
<EmailLogTab :org-id="orgId" />
</VWindowItem>
</VWindow>
<!-- Content -->
<VCol
cols="12"
md="8"
lg="9"
>
<component
:is="activeComponent"
:org-id="orgId"
/>
</VCol>
</VRow>
</div>
</template>

View File

@@ -1,14 +0,0 @@
<script setup lang="ts">
import { useOrganisationStore } from '@/stores/useOrganisationStore'
import PersonTagsTab from '@/components/organisation/PersonTagsTab.vue'
const orgStore = useOrganisationStore()
const orgId = computed(() => orgStore.activeOrganisationId ?? '')
</script>
<template>
<div>
<PersonTagsTab :org-id="orgId" />
</div>
</template>