30367392ff
Refatora MelissaBusca pra usar PrimeVue Dialog em vez de popover absolute. Resolve definitivamente o bug do panel estourar viewport quando ha muitos resultados. Mudancas: 1. Trigger no dock: input -> <button> com aparencia de input. Clica ou Ctrl+K abre Dialog. Mantem placeholder + Ctrl+K kbd hint. 2. Dialog Spotlight: 640px max-width, posicionado 10vh do topo (estilo Spotlight macOS / Linear / GitHub). Backdrop blur escuro, dismissable mask, sem header, sem closable button (Esc cobre). 3. Input REAL dentro do Dialog: autofocus on open via nextTick. Mantem v-model="query" + @keydown="onKeydown" (Arrow/Enter). 4. Panel de resultados: era position:absolute com max-height:60vh (estourava em layouts com input perto do bottom). Agora vive DENTRO do Dialog (flex:1, max-height:70vh no content), scroll interno garantido por design — conteudo NUNCA passa do bottom da pagina. 5. Remove: onClickOutside (dismissableMask cobre), Transition mb-fade (Dialog tem sua animacao). Comportamento end-user identico (Ctrl+K, navegacao com setas, Enter seleciona, Esc fecha) mas visual + manutencao muito melhor. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
693 lines
26 KiB
Vue
693 lines
26 KiB
Vue
<script setup>
|
||
/*
|
||
* MelissaBusca
|
||
* --------------------------------------------------
|
||
* Busca rápida glass-style. Adaptação do GlobalSearch da topbar
|
||
* (`src/components/search/GlobalSearch.vue`) pro layout Melissa.
|
||
*
|
||
* Diferenças vs. GlobalSearch:
|
||
* - Não chama Supabase — recebe pacientes/eventos via prop (preview)
|
||
* - Visual glass (white-on-glass) ao invés de surface-card
|
||
* - Sem loading state (busca client-side, instantâneo)
|
||
* - Emite ações pro parent decidir o que fazer
|
||
*
|
||
* Quando promover pra produção: trocar a busca por chamada à RPC
|
||
* `search_global` + manter a mesma estrutura de panel/items.
|
||
*/
|
||
import { ref, computed, onMounted, onBeforeUnmount, watch, nextTick } from 'vue';
|
||
import { supabase } from '@/lib/supabase/client';
|
||
import { useRecentPatients } from '@/composables/useRecentPatients';
|
||
|
||
const props = defineProps({
|
||
pacientes: { type: Array, default: () => [] },
|
||
eventos: { type: Array, default: () => [] },
|
||
atalhos: {
|
||
type: Array,
|
||
default: () => [
|
||
{ id: 'agenda', label: 'Agenda', icon: 'pi pi-calendar', sublabel: 'Sessões e compromissos', keywords: ['agenda', 'calendario', 'sessoes', 'hoje'] },
|
||
{ id: 'pacientes', label: 'Pacientes', icon: 'pi pi-users', sublabel: 'Cadastro e prontuários', keywords: ['pacientes', 'lista', 'cadastro'] },
|
||
{ id: 'conversas', label: 'WhatsApp', icon: 'pi pi-whatsapp', sublabel: 'Conversas em andamento', keywords: ['whatsapp', 'conversas', 'mensagens', 'chat'] },
|
||
{ id: 'financeiro', label: 'Financeiro', icon: 'pi pi-wallet', sublabel: 'Recebíveis e lançamentos', keywords: ['financeiro', 'pagamentos', 'cobrancas', 'dinheiro'] },
|
||
{ id: 'configuracoes', label: 'Configurações', icon: 'pi pi-cog', sublabel: 'Preferências e equipe', keywords: ['configuracoes', 'ajustes', 'preferencias', 'settings'] }
|
||
]
|
||
}
|
||
});
|
||
|
||
const emit = defineEmits(['acao', 'paciente', 'evento', 'documento', 'intake']);
|
||
|
||
const rootEl = ref(null);
|
||
const inputEl = ref(null);
|
||
const query = ref('');
|
||
const showPanel = ref(false);
|
||
const activeIndex = ref(-1);
|
||
|
||
// RPC search_global results — preenchidos por debounce conforme o usuário digita.
|
||
// Cliente-side (props.pacientes / props.eventos) continua sendo fallback rápido
|
||
// pra digitação curta (1 char) e como complemento aos primeiros resultados.
|
||
const rpcResults = ref({ patients: [], appointments: [], documents: [], services: [], intakes: [] });
|
||
const searching = ref(false);
|
||
let debounceT = null;
|
||
let searchSeq = 0;
|
||
|
||
// Recently viewed (localStorage) — só aparece quando o input está vazio
|
||
const { items: recentPatients, hasItems: hasRecentPatients } = useRecentPatients();
|
||
const showRecent = computed(() => !query.value.trim() && hasRecentPatients.value);
|
||
const recentItems = computed(() => showRecent.value ? (recentPatients.value || []).slice(0, 5) : []);
|
||
|
||
function normalize(s) {
|
||
return String(s || '')
|
||
.normalize('NFD')
|
||
.replace(/[̀-ͯ]/g, '')
|
||
.toLowerCase()
|
||
.trim();
|
||
}
|
||
|
||
function fmtHora(h) {
|
||
const horas = Math.floor(h);
|
||
const mins = Math.round((h - horas) * 60);
|
||
return `${String(horas).padStart(2, '0')}:${String(mins).padStart(2, '0')}`;
|
||
}
|
||
|
||
const filteredAtalhos = computed(() => {
|
||
const q = normalize(query.value);
|
||
if (!q) return props.atalhos.slice(0, 4); // 4 defaults quando vazio
|
||
return props.atalhos.filter((a) => {
|
||
const hay = normalize(a.label + ' ' + (a.keywords || []).join(' '));
|
||
return hay.includes(q);
|
||
}).slice(0, 5);
|
||
});
|
||
|
||
// Pacientes — combina RPC (autoritativo, todos os pacientes) com props (preview de hoje).
|
||
// RPC tem prioridade; props complementa quando RPC ainda não trouxe nada.
|
||
//
|
||
// Shape do RPC search_global (patients): { id, label, sublabel, avatar_url, deeplink, score }
|
||
// label = nome_completo; sublabel = email_principal ou telefone.
|
||
const filteredPacientes = computed(() => {
|
||
const q = normalize(query.value);
|
||
if (q.length < 2) return [];
|
||
|
||
const rpc = rpcResults.value.patients || [];
|
||
if (rpc.length) {
|
||
return rpc.slice(0, 5).map(p => ({
|
||
id: p.id,
|
||
nome: p.label || '(sem nome)',
|
||
sub: p.sublabel || '',
|
||
avatar_url: p.avatar_url || null
|
||
}));
|
||
}
|
||
// Fallback client-side (props.pacientes vem do MelissaLayout — shape diferente)
|
||
return props.pacientes
|
||
.filter((p) => normalize(p.nome).includes(q))
|
||
.slice(0, 5)
|
||
.map(p => ({ id: p.id, nome: p.nome, sub: '', avatar_url: null }));
|
||
});
|
||
|
||
const filteredEventos = computed(() => {
|
||
const q = normalize(query.value);
|
||
if (q.length < 2) return [];
|
||
return props.eventos
|
||
.filter((e) => {
|
||
const hay = normalize(
|
||
(e.label || '') + ' ' + (e.pacienteNome || '') + ' ' + (e.descricao || '')
|
||
);
|
||
return hay.includes(q);
|
||
})
|
||
.slice(0, 5);
|
||
});
|
||
|
||
// Resultados exclusivos da RPC (não há fallback client-side)
|
||
const rpcAppointments = computed(() => rpcResults.value.appointments || []);
|
||
const rpcDocuments = computed(() => rpcResults.value.documents || []);
|
||
const rpcIntakes = computed(() => rpcResults.value.intakes || []);
|
||
|
||
const flatList = computed(() => {
|
||
const out = [];
|
||
recentItems.value.forEach((p, i) => out.push({ group: 'recent', item: p, idx: i }));
|
||
filteredAtalhos.value.forEach((a, i) => out.push({ group: 'atalhos', item: a, idx: i }));
|
||
filteredPacientes.value.forEach((p, i) => out.push({ group: 'pacientes', item: p, idx: i }));
|
||
filteredEventos.value.forEach((e, i) => out.push({ group: 'eventos', item: e, idx: i }));
|
||
rpcAppointments.value.forEach((a, i) => out.push({ group: 'rpc-appointments', item: a, idx: i }));
|
||
rpcDocuments.value.forEach((d, i) => out.push({ group: 'rpc-documents', item: d, idx: i }));
|
||
rpcIntakes.value.forEach((r, i) => out.push({ group: 'rpc-intakes', item: r, idx: i }));
|
||
return out;
|
||
});
|
||
|
||
const hasAnyResult = computed(() => flatList.value.length > 0);
|
||
|
||
function findFlatIndex(group, idx) {
|
||
return flatList.value.findIndex((x) => x.group === group && x.idx === idx);
|
||
}
|
||
|
||
function selectEntry(entry) {
|
||
if (entry.group === 'atalhos') emit('acao', entry.item.id);
|
||
else if (entry.group === 'pacientes') emit('paciente', entry.item);
|
||
else if (entry.group === 'recent') emit('paciente', { id: entry.item.id, nome: entry.item.nome, ...entry.item.extras });
|
||
else if (entry.group === 'eventos') emit('evento', entry.item);
|
||
else if (entry.group === 'rpc-appointments') {
|
||
// Sessão da RPC: deeplink pra agenda com evento focado
|
||
emit('evento', { id: entry.item.id, deeplink: entry.item.deeplink });
|
||
} else if (entry.group === 'rpc-documents') {
|
||
// Documento da RPC: extrai patient_id da deeplink se possível
|
||
const dl = entry.item.deeplink || '';
|
||
const m = dl.match(/patients\/([0-9a-f-]+)/i);
|
||
emit('documento', { id: entry.item.id, patient_id: m?.[1] || null, label: entry.item.label });
|
||
} else if (entry.group === 'rpc-intakes') {
|
||
emit('intake', entry.item);
|
||
}
|
||
closePanel();
|
||
}
|
||
|
||
function closePanel() {
|
||
showPanel.value = false;
|
||
query.value = '';
|
||
activeIndex.value = -1;
|
||
}
|
||
|
||
function openDialog() {
|
||
showPanel.value = true;
|
||
// Foca input do Dialog após ele montar
|
||
nextTick(() => inputEl.value?.focus());
|
||
}
|
||
|
||
function onKeydown(e) {
|
||
if (e.key === 'ArrowDown') {
|
||
e.preventDefault();
|
||
activeIndex.value = Math.min(activeIndex.value + 1, flatList.value.length - 1);
|
||
} else if (e.key === 'ArrowUp') {
|
||
e.preventDefault();
|
||
activeIndex.value = Math.max(activeIndex.value - 1, 0);
|
||
} else if (e.key === 'Enter' && activeIndex.value >= 0) {
|
||
e.preventDefault();
|
||
selectEntry(flatList.value[activeIndex.value]);
|
||
}
|
||
// Escape é tratado pelo Dialog (dismissableMask + closable)
|
||
}
|
||
|
||
function onGlobalKeydown(e) {
|
||
// Ctrl+K / ⌘+K → abre dialog
|
||
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'k') {
|
||
e.preventDefault();
|
||
openDialog();
|
||
}
|
||
}
|
||
|
||
// ── RPC search_global debounced ───────────────────────────────────────────
|
||
// Mesmo padrão do GlobalSearch.vue: query >= 2 chars dispara em 200ms,
|
||
// controla ordem via searchSeq pra ignorar respostas obsoletas.
|
||
function resetRpcResults() {
|
||
rpcResults.value = { patients: [], appointments: [], documents: [], services: [], intakes: [] };
|
||
}
|
||
|
||
watch(query, (v) => {
|
||
if (debounceT) clearTimeout(debounceT);
|
||
const q = String(v || '').trim();
|
||
if (q.length < 2) {
|
||
resetRpcResults();
|
||
searching.value = false;
|
||
return;
|
||
}
|
||
searching.value = true;
|
||
const mySeq = ++searchSeq;
|
||
debounceT = setTimeout(async () => {
|
||
try {
|
||
const { data, error } = await supabase.rpc('search_global', { p_q: q, p_limit: 6 });
|
||
if (mySeq !== searchSeq) return;
|
||
if (error) {
|
||
console.error('[MelissaBusca search_global]', error);
|
||
resetRpcResults();
|
||
} else {
|
||
rpcResults.value = {
|
||
patients: Array.isArray(data?.patients) ? data.patients : [],
|
||
appointments: Array.isArray(data?.appointments) ? data.appointments : [],
|
||
documents: Array.isArray(data?.documents) ? data.documents : [],
|
||
services: Array.isArray(data?.services) ? data.services : [],
|
||
intakes: Array.isArray(data?.intakes) ? data.intakes : []
|
||
};
|
||
}
|
||
} catch (e) {
|
||
if (mySeq !== searchSeq) return;
|
||
console.error('[MelissaBusca search_global exception]', e);
|
||
resetRpcResults();
|
||
} finally {
|
||
if (mySeq === searchSeq) searching.value = false;
|
||
}
|
||
}, 200);
|
||
});
|
||
|
||
onMounted(() => {
|
||
window.addEventListener('keydown', onGlobalKeydown);
|
||
});
|
||
onBeforeUnmount(() => {
|
||
window.removeEventListener('keydown', onGlobalKeydown);
|
||
if (debounceT) clearTimeout(debounceT);
|
||
});
|
||
</script>
|
||
|
||
<template>
|
||
<div ref="rootEl" class="mb-search">
|
||
<!-- Trigger: aparência de input, mas é botão (abre Dialog) -->
|
||
<button type="button" class="mb-field" @click="openDialog" :aria-label="'Buscar (Ctrl+K)'">
|
||
<i class="pi pi-search mb-field__icon" />
|
||
<span class="mb-field__placeholder">Buscar paciente, agenda, atalho…</span>
|
||
<span class="mb-field__kbd" aria-hidden="true">Ctrl K</span>
|
||
</button>
|
||
|
||
<!-- Dialog Spotlight: input grande no topo + resultados em coluna -->
|
||
<Dialog
|
||
v-model:visible="showPanel"
|
||
modal
|
||
:draggable="false"
|
||
:closable="false"
|
||
:dismissableMask="true"
|
||
:showHeader="false"
|
||
class="mb-dialog"
|
||
:style="{ width: '640px', maxWidth: '94vw' }"
|
||
pt:mask:class="mb-dialog__mask"
|
||
pt:content:class="mb-dialog__content"
|
||
@hide="closePanel"
|
||
>
|
||
<!-- Field do Dialog (input real, autofocus) -->
|
||
<div class="mb-dialog__field">
|
||
<i class="pi pi-search mb-dialog__field-icon" />
|
||
<input
|
||
ref="inputEl"
|
||
v-model="query"
|
||
type="text"
|
||
placeholder="Buscar paciente, agenda, atalho…"
|
||
class="mb-dialog__input"
|
||
@keydown="onKeydown"
|
||
autocomplete="off"
|
||
spellcheck="false"
|
||
/>
|
||
<span class="mb-dialog__esc" aria-hidden="true">Esc</span>
|
||
</div>
|
||
|
||
<!-- Painel de resultados (scroll interno) -->
|
||
<div class="mb-panel" role="listbox">
|
||
<div
|
||
v-if="query.trim().length >= 2 && !hasAnyResult"
|
||
class="mb-empty"
|
||
>
|
||
Nada encontrado pra "<span class="text-white/80">{{ query.trim() }}</span>"
|
||
</div>
|
||
|
||
<!-- Acessados recentemente (só quando query vazia) -->
|
||
<div v-if="showRecent" class="mb-group">
|
||
<div class="mb-group__title">Acessados recentemente</div>
|
||
<button
|
||
v-for="(p, i) in recentItems"
|
||
:key="'mr-' + p.id"
|
||
class="mb-item"
|
||
:class="{ 'is-active': findFlatIndex('recent', i) === activeIndex }"
|
||
@click="selectEntry({ group: 'recent', item: p })"
|
||
@mouseenter="activeIndex = findFlatIndex('recent', i)"
|
||
>
|
||
<span class="mb-item__icon"><i class="pi pi-history" /></span>
|
||
<span class="mb-item__main">
|
||
<span class="mb-item__label">{{ p.nome }}</span>
|
||
<span class="mb-item__sub">{{ p.extras?.telefone || p.extras?.email || 'Abrir prontuário' }}</span>
|
||
</span>
|
||
<i class="mb-item__go pi pi-arrow-right" />
|
||
</button>
|
||
</div>
|
||
|
||
<!-- Atalhos -->
|
||
<div v-if="filteredAtalhos.length" class="mb-group">
|
||
<div class="mb-group__title">{{ query.trim() ? 'Ações' : 'Atalhos' }}</div>
|
||
<button
|
||
v-for="(a, i) in filteredAtalhos"
|
||
:key="'a-' + a.id"
|
||
class="mb-item"
|
||
:class="{ 'is-active': findFlatIndex('atalhos', i) === activeIndex }"
|
||
@click="selectEntry({ group: 'atalhos', item: a })"
|
||
@mouseenter="activeIndex = findFlatIndex('atalhos', i)"
|
||
>
|
||
<span class="mb-item__icon"><i :class="a.icon" /></span>
|
||
<span class="mb-item__main">
|
||
<span class="mb-item__label">{{ a.label }}</span>
|
||
<span v-if="a.sublabel" class="mb-item__sub">{{ a.sublabel }}</span>
|
||
</span>
|
||
<i class="mb-item__go pi pi-arrow-right" />
|
||
</button>
|
||
</div>
|
||
|
||
<!-- Pacientes -->
|
||
<div v-if="filteredPacientes.length" class="mb-group">
|
||
<div class="mb-group__title">Pacientes</div>
|
||
<button
|
||
v-for="(p, i) in filteredPacientes"
|
||
:key="'p-' + p.id"
|
||
class="mb-item"
|
||
:class="{ 'is-active': findFlatIndex('pacientes', i) === activeIndex }"
|
||
@click="selectEntry({ group: 'pacientes', item: p })"
|
||
@mouseenter="activeIndex = findFlatIndex('pacientes', i)"
|
||
>
|
||
<span class="mb-item__icon mb-item__icon--patient"><i class="pi pi-user" /></span>
|
||
<span class="mb-item__main">
|
||
<span class="mb-item__label">{{ p.nome }}</span>
|
||
<span class="mb-item__sub">{{ p.sub || 'Abrir prontuário' }}</span>
|
||
</span>
|
||
<i class="mb-item__go pi pi-arrow-right" />
|
||
</button>
|
||
</div>
|
||
|
||
<!-- Eventos -->
|
||
<div v-if="filteredEventos.length" class="mb-group">
|
||
<div class="mb-group__title">Agenda de hoje</div>
|
||
<button
|
||
v-for="(e, i) in filteredEventos"
|
||
:key="'e-' + e.id"
|
||
class="mb-item"
|
||
:class="{ 'is-active': findFlatIndex('eventos', i) === activeIndex }"
|
||
@click="selectEntry({ group: 'eventos', item: e })"
|
||
@mouseenter="activeIndex = findFlatIndex('eventos', i)"
|
||
>
|
||
<span
|
||
class="mb-item__icon"
|
||
:style="{ backgroundColor: `${e.color}33`, color: e.color }"
|
||
>
|
||
<i class="pi pi-clock" />
|
||
</span>
|
||
<span class="mb-item__main">
|
||
<span class="mb-item__label">{{ e.label }}</span>
|
||
<span class="mb-item__sub">{{ fmtHora(e.startH) }} – {{ fmtHora(e.endH) }}</span>
|
||
</span>
|
||
<i class="mb-item__go pi pi-arrow-right" />
|
||
</button>
|
||
</div>
|
||
|
||
<!-- RPC: Sessões/agendamentos (qualquer data)
|
||
RPC retorna { id, label, sublabel, deeplink }. Sublabel ja vem
|
||
com "Paciente · dd/mm/yyyy HH:MM". Cor do icone = cor de sessao
|
||
(indigo-500, igual ao pickColor() padrao). -->
|
||
<div v-if="rpcAppointments.length" class="mb-group">
|
||
<div class="mb-group__title">Sessões</div>
|
||
<button
|
||
v-for="(e, i) in rpcAppointments"
|
||
:key="'rpc-a-' + e.id"
|
||
class="mb-item"
|
||
:class="{ 'is-active': findFlatIndex('rpc-appointments', i) === activeIndex }"
|
||
@click="selectEntry({ group: 'rpc-appointments', item: e })"
|
||
@mouseenter="activeIndex = findFlatIndex('rpc-appointments', i)"
|
||
>
|
||
<span class="mb-item__icon mb-item__icon--sessao"><i class="pi pi-calendar" /></span>
|
||
<span class="mb-item__main">
|
||
<span class="mb-item__label">{{ e.label || 'Sessão' }}</span>
|
||
<span class="mb-item__sub">{{ e.sublabel || 'Sem detalhes' }}</span>
|
||
</span>
|
||
<i class="mb-item__go pi pi-arrow-right" />
|
||
</button>
|
||
</div>
|
||
|
||
<!-- RPC: Documentos -->
|
||
<div v-if="rpcDocuments.length" class="mb-group">
|
||
<div class="mb-group__title">Documentos</div>
|
||
<button
|
||
v-for="(d, i) in rpcDocuments"
|
||
:key="'rpc-d-' + d.id"
|
||
class="mb-item"
|
||
:class="{ 'is-active': findFlatIndex('rpc-documents', i) === activeIndex }"
|
||
@click="selectEntry({ group: 'rpc-documents', item: d })"
|
||
@mouseenter="activeIndex = findFlatIndex('rpc-documents', i)"
|
||
>
|
||
<span class="mb-item__icon mb-item__icon--doc"><i class="pi pi-file" /></span>
|
||
<span class="mb-item__main">
|
||
<span class="mb-item__label">{{ d.label || 'Documento' }}</span>
|
||
<span class="mb-item__sub">{{ d.sublabel || '' }}</span>
|
||
</span>
|
||
<i class="mb-item__go pi pi-arrow-right" />
|
||
</button>
|
||
</div>
|
||
|
||
<!-- RPC: Cadastros recebidos (intakes) -->
|
||
<div v-if="rpcIntakes.length" class="mb-group">
|
||
<div class="mb-group__title">Cadastros recebidos</div>
|
||
<button
|
||
v-for="(r, i) in rpcIntakes"
|
||
:key="'rpc-i-' + r.id"
|
||
class="mb-item"
|
||
:class="{ 'is-active': findFlatIndex('rpc-intakes', i) === activeIndex }"
|
||
@click="selectEntry({ group: 'rpc-intakes', item: r })"
|
||
@mouseenter="activeIndex = findFlatIndex('rpc-intakes', i)"
|
||
>
|
||
<span class="mb-item__icon mb-item__icon--intake"><i class="pi pi-inbox" /></span>
|
||
<span class="mb-item__main">
|
||
<span class="mb-item__label">{{ r.label || 'Cadastro' }}</span>
|
||
<span class="mb-item__sub">{{ r.sublabel || '' }}</span>
|
||
</span>
|
||
<i class="mb-item__go pi pi-arrow-right" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</Dialog>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.mb-search {
|
||
position: relative;
|
||
width: 100%;
|
||
max-width: 560px;
|
||
margin-left: auto;
|
||
margin-right: auto;
|
||
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
|
||
}
|
||
|
||
/* ─── Trigger no dock (visual de input, mas é botão que abre Dialog) ─── */
|
||
.mb-field {
|
||
position: relative;
|
||
display: flex;
|
||
align-items: center;
|
||
width: 100%;
|
||
background: var(--m-bg-soft);
|
||
backdrop-filter: blur(20px) saturate(140%);
|
||
-webkit-backdrop-filter: blur(20px) saturate(140%);
|
||
border: 1px solid var(--m-border-strong);
|
||
border-radius: 12px;
|
||
padding: 0 14px;
|
||
height: 44px;
|
||
cursor: pointer;
|
||
text-align: left;
|
||
transition: background-color 160ms ease, border-color 160ms ease;
|
||
}
|
||
.mb-field:hover {
|
||
background: var(--m-bg-soft-hover);
|
||
}
|
||
.mb-field__icon {
|
||
color: var(--m-text-muted);
|
||
font-size: 0.95rem;
|
||
margin-right: 10px;
|
||
flex-shrink: 0;
|
||
}
|
||
.mb-field__placeholder {
|
||
flex: 1;
|
||
color: var(--m-text-muted);
|
||
font-size: 0.9rem;
|
||
font-family: inherit;
|
||
min-width: 0;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
.mb-field__kbd {
|
||
color: var(--m-text-muted);
|
||
font-size: 0.62rem;
|
||
font-weight: 500;
|
||
padding: 2px 7px;
|
||
border-radius: 4px;
|
||
background: var(--m-bg-soft);
|
||
border: 1px solid var(--m-border-strong);
|
||
margin-left: 8px;
|
||
flex-shrink: 0;
|
||
letter-spacing: 0.05em;
|
||
}
|
||
|
||
/* ─── Dialog Spotlight (PrimeVue Dialog customizado) ─── */
|
||
:global(.mb-dialog__mask) {
|
||
backdrop-filter: blur(8px);
|
||
-webkit-backdrop-filter: blur(8px);
|
||
background: rgba(0, 0, 0, 0.55) !important;
|
||
}
|
||
:global(.mb-dialog) {
|
||
border-radius: 14px !important;
|
||
overflow: hidden;
|
||
/* Posiciona mais alto que o centro (estilo Spotlight) */
|
||
margin-top: 10vh !important;
|
||
align-self: flex-start;
|
||
}
|
||
:global(.mb-dialog .mb-dialog__content) {
|
||
padding: 0 !important;
|
||
background: var(--surface-card) !important;
|
||
border-radius: 14px !important;
|
||
overflow: hidden;
|
||
max-height: 70vh;
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
|
||
.mb-dialog__field {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
padding: 14px 18px;
|
||
border-bottom: 1px solid var(--surface-border);
|
||
background: var(--surface-card);
|
||
flex-shrink: 0;
|
||
}
|
||
.mb-dialog__field-icon {
|
||
color: var(--text-color-secondary);
|
||
font-size: 1.05rem;
|
||
flex-shrink: 0;
|
||
}
|
||
.mb-dialog__input {
|
||
flex: 1;
|
||
background: transparent;
|
||
border: none;
|
||
outline: none;
|
||
color: var(--text-color);
|
||
font-size: 1.05rem;
|
||
font-family: inherit;
|
||
min-width: 0;
|
||
}
|
||
.mb-dialog__input::placeholder {
|
||
color: var(--text-color-secondary);
|
||
opacity: 0.7;
|
||
}
|
||
.mb-dialog__esc {
|
||
color: var(--text-color-secondary);
|
||
font-size: 0.65rem;
|
||
font-weight: 500;
|
||
padding: 2px 8px;
|
||
border-radius: 4px;
|
||
background: var(--surface-ground);
|
||
border: 1px solid var(--surface-border);
|
||
flex-shrink: 0;
|
||
letter-spacing: 0.05em;
|
||
opacity: 0.8;
|
||
}
|
||
|
||
/* Panel agora vive DENTRO do Dialog (não mais absolute). Scroll interno
|
||
resolve o bug de overflow — o conteúdo nunca passa do bottom porque
|
||
o Dialog tem max-height e o panel é flex:1. */
|
||
.mb-panel {
|
||
flex: 1 1 auto;
|
||
overflow-y: auto;
|
||
overflow-x: hidden;
|
||
padding: 6px;
|
||
background: var(--surface-card);
|
||
scrollbar-width: thin;
|
||
scrollbar-color: var(--surface-border) transparent;
|
||
min-height: 0; /* permite shrink no flex */
|
||
}
|
||
.mb-panel::-webkit-scrollbar { width: 6px; }
|
||
.mb-panel::-webkit-scrollbar-thumb {
|
||
background: var(--surface-border);
|
||
border-radius: 3px;
|
||
}
|
||
|
||
.mb-empty {
|
||
padding: 18px 14px;
|
||
text-align: center;
|
||
color: var(--text-color-secondary);
|
||
font-size: 0.85rem;
|
||
}
|
||
|
||
.mb-group + .mb-group {
|
||
margin-top: 4px;
|
||
padding-top: 4px;
|
||
border-top: 1px solid var(--surface-border);
|
||
}
|
||
.mb-group__title {
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.18em;
|
||
color: var(--text-color-secondary);
|
||
font-size: 0.62rem;
|
||
font-weight: 700;
|
||
padding: 8px 10px 4px;
|
||
opacity: 0.75;
|
||
}
|
||
|
||
.mb-item {
|
||
width: 100%;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
padding: 9px 10px;
|
||
background: transparent;
|
||
border: none;
|
||
border-radius: 8px;
|
||
color: var(--text-color);
|
||
text-align: left;
|
||
cursor: pointer;
|
||
font-family: inherit;
|
||
transition: background-color 100ms ease;
|
||
}
|
||
.mb-item:hover,
|
||
.mb-item.is-active {
|
||
background: color-mix(in srgb, var(--p-primary-color) 8%, transparent);
|
||
}
|
||
.mb-item__icon {
|
||
width: 32px;
|
||
height: 32px;
|
||
display: grid;
|
||
place-items: center;
|
||
background: var(--surface-ground);
|
||
border-radius: 7px;
|
||
color: var(--text-color-secondary);
|
||
flex-shrink: 0;
|
||
font-size: 0.9rem;
|
||
}
|
||
/* Cores por tipo — semântica fixa (não depende do tema, é categoria). */
|
||
.mb-item__icon--patient {
|
||
background: rgba(244, 114, 182, 0.18);
|
||
color: #ec4899;
|
||
}
|
||
.mb-item__icon--sessao {
|
||
background: rgba(99, 102, 241, 0.20);
|
||
color: #6366f1;
|
||
}
|
||
.mb-item__icon--doc {
|
||
background: rgba(14, 165, 233, 0.18);
|
||
color: #0ea5e9;
|
||
}
|
||
.mb-item__icon--intake {
|
||
background: rgba(251, 146, 60, 0.18);
|
||
color: #f97316;
|
||
}
|
||
/* Dark mode: clareia as cores semânticas pra manter contraste */
|
||
:root.app-dark .mb-item__icon--patient { color: #f9a8d4; }
|
||
:root.app-dark .mb-item__icon--sessao { color: #a5b4fc; }
|
||
:root.app-dark .mb-item__icon--doc { color: #7dd3fc; }
|
||
:root.app-dark .mb-item__icon--intake { color: #fdba74; }
|
||
|
||
.mb-item__main {
|
||
flex: 1;
|
||
min-width: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 2px;
|
||
}
|
||
.mb-item__label {
|
||
font-size: 0.88rem;
|
||
font-weight: 500;
|
||
color: var(--text-color);
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
.mb-item__sub {
|
||
font-size: 0.74rem;
|
||
color: var(--text-color-secondary);
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
.mb-item__go {
|
||
color: var(--text-color-secondary);
|
||
opacity: 0.5;
|
||
font-size: 0.75rem;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
</style>
|