Files
agenciapsilmno/src/features/patients/prontuario/PatientProntuario.vue
T
Leonardo 2dae4a11ae roadmap #11: recently viewed (ultimos 5 pacientes acessados)
ROADMAP item #1.3 #11. localStorage por user_id pra isolar sessoes
diferentes no mesmo browser. ROADMAP sugeria localStorage OU tabela
user_recent_access — escolhi localStorage por simplicidade (sem
migration adicional + zero round-trip por visita).

composables/useRecentPatients.js:
- useRecentPatients() — composable reativo Tipo A: items + hasItems
  + addVisit + remove + clear + refresh
- registerPatientVisit(patient) — helper stateless pra usar fora
  de setup (ex: navigation guards, action handlers)
- Sincroniza entre instancias na mesma aba via CustomEvent + 'storage'
- Max 5 items. Dedup por id, novo no topo.

Wire-up de visita (registra ao carregar prontuario):
- MelissaPaciente.vue: registerPatientVisit no loadAll apos detail.load
- PatientProntuario.vue: registerPatientVisit em loadDetail apos p resolved

Wire-up de visualizacao (mostra quando query vazia):
- GlobalSearch.vue: grupo "Acessados recentemente" antes dos Atalhos.
  goTo("recent") navega pra /therapist/patients/:id.
- MelissaBusca.vue: grupo "Acessados recentemente". emit('paciente')
  reusando a logica do MelissaLayout que ja navega pra
  /melissa/paciente?id=X.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 05:17:51 -03:00

