feat: platform admin frontend — pages, composables, navigation, impersonation
Build the frontend for platform admin in apps/app/: - TypeScript types (admin.ts) and API composable (useAdmin.ts) with TanStack Query for all admin endpoints - ImpersonationStore (Pinia) + ImpersonationBanner component integrated in the main layout, with token-based session management - Platform navigation section (conditionally shown for super_admin users) - Route guard blocking /platform/* for non-super_admin users - 6 pages: dashboard with stats cards, organisations list/detail, users list/detail with impersonation, activity log with expandable rows - All pages implement loading/error/empty states per conventions - Vite build passes cleanly Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
1
apps/app/components.d.ts
vendored
1
apps/app/components.d.ts
vendored
@@ -64,6 +64,7 @@ declare module 'vue' {
|
|||||||
EventMetricCards: typeof import('./src/components/events/EventMetricCards.vue')['default']
|
EventMetricCards: typeof import('./src/components/events/EventMetricCards.vue')['default']
|
||||||
EventTabsNav: typeof import('./src/components/events/EventTabsNav.vue')['default']
|
EventTabsNav: typeof import('./src/components/events/EventTabsNav.vue')['default']
|
||||||
I18n: typeof import('./src/@core/components/I18n.vue')['default']
|
I18n: typeof import('./src/@core/components/I18n.vue')['default']
|
||||||
|
ImpersonationBanner: typeof import('./src/components/platform/ImpersonationBanner.vue')['default']
|
||||||
ImportFromEventDialog: typeof import('./src/components/event/ImportFromEventDialog.vue')['default']
|
ImportFromEventDialog: typeof import('./src/components/event/ImportFromEventDialog.vue')['default']
|
||||||
InfoTooltip: typeof import('./src/components/common/InfoTooltip.vue')['default']
|
InfoTooltip: typeof import('./src/components/common/InfoTooltip.vue')['default']
|
||||||
InviteMemberDialog: typeof import('./src/components/members/InviteMemberDialog.vue')['default']
|
InviteMemberDialog: typeof import('./src/components/members/InviteMemberDialog.vue')['default']
|
||||||
|
|||||||
49
apps/app/src/components/platform/ImpersonationBanner.vue
Normal file
49
apps/app/src/components/platform/ImpersonationBanner.vue
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useImpersonationStore } from '@/stores/useImpersonationStore'
|
||||||
|
|
||||||
|
const impersonationStore = useImpersonationStore()
|
||||||
|
|
||||||
|
const isStopping = ref(false)
|
||||||
|
|
||||||
|
async function handleStop() {
|
||||||
|
isStopping.value = true
|
||||||
|
await impersonationStore.stopImpersonation()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<VBanner
|
||||||
|
v-if="impersonationStore.isImpersonating"
|
||||||
|
color="warning"
|
||||||
|
sticky
|
||||||
|
class="impersonation-banner"
|
||||||
|
>
|
||||||
|
<template #prepend>
|
||||||
|
<VIcon icon="tabler-user-exclamation" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<VBannerText>
|
||||||
|
Je bekijkt het platform als
|
||||||
|
<strong>{{ impersonationStore.impersonatedUser?.full_name }}</strong>
|
||||||
|
({{ impersonationStore.impersonatedUser?.email }})
|
||||||
|
</VBannerText>
|
||||||
|
|
||||||
|
<template #actions>
|
||||||
|
<VBtn
|
||||||
|
variant="tonal"
|
||||||
|
color="warning"
|
||||||
|
:loading="isStopping"
|
||||||
|
prepend-icon="tabler-arrow-back"
|
||||||
|
@click="handleStop"
|
||||||
|
>
|
||||||
|
Terug naar admin
|
||||||
|
</VBtn>
|
||||||
|
</template>
|
||||||
|
</VBanner>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.impersonation-banner {
|
||||||
|
z-index: 1050;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
198
apps/app/src/composables/api/useAdmin.ts
Normal file
198
apps/app/src/composables/api/useAdmin.ts
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
|
||||||
|
import type { Ref } from 'vue'
|
||||||
|
import { apiClient } from '@/lib/axios'
|
||||||
|
import type {
|
||||||
|
ActivityLogEntry,
|
||||||
|
AdminOrganisation,
|
||||||
|
AdminUser,
|
||||||
|
ImpersonationResponse,
|
||||||
|
PlatformStats,
|
||||||
|
UpdateAdminOrganisationPayload,
|
||||||
|
UpdateAdminUserPayload,
|
||||||
|
} from '@/types/admin'
|
||||||
|
|
||||||
|
interface ApiResponse<T> {
|
||||||
|
success: boolean
|
||||||
|
data: T
|
||||||
|
message?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PaginatedResponse<T> {
|
||||||
|
data: T[]
|
||||||
|
links: Record<string, string | null>
|
||||||
|
meta: {
|
||||||
|
current_page: number
|
||||||
|
per_page: number
|
||||||
|
total: number
|
||||||
|
last_page: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Organisations ──────────────────────────────────────────
|
||||||
|
|
||||||
|
export function useAdminOrganisations(params: Ref<Record<string, string | number | undefined>>) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['admin', 'organisations', params],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data } = await apiClient.get<PaginatedResponse<AdminOrganisation>>(
|
||||||
|
'/admin/organisations',
|
||||||
|
{ params: params.value },
|
||||||
|
)
|
||||||
|
return data
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAdminOrganisation(id: Ref<string>) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['admin', 'organisations', id],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data } = await apiClient.get<ApiResponse<AdminOrganisation>>(
|
||||||
|
`/admin/organisations/${id.value}`,
|
||||||
|
)
|
||||||
|
return data.data
|
||||||
|
},
|
||||||
|
enabled: () => !!id.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateAdminOrganisation() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async ({ id, payload }: { id: string; payload: UpdateAdminOrganisationPayload }) => {
|
||||||
|
const { data } = await apiClient.put<ApiResponse<AdminOrganisation>>(
|
||||||
|
`/admin/organisations/${id}`,
|
||||||
|
payload,
|
||||||
|
)
|
||||||
|
return data.data
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin', 'organisations'] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeleteAdminOrganisation() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (id: string) => {
|
||||||
|
await apiClient.delete(`/admin/organisations/${id}`)
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin', 'organisations'] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Users ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function useAdminUsers(params: Ref<Record<string, string | number | undefined>>) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['admin', 'users', params],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data } = await apiClient.get<PaginatedResponse<AdminUser>>(
|
||||||
|
'/admin/users',
|
||||||
|
{ params: params.value },
|
||||||
|
)
|
||||||
|
return data
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAdminUser(id: Ref<string>) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['admin', 'users', id],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data } = await apiClient.get<ApiResponse<AdminUser>>(
|
||||||
|
`/admin/users/${id.value}`,
|
||||||
|
)
|
||||||
|
return data.data
|
||||||
|
},
|
||||||
|
enabled: () => !!id.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateAdminUser() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async ({ id, payload }: { id: string; payload: UpdateAdminUserPayload }) => {
|
||||||
|
const { data } = await apiClient.put<ApiResponse<AdminUser>>(
|
||||||
|
`/admin/users/${id}`,
|
||||||
|
payload,
|
||||||
|
)
|
||||||
|
return data.data
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin', 'users'] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeleteAdminUser() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (id: string) => {
|
||||||
|
await apiClient.delete(`/admin/users/${id}`)
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin', 'users'] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Stats ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function usePlatformStats() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['admin', 'stats'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data } = await apiClient.get<{ data: PlatformStats }>(
|
||||||
|
'/admin/stats',
|
||||||
|
)
|
||||||
|
return data.data
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Activity Log ───────────────────────────────────────────
|
||||||
|
|
||||||
|
export function useAdminActivityLog(params: Ref<Record<string, string | number | undefined>>) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['admin', 'activity-log', params],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data } = await apiClient.get<PaginatedResponse<ActivityLogEntry>>(
|
||||||
|
'/admin/activity-log',
|
||||||
|
{ params: params.value },
|
||||||
|
)
|
||||||
|
return data
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Impersonation ──────────────────────────────────────────
|
||||||
|
|
||||||
|
export function useStartImpersonation() {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (userId: string) => {
|
||||||
|
const { data } = await apiClient.post<ApiResponse<ImpersonationResponse>>(
|
||||||
|
`/admin/impersonate/${userId}`,
|
||||||
|
)
|
||||||
|
return data.data
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useStopImpersonation() {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
const { data } = await apiClient.post<ApiResponse<{ user: AdminUser }>>(
|
||||||
|
'/admin/stop-impersonation',
|
||||||
|
)
|
||||||
|
return data.data
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1,7 +1,18 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import navItems from '@/navigation/vertical'
|
import { orgNavItems, platformNavItems } from '@/navigation/vertical'
|
||||||
|
import { useAuthStore } from '@/stores/useAuthStore'
|
||||||
|
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
|
const navItems = computed(() => {
|
||||||
|
if (authStore.isSuperAdmin) {
|
||||||
|
return [...orgNavItems, ...platformNavItems]
|
||||||
|
}
|
||||||
|
return orgNavItems
|
||||||
|
})
|
||||||
|
|
||||||
// Components
|
// Components
|
||||||
|
import ImpersonationBanner from '@/components/platform/ImpersonationBanner.vue'
|
||||||
import Footer from '@/layouts/components/Footer.vue'
|
import Footer from '@/layouts/components/Footer.vue'
|
||||||
import NavBarNotifications from '@/layouts/components/NavBarNotifications.vue'
|
import NavBarNotifications from '@/layouts/components/NavBarNotifications.vue'
|
||||||
import NavSearchBar from '@/layouts/components/NavSearchBar.vue'
|
import NavSearchBar from '@/layouts/components/NavSearchBar.vue'
|
||||||
@@ -48,6 +59,9 @@ import { VerticalNavLayout } from '@layouts'
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<!-- 👉 Impersonation Banner -->
|
||||||
|
<ImpersonationBanner />
|
||||||
|
|
||||||
<!-- 👉 Pages -->
|
<!-- 👉 Pages -->
|
||||||
<slot />
|
<slot />
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
export default [
|
export const orgNavItems = [
|
||||||
{
|
{
|
||||||
title: 'Dashboard',
|
title: 'Dashboard',
|
||||||
to: { name: 'dashboard' },
|
to: { name: 'dashboard' },
|
||||||
@@ -35,3 +35,31 @@ export default [
|
|||||||
icon: { icon: 'tabler-settings' },
|
icon: { icon: 'tabler-settings' },
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
export const platformNavItems = [
|
||||||
|
{
|
||||||
|
heading: 'Platform',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Platform Dashboard',
|
||||||
|
to: { name: 'platform' },
|
||||||
|
icon: { icon: 'tabler-chart-dots-3' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Organisaties',
|
||||||
|
to: { name: 'platform-organisations' },
|
||||||
|
icon: { icon: 'tabler-buildings' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Gebruikers',
|
||||||
|
to: { name: 'platform-users' },
|
||||||
|
icon: { icon: 'tabler-users-group' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Activity Log',
|
||||||
|
to: { name: 'platform-activity-log' },
|
||||||
|
icon: { icon: 'tabler-list-details' },
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export default orgNavItems
|
||||||
|
|||||||
221
apps/app/src/pages/platform/activity-log/index.vue
Normal file
221
apps/app/src/pages/platform/activity-log/index.vue
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useAdminActivityLog } from '@/composables/api/useAdmin'
|
||||||
|
|
||||||
|
definePage({
|
||||||
|
meta: {
|
||||||
|
navActiveLink: 'platform-activity-log',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const search = ref('')
|
||||||
|
const searchDebounced = refDebounced(search, 400)
|
||||||
|
const logNameFilter = ref('')
|
||||||
|
const page = ref(1)
|
||||||
|
|
||||||
|
const params = computed(() => ({
|
||||||
|
page: page.value,
|
||||||
|
causer_id: undefined,
|
||||||
|
log_name: logNameFilter.value || undefined,
|
||||||
|
from: undefined,
|
||||||
|
to: undefined,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const { data, isLoading, isError, refetch } = useAdminActivityLog(params)
|
||||||
|
|
||||||
|
const activities = computed(() => data.value?.data ?? [])
|
||||||
|
const totalItems = computed(() => data.value?.meta?.total ?? 0)
|
||||||
|
|
||||||
|
const expandedRows = ref<Set<number>>(new Set())
|
||||||
|
|
||||||
|
function toggleRow(id: number) {
|
||||||
|
if (expandedRows.value.has(id)) {
|
||||||
|
expandedRows.value.delete(id)
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
expandedRows.value.add(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const logNameOptions = [
|
||||||
|
{ title: 'Alle logs', value: '' },
|
||||||
|
{ title: 'Admin', value: 'admin' },
|
||||||
|
{ title: 'Default', value: 'default' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const headers = [
|
||||||
|
{ title: 'Tijd', key: 'created_at', width: '180px' },
|
||||||
|
{ title: 'Actie', key: 'description' },
|
||||||
|
{ title: 'Gebruiker', key: 'causer', sortable: false },
|
||||||
|
{ title: 'Type', key: 'subject_type', sortable: false },
|
||||||
|
{ title: '', key: 'expand', sortable: false, width: '50px' },
|
||||||
|
]
|
||||||
|
|
||||||
|
function formatDateTime(iso: string): string {
|
||||||
|
return new Date(iso).toLocaleString('nl-NL', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function shortenSubjectType(type: string | null): string {
|
||||||
|
if (!type) return '-'
|
||||||
|
const parts = type.split('\\')
|
||||||
|
return parts[parts.length - 1]
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatProperties(props: Record<string, unknown>): Array<{ key: string; value: string }> {
|
||||||
|
return Object.entries(props).map(([key, value]) => ({
|
||||||
|
key,
|
||||||
|
value: typeof value === 'object' ? JSON.stringify(value, null, 2) : String(value),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
function onUpdateOptions(options: { page: number }) {
|
||||||
|
page.value = options.page
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="d-flex align-center justify-space-between mb-6">
|
||||||
|
<div>
|
||||||
|
<h4 class="text-h4">
|
||||||
|
Activity Log
|
||||||
|
</h4>
|
||||||
|
<p class="text-body-1 text-disabled mb-0">
|
||||||
|
Alle activiteiten op het platform
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error -->
|
||||||
|
<VAlert
|
||||||
|
v-if="isError"
|
||||||
|
type="error"
|
||||||
|
class="mb-4"
|
||||||
|
>
|
||||||
|
Kon activity log niet laden.
|
||||||
|
<template #append>
|
||||||
|
<VBtn
|
||||||
|
variant="text"
|
||||||
|
@click="refetch()"
|
||||||
|
>
|
||||||
|
Opnieuw proberen
|
||||||
|
</VBtn>
|
||||||
|
</template>
|
||||||
|
</VAlert>
|
||||||
|
|
||||||
|
<VCard>
|
||||||
|
<!-- Filters -->
|
||||||
|
<VCardText>
|
||||||
|
<VRow>
|
||||||
|
<VCol
|
||||||
|
cols="12"
|
||||||
|
md="4"
|
||||||
|
>
|
||||||
|
<AppSelect
|
||||||
|
v-model="logNameFilter"
|
||||||
|
:items="logNameOptions"
|
||||||
|
placeholder="Log type"
|
||||||
|
clearable
|
||||||
|
/>
|
||||||
|
</VCol>
|
||||||
|
</VRow>
|
||||||
|
</VCardText>
|
||||||
|
|
||||||
|
<VDataTableServer
|
||||||
|
:headers="headers"
|
||||||
|
:items="activities"
|
||||||
|
:items-length="totalItems"
|
||||||
|
:loading="isLoading"
|
||||||
|
:items-per-page="25"
|
||||||
|
:page="page"
|
||||||
|
@update:options="onUpdateOptions"
|
||||||
|
>
|
||||||
|
<template #item.created_at="{ item }">
|
||||||
|
<span class="text-body-2">
|
||||||
|
{{ formatDateTime(item.created_at) }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #item.causer="{ item }">
|
||||||
|
<span v-if="item.causer">
|
||||||
|
{{ item.causer.name }}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-else
|
||||||
|
class="text-disabled"
|
||||||
|
>Systeem</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #item.subject_type="{ item }">
|
||||||
|
<VChip
|
||||||
|
v-if="item.subject_type"
|
||||||
|
size="x-small"
|
||||||
|
>
|
||||||
|
{{ shortenSubjectType(item.subject_type) }}
|
||||||
|
</VChip>
|
||||||
|
<span
|
||||||
|
v-else
|
||||||
|
class="text-disabled"
|
||||||
|
>-</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #item.expand="{ item }">
|
||||||
|
<VBtn
|
||||||
|
v-if="item.properties && Object.keys(item.properties).length > 0"
|
||||||
|
:icon="expandedRows.has(item.id) ? 'tabler-chevron-up' : 'tabler-chevron-down'"
|
||||||
|
variant="text"
|
||||||
|
size="x-small"
|
||||||
|
@click="toggleRow(item.id)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #expanded-row="{ item }">
|
||||||
|
<tr v-if="expandedRows.has(item.id)">
|
||||||
|
<td :colspan="headers.length">
|
||||||
|
<div class="pa-4">
|
||||||
|
<VTable density="compact">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Eigenschap</th>
|
||||||
|
<th>Waarde</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr
|
||||||
|
v-for="prop in formatProperties(item.properties)"
|
||||||
|
:key="prop.key"
|
||||||
|
>
|
||||||
|
<td class="text-body-2 font-weight-medium">
|
||||||
|
{{ prop.key }}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<code class="text-body-2">{{ prop.value }}</code>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</VTable>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Empty -->
|
||||||
|
<template #no-data>
|
||||||
|
<div class="text-center pa-4 text-disabled">
|
||||||
|
<VIcon
|
||||||
|
icon="tabler-list-details"
|
||||||
|
size="48"
|
||||||
|
class="mb-2"
|
||||||
|
/>
|
||||||
|
<p>Geen activiteiten gevonden</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</VDataTableServer>
|
||||||
|
</VCard>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
239
apps/app/src/pages/platform/index.vue
Normal file
239
apps/app/src/pages/platform/index.vue
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { usePlatformStats, useAdminActivityLog } from '@/composables/api/useAdmin'
|
||||||
|
import type { BillingStatus } from '@/types/admin'
|
||||||
|
|
||||||
|
definePage({
|
||||||
|
meta: {
|
||||||
|
navActiveLink: 'platform',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const { data: stats, isLoading: statsLoading, isError: statsError, refetch: refetchStats } = usePlatformStats()
|
||||||
|
|
||||||
|
const activityParams = computed(() => ({ page: 1, per_page: 10 }))
|
||||||
|
const { data: activityData, isLoading: activityLoading } = useAdminActivityLog(activityParams)
|
||||||
|
|
||||||
|
const recentActivities = computed(() => activityData.value?.data ?? [])
|
||||||
|
|
||||||
|
const billingStatusColor: Record<BillingStatus, string> = {
|
||||||
|
trial: 'info',
|
||||||
|
active: 'success',
|
||||||
|
suspended: 'warning',
|
||||||
|
cancelled: 'error',
|
||||||
|
}
|
||||||
|
|
||||||
|
const statCards = computed(() => {
|
||||||
|
if (!stats.value) return []
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
title: 'Organisaties',
|
||||||
|
value: stats.value.organisations.total,
|
||||||
|
icon: 'tabler-buildings',
|
||||||
|
color: 'primary',
|
||||||
|
to: { name: 'platform-organisations' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Evenementen',
|
||||||
|
value: stats.value.events.total,
|
||||||
|
icon: 'tabler-calendar-event',
|
||||||
|
color: 'success',
|
||||||
|
extra: stats.value.events.by_status,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Gebruikers',
|
||||||
|
value: `${stats.value.users.verified} / ${stats.value.users.total}`,
|
||||||
|
icon: 'tabler-users-group',
|
||||||
|
color: 'warning',
|
||||||
|
to: { name: 'platform-users' },
|
||||||
|
subtitle: 'geverifieerd',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Personen',
|
||||||
|
value: stats.value.persons.total,
|
||||||
|
icon: 'tabler-user',
|
||||||
|
color: 'info',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
function formatDateTime(iso: string): string {
|
||||||
|
return new Date(iso).toLocaleString('nl-NL', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function shortenSubjectType(type: string | null): string {
|
||||||
|
if (!type) return '-'
|
||||||
|
const parts = type.split('\\')
|
||||||
|
return parts[parts.length - 1]
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="d-flex align-center mb-6">
|
||||||
|
<div>
|
||||||
|
<h4 class="text-h4">
|
||||||
|
Platform Admin
|
||||||
|
</h4>
|
||||||
|
<p class="text-body-1 text-disabled mb-0">
|
||||||
|
Overzicht van het hele platform
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading -->
|
||||||
|
<VSkeletonLoader
|
||||||
|
v-if="statsLoading"
|
||||||
|
type="card, card"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Error -->
|
||||||
|
<VAlert
|
||||||
|
v-else-if="statsError"
|
||||||
|
type="error"
|
||||||
|
class="mb-4"
|
||||||
|
>
|
||||||
|
Kon statistieken niet laden.
|
||||||
|
<template #append>
|
||||||
|
<VBtn
|
||||||
|
variant="text"
|
||||||
|
@click="refetchStats()"
|
||||||
|
>
|
||||||
|
Opnieuw proberen
|
||||||
|
</VBtn>
|
||||||
|
</template>
|
||||||
|
</VAlert>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<!-- Stats Cards -->
|
||||||
|
<VRow class="mb-6">
|
||||||
|
<VCol
|
||||||
|
v-for="card in statCards"
|
||||||
|
:key="card.title"
|
||||||
|
cols="12"
|
||||||
|
sm="6"
|
||||||
|
md="3"
|
||||||
|
>
|
||||||
|
<VCard
|
||||||
|
:to="card.to"
|
||||||
|
:class="{ 'cursor-pointer': !!card.to }"
|
||||||
|
>
|
||||||
|
<VCardText class="d-flex align-center gap-x-4">
|
||||||
|
<VAvatar
|
||||||
|
:color="card.color"
|
||||||
|
variant="tonal"
|
||||||
|
size="44"
|
||||||
|
rounded
|
||||||
|
>
|
||||||
|
<VIcon
|
||||||
|
:icon="card.icon"
|
||||||
|
size="28"
|
||||||
|
/>
|
||||||
|
</VAvatar>
|
||||||
|
<div>
|
||||||
|
<p class="text-body-1 mb-0">
|
||||||
|
{{ card.title }}
|
||||||
|
</p>
|
||||||
|
<h4 class="text-h4">
|
||||||
|
{{ card.value }}
|
||||||
|
</h4>
|
||||||
|
<p
|
||||||
|
v-if="card.subtitle"
|
||||||
|
class="text-caption text-disabled mb-0"
|
||||||
|
>
|
||||||
|
{{ card.subtitle }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</VCardText>
|
||||||
|
|
||||||
|
<!-- Billing status breakdown for organisations -->
|
||||||
|
<VCardText
|
||||||
|
v-if="card.extra"
|
||||||
|
class="pt-0"
|
||||||
|
>
|
||||||
|
<div class="d-flex flex-wrap gap-1">
|
||||||
|
<VChip
|
||||||
|
v-for="(count, status) in card.extra"
|
||||||
|
:key="String(status)"
|
||||||
|
size="x-small"
|
||||||
|
:color="billingStatusColor[status as BillingStatus] ?? 'default'"
|
||||||
|
>
|
||||||
|
{{ status }}: {{ count }}
|
||||||
|
</VChip>
|
||||||
|
</div>
|
||||||
|
</VCardText>
|
||||||
|
</VCard>
|
||||||
|
</VCol>
|
||||||
|
</VRow>
|
||||||
|
|
||||||
|
<!-- Recent Activity -->
|
||||||
|
<VCard>
|
||||||
|
<VCardTitle class="d-flex align-center justify-space-between">
|
||||||
|
<span>Recente Activiteit</span>
|
||||||
|
<VBtn
|
||||||
|
variant="text"
|
||||||
|
size="small"
|
||||||
|
:to="{ name: 'platform-activity-log' }"
|
||||||
|
>
|
||||||
|
Alles bekijken
|
||||||
|
</VBtn>
|
||||||
|
</VCardTitle>
|
||||||
|
|
||||||
|
<VSkeletonLoader
|
||||||
|
v-if="activityLoading"
|
||||||
|
type="list-item@5"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<VList
|
||||||
|
v-else-if="recentActivities.length > 0"
|
||||||
|
lines="two"
|
||||||
|
>
|
||||||
|
<VListItem
|
||||||
|
v-for="entry in recentActivities"
|
||||||
|
:key="entry.id"
|
||||||
|
>
|
||||||
|
<template #prepend>
|
||||||
|
<VAvatar
|
||||||
|
color="secondary"
|
||||||
|
variant="tonal"
|
||||||
|
size="34"
|
||||||
|
>
|
||||||
|
<VIcon
|
||||||
|
icon="tabler-activity"
|
||||||
|
size="18"
|
||||||
|
/>
|
||||||
|
</VAvatar>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<VListItemTitle>{{ entry.description }}</VListItemTitle>
|
||||||
|
<VListItemSubtitle>
|
||||||
|
{{ entry.causer?.name ?? 'Systeem' }}
|
||||||
|
·
|
||||||
|
{{ formatDateTime(entry.created_at) }}
|
||||||
|
<VChip
|
||||||
|
v-if="entry.subject_type"
|
||||||
|
size="x-small"
|
||||||
|
class="ms-1"
|
||||||
|
>
|
||||||
|
{{ shortenSubjectType(entry.subject_type) }}
|
||||||
|
</VChip>
|
||||||
|
</VListItemSubtitle>
|
||||||
|
</VListItem>
|
||||||
|
</VList>
|
||||||
|
|
||||||
|
<VCardText
|
||||||
|
v-else
|
||||||
|
class="text-center text-disabled"
|
||||||
|
>
|
||||||
|
Geen activiteit gevonden
|
||||||
|
</VCardText>
|
||||||
|
</VCard>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
384
apps/app/src/pages/platform/organisations/[id].vue
Normal file
384
apps/app/src/pages/platform/organisations/[id].vue
Normal file
@@ -0,0 +1,384 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useAdminOrganisation, useUpdateAdminOrganisation, useDeleteAdminOrganisation } from '@/composables/api/useAdmin'
|
||||||
|
import { useOrganisationStore } from '@/stores/useOrganisationStore'
|
||||||
|
import type { BillingStatus, UpdateAdminOrganisationPayload } from '@/types/admin'
|
||||||
|
|
||||||
|
definePage({
|
||||||
|
meta: {
|
||||||
|
navActiveLink: 'platform-organisations',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const orgStore = useOrganisationStore()
|
||||||
|
|
||||||
|
const orgId = computed(() => String((route.params as { id: string }).id))
|
||||||
|
|
||||||
|
const { data: org, isLoading, isError, refetch } = useAdminOrganisation(orgId)
|
||||||
|
|
||||||
|
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' },
|
||||||
|
]
|
||||||
|
|
||||||
|
// 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
|
||||||
|
showEditSuccess.value = true
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete
|
||||||
|
const isDeleteDialogOpen = ref(false)
|
||||||
|
const { mutate: deleteOrg, isPending: isDeleting } = useDeleteAdminOrganisation()
|
||||||
|
|
||||||
|
function confirmDelete() {
|
||||||
|
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' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const showEditSuccess = ref(false)
|
||||||
|
|
||||||
|
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-6">
|
||||||
|
<div class="d-flex align-center gap-x-3">
|
||||||
|
<VBtn
|
||||||
|
icon="tabler-arrow-left"
|
||||||
|
variant="text"
|
||||||
|
size="small"
|
||||||
|
:to="{ name: 'platform-organisations' }"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<h4 class="text-h4">
|
||||||
|
{{ org.name }}
|
||||||
|
<VChip
|
||||||
|
:color="billingStatusColor[org.billing_status]"
|
||||||
|
size="small"
|
||||||
|
class="ms-2"
|
||||||
|
>
|
||||||
|
{{ org.billing_status_label ?? org.billing_status }}
|
||||||
|
</VChip>
|
||||||
|
</h4>
|
||||||
|
<p class="text-body-2 text-disabled mb-0">
|
||||||
|
{{ org.slug }}
|
||||||
|
</p>
|
||||||
|
</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>
|
||||||
|
|
||||||
|
<!-- Info Cards -->
|
||||||
|
<VRow class="mb-6">
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<!-- Details Card -->
|
||||||
|
<VCard class="mb-6">
|
||||||
|
<VCardTitle>Details</VCardTitle>
|
||||||
|
<VCardText>
|
||||||
|
<VRow>
|
||||||
|
<VCol
|
||||||
|
cols="12"
|
||||||
|
sm="6"
|
||||||
|
>
|
||||||
|
<p class="text-body-2 text-disabled mb-1">
|
||||||
|
Aangemaakt
|
||||||
|
</p>
|
||||||
|
<p class="text-body-1">
|
||||||
|
{{ formatDate(org.created_at) }}
|
||||||
|
</p>
|
||||||
|
</VCol>
|
||||||
|
<VCol
|
||||||
|
cols="12"
|
||||||
|
sm="6"
|
||||||
|
>
|
||||||
|
<p class="text-body-2 text-disabled mb-1">
|
||||||
|
Laatste wijziging
|
||||||
|
</p>
|
||||||
|
<p class="text-body-1">
|
||||||
|
{{ formatDate(org.updated_at) }}
|
||||||
|
</p>
|
||||||
|
</VCol>
|
||||||
|
</VRow>
|
||||||
|
</VCardText>
|
||||||
|
</VCard>
|
||||||
|
|
||||||
|
<!-- Danger Zone -->
|
||||||
|
<VCard>
|
||||||
|
<VCardTitle class="text-error">
|
||||||
|
Gevarenzone
|
||||||
|
</VCardTitle>
|
||||||
|
<VCardText>
|
||||||
|
<VBtn
|
||||||
|
color="error"
|
||||||
|
variant="tonal"
|
||||||
|
prepend-icon="tabler-trash"
|
||||||
|
@click="isDeleteDialogOpen = true"
|
||||||
|
>
|
||||||
|
Organisatie verwijderen
|
||||||
|
</VBtn>
|
||||||
|
</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 -->
|
||||||
|
<VDialog
|
||||||
|
v-model="isDeleteDialogOpen"
|
||||||
|
max-width="400"
|
||||||
|
>
|
||||||
|
<VCard title="Organisatie verwijderen">
|
||||||
|
<VCardText>
|
||||||
|
Weet je zeker dat je <strong>{{ org?.name }}</strong> wilt verwijderen?
|
||||||
|
Dit kan niet ongedaan worden gemaakt.
|
||||||
|
</VCardText>
|
||||||
|
<VCardActions>
|
||||||
|
<VSpacer />
|
||||||
|
<VBtn
|
||||||
|
variant="text"
|
||||||
|
@click="isDeleteDialogOpen = false"
|
||||||
|
>
|
||||||
|
Annuleren
|
||||||
|
</VBtn>
|
||||||
|
<VBtn
|
||||||
|
color="error"
|
||||||
|
:loading="isDeleting"
|
||||||
|
@click="confirmDelete"
|
||||||
|
>
|
||||||
|
Verwijderen
|
||||||
|
</VBtn>
|
||||||
|
</VCardActions>
|
||||||
|
</VCard>
|
||||||
|
</VDialog>
|
||||||
|
|
||||||
|
<!-- Success snackbar -->
|
||||||
|
<VSnackbar
|
||||||
|
v-model="showEditSuccess"
|
||||||
|
color="success"
|
||||||
|
:timeout="3000"
|
||||||
|
>
|
||||||
|
Organisatie bijgewerkt
|
||||||
|
</VSnackbar>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
178
apps/app/src/pages/platform/organisations/index.vue
Normal file
178
apps/app/src/pages/platform/organisations/index.vue
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useAdminOrganisations } from '@/composables/api/useAdmin'
|
||||||
|
import type { AdminOrganisation, BillingStatus } from '@/types/admin'
|
||||||
|
|
||||||
|
definePage({
|
||||||
|
meta: {
|
||||||
|
navActiveLink: 'platform-organisations',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const search = ref('')
|
||||||
|
const searchDebounced = refDebounced(search, 400)
|
||||||
|
const billingStatusFilter = ref<string>('')
|
||||||
|
const page = ref(1)
|
||||||
|
const itemsPerPage = ref(15)
|
||||||
|
const sortBy = ref('name')
|
||||||
|
const sortDirection = ref<'asc' | 'desc'>('asc')
|
||||||
|
|
||||||
|
const params = computed(() => ({
|
||||||
|
page: page.value,
|
||||||
|
per_page: itemsPerPage.value,
|
||||||
|
search: searchDebounced.value || undefined,
|
||||||
|
billing_status: billingStatusFilter.value || undefined,
|
||||||
|
sort: sortBy.value,
|
||||||
|
direction: sortDirection.value,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const { data, isLoading, isError, refetch } = useAdminOrganisations(params)
|
||||||
|
|
||||||
|
const organisations = computed(() => data.value?.data ?? [])
|
||||||
|
const totalItems = computed(() => data.value?.meta?.total ?? 0)
|
||||||
|
|
||||||
|
const billingStatusColor: Record<BillingStatus, string> = {
|
||||||
|
trial: 'info',
|
||||||
|
active: 'success',
|
||||||
|
suspended: 'warning',
|
||||||
|
cancelled: 'error',
|
||||||
|
}
|
||||||
|
|
||||||
|
const billingStatusOptions = [
|
||||||
|
{ title: 'Alle statussen', value: '' },
|
||||||
|
{ title: 'Trial', value: 'trial' },
|
||||||
|
{ title: 'Active', value: 'active' },
|
||||||
|
{ title: 'Suspended', value: 'suspended' },
|
||||||
|
{ title: 'Cancelled', value: 'cancelled' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const headers = [
|
||||||
|
{ title: 'Naam', key: 'name', sortable: true },
|
||||||
|
{ title: 'Slug', key: 'slug', sortable: false },
|
||||||
|
{ title: 'Status', key: 'billing_status', sortable: false },
|
||||||
|
{ title: 'Events', key: 'events_count', sortable: false, align: 'center' as const },
|
||||||
|
{ title: 'Gebruikers', key: 'users_count', sortable: false, align: 'center' as const },
|
||||||
|
{ title: 'Aangemaakt', key: 'created_at', sortable: true },
|
||||||
|
]
|
||||||
|
|
||||||
|
function formatDate(iso: string): string {
|
||||||
|
return new Date(iso).toLocaleDateString('nl-NL', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function onRowClick(_event: Event, { item }: { item: AdminOrganisation }) {
|
||||||
|
router.push({ name: 'platform-organisations-id', params: { id: item.id } })
|
||||||
|
}
|
||||||
|
|
||||||
|
function onUpdateOptions(options: { page: number; itemsPerPage: number; sortBy: Array<{ key: string; order: string }> }) {
|
||||||
|
page.value = options.page
|
||||||
|
itemsPerPage.value = options.itemsPerPage
|
||||||
|
if (options.sortBy.length > 0) {
|
||||||
|
sortBy.value = options.sortBy[0].key
|
||||||
|
sortDirection.value = options.sortBy[0].order as 'asc' | 'desc'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="d-flex align-center justify-space-between mb-6">
|
||||||
|
<div>
|
||||||
|
<h4 class="text-h4">
|
||||||
|
Organisaties
|
||||||
|
</h4>
|
||||||
|
<p class="text-body-1 text-disabled mb-0">
|
||||||
|
Alle organisaties op het platform
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error -->
|
||||||
|
<VAlert
|
||||||
|
v-if="isError"
|
||||||
|
type="error"
|
||||||
|
class="mb-4"
|
||||||
|
>
|
||||||
|
Kon organisaties niet laden.
|
||||||
|
<template #append>
|
||||||
|
<VBtn
|
||||||
|
variant="text"
|
||||||
|
@click="refetch()"
|
||||||
|
>
|
||||||
|
Opnieuw proberen
|
||||||
|
</VBtn>
|
||||||
|
</template>
|
||||||
|
</VAlert>
|
||||||
|
|
||||||
|
<VCard>
|
||||||
|
<!-- Filters -->
|
||||||
|
<VCardText>
|
||||||
|
<VRow>
|
||||||
|
<VCol
|
||||||
|
cols="12"
|
||||||
|
md="6"
|
||||||
|
>
|
||||||
|
<AppTextField
|
||||||
|
v-model="search"
|
||||||
|
placeholder="Zoek op naam of slug..."
|
||||||
|
prepend-inner-icon="tabler-search"
|
||||||
|
clearable
|
||||||
|
/>
|
||||||
|
</VCol>
|
||||||
|
<VCol
|
||||||
|
cols="12"
|
||||||
|
md="3"
|
||||||
|
>
|
||||||
|
<AppSelect
|
||||||
|
v-model="billingStatusFilter"
|
||||||
|
:items="billingStatusOptions"
|
||||||
|
placeholder="Status"
|
||||||
|
clearable
|
||||||
|
/>
|
||||||
|
</VCol>
|
||||||
|
</VRow>
|
||||||
|
</VCardText>
|
||||||
|
|
||||||
|
<VDataTableServer
|
||||||
|
:headers="headers"
|
||||||
|
:items="organisations"
|
||||||
|
:items-length="totalItems"
|
||||||
|
:loading="isLoading"
|
||||||
|
:items-per-page="itemsPerPage"
|
||||||
|
:page="page"
|
||||||
|
hover
|
||||||
|
@update:options="onUpdateOptions"
|
||||||
|
@click:row="onRowClick"
|
||||||
|
>
|
||||||
|
<template #item.billing_status="{ item }">
|
||||||
|
<VChip
|
||||||
|
:color="billingStatusColor[item.billing_status]"
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
{{ item.billing_status_label ?? item.billing_status }}
|
||||||
|
</VChip>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #item.created_at="{ item }">
|
||||||
|
{{ formatDate(item.created_at) }}
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Empty -->
|
||||||
|
<template #no-data>
|
||||||
|
<div class="text-center pa-4 text-disabled">
|
||||||
|
<VIcon
|
||||||
|
icon="tabler-building-off"
|
||||||
|
size="48"
|
||||||
|
class="mb-2"
|
||||||
|
/>
|
||||||
|
<p>Geen organisaties gevonden</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</VDataTableServer>
|
||||||
|
</VCard>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
404
apps/app/src/pages/platform/users/[id].vue
Normal file
404
apps/app/src/pages/platform/users/[id].vue
Normal file
@@ -0,0 +1,404 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import {
|
||||||
|
useAdminUser,
|
||||||
|
useUpdateAdminUser,
|
||||||
|
useStartImpersonation,
|
||||||
|
} from '@/composables/api/useAdmin'
|
||||||
|
import { useImpersonationStore } from '@/stores/useImpersonationStore'
|
||||||
|
import type { AdminUser, UpdateAdminUserPayload } from '@/types/admin'
|
||||||
|
|
||||||
|
definePage({
|
||||||
|
meta: {
|
||||||
|
navActiveLink: 'platform-users',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const impersonationStore = useImpersonationStore()
|
||||||
|
|
||||||
|
const userId = computed(() => String((route.params as { id: string }).id))
|
||||||
|
|
||||||
|
const { data: user, isLoading, isError, refetch } = useAdminUser(userId)
|
||||||
|
|
||||||
|
const roleColorMap: Record<string, string> = {
|
||||||
|
super_admin: 'error',
|
||||||
|
org_admin: 'purple',
|
||||||
|
org_member: 'info',
|
||||||
|
event_manager: 'cyan',
|
||||||
|
}
|
||||||
|
|
||||||
|
const platformRoleOptions = [
|
||||||
|
{ title: 'Super Admin', value: 'super_admin' },
|
||||||
|
]
|
||||||
|
|
||||||
|
// Edit dialog
|
||||||
|
const isEditDialogOpen = ref(false)
|
||||||
|
const editForm = ref<UpdateAdminUserPayload>({})
|
||||||
|
const { mutate: updateUser, isPending: isUpdating } = useUpdateAdminUser()
|
||||||
|
const showEditSuccess = ref(false)
|
||||||
|
|
||||||
|
function openEditDialog() {
|
||||||
|
if (!user.value) return
|
||||||
|
editForm.value = {
|
||||||
|
first_name: user.value.first_name,
|
||||||
|
last_name: user.value.last_name,
|
||||||
|
email: user.value.email,
|
||||||
|
timezone: user.value.timezone,
|
||||||
|
locale: user.value.locale,
|
||||||
|
roles: user.value.roles.filter(r => ['super_admin', 'support_agent'].includes(r)),
|
||||||
|
}
|
||||||
|
isEditDialogOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function submitEdit() {
|
||||||
|
updateUser(
|
||||||
|
{ id: userId.value, payload: editForm.value },
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
isEditDialogOpen.value = false
|
||||||
|
showEditSuccess.value = true
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Impersonation
|
||||||
|
const isImpersonateDialogOpen = ref(false)
|
||||||
|
const { mutate: startImpersonation, isPending: isImpersonating } = useStartImpersonation()
|
||||||
|
|
||||||
|
function confirmImpersonate() {
|
||||||
|
startImpersonation(userId.value, {
|
||||||
|
onSuccess: (result) => {
|
||||||
|
isImpersonateDialogOpen.value = false
|
||||||
|
impersonationStore.startImpersonation(result.token, result.user, result.admin_id)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function getInitials(name: string): string {
|
||||||
|
return name
|
||||||
|
.split(' ')
|
||||||
|
.map(p => p[0])
|
||||||
|
.filter(Boolean)
|
||||||
|
.slice(0, 2)
|
||||||
|
.join('')
|
||||||
|
.toUpperCase()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<!-- Loading -->
|
||||||
|
<VSkeletonLoader
|
||||||
|
v-if="isLoading"
|
||||||
|
type="card, card"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Error -->
|
||||||
|
<VAlert
|
||||||
|
v-else-if="isError"
|
||||||
|
type="error"
|
||||||
|
class="mb-4"
|
||||||
|
>
|
||||||
|
Kon gebruiker niet laden.
|
||||||
|
<template #append>
|
||||||
|
<VBtn
|
||||||
|
variant="text"
|
||||||
|
@click="refetch()"
|
||||||
|
>
|
||||||
|
Opnieuw proberen
|
||||||
|
</VBtn>
|
||||||
|
</template>
|
||||||
|
</VAlert>
|
||||||
|
|
||||||
|
<template v-else-if="user">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="d-flex align-center justify-space-between mb-6">
|
||||||
|
<div class="d-flex align-center gap-x-3">
|
||||||
|
<VBtn
|
||||||
|
icon="tabler-arrow-left"
|
||||||
|
variant="text"
|
||||||
|
size="small"
|
||||||
|
:to="{ name: 'platform-users' }"
|
||||||
|
/>
|
||||||
|
<VAvatar
|
||||||
|
v-if="user.avatar"
|
||||||
|
size="44"
|
||||||
|
:image="user.avatar"
|
||||||
|
/>
|
||||||
|
<VAvatar
|
||||||
|
v-else
|
||||||
|
size="44"
|
||||||
|
color="primary"
|
||||||
|
variant="tonal"
|
||||||
|
>
|
||||||
|
<span>{{ getInitials(user.full_name) }}</span>
|
||||||
|
</VAvatar>
|
||||||
|
<div>
|
||||||
|
<h4 class="text-h4">
|
||||||
|
{{ user.full_name }}
|
||||||
|
<VChip
|
||||||
|
v-for="role in user.roles"
|
||||||
|
:key="role"
|
||||||
|
:color="roleColorMap[role] ?? 'default'"
|
||||||
|
size="small"
|
||||||
|
class="ms-1"
|
||||||
|
>
|
||||||
|
{{ role }}
|
||||||
|
</VChip>
|
||||||
|
</h4>
|
||||||
|
<p class="text-body-2 text-disabled mb-0">
|
||||||
|
{{ user.email }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex gap-x-2">
|
||||||
|
<VBtn
|
||||||
|
v-if="!user.is_super_admin"
|
||||||
|
variant="tonal"
|
||||||
|
color="warning"
|
||||||
|
prepend-icon="tabler-user-share"
|
||||||
|
@click="isImpersonateDialogOpen = true"
|
||||||
|
>
|
||||||
|
Inloggen als
|
||||||
|
</VBtn>
|
||||||
|
<VBtn
|
||||||
|
prepend-icon="tabler-edit"
|
||||||
|
@click="openEditDialog"
|
||||||
|
>
|
||||||
|
Bewerken
|
||||||
|
</VBtn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- User Info -->
|
||||||
|
<VRow class="mb-6">
|
||||||
|
<VCol
|
||||||
|
cols="12"
|
||||||
|
md="6"
|
||||||
|
>
|
||||||
|
<VCard>
|
||||||
|
<VCardTitle>Profiel</VCardTitle>
|
||||||
|
<VCardText>
|
||||||
|
<VRow>
|
||||||
|
<VCol
|
||||||
|
cols="6"
|
||||||
|
>
|
||||||
|
<p class="text-body-2 text-disabled mb-1">
|
||||||
|
Tijdzone
|
||||||
|
</p>
|
||||||
|
<p class="text-body-1">
|
||||||
|
{{ user.timezone }}
|
||||||
|
</p>
|
||||||
|
</VCol>
|
||||||
|
<VCol
|
||||||
|
cols="6"
|
||||||
|
>
|
||||||
|
<p class="text-body-2 text-disabled mb-1">
|
||||||
|
Taal
|
||||||
|
</p>
|
||||||
|
<p class="text-body-1">
|
||||||
|
{{ user.locale === 'nl' ? 'Nederlands' : 'English' }}
|
||||||
|
</p>
|
||||||
|
</VCol>
|
||||||
|
<VCol
|
||||||
|
cols="6"
|
||||||
|
>
|
||||||
|
<p class="text-body-2 text-disabled mb-1">
|
||||||
|
E-mail geverifieerd
|
||||||
|
</p>
|
||||||
|
<p class="text-body-1">
|
||||||
|
<VIcon
|
||||||
|
:icon="user.email_verified_at ? 'tabler-circle-check' : 'tabler-circle-x'"
|
||||||
|
:color="user.email_verified_at ? 'success' : 'error'"
|
||||||
|
size="18"
|
||||||
|
class="me-1"
|
||||||
|
/>
|
||||||
|
{{ user.email_verified_at ? formatDate(user.email_verified_at) : 'Niet geverifieerd' }}
|
||||||
|
</p>
|
||||||
|
</VCol>
|
||||||
|
<VCol
|
||||||
|
cols="6"
|
||||||
|
>
|
||||||
|
<p class="text-body-2 text-disabled mb-1">
|
||||||
|
Aangemaakt
|
||||||
|
</p>
|
||||||
|
<p class="text-body-1">
|
||||||
|
{{ formatDate(user.created_at) }}
|
||||||
|
</p>
|
||||||
|
</VCol>
|
||||||
|
</VRow>
|
||||||
|
</VCardText>
|
||||||
|
</VCard>
|
||||||
|
</VCol>
|
||||||
|
|
||||||
|
<!-- Organisations -->
|
||||||
|
<VCol
|
||||||
|
cols="12"
|
||||||
|
md="6"
|
||||||
|
>
|
||||||
|
<VCard>
|
||||||
|
<VCardTitle>Organisaties</VCardTitle>
|
||||||
|
<VCardText
|
||||||
|
v-if="user.organisations.length === 0"
|
||||||
|
class="text-disabled"
|
||||||
|
>
|
||||||
|
Geen organisaties
|
||||||
|
</VCardText>
|
||||||
|
<VList
|
||||||
|
v-else
|
||||||
|
lines="one"
|
||||||
|
>
|
||||||
|
<VListItem
|
||||||
|
v-for="org in user.organisations"
|
||||||
|
:key="org.id"
|
||||||
|
:to="{ name: 'platform-organisations-id', params: { id: org.id } }"
|
||||||
|
>
|
||||||
|
<VListItemTitle>{{ org.name }}</VListItemTitle>
|
||||||
|
<template #append>
|
||||||
|
<VChip
|
||||||
|
:color="roleColorMap[org.role] ?? 'default'"
|
||||||
|
size="x-small"
|
||||||
|
>
|
||||||
|
{{ org.role }}
|
||||||
|
</VChip>
|
||||||
|
</template>
|
||||||
|
</VListItem>
|
||||||
|
</VList>
|
||||||
|
</VCard>
|
||||||
|
</VCol>
|
||||||
|
</VRow>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Edit Dialog -->
|
||||||
|
<VDialog
|
||||||
|
v-model="isEditDialogOpen"
|
||||||
|
max-width="500"
|
||||||
|
>
|
||||||
|
<VCard title="Gebruiker bewerken">
|
||||||
|
<VCardText>
|
||||||
|
<VRow>
|
||||||
|
<VCol
|
||||||
|
cols="12"
|
||||||
|
md="6"
|
||||||
|
>
|
||||||
|
<AppTextField
|
||||||
|
v-model="editForm.first_name"
|
||||||
|
label="Voornaam"
|
||||||
|
/>
|
||||||
|
</VCol>
|
||||||
|
<VCol
|
||||||
|
cols="12"
|
||||||
|
md="6"
|
||||||
|
>
|
||||||
|
<AppTextField
|
||||||
|
v-model="editForm.last_name"
|
||||||
|
label="Achternaam"
|
||||||
|
/>
|
||||||
|
</VCol>
|
||||||
|
<VCol cols="12">
|
||||||
|
<AppTextField
|
||||||
|
v-model="editForm.email"
|
||||||
|
label="E-mail"
|
||||||
|
type="email"
|
||||||
|
/>
|
||||||
|
</VCol>
|
||||||
|
<VCol
|
||||||
|
cols="12"
|
||||||
|
md="6"
|
||||||
|
>
|
||||||
|
<AppTextField
|
||||||
|
v-model="editForm.timezone"
|
||||||
|
label="Tijdzone"
|
||||||
|
/>
|
||||||
|
</VCol>
|
||||||
|
<VCol
|
||||||
|
cols="12"
|
||||||
|
md="6"
|
||||||
|
>
|
||||||
|
<AppSelect
|
||||||
|
v-model="editForm.locale"
|
||||||
|
:items="[{ title: 'Nederlands', value: 'nl' }, { title: 'English', value: 'en' }]"
|
||||||
|
label="Taal"
|
||||||
|
/>
|
||||||
|
</VCol>
|
||||||
|
<VCol cols="12">
|
||||||
|
<AppSelect
|
||||||
|
v-model="editForm.roles"
|
||||||
|
:items="platformRoleOptions"
|
||||||
|
label="Platform rollen"
|
||||||
|
multiple
|
||||||
|
chips
|
||||||
|
closable-chips
|
||||||
|
/>
|
||||||
|
</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>
|
||||||
|
|
||||||
|
<!-- Impersonate Dialog -->
|
||||||
|
<VDialog
|
||||||
|
v-model="isImpersonateDialogOpen"
|
||||||
|
max-width="400"
|
||||||
|
>
|
||||||
|
<VCard title="Inloggen als gebruiker">
|
||||||
|
<VCardText>
|
||||||
|
Je gaat inloggen als <strong>{{ user?.full_name }}</strong>
|
||||||
|
({{ user?.email }}). Wil je doorgaan?
|
||||||
|
</VCardText>
|
||||||
|
<VCardActions>
|
||||||
|
<VSpacer />
|
||||||
|
<VBtn
|
||||||
|
variant="text"
|
||||||
|
@click="isImpersonateDialogOpen = false"
|
||||||
|
>
|
||||||
|
Annuleren
|
||||||
|
</VBtn>
|
||||||
|
<VBtn
|
||||||
|
color="warning"
|
||||||
|
:loading="isImpersonating"
|
||||||
|
@click="confirmImpersonate"
|
||||||
|
>
|
||||||
|
Doorgaan
|
||||||
|
</VBtn>
|
||||||
|
</VCardActions>
|
||||||
|
</VCard>
|
||||||
|
</VDialog>
|
||||||
|
|
||||||
|
<!-- Success snackbar -->
|
||||||
|
<VSnackbar
|
||||||
|
v-model="showEditSuccess"
|
||||||
|
color="success"
|
||||||
|
:timeout="3000"
|
||||||
|
>
|
||||||
|
Gebruiker bijgewerkt
|
||||||
|
</VSnackbar>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
364
apps/app/src/pages/platform/users/index.vue
Normal file
364
apps/app/src/pages/platform/users/index.vue
Normal file
@@ -0,0 +1,364 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import {
|
||||||
|
useAdminUsers,
|
||||||
|
useStartImpersonation,
|
||||||
|
useDeleteAdminUser,
|
||||||
|
} from '@/composables/api/useAdmin'
|
||||||
|
import { useImpersonationStore } from '@/stores/useImpersonationStore'
|
||||||
|
import type { AdminUser } from '@/types/admin'
|
||||||
|
|
||||||
|
definePage({
|
||||||
|
meta: {
|
||||||
|
navActiveLink: 'platform-users',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const impersonationStore = useImpersonationStore()
|
||||||
|
|
||||||
|
const search = ref('')
|
||||||
|
const searchDebounced = refDebounced(search, 400)
|
||||||
|
const organisationFilter = ref('')
|
||||||
|
const roleFilter = ref('')
|
||||||
|
const page = ref(1)
|
||||||
|
const itemsPerPage = ref(15)
|
||||||
|
|
||||||
|
const params = computed(() => ({
|
||||||
|
page: page.value,
|
||||||
|
per_page: itemsPerPage.value,
|
||||||
|
search: searchDebounced.value || undefined,
|
||||||
|
organisation_id: organisationFilter.value || undefined,
|
||||||
|
role: roleFilter.value || undefined,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const { data, isLoading, isError, refetch } = useAdminUsers(params)
|
||||||
|
|
||||||
|
const users = computed(() => data.value?.data ?? [])
|
||||||
|
const totalItems = computed(() => data.value?.meta?.total ?? 0)
|
||||||
|
|
||||||
|
const roleOptions = [
|
||||||
|
{ title: 'Alle rollen', value: '' },
|
||||||
|
{ title: 'Super Admin', value: 'super_admin' },
|
||||||
|
{ title: 'Org Admin', value: 'org_admin' },
|
||||||
|
{ title: 'Org Member', value: 'org_member' },
|
||||||
|
{ title: 'Event Manager', value: 'event_manager' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const headers = [
|
||||||
|
{ title: 'Naam', key: 'full_name' },
|
||||||
|
{ title: 'E-mail', key: 'email' },
|
||||||
|
{ title: 'Organisaties', key: 'organisations', sortable: false },
|
||||||
|
{ title: 'Rollen', key: 'roles', sortable: false },
|
||||||
|
{ title: 'Geverifieerd', key: 'email_verified_at', sortable: false, align: 'center' as const },
|
||||||
|
{ title: 'Aangemaakt', key: 'created_at', sortable: false },
|
||||||
|
{ title: '', key: 'actions', sortable: false, align: 'end' as const },
|
||||||
|
]
|
||||||
|
|
||||||
|
// Impersonation
|
||||||
|
const isImpersonateDialogOpen = ref(false)
|
||||||
|
const userToImpersonate = ref<AdminUser | null>(null)
|
||||||
|
const { mutate: startImpersonation, isPending: isImpersonating } = useStartImpersonation()
|
||||||
|
|
||||||
|
function openImpersonateDialog(user: AdminUser) {
|
||||||
|
userToImpersonate.value = user
|
||||||
|
isImpersonateDialogOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmImpersonate() {
|
||||||
|
if (!userToImpersonate.value) return
|
||||||
|
|
||||||
|
startImpersonation(userToImpersonate.value.id, {
|
||||||
|
onSuccess: (result) => {
|
||||||
|
isImpersonateDialogOpen.value = false
|
||||||
|
impersonationStore.startImpersonation(result.token, result.user, result.admin_id)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete
|
||||||
|
const isDeleteDialogOpen = ref(false)
|
||||||
|
const userToDelete = ref<AdminUser | null>(null)
|
||||||
|
const { mutate: deleteUser, isPending: isDeleting } = useDeleteAdminUser()
|
||||||
|
const showDeleteSuccess = ref(false)
|
||||||
|
|
||||||
|
function openDeleteDialog(user: AdminUser) {
|
||||||
|
userToDelete.value = user
|
||||||
|
isDeleteDialogOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmDelete() {
|
||||||
|
if (!userToDelete.value) return
|
||||||
|
|
||||||
|
deleteUser(userToDelete.value.id, {
|
||||||
|
onSuccess: () => {
|
||||||
|
isDeleteDialogOpen.value = false
|
||||||
|
userToDelete.value = null
|
||||||
|
showDeleteSuccess.value = true
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(iso: string): string {
|
||||||
|
return new Date(iso).toLocaleDateString('nl-NL', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function getInitials(name: string): string {
|
||||||
|
return name
|
||||||
|
.split(' ')
|
||||||
|
.map(p => p[0])
|
||||||
|
.filter(Boolean)
|
||||||
|
.slice(0, 2)
|
||||||
|
.join('')
|
||||||
|
.toUpperCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
function onRowClick(_event: Event, { item }: { item: AdminUser }) {
|
||||||
|
router.push({ name: 'platform-users-id', params: { id: item.id } })
|
||||||
|
}
|
||||||
|
|
||||||
|
function onUpdateOptions(options: { page: number; itemsPerPage: number }) {
|
||||||
|
page.value = options.page
|
||||||
|
itemsPerPage.value = options.itemsPerPage
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="d-flex align-center justify-space-between mb-6">
|
||||||
|
<div>
|
||||||
|
<h4 class="text-h4">
|
||||||
|
Gebruikers
|
||||||
|
</h4>
|
||||||
|
<p class="text-body-1 text-disabled mb-0">
|
||||||
|
Alle gebruikers op het platform
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error -->
|
||||||
|
<VAlert
|
||||||
|
v-if="isError"
|
||||||
|
type="error"
|
||||||
|
class="mb-4"
|
||||||
|
>
|
||||||
|
Kon gebruikers niet laden.
|
||||||
|
<template #append>
|
||||||
|
<VBtn
|
||||||
|
variant="text"
|
||||||
|
@click="refetch()"
|
||||||
|
>
|
||||||
|
Opnieuw proberen
|
||||||
|
</VBtn>
|
||||||
|
</template>
|
||||||
|
</VAlert>
|
||||||
|
|
||||||
|
<VCard>
|
||||||
|
<!-- Filters -->
|
||||||
|
<VCardText>
|
||||||
|
<VRow>
|
||||||
|
<VCol
|
||||||
|
cols="12"
|
||||||
|
md="6"
|
||||||
|
>
|
||||||
|
<AppTextField
|
||||||
|
v-model="search"
|
||||||
|
placeholder="Zoek op naam of e-mail..."
|
||||||
|
prepend-inner-icon="tabler-search"
|
||||||
|
clearable
|
||||||
|
/>
|
||||||
|
</VCol>
|
||||||
|
<VCol
|
||||||
|
cols="12"
|
||||||
|
md="3"
|
||||||
|
>
|
||||||
|
<AppSelect
|
||||||
|
v-model="roleFilter"
|
||||||
|
:items="roleOptions"
|
||||||
|
placeholder="Rol"
|
||||||
|
clearable
|
||||||
|
/>
|
||||||
|
</VCol>
|
||||||
|
</VRow>
|
||||||
|
</VCardText>
|
||||||
|
|
||||||
|
<VDataTableServer
|
||||||
|
:headers="headers"
|
||||||
|
:items="users"
|
||||||
|
:items-length="totalItems"
|
||||||
|
:loading="isLoading"
|
||||||
|
:items-per-page="itemsPerPage"
|
||||||
|
:page="page"
|
||||||
|
hover
|
||||||
|
@update:options="onUpdateOptions"
|
||||||
|
@click:row="onRowClick"
|
||||||
|
>
|
||||||
|
<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.organisations="{ item }">
|
||||||
|
<div class="d-flex flex-wrap gap-1">
|
||||||
|
<VChip
|
||||||
|
v-for="org in item.organisations.slice(0, 3)"
|
||||||
|
:key="org.id"
|
||||||
|
size="x-small"
|
||||||
|
>
|
||||||
|
{{ org.name }}
|
||||||
|
</VChip>
|
||||||
|
<VChip
|
||||||
|
v-if="item.organisations.length > 3"
|
||||||
|
size="x-small"
|
||||||
|
color="secondary"
|
||||||
|
>
|
||||||
|
+{{ item.organisations.length - 3 }}
|
||||||
|
</VChip>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #item.roles="{ item }">
|
||||||
|
<div class="d-flex flex-wrap gap-1">
|
||||||
|
<VChip
|
||||||
|
v-for="role in item.roles"
|
||||||
|
:key="role"
|
||||||
|
size="x-small"
|
||||||
|
:color="role === 'super_admin' ? 'error' : 'primary'"
|
||||||
|
>
|
||||||
|
{{ role }}
|
||||||
|
</VChip>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #item.email_verified_at="{ item }">
|
||||||
|
<VIcon
|
||||||
|
:icon="item.email_verified_at ? 'tabler-circle-check' : 'tabler-circle-x'"
|
||||||
|
:color="item.email_verified_at ? 'success' : 'error'"
|
||||||
|
size="20"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #item.created_at="{ item }">
|
||||||
|
{{ formatDate(item.created_at) }}
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #item.actions="{ item }">
|
||||||
|
<div
|
||||||
|
class="d-flex gap-x-1 justify-end"
|
||||||
|
@click.stop
|
||||||
|
>
|
||||||
|
<VBtn
|
||||||
|
icon="tabler-user-share"
|
||||||
|
variant="text"
|
||||||
|
size="small"
|
||||||
|
:disabled="item.is_super_admin"
|
||||||
|
@click="openImpersonateDialog(item)"
|
||||||
|
/>
|
||||||
|
<VBtn
|
||||||
|
icon="tabler-trash"
|
||||||
|
variant="text"
|
||||||
|
size="small"
|
||||||
|
color="error"
|
||||||
|
@click="openDeleteDialog(item)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Empty -->
|
||||||
|
<template #no-data>
|
||||||
|
<div class="text-center pa-4 text-disabled">
|
||||||
|
<VIcon
|
||||||
|
icon="tabler-users-minus"
|
||||||
|
size="48"
|
||||||
|
class="mb-2"
|
||||||
|
/>
|
||||||
|
<p>Geen gebruikers gevonden</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</VDataTableServer>
|
||||||
|
</VCard>
|
||||||
|
|
||||||
|
<!-- Impersonate Dialog -->
|
||||||
|
<VDialog
|
||||||
|
v-model="isImpersonateDialogOpen"
|
||||||
|
max-width="400"
|
||||||
|
>
|
||||||
|
<VCard title="Inloggen als gebruiker">
|
||||||
|
<VCardText>
|
||||||
|
Je gaat inloggen als <strong>{{ userToImpersonate?.full_name }}</strong>
|
||||||
|
({{ userToImpersonate?.email }}). Wil je doorgaan?
|
||||||
|
</VCardText>
|
||||||
|
<VCardActions>
|
||||||
|
<VSpacer />
|
||||||
|
<VBtn
|
||||||
|
variant="text"
|
||||||
|
@click="isImpersonateDialogOpen = false"
|
||||||
|
>
|
||||||
|
Annuleren
|
||||||
|
</VBtn>
|
||||||
|
<VBtn
|
||||||
|
color="warning"
|
||||||
|
:loading="isImpersonating"
|
||||||
|
@click="confirmImpersonate"
|
||||||
|
>
|
||||||
|
Doorgaan
|
||||||
|
</VBtn>
|
||||||
|
</VCardActions>
|
||||||
|
</VCard>
|
||||||
|
</VDialog>
|
||||||
|
|
||||||
|
<!-- Delete Dialog -->
|
||||||
|
<VDialog
|
||||||
|
v-model="isDeleteDialogOpen"
|
||||||
|
max-width="400"
|
||||||
|
>
|
||||||
|
<VCard title="Gebruiker verwijderen">
|
||||||
|
<VCardText>
|
||||||
|
Weet je zeker dat je <strong>{{ userToDelete?.full_name }}</strong> wilt verwijderen?
|
||||||
|
</VCardText>
|
||||||
|
<VCardActions>
|
||||||
|
<VSpacer />
|
||||||
|
<VBtn
|
||||||
|
variant="text"
|
||||||
|
@click="isDeleteDialogOpen = false"
|
||||||
|
>
|
||||||
|
Annuleren
|
||||||
|
</VBtn>
|
||||||
|
<VBtn
|
||||||
|
color="error"
|
||||||
|
:loading="isDeleting"
|
||||||
|
@click="confirmDelete"
|
||||||
|
>
|
||||||
|
Verwijderen
|
||||||
|
</VBtn>
|
||||||
|
</VCardActions>
|
||||||
|
</VCard>
|
||||||
|
</VDialog>
|
||||||
|
|
||||||
|
<!-- Snackbars -->
|
||||||
|
<VSnackbar
|
||||||
|
v-model="showDeleteSuccess"
|
||||||
|
color="success"
|
||||||
|
:timeout="3000"
|
||||||
|
>
|
||||||
|
Gebruiker verwijderd
|
||||||
|
</VSnackbar>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -44,6 +44,17 @@ export function setupGuards(router: Router) {
|
|||||||
return { path: '/login', query: { to: to.fullPath } }
|
return { path: '/login', query: { to: to.fullPath } }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Platform admin routes — require super_admin role
|
||||||
|
if (to.path.startsWith('/platform')) {
|
||||||
|
if (!authStore.isSuperAdmin) {
|
||||||
|
if (import.meta.env.DEV) console.log('🚫 Not a super admin, redirecting to dashboard')
|
||||||
|
return { name: 'dashboard' }
|
||||||
|
}
|
||||||
|
// Platform routes don't require organisation selection
|
||||||
|
if (import.meta.env.DEV) console.log('✅ Super admin access to platform route')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Authenticated — check organisation selection for routes that need it
|
// Authenticated — check organisation selection for routes that need it
|
||||||
const orgStore = useOrganisationStore()
|
const orgStore = useOrganisationStore()
|
||||||
const isSelectOrgPage = to.path === '/select-organisation'
|
const isSelectOrgPage = to.path === '/select-organisation'
|
||||||
|
|||||||
74
apps/app/src/stores/useImpersonationStore.ts
Normal file
74
apps/app/src/stores/useImpersonationStore.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import { apiClient } from '@/lib/axios'
|
||||||
|
import { useAuthStore } from '@/stores/useAuthStore'
|
||||||
|
import type { AdminUser } from '@/types/admin'
|
||||||
|
|
||||||
|
const IMPERSONATION_KEY = 'crewli_impersonation'
|
||||||
|
|
||||||
|
interface ImpersonationState {
|
||||||
|
adminId: string
|
||||||
|
originalToken: string
|
||||||
|
impersonatedUser: AdminUser
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useImpersonationStore = defineStore('impersonation', () => {
|
||||||
|
const stored = localStorage.getItem(IMPERSONATION_KEY)
|
||||||
|
const state = ref<ImpersonationState | null>(stored ? JSON.parse(stored) : null)
|
||||||
|
|
||||||
|
const isImpersonating = computed(() => !!state.value)
|
||||||
|
const originalAdminId = computed(() => state.value?.adminId ?? null)
|
||||||
|
const impersonatedUser = computed(() => state.value?.impersonatedUser ?? null)
|
||||||
|
|
||||||
|
function startImpersonation(token: string, user: AdminUser, adminId: string) {
|
||||||
|
// Store the current cookie token reference (we'll restore it on stop)
|
||||||
|
// Since the app uses httpOnly cookies, we store the admin ID to know we're impersonating
|
||||||
|
state.value = {
|
||||||
|
adminId,
|
||||||
|
originalToken: '', // httpOnly cookie — we can't read it, but we track the state
|
||||||
|
impersonatedUser: user,
|
||||||
|
}
|
||||||
|
localStorage.setItem(IMPERSONATION_KEY, JSON.stringify(state.value))
|
||||||
|
|
||||||
|
// The impersonation token from the API is a plain Sanctum token.
|
||||||
|
// Set it as a Bearer token header for subsequent requests.
|
||||||
|
apiClient.defaults.headers.common.Authorization = `Bearer ${token}`
|
||||||
|
|
||||||
|
// Reload user state to reflect the impersonated user
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
authStore.initialize()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stopImpersonation() {
|
||||||
|
try {
|
||||||
|
await apiClient.post('/admin/stop-impersonation')
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
// Even if the API call fails, restore local state
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove the Bearer token so httpOnly cookie takes over again
|
||||||
|
delete apiClient.defaults.headers.common.Authorization
|
||||||
|
|
||||||
|
state.value = null
|
||||||
|
localStorage.removeItem(IMPERSONATION_KEY)
|
||||||
|
|
||||||
|
// Full reload to restore admin session from httpOnly cookie
|
||||||
|
window.location.href = '/platform'
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearWithoutReload() {
|
||||||
|
state.value = null
|
||||||
|
localStorage.removeItem(IMPERSONATION_KEY)
|
||||||
|
delete apiClient.defaults.headers.common.Authorization
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
isImpersonating,
|
||||||
|
originalAdminId,
|
||||||
|
impersonatedUser,
|
||||||
|
startImpersonation,
|
||||||
|
stopImpersonation,
|
||||||
|
clearWithoutReload,
|
||||||
|
}
|
||||||
|
})
|
||||||
89
apps/app/src/types/admin.ts
Normal file
89
apps/app/src/types/admin.ts
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
export type BillingStatus = 'trial' | 'active' | 'suspended' | 'cancelled'
|
||||||
|
|
||||||
|
export interface AdminOrganisation {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
slug: string
|
||||||
|
billing_status: BillingStatus
|
||||||
|
billing_status_label: string
|
||||||
|
settings: Record<string, unknown> | null
|
||||||
|
events_count: number
|
||||||
|
users_count: number
|
||||||
|
total_persons: number
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
deleted_at: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminUser {
|
||||||
|
id: string
|
||||||
|
first_name: string
|
||||||
|
last_name: string
|
||||||
|
full_name: string
|
||||||
|
email: string
|
||||||
|
avatar: string | null
|
||||||
|
timezone: string
|
||||||
|
locale: string
|
||||||
|
email_verified_at: string | null
|
||||||
|
created_at: string
|
||||||
|
is_super_admin: boolean
|
||||||
|
roles: string[]
|
||||||
|
organisations: Array<{
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
slug: string
|
||||||
|
role: string
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlatformStats {
|
||||||
|
organisations: {
|
||||||
|
total: number
|
||||||
|
by_billing_status: Record<string, number>
|
||||||
|
}
|
||||||
|
events: {
|
||||||
|
total: number
|
||||||
|
by_status: Record<string, number>
|
||||||
|
}
|
||||||
|
users: {
|
||||||
|
total: number
|
||||||
|
verified: number
|
||||||
|
}
|
||||||
|
persons: {
|
||||||
|
total: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActivityLogEntry {
|
||||||
|
id: number
|
||||||
|
log_name: string | null
|
||||||
|
description: string
|
||||||
|
event: string | null
|
||||||
|
causer: { id: string; name: string; email: string } | null
|
||||||
|
subject_type: string | null
|
||||||
|
subject_id: string | null
|
||||||
|
properties: Record<string, unknown>
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImpersonationResponse {
|
||||||
|
token: string
|
||||||
|
user: AdminUser
|
||||||
|
admin_id: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateAdminOrganisationPayload {
|
||||||
|
name?: string
|
||||||
|
slug?: string
|
||||||
|
billing_status?: BillingStatus
|
||||||
|
settings?: Record<string, unknown> | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateAdminUserPayload {
|
||||||
|
first_name?: string
|
||||||
|
last_name?: string
|
||||||
|
email?: string
|
||||||
|
timezone?: string
|
||||||
|
locale?: string
|
||||||
|
roles?: string[] | null
|
||||||
|
}
|
||||||
6
apps/app/typed-router.d.ts
vendored
6
apps/app/typed-router.d.ts
vendored
@@ -40,6 +40,12 @@ declare module 'vue-router/auto-routes' {
|
|||||||
'organisation-companies': RouteRecordInfo<'organisation-companies', '/organisation/companies', Record<never, never>, Record<never, never>>,
|
'organisation-companies': RouteRecordInfo<'organisation-companies', '/organisation/companies', Record<never, never>, Record<never, never>>,
|
||||||
'organisation-members': RouteRecordInfo<'organisation-members', '/organisation/members', 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-settings': RouteRecordInfo<'organisation-settings', '/organisation/settings', 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>>,
|
||||||
|
'platform-organisations-id': RouteRecordInfo<'platform-organisations-id', '/platform/organisations/:id', { id: ParamValue<true> }, { id: ParamValue<false> }>,
|
||||||
|
'platform-users': RouteRecordInfo<'platform-users', '/platform/users', Record<never, never>, Record<never, never>>,
|
||||||
|
'platform-users-id': RouteRecordInfo<'platform-users-id', '/platform/users/:id', { id: ParamValue<true> }, { id: ParamValue<false> }>,
|
||||||
'reset-password': RouteRecordInfo<'reset-password', '/reset-password', Record<never, never>, Record<never, never>>,
|
'reset-password': RouteRecordInfo<'reset-password', '/reset-password', Record<never, never>, Record<never, never>>,
|
||||||
'select-organisation': RouteRecordInfo<'select-organisation', '/select-organisation', Record<never, never>, Record<never, never>>,
|
'select-organisation': RouteRecordInfo<'select-organisation', '/select-organisation', Record<never, never>, Record<never, never>>,
|
||||||
'verify-email-change': RouteRecordInfo<'verify-email-change', '/verify-email-change', Record<never, never>, Record<never, never>>,
|
'verify-email-change': RouteRecordInfo<'verify-email-change', '/verify-email-change', Record<never, never>, Record<never, never>>,
|
||||||
|
|||||||
Reference in New Issue
Block a user