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

View File

@@ -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