From df61cc4d995036be3f5cd67b6c88c1a13d499d8b Mon Sep 17 00:00:00 2001 From: Leonardo Date: Fri, 8 May 2026 09:23:48 -0300 Subject: [PATCH] MelissaPaciente Fase 1: foundation (5 composables + skeleton 7 tabs + slug paciente) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- Obsidian/Brain/log.md | 53 + .../patients/composables/usePatientDetail.js | 108 ++ .../composables/usePatientDocuments.js | 70 + .../composables/usePatientFinancial.js | 82 ++ .../composables/usePatientMessages.js | 64 + .../composables/usePatientSessions.js | 83 ++ src/layout/melissa/MelissaLayout.vue | 19 +- src/layout/melissa/MelissaPaciente.vue | 1190 +++++++++++++++++ 8 files changed, 1668 insertions(+), 1 deletion(-) create mode 100644 src/features/patients/composables/usePatientDetail.js create mode 100644 src/features/patients/composables/usePatientDocuments.js create mode 100644 src/features/patients/composables/usePatientFinancial.js create mode 100644 src/features/patients/composables/usePatientMessages.js create mode 100644 src/features/patients/composables/usePatientSessions.js create mode 100644 src/layout/melissa/MelissaPaciente.vue diff --git a/Obsidian/Brain/log.md b/Obsidian/Brain/log.md index 060f773..8982489 100644 --- a/Obsidian/Brain/log.md +++ b/Obsidian/Brain/log.md @@ -50,6 +50,59 @@ Touched: none ## [2026-05-08 00:00] session | Melissa cfg-* nativas + temas + cronometro DB Touched: none +## [2026-05-08 11:30] session | MelissaPaciente Fase 1 (foundation: composables + skeleton + slug) +Touched: none (sem mudanca de wiki) +Detalhes: User escolheu "Full rewrite Melissa nativo" pra portar +PatientProntuario.vue (3593L Dialog) pro Melissa. Plano em 8 fases (2-8 sao +cada tab/wireup, sessao dedicada). Fase 1 entregue: + +5 COMPOSABLES NOVOS em src/features/patients/composables/: +- usePatientDetail.js (108L) — patients + groups + tags via 4 queries + (getPatientById, getPatientRelations, getGroupsByIds, getTagsByIds). + Espelha 1:1 a logica de loadDetail() do PatientProntuario L893-953. +- usePatientSessions.js (83L) — agenda_eventos limit 100 ordenado desc + + computeds proximaSessao/ultimaSessao/totalSessoes/totalRealizadas/ + totalFaltas/totalCanceladas. +- usePatientFinancial.js (82L) — financial_records (type=receita) limit 100 + + computeds totalRecebido/EmAberto/Atrasado/ultimoPago. +- usePatientMessages.js (64L) — conversation_messages limit 200 + computeds + recentes (top 4)/totalIn/totalOut/ultimaMensagem. +- usePatientDocuments.js (70L) — documents (deleted_at IS NULL) limit 200 + + computeds total/totalBytes/tiposCount/ultimo. + +MELISSAPACIENTE.VUE NOVO (1190L) em src/layout/melissa/: +- Prefixo CSS .mpa-* (Melissa PAciente). Chrome glass + drawer mobile + + right: max(...) >=1024px (mesmo padrao MelissaAgendador/Negocio). +- Header: avatar + nome + ageLabel + pronomes + status/convenio Tag + + 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 contém 4 cards: Acoes Rapidas (Conversar/Editar/Lancamento) + + Navegacao (7 tabs com icones coloridos) + Sub-nav Perfil (visivel so + quando aba Perfil ativa, 6 secoes) + Vinculos (chips de grupos+tags). +- Main: 7 tabs com placeholders ("Em desenvolvimento — Fase X"). Aba + Visao Geral ja mostra 4 KPIs reais via composables (sessoes totais, + em aberto, mensagens, documentos). +- Props :patient-id; emits close/edit/add-financial/open-whatsapp. +- Watch immediate em props.patientId, dispara loadAll() via Promise.all + dos 5 composables. + +MELISSALAYOUT.VUE atualizado: +- Import MelissaPaciente. +- SECOES.paciente entry novo (label/icon/descricao). +- 'paciente' adicionado ao MELISSA_NON_CONFIG_SLUGS. +- Render condicional com :patient-id="String(route.query.id || '')" — + navegacao via /melissa/paciente?id=xxx. + +NAO ALTERADO: PatientProntuario.vue continua intocado nos 4 callsites +(TherapistDashboard, MelissaAgenda, MelissaPacientes, PatientsListPage). +Migration acontece nas Fases 2-8. Fase 8 troca os callsites no Melissa. + +ESLint: 0 errors da minha mudanca. 2 errors pre-existentes em MelissaLayout +(duplicate key 'financeiro' L242, empty block L1130) — nao toquei aquelas +linhas. PatientProntuario tem 2 outros pre-existentes. Working tree: +MelissaLayout.vue + 6 arquivos novos. + ## [2026-05-08 09:30] session | Chrome+preview em 7 paginas Melissa (LinkExterno preview novo) Touched: none (sem mudanca de wiki - aplicacao do pattern existente) Detalhes: Aplicou o chrome `right: max(6px, min(50%, calc(100% - 1006px)))` diff --git a/src/features/patients/composables/usePatientDetail.js b/src/features/patients/composables/usePatientDetail.js new file mode 100644 index 0000000..4fee06f --- /dev/null +++ b/src/features/patients/composables/usePatientDetail.js @@ -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 + }; +} diff --git a/src/features/patients/composables/usePatientDocuments.js b/src/features/patients/composables/usePatientDocuments.js new file mode 100644 index 0000000..873aeac --- /dev/null +++ b/src/features/patients/composables/usePatientDocuments.js @@ -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 + }; +} diff --git a/src/features/patients/composables/usePatientFinancial.js b/src/features/patients/composables/usePatientFinancial.js new file mode 100644 index 0000000..e32dc16 --- /dev/null +++ b/src/features/patients/composables/usePatientFinancial.js @@ -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 + }; +} diff --git a/src/features/patients/composables/usePatientMessages.js b/src/features/patients/composables/usePatientMessages.js new file mode 100644 index 0000000..543e87c --- /dev/null +++ b/src/features/patients/composables/usePatientMessages.js @@ -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 + }; +} diff --git a/src/features/patients/composables/usePatientSessions.js b/src/features/patients/composables/usePatientSessions.js new file mode 100644 index 0000000..1570186 --- /dev/null +++ b/src/features/patients/composables/usePatientSessions.js @@ -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 + }; +} diff --git a/src/layout/melissa/MelissaLayout.vue b/src/layout/melissa/MelissaLayout.vue index f22826e..da0200d 100644 --- a/src/layout/melissa/MelissaLayout.vue +++ b/src/layout/melissa/MelissaLayout.vue @@ -39,6 +39,7 @@ import MelissaTags from './MelissaTags.vue'; import MelissaGrupos from './MelissaGrupos.vue'; import MelissaConfiguracoes from './MelissaConfiguracoes.vue'; import MelissaPerfil from './MelissaPerfil.vue'; +import MelissaPaciente from './MelissaPaciente.vue'; import MelissaPlano from './MelissaPlano.vue'; import MelissaNegocio from './MelissaNegocio.vue'; import MelissaAlterarPlano from './MelissaAlterarPlano.vue'; @@ -199,6 +200,11 @@ const SECOES = { grupos: { label: 'Grupos de pacientes', icon: 'pi pi-th-large', descricao: 'Categorize pacientes por grupos.' }, tags: { label: 'Tags', icon: 'pi pi-tag', descricao: 'Etiquetas livres pra organizar pacientes.' }, medicos: { label: 'Médicos e referências', icon: 'pi pi-user-edit', descricao: 'Profissionais que encaminham ou recebem pacientes.' }, + // Pagina nativa do prontuario do paciente (MelissaPaciente) — Fase 1 foundation. + // ID do paciente vem via route.query.id (?id=xxx). Substitui gradualmente o + // PatientProntuario.vue legado (3593L Dialog) que continua nos 4 callsites + // ate Fase 8 (wire-up final). + paciente: { label: 'Paciente', icon: 'pi pi-user', descricao: 'Prontuario completo: visao geral, perfil, sessoes, financeiro, documentos, conversas.' }, // Pagina unificada do Layout Melissa (Aparencia + Fundo + Relogio + Cronometro) aparencia: { label: 'Layout Melissa', icon: 'pi pi-palette', descricao: 'Aparência, plano de fundo, relógio e cronômetro do resumo.' }, // Pagina nativa do perfil (MelissaPerfil) — saiu do MelissaConfiguracoes @@ -258,7 +264,7 @@ const MELISSA_EMBED_KEYS = []; // conversas etc. + as MELISSA_EMBED_KEYS. Usado no isConfigRoute pra // evitar colisão (ex: /melissa/agenda → MelissaAgenda, não config). const MELISSA_NON_CONFIG_SLUGS = new Set([ - 'agenda', 'pacientes', 'compromissos', 'recorrencias', 'conversas', + 'agenda', 'pacientes', 'paciente', 'compromissos', 'recorrencias', 'conversas', 'tags', 'grupos', 'cadastros-recebidos', 'medicos', 'agendamentos-recebidos', 'link-externo', 'notificacoes', 'financeiro', 'financeiro-lancamentos', 'documentos', 'documentos-templates', 'relatorios', @@ -1859,6 +1865,17 @@ function onKeydown(e) { @close="fecharSecao" /> + + + +/* + * MelissaPaciente — Pagina nativa Melissa pra Prontuario do Paciente. + * + * FASE 1 (foundation): chrome glass + drawer mobile + sidebar (avatar + + * acoes + nav 7 tabs) + main com 7 abas placeholders. Os composables + * de dados (usePatientDetail/Sessions/Financial/Messages/Documents) ja + * carregam o paciente quando :patientId muda. + * + * Fase 1 NAO substitui ainda o PatientProntuario.vue legado (3593L) — + * os 4 callsites continuam funcionando. Vai sendo migrado nas fases + * 2-8 (cada tab uma sessao dedicada). + * + * Layout: + * - sidebar (320px, ESQUERDA, drawer no mobile) — header avatar + + * acoes rapidas + nav 7 tabs + sub-nav perfil + * - main (flex 1) — tab content + * + * Prefixo CSS: .mpa-* (Melissa PAciente). + */ +import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'; +import { useToast } from 'primevue/usetoast'; +import MelissaConfigList from './MelissaConfigList.vue'; +import { usePatientDetail } from '@/features/patients/composables/usePatientDetail'; +import { usePatientSessions } from '@/features/patients/composables/usePatientSessions'; +import { usePatientFinancial } from '@/features/patients/composables/usePatientFinancial'; +import { usePatientMessages } from '@/features/patients/composables/usePatientMessages'; +import { usePatientDocuments } from '@/features/patients/composables/usePatientDocuments'; +// Tag/Skeleton: auto via PrimeVueResolver + +const props = defineProps({ + patientId: { type: String, default: '' } +}); +const emit = defineEmits(['close', 'edit', 'add-financial', 'open-whatsapp']); + +const toast = useToast(); + +// ── Composables de dados ─────────────────────────────────── +const detail = usePatientDetail(); +const sessionsHook = usePatientSessions(); +const financialHook = usePatientFinancial(); +const messagesHook = usePatientMessages(); +const documentsHook = usePatientDocuments(); + +// ── Breakpoints + drawer ─────────────────────────────────── +const drawerOpen = ref(false); +const isMobile = ref(false); +let _mqMobile = null; +function _onMqMobileChange(e) { + isMobile.value = e.matches; + if (!e.matches) drawerOpen.value = false; +} +function toggleDrawer() { drawerOpen.value = !drawerOpen.value; } +function fecharDrawer() { drawerOpen.value = false; } + +// Toggle entre cards (default) e lista de configs +const cfgOpen = ref(false); +function toggleCfg() { cfgOpen.value = !cfgOpen.value; } +function fecharCfg() { cfgOpen.value = false; } + +// ── Tabs ─────────────────────────────────────────────────── +const TABS = [ + { key: 'overview', label: 'Visão Geral', icon: 'pi pi-chart-line', color: '#a855f7' }, + { key: 'perfil', label: 'Perfil', icon: 'pi pi-user', color: '#3b82f6' }, + { key: 'pron', label: 'Prontuário', icon: 'pi pi-file-edit', color: '#06b6d4' }, + { key: 'agenda', label: 'Agenda', icon: 'pi pi-calendar', color: '#10b981' }, + { key: 'financ', label: 'Financeiro', icon: 'pi pi-wallet', color: '#f59e0b' }, + { key: 'doc', label: 'Documentos', icon: 'pi pi-folder', color: '#f97316' }, + { key: 'conv', label: 'Conversas', icon: 'pi pi-whatsapp', color: '#22c55e' } +]; +const activeTab = ref('overview'); +function selectTab(key) { + activeTab.value = key; + if (isMobile.value) drawerOpen.value = false; +} + +// Sub-nav da aba Perfil +const PROFILE_SECTIONS = [ + { key: 'pessoais', label: 'Informações Pessoais', icon: 'pi pi-pencil' }, + { key: 'endereco', label: 'Endereço', icon: 'pi pi-map-marker' }, + { key: 'adicional', label: 'Dados Adicionais', icon: 'pi pi-tags' }, + { key: 'resp', label: 'Responsável', icon: 'pi pi-users' }, + { key: 'anot', label: 'Anotações', icon: 'pi pi-file-edit' }, + { key: 'sess', label: 'Sessões', icon: 'pi pi-calendar' } +]; +const activeProfileSection = ref('pessoais'); + +// ── Helpers de exibicao ──────────────────────────────────── +function dash(v) { + const s = String(v ?? '').trim(); + return s || '—'; +} + +const patientData = computed(() => detail.patient.value || {}); +const nomeCompleto = computed(() => patientData.value?.nome_completo || patientData.value?.nome || 'Paciente'); +const avatarUrl = computed(() => + patientData.value?.avatar_url || patientData.value?.avatar || null +); +const avatarInitials = computed(() => { + const parts = String(nomeCompleto.value || '').trim().split(/\s+/).filter(Boolean); + if (!parts.length) return '·'; + if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase(); + return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase(); +}); + +const ageLabel = computed(() => { + const dob = patientData.value?.data_nascimento; + if (!dob) return ''; + const birth = new Date(dob); + if (Number.isNaN(birth.getTime())) return ''; + const now = new Date(); + let age = now.getFullYear() - birth.getFullYear(); + const m = now.getMonth() - birth.getMonth(); + if (m < 0 || (m === 0 && now.getDate() < birth.getDate())) age--; + return `${age} anos`; +}); + +const convenio = computed(() => patientData.value?.convenio || patientData.value?.plano_saude || ''); +const statusPaciente = computed(() => patientData.value?.status || ''); +const riscoElevado = computed(() => !!patientData.value?.risco_elevado); + +function tagStyle(t) { + if (!t?.color) return {}; + return { + background: `${t.color}22`, + color: t.color, + border: `1px solid ${t.color}44` + }; +} + +// ── KPIs basicos pro Visao Geral (placeholder Fase 1) ────── +const kpiSessoes = computed(() => sessionsHook.totalSessoes.value); +const kpiRealizadas = computed(() => sessionsHook.totalRealizadas.value); +const kpiEmAberto = computed(() => financialHook.totalEmAberto.value); +const kpiAtrasado = computed(() => financialHook.totalAtrasado.value); +const kpiMensagens = computed(() => messagesHook.messages.value.length); +const kpiDocumentos = computed(() => documentsHook.total.value); + +// ── Acoes ────────────────────────────────────────────────── +function close() { emit('close'); } +function editPatient() { emit('edit', props.patientId); } +function addFinancial() { emit('add-financial', props.patientId); } +function openWhatsapp() { emit('open-whatsapp', props.patientId); } + +// ── Load data quando patientId muda ──────────────────────── +async function loadAll(id) { + if (!id) return; + await Promise.all([ + detail.load(id), + sessionsHook.load(id), + financialHook.load(id), + messagesHook.load(id), + documentsHook.load(id) + ]); +} + +watch(() => props.patientId, async (id) => { + activeTab.value = 'overview'; + activeProfileSection.value = 'pessoais'; + await loadAll(id); +}, { immediate: true }); + +// ── Lifecycle (matchMedia) ───────────────────────────────── +onMounted(() => { + if (typeof window !== 'undefined' && window.matchMedia) { + _mqMobile = window.matchMedia('(max-width: 1023px)'); + isMobile.value = _mqMobile.matches; + try { _mqMobile.addEventListener('change', _onMqMobileChange); } + catch { _mqMobile.addListener(_onMqMobileChange); } + } +}); + +onBeforeUnmount(() => { + if (_mqMobile) { + try { _mqMobile.removeEventListener('change', _onMqMobileChange); } + catch { _mqMobile.removeListener(_onMqMobileChange); } + } +}); + +// Suprime "unused" do toast (vai ser usado nas Fases 2+) +void toast; + + + + +