mirror of
https://github.com/InvoiceShelf/InvoiceShelf.git
synced 2026-04-15 17:24:10 +00:00
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:
@@ -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),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user