3597 lines
181 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!--
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/features/patients/prontuario/PatientProntuario.vue
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
-->
<script setup>
import { ref, computed, watch, nextTick, onMounted, onBeforeUnmount } from 'vue';
import Chip from 'primevue/chip';
import Accordion from 'primevue/accordion';
import AccordionPanel from 'primevue/accordionpanel';
import AccordionHeader from 'primevue/accordionheader';
import AccordionContent from 'primevue/accordioncontent';
import Popover from 'primevue/popover';
import { useToast } from 'primevue/usetoast';
import { useConfirm } from 'primevue/useconfirm';
import { supabase } from '@/lib/supabase/client';
import { useTenantStore } from '@/stores/tenantStore';
import { useConversationDrawerStore } from '@/stores/conversationDrawerStore';
import DocumentsListPage from '@/features/documents/DocumentsListPage.vue';
import PatientConversationsTab from './PatientConversationsTab.vue';
import { registerPatientVisit } from '@/composables/useRecentPatients';
// ── PROPS / EMITS ──────────────────────────────────────────────
const props = defineProps({
modelValue: { type: Boolean, default: false },
patient: { type: Object, default: () => ({}) },
});
const emit = defineEmits(['update:modelValue', 'close', 'edit', 'add-financial']);
const toast = useToast();
const confirm = useConfirm();
const tenantStore = useTenantStore();
const conversationDrawerStore = useConversationDrawerStore();
const model = computed({
get: () => props.modelValue,
set: (v) => emit('update:modelValue', v),
});
// ── UTILS ──────────────────────────────────────────────────────
function isEmpty(v) {
if (v === null || v === undefined) return true;
return !String(v).trim();
}
function dash(v) {
const s = String(v ?? '').trim();
return s || '—';
}
function pick(obj, keys = []) {
for (const k of keys) {
const v = obj?.[k];
if (!isEmpty(v)) return v;
}
return null;
}
// ── TABS PRINCIPAIS ────────────────────────────────────────────
// activeTab=0 abre o dashboard "Visão Geral" (resumo + timeline + KPIs).
// As abas seguintes mantém o que já existia, deslocadas em +1.
const activeTab = ref(0);
const mainTabs = [
{ label: 'Visão Geral', icon: 'pi pi-chart-line' },
{ label: 'Perfil', icon: 'pi pi-user' },
{ label: 'Prontuário', icon: 'pi pi-file-edit' },
{ label: 'Agenda', icon: 'pi pi-calendar' },
{ label: 'Financeiro', icon: 'pi pi-wallet' },
{ label: 'Documentos', icon: 'pi pi-folder' },
{ label: 'Conversas', icon: 'pi pi-whatsapp' },
];
// ── ACCORDION DA ABA PERFIL ────────────────────────────────────
const accordionValues = ['0', '1', '2', '3', '4', '5'];
const activeAccValues = ref(['0']);
const activeAccValue = computed(() => activeAccValues.value?.[0] ?? null);
const panelHeaderRefs = ref([]);
function setPanelHeaderRef(el, idx) {
if (!el) return;
panelHeaderRefs.value[idx] = el;
}
const allOpen = computed(() =>
accordionValues.every((v) => activeAccValues.value.includes(v))
);
function toggleAllAccordions() {
activeAccValues.value = allOpen.value ? [] : [...accordionValues];
}
async function openPanel(i) {
// Aba 1 = "Perfil" (após inserção da aba 0 "Visão Geral").
activeTab.value = 1;
const v = String(i);
activeAccValues.value = [v];
await nextTick();
const headerRef = panelHeaderRefs.value?.[i];
const el = headerRef?.$el ?? headerRef;
if (el && typeof el.scrollIntoView === 'function') {
el.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
}
const profileNavItems = [
{ value: '0', label: 'Informações Pessoais', icon: 'pi pi-pencil' },
{ value: '1', label: 'Endereço', icon: 'pi pi-map-marker' },
{ value: '2', label: 'Dados Adicionais', icon: 'pi pi-tags' },
{ value: '3', label: 'Responsável', icon: 'pi pi-users' },
{ value: '4', label: 'Anotações', icon: 'pi pi-file-edit' },
{ value: '5', label: 'Sessões', icon: 'pi pi-calendar' },
];
const navPopover = ref(null);
function toggleNav(event) { navPopover.value?.toggle(event); }
function selectNav(item) {
openPanel(Number(item.value));
navPopover.value?.hide();
}
const selectedNav = computed(() =>
profileNavItems.find((i) => i.value === activeAccValue.value) || null
);
// Responsivo
const isCompact = ref(false);
let mql = null, mqlHandler = null;
function syncCompact() { isCompact.value = !!mql?.matches; }
onMounted(() => {
mql = window.matchMedia('(max-width: 1199px)');
mqlHandler = () => syncCompact();
mql.addEventListener?.('change', mqlHandler);
mql.addListener?.(mqlHandler);
syncCompact();
});
onBeforeUnmount(() => {
mql?.removeEventListener?.('change', mqlHandler);
mql?.removeListener?.(mqlHandler);
});
// ── DATA LOAD ──────────────────────────────────────────────────
const loading = ref(false);
const loadError = ref('');
const patientFull = ref(null);
const groups = ref([]);
const tags = ref([]);
const sessions = ref([]);
const sessionsLoading = ref(false);
// Dashboard "Visão Geral" — agregações leves carregadas em paralelo.
const recentMessages = ref([]); // últimas 4 trocadas (in/out)
const messagesLoading = ref(false);
const financialRecords = ref([]); // últimos 12 lançamentos
const financialLoading = ref(false);
// Aba Documentos — só metadata pra KPIs (DocumentsListPage carrega o
// detalhe via composable próprio). Mantemos esse loader paralelo pra
// que os números apareçam imediatamente sem depender do componente embed.
const documentsList = ref([]);
const documentsLoading = ref(false);
const patientData = computed(() => patientFull.value || props.patient || {});
// Avatar
const avatarUrl = computed(() =>
patientData.value?.avatar_url || patientData.value?.avatar || null
);
// Grupos
const groupNames = computed(() =>
(groups.value || []).map((g) => g?.name).filter(Boolean)
);
const groupLabel = computed(() => {
const n = groupNames.value.length;
if (n === 0) return '—';
return groupNames.value.join(', ');
});
const groupCountLabel = computed(() =>
groupNames.value.length <= 1 ? 'Grupo' : 'Grupos'
);
// ── COMPUTED FIELDS ────────────────────────────────────────────
const birthValue = computed(() => pick(patientData.value, ['data_nascimento', 'birth_date']));
const nomeCompleto = computed(() => pick(patientData.value, ['nome_completo', 'name']));
const telefone = computed(() => pick(patientData.value, ['telefone', 'phone']));
const emailPrincipal = computed(() => pick(patientData.value, ['email_principal', 'email']));
const emailAlternativo = computed(() => pick(patientData.value, ['email_alternativo', 'email_alt', 'emailAlt']));
const telefoneAlternativo = computed(() => pick(patientData.value, ['telefone_alternativo', 'phone_alt', 'phoneAlt']));
const genero = computed(() => pick(patientData.value, ['genero', 'gender']));
const estadoCivil = computed(() => pick(patientData.value, ['estado_civil', 'marital_status']));
const naturalidade = computed(() => pick(patientData.value, ['naturalidade', 'birthplace', 'place_of_birth']));
const observacoes = computed(() => pick(patientData.value, ['observacoes', 'notes_short']));
const ondeNosConheceu = computed(() => pick(patientData.value, ['onde_nos_conheceu', 'lead_source']));
const encaminhadoPor = computed(() => pick(patientData.value, ['encaminhado_por', 'referred_by']));
const statusPaciente = computed(() => pick(patientData.value, ['status']));
const convenio = computed(() => pick(patientData.value, ['convenio']));
const patientScope = computed(() => pick(patientData.value, ['patient_scope']));
const riscoElevado = computed(() => !!patientData.value?.risco_elevado);
const cep = computed(() => pick(patientData.value, ['cep', 'postal_code']));
const pais = computed(() => pick(patientData.value, ['pais', 'country']));
const cidade = computed(() => pick(patientData.value, ['cidade', 'city']));
const estado = computed(() => pick(patientData.value, ['estado', 'state']));
const endereco = computed(() => pick(patientData.value, ['endereco', 'address_line']));
const numero = computed(() => pick(patientData.value, ['numero', 'address_number']));
const bairro = computed(() => pick(patientData.value, ['bairro', 'neighborhood']));
const complemento = computed(() => pick(patientData.value, ['complemento', 'address_complement']));
const escolaridade = computed(() => pick(patientData.value, ['escolaridade', 'education', 'education_level']));
const profissao = computed(() => pick(patientData.value, ['profissao', 'profession']));
const nomeParente = computed(() => pick(patientData.value, ['nome_parente', 'relative_name']));
const grauParentesco = computed(() => pick(patientData.value, ['grau_parentesco', 'relative_relation']));
const telefoneParente = computed(() => pick(patientData.value, ['telefone_parente', 'relative_phone']));
const nomeResponsavel = computed(() => pick(patientData.value, ['nome_responsavel', 'guardian_name']));
const cpfResponsavel = computed(() => pick(patientData.value, ['cpf_responsavel', 'guardian_cpf']));
const telefoneResponsavel = computed(() => pick(patientData.value, ['telefone_responsavel', 'guardian_phone']));
const observacaoResponsavel = computed(() => pick(patientData.value, ['observacao_responsavel', 'guardian_note']));
const notasInternas = computed(() => pick(patientData.value, ['notas_internas', 'notes']));
// Métricas (lidas do patientData ou fallback null)
const totalSessoes = computed(() => patientData.value?.total_sessoes ?? null);
const comparecimentoPct = computed(() => patientData.value?.comparecimento_pct ?? null);
const ltvTotal = computed(() => patientData.value?.ltv_total ?? null);
const diasUltimaSessao = computed(() => patientData.value?.dias_ultima_sessao ?? null);
// ── DASHBOARD DERIVADOS (Visão Geral) ───────────────────────────
// sessions vem ordenado DESC (mais recente primeiro). Derivamos:
// - presentes: status realizado/presente
// - faltas/cancel.: contagem pra contraste no card
// - próxima: primeira futura na ordem cronológica
// - últimas atendidas: top 6 realizadas pra timeline
const sessoesPresentes = computed(() =>
sessions.value.filter((s) => /realiz|present/i.test(String(s.status || ''))).length
);
const sessoesFaltadas = computed(() =>
sessions.value.filter((s) => /falta/i.test(String(s.status || ''))).length
);
const sessoesCanceladas = computed(() =>
sessions.value.filter((s) => /cancel|remarca/i.test(String(s.status || ''))).length
);
const proximaSessao = computed(() => {
const now = Date.now();
// ordem DESC; pegar a primeira cuja inicio_em > now (no array, é a última futura)
const futuras = 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));
return futuras[0] || null;
});
const ultimasAtendidas = computed(() =>
sessions.value
.filter((s) => /realiz|present|falt|cancel|remarca/i.test(String(s.status || '')))
.slice(0, 6)
);
function diasAteSessao(iso) {
if (!iso) return null;
const ms = new Date(iso).getTime() - Date.now();
if (Number.isNaN(ms)) return null;
return Math.ceil(ms / (1000 * 60 * 60 * 24));
}
// Status financeiro: agrega lançamentos pra dizer
// - emDia: nenhum pendente está vencido (paid_at NULL && due_date < hoje)
// - proxVenc: próximo pendente com due_date no futuro
// - totalPendente / totalPago: somatório
const statusFinanceiro = computed(() => {
const recs = financialRecords.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
};
});
// Última atividade na conversa (data + lado: in/out)
const ultimaMensagem = computed(() => recentMessages.value[0] || null);
// ── DASHBOARD: aba Financeiro ─────────────────────────────────
// Status de cada lançamento; "vencido" considera due_date < hoje && !paid_at
function recordStatus(r) {
if (r?.paid_at) return 'pago';
if (r?.due_date) {
const ms = new Date(r.due_date + 'T23:59:59').getTime();
if (!Number.isNaN(ms) && ms < Date.now()) return 'vencido';
}
return 'pendente';
}
const RECORD_STATUS_LABEL = { pago: 'Pago', pendente: 'Pendente', vencido: 'Vencido' };
const recordsOrdenados = computed(() => {
return [...financialRecords.value].sort((a, b) => {
// due_date primeiro (DESC), depois created_at
const da = a.due_date || a.created_at;
const db = b.due_date || b.created_at;
return new Date(db) - new Date(da);
});
});
function fmtPaymentMethod(v) {
const s = String(v || '').toLowerCase();
if (!s) return '';
if (s === 'pix') return 'PIX';
if (s === 'cartao' || s === 'cartão' || s === 'credit_card') return 'Cartão';
if (s === 'dinheiro' || s === 'cash') return 'Dinheiro';
if (s === 'boleto') return 'Boleto';
if (s === 'transferencia' || s === 'transfer') return 'Transferência';
return v;
}
// ── DASHBOARD: aba Agenda ─────────────────────────────────────
// Filtro + agrupamento cronológico das sessões (DESC).
const agendaFilter = ref('all'); // all | future | past | realiz | falt | cancel
const agendaFilters = [
{ value: 'all', label: 'Todas', icon: 'pi pi-list' },
{ value: 'future', label: 'Próximas', icon: 'pi pi-calendar-plus' },
{ value: 'past', label: 'Passadas', icon: 'pi pi-history' },
{ value: 'realiz', label: 'Realizadas', icon: 'pi pi-check-circle' },
{ value: 'falt', label: 'Faltas', icon: 'pi pi-user-minus' },
{ value: 'cancel', label: 'Canceladas', icon: 'pi pi-ban' }
];
const agendaSessoesFiltradas = computed(() => {
const list = sessions.value || [];
const now = Date.now();
switch (agendaFilter.value) {
case 'future': return list.filter((s) => s.inicio_em && new Date(s.inicio_em).getTime() > now);
case 'past': return list.filter((s) => s.inicio_em && new Date(s.inicio_em).getTime() <= now);
case 'realiz': return list.filter((s) => /realiz|present/i.test(String(s.status || '')));
case 'falt': return list.filter((s) => /falt/i.test(String(s.status || '')));
case 'cancel': return list.filter((s) => /cancel|remarca/i.test(String(s.status || '')));
default: return list;
}
});
// Agrupa por "MÊS de YYYY" mantendo ordem DESC (mais recente primeiro)
const agendaAgrupadas = computed(() => {
const groups = [];
let key = null;
for (const ev of agendaSessoesFiltradas.value) {
if (!ev.inicio_em) continue;
const d = new Date(ev.inicio_em);
const k = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
if (k !== key) {
key = k;
groups.push({
key: k,
label: d.toLocaleDateString('pt-BR', { month: 'long', year: 'numeric' })
.replace(/(^|\s)\S/g, (l) => l.toUpperCase()),
items: []
});
}
groups[groups.length - 1].items.push(ev);
}
return groups;
});
// Atualiza status de uma sessão específica e recarrega lista.
async function updateSessionStatus(ev, novoStatus, msg) {
if (!ev?.id || sessionBusy.value) return;
sessionBusy.value = true;
try {
const { error } = await supabase
.from('agenda_eventos')
.update({ status: novoStatus })
.eq('id', ev.id);
if (error) throw error;
toast.add({ severity: 'success', summary: msg, life: 2200 });
const pid = patientData.value?.id;
if (pid) await loadSessions(pid);
} catch (e) {
toast.add({ severity: 'error', summary: 'Falha ao atualizar', detail: e?.message || 'Erro inesperado', life: 4000 });
} finally {
sessionBusy.value = false;
}
}
// ── DASHBOARD: aba Conversas ─────────────────────────────────
// recentMessages traz até 200 mensagens — agregamos in/out, primeiro
// e último contato. Canais únicos pra mostrar quais foram usados.
const convTotal = computed(() => recentMessages.value.length);
const convInbound = computed(() => recentMessages.value.filter((m) => m.direction === 'inbound').length);
const convOutbound = computed(() => recentMessages.value.filter((m) => m.direction === 'outbound').length);
const convLast = computed(() => recentMessages.value[0] || null);
const convFirst = computed(() => recentMessages.value[recentMessages.value.length - 1] || null);
const convChannels = computed(() => {
const set = new Set();
for (const m of recentMessages.value) if (m.channel) set.add(m.channel);
return [...set];
});
function chConvLabel(c) {
const k = String(c || '').toLowerCase();
if (k === 'whatsapp') return 'WhatsApp';
if (k === 'sms') return 'SMS';
if (k === 'email') return 'E-mail';
return c || '';
}
// Adicionar telefone WhatsApp ao paciente quando não tem cadastrado.
// Sem isso, conversationDrawerStore.openForPatient falha silencioso e o
// histórico de mensagens fica vazio. Form simples só com dígitos (10 ou
// 11 — DDD + número), sem máscara pra não depender de InputMask aqui.
const phoneDlg = ref(false);
const phoneInput = ref('');
const phoneSaving = ref(false);
function openAddPhone() {
phoneInput.value = String(patientData.value?.telefone || '').replace(/\D/g, '');
phoneDlg.value = true;
}
// Soft-check: existe outro paciente com esse mesmo número? Considera
// tanto patients.telefone (legado) quanto contact_phones (novo).
// Retorna o paciente conflitante ou null. Errors fail-open — não
// bloqueamos save por falha no check.
async function findPhoneOwner(digits, excludeId) {
try {
const { data: byPat } = await supabase
.from('patients')
.select('id, nome_completo')
.eq('telefone', digits)
.neq('id', excludeId)
.limit(1)
.maybeSingle();
if (byPat?.id) return byPat;
const { data: byCp } = await supabase
.from('contact_phones')
.select('entity_id')
.eq('entity_type', 'patient')
.eq('number', digits)
.neq('entity_id', excludeId)
.limit(1)
.maybeSingle();
if (byCp?.entity_id) {
const { data: p } = await supabase
.from('patients')
.select('id, nome_completo')
.eq('id', byCp.entity_id)
.maybeSingle();
return p || null;
}
} catch { /* fail open */ }
return null;
}
async function savePhone() {
const id = patientData.value?.id;
const digits = String(phoneInput.value || '').replace(/\D/g, '');
if (!id) return;
if (digits.length < 10 || digits.length > 11) {
toast.add({ severity: 'warn', summary: 'Telefone inválido', detail: 'Use DDD + número (10 ou 11 dígitos).', life: 3500 });
return;
}
const tenantId = tenantStore.activeTenantId;
if (!tenantId) {
toast.add({ severity: 'error', summary: 'Sem tenant ativo', life: 3500 });
return;
}
// Soft-check de duplicidade — números compartilhados são legítimos
// (mãe agendando pra filho, dependentes), mas avisar evita typo
// virar bug de roteamento de mensagens recebidas via webhook.
phoneSaving.value = true;
const owner = await findPhoneOwner(digits, id);
phoneSaving.value = false;
if (owner) {
confirm.require({
header: 'Telefone já cadastrado',
message: `O telefone ${fmtPhoneMobile(digits)} já está cadastrado em "${owner.nome_completo}". Salvar mesmo assim?`,
icon: 'pi pi-exclamation-triangle',
acceptLabel: 'Salvar mesmo assim',
rejectLabel: 'Cancelar',
acceptClass: 'p-button-warning',
accept: () => _persistPhone(id, digits, tenantId),
reject: () => {}
});
return;
}
await _persistPhone(id, digits, tenantId);
}
async function _persistPhone(id, digits, tenantId) {
phoneSaving.value = true;
try {
// Caminho canônico: gravar em contact_phones. O trigger
// sync_legacy_phone_fields cuida de atualizar patients.telefone
// automaticamente — fazer UPDATE manual aqui criaria estado
// inconsistente (campo legado preenchido sem contact_phone
// correspondente, foi exatamente o bug que afetou pacientes
// salvos antes desse refator).
// 1) Garante o contact_type "whatsapp" (system, slug fixo via
// seed_014_global_data).
const { data: ctype, error: errType } = await supabase
.from('contact_types')
.select('id')
.eq('slug', 'whatsapp')
.order('is_system', { ascending: false })
.limit(1)
.maybeSingle();
if (errType) throw errType;
if (!ctype?.id) throw new Error('Tipo de contato "WhatsApp" não encontrado.');
// 2) Insere ou atualiza em contact_phones (entity_type=patient).
const { data: existing } = await supabase
.from('contact_phones')
.select('id, is_primary')
.eq('entity_type', 'patient')
.eq('entity_id', id)
.eq('contact_type_id', ctype.id)
.limit(1)
.maybeSingle();
if (existing?.id) {
const { error: errUpd } = await supabase
.from('contact_phones')
.update({ number: digits, whatsapp_linked_at: new Date().toISOString() })
.eq('id', existing.id);
if (errUpd) throw errUpd;
} else {
const { count } = await supabase
.from('contact_phones')
.select('id', { count: 'exact', head: true })
.eq('entity_type', 'patient')
.eq('entity_id', id);
const isPrimary = (count || 0) === 0;
const { error: errIns } = await supabase
.from('contact_phones')
.insert({
tenant_id: tenantId,
entity_type: 'patient',
entity_id: id,
contact_type_id: ctype.id,
number: digits,
is_primary: isPrimary,
whatsapp_linked_at: new Date().toISOString(),
position: 10
});
if (errIns) throw errIns;
}
// 3) Sincroniza estado local pra UI atualizar (a trigger já
// gravou no DB, mas patientFull aqui é uma cópia que precisa
// refletir a mudança).
patientFull.value = { ...(patientFull.value || {}), telefone: digits };
toast.add({ severity: 'success', summary: 'Telefone salvo', detail: 'Cadastrado também como WhatsApp.', life: 2400 });
phoneDlg.value = false;
// 5) Retoma o fluxo do drawer se foi acionado por uma tentativa
// de abertura sem telefone.
if (_resumeDrawerAfterPhone.value) {
_resumeDrawerAfterPhone.value = false;
await conversationDrawerStore.openForPatient(String(id));
}
} catch (e) {
toast.add({ severity: 'error', summary: 'Falha ao salvar', detail: e?.message || 'Erro inesperado', life: 3500 });
} finally {
phoneSaving.value = false;
}
}
// ── DASHBOARD: aba Documentos ─────────────────────────────────
// Labels amigáveis pra os tipos do schema (espelha TIPOS_DOCUMENTO de
// useDocuments.js — manter sincronizado se forem incluídos novos).
const DOC_TYPE_LABEL = {
laudo: 'Laudo',
receita: 'Receita',
exame: 'Exame',
termo_assinado: 'Termo assinado',
relatorio: 'Relatório',
declaracao: 'Declaração',
outro: 'Outro'
};
const docTotal = computed(() => documentsList.value.length);
const docTopType = computed(() => {
const por = {};
for (const d of documentsList.value) {
const t = d.tipo_documento || 'outro';
por[t] = (por[t] || 0) + 1;
}
const entries = Object.entries(por).sort((a, b) => b[1] - a[1]);
if (!entries.length) return null;
const [tipo, count] = entries[0];
return { tipo, count, label: DOC_TYPE_LABEL[tipo] || tipo };
});
const docLast = computed(() => documentsList.value[0] || null);
const docPendentes = computed(() =>
documentsList.value.filter((d) => d.status_revisao === 'pendente').length
);
function fmtSize(bytes) {
const b = Number(bytes) || 0;
if (b < 1024) return `${b} B`;
if (b < 1024 * 1024) return `${(b / 1024).toFixed(1)} KB`;
if (b < 1024 * 1024 * 1024) return `${(b / (1024 * 1024)).toFixed(1)} MB`;
return `${(b / (1024 * 1024 * 1024)).toFixed(1)} GB`;
}
const docSizeTotal = computed(() => {
const total = documentsList.value.reduce((acc, d) => acc + (Number(d.tamanho_bytes) || 0), 0);
return fmtSize(total);
});
// Para renderizar hora HH:MM curta nos cards
function fmtHourShort(iso) {
if (!iso) return '';
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return '';
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
}
function fmtDayShort(iso) {
if (!iso) return '';
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return '';
return d.toLocaleDateString('pt-BR', { weekday: 'short' }).replace('.', '');
}
// ── FORMATTERS ─────────────────────────────────────────────────
function nameInitials(name) {
if (!name) return '?';
return String(name).split(' ').filter(Boolean).slice(0, 2)
.map((w) => w[0].toUpperCase()).join('');
}
const avatarInitials = computed(() => nameInitials(nomeCompleto.value));
function onlyDigits(v) { return String(v ?? '').replace(/\D/g, ''); }
function fmtCPF(v) {
const d = onlyDigits(v);
if (!d) return '—';
if (d.length !== 11) return d;
return `${d.slice(0,3)}.${d.slice(3,6)}.${d.slice(6,9)}-${d.slice(9,11)}`;
}
function fmtRG(v) {
const s = String(v ?? '').toUpperCase().replace(/[^0-9X]/g, '');
if (!s) return '—';
if (s.length === 9) return `${s.slice(0,2)}.${s.slice(2,5)}.${s.slice(5,8)}-${s.slice(8)}`;
if (s.length === 8) return `${s.slice(0,2)}.${s.slice(2,5)}.${s.slice(5,8)}`;
return s;
}
function parseDateLoose(v) {
if (!v) return null;
const s = String(v).trim();
if (/^\d{4}-\d{2}-\d{2}/.test(s)) {
const d = new Date(s.slice(0, 10));
return isNaN(d) ? null : d;
}
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 isNaN(d) ? null : d;
}
const d = new Date(s);
return isNaN(d) ? null : d;
}
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()}`;
}
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;
}
const ageLabel = computed(() => {
const age = calcAge(birthValue.value);
return age == null ? '—' : `${age} anos`;
});
function fmtPhoneMobile(v) {
const d = onlyDigits(v);
if (!d) return '—';
if (d.length === 11) return `(${d.slice(0,2)}) ${d.slice(2,7)}-${d.slice(7,11)}`;
if (d.length === 10) return `(${d.slice(0,2)}) ${d.slice(2,6)}-${d.slice(6,10)}`;
return d;
}
function fmtGender(v) {
const s = String(v ?? '').trim();
if (!s) return '—';
const x = s.toLowerCase();
if (['m','masc','masculino','male','man','homem'].includes(x)) return 'Masculino';
if (['f','fem','feminino','female','woman','mulher'].includes(x)) return 'Feminino';
if (['nb','nao-binario','não-binário','nonbinary','non-binary'].includes(x)) return 'Não-binário';
if (['outro','other'].includes(x)) return 'Outro';
return s;
}
function fmtMarital(v) {
const s = String(v ?? '').trim();
if (!s) return '—';
const x = s.toLowerCase();
if (['solteiro','solteira','single'].includes(x)) return 'Solteiro(a)';
if (['casado','casada','married'].includes(x)) return 'Casado(a)';
if (['divorciado','divorciada','divorced'].includes(x)) return 'Divorciado(a)';
if (['viuvo','viúva','viuvo(a)','widowed'].includes(x)) return 'Viúvo(a)';
if (['uniao estavel','união estável','civil union'].includes(x)) return 'União estável';
return s;
}
function fmtCurrency(v) {
if (!v && v !== 0) return '—';
return `R$ ${Number(v).toLocaleString('pt-BR')}`;
}
function fmtDateTimeBR(iso) {
if (!iso) return '—';
const d = new Date(iso);
if (isNaN(d)) 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}`;
}
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 pt-BR — usada nos cards do dashboard.
// "agora", "há 5 min", "há 2 h", "ontem", "há 3 dias", "em 2 dias", "em 3 sem".
function fmtRelative(iso) {
if (!iso) return '—';
const target = new Date(iso).getTime();
if (Number.isNaN(target)) return '—';
const diff = target - Date.now(); // negativo = passado
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);
}
// ── TAG COLORS ─────────────────────────────────────────────────
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 => 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];
}
function bestTextColor(bg) {
const rgb = hexToRgb(normalizeHexColor(bg));
if (!rgb) return '#0f172a';
return relativeLuminance(rgb) < 0.45 ? '#ffffff' : '#0f172a';
}
function tagStyle(t) {
const bg = normalizeHexColor(t?.color || t?.cor);
if (!bg) return {};
return { background: bg, color: bestTextColor(bg), borderColor: 'transparent' };
}
// ── SESSION STATUS ─────────────────────────────────────────────
const STATUS_LABEL = {
agendado: 'Agendado', realizado: 'Realizado', faltou: 'Faltou',
cancelado: 'Cancelado', remarcado: 'Remarcado', bloqueado: 'Bloqueado',
};
const STATUS_SEVERITY = {
agendado: 'info', realizado: 'success', faltou: 'danger',
cancelado: 'warn', remarcado: 'secondary', bloqueado: 'secondary',
};
// ── SUPABASE ───────────────────────────────────────────────────
async function loadSessions(patientId) {
sessionsLoading.value = true;
sessions.value = [];
try {
const { data, error } = 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 (error) throw error;
sessions.value = data || [];
} catch { sessions.value = []; }
finally { sessionsLoading.value = false; }
}
// Mensagens (in+out) — alimenta card "Últimas mensagens" do Visão Geral
// (4 primeiras) e os KPIs da aba Conversas (count agregado). Limite 200
// é suficiente pra estatísticas sem carregar conversa inteira (essa fica
// no PatientConversationsTab embedded).
async function loadRecentMessages(patientId) {
messagesLoading.value = true;
recentMessages.value = [];
try {
const { data, error } = 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 (error) throw error;
recentMessages.value = data || [];
} catch { recentMessages.value = []; }
finally { messagesLoading.value = false; }
}
// Documentos do paciente — só os campos pra KPIs (count, tipo, última).
// Filtra deletados (deleted_at IS NULL) pra coerência com listagem padrão.
async function loadDocumentsList(patientId) {
documentsLoading.value = true;
documentsList.value = [];
try {
const { data, error } = 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 (error) throw error;
documentsList.value = data || [];
} catch { documentsList.value = []; }
finally { documentsLoading.value = false; }
}
// Lançamentos financeiros — alimenta o card de pagamento na Visão Geral
// e a aba Financeiro completa. Schema: paid_at NULL = pendente, preenchido
// = pago. "Vencido" = paid_at IS NULL AND due_date < hoje.
async function loadFinancialRecent(patientId) {
financialLoading.value = true;
financialRecords.value = [];
try {
const { data, error } = 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 (error) throw error;
financialRecords.value = data || [];
} catch { financialRecords.value = []; }
finally { financialLoading.value = false; }
}
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 }));
}
async function loadDetail(id) {
loadError.value = '';
loading.value = true;
patientFull.value = null;
groups.value = [];
tags.value = [];
recentMessages.value = [];
financialRecords.value = [];
documentsList.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 no banco).');
patientFull.value = p;
// Registra no "recentemente acessados" (localStorage)
try { await registerPatientVisit(p); } catch { /* ignore */ }
const [g, t] = await Promise.all([
getGroupsByIds(rel.groupIds || []),
getTagsByIds(rel.tagIds || []),
loadSessions(id),
loadRecentMessages(id),
loadFinancialRecent(id),
loadDocumentsList(id),
]);
groups.value = g;
tags.value = t;
} catch (e) {
loadError.value = e?.message || 'Falha ao buscar dados no Supabase.';
toast.add({ severity: 'error', summary: 'Erro ao carregar', detail: loadError.value, life: 4500 });
} finally {
loading.value = false;
}
}
watch(
[() => props.modelValue, () => props.patient?.id],
async ([open, id], [prevOpen, prevId]) => {
if (!open || !id) return;
if (open === prevOpen && id === prevId) return;
activeTab.value = 0;
activeAccValues.value = ['0'];
await loadDetail(id);
},
{ immediate: true }
);
// ── AÇÕES ──────────────────────────────────────────────────────
function close() {
model.value = false;
emit('close');
}
function editPatient() {
const id = patientData.value?.id;
if (!id) return;
close();
emit('edit', String(id));
}
// ── AÇÕES rápidas (sidebar) ───────────────────────────────────
// Status sessão: aplicado à proximaSessao (única que faz sentido aqui;
// sem proximaSessao os botões ficam disabled). Após update, recarrega
// sessions pra refletir contagens do dashboard.
const sessionBusy = ref(false);
async function setProximaSessaoStatus(novoStatus, msgSucesso) {
const ev = proximaSessao.value;
if (!ev?.id || sessionBusy.value) return;
sessionBusy.value = true;
try {
const { error } = await supabase
.from('agenda_eventos')
.update({ status: novoStatus })
.eq('id', ev.id);
if (error) throw error;
toast.add({ severity: 'success', summary: msgSucesso, life: 2200 });
const pid = patientData.value?.id;
if (pid) await loadSessions(pid);
} catch (e) {
toast.add({ severity: 'error', summary: 'Falha ao atualizar', detail: e?.message || 'Erro inesperado', life: 4000 });
} finally {
sessionBusy.value = false;
}
}
// Solicita ao parent abrir o dialog/rota de novo lançamento financeiro
// pra esse paciente. Emit é genérico — parent decide o destino.
function onAddFinancial() {
const id = patientData.value?.id;
emit('add-financial', { patientId: id, patientName: nomeCompleto.value });
}
function onMarcarRealizada() { setProximaSessaoStatus('realizado', 'Sessão marcada como realizada'); }
function onMarcarFalta() { setProximaSessaoStatus('faltou', 'Marcada como falta'); }
function onMarcarReagendar() { setProximaSessaoStatus('remarcar', 'Marcada para remarcar'); }
function onMarcarCancelar() { setProximaSessaoStatus('cancelado', 'Sessão cancelada'); }
// Abre conversa do paciente no drawer global. Se o paciente não tem
// telefone cadastrado, abre o phoneDlg pra capturar — após salvar,
// continua o fluxo automaticamente via flag `_resumeDrawerAfterPhone`.
async function abrirWhatsappPaciente() {
const pid = patientData.value?.id;
if (!pid) return;
if (!telefone.value) {
_resumeDrawerAfterPhone.value = true;
openAddPhone();
return;
}
await conversationDrawerStore.openForPatient(String(pid));
if (!conversationDrawerStore.isOpen) {
const detail = conversationDrawerStore.error?.message || 'Não foi possível abrir a conversa.';
toast.add({ severity: 'warn', summary: 'WhatsApp', detail, life: 3500 });
conversationDrawerStore.error = null;
}
}
const _resumeDrawerAfterPhone = ref(false);
// Vai pra aba "Perfil" e abre o accordion de Sessões (item value="5").
function abrirSessoesPaciente() {
activeTab.value = 1;
activeAccValues.value = ['5'];
nextTick(() => {
const el = panelHeaderRefs.value?.[5]?.$el ?? panelHeaderRefs.value?.[5];
if (el?.scrollIntoView) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
});
}
// Slug do status da próxima sessão pra destacar botão ativo
const proximaStatusSlug = computed(() => {
const s = String(proximaSessao.value?.status || '').toLowerCase();
if (s === 'realizada') return 'realizado';
if (s === 'cancelada') return 'cancelado';
return s;
});
const temProximaSessao = computed(() => !!proximaSessao.value?.id);
async function copyResumo() {
const txt = `Paciente: ${dash(nomeCompleto.value)}
Idade: ${ageLabel.value}
${groupCountLabel.value}: ${groupLabel.value}
Telefone: ${fmtPhoneMobile(telefone.value)}
Email: ${dash(emailPrincipal.value)}
CPF: ${fmtCPF(patientData.value?.cpf)}
RG: ${fmtRG(patientData.value?.rg)}
Nascimento: ${fmtDateBR(birthValue.value)}
Tags: ${(tags.value || []).map(t => t.name).filter(Boolean).join(', ') || '—'}`;
try {
await navigator.clipboard.writeText(txt);
toast.add({ severity: 'success', summary: 'Copiado', detail: 'Resumo copiado para a área de transferência.', life: 2200 });
} catch {
toast.add({ severity: 'error', summary: 'Falha', detail: 'Não foi possível copiar.', life: 3000 });
}
}
</script>
<template>
<Dialog
v-model:visible="model"
modal
:draggable="false"
maximizable
:style="{ width: '96vw', maxWidth: '1400px' }"
:contentStyle="{ padding: 0 }"
pt:mask:class="backdrop-blur-sm"
pt:title:class="text-[0.95rem] font-bold text-[var(--text-color)] truncate"
@hide="close"
>
<!-- HEADER DO DIALOG -->
<!-- Compacto: max 2 linhas, altura padrão do Dialog. Sem botões
extras no header ações ficam dentro do Dashboard. -->
<template #header>
<div class="pp-head">
<div v-if="avatarUrl" class="pp-head__avatar">
<img :src="avatarUrl" alt="" />
</div>
<div v-else class="pp-head__avatar pp-head__avatar--initials">
<span>{{ avatarInitials }}</span>
</div>
<div class="pp-head__id">
<div class="pp-head__line1">
<span class="pp-head__name">{{ dash(nomeCompleto) }}</span>
<span v-if="riscoElevado" class="pp-head__pill pp-head__pill--danger">
<i class="pi pi-exclamation-circle" /> risco
</span>
</div>
<div class="pp-head__line2">
<span>{{ ageLabel }}</span>
<span v-if="patientData.pronomes" class="pp-head__sep">·</span>
<span v-if="patientData.pronomes">{{ patientData.pronomes }}</span>
<span v-if="convenio" class="pp-head__sep">·</span>
<span v-if="convenio" class="pp-head__chip">{{ convenio }}</span>
</div>
</div>
</div>
</template>
<!-- CONTEÚDO -->
<div class="bg-[var(--surface-ground)] min-h-[60vh]">
<!-- Loading -->
<div v-if="loading"
class="flex items-center justify-center py-20 gap-2 text-sm text-[var(--text-color-secondary)]">
<i class="pi pi-spin pi-spinner" /> Carregando
</div>
<!-- Erro -->
<div v-else-if="loadError" class="p-4">
<div class="flex items-start gap-3 rounded-xl border border-red-200 bg-red-50 px-4 py-3">
<i class="pi pi-exclamation-circle text-red-500 mt-0.5 shrink-0" />
<div>
<p class="text-sm font-semibold text-red-700">Falha ao carregar</p>
<p class="text-xs text-red-500 mt-0.5">{{ loadError }}</p>
</div>
</div>
</div>
<div v-else class="grid grid-cols-1 xl:grid-cols-[240px_1fr] gap-3 p-3 md:p-4">
<!-- SIDEBAR -->
<aside class="rounded-xl border border-[var(--surface-border)]
bg-[var(--surface-card)] p-4 shadow-sm
xl:sticky xl:top-2 xl:self-start space-y-0">
<!-- Banner risco elevado (sidebar) -->
<div v-if="riscoElevado"
class="flex items-start gap-2 rounded-lg border border-red-200
bg-red-50 px-3 py-2 mb-4">
<i class="pi pi-exclamation-circle text-red-500 text-sm mt-0.5 shrink-0" />
<p class="text-[0.73rem] font-semibold text-red-700 leading-snug">
Risco elevado sinalizado
</p>
</div>
<!-- Avatar + nome + badges -->
<div class="flex items-center gap-3 pb-4 mb-4
border-b border-[var(--surface-border)]
xl:flex-col xl:items-center xl:gap-2 xl:text-center">
<!-- Avatar -->
<div class="shrink-0 w-16 h-16 xl:w-20 xl:h-20 rounded-full overflow-hidden">
<img v-if="avatarUrl" :src="avatarUrl" alt="avatar"
class="w-full h-full object-cover" />
<div v-else class="w-full h-full bg-indigo-100 flex items-center justify-center">
<span class="text-xl font-bold text-indigo-700 tracking-tight">
{{ avatarInitials }}
</span>
</div>
</div>
<!-- Info -->
<div class="flex-1 min-w-0 xl:text-center">
<div class="text-sm font-bold text-[var(--text-color)] truncate">
{{ dash(nomeCompleto) }}
</div>
<div class="text-xs text-[var(--text-color-secondary)] mt-0.5">
{{ ageLabel }}
<template v-if="patientData.pronomes"> · {{ patientData.pronomes }}</template>
</div>
<div v-if="naturalidade || estado"
class="text-xs text-[var(--text-color-secondary)]">
{{ dash(naturalidade) }}<template v-if="estado">, {{ estado }}</template>
</div>
<!-- Status + Convênio + Scope -->
<div class="flex flex-wrap gap-1 mt-2 xl:justify-center">
<Tag v-if="statusPaciente" :value="statusPaciente" severity="success" class="!text-[0.68rem]" />
<Tag v-if="convenio" :value="convenio" severity="info" class="!text-[0.68rem]" />
<Tag v-if="patientScope" :value="patientScope" severity="secondary" class="!text-[0.68rem]" />
</div>
<!-- Tags coloridas -->
<div v-if="tags?.length" class="flex flex-wrap gap-1 mt-1.5 xl:justify-center">
<span v-for="t in tags" :key="t.id"
class="inline-flex items-center px-2 py-0.5 rounded-full text-[0.68rem] font-medium"
:style="tagStyle(t)">
{{ t.name }}
</span>
</div>
</div>
</div>
<!-- Métricas 2×2 -->
<div v-if="totalSessoes != null || comparecimentoPct != null || ltvTotal != null || diasUltimaSessao != null"
class="grid grid-cols-2 gap-3 text-center pb-4 mb-4 border-b border-[var(--surface-border)]">
<div v-if="totalSessoes != null">
<p class="text-xl font-bold text-[var(--text-color)]">{{ totalSessoes }}</p>
<p class="text-[0.65rem] text-[var(--text-color-secondary)] mt-0.5">Sessões</p>
</div>
<div v-if="comparecimentoPct != null">
<p class="text-xl font-bold text-emerald-600">{{ comparecimentoPct }}%</p>
<p class="text-[0.65rem] text-[var(--text-color-secondary)] mt-0.5">Comparec.</p>
</div>
<div v-if="ltvTotal != null">
<p class="text-base font-bold text-[var(--text-color)]">{{ fmtCurrency(ltvTotal) }}</p>
<p class="text-[0.65rem] text-[var(--text-color-secondary)] mt-0.5">LTV total</p>
</div>
<div v-if="diasUltimaSessao != null">
<p class="text-xl font-bold text-amber-500">{{ diasUltimaSessao }}d</p>
<p class="text-[0.65rem] text-[var(--text-color-secondary)] mt-0.5">Últ. sessão</p>
</div>
</div>
<!-- Ações rápidas espelham o MelissaEventoPanel:
status da próxima sessão (Realizada/Falta/Reagendar/
Cancelar) + atalhos (Sessões/Conversar/Editar). -->
<div class="pp-actions">
<section class="pp-actions__section">
<div class="pp-actions__label">
Marcar próxima sessão como:
<span v-if="!temProximaSessao" class="pp-actions__hint">(sem sessão futura)</span>
</div>
<div class="pp-actions__group">
<button
type="button"
class="pp-act pp-act--ok"
:class="{ 'is-current': proximaStatusSlug === 'realizado' }"
:disabled="!temProximaSessao || sessionBusy"
@click="onMarcarRealizada"
>
<i class="pi pi-check-circle" />
<span class="pp-act__label">Realizada</span>
</button>
<button
type="button"
class="pp-act pp-act--warn"
:class="{ 'is-current': proximaStatusSlug === 'faltou' }"
:disabled="!temProximaSessao || sessionBusy"
@click="onMarcarFalta"
>
<i class="pi pi-user-minus" />
<span class="pp-act__label">Falta</span>
</button>
<button
type="button"
class="pp-act"
:class="{ 'is-current': proximaStatusSlug === 'remarcar' || proximaStatusSlug === 'remarcado' }"
:disabled="!temProximaSessao || sessionBusy"
@click="onMarcarReagendar"
>
<i class="pi pi-calendar-clock" />
<span class="pp-act__label">Reagendar</span>
</button>
<button
type="button"
class="pp-act pp-act--danger"
:class="{ 'is-current': proximaStatusSlug === 'cancelado' }"
:disabled="!temProximaSessao || sessionBusy"
@click="onMarcarCancelar"
>
<i class="pi pi-ban" />
<span class="pp-act__label">Cancelar</span>
</button>
</div>
</section>
<section class="pp-actions__section">
<div class="pp-actions__group">
<button type="button" class="pp-act" @click="abrirSessoesPaciente">
<i class="pi pi-history" />
<span class="pp-act__label">Sessões</span>
</button>
<button type="button" class="pp-act" @click="abrirWhatsappPaciente">
<i class="pi pi-whatsapp" />
<span class="pp-act__label">Conversar</span>
</button>
<button type="button" class="pp-act" @click="editPatient">
<i class="pi pi-pencil" />
<span class="pp-act__label">Editar</span>
</button>
</div>
</section>
</div>
<!-- Nav tabs principais -->
<div class="flex flex-col gap-0.5">
<p class="text-[0.62rem] font-bold uppercase tracking-widest text-[var(--text-color-secondary)] opacity-50 px-3 mb-1">
Navegação
</p>
<button
v-for="(tab, i) in mainTabs" :key="i"
type="button"
class="flex items-center gap-2.5 rounded-lg px-3 py-2 text-left
text-[0.82rem] border transition-colors duration-100"
:class="activeTab === i
? 'bg-indigo-50 border-indigo-200 text-indigo-700 font-semibold'
: 'border-transparent text-[var(--text-color)] hover:bg-[var(--surface-ground)] font-medium'"
@click="activeTab = i; if(i === 1) activeAccValues = ['0']"
>
<i :class="tab.icon" class="text-sm opacity-60 shrink-0" />
<span>{{ tab.label }}</span>
</button>
</div>
<!-- Submenu Perfil (desktop, aba 0) -->
<div v-if="activeTab === 1 && !isCompact"
class="mt-3 pt-3 border-t border-[var(--surface-border)] flex flex-col gap-0.5">
<p class="text-[0.62rem] font-bold uppercase tracking-widest text-[var(--text-color-secondary)] opacity-50 px-3 mb-1">
Seções do perfil
</p>
<button
v-for="item in profileNavItems" :key="item.value"
type="button"
class="flex items-center gap-2.5 rounded-lg px-3 py-1.5 text-left
text-[0.79rem] border transition-colors duration-100"
:class="activeAccValue === item.value
? 'bg-indigo-50 border-indigo-200 text-indigo-700 font-semibold'
: 'border-transparent text-[var(--text-color-secondary)] hover:bg-[var(--surface-ground)] font-medium'"
@click="openPanel(Number(item.value))"
>
<i :class="item.icon" class="text-xs opacity-60 shrink-0" />
<span>{{ item.label }}</span>
</button>
</div>
</aside>
<!-- MAIN -->
<main class="pp-main min-w-0 rounded-xl border border-[var(--surface-border)]
shadow-sm overflow-hidden">
<!-- Tab bar -->
<div class="flex border-b border-[var(--surface-border)] overflow-x-auto scrollbar-none">
<button
v-for="(tab, i) in mainTabs" :key="i"
type="button"
class="flex items-center gap-1.5 px-4 py-3 text-[0.82rem] font-medium
whitespace-nowrap border-b-2 transition-colors duration-150 shrink-0"
:class="activeTab === i
? 'border-[var(--primary-color)] text-[var(--primary-color)]'
: 'border-transparent text-[var(--text-color-secondary)] hover:text-[var(--text-color)]'"
@click="activeTab = i; if(i === 1) activeAccValues = ['0']"
>
<i :class="tab.icon" class="text-sm" />
{{ tab.label }}
</button>
</div>
<!--
ABA 0: VISÃO GERAL dashboard resumo
-->
<div v-show="activeTab === 0" class="pp-overview">
<div class="pp-ov__bg" aria-hidden="true">
<span class="pp-ov__spot pp-ov__spot--a" />
<span class="pp-ov__spot pp-ov__spot--b" />
<span class="pp-ov__line pp-ov__line--1" />
<span class="pp-ov__line pp-ov__line--2" />
</div>
<div class="pp-ov__inner">
<!-- KPI cards -->
<div class="pp-kpis">
<!-- 1. Sessões presentes -->
<article class="pp-kpi" style="--c:#4ade80; --c-dim:rgba(74,222,128,0.1); --c-border:rgba(74,222,128,0.3)">
<div class="pp-kpi__shine" />
<span class="pp-kpi__num">01</span>
<header class="pp-kpi__head">
<div class="pp-kpi__icon"><i class="pi pi-check-circle" /></div>
<span class="pp-kpi__tag">Sessões</span>
</header>
<div class="pp-kpi__body">
<div class="pp-kpi__big">{{ sessoesPresentes }}</div>
<div class="pp-kpi__cap">
de {{ sessions.length }} totais
<span v-if="sessoesFaltadas" class="pp-kpi__cap-warn">· {{ sessoesFaltadas }} {{ sessoesFaltadas === 1 ? 'falta' : 'faltas' }}</span>
<span v-if="sessoesCanceladas" class="pp-kpi__cap-dim">· {{ sessoesCanceladas }} cancel.</span>
</div>
</div>
</article>
<!-- 2. Pagamento -->
<article class="pp-kpi"
:style="statusFinanceiro.emDia === false
? '--c:#f87171; --c-dim:rgba(248,113,113,0.10); --c-border:rgba(248,113,113,0.30)'
: '--c:var(--primary-color); --c-dim:color-mix(in srgb, var(--primary-color) 10%, transparent); --c-border:color-mix(in srgb, var(--primary-color) 30%, transparent)'">
<div class="pp-kpi__shine" />
<span class="pp-kpi__num">02</span>
<header class="pp-kpi__head">
<div class="pp-kpi__icon"><i class="pi pi-wallet" /></div>
<span class="pp-kpi__tag">Pagamento</span>
</header>
<div class="pp-kpi__body">
<template v-if="financialLoading">
<div class="pp-kpi__big pp-kpi__big--small"></div>
<div class="pp-kpi__cap">Carregando</div>
</template>
<template v-else-if="!financialRecords.length">
<div class="pp-kpi__big pp-kpi__big--small"></div>
<div class="pp-kpi__cap">Sem lançamentos</div>
</template>
<template v-else>
<div class="pp-kpi__big pp-kpi__big--small">
{{ statusFinanceiro.emDia === false ? 'Em atraso' : 'Em dia' }}
</div>
<div class="pp-kpi__cap">
<template v-if="statusFinanceiro.proxVenc">
Próx. venc. {{ fmtDateBR(statusFinanceiro.proxVenc.due_date) }}
<span class="pp-kpi__cap-dim">· {{ fmtRelative(statusFinanceiro.proxVenc.due_date) }}</span>
</template>
<template v-else-if="statusFinanceiro.totalPendente > 0">
{{ fmtCurrency(statusFinanceiro.totalPendente) }} pendente
</template>
<template v-else>
Tudo quitado
</template>
</div>
</template>
</div>
</article>
<!-- 3. Próxima sessão -->
<article class="pp-kpi" style="--c:#60a5fa; --c-dim:rgba(96,165,250,0.10); --c-border:rgba(96,165,250,0.30)">
<div class="pp-kpi__shine" />
<span class="pp-kpi__num">03</span>
<header class="pp-kpi__head">
<div class="pp-kpi__icon"><i class="pi pi-calendar-clock" /></div>
<span class="pp-kpi__tag">Próxima</span>
</header>
<div class="pp-kpi__body">
<template v-if="proximaSessao">
<div class="pp-kpi__big pp-kpi__big--small">{{ fmtRelative(proximaSessao.inicio_em) }}</div>
<div class="pp-kpi__cap">
{{ fmtDateTimeBR(proximaSessao.inicio_em) }}
<span v-if="proximaSessao.modalidade" class="pp-kpi__cap-dim">· {{ proximaSessao.modalidade === 'online' ? 'Online' : 'Presencial' }}</span>
</div>
</template>
<template v-else>
<div class="pp-kpi__big pp-kpi__big--small"></div>
<div class="pp-kpi__cap">Sem sessão futura agendada</div>
</template>
</div>
</article>
<!-- 4. Mensagens -->
<article class="pp-kpi" style="--c:#34d399; --c-dim:rgba(52,211,153,0.10); --c-border:rgba(52,211,153,0.30)">
<div class="pp-kpi__shine" />
<span class="pp-kpi__num">04</span>
<header class="pp-kpi__head">
<div class="pp-kpi__icon"><i class="pi pi-comments" /></div>
<span class="pp-kpi__tag">Mensagens</span>
</header>
<div class="pp-kpi__body">
<template v-if="messagesLoading">
<div class="pp-kpi__big pp-kpi__big--small"></div>
<div class="pp-kpi__cap">Carregando</div>
</template>
<template v-else-if="ultimaMensagem">
<div class="pp-kpi__big pp-kpi__big--small">{{ fmtRelative(ultimaMensagem.created_at) }}</div>
<div class="pp-kpi__cap">
Última {{ ultimaMensagem.direction === 'inbound' ? 'recebida' : 'enviada' }}
<span class="pp-kpi__cap-dim">· {{ recentMessages.length }} no histórico</span>
</div>
</template>
<template v-else>
<div class="pp-kpi__big pp-kpi__big--small"></div>
<div class="pp-kpi__cap">Nenhuma conversa registrada</div>
</template>
</div>
</article>
</div>
<!-- Painéis: Timeline + Mensagens recentes -->
<div class="pp-grid">
<!-- Timeline -->
<section class="pp-panel">
<header class="pp-panel__head">
<div class="pp-panel__title"><i class="pi pi-history" /> Timeline de atendimentos</div>
<span class="pp-panel__badge">{{ ultimasAtendidas.length }}</span>
</header>
<div class="pp-panel__body">
<div v-if="sessionsLoading" class="pp-empty">Carregando</div>
<div v-else-if="!ultimasAtendidas.length" class="pp-empty pp-empty--rich">
<div class="pp-empty__icon"><i class="pi pi-history" /></div>
<div class="pp-empty__title">Sem atendimentos registrados</div>
<div class="pp-empty__sub">As sessões realizadas com este paciente aparecerão aqui em ordem cronológica.</div>
</div>
<ol v-else class="pp-timeline">
<li
v-for="s in ultimasAtendidas"
:key="s.id"
class="pp-tl"
:data-status="String(s.status || 'agendado').toLowerCase()"
>
<span class="pp-tl__dot" />
<div class="pp-tl__main">
<div class="pp-tl__top">
<span class="pp-tl__when">{{ fmtDateTimeBR(s.inicio_em) }}</span>
<span class="pp-tl__rel">{{ fmtRelative(s.inicio_em) }}</span>
</div>
<div class="pp-tl__row">
<Tag
:value="STATUS_LABEL[s.status] || s.status || '—'"
:severity="STATUS_SEVERITY[s.status] || 'info'"
class="pp-tl__tag"
/>
<span v-if="s.modalidade" class="pp-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="pp-tl__chip pp-tl__chip--dim">
<i class="pi pi-clock" /> {{ sessionDuration(s.inicio_em, s.fim_em) }}
</span>
</div>
<p v-if="s.observacoes" class="pp-tl__note">{{ s.observacoes }}</p>
</div>
</li>
</ol>
</div>
</section>
<!-- Mensagens recentes + Notas internas -->
<div class="pp-grid__col">
<section class="pp-panel">
<header class="pp-panel__head">
<div class="pp-panel__title"><i class="pi pi-comments" /> Últimas mensagens</div>
<span class="pp-panel__badge">{{ recentMessages.length }}</span>
</header>
<div class="pp-panel__body">
<div v-if="messagesLoading" class="pp-empty">Carregando</div>
<div v-else-if="!recentMessages.length" class="pp-empty pp-empty--rich">
<div class="pp-empty__icon"><i class="pi pi-comments" /></div>
<div class="pp-empty__title">Sem conversa registrada</div>
<div class="pp-empty__sub">As últimas mensagens trocadas com o paciente aparecerão aqui.</div>
</div>
<ul v-else class="pp-msgs">
<li v-for="m in recentMessages.slice(0, 4)" :key="m.id"
class="pp-msg"
:class="m.direction === 'inbound' ? 'pp-msg--in' : 'pp-msg--out'"
>
<div class="pp-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="pp-msg__sep">·</span>
<span>{{ fmtRelative(m.created_at) }}</span>
</div>
<p class="pp-msg__body">{{ m.body || '—' }}</p>
</li>
</ul>
</div>
</section>
<section v-if="notasInternas || observacoes" class="pp-panel">
<header class="pp-panel__head">
<div class="pp-panel__title"><i class="pi pi-file-edit" /> Notas e observações</div>
</header>
<div class="pp-panel__body pp-notes">
<div v-if="observacoes" class="pp-note">
<p class="pp-note__label">Observações gerais</p>
<p class="pp-note__text">{{ observacoes }}</p>
</div>
<div v-if="notasInternas" class="pp-note">
<p class="pp-note__label">
<i class="pi pi-lock" /> Internas
</p>
<p class="pp-note__text">{{ notasInternas }}</p>
</div>
</div>
</section>
</div>
</div>
</div>
</div>
<!--
ABA 1: PERFIL accordion
-->
<div v-show="activeTab === 1">
<!-- Banner risco (no topo do conteúdo, abaixo das tabs) -->
<div v-if="riscoElevado"
class="flex items-start gap-3 border-b border-red-100 bg-red-50 px-4 py-3">
<i class="pi pi-exclamation-circle text-red-500 mt-0.5 shrink-0" />
<div>
<p class="text-sm font-semibold text-red-700">
Atenção paciente com risco elevado sinalizado
</p>
<p class="text-xs text-red-500 mt-0.5">
Verifique as anotações internas e a seção de sessões para mais detalhes.
</p>
</div>
</div>
<!-- Nav compacto (<xl) -->
<div v-if="isCompact"
class="sticky top-0 z-10 border-b border-[var(--surface-border)]
bg-[var(--surface-card)] px-4 py-2.5">
<Button type="button" class="w-full !rounded-full"
icon="pi pi-chevron-down" iconPos="right"
:label="selectedNav ? selectedNav.label : 'Selecionar seção'"
@click="toggleNav($event)" />
<Popover ref="navPopover">
<div class="flex min-w-[240px] flex-col gap-0.5 p-1">
<button
v-for="item in profileNavItems" :key="item.value"
type="button"
class="flex items-center gap-2.5 rounded-lg px-3 py-2
text-left text-sm border cursor-pointer"
:class="activeAccValue === item.value
? 'bg-indigo-50 border-indigo-200 text-indigo-700 font-semibold'
: 'border-transparent text-[var(--text-color)] hover:bg-[var(--surface-ground)] font-medium'"
@click="selectNav(item)"
>
<i :class="item.icon" class="text-sm opacity-60 shrink-0" />
<span>{{ item.label }}</span>
</button>
</div>
</Popover>
</div>
<!-- Accordion -->
<div class="p-4">
<Accordion multiple v-model:value="activeAccValues" class="accordion-clean">
<!-- 1. INFORMAÇÕES PESSOAIS -->
<AccordionPanel value="0">
<AccordionHeader :ref="(el) => setPanelHeaderRef(el, 0)">
<span class="flex items-center gap-2 text-[0.83rem] font-bold">
<i class="pi pi-pencil text-indigo-500 text-sm" />
1. Informações Pessoais
</span>
</AccordionHeader>
<AccordionContent>
<div class="pt-3 grid grid-cols-1 md:grid-cols-2 gap-4">
<!-- Dados de cadastro -->
<div class="rounded-xl border border-[var(--surface-border)] bg-[var(--surface-ground)] p-4">
<p class="text-[0.65rem] font-bold uppercase tracking-widest text-[var(--text-color-secondary)] mb-3 opacity-70">Dados de cadastro</p>
<div class="flex items-baseline justify-between gap-4 py-1.5 border-b border-[var(--surface-border)]">
<span class="text-[0.8rem] text-[var(--text-color-secondary)] shrink-0">Nome completo</span>
<span class="text-[0.8rem] text-[var(--text-color)] font-medium text-right">{{ dash(nomeCompleto) }}</span>
</div>
<div class="flex items-baseline justify-between gap-4 py-1.5 border-b border-[var(--surface-border)]">
<span class="text-[0.8rem] text-[var(--text-color-secondary)] shrink-0">Data de nascimento</span>
<span class="text-[0.8rem] text-[var(--text-color)] font-medium text-right">
{{ fmtDateBR(birthValue) }}
<span v-if="calcAge(birthValue) != null" class="text-[var(--text-color-secondary)] font-normal">({{ calcAge(birthValue) }} a)</span>
</span>
</div>
<div class="flex items-baseline justify-between gap-4 py-1.5 border-b border-[var(--surface-border)]">
<span class="text-[0.8rem] text-[var(--text-color-secondary)] shrink-0">Gênero</span>
<span class="text-[0.8rem] text-[var(--text-color)] font-medium text-right">{{ fmtGender(genero) }}</span>
</div>
<div class="flex items-baseline justify-between gap-4 py-1.5 border-b border-[var(--surface-border)]">
<span class="text-[0.8rem] text-[var(--text-color-secondary)] shrink-0">Estado civil</span>
<span class="text-[0.8rem] text-[var(--text-color)] font-medium text-right">{{ fmtMarital(estadoCivil) }}</span>
</div>
<div class="flex items-baseline justify-between gap-4 py-1.5 border-b border-[var(--surface-border)]">
<span class="text-[0.8rem] text-[var(--text-color-secondary)] shrink-0">CPF</span>
<span class="text-[0.8rem] text-[var(--text-color)] font-medium text-right font-mono">{{ fmtCPF(patientData.cpf) }}</span>
</div>
<div class="flex items-baseline justify-between gap-4 py-1.5 border-b border-[var(--surface-border)]">
<span class="text-[0.8rem] text-[var(--text-color-secondary)] shrink-0">RG</span>
<span class="text-[0.8rem] text-[var(--text-color)] font-medium text-right font-mono">{{ fmtRG(patientData.rg) }}</span>
</div>
<div class="flex items-baseline justify-between gap-4 py-1.5">
<span class="text-[0.8rem] text-[var(--text-color-secondary)] shrink-0">Naturalidade</span>
<span class="text-[0.8rem] text-[var(--text-color)] font-medium text-right">{{ dash(naturalidade) }}</span>
</div>
</div>
<!-- Contato + Origem -->
<div class="space-y-3">
<div class="rounded-xl border border-[var(--surface-border)] bg-[var(--surface-ground)] p-4">
<p class="text-[0.65rem] font-bold uppercase tracking-widest text-[var(--text-color-secondary)] mb-3 opacity-70">Contato</p>
<div class="flex items-baseline justify-between gap-4 py-1.5 border-b border-[var(--surface-border)]">
<span class="text-[0.8rem] text-[var(--text-color-secondary)] shrink-0">Telefone / Celular</span>
<a v-if="telefone" :href="`tel:${telefone}`" class="text-[0.8rem] font-medium text-right text-[var(--primary-color)] hover:underline">{{ fmtPhoneMobile(telefone) }}</a>
<span v-else class="text-[0.8rem] text-[var(--text-color)] font-medium text-right"></span>
</div>
<div class="flex items-baseline justify-between gap-4 py-1.5 border-b border-[var(--surface-border)]">
<span class="text-[0.8rem] text-[var(--text-color-secondary)] shrink-0">Telefone alternativo</span>
<a v-if="telefoneAlternativo" :href="`tel:${telefoneAlternativo}`" class="text-[0.8rem] font-medium text-right text-[var(--primary-color)] hover:underline">{{ fmtPhoneMobile(telefoneAlternativo) }}</a>
<span v-else class="text-[0.8rem] text-[var(--text-color)] font-medium text-right"></span>
</div>
<div class="flex items-baseline justify-between gap-4 py-1.5 border-b border-[var(--surface-border)]">
<span class="text-[0.8rem] text-[var(--text-color-secondary)] shrink-0">Email principal</span>
<a v-if="emailPrincipal" :href="`mailto:${emailPrincipal}`" class="text-[0.8rem] font-medium text-right text-[var(--primary-color)] hover:underline truncate max-w-[200px] inline-block">{{ emailPrincipal }}</a>
<span v-else class="text-[0.8rem] text-[var(--text-color)] font-medium text-right"></span>
</div>
<div class="flex items-baseline justify-between gap-4 py-1.5">
<span class="text-[0.8rem] text-[var(--text-color-secondary)] shrink-0">Email alternativo</span>
<a v-if="emailAlternativo" :href="`mailto:${emailAlternativo}`" class="text-[0.8rem] font-medium text-right text-[var(--primary-color)] hover:underline truncate max-w-[200px] inline-block">{{ emailAlternativo }}</a>
<span v-else class="text-[0.8rem] text-[var(--text-color)] font-medium text-right"></span>
</div>
</div>
<div class="rounded-xl border border-[var(--surface-border)] bg-[var(--surface-ground)] p-4">
<p class="text-[0.65rem] font-bold uppercase tracking-widest text-[var(--text-color-secondary)] mb-3 opacity-70">Origem</p>
<div class="flex items-baseline justify-between gap-4 py-1.5 border-b border-[var(--surface-border)]">
<span class="text-[0.8rem] text-[var(--text-color-secondary)] shrink-0">{{ groupCountLabel }}</span>
<span class="text-[0.8rem] text-[var(--text-color)] font-medium text-right">{{ groupLabel }}</span>
</div>
<div class="flex items-start justify-between gap-4 py-1.5 border-b border-[var(--surface-border)]">
<span class="text-[0.8rem] text-[var(--text-color-secondary)] shrink-0">Tags</span>
<div class="flex flex-wrap gap-1 justify-end">
<span v-for="t in tags" :key="t.id"
class="inline-flex items-center px-2 py-0.5 rounded-full text-[0.72rem] font-medium"
:style="tagStyle(t)">{{ t.name }}</span>
<span v-if="!tags?.length" class="text-[0.8rem] text-[var(--text-color)] font-medium"></span>
</div>
</div>
<div class="flex items-baseline justify-between gap-4 py-1.5 border-b border-[var(--surface-border)]">
<span class="text-[0.8rem] text-[var(--text-color-secondary)] shrink-0">Onde nos conheceu?</span>
<span class="text-[0.8rem] text-[var(--text-color)] font-medium text-right">{{ dash(ondeNosConheceu) }}</span>
</div>
<div class="flex items-baseline justify-between gap-4 py-1.5">
<span class="text-[0.8rem] text-[var(--text-color-secondary)] shrink-0">Encaminhado por</span>
<span class="text-[0.8rem] text-[var(--text-color)] font-medium text-right">{{ dash(encaminhadoPor) }}</span>
</div>
</div>
</div>
<!-- Observações full-width -->
<div v-if="observacoes"
class="md:col-span-2 rounded-xl border border-[var(--surface-border)] bg-[var(--surface-ground)] p-4">
<p class="text-[0.65rem] font-bold uppercase tracking-widest text-[var(--text-color-secondary)] mb-3 opacity-70">Observações</p>
<p class="text-[0.83rem] text-[var(--text-color)] leading-relaxed whitespace-pre-wrap">{{ observacoes }}</p>
</div>
</div>
</AccordionContent>
</AccordionPanel>
<!-- 2. ENDEREÇO -->
<AccordionPanel value="1">
<AccordionHeader :ref="(el) => setPanelHeaderRef(el, 1)">
<span class="flex items-center gap-2 text-[0.83rem] font-bold">
<i class="pi pi-map-marker text-indigo-500 text-sm" />
2. Endereço
</span>
</AccordionHeader>
<AccordionContent>
<div class="pt-3">
<div class="rounded-xl border border-[var(--surface-border)] bg-[var(--surface-ground)] p-4">
<p class="text-[0.65rem] font-bold uppercase tracking-widest text-[var(--text-color-secondary)] mb-3 opacity-70">Localização</p>
<div class="grid grid-cols-1 sm:grid-cols-2">
<div class="flex items-baseline justify-between gap-4 py-1.5 border-b border-[var(--surface-border)]">
<span class="text-[0.8rem] text-[var(--text-color-secondary)] shrink-0">CEP</span>
<span class="text-[0.8rem] text-[var(--text-color)] font-medium text-right font-mono">{{ dash(cep) }}</span>
</div>
<div class="flex items-baseline justify-between gap-4 py-1.5 border-b border-[var(--surface-border)]">
<span class="text-[0.8rem] text-[var(--text-color-secondary)] shrink-0">País</span>
<span class="text-[0.8rem] text-[var(--text-color)] font-medium text-right">{{ dash(pais) }}</span>
</div>
<div class="flex items-baseline justify-between gap-4 py-1.5 border-b border-[var(--surface-border)]">
<span class="text-[0.8rem] text-[var(--text-color-secondary)] shrink-0">Cidade</span>
<span class="text-[0.8rem] text-[var(--text-color)] font-medium text-right">{{ dash(cidade) }}</span>
</div>
<div class="flex items-baseline justify-between gap-4 py-1.5 border-b border-[var(--surface-border)]">
<span class="text-[0.8rem] text-[var(--text-color-secondary)] shrink-0">Estado</span>
<span class="text-[0.8rem] text-[var(--text-color)] font-medium text-right">{{ dash(estado) }}</span>
</div>
<div class="flex items-baseline justify-between gap-4 py-1.5 border-b border-[var(--surface-border)]">
<span class="text-[0.8rem] text-[var(--text-color-secondary)] shrink-0">Endereço</span>
<span class="text-[0.8rem] text-[var(--text-color)] font-medium text-right">{{ dash(endereco) }}</span>
</div>
<div class="flex items-baseline justify-between gap-4 py-1.5 border-b border-[var(--surface-border)]">
<span class="text-[0.8rem] text-[var(--text-color-secondary)] shrink-0">Número</span>
<span class="text-[0.8rem] text-[var(--text-color)] font-medium text-right">{{ dash(numero) }}</span>
</div>
<div class="flex items-baseline justify-between gap-4 py-1.5 border-b border-[var(--surface-border)]">
<span class="text-[0.8rem] text-[var(--text-color-secondary)] shrink-0">Bairro</span>
<span class="text-[0.8rem] text-[var(--text-color)] font-medium text-right">{{ dash(bairro) }}</span>
</div>
<div class="flex items-baseline justify-between gap-4 py-1.5">
<span class="text-[0.8rem] text-[var(--text-color-secondary)] shrink-0">Complemento</span>
<span class="text-[0.8rem] text-[var(--text-color)] font-medium text-right">{{ dash(complemento) }}</span>
</div>
</div>
</div>
</div>
</AccordionContent>
</AccordionPanel>
<!-- 3. DADOS ADICIONAIS -->
<AccordionPanel value="2">
<AccordionHeader :ref="(el) => setPanelHeaderRef(el, 2)">
<span class="flex items-center gap-2 text-[0.83rem] font-bold">
<i class="pi pi-tags text-indigo-500 text-sm" />
3. Dados Adicionais
</span>
</AccordionHeader>
<AccordionContent>
<div class="pt-3">
<div class="rounded-xl border border-[var(--surface-border)] bg-[var(--surface-ground)] p-4">
<p class="text-[0.65rem] font-bold uppercase tracking-widest text-[var(--text-color-secondary)] mb-3 opacity-70">Formação & família</p>
<div class="grid grid-cols-1 sm:grid-cols-2">
<div class="flex items-baseline justify-between gap-4 py-1.5 border-b border-[var(--surface-border)]">
<span class="text-[0.8rem] text-[var(--text-color-secondary)] shrink-0">Escolaridade</span>
<span class="text-[0.8rem] text-[var(--text-color)] font-medium text-right">{{ dash(escolaridade) }}</span>
</div>
<div class="flex items-baseline justify-between gap-4 py-1.5 border-b border-[var(--surface-border)]">
<span class="text-[0.8rem] text-[var(--text-color-secondary)] shrink-0">Profissão</span>
<span class="text-[0.8rem] text-[var(--text-color)] font-medium text-right">{{ dash(profissao) }}</span>
</div>
<div class="flex items-baseline justify-between gap-4 py-1.5 border-b border-[var(--surface-border)]">
<span class="text-[0.8rem] text-[var(--text-color-secondary)] shrink-0">Nome de um parente</span>
<span class="text-[0.8rem] text-[var(--text-color)] font-medium text-right">{{ dash(nomeParente) }}</span>
</div>
<div class="flex items-baseline justify-between gap-4 py-1.5 border-b border-[var(--surface-border)]">
<span class="text-[0.8rem] text-[var(--text-color-secondary)] shrink-0">Grau de parentesco</span>
<span class="text-[0.8rem] text-[var(--text-color)] font-medium text-right">{{ dash(grauParentesco) }}</span>
</div>
<div class="flex items-baseline justify-between gap-4 py-1.5">
<span class="text-[0.8rem] text-[var(--text-color-secondary)] shrink-0">Telefone do parente</span>
<a v-if="telefoneParente" :href="`tel:${telefoneParente}`" class="text-[0.8rem] font-medium text-right text-[var(--primary-color)] hover:underline">{{ fmtPhoneMobile(telefoneParente) }}</a>
<span v-else class="text-[0.8rem] text-[var(--text-color)] font-medium text-right"></span>
</div>
</div>
</div>
</div>
</AccordionContent>
</AccordionPanel>
<!-- 4. RESPONSÁVEL -->
<AccordionPanel value="3">
<AccordionHeader :ref="(el) => setPanelHeaderRef(el, 3)">
<span class="flex items-center gap-2 text-[0.83rem] font-bold">
<i class="pi pi-users text-indigo-500 text-sm" />
4. Responsável
</span>
</AccordionHeader>
<AccordionContent>
<div class="pt-3">
<div class="rounded-xl border border-[var(--surface-border)] bg-[var(--surface-ground)] p-4">
<p class="text-[0.65rem] font-bold uppercase tracking-widest text-[var(--text-color-secondary)] mb-3 opacity-70">Dados do responsável</p>
<div class="flex items-baseline justify-between gap-4 py-1.5 border-b border-[var(--surface-border)]">
<span class="text-[0.8rem] text-[var(--text-color-secondary)] shrink-0">Nome</span>
<span class="text-[0.8rem] text-[var(--text-color)] font-medium text-right">{{ dash(nomeResponsavel) }}</span>
</div>
<div class="flex items-baseline justify-between gap-4 py-1.5 border-b border-[var(--surface-border)]">
<span class="text-[0.8rem] text-[var(--text-color-secondary)] shrink-0">CPF</span>
<span class="text-[0.8rem] text-[var(--text-color)] font-medium text-right font-mono">{{ fmtCPF(cpfResponsavel) }}</span>
</div>
<div class="flex items-baseline justify-between gap-4 py-1.5 border-b border-[var(--surface-border)]">
<span class="text-[0.8rem] text-[var(--text-color-secondary)] shrink-0">Telefone</span>
<a v-if="telefoneResponsavel" :href="`tel:${telefoneResponsavel}`" class="text-[0.8rem] font-medium text-right text-[var(--primary-color)] hover:underline">{{ fmtPhoneMobile(telefoneResponsavel) }}</a>
<span v-else class="text-[0.8rem] text-[var(--text-color)] font-medium text-right"></span>
</div>
<div v-if="observacaoResponsavel" class="pt-2">
<p class="text-[0.75rem] text-[var(--text-color-secondary)] mb-1">Observação</p>
<p class="text-[0.83rem] text-[var(--text-color)] leading-relaxed whitespace-pre-wrap">{{ observacaoResponsavel }}</p>
</div>
<div v-else class="flex items-baseline justify-between gap-4 py-1.5">
<span class="text-[0.8rem] text-[var(--text-color-secondary)] shrink-0">Observação</span>
<span class="text-[0.8rem] text-[var(--text-color)] font-medium text-right"></span>
</div>
</div>
</div>
</AccordionContent>
</AccordionPanel>
<!-- 5. ANOTAÇÕES INTERNAS -->
<AccordionPanel value="4">
<AccordionHeader :ref="(el) => setPanelHeaderRef(el, 4)">
<span class="flex items-center gap-2 text-[0.83rem] font-bold">
<i class="pi pi-file-edit text-indigo-500 text-sm" />
5. Anotações Internas
</span>
</AccordionHeader>
<AccordionContent>
<div class="pt-3">
<div class="rounded-xl border border-[var(--surface-border)] bg-[var(--surface-ground)] p-4">
<div class="flex items-center gap-1.5 text-xs text-[var(--text-color-secondary)] opacity-70 mb-3">
<i class="pi pi-lock text-[0.7rem]" />
Campo interno: não aparece no cadastro externo.
</div>
<p class="text-[0.83rem] text-[var(--text-color)] leading-relaxed whitespace-pre-wrap min-h-[4rem]">
{{ dash(notasInternas) }}
</p>
</div>
</div>
</AccordionContent>
</AccordionPanel>
<!-- 6. SESSÕES -->
<AccordionPanel value="5">
<AccordionHeader :ref="(el) => setPanelHeaderRef(el, 5)">
<span class="flex items-center gap-2 text-[0.83rem] font-bold">
<i class="pi pi-calendar text-indigo-500 text-sm" />
6. Sessões
</span>
</AccordionHeader>
<AccordionContent>
<div class="pt-3">
<div v-if="sessionsLoading"
class="flex items-center gap-2 text-sm text-[var(--text-color-secondary)] py-4">
<i class="pi pi-spin pi-spinner" /> Carregando sessões
</div>
<div v-else-if="!sessions.length"
class="text-sm text-[var(--text-color-secondary)] py-4 text-center">
Nenhuma sessão registrada para este paciente.
</div>
<div v-else class="flex flex-col gap-2">
<div
v-for="s in sessions" :key="s.id"
class="rounded-xl border border-[var(--surface-border)]
bg-[var(--surface-ground)] px-4 py-3
flex flex-col sm:flex-row sm:items-center
sm:justify-between gap-2"
>
<div class="flex flex-col gap-1">
<p class="text-[0.85rem] font-semibold text-[var(--text-color)]">
{{ s.titulo_custom || s.titulo || (s.tipo ? s.tipo : 'Sessão') }}
</p>
<div class="text-[0.76rem] text-[var(--text-color-secondary)] flex flex-wrap gap-x-3 gap-y-1">
<span><i class="pi pi-calendar mr-1 opacity-50" />{{ fmtDateTimeBR(s.inicio_em) }}</span>
<span v-if="sessionDuration(s.inicio_em, s.fim_em)">
<i class="pi pi-clock mr-1 opacity-50" />{{ sessionDuration(s.inicio_em, s.fim_em) }}
</span>
<span v-if="s.modalidade">
<i class="pi pi-video mr-1 opacity-50" />{{ s.modalidade === 'online' ? 'Online' : 'Presencial' }}
</span>
</div>
<p v-if="s.observacoes"
class="text-[0.76rem] text-[var(--text-color-secondary)] mt-0.5 line-clamp-2">
{{ s.observacoes }}
</p>
</div>
<Tag :value="STATUS_LABEL[s.status] || s.status || 'Agendado'"
:severity="STATUS_SEVERITY[s.status] || 'info'"
class="shrink-0" />
</div>
</div>
</div>
</AccordionContent>
</AccordionPanel>
</Accordion>
</div>
</div><!-- /aba perfil -->
<!-- ABAS PLACEHOLDER -->
<!--
ABA 2: PRONTUÁRIO EVOLUTIVO em breve
-->
<div v-show="activeTab === 2" class="pp-pron">
<div class="pp-empty pp-empty--rich">
<div class="pp-empty__icon"><i class="pi pi-file-edit" /></div>
<div class="pp-empty__title">Prontuário evolutivo</div>
<div class="pp-empty__sub">Em breve anamnese, evolução das sessões e plano terapêutico em um lugar, com histórico clínico completo do paciente.</div>
</div>
</div>
<!--
ABA 3: AGENDA sessões agrupadas por mês
-->
<div v-show="activeTab === 3" class="pp-ag">
<div v-if="sessionsLoading" class="pp-empty">
<i class="pi pi-spin pi-spinner" /> Carregando
</div>
<template v-else>
<!-- KPIs -->
<div class="pp-ag__kpis">
<article class="pp-kpi" style="--c:var(--primary-color); --c-dim:color-mix(in srgb, var(--primary-color) 10%, transparent); --c-border:color-mix(in srgb, var(--primary-color) 30%, transparent)">
<div class="pp-kpi__shine" />
<header class="pp-kpi__head">
<div class="pp-kpi__icon"><i class="pi pi-calendar" /></div>
<span class="pp-kpi__tag">Total</span>
</header>
<div class="pp-kpi__body">
<div class="pp-kpi__big">{{ sessions.length }}</div>
<div class="pp-kpi__cap">sessões registradas</div>
</div>
</article>
<article class="pp-kpi" style="--c:#4ade80; --c-dim:rgba(74,222,128,0.10); --c-border:rgba(74,222,128,0.30)">
<div class="pp-kpi__shine" />
<header class="pp-kpi__head">
<div class="pp-kpi__icon"><i class="pi pi-check-circle" /></div>
<span class="pp-kpi__tag">Realizadas</span>
</header>
<div class="pp-kpi__body">
<div class="pp-kpi__big">{{ sessoesPresentes }}</div>
<div class="pp-kpi__cap">
<template v-if="sessions.length">{{ Math.round((sessoesPresentes / sessions.length) * 100) }}% do total</template>
<template v-else></template>
</div>
</div>
</article>
<article class="pp-kpi" :style="sessoesFaltadas > 0
? '--c:#f87171; --c-dim:rgba(248,113,113,0.10); --c-border:rgba(248,113,113,0.30)'
: '--c:#94a3b8; --c-dim:rgba(148,163,184,0.10); --c-border:rgba(148,163,184,0.25)'">
<div class="pp-kpi__shine" />
<header class="pp-kpi__head">
<div class="pp-kpi__icon"><i class="pi pi-user-minus" /></div>
<span class="pp-kpi__tag">Faltas</span>
</header>
<div class="pp-kpi__body">
<div class="pp-kpi__big">{{ sessoesFaltadas }}</div>
<div class="pp-kpi__cap">
<template v-if="sessoesCanceladas">+ {{ sessoesCanceladas }} cancel.</template>
<template v-else>nenhuma falta</template>
</div>
</div>
</article>
<article class="pp-kpi" style="--c:#60a5fa; --c-dim:rgba(96,165,250,0.10); --c-border:rgba(96,165,250,0.30)">
<div class="pp-kpi__shine" />
<header class="pp-kpi__head">
<div class="pp-kpi__icon"><i class="pi pi-calendar-clock" /></div>
<span class="pp-kpi__tag">Próxima</span>
</header>
<div class="pp-kpi__body">
<template v-if="proximaSessao">
<div class="pp-kpi__big pp-kpi__big--small">{{ fmtRelative(proximaSessao.inicio_em) }}</div>
<div class="pp-kpi__cap">{{ fmtDateBR(proximaSessao.inicio_em) }} · {{ fmtHourShort(proximaSessao.inicio_em) }}</div>
</template>
<template v-else>
<div class="pp-kpi__big pp-kpi__big--small"></div>
<div class="pp-kpi__cap">Sem futura</div>
</template>
</div>
</article>
</div>
<!-- Filtros -->
<div class="pp-ag__filters" role="tablist">
<button
v-for="f in agendaFilters" :key="f.value"
type="button"
role="tab"
:aria-selected="agendaFilter === f.value"
class="pp-ag__filter"
:class="{ 'is-active': agendaFilter === f.value }"
@click="agendaFilter = f.value"
>
<i :class="f.icon" />
<span>{{ f.label }}</span>
</button>
</div>
<!-- Lista agrupada por mês -->
<div v-if="!agendaAgrupadas.length" class="pp-empty pp-empty--rich">
<div class="pp-empty__icon"><i class="pi pi-calendar-times" /></div>
<div class="pp-empty__title">{{ sessions.length ? 'Nenhuma sessão neste filtro' : 'Sem sessões registradas' }}</div>
<div class="pp-empty__sub">
<template v-if="sessions.length">Tente outro filtro acima ou veja "Todas" pra listar o histórico completo.</template>
<template v-else>As sessões agendadas e atendidas com este paciente aparecerão aqui.</template>
</div>
</div>
<section
v-for="g in agendaAgrupadas" :key="g.key"
class="pp-panel pp-ag__group"
>
<header class="pp-panel__head">
<div class="pp-panel__title"><i class="pi pi-calendar" /> {{ g.label }}</div>
<span class="pp-panel__badge">{{ g.items.length }}</span>
</header>
<ul class="pp-ag__list">
<li
v-for="s in g.items" :key="s.id"
class="pp-ag__item"
:data-status="String(s.status || 'agendado').toLowerCase()"
>
<div class="pp-ag__date">
<span class="pp-ag__date-dow">{{ fmtDayShort(s.inicio_em) }}</span>
<span class="pp-ag__date-day">{{ new Date(s.inicio_em).getDate() }}</span>
<span class="pp-ag__date-time">{{ fmtHourShort(s.inicio_em) }}</span>
</div>
<div class="pp-ag__main">
<div class="pp-ag__top">
<Tag
:value="STATUS_LABEL[s.status] || s.status || 'Agendado'"
:severity="STATUS_SEVERITY[s.status] || 'info'"
class="pp-ag__tag"
/>
<span v-if="s.modalidade" class="pp-ag__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="pp-ag__chip pp-ag__chip--dim">
<i class="pi pi-clock" /> {{ sessionDuration(s.inicio_em, s.fim_em) }}
</span>
<span class="pp-ag__rel">{{ fmtRelative(s.inicio_em) }}</span>
</div>
<div v-if="s.titulo_custom || s.titulo" class="pp-ag__title">
{{ s.titulo_custom || s.titulo }}
</div>
<p v-if="s.observacoes" class="pp-ag__note">{{ s.observacoes }}</p>
</div>
<div class="pp-ag__actions">
<button type="button"
v-tooltip.left="'Marcar como realizada'"
class="pp-ag__act pp-ag__act--ok"
:disabled="sessionBusy"
@click="updateSessionStatus(s, 'realizado', 'Sessão marcada como realizada')">
<i class="pi pi-check-circle" />
</button>
<button type="button"
v-tooltip.left="'Marcar como falta'"
class="pp-ag__act pp-ag__act--warn"
:disabled="sessionBusy"
@click="updateSessionStatus(s, 'faltou', 'Marcada como falta')">
<i class="pi pi-user-minus" />
</button>
<button type="button"
v-tooltip.left="'Cancelar'"
class="pp-ag__act pp-ag__act--danger"
:disabled="sessionBusy"
@click="updateSessionStatus(s, 'cancelado', 'Sessão cancelada')">
<i class="pi pi-ban" />
</button>
</div>
</li>
</ul>
</section>
</template>
</div>
<!--
ABA 4: FINANCEIRO KPIs + tabela
-->
<div v-show="activeTab === 4" class="pp-fin">
<div v-if="financialLoading" class="pp-empty">
<i class="pi pi-spin pi-spinner" /> Carregando
</div>
<div v-else-if="!financialRecords.length" class="pp-empty pp-empty--rich">
<div class="pp-empty__icon"><i class="pi pi-wallet" /></div>
<div class="pp-empty__title">Sem lançamentos financeiros</div>
<div class="pp-empty__sub">Adicione o primeiro lançamento de cobrança ou recebimento deste paciente.</div>
<Button
label="Novo lançamento"
icon="pi pi-plus"
class="pp-empty__cta rounded-full"
@click="onAddFinancial"
/>
</div>
<template v-else>
<!-- KPIs do financeiro -->
<div class="pp-fin__kpis">
<article class="pp-kpi" style="--c:#4ade80; --c-dim:rgba(74,222,128,0.10); --c-border:rgba(74,222,128,0.30)">
<div class="pp-kpi__shine" />
<header class="pp-kpi__head">
<div class="pp-kpi__icon"><i class="pi pi-check-circle" /></div>
<span class="pp-kpi__tag">Pago</span>
</header>
<div class="pp-kpi__body">
<div class="pp-kpi__big pp-kpi__big--small">{{ fmtCurrency(statusFinanceiro.totalPago) }}</div>
<div class="pp-kpi__cap">
{{ financialRecords.filter((r) => !!r.paid_at).length }} {{ financialRecords.filter((r) => !!r.paid_at).length === 1 ? 'lançamento' : 'lançamentos' }}
</div>
</div>
</article>
<article class="pp-kpi" style="--c:var(--primary-color); --c-dim:color-mix(in srgb, var(--primary-color) 10%, transparent); --c-border:color-mix(in srgb, var(--primary-color) 30%, transparent)">
<div class="pp-kpi__shine" />
<header class="pp-kpi__head">
<div class="pp-kpi__icon"><i class="pi pi-clock" /></div>
<span class="pp-kpi__tag">Pendente</span>
</header>
<div class="pp-kpi__body">
<div class="pp-kpi__big pp-kpi__big--small">{{ fmtCurrency(statusFinanceiro.totalPendente) }}</div>
<div class="pp-kpi__cap">
<template v-if="statusFinanceiro.proxVenc">
Próx. venc. {{ fmtDateBR(statusFinanceiro.proxVenc.due_date) }}
</template>
<template v-else>A receber</template>
</div>
</div>
</article>
<article class="pp-kpi" :style="statusFinanceiro.vencidos > 0
? '--c:#f87171; --c-dim:rgba(248,113,113,0.10); --c-border:rgba(248,113,113,0.30)'
: '--c:#94a3b8; --c-dim:rgba(148,163,184,0.10); --c-border:rgba(148,163,184,0.25)'">
<div class="pp-kpi__shine" />
<header class="pp-kpi__head">
<div class="pp-kpi__icon"><i class="pi pi-exclamation-triangle" /></div>
<span class="pp-kpi__tag">Em atraso</span>
</header>
<div class="pp-kpi__body">
<div class="pp-kpi__big pp-kpi__big--small">
{{ statusFinanceiro.vencidos }}
</div>
<div class="pp-kpi__cap">
{{ statusFinanceiro.vencidos === 0 ? 'Tudo em dia' : (statusFinanceiro.vencidos === 1 ? 'lançamento vencido' : 'lançamentos vencidos') }}
</div>
</div>
</article>
</div>
<!-- Tabela de lançamentos -->
<section class="pp-panel pp-fin__list">
<header class="pp-panel__head">
<div class="pp-panel__title"><i class="pi pi-list" /> Lançamentos</div>
<span class="pp-panel__badge">{{ recordsOrdenados.length }}</span>
</header>
<div class="pp-fin__table" role="table">
<div class="pp-fin__row pp-fin__row--head" role="row">
<span role="columnheader">Vencimento</span>
<span role="columnheader">Descrição</span>
<span role="columnheader">Forma</span>
<span role="columnheader" class="pp-fin__col-amount">Valor</span>
<span role="columnheader">Status</span>
</div>
<div
v-for="r in recordsOrdenados" :key="r.id"
class="pp-fin__row"
:data-status="recordStatus(r)"
role="row"
>
<span class="pp-fin__date" role="cell">
<span class="pp-fin__date-main">{{ r.due_date ? fmtDateBR(r.due_date) : fmtDateBR(r.created_at) }}</span>
<span v-if="r.due_date" class="pp-fin__date-rel">{{ fmtRelative(r.due_date) }}</span>
</span>
<span class="pp-fin__desc" role="cell">
{{ r.description || (r.category ? r.category : 'Lançamento') }}
</span>
<span class="pp-fin__method" role="cell">
{{ fmtPaymentMethod(r.payment_method) || '—' }}
</span>
<span class="pp-fin__amount" role="cell">
{{ fmtCurrency(Number(r.amount) || 0) }}
</span>
<span class="pp-fin__status-cell" role="cell">
<span class="pp-fin__status" :data-status="recordStatus(r)">
<span class="pp-fin__status-dot" />
{{ RECORD_STATUS_LABEL[recordStatus(r)] }}
</span>
</span>
</div>
</div>
</section>
</template>
</div>
<!--
ABA 5: DOCUMENTOS KPIs + DocumentsListPage embedded
-->
<div v-show="activeTab === 5" class="pp-doc">
<!-- KPIs ( se tiver algo) -->
<div v-if="documentsLoading" class="pp-empty">
<i class="pi pi-spin pi-spinner" /> Carregando
</div>
<div v-else-if="docTotal" class="pp-doc__kpis">
<article class="pp-kpi" style="--c:var(--primary-color); --c-dim:color-mix(in srgb, var(--primary-color) 10%, transparent); --c-border:color-mix(in srgb, var(--primary-color) 30%, transparent)">
<div class="pp-kpi__shine" />
<header class="pp-kpi__head">
<div class="pp-kpi__icon"><i class="pi pi-folder" /></div>
<span class="pp-kpi__tag">Total</span>
</header>
<div class="pp-kpi__body">
<div class="pp-kpi__big">{{ docTotal }}</div>
<div class="pp-kpi__cap">{{ docSizeTotal }} no total</div>
</div>
</article>
<article v-if="docTopType" class="pp-kpi" style="--c:#0ea5e9; --c-dim:rgba(14,165,233,0.10); --c-border:rgba(14,165,233,0.30)">
<div class="pp-kpi__shine" />
<header class="pp-kpi__head">
<div class="pp-kpi__icon"><i class="pi pi-tag" /></div>
<span class="pp-kpi__tag">Mais comum</span>
</header>
<div class="pp-kpi__body">
<div class="pp-kpi__big pp-kpi__big--small">{{ docTopType.label }}</div>
<div class="pp-kpi__cap">{{ docTopType.count }} {{ docTopType.count === 1 ? 'documento' : 'documentos' }}</div>
</div>
</article>
<article v-if="docLast" class="pp-kpi" style="--c:#4ade80; --c-dim:rgba(74,222,128,0.10); --c-border:rgba(74,222,128,0.30)">
<div class="pp-kpi__shine" />
<header class="pp-kpi__head">
<div class="pp-kpi__icon"><i class="pi pi-clock" /></div>
<span class="pp-kpi__tag">Último</span>
</header>
<div class="pp-kpi__body">
<div class="pp-kpi__big pp-kpi__big--small">{{ fmtRelative(docLast.created_at) }}</div>
<div class="pp-kpi__cap">{{ fmtDateBR(docLast.created_at) }}</div>
</div>
</article>
<article v-if="docPendentes > 0" class="pp-kpi" style="--c:#f59e0b; --c-dim:rgba(245,158,11,0.10); --c-border:rgba(245,158,11,0.30)">
<div class="pp-kpi__shine" />
<header class="pp-kpi__head">
<div class="pp-kpi__icon"><i class="pi pi-exclamation-circle" /></div>
<span class="pp-kpi__tag">Revisão</span>
</header>
<div class="pp-kpi__body">
<div class="pp-kpi__big">{{ docPendentes }}</div>
<div class="pp-kpi__cap">{{ docPendentes === 1 ? 'pendente' : 'pendentes' }}</div>
</div>
</article>
</div>
<!-- Lista (DocumentsListPage embedded) dentro de um panel -->
<section class="pp-panel pp-doc__list">
<DocumentsListPage
:patientId="patient?.id"
:patientName="patient?.nome_completo || patient?.nome_social || ''"
embedded
/>
</section>
</div>
<!--
ABA 6: CONVERSAS KPIs + PatientConversationsTab
-->
<div v-show="activeTab === 6" class="pp-conv">
<div v-if="messagesLoading" class="pp-empty">
<i class="pi pi-spin pi-spinner" /> Carregando
</div>
<template v-else>
<div v-if="convTotal" class="pp-conv__kpis">
<article class="pp-kpi" style="--c:#25d366; --c-dim:rgba(37,211,102,0.10); --c-border:rgba(37,211,102,0.30)">
<div class="pp-kpi__shine" />
<header class="pp-kpi__head">
<div class="pp-kpi__icon"><i class="pi pi-comments" /></div>
<span class="pp-kpi__tag">Mensagens</span>
</header>
<div class="pp-kpi__body">
<div class="pp-kpi__big">{{ convTotal }}</div>
<div class="pp-kpi__cap">
<template v-if="convChannels.length">via {{ convChannels.map(chConvLabel).join(', ') }}</template>
<template v-else>no histórico</template>
</div>
</div>
</article>
<article class="pp-kpi" style="--c:#4ade80; --c-dim:rgba(74,222,128,0.10); --c-border:rgba(74,222,128,0.30)">
<div class="pp-kpi__shine" />
<header class="pp-kpi__head">
<div class="pp-kpi__icon"><i class="pi pi-arrow-down-left" /></div>
<span class="pp-kpi__tag">Recebidas</span>
</header>
<div class="pp-kpi__body">
<div class="pp-kpi__big">{{ convInbound }}</div>
<div class="pp-kpi__cap">
<template v-if="convTotal">{{ Math.round((convInbound / convTotal) * 100) }}% do total</template>
<template v-else></template>
</div>
</div>
</article>
<article class="pp-kpi" style="--c:#60a5fa; --c-dim:rgba(96,165,250,0.10); --c-border:rgba(96,165,250,0.30)">
<div class="pp-kpi__shine" />
<header class="pp-kpi__head">
<div class="pp-kpi__icon"><i class="pi pi-arrow-up-right" /></div>
<span class="pp-kpi__tag">Enviadas</span>
</header>
<div class="pp-kpi__body">
<div class="pp-kpi__big">{{ convOutbound }}</div>
<div class="pp-kpi__cap">
<template v-if="convTotal">{{ Math.round((convOutbound / convTotal) * 100) }}% do total</template>
<template v-else></template>
</div>
</div>
</article>
<article class="pp-kpi" style="--c:var(--primary-color); --c-dim:color-mix(in srgb, var(--primary-color) 10%, transparent); --c-border:color-mix(in srgb, var(--primary-color) 30%, transparent)">
<div class="pp-kpi__shine" />
<header class="pp-kpi__head">
<div class="pp-kpi__icon"><i class="pi pi-clock" /></div>
<span class="pp-kpi__tag">Última</span>
</header>
<div class="pp-kpi__body">
<template v-if="convLast">
<div class="pp-kpi__big pp-kpi__big--small">{{ fmtRelative(convLast.created_at) }}</div>
<div class="pp-kpi__cap">
{{ convLast.direction === 'inbound' ? 'Recebida' : 'Enviada' }}
<span v-if="convFirst" class="pp-kpi__cap-dim">· {{ fmtRelative(convFirst.created_at) }}</span>
</div>
</template>
<template v-else>
<div class="pp-kpi__big pp-kpi__big--small"></div>
<div class="pp-kpi__cap">Sem mensagens</div>
</template>
</div>
</article>
</div>
<!-- Atalho pra abrir o drawer (continuar a conversa) -->
<div class="pp-conv__cta">
<Button
label="Abrir conversa no drawer"
icon="pi pi-whatsapp"
severity="success"
outlined
class="rounded-full"
@click="abrirWhatsappPaciente"
/>
<span class="pp-conv__cta-hint">Continue a conversa em tempo real sem fechar o prontuário.</span>
</div>
<!-- Histórico completo via componente original -->
<section class="pp-panel pp-conv__list">
<PatientConversationsTab
:patient-id="patient?.id"
:patient-name="patient?.nome_completo || patient?.nome_social || ''"
/>
</section>
</template>
</div>
</main>
</div>
</div>
<!-- FOOTER -->
<template #footer>
<div class="flex items-center justify-between gap-2 flex-wrap px-1">
<Button
v-if="activeTab === 1"
:label="allOpen ? 'Fechar seções' : 'Abrir seções'"
:icon="allOpen ? 'pi pi-angle-double-up' : 'pi pi-angle-double-down'"
severity="secondary" outlined class="rounded-full"
@click="toggleAllAccordions"
/>
<div v-else />
<div class="flex items-center gap-2">
<Button label="Copiar resumo" icon="pi pi-copy"
severity="secondary" outlined class="rounded-full"
@click="copyResumo" />
<Button label="Editar paciente" icon="pi pi-pencil"
severity="secondary" outlined class="rounded-full"
@click="editPatient" />
<Button label="Fechar" icon="pi pi-times"
severity="secondary" class="rounded-full"
@click="close" />
</div>
</div>
</template>
</Dialog>
<!-- ConfirmDialog local garante que confirm.require funciona mesmo
se o parent que abriu o prontuário não tiver um <ConfirmDialog>
montado (ex: contexto fora do MelissaLayout). -->
<ConfirmDialog />
<!-- Dialog: adicionar telefone WhatsApp ao paciente.
Salva em patients.telefone (campo único usado pelo
conversationDrawerStore.openForPatient pra criar a thread).
Se foi acionado por uma tentativa de abrir o drawer (flag
_resumeDrawerAfterPhone), o drawer abre automaticamente após
o save bem-sucedido. -->
<Dialog
v-model:visible="phoneDlg"
modal
:draggable="false"
:closable="!phoneSaving"
header="Adicionar telefone WhatsApp"
class="w-[94vw] max-w-md"
pt:mask:class="backdrop-blur-sm"
@hide="_resumeDrawerAfterPhone = false"
>
<div class="flex flex-col gap-3 pt-1">
<FloatLabel variant="on">
<InputMask
id="ppd-phone"
v-model="phoneInput"
mask="(99) 99999-9999"
slotChar="_"
class="w-full"
autofocus
@keydown.enter="savePhone"
/>
<label for="ppd-phone">Telefone WhatsApp</label>
</FloatLabel>
<span class="text-xs text-[var(--text-color-secondary)]">
<i class="pi pi-info-circle text-[0.7rem] mr-1 opacity-70" />
DDD + número. Usado pra enviar/receber mensagens via WhatsApp.
</span>
</div>
<template #footer>
<Button label="Cancelar" severity="secondary" outlined class="rounded-full" :disabled="phoneSaving" @click="phoneDlg = false" />
<Button label="Salvar" icon="pi pi-check" :loading="phoneSaving" class="rounded-full" @click="savePhone" />
</template>
</Dialog>
</template>
<style scoped>
:deep(.accordion-clean .p-accordionpanel) {
border-radius: 0.75rem;
margin-bottom: 0.5rem;
border: 1px solid var(--surface-border);
overflow: hidden;
}
:deep(.accordion-clean .p-accordionpanel:last-child) { margin-bottom: 0; }
:deep(.accordion-clean .p-accordionheader) {
background: var(--surface-ground);
border: none;
padding: 0.875rem 1rem;
}
:deep(.accordion-clean .p-accordionheader:hover) {
background: var(--surface-hover, #f1f5f9);
}
:deep(.accordion-clean .p-accordioncontent-content) {
padding: 0 1rem 1rem;
border-top: 1px solid var(--surface-border);
}
.scrollbar-none::-webkit-scrollbar { display: none; }
.scrollbar-none { -ms-overflow-style: none; scrollbar-width: none; }
/* ════════════════════════════════════════════════════════════════
HEADER do Dialog — compacto, max 2 linhas, altura padrão.
Sem #header slot grande; só avatar mini + nome + meta inline.
════════════════════════════════════════════════════════════════ */
.pp-head {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
flex: 1;
}
.pp-head__avatar {
flex-shrink: 0;
width: 32px;
height: 32px;
border-radius: 50%;
overflow: hidden;
background: linear-gradient(135deg, var(--primary-color), color-mix(in srgb, var(--primary-color) 70%, white));
display: grid;
place-items: center;
}
.pp-head__avatar img { width: 100%; height: 100%; object-fit: cover; }
.pp-head__avatar--initials span {
color: white;
font-size: 0.7rem;
font-weight: 700;
letter-spacing: -0.02em;
}
.pp-head__id {
min-width: 0;
flex: 1;
display: flex;
flex-direction: column;
gap: 1px;
}
.pp-head__line1 {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
}
.pp-head__name {
font-size: 0.92rem;
font-weight: 700;
color: var(--text-color);
letter-spacing: -0.01em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
}
.pp-head__line2 {
display: flex;
align-items: center;
gap: 5px;
font-size: 0.7rem;
color: var(--text-color-secondary);
flex-wrap: nowrap;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
}
.pp-head__sep { opacity: 0.4; }
.pp-head__chip {
background: color-mix(in srgb, var(--primary-color) 12%, transparent);
border: 1px solid color-mix(in srgb, var(--primary-color) 25%, transparent);
color: var(--primary-color);
padding: 1px 7px;
border-radius: 100px;
font-size: 0.62rem;
font-weight: 600;
line-height: 1.4;
}
.pp-head__pill {
display: inline-flex;
align-items: center;
gap: 3px;
padding: 1px 7px;
border-radius: 100px;
font-size: 0.6rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
line-height: 1.4;
flex-shrink: 0;
}
.pp-head__pill--danger {
background: rgba(248, 113, 113, 0.12);
border: 1px solid rgba(248, 113, 113, 0.35);
color: #b91c1c;
}
.pp-head__pill i { font-size: 0.55rem; }
/* ════════════════════════════════════════════════════════════════
OVERVIEW — dashboard resumo (estilo HomeCards adaptado)
════════════════════════════════════════════════════════════════ */
/* Tokens + background da Visão Geral vivem no <main> pra cascatear pra
tab bar e demais abas, e pra que o background atmosférico cubra todo
o card (não só a div da Visão Geral). */
.pp-main {
--pp-bg: var(--surface-ground);
--pp-surface: var(--surface-card);
--pp-border: var(--surface-border);
--pp-text: var(--text-color);
--pp-muted: var(--text-color-secondary);
--pp-dim: color-mix(in srgb, var(--text-color-secondary) 60%, transparent);
--pp-primary: var(--primary-color, #7c6af7);
--pp-r: 16px;
--pp-r-lg: 20px;
background: var(--pp-bg);
color: var(--pp-text);
}
.pp-overview {
position: relative;
isolation: isolate;
font-family: 'DM Sans', system-ui, sans-serif;
overflow: hidden;
}
/* Camada decorativa (spots + linhas) */
.pp-ov__bg {
position: absolute;
inset: 0;
pointer-events: none;
z-index: 0;
overflow: hidden;
}
.pp-ov__spot {
position: absolute;
border-radius: 50%;
filter: blur(80px);
}
.pp-ov__spot--a {
width: 480px; height: 480px;
top: -160px; right: -160px;
background: radial-gradient(circle, color-mix(in srgb, var(--pp-primary) 14%, transparent), transparent 60%);
animation: pp-flt 22s ease-in-out infinite alternate;
}
.pp-ov__spot--b {
width: 420px; height: 420px;
bottom: -120px; left: -120px;
background: radial-gradient(circle, rgba(74, 222, 128, 0.10), transparent 60%);
animation: pp-flt 28s ease-in-out infinite alternate-reverse;
}
@keyframes pp-flt {
from { transform: translate(0, 0) scale(1); }
to { transform: translate(40px, 30px) scale(1.06); }
}
.pp-ov__line {
position: absolute;
left: 0; right: 0;
height: 1px;
background: linear-gradient(90deg, transparent, color-mix(in srgb, var(--pp-text) 6%, transparent), transparent);
}
.pp-ov__line--1 { top: 140px; }
.pp-ov__line--2 { bottom: 220px; }
.pp-ov__inner {
position: relative;
z-index: 1;
padding: 12px;
display: flex;
flex-direction: column;
gap: 24px;
}
/* ─── KPI CARDS ─── */
.pp-kpis {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 14px;
}
@media (max-width: 1100px) { .pp-kpis { grid-template-columns: repeat(2, 1fr); } }
@media (max-width: 520px) { .pp-kpis { grid-template-columns: 1fr; } }
.pp-kpi {
--c: var(--pp-primary);
--c-dim: color-mix(in srgb, var(--pp-primary) 10%, transparent);
--c-border: color-mix(in srgb, var(--pp-primary) 30%, transparent);
position: relative;
overflow: hidden;
display: flex;
flex-direction: column;
gap: 14px;
padding: 18px 18px 16px;
border-radius: var(--pp-r-lg);
border: 1px solid var(--pp-border);
background: var(--pp-surface);
transition: transform 0.22s cubic-bezier(0.22, 1, 0.36, 1),
border-color 0.22s ease,
box-shadow 0.22s ease;
animation: pp-fade 0.5s ease both;
}
.pp-kpi:hover {
transform: translateY(-3px);
border-color: var(--c-border);
box-shadow: 0 14px 36px -14px color-mix(in srgb, var(--c) 26%, transparent),
inset 0 1px 0 color-mix(in srgb, var(--pp-text) 4%, transparent);
}
.pp-kpi:hover .pp-kpi__shine { opacity: 1; }
.pp-kpi:hover .pp-kpi__num { opacity: 0.06; }
.pp-kpi:hover .pp-kpi__icon { background: color-mix(in srgb, var(--c) 18%, transparent); }
.pp-kpi__num {
position: absolute;
top: 8px; right: 14px;
font-size: 3rem;
font-weight: 900;
letter-spacing: -0.06em;
line-height: 1;
color: var(--pp-text);
opacity: 0.025;
pointer-events: none;
user-select: none;
transition: opacity 0.22s ease;
}
.pp-kpi__shine {
position: absolute;
inset: 0;
border-radius: inherit;
opacity: 0;
pointer-events: none;
transition: opacity 0.3s ease;
background: radial-gradient(ellipse at 25% 0%, color-mix(in srgb, var(--c) 12%, transparent), transparent 55%);
}
.pp-kpi__head {
display: flex;
align-items: center;
justify-content: space-between;
}
.pp-kpi__icon {
width: 36px; height: 36px;
display: grid;
place-items: center;
border-radius: 10px;
font-size: 0.95rem;
background: var(--c-dim);
border: 1px solid var(--c-border);
color: var(--c);
transition: background 0.22s ease;
}
.pp-kpi__tag {
font-size: 0.6rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.12em;
color: var(--c);
opacity: 0.75;
}
.pp-kpi__body { flex: 1; }
.pp-kpi__big {
font-size: 1.85rem;
font-weight: 700;
letter-spacing: -0.04em;
line-height: 1.1;
color: var(--pp-text);
}
.pp-kpi__big--small {
font-size: 1.05rem;
line-height: 1.2;
letter-spacing: -0.02em;
}
.pp-kpi__cap {
margin-top: 6px;
font-size: 0.74rem;
color: var(--pp-muted);
line-height: 1.5;
}
.pp-kpi__cap-warn { color: #f87171; font-weight: 600; }
.pp-kpi__cap-dim { opacity: 0.6; }
/* ─── GRID (timeline + side col) ─── */
.pp-grid {
display: grid;
grid-template-columns: 1fr 360px;
gap: 16px;
}
.pp-grid__col {
display: flex;
flex-direction: column;
gap: 16px;
}
@media (max-width: 1100px) {
.pp-grid { grid-template-columns: 1fr; }
}
/* ─── PANELS ─── */
.pp-panel {
border: 1px solid var(--pp-border);
border-radius: var(--pp-r-lg);
background: var(--pp-surface);
overflow: hidden;
animation: pp-fade 0.5s ease both;
}
.pp-panel__head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 13px 18px;
border-bottom: 1px solid var(--pp-border);
}
.pp-panel__title {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.78rem;
font-weight: 600;
color: var(--pp-text);
opacity: 0.85;
}
.pp-panel__title .pi { font-size: 0.78rem; opacity: 0.6; }
.pp-panel__badge {
font-size: 0.6rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
padding: 2px 9px;
border-radius: 100px;
background: var(--pp-bg);
border: 1px solid var(--pp-border);
color: var(--pp-muted);
}
.pp-panel__body {
padding: 16px 18px;
}
.pp-empty {
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
gap: 8px;
padding: 28px 12px;
color: var(--pp-dim);
font-size: 0.82rem;
text-align: center;
}
.pp-empty .pi { font-size: 1.3rem; opacity: 0.5; }
/* Variante rica — border tracejada, ícone grande, CTA. Usada quando
o empty state precisa convidar a uma ação (ex: financeiro vazio). */
.pp-empty--rich {
gap: 14px;
padding: 48px 24px;
border: 2px dashed color-mix(in srgb, var(--primary-color) 35%, var(--surface-border));
border-radius: 16px;
background:
radial-gradient(ellipse at top, color-mix(in srgb, var(--primary-color) 5%, transparent), transparent 70%),
var(--surface-card);
color: var(--text-color-secondary);
}
.pp-empty--rich .pp-empty__icon {
width: 72px;
height: 72px;
display: grid;
place-items: center;
border-radius: 50%;
background: color-mix(in srgb, var(--primary-color) 10%, transparent);
border: 1px solid color-mix(in srgb, var(--primary-color) 25%, transparent);
color: var(--primary-color);
margin-bottom: 4px;
}
.pp-empty--rich .pp-empty__icon .pi {
font-size: 2rem;
opacity: 1;
}
.pp-empty--rich .pp-empty__title {
font-size: 1rem;
font-weight: 700;
color: var(--text-color);
letter-spacing: -0.02em;
}
.pp-empty--rich .pp-empty__sub {
font-size: 0.82rem;
color: var(--text-color-secondary);
max-width: 340px;
line-height: 1.5;
}
.pp-empty--rich .pp-empty__cta {
margin-top: 6px;
}
/* ─── TIMELINE ─── */
.pp-timeline {
list-style: none;
margin: 0;
padding: 0;
position: relative;
}
.pp-timeline::before {
content: '';
position: absolute;
left: 7px;
top: 6px;
bottom: 6px;
width: 1px;
background: linear-gradient(180deg, var(--pp-border) 0%, transparent 100%);
}
.pp-tl {
position: relative;
padding: 0 0 14px 28px;
--tl-c: #94a3b8;
}
.pp-tl:last-child { padding-bottom: 0; }
.pp-tl[data-status*="realiz"] { --tl-c: #4ade80; }
.pp-tl[data-status*="present"] { --tl-c: #4ade80; }
.pp-tl[data-status*="falt"] { --tl-c: #f87171; }
.pp-tl[data-status*="cancel"], .pp-tl[data-status*="remarc"] { --tl-c: #f59e0b; }
.pp-tl[data-status*="agend"] { --tl-c: #60a5fa; }
.pp-tl__dot {
position: absolute;
left: 0;
top: 4px;
width: 14px;
height: 14px;
border-radius: 50%;
background: var(--pp-surface);
border: 2px solid var(--tl-c);
box-shadow: 0 0 0 3px var(--pp-surface), 0 0 12px color-mix(in srgb, var(--tl-c) 50%, transparent);
}
.pp-tl__main { display: flex; flex-direction: column; gap: 6px; }
.pp-tl__top {
display: flex;
align-items: baseline;
gap: 8px;
flex-wrap: wrap;
}
.pp-tl__when {
font-size: 0.78rem;
font-weight: 600;
color: var(--pp-text);
}
.pp-tl__rel {
font-size: 0.7rem;
color: var(--pp-muted);
opacity: 0.75;
}
.pp-tl__row {
display: flex;
gap: 6px;
align-items: center;
flex-wrap: wrap;
}
.pp-tl__tag :deep(.p-tag) {
font-size: 0.62rem !important;
padding: 2px 8px !important;
border-radius: 100px !important;
}
.pp-tl__chip {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 0.66rem;
color: var(--pp-muted);
padding: 2px 8px;
border-radius: 100px;
background: var(--pp-bg);
border: 1px solid var(--pp-border);
}
.pp-tl__chip i { font-size: 0.6rem; opacity: 0.7; }
.pp-tl__chip--dim { opacity: 0.7; }
.pp-tl__note {
margin: 0;
font-size: 0.74rem;
color: var(--pp-muted);
line-height: 1.5;
padding: 6px 10px;
background: var(--pp-bg);
border-left: 2px solid var(--tl-c);
border-radius: 4px;
}
/* ─── MENSAGENS ─── */
.pp-msgs {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 10px;
}
.pp-msg {
background: var(--pp-bg);
border: 1px solid var(--pp-border);
border-radius: 12px;
padding: 10px 12px;
transition: border-color 0.16s ease, transform 0.16s ease;
}
.pp-msg:hover {
transform: translateX(2px);
border-color: color-mix(in srgb, var(--pp-primary) 30%, var(--pp-border));
}
.pp-msg--in { border-left: 3px solid #4ade80; }
.pp-msg--out { border-left: 3px solid #60a5fa; }
.pp-msg__meta {
display: flex;
align-items: center;
gap: 5px;
font-size: 0.66rem;
color: var(--pp-muted);
margin-bottom: 5px;
text-transform: uppercase;
letter-spacing: 0.04em;
font-weight: 600;
}
.pp-msg__meta i { font-size: 0.65rem; }
.pp-msg--in .pp-msg__meta i { color: #4ade80; }
.pp-msg--out .pp-msg__meta i { color: #60a5fa; }
.pp-msg__sep { opacity: 0.4; }
.pp-msg__body {
margin: 0;
font-size: 0.78rem;
line-height: 1.5;
color: var(--pp-text);
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* ─── NOTAS ─── */
.pp-notes {
display: flex;
flex-direction: column;
gap: 14px;
}
.pp-note {
padding: 12px 14px;
background: var(--pp-bg);
border: 1px solid var(--pp-border);
border-radius: 12px;
}
.pp-note__label {
margin: 0 0 6px 0;
font-size: 0.62rem;
text-transform: uppercase;
letter-spacing: 0.1em;
font-weight: 700;
color: var(--pp-muted);
opacity: 0.65;
display: flex;
align-items: center;
gap: 5px;
}
.pp-note__label i { font-size: 0.6rem; }
.pp-note__text {
margin: 0;
font-size: 0.8rem;
line-height: 1.55;
color: var(--pp-text);
white-space: pre-wrap;
word-break: break-word;
}
@keyframes pp-fade {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
/* ════════════════════════════════════════════════════════════════
ABA CONVERSAS — KPIs + CTA + PatientConversationsTab.
════════════════════════════════════════════════════════════════ */
.pp-conv {
padding: 12px;
display: flex;
flex-direction: column;
gap: 16px;
}
.pp-conv__kpis {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 14px;
}
@media (max-width: 1100px) { .pp-conv__kpis { grid-template-columns: repeat(2, 1fr); } }
@media (max-width: 520px) { .pp-conv__kpis { grid-template-columns: 1fr; } }
.pp-conv__cta {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 14px;
border: 1px dashed color-mix(in srgb, var(--primary-color) 30%, var(--surface-border));
border-radius: 14px;
background: color-mix(in srgb, var(--primary-color) 4%, transparent);
}
.pp-conv__cta-hint {
font-size: 0.74rem;
color: var(--text-color-secondary);
line-height: 1.4;
flex: 1;
min-width: 0;
}
@media (max-width: 520px) {
.pp-conv__cta { flex-direction: column; align-items: stretch; }
.pp-conv__cta-hint { text-align: center; }
}
.pp-conv__list { padding: 14px 16px; animation: pp-fade 0.4s ease both; }
/* ════════════════════════════════════════════════════════════════
ABA DOCUMENTOS — KPIs + DocumentsListPage embedded num panel.
════════════════════════════════════════════════════════════════ */
.pp-doc {
padding: 12px;
display: flex;
flex-direction: column;
gap: 16px;
}
.pp-doc__kpis {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 14px;
}
@media (max-width: 1100px) { .pp-doc__kpis { grid-template-columns: repeat(2, 1fr); } }
@media (max-width: 520px) { .pp-doc__kpis { grid-template-columns: 1fr; } }
.pp-doc__list { padding: 14px 16px; animation: pp-fade 0.4s ease both; }
/* O DocumentsListPage embedded já tem header próprio — só refinamos
pra integrar com o panel: removemos seu margin-bottom default e
usamos as cores do tema. */
.pp-doc__list :deep(.pi-spinner) { color: var(--primary-color); }
/* ════════════════════════════════════════════════════════════════
ABA PRONTUÁRIO EVOLUTIVO — wrapper só pra dar respiro ao empty.
════════════════════════════════════════════════════════════════ */
.pp-pron {
padding: 12px;
display: flex;
flex-direction: column;
gap: 16px;
}
/* ════════════════════════════════════════════════════════════════
ABA AGENDA — KPIs + filtros pill + lista agrupada por mês.
════════════════════════════════════════════════════════════════ */
.pp-ag {
padding: 12px;
display: flex;
flex-direction: column;
gap: 16px;
}
.pp-ag__kpis {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 14px;
}
@media (max-width: 1100px) { .pp-ag__kpis { grid-template-columns: repeat(2, 1fr); } }
@media (max-width: 520px) { .pp-ag__kpis { grid-template-columns: 1fr; } }
/* Filtros pill */
.pp-ag__filters {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.pp-ag__filter {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
border-radius: 100px;
border: 1px solid var(--surface-border);
background: var(--surface-card);
color: var(--text-color-secondary);
font-size: 0.74rem;
font-weight: 500;
cursor: pointer;
transition: all 140ms ease;
}
.pp-ag__filter i { font-size: 0.7rem; opacity: 0.7; }
.pp-ag__filter:hover {
color: var(--text-color);
border-color: color-mix(in srgb, var(--primary-color) 40%, var(--surface-border));
}
.pp-ag__filter.is-active {
color: var(--primary-color);
background: color-mix(in srgb, var(--primary-color) 12%, transparent);
border-color: color-mix(in srgb, var(--primary-color) 35%, transparent);
font-weight: 600;
}
.pp-ag__filter.is-active i { opacity: 1; }
.pp-ag__group { animation: pp-fade 0.4s ease both; }
.pp-ag__list {
list-style: none;
margin: 0;
padding: 0;
}
.pp-ag__item {
--st-c: #94a3b8;
display: grid;
grid-template-columns: 80px 1fr auto;
gap: 14px;
padding: 12px 18px;
border-top: 1px solid var(--surface-border);
transition: background-color 140ms ease;
}
.pp-ag__item:hover {
background: color-mix(in srgb, var(--text-color) 3%, transparent);
}
.pp-ag__item:hover .pp-ag__actions { opacity: 1; }
.pp-ag__item[data-status*="realiz"] { --st-c: #4ade80; }
.pp-ag__item[data-status*="present"] { --st-c: #4ade80; }
.pp-ag__item[data-status*="falt"] { --st-c: #f87171; }
.pp-ag__item[data-status*="cancel"], .pp-ag__item[data-status*="remarca"] { --st-c: #f59e0b; }
.pp-ag__item[data-status*="agend"] { --st-c: #60a5fa; }
.pp-ag__date {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
border: 1px solid var(--surface-border);
border-left: 3px solid var(--st-c);
border-radius: 10px;
padding: 6px 4px;
background: var(--surface-card);
min-width: 72px;
}
.pp-ag__date-dow {
font-size: 0.62rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-color-secondary);
}
.pp-ag__date-day {
font-size: 1.4rem;
font-weight: 700;
line-height: 1;
letter-spacing: -0.04em;
color: var(--text-color);
margin: 2px 0;
}
.pp-ag__date-time {
font-size: 0.7rem;
font-variant-numeric: tabular-nums;
color: var(--text-color-secondary);
}
.pp-ag__main {
min-width: 0;
display: flex;
flex-direction: column;
gap: 6px;
justify-content: center;
}
.pp-ag__top {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.pp-ag__tag :deep(.p-tag) {
font-size: 0.62rem !important;
padding: 2px 8px !important;
border-radius: 100px !important;
}
.pp-ag__chip {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 0.66rem;
color: var(--text-color-secondary);
padding: 2px 8px;
border-radius: 100px;
background: var(--surface-ground);
border: 1px solid var(--surface-border);
}
.pp-ag__chip i { font-size: 0.6rem; opacity: 0.75; }
.pp-ag__chip--dim { opacity: 0.7; }
.pp-ag__rel {
margin-left: auto;
font-size: 0.66rem;
color: var(--text-color-secondary);
opacity: 0.75;
}
.pp-ag__title {
font-size: 0.82rem;
font-weight: 600;
color: var(--text-color);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.pp-ag__note {
margin: 0;
font-size: 0.74rem;
color: var(--text-color-secondary);
line-height: 1.5;
padding: 6px 10px;
background: var(--surface-ground);
border-left: 2px solid var(--st-c);
border-radius: 4px;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.pp-ag__actions {
display: flex;
flex-direction: column;
gap: 4px;
opacity: 0;
transition: opacity 140ms ease;
align-self: center;
}
.pp-ag__act {
width: 32px;
height: 32px;
display: grid;
place-items: center;
border: 1px solid var(--surface-border);
background: var(--surface-card);
border-radius: 8px;
color: var(--text-color-secondary);
cursor: pointer;
transition: all 140ms ease;
font-size: 0.78rem;
}
.pp-ag__act:hover:not(:disabled) {
transform: translateX(-1px);
}
.pp-ag__act:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.pp-ag__act--ok:hover:not(:disabled) { color: rgb(16, 185, 129); border-color: rgba(16, 185, 129, 0.5); background: rgba(16, 185, 129, 0.10); }
.pp-ag__act--warn:hover:not(:disabled) { color: rgb(245, 158, 11); border-color: rgba(245, 158, 11, 0.5); background: rgba(245, 158, 11, 0.10); }
.pp-ag__act--danger:hover:not(:disabled) { color: rgb(239, 68, 68); border-color: rgba(239, 68, 68, 0.5); background: rgba(239, 68, 68, 0.10); }
@media (max-width: 760px) {
.pp-ag__item { grid-template-columns: 64px 1fr; padding: 10px 14px; }
.pp-ag__actions { grid-column: 1 / -1; flex-direction: row; opacity: 1; justify-self: end; }
.pp-ag__rel { margin-left: 0; }
}
/* ════════════════════════════════════════════════════════════════
ABA FINANCEIRO — KPIs no topo + tabela de lançamentos.
════════════════════════════════════════════════════════════════ */
.pp-fin {
padding: 12px;
display: flex;
flex-direction: column;
gap: 16px;
}
.pp-fin__kpis {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 14px;
}
@media (max-width: 760px) { .pp-fin__kpis { grid-template-columns: 1fr; } }
.pp-fin__list { animation: pp-fade 0.4s ease both; }
.pp-fin__table {
display: flex;
flex-direction: column;
}
.pp-fin__row {
display: grid;
grid-template-columns: 130px 1fr 110px 110px 110px;
align-items: center;
gap: 12px;
padding: 10px 18px;
border-top: 1px solid var(--surface-border);
transition: background-color 140ms ease;
font-size: 0.78rem;
}
.pp-fin__row:not(.pp-fin__row--head):hover {
background: color-mix(in srgb, var(--text-color) 3%, transparent);
}
.pp-fin__row--head {
border-top: none;
padding: 10px 18px;
background: var(--surface-ground);
font-size: 0.6rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-color-secondary);
opacity: 0.7;
}
@media (max-width: 760px) {
.pp-fin__row {
grid-template-columns: 1fr 1fr;
gap: 6px 12px;
}
.pp-fin__row--head { display: none; }
.pp-fin__date { grid-column: 1 / 2; order: 1; }
.pp-fin__amount { grid-column: 2 / 3; order: 2; text-align: right; }
.pp-fin__desc { grid-column: 1 / -1; order: 3; }
.pp-fin__method { grid-column: 1 / 2; order: 4; }
.pp-fin__status-cell { grid-column: 2 / 3; order: 5; justify-self: end; }
}
.pp-fin__col-amount { text-align: right; }
.pp-fin__date {
display: flex;
flex-direction: column;
gap: 1px;
}
.pp-fin__date-main {
font-weight: 600;
color: var(--text-color);
font-variant-numeric: tabular-nums;
}
.pp-fin__date-rel {
font-size: 0.66rem;
color: var(--text-color-secondary);
opacity: 0.75;
}
.pp-fin__desc {
color: var(--text-color);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.pp-fin__method {
color: var(--text-color-secondary);
font-size: 0.74rem;
}
.pp-fin__amount {
font-weight: 700;
color: var(--text-color);
text-align: right;
font-variant-numeric: tabular-nums;
letter-spacing: -0.01em;
}
.pp-fin__row[data-status="vencido"] .pp-fin__amount { color: rgb(239, 68, 68); }
.pp-fin__row[data-status="pago"] .pp-fin__amount { color: rgb(16, 185, 129); }
.pp-fin__status {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 3px 9px;
border-radius: 100px;
font-size: 0.66rem;
font-weight: 600;
letter-spacing: 0.02em;
border: 1px solid;
--st-c: #94a3b8;
--st-bg: rgba(148, 163, 184, 0.10);
--st-border: rgba(148, 163, 184, 0.30);
color: var(--st-c);
background: var(--st-bg);
border-color: var(--st-border);
}
.pp-fin__status[data-status="pago"] {
--st-c: rgb(16, 185, 129);
--st-bg: rgba(16, 185, 129, 0.10);
--st-border: rgba(16, 185, 129, 0.30);
}
.pp-fin__status[data-status="pendente"] {
--st-c: var(--primary-color);
--st-bg: color-mix(in srgb, var(--primary-color) 10%, transparent);
--st-border: color-mix(in srgb, var(--primary-color) 30%, transparent);
}
.pp-fin__status[data-status="vencido"] {
--st-c: rgb(239, 68, 68);
--st-bg: rgba(239, 68, 68, 0.10);
--st-border: rgba(239, 68, 68, 0.30);
}
.pp-fin__status-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--st-c);
box-shadow: 0 0 6px var(--st-c);
}
/* ════════════════════════════════════════════════════════════════
AÇÕES da sidebar — espelha .evento-actions do MelissaEventoPanel.
Status da próxima sessão (Realizada/Falta/Reagendar/Cancelar)
+ atalhos (Sessões/Conversar/Editar).
════════════════════════════════════════════════════════════════ */
.pp-actions {
display: flex;
flex-direction: column;
gap: 12px;
padding-bottom: 14px;
margin-bottom: 14px;
border-bottom: 1px solid var(--surface-border);
}
.pp-actions__section {
display: flex;
flex-direction: column;
gap: 6px;
}
.pp-actions__label {
font-size: 0.62rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-color-secondary);
opacity: 0.7;
padding-left: 2px;
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.pp-actions__hint {
font-size: 0.6rem;
font-weight: 500;
text-transform: none;
letter-spacing: 0;
opacity: 0.7;
font-style: italic;
}
.pp-actions__group {
display: flex;
gap: 4px;
background: var(--surface-ground);
border: 1px solid var(--surface-border);
border-radius: 12px;
padding: 4px;
}
.pp-act {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
padding: 8px 4px;
background: transparent;
border: none;
color: var(--text-color);
border-radius: 9px;
cursor: pointer;
font-size: 1rem;
font-family: inherit;
transition: background-color 140ms ease, color 140ms ease, transform 140ms ease, box-shadow 140ms ease;
}
.pp-act__label {
font-size: 0.68rem;
line-height: 1.1;
font-weight: 500;
letter-spacing: 0.01em;
white-space: nowrap;
}
.pp-act:hover:not(:disabled) {
background: color-mix(in srgb, var(--text-color) 6%, transparent);
transform: translateY(-1px);
}
.pp-act:focus-visible {
outline: 2px solid var(--primary-color);
outline-offset: 2px;
}
.pp-act:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.pp-act--ok:hover:not(:disabled) {
color: rgb(16, 185, 129);
background: rgba(16, 185, 129, 0.12);
}
.pp-act--warn:hover:not(:disabled) {
color: rgb(245, 158, 11);
background: rgba(245, 158, 11, 0.12);
}
.pp-act--danger:hover:not(:disabled) {
color: rgb(239, 68, 68);
background: rgba(239, 68, 68, 0.12);
}
/* Estado .is-current — destaca o status atual da próxima sessão */
.pp-act.is-current {
background: color-mix(in srgb, var(--text-color) 10%, transparent);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--text-color) 20%, transparent);
}
.pp-act--ok.is-current {
color: rgb(16, 185, 129);
background: rgba(16, 185, 129, 0.16);
box-shadow: inset 0 0 0 1px rgba(16, 185, 129, 0.5);
}
.pp-act--warn.is-current {
color: rgb(245, 158, 11);
background: rgba(245, 158, 11, 0.16);
box-shadow: inset 0 0 0 1px rgba(245, 158, 11, 0.5);
}
.pp-act--danger.is-current {
color: rgb(239, 68, 68);
background: rgba(239, 68, 68, 0.16);
box-shadow: inset 0 0 0 1px rgba(239, 68, 68, 0.5);
}
</style>