Sessoes 1-6 acumuladas: hardening B2, defesa em camadas, +192 testes

Repositorio estava ha ~5 sessoes sem commit. Consolida tudo desde d088a89.

Ver commit.md na raiz para descricao completa por sessao.

# Numeros
- A# auditoria abertos: 0/30
- V# verificacoes abertos: 5/52 (todos adiados com plano)
- T# testes escritos: 10/10
- Vitest: 192/192
- SQL integration: 33/33
- E2E (Playwright, novo): 5/5
- Migrations: 17 (10 novas Sessao 6)
- Areas auditadas: 7 (+documentos com 10 V#)

# Highlights Sessao 6 (hoje)
- V#34/V#41 Opcao B2: tenant_features com plano + override (RPC SECURITY DEFINER, tela /saas/tenant-features)
- A#20 rev2 self-hosted: defesa em 5 camadas (honeypot + rate limit + math captcha condicional + paranoid mode + dashboard /saas/security)
- Documentos hardening (V#43-V#49): tenant scoping em storage policies (vazamento entre clinicas eliminado), RPC validate_share_token, signatures policy granular
- SaaS Twilio Config (/saas/twilio-config): UI editavel para SID/webhook/cotacao; AUTH_TOKEN permanece em env var
- T#9 + T#10: useAgendaEvents.spec.js + Playwright E2E (descobriu bug no front que foi corrigido)

# Sessoes anteriores (1-5) consolidadas
- Sessao 1: auth/router/session, normalizeRole extraido
- Sessao 2: agenda - composables/services consolidados
- Sessao 3: pacientes - tenant_id em todas queries
- Sessao 4: security review pagina publica - 14/15 vulnerabilidades corrigidas
- Sessao 5: SaaS - P0 (A#30: 7 tabelas com RLS off corrigidas)

# .gitignore ajustado
- supabase/* + !supabase/functions/ (mantem 10 edge functions, ignora .temp/migrations gerados pelo CLI)
- database-novo/backups/ (regeneravel via db.cjs backup)
- test-results/ + playwright-report/
- .claude/settings.local.json (config local com senha de dev removida do tracking)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Leonardo
2026-04-19 15:42:46 -03:00
parent d088a89fb7
commit 7c20b518d4
175 changed files with 37325 additions and 37968 deletions
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,667 +0,0 @@
<!--
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/features/patients/PatientsDetailPage.vue
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
-->
<script setup>
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
// ── DADOS MOCKADOS ──────────────────────────────────────────────
const patient = ref({
nome_completo: 'Mariana Lima',
nome_social: null,
pronomes: 'ela/dela',
data_nascimento: '1992-06-14',
cpf: '12345678990',
genero: 'Feminino',
estado_civil: 'Solteira',
escolaridade: 'Superior completo',
profissao: 'Desenvolvedora',
etnia: null,
naturalidade: 'São Carlos',
telefone: '16991234567',
email_principal: 'mariana@email.com',
canal_preferido: 'WhatsApp',
horario_contato: '08h18h',
cep: '13560-000',
cidade: 'São Carlos',
estado: 'SP',
status: 'Ativa',
convenio: 'Unimed',
patient_scope: 'Clínica',
risco_elevado: true,
onde_nos_conheceu: 'Indicação',
encaminhado_por: 'Dr. Roberto (psiq.)',
motivo_saida: null,
avatar_url: null,
})
const tags = ref([
{ id: '1', name: 'Ansiedade', color: '#8B5CF6' },
{ id: '2', name: 'TCC', color: '#10B981' },
])
const metricas = ref({
total_sessoes: 47,
comparecimento_pct: 92,
ltv_total: 8460,
dias_ultima_sessao: 18,
})
const contatos = ref([
{
id: '1', nome: 'Maria Lima', relacao: 'mãe',
telefone: '16988880001', email: 'maria@email.com', is_primario: true,
},
{
id: '2', nome: 'Dr. Roberto Oliveira', relacao: 'psiquiatra',
telefone: '1633221100', email: null, crm: 'CRM 54321', is_primario: false,
},
])
const engajamento = ref({
comparecimento_pct: 92,
pagamentos_em_dia_pct: 100,
tarefas_concluidas_pct: 60,
score_geral: 84,
em_tratamento_meses: 14,
proxima_sessao: '2025-03-27T14:00:00',
})
const timeline = ref([
{ id: '1', titulo: 'Risco elevado sinalizado', subtitulo: 'Atenção', data: '2025-03-12', autor: 'Dra. Ana Lima', cor: '#EF4444' },
{ id: '2', titulo: 'GAD-7 respondido · Score 12 (ansiedade moderada)', data: '2025-03-10', canal: 'via portal', cor: '#10B981' },
{ id: '3', titulo: 'TCLE assinado digitalmente', data: '2024-01-02', canal: 'via portal', cor: '#3B82F6' },
{ id: '4', titulo: 'Primeira sessão realizada', data: '2024-01-15', canal: 'presencial', cor: '#10B981' },
])
// ── NAVEGAÇÃO ────────────────────────────────────────────────────
const activeTab = ref('perfil')
const tabs = [
{ key: 'perfil', label: 'Perfil' },
{ key: 'prontuario', label: 'Prontuário' },
{ key: 'agenda', label: 'Agenda' },
{ key: 'financeiro', label: 'Financeiro' },
{ key: 'documentos', label: 'Documentos' },
]
const sideNavItems = [
{ key: 'dados', label: 'Dados pessoais', icon: 'pi pi-user' },
{ key: 'contato', label: 'Contato & origem', icon: 'pi pi-phone' },
{ key: 'rede', label: 'Rede de suporte', icon: 'pi pi-users' },
{ key: 'engajamento', label: 'Engajamento', icon: 'pi pi-chart-bar' },
{ key: 'timeline', label: 'Linha do tempo', icon: 'pi pi-history' },
]
const activeSideNav = ref('dados')
const isCompact = ref(false)
let mql = null, mqlHandler = null
function syncCompact() { isCompact.value = !!mql?.matches }
onMounted(() => {
mql = window.matchMedia('(max-width: 1023px)')
mqlHandler = () => syncCompact()
mql.addEventListener?.('change', mqlHandler)
mql.addListener?.(mqlHandler)
syncCompact()
})
onBeforeUnmount(() => {
mql?.removeEventListener?.('change', mqlHandler)
mql?.removeListener?.(mqlHandler)
})
function scrollToSection(key) {
activeSideNav.value = key
const el = document.getElementById(`section-${key}`)
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
// ── FORMATADORES ─────────────────────────────────────────────────
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
}
const d = new Date(s)
return isNaN(d) ? null : d
}
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
}
function fmtDateBR(v) {
const d = parseDateLoose(v)
if (!d) return '—'
return `${String(d.getDate()).padStart(2,'0')}/${String(d.getMonth()+1).padStart(2,'0')}/${d.getFullYear()}`
}
function fmtPhone(v) {
const d = String(v ?? '').replace(/\D/g, '')
if (d.length === 11) return `(${d.slice(0,2)}) ${d.slice(2,7)}-${d.slice(7)}`
if (d.length === 10) return `(${d.slice(0,2)}) ${d.slice(2,6)}-${d.slice(6)}`
return v || '—'
}
function maskCPF(v) {
if (!v) return '—'
const d = String(v).replace(/\D/g, '')
return `•••${d.slice(3,6)}••••${d.slice(9)}`
}
function fmtCurrency(v) {
return `R$ ${Number(v).toLocaleString('pt-BR')}`
}
function fmtProximaSessao(iso) {
if (!iso) return '—'
const dt = new Date(iso)
return `${String(dt.getDate()).padStart(2,'0')}/${String(dt.getMonth()+1).padStart(2,'0')} às ${String(dt.getHours()).padStart(2,'0')}h`
}
const ageLabel = computed(() => calcAge(patient.value.data_nascimento))
const birthLabel = computed(() => {
const age = calcAge(patient.value.data_nascimento)
return `${fmtDateBR(patient.value.data_nascimento)}${age != null ? ` (${age} a)` : ''}`
})
function nameInitials(name) {
if (!name) return '?'
return String(name).split(' ').filter(Boolean).slice(0,2).map(w => w[0].toUpperCase()).join('')
}
const initials = computed(() => nameInitials(patient.value.nome_completo))
function hexToRgb(hex) {
const h = String(hex ?? '').replace('#','').trim()
if (h.length !== 6 && h.length !== 3) return null
const full = h.length === 3 ? h.split('').map(c=>c+c).join('') : h
return { r: parseInt(full.slice(0,2),16), g: parseInt(full.slice(2,4),16), b: parseInt(full.slice(4,6),16) }
}
function bestTextColor(hex) {
const rgb = hexToRgb(hex)
if (!rgb) return '#0f172a'
const lum = 0.2126*(rgb.r/255) + 0.7152*(rgb.g/255) + 0.0722*(rgb.b/255)
return lum < 0.45 ? '#ffffff' : '#0f172a'
}
function tagStyle(t) {
const bg = t?.color || t?.cor || ''
return bg ? { backgroundColor: bg, color: bestTextColor(bg) } : {}
}
</script>
<template>
<!-- BARRA SUPERIOR -->
<div class="sticky top-0 z-20 flex items-center justify-between
px-4 py-2.5 bg-[var(--surface-0)]
border-b border-[var(--surface-border)]">
<Button icon="pi pi-arrow-left" label="Pacientes"
severity="secondary" text size="small" />
<div class="flex gap-2">
<Button label="Editar" outlined size="small" />
<Button label="+ Sessão" size="small" />
</div>
</div>
<!-- LAYOUT PRINCIPAL -->
<div class="min-h-screen bg-[var(--surface-ground)]">
<div class="max-w-6xl mx-auto px-4 py-5">
<div class="grid grid-cols-1 lg:grid-cols-[220px_1fr] gap-4 items-start">
<!--
SIDEBAR ESQUERDA
-->
<aside class="lg:sticky lg:top-[57px] space-y-3">
<!-- Bloco avatar + nome + badges + métricas -->
<div class="rounded-xl border border-[var(--surface-border)]
bg-[var(--surface-card)] p-4 shadow-sm">
<div class="flex flex-col items-center text-center gap-2.5">
<!-- Avatar ou iniciais -->
<div v-if="patient.avatar_url"
class="w-16 h-16 rounded-full overflow-hidden">
<img :src="patient.avatar_url" class="w-full h-full object-cover" alt="avatar" />
</div>
<div v-else
class="w-16 h-16 rounded-full bg-indigo-100
flex items-center justify-center">
<span class="text-xl font-bold text-indigo-700 tracking-tight">{{ initials }}</span>
</div>
<!-- Nome e sub-linha -->
<div>
<p class="text-sm font-bold text-[var(--text-color)] leading-tight">
{{ patient.nome_completo }}
</p>
<p class="text-xs text-[var(--text-color-secondary)] mt-0.5">
{{ ageLabel }} anos · {{ patient.pronomes }}
</p>
<p class="text-xs text-[var(--text-color-secondary)]">
{{ patient.naturalidade }}, {{ patient.estado }}
</p>
</div>
<!-- Status + convenio + scope -->
<div class="flex flex-wrap justify-center gap-1">
<Tag value="Ativa" severity="success" class="!text-[0.7rem]" />
<Tag :value="patient.convenio" severity="info" class="!text-[0.7rem]" />
<Tag :value="patient.patient_scope" severity="secondary" class="!text-[0.7rem]" />
</div>
<!-- Tags com cor -->
<div class="flex flex-wrap justify-center gap-1">
<span v-for="tag in tags" :key="tag.id"
class="inline-flex items-center px-2 py-0.5 rounded-full text-[0.7rem] font-medium"
:style="tagStyle(tag)">
{{ tag.name }}
</span>
</div>
</div>
<Divider class="!my-3" />
<!-- Métricas 2x2 -->
<div class="grid grid-cols-2 gap-3 text-center">
<div>
<p class="text-xl font-bold text-[var(--text-color)]">{{ metricas.total_sessoes }}</p>
<p class="text-[0.68rem] text-[var(--text-color-secondary)] mt-0.5">Sessões</p>
</div>
<div>
<p class="text-xl font-bold text-emerald-600">{{ metricas.comparecimento_pct }}%</p>
<p class="text-[0.68rem] text-[var(--text-color-secondary)] mt-0.5">Comparec.</p>
</div>
<div>
<p class="text-base font-bold text-[var(--text-color)]">{{ fmtCurrency(metricas.ltv_total) }}</p>
<p class="text-[0.68rem] text-[var(--text-color-secondary)] mt-0.5">LTV total</p>
</div>
<div>
<p class="text-xl font-bold text-amber-500">{{ metricas.dias_ultima_sessao }}d</p>
<p class="text-[0.68rem] text-[var(--text-color-secondary)] mt-0.5">Últ. sessão</p>
</div>
</div>
</div>
<!-- Nav lateral (desktop + aba perfil) -->
<div v-if="!isCompact && activeTab === 'perfil'"
class="rounded-xl border border-[var(--surface-border)]
bg-[var(--surface-card)] p-2 shadow-sm">
<button
v-for="item in sideNavItems" :key="item.key"
type="button"
class="flex w-full items-center gap-2.5 rounded-lg px-3 py-2
text-left text-sm border transition-colors duration-100"
:class="activeSideNav === item.key
? '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="scrollToSection(item.key)"
>
<i :class="item.icon" class="text-sm opacity-60 shrink-0" />
<span>{{ item.label }}</span>
</button>
</div>
</aside>
<!--
CONTEÚDO DIREITA
-->
<div class="min-w-0 space-y-4">
<!-- Banner risco elevado -->
<div v-if="patient.risco_elevado"
class="flex items-start gap-3 rounded-xl border border-red-200
bg-red-50 px-4 py-3">
<i class="pi pi-circle-fill text-red-500 mt-0.5 text-[0.5rem]" />
<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">
Ideação passiva relatada em 12/03 · Sinalizado por Dra. Ana Lima
</p>
</div>
</div>
<!-- PAINEL COM TABS -->
<div class="rounded-xl border border-[var(--surface-border)]
bg-[var(--surface-card)] shadow-sm overflow-hidden">
<!-- Tab bar -->
<div class="flex border-b border-[var(--surface-border)] overflow-x-auto">
<button
v-for="tab in tabs" :key="tab.key"
type="button"
class="shrink-0 px-5 py-3 text-sm font-medium border-b-2
transition-colors duration-100 whitespace-nowrap"
:class="activeTab === tab.key
? 'border-[var(--primary-color)] text-[var(--primary-color)]'
: 'border-transparent text-[var(--text-color-secondary)] hover:text-[var(--text-color)]'"
@click="activeTab = tab.key"
>
{{ tab.label }}
</button>
</div>
<!-- ABA PERFIL -->
<div v-if="activeTab === 'perfil'" class="p-4 space-y-4">
<!-- DADOS PESSOAIS + CONTATO/ORIGEM -->
<div id="section-dados" class="grid grid-cols-1 md:grid-cols-2 gap-4">
<!-- Dados pessoais -->
<div class="rounded-xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-4">
<p class="text-[0.65rem] font-bold uppercase tracking-widest text-[var(--text-color-secondary)] mb-3">
DADOS PESSOAIS
</p>
<div class="flex items-baseline justify-between gap-4 py-1.5 border-b border-[var(--surface-border)]">
<span class="text-[0.82rem] text-[var(--text-color-secondary)] shrink-0">Nome completo</span>
<span class="text-[0.82rem] text-[var(--text-color)] font-medium text-right">{{ patient.nome_completo }}</span>
</div>
<div class="flex items-baseline justify-between gap-4 py-1.5 border-b border-[var(--surface-border)]">
<span class="text-[0.82rem] text-[var(--text-color-secondary)] shrink-0">Nome social</span>
<span class="text-[0.82rem] text-[var(--text-color)] font-medium text-right flex items-center gap-1.5">
<span class="text-[var(--text-color-secondary)]"></span>
<span class="text-[0.65rem] font-semibold text-amber-600 bg-amber-50 border border-amber-200 px-1.5 py-0.5 rounded-full">novo</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.82rem] text-[var(--text-color-secondary)] shrink-0">Pronomes</span>
<span class="text-[0.82rem] text-[var(--text-color)] font-medium text-right flex items-center gap-1.5">
{{ patient.pronomes }}
<span class="text-[0.65rem] font-semibold text-amber-600 bg-amber-50 border border-amber-200 px-1.5 py-0.5 rounded-full">novo</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.82rem] text-[var(--text-color-secondary)] shrink-0">Data de nascimento</span>
<span class="text-[0.82rem] text-[var(--text-color)] font-medium text-right">{{ birthLabel }}</span>
</div>
<div class="flex items-baseline justify-between gap-4 py-1.5 border-b border-[var(--surface-border)]">
<span class="text-[0.82rem] text-[var(--text-color-secondary)] shrink-0">CPF</span>
<span class="text-[0.82rem] text-[var(--text-color)] font-medium text-right font-mono">{{ maskCPF(patient.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.82rem] text-[var(--text-color-secondary)] shrink-0">Gênero</span>
<span class="text-[0.82rem] text-[var(--text-color)] font-medium text-right">{{ patient.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.82rem] text-[var(--text-color-secondary)] shrink-0">Estado civil</span>
<span class="text-[0.82rem] text-[var(--text-color)] font-medium text-right">{{ patient.estado_civil }}</span>
</div>
<div class="flex items-baseline justify-between gap-4 py-1.5 border-b border-[var(--surface-border)]">
<span class="text-[0.82rem] text-[var(--text-color-secondary)] shrink-0">Escolaridade</span>
<span class="text-[0.82rem] text-[var(--text-color)] font-medium text-right">{{ patient.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.82rem] text-[var(--text-color-secondary)] shrink-0">Profissão</span>
<span class="text-[0.82rem] text-[var(--text-color)] font-medium text-right">{{ patient.profissao }}</span>
</div>
<div class="flex items-baseline justify-between gap-4 py-1.5">
<span class="text-[0.82rem] text-[var(--text-color-secondary)] shrink-0">Etnia</span>
<span class="text-[0.82rem] text-[var(--text-color)] font-medium text-right flex items-center gap-1.5">
<span class="italic text-[var(--text-color-secondary)]">Não informado</span>
<span class="text-[0.65rem] font-semibold text-amber-600 bg-amber-50 border border-amber-200 px-1.5 py-0.5 rounded-full">novo</span>
</span>
</div>
</div>
<!-- Coluna direita: Contato + Origem -->
<div id="section-contato" class="space-y-4">
<!-- Contato -->
<div class="rounded-xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-4">
<p class="text-[0.65rem] font-bold uppercase tracking-widest text-[var(--text-color-secondary)] mb-3">
CONTATO
</p>
<div class="flex items-baseline justify-between gap-4 py-1.5 border-b border-[var(--surface-border)]">
<span class="text-[0.82rem] text-[var(--text-color-secondary)] shrink-0">WhatsApp</span>
<a :href="`tel:${patient.telefone}`"
class="text-[0.82rem] font-medium text-right text-[var(--primary-color)] hover:underline">
{{ fmtPhone(patient.telefone) }}
</a>
</div>
<div class="flex items-baseline justify-between gap-4 py-1.5 border-b border-[var(--surface-border)]">
<span class="text-[0.82rem] text-[var(--text-color-secondary)] shrink-0">Email</span>
<a :href="`mailto:${patient.email_principal}`"
class="text-[0.82rem] font-medium text-right text-[var(--primary-color)] hover:underline truncate max-w-[180px] inline-block">
{{ patient.email_principal }}
</a>
</div>
<div class="flex items-baseline justify-between gap-4 py-1.5 border-b border-[var(--surface-border)]">
<span class="text-[0.82rem] text-[var(--text-color-secondary)] shrink-0">Canal preferido</span>
<span class="text-[0.82rem] text-[var(--text-color)] font-medium text-right flex items-center gap-1.5">
{{ patient.canal_preferido }}
<span class="text-[0.65rem] font-semibold text-amber-600 bg-amber-50 border border-amber-200 px-1.5 py-0.5 rounded-full">novo</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.82rem] text-[var(--text-color-secondary)] shrink-0">Horário de contato</span>
<span class="text-[0.82rem] text-[var(--text-color)] font-medium text-right flex items-center gap-1.5">
{{ patient.horario_contato }}
<span class="text-[0.65rem] font-semibold text-amber-600 bg-amber-50 border border-amber-200 px-1.5 py-0.5 rounded-full">novo</span>
</span>
</div>
<div class="flex items-baseline justify-between gap-4 py-1.5">
<span class="text-[0.82rem] text-[var(--text-color-secondary)] shrink-0">CEP</span>
<span class="text-[0.82rem] text-[var(--text-color)] font-medium text-right">
{{ patient.cep }} · {{ patient.cidade }}
</span>
</div>
</div>
<!-- Origem -->
<div class="rounded-xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-4">
<p class="text-[0.65rem] font-bold uppercase tracking-widest text-[var(--text-color-secondary)] mb-3">
ORIGEM
</p>
<div class="flex items-baseline justify-between gap-4 py-1.5 border-b border-[var(--surface-border)]">
<span class="text-[0.82rem] text-[var(--text-color-secondary)] shrink-0">Como chegou</span>
<span class="text-[0.82rem] text-[var(--text-color)] font-medium text-right">{{ patient.onde_nos_conheceu }}</span>
</div>
<div class="flex items-baseline justify-between gap-4 py-1.5 border-b border-[var(--surface-border)]">
<span class="text-[0.82rem] text-[var(--text-color-secondary)] shrink-0">Encaminhado por</span>
<span class="text-[0.82rem] text-[var(--text-color)] font-medium text-right">{{ patient.encaminhado_por }}</span>
</div>
<div class="flex items-baseline justify-between gap-4 py-1.5 border-b border-[var(--surface-border)]">
<span class="text-[0.82rem] text-[var(--text-color-secondary)] shrink-0">Método de pag.</span>
<span class="text-[0.82rem] text-[var(--text-color)] font-medium text-right flex items-center gap-1.5">
PIX
<span class="text-[0.65rem] font-semibold text-amber-600 bg-amber-50 border border-amber-200 px-1.5 py-0.5 rounded-full">novo</span>
</span>
</div>
<div class="flex items-baseline justify-between gap-4 py-1.5">
<span class="text-[0.82rem] text-[var(--text-color-secondary)] shrink-0">Motivo de saída</span>
<span class="text-[0.82rem] text-[var(--text-color)] font-medium text-right flex items-center gap-1.5">
<span class="text-[var(--text-color-secondary)]"></span>
<span class="text-[0.65rem] font-semibold text-amber-600 bg-amber-50 border border-amber-200 px-1.5 py-0.5 rounded-full">novo</span>
</span>
</div>
</div>
</div>
</div>
<!-- REDE DE SUPORTE + ENGAJAMENTO -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<!-- Contatos & rede -->
<div id="section-rede"
class="rounded-xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-4">
<div class="flex items-center gap-2 mb-3">
<p class="text-[0.65rem] font-bold uppercase tracking-widest text-[var(--text-color-secondary)]">
CONTATOS &amp; REDE DE SUPORTE
</p>
<span class="text-[0.65rem] font-semibold text-amber-600 bg-amber-50 border border-amber-200 px-1.5 py-0.5 rounded-full">NOVO</span>
</div>
<div class="space-y-2">
<div v-for="c in contatos" :key="c.id"
class="flex items-start gap-3 rounded-lg border
border-[var(--surface-border)] p-3
bg-[var(--surface-ground)]">
<div class="w-8 h-8 rounded-full bg-indigo-100 flex items-center justify-center shrink-0">
<span class="text-[0.65rem] font-bold text-indigo-700">{{ nameInitials(c.nome) }}</span>
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-1.5 flex-wrap">
<span class="text-[0.82rem] font-semibold text-[var(--text-color)]">{{ c.nome }}</span>
<span class="text-[0.75rem] text-[var(--text-color-secondary)]">· {{ c.relacao }}</span>
<Tag v-if="c.is_primario" value="emergência" severity="danger"
class="!text-[0.65rem] !py-0 !px-1.5 shrink-0" />
</div>
<p class="text-[0.76rem] text-[var(--text-color-secondary)] mt-0.5">
<a :href="`tel:${c.telefone}`" class="text-[var(--primary-color)] hover:underline">{{ fmtPhone(c.telefone) }}</a>
<template v-if="c.email"> · {{ c.email }}</template>
<template v-if="c.crm"> · {{ c.crm }}</template>
</p>
</div>
</div>
</div>
<button type="button"
class="mt-3 w-full flex items-center gap-2.5 px-3 py-2 rounded-lg
border border-dashed border-[var(--surface-border)]
text-[0.82rem] text-[var(--text-color-secondary)]
hover:bg-[var(--surface-ground)] transition-colors">
<span class="w-7 h-7 rounded-full bg-[var(--surface-ground)] border border-[var(--surface-border)] flex items-center justify-center">
<i class="pi pi-plus text-[0.65rem]" />
</span>
Adicionar contato
</button>
</div>
<!-- Engajamento -->
<div id="section-engajamento"
class="rounded-xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-4">
<div class="flex items-center gap-2 mb-4">
<p class="text-[0.65rem] font-bold uppercase tracking-widest text-[var(--text-color-secondary)]">
ENGAJAMENTO
</p>
<span class="text-[0.65rem] font-semibold text-amber-600 bg-amber-50 border border-amber-200 px-1.5 py-0.5 rounded-full">NOVO</span>
</div>
<div class="space-y-3">
<div>
<div class="flex justify-between text-[0.78rem] mb-1.5">
<span class="text-[var(--text-color-secondary)]">Comparecimento</span>
<span class="font-semibold text-emerald-600">{{ engajamento.comparecimento_pct }}%</span>
</div>
<ProgressBar :value="engajamento.comparecimento_pct" :showValue="false" class="progress-success" />
</div>
<div>
<div class="flex justify-between text-[0.78rem] mb-1.5">
<span class="text-[var(--text-color-secondary)]">Pagamentos em dia</span>
<span class="font-semibold text-emerald-600">{{ engajamento.pagamentos_em_dia_pct }}%</span>
</div>
<ProgressBar :value="engajamento.pagamentos_em_dia_pct" :showValue="false" class="progress-success" />
</div>
<div>
<div class="flex justify-between text-[0.78rem] mb-1.5">
<span class="text-[var(--text-color-secondary)]">Tarefas concluídas</span>
<span class="font-semibold text-amber-500">{{ engajamento.tarefas_concluidas_pct }}%</span>
</div>
<ProgressBar :value="engajamento.tarefas_concluidas_pct" :showValue="false" class="progress-warning" />
</div>
</div>
<Divider class="!my-3" />
<div class="flex items-baseline justify-between gap-4 py-1.5 border-b border-[var(--surface-border)]">
<span class="text-[0.82rem] text-[var(--text-color-secondary)] shrink-0">Score geral</span>
<span class="text-[0.82rem] text-[var(--text-color)] font-medium text-right">
<span class="text-lg font-bold">{{ engajamento.score_geral }}</span> / 100
</span>
</div>
<div class="flex items-baseline justify-between gap-4 py-1.5 border-b border-[var(--surface-border)]">
<span class="text-[0.82rem] text-[var(--text-color-secondary)] shrink-0">Em tratamento </span>
<span class="text-[0.82rem] text-[var(--text-color)] font-medium text-right">{{ engajamento.em_tratamento_meses }} meses</span>
</div>
<div class="flex items-baseline justify-between gap-4 py-1.5">
<span class="text-[0.82rem] text-[var(--text-color-secondary)] shrink-0">Próxima sessão</span>
<span class="text-[0.82rem] text-[var(--text-color)] font-medium text-right">{{ fmtProximaSessao(engajamento.proxima_sessao) }}</span>
</div>
</div>
</div>
<!-- LINHA DO TEMPO -->
<div id="section-timeline"
class="rounded-xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-4">
<div class="flex items-center gap-2 mb-4">
<p class="text-[0.65rem] font-bold uppercase tracking-widest text-[var(--text-color-secondary)]">
LINHA DO TEMPO
</p>
<span class="text-[0.65rem] font-semibold text-amber-600 bg-amber-50 border border-amber-200 px-1.5 py-0.5 rounded-full">NOVO</span>
</div>
<div class="space-y-0">
<div v-for="(item, idx) in timeline" :key="item.id" class="flex gap-4">
<!-- Dot + linha vertical -->
<div class="flex flex-col items-center">
<div class="w-3 h-3 rounded-full mt-1 shrink-0 ring-2 ring-[var(--surface-card)] shadow-sm"
:style="{ backgroundColor: item.cor }" />
<div v-if="idx < timeline.length - 1"
class="w-px flex-1 bg-[var(--surface-border)] my-1" />
</div>
<!-- Conteúdo -->
<div class="pb-5 min-w-0">
<p class="text-[0.85rem] font-semibold text-[var(--text-color)] leading-snug">
{{ item.titulo }}
<span v-if="item.subtitulo" class="font-normal text-[var(--text-color-secondary)]"> · {{ item.subtitulo }}</span>
</p>
<p class="text-[0.75rem] text-[var(--text-color-secondary)] mt-0.5">
{{ fmtDateBR(item.data) }}
<template v-if="item.autor"> · {{ item.autor }}</template>
<template v-else-if="item.canal"> · {{ item.canal }}</template>
</p>
</div>
</div>
</div>
</div>
</div>
<!-- FIM ABA PERFIL -->
<!-- Placeholder outras abas -->
<div v-if="activeTab !== 'perfil'" class="p-10 text-center text-[var(--text-color-secondary)]">
<i class="pi pi-clock text-4xl mb-3 block opacity-20" />
<p class="text-sm">Em breve</p>
</div>
</div><!-- /painel tabs -->
</div><!-- /conteúdo direita -->
</div><!-- /grid -->
</div><!-- /max-w -->
</div><!-- /wrapper -->
</template>
<style scoped>
:deep(.progress-success .p-progressbar-value) { background: #22c55e; }
:deep(.progress-warning .p-progressbar-value) { background: #f59e0b; }
:deep(.p-progressbar) {
height: 0.45rem;
border-radius: 9999px;
overflow: hidden;
}
:deep(.p-progressbar-value) { border-radius: 9999px; }
</style>