diff --git a/apps/app/src/components-v2/layout/SidebarNav.vue b/apps/app/src/components-v2/layout/SidebarNav.vue
new file mode 100644
index 00000000..adbb276d
--- /dev/null
+++ b/apps/app/src/components-v2/layout/SidebarNav.vue
@@ -0,0 +1,128 @@
+
+
+
+
+
+
+
+
diff --git a/apps/app/src/components-v2/layout/__tests__/sidebarNavActive.spec.ts b/apps/app/src/components-v2/layout/__tests__/sidebarNavActive.spec.ts
new file mode 100644
index 00000000..82ca06d2
--- /dev/null
+++ b/apps/app/src/components-v2/layout/__tests__/sidebarNavActive.spec.ts
@@ -0,0 +1,46 @@
+import { describe, expect, it } from 'vitest'
+import { isNavItemActive } from '@/components-v2/layout/sidebarNavActive'
+import type { V2NavItem } from '@/types/v2/nav'
+
+function makeItem(name: string): V2NavItem {
+ return { id: name, label: name, icon: 'tabler-home', to: { name } }
+}
+
+describe('isNavItemActive', () => {
+ it('returns true when the current route name exactly matches', () => {
+ expect(isNavItemActive(makeItem('dashboard'), 'dashboard')).toBe(true)
+ })
+
+ it('returns false when the current route name differs', () => {
+ expect(isNavItemActive(makeItem('dashboard'), 'events')).toBe(false)
+ })
+
+ it('returns false when currentRouteName is null', () => {
+ expect(isNavItemActive(makeItem('dashboard'), null)).toBe(false)
+ })
+
+ it('returns false when currentRouteName is undefined', () => {
+ expect(isNavItemActive(makeItem('dashboard'), undefined)).toBe(false)
+ })
+
+ it('returns true for nested routes: item name is a prefix of current route (dash-separated)', () => {
+ // e.g. item "organisation" should be active on route "organisation-settings"
+ expect(isNavItemActive(makeItem('organisation'), 'organisation-settings')).toBe(true)
+ })
+
+ it('does not match partial-word prefixes: "org" should NOT match "organisation-settings"', () => {
+ expect(isNavItemActive(makeItem('org'), 'organisation-settings')).toBe(false)
+ })
+
+ it('handles symbol route names: returns false (not string — no match)', () => {
+ const sym = Symbol('dashboard')
+
+ expect(isNavItemActive(makeItem('dashboard'), sym)).toBe(false)
+ })
+
+ it('item with to as { name: string } matches correctly', () => {
+ const item: V2NavItem = { id: 'events', label: 'Events', icon: 'tabler-calendar', to: { name: 'events' } }
+
+ expect(isNavItemActive(item, 'events')).toBe(true)
+ })
+})
diff --git a/apps/app/src/components-v2/layout/sidebarNavActive.ts b/apps/app/src/components-v2/layout/sidebarNavActive.ts
new file mode 100644
index 00000000..9305bacc
--- /dev/null
+++ b/apps/app/src/components-v2/layout/sidebarNavActive.ts
@@ -0,0 +1,37 @@
+import type { V2NavItem } from '@/types/v2/nav'
+
+/**
+ * Pure helper — determines whether a V2NavItem is "active" given the current
+ * route name.
+ *
+ * Active rules (simplest correct definition):
+ * 1. Exact match: currentRouteName === item.to.name
+ * 2. Prefix match for nested routes: currentRouteName starts with
+ * item.to.name + '-' (e.g. item "organisation" is active on
+ * "organisation-settings"). The dash boundary prevents "org" from
+ * spuriously matching "organisation-settings".
+ *
+ * Only `to` values that are a plain object with a string `name` property are
+ * compared — string/array `to` values always return false (router-push
+ * style not used in v1 nav).
+ */
+export function isNavItemActive(
+ item: V2NavItem,
+ currentRouteName: string | symbol | null | undefined,
+): boolean {
+ if (typeof currentRouteName !== 'string')
+ return false
+
+ const to = item.to
+
+ if (typeof to !== 'object' || to === null || Array.isArray(to))
+ return false
+
+ const itemName = (to as { name?: unknown }).name
+
+ if (typeof itemName !== 'string')
+ return false
+
+ return currentRouteName === itemName
+ || currentRouteName.startsWith(`${itemName}-`)
+}
diff --git a/apps/app/src/composables/__tests__/useV2Nav.spec.ts b/apps/app/src/composables/__tests__/useV2Nav.spec.ts
new file mode 100644
index 00000000..dd7a4e9b
--- /dev/null
+++ b/apps/app/src/composables/__tests__/useV2Nav.spec.ts
@@ -0,0 +1,77 @@
+import { describe, expect, it } from 'vitest'
+import { toV2NavGroups } from '@/composables/useV2Nav'
+import type { V2NavItem } from '@/types/v2/nav'
+
+// Hand-built fixture — does NOT depend on the real orgNavItems contents.
+const fixture = [
+ { title: 'Alpha', to: { name: 'alpha' }, icon: { icon: 'tabler-home' } },
+ { title: 'Beta', to: { name: 'beta' }, icon: { icon: 'tabler-bell' } },
+ { heading: 'Group One' },
+ { title: 'Gamma', to: { name: 'gamma' }, icon: { icon: 'tabler-star' }, count: 5 },
+ { heading: 'Group Two' },
+ { title: 'Delta', to: { name: 'delta' }, icon: { icon: 'tabler-settings' } },
+] as const
+
+describe('toV2NavGroups', () => {
+ it('places items before the first heading into a leading group with label ""', () => {
+ const groups = toV2NavGroups(fixture)
+
+ expect(groups[0].label).toBe('')
+ expect(groups[0].items).toHaveLength(2)
+ })
+
+ it('starts a new group when a heading entry is encountered', () => {
+ const groups = toV2NavGroups(fixture)
+
+ expect(groups).toHaveLength(3)
+ expect(groups[1].label).toBe('Group One')
+ expect(groups[2].label).toBe('Group Two')
+ })
+
+ it('maps item fields correctly: id, label, icon, to', () => {
+ const groups = toV2NavGroups(fixture)
+ const alpha = groups[0].items[0] as V2NavItem
+
+ expect(alpha.id).toBe('alpha')
+ expect(alpha.label).toBe('Alpha')
+ expect(alpha.icon).toBe('tabler-home')
+ expect(alpha.to).toEqual({ name: 'alpha' })
+ })
+
+ it('passes count through when present', () => {
+ const groups = toV2NavGroups(fixture)
+ const gamma = groups[1].items[0] as V2NavItem
+
+ expect(gamma.count).toBe(5)
+ })
+
+ it('leaves count undefined when absent', () => {
+ const groups = toV2NavGroups(fixture)
+ const alpha = groups[0].items[0] as V2NavItem
+
+ expect(alpha.count).toBeUndefined()
+ })
+
+ it('id is derived from the route name (kebab already for dotted names)', () => {
+ const groups = toV2NavGroups(fixture)
+ const delta = groups[2].items[0] as V2NavItem
+
+ expect(delta.id).toBe('delta')
+ })
+
+ it('returns empty groups array for an empty input', () => {
+ expect(toV2NavGroups([])).toEqual([])
+ })
+
+ it('items-only input (no headings) returns a single leading group', () => {
+ const onlyItems = [
+ { title: 'A', to: { name: 'a' }, icon: { icon: 'tabler-a' } },
+ ] as const
+
+ const groups = toV2NavGroups(onlyItems)
+
+ expect(groups).toHaveLength(1)
+ expect(groups[0].label).toBe('')
+ expect(groups[0].items).toHaveLength(1)
+ })
+})
diff --git a/apps/app/src/composables/useV2Nav.ts b/apps/app/src/composables/useV2Nav.ts
new file mode 100644
index 00000000..ec1aa57a
--- /dev/null
+++ b/apps/app/src/composables/useV2Nav.ts
@@ -0,0 +1,95 @@
+import { computed } from 'vue'
+import type { ComputedRef } from 'vue'
+import type { V2NavGroup, V2NavItem } from '@/types/v2/nav'
+
+// ---------------------------------------------------------------------------
+// Local discriminated union for v1 nav entries (no `any`)
+// ---------------------------------------------------------------------------
+
+interface V1NavHeading {
+ heading: string
+}
+
+interface V1NavLink {
+ title: string
+ to: { name: string }
+ icon: { icon: string }
+ count?: number
+}
+
+export type V1NavEntry = V1NavHeading | V1NavLink
+
+function isHeading(entry: V1NavEntry): entry is V1NavHeading {
+ return 'heading' in entry
+}
+
+// ---------------------------------------------------------------------------
+// Pure adapter — exported so it can be unit-tested without mounting
+// ---------------------------------------------------------------------------
+
+/**
+ * Folds a flat v1 nav array into V2NavGroup[].
+ *
+ * - A `{ heading }` entry closes the current group and opens a new one.
+ * - Items before the first heading are placed in a leading group with label ''.
+ * - A pure function with no side-effects.
+ */
+export function toV2NavGroups(items: readonly V1NavEntry[]): V2NavGroup[] {
+ if (items.length === 0)
+ return []
+
+ const groups: V2NavGroup[] = []
+ let current: V2NavGroup | null = null
+
+ for (const entry of items) {
+ if (isHeading(entry)) {
+ // Close current group (if any) and start a new named group.
+ if (current !== null)
+ groups.push(current)
+
+ current = { label: entry.heading, items: [] }
+ }
+ else {
+ // Ensure there is a current group (leading ungrouped section).
+ if (current === null)
+ current = { label: '', items: [] }
+
+ const navItem: V2NavItem = {
+ id: entry.to.name,
+ label: entry.title,
+ icon: entry.icon.icon,
+ to: { name: entry.to.name },
+ ...(entry.count !== undefined ? { count: entry.count } : {}),
+ }
+
+ current.items.push(navItem)
+ }
+ }
+
+ // Flush the last open group.
+ if (current !== null)
+ groups.push(current)
+
+ return groups
+}
+
+// ---------------------------------------------------------------------------
+// Composable
+// ---------------------------------------------------------------------------
+
+/**
+ * Wraps toV2NavGroups in a computed ref.
+ *
+ * Accepts the raw v1 nav items as a parameter so the composable (composables
+ * boundary zone) does not need to import from @/navigation (navigation zone).
+ * Call-sites in layouts/pages — which ARE allowed to import navigation —
+ * pass orgNavItems directly:
+ *
+ * import { orgNavItems } from '@/navigation/vertical'
+ * const { groups } = useV2Nav(orgNavItems)
+ */
+export function useV2Nav(items: readonly V1NavEntry[]): { groups: ComputedRef } {
+ const groups = computed(() => toV2NavGroups(items))
+
+ return { groups }
+}
diff --git a/apps/app/src/types/v2/nav.ts b/apps/app/src/types/v2/nav.ts
new file mode 100644
index 00000000..18c9b4a3
--- /dev/null
+++ b/apps/app/src/types/v2/nav.ts
@@ -0,0 +1,14 @@
+import type { RouteLocationRaw } from 'vue-router'
+
+export interface V2NavItem {
+ id: string
+ label: string
+ icon: string // e.g. 'tabler-smart-home'
+ to: RouteLocationRaw
+ count?: number
+}
+
+export interface V2NavGroup {
+ label: string // '' for an ungrouped leading section
+ items: V2NavItem[]
+}