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:
@@ -14,7 +14,8 @@
|
||||
* 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 } from 'vue';
|
||||
import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue';
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
|
||||
const props = defineProps({
|
||||
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 inputEl = ref(null);
|
||||
@@ -39,6 +40,14 @@ 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;
|
||||
|
||||
function normalize(s) {
|
||||
return String(s || '')
|
||||
.normalize('NFD')
|
||||
@@ -62,9 +71,22 @@ const filteredAtalhos = computed(() => {
|
||||
}).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);
|
||||
@@ -83,11 +105,19 @@ const filteredEventos = computed(() => {
|
||||
.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 = [];
|
||||
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;
|
||||
});
|
||||
|
||||
@@ -101,6 +131,9 @@ 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 === '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();
|
||||
}
|
||||
|
||||
@@ -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(() => {
|
||||
document.addEventListener('mousedown', onClickOutside);
|
||||
window.addEventListener('keydown', onGlobalKeydown);
|
||||
@@ -156,6 +232,7 @@ onMounted(() => {
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('mousedown', onClickOutside);
|
||||
window.removeEventListener('keydown', onGlobalKeydown);
|
||||
if (debounceT) clearTimeout(debounceT);
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -248,6 +325,66 @@ onBeforeUnmount(() => {
|
||||
<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>
|
||||
|
||||
@@ -2337,8 +2337,10 @@ function onKeydown(e) {
|
||||
:pacientes="pacientesReais"
|
||||
:eventos="eventosHojeReais"
|
||||
@acao="abrirSecao"
|
||||
@paciente="() => abrirSecao('pacientes')"
|
||||
@paciente="(p) => p?.id ? router.push({ path: '/melissa/paciente', query: { id: String(p.id) } }) : abrirSecao('pacientes')"
|
||||
@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) -->
|
||||
|
||||
Reference in New Issue
Block a user