This commit is contained in:
Leonardo
2026-03-06 06:37:13 -03:00
parent d58dc21297
commit f733db8436
146 changed files with 43436 additions and 12779 deletions
+68 -39
View File
@@ -1,10 +1,10 @@
<!-- src/layout/AppMenuItem.vue -->
<script setup>
import { useLayout } from '@/layout/composables/layout'
import { computed, ref, nextTick } from 'vue'
import { computed, ref, nextTick, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import Popover from 'primevue/popover'
import Button from 'primevue/button'
import { useTenantStore } from '@/stores/tenantStore'
import { useEntitlementsStore } from '@/stores/entitlementsStore'
@@ -31,54 +31,85 @@ const fullPath = computed(() =>
)
// ==============================
// Active logic: mantém submenu aberto se algum descendente estiver ativo
// Visible: boolean OU function() -> boolean
// ==============================
const isVisible = computed(() => {
const v = props.item?.visible
if (typeof v === 'function') return !!v()
if (v === undefined || v === null) return true
return v !== false
})
// ==============================
// Helpers de rota: aceita string OU objeto
// ==============================
function toPath (to) {
if (!to) return ''
if (typeof to === 'string') return to
try { return router.resolve(to).path || '' } catch { return '' }
}
// ==============================
// Active logic
// ==============================
function isSameRoute (current, target) {
if (!current || !target) return false
return current === target || current.startsWith(target + '/')
const cur = typeof current === 'string' ? current : toPath(current)
const tar = typeof target === 'string' ? target : toPath(target)
if (!cur || !tar) return false
return cur === tar || cur.startsWith(tar + '/')
}
function hasActiveDescendant (node, currentPath) {
const children = node?.items || []
for (const child of children) {
if (child?.to && isSameRoute(currentPath, child.to)) return true
const childTo = toPath(child?.to)
if (childTo && isSameRoute(currentPath, childTo)) return true
if (child?.items?.length && hasActiveDescendant(child, currentPath)) return true
}
return false
}
const isActive = computed(() => {
const current = layoutState.activePath || ''
const current = typeof layoutState.activePath === 'string'
? layoutState.activePath
: toPath(layoutState.activePath)
const item = props.item
// grupo com submenu: active se qualquer descendente estiver ativo
if (item?.items?.length) {
if (hasActiveDescendant(item, current)) return true
// fallback pelo "path" (útil para rotas internas tipo /saas/plans/123)
return item.path ? current.startsWith(fullPath.value || '') : false
}
// folha: active se rota igual ao to
return item?.to ? isSameRoute(current, item.to) : false
const leafTo = toPath(item?.to)
return leafTo ? isSameRoute(current, leafTo) : false
})
// ==============================
// Feature lock + label
// ✅ PRO badge (agora 100% por entitlementsStore)
// ==============================
const ownerId = computed(() => tenantStore.activeTenantId || null)
const isLocked = computed(() => {
const showProBadge = computed(() => {
const feature = props.item?.feature
return !!(props.item?.proBadge && feature && ownerId.value && !entitlementsStore.has(feature))
if (!props.item?.proBadge || !feature) return false
// se tiver a feature, NÃO é PRO (não mostra badge)
// se NÃO tiver, mostra PRO
try {
return !entitlementsStore.has(feature)
} catch {
// se der erro, não mostra (evita “PRO fantasma”)
return false
}
})
// 🔒 locked quando precisa mostrar PRO (ou seja, não tem feature)
const isLocked = computed(() => !!(props.item?.proBadge && showProBadge.value))
const itemDisabled = computed(() => !!props.item?.disabled)
const isBlocked = computed(() => itemDisabled.value || isLocked.value)
const labelText = computed(() => {
const base = props.item?.label || ''
return props.item?.proBadge && isLocked.value ? `${base} (PRO)` : base
return props.item?.label || ''
})
const itemClick = async (event, item) => {
@@ -96,17 +127,14 @@ const itemClick = async (event, item) => {
return
}
// 🚫 disabled -> bloqueia
if (itemDisabled.value) {
event.preventDefault()
event.stopPropagation()
return
}
// commands
if (item?.command) item.command({ originalEvent: event, item })
// ✅ submenu: expande/colapsa e não navega
if (item?.items?.length) {
event.preventDefault()
event.stopPropagation()
@@ -114,24 +142,22 @@ const itemClick = async (event, item) => {
if (isActive.value) {
layoutState.activePath = props.parentPath || ''
} else {
layoutState.activePath = fullPath.value
layoutState.activePath = fullPath.value || ''
layoutState.menuHoverActive = true
}
return
}
// ✅ leaf: marca ativo e NÃO fecha menu
if (item?.to) layoutState.activePath = item.to
if (item?.to) layoutState.activePath = toPath(item.to)
}
const onMouseEnter = () => {
if (isDesktop() && props.root && props.item?.items && layoutState.menuHoverActive) {
layoutState.activePath = fullPath.value
layoutState.activePath = fullPath.value || ''
}
}
/* ---------- POPUP + ---------- */
function togglePopover (event) {
if (isBlocked.value) return
pop.value?.toggle(event)
@@ -143,10 +169,7 @@ function closePopover () {
function abrirCadastroRapido () {
closePopover()
emit('quick-create', {
entity: props.item?.quickCreateEntity || 'patient',
mode: 'rapido'
})
emit('quick-create', { entity: props.item?.quickCreateEntity || 'patient', mode: 'rapido' })
}
async function irCadastroCompleto () {
@@ -157,17 +180,17 @@ async function irCadastroCompleto () {
layoutState.menuHoverActive = false
await nextTick()
router.push('/admin/patients/cadastro')
router.push({ name: 'admin-pacientes-cadastro' })
}
</script>
<template>
<li :class="{ 'layout-root-menuitem': root, 'active-menuitem': isActive }">
<div v-if="root && item.visible !== false" class="layout-menuitem-root-text">
<li v-show="isVisible" :class="{ 'layout-root-menuitem': root, 'active-menuitem': isActive }">
<div v-if="root" class="layout-menuitem-root-text">
{{ item.label }}
</div>
<div v-if="!root && item.visible !== false" class="flex align-items-center justify-content-between w-full">
<div v-if="!root" class="flex align-items-center justify-content-between w-full">
<component
:is="item.to && !item.items ? 'router-link' : 'a'"
v-bind="item.to && !item.items ? { to: item.to } : { href: item.url }"
@@ -183,8 +206,14 @@ async function irCadastroCompleto () {
<span class="layout-menuitem-text">
{{ labelText }}
<!-- (debug) pode remover depois -->
<small style="opacity:.6">[locked={{ isLocked }}]</small>
</span>
<!-- Badge PRO some quando tem entitlements -->
<span
v-if="item.proBadge && showProBadge"
class="ml-2 text-xs px-2 py-0.5 rounded border border-[var(--surface-border)] opacity-80"
>
PRO
</span>
<i v-if="item.items" class="pi pi-fw pi-angle-down layout-submenu-toggler" />
@@ -209,7 +238,7 @@ async function irCadastroCompleto () {
</div>
</Popover>
<Transition v-if="item.items && item.visible !== false" name="layout-submenu">
<Transition v-if="item.items" name="layout-submenu">
<ul v-show="root ? true : isActive" class="layout-submenu">
<app-menu-item
v-for="child in item.items"
@@ -222,4 +251,4 @@ async function irCadastroCompleto () {
</ul>
</Transition>
</li>
</template>
</template>