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