Files
agenciapsilmno/src/layout/AppMenu.vue

466 lines
13 KiB
Vue

<script setup>
import { computed, ref, watch, onMounted, onBeforeUnmount, nextTick } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useLayout } from '@/layout/composables/layout'
import AppMenuItem from './AppMenuItem.vue'
import AppMenuFooterPanel from './AppMenuFooterPanel.vue'
import ComponentCadastroRapido from '@/components/ComponentCadastroRapido.vue'
import { useMenuStore } from '@/stores/menuStore'
const route = useRoute()
const router = useRouter()
const { layoutState } = useLayout()
const menuStore = useMenuStore()
// ======================================================
// ✅ 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)
// ======================================================
// raw (pode piscar vazio)
const rawModel = computed(() => menuStore.model || [])
// último menu válido
const lastGoodModel = ref([])
// debounce curto para aceitar "vazio real" (ex.: logout) sem travar UI
let acceptEmptyT = null
function setLastGoodIfValid (m) {
if (Array.isArray(m) && m.length) {
lastGoodModel.value = m
}
}
watch(
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, deep: false }
)
// 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)
watch(
() => route.path,
(p) => { layoutState.activePath = p },
{ immediate: true }
)
// ======================================================
// 🔎 Busca no menu (mantive igual)
// ======================================================
const query = ref('')
const showResults = ref(false)
const activeIndex = ref(-1)
const forcedOpen = ref(false)
const searchEl = ref(null)
const searchWrapEl = ref(null)
const RECENT_KEY = 'menu_search_recent'
const recent = ref([])
function loadRecent () {
try { recent.value = JSON.parse(localStorage.getItem(RECENT_KEY) || '[]') } catch { recent.value = [] }
}
function saveRecent (q) {
const v = String(q || '').trim()
if (!v) return
const list = [v, ...recent.value.filter(x => x !== v)].slice(0, 8)
recent.value = list
localStorage.setItem(RECENT_KEY, JSON.stringify(list))
}
function clearRecent () {
recent.value = []
try { localStorage.removeItem(RECENT_KEY) } catch {}
}
loadRecent()
watch(query, (v) => {
const hasText = !!v?.trim()
if (hasText) {
forcedOpen.value = false
showResults.value = true
return
}
showResults.value = forcedOpen.value
})
function clearSearch () {
query.value = ''
activeIndex.value = -1
showResults.value = false
forcedOpen.value = false
}
function norm (s) {
return String(s || '')
.toLowerCase()
.normalize('NFD')
.replace(/\p{Diacritic}/gu, '')
.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 (!isVisibleItem(it)) continue
const nextTrail = [...trail, it?.label].filter(Boolean)
if (it?.to && !it?.items?.length) {
out.push({
label: it.label || it.to,
to: it.to,
icon: it.icon,
trail: nextTrail,
// ✅ usa cálculo dinâmico vindo do navigation (quando existir)
proBadge: !!(it.__showProBadge ?? it.proBadge),
feature: it.feature || null
})
}
if (it?.items?.length) {
out.push(...flattenMenu(it.items, nextTrail))
}
}
return out
}
const allLinks = computed(() => flattenMenu(model.value))
const results = computed(() => {
const q = norm(query.value)
if (!q) return []
const wantPro = q === 'pro' || q.startsWith('pro ') || q.includes(' pro')
return allLinks.value
.filter(r => {
const hay = `${norm(r.label)} ${norm(r.trail.join(' > '))} ${norm(r.to)}`
if (hay.includes(q)) return true
if (wantPro && (r.proBadge || r.feature)) return true
return false
})
.slice(0, 12)
})
watch(results, (list) => {
activeIndex.value = list.length ? 0 : -1
})
function escapeHtml (s) {
return String(s || '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;')
}
function highlight (text, q) {
const queryNorm = norm(q)
const raw = String(text || '')
if (!queryNorm) return escapeHtml(raw)
const rawNorm = norm(raw)
const idx = rawNorm.indexOf(queryNorm)
if (idx < 0) return escapeHtml(raw)
const before = escapeHtml(raw.slice(0, idx))
const mid = escapeHtml(raw.slice(idx, idx + queryNorm.length))
const after = escapeHtml(raw.slice(idx + queryNorm.length))
return `${before}<mark class="px-1 rounded bg-yellow-200/40">${mid}</mark>${after}`
}
function onSearchKeydown (e) {
if (e.key === 'Escape') {
showResults.value = false
forcedOpen.value = false
return
}
if (e.key === 'ArrowDown') {
e.preventDefault()
if (!results.value.length) return
showResults.value = true
activeIndex.value = (activeIndex.value + 1) % results.value.length
return
}
if (e.key === 'ArrowUp') {
e.preventDefault()
if (!results.value.length) return
showResults.value = true
activeIndex.value = (activeIndex.value - 1 + results.value.length) % results.value.length
return
}
if (e.key === 'Enter') {
if (showResults.value && results.value.length && activeIndex.value >= 0) {
e.preventDefault()
goTo(results.value[activeIndex.value])
}
}
}
function isTypingTarget (el) {
if (!el) return false
const tag = (el.tagName || '').toLowerCase()
return tag === 'input' || tag === 'textarea' || el.isContentEditable
}
function focusSearch () {
forcedOpen.value = true
showResults.value = true
requestAnimationFrame(() => {
requestAnimationFrame(() => {
const inst = searchEl.value
const input =
inst?.$el?.tagName === 'INPUT'
? inst.$el
: inst?.$el?.querySelector?.('input')
input?.focus?.()
})
})
}
function onGlobalKeydown (e) {
if (isTypingTarget(document.activeElement)) return
const isK = e.key?.toLowerCase() === 'k'
const withCmdOrCtrl = e.ctrlKey || e.metaKey
if (withCmdOrCtrl && isK) {
e.preventDefault()
e.stopPropagation()
focusSearch()
}
}
function applyRecent (q) {
query.value = q
forcedOpen.value = true
showResults.value = true
activeIndex.value = 0
nextTick(() => focusSearch())
}
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
}
}
onMounted(() => {
window.addEventListener('keydown', onGlobalKeydown, true)
document.addEventListener('mousedown', onDocMouseDown)
})
onBeforeUnmount(() => {
if (acceptEmptyT) clearTimeout(acceptEmptyT)
window.removeEventListener('keydown', onGlobalKeydown, true)
document.removeEventListener('mousedown', onDocMouseDown)
})
async function goTo (r) {
saveRecent(query.value)
query.value = ''
showResults.value = false
activeIndex.value = -1
forcedOpen.value = false
await router.push(r.to)
}
// ==============================
// Quick create
// ==============================
const quickDialog = ref(false)
function onQuickCreate () { quickDialog.value = true }
function onQuickCreated () { quickDialog.value = false }
function onSearchFocus () {
if (!query.value?.trim()) {
forcedOpen.value = true
showResults.value = true
}
}
</script>
<template>
<div class="flex flex-col h-full">
<!-- 🔎 TOPO FIXO -->
<div ref="searchWrapEl" class="px-3 pt-3 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"
@focus="onSearchFocus"
@keydown="onSearchKeydown"
/>
</IconField>
<label for="menu_search">Encontrar menu...</label>
</FloatLabel>
<button
v-if="query.trim()"
type="button"
class="absolute right-2 top-1/2 -translate-y-1/2 opacity-70 hover:opacity-100"
@mousedown.prevent="clearSearch"
aria-label="Limpar busca"
>
<i class="pi pi-times" />
</button>
</div>
<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"
>
<div class="px-3 py-2 text-xs opacity-70 flex items-center justify-content-between">
<span>Recentes</span>
<button
type="button"
class="opacity-70 hover:opacity-100"
@mousedown.prevent="clearRecent"
aria-label="Limpar recentes"
>
<i class="pi pi-trash" />
</button>
</div>
<button
v-for="q in recent"
:key="q"
class="w-full text-left px-3 py-2 hover:bg-[var(--surface-hover)] flex items-center gap-2"
type="button"
@click.stop.prevent="applyRecent(q)"
>
<i class="pi pi-history opacity-70" />
<div class="flex-1">{{ q }}</div>
</button>
</div>
<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="String(r.to)"
type="button"
@mousedown.prevent="goTo(r)"
:class="[
'w-full text-left px-3 py-2 flex items-center gap-2',
i === activeIndex ? 'bg-[var(--surface-hover)]' : 'hover:bg-[var(--surface-hover)]'
]"
>
<i v-if="r.icon" :class="r.icon" class="opacity-80" />
<div class="flex flex-col flex-1">
<div class="font-medium leading-tight" v-html="highlight(r.label, query)" />
<small class="opacity-70">{{ r.trail.join(' > ') }}</small>
</div>
<span
v-if="r.proBadge"
class="text-xs px-2 py-0.5 rounded border border-[var(--surface-border)] opacity-80"
>
PRO
</span>
</button>
</div>
<div v-else-if="showResults && query && !results.length" class="mt-2 px-3 py-2 text-sm opacity-70">
Nenhum item encontrado.
</div>
</div>
<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" />
</template>
</ul>
</div>
<AppMenuFooterPanel />
<ComponentCadastroRapido
v-model="quickDialog"
title="Cadastro Rápido"
table-name="patients"
name-field="nome_completo"
email-field="email_principal"
phone-field="telefone"
:extra-payload="{ status: 'Ativo' }"
@created="onQuickCreated"
/>
</div>
</template>