roadmap #10: MelissaBusca usa RPC search_global (promovida de preview)

Fecha ROADMAP #1.3 #10 (busca global topbar). GlobalSearch.vue
classic+rail ja usava RPC. MelissaBusca era client-side preview com
fallback nas props (pacientes+eventos do dia) — agora consulta a
mesma RPC search_global com debounce 200ms + searchSeq pra descartar
respostas obsoletas.

3 grupos novos exibidos quando RPC retorna:
  - rpc-appointments  -> sessoes qualquer data (alem de "hoje")
  - rpc-documents     -> documentos por nome/tipo
  - rpc-intakes       -> cadastros recebidos

Pacientes mescla: RPC tem prioridade (todos os pacientes); props
mantida como fallback rapido (digitacao curta antes do debounce).
Emits estendidos: novos 'documento' + 'intake' alem dos existentes
'acao' + 'paciente' + 'evento'.

MelissaLayout atualizado:
  - @paciente agora navega pra /melissa/paciente?id=X (antes ignorava
    payload e so abria secao generica — bug existente)
  - @documento abre prontuario do paciente com tab=documentos
  - @intake abre /melissa cadastros-recebidos

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Leonardo
2026-05-21 05:13:47 -03:00
parent 36402cd0bf
commit e7a9bdab5f
2 changed files with 142 additions and 3 deletions
+139 -2
View File
@@ -14,7 +14,8 @@
* Quando promover pra produção: trocar a busca por chamada à RPC * Quando promover pra produção: trocar a busca por chamada à RPC
* `search_global` + manter a mesma estrutura de panel/items. * `search_global` + manter a mesma estrutura de panel/items.
*/ */
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'; import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue';
import { supabase } from '@/lib/supabase/client';
const props = defineProps({ const props = defineProps({
pacientes: { type: Array, default: () => [] }, pacientes: { type: Array, default: () => [] },
@@ -31,7 +32,7 @@ const props = defineProps({
} }
}); });
const emit = defineEmits(['acao', 'paciente', 'evento']); const emit = defineEmits(['acao', 'paciente', 'evento', 'documento', 'intake']);
const rootEl = ref(null); const rootEl = ref(null);
const inputEl = ref(null); const inputEl = ref(null);
@@ -39,6 +40,14 @@ const query = ref('');
const showPanel = ref(false); const showPanel = ref(false);
const activeIndex = ref(-1); 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;
function normalize(s) { function normalize(s) {
return String(s || '') return String(s || '')
.normalize('NFD') .normalize('NFD')
@@ -62,9 +71,22 @@ const filteredAtalhos = computed(() => {
}).slice(0, 5); }).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 filteredPacientes = computed(() => {
const q = normalize(query.value); const q = normalize(query.value);
if (q.length < 2) return []; 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 return props.pacientes
.filter((p) => normalize(p.nome).includes(q)) .filter((p) => normalize(p.nome).includes(q))
.slice(0, 5); .slice(0, 5);
@@ -83,11 +105,19 @@ const filteredEventos = computed(() => {
.slice(0, 5); .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 flatList = computed(() => {
const out = []; const out = [];
filteredAtalhos.value.forEach((a, i) => out.push({ group: 'atalhos', item: a, 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 })); 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 })); 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; return out;
}); });
@@ -101,6 +131,9 @@ function selectEntry(entry) {
if (entry.group === 'atalhos') emit('acao', entry.item.id); if (entry.group === 'atalhos') emit('acao', entry.item.id);
else if (entry.group === 'pacientes') emit('paciente', entry.item); else if (entry.group === 'pacientes') emit('paciente', entry.item);
else if (entry.group === 'eventos') emit('evento', entry.item); 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(); closePanel();
} }
@@ -149,6 +182,49 @@ function onGlobalKeydown(e) {
} }
} }
// ── 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(() => { onMounted(() => {
document.addEventListener('mousedown', onClickOutside); document.addEventListener('mousedown', onClickOutside);
window.addEventListener('keydown', onGlobalKeydown); window.addEventListener('keydown', onGlobalKeydown);
@@ -156,6 +232,7 @@ onMounted(() => {
onBeforeUnmount(() => { onBeforeUnmount(() => {
document.removeEventListener('mousedown', onClickOutside); document.removeEventListener('mousedown', onClickOutside);
window.removeEventListener('keydown', onGlobalKeydown); window.removeEventListener('keydown', onGlobalKeydown);
if (debounceT) clearTimeout(debounceT);
}); });
</script> </script>
@@ -248,6 +325,66 @@ onBeforeUnmount(() => {
<i class="mb-item__go pi pi-arrow-right" /> <i class="mb-item__go pi pi-arrow-right" />
</button> </button>
</div> </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> </div>
</Transition> </Transition>
</div> </div>
+3 -1
View File
@@ -2337,8 +2337,10 @@ function onKeydown(e) {
:pacientes="pacientesReais" :pacientes="pacientesReais"
:eventos="eventosHojeReais" :eventos="eventosHojeReais"
@acao="abrirSecao" @acao="abrirSecao"
@paciente="() => abrirSecao('pacientes')" @paciente="(p) => p?.id ? router.push({ path: '/melissa/paciente', query: { id: String(p.id) } }) : abrirSecao('pacientes')"
@evento="abrirEvento" @evento="abrirEvento"
@documento="(d) => d?.patient_id ? router.push({ path: '/melissa/paciente', query: { id: String(d.patient_id), tab: 'documentos' } }) : abrirSecao('pacientes')"
@intake="() => abrirSecao('cadastros-recebidos')"
/> />
<!-- Timeline horizontal + vertical (responsivo) --> <!-- Timeline horizontal + vertical (responsivo) -->