Files
agenciapsilmno/src/layout/melissa/composables/useMelissaPacientesAside.js
T
Leonardo 269b531158 Melissa: blueprint tabular + Cadastros/Agendamentos/Pacientes + restore
Sprint E (05-05). Blueprint tabular oficial pras paginas Melissa de
listagem (DataTable + sidebar com stats e filtros coloridos, view
toggle list/grade, subheader explicativo, mobile pencil+popover).

Novo arquivo:
- blueprints/melissa-table-page-blueprint.md (~530L, 18 secoes) —
  referencia canonica MelissaCadastrosRecebidos

Paginas refatoradas/criadas:
- MelissaCadastrosRecebidos: refator pra blueprint (DataTable + frozen
  action + view toggle + subheader)
- MelissaAgendamentosRecebidos (NOVO): substitui o embed via
  MelissaEmbed; 4 status coloridos (Pendente/Autorizado/Convertido/
  Recusado), 3 acoes condicionais (Recusar/Autorizar/Converter em
  sessao), wired com AgendaEventDialog
- MelissaPacientes: refator parcial (subheader, sombras, status pills
  coloridas, email/phone colunas proprias, mobile pencil+popover, fix
  scroll mobile com min-height:0 na .mp-list, view toggle persistido,
  tags/grupos color fix g.cor->g.color, restore de arquivados)
- MelissaEmbed: agendamentos-recebidos removido do EMBED_MAP
- MelissaLayout: wire-up MelissaAgendamentosRecebidos nativo
- composables/useMelissaPacientes + useMelissaPacientesAside ajustes

Restore de pacientes arquivados:
- patientsRepository: novo restorePatient(id, { tenantId })
- PatientsCadastroPage statusOpts: +Arquivado (fecha gap de
  inconsistencia ao editar paciente arquivado)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 09:13:53 -03:00

192 lines
7.1 KiB
JavaScript

/*
* 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<number>} opts.pagina ref 1-based
* @param {import('vue').Ref<string>} 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 })
};
}