ZERADO
This commit is contained in:
@@ -7,114 +7,84 @@ import AppMenuItem from './AppMenuItem.vue'
|
||||
import AppMenuFooterPanel from './AppMenuFooterPanel.vue'
|
||||
import ComponentCadastroRapido from '@/components/ComponentCadastroRapido.vue'
|
||||
|
||||
import FloatLabel from 'primevue/floatlabel'
|
||||
import IconField from 'primevue/iconfield'
|
||||
import InputIcon from 'primevue/inputicon'
|
||||
import InputText from 'primevue/inputtext'
|
||||
|
||||
import { sessionRole, sessionIsSaasAdmin } from '@/app/session'
|
||||
import { getMenuByRole } from '@/navigation'
|
||||
|
||||
import { useTenantStore } from '@/stores/tenantStore'
|
||||
import { useEntitlementsStore } from '@/stores/entitlementsStore'
|
||||
import { useMenuStore } from '@/stores/menuStore'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { layoutState } = useLayout()
|
||||
|
||||
const tenantStore = useTenantStore()
|
||||
const entitlementsStore = useEntitlementsStore()
|
||||
const menuStore = useMenuStore()
|
||||
|
||||
const tenantId = computed(() => tenantStore.activeTenantId || null)
|
||||
// ======================================================
|
||||
// ✅ Blindagem anti-“menu some”
|
||||
// - se o menuStore.model piscar como [], mantém o último menu válido
|
||||
// - evita sumiço ao entrar em /admin/clinic/features (reset momentâneo)
|
||||
// ======================================================
|
||||
|
||||
/**
|
||||
* ✅ Role canônico pro MENU:
|
||||
* - PRIORIDADE 1: contexto de rota (evita menu errado quando role do tenant atrasa/falha)
|
||||
* Ex.: /therapist/* => força menu therapist; /admin* => força menu admin
|
||||
* - PRIORIDADE 2: se há tenant ativo: usa role do tenant
|
||||
* - PRIORIDADE 3: fallback pro sessionRole (ex.: telas fora de tenant)
|
||||
*
|
||||
* Motivo: o bug que você descreveu (terapeuta vendo admin.menu) geralmente é:
|
||||
* - tenant role ainda não carregou OU tenantId está null
|
||||
* - sessionRole vem como 'admin'
|
||||
* Então, rota > tenant > session elimina o menu “trocar sozinho”.
|
||||
*/
|
||||
const navRole = computed(() => {
|
||||
const p = String(route.path || '')
|
||||
// raw (pode piscar vazio)
|
||||
const rawModel = computed(() => menuStore.model || [])
|
||||
|
||||
// ✅ blindagem por contexto
|
||||
if (p.startsWith('/therapist')) return 'therapist'
|
||||
if (p.startsWith('/admin') || p.startsWith('/clinic')) return 'clinic_admin'
|
||||
if (p.startsWith('/patient')) return 'patient'
|
||||
// último menu válido
|
||||
const lastGoodModel = ref([])
|
||||
|
||||
// ✅ dentro de tenant: confia no role do tenant
|
||||
if (tenantId.value) return tenantStore.activeRole || null
|
||||
// debounce curto para aceitar "vazio real" (ex.: logout) sem travar UI
|
||||
let acceptEmptyT = null
|
||||
|
||||
// ✅ fora de tenant: fallback pro sessionRole
|
||||
return sessionRole.value || null
|
||||
})
|
||||
|
||||
const model = computed(() => {
|
||||
// ✅ role efetivo do menu já vem “canônico” do navRole
|
||||
const effectiveRole = navRole.value
|
||||
|
||||
const base = getMenuByRole(effectiveRole, { isSaasAdmin: sessionIsSaasAdmin.value }) || []
|
||||
|
||||
const normalize = (s) => String(s || '').toLowerCase()
|
||||
const priorityOrder = (group) => {
|
||||
const label = normalize(group?.label)
|
||||
if (label.includes('saas')) return 0
|
||||
if (label.includes('pacientes')) return 1
|
||||
return 99
|
||||
function setLastGoodIfValid (m) {
|
||||
if (Array.isArray(m) && m.length) {
|
||||
lastGoodModel.value = m
|
||||
}
|
||||
}
|
||||
|
||||
return [...base].sort((a, b) => priorityOrder(a) - priorityOrder(b))
|
||||
})
|
||||
|
||||
// quando troca tenant -> recarrega entitlements
|
||||
watch(
|
||||
tenantId,
|
||||
async (id) => {
|
||||
entitlementsStore.invalidate()
|
||||
if (id) await entitlementsStore.loadForTenant(id, { force: true })
|
||||
rawModel,
|
||||
(m) => {
|
||||
// se veio com itens, atualiza na hora
|
||||
if (Array.isArray(m) && m.length) {
|
||||
if (acceptEmptyT) clearTimeout(acceptEmptyT)
|
||||
setLastGoodIfValid(m)
|
||||
return
|
||||
}
|
||||
|
||||
// se veio vazio, NÃO derruba o menu imediatamente.
|
||||
// Só aceita vazio se continuar vazio por um tempinho.
|
||||
if (acceptEmptyT) clearTimeout(acceptEmptyT)
|
||||
acceptEmptyT = setTimeout(() => {
|
||||
// se ainda estiver vazio, e você quiser realmente limpar, faça aqui.
|
||||
// Por padrão, manteremos o último menu válido para evitar UX quebrada.
|
||||
// Se quiser limpar em logout, o logout deve limpar lastGoodModel explicitamente.
|
||||
}, 250)
|
||||
},
|
||||
{ immediate: true }
|
||||
{ immediate: true, deep: false }
|
||||
)
|
||||
|
||||
// ✅ quando troca role efetivo do menu (via rota/tenant/session) -> recarrega entitlements do tenant atual
|
||||
watch(
|
||||
() => navRole.value,
|
||||
async () => {
|
||||
if (!tenantId.value) return
|
||||
entitlementsStore.invalidate()
|
||||
await entitlementsStore.loadForTenant(tenantId.value, { force: true })
|
||||
}
|
||||
)
|
||||
// model final exibido (com fallback)
|
||||
const model = computed(() => {
|
||||
const m = rawModel.value
|
||||
if (Array.isArray(m) && m.length) return m
|
||||
if (Array.isArray(lastGoodModel.value) && lastGoodModel.value.length) return lastGoodModel.value
|
||||
return []
|
||||
})
|
||||
|
||||
// ✅ rota -> activePath (NÃO fecha menu em nenhum cenário)
|
||||
// ✅ rota -> activePath (NÃO fecha menu)
|
||||
watch(
|
||||
() => route.path,
|
||||
(p) => { layoutState.activePath = p },
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// ==============================
|
||||
// 🔎 Busca no menu (flatten + resultados)
|
||||
// ==============================
|
||||
// ======================================================
|
||||
// 🔎 Busca no menu (mantive igual)
|
||||
// ======================================================
|
||||
const query = ref('')
|
||||
const showResults = ref(false)
|
||||
const activeIndex = ref(-1)
|
||||
|
||||
// ✅ garante Ctrl/Cmd+K mesmo sem recentes
|
||||
const forcedOpen = ref(false)
|
||||
|
||||
// ref do InputText (pra Ctrl/Cmd + K)
|
||||
const searchEl = ref(null)
|
||||
|
||||
// wrapper pra click-outside
|
||||
const searchWrapEl = ref(null)
|
||||
|
||||
// Recentes
|
||||
const RECENT_KEY = 'menu_search_recent'
|
||||
const recent = ref([])
|
||||
|
||||
@@ -136,15 +106,11 @@ loadRecent()
|
||||
|
||||
watch(query, (v) => {
|
||||
const hasText = !!v?.trim()
|
||||
|
||||
// digitou: abre e sai do modo "forced"
|
||||
if (hasText) {
|
||||
forcedOpen.value = false
|
||||
showResults.value = true
|
||||
return
|
||||
}
|
||||
|
||||
// vazio: mantém aberto apenas se foi "forçado" (Ctrl/Cmd+K ou foco)
|
||||
showResults.value = forcedOpen.value
|
||||
})
|
||||
|
||||
@@ -163,10 +129,17 @@ function norm (s) {
|
||||
.trim()
|
||||
}
|
||||
|
||||
function isVisibleItem (it) {
|
||||
const v = it?.visible
|
||||
if (typeof v === 'function') return !!v()
|
||||
if (v === undefined || v === null) return true
|
||||
return v !== false
|
||||
}
|
||||
|
||||
function flattenMenu (items, trail = []) {
|
||||
const out = []
|
||||
for (const it of (items || [])) {
|
||||
if (it?.visible === false) continue
|
||||
if (!isVisibleItem(it)) continue
|
||||
|
||||
const nextTrail = [...trail, it?.label].filter(Boolean)
|
||||
|
||||
@@ -176,7 +149,10 @@ function flattenMenu (items, trail = []) {
|
||||
to: it.to,
|
||||
icon: it.icon,
|
||||
trail: nextTrail,
|
||||
proBadge: !!it.proBadge,
|
||||
|
||||
// ✅ usa cálculo dinâmico vindo do navigation (quando existir)
|
||||
proBadge: !!(it.__showProBadge ?? it.proBadge),
|
||||
|
||||
feature: it.feature || null
|
||||
})
|
||||
}
|
||||
@@ -210,7 +186,6 @@ watch(results, (list) => {
|
||||
activeIndex.value = list.length ? 0 : -1
|
||||
})
|
||||
|
||||
// ===== highlight =====
|
||||
function escapeHtml (s) {
|
||||
return String(s || '')
|
||||
.replace(/&/g, '&')
|
||||
@@ -235,7 +210,6 @@ function highlight (text, q) {
|
||||
return `${before}<mark class="px-1 rounded bg-yellow-200/40">${mid}</mark>${after}`
|
||||
}
|
||||
|
||||
// ===== teclado =====
|
||||
function onSearchKeydown (e) {
|
||||
if (e.key === 'Escape') {
|
||||
showResults.value = false
|
||||
@@ -273,7 +247,6 @@ function isTypingTarget (el) {
|
||||
return tag === 'input' || tag === 'textarea' || el.isContentEditable
|
||||
}
|
||||
|
||||
// ===== Ctrl/Cmd + K =====
|
||||
function focusSearch () {
|
||||
forcedOpen.value = true
|
||||
showResults.value = true
|
||||
@@ -304,25 +277,18 @@ function onGlobalKeydown (e) {
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ Recentes: aplicar query + abrir + focar (sem depender de watch timing)
|
||||
function applyRecent (q) {
|
||||
query.value = q
|
||||
forcedOpen.value = true
|
||||
showResults.value = true
|
||||
activeIndex.value = 0
|
||||
|
||||
nextTick(() => {
|
||||
// garante foco e teclado funcionando
|
||||
focusSearch()
|
||||
})
|
||||
nextTick(() => focusSearch())
|
||||
}
|
||||
|
||||
// click outside para fechar painel
|
||||
function onDocMouseDown (e) {
|
||||
if (!showResults.value) return
|
||||
const root = searchWrapEl.value
|
||||
if (!root) return
|
||||
|
||||
if (!root.contains(e.target)) {
|
||||
showResults.value = false
|
||||
forcedOpen.value = false
|
||||
@@ -334,6 +300,7 @@ onMounted(() => {
|
||||
document.addEventListener('mousedown', onDocMouseDown)
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
if (acceptEmptyT) clearTimeout(acceptEmptyT)
|
||||
window.removeEventListener('keydown', onGlobalKeydown, true)
|
||||
document.removeEventListener('mousedown', onDocMouseDown)
|
||||
})
|
||||
@@ -356,7 +323,6 @@ const quickDialog = ref(false)
|
||||
function onQuickCreate () { quickDialog.value = true }
|
||||
function onQuickCreated () { quickDialog.value = false }
|
||||
|
||||
// controle de “recentes”: mostrar ao focar (mesmo sem recentes, para exibir dicas)
|
||||
function onSearchFocus () {
|
||||
if (!query.value?.trim()) {
|
||||
forcedOpen.value = true
|
||||
@@ -370,12 +336,29 @@ function onSearchFocus () {
|
||||
<!-- 🔎 TOPO FIXO -->
|
||||
<div ref="searchWrapEl" class="pb-2 border-b border-[var(--surface-border)] bg-[var(--surface-card)]">
|
||||
<div class="relative">
|
||||
<div
|
||||
aria-hidden="true"
|
||||
style="position:absolute; left:-9999px; top:-9999px; width:1px; height:1px; overflow:hidden;"
|
||||
>
|
||||
<input type="email" name="email" autocomplete="username" tabindex="-1" disabled />
|
||||
<input type="password" name="password" autocomplete="current-password" tabindex="-1" disabled />
|
||||
</div>
|
||||
|
||||
<FloatLabel variant="on" class="w-full">
|
||||
<IconField class="w-full">
|
||||
<InputIcon class="pi pi-search" />
|
||||
<InputText
|
||||
ref="searchEl"
|
||||
id="menu_search"
|
||||
name="menu_search"
|
||||
type="search"
|
||||
inputmode="search"
|
||||
autocomplete="off"
|
||||
autocapitalize="off"
|
||||
autocorrect="off"
|
||||
spellcheck="false"
|
||||
data-lpignore="true"
|
||||
data-1p-ignore="true"
|
||||
v-model="query"
|
||||
class="w-full pr-10"
|
||||
variant="filled"
|
||||
@@ -383,10 +366,9 @@ function onSearchFocus () {
|
||||
@keydown="onSearchKeydown"
|
||||
/>
|
||||
</IconField>
|
||||
<label for="menu_search">Buscar no menu</label>
|
||||
<label for="menu_search">Encontrar menu...</label>
|
||||
</FloatLabel>
|
||||
|
||||
<!-- ✅ botão limpar busca -->
|
||||
<button
|
||||
v-if="query.trim()"
|
||||
type="button"
|
||||
@@ -398,7 +380,6 @@ function onSearchFocus () {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Recentes (quando query vazio) -->
|
||||
<div
|
||||
v-if="showResults && !query.trim() && recent.length"
|
||||
class="mt-2 border border-[var(--surface-border)] rounded-xl bg-[var(--surface-card)] shadow-sm overflow-hidden"
|
||||
@@ -427,14 +408,13 @@ function onSearchFocus () {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Resultados -->
|
||||
<div
|
||||
v-else-if="showResults && results.length"
|
||||
class="mt-2 border border-[var(--surface-border)] rounded-xl bg-[var(--surface-card)] shadow-sm overflow-hidden"
|
||||
>
|
||||
<button
|
||||
v-for="(r, i) in results"
|
||||
:key="r.to"
|
||||
:key="String(r.to)"
|
||||
type="button"
|
||||
@mousedown.prevent="goTo(r)"
|
||||
:class="[
|
||||
@@ -449,7 +429,7 @@ function onSearchFocus () {
|
||||
</div>
|
||||
|
||||
<span
|
||||
v-if="r.proBadge || r.feature"
|
||||
v-if="r.proBadge"
|
||||
class="text-xs px-2 py-0.5 rounded border border-[var(--surface-border)] opacity-80"
|
||||
>
|
||||
PRO
|
||||
@@ -457,48 +437,19 @@ function onSearchFocus () {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="showResults && query && !results.length"
|
||||
class="mt-2 px-3 py-2 text-sm opacity-70"
|
||||
>
|
||||
<div v-else-if="showResults && query && !results.length" class="mt-2 px-3 py-2 text-sm opacity-70">
|
||||
Nenhum item encontrado.
|
||||
</div>
|
||||
|
||||
<!-- ✅ instruções embaixo quando houver recentes/resultados/uso -->
|
||||
<div
|
||||
v-if="showResults && (recent.length || results.length || query.trim())"
|
||||
class="mt-2 px-3 text-xs opacity-70 flex flex-wrap gap-x-3 gap-y-1"
|
||||
>
|
||||
<span><b>Ctrl+K</b>/<b>Cmd+K</b> focar</span>
|
||||
<span><b>↑↓</b> navegar</span>
|
||||
<span><b>Enter</b> abrir</span>
|
||||
<span><b>Esc</b> fechar</span>
|
||||
</div>
|
||||
|
||||
<!-- fallback quando não tem nada -->
|
||||
<div
|
||||
v-else-if="showResults && !query.trim() && !recent.length"
|
||||
class="mt-2 px-3 py-2 text-xs opacity-60"
|
||||
>
|
||||
Dica: pressione <b>Ctrl + K</b> (ou <b>Cmd + K</b>) para buscar.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ✅ SOMENTE O MENU ROLA -->
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
<ul class="layout-menu pb-20">
|
||||
<template v-for="(item, i) in model" :key="i">
|
||||
<AppMenuItem
|
||||
:item="item"
|
||||
:index="i"
|
||||
:root="true"
|
||||
@quick-create="onQuickCreate"
|
||||
/>
|
||||
<AppMenuItem :item="item" :index="i" :root="true" @quick-create="onQuickCreate" />
|
||||
</template>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- rodapé fixo -->
|
||||
<AppMenuFooterPanel />
|
||||
|
||||
<ComponentCadastroRapido
|
||||
|
||||
Reference in New Issue
Block a user