MelissaPaciente Fase 2: Tab Visao Geral completa (4 KPIs + timeline + msgs + notas)

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) <noreply@anthropic.com>
This commit is contained in:
Leonardo
2026-05-08 09:31:36 -03:00
parent df61cc4d99
commit ab7526b8d7
5 changed files with 819 additions and 73 deletions
@@ -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 ? `${min} min` : `em ${min} min`;
const h = Math.round(min / 60);
if (h < 24) return past ? `${h} h` : `em ${h} h`;
const d = Math.round(h / 24);
if (d === 1) return past ? 'ontem' : 'amanhã';
if (d < 7) return past ? `${d} dias` : `em ${d} dias`;
const w = Math.round(d / 7);
if (w < 5) return past ? `${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' };
}