chore(primevue): F3 — PrimeVue foundation with parallel-mode operation #24

Merged
bert.hausmans merged 10 commits from chore/f3-primevue-foundation into main 2026-05-11 20:07:58 +02:00
10 changed files with 415 additions and 407 deletions
Showing only changes of commit 4391550140 - Show all commits

View File

@@ -10,6 +10,7 @@ declare global {
const COOKIE_MAX_AGE_1_YEAR: typeof import('./src/utils/constants')['COOKIE_MAX_AGE_1_YEAR']
const CreateUrl: typeof import('./src/@core/composable/CreateUrl')['CreateUrl']
const EffectScope: typeof import('vue')['EffectScope']
const FORM_API_ERRORS_KEY: typeof import('./src/composables/useFormError')['FORM_API_ERRORS_KEY']
const PUBLIC_FORM_LOCALE_KEY: typeof import('./src/composables/publicFormInjection')['PUBLIC_FORM_LOCALE_KEY']
const PUBLIC_FORM_TOKEN_KEY: typeof import('./src/composables/publicFormInjection')['PUBLIC_FORM_TOKEN_KEY']
const acceptHMRUpdate: typeof import('pinia')['acceptHMRUpdate']
@@ -245,6 +246,7 @@ declare global {
const useFocus: typeof import('@vueuse/core')['useFocus']
const useFocusWithin: typeof import('@vueuse/core')['useFocusWithin']
const useFormDraft: typeof import('./src/composables/useFormDraft')['useFormDraft']
const useFormError: typeof import('./src/composables/useFormError')['useFormError']
const useFps: typeof import('@vueuse/core')['useFps']
const useFullscreen: typeof import('@vueuse/core')['useFullscreen']
const useGamepad: typeof import('@vueuse/core')['useGamepad']
@@ -394,6 +396,7 @@ declare module 'vue' {
interface ComponentCustomProperties {
readonly COOKIE_MAX_AGE_1_YEAR: UnwrapRef<typeof import('./src/utils/constants')['COOKIE_MAX_AGE_1_YEAR']>
readonly EffectScope: UnwrapRef<typeof import('vue')['EffectScope']>
readonly FORM_API_ERRORS_KEY: UnwrapRef<typeof import('./src/composables/useFormError')['FORM_API_ERRORS_KEY']>
readonly PUBLIC_FORM_LOCALE_KEY: UnwrapRef<typeof import('./src/composables/publicFormInjection')['PUBLIC_FORM_LOCALE_KEY']>
readonly PUBLIC_FORM_TOKEN_KEY: UnwrapRef<typeof import('./src/composables/publicFormInjection')['PUBLIC_FORM_TOKEN_KEY']>
readonly acceptHMRUpdate: UnwrapRef<typeof import('pinia')['acceptHMRUpdate']>
@@ -621,6 +624,7 @@ declare module 'vue' {
readonly useFocus: UnwrapRef<typeof import('@vueuse/core')['useFocus']>
readonly useFocusWithin: UnwrapRef<typeof import('@vueuse/core')['useFocusWithin']>
readonly useFormDraft: UnwrapRef<typeof import('./src/composables/useFormDraft')['useFormDraft']>
readonly useFormError: UnwrapRef<typeof import('./src/composables/useFormError')['useFormError']>
readonly useFps: UnwrapRef<typeof import('@vueuse/core')['useFps']>
readonly useFullscreen: UnwrapRef<typeof import('@vueuse/core')['useFullscreen']>
readonly useGamepad: UnwrapRef<typeof import('@vueuse/core')['useGamepad']>

View File

@@ -96,9 +96,11 @@ declare module 'vue' {
FormErrorState: typeof import('./src/components/shared/public-form/FormErrorState.vue')['default']
FormFailureDetail: typeof import('./src/components/form-failures/FormFailureDetail.vue')['default']
FormFailuresTable: typeof import('./src/components/form-failures/FormFailuresTable.vue')['default']
FormField: typeof import('./src/components/forms/FormField.vue')['default']
FormStepper: typeof import('./src/components/shared/public-form/FormStepper.vue')['default']
GridBg: typeof import('./src/components/timetable/GridBg.vue')['default']
I18n: typeof import('./src/@core/components/I18n.vue')['default']
Icon: typeof import('./src/components/Icon.vue')['default']
IdentityMatchBanner: typeof import('./src/components/shared/public-form/IdentityMatchBanner.vue')['default']
ImageUploadField: typeof import('./src/components/common/ImageUploadField.vue')['default']
ImpersonateDialog: typeof import('./src/components/platform/ImpersonateDialog.vue')['default']

View File

@@ -1,9 +1,54 @@
<script setup lang="ts">
import DefaultLayoutWithVerticalNav from '@/layouts/components/DefaultLayoutWithVerticalNav.vue'
// OrganizerLayout — F3 rewrite. Delegates to the PrimeVue-only
// AppShell with org + (conditionally) platform nav items. Replaces
// the previous DefaultLayoutWithVerticalNav (Vuexy) carrier.
//
// Per RFC AD-3 + R-10: filename preserved, contents rewritten in
// isolation. Vuetify-based topbar features (search, theme switcher,
// notifications, org switcher, context switcher, shortcuts,
// impersonation banner) are absent during F3 and return in F4 when
// each is rewritten to PrimeVue. See the B7 commit body for the
// regression list.
import { computed } from 'vue'
import AppShell from '@/layouts/components/AppShell.vue'
import { orgNavItems, platformNavItems } from '@/navigation/vertical'
import { useAuthStore } from '@/stores/useAuthStore'
import { useImpersonationStore } from '@/stores/useImpersonationStore'
const authStore = useAuthStore()
const impersonationStore = useImpersonationStore()
const hasOrganisation = computed(() => !!authStore.organisations.length)
const navItems = computed(() => {
const orgName = authStore.currentOrganisation?.name ?? 'Beheer'
let orgItems = orgNavItems.map(item => {
if ('heading' in item && item.heading === 'Beheer')
return { ...item, heading: orgName }
return item
})
if (impersonationStore.isImpersonating && !hasOrganisation.value) {
orgItems = orgItems.filter(item => {
if ('heading' in item)
return false
return 'to' in item && item.to?.name === 'dashboard'
})
}
if (authStore.isSuperAdmin && !impersonationStore.isImpersonating)
return [...orgItems, ...platformNavItems]
return orgItems
})
</script>
<template>
<DefaultLayoutWithVerticalNav>
<AppShell :nav-items="navItems">
<RouterView />
</DefaultLayoutWithVerticalNav>
</AppShell>
</template>

View File

@@ -1,234 +1,35 @@
<script setup lang="ts">
// Portal layout — content migrated from apps/portal/src/layouts/portal.vue
// during WS-3 PR-B1 (single-SPA consolidation). Used by every page
// under /portal/** plus selected non-auth portal entry points
// (registreren, wachtwoord-instellen). Authenticated portal users see
// the navbar + mobile drawer; unauthenticated visits render only the
// main content + footer.
// PortalLayout — F3 rewrite. Delegates to AppShell with portal nav
// items, replacing the previous Vuexy-based 234-line layout.
//
// Per RFC AD-3 + R-10, filename preserved, contents rewritten. The
// previous PortalLayout's event-mode/platform-mode topbar variant
// (driven by route.meta.navMode), the embedded UserAvatarMenu, and
// the ContextSwitcher widget are absent during F3 — they re-enter in
// F4 when each is rewritten to PrimeVue. See the B7 commit body for
// the regression list.
import { useRoute, useRouter } from 'vue-router'
import type AppLoadingIndicator from '@/components/AppLoadingIndicator.vue'
import ContextSwitcher from '@/components/shared/ContextSwitcher.vue'
import UserAvatarMenu from '@/components/portal/UserAvatarMenu.vue'
import { useAuthStore } from '@/stores/useAuthStore'
import { usePortalStore } from '@/stores/portal/usePortalStore'
import AppShell from '@/layouts/components/AppShell.vue'
const { injectSkinClasses } = useSkins()
injectSkinClasses()
const authStore = useAuthStore()
const portal = usePortalStore()
const route = useRoute()
const router = useRouter()
const isMobileMenuOpen = ref(false)
// Navbar mode: 'event' shows org name + event name + back link.
// Default ('platform') shows Crewli logo + page title.
const isEventMode = computed(() => route.meta.navMode === 'event')
const navTitle = computed(() => route.meta.navTitle)
const eventName = computed(() => portal.activeEvent?.event_name ?? '')
const orgName = computed(() => portal.activeEvent?.organisation_name ?? '')
const mobileNavItems = computed(() => [
{ title: 'Mijn evenementen', to: '/portal/evenementen', icon: 'tabler-calendar-event' },
{ title: 'Mijn Profiel', to: '/portal/profiel', icon: 'tabler-user' },
])
const isFallbackStateActive = ref(false)
const refLoadingIndicator = ref<InstanceType<typeof AppLoadingIndicator> | null>(null)
watch([isFallbackStateActive, refLoadingIndicator], () => {
if (isFallbackStateActive.value && refLoadingIndicator.value)
refLoadingIndicator.value.fallbackHandle()
if (!isFallbackStateActive.value && refLoadingIndicator.value)
refLoadingIndicator.value.resolveHandle()
}, { immediate: true })
async function logout() {
isMobileMenuOpen.value = false
await authStore.logout()
await router.push('/login')
}
const portalNavItems = [
{
title: 'Mijn evenementen',
to: { name: 'portal-evenementen' },
icon: { icon: 'tabler-calendar-event' },
},
{
title: 'Mijn Profiel',
to: { name: 'portal-profiel' },
icon: { icon: 'tabler-user' },
},
]
</script>
<template>
<VApp>
<AppLoadingIndicator ref="refLoadingIndicator" />
<!-- Navbar: only shown when authenticated -->
<VAppBar
v-if="authStore.isAuthenticated"
flat
color="surface"
border="b"
height="64"
>
<VContainer
fluid
class="d-flex align-center py-0"
style="max-inline-size: 1440px;"
>
<!-- Event mode: Org name + Event name + Back link -->
<template v-if="isEventMode">
<div class="d-flex align-center gap-x-2 flex-shrink-0">
<VIcon
icon="tabler-building"
size="24"
color="primary"
/>
<span
v-if="orgName"
class="text-subtitle-1 font-weight-medium text-high-emphasis d-none d-sm-inline text-truncate"
style="max-width: 200px;"
>
{{ orgName }}
</span>
</div>
<span
v-if="eventName"
class="text-body-1 text-medium-emphasis ms-2 text-truncate d-none d-sm-inline"
style="max-width: 250px;"
>
{{ eventName }}
</span>
<VBtn
variant="text"
size="small"
color="default"
class="text-medium-emphasis ms-2 d-none d-md-flex"
to="/portal/evenementen"
>
<VIcon
start
icon="tabler-arrow-left"
size="16"
/>
Evenementen
</VBtn>
</template>
<!-- Platform mode: Crewli logo + optional page title -->
<template v-else>
<RouterLink
to="/portal/evenementen"
class="d-flex align-center gap-x-2 text-decoration-none flex-shrink-0"
>
<VIcon
icon="tabler-users-group"
size="26"
color="primary"
/>
<span class="text-h6 font-weight-bold text-high-emphasis d-none d-sm-inline">
Crewli
</span>
</RouterLink>
<span
v-if="navTitle"
class="text-body-1 text-medium-emphasis ms-4 d-none d-md-inline"
>
{{ navTitle }}
</span>
</template>
<VSpacer />
<!-- Right section: context switcher + avatar menu (desktop) -->
<div class="d-none d-md-flex align-center">
<ContextSwitcher class="me-2" />
<UserAvatarMenu />
</div>
<!-- Mobile nav toggle -->
<VAppBarNavIcon
class="d-md-none"
@click="isMobileMenuOpen = !isMobileMenuOpen"
/>
</VContainer>
</VAppBar>
<!-- Mobile navigation drawer -->
<VNavigationDrawer
v-if="authStore.isAuthenticated"
v-model="isMobileMenuOpen"
temporary
location="end"
class="d-md-none"
>
<div class="pa-4 pb-2">
<div class="d-flex align-center gap-3">
<VAvatar
size="40"
color="primary"
>
<span class="text-body-2 font-weight-medium text-white">
{{ (authStore.user?.first_name?.charAt(0) ?? '') + (authStore.user?.last_name?.charAt(0) ?? '') }}
</span>
</VAvatar>
<div class="min-w-0">
<div class="text-body-1 font-weight-bold text-truncate">
{{ authStore.user?.full_name }}
</div>
<div class="text-caption text-medium-emphasis text-truncate">
{{ authStore.user?.email }}
</div>
</div>
</div>
</div>
<VDivider />
<VList nav>
<VListItem
v-for="item in mobileNavItems"
:key="item.to"
:to="item.to"
:prepend-icon="item.icon"
:title="item.title"
@click="isMobileMenuOpen = false"
/>
<VDivider class="my-2" />
<VListItem
prepend-icon="tabler-logout"
title="Uitloggen"
class="text-error"
@click="logout"
/>
</VList>
</VNavigationDrawer>
<VMain>
<VContainer
fluid
class="pa-4 pa-sm-6"
style="max-inline-size: 1440px;"
>
<RouterView v-slot="{ Component }">
<Suspense
:timeout="0"
@fallback="isFallbackStateActive = true"
@resolve="isFallbackStateActive = false"
>
<Component :is="Component" />
</Suspense>
</RouterView>
</VContainer>
</VMain>
<VFooter
app
color="transparent"
class="justify-center text-caption text-medium-emphasis py-3"
>
Powered by Crewli
</VFooter>
</VApp>
<AppShell
:nav-items="portalNavItems"
title="Crewli Portal"
>
<RouterView />
</AppShell>
</template>

View File

@@ -1,16 +1,13 @@
<script setup lang="ts">
// Public layout skeleton — WS-3 session 1a.
//
// Used for unauthenticated routes: login, password-reset, public
// form viewer. Intentionally minimal — no nav, no branding chrome
// in this skeleton. Branding (logo + responsive shell) lands in a
// later session.
// PublicLayout.vue — F3 rewrite. Used by unauthenticated public-facing
// pages (form-fill flows, public registration). Minimal Tailwind
// container, no chrome. Per RFC AD-3 the previous VApp + VMain wrapper
// is replaced; no PrimeVue chrome is needed because the public form
// renderer brings its own visual frame.
</script>
<template>
<VApp>
<VMain>
<RouterView />
</VMain>
</VApp>
<main class="crewli-public-layout min-h-screen bg-surface-50">
<RouterView />
</main>
</template>

View File

@@ -1,13 +1,34 @@
import { describe, expect, it, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
// Mock the import path so OrganizerLayout's `import DefaultLayoutWithVerticalNav`
// statement doesn't pull in the real component (which transitively imports
// Vuetify .css files that the trimmed-down vitest config can't transform).
vi.mock('@/layouts/components/DefaultLayoutWithVerticalNav.vue', () => ({
default: { template: '<div><slot /></div>' },
// Mock AppShell so the OrganizerLayout test isolates the layout
// wrapper's job (compute nav items, pass them to AppShell, expose
// RouterView via default slot). AppShell's own behavior is exercised
// by its own tests / Playwright CT in F4.
vi.mock('@/layouts/components/AppShell.vue', () => ({
default: {
template: '<div data-test="app-shell"><slot /></div>',
props: ['navItems', 'title'],
},
}))
vi.mock('@/stores/useAuthStore', () => ({
useAuthStore: () => ({
organisations: [],
currentOrganisation: null,
isSuperAdmin: false,
}),
}))
vi.mock('@/stores/useImpersonationStore', () => ({
useImpersonationStore: () => ({
isImpersonating: false,
}),
}))
setActivePinia(createPinia())
const OrganizerLayout = (await import('../OrganizerLayout.vue')).default
describe('OrganizerLayout', () => {
@@ -19,6 +40,7 @@ describe('OrganizerLayout', () => {
})
expect(wrapper.exists()).toBe(true)
expect(wrapper.find('[data-test="app-shell"]').exists()).toBe(true)
})
it('renders a RouterView in its slot', () => {

View File

@@ -2,97 +2,54 @@ import { describe, expect, it, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
// Mock auto-imported composables / external pinia stores so the layout's
// <script setup> doesn't reach into Vuexy or the real auth + portal
// stores during a unit test mount.
vi.mock('@core/composable/useSkins', () => ({
useSkins: () => ({ injectSkinClasses: () => {} }),
}))
vi.mock('vue-router', async importOriginal => ({
...(await importOriginal<typeof import('vue-router')>()),
useRoute: () => ({ meta: {} }),
useRouter: () => ({ push: vi.fn() }),
}))
vi.mock('@/stores/useAuthStore', () => ({
useAuthStore: () => ({
isAuthenticated: false,
user: null,
logout: vi.fn(),
}),
}))
vi.mock('@/stores/portal/usePortalStore', () => ({
usePortalStore: () => ({ activeEvent: null }),
}))
vi.mock('@/components/portal/UserAvatarMenu.vue', () => ({
default: { template: '<div data-test="avatar" />' },
}))
// PortalLayout was rewritten in F3 (B7) to delegate its chrome to
// AppShell. Mock AppShell so this test isolates the wrapper's job
// (define portal nav items, pass them to AppShell, expose RouterView
// via default slot). AppShell's own behavior — sidebar rendering,
// drawer toggle, user menu — is covered by AppShell's own tests
// (F4 will add them via Playwright CT against the mounted shell).
// AppLoadingIndicator is stubbed at mount time below (see vuetifyStubs).
const PortalLayout = (await import('../PortalLayout.vue')).default
// Stub Vuetify components as their semantic HTML equivalents so we can
// assert structure without pulling vuetify/components. AppLoadingIndicator
// is also stubbed here with the methods (.fallbackHandle / .resolveHandle)
// that PortalLayout's watcher invokes via the template ref.
const vuetifyStubs = {
AppLoadingIndicator: {
template: '<div data-test="loading" />',
setup(_p: unknown, { expose }: { expose: (api: Record<string, () => void>) => void }) {
expose({ fallbackHandle() {}, resolveHandle() {} })
},
vi.mock('@/layouts/components/AppShell.vue', () => ({
default: {
name: 'AppShell',
template: '<div data-test="app-shell"><slot /></div>',
props: ['navItems', 'title'],
},
VApp: { template: '<div><slot /></div>' },
VAppBar: { template: '<header><slot /></header>' },
VMain: { template: '<main><slot /></main>' },
VFooter: { template: '<footer><slot /></footer>' },
VContainer: { template: '<div><slot /></div>' },
VNavigationDrawer: { template: '<aside><slot /></aside>' },
VAppBarNavIcon: true,
VBtn: true,
VIcon: true,
VSpacer: true,
VAvatar: true,
VList: true,
VListItem: true,
VDivider: true,
RouterLink: true,
}
}))
setActivePinia(createPinia())
const PortalLayout = (await import('../PortalLayout.vue')).default
describe('PortalLayout', () => {
it('mounts without throwing', () => {
const wrapper = mount(PortalLayout, {
global: { stubs: { ...vuetifyStubs, RouterView: true } },
global: { stubs: { RouterView: true } },
})
expect(wrapper.exists()).toBe(true)
expect(wrapper.find('[data-test="app-shell"]').exists()).toBe(true)
})
it('renders a main region and footer (header is auth-conditional)', () => {
const wrapper = mount(PortalLayout, {
global: { stubs: { ...vuetifyStubs, RouterView: true } },
})
expect(wrapper.find('main').exists()).toBe(true)
expect(wrapper.find('footer').exists()).toBe(true)
// unauthenticated mock → no navbar, no drawer
expect(wrapper.find('header').exists()).toBe(false)
expect(wrapper.find('aside').exists()).toBe(false)
})
it('renders a RouterView inside the main region', () => {
it('renders a RouterView inside the AppShell slot', () => {
const wrapper = mount(PortalLayout, {
global: {
stubs: {
...vuetifyStubs,
RouterView: { template: '<div data-test="router-view" />' },
},
stubs: { RouterView: { template: '<div data-test="router-view" />' } },
},
})
expect(wrapper.find('main [data-test="router-view"]').exists()).toBe(true)
expect(wrapper.find('[data-test="app-shell"] [data-test="router-view"]').exists()).toBe(true)
})
it('forwards portal-specific nav items and title to AppShell', () => {
const wrapper = mount(PortalLayout, {
global: { stubs: { RouterView: true } },
})
const shell = wrapper.findComponent({ name: 'AppShell' })
const navItems = shell.props('navItems') as Array<{ title: string }>
expect(shell.props('title')).toBe('Crewli Portal')
expect(navItems.map(i => i.title)).toEqual(['Mijn evenementen', 'Mijn Profiel'])
})
})

View File

@@ -1,47 +1,15 @@
<script lang="ts" setup>
import type AppLoadingIndicator from '@/components/AppLoadingIndicator.vue'
const { injectSkinClasses } = useSkins()
// This will inject classes in body tag for accurate styling
injectSkinClasses()
// SECTION: Loading Indicator
const isFallbackStateActive = ref(false)
const refLoadingIndicator = ref<InstanceType<typeof AppLoadingIndicator> | null>(null)
// watching if the fallback state is active and the refLoadingIndicator component is available
watch([isFallbackStateActive, refLoadingIndicator], () => {
if (isFallbackStateActive.value && refLoadingIndicator.value)
refLoadingIndicator.value.fallbackHandle()
if (!isFallbackStateActive.value && refLoadingIndicator.value)
refLoadingIndicator.value.resolveHandle()
}, { immediate: true })
// !SECTION
<script setup lang="ts">
// blank.vue — F3 rewrite. Minimal chrome-less layout used by routes
// with meta.layout = 'blank' (login, password-reset, etc.). No
// AppShell, no sidebar, no topbar — just a centered content area.
//
// Per RFC AD-3, filename preserved. The previous Vuexy Suspense
// fallback + AppLoadingIndicator stack is dropped; pages handle their
// own loading state via PrimeVue ProgressSpinner where needed.
</script>
<template>
<AppLoadingIndicator ref="refLoadingIndicator" />
<div
class="layout-wrapper layout-blank"
data-allow-mismatch
>
<RouterView #="{Component}">
<Suspense
:timeout="0"
@fallback="isFallbackStateActive = true"
@resolve="isFallbackStateActive = false"
>
<Component :is="Component" />
</Suspense>
</RouterView>
<div class="crewli-blank-layout flex min-h-screen flex-col bg-surface-50">
<RouterView />
</div>
</template>
<style>
.layout-wrapper.layout-blank {
flex-direction: column;
}
</style>

View File

@@ -0,0 +1,214 @@
<script setup lang="ts">
// AppShell — PrimeVue-only application chrome introduced in F3 per the
// RFC-WS-FRONTEND-PRIMEVUE AD-3 layout rewrite, B7-option-B (alongside
// the Vuexy carrier DefaultLayoutWithVerticalNav.vue).
//
// Hard constraint per F3 sprint plan: no Vuetify or @core/@layouts
// imports inside this component. Vuetify-based features that previously
// lived in the topbar (search, theme switcher, notifications,
// org switcher, context switcher, shortcuts, impersonation banner,
// rich user profile menu) are absent from AppShell — they migrate in
// F4 sub-packages and re-enter through this component then. See the
// B7 commit body for the explicit regression list.
//
// Layout: Tailwind CSS grid. Desktop (lg+) shows a permanent sidebar;
// mobile (<lg) hides it and shows a hamburger toggle that opens a
// PrimeVue Drawer overlay. Content area renders the default slot
// (a RouterView from the wrapping layout file).
import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
import Drawer from 'primevue/drawer'
import Button from 'primevue/button'
import Avatar from 'primevue/avatar'
import Menu from 'primevue/menu'
import Icon from '@/components/Icon.vue'
import { useAuthStore } from '@/stores/useAuthStore'
interface NavHeading {
heading: string
}
interface NavLink {
title: string
to: { name: string }
icon: { icon: string }
}
type NavItem = NavHeading | NavLink
interface Props {
navItems: NavItem[]
title?: string
}
withDefaults(defineProps<Props>(), {
title: 'Crewli',
})
const router = useRouter()
const authStore = useAuthStore()
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 {
return 'heading' in item
}
function navigate(item: NavLink) {
mobileNavOpen.value = false
router.push(item.to)
}
function toggleUserMenu(event: Event) {
userMenuRef.value?.toggle(event)
}
</script>
<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>
<nav class="flex-1 overflow-y-auto p-3">
<template
v-for="(item, idx) in navItems"
:key="idx"
>
<div
v-if="isHeading(item)"
class="mt-4 mb-2 px-2 text-xs font-semibold uppercase tracking-wider text-surface-500"
>
{{ item.heading }}
</div>
<button
v-else
type="button"
class="flex w-full items-center gap-3 rounded px-3 py-2 text-sm text-surface-700 transition hover:bg-primary-50 hover:text-primary-600"
@click="navigate(item)"
>
<Icon
:name="item.icon.icon"
size="20"
/>
<span>{{ item.title }}</span>
</button>
</template>
</nav>
</aside>
<!-- Mobile drawer (overlay, <lg) -->
<Drawer
v-model:visible="mobileNavOpen"
position="left"
class="lg:hidden"
:pt="{ root: { style: { width: '16rem' } } }"
>
<template #header>
<span class="text-lg font-semibold text-primary-500">{{ title }}</span>
</template>
<nav class="flex flex-col">
<template
v-for="(item, idx) in navItems"
:key="idx"
>
<div
v-if="isHeading(item)"
class="mt-4 mb-2 px-2 text-xs font-semibold uppercase tracking-wider text-surface-500"
>
{{ item.heading }}
</div>
<button
v-else
type="button"
class="flex w-full items-center gap-3 rounded px-3 py-2 text-sm text-surface-700 transition hover:bg-primary-50 hover:text-primary-600"
@click="navigate(item)"
>
<Icon
:name="item.icon.icon"
size="20"
/>
<span>{{ item.title }}</span>
</button>
</template>
</nav>
</Drawer>
<!-- Main column -->
<div class="flex flex-1 flex-col min-w-0">
<!-- Top bar -->
<header class="flex h-16 items-center justify-between border-b border-surface-200 bg-surface-0 px-4">
<div class="flex items-center gap-2">
<Button
class="lg:hidden"
severity="secondary"
text
rounded
aria-label="Menu openen"
@click="mobileNavOpen = true"
>
<Icon
name="tabler-menu-2"
size="24"
/>
</Button>
<span class="text-base font-medium text-surface-700 lg:hidden">{{ title }}</span>
</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>
<!-- Page content -->
<main class="flex-1 overflow-x-hidden p-4 lg:p-6">
<slot />
</main>
</div>
</div>
</template>

View File

@@ -1,57 +1,55 @@
<script lang="ts" setup>
import type AppLoadingIndicator from '@/components/AppLoadingIndicator.vue'
import { useConfigStore } from '@core/stores/config'
import { AppContentLayoutNav } from '@layouts/enums'
import { switchToVerticalNavOnLtOverlayNavBreakpoint } from '@layouts/utils'
<script setup lang="ts">
// default.vue — F3 rewrite. The vite-plugin-vue-meta-layouts plugin
// resolves routes without an explicit meta.layout to this file.
// Delegates to AppShell with org + platform nav, mirroring
// OrganizerLayout (the implicit "logged-in user" layout).
//
// Per RFC AD-3, the filename and routing behaviour are preserved;
// contents rewritten to PrimeVue. The Vuexy horizontal/vertical
// switch (driven by useConfigStore.appContentLayoutNav) and the
// Suspense+fallback loading indicator are removed — F4 will
// reintroduce loading state via PrimeVue ProgressBar/Spinner if/where
// needed.
const DefaultLayoutWithHorizontalNav = defineAsyncComponent(() => import('./components/DefaultLayoutWithHorizontalNav.vue'))
const DefaultLayoutWithVerticalNav = defineAsyncComponent(() => import('./components/DefaultLayoutWithVerticalNav.vue'))
import { computed } from 'vue'
import AppShell from '@/layouts/components/AppShell.vue'
import { orgNavItems, platformNavItems } from '@/navigation/vertical'
import { useAuthStore } from '@/stores/useAuthStore'
import { useImpersonationStore } from '@/stores/useImpersonationStore'
const configStore = useConfigStore()
const authStore = useAuthStore()
const impersonationStore = useImpersonationStore()
// This will switch to vertical nav when define breakpoint is reached when in horizontal nav layout
// Remove below composable usage if you are not using horizontal nav layout in your app
switchToVerticalNavOnLtOverlayNavBreakpoint()
const hasOrganisation = computed(() => !!authStore.organisations.length)
const { layoutAttrs, injectSkinClasses } = useSkins()
const navItems = computed(() => {
const orgName = authStore.currentOrganisation?.name ?? 'Beheer'
injectSkinClasses()
let orgItems = orgNavItems.map(item => {
if ('heading' in item && item.heading === 'Beheer')
return { ...item, heading: orgName }
// SECTION: Loading Indicator
const isFallbackStateActive = ref(false)
const refLoadingIndicator = ref<InstanceType<typeof AppLoadingIndicator> | null>(null)
return item
})
// watching if the fallback state is active and the refLoadingIndicator component is available
watch([isFallbackStateActive, refLoadingIndicator], () => {
if (isFallbackStateActive.value && refLoadingIndicator.value)
refLoadingIndicator.value.fallbackHandle()
if (impersonationStore.isImpersonating && !hasOrganisation.value) {
orgItems = orgItems.filter(item => {
if ('heading' in item)
return false
if (!isFallbackStateActive.value && refLoadingIndicator.value)
refLoadingIndicator.value.resolveHandle()
}, { immediate: true })
// !SECTION
return 'to' in item && item.to?.name === 'dashboard'
})
}
if (authStore.isSuperAdmin && !impersonationStore.isImpersonating)
return [...orgItems, ...platformNavItems]
return orgItems
})
</script>
<template>
<Component
v-bind="layoutAttrs"
:is="configStore.appContentLayoutNav === AppContentLayoutNav.Vertical ? DefaultLayoutWithVerticalNav : DefaultLayoutWithHorizontalNav"
>
<AppLoadingIndicator ref="refLoadingIndicator" />
<RouterView v-slot="{ Component }">
<Suspense
:timeout="0"
@fallback="isFallbackStateActive = true"
@resolve="isFallbackStateActive = false"
>
<Component :is="Component" />
</Suspense>
</RouterView>
</Component>
<AppShell :nav-items="navItems">
<RouterView />
</AppShell>
</template>
<style lang="scss">
// As we are using `layouts` plugin we need its styles to be imported
@use "@layouts/styles/default-layout";
</style>