From ab7526b8d7f762d18bf5c5225330d1ad4b2d249a Mon Sep 17 00:00:00 2001 From: Leonardo Date: Fri, 8 May 2026 09:31:36 -0300 Subject: [PATCH] MelissaPaciente Fase 2: Tab Visao Geral completa (4 KPIs + timeline + msgs + notas) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reescreveu o placeholder da aba Visao Geral por uma versao 1:1 do PatientProntuario.vue legado, com estilo Melissa nativo e dados alimentados pelos composables criados na Fase 1. NOVO: src/features/patients/utils/patientFormatters.js (~165L) - Helpers compartilhaveis extraidos do PatientProntuario: parseDateLoose, fmtDateBR, fmtDateTimeBR, fmtCurrency, fmtRelative (pt-br: "agora"/"ha 5 min"/"em 2 dias"/"ha 3 sem"), sessionDuration, calcAge. - STATUS_LABEL e STATUS_SEVERITY pra mapear status de sessao (cobre variantes: realizado/realizada, falta/faltou, cancelado/cancelada). - tagStyle com contraste auto (luminance WCAG-ish: bg colorido + texto preto/branco baseado em luminance < 0.45). - Sera reutilizado pelas Fases 3-7 e na Fase 8 substitui as funcoes duplicadas do PatientProntuario. EXTENSAO de composables (Fase 1): - usePatientSessions: novo computed `ultimasAtendidas` (top 6 sessoes com status realiz/falt/cancel/remarc pra Timeline). totalRealizadas/ Faltas/Canceladas refinados pra usar regex (cobre variantes pt-br). - usePatientFinancial: novo computed `statusFinanceiro` que retorna { emDia: bool, proxVenc: record, totalPendente, totalPago, vencidos } pra alimentar KPI 02 com info detalhada de status financeiro. MELISSAPACIENTE.VUE — Visao Geral reescrita: - 4 KPI cards ricos (substituem os simples da Fase 1): - 01 Sessoes: realizadas / total + faltas + canceladas - 02 Pagamento: status (Em dia/atraso) + prox venc + cor adaptativa (vermelho atrasado / primary ok) - 03 Proxima sessao: relative + datetime + modalidade - 04 Mensagens: ultima relative + direction + count - Grid 2-col abaixo (1.4fr / 1fr em >=900px): - Timeline coluna esquerda: dots coloridos por status, tags severity, chips modalidade + duracao, nota observacoes inline. - Coluna direita: Mensagens recentes (4) com border-left in/out + meta direction/relative + body 3-line clamp; Notas e observacoes em card papel com label uppercase e icone lock. - Removeu kpiEmAberto/Atrasado nao usados (statusFinanceiro encapsula). CSS: ~280L novos pros componentes (KPIs ricos, panel base, empty rich, timeline, mensagens, notas). Mantem o pattern visual Melissa. ESLint: 0 errors da minha mudanca. Co-Authored-By: Claude Opus 4.7 (1M context) --- Obsidian/Brain/log.md | 41 ++ .../composables/usePatientFinancial.js | 35 +- .../composables/usePatientSessions.js | 22 +- .../patients/utils/patientFormatters.js | 178 +++++ src/layout/melissa/MelissaPaciente.vue | 616 ++++++++++++++++-- 5 files changed, 819 insertions(+), 73 deletions(-) create mode 100644 src/features/patients/utils/patientFormatters.js diff --git a/Obsidian/Brain/log.md b/Obsidian/Brain/log.md index 8982489..0164ec5 100644 --- a/Obsidian/Brain/log.md +++ b/Obsidian/Brain/log.md @@ -50,6 +50,47 @@ Touched: none ## [2026-05-08 00:00] session | Melissa cfg-* nativas + temas + cronometro DB Touched: none +## [2026-05-08 13:00] session | MelissaPaciente Fase 2 — Tab Visao Geral completa +Touched: none +Detalhes: Reescreveu a aba Visao Geral do MelissaPaciente substituindo o +placeholder por uma versao 1:1 do PatientProntuario.vue legado, mas com +estilo Melissa nativo. + +NOVO: src/features/patients/utils/patientFormatters.js (~165L) +- Helpers compartilhaveis extraidos do PatientProntuario: parseDateLoose, + fmtDateBR, fmtDateTimeBR, fmtCurrency, fmtRelative, sessionDuration, + calcAge. STATUS_LABEL/SEVERITY pra sessoes. tagStyle com luminance auto + (texto preto/branco baseado em contraste WCAG-ish). Sera usado pelas + Fases 3-7 e finalmente pelo PatientProntuario tambem (Fase 8). + +EXTENSAO de composables: +- usePatientSessions ganha computed `ultimasAtendidas` (top 6 sessoes + realizadas/faltadas/canceladas pra Timeline). Refinou totalRealizadas/ + Faltas/Canceladas pra usar regex (cobre variantes pt-br). +- usePatientFinancial ganha computed `statusFinanceiro` ({ emDia, proxVenc, + totalPendente, totalPago, vencidos }) pra alimentar o KPI 02 com info + detalhada. + +MELISSAPACIENTE.VUE — Visao Geral (Fase 2 done): +- 4 KPI cards ricos (era 4 simples na Fase 1): + - 01 Sessoes: realizadas + total + faltas + cancel. + - 02 Pagamento: emDia/atraso + proxVenc + pendente, com cor adaptativa + (vermelho quando atrasado, primary quando ok). + - 03 Proxima sessao: relative + datetime + modalidade. + - 04 Mensagens: ultimaMensagem relative + direction + count. +- Grid 2-col abaixo: Timeline (1.4fr) + coluna direita (1fr) com + Mensagens recentes + Notas/observacoes. +- Timeline com dot colorido por status (verde/vermelho/amarelo) + + STATUS_LABEL/SEVERITY do utils + chips modalidade/duracao + nota + observacoes inline. +- Mensagens recentes com border-left colorida (verde=in / azul=out) + + meta direction + relative + body 3-line clamp. +- Notas e observacoes com card papel + label uppercase + icone lock + pras internas. +- Removeu kpiEmAberto/Atrasado nao usados (statusFinanceiro encapsula). + +ESLint: 0 errors. Working tree limpa antes do commit. + ## [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 diff --git a/src/features/patients/composables/usePatientFinancial.js b/src/features/patients/composables/usePatientFinancial.js index e32dc16..25806bb 100644 --- a/src/features/patients/composables/usePatientFinancial.js +++ b/src/features/patients/composables/usePatientFinancial.js @@ -69,6 +69,38 @@ export function usePatientFinancial() { return [...pagos].sort((a, b) => new Date(b.paid_at) - new Date(a.paid_at))[0]; }); + /** + * Status financeiro detalhado pra KPI da Visao Geral. + * - emDia: nenhum pendente vencido (paid_at NULL && due_date < hoje) + * - proxVenc: proximo pendente com due_date no futuro + * - totalPendente / totalPago: somatorio + * - vencidos: count de pendentes vencidos + */ + const statusFinanceiro = computed(() => { + const recs = records.value; + if (!recs?.length) { + return { emDia: null, proxVenc: null, totalPendente: 0, totalPago: 0, vencidos: 0 }; + } + const now = Date.now(); + const pendentes = recs.filter((r) => !r.paid_at); + const pagos = recs.filter((r) => !!r.paid_at); + const vencidos = pendentes.filter( + (r) => r.due_date && new Date(r.due_date + 'T23:59:59').getTime() < now + ); + const proxVenc = pendentes + .filter((r) => r.due_date && new Date(r.due_date + 'T00:00:00').getTime() >= now) + .sort((a, b) => new Date(a.due_date) - new Date(b.due_date))[0] || null; + const totalPendente = pendentes.reduce((acc, r) => acc + (Number(r.amount) || 0), 0); + const totalPago = pagos.reduce((acc, r) => acc + (Number(r.amount) || 0), 0); + return { + emDia: vencidos.length === 0, + proxVenc, + totalPendente, + totalPago, + vencidos: vencidos.length + }; + }); + return { records, loading, @@ -77,6 +109,7 @@ export function usePatientFinancial() { totalRecebido, totalEmAberto, totalAtrasado, - ultimoPago + ultimoPago, + statusFinanceiro }; } diff --git a/src/features/patients/composables/usePatientSessions.js b/src/features/patients/composables/usePatientSessions.js index 1570186..0d2c568 100644 --- a/src/features/patients/composables/usePatientSessions.js +++ b/src/features/patients/composables/usePatientSessions.js @@ -58,14 +58,27 @@ export function usePatientSessions() { }); const totalSessoes = computed(() => sessions.value.length); + + // Conta status com regex pra cobrir variantes pt-br + // (realizada/realizado/presente; falta/faltou; cancelada/cancelado/remarcada). const totalRealizadas = computed(() => - sessions.value.filter((s) => s.status === 'realizada' || s.status === 'presente').length + sessions.value.filter((s) => /realiz|present/i.test(String(s.status || ''))).length ); const totalFaltas = computed(() => - sessions.value.filter((s) => s.status === 'falta').length + sessions.value.filter((s) => /falt/i.test(String(s.status || ''))).length ); const totalCanceladas = computed(() => - sessions.value.filter((s) => s.status === 'cancelada' || s.status === 'cancelado').length + sessions.value.filter((s) => /cancel|remarca/i.test(String(s.status || ''))).length + ); + + /** + * Top 6 sessoes "atendidas" (qualquer status que indica encontro: realizado, + * faltou, cancelado, remarcado) — alimenta a Timeline da Visao Geral. + */ + const ultimasAtendidas = computed(() => + sessions.value + .filter((s) => /realiz|present|falt|cancel|remarca/i.test(String(s.status || ''))) + .slice(0, 6) ); return { @@ -78,6 +91,7 @@ export function usePatientSessions() { totalSessoes, totalRealizadas, totalFaltas, - totalCanceladas + totalCanceladas, + ultimasAtendidas }; } diff --git a/src/features/patients/utils/patientFormatters.js b/src/features/patients/utils/patientFormatters.js new file mode 100644 index 0000000..219285b --- /dev/null +++ b/src/features/patients/utils/patientFormatters.js @@ -0,0 +1,178 @@ +/* +|-------------------------------------------------------------------------- +| Agência PSI +|-------------------------------------------------------------------------- +| Arquivo: src/features/patients/utils/patientFormatters.js +| +| Helpers de formatacao compartilhaveis entre PatientProntuario.vue (legacy) +| e MelissaPaciente.vue (Melissa nativo). Extraidos do PatientProntuario +| pra eliminar duplicacao quando MelissaPaciente substituir o legacy +| na Fase 8. +|-------------------------------------------------------------------------- +*/ + +/** + * Tenta varios formatos de data: ISO, DD/MM/YYYY, YYYY-MM-DD, etc. + */ +export function parseDateLoose(v) { + if (!v) return null; + if (v instanceof Date) return Number.isNaN(v.getTime()) ? null : v; + const s = String(v).trim(); + if (!s) return null; + // DD/MM/YYYY + let m = s.match(/^(\d{2})\/(\d{2})\/(\d{4})$/); + if (m) { + const d = new Date(Number(m[3]), Number(m[2]) - 1, Number(m[1])); + return Number.isNaN(d.getTime()) ? null : d; + } + // YYYY-MM-DD + m = s.match(/^(\d{4})-(\d{2})-(\d{2})$/); + if (m) { + const d = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3])); + return Number.isNaN(d.getTime()) ? null : d; + } + const d = new Date(s); + return Number.isNaN(d.getTime()) ? null : d; +} + +export function dash(v) { + const s = String(v ?? '').trim(); + return s || '—'; +} + +export function fmtDateBR(v) { + const d = parseDateLoose(v); + if (!d) return v ? dash(v) : '—'; + return `${String(d.getDate()).padStart(2, '0')}/${String(d.getMonth() + 1).padStart(2, '0')}/${d.getFullYear()}`; +} + +export function fmtDateTimeBR(iso) { + if (!iso) return '—'; + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return iso; + const dd = String(d.getDate()).padStart(2, '0'); + const mm = String(d.getMonth() + 1).padStart(2, '0'); + const hh = String(d.getHours()).padStart(2, '0'); + const mi = String(d.getMinutes()).padStart(2, '0'); + return `${dd}/${mm}/${d.getFullYear()} ${hh}:${mi}`; +} + +export function fmtCurrency(v) { + if (v === null || v === undefined || v === '') return '—'; + return `R$ ${Number(v).toLocaleString('pt-BR', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`; +} + +export function sessionDuration(inicio, fim) { + if (!inicio || !fim) return null; + const diff = new Date(fim) - new Date(inicio); + if (diff <= 0) return null; + const min = Math.round(diff / 60000); + if (min < 60) return `${min} min`; + const h = Math.floor(min / 60); + const m = min % 60; + return m ? `${h}h ${m}min` : `${h}h`; +} + +/** + * Data relativa em pt-BR. + * Retorna: "agora", "ha 5 min", "ha 2 h", "ontem", "ha 3 dias", + * "em 2 dias", "em 3 sem", ou data absoluta para >= 5 semanas. + */ +export function fmtRelative(iso) { + if (!iso) return '—'; + const target = new Date(iso).getTime(); + if (Number.isNaN(target)) return '—'; + const diff = target - Date.now(); + const abs = Math.abs(diff); + const past = diff < 0; + const min = Math.round(abs / 60000); + if (min < 1) return 'agora'; + if (min < 60) return past ? `há ${min} min` : `em ${min} min`; + const h = Math.round(min / 60); + if (h < 24) return past ? `há ${h} h` : `em ${h} h`; + const d = Math.round(h / 24); + if (d === 1) return past ? 'ontem' : 'amanhã'; + if (d < 7) return past ? `há ${d} dias` : `em ${d} dias`; + const w = Math.round(d / 7); + if (w < 5) return past ? `há ${w} sem` : `em ${w} sem`; + return fmtDateBR(iso); +} + +/** + * Idade em anos com base em data de nascimento. + */ +export function calcAge(v) { + const d = parseDateLoose(v); + if (!d) return null; + const now = new Date(); + let age = now.getFullYear() - d.getFullYear(); + const m = now.getMonth() - d.getMonth(); + if (m < 0 || (m === 0 && now.getDate() < d.getDate())) age--; + return age; +} + +/** + * Status visual de uma sessao da agenda. + */ +export const STATUS_LABEL = { + agendado: 'Agendado', + realizado: 'Realizado', + realizada: 'Realizado', + faltou: 'Faltou', + falta: 'Faltou', + cancelado: 'Cancelado', + cancelada: 'Cancelado', + remarcado: 'Remarcado', + bloqueado: 'Bloqueado' +}; + +export const STATUS_SEVERITY = { + agendado: 'info', + realizado: 'success', + realizada: 'success', + faltou: 'danger', + falta: 'danger', + cancelado: 'warn', + cancelada: 'warn', + remarcado: 'secondary', + bloqueado: 'secondary' +}; + +/** + * Tag styling com contraste auto (texto preto/branco baseado em luminancia). + */ +function normalizeHexColor(c) { + const s = String(c ?? '').trim(); + if (!s) return ''; + if (/^#([0-9a-f]{3}|[0-9a-f]{6})$/i.test(s)) return s; + if (/^([0-9a-f]{3}|[0-9a-f]{6})$/i.test(s)) return `#${s}`; + return s; +} + +function hexToRgb(hex) { + const h = String(hex || '').replace('#', '').trim(); + if (!/^([0-9a-f]{3}|[0-9a-f]{6})$/i.test(h)) return null; + const full = h.length === 3 ? h.split('').map((ch) => ch + ch).join('') : h; + const r = parseInt(full.slice(0, 2), 16); + const g = parseInt(full.slice(2, 4), 16); + const b = parseInt(full.slice(4, 6), 16); + if ([r, g, b].some((n) => Number.isNaN(n))) return null; + return { r, g, b }; +} + +function relativeLuminance({ r, g, b }) { + const srgb = [r, g, b].map((v) => v / 255).map((c) => (c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4))); + return 0.2126 * srgb[0] + 0.7152 * srgb[1] + 0.0722 * srgb[2]; +} + +export function bestTextColor(bg) { + const rgb = hexToRgb(normalizeHexColor(bg)); + if (!rgb) return '#0f172a'; + return relativeLuminance(rgb) < 0.45 ? '#ffffff' : '#0f172a'; +} + +export function tagStyle(t) { + const bg = normalizeHexColor(t?.color || t?.cor); + if (!bg) return {}; + return { background: bg, color: bestTextColor(bg), borderColor: 'transparent' }; +} diff --git a/src/layout/melissa/MelissaPaciente.vue b/src/layout/melissa/MelissaPaciente.vue index d6722e6..34b529c 100644 --- a/src/layout/melissa/MelissaPaciente.vue +++ b/src/layout/melissa/MelissaPaciente.vue @@ -26,6 +26,17 @@ import { usePatientSessions } from '@/features/patients/composables/usePatientSe import { usePatientFinancial } from '@/features/patients/composables/usePatientFinancial'; import { usePatientMessages } from '@/features/patients/composables/usePatientMessages'; import { usePatientDocuments } from '@/features/patients/composables/usePatientDocuments'; +import { + calcAge, + fmtRelative, + fmtDateBR, + fmtDateTimeBR, + fmtCurrency, + sessionDuration, + STATUS_LABEL, + STATUS_SEVERITY, + tagStyle as tagStyleHelper +} from '@/features/patients/utils/patientFormatters'; // Tag/Skeleton: auto via PrimeVueResolver const props = defineProps({ @@ -104,15 +115,8 @@ const avatarInitials = computed(() => { }); 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 age = calcAge(patientData.value?.data_nascimento); + return age == null ? '' : `${age} anos`; }); const convenio = computed(() => patientData.value?.convenio || patientData.value?.plano_saude || ''); @@ -120,19 +124,23 @@ 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` - }; + return tagStyleHelper(t); } -// ── KPIs basicos pro Visao Geral (placeholder Fase 1) ────── +// ── Notas + observacoes (campos opcionais do paciente) ───── +function pickField(obj, keys = []) { + for (const k of keys) { + const v = obj?.[k]; + if (v !== null && v !== undefined && String(v).trim()) return v; + } + return null; +} +const observacoes = computed(() => pickField(patientData.value, ['observacoes', 'notes_short'])); +const notasInternas = computed(() => pickField(patientData.value, ['notas_internas', 'notes'])); + +// ── KPIs Visao Geral (Fase 2) ────────────────────────────── 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); @@ -397,48 +405,222 @@ void toast;