feat(modules): dynamic sidebar group rendering active modules

The sidebar gains a new section that lists each currently-activated module
as a direct shortcut to its settings page. This is the always-visible
companion to the company-context Active Modules index — both surface the
same set of modules, but the index is the catalog landing page and the
sidebar group is the per-module quick access.

- BootstrapController returns module_menu populated from
  \InvoiceShelf\Modules\Registry::allMenu(), but only on the company-context
  branch — not on the super-admin branch (lines 53-69), since super admins
  don't see the dynamic group. Because nwidart only boots service providers
  for currently-activated modules, the registry naturally contains only
  active modules at request time, no extra filtering needed.

- bootstrap.service.ts BootstrapResponse type extended with
  module_menu?: ModuleMenuItem[]; new ModuleMenuItem interface
  (title/link/icon) — shaped distinctly from MenuItem because module entries
  use namespaced i18n keys and don't carry group/ability metadata.

- global.store.ts exposes a moduleMenu ref + a hasActiveModules computed.

- SiteSidebar.vue appends a new "Modules" section after the existing
  menuGroups output, in both the mobile (Dialog) and desktop branches. The
  section is hidden when hasActiveModules is false. Uses the
  modules.sidebar.section_title i18n key added in the previous commit.
This commit is contained in:
Darko Gjorgjijoski
2026-04-09 00:29:56 +02:00
parent e6eeacb6d4
commit 7743c2e126
4 changed files with 95 additions and 1 deletions

View File

@@ -15,6 +15,7 @@ use App\Models\Setting;
use App\Traits\GeneratesMenuTrait;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use InvoiceShelf\Modules\Registry as ModuleRegistry;
use Silber\Bouncer\BouncerFacade;
class BootstrapController extends Controller
@@ -118,6 +119,7 @@ class BootstrapController extends Controller
'main_menu' => $main_menu,
'setting_menu' => $setting_menu,
'modules' => Module::where('enabled', true)->pluck('name'),
'module_menu' => array_values(ModuleRegistry::allMenu()),
'pending_invitations' => CompanyInvitationResource::collection($pendingInvitations),
]);
}

View File

