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
+41
View File
@@ -50,6 +50,47 @@ Touched: none
## [2026-05-08 00:00] session | Melissa cfg-* nativas + temas + cronometro DB ## [2026-05-08 00:00] session | Melissa cfg-* nativas + temas + cronometro DB
Touched: none 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) ## [2026-05-08 11:30] session | MelissaPaciente Fase 1 (foundation: composables + skeleton + slug)
Touched: none (sem mudanca de wiki) Touched: none (sem mudanca de wiki)
Detalhes: User escolheu "Full rewrite Melissa nativo" pra portar Detalhes: User escolheu "Full rewrite Melissa nativo" pra portar
@@ -69,6 +69,38 @@ export function usePatientFinancial() {
return [...pagos].sort((a, b) => new Date(b.paid_at) - new Date(a.paid_at))[0]; 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 { return {
records, records,
loading, loading,
@@ -77,6 +109,7 @@ export function usePatientFinancial() {
totalRecebido, totalRecebido,
totalEmAberto, totalEmAberto,
totalAtrasado, totalAtrasado,
ultimoPago ultimoPago,
statusFinanceiro
}; };
} }
@@ -58,14 +58,27 @@ export function usePatientSessions() {
}); });
const totalSessoes = computed(() => sessions.value.length); 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(() => 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(() => 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(() => 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 { return {
@@ -78,6 +91,7 @@ export function usePatientSessions() {
totalSessoes, totalSessoes,
totalRealizadas, totalRealizadas,
totalFaltas, 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 ? `${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' };
}
+548 -68
View File
@@ -26,6 +26,17 @@ import { usePatientSessions } from '@/features/patients/composables/usePatientSe
import { usePatientFinancial } from '@/features/patients/composables/usePatientFinancial'; import { usePatientFinancial } from '@/features/patients/composables/usePatientFinancial';
import { usePatientMessages } from '@/features/patients/composables/usePatientMessages'; import { usePatientMessages } from '@/features/patients/composables/usePatientMessages';
import { usePatientDocuments } from '@/features/patients/composables/usePatientDocuments'; 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 // Tag/Skeleton: auto via PrimeVueResolver
const props = defineProps({ const props = defineProps({
@@ -104,15 +115,8 @@ const avatarInitials = computed(() => {
}); });
const ageLabel = computed(() => { const ageLabel = computed(() => {
const dob = patientData.value?.data_nascimento; const age = calcAge(patientData.value?.data_nascimento);
if (!dob) return ''; return age == null ? '' : `${age} anos`;
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 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); const riscoElevado = computed(() => !!patientData.value?.risco_elevado);
function tagStyle(t) { function tagStyle(t) {
if (!t?.color) return {}; return tagStyleHelper(t);
return {
background: `${t.color}22`,
color: t.color,
border: `1px solid ${t.color}44`
};
} }
// ── 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 kpiSessoes = computed(() => sessionsHook.totalSessoes.value);
const kpiRealizadas = computed(() => sessionsHook.totalRealizadas.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 kpiMensagens = computed(() => messagesHook.messages.value.length);
const kpiDocumentos = computed(() => documentsHook.total.value); const kpiDocumentos = computed(() => documentsHook.total.value);
@@ -397,48 +405,222 @@ void toast;
<!-- Conteudo de cada aba --> <!-- Conteudo de cada aba -->
<template v-else> <template v-else>
<!-- ABA: Visao Geral (KPIs simples ja funcionam Fase 1) --> <!-- ABA: Visao Geral (Fase 2 KPIs + Timeline + Mensagens + Notas) -->
<div v-if="activeTab === 'overview'" class="mpa-tab"> <div v-if="activeTab === 'overview'" class="mpa-tab">
<!-- 4 KPI cards -->
<div class="mpa-kpis"> <div class="mpa-kpis">
<article class="mpa-kpi" style="--c:#10b981"> <!-- 01 Sessoes -->
<div class="mpa-kpi__icon"><i class="pi pi-calendar" /></div> <article class="mpa-kpi" style="--c:#4ade80">
<div class="mpa-kpi__value">{{ kpiSessoes }}</div> <span class="mpa-kpi__num">01</span>
<div class="mpa-kpi__label">Sessões totais</div> <header class="mpa-kpi__head">
<div class="mpa-kpi__sub">{{ kpiRealizadas }} realizadas</div> <div class="mpa-kpi__icon"><i class="pi pi-check-circle" /></div>
</article> <span class="mpa-kpi__tag">Sessões</span>
<article class="mpa-kpi" style="--c:#f59e0b"> </header>
<div class="mpa-kpi__icon"><i class="pi pi-wallet" /></div> <div class="mpa-kpi__big">{{ kpiRealizadas }}</div>
<div class="mpa-kpi__value">R$ {{ kpiEmAberto.toFixed(2) }}</div> <div class="mpa-kpi__cap">
<div class="mpa-kpi__label">Em aberto</div> de {{ kpiSessoes }} totais
<div v-if="kpiAtrasado > 0" class="mpa-kpi__sub mpa-kpi__sub--alert"> <span v-if="sessionsHook.totalFaltas.value" class="mpa-kpi__cap-warn">
R$ {{ kpiAtrasado.toFixed(2) }} atrasado · {{ sessionsHook.totalFaltas.value }}
{{ sessionsHook.totalFaltas.value === 1 ? 'falta' : 'faltas' }}
</span>
<span v-if="sessionsHook.totalCanceladas.value" class="mpa-kpi__cap-dim">
· {{ sessionsHook.totalCanceladas.value }} cancel.
</span>
</div> </div>
</article> </article>
<article class="mpa-kpi" style="--c:#22c55e">
<div class="mpa-kpi__icon"><i class="pi pi-whatsapp" /></div> <!-- 02 Pagamento -->
<div class="mpa-kpi__value">{{ kpiMensagens }}</div> <article
<div class="mpa-kpi__label">Mensagens trocadas</div> class="mpa-kpi"
:style="financialHook.statusFinanceiro.value.emDia === false
? '--c:#f87171'
: '--c:var(--p-primary-color)'"
>
<span class="mpa-kpi__num">02</span>
<header class="mpa-kpi__head">
<div class="mpa-kpi__icon"><i class="pi pi-wallet" /></div>
<span class="mpa-kpi__tag">Pagamento</span>
</header>
<template v-if="financialHook.loading.value">
<div class="mpa-kpi__big mpa-kpi__big--small"></div>
<div class="mpa-kpi__cap">Carregando</div>
</template>
<template v-else-if="!financialHook.records.value.length">
<div class="mpa-kpi__big mpa-kpi__big--small"></div>
<div class="mpa-kpi__cap">Sem lançamentos</div>
</template>
<template v-else>
<div class="mpa-kpi__big mpa-kpi__big--small">
{{ financialHook.statusFinanceiro.value.emDia === false ? 'Em atraso' : 'Em dia' }}
</div>
<div class="mpa-kpi__cap">
<template v-if="financialHook.statusFinanceiro.value.proxVenc">
Próx. venc. {{ fmtDateBR(financialHook.statusFinanceiro.value.proxVenc.due_date) }}
<span class="mpa-kpi__cap-dim">
· {{ fmtRelative(financialHook.statusFinanceiro.value.proxVenc.due_date) }}
</span>
</template>
<template v-else-if="financialHook.statusFinanceiro.value.totalPendente > 0">
{{ fmtCurrency(financialHook.statusFinanceiro.value.totalPendente) }} pendente
</template>
<template v-else>Tudo quitado</template>
</div>
</template>
</article> </article>
<article class="mpa-kpi" style="--c:#f97316">
<div class="mpa-kpi__icon"><i class="pi pi-folder" /></div> <!-- 03 Proxima sessao -->
<div class="mpa-kpi__value">{{ kpiDocumentos }}</div> <article class="mpa-kpi" style="--c:#60a5fa">
<div class="mpa-kpi__label">Documentos</div> <span class="mpa-kpi__num">03</span>
<header class="mpa-kpi__head">
<div class="mpa-kpi__icon"><i class="pi pi-calendar-clock" /></div>
<span class="mpa-kpi__tag">Próxima</span>
</header>
<template v-if="sessionsHook.proximaSessao.value">
<div class="mpa-kpi__big mpa-kpi__big--small">
{{ fmtRelative(sessionsHook.proximaSessao.value.inicio_em) }}
</div>
<div class="mpa-kpi__cap">
{{ fmtDateTimeBR(sessionsHook.proximaSessao.value.inicio_em) }}
<span v-if="sessionsHook.proximaSessao.value.modalidade" class="mpa-kpi__cap-dim">
· {{ sessionsHook.proximaSessao.value.modalidade === 'online' ? 'Online' : 'Presencial' }}
</span>
</div>
</template>
<template v-else>
<div class="mpa-kpi__big mpa-kpi__big--small"></div>
<div class="mpa-kpi__cap">Sem sessão futura agendada</div>
</template>
</article>
<!-- 04 Mensagens -->
<article class="mpa-kpi" style="--c:#34d399">
<span class="mpa-kpi__num">04</span>
<header class="mpa-kpi__head">
<div class="mpa-kpi__icon"><i class="pi pi-comments" /></div>
<span class="mpa-kpi__tag">Mensagens</span>
</header>
<template v-if="messagesHook.loading.value">
<div class="mpa-kpi__big mpa-kpi__big--small"></div>
<div class="mpa-kpi__cap">Carregando</div>
</template>
<template v-else-if="messagesHook.ultimaMensagem.value">
<div class="mpa-kpi__big mpa-kpi__big--small">
{{ fmtRelative(messagesHook.ultimaMensagem.value.created_at) }}
</div>
<div class="mpa-kpi__cap">
Última {{ messagesHook.ultimaMensagem.value.direction === 'inbound' ? 'recebida' : 'enviada' }}
<span class="mpa-kpi__cap-dim">· {{ kpiMensagens }} no histórico</span>
</div>
</template>
<template v-else>
<div class="mpa-kpi__big mpa-kpi__big--small"></div>
<div class="mpa-kpi__cap">Nenhuma conversa registrada</div>
</template>
</article> </article>
</div> </div>
<div class="mpa-w"> <!-- Grid 2-col: Timeline + Mensagens/Notas -->
<div class="mpa-w__head"> <div class="mpa-grid">
<div class="mpa-w__icon"><i class="pi pi-info-circle" /></div> <!-- Timeline -->
<div class="mpa-w__title"> <section class="mpa-panel">
<div class="mpa-w__title-text">Visão Geral Fase 1</div> <header class="mpa-panel__head">
<div class="mpa-w__sub">KPIs básicos funcionam. Timeline + cards ricos: Fase 2.</div> <div class="mpa-panel__title"><i class="pi pi-history" /> Timeline de atendimentos</div>
<span class="mpa-panel__badge">{{ sessionsHook.ultimasAtendidas.value.length }}</span>
</header>
<div class="mpa-panel__body">
<div v-if="sessionsHook.loading.value" class="mpa-empty">Carregando</div>
<div v-else-if="!sessionsHook.ultimasAtendidas.value.length" class="mpa-empty mpa-empty--rich">
<div class="mpa-empty__icon"><i class="pi pi-history" /></div>
<div class="mpa-empty__title">Sem atendimentos registrados</div>
<div class="mpa-empty__sub">As sessões realizadas aparecerão aqui em ordem cronológica.</div>
</div>
<ol v-else class="mpa-timeline">
<li
v-for="s in sessionsHook.ultimasAtendidas.value"
:key="s.id"
class="mpa-tl"
:data-status="String(s.status || 'agendado').toLowerCase()"
>
<span class="mpa-tl__dot" />
<div class="mpa-tl__main">
<div class="mpa-tl__top">
<span class="mpa-tl__when">{{ fmtDateTimeBR(s.inicio_em) }}</span>
<span class="mpa-tl__rel">{{ fmtRelative(s.inicio_em) }}</span>
</div>
<div class="mpa-tl__row">
<Tag
:value="STATUS_LABEL[s.status] || s.status || '—'"
:severity="STATUS_SEVERITY[s.status] || 'info'"
class="mpa-tl__tag"
/>
<span v-if="s.modalidade" class="mpa-tl__chip">
<i :class="s.modalidade === 'online' ? 'pi pi-video' : 'pi pi-map-marker'" />
{{ s.modalidade === 'online' ? 'Online' : 'Presencial' }}
</span>
<span v-if="sessionDuration(s.inicio_em, s.fim_em)" class="mpa-tl__chip mpa-tl__chip--dim">
<i class="pi pi-clock" />
{{ sessionDuration(s.inicio_em, s.fim_em) }}
</span>
</div>
<p v-if="s.observacoes" class="mpa-tl__note">{{ s.observacoes }}</p>
</div>
</li>
</ol>
</div> </div>
</div> </section>
<div class="mpa-w__body">
<p class="mpa-placeholder"> <!-- Coluna direita: Mensagens + Notas -->
Esta aba será expandida na <strong>Fase 2</strong> com timeline de últimas <div class="mpa-grid__col">
interações, próxima sessão, alertas e cards de últimas mensagens/lançamentos. <!-- Mensagens recentes -->
</p> <section class="mpa-panel">
<header class="mpa-panel__head">
<div class="mpa-panel__title"><i class="pi pi-comments" /> Últimas mensagens</div>
<span class="mpa-panel__badge">{{ kpiMensagens }}</span>
</header>
<div class="mpa-panel__body">
<div v-if="messagesHook.loading.value" class="mpa-empty">Carregando</div>
<div v-else-if="!messagesHook.recentes.value.length" class="mpa-empty mpa-empty--rich">
<div class="mpa-empty__icon"><i class="pi pi-comments" /></div>
<div class="mpa-empty__title">Sem conversa registrada</div>
<div class="mpa-empty__sub">As últimas mensagens aparecerão aqui.</div>
</div>
<ul v-else class="mpa-msgs">
<li
v-for="m in messagesHook.recentes.value"
:key="m.id"
class="mpa-msg"
:class="m.direction === 'inbound' ? 'mpa-msg--in' : 'mpa-msg--out'"
>
<div class="mpa-msg__meta">
<i :class="m.direction === 'inbound' ? 'pi pi-arrow-down-left' : 'pi pi-arrow-up-right'" />
<span>{{ m.direction === 'inbound' ? 'Recebida' : 'Enviada' }}</span>
<span class="mpa-msg__sep">·</span>
<span>{{ fmtRelative(m.created_at) }}</span>
</div>
<p class="mpa-msg__body">{{ m.body || '—' }}</p>
</li>
</ul>
</div>
</section>
<!-- Notas e observacoes -->
<section v-if="notasInternas || observacoes" class="mpa-panel">
<header class="mpa-panel__head">
<div class="mpa-panel__title"><i class="pi pi-file-edit" /> Notas e observações</div>
</header>
<div class="mpa-panel__body mpa-notes">
<div v-if="observacoes" class="mpa-note">
<p class="mpa-note__label">Observações gerais</p>
<p class="mpa-note__text">{{ observacoes }}</p>
</div>
<div v-if="notasInternas" class="mpa-note">
<p class="mpa-note__label">
<i class="pi pi-lock" /> Internas
</p>
<p class="mpa-note__text">{{ notasInternas }}</p>
</div>
</div>
</section>
</div> </div>
</div> </div>
</div> </div>
@@ -1019,62 +1201,360 @@ void toast;
border-color: color-mix(in srgb, var(--p-primary-color) 28%, transparent); border-color: color-mix(in srgb, var(--p-primary-color) 28%, transparent);
} }
/* ═══════ KPIs (Visao Geral Fase 1) ═══════ */ /* ═══════ KPIs (Visao Geral Fase 2 — 4 cards ricos) ═══════ */
.mpa-kpis { .mpa-kpis {
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 12px; gap: 12px;
} }
.mpa-kpi { .mpa-kpi {
position: relative; position: relative;
padding: 14px; padding: 14px 14px 12px;
border-radius: 12px; border-radius: 12px;
background: var(--m-bg-soft); background: var(--m-bg-soft);
border: 1px solid var(--m-border); border: 1px solid color-mix(in srgb, var(--c, #6366f1) 28%, var(--m-border));
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
overflow: hidden; overflow: hidden;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 4px; gap: 4px;
min-height: 110px;
} }
.mpa-kpi::before { .mpa-kpi::before {
content: ''; content: '';
position: absolute; position: absolute;
inset: 0; inset: 0;
background: linear-gradient(135deg, var(--c, #6366f1) 0%, transparent 60%); background: linear-gradient(135deg, var(--c, #6366f1) 0%, transparent 65%);
opacity: 0.08; opacity: 0.10;
pointer-events: none; pointer-events: none;
} }
.mpa-kpi__num {
position: absolute;
top: 8px;
right: 14px;
font-family: 'JetBrains Mono', ui-monospace, monospace;
font-size: 0.66rem;
color: var(--c, #6366f1);
opacity: 0.45;
font-weight: 800;
pointer-events: none;
}
.mpa-kpi__head {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
}
.mpa-kpi__icon { .mpa-kpi__icon {
width: 32px; width: 32px;
height: 32px; height: 32px;
display: grid; display: grid;
place-items: center; place-items: center;
border-radius: 8px; border-radius: 8px;
background: color-mix(in srgb, var(--c, #6366f1) 15%, transparent); background: color-mix(in srgb, var(--c, #6366f1) 16%, transparent);
color: var(--c, #6366f1); color: var(--c, #6366f1);
margin-bottom: 4px; flex-shrink: 0;
} }
.mpa-kpi__icon > i { font-size: 0.85rem; } .mpa-kpi__icon > i { font-size: 0.85rem; }
.mpa-kpi__value { .mpa-kpi__tag {
font-size: 1.4rem; font-size: 0.66rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--m-text-muted);
}
.mpa-kpi__big {
font-size: 1.6rem;
font-weight: 800; font-weight: 800;
color: var(--m-text); color: var(--m-text);
line-height: 1.1; line-height: 1.05;
letter-spacing: -0.02em; letter-spacing: -0.02em;
margin-top: auto;
} }
.mpa-kpi__label { .mpa-kpi__big--small { font-size: 1.1rem; }
font-size: 0.78rem; .mpa-kpi__cap {
font-size: 0.74rem;
color: var(--m-text-muted); color: var(--m-text-muted);
margin-top: 4px;
line-height: 1.4;
}
.mpa-kpi__cap-warn {
color: rgb(245, 158, 11);
font-weight: 600; font-weight: 600;
} }
.mpa-kpi__sub { .mpa-kpi__cap-dim {
opacity: 0.7;
}
/* ═══════ Grid 2-col (Timeline + Mensagens/Notas) ═══════ */
.mpa-grid {
display: grid;
grid-template-columns: 1fr;
gap: 12px;
}
@media (min-width: 900px) {
.mpa-grid {
grid-template-columns: 1.4fr 1fr;
}
}
.mpa-grid__col {
display: flex;
flex-direction: column;
gap: 12px;
min-width: 0;
}
/* ═══════ Painel base ═══════ */
.mpa-panel {
background: var(--m-bg-soft);
border: 1px solid var(--m-border);
border-radius: 12px;
overflow: hidden;
display: flex;
flex-direction: column;
}
.mpa-panel__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 12px 14px;
border-bottom: 1px solid var(--m-border);
}
.mpa-panel__title {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 0.88rem;
font-weight: 700;
color: var(--m-text);
}
.mpa-panel__title > i {
color: var(--p-primary-color);
font-size: 0.85rem;
}
.mpa-panel__badge {
background: color-mix(in srgb, var(--p-primary-color) 16%, transparent);
color: var(--p-primary-color);
border-radius: 999px;
padding: 2px 10px;
font-size: 0.7rem;
font-weight: 700;
}
.mpa-panel__body {
padding: 12px 14px 14px;
flex: 1;
min-height: 0;
}
/* ═══════ Empty state ═══════ */
.mpa-empty {
text-align: center;
color: var(--m-text-muted);
padding: 16px 0;
font-size: 0.82rem;
}
.mpa-empty--rich {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
padding: 24px 12px;
}
.mpa-empty__icon {
width: 44px;
height: 44px;
display: grid;
place-items: center;
border-radius: 50%;
background: var(--m-bg-medium);
color: var(--m-text-muted);
margin-bottom: 4px;
}
.mpa-empty__icon > i { font-size: 1rem; }
.mpa-empty__title {
font-size: 0.88rem;
font-weight: 700;
color: var(--m-text);
}
.mpa-empty__sub {
font-size: 0.74rem;
color: var(--m-text-muted);
line-height: 1.4;
max-width: 280px;
}
/* ═══════ Timeline (sessoes atendidas) ═══════ */
.mpa-timeline {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
position: relative;
}
.mpa-timeline::before {
content: '';
position: absolute;
top: 6px;
bottom: 6px;
left: 6px;
width: 1.5px;
background: var(--m-border);
}
.mpa-tl {
position: relative;
padding-left: 22px;
padding-bottom: 14px;
}
.mpa-tl:last-child { padding-bottom: 0; }
.mpa-tl__dot {
position: absolute;
top: 6px;
left: 1px;
width: 11px;
height: 11px;
border-radius: 50%;
background: var(--m-bg-medium);
border: 2px solid var(--m-text-muted);
}
.mpa-tl[data-status*="realiz"] .mpa-tl__dot,
.mpa-tl[data-status*="present"] .mpa-tl__dot { border-color: rgb(34, 197, 94); }
.mpa-tl[data-status*="falt"] .mpa-tl__dot { border-color: rgb(239, 68, 68); }
.mpa-tl[data-status*="cancel"] .mpa-tl__dot,
.mpa-tl[data-status*="remarc"] .mpa-tl__dot { border-color: rgb(245, 158, 11); }
.mpa-tl__main {
display: flex;
flex-direction: column;
gap: 4px;
}
.mpa-tl__top {
display: flex;
align-items: baseline;
gap: 8px;
flex-wrap: wrap;
}
.mpa-tl__when {
font-size: 0.82rem;
font-weight: 700;
color: var(--m-text);
}
.mpa-tl__rel {
font-size: 0.7rem; font-size: 0.7rem;
color: var(--m-text-muted); color: var(--m-text-muted);
opacity: 0.8;
margin-top: 2px;
} }
.mpa-kpi__sub--alert { color: rgb(220, 38, 38); font-weight: 600; } .mpa-tl__row {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.mpa-tl__tag {
font-size: 0.66rem !important;
}
.mpa-tl__chip {
display: inline-flex;
align-items: center;
gap: 3px;
padding: 2px 8px;
border-radius: 999px;
background: var(--m-bg-medium);
color: var(--m-text);
font-size: 0.66rem;
font-weight: 600;
}
.mpa-tl__chip > i { font-size: 0.62rem; opacity: 0.7; }
.mpa-tl__chip--dim {
background: transparent;
color: var(--m-text-muted);
border: 1px solid var(--m-border);
}
.mpa-tl__note {
font-size: 0.78rem;
color: var(--m-text-muted);
line-height: 1.4;
padding: 6px 8px;
border-left: 2px solid var(--m-border);
background: var(--m-bg-medium);
border-radius: 4px;
margin-top: 4px;
}
/* ═══════ Mensagens recentes ═══════ */
.mpa-msgs {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.mpa-msg {
padding: 8px 10px;
border-radius: 8px;
background: var(--m-bg-medium);
border: 1px solid var(--m-border);
border-left-width: 3px;
}
.mpa-msg--in {
border-left-color: rgb(34, 197, 94);
}
.mpa-msg--out {
border-left-color: rgb(96, 165, 250);
}
.mpa-msg__meta {
display: flex;
align-items: center;
gap: 5px;
font-size: 0.66rem;
color: var(--m-text-muted);
font-weight: 600;
margin-bottom: 4px;
}
.mpa-msg__meta > i { font-size: 0.62rem; opacity: 0.7; }
.mpa-msg__sep { opacity: 0.4; }
.mpa-msg__body {
font-size: 0.82rem;
color: var(--m-text);
line-height: 1.45;
word-break: break-word;
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
}
/* ═══════ Notas e observacoes ═══════ */
.mpa-notes {
display: flex;
flex-direction: column;
gap: 12px;
}
.mpa-note {
padding: 10px 12px;
border-radius: 8px;
background: var(--m-bg-medium);
border: 1px solid var(--m-border);
}
.mpa-note__label {
font-size: 0.7rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--m-text-muted);
margin-bottom: 4px;
display: inline-flex;
align-items: center;
gap: 4px;
}
.mpa-note__label > i { font-size: 0.66rem; opacity: 0.7; }
.mpa-note__text {
font-size: 0.82rem;
color: var(--m-text);
line-height: 1.5;
white-space: pre-wrap;
word-break: break-word;
}
/* ═══════ Placeholder (em desenvolvimento) ═══════ */ /* ═══════ Placeholder (em desenvolvimento) ═══════ */
.mpa-placeholder { .mpa-placeholder {