315 lines
12 KiB
Vue
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>
|