Melissa: hub Configuracoes + Embed + 9 Pages novas + dialog blueprint dark
Sprints 04-29 + 04-30 acumuladas. - MelissaConfiguracoes: hub 2-col com 6 grupos (Layout/Conta/Agenda/ Financeiro/WhatsApp/Sistema), tudo embedado via MelissaEmbed. - MelissaEmbed: wrapper generico que injeta layout-variant=melissa e remove cromos pra reaproveitar Pages tradicionais. - 9 Melissa Pages novas: CadastrosRecebidos, Compromissos, Configuracoes, Conversas, Embed, Grupos, Medicos, Recorrencias, Tags. - Dialog blueprint atualizado: bg-gray-100 (hardcoded light) -> bg-[var(--surface-ground)] (tema-aware). 22 dialogs migrados em 9 arquivos. Anti-pattern documentado. - PatientsCadastroPage: bug fix dropdown Grupo (optionLabel nome->name), toggle vertical/abas com persist localStorage, sticky margin-top. - Surface picker no popover do MelissaLayout (8 swatches). - useTopbarPlanMenu, useMelissaWhatsapp, useMelissaPacientesAside novos. - Migration: status agenda remarcado/confirmado. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,183 @@
|
||||
/*
|
||||
* 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;
|
||||
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 })
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user