Files
crewli/apps/app/src/pages/platform/organisations/[id].vue
bert.hausmans a8a2bc92d1 feat: refactor organisation pages with tabs, members tab, and danger zone
Organizer org page (/organisation):
- Timestamps moved below title as muted caption
- VTabs with Algemeen (details) and Leden (members) tabs
- Members content embedded from separate page with full functionality:
  invite, edit role, change email, remove, pending invitations

Platform org detail (/platform/organisations/[id]):
- Timestamps moved below title alongside slug
- VTabs with Algemeen and Leden tabs
- Danger zone redesigned: type-to-confirm delete dialog, disabled
  Transfer Ownership button with "Nog niet beschikbaar" tooltip

Navigation:
- Removed standalone "Leden" menu item (now a tab on org page)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 00:59:45 +02:00

760 lines
21 KiB
Vue

<script setup lang="ts">
import {
useAdminOrganisation,
useUpdateAdminOrganisation,
useDeleteAdminOrganisation,
useInviteOrganisationMember,
useRemoveOrganisationMember,
useUpdateOrganisationMemberRole,
} from '@/composables/api/useAdmin'
import { useAuthStore } from '@/stores/useAuthStore'
import { useOrganisationStore } from '@/stores/useOrganisationStore'
import type { BillingStatus, InviteMemberPayload, UpdateAdminOrganisationPayload } from '@/types/admin'
definePage({
meta: {
navActiveLink: 'platform-organisations',
},
})
const route = useRoute()
const router = useRouter()
const authStore = useAuthStore()
const orgStore = useOrganisationStore()
const orgId = computed(() => String((route.params as { id: string }).id))
const { data: orgData, isLoading, isError, refetch } = useAdminOrganisation(orgId)
const org = computed(() => orgData.value?.organisation)
const members = computed(() => orgData.value?.members ?? [])
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' },
]
const roleOptions = [
{ title: 'Admin', value: 'org_admin' },
{ title: 'Lid', value: 'org_member' },
]
// ─── Tabs ──────────────────────────────────────────────────
const tabs = [
{ value: 'algemeen', label: 'Algemeen', icon: 'tabler-building' },
{ value: 'leden', label: 'Leden', icon: 'tabler-users-group' },
]
const activeTab = computed({
get: () => {
const tab = route.query.tab as string
return tabs.some(t => t.value === tab) ? tab : 'algemeen'
},
set: (value: string) => {
router.replace({ query: { ...route.query, tab: value } })
},
})
// ─── 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
showSnackbar('Organisatie bijgewerkt', 'success')
},
},
)
}
// ─── Delete (type-to-confirm) ──────────────────────────────
const isDeleteDialogOpen = ref(false)
const deleteConfirmName = ref('')
const { mutate: deleteOrg, isPending: isDeleting } = useDeleteAdminOrganisation()
const deleteConfirmValid = computed(() =>
deleteConfirmName.value === org.value?.name,
)
function openDeleteDialog() {
deleteConfirmName.value = ''
isDeleteDialogOpen.value = true
}
function confirmDelete() {
if (!deleteConfirmValid.value) return
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' })
}
// ─── Invite Member ─────────────────────────────────────────
const isInviteDialogOpen = ref(false)
const inviteForm = ref<InviteMemberPayload>({ email: '', role: 'org_member' })
const inviteError = ref('')
const { mutate: inviteMember, isPending: isInviting } = useInviteOrganisationMember()
function openInviteDialog() {
inviteForm.value = { email: '', role: 'org_member' }
inviteError.value = ''
isInviteDialogOpen.value = true
}
function submitInvite() {
inviteError.value = ''
inviteMember(
{ organisationId: orgId.value, payload: inviteForm.value },
{
onSuccess: (data) => {
isInviteDialogOpen.value = false
showSnackbar(data.message ?? 'Uitnodiging verstuurd', 'success')
},
onError: (err: unknown) => {
const error = err as { response?: { data?: { message?: string } } }
inviteError.value = error.response?.data?.message ?? 'Er is een fout opgetreden.'
},
},
)
}
// ─── Remove Member ─────────────────────────────────────────
const isRemoveDialogOpen = ref(false)
const memberToRemove = ref<{ id: string; full_name: string } | null>(null)
const { mutate: removeMember, isPending: isRemoving } = useRemoveOrganisationMember()
function openRemoveDialog(member: { id: string; full_name: string }) {
memberToRemove.value = member
isRemoveDialogOpen.value = true
}
function confirmRemove() {
if (!memberToRemove.value) return
removeMember(
{ organisationId: orgId.value, userId: memberToRemove.value.id },
{
onSuccess: () => {
isRemoveDialogOpen.value = false
memberToRemove.value = null
showSnackbar('Lid verwijderd', 'success')
},
},
)
}
// ─── Update Member Role ────────────────────────────────────
const { mutate: updateMemberRole } = useUpdateOrganisationMemberRole()
function onRoleChange(userId: string, newRole: string) {
updateMemberRole(
{ organisationId: orgId.value, userId, payload: { role: newRole } },
{
onSuccess: () => {
showSnackbar('Rol bijgewerkt', 'success')
},
},
)
}
// ─── Snackbar ──────────────────────────────────────────────
const snackbar = ref({ show: false, message: '', color: 'success' })
function showSnackbar(message: string, color: string) {
snackbar.value = { show: true, message, color }
}
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-2">
<div class="d-flex align-center gap-x-3">
<VBtn
icon="tabler-arrow-left"
variant="text"
size="small"
:to="{ name: 'platform-organisations' }"
/>
<div>
<div class="d-flex align-center gap-x-2">
<h4 class="text-h4">
{{ org.name }}
</h4>
<VChip
:color="billingStatusColor[org.billing_status]"
size="small"
>
{{ org.billing_status_label ?? org.billing_status }}
</VChip>
</div>
<span class="text-caption text-medium-emphasis">
{{ org.slug }}
&middot; Aangemaakt op {{ formatDate(org.created_at) }}
&middot; Gewijzigd op {{ formatDate(org.updated_at) }}
</span>
</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>
<!-- KPI Cards -->
<VRow class="mb-6 mt-4">
<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>
<!-- Tabs -->
<VTabs
v-model="activeTab"
class="mb-6"
>
<VTab
v-for="tab in tabs"
:key="tab.value"
:value="tab.value"
:prepend-icon="tab.icon"
>
{{ tab.label }}
</VTab>
</VTabs>
<VWindow
v-model="activeTab"
class="disable-tab-transition"
>
<!-- Algemeen tab -->
<VWindowItem value="algemeen">
<VCard>
<VCardTitle>Details</VCardTitle>
<VCardText>
<VRow>
<VCol
cols="12"
sm="6"
md="3"
>
<p class="text-body-2 text-disabled mb-1">
Slug
</p>
<p class="text-body-1">
{{ org.slug }}
</p>
</VCol>
<VCol
cols="12"
sm="6"
md="3"
>
<p class="text-body-2 text-disabled mb-1">
Billing status
</p>
<VChip
:color="billingStatusColor[org.billing_status]"
size="small"
>
{{ org.billing_status_label ?? org.billing_status }}
</VChip>
</VCol>
</VRow>
</VCardText>
</VCard>
</VWindowItem>
<!-- 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"
>
<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.
</VCardText>
</VCard>
</VWindowItem>
</VWindow>
<!-- Danger Zone -->
<VCard
variant="outlined"
color="error"
class="mt-6"
>
<VCardTitle>Danger Zone</VCardTitle>
<VCardText>
<div class="d-flex justify-space-between align-center mb-4">
<div>
<p class="text-subtitle-1 font-weight-medium mb-1">
Verwijder organisatie
</p>
<p class="text-body-2 text-medium-emphasis mb-0">
Alle gegevens van deze organisatie worden permanent verwijderd.
Deze actie kan niet ongedaan worden gemaakt.
</p>
</div>
<VBtn
color="error"
variant="outlined"
class="ms-4 flex-shrink-0"
@click="openDeleteDialog"
>
Verwijder
</VBtn>
</div>
<VDivider class="mb-4" />
<div class="d-flex justify-space-between align-center">
<div>
<p class="text-subtitle-1 font-weight-medium mb-1">
Transfer Ownership
</p>
<p class="text-body-2 text-medium-emphasis mb-0">
Draag het eigenaarschap van deze organisatie over aan een andere gebruiker.
</p>
</div>
<VTooltip location="top">
<template #activator="{ props }">
<span v-bind="props">
<VBtn
color="error"
variant="outlined"
class="ms-4 flex-shrink-0"
disabled
>
Transfer
</VBtn>
</span>
</template>
Nog niet beschikbaar
</VTooltip>
</div>
</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 (type-to-confirm) -->
<VDialog
v-model="isDeleteDialogOpen"
max-width="480"
>
<VCard title="Organisatie verwijderen">
<VCardText>
<VAlert
type="error"
variant="tonal"
class="mb-4"
>
Deze actie kan niet ongedaan worden gemaakt. Alle gegevens van
deze organisatie worden permanent verwijderd.
</VAlert>
<p class="text-body-2 mb-2">
Typ <strong>{{ org?.name }}</strong> om te bevestigen:
</p>
<AppTextField
v-model="deleteConfirmName"
placeholder="Organisatienaam"
/>
</VCardText>
<VCardActions>
<VSpacer />
<VBtn
variant="text"
@click="isDeleteDialogOpen = false"
>
Annuleren
</VBtn>
<VBtn
color="error"
:loading="isDeleting"
:disabled="!deleteConfirmValid"
@click="confirmDelete"
>
Definitief verwijderen
</VBtn>
</VCardActions>
</VCard>
</VDialog>
<!-- Invite Dialog -->
<VDialog
v-model="isInviteDialogOpen"
max-width="500"
>
<VCard title="Lid uitnodigen">
<VCardText>
<VAlert
v-if="inviteError"
type="error"
variant="tonal"
class="mb-4"
density="comfortable"
>
{{ inviteError }}
</VAlert>
<VRow>
<VCol cols="12">
<AppTextField
v-model="inviteForm.email"
label="Email"
type="email"
placeholder="naam@voorbeeld.nl"
/>
</VCol>
<VCol cols="12">
<AppSelect
v-model="inviteForm.role"
:items="roleOptions"
label="Rol"
/>
</VCol>
</VRow>
</VCardText>
<VCardActions>
<VSpacer />
<VBtn
variant="tonal"
@click="isInviteDialogOpen = false"
>
Annuleren
</VBtn>
<VBtn
color="primary"
:loading="isInviting"
@click="submitInvite"
>
Uitnodigen
</VBtn>
</VCardActions>
</VCard>
</VDialog>
<!-- Remove Member Dialog -->
<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 deze organisatie?
</VCardText>
<VCardActions>
<VSpacer />
<VBtn
variant="text"
@click="isRemoveDialogOpen = false"
>
Annuleren
</VBtn>
<VBtn
color="error"
:loading="isRemoving"
@click="confirmRemove"
>
Verwijderen
</VBtn>
</VCardActions>
</VCard>
</VDialog>
<!-- Snackbar -->
<VSnackbar
v-model="snackbar.show"
:color="snackbar.color"
:timeout="3000"
>
{{ snackbar.message }}
</VSnackbar>
</div>
</template>