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:
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: '08h–18h',
|
||||
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 & 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 há</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>
|
||||
Reference in New Issue
Block a user