/* * useMelissaPacientesAside — paginação async DEDICADA pra coluna direita * da MelissaAgenda (lista de "Pacientes" lá no aside). * -------------------------------------------------- * Por que existe (em vez de reaproveitar `useMelissaPacientes`): * - useMelissaPacientes carrega TUDO num array só (até 1000) porque outras * partes do sistema dependem da lista completa em memória (lookup por ID * em eventos da agenda, página MelissaPacientes, cards de resumo). * - A coluna do aside só precisa renderizar 6 por vez. Pra clínicas com * milhares de pacientes, faz sentido essa coluna ir ao banco a cada * página/busca em vez de paginar client-side em cima de um array gigante. * * Compromisso: a ordenação é alfabética (server-side por nome_completo). O * destaque visual de "novo paciente" continua funcionando (compara created_at * < 7 dias no consumer), mas pacientes novos NÃO são mais empurrados pro * topo da lista — eles aparecem na ordem alfabética normal. Pra ver os mais * recentes, usuário precisa filtrar/buscar pelo nome. * * Sanitização (regra do projeto): * - busca trimada e capada em 100 chars * - wildcards LIKE (%, _) são escapados antes de irem pro .ilike() * * Race-safety: * - sequence number (_seq) ignora respostas tardias de queries antigas * quando o usuário troca de página/digita rápido. */ import { ref, computed, watch } from 'vue'; import { supabase } from '@/lib/supabase/client'; import { useTenantStore } from '@/stores/tenantStore'; const SEARCH_MAX_LEN = 100; const DEBOUNCE_MS = 250; function normalizeStatus(s) { const v = String(s || '').toLowerCase().trim(); if (!v) return 'Ativo'; if (v === 'active' || v === 'ativo') return 'Ativo'; if (v === 'inactive' || v === 'inativo') return 'Inativo'; return v.charAt(0).toUpperCase() + v.slice(1); } // Escapa wildcards do LIKE/ILIKE pra evitar que o usuário injete // padrões de busca não intencionais ao digitar % ou _. function escapeLike(s) { return String(s).replace(/\\/g, '\\\\').replace(/%/g, '\\%').replace(/_/g, '\\_'); } /** * @param {object} opts * @param {import('vue').Ref} opts.pagina ref 1-based * @param {import('vue').Ref} opts.busca ref de string (texto livre) * @param {number} [opts.porPagina=6] tamanho da página */ export function useMelissaPacientesAside(opts) { const { pagina, busca, porPagina = 6 } = opts; const tenantStore = useTenantStore(); const pacientes = ref([]); const total = ref(0); const loading = ref(false); const error = ref(null); const _uid = ref(null); const _seq = ref(0); let _debounceTimer = null; async function _ensureUid() { if (_uid.value) return _uid.value; // Fast path: session já hidratada no storage (<10ms). const { data: ses } = await supabase.auth.getSession(); const uid = ses?.session?.user?.id; if (uid) { _uid.value = uid; return uid; } // Fallback: round-trip pro auth server (cold start). const { data, error: err } = await supabase.auth.getUser(); if (err) return null; _uid.value = data?.user?.id || null; return _uid.value; } async function _fetch() { const seq = ++_seq.value; const userId = await _ensureUid(); if (typeof tenantStore.ensureLoaded === 'function') { await tenantStore.ensureLoaded(); } const tid = tenantStore.activeTenantId || tenantStore.tenantId || null; if (!userId || !tid) { if (seq !== _seq.value) return; pacientes.value = []; total.value = 0; return; } // Sanitização da busca: trim + cap + escape de wildcards LIKE. const rawQ = String(busca.value || '').trim().slice(0, SEARCH_MAX_LEN); const hasQ = rawQ.length > 0; const start = Math.max(0, (pagina.value - 1) * porPagina); const end = start + porPagina - 1; loading.value = true; error.value = null; try { let q = supabase .from('patients') .select( 'id, nome_completo, email_principal, telefone, status, avatar_url, last_attended_at, created_at, data_nascimento', { count: 'exact' } ) .eq('owner_id', userId) .eq('tenant_id', tid) // Mesmo critério do useMelissaPacientes original (onlyActive=true). // DB tem valores variados ('ativo'/'Ativo'/'active'); aceita os 3. .in('status', ['ativo', 'Ativo', 'active']) .order('nome_completo', { ascending: true }) .range(start, end); if (hasQ) { q = q.ilike('nome_completo', `%${escapeLike(rawQ)}%`); } const { data, error: err, count } = await q; if (err) throw err; // Race-guard: outra chamada disparou enquanto esperávamos a resposta. if (seq !== _seq.value) return; pacientes.value = (data || []).map((r) => ({ id: r.id, nome: r.nome_completo || '', email: r.email_principal || '', telefone: r.telefone || '', avatar_url: r.avatar_url || null, status: normalizeStatus(r.status), last_attended_at: r.last_attended_at || null, created_at: r.created_at || null, data_nascimento: r.data_nascimento || null })); total.value = count ?? 0; } catch (e) { if (seq !== _seq.value) return; error.value = e?.message || 'Erro ao carregar pacientes'; pacientes.value = []; total.value = 0; // eslint-disable-next-line no-console console.warn('[useMelissaPacientesAside]', e); } finally { if (seq === _seq.value) loading.value = false; } } function _scheduleFetch({ debounce }) { if (_debounceTimer) { clearTimeout(_debounceTimer); _debounceTimer = null; } if (debounce) { _debounceTimer = setTimeout(() => { _debounceTimer = null; _fetch(); }, DEBOUNCE_MS); } else { _fetch(); } } // Página muda → fetch imediato (clique no paginator é deliberado). watch(pagina, () => _scheduleFetch({ debounce: false }), { immediate: true }); // Busca muda → debounce (usuário digitando). watch(busca, () => { // Reset implícito: ao buscar, qualquer página > 1 deve voltar pra 1. // Como `pagina` é refs do consumer, não mexemos aqui — o consumer faz isso. _scheduleFetch({ debounce: true }); }); const totalPaginas = computed(() => Math.max(1, Math.ceil(total.value / porPagina))); return { pacientes, total, totalPaginas, loading, error, refetch: () => _scheduleFetch({ debounce: false }) }; }