Migrate navigation from horizontal navbar to Vuexy vertical sidebar
Replaces the horizontal VAppBar nav links with a proper vertical sidebar matching the Vuexy design system: 260px fixed sidebar with collapsible mini mode, section titles, active link highlighting, and mobile overlay. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,9 @@
|
|||||||
// Import Vuexy's full Vuetify component override chain
|
// Import Vuexy's full Vuetify component override chain
|
||||||
@use "@core-scss/template/libs/vuetify";
|
@use "@core-scss/template/libs/vuetify";
|
||||||
|
|
||||||
|
// Vertical sidebar navigation layout
|
||||||
|
@use "@layouts/styles/vertical-nav";
|
||||||
|
|
||||||
// ━━━ Project-specific overrides ━━━
|
// ━━━ Project-specific overrides ━━━
|
||||||
|
|
||||||
html {
|
html {
|
||||||
|
|||||||
100
website/resources/ts/@layouts/components/VerticalNav.vue
Normal file
100
website/resources/ts/@layouts/components/VerticalNav.vue
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { usePage } from '@inertiajs/vue3'
|
||||||
|
import { Link } from '@inertiajs/vue3'
|
||||||
|
import { computed, ref, watch } from 'vue'
|
||||||
|
import type { NavGroup, NavLink, NavSectionTitle, VerticalNavItems } from '@layouts/types'
|
||||||
|
import VerticalNavLink from './VerticalNavLink.vue'
|
||||||
|
import VerticalNavGroup from './VerticalNavGroup.vue'
|
||||||
|
import VerticalNavSectionTitle from './VerticalNavSectionTitle.vue'
|
||||||
|
import logoWhite from '@images/ezscale_logo_white.png'
|
||||||
|
import { useTheme } from 'vuetify'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
navItems: VerticalNavItems
|
||||||
|
isOverlayNavActive: boolean
|
||||||
|
isCollapsed: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:isOverlayNavActive': [value: boolean]
|
||||||
|
'update:isCollapsed': [value: boolean]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const theme = useTheme()
|
||||||
|
const isDark = computed(() => theme.global.current.value.dark)
|
||||||
|
|
||||||
|
const navRef = ref<HTMLElement | null>(null)
|
||||||
|
const isHovered = ref<boolean>(false)
|
||||||
|
|
||||||
|
const page = usePage()
|
||||||
|
|
||||||
|
// Mini mode: collapsed AND not hovered AND not mobile overlay
|
||||||
|
const isMini = computed<boolean>(() => props.isCollapsed && !isHovered.value && !props.isOverlayNavActive)
|
||||||
|
|
||||||
|
// Auto-close overlay on route change
|
||||||
|
watch(() => page.url, () => {
|
||||||
|
if (props.isOverlayNavActive) {
|
||||||
|
emit('update:isOverlayNavActive', false)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function resolveNavItemComponent(item: NavLink | NavGroup | NavSectionTitle): typeof VerticalNavLink | typeof VerticalNavGroup | typeof VerticalNavSectionTitle {
|
||||||
|
if ('heading' in item) return VerticalNavSectionTitle
|
||||||
|
if ('children' in item) return VerticalNavGroup
|
||||||
|
return VerticalNavLink
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleCollapse(): void {
|
||||||
|
emit('update:isCollapsed', !props.isCollapsed)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<aside
|
||||||
|
ref="navRef"
|
||||||
|
class="layout-vertical-nav"
|
||||||
|
:class="{
|
||||||
|
'overlay-nav': isOverlayNavActive,
|
||||||
|
visible: isOverlayNavActive,
|
||||||
|
hovered: isHovered,
|
||||||
|
}"
|
||||||
|
@mouseenter="isHovered = true"
|
||||||
|
@mouseleave="isHovered = false"
|
||||||
|
>
|
||||||
|
<!-- Nav Header (Logo + Toggle) -->
|
||||||
|
<div class="nav-header">
|
||||||
|
<Link href="/dashboard" class="nav-header-logo">
|
||||||
|
<img
|
||||||
|
:src="logoWhite"
|
||||||
|
alt="EZSCALE"
|
||||||
|
:class="{ 'logo-light': !isDark }"
|
||||||
|
class="nav-logo-img"
|
||||||
|
/>
|
||||||
|
<span v-show="!isMini" class="nav-logo-title">EZSCALE</span>
|
||||||
|
</Link>
|
||||||
|
<VBtn
|
||||||
|
v-show="!isMini"
|
||||||
|
icon
|
||||||
|
variant="text"
|
||||||
|
size="x-small"
|
||||||
|
class="nav-collapse-btn"
|
||||||
|
@click="toggleCollapse"
|
||||||
|
>
|
||||||
|
<VIcon :icon="isCollapsed ? 'tabler-layout-sidebar-right-expand' : 'tabler-layout-sidebar-left-collapse'" size="20" />
|
||||||
|
</VBtn>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Nav Items -->
|
||||||
|
<ul class="nav-items">
|
||||||
|
<Component
|
||||||
|
:is="resolveNavItemComponent(item)"
|
||||||
|
v-for="(item, index) in navItems"
|
||||||
|
:key="index"
|
||||||
|
:item="item"
|
||||||
|
:is-mini="isMini"
|
||||||
|
/>
|
||||||
|
</ul>
|
||||||
|
</aside>
|
||||||
|
</template>
|
||||||
108
website/resources/ts/@layouts/components/VerticalNavGroup.vue
Normal file
108
website/resources/ts/@layouts/components/VerticalNavGroup.vue
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { usePage } from '@inertiajs/vue3'
|
||||||
|
import { computed, ref, watch } from 'vue'
|
||||||
|
import type { NavGroup, NavLink } from '@layouts/types'
|
||||||
|
import VerticalNavLink from './VerticalNavLink.vue'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
item: NavGroup
|
||||||
|
isMini?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
isMini: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const page = usePage()
|
||||||
|
|
||||||
|
function isChildActive(children: (NavLink | NavGroup)[]): boolean {
|
||||||
|
return children.some((child) => {
|
||||||
|
if ('children' in child) {
|
||||||
|
return isChildActive(child.children)
|
||||||
|
}
|
||||||
|
const url = page.url
|
||||||
|
if (child.to === '/dashboard') {
|
||||||
|
return url === '/dashboard' || url === '/dashboard/'
|
||||||
|
}
|
||||||
|
return url.startsWith(child.to)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const isGroupActive = computed<boolean>(() => isChildActive(props.item.children))
|
||||||
|
const isOpen = ref<boolean>(isGroupActive.value)
|
||||||
|
|
||||||
|
// Auto-open/close when route changes
|
||||||
|
watch(() => page.url, () => {
|
||||||
|
if (isGroupActive.value) {
|
||||||
|
isOpen.value = true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Close when entering mini mode
|
||||||
|
watch(() => props.isMini, (mini) => {
|
||||||
|
if (mini) {
|
||||||
|
isOpen.value = false
|
||||||
|
}
|
||||||
|
else if (isGroupActive.value) {
|
||||||
|
isOpen.value = true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function toggleGroup(): void {
|
||||||
|
if (!props.isMini) {
|
||||||
|
isOpen.value = !isOpen.value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveComponent(child: NavLink | NavGroup): typeof VerticalNavLink | typeof VerticalNavGroup {
|
||||||
|
if ('children' in child) {
|
||||||
|
return VerticalNavGroup
|
||||||
|
}
|
||||||
|
return VerticalNavLink
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<li class="nav-group" :class="{ active: isGroupActive, open: isOpen, disabled: item.disable }">
|
||||||
|
<div class="nav-group-label" @click="toggleGroup">
|
||||||
|
<VIcon
|
||||||
|
v-if="item.icon"
|
||||||
|
:icon="item.icon"
|
||||||
|
class="nav-item-icon"
|
||||||
|
size="22"
|
||||||
|
/>
|
||||||
|
<VIcon
|
||||||
|
v-else
|
||||||
|
icon="tabler-circle"
|
||||||
|
class="nav-item-icon"
|
||||||
|
size="12"
|
||||||
|
/>
|
||||||
|
<span v-show="!isMini" class="nav-item-title">{{ item.title }}</span>
|
||||||
|
<span
|
||||||
|
v-if="item.badgeContent"
|
||||||
|
v-show="!isMini"
|
||||||
|
class="nav-item-badge"
|
||||||
|
:class="item.badgeClass"
|
||||||
|
>
|
||||||
|
{{ item.badgeContent }}
|
||||||
|
</span>
|
||||||
|
<VIcon
|
||||||
|
v-show="!isMini"
|
||||||
|
icon="tabler-chevron-right"
|
||||||
|
class="nav-group-arrow"
|
||||||
|
size="18"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Transition name="vertical-nav-group">
|
||||||
|
<ul v-show="isOpen && !isMini" class="nav-group-children">
|
||||||
|
<Component
|
||||||
|
:is="resolveComponent(child)"
|
||||||
|
v-for="(child, index) in item.children"
|
||||||
|
:key="index"
|
||||||
|
:item="child"
|
||||||
|
:is-mini="isMini"
|
||||||
|
/>
|
||||||
|
</ul>
|
||||||
|
</Transition>
|
||||||
|
</li>
|
||||||
|
</template>
|
||||||
107
website/resources/ts/@layouts/components/VerticalNavLayout.vue
Normal file
107
website/resources/ts/@layouts/components/VerticalNavLayout.vue
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { ref, watch, onMounted, onBeforeUnmount } from 'vue'
|
||||||
|
import type { VerticalNavItems } from '@layouts/types'
|
||||||
|
import VerticalNav from './VerticalNav.vue'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
navItems: VerticalNavItems
|
||||||
|
}
|
||||||
|
|
||||||
|
defineProps<Props>()
|
||||||
|
|
||||||
|
const isOverlayNavActive = ref<boolean>(false)
|
||||||
|
const isNavCollapsed = ref<boolean>(false)
|
||||||
|
|
||||||
|
// Persist collapsed state in localStorage
|
||||||
|
onMounted(() => {
|
||||||
|
const saved = localStorage.getItem('ezscale-nav-collapsed')
|
||||||
|
if (saved !== null) {
|
||||||
|
isNavCollapsed.value = saved === 'true'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(isNavCollapsed, (val) => {
|
||||||
|
localStorage.setItem('ezscale-nav-collapsed', String(val))
|
||||||
|
})
|
||||||
|
|
||||||
|
// Close overlay when window resizes to desktop
|
||||||
|
const BREAKPOINT = 1280
|
||||||
|
|
||||||
|
function handleResize(): void {
|
||||||
|
if (window.innerWidth >= BREAKPOINT && isOverlayNavActive.value) {
|
||||||
|
isOverlayNavActive.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
window.addEventListener('resize', handleResize)
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
window.removeEventListener('resize', handleResize)
|
||||||
|
})
|
||||||
|
|
||||||
|
function toggleOverlayNav(value: boolean): void {
|
||||||
|
isOverlayNavActive.value = value
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="layout-wrapper layout-nav-type-vertical"
|
||||||
|
:class="{
|
||||||
|
'layout-vertical-nav-collapsed': isNavCollapsed,
|
||||||
|
'layout-overlay-nav': false,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<!-- Vertical Nav -->
|
||||||
|
<VerticalNav
|
||||||
|
:nav-items="navItems"
|
||||||
|
v-model:is-overlay-nav-active="isOverlayNavActive"
|
||||||
|
:is-collapsed="isNavCollapsed"
|
||||||
|
@update:is-collapsed="isNavCollapsed = $event"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Content Wrapper -->
|
||||||
|
<div class="layout-content-wrapper">
|
||||||
|
<!-- Navbar -->
|
||||||
|
<header class="layout-navbar">
|
||||||
|
<div class="navbar-content d-flex align-center h-100 px-4">
|
||||||
|
<!-- Mobile hamburger (shown below 1280px via CSS) -->
|
||||||
|
<VBtn
|
||||||
|
icon
|
||||||
|
variant="text"
|
||||||
|
size="small"
|
||||||
|
class="d-lg-none me-2"
|
||||||
|
@click="toggleOverlayNav(true)"
|
||||||
|
>
|
||||||
|
<VIcon icon="tabler-menu-2" size="22" />
|
||||||
|
</VBtn>
|
||||||
|
|
||||||
|
<slot name="navbar" />
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Main Content -->
|
||||||
|
<main class="layout-page-content">
|
||||||
|
<slot />
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<footer class="layout-footer">
|
||||||
|
<slot name="footer">
|
||||||
|
<span class="text-body-2 text-medium-emphasis">
|
||||||
|
© {{ new Date().getFullYear() }} EZSCALE. All rights reserved.
|
||||||
|
</span>
|
||||||
|
</slot>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Overlay Backdrop -->
|
||||||
|
<div
|
||||||
|
class="layout-overlay"
|
||||||
|
:class="{ visible: isOverlayNavActive }"
|
||||||
|
@click="toggleOverlayNav(false)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
52
website/resources/ts/@layouts/components/VerticalNavLink.vue
Normal file
52
website/resources/ts/@layouts/components/VerticalNavLink.vue
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { Link, usePage } from '@inertiajs/vue3'
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import type { NavLink } from '@layouts/types'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
item: NavLink
|
||||||
|
isMini?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
isMini: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const page = usePage()
|
||||||
|
const isActive = computed<boolean>(() => {
|
||||||
|
const url = page.url
|
||||||
|
// Exact match for dashboard, prefix match for everything else
|
||||||
|
if (props.item.to === '/dashboard') {
|
||||||
|
return url === '/dashboard' || url === '/dashboard/'
|
||||||
|
}
|
||||||
|
return url.startsWith(props.item.to)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<li class="nav-link" :class="{ active: isActive, disabled: item.disable }">
|
||||||
|
<Link :href="item.to" class="nav-link-btn">
|
||||||
|
<VIcon
|
||||||
|
v-if="item.icon"
|
||||||
|
:icon="item.icon"
|
||||||
|
class="nav-item-icon"
|
||||||
|
size="22"
|
||||||
|
/>
|
||||||
|
<VIcon
|
||||||
|
v-else
|
||||||
|
icon="tabler-circle"
|
||||||
|
class="nav-item-icon"
|
||||||
|
size="12"
|
||||||
|
/>
|
||||||
|
<span v-show="!isMini" class="nav-item-title">{{ item.title }}</span>
|
||||||
|
<span
|
||||||
|
v-if="item.badgeContent"
|
||||||
|
v-show="!isMini"
|
||||||
|
class="nav-item-badge"
|
||||||
|
:class="item.badgeClass"
|
||||||
|
>
|
||||||
|
{{ item.badgeContent }}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import type { NavSectionTitle } from '@layouts/types'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
item: NavSectionTitle
|
||||||
|
isMini?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
withDefaults(defineProps<Props>(), {
|
||||||
|
isMini: false,
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<li class="nav-section-title">
|
||||||
|
<div v-show="!isMini" class="nav-section-title-text">
|
||||||
|
{{ item.heading }}
|
||||||
|
</div>
|
||||||
|
<VIcon v-show="isMini" icon="tabler-dots" size="16" class="nav-section-title-icon" />
|
||||||
|
</li>
|
||||||
|
</template>
|
||||||
311
website/resources/ts/@layouts/styles/_vertical-nav.scss
Normal file
311
website/resources/ts/@layouts/styles/_vertical-nav.scss
Normal file
@@ -0,0 +1,311 @@
|
|||||||
|
@use "@configured-variables" as variables;
|
||||||
|
@use "variables" as layout-variables;
|
||||||
|
|
||||||
|
// ━━━ Layout Wrapper ━━━
|
||||||
|
.layout-wrapper.layout-nav-type-vertical {
|
||||||
|
display: flex;
|
||||||
|
min-block-size: 100vh;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ━━━ Vertical Nav (Sidebar) ━━━
|
||||||
|
.layout-vertical-nav {
|
||||||
|
position: fixed;
|
||||||
|
inset-block: 0;
|
||||||
|
inset-inline-start: 0;
|
||||||
|
z-index: layout-variables.$layout-vertical-nav-z-index;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
inline-size: layout-variables.$layout-vertical-nav-width;
|
||||||
|
background-color: rgb(var(--v-theme-surface));
|
||||||
|
border-inline-end: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
||||||
|
transition: inline-size 0.25s ease-in-out, transform 0.25s ease-in-out, box-shadow 0.25s ease-in-out;
|
||||||
|
|
||||||
|
// ━━━ Nav Header ━━━
|
||||||
|
.nav-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 16px 16px 8px;
|
||||||
|
min-block-size: layout-variables.$layout-vertical-nav-navbar-height;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-header-logo {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
text-decoration: none;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-logo-img {
|
||||||
|
block-size: 28px;
|
||||||
|
inline-size: auto;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-logo-title {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
color: rgb(var(--v-theme-on-surface));
|
||||||
|
white-space: nowrap;
|
||||||
|
transition: opacity 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-collapse-btn {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ━━━ Nav Items List ━━━
|
||||||
|
.nav-items {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
flex-grow: 1;
|
||||||
|
padding-block: 4px;
|
||||||
|
padding-inline: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ━━━ Nav Link ━━━
|
||||||
|
.nav-link {
|
||||||
|
margin-block: 2px;
|
||||||
|
|
||||||
|
.nav-link-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
text-decoration: none;
|
||||||
|
color: rgba(var(--v-theme-on-surface), 0.78);
|
||||||
|
transition: background-color 0.15s ease, color 0.15s ease;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item-icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item-title {
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
flex-grow: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item-badge {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background-color: rgb(var(--v-theme-error));
|
||||||
|
color: rgb(var(--v-theme-on-error));
|
||||||
|
line-height: 1;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:not(.disabled) .nav-link-btn:hover {
|
||||||
|
background-color: rgba(var(--v-theme-on-surface), 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active .nav-link-btn {
|
||||||
|
background: linear-gradient(270deg, rgb(var(--v-theme-primary)), rgba(var(--v-theme-primary), 0.7));
|
||||||
|
color: rgb(var(--v-theme-on-primary));
|
||||||
|
box-shadow: 0 2px 6px 0 rgba(var(--v-theme-primary), 0.4);
|
||||||
|
|
||||||
|
.nav-item-icon {
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.disabled .nav-link-btn {
|
||||||
|
opacity: 0.4;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ━━━ Nav Group ━━━
|
||||||
|
.nav-group {
|
||||||
|
margin-block: 2px;
|
||||||
|
|
||||||
|
.nav-group-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: rgba(var(--v-theme-on-surface), 0.78);
|
||||||
|
transition: background-color 0.15s ease;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-group-label:hover {
|
||||||
|
background-color: rgba(var(--v-theme-on-surface), 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active > .nav-group-label {
|
||||||
|
color: rgb(var(--v-theme-primary));
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-group-arrow {
|
||||||
|
margin-inline-start: auto;
|
||||||
|
transition: transform 0.25s ease-in-out;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.open > .nav-group-label .nav-group-arrow {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-group-children {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
padding-inline-start: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.disabled > .nav-group-label {
|
||||||
|
opacity: 0.4;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ━━━ Section Title ━━━
|
||||||
|
.nav-section-title {
|
||||||
|
margin-block: 12px 4px;
|
||||||
|
padding-inline: 12px;
|
||||||
|
|
||||||
|
.nav-section-title-text {
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: rgba(var(--v-theme-on-surface), 0.38);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-section-title-icon {
|
||||||
|
color: rgba(var(--v-theme-on-surface), 0.38);
|
||||||
|
margin-inline: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:first-child {
|
||||||
|
margin-block-start: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ━━━ Collapsed / Mini Mode ━━━
|
||||||
|
.layout-vertical-nav-collapsed .layout-vertical-nav:not(:hover) {
|
||||||
|
inline-size: layout-variables.$layout-vertical-nav-collapsed-width;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout-vertical-nav-collapsed .layout-vertical-nav:hover {
|
||||||
|
box-shadow: 0 4px 25px 0 rgba(0, 0, 0, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ━━━ Content Wrapper ━━━
|
||||||
|
.layout-content-wrapper {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex-grow: 1;
|
||||||
|
min-block-size: 100vh;
|
||||||
|
padding-inline-start: layout-variables.$layout-vertical-nav-width;
|
||||||
|
transition: padding-inline-start 0.25s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout-vertical-nav-collapsed .layout-content-wrapper {
|
||||||
|
padding-inline-start: layout-variables.$layout-vertical-nav-collapsed-width;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ━━━ Navbar ━━━
|
||||||
|
.layout-navbar {
|
||||||
|
position: sticky;
|
||||||
|
inset-block-start: 0;
|
||||||
|
z-index: layout-variables.$layout-vertical-nav-layout-navbar-z-index;
|
||||||
|
background-color: rgb(var(--v-theme-surface));
|
||||||
|
border-block-end: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
||||||
|
min-block-size: layout-variables.$layout-vertical-nav-navbar-height;
|
||||||
|
|
||||||
|
.navbar-content {
|
||||||
|
block-size: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ━━━ Page Content ━━━
|
||||||
|
.layout-page-content {
|
||||||
|
flex-grow: 1;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ━━━ Footer ━━━
|
||||||
|
.layout-footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-block-size: layout-variables.$layout-vertical-nav-footer-height;
|
||||||
|
padding-inline: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ━━━ Overlay ━━━
|
||||||
|
.layout-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: layout-variables.$layout-overlay-z-index;
|
||||||
|
background-color: rgba(0, 0, 0, 0.5);
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity 0.25s ease-in-out;
|
||||||
|
|
||||||
|
&.visible {
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ━━━ Responsive (Mobile Overlay) ━━━
|
||||||
|
@media (max-width: 1279.98px) {
|
||||||
|
.layout-vertical-nav {
|
||||||
|
position: fixed;
|
||||||
|
transform: translateX(-100%);
|
||||||
|
|
||||||
|
&.visible {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout-content-wrapper {
|
||||||
|
padding-inline-start: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout-vertical-nav-collapsed .layout-content-wrapper {
|
||||||
|
padding-inline-start: 0 !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ━━━ Transition for nav group expand/collapse ━━━
|
||||||
|
.vertical-nav-group-enter-active,
|
||||||
|
.vertical-nav-group-leave-active {
|
||||||
|
overflow: hidden;
|
||||||
|
transition: max-height 0.3s ease-in-out, opacity 0.25s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vertical-nav-group-enter-from,
|
||||||
|
.vertical-nav-group-leave-to {
|
||||||
|
max-height: 0;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vertical-nav-group-enter-to,
|
||||||
|
.vertical-nav-group-leave-from {
|
||||||
|
max-height: 500px;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
23
website/resources/ts/@layouts/types.ts
Normal file
23
website/resources/ts/@layouts/types.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
export interface NavLink {
|
||||||
|
title: string
|
||||||
|
to: string
|
||||||
|
icon?: string
|
||||||
|
badgeContent?: string
|
||||||
|
badgeClass?: string
|
||||||
|
disable?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NavGroup {
|
||||||
|
title: string
|
||||||
|
icon?: string
|
||||||
|
children: (NavLink | NavGroup)[]
|
||||||
|
badgeContent?: string
|
||||||
|
badgeClass?: string
|
||||||
|
disable?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NavSectionTitle {
|
||||||
|
heading: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type VerticalNavItems = (NavLink | NavGroup | NavSectionTitle)[]
|
||||||
@@ -1,15 +1,11 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { Link, usePage } from '@inertiajs/vue3'
|
import { Link, usePage } from '@inertiajs/vue3'
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { useTheme } from 'vuetify'
|
|
||||||
import { accountNavItems } from '@/navigation/account'
|
import { accountNavItems } from '@/navigation/account'
|
||||||
import FlashMessages from '@/Components/FlashMessages.vue'
|
import FlashMessages from '@/Components/FlashMessages.vue'
|
||||||
import NotificationBell from '@/Components/NotificationBell.vue'
|
import NotificationBell from '@/Components/NotificationBell.vue'
|
||||||
import ThemeSwitcher from '@/Components/ThemeSwitcher.vue'
|
import ThemeSwitcher from '@/Components/ThemeSwitcher.vue'
|
||||||
import logoWhite from '@images/ezscale_logo_white.png'
|
import VerticalNavLayout from '@layouts/components/VerticalNavLayout.vue'
|
||||||
|
|
||||||
const theme = useTheme()
|
|
||||||
const isDark = computed(() => theme.global.current.value.dark)
|
|
||||||
|
|
||||||
interface AuthUser {
|
interface AuthUser {
|
||||||
name: string
|
name: string
|
||||||
@@ -27,53 +23,17 @@ const props = computed(() => page.props as unknown as PageProps)
|
|||||||
const user = computed(() => props.value.auth?.user)
|
const user = computed(() => props.value.auth?.user)
|
||||||
const isImpersonating = computed(() => props.value.impersonating)
|
const isImpersonating = computed(() => props.value.impersonating)
|
||||||
const adminUrl = computed(() => `https://${props.value.domains?.admin}`)
|
const adminUrl = computed(() => `https://${props.value.domains?.admin}`)
|
||||||
const currentUrl = computed(() => page.url)
|
|
||||||
|
|
||||||
function isActive(matchPrefix: string): boolean {
|
|
||||||
return currentUrl.value.startsWith(matchPrefix)
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<VApp>
|
<VApp>
|
||||||
<VAppBar flat>
|
<VerticalNavLayout :nav-items="accountNavItems">
|
||||||
<VContainer class="d-flex align-center">
|
<template #navbar>
|
||||||
<Link href="/dashboard" class="d-inline-flex align-center">
|
|
||||||
<img
|
|
||||||
:src="logoWhite"
|
|
||||||
alt="EZSCALE"
|
|
||||||
:class="{ 'logo-light': !isDark }"
|
|
||||||
style="height: 32px; width: auto;"
|
|
||||||
>
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<VSpacer />
|
<VSpacer />
|
||||||
|
|
||||||
<div class="d-flex align-center ga-1">
|
|
||||||
<Link
|
|
||||||
v-for="item in accountNavItems"
|
|
||||||
:key="item.href"
|
|
||||||
:href="item.href"
|
|
||||||
class="text-decoration-none"
|
|
||||||
>
|
|
||||||
<VBtn
|
|
||||||
variant="text"
|
|
||||||
:color="isActive(item.matchPrefix) ? 'primary' : undefined"
|
|
||||||
size="small"
|
|
||||||
>
|
|
||||||
<VIcon :icon="item.icon" start />
|
|
||||||
{{ item.title }}
|
|
||||||
</VBtn>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<VSpacer />
|
|
||||||
|
|
||||||
<div class="d-flex align-center ga-2">
|
|
||||||
<NotificationBell />
|
<NotificationBell />
|
||||||
<ThemeSwitcher />
|
<ThemeSwitcher />
|
||||||
|
|
||||||
<span v-if="user" class="text-body-2">
|
<span v-if="user" class="text-body-2 ms-3">
|
||||||
{{ user.name }}
|
{{ user.name }}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
@@ -82,24 +42,21 @@ function isActive(matchPrefix: string): boolean {
|
|||||||
href="/logout"
|
href="/logout"
|
||||||
method="post"
|
method="post"
|
||||||
as="button"
|
as="button"
|
||||||
class="text-decoration-none"
|
class="text-decoration-none ms-2"
|
||||||
>
|
>
|
||||||
<VBtn variant="text" size="small" color="error">
|
<VBtn variant="text" size="small" color="error">
|
||||||
<VIcon icon="tabler-logout" start />
|
<VIcon icon="tabler-logout" start />
|
||||||
Log out
|
Log out
|
||||||
</VBtn>
|
</VBtn>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</template>
|
||||||
</VContainer>
|
|
||||||
</VAppBar>
|
|
||||||
|
|
||||||
<!-- Impersonation Banner -->
|
<!-- Impersonation Banner -->
|
||||||
<VBanner
|
<VBanner
|
||||||
v-if="isImpersonating"
|
v-if="isImpersonating"
|
||||||
color="warning"
|
color="warning"
|
||||||
icon="tabler-user-shield"
|
icon="tabler-user-shield"
|
||||||
sticky
|
class="mb-4"
|
||||||
class="impersonation-banner"
|
|
||||||
>
|
>
|
||||||
<template #text>
|
<template #text>
|
||||||
You are impersonating <strong>{{ user?.name }}</strong>. Actions will be attributed to this user.
|
You are impersonating <strong>{{ user?.name }}</strong>. Actions will be attributed to this user.
|
||||||
@@ -118,17 +75,8 @@ function isActive(matchPrefix: string): boolean {
|
|||||||
</template>
|
</template>
|
||||||
</VBanner>
|
</VBanner>
|
||||||
|
|
||||||
<VMain>
|
|
||||||
<VContainer>
|
|
||||||
<FlashMessages />
|
<FlashMessages />
|
||||||
<slot />
|
<slot />
|
||||||
</VContainer>
|
</VerticalNavLayout>
|
||||||
</VMain>
|
|
||||||
|
|
||||||
<VFooter app class="text-center d-flex align-center justify-center">
|
|
||||||
<span class="text-body-2 text-medium-emphasis">
|
|
||||||
© {{ new Date().getFullYear() }} EZSCALE. All rights reserved.
|
|
||||||
</span>
|
|
||||||
</VFooter>
|
|
||||||
</VApp>
|
</VApp>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,15 +1,11 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { Link, usePage } from '@inertiajs/vue3'
|
import { Link, usePage } from '@inertiajs/vue3'
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { useTheme } from 'vuetify'
|
|
||||||
import { adminNavItems } from '@/navigation/admin'
|
import { adminNavItems } from '@/navigation/admin'
|
||||||
import FlashMessages from '@/Components/FlashMessages.vue'
|
import FlashMessages from '@/Components/FlashMessages.vue'
|
||||||
import NotificationBell from '@/Components/NotificationBell.vue'
|
import NotificationBell from '@/Components/NotificationBell.vue'
|
||||||
import ThemeSwitcher from '@/Components/ThemeSwitcher.vue'
|
import ThemeSwitcher from '@/Components/ThemeSwitcher.vue'
|
||||||
import logoWhite from '@images/ezscale_logo_white.png'
|
import VerticalNavLayout from '@layouts/components/VerticalNavLayout.vue'
|
||||||
|
|
||||||
const theme = useTheme()
|
|
||||||
const isDark = computed(() => theme.global.current.value.dark)
|
|
||||||
|
|
||||||
interface AuthUser {
|
interface AuthUser {
|
||||||
name: string
|
name: string
|
||||||
@@ -24,54 +20,19 @@ interface PageProps {
|
|||||||
const page = usePage()
|
const page = usePage()
|
||||||
const props = computed(() => page.props as unknown as PageProps)
|
const props = computed(() => page.props as unknown as PageProps)
|
||||||
const user = computed(() => props.value.auth?.user)
|
const user = computed(() => props.value.auth?.user)
|
||||||
const accountDomain = computed(() => props.value.domains?.account)
|
const accountUrl = computed(() => `https://${props.value.domains?.account}`)
|
||||||
const accountUrl = computed(() => `https://${accountDomain.value}`)
|
|
||||||
const currentUrl = computed(() => page.url)
|
|
||||||
|
|
||||||
function isActive(matchPrefix: string): boolean {
|
|
||||||
return currentUrl.value.startsWith(matchPrefix)
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<VApp>
|
<VApp>
|
||||||
<VAppBar flat>
|
<VerticalNavLayout :nav-items="adminNavItems">
|
||||||
<VContainer class="d-flex align-center">
|
<template #navbar>
|
||||||
<Link href="/dashboard" class="d-inline-flex align-center ga-2">
|
<VChip size="small" color="error" variant="flat" class="me-2">
|
||||||
<img
|
|
||||||
:src="logoWhite"
|
|
||||||
alt="EZSCALE"
|
|
||||||
:class="{ 'logo-light': !isDark }"
|
|
||||||
style="height: 32px; width: auto;"
|
|
||||||
>
|
|
||||||
<VChip size="small" color="error" variant="flat">
|
|
||||||
Admin
|
Admin
|
||||||
</VChip>
|
</VChip>
|
||||||
</Link>
|
|
||||||
|
|
||||||
<VSpacer />
|
<VSpacer />
|
||||||
|
|
||||||
<div class="d-flex align-center ga-1">
|
|
||||||
<Link
|
|
||||||
v-for="item in adminNavItems"
|
|
||||||
:key="item.href"
|
|
||||||
:href="item.href"
|
|
||||||
class="text-decoration-none"
|
|
||||||
>
|
|
||||||
<VBtn
|
|
||||||
variant="text"
|
|
||||||
:color="isActive(item.matchPrefix) ? 'primary' : undefined"
|
|
||||||
size="small"
|
|
||||||
>
|
|
||||||
<VIcon :icon="item.icon" start />
|
|
||||||
{{ item.title }}
|
|
||||||
</VBtn>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<VSpacer />
|
|
||||||
|
|
||||||
<div class="d-flex align-center ga-2">
|
|
||||||
<a
|
<a
|
||||||
v-if="user"
|
v-if="user"
|
||||||
:href="accountUrl + '/dashboard'"
|
:href="accountUrl + '/dashboard'"
|
||||||
@@ -86,7 +47,7 @@ function isActive(matchPrefix: string): boolean {
|
|||||||
<NotificationBell />
|
<NotificationBell />
|
||||||
<ThemeSwitcher />
|
<ThemeSwitcher />
|
||||||
|
|
||||||
<span v-if="user" class="text-body-2">
|
<span v-if="user" class="text-body-2 ms-3">
|
||||||
{{ user.name }}
|
{{ user.name }}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
@@ -95,28 +56,17 @@ function isActive(matchPrefix: string): boolean {
|
|||||||
:href="accountUrl + '/logout'"
|
:href="accountUrl + '/logout'"
|
||||||
method="post"
|
method="post"
|
||||||
as="button"
|
as="button"
|
||||||
class="text-decoration-none"
|
class="text-decoration-none ms-2"
|
||||||
>
|
>
|
||||||
<VBtn variant="text" size="small" color="error">
|
<VBtn variant="text" size="small" color="error">
|
||||||
<VIcon icon="tabler-logout" start />
|
<VIcon icon="tabler-logout" start />
|
||||||
Log out
|
Log out
|
||||||
</VBtn>
|
</VBtn>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</template>
|
||||||
</VContainer>
|
|
||||||
</VAppBar>
|
|
||||||
|
|
||||||
<VMain>
|
|
||||||
<VContainer>
|
|
||||||
<FlashMessages />
|
<FlashMessages />
|
||||||
<slot />
|
<slot />
|
||||||
</VContainer>
|
</VerticalNavLayout>
|
||||||
</VMain>
|
|
||||||
|
|
||||||
<VFooter app class="text-center d-flex align-center justify-center">
|
|
||||||
<span class="text-body-2 text-medium-emphasis">
|
|
||||||
© {{ new Date().getFullYear() }} EZSCALE. All rights reserved.
|
|
||||||
</span>
|
|
||||||
</VFooter>
|
|
||||||
</VApp>
|
</VApp>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
export interface NavItem {
|
import type { VerticalNavItems } from '@layouts/types'
|
||||||
title: string
|
|
||||||
href: string
|
|
||||||
icon: string
|
|
||||||
matchPrefix: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export const accountNavItems: NavItem[] = [
|
export const accountNavItems: VerticalNavItems = [
|
||||||
{ title: 'Dashboard', href: '/dashboard', icon: 'tabler-smart-home', matchPrefix: '/dashboard' },
|
{ heading: 'Account' },
|
||||||
{ title: 'Services', href: '/services', icon: 'tabler-server', matchPrefix: '/services' },
|
{ title: 'Dashboard', to: '/dashboard', icon: 'tabler-smart-home' },
|
||||||
{ title: 'Subscriptions', href: '/subscriptions', icon: 'tabler-receipt', matchPrefix: '/subscriptions' },
|
{ title: 'Services', to: '/services', icon: 'tabler-server' },
|
||||||
{ title: 'Billing', href: '/billing', icon: 'tabler-credit-card', matchPrefix: '/billing' },
|
{ title: 'Subscriptions', to: '/subscriptions', icon: 'tabler-receipt' },
|
||||||
{ title: 'Plans', href: '/plans', icon: 'tabler-package', matchPrefix: '/plans' },
|
|
||||||
{ title: 'Support', href: '/tickets', icon: 'tabler-headset', matchPrefix: '/tickets' },
|
{ heading: 'Billing' },
|
||||||
{ title: 'Settings', href: '/profile', icon: 'tabler-settings', matchPrefix: '/profile' },
|
{ title: 'Billing', to: '/billing', icon: 'tabler-credit-card' },
|
||||||
|
{ title: 'Plans', to: '/plans', icon: 'tabler-package' },
|
||||||
|
|
||||||
|
{ heading: 'Support' },
|
||||||
|
{ title: 'Tickets', to: '/tickets', icon: 'tabler-headset' },
|
||||||
|
{ title: 'Settings', to: '/profile', icon: 'tabler-settings' },
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,14 +1,23 @@
|
|||||||
import type { NavItem } from './account'
|
import type { VerticalNavItems } from '@layouts/types'
|
||||||
|
|
||||||
export const adminNavItems: NavItem[] = [
|
export const adminNavItems: VerticalNavItems = [
|
||||||
{ title: 'Dashboard', href: '/dashboard', icon: 'tabler-smart-home', matchPrefix: '/dashboard' },
|
{ heading: 'Dashboard' },
|
||||||
{ title: 'Plans', href: '/plans', icon: 'tabler-package', matchPrefix: '/plans' },
|
{ title: 'Analytics', to: '/dashboard', icon: 'tabler-smart-home' },
|
||||||
{ title: 'Customers', href: '/customers', icon: 'tabler-users', matchPrefix: '/customers' },
|
|
||||||
{ title: 'Services', href: '/services', icon: 'tabler-server', matchPrefix: '/services' },
|
{ heading: 'Management' },
|
||||||
{ title: 'Orders', href: '/orders', icon: 'tabler-shopping-cart', matchPrefix: '/orders' },
|
{ title: 'Customers', to: '/customers', icon: 'tabler-users' },
|
||||||
{ title: 'Invoices', href: '/invoices', icon: 'tabler-file-invoice', matchPrefix: '/invoices' },
|
{ title: 'Plans', to: '/plans', icon: 'tabler-package' },
|
||||||
{ title: 'Coupons', href: '/coupons', icon: 'tabler-discount-2', matchPrefix: '/coupons' },
|
{ title: 'Services', to: '/services', icon: 'tabler-server' },
|
||||||
{ title: 'Tickets', href: '/tickets', icon: 'tabler-message-circle', matchPrefix: '/tickets' },
|
{ title: 'Orders', to: '/orders', icon: 'tabler-shopping-cart' },
|
||||||
{ title: 'Audit Logs', href: '/audit-logs', icon: 'tabler-clipboard-list', matchPrefix: '/audit-logs' },
|
|
||||||
{ title: 'Settings', href: '/settings', icon: 'tabler-settings', matchPrefix: '/settings' },
|
{ heading: 'Billing' },
|
||||||
|
{ title: 'Invoices', to: '/invoices', icon: 'tabler-file-invoice' },
|
||||||
|
{ title: 'Coupons', to: '/coupons', icon: 'tabler-discount-2' },
|
||||||
|
|
||||||
|
{ heading: 'Support' },
|
||||||
|
{ title: 'Tickets', to: '/tickets', icon: 'tabler-message-circle' },
|
||||||
|
|
||||||
|
{ heading: 'System' },
|
||||||
|
{ title: 'Audit Logs', to: '/audit-logs', icon: 'tabler-clipboard-list' },
|
||||||
|
{ title: 'Settings', to: '/settings', icon: 'tabler-settings' },
|
||||||
]
|
]
|
||||||
|
|||||||
Reference in New Issue
Block a user