MelissaPaciente Fase 1: foundation (5 composables + skeleton 7 tabs + slug paciente)

Inicio do port do PatientProntuario.vue (3593L Dialog) pra Melissa nativo.
Plano em 8 fases — esta entrega cobre apenas a Fase 1 (foundation).
PatientProntuario continua intocado nos 4 callsites (TherapistDashboard,
MelissaAgenda, MelissaPacientes, PatientsListPage); migration acontece
nas fases 2-8.

5 COMPOSABLES NOVOS em src/features/patients/composables/
- usePatientDetail.js (108L): patients + groups + tags
- usePatientSessions.js (83L): agenda_eventos + computeds proxima/ultima/totais
- usePatientFinancial.js (82L): financial_records + computeds totalRecebido/Aberto/Atrasado
- usePatientMessages.js (64L): conversation_messages + computeds recentes/totalIn/Out
- usePatientDocuments.js (70L): documents + computeds total/Bytes/tiposCount

Cada composable encapsula a query original do PatientProntuario.vue +
adiciona computeds derivados. Reutilizaveis em outros lugares no futuro
(dashboards, relatorios, etc).

MELISSAPACIENTE.VUE NOVO (1190L) em src/layout/melissa/
- Prefixo CSS .mpa-*. Chrome glass + drawer mobile + right: max(...) >=1024px
  (mesmo padrao MelissaAgendador/Negocio).
- Header: avatar + nome + ageLabel + pronomes + Tag status/convenio +
  risco-elevado pill + actions (Conversar / Editar / Close).
- Subheader condicional: banner risco elevado.
- Body 2-col: sidebar 320px (esquerda, drawer no mobile) + main flex 1.
- Sidebar com 4 cards: Acoes Rapidas / Navegacao 7 tabs / Sub-nav Perfil /
  Vinculos (chips grupos+tags).
- Main: 7 tabs (Visao Geral / Perfil / Prontuario / Agenda / Financeiro /
  Documentos / Conversas). Visao Geral ja mostra 4 KPIs reais via composables.
  Outras 6 abas com placeholders "Em desenvolvimento — Fase X".

MELISSALAYOUT.VUE
- Import MelissaPaciente.
- SECOES.paciente entry novo.
- 'paciente' adicionado ao MELISSA_NON_CONFIG_SLUGS.
- Render condicional com :patient-id="String(route.query.id || '')"
  — navegacao via /melissa/paciente?id=xxx.

