Files
agenciapsilmno/src/layout/AppMenuItem.vue
T

315 lines
12 KiB
Vue

<!--
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/layout/AppMenuItem.vue
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
-->
<script setup>
import { useLayout } from '@/layout/composables/layout';
import { computed, ref, nextTick, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import Popover from 'primevue/popover';
import PatientCadastroDialog from '@/components/ui/PatientCadastroDialog.vue';
import { useTenantStore } from '@/stores/tenantStore';
import { useEntitlementsStore } from '@/stores/entitlementsStore';
import { useMenuBadges } from '@/composables/useMenuBadges';
const { layoutState, isDesktop } = useLayout();
const router = useRouter();
const pop = ref(null);
const tenantStore = useTenantStore();
const entitlementsStore = useEntitlementsStore();
const menuBadges = useMenuBadges();
function menuBadgeLabel(item) {
const key = item?.badgeKey;
if (!key) return null;
const val = menuBadges[key]?.value || 0;
if (!val) return null;
return key === 'agendaHoje' ? `${val} hoje` : String(val);
}
const emit = defineEmits(['quick-create']);
const props = defineProps({
item: { type: Object, default: () => ({}) },
index: { type: Number, default: 0 },
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));
// ==============================
// 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) {
const cur = typeof current === 'string' ? current : toPath(current);
const tar = typeof target === 'string' ? target : toPath(target);
if (!cur || !tar) return false;
if (cur === tar) return true;
// Prefix match apenas para paths com 2+ segmentos (ex: /therapist/patients).
// Paths de 1 segmento (ex: /therapist) só ativam em match exato,
// evitando que o Dashboard fique ativo em todas as sub-rotas.
const segments = tar.split('/').filter(Boolean);
return segments.length >= 2 && cur.startsWith(tar + '/');
}
function hasActiveDescendant(node, currentPath) {
const children = node?.items || [];
for (const child of children) {
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 = typeof layoutState.activePath === 'string' ? layoutState.activePath : toPath(layoutState.activePath);
const item = props.item;
if (item?.items?.length) {
if (hasActiveDescendant(item, current)) return true;
return item.path ? current.startsWith(fullPath.value || '') : false;
}
const leafTo = toPath(item?.to);
return leafTo ? isSameRoute(current, leafTo) : false;
});
// ==============================
// ✅ PRO badge (agora 100% por entitlementsStore)
// ==============================
const showProBadge = computed(() => {
const feature = props.item?.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(() => {
return props.item?.label || '';
});
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;
}
if (itemDisabled.value) {
event.preventDefault();
event.stopPropagation();
return;
}
if (item?.command) item.command({ originalEvent: event, item });
if (item?.items?.length) {
event.preventDefault();
event.stopPropagation();
if (isActive.value) {
layoutState.activePath = props.parentPath || '';
} else {
layoutState.activePath = fullPath.value || '';
layoutState.menuHoverActive = true;
}
return;
}
if (item?.to) layoutState.activePath = toPath(item.to);
};
const onMouseEnter = () => {
if (isDesktop() && props.root && props.item?.items && layoutState.menuHoverActive) {
layoutState.activePath = fullPath.value || '';
}
};
const showCadastroDialog = ref(false);
/* ---------- 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' });
}
function irCadastroCompleto() {
closePopover();
layoutState.overlayMenuActive = false;
layoutState.mobileMenuActive = false;
layoutState.menuHoverActive = false;
showCadastroDialog.value = true;
}
async function irLinkCadastro() {
closePopover();
layoutState.overlayMenuActive = false;
layoutState.mobileMenuActive = false;
layoutState.menuHoverActive = false;
await nextTick();
const linkTo = props.item?.quickCreateLinkTo || '/therapist/patients/link-externo';
router.push(linkTo);
}
</script>
<template>
<PatientCadastroDialog v-if="item.quickCreate" v-model="showCadastroDialog" />
<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" 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' : '', { 'active-route': isActive && !item.items }]"
: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 }}
</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>
<!-- Badge contador (agenda hoje / cadastros / agendamentos) -->
<span v-if="menuBadgeLabel(item)" class="ml-auto text-[0.62rem] font-bold px-1.5 py-px rounded-full bg-[var(--primary-color)] text-white leading-none">
{{ menuBadgeLabel(item) }}
</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-col gap-0.5 min-w-[190px] py-0.5">
<button class="flex items-center gap-2.5 px-3 py-2 rounded-md cursor-pointer border-0 bg-transparent text-left w-full transition-colors duration-100 hover:bg-[var(--surface-ground,#f8fafc)]" @click="abrirCadastroRapido">
<div class="w-7 h-7 rounded-md flex items-center justify-center shrink-0 bg-indigo-500/10 text-indigo-500">
<i class="pi pi-bolt text-xs" />
</div>
<div>
<div class="text-sm font-semibold text-[var(--text-color)]">Cadastro Rápido</div>
<div class="text-[0.68rem] text-[var(--text-color-secondary)]">Nome, e-mail e telefone</div>
</div>
</button>
<button class="flex items-center gap-2.5 px-3 py-2 rounded-md cursor-pointer border-0 bg-transparent text-left w-full transition-colors duration-100 hover:bg-[var(--surface-ground,#f8fafc)]" @click="irCadastroCompleto">
<div class="w-7 h-7 rounded-md flex items-center justify-center shrink-0 bg-emerald-500/10 text-emerald-600">
<i class="pi pi-user-plus text-xs" />
</div>
<div>
<div class="text-sm font-semibold text-[var(--text-color)]">Cadastro Completo</div>
<div class="text-[0.68rem] text-[var(--text-color-secondary)]">Formulário detalhado</div>
</div>
</button>
<div class="mx-3 my-1 border-t border-[var(--surface-border,#e2e8f0)]" />
<button class="flex items-center gap-2.5 px-3 py-2 rounded-md cursor-pointer border-0 bg-transparent text-left w-full transition-colors duration-100 hover:bg-[var(--surface-ground,#f8fafc)]" @click="irLinkCadastro">
<div class="w-7 h-7 rounded-md flex items-center justify-center shrink-0 bg-sky-500/10 text-sky-600">
<i class="pi pi-link text-xs" />
</div>
<div>
<div class="text-sm font-semibold text-[var(--text-color)]">Link de Cadastro</div>
<div class="text-[0.68rem] text-[var(--text-color-secondary)]">Enviar link ao paciente</div>
</div>
</button>
</div>
</Popover>
<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" :key="(child.to || '') + '|' + (child.path || '') + '|' + child.label" :item="child" :root="false" :parentPath="fullPath" @quick-create="emit('quick-create', $event)" />
</ul>
</Transition>
</li>
</template>