466 lines
13 KiB
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, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''')
|
|
}
|
|
|
|
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> |