ESLint: 0 errors da mudanca. 2 errors pre-existentes em MelissaLayout
(duplicate key 'financeiro' L242, empty block L1130) — nao toquei essas
linhas. PatientProntuario tem outros pre-existentes nao tocados.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Leonardo
2026-05-08 09:23:48 -03:00
parent f3f0d831d2
commit df61cc4d99
8 changed files with 1668 additions and 1 deletions
@@ -0,0 +1,108 @@
/*
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Arquivo: src/features/patients/composables/usePatientDetail.js
|
| Composable de detalhe completo de paciente — patient row + grupos + tags
| Extraido do PatientProntuario.vue pra permitir reuso em MelissaPaciente.vue.
| Mantem a mesma logica original (Promise.all em 2 etapas, RLS-aware).
|--------------------------------------------------------------------------
*/
import { ref } from 'vue';
import { supabase } from '@/lib/supabase/client';
async function getPatientById(id) {
const { data, error } = await supabase
.from('patients')
.select('*')
.eq('id', id)
.maybeSingle();
if (error) throw error;
return data;
}
async function getPatientRelations(id) {
const { data: g, error: ge } = await supabase
.from('patient_group_patient')
.select('patient_group_id')
.eq('patient_id', id);
if (ge) throw ge;
const { data: t, error: te } = await supabase
.from('patient_patient_tag')
.select('tag_id')
.eq('patient_id', id);
if (te) throw te;
return {
groupIds: (g || []).map((x) => x.patient_group_id).filter(Boolean),
tagIds: (t || []).map((x) => x.tag_id).filter(Boolean)
};
}
async function getGroupsByIds(ids) {
if (!ids?.length) return [];
const { data, error } = await supabase
.from('patient_groups')
.select('id, nome')
.in('id', ids)
.order('nome', { ascending: true });
if (error) throw error;
return (data || []).map((g) => ({ id: g.id, name: g.nome }));
}
async function getTagsByIds(ids) {
if (!ids?.length) return [];
const { data, error } = await supabase
.from('patient_tags')
.select('id, nome, cor')
.in('id', ids)
.order('nome', { ascending: true });
if (error) throw error;
return (data || []).map((t) => ({ id: t.id, name: t.nome, color: t.cor }));
}
export function usePatientDetail() {
const patient = ref(null);
const groups = ref([]);
const tags = ref([]);
const loading = ref(false);
const loadError = ref('');
async function load(id) {
if (!id) {
patient.value = null;
groups.value = [];
tags.value = [];
return;
}
loading.value = true;
loadError.value = '';
patient.value = null;
groups.value = [];
tags.value = [];
try {
const [p, rel] = await Promise.all([getPatientById(id), getPatientRelations(id)]);
if (!p) throw new Error('Paciente não retornou dados (RLS bloqueando ou ID não existe).');
patient.value = p;
const [g, t] = await Promise.all([
getGroupsByIds(rel.groupIds || []),
getTagsByIds(rel.tagIds || [])
]);
groups.value = g;
tags.value = t;
} catch (e) {
loadError.value = e?.message || 'Falha ao buscar dados.';
} finally {
loading.value = false;
}
}
return {
patient,
groups,
tags,
loading,
loadError,
load
};
}
@@ -0,0 +1,70 @@
/*
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Arquivo: src/features/patients/composables/usePatientDocuments.js
|
| Documentos do paciente — carrega so os campos pra KPIs (count, tipo,
| ultima atualizacao). O detalhe completo fica em DocumentsListPage que
| tem composable proprio. Filtra deletados (deleted_at IS NULL).
|--------------------------------------------------------------------------
*/
import { ref, computed } from 'vue';
import { supabase } from '@/lib/supabase/client';
export function usePatientDocuments() {
const documents = ref([]);
const loading = ref(false);
const error = ref('');
async function load(patientId) {
if (!patientId) {
documents.value = [];
return;
}
loading.value = true;
error.value = '';
documents.value = [];
try {
const { data, error: err } = await supabase
.from('documents')
.select('id, tipo_documento, created_at, status_revisao, tamanho_bytes')
.eq('patient_id', patientId)
.is('deleted_at', null)
.order('created_at', { ascending: false })
.limit(200);
if (err) throw err;
documents.value = data || [];
} catch (e) {
error.value = e?.message || 'Falha ao carregar documentos.';
documents.value = [];
} finally {
loading.value = false;
}
}
const total = computed(() => documents.value.length);
const totalBytes = computed(() =>
documents.value.reduce((acc, d) => acc + Number(d.tamanho_bytes || 0), 0)
);
const tiposCount = computed(() => {
const map = new Map();
documents.value.forEach((d) => {
const k = d.tipo_documento || 'outro';
map.set(k, (map.get(k) || 0) + 1);
});
return Object.fromEntries(map);
});
const ultimo = computed(() => documents.value[0] || null);
return {
documents,
loading,
error,
load,
total,
totalBytes,
tiposCount,
ultimo
};
}
@@ -0,0 +1,82 @@
/*
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Arquivo: src/features/patients/composables/usePatientFinancial.js
|
| Lancamentos financeiros (financial_records) do paciente. Filtra type=receita,
| limita 100. Schema: paid_at NULL = pendente, preenchido = pago.
| "Vencido" = paid_at IS NULL AND due_date < hoje.
| Computeds derivados: kpis (em aberto, atrasado, total, ultimo pago).
|--------------------------------------------------------------------------
*/
import { ref, computed } from 'vue';
import { supabase } from '@/lib/supabase/client';
export function usePatientFinancial() {
const records = ref([]);
const loading = ref(false);
const error = ref('');
async function load(patientId) {
if (!patientId) {
records.value = [];
return;
}
loading.value = true;
error.value = '';
records.value = [];
try {
const { data, error: err } = await supabase
.from('financial_records')
.select('id, type, amount, due_date, paid_at, description, payment_method, category, created_at')
.eq('patient_id', patientId)
.eq('type', 'receita')
.order('created_at', { ascending: false })
.limit(100);
if (err) throw err;
records.value = data || [];
} catch (e) {
error.value = e?.message || 'Falha ao carregar lançamentos.';
records.value = [];
} finally {
loading.value = false;
}
}
const totalRecebido = computed(() =>
records.value
.filter((r) => r.paid_at)
.reduce((acc, r) => acc + Number(r.amount || 0), 0)
);
const totalEmAberto = computed(() =>
records.value
.filter((r) => !r.paid_at)
.reduce((acc, r) => acc + Number(r.amount || 0), 0)
);
const totalAtrasado = computed(() => {
const today = new Date().toISOString().slice(0, 10);
return records.value
.filter((r) => !r.paid_at && r.due_date && r.due_date < today)
.reduce((acc, r) => acc + Number(r.amount || 0), 0);
});
const ultimoPago = computed(() => {
const pagos = records.value.filter((r) => r.paid_at);
if (!pagos.length) return null;
return [...pagos].sort((a, b) => new Date(b.paid_at) - new Date(a.paid_at))[0];
});
return {
records,
loading,
error,
load,
totalRecebido,
totalEmAberto,
totalAtrasado,
ultimoPago
};
}
@@ -0,0 +1,64 @@
/*
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Arquivo: src/features/patients/composables/usePatientMessages.js
|
| Mensagens de conversa do paciente. Carrega 200 mais recentes (in+out)
| pra alimentar o card "Ultimas mensagens" (Visao Geral, top 4) e os
| KPIs da aba Conversas. Conversa completa fica no PatientConversationsTab.
|--------------------------------------------------------------------------
*/
import { ref, computed } from 'vue';
import { supabase } from '@/lib/supabase/client';
export function usePatientMessages() {
const messages = ref([]);
const loading = ref(false);
const error = ref('');
async function load(patientId) {
if (!patientId) {
messages.value = [];
return;
}
loading.value = true;
error.value = '';
messages.value = [];
try {
const { data, error: err } = await supabase
.from('conversation_messages')
.select('id, body, direction, created_at, channel, kanban_status')
.eq('patient_id', patientId)
.order('created_at', { ascending: false })
.limit(200);
if (err) throw err;
messages.value = data || [];
} catch (e) {
error.value = e?.message || 'Falha ao carregar mensagens.';
messages.value = [];
} finally {
loading.value = false;
}
}
const recentes = computed(() => messages.value.slice(0, 4));
const totalIn = computed(() =>
messages.value.filter((m) => m.direction === 'in' || m.direction === 'inbound').length
);
const totalOut = computed(() =>
messages.value.filter((m) => m.direction === 'out' || m.direction === 'outbound').length
);
const ultimaMensagem = computed(() => messages.value[0] || null);
return {
messages,
loading,
error,
load,
recentes,
totalIn,
totalOut,
ultimaMensagem
};
}
@@ -0,0 +1,83 @@
/*
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Arquivo: src/features/patients/composables/usePatientSessions.js
|
| Carrega sessoes (agenda_eventos) do paciente. Limit 100 mais recentes
| ordenadas desc por inicio_em. Compativel com a logica original do
| PatientProntuario.vue.
|--------------------------------------------------------------------------
*/
import { ref, computed } from 'vue';
import { supabase } from '@/lib/supabase/client';
export function usePatientSessions() {
const sessions = ref([]);
const loading = ref(false);
const error = ref('');
async function load(patientId) {
if (!patientId) {
sessions.value = [];
return;
}
loading.value = true;
error.value = '';
sessions.value = [];
try {
const { data, error: err } = await supabase
.from('agenda_eventos')
.select('id, inicio_em, fim_em, status, modalidade, tipo, titulo, titulo_custom, observacoes')
.eq('patient_id', patientId)
.order('inicio_em', { ascending: false })
.limit(100);
if (err) throw err;
sessions.value = data || [];
} catch (e) {
error.value = e?.message || 'Falha ao carregar sessões.';
sessions.value = [];
} finally {
loading.value = false;
}
}
// Helpers derivados — proxima sessao agendada e status corrente
const proximaSessao = computed(() => {
const now = Date.now();
return [...sessions.value]
.filter((s) => s.inicio_em && new Date(s.inicio_em).getTime() >= now)
.sort((a, b) => new Date(a.inicio_em) - new Date(b.inicio_em))[0] || null;
});
const ultimaSessao = computed(() => {
const now = Date.now();
return sessions.value
.filter((s) => s.inicio_em && new Date(s.inicio_em).getTime() < now)
.sort((a, b) => new Date(b.inicio_em) - new Date(a.inicio_em))[0] || null;
});
const totalSessoes = computed(() => sessions.value.length);
const totalRealizadas = computed(() =>
sessions.value.filter((s) => s.status === 'realizada' || s.status === 'presente').length
);
const totalFaltas = computed(() =>
sessions.value.filter((s) => s.status === 'falta').length
);
const totalCanceladas = computed(() =>
sessions.value.filter((s) => s.status === 'cancelada' || s.status === 'cancelado').length
);
return {
sessions,
loading,
error,
load,
proximaSessao,
ultimaSessao,
totalSessoes,
totalRealizadas,
totalFaltas,
totalCanceladas
};
}