feat(appshell): add org-switcher card and bump sidebar width to w-72

Introduces SidebarHeader.vue — a PrimeVue-only org-switcher that
replaces the centered Crewli wordmark at the top of the sidebar. The
component mirrors the legacy Vuetify OrganisationSwitcher (avatar with
org initials, organisation name, plan-tier placeholder, dropdown
chevron, PrimeVue Menu of available orgs) but cannot reuse it
directly per the R-10 layout-shell-isolation invariant.

Plan-tier shows a hardcoded "Pro" placeholder until the backend
Organisation resource exposes a plan field — tracked separately, not
in F3.5 scope. When the user has no active organisation (portal
users, fresh super_admin), the component degrades to the original
title block so PortalLayout continues to read "Crewli Portal".

Desktop sidebar width bumped w-64 → w-72 (256 → 288 px) to give the
org-switcher card breathing room and accommodate the user-info card
arriving in B3. Mobile Drawer width bumped 16rem → 18rem to match.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-12 00:41:09 +02:00
parent a17dbb7dfd
commit 8f3a404a42
2 changed files with 109 additions and 7 deletions

View File

@@ -94,10 +94,8 @@ function toggleUserMenu(event: Event) {
<template>
<div class="crewli-app-shell flex min-h-screen">
<!-- Desktop sidebar (lg+) -->
<aside class="hidden lg:flex w-64 flex-col border-r border-surface-200 bg-surface-0">
<div class="flex h-16 items-center justify-center border-b border-surface-200">
<span class="text-xl font-semibold text-primary-500">{{ title }}</span>
</div>
<aside class="hidden lg:flex w-72 flex-col border-r border-surface-200 bg-surface-0">
<SidebarHeader :title="title" />
<nav class="flex-1 overflow-y-auto p-3">
<template
v-for="(item, idx) in navItems"
@@ -130,12 +128,12 @@ function toggleUserMenu(event: Event) {
v-model:visible="mobileNavOpen"
position="left"
class="lg:hidden"
:pt="{ root: { style: { width: '16rem' } } }"
:pt="{ root: { style: { width: '18rem' } }, header: { class: 'p-0' } }"
>
<template #header>
<span class="text-lg font-semibold text-primary-500">{{ title }}</span>
<SidebarHeader :title="title" />
</template>
<nav class="flex flex-col">
<nav class="flex flex-col p-3">
<template
v-for="(item, idx) in navItems"
:key="idx"

View File

@@ -0,0 +1,104 @@
<script setup lang="ts">
// SidebarHeader — top-of-sidebar org-switcher card introduced in F3.5
// per the AppShell mockup-parity sprint. PrimeVue-only rewrite of the
// legacy Vuetify OrganisationSwitcher (apps/app/src/components/layout/
// OrganisationSwitcher.vue), which cannot be reused inside AppShell
// per the R-10 layout-shell-isolation invariant.
//
// When the auth store has no active organisation (e.g. portal users,
// or a fresh super_admin without an org), the component degrades to a
// plain title block — the same visual the F3 AppShell shipped with.
import { computed, ref } from 'vue'
import Menu from 'primevue/menu'
import Avatar from 'primevue/avatar'
import Icon from '@/components/Icon.vue'
import { useAuthStore } from '@/stores/useAuthStore'
interface Props {
title?: string
}
withDefaults(defineProps<Props>(), {
title: 'Crewli',
})
const authStore = useAuthStore()
const menuRef = ref<InstanceType<typeof Menu> | null>(null)
const currentOrg = computed(() => authStore.currentOrganisation)
const organisations = computed(() => authStore.organisations)
const hasSwitcher = computed(() => !!currentOrg.value)
const hasMultiple = computed(() => organisations.value.length > 1)
const orgInitials = computed(() => {
const name = currentOrg.value?.name ?? ''
const parts = name.split(/\s+/).filter(Boolean).slice(0, 2)
const initials = parts.map(w => w[0]?.toUpperCase() ?? '').join('')
return initials || '?'
})
// Plan tier placeholder. Backend `Organisation` resource does not yet
// expose a plan field (see types/auth.ts:17-27); 'Pro' is hardcoded
// per Bert's A6.1 decision until that field lands.
const planLabel = 'Pro'
const menuItems = computed(() =>
organisations.value.map(org => ({
label: org.name,
command: () => authStore.setActiveOrganisation(org.id),
})),
)
function toggleMenu(event: Event) {
if (!hasMultiple.value)
return
menuRef.value?.toggle(event)
}
</script>
<template>
<div class="border-b border-surface-200">
<button
v-if="hasSwitcher"
type="button"
class="flex w-full items-center gap-3 px-3 py-3 text-left transition enabled:hover:bg-surface-100 disabled:cursor-default"
:disabled="!hasMultiple"
:aria-label="hasMultiple ? 'Organisatie wisselen' : currentOrg?.name"
:aria-haspopup="hasMultiple ? 'menu' : undefined"
@click="toggleMenu"
>
<Avatar
:label="orgInitials"
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">
{{ currentOrg?.name }}
</span>
<span class="text-xs text-surface-500">{{ planLabel }}</span>
</div>
<Icon
v-if="hasMultiple"
name="tabler-chevron-down"
size="18"
class="shrink-0 text-surface-500"
/>
</button>
<div
v-else
class="flex h-16 items-center justify-center px-3"
>
<span class="text-xl font-semibold text-primary-500">{{ title }}</span>
</div>
<Menu
ref="menuRef"
:model="menuItems"
:popup="true"
/>
</div>
</template>