@@ -15,6 +15,20 @@ export interface MenuItem {
ability?: string
}
/**
* Sidebar item registered by an active module via
* \InvoiceShelf\Modules\Registry::registerMenu() in the module's ServiceProvider::boot().
*
* Distinct shape from MenuItem because module entries are namespaced (i18n
* keys come from the module's lang files) and don't carry group/ability —
* they always render under the dynamic "Modules" sidebar section.
*/
export interface ModuleMenuItem {
title: string
link: string
icon: string
}
export interface BootstrapResponse {
current_user: User
current_user_settings: Record<string, string>
@@ -28,6 +42,7 @@ export interface BootstrapResponse {
config: Record<string, unknown>
global_settings: Record<string, string>
modules: string[]
module_menu?: ModuleMenuItem[]
admin_mode?: boolean
pending_invitations?: Array<{
token: string

View File

@@ -99,6 +99,34 @@
{{ $t(item.title) }}
</router-link>
</nav>
<!-- Dynamic Modules section (one entry per active module's registered settings link) -->
<nav v-if="globalStore.hasActiveModules" class="mt-5 space-y-1">
<div class="px-4 mt-6 mb-2 text-xs font-semibold text-subtle uppercase tracking-wider">
{{ $t('modules.sidebar.section_title') }}
</div>
<router-link
v-for="(item, idx) in globalStore.moduleMenu"
:key="`module-${idx}`"
:to="item.link"
:class="[
hasActiveUrl(item.link)
? 'text-primary-600 bg-primary-50 font-semibold'
: 'text-body hover:bg-hover',
'cursor-pointer mx-3 px-3 py-2.5 flex items-center rounded-lg text-sm not-italic font-medium transition-colors',
]"
@click="globalStore.setSidebarVisibility(false)"
>
<BaseIcon
:name="item.icon"
:class="[
hasActiveUrl(item.link) ? 'text-primary-500' : 'text-subtle',
'mr-3 shrink-0 h-5 w-5',
]"
/>
{{ $t(item.title) }}
</router-link>
</nav>
</div>
</div>
</TransitionChild>
@@ -168,6 +196,49 @@
</router-link>
</div>
<!-- Dynamic Modules section: one shortcut per active module's registered
settings link. Hidden when no modules are active. -->
<div
v-if="globalStore.hasActiveModules"
class="p-0 m-0 mt-4 list-none"
>
<div
v-if="!globalStore.isSidebarCollapsed"
class="px-6 mt-6 mb-2 text-xs font-semibold text-subtle uppercase tracking-wider whitespace-nowrap"
>
{{ $t('modules.sidebar.section_title') }}
</div>
<div
v-else
class="mx-3 my-2 border-t border-line-light"
/>
<router-link
v-for="(item, idx) in globalStore.moduleMenu"
:key="`module-desktop-${idx}`"
:to="item.link"
v-tooltip="globalStore.isSidebarCollapsed ? { content: $t(item.title), placement: 'right' } : null"
:class="[
hasActiveUrl(item.link)
? 'text-primary-600 bg-primary-50 font-semibold'
: 'text-body hover:bg-hover',
globalStore.isSidebarCollapsed
? 'cursor-pointer mx-2 px-0 py-2.5 group flex items-center justify-center rounded-lg text-sm font-medium transition-colors'
: 'cursor-pointer mx-3 px-3 py-2.5 group flex items-center rounded-lg text-sm not-italic font-medium transition-colors',
]"
>
<BaseIcon
:name="item.icon"
:class="[
hasActiveUrl(item.link) ? 'text-primary-500' : 'text-subtle group-hover:text-body',
globalStore.isSidebarCollapsed ? 'shrink-0 h-6 w-6' : 'mr-3 shrink-0 h-5 w-5',
]"
/>
<span v-if="!globalStore.isSidebarCollapsed" class="whitespace-nowrap">
{{ $t(item.title) }}
</span>
</router-link>
</div>
<!-- Bottom toolbar -->
<div class="mt-auto sticky bottom-0 border-t border-white/10 bg-surface/80 backdrop-blur-xl p-2 flex flex-col items-center gap-1">
<button

View File

@@ -2,7 +2,7 @@ import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import groupBy from 'lodash/groupBy'
import { bootstrapService } from '@/scripts/api/services/bootstrap.service'
import type { MenuItem, BootstrapResponse } from '@/scripts/api/services/bootstrap.service'
import type { MenuItem, ModuleMenuItem, BootstrapResponse } from '@/scripts/api/services/bootstrap.service'
import { settingService } from '@/scripts/api/services/setting.service'
import type {
DateFormat,
@@ -35,6 +35,7 @@ export const useGlobalStore = defineStore('global', () => {
const mainMenu = ref<MenuItem[]>([])
const settingMenu = ref<MenuItem[]>([])
const moduleMenu = ref<ModuleMenuItem[]>([])
const isAppLoaded = ref<boolean>(false)
const isSidebarOpen = ref<boolean>(false)
@@ -48,6 +49,8 @@ export const useGlobalStore = defineStore('global', () => {
return Object.values(groupBy(mainMenu.value, 'group'))
})
const hasActiveModules = computed<boolean>(() => moduleMenu.value.length > 0)
// Actions
async function bootstrap(options?: { adminMode?: boolean }): Promise<BootstrapResponse> {
const companyStore = useCompanyStore()
@@ -59,6 +62,7 @@ export const useGlobalStore = defineStore('global', () => {
mainMenu.value = response.main_menu
settingMenu.value = response.setting_menu
moduleMenu.value = response.module_menu ?? []
config.value = response.config
globalSettings.value = response.global_settings
@@ -286,6 +290,7 @@ export const useGlobalStore = defineStore('global', () => {
fiscalYears,
mainMenu,
settingMenu,
moduleMenu,
isAppLoaded,
isSidebarOpen,
isSidebarCollapsed,
@@ -293,6 +298,7 @@ export const useGlobalStore = defineStore('global', () => {
downloadReport,
// Getters
menuGroups,
hasActiveModules,
// Actions
bootstrap,
fetchCurrencies,