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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user