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

@@ -91,6 +91,14 @@ declare module 'vue' {
ScrollToTop: typeof import('./src/@core/components/ScrollToTop.vue')['default']
SectionsShiftsPanel: typeof import('./src/components/sections/SectionsShiftsPanel.vue')['default']
SecurityTab: typeof import('./src/components/account-settings/SecurityTab.vue')['default']
SettingsCrowdTypes: typeof import('./src/components/organisation/settings/SettingsCrowdTypes.vue')['default']
SettingsEmailBranding: typeof import('./src/components/organisation/settings/SettingsEmailBranding.vue')['default']
SettingsEmailLog: typeof import('./src/components/organisation/settings/SettingsEmailLog.vue')['default']
SettingsEmailTemplates: typeof import('./src/components/organisation/settings/SettingsEmailTemplates.vue')['default']
SettingsGeneral: typeof import('./src/components/organisation/settings/SettingsGeneral.vue')['default']
SettingsMembers: typeof import('./src/components/organisation/settings/SettingsMembers.vue')['default']
SettingsRegistrationFields: typeof import('./src/components/organisation/settings/SettingsRegistrationFields.vue')['default']
SettingsTags: typeof import('./src/components/organisation/settings/SettingsTags.vue')['default']
ShareProjectDialog: typeof import('./src/components/dialogs/ShareProjectDialog.vue')['default']
ShiftDetailPanel: typeof import('./src/components/shifts/ShiftDetailPanel.vue')['default']
Shortcuts: typeof import('./src/@core/components/Shortcuts.vue')['default']

View File

@@ -0,0 +1,11 @@
<script setup lang="ts">
import CrowdTypesManager from '@/components/organisations/CrowdTypesManager.vue'
defineProps<{
orgId: string
}>()
</script>
<template>
<CrowdTypesManager :org-id="orgId" />
</template>

View File

@@ -0,0 +1,11 @@
<script setup lang="ts">
import EmailBrandingTab from '@/components/organisation/EmailBrandingTab.vue'
defineProps<{
orgId: string
}>()
</script>
<template>
<EmailBrandingTab :org-id="orgId" />
</template>

View File

@@ -0,0 +1,11 @@
<script setup lang="ts">
import EmailLogTab from '@/components/organisation/EmailLogTab.vue'
defineProps<{
orgId: string
}>()
</script>
<template>
<EmailLogTab :org-id="orgId" />
</template>

View File

@@ -0,0 +1,11 @@
<script setup lang="ts">
import EmailTemplatesTab from '@/components/organisation/EmailTemplatesTab.vue'
defineProps<{
orgId: string
}>()
</script>
<template>
<EmailTemplatesTab :org-id="orgId" />
</template>

View File

@@ -0,0 +1,321 @@
<script setup lang="ts">
import { VForm } from 'vuetify/components/VForm'
import { useMyOrganisation, useUpdateOrganisation } from '@/composables/api/useOrganisations'
import { useAuthStore } from '@/stores/useAuthStore'
import { requiredValidator } from '@core/utils/validators'
import type { AxiosError } from 'axios'
import type { ApiErrorResponse } from '@/types/auth'
defineProps<{
orgId: string
}>()
const authStore = useAuthStore()
const isOrgAdmin = computed(() => {
const role = authStore.currentOrganisation?.role
return role === 'org_admin' || authStore.isSuperAdmin
})
const { data: organisation, isLoading, isError, refetch } = useMyOrganisation()
const { mutate: updateOrganisation, isPending } = useUpdateOrganisation()
const name = ref('')
const errors = ref<Record<string, string>>({})
const refVForm = ref<VForm>()
const showSuccess = ref(false)
const showCopied = ref(false)
const isDeleteDialogOpen = ref(false)
const showDeleteNotice = ref(false)
watch(() => organisation.value, (org) => {
if (org) {
name.value = org.name
}
}, { immediate: true })
function onSubmit() {
refVForm.value?.validate().then(({ valid }) => {
if (!valid || !organisation.value) return
errors.value = {}
updateOrganisation(
{ id: organisation.value.id, name: name.value },
{
onSuccess: () => {
showSuccess.value = true
},
onError: (err: Error) => {
const data = (err as AxiosError<ApiErrorResponse>).response?.data
if (data?.errors) {
errors.value = { name: data.errors.name?.[0] ?? '' }
}
else if (data?.message) {
errors.value = { name: data.message }
}
},
},
)
})
}
function copySlug() {
if (organisation.value?.slug) {
navigator.clipboard.writeText(organisation.value.slug)
showCopied.value = true
}
}
</script>
<template>
<!-- Loading -->
<VSkeletonLoader
v-if="isLoading"
type="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="organisation">
<!-- Organisation details -->
<VCard
title="Organisatiegegevens"
class="mb-6"
>
<VCardText>
<VForm
ref="refVForm"
@submit.prevent="onSubmit"
>
<VRow>
<VCol
cols="12"
md="6"
>
<AppTextField
v-model="name"
label="Organisatienaam"
:rules="[requiredValidator]"
:error-messages="errors.name"
:readonly="!isOrgAdmin"
/>
</VCol>
<VCol
cols="12"
md="6"
>
<AppTextField
:model-value="organisation.slug"
label="Slug"
readonly
>
<template #append-inner>
<VBtn
icon="tabler-copy"
variant="text"
size="x-small"
@click="copySlug"
/>
</template>
</AppTextField>
</VCol>
<VCol
cols="12"
md="6"
>
<AppTextField
label="Contactpersoon"
disabled
>
<template #append-inner>
<VTooltip text="Binnenkort beschikbaar">
<template #activator="{ props: tooltipProps }">
<VIcon
v-bind="tooltipProps"
icon="tabler-info-circle"
size="18"
color="disabled"
/>
</template>
</VTooltip>
</template>
</AppTextField>
</VCol>
<VCol
cols="12"
md="6"
>
<AppTextField
label="Contact e-mail"
disabled
>
<template #append-inner>
<VTooltip text="Binnenkort beschikbaar">
<template #activator="{ props: tooltipProps }">
<VIcon
v-bind="tooltipProps"
icon="tabler-info-circle"
size="18"
color="disabled"
/>
</template>
</VTooltip>
</template>
</AppTextField>
</VCol>
<VCol
cols="12"
md="6"
>
<AppTextField
label="Telefoon"
disabled
>
<template #append-inner>
<VTooltip text="Binnenkort beschikbaar">
<template #activator="{ props: tooltipProps }">
<VIcon
v-bind="tooltipProps"
icon="tabler-info-circle"
size="18"
color="disabled"
/>
</template>
</VTooltip>
</template>
</AppTextField>
</VCol>
<VCol
cols="12"
md="6"
>
<AppTextField
label="Website"
disabled
>
<template #append-inner>
<VTooltip text="Binnenkort beschikbaar">
<template #activator="{ props: tooltipProps }">
<VIcon
v-bind="tooltipProps"
icon="tabler-info-circle"
size="18"
color="disabled"
/>
</template>
</VTooltip>
</template>
</AppTextField>
</VCol>
</VRow>
<div
v-if="isOrgAdmin"
class="d-flex justify-end mt-4"
>
<VBtn
type="submit"
:loading="isPending"
>
Wijzigingen opslaan
</VBtn>
</div>
</VForm>
</VCardText>
</VCard>
<!-- Danger zone -->
<VCard
v-if="isOrgAdmin"
class="border-error"
>
<VCardText>
<div class="d-flex align-center justify-space-between">
<div>
<h6 class="text-h6 text-error">
Organisatie verwijderen
</h6>
<p class="text-body-2 text-medium-emphasis mb-0">
Verwijder deze organisatie en alle bijbehorende gegevens permanent. Deze actie kan niet ongedaan worden gemaakt.
</p>
</div>
<VBtn
color="error"
variant="tonal"
class="ms-4"
@click="isDeleteDialogOpen = true"
>
Verwijderen
</VBtn>
</div>
</VCardText>
</VCard>
</template>
<!-- Delete confirmation dialog -->
<VDialog
v-model="isDeleteDialogOpen"
max-width="440"
>
<VCard title="Organisatie verwijderen">
<VCardText class="text-body-1">
Het verwijderen van een organisatie is momenteel niet mogelijk via de applicatie.
Neem contact op met de platform beheerder.
</VCardText>
<VCardActions>
<VSpacer />
<VBtn
variant="text"
@click="isDeleteDialogOpen = false"
>
Sluiten
</VBtn>
<VBtn
color="error"
@click="isDeleteDialogOpen = false; showDeleteNotice = true"
>
Begrepen
</VBtn>
</VCardActions>
</VCard>
</VDialog>
<!-- Snackbars -->
<VSnackbar
v-model="showSuccess"
color="success"
:timeout="3000"
>
Organisatie bijgewerkt
</VSnackbar>
<VSnackbar
v-model="showCopied"
color="info"
:timeout="2000"
>
Slug gekopieerd
</VSnackbar>
<VSnackbar
v-model="showDeleteNotice"
color="info"
:timeout="4000"
>
Neem contact op met de platform beheerder
</VSnackbar>
</template>

View File

@@ -0,0 +1,484 @@
<script setup lang="ts">
import { useMemberList, useRemoveMember, useRevokeInvitation } from '@/composables/api/useMembers'
import { useAdminChangeEmail } from '@/composables/api/useAccount'
import { useAuthStore } from '@/stores/useAuthStore'
import InviteMemberDialog from '@/components/members/InviteMemberDialog.vue'
import EditMemberRoleDialog from '@/components/members/EditMemberRoleDialog.vue'
import type { Member } from '@/types/member'
const props = defineProps<{
orgId: string
}>()
const authStore = useAuthStore()
const orgIdRef = computed(() => props.orgId)
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(orgIdRef)
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(orgIdRef)
// Revoke invitation
const isRevokeDialogOpen = ref(false)
const invitationToRevoke = ref<{ id: string; email: string } | null>(null)
const { mutate: revokeInvitation, isPending: isRevoking } = useRevokeInvitation(orgIdRef)
// 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(orgIdRef)
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>
<!-- 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>
<!-- 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>
</template>

View File

@@ -0,0 +1,11 @@
<script setup lang="ts">
import RegistrationFieldTemplatesTab from '@/components/organisation/RegistrationFieldTemplatesTab.vue'
defineProps<{
orgId: string
}>()
</script>
<template>
<RegistrationFieldTemplatesTab :org-id="orgId" />
</template>

View File

@@ -0,0 +1,11 @@
<script setup lang="ts">
import PersonTagsTab from '@/components/organisation/PersonTagsTab.vue'
defineProps<{
orgId: string
}>()
</script>
<template>
<PersonTagsTab :org-id="orgId" />
</template>

View File

@@ -17,26 +17,11 @@ export const orgNavItems = [
to: { name: 'organisation' },
icon: { icon: 'tabler-building' },
},
{
title: 'Leden',
to: { name: 'organisation-members' },
icon: { icon: 'tabler-users' },
},
{
title: 'Bedrijven',
to: { name: 'organisation-companies' },
icon: { icon: 'tabler-building' },
},
{
title: 'Tags & Vaardigheden',
to: { name: 'organisation-tags' },
icon: { icon: 'tabler-tag' },
},
{
title: 'Crowd types',
to: { name: 'organisation-crowd-types' },
icon: { icon: 'tabler-users-group' },
},
{
title: 'Instellingen',
to: { name: 'organisation-settings' },

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>

View File

@@ -10,6 +10,7 @@ export interface Organisation {
email_sender_name: string | null
email_footer_text: string | null
created_at: string
updated_at: string
}
export interface OrganisationMember {

View File

@@ -38,10 +38,7 @@ declare module 'vue-router/auto-routes' {
'login': RouteRecordInfo<'login', '/login', Record<never, never>, Record<never, never>>,
'organisation': RouteRecordInfo<'organisation', '/organisation', Record<never, never>, Record<never, never>>,
'organisation-companies': RouteRecordInfo<'organisation-companies', '/organisation/companies', Record<never, never>, Record<never, never>>,
'organisation-crowd-types': RouteRecordInfo<'organisation-crowd-types', '/organisation/crowd-types', Record<never, never>, Record<never, never>>,
'organisation-members': RouteRecordInfo<'organisation-members', '/organisation/members', Record<never, never>, Record<never, never>>,
'organisation-settings': RouteRecordInfo<'organisation-settings', '/organisation/settings', Record<never, never>, Record<never, never>>,
'organisation-tags': RouteRecordInfo<'organisation-tags', '/organisation/tags', Record<never, never>, Record<never, never>>,
'platform': RouteRecordInfo<'platform', '/platform', Record<never, never>, Record<never, never>>,
'platform-activity-log': RouteRecordInfo<'platform-activity-log', '/platform/activity-log', Record<never, never>, Record<never, never>>,
'platform-organisations': RouteRecordInfo<'platform-organisations', '/platform/organisations', Record<never, never>, Record<never, never>>,