Files
agenciapsilmno/src/layout/melissa/MelissaBusca.vue
T
Leonardo 2dae4a11ae roadmap #11: recently viewed (ultimos 5 pacientes acessados)
ROADMAP item #1.3 #11. localStorage por user_id pra isolar sessoes
diferentes no mesmo browser. ROADMAP sugeria localStorage OU tabela
user_recent_access — escolhi localStorage por simplicidade (sem
migration adicional + zero round-trip por visita).

composables/useRecentPatients.js:
- useRecentPatients() — composable reativo Tipo A: items + hasItems
  + addVisit + remove + clear + refresh
- registerPatientVisit(patient) — helper stateless pra usar fora
  de setup (ex: navigation guards, action handlers)
- Sincroniza entre instancias na mesma aba via CustomEvent + 'storage'
- Max 5 items. Dedup por id, novo no topo.

Wire-up de visita (registra ao carregar prontuario):
- MelissaPaciente.vue: registerPatientVisit no loadAll apos detail.load
- PatientProntuario.vue: registerPatientVisit em loadDetail apos p resolved

Wire-up de visualizacao (mostra quando query vazia):
- GlobalSearch.vue: grupo "Acessados recentemente" antes dos Atalhos.
  goTo("recent") navega pra /therapist/patients/:id.
- MelissaBusca.vue: grupo "Acessados recentemente". emit('paciente')
  reusando a logica do MelissaLayout que ja navega pra
  /melissa/paciente?id=X.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 05:17:51 -03:00

