feat(appshell): add user-info card to sidebar bottom; remove topbar avatar
Consolidates the user menu into a single sidebar-bottom location. SidebarUserCard.vue shows avatar (initial), full name, role (Dutch label mapped from org pivot role or 'Super Admin' fallback) and a chevron-up that opens a PrimeVue Menu with "Mijn Profiel" and "Uitloggen". The Menu uses popup mode; PrimeVue v4's absolutePosition logic auto-flips above the trigger when the panel would overflow the viewport bottom — verify in Phase C. AppShell loses the topbar avatar Button + Menu and the associated state (userMenuRef, userInitial, userMenuItems, toggleUserMenu) plus its imports (Avatar, Menu, useAuthStore, computed). The component is now a pure layout shell with no auth-store coupling. The topbar's right side is intentionally empty in this commit; B4 fills it with breadcrumb / notification bell / help icon. Layout: nav uses min-h-0 flex-1 overflow-y-auto so it shrinks under viewport pressure and lets the user card stay pinned at the bottom of the sidebar. Mobile Drawer's content pt-override sets the same flex-column behaviour so the user card sits flush at the bottom of the drawer overlay. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -16,14 +16,11 @@
|
|||||||
// PrimeVue Drawer overlay. Content area renders the default slot
|
// PrimeVue Drawer overlay. Content area renders the default slot
|
||||||
// (a RouterView from the wrapping layout file).
|
// (a RouterView from the wrapping layout file).
|
||||||
|
|
||||||
import { computed, ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import Drawer from 'primevue/drawer'
|
import Drawer from 'primevue/drawer'
|
||||||
import Button from 'primevue/button'
|
import Button from 'primevue/button'
|
||||||
import Avatar from 'primevue/avatar'
|
|
||||||
import Menu from 'primevue/menu'
|
|
||||||
import Icon from '@/components/Icon.vue'
|
import Icon from '@/components/Icon.vue'
|
||||||
import { useAuthStore } from '@/stores/useAuthStore'
|
|
||||||
|
|
||||||
interface NavHeading {
|
interface NavHeading {
|
||||||
heading: string
|
heading: string
|
||||||
@@ -45,37 +42,8 @@ withDefaults(defineProps<Props>(), {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const authStore = useAuthStore()
|
|
||||||
|
|
||||||
const mobileNavOpen = ref(false)
|
const mobileNavOpen = ref(false)
|
||||||
const userMenuRef = ref<InstanceType<typeof Menu> | null>(null)
|
|
||||||
|
|
||||||
const userInitial = computed(() => {
|
|
||||||
const name = authStore.user?.full_name ?? ''
|
|
||||||
|
|
||||||
return name.charAt(0).toUpperCase() || '?'
|
|
||||||
})
|
|
||||||
|
|
||||||
const userMenuItems = computed(() => [
|
|
||||||
{
|
|
||||||
label: authStore.user?.full_name ?? 'Gebruiker',
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
label: 'Mijn Profiel',
|
|
||||||
icon: 'tabler-user',
|
|
||||||
command: () => router.push({ name: 'account-settings' }),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Uitloggen',
|
|
||||||
icon: 'tabler-logout',
|
|
||||||
command: async () => {
|
|
||||||
await authStore.logout()
|
|
||||||
await router.push('/login')
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
])
|
|
||||||
|
|
||||||
function isHeading(item: NavItem): item is NavHeading {
|
function isHeading(item: NavItem): item is NavHeading {
|
||||||
return 'heading' in item
|
return 'heading' in item
|
||||||
@@ -85,10 +53,6 @@ function navigate(item: NavLink) {
|
|||||||
mobileNavOpen.value = false
|
mobileNavOpen.value = false
|
||||||
router.push(item.to)
|
router.push(item.to)
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleUserMenu(event: Event) {
|
|
||||||
userMenuRef.value?.toggle(event)
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -96,7 +60,7 @@ function toggleUserMenu(event: Event) {
|
|||||||
<!-- Desktop sidebar (lg+) -->
|
<!-- Desktop sidebar (lg+) -->
|
||||||
<aside class="hidden lg:flex w-72 flex-col border-r border-surface-200 bg-surface-0">
|
<aside class="hidden lg:flex w-72 flex-col border-r border-surface-200 bg-surface-0">
|
||||||
<SidebarHeader :title="title" />
|
<SidebarHeader :title="title" />
|
||||||
<nav class="flex-1 overflow-y-auto p-3">
|
<nav class="min-h-0 flex-1 overflow-y-auto p-3">
|
||||||
<template
|
<template
|
||||||
v-for="(item, idx) in navItems"
|
v-for="(item, idx) in navItems"
|
||||||
:key="idx"
|
:key="idx"
|
||||||
@@ -121,6 +85,7 @@ function toggleUserMenu(event: Event) {
|
|||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
</nav>
|
</nav>
|
||||||
|
<SidebarUserCard />
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<!-- Mobile drawer (overlay, <lg) -->
|
<!-- Mobile drawer (overlay, <lg) -->
|
||||||
@@ -128,12 +93,16 @@ function toggleUserMenu(event: Event) {
|
|||||||
v-model:visible="mobileNavOpen"
|
v-model:visible="mobileNavOpen"
|
||||||
position="left"
|
position="left"
|
||||||
class="lg:hidden"
|
class="lg:hidden"
|
||||||
:pt="{ root: { style: { width: '18rem' } }, header: { class: 'p-0' } }"
|
:pt="{
|
||||||
|
root: { style: { width: '18rem' } },
|
||||||
|
header: { class: 'p-0' },
|
||||||
|
content: { class: 'flex flex-col flex-1 min-h-0 p-0' },
|
||||||
|
}"
|
||||||
>
|
>
|
||||||
<template #header>
|
<template #header>
|
||||||
<SidebarHeader :title="title" />
|
<SidebarHeader :title="title" />
|
||||||
</template>
|
</template>
|
||||||
<nav class="flex flex-col p-3">
|
<nav class="min-h-0 flex-1 overflow-y-auto p-3">
|
||||||
<template
|
<template
|
||||||
v-for="(item, idx) in navItems"
|
v-for="(item, idx) in navItems"
|
||||||
:key="idx"
|
:key="idx"
|
||||||
@@ -158,6 +127,7 @@ function toggleUserMenu(event: Event) {
|
|||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
</nav>
|
</nav>
|
||||||
|
<SidebarUserCard />
|
||||||
</Drawer>
|
</Drawer>
|
||||||
|
|
||||||
<!-- Main column -->
|
<!-- Main column -->
|
||||||
@@ -180,27 +150,6 @@ function toggleUserMenu(event: Event) {
|
|||||||
</Button>
|
</Button>
|
||||||
<span class="text-base font-medium text-surface-700 lg:hidden">{{ title }}</span>
|
<span class="text-base font-medium text-surface-700 lg:hidden">{{ title }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<Button
|
|
||||||
severity="secondary"
|
|
||||||
text
|
|
||||||
rounded
|
|
||||||
aria-label="Gebruikersmenu openen"
|
|
||||||
@click="toggleUserMenu"
|
|
||||||
>
|
|
||||||
<Avatar
|
|
||||||
:label="userInitial"
|
|
||||||
shape="circle"
|
|
||||||
class="bg-primary-500 text-white"
|
|
||||||
/>
|
|
||||||
</Button>
|
|
||||||
<Menu
|
|
||||||
ref="userMenuRef"
|
|
||||||
:model="userMenuItems"
|
|
||||||
:popup="true"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<!-- Page content -->
|
<!-- Page content -->
|
||||||
|
|||||||
113
apps/app/src/layouts/components/SidebarUserCard.vue
Normal file
113
apps/app/src/layouts/components/SidebarUserCard.vue
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
// SidebarUserCard — bottom-of-sidebar user identity + menu, introduced
|
||||||
|
// in F3.5 per Bert's A6.5 decision. Consolidates the user menu in a
|
||||||
|
// single location (sidebar bottom) and removes the topbar avatar
|
||||||
|
// dropdown, eliminating two paths to the same Logout / Profile actions.
|
||||||
|
//
|
||||||
|
// The PrimeVue Menu in popup mode auto-flips above the trigger when
|
||||||
|
// the panel would overflow the viewport bottom (per PrimeVue v4's
|
||||||
|
// DomHandler.absolutePosition logic). The card sits flush against the
|
||||||
|
// bottom of the sidebar, so the dropdown reliably opens upward.
|
||||||
|
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import Menu from 'primevue/menu'
|
||||||
|
import Avatar from 'primevue/avatar'
|
||||||
|
import Icon from '@/components/Icon.vue'
|
||||||
|
import { useAuthStore } from '@/stores/useAuthStore'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
|
const menuRef = ref<InstanceType<typeof Menu> | null>(null)
|
||||||
|
|
||||||
|
const userInitial = computed(() => {
|
||||||
|
const name = authStore.user?.full_name ?? ''
|
||||||
|
|
||||||
|
return name.charAt(0).toUpperCase() || '?'
|
||||||
|
})
|
||||||
|
|
||||||
|
const userName = computed(() => authStore.user?.full_name ?? 'Gebruiker')
|
||||||
|
|
||||||
|
const roleLabels: Record<string, string> = {
|
||||||
|
super_admin: 'Super Admin',
|
||||||
|
org_admin: 'Beheerder',
|
||||||
|
org_member: 'Lid',
|
||||||
|
event_manager: 'Eventmanager',
|
||||||
|
staff_coordinator: 'Staf coördinator',
|
||||||
|
volunteer_coordinator: 'Vrijwilligers coördinator',
|
||||||
|
}
|
||||||
|
|
||||||
|
const roleLabel = computed(() => {
|
||||||
|
const orgRole = authStore.currentOrganisation?.role
|
||||||
|
if (orgRole)
|
||||||
|
return roleLabels[orgRole] ?? orgRole
|
||||||
|
if (authStore.isSuperAdmin)
|
||||||
|
return 'Super Admin'
|
||||||
|
|
||||||
|
return ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const menuItems = computed(() => [
|
||||||
|
{
|
||||||
|
label: userName.value,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
label: 'Mijn Profiel',
|
||||||
|
icon: 'tabler-user',
|
||||||
|
command: () => router.push({ name: 'account-settings' }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Uitloggen',
|
||||||
|
icon: 'tabler-logout',
|
||||||
|
command: async () => {
|
||||||
|
await authStore.logout()
|
||||||
|
await router.push('/login')
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
function toggleMenu(event: Event) {
|
||||||
|
menuRef.value?.toggle(event)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="border-t border-surface-200">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex w-full items-center gap-3 px-3 py-3 text-left transition hover:bg-surface-100"
|
||||||
|
aria-label="Gebruikersmenu openen"
|
||||||
|
aria-haspopup="menu"
|
||||||
|
@click="toggleMenu"
|
||||||
|
>
|
||||||
|
<Avatar
|
||||||
|
:label="userInitial"
|
||||||
|
shape="circle"
|
||||||
|
class="shrink-0 bg-primary-500 text-white"
|
||||||
|
/>
|
||||||
|
<div class="flex min-w-0 flex-1 flex-col">
|
||||||
|
<span class="truncate text-sm font-semibold text-surface-900">
|
||||||
|
{{ userName }}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-if="roleLabel"
|
||||||
|
class="truncate text-xs text-surface-500"
|
||||||
|
>{{ roleLabel }}</span>
|
||||||
|
</div>
|
||||||
|
<Icon
|
||||||
|
name="tabler-chevron-up"
|
||||||
|
size="18"
|
||||||
|
class="shrink-0 text-surface-500"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<Menu
|
||||||
|
ref="menuRef"
|
||||||
|
:model="menuItems"
|
||||||
|
:popup="true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
Reference in New Issue
Block a user