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>
This commit is contained in:
Leonardo
2026-05-06 09:13:53 -03:00
parent 6d9b36d592
commit 269b531158
10 changed files with 4839 additions and 316 deletions
@@ -17,6 +17,7 @@
import { ref, onMounted } from 'vue';
import { supabase } from '@/lib/supabase/client';
import { useTenantStore } from '@/stores/tenantStore';
import { useMelissaCacheStore, MELISSA_CACHE_TTL } from '@/stores/melissaCacheStore';
// Mesma lógica da PatientsListPage.vue — DB pode ter valores variados
function normalizeStatus(s) {
@@ -37,7 +38,9 @@ function normalizeStatus(s) {
*/
export function useMelissaPacientes(opts = {}) {
const onlyActive = opts.onlyActive !== false; // default true (compat)
const autoFetch = opts.autoFetch !== false; // default true (compat)
const tenantStore = useTenantStore();
const cache = useMelissaCacheStore();
const pacientes = ref([]);
const loading = ref(false);
@@ -46,17 +49,51 @@ export function useMelissaPacientes(opts = {}) {
async function ensureUid() {
if (uid.value) return uid.value;
// Fast path: session do storage local (<10ms vs ~80ms do getUser)
const { data: ses } = await supabase.auth.getSession();
if (ses?.session?.user?.id) {
uid.value = ses.session.user.id;
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 fetchPacientes() {
const userId = await ensureUid();
async function _doFetch(userId, tid, cacheKey) {
const { data, error: err } = await supabase
.from('patients')
.select('id, nome_completo, email_principal, telefone, status, avatar_url, last_attended_at, created_at, data_nascimento')
.eq('owner_id', userId)
.eq('tenant_id', tid)
.order('nome_completo', { ascending: true })
.limit(1000);
// Garante que o tenantStore foi hidratado (preview misc não passa por
// guard de auth, então o store pode estar vazio mesmo com user logado)
if (err) throw err;
const todos = (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
}));
const finalList = onlyActive ? todos.filter((p) => p.status === 'Ativo') : todos;
cache.set('pacientesTimeline', finalList, cacheKey);
pacientes.value = finalList;
return finalList;
}
// useCache=true (boot/auto): hidrata do cache se válido + revalida em background.
// useCache=false (refetch após mutation): força query nova, descarta cache.
async function _fetch({ useCache = true } = {}) {
const userId = await ensureUid();
if (typeof tenantStore.ensureLoaded === 'function') {
await tenantStore.ensureLoaded();
}
@@ -67,35 +104,28 @@ export function useMelissaPacientes(opts = {}) {
return;
}
const cacheKey = `${userId}:${tid}:${onlyActive ? 'a' : 'all'}`;
if (useCache) {
const cached = cache.get('pacientesTimeline', cacheKey, MELISSA_CACHE_TTL.pacientesTimeline);
if (cached) {
pacientes.value = cached;
_doFetch(userId, tid, cacheKey).catch((e) => {
// eslint-disable-next-line no-console
console.warn('[useMelissaPacientes] revalidate', e);
});
return;
}
} else {
// Force: invalida o slot pra outras instâncias (se houver) também
// pegarem fresh na próxima leitura.
cache.invalidate('pacientesTimeline');
}
loading.value = true;
error.value = null;
try {
// Não filtra status no SQL — DB tem valores inconsistentes
// ('ativo', 'Ativo', 'active', null...). Normaliza e filtra no client.
const { data, error: err } = await supabase
.from('patients')
.select('id, nome_completo, email_principal, telefone, status, avatar_url, last_attended_at, created_at, data_nascimento')
.eq('owner_id', userId)
.eq('tenant_id', tid)
.order('nome_completo', { ascending: true })
.limit(1000);
if (err) throw err;
const todos = (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
}));
pacientes.value = onlyActive ? todos.filter((p) => p.status === 'Ativo') : todos;
await _doFetch(userId, tid, cacheKey);
} catch (e) {
error.value = e?.message || 'Erro ao carregar pacientes';
pacientes.value = [];
@@ -106,12 +136,15 @@ export function useMelissaPacientes(opts = {}) {
}
}
onMounted(fetchPacientes);
if (autoFetch) onMounted(() => _fetch({ useCache: true }));
return {
pacientes,
loading,
error,
refetch: fetchPacientes
// refetch força query nova (uso em handlers pós-mutation: criar/editar/deletar).
refetch: () => _fetch({ useCache: false }),
// fetchCached usa stale-while-revalidate (uso em defer/idle callback).
fetchCached: () => _fetch({ useCache: true })
};
}
@@ -66,6 +66,14 @@ export function useMelissaPacientesAside(opts) {
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;