593 lines
22 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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 } 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.
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.nome_completo || p.nome_social || p.nome || '(sem nome)',
email: p.email,
telefone: p.telefone
}));
}
// Fallback client-side
return props.pacientes
.filter((p) => normalize(p.nome).includes(q))
.slice(0, 5);
});
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') emit('evento', entry.item);
else if (entry.group === 'rpc-documents') emit('documento', entry.item);
else if (entry.group === 'rpc-intakes') emit('intake', entry.item);
closePanel();
}
function closePanel() {
showPanel.value = false;
query.value = '';
activeIndex.value = -1;
inputEl.value?.blur();
}
function onFocus() {
showPanel.value = true;
}
function onClickOutside(e) {
if (rootEl.value && !rootEl.value.contains(e.target)) {
showPanel.value = false;
activeIndex.value = -1;
}
}
function onKeydown(e) {
if (!showPanel.value) return;
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]);
} else if (e.key === 'Escape') {
// Stop bubbling pra ESC do parent não fechar overlay aleatório
e.stopPropagation();
closePanel();
}
}
function onGlobalKeydown(e) {
// Ctrl+K / ⌘+K → foca input
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'k') {
e.preventDefault();
showPanel.value = true;
inputEl.value?.focus();
}
}
// ── 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(() => {
document.addEventListener('mousedown', onClickOutside);
window.addEventListener('keydown', onGlobalKeydown);
});
onBeforeUnmount(() => {
document.removeEventListener('mousedown', onClickOutside);
window.removeEventListener('keydown', onGlobalKeydown);
if (debounceT) clearTimeout(debounceT);
});
</script>
<template>
<div ref="rootEl" class="mb-search">
<div class="mb-field">
<i class="pi pi-search mb-field__icon" />
<input
ref="inputEl"
v-model="query"
type="text"
placeholder="Buscar paciente, agenda, atalho…"
class="mb-field__input"
@focus="onFocus"
@keydown="onKeydown"
/>
<span class="mb-field__kbd" aria-hidden="true">Ctrl K</span>
</div>
<Transition name="mb-fade">
<div v-if="showPanel" 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 ( 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"><i class="pi pi-user" /></span>
<span class="mb-item__main">
<span class="mb-item__label">{{ p.nome }}</span>
<span class="mb-item__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) -->
<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"><i class="pi pi-calendar" /></span>
<span class="mb-item__main">
<span class="mb-item__label">{{ e.paciente_nome || e.title || 'Sessão' }}</span>
<span class="mb-item__sub">{{ e.inicio_em ? new Date(e.inicio_em).toLocaleDateString('pt-BR') + ' ' + new Date(e.inicio_em).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' }) : 'Sem data' }}</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"><i class="pi pi-file" /></span>
<span class="mb-item__main">
<span class="mb-item__label">{{ d.nome_original || 'Documento' }}</span>
<span class="mb-item__sub">{{ d.paciente_nome ? `${d.paciente_nome} · ` : '' }}{{ d.tipo_documento || 'outro' }}</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"><i class="pi pi-inbox" /></span>
<span class="mb-item__main">
<span class="mb-item__label">{{ r.nome_completo || 'Cadastro' }}</span>
<span class="mb-item__sub">{{ r.created_at ? new Date(r.created_at).toLocaleDateString('pt-BR') : '' }}</span>
</span>
<i class="mb-item__go pi pi-arrow-right" />
</button>
</div>
</div>
</Transition>
</div>
</template>
<style scoped>
.mb-search {
position: relative;
width: 100%;
max-width: 560px;
/* Só horizontal — top/bottom ficam livres pro parent (mt-X do Tailwind) */
margin-left: auto;
margin-right: auto;
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
}
.mb-field {
position: relative;
display: flex;
align-items: center;
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;
transition: background-color 160ms ease, border-color 160ms ease;
}
.mb-field:focus-within {
background: var(--m-bg-soft-hover);
border-color: var(--m-border-strong);
}
.mb-field__icon {
color: var(--m-text-muted);
font-size: 0.95rem;
margin-right: 10px;
flex-shrink: 0;
}
.mb-field__input {
flex: 1;
background: transparent;
border: none;
outline: none;
color: white;
font-size: 0.9rem;
font-family: inherit;
min-width: 0;
}
.mb-field__input::placeholder {
color: var(--m-text-muted);
}
.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;
}
.mb-panel {
position: absolute;
top: calc(100% + 6px);
left: 0;
right: 0;
z-index: 30;
max-height: 60vh;
overflow-y: auto;
background: var(--m-bg-medium);
backdrop-filter: blur(28px) saturate(160%);
-webkit-backdrop-filter: blur(28px) saturate(160%);
border: 1px solid var(--m-border-strong);
border-radius: 12px;
padding: 6px;
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.45);
scrollbar-width: thin;
scrollbar-color: var(--m-border-strong) transparent;
}
.mb-panel::-webkit-scrollbar { width: 6px; }
.mb-panel::-webkit-scrollbar-thumb {
background: var(--m-border-strong);
border-radius: 3px;
}
.mb-empty {
padding: 18px 14px;
text-align: center;
color: var(--m-text-muted);
font-size: 0.85rem;
}
.mb-group + .mb-group {
margin-top: 4px;
padding-top: 4px;
border-top: 1px solid var(--m-border);
}
.mb-group__title {
text-transform: uppercase;
letter-spacing: 0.18em;
color: var(--m-text-faint);
font-size: 0.6rem;
font-weight: 600;
padding: 8px 10px 4px;
}
.mb-item {
width: 100%;
display: flex;
align-items: center;
gap: 10px;
padding: 8px 10px;
background: transparent;
border: none;
border-radius: 8px;
color: white;
text-align: left;
cursor: pointer;
font-family: inherit;
transition: background-color 100ms ease;
}
.mb-item:hover,
.mb-item.is-active {
background: var(--m-bg-soft);
}
.mb-item__icon {
width: 30px;
height: 30px;
display: grid;
place-items: center;
background: var(--m-bg-soft);
border-radius: 7px;
color: var(--m-text-muted);
flex-shrink: 0;
font-size: 0.85rem;
}
.mb-item__main {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 1px;
}
.mb-item__label {
font-size: 0.85rem;
color: white;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mb-item__sub {
font-size: 0.7rem;
color: var(--m-text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mb-item__go {
color: var(--m-text-faint);
font-size: 0.7rem;
flex-shrink: 0;
}
.mb-fade-enter-active,
.mb-fade-leave-active {
transition: opacity 140ms ease, transform 160ms ease;
}
.mb-fade-enter-from,
.mb-fade-leave-to {
opacity: 0;
transform: translateY(-6px);
}
</style>