first commit

This commit is contained in:
Leonardo
2026-02-18 22:36:45 -03:00
parent ec6b6ef53a
commit 676042268b
122 changed files with 26354 additions and 1615 deletions

View File

@@ -1,78 +1,225 @@
<script setup>
import { useLayout } from '@/layout/composables/layout';
import { computed } from 'vue';
import { useLayout } from '@/layout/composables/layout'
import { computed, ref, nextTick } from 'vue'
import { useRouter } from 'vue-router'
const { layoutState, isDesktop } = useLayout();
import Popover from 'primevue/popover'
import Button from 'primevue/button'
import { useTenantStore } from '@/stores/tenantStore'
import { useEntitlementsStore } from '@/stores/entitlementsStore'
const { layoutState, isDesktop } = useLayout()
const router = useRouter()
const pop = ref(null)
const tenantStore = useTenantStore()
const entitlementsStore = useEntitlementsStore()
const emit = defineEmits(['quick-create'])
const props = defineProps({
item: {
type: Object,
default: () => ({})
},
root: {
type: Boolean,
default: true
},
parentPath: {
type: String,
default: null
}
});
item: { type: Object, default: () => ({}) },
root: { type: Boolean, default: false },
parentPath: { type: String, default: null }
})
const fullPath = computed(() => (props.item.path ? (props.parentPath ? props.parentPath + props.item.path : props.item.path) : null));
const fullPath = computed(() =>
props.item?.path
? (props.parentPath ? props.parentPath + props.item.path : props.item.path)
: null
)
// ==============================
// Active logic: mantém submenu aberto se algum descendente estiver ativo
// ==============================
function isSameRoute (current, target) {
if (!current || !target) return false
return current === target || current.startsWith(target + '/')
}
function hasActiveDescendant (node, currentPath) {
const children = node?.items || []
for (const child of children) {
if (child?.to && isSameRoute(currentPath, child.to)) return true
if (child?.items?.length && hasActiveDescendant(child, currentPath)) return true
}
return false
}
const isActive = computed(() => {
return props.item.path ? layoutState.activePath?.startsWith(fullPath.value) : layoutState.activePath === props.item.to;
});
const current = layoutState.activePath || ''
const item = props.item
const itemClick = (event, item) => {
if (item.disabled) {
event.preventDefault();
return;
}
// grupo com submenu: active se qualquer descendente estiver ativo
if (item?.items?.length) {
if (hasActiveDescendant(item, current)) return true
if (item.command) {
item.command({ originalEvent: event, item: item });
}
// fallback pelo "path" (útil para rotas internas tipo /saas/plans/123)
return item.path ? current.startsWith(fullPath.value || '') : false
}
if (item.items) {
if (isActive.value) {
layoutState.activePath = layoutState.activePath.replace(item.path, '');
} else {
layoutState.activePath = fullPath.value;
layoutState.menuHoverActive = true;
}
// folha: active se rota igual ao to
return item?.to ? isSameRoute(current, item.to) : false
})
// ==============================
// Feature lock + label
// ==============================
const ownerId = computed(() => tenantStore.activeTenantId || null)
const isLocked = computed(() => {
const feature = props.item?.feature
return !!(props.item?.proBadge && feature && ownerId.value && !entitlementsStore.has(feature))
})
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
})
const itemClick = async (event, item) => {
// 🔒 locked -> CTA upgrade
if (props.item?.proBadge && isLocked.value) {
event.preventDefault()
event.stopPropagation()
layoutState.overlayMenuActive = false
layoutState.mobileMenuActive = false
layoutState.menuHoverActive = false
await nextTick()
await router.push({ name: 'upgrade', query: { feature: props.item?.feature || '' } })
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()
if (isActive.value) {
layoutState.activePath = props.parentPath || ''
} else {
layoutState.overlayMenuActive = false;
layoutState.mobileMenuActive = false;
layoutState.menuHoverActive = false;
layoutState.activePath = fullPath.value
layoutState.menuHoverActive = true
}
};
return
}
// ✅ leaf: marca ativo e NÃO fecha menu
if (item?.to) layoutState.activePath = item.to
}
const onMouseEnter = () => {
if (isDesktop() && props.root && props.item.items && layoutState.menuHoverActive) {
layoutState.activePath = fullPath.value;
}
};
if (isDesktop() && props.root && props.item?.items && layoutState.menuHoverActive) {
layoutState.activePath = fullPath.value
}
}
/* ---------- POPUP + ---------- */
function togglePopover (event) {
if (isBlocked.value) return
pop.value?.toggle(event)
}
function closePopover () {
try { pop.value?.hide() } catch {}
}
function abrirCadastroRapido () {
closePopover()
emit('quick-create', {
entity: props.item?.quickCreateEntity || 'patient',
mode: 'rapido'
})
}
async function irCadastroCompleto () {
closePopover()
layoutState.overlayMenuActive = false
layoutState.mobileMenuActive = false
layoutState.menuHoverActive = false
await nextTick()
router.push('/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">{{ item.label }}</div>
<a v-if="(!item.to || item.items) && item.visible !== false" :href="item.url" @click="itemClick($event, item)" :class="item.class" :target="item.target" tabindex="0" @mouseenter="onMouseEnter">
<i :class="item.icon" class="layout-menuitem-icon" />
<span class="layout-menuitem-text">{{ item.label }}</span>
<i class="pi pi-fw pi-angle-down layout-submenu-toggler" v-if="item.items" />
</a>
<router-link v-if="item.to && !item.items && item.visible !== false" @click="itemClick($event, item)" exactActiveClass="active-route" :class="item.class" tabindex="0" :to="item.to" @mouseenter="onMouseEnter">
<i :class="item.icon" class="layout-menuitem-icon" />
<span class="layout-menuitem-text">{{ item.label }}</span>
<i class="pi pi-fw pi-angle-down layout-submenu-toggler" v-if="item.items" />
</router-link>
<Transition v-if="item.items && item.visible !== false" name="layout-submenu">
<ul v-show="root ? true : isActive" class="layout-submenu">
<app-menu-item v-for="child in item.items" :key="child.label + '_' + (child.to || child.path)" :item="child" :root="false" :parentPath="fullPath" />
</ul>
</Transition>
</li>
<li :class="{ 'layout-root-menuitem': root, 'active-menuitem': isActive }">
<div v-if="root && item.visible !== false" 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">
<component
:is="item.to && !item.items ? 'router-link' : 'a'"
v-bind="item.to && !item.items ? { to: item.to } : { href: item.url }"
@click="itemClick($event, item)"
:class="[item.class, isBlocked ? 'opacity-60 cursor-pointer' : '']"
:target="item.target"
tabindex="0"
@mouseenter="onMouseEnter"
class="flex align-items-center flex-1"
:aria-disabled="isBlocked ? 'true' : 'false'"
>
<i :class="item.icon" class="layout-menuitem-icon" />
<span class="layout-menuitem-text">
{{ labelText }}
<!-- (debug) pode remover depois -->
<small style="opacity:.6">[locked={{ isLocked }}]</small>
</span>
<i v-if="item.items" class="pi pi-fw pi-angle-down layout-submenu-toggler" />
</component>
<Button
v-if="item.quickCreate"
icon="pi pi-plus"
text
rounded
size="small"
class="ml-2"
:disabled="isBlocked"
@click.stop="togglePopover"
/>
</div>
<Popover v-if="item.quickCreate" ref="pop">
<div class="flex flex-column gap-2 min-w-[180px]">
<Button label="Cadastro rápido" icon="pi pi-bolt" text @click="abrirCadastroRapido" />
<Button label="Cadastro completo" icon="pi pi-user-plus" text @click="irCadastroCompleto" />
</div>
</Popover>
<Transition v-if="item.items && item.visible !== false" name="layout-submenu">
<ul v-show="root ? true : isActive" class="layout-submenu">
<app-menu-item
v-for="child in item.items"
:key="(child.to || '') + '|' + (child.path || '') + '|' + child.label"
:item="child"
:root="false"
:parentPath="fullPath"
@quick-create="emit('quick-create', $event)"
/>
</ul>
</Transition>
</li>
</template>