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>
485 lines
13 KiB
Vue
485 lines
13 KiB
Vue
<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>
|