Files
agenciapsilmno/src/features/patients/cadastro/PatientsCadastroPage.vue
T
Leonardo 7c20b518d4 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>
2026-04-19 15:42:46 -03:00

1992 lines
114 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/cadastro/PatientsCadastroPage.vue
| Data: 2026 · São Carlos/SP Brasil
|--------------------------------------------------------------------------
| Engenharia reversa da tela de detalhe (PatientsDetailPage):
|
| SEÇÃO 0 Identidade (indigo)
| nome_completo header: "Mariana Lima"
| nome_social card Dados pessoais: "Nome social" [NOVO]
| pronomes header: "ela/dela" + card Dados pessoais [NOVO]
| data_nascimento header: "32 anos"
| genero card Dados pessoais: "Gênero"
| estado_civil card Dados pessoais: "Estado civil"
| cpf card Dados pessoais: mascarado 45690
| rg card Dados pessoais
| etnia card Dados pessoais: "Etnia" [NOVO]
| naturalidade card Dados pessoais
| profissao card Dados pessoais: "Profissão"
| escolaridade card Dados pessoais: "Escolaridade"
| telefone card Contato: "WhatsApp (16) 99123-4567"
| email_principal card Contato: "mariana@email.com"
| canal_preferido card Contato: "Canal preferido: WhatsApp" [NOVO]
| horario_contato card Contato: "Horário: 08h18h" [NOVO]
|
| SEÇÃO 1 Endereço (teal)
| cep card Contato: "13560-000 · São Carlos"
| cidade / estado header: "São Carlos, SP"
|
| SEÇÃO 2 Clínico & origem (violet)
| status badge verde "Ativa" [NOVO]
| convenio badge azul "Unimed" [NOVO]
| patient_scope badge cinza "Clínica" [NOVO]
| tags[] chips coloridos "Ansiedade", "TCC"
| grupo define modelo de anamnese
| onde_nos_conheceu card Origem: "Como chegou: Indicação"
| encaminhado_por card Origem: "Dr. Roberto (psiq.)"
| metodo_pagamento card Origem: "Método de pag.: PIX" [NOVO]
| motivo_saida card Origem: "Motivo de saída" [NOVO]
|
| SEÇÃO 3 Rede de suporte (amber)
| patient_support_contacts[] card "Contatos & rede de suporte" [NOVO]
| cada item: nome, relacao, tipo, telefone, email, is_primario
| is_primario = true badge vermelho "emergência"
|
| SEÇÃO 4 Responsável (sky)
| nome_responsavel, cpf_responsavel, telefone_responsavel, etc.
|
| SEÇÃO 5 Anotações internas (rose)
| notas_internas campo interno, não vai ao paciente
|--------------------------------------------------------------------------
-->
<script setup>
import { ref, computed, watch, nextTick, onMounted, onBeforeUnmount } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useRoleGuard } from '@/composables/useRoleGuard'
import { useToast } from 'primevue/usetoast'
import { useConfirm } from 'primevue/useconfirm'
import { supabase } from '@/lib/supabase/client'
import { useTenantStore } from '@/stores/tenantStore'
import { logError } from '@/support/supportLogger'
import { digitsOnly, fmtCPF, fmtRG, fmtPhone, toISODate, generateCPF } from '@/utils/validators'
import CadastroRapidoConvenio from '@/components/CadastroRapidoConvenio.vue'
import CadastroRapidoMedico from '@/components/CadastroRapidoMedico.vue'
// ─────────────────────────────────────────────────────────
// Props / emits
// ─────────────────────────────────────────────────────────
const props = defineProps({
dialogMode: { type: Boolean, default: false },
patientId: { type: String, default: null },
})
const emit = defineEmits(['cancel', 'created'])
// ─────────────────────────────────────────────────────────
// Infra
// ─────────────────────────────────────────────────────────
const { canSee } = useRoleGuard()
const route = useRoute()
const router = useRouter()
const toast = useToast()
const confirm = useConfirm()
const tenantStore = useTenantStore()
async function getCurrentTenantId () {
return tenantStore.tenantId || tenantStore.currentTenantId || tenantStore.tenant?.id
}
// Helper sync para blindar queries com .eq('tenant_id', ...) — defesa em profundidade.
function currentTenantId () {
return tenantStore.activeTenantId || tenantStore.tenantId || tenantStore.tenant?.id || null
}
async function getCurrentMemberId (tenantId) {
const { data: a, error: ae } = await supabase.auth.getUser(); if (ae) throw ae
const uid = a?.user?.id; if (!uid) throw new Error('Sessão inválida.')
const { data, error } = await supabase.from('tenant_members').select('id')
.eq('tenant_id', tenantId).eq('user_id', uid).eq('status', 'active').single()
if (error) throw error
if (!data?.id) throw new Error('Responsible member not found')
return data.id
}
async function getOwnerId () {
const { data, error } = await supabase.auth.getUser(); if (error) throw error
const uid = data?.user?.id; if (!uid) throw new Error('Sessão inválida.'); return uid
}
async function resolveTenantContextOrFail () {
const { data: a, error: ae } = await supabase.auth.getUser(); if (ae) throw ae
const uid = a?.user?.id; if (!uid) throw new Error('Sessão inválida.')
const storeTid = await getCurrentTenantId()
if (storeTid) {
try { const mid = await getCurrentMemberId(storeTid); return { tenantId: storeTid, memberId: mid } } catch (_) {}
}
const { data, error } = await supabase.from('tenant_members').select('id, tenant_id')
.eq('user_id', uid).eq('status', 'active').order('created_at', { ascending: false }).limit(1).single()
if (error) throw error
if (!data?.tenant_id || !data?.id) throw new Error('Responsible member not found')
return { tenantId: data.tenant_id, memberId: data.id }
}
// ─────────────────────────────────────────────────────────
// Route helpers
// ─────────────────────────────────────────────────────────
const patientId = computed(() =>
props.dialogMode
? (props.patientId || null)
: (String(route.params?.id || '').trim() || null)
)
const isEdit = computed(() => !!patientId.value)
function getAreaKey () {
const seg = String(route.path || '').split('/').filter(Boolean)[0] || 'admin'
return seg === 'therapist' ? 'therapist' : 'admin'
}
function getPatientsRoutes () {
const area = getAreaKey()
if (area === 'therapist') return { listName: 'therapist-patients', listPath: '/therapist/patients' }
return { listName: 'admin-pacientes', listPath: '/admin/pacientes' }
}
async function safePush (toNameObj, fallbackPath) {
try { const r = router.resolve(toNameObj); if (r?.matched?.length) return router.push(toNameObj) } catch (_) {}
return router.push(fallbackPath)
}
function goBack () {
if (props.dialogMode) { emit('cancel'); return }
const { listName, listPath } = getPatientsRoutes()
if (window.history.length > 1) router.back()
else safePush({ name: listName }, listPath)
}
// ─────────────────────────────────────────────────────────
// Accordion + nav
// ─────────────────────────────────────────────────────────
const activeValue = ref('0')
const panelHeaderRefs = ref([])
function setPanelHeaderRef (el, idx) {
if (!el) return
panelHeaderRefs.value[idx] = el
}
async function openPanel (i) {
activeValue.value = String(i)
await nextTick()
const headerRef = panelHeaderRefs.value?.[i]
const el = headerRef?.$el ?? headerRef
if (!el) return
const sc = el.closest('.l2-main') || document.querySelector('.l2-main')
if (sc) {
const off = el.getBoundingClientRect().top - sc.getBoundingClientRect().top + sc.scrollTop - 16
sc.scrollTo({ top: Math.max(0, off), behavior: 'smooth' })
} else if (typeof el.scrollIntoView === 'function') {
el.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
}
}
// Definição das seções — accent é a chave para a paleta abaixo
const sections = [
{ value: '0', label: 'Identidade', sub: 'Nome · pronomes · CPF · contato', icon: 'pi pi-user', accent: 'indigo' },
{ value: '1', label: 'Endereço', sub: 'CEP · cidade/UF no header', icon: 'pi pi-map-marker', accent: 'teal' },
{ value: '2', label: 'Clínico & origem', sub: 'Badges · tags · convênio · como chegou', icon: 'pi pi-heart', accent: 'violet' },
{ value: '3', label: 'Rede de suporte', sub: 'Card "Contatos & rede de suporte"', icon: 'pi pi-users', accent: 'amber' },
{ value: '4', label: 'Responsável', sub: 'Para menores ou cobrança em terceiro', icon: 'pi pi-id-card', accent: 'sky' },
{ value: '5', label: 'Anotações internas', sub: 'Visível apenas para a equipe', icon: 'pi pi-lock', accent: 'rose' },
]
// Paleta clara por accent
const pal = {
indigo: {
panelBorder: 'border-indigo-100',
bg: 'bg-indigo-50/60',
iconBox: 'bg-indigo-100 text-indigo-600',
activeBtn: 'bg-indigo-500/10 border-indigo-300/50 text-indigo-700',
divTxt: 'text-indigo-500',
divLine: 'bg-indigo-200/50',
hint: 'text-indigo-500/80',
infoBox: 'bg-indigo-50 border-indigo-200/50 text-indigo-700',
},
teal: {
panelBorder: 'border-teal-100',
bg: 'bg-teal-50/60',
iconBox: 'bg-teal-100 text-teal-600',
activeBtn: 'bg-teal-500/10 border-teal-300/50 text-teal-700',
divTxt: 'text-teal-500',
divLine: 'bg-teal-200/50',
hint: 'text-teal-600/80',
infoBox: 'bg-teal-50 border-teal-200/50 text-teal-700',
},
violet: {
panelBorder: 'border-violet-100',
bg: 'bg-violet-50/60',
iconBox: 'bg-violet-100 text-violet-600',
activeBtn: 'bg-violet-500/10 border-violet-300/50 text-violet-700',
divTxt: 'text-violet-500',
divLine: 'bg-violet-200/50',
hint: 'text-violet-500/80',
infoBox: 'bg-violet-50 border-violet-200/50 text-violet-700',
},
amber: {
panelBorder: 'border-amber-100',
bg: 'bg-amber-50/60',
iconBox: 'bg-amber-100 text-amber-700',
activeBtn: 'bg-amber-500/10 border-amber-300/50 text-amber-700',
divTxt: 'text-amber-500',
divLine: 'bg-amber-200/50',
hint: 'text-amber-600/80',
infoBox: 'bg-amber-50 border-amber-200/50 text-amber-700',
},
sky: {
panelBorder: 'border-sky-100',
bg: 'bg-sky-50/60',
iconBox: 'bg-sky-100 text-sky-600',
activeBtn: 'bg-sky-500/10 border-sky-300/50 text-sky-700',
divTxt: 'text-sky-500',
divLine: 'bg-sky-200/50',
hint: 'text-sky-500/80',
infoBox: 'bg-sky-50 border-sky-200/50 text-sky-700',
},
rose: {
panelBorder: 'border-rose-100',
bg: 'bg-rose-50/60',
iconBox: 'bg-rose-100 text-rose-600',
activeBtn: 'bg-rose-500/10 border-rose-300/50 text-rose-700',
divTxt: 'text-rose-500',
divLine: 'bg-rose-200/50',
hint: 'text-rose-500/80',
infoBox: 'bg-rose-50 border-rose-200/50 text-rose-700',
},
}
// Nav popover + responsivo
const navPopover = ref(null)
const isCompact = ref(false)
let mql = null, mqlCb = null
function syncCompact () { isCompact.value = !!mql?.matches }
function toggleNav (e) { navPopover.value?.toggle(e) }
function selectNav (s) { openPanel(Number(s.value)); navPopover.value?.hide() }
const selectedSection = computed(() => sections.find(s => s.value === activeValue.value) || sections[0])
onMounted(() => {
mql = window.matchMedia('(max-width: 1199px)'); syncCompact()
mqlCb = () => syncCompact()
if (mql.addEventListener) mql.addEventListener('change', mqlCb)
else mql.addListener(mqlCb)
})
onBeforeUnmount(() => {
if (!mql || !mqlCb) return
if (mql.removeEventListener) mql.removeEventListener('change', mqlCb)
else mql.removeListener(mqlCb)
})
// ─────────────────────────────────────────────────────────
// Avatar
// ─────────────────────────────────────────────────────────
const avatarFile = ref(null)
const avatarPreviewUrl = ref('')
const avatarUploading = ref(false)
const AVATAR_BUCKET = 'avatars'
function isImg (f) { return !!f && typeof f.type === 'string' && f.type.startsWith('image/') }
function ext (f) { const n = String(f?.name||''); const e = n.includes('.')?n.split('.').pop():''; return String(e||'').toLowerCase().replace(/[^a-z0-9]/g,'')||'png' }
function revokePreview () {
if (avatarPreviewUrl.value?.startsWith('blob:')) { try { URL.revokeObjectURL(avatarPreviewUrl.value) } catch (_) {} }
avatarPreviewUrl.value = ''
}
function onAvatarPicked (ev) {
const file = ev?.target?.files?.[0]||null; avatarFile.value=null; revokePreview()
if (!file) return
if (!isImg(file)) { toast.add({ severity:'warn', summary:'Avatar', detail:'Use PNG/JPG/WebP.', life:3000 }); return }
avatarFile.value = file; avatarPreviewUrl.value = URL.createObjectURL(file)
}
async function getReadableAvatarUrl (path) {
try { const { data: p } = supabase.storage.from(AVATAR_BUCKET).getPublicUrl(path); if (p?.publicUrl) return p.publicUrl } catch (_) {}
const { data, error } = await supabase.storage.from(AVATAR_BUCKET).createSignedUrl(path, 60*60*24*7)
if (error) throw error; return data.signedUrl
}
async function uploadAvatarToStorage ({ ownerId, patientId: pid, file }) {
if (file.size > 3*1024*1024) throw new Error('Imagem até 3MB.')
const path = `owners/${ownerId}/patients/${pid}/avatar.${ext(file)}`
const { error } = await supabase.storage.from(AVATAR_BUCKET).upload(path, file, { upsert:true, cacheControl:'3600', contentType:file.type||'image/*' })
if (error) throw error; return { publicUrl: await getReadableAvatarUrl(path) }
}
async function maybeUploadAvatar (ownerId, id) {
if (!avatarFile.value) return null
avatarUploading.value = true
try {
const { publicUrl } = await uploadAvatarToStorage({ ownerId, patientId: id, file: avatarFile.value })
form.value.avatar_url = publicUrl; avatarFile.value=null; revokePreview(); avatarPreviewUrl.value=publicUrl
await updatePatient(id, { avatar_url: publicUrl }); return publicUrl
} catch (e) { toast.add({ severity:'warn', summary:'Avatar', detail:e?.message||'Falha no upload.', life:4500 }); return null }
finally { avatarUploading.value=false }
}
// ─────────────────────────────────────────────────────────
// Form state
// ─────────────────────────────────────────────────────────
function resetForm () {
return {
// Identidade — alimenta header + card Dados pessoais + card Contato
nome_completo: '', nome_social: '', pronomes: '',
data_nascimento: '', genero: '', estado_civil: '',
cpf: '', rg: '', naturalidade: '', etnia: '',
profissao: '', escolaridade: '',
// Contato
telefone: '', email_principal: '',
email_alternativo: '', telefone_alternativo: '',
canal_preferido: '', horario_contato: '',
// Endereço — cep + cidade → card Contato + header
cep: '', pais: 'Brasil', cidade: '', estado: 'SP',
endereco: '', numero: '', bairro: '', complemento: '',
// Clínico — badges no header
status: 'Ativo', convenio: '', patient_scope: '',
// Origem — card Origem
onde_nos_conheceu: '', encaminhado_por: '',
motivo_saida: '',
// Responsável
nome_responsavel: '', cpf_responsavel: '',
telefone_responsavel: '', observacao_responsavel: '',
cobranca_no_responsavel: false,
// Interno
observacoes: '', notas_internas: '', avatar_url: '',
}
}
const form = ref(resetForm())
// ── Helpers ───────────────────────────────────────────────
function parseDDMMYYYY (s) {
const str = String(s||'').trim()
const m = /^(\d{2})-(\d{2})-(\d{4})$/.exec(str); if (!m) return null
const dd=Number(m[1]),mm=Number(m[2]),yyyy=Number(m[3])
const dt=new Date(yyyy,mm-1,dd)
if (isNaN(dt.getTime())||dt.getFullYear()!==yyyy||dt.getMonth()!==mm-1||dt.getDate()!==dd) return null
return dt
}
function isoToDDMMYYYY (v) {
if (!v) return ''
const s=String(v).trim()
if (/^\d{2}-\d{2}-\d{4}$/.test(s)) return s
const m=/^(\d{4})-(\d{2})-(\d{2})/.exec(s); if (m) return `${m[3]}-${m[2]}-${m[1]}`
const d=new Date(s); if (isNaN(d.getTime())) return ''
return `${String(d.getDate()).padStart(2,'0')}-${String(d.getMonth()+1).padStart(2,'0')}-${d.getFullYear()}`
}
const ageLabel = computed(() => {
const dt=parseDDMMYYYY(form.value?.data_nascimento); if (!dt) return '—'
const now=new Date(); let age=now.getFullYear()-dt.getFullYear()
const mo=now.getMonth()-dt.getMonth()
if (mo<0||(mo===0&&now.getDate()<dt.getDate())) age--
if (age<0||age>130) return '—'; return `${age} anos`
})
const avatarIniciais = computed(() =>
(form.value.nome_completo||'').split(' ').filter(Boolean).map(w=>w[0].toUpperCase()).slice(0,2).join('')
)
// ── DB map ─────────────────────────────────────────────────
function mapDbToForm (p) {
return {
...resetForm(),
nome_completo: p.nome_completo??'', nome_social: p.nome_social??'', pronomes: p.pronomes??'',
data_nascimento: p.data_nascimento ? isoToDDMMYYYY(p.data_nascimento) : '',
genero: p.genero??'', estado_civil: p.estado_civil??'',
cpf: fmtCPF(p.cpf??''), rg: fmtRG(p.rg??''),
naturalidade: p.naturalidade??'', etnia: p.etnia??'',
profissao: p.profissao??'', escolaridade: p.escolaridade??'',
telefone: fmtPhone(p.telefone??''), email_principal: p.email_principal??'',
email_alternativo: p.email_alternativo??'', telefone_alternativo: fmtPhone(p.telefone_alternativo??''),
canal_preferido: p.canal_preferido??'', horario_contato: p.horario_contato??'',
cep: p.cep??'', pais: p.pais??'Brasil', cidade: p.cidade??'', estado: p.estado??'SP',
endereco: p.endereco??'', numero: p.numero??'', bairro: p.bairro??'', complemento: p.complemento??'',
status: p.status??'Ativo', convenio: p.convenio??'', patient_scope: p.patient_scope??'',
onde_nos_conheceu: p.onde_nos_conheceu??'', encaminhado_por: p.encaminhado_por??'',
motivo_saida: p.motivo_saida??'',
nome_responsavel: p.nome_responsavel??'', cpf_responsavel: fmtCPF(p.cpf_responsavel??''),
telefone_responsavel: fmtPhone(p.telefone_responsavel??''),
observacao_responsavel: p.observacao_responsavel??'',
cobranca_no_responsavel: !!p.cobranca_no_responsavel,
observacoes: p.observacoes??'', notas_internas: p.notas_internas??'', avatar_url: p.avatar_url??'',
}
}
// ── Sanitize ──────────────────────────────────────────────
const ALLOWED = new Set([
'owner_id','tenant_id','responsible_member_id',
'nome_completo','nome_social','pronomes',
'data_nascimento','genero','estado_civil','cpf','rg','naturalidade','etnia',
'profissao','escolaridade',
'telefone','email_principal','email_alternativo','telefone_alternativo',
'canal_preferido','horario_contato',
'cep','pais','cidade','estado','endereco','numero','bairro','complemento',
'status','convenio','convenio_id','patient_scope',
'onde_nos_conheceu','encaminhado_por','motivo_saida',
'nome_responsavel','cpf_responsavel','telefone_responsavel',
'observacao_responsavel','cobranca_no_responsavel',
'observacoes','notas_internas','avatar_url',
])
function sanitizePayload (raw, ownerId) {
const p = { owner_id: ownerId }
Object.keys(raw).forEach(k => { p[k] = raw[k]??null })
Object.keys(p).forEach(k => {
if (p[k]==='') p[k]=null
if (typeof p[k]==='string') { const t=p[k].trim(); p[k]=t===''?null:t }
})
p.cpf = p.cpf ? digitsOnly(p.cpf) : null
p.rg = p.rg ? digitsOnly(p.rg) : null
p.cpf_responsavel = p.cpf_responsavel ? digitsOnly(p.cpf_responsavel) : null
p.telefone = p.telefone ? digitsOnly(p.telefone) : null
p.telefone_alternativo = p.telefone_alternativo ? digitsOnly(p.telefone_alternativo) : null
p.telefone_responsavel = p.telefone_responsavel ? digitsOnly(p.telefone_responsavel) : null
p.data_nascimento = p.data_nascimento ? (toISODate(p.data_nascimento)||null) : null
p.cobranca_no_responsavel = !!raw.cobranca_no_responsavel
// convenio_id vem do ref separado (não do form)
p.convenio_id = convenioId.value || null
const out = {}
Object.keys(p).forEach(k => { if (ALLOWED.has(k)) out[k]=p[k] })
return out
}
// ─────────────────────────────────────────────────────────
// Rede de suporte — patient_support_contacts
// Entidade separada que alimenta o card "Contatos & rede de suporte" do detalhe
// is_primario = true → badge vermelho "emergência" no detalhe
// ─────────────────────────────────────────────────────────
const contatosSuporte = ref([])
const novoContato = () => ({ _k: Date.now()+Math.random(), nome:'', relacao:'', tipo:'', telefone:'', email:'', is_primario:false })
function addContato () { contatosSuporte.value.push(novoContato()) }
function removeContato (i) { contatosSuporte.value.splice(i,1) }
function iniciaisFor (n) { return (n||'').split(' ').filter(Boolean).map(w=>w[0].toUpperCase()).slice(0,2).join('') }
async function saveContatosSuporte (pid, tenantId, ownerId) {
const { error: del } = await supabase.from('patient_support_contacts')
.delete().eq('patient_id', pid).eq('owner_id', ownerId)
if (del) throw del
const rows = contatosSuporte.value.filter(c=>c.nome.trim()).map(c=>({
patient_id: pid, owner_id: ownerId, tenant_id: tenantId,
nome: c.nome.trim()||null,
relacao: c.relacao||null,
tipo: c.tipo||null,
telefone: c.telefone ? digitsOnly(c.telefone) : null,
email: c.email||null,
is_primario: !!c.is_primario,
}))
if (!rows.length) return
const { error: ins } = await supabase.from('patient_support_contacts').insert(rows)
if (ins) throw ins
}
async function loadContatosSuporte (pid) {
try {
const { data, error } = await supabase.from('patient_support_contacts')
.select('*').eq('patient_id', pid).order('is_primario', { ascending:false })
if (error) throw error
contatosSuporte.value = (data||[]).map(c=>({
_k: c.id, nome: c.nome||'', relacao: c.relacao||'', tipo: c.tipo||'',
telefone: fmtPhone(c.telefone||''), email: c.email||'', is_primario: !!c.is_primario,
}))
} catch (_) { contatosSuporte.value = [] }
}
// ─────────────────────────────────────────────────────────
// DB calls
// ─────────────────────────────────────────────────────────
async function listGroups () {
const tid = currentTenantId()
let q = supabase.from('patient_groups').select('id,nome,descricao,cor,is_system,is_active').eq('is_active',true).order('nome',{ascending:true})
if (tid) q = q.eq('tenant_id', tid)
const { data, error } = await q
if (error) throw error
return (data||[]).map(g=>({...g,name:g.nome,color:g.cor}))
}
async function listTags () {
const tid = currentTenantId()
let q = supabase.from('patient_tags').select('id,nome,cor').order('nome',{ascending:true})
if (tid) q = q.eq('tenant_id', tid)
const { data, error } = await q
if (error) throw error
return (data||[]).map(t=>({...t,name:t.nome,color:t.cor}))
}
async function getPatientById (id) {
const tid = currentTenantId()
let q = supabase.from('patients').select('*').eq('id',id)
if (tid) q = q.eq('tenant_id', tid)
const { data, error } = await q.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 createPatient (payload) {
const { data, error } = await supabase.from('patients').insert(payload).select('id').single()
if (error) throw error; return data
}
async function updatePatient (id, payload) {
const tid = currentTenantId()
let q = supabase.from('patients').update({ ...payload, updated_at:new Date().toISOString() }).eq('id',id)
if (tid) q = q.eq('tenant_id', tid)
const { error } = await q
if (error) throw error
}
const groups = ref([])
const tags = ref([])
const grupoIdSelecionado = ref(null)
const tagIdsSelecionadas = ref([])
async function replacePatientGroups (patient_id, groupId) {
const { error } = await supabase.from('patient_group_patient').delete().eq('patient_id',patient_id); if (error) throw error
if (!groupId) return
const { tenantId } = await resolveTenantContextOrFail()
const { error:ins } = await supabase.from('patient_group_patient').insert({ patient_id, patient_group_id:groupId, tenant_id:tenantId }); if (ins) throw ins
}
async function replacePatientTags (patient_id, tagIds) {
const ownerId = await getOwnerId()
const { error } = await supabase.from('patient_patient_tag').delete().eq('patient_id',patient_id).eq('owner_id',ownerId); if (error) throw error
const clean = Array.from(new Set((tagIds||[]).filter(Boolean))); if (!clean.length) return
const { tenantId } = await resolveTenantContextOrFail()
const { error:ins } = await supabase.from('patient_patient_tag').insert(clean.map(tag_id=>({ owner_id:ownerId, patient_id, tag_id, tenant_id:tenantId }))); if (ins) throw ins
}
async function onCepBlur () {
try {
const cep = digitsOnly(form.value.cep); if (cep.length!==8) return
const res = await fetch(`https://viacep.com.br/ws/${cep}/json/`)
const d = await res.json(); if (!d||d.erro) return
form.value.cidade = d.localidade || form.value.cidade
form.value.estado = d.uf || form.value.estado
form.value.bairro = d.bairro || form.value.bairro
form.value.endereco = d.logradouro || form.value.endereco
if (!form.value.complemento) form.value.complemento = d.complemento||''
toast.add({ severity:'success', summary:'CEP', detail:`${d.localidade} / ${d.uf}`, life:2000 })
} catch (_) {}
}
// ─────────────────────────────────────────────────────────
// UI state + fetch
// ─────────────────────────────────────────────────────────
const loading = ref(false)
const saving = ref(false)
const deleting = ref(false)
async function fetchAll () {
loading.value = true
try {
const [gRes, tRes] = await Promise.allSettled([listGroups(), listTags()])
groups.value = gRes.status==='fulfilled' ? gRes.value||[] : []
tags.value = tRes.status==='fulfilled' ? tRes.value||[] : []
if (isEdit.value) {
const p = await getPatientById(patientId.value)
form.value = mapDbToForm(p)
avatarPreviewUrl.value = form.value.avatar_url||''
const rel = await getPatientRelations(patientId.value)
grupoIdSelecionado.value = rel.groupIds?.[0]||null
tagIdsSelecionadas.value = rel.tagIds||[]
await loadContatosSuporte(patientId.value)
// Restaura convênio selecionado
convenioId.value = p.convenio_id || null
convenioNome.value = p.convenio || ''
// Restaura médicos a partir do texto encaminhado_por salvo no banco
// (sem tabela patient_medicos, mantemos o texto como fonte da verdade)
medicosSelecionados.value = []
// encaminhado_por já vem populado via mapDbToForm — não zeramos
} else {
grupoIdSelecionado.value=null; tagIdsSelecionadas.value=[]
avatarFile.value=null; revokePreview(); contatosSuporte.value=[]
convenioId.value=null; convenioNome.value=''; medicosSelecionados.value=[]
}
} catch (err) {
toast.add({ severity:'error', summary:'Erro', detail:err?.message||'Falha ao carregar.', life:3500 })
} finally { loading.value=false }
}
watch(patientId, fetchAll, { immediate:true })
// ─────────────────────────────────────────────────────────
// Submit
// ─────────────────────────────────────────────────────────
async function onSubmit () {
saving.value = true
try {
const ownerId = await getOwnerId()
const { tenantId, memberId } = await resolveTenantContextOrFail()
const nome = String(form.value?.nome_completo||'').trim()
if (!nome) {
toast.add({ severity:'warn', summary:'Nome obrigatório', detail:'Preencha o nome completo.', life:3500 })
await openPanel(0); return
}
const payload = sanitizePayload(form.value, ownerId)
payload.tenant_id = tenantId; payload.responsible_member_id = memberId
if (isEdit.value) {
await updatePatient(patientId.value, payload)
await maybeUploadAvatar(ownerId, patientId.value)
await replacePatientGroups(patientId.value, grupoIdSelecionado.value)
await replacePatientTags(patientId.value, tagIdsSelecionadas.value)
await saveContatosSuporte(patientId.value, tenantId, ownerId)
toast.add({ severity:'success', summary:'Salvo', detail:'Paciente atualizado.', life:2500 })
if (props.dialogMode) { emit('created', { id:patientId.value }); return }
return
}
const created = await createPatient(payload)
await maybeUploadAvatar(ownerId, created.id)
await replacePatientGroups(created.id, grupoIdSelecionado.value)
await replacePatientTags(created.id, tagIdsSelecionadas.value)
await saveContatosSuporte(created.id, tenantId, ownerId)
toast.add({ severity:'success', summary:'Salvo', detail:'Paciente cadastrado.', life:2500 })
if (props.dialogMode) { emit('created', created); return }
form.value=resetForm(); grupoIdSelecionado.value=null; tagIdsSelecionadas.value=[]
contatosSuporte.value=[]; avatarFile.value=null; revokePreview(); avatarPreviewUrl.value=''
convenioId.value=null; convenioNome.value=''; medicosSelecionados.value=[]
await openPanel(0)
} catch (e) {
logError('patients.cadastro', 'save falhou', e)
toast.add({ severity:'error', summary:'Erro', detail:e?.message||'Falha ao salvar.', life:4000 })
} finally { saving.value=false }
}
// ─────────────────────────────────────────────────────────
// Delete
// ─────────────────────────────────────────────────────────
function confirmDelete () {
if (!isEdit.value) return
confirm.require({
header:'Excluir paciente', message:'Esta ação não pode ser desfeita.',
icon:'pi pi-exclamation-triangle', acceptLabel:'Excluir',
rejectLabel:'Cancelar', acceptClass:'p-button-danger',
accept: () => doDelete(),
})
}
async function doDelete () {
deleting.value = true
try {
const pid = patientId.value
const tables = [
['patient_group_patient', 'patient_id'],
['patient_patient_tag', 'patient_id'],
['patient_support_contacts', 'patient_id'],
['patients', 'id'],
]
for (const [tbl, col] of tables) {
const { error } = await supabase.from(tbl).delete().eq(col, pid); if (error) throw error
}
toast.add({ severity:'success', summary:'Excluído', detail:'Paciente removido.', life:2500 })
if (props.dialogMode) { emit('created', null); return }
goBack()
} catch (err) {
toast.add({ severity:'error', summary:'Erro', detail:err?.message||'Falha ao excluir.', life:4000 })
} finally { deleting.value=false }
}
// ─────────────────────────────────────────────────────────
// Opções
// ─────────────────────────────────────────────────────────
const genderOptions = [
{ label:'Feminino',value:'Feminino' },{ label:'Masculino',value:'Masculino' },
{ label:'Não-binário',value:'Não-binário' },{ label:'Prefere não informar',value:'Prefere não informar' },{ label:'Outro',value:'Outro' },
]
const maritalOpts = [
{ label:'Solteiro(a)',value:'Solteiro(a)' },{ label:'Casado(a)',value:'Casado(a)' },
{ label:'União estável',value:'União estável' },{ label:'Divorciado(a)',value:'Divorciado(a)' },
{ label:'Separado(a)',value:'Separado(a)' },{ label:'Viúvo(a)',value:'Viúvo(a)' },
{ label:'Prefere não informar',value:'Prefere não informar' },
]
const pronounsOpts = [{ label:'ela/dela',value:'ela/dela' },{ label:'ele/dele',value:'ele/dele' },{ label:'elu/delu',value:'elu/delu' },{ label:'Prefere não informar',value:'Prefere não informar' }]
const etniaOpts = [{ label:'Amarela',value:'Amarela' },{ label:'Branca',value:'Branca' },{ label:'Indígena',value:'Indígena' },{ label:'Parda',value:'Parda' },{ label:'Preta',value:'Preta' },{ label:'Prefere não informar',value:'Prefere não informar' }]
const escolaridadeOpts = [
{ label:'Não alfabetizado(a)', value:'Não alfabetizado(a)' },
{ label:'Ensino fundamental incompleto',value:'Ensino fundamental incompleto'},
{ label:'Ensino fundamental completo', value:'Ensino fundamental completo' },
{ label:'Ensino médio incompleto', value:'Ensino médio incompleto' },
{ label:'Ensino médio completo', value:'Ensino médio completo' },
{ label:'Ensino técnico', value:'Ensino técnico' },
{ label:'Superior incompleto', value:'Superior incompleto' },
{ label:'Superior completo', value:'Superior completo' },
{ label:'Pós-graduação / Especialização',value:'Pós-graduação / Especialização'},
{ label:'Mestrado', value:'Mestrado' },
{ label:'Doutorado', value:'Doutorado' },
{ label:'Prefere não informar', value:'Prefere não informar' },
]
const canalOpts = [{ label:'WhatsApp',value:'WhatsApp' },{ label:'Telefone',value:'Telefone' },{ label:'E-mail',value:'E-mail' },{ label:'SMS',value:'SMS' }]
const statusOpts = [{ label:'Ativo',value:'Ativo' },{ label:'Em espera',value:'Em espera' },{ label:'Inativo',value:'Inativo' },{ label:'Alta',value:'Alta' }]
const scopeOpts = [{ label:'Clínica',value:'Clínica' },{ label:'Particular',value:'Particular' },{ label:'Online',value:'Online' },{ label:'Híbrido',value:'Híbrido' }]
const tipoContatoOpts = [{ label:'Emergência',value:'emergencia' },{ label:'Familiar',value:'familiar' },{ label:'Profissional de saúde',value:'profissional_saude' },{ label:'Amigo(a)',value:'amigo' },{ label:'Outro',value:'outro' }]
// ─────────────────────────────────────────────────────────
// Dialogs Grupo / Tag
// ─────────────────────────────────────────────────────────
const createGroupDialog=ref(false); const createGroupSaving=ref(false); const createGroupError=ref(''); const newGroup=ref({ name:'',color:'#6366F1' })
const createTagDialog=ref(false); const createTagSaving=ref(false); const createTagError=ref(''); const newTag=ref({ name:'',color:'#22C55E' })
function openGroupDlg () { createGroupError.value=''; newGroup.value={ name:'',color:'#6366F1' }; createGroupDialog.value=true }
function openTagDlg () { createTagError.value=''; newTag.value={ name:'',color:'#22C55E' }; createTagDialog.value=true }
async function createGroupPersist () {
if (createGroupSaving.value) return; createGroupError.value=''
const name=String(newGroup.value?.name||'').trim(); const color=String(newGroup.value?.color||'').trim()||'#6366F1'
if (!name) { createGroupError.value='Informe um nome.'; return }
createGroupSaving.value=true
try {
const ownerId=await getOwnerId(); const { tenantId }=await resolveTenantContextOrFail()
const { data, error }=await supabase.from('patient_groups').insert({ owner_id:ownerId, tenant_id:tenantId, nome:name, cor:color, is_system:false, is_active:true }).select('id').single()
if (error) throw error
groups.value=await listGroups(); if (data?.id) grupoIdSelecionado.value=data.id
toast.add({ severity:'success', summary:'Grupo criado.', life:2500 }); createGroupDialog.value=false
} catch (e) {
const msg=e?.message||''
createGroupError.value=(e?.code==='23505'||/duplicate/i.test(msg)) ? 'Já existe esse grupo.' : (msg||'Falha.')
} finally { createGroupSaving.value=false }
}
async function createTagPersist () {
if (createTagSaving.value) return; createTagError.value=''
const name=String(newTag.value?.name||'').trim(); const color=String(newTag.value?.color||'').trim()||'#22C55E'
if (!name) { createTagError.value='Informe um nome.'; return }
createTagSaving.value=true
try {
const ownerId=await getOwnerId(); const { tenantId }=await resolveTenantContextOrFail()
const { data, error }=await supabase.from('patient_tags').insert({ owner_id:ownerId, tenant_id:tenantId, nome:name, cor:color }).select('id').single()
if (error) throw error
tags.value=await listTags()
if (data?.id) { const s=new Set([...(tagIdsSelecionadas.value||[]),data.id]); tagIdsSelecionadas.value=Array.from(s) }
toast.add({ severity:'success', summary:'Tag criada.', life:2500 }); createTagDialog.value=false
} catch (e) {
const msg=e?.message||''
createTagError.value=(e?.code==='23505'||/duplicate/i.test(msg)) ? 'Já existe essa tag.' : (msg||'Falha.')
} finally { createTagSaving.value=false }
}
// ─────────────────────────────────────────────────────────
// Dialog Convênio — CadastroRapidoConvenio
// Seleciona 1 convênio da tabela insurance_plans
// Armazena: convenioId (uuid) + convenioNome (string para exibição)
// ─────────────────────────────────────────────────────────
const showConvenioDlg = ref(false)
const convenioId = ref(null) // insurance_plans.id selecionado
const convenioNome = ref('') // nome exibido no campo
function onConvenioSelected (plan) {
if (!plan) {
convenioId.value = null
convenioNome.value = ''
form.value.convenio = ''
} else {
convenioId.value = plan.id
convenioNome.value = plan.name
form.value.convenio = plan.name // salva o nome no campo texto (exibido como badge)
}
}
function clearConvenio () {
convenioId.value = null
convenioNome.value = ''
form.value.convenio = ''
}
// ─────────────────────────────────────────────────────────
// Dialog Médico — CadastroRapidoMedico
// Pode selecionar/adicionar múltiplos médicos encaminhadores
// Armazenados como array de objetos; no save persistimos
// os nomes em encaminhado_por (text) e os IDs em patient_medicos[]
// ─────────────────────────────────────────────────────────
const showMedicoDlg = ref(false)
const medicosSelecionados = ref([]) // [{ id, nome, crm, especialidade }]
const medicoEditandoId = ref(null) // id do médico sendo editado no dialog
function onMedicoSelected (medico) {
if (!medico) return
// Se veio de uma edição, atualiza o item existente
const idx = medicosSelecionados.value.findIndex(m => m.id === medico.id)
if (idx !== -1) {
medicosSelecionados.value[idx] = { id: medico.id, nome: medico.nome, crm: medico.crm||'', especialidade: medico.especialidade||'' }
syncEncaminhadoPor(); return
}
// Evita duplicata nova
if (medicosSelecionados.value.some(m => m.id === medico.id)) {
toast.add({ severity: 'info', summary: 'Médico já adicionado', life: 2000 }); return
}
medicosSelecionados.value.push({ id: medico.id, nome: medico.nome, crm: medico.crm||'', especialidade: medico.especialidade||'' })
syncEncaminhadoPor()
}
function openMedicoEdit (id) {
medicoEditandoId.value = id
showMedicoDlg.value = true
}
function removeMedico (idx) {
medicosSelecionados.value.splice(idx, 1)
// Se ainda há médicos no picker, atualiza o campo
// Se removeu todos, limpa o campo também
if (medicosSelecionados.value.length > 0) {
form.value.encaminhado_por = medicosSelecionados.value
.map(m => m.crm ? `${m.nome} (${m.crm})` : m.nome)
.join(', ')
} else {
form.value.encaminhado_por = ''
}
}
function syncEncaminhadoPor () {
// Só sobrescreve se há médicos selecionados via picker
// Se o array está vazio, preserva o texto que veio do banco
if (medicosSelecionados.value.length === 0) return
form.value.encaminhado_por = medicosSelecionados.value
.map(m => m.crm ? `${m.nome} (${m.crm})` : m.nome)
.join(', ')
}
// ─────────────────────────────────────────────────────────
// Progresso de preenchimento por seção
// ─────────────────────────────────────────────────────────
const progSec = computed(() => {
const f = form.value
return [
{ key:'0', filled:[f.nome_completo,f.telefone,f.email_principal,f.data_nascimento,f.pronomes].filter(Boolean).length, total:5 },
{ key:'1', filled:[f.cep,f.cidade,f.estado,f.endereco].filter(Boolean).length, total:4 },
{ key:'2', filled:[f.status,f.convenio,f.onde_nos_conheceu].filter(Boolean).length, total:3 },
{ key:'3', filled:contatosSuporte.value.filter(c=>c.nome.trim()).length>0 ? 1 : 0, total:1 },
{ key:'4', filled:[f.nome_responsavel,f.telefone_responsavel].filter(Boolean).length, total:2 },
{ key:'5', filled:f.notas_internas ? 1 : 0, total:1 },
]
})
const progGeral = computed(() => {
const tot = progSec.value.reduce((a,s)=>a+s.total,0)
const fil = progSec.value.reduce((a,s)=>a+s.filled,0)
return Math.round((fil/tot)*100)
})
function p (key) { return progSec.value.find(x=>x.key===key) || { filled:0, total:1 } }
// ─────────────────────────────────────────────────────────
// Fake fill
// ─────────────────────────────────────────────────────────
function ri (a,b) { return Math.floor(Math.random()*(b-a+1))+a }
function pk (arr) { return arr[ri(0,arr.length-1)] }
function mb (v=.5) { return Math.random()<v }
function p2 (n) { return String(n).padStart(2,'0') }
function fillRandomPatient () {
const first=['Ana','Bruno','Carla','Daniel','Eduarda','Felipe','Giovana','Henrique','Isabela','João','Larissa','Marcos']
const last=['Silva','Santos','Oliveira','Souza','Pereira','Lima','Ferreira','Almeida','Costa','Gomes']
const emailIdx = ri(1, 7) // teste1 até teste7
const nome=`${pk(first)} ${pk(last)} ${pk(last)}`
const age=ri(18,75); const y=new Date().getFullYear()
form.value={ ...resetForm(),
nome_completo: nome,
nome_social: mb(.3) ? pk(first) : '',
pronomes: pk(['ela/dela','ele/dele','elu/delu']),
data_nascimento:`${p2(ri(1,28))}-${p2(ri(1,12))}-${y-age}`,
genero: pk(['Feminino','Masculino','Não-binário']),
estado_civil: pk(['Solteiro(a)','Casado(a)','União estável']),
cpf: fmtCPF(generateCPF()),
rg: fmtRG(String(ri(10000000,999999999))),
etnia: pk(['Branca','Parda','Preta','Amarela','Indígena']),
naturalidade: 'São Carlos',
profissao: pk(['Estudante','Professora','Desenvolvedor','Enfermeira','Autônomo']),
escolaridade: pk(['Ensino médio completo','Ensino técnico','Superior incompleto','Superior completo','Pós-graduação / Especialização','Mestrado']),
// Contatos fixos
telefone: '(16) 98828-0038',
telefone_alternativo: '(16) 99600-5268',
email_principal: `teste${emailIdx}@agenciapsi.com.br`,
email_alternativo: `teste${emailIdx === 7 ? 1 : emailIdx + 1}@agenciapsi.com.br`,
canal_preferido: pk(['WhatsApp','Telefone','E-mail']),
horario_contato: pk(['08h12h','13h18h','08h20h','Qualquer horário']),
// Endereço fixo
cep: '13561-260',
pais: 'Brasil',
cidade: 'São Carlos',
estado: 'SP',
endereco: 'Rua Conde do Pinhal',
numero: '457',
bairro: 'Centro',
complemento: 'Apartamento',
observacoes: 'Próximo ao posto de saúde do centro.',
// Clínico
status: 'Ativo',
convenio: '', // selecionado via dialog de convênio
patient_scope: pk(['Clínica','Online','Híbrido']),
onde_nos_conheceu: pk(['Instagram','Google','Indicação','Site']),
encaminhado_por: '', // preenchido via dialog de médicos
// Interno
notas_internas: 'Paciente apresenta discurso organizado. Acompanhar evolução clínica.',
}
contatosSuporte.value = [{
_k: 1,
nome: `${pk(first)} ${pk(last)}`,
relacao: pk(['mãe','pai','cônjuge','psiquiatra']),
tipo: 'emergencia',
telefone: '(16) 98828-0038',
email: `teste${emailIdx}@agenciapsi.com.br`,
is_primario: true,
}]
if (groups.value.length) grupoIdSelecionado.value = pk(groups.value).id
if (tags.value.length) {
const sh=[...tags.value].sort(()=>Math.random()-.5)
tagIdsSelecionadas.value = sh.slice(0,ri(1,Math.min(3,tags.value.length))).map(t=>t.id)
}
toast.add({ severity:'info', summary:'Preenchido', detail:'Dados fictícios aplicados.', life:2500 })
}
defineExpose({ fillRandomPatient, onSubmit, confirmDelete, saving, deleting, canSee, isEdit })
</script>
<template>
<ConfirmDialog v-if="!dialogMode" />
<div class="h-px" />
<!--
HERO STICKY preview ao vivo do que aparece no detalhe
-->
<section
v-if="!dialogMode"
class="sticky mx-3 md:mx-4 mb-3 z-20 overflow-hidden rounded-xl border border-[var(--surface-border)] bg-[var(--surface-card)] px-4 py-2.5 shadow-sm"
:style="{ top: 'var(--layout-sticky-top, 56px)' }"
>
<!-- Blobs decorativos -->
<div class="absolute inset-0 pointer-events-none overflow-hidden" aria-hidden="true">
<div class="absolute w-72 h-72 -top-20 -right-10 rounded-full blur-[80px] bg-indigo-400/10" />
<div class="absolute w-56 h-56 -bottom-16 -left-10 rounded-full blur-[60px] bg-teal-400/[0.07]" />
</div>
<div class="relative flex items-center gap-3">
<!-- Avatar ao vivo com iniciais -->
<div class="w-9 h-9 rounded-full overflow-hidden border-2 border-[var(--surface-border)] bg-indigo-50 flex items-center justify-center shrink-0">
<img v-if="avatarPreviewUrl || form.avatar_url" :src="avatarPreviewUrl || form.avatar_url" class="w-full h-full object-cover" alt="" />
<span v-else-if="avatarIniciais" class="text-indigo-600 font-black text-[0.8rem] select-none">{{ avatarIniciais }}</span>
<i v-else class="pi pi-user text-indigo-300 text-sm" />
</div>
<!-- Nome + pronomes + status ao vivo -->
<div class="hidden sm:block min-w-0">
<div class="font-bold text-[0.92rem] text-[var(--text-color)] truncate leading-tight">
{{ form.nome_completo || (isEdit ? 'Editar paciente' : 'Novo paciente') }}
</div>
<div class="text-[0.7rem] text-[var(--text-color-secondary)] flex items-center gap-1.5 flex-wrap">
<template v-if="ageLabel !== '—'">{{ ageLabel }}</template>
<template v-else>Cadastro completo</template>
<template v-if="form.pronomes">
· <span class="text-indigo-500 font-medium">{{ form.pronomes }}</span>
</template>
<template v-if="form.cidade">
· {{ form.cidade }}<template v-if="form.estado">, {{ form.estado }}</template>
</template>
<span v-if="form.status"
class="inline-flex items-center px-1.5 py-0 rounded-full text-[0.6rem] font-bold bg-green-100 text-green-700 ml-0.5">
{{ form.status }}
</span>
</div>
</div>
<!-- Progresso -->
<div class="hidden lg:flex flex-1 items-center gap-3 px-3 max-w-xs">
<div class="flex-1 min-w-0">
<div class="flex justify-between text-[0.65rem] text-[var(--text-color-secondary)] mb-1">
<span>Preenchimento</span><span class="font-bold">{{ progGeral }}%</span>
</div>
<ProgressBar :value="progGeral" :showValue="false" style="height:4px;border-radius:99px;" />
</div>
<div class="flex gap-1 shrink-0">
<button
v-for="s in progSec" :key="s.key" type="button"
class="w-1.5 h-1.5 rounded-full transition-all duration-150 hover:scale-150"
:class="s.filled===s.total ? 'bg-emerald-500' : s.filled>0 ? 'bg-amber-400' : 'bg-[var(--surface-border)]'"
:title="sections[Number(s.key)]?.label"
@click="openPanel(Number(s.key))"
/>
</div>
</div>
<div class="flex-1" />
<!-- Ações -->
<div v-if="!dialogMode" class="flex items-center gap-1.5 shrink-0">
<Button
v-if="canSee('testMODE')"
label="Preencher tudo" icon="pi pi-bolt"
severity="secondary" outlined size="small"
class="rounded-full hidden xl:flex"
@click="fillRandomPatient"
/>
<Button icon="pi pi-arrow-left" severity="secondary" outlined class="h-9 w-9 rounded-full" title="Voltar" @click="goBack" />
<Button v-if="isEdit" icon="pi pi-trash" severity="danger" outlined class="h-9 w-9 rounded-full" :loading="deleting" @click="confirmDelete" />
<Button label="Salvar" icon="pi pi-check" class="rounded-full" :loading="saving" @click="onSubmit" />
</div>
</div>
</section>
<!--
CORPO
-->
<div class="px-3 md:px-4 pb-8">
<div v-if="loading" class="flex items-center justify-center py-20 gap-2 text-[var(--text-color-secondary)]">
<i class="pi pi-spin pi-spinner text-xl" /> Carregando
</div>
<div v-else class="grid grid-cols-1 gap-3 xl:grid-cols-[220px_1fr] max-w-[1040px] mx-auto">
<!-- SIDEBAR -->
<aside
class="rounded-xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-3 xl:sticky xl:self-start shadow-sm"
:class="dialogMode ? 'xl:top-4' : 'xl:top-[calc(var(--layout-sticky-top,56px)+3.6rem)]'"
>
<!-- Avatar + upload -->
<div class="flex items-center gap-3 pb-3 mb-3 border-b border-[var(--surface-border)] xl:flex-col xl:items-center xl:text-center">
<div class="relative shrink-0">
<div class="w-16 h-16 xl:w-[4.5rem] xl:h-[4.5rem] rounded-full overflow-hidden border-2 border-[var(--surface-border)] bg-[var(--surface-ground)] flex items-center justify-center">
<img v-if="avatarPreviewUrl||form.avatar_url" :src="avatarPreviewUrl||form.avatar_url" class="w-full h-full object-cover" alt="Avatar" />
<span v-else-if="avatarIniciais" class="text-2xl font-black text-indigo-500 select-none">{{ avatarIniciais }}</span>
<i v-else class="pi pi-user text-2xl text-[var(--text-color-secondary)] opacity-25" />
</div>
</div>
<div class="flex-1 xl:w-full">
<input type="file" accept="image/*"
class="block w-full text-[0.72rem] text-[var(--text-color-secondary)]
file:mr-2 file:rounded-full file:border file:border-[var(--surface-border)]
file:bg-[var(--surface-ground)] file:px-3 file:py-1 file:text-[0.68rem]
file:cursor-pointer hover:file:bg-indigo-50 hover:file:border-indigo-300"
@change="onAvatarPicked"
/>
<div class="mt-1 text-[0.63rem] text-[var(--text-color-secondary)] opacity-60">
Foto opcional · máx 3 MB<span v-if="avatarUploading" class="text-indigo-500 ml-1">(enviando)</span>
</div>
</div>
</div>
<!-- Nav desktop ( xl) -->
<div v-if="!isCompact" class="flex flex-col gap-0.5">
<div class="text-[0.62rem] font-bold uppercase tracking-widest text-[var(--text-color-secondary)] opacity-40 px-2 mb-1">Seções</div>
<button
v-for="s in sections" :key="s.value" type="button"
class="flex items-center gap-2 rounded-lg px-2.5 py-[7px] text-left text-[0.82rem] border transition-all duration-150"
:class="activeValue===s.value ? pal[s.accent].activeBtn : 'border-transparent text-[var(--text-color)] hover:bg-[var(--surface-ground)] font-medium'"
@click="openPanel(Number(s.value))"
>
<span
class="flex items-center justify-center w-[22px] h-[22px] rounded-md shrink-0 text-[0.68rem]"
:class="activeValue===s.value ? pal[s.accent].iconBox : 'bg-[var(--surface-ground)] text-[var(--text-color-secondary)]'"
>
<i :class="s.icon" />
</span>
<span class="flex-1 leading-tight">{{ s.label }}</span>
<!-- dot de progresso -->
<span class="w-[6px] h-[6px] rounded-full shrink-0"
:class="p(s.value).filled===p(s.value).total ? 'bg-emerald-500' : p(s.value).filled>0 ? 'bg-amber-400' : 'bg-[var(--surface-border)]'"
/>
</button>
<!-- Barra de preenchimento -->
<div class="mt-3 pt-3 border-t border-[var(--surface-border)]">
<div class="flex justify-between text-[0.63rem] text-[var(--text-color-secondary)] mb-1.5">
<span>Preenchimento geral</span><span class="font-bold">{{ progGeral }}%</span>
</div>
<ProgressBar :value="progGeral" :showValue="false" style="height:4px;border-radius:99px;" />
<div class="flex gap-3 mt-2 text-[0.6rem] text-[var(--text-color-secondary)]">
<span class="flex items-center gap-1"><span class="w-1.5 h-1.5 rounded-full bg-emerald-500 inline-block"/>Completo</span>
<span class="flex items-center gap-1"><span class="w-1.5 h-1.5 rounded-full bg-amber-400 inline-block"/>Parcial</span>
<span class="flex items-center gap-1"><span class="w-1.5 h-1.5 rounded-full bg-[var(--surface-border)] inline-block"/>Vazio</span>
</div>
</div>
</div>
<!-- Nav compacto (< xl) -->
<div v-if="isCompact">
<Button
type="button" class="w-full !rounded-full"
icon="pi pi-list" iconPos="right"
:label="selectedSection?.label || 'Selecionar seção'"
severity="secondary" outlined
@click="toggleNav($event)"
/>
<Popover ref="navPopover" :pt="{ root:{ class:'z-[9999999]' } }">
<div class="flex min-w-[220px] flex-col gap-0.5 p-1">
<button
v-for="s in sections" :key="s.value" type="button"
class="flex items-center gap-2.5 rounded-lg px-3 py-2 text-left text-[0.85rem] border"
:class="activeValue===s.value ? pal[s.accent].activeBtn : 'border-transparent text-[var(--text-color)] hover:bg-[var(--surface-ground)]'"
@click="selectNav(s)"
>
<span class="flex items-center justify-center w-6 h-6 rounded-md text-[0.7rem]" :class="pal[s.accent].iconBox"><i :class="s.icon"/></span>
{{ s.label }}
</button>
</div>
</Popover>
</div>
<!-- Atalhos de cadastro -->
<div class="mt-3 pt-3 border-t border-[var(--surface-border)] flex flex-col gap-1.5">
<div class="text-[0.62rem] font-bold uppercase tracking-widest text-[var(--text-color-secondary)] opacity-40 px-1 mb-0.5">Cadastros rápidos</div>
<button
type="button"
class="flex items-center gap-2 rounded-lg px-2.5 py-[7px] text-left text-[0.8rem] border border-transparent hover:bg-indigo-50/60 hover:border-indigo-100 text-[var(--text-color)] font-medium transition-all duration-150 group"
@click="openGroupDlg"
>
<span class="flex items-center justify-center w-[22px] h-[22px] rounded-md bg-indigo-100 text-indigo-600 text-[0.68rem] shrink-0 group-hover:bg-indigo-200 transition-colors">
<i class="pi pi-folder-open"/>
</span>
<span class="flex-1">Grupos</span>
<i class="pi pi-plus text-[0.6rem] text-[var(--text-color-secondary)] opacity-40 group-hover:opacity-80"/>
</button>
<button
type="button"
class="flex items-center gap-2 rounded-lg px-2.5 py-[7px] text-left text-[0.8rem] border border-transparent hover:bg-violet-50/60 hover:border-violet-100 text-[var(--text-color)] font-medium transition-all duration-150 group"
@click="openTagDlg"
>
<span class="flex items-center justify-center w-[22px] h-[22px] rounded-md bg-violet-100 text-violet-600 text-[0.68rem] shrink-0 group-hover:bg-violet-200 transition-colors">
<i class="pi pi-tag"/>
</span>
<span class="flex-1">Tags clínicas</span>
<i class="pi pi-plus text-[0.6rem] text-[var(--text-color-secondary)] opacity-40 group-hover:opacity-80"/>
</button>
<button
type="button"
class="flex items-center gap-2 rounded-lg px-2.5 py-[7px] text-left text-[0.8rem] border border-transparent hover:bg-teal-50/60 hover:border-teal-100 text-[var(--text-color)] font-medium transition-all duration-150 group"
@click="showMedicoDlg = true"
>
<span class="flex items-center justify-center w-[22px] h-[22px] rounded-md bg-teal-100 text-teal-600 text-[0.68rem] shrink-0 group-hover:bg-teal-200 transition-colors">
<i class="pi pi-user-plus"/>
</span>
<span class="flex-1">Médicos</span>
<i class="pi pi-plus text-[0.6rem] text-[var(--text-color-secondary)] opacity-40 group-hover:opacity-80"/>
</button>
<button
type="button"
class="flex items-center gap-2 rounded-lg px-2.5 py-[7px] text-left text-[0.8rem] border border-transparent hover:bg-blue-50/60 hover:border-blue-100 text-[var(--text-color)] font-medium transition-all duration-150 group"
@click="showConvenioDlg = true"
>
<span class="flex items-center justify-center w-[22px] h-[22px] rounded-md bg-blue-100 text-blue-600 text-[0.68rem] shrink-0 group-hover:bg-blue-200 transition-colors">
<i class="pi pi-shield"/>
</span>
<span class="flex-1">Convênios</span>
<i class="pi pi-plus text-[0.6rem] text-[var(--text-color-secondary)] opacity-40 group-hover:opacity-80"/>
</button>
</div>
</aside>
<!-- MAIN -->
<main class="rounded-xl border border-[var(--surface-border)] bg-[var(--surface-card)] overflow-hidden shadow-sm">
<Accordion :multiple="false" v-model:value="activeValue">
<!--
SEÇÃO 0 IDENTIDADE (indigo)
header: nome, pronomes, idade, cidade
card Dados pessoais: todos os campos
card Contato: tel, email, canal, CEP
-->
<AccordionPanel value="0" :class="`border-b ${pal.indigo.panelBorder}`">
<AccordionHeader :ref="el => setPanelHeaderRef(el, 0)" :pt="{ root: { class: pal.indigo.bg } }">
<div class="flex items-center gap-3 w-full pr-2">
<span class="flex items-center justify-center w-7 h-7 rounded-lg shrink-0 text-[0.78rem]" :class="pal.indigo.iconBox"><i class="pi pi-user"/></span>
<div class="flex-1 min-w-0">
<div class="font-semibold text-[0.88rem]">Identidade</div>
<div class="text-[0.68rem] text-[var(--text-color-secondary)] font-normal">Nome · pronomes · CPF · etnia · contato · canal</div>
</div>
<Tag v-if="p('0').filled===p('0').total" value="Completo" severity="success" class="text-[0.6rem] shrink-0" />
<span v-else-if="p('0').filled>0" class="text-[0.67rem] text-amber-600 font-bold shrink-0">{{ p('0').filled }}/{{ p('0').total }}</span>
</div>
</AccordionHeader>
<AccordionContent>
<div class="p-4">
<!-- Nome & identidade -->
<div class="flex items-center gap-2 mb-4">
<span class="text-[0.63rem] font-bold uppercase tracking-widest" :class="pal.indigo.divTxt">Nome & identidade</span>
<div class="flex-1 h-px" :class="pal.indigo.divLine"/>
</div>
<div class="grid grid-cols-1 gap-3.5 xl:grid-cols-2 mb-6">
<!-- Nome completo full width -->
<div class="xl:col-span-2">
<FloatLabel variant="on">
<IconField><InputIcon class="pi pi-user"/><InputText id="f_nome" v-model="form.nome_completo" class="w-full" variant="filled"/></IconField>
<label for="f_nome">Nome completo *</label>
</FloatLabel>
<div class="mt-1 text-[0.63rem]" :class="pal.indigo.hint">Exibido no header do perfil do paciente.</div>
</div>
<!-- Nome social -->
<div>
<FloatLabel variant="on">
<InputText id="f_nome_social" v-model="form.nome_social" class="w-full" variant="filled"/>
<label for="f_nome_social">Nome social</label>
</FloatLabel>
<div class="mt-1 text-[0.63rem]" :class="pal.indigo.hint">Card "Dados pessoais" como prefere ser chamado(a).</div>
</div>
<!-- Pronomes -->
<div>
<FloatLabel variant="on">
<Select id="f_pronomes" v-model="form.pronomes" :options="pronounsOpts" optionLabel="label" optionValue="value" class="w-full" variant="filled"/>
<label for="f_pronomes">Pronomes</label>
</FloatLabel>
<div class="mt-1 text-[0.63rem]" :class="pal.indigo.hint">Header: <em>"32 anos · <strong>ela/dela</strong> · São Carlos, SP"</em></div>
</div>
<!-- Data de nascimento -->
<div>
<FloatLabel variant="on">
<IconField><InputIcon class="pi pi-calendar"/><InputMask id="f_nasc" v-model="form.data_nascimento" mask="99-99-9999" :unmask="false" class="w-full" variant="filled"/></IconField>
<label for="f_nasc">Data de nascimento</label>
</FloatLabel>
<div v-if="ageLabel!=='—'" class="mt-1 text-[0.63rem] text-indigo-600 font-semibold"><i class="pi pi-info-circle mr-1"/>{{ ageLabel }}</div>
</div>
<!-- Gênero -->
<div>
<FloatLabel variant="on">
<Select id="f_genero" v-model="form.genero" :options="genderOptions" optionLabel="label" optionValue="value" class="w-full" variant="filled"/>
<label for="f_genero">Gênero</label>
</FloatLabel>
</div>
<!-- Estado civil -->
<div>
<FloatLabel variant="on">
<Select id="f_ec" v-model="form.estado_civil" :options="maritalOpts" optionLabel="label" optionValue="value" class="w-full" variant="filled"/>
<label for="f_ec">Estado civil</label>
</FloatLabel>
</div>
<!-- CPF -->
<div>
<FloatLabel variant="on">
<IconField><InputIcon class="pi pi-id-card"/><InputMask id="f_cpf" v-model="form.cpf" mask="999.999.999-99" :unmask="false" class="w-full" variant="filled"/></IconField>
<label for="f_cpf">CPF</label>
</FloatLabel>
<div class="mt-1 text-[0.63rem]" :class="pal.indigo.hint">Exibido mascarado: <em>45690</em></div>
</div>
<!-- RG -->
<div>
<FloatLabel variant="on">
<IconField><InputIcon class="pi pi-id-card"/><InputText id="f_rg" v-model="form.rg" class="w-full" variant="filled"/></IconField>
<label for="f_rg">RG</label>
</FloatLabel>
</div>
<!-- Etnia -->
<div>
<FloatLabel variant="on">
<Select id="f_etnia" v-model="form.etnia" :options="etniaOpts" optionLabel="label" optionValue="value" class="w-full" variant="filled"/>
<label for="f_etnia">Etnia / raça</label>
</FloatLabel>
<div class="mt-1 text-[0.63rem]" :class="pal.indigo.hint">Card "Dados pessoais" linha "Etnia".</div>
</div>
<!-- Naturalidade -->
<div>
<FloatLabel variant="on">
<IconField><InputIcon class="pi pi-map"/><InputText id="f_nat" v-model="form.naturalidade" class="w-full" variant="filled"/></IconField>
<label for="f_nat">Naturalidade</label>
</FloatLabel>
</div>
<!-- Profissão -->
<div>
<FloatLabel variant="on">
<IconField><InputIcon class="pi pi-briefcase"/><InputText id="f_prof" v-model="form.profissao" class="w-full" variant="filled"/></IconField>
<label for="f_prof">Profissão</label>
</FloatLabel>
<div class="mt-1 text-[0.63rem]" :class="pal.indigo.hint">Card "Dados pessoais" "Desenvolvedora".</div>
</div>
<!-- Escolaridade -->
<div>
<FloatLabel variant="on">
<Select id="f_esc" v-model="form.escolaridade" :options="escolaridadeOpts" optionLabel="label" optionValue="value" class="w-full" variant="filled"/>
<label for="f_esc">Escolaridade</label>
</FloatLabel>
<div class="mt-1 text-[0.63rem]" :class="pal.indigo.hint">Card "Dados pessoais" "Superior completo".</div>
</div>
</div>
<!-- Contato alimenta card "Contato" -->
<div class="flex items-center gap-2 mb-4">
<span class="text-[0.63rem] font-bold uppercase tracking-widest" :class="pal.indigo.divTxt">Contato</span>
<div class="flex-1 h-px" :class="pal.indigo.divLine"/>
<span class="text-[0.6rem]" :class="pal.indigo.hint">Card "Contato" no detalhe</span>
</div>
<div class="grid grid-cols-1 gap-3.5 xl:grid-cols-2">
<!-- Telefone -->
<div>
<FloatLabel variant="on">
<IconField><InputIcon class="pi pi-phone"/><InputMask id="f_tel" v-model="form.telefone" mask="(99) 99999-9999" :unmask="false" class="w-full" variant="filled"/></IconField>
<label for="f_tel">Telefone / celular *</label>
</FloatLabel>
<div class="mt-1 text-[0.63rem]" :class="pal.indigo.hint">Exibido como "WhatsApp" clicável no perfil.</div>
</div>
<!-- Telefone alternativo -->
<div>
<FloatLabel variant="on">
<IconField><InputIcon class="pi pi-phone"/><InputMask id="f_tel2" v-model="form.telefone_alternativo" mask="(99) 99999-9999" :unmask="false" class="w-full" variant="filled"/></IconField>
<label for="f_tel2">Telefone alternativo</label>
</FloatLabel>
</div>
<!-- E-mail principal -->
<div>
<FloatLabel variant="on">
<IconField><InputIcon class="pi pi-envelope"/><InputText id="f_email" v-model="form.email_principal" class="w-full" variant="filled"/></IconField>
<label for="f_email">E-mail principal *</label>
</FloatLabel>
<div class="mt-1 text-[0.63rem]" :class="pal.indigo.hint">Link mailto: no card Contato.</div>
</div>
<!-- E-mail alternativo -->
<div>
<FloatLabel variant="on">
<IconField><InputIcon class="pi pi-envelope"/><InputText id="f_email2" v-model="form.email_alternativo" class="w-full" variant="filled"/></IconField>
<label for="f_email2">E-mail alternativo</label>
</FloatLabel>
</div>
<!-- Canal preferido -->
<div>
<FloatLabel variant="on">
<Select id="f_canal" v-model="form.canal_preferido" :options="canalOpts" optionLabel="label" optionValue="value" class="w-full" variant="filled"/>
<label for="f_canal">Canal preferido de contato</label>
</FloatLabel>
<div class="mt-1 text-[0.63rem]" :class="pal.indigo.hint">Card Contato "Canal preferido: <strong>WhatsApp</strong>".</div>
</div>
<!-- Horário de contato -->
<div>
<FloatLabel variant="on">
<IconField><InputIcon class="pi pi-clock"/><InputText id="f_horario" v-model="form.horario_contato" class="w-full" variant="filled" placeholder="Ex: 08h18h"/></IconField>
<label for="f_horario">Horário de contato</label>
</FloatLabel>
<div class="mt-1 text-[0.63rem]" :class="pal.indigo.hint">Card Contato "Horário: <strong>08h18h</strong>".</div>
</div>
<!-- Observações de endereço -->
<div class="xl:col-span-2">
<FloatLabel variant="on">
<Textarea id="f_obs" v-model="form.observacoes" rows="2" class="w-full" variant="filled"/>
<label for="f_obs">Observações de endereço</label>
</FloatLabel>
<div class="mt-1 text-[0.63rem]" :class="pal.indigo.hint">Ex: Próximo ao posto, portão azul, sem interfone.</div>
</div>
</div>
</div>
</AccordionContent>
</AccordionPanel>
<!--
SEÇÃO 1 ENDEREÇO (teal)
card Contato: "CEP · São Carlos"
header: "São Carlos, SP"
-->
<AccordionPanel value="1" :class="`border-b ${pal.teal.panelBorder}`">
<AccordionHeader :ref="el => setPanelHeaderRef(el, 1)" :pt="{ root: { class: pal.teal.bg } }">
<div class="flex items-center gap-3 w-full pr-2">
<span class="flex items-center justify-center w-7 h-7 rounded-lg shrink-0 text-[0.78rem]" :class="pal.teal.iconBox"><i class="pi pi-map-marker"/></span>
<div class="flex-1 min-w-0">
<div class="font-semibold text-[0.88rem]">Endereço</div>
<div class="text-[0.68rem] text-[var(--text-color-secondary)] font-normal">CEP auto-preenchido · cidade e UF aparecem no header</div>
</div>
<Tag v-if="p('1').filled===p('1').total" value="Completo" severity="success" class="text-[0.6rem] shrink-0" />
<span v-else-if="p('1').filled>0" class="text-[0.67rem] text-amber-600 font-bold shrink-0">{{ p('1').filled }}/{{ p('1').total }}</span>
</div>
</AccordionHeader>
<AccordionContent>
<div class="p-4">
<div :class="`flex items-start gap-2 mb-4 p-2.5 rounded-lg border text-[0.75rem] ${pal.teal.infoBox}`">
<i class="pi pi-lightbulb mt-0.5 shrink-0"/>
<span>Digite o CEP e cidade, estado, bairro e logradouro são preenchidos automaticamente via <strong>ViaCEP</strong>.</span>
</div>
<div class="grid grid-cols-1 gap-3.5 xl:grid-cols-2">
<div>
<FloatLabel variant="on">
<IconField><InputIcon class="pi pi-map-marker"/><InputText id="f_cep" v-model="form.cep" class="w-full" @blur="onCepBlur" variant="filled" placeholder="00000-000"/></IconField>
<label for="f_cep">CEP</label>
</FloatLabel>
<div class="mt-1 text-[0.63rem]" :class="pal.teal.hint">Card Contato <em>"13560-000 · São Carlos"</em></div>
</div>
<div>
<FloatLabel variant="on">
<IconField><InputIcon class="pi pi-globe"/><InputText id="f_pais" v-model="form.pais" class="w-full" variant="filled"/></IconField>
<label for="f_pais">País</label>
</FloatLabel>
</div>
<div>
<FloatLabel variant="on">
<IconField><InputIcon class="pi pi-building"/><InputText id="f_city" v-model="form.cidade" class="w-full" variant="filled"/></IconField>
<label for="f_city">Cidade</label>
</FloatLabel>
<div class="mt-1 text-[0.63rem]" :class="pal.teal.hint">Header <em>"<strong>São Carlos</strong>, SP"</em></div>
</div>
<div>
<FloatLabel variant="on">
<IconField><InputIcon class="pi pi-compass"/><InputText id="f_uf" v-model="form.estado" class="w-full" variant="filled"/></IconField>
<label for="f_uf">Estado (UF)</label>
</FloatLabel>
<div class="mt-1 text-[0.63rem]" :class="pal.teal.hint">Header "São Carlos, <strong>SP</strong>"</div>
</div>
<div class="xl:col-span-2">
<FloatLabel variant="on">
<IconField><InputIcon class="pi pi-map"/><InputText id="f_addr" v-model="form.endereco" class="w-full" variant="filled"/></IconField>
<label for="f_addr">Logradouro</label>
</FloatLabel>
</div>
<div>
<FloatLabel variant="on">
<IconField><InputIcon class="pi pi-hashtag"/><InputText id="f_num" v-model="form.numero" class="w-full" variant="filled"/></IconField>
<label for="f_num">Número</label>
</FloatLabel>
</div>
<div>
<FloatLabel variant="on">
<IconField><InputIcon class="pi pi-map-marker"/><InputText id="f_bairro" v-model="form.bairro" class="w-full" variant="filled"/></IconField>
<label for="f_bairro">Bairro</label>
</FloatLabel>
</div>
<div class="xl:col-span-2">
<FloatLabel variant="on">
<IconField><InputIcon class="pi pi-align-left"/><InputText id="f_compl" v-model="form.complemento" class="w-full" variant="filled"/></IconField>
<label for="f_compl">Complemento</label>
</FloatLabel>
</div>
</div>
</div>
</AccordionContent>
</AccordionPanel>
<!--
SEÇÃO 2 CLÍNICO & ORIGEM (violet)
header: badges Ativa / Unimed / Clínica
header: chips Ansiedade / TCC (tags)
card Origem: como chegou, pag, saída
-->
<AccordionPanel value="2" :class="`border-b ${pal.violet.panelBorder}`">
<AccordionHeader :ref="el => setPanelHeaderRef(el, 2)" :pt="{ root: { class: pal.violet.bg } }">
<div class="flex items-center gap-3 w-full pr-2">
<span class="flex items-center justify-center w-7 h-7 rounded-lg shrink-0 text-[0.78rem]" :class="pal.violet.iconBox"><i class="pi pi-heart"/></span>
<div class="flex-1 min-w-0">
<div class="font-semibold text-[0.88rem]">Clínico & origem</div>
<div class="text-[0.68rem] text-[var(--text-color-secondary)] font-normal">Badges de status/convênio · tags · card Origem</div>
</div>
<Tag v-if="p('2').filled===p('2').total" value="Completo" severity="success" class="text-[0.6rem] shrink-0" />
<span v-else-if="p('2').filled>0" class="text-[0.67rem] text-amber-600 font-bold shrink-0">{{ p('2').filled }}/{{ p('2').total }}</span>
</div>
</AccordionHeader>
<AccordionContent>
<div class="p-4">
<!-- Preview dos badges ao vivo -->
<div v-if="form.status||convenioNome||form.patient_scope||tagIdsSelecionadas.length"
class="flex flex-wrap items-center gap-1.5 p-2.5 mb-4 rounded-lg border border-violet-200/50 bg-white/50">
<span class="text-[0.6rem] text-violet-400 w-full -mb-0.5">Preview dos badges no header:</span>
<Tag v-if="form.status" :value="form.status" severity="success" class="text-[0.68rem]"/>
<Tag v-if="convenioNome" :value="convenioNome" severity="info" class="text-[0.68rem]"/>
<Tag v-if="form.patient_scope" :value="form.patient_scope" severity="secondary" class="text-[0.68rem]"/>
<span
v-for="tid in tagIdsSelecionadas.slice(0,4)" :key="tid"
class="inline-flex items-center px-2 py-0.5 rounded-full text-[0.65rem] font-semibold text-white"
:style="{ background: tags.find(t=>t.id===tid)?.color||'#6366F1' }"
>
{{ tags.find(t=>t.id===tid)?.name||'' }}
</span>
</div>
<!-- Situação clínica -->
<div class="flex items-center gap-2 mb-4">
<span class="text-[0.63rem] font-bold uppercase tracking-widest" :class="pal.violet.divTxt">Situação clínica</span>
<div class="flex-1 h-px" :class="pal.violet.divLine"/>
<span class="text-[0.6rem]" :class="pal.violet.hint">Badges no header do perfil</span>
</div>
<div class="grid grid-cols-1 gap-3.5 xl:grid-cols-3 mb-6">
<div>
<FloatLabel variant="on">
<Select id="f_status" v-model="form.status" :options="statusOpts" optionLabel="label" optionValue="value" class="w-full" variant="filled"/>
<label for="f_status">Status</label>
</FloatLabel>
<div class="mt-1 text-[0.63rem]" :class="pal.violet.hint">Badge <span class="font-bold text-green-600">verde</span> no header.</div>
</div>
<div>
<!-- CONVÊNIO seleciona de insurance_plans, máx 1 -->
<div class="text-[0.72rem] text-[var(--text-color-secondary)] mb-1.5 font-medium">Convênio</div>
<div class="flex gap-2 items-start">
<!-- Chip do selecionado -->
<div
v-if="convenioNome"
class="flex-1 flex items-center gap-2 px-3 py-2 rounded-lg border border-blue-200/70 bg-blue-50/60 min-w-0"
>
<i class="pi pi-shield text-blue-500 text-[0.8rem] shrink-0"/>
<span class="text-[0.85rem] font-semibold text-blue-700 truncate flex-1">{{ convenioNome }}</span>
<button type="button" class="shrink-0 text-blue-300 hover:text-blue-600 transition-colors" title="Remover convênio" @click="clearConvenio">
<i class="pi pi-times text-[0.7rem]"/>
</button>
</div>
<!-- Placeholder quando vazio -->
<div
v-else
class="flex-1 flex items-center gap-2 px-3 py-2.5 rounded-lg border border-dashed border-[var(--surface-border)] bg-[var(--surface-ground)] cursor-pointer hover:border-blue-300 hover:bg-blue-50/40 transition-all"
@click="showConvenioDlg = true"
>
<i class="pi pi-shield text-[var(--text-color-secondary)] opacity-40 text-[0.8rem]"/>
<span class="text-[0.82rem] text-[var(--text-color-secondary)] opacity-60">Nenhum convênio</span>
</div>
<Button
:icon="convenioNome ? 'pi pi-pencil' : 'pi pi-plus'"
severity="secondary" outlined
class="shrink-0 h-[38px] w-[38px]"
:title="convenioNome ? 'Trocar convênio' : 'Selecionar convênio'"
@click="showConvenioDlg = true"
/>
</div>
<div class="mt-1 text-[0.63rem]" :class="pal.violet.hint">Badge <span class="font-bold text-blue-500">azul</span> no header · máx 1 convênio.</div>
</div>
<div>
<FloatLabel variant="on">
<Select id="f_scope" v-model="form.patient_scope" :options="scopeOpts" optionLabel="label" optionValue="value" class="w-full" variant="filled"/>
<label for="f_scope">Escopo de atendimento</label>
</FloatLabel>
<div class="mt-1 text-[0.63rem]" :class="pal.violet.hint">Badge <span class="font-bold text-gray-500">cinza</span> no header.</div>
</div>
</div>
<!-- Organização: grupo + tags -->
<div class="flex items-center gap-2 mb-4">
<span class="text-[0.63rem] font-bold uppercase tracking-widest" :class="pal.violet.divTxt">Organização</span>
<div class="flex-1 h-px" :class="pal.violet.divLine"/>
<span class="text-[0.6rem]" :class="pal.violet.hint">Chips coloridos no header</span>
</div>
<div class="grid grid-cols-1 gap-3.5 xl:grid-cols-2 mb-6">
<div class="flex gap-2">
<div class="flex-1 min-w-0">
<FloatLabel variant="on">
<IconField><InputIcon class="pi pi-folder-open"/>
<Select id="f_grupo" v-model="grupoIdSelecionado" :options="groups" optionLabel="nome" optionValue="id" class="w-full pl-[25px]" showClear filter variant="filled"/>
</IconField>
<label for="f_grupo">Grupo</label>
</FloatLabel>
<div class="mt-1 text-[0.63rem]" :class="pal.violet.hint">Define o modelo de anamnese.</div>
</div>
<Button icon="pi pi-plus" severity="secondary" outlined class="shrink-0 h-[42px] mt-[1px]" title="Criar grupo" @click="openGroupDlg"/>
</div>
<div class="flex gap-2">
<div class="flex-1 min-w-0">
<FloatLabel variant="on">
<IconField><InputIcon class="pi pi-tag"/>
<MultiSelect id="f_tags" v-model="tagIdsSelecionadas" :options="tags" optionLabel="name" optionValue="id" class="w-full pl-[25px]" display="chip" filter variant="filled"/>
</IconField>
<label for="f_tags">Tags clínicas</label>
</FloatLabel>
<div class="mt-1 text-[0.63rem]" :class="pal.violet.hint">Aparecem como chips coloridos no header do perfil.</div>
</div>
<Button icon="pi pi-plus" severity="secondary" outlined class="shrink-0 h-[42px] mt-[1px]" title="Criar tag" @click="openTagDlg"/>
</div>
</div>
<!-- Origem alimenta card "Origem" do detalhe -->
<div class="flex items-center gap-2 mb-4">
<span class="text-[0.63rem] font-bold uppercase tracking-widest" :class="pal.violet.divTxt">Origem</span>
<div class="flex-1 h-px" :class="pal.violet.divLine"/>
<span class="text-[0.6rem]" :class="pal.violet.hint">Card "Origem" no perfil</span>
</div>
<div class="grid grid-cols-1 gap-3.5 xl:grid-cols-2">
<div>
<FloatLabel variant="on">
<IconField><InputIcon class="pi pi-megaphone"/><InputText id="f_lead" v-model="form.onde_nos_conheceu" class="w-full" variant="filled"/></IconField>
<label for="f_lead">Como chegou até mim?</label>
</FloatLabel>
<div class="mt-1 text-[0.63rem]" :class="pal.violet.hint">Origem "Como chegou: <strong>Indicação</strong>".</div>
</div>
<div>
<!-- ENCAMINHADO POR múltiplos médicos -->
<div class="text-[0.72rem] text-[var(--text-color-secondary)] mb-1.5 font-medium">Encaminhado por</div>
<!-- Chips dos médicos selecionados via picker -->
<div v-if="medicosSelecionados.length" class="flex flex-wrap gap-1.5 mb-2">
<div
v-for="(m, idx) in medicosSelecionados" :key="m.id"
class="flex items-center gap-1.5 pl-2 pr-1 py-1 rounded-full border border-teal-200/70 bg-teal-50/70 max-w-full"
>
<div class="w-5 h-5 rounded-full bg-teal-200 flex items-center justify-center text-[0.6rem] font-black text-teal-800 shrink-0">
{{ (m.nome||'?').split(' ').map(w=>w[0].toUpperCase()).slice(0,1).join('') }}
</div>
<span class="text-[0.77rem] font-semibold text-teal-700 truncate max-w-[130px]">{{ m.nome }}</span>
<span v-if="m.especialidade" class="text-[0.65rem] text-teal-500/80 hidden sm:inline">· {{ m.especialidade }}</span>
<button type="button" class="shrink-0 text-teal-400 hover:text-teal-700 transition-colors p-0.5 rounded" title="Editar dados do médico" @click="openMedicoEdit(m.id)">
<i class="pi pi-pencil text-[0.62rem]"/>
</button>
<button type="button" class="shrink-0 text-teal-300 hover:text-red-500 transition-colors p-0.5 rounded" @click="removeMedico(idx)" title="Remover">
<i class="pi pi-times text-[0.65rem]"/>
</button>
</div>
</div>
<!-- Fallback: texto salvo no banco (edição sem picker) -->
<div
v-else-if="form.encaminhado_por && isEdit"
class="flex items-center gap-2 mb-2 px-3 py-2 rounded-lg border border-[var(--surface-border)] bg-[var(--surface-ground)] text-[0.82rem] text-[var(--text-color-secondary)]"
>
<i class="pi pi-user text-[0.75rem] shrink-0 opacity-50"/>
<span class="flex-1 truncate">{{ form.encaminhado_por }}</span>
<button
type="button"
class="shrink-0 text-[var(--text-color-secondary)] opacity-40 hover:opacity-80 hover:text-red-500 transition-all text-[0.68rem]"
title="Limpar"
@click="form.encaminhado_por = ''"
>
<i class="pi pi-times"/>
</button>
</div>
<Button
icon="pi pi-user-plus"
:label="medicosSelecionados.length || form.encaminhado_por ? 'Adicionar outro médico' : 'Selecionar médico'"
severity="secondary" outlined size="small"
class="rounded-full w-full"
@click="showMedicoDlg = true"
/>
<div class="mt-1 text-[0.63rem]" :class="pal.violet.hint">Pode adicionar mais de um profissional de referência.</div>
</div>
<div>
<FloatLabel variant="on">
<IconField><InputIcon class="pi pi-sign-out"/><InputText id="f_saida" v-model="form.motivo_saida" class="w-full" variant="filled" placeholder="Se aplicável"/></IconField>
<label for="f_saida">Motivo de saída</label>
</FloatLabel>
<div class="mt-1 text-[0.63rem]" :class="pal.violet.hint">Origem "Motivo de saída" quando preenchido.</div>
</div>
</div>
</div>
</AccordionContent>
</AccordionPanel>
<!--
SEÇÃO 3 REDE DE SUPORTE (amber)
card "Contatos & rede de suporte" do detalhe
is_primario = badge vermelho "emergência"
-->
<AccordionPanel value="3" :class="`border-b ${pal.amber.panelBorder}`">
<AccordionHeader :ref="el => setPanelHeaderRef(el, 3)" :pt="{ root: { class: pal.amber.bg } }">
<div class="flex items-center gap-3 w-full pr-2">
<span class="flex items-center justify-center w-7 h-7 rounded-lg shrink-0 text-[0.78rem]" :class="pal.amber.iconBox"><i class="pi pi-users"/></span>
<div class="flex-1 min-w-0">
<div class="font-semibold text-[0.88rem]">Rede de suporte</div>
<div class="text-[0.68rem] text-[var(--text-color-secondary)] font-normal">Alimenta o card "Contatos &amp; rede de suporte" do perfil</div>
</div>
<Tag
v-if="contatosSuporte.filter(c=>c.nome.trim()).length"
:value="`${contatosSuporte.filter(c=>c.nome.trim()).length} contato${contatosSuporte.filter(c=>c.nome.trim()).length>1?'s':''}`"
severity="success" class="text-[0.6rem] shrink-0"
/>
<Tag v-else value="Opcional" severity="secondary" class="text-[0.6rem] shrink-0"/>
</div>
</AccordionHeader>
<AccordionContent>
<div class="p-4">
<div :class="`flex items-start gap-2 mb-4 p-2.5 rounded-lg border text-[0.75rem] ${pal.amber.infoBox}`">
<i class="pi pi-info-circle mt-0.5 shrink-0"/>
<span>Cada contato aqui aparece no card <strong>"Contatos &amp; rede de suporte"</strong> do perfil. O marcado como <strong>emergência primária</strong> recebe badge vermelho.</span>
</div>
<!-- Lista de contatos -->
<div class="flex flex-col gap-3 mb-3">
<div
v-for="(c, idx) in contatosSuporte" :key="c._k"
class="rounded-xl border bg-white/70 p-3.5 transition-colors"
:class="c.is_primario ? 'border-red-200' : 'border-amber-200/60'"
>
<!-- Cabeçalho do contato -->
<div class="flex items-center justify-between mb-3">
<div class="flex items-center gap-2">
<div class="w-8 h-8 rounded-full bg-amber-200/70 flex items-center justify-center font-black text-[0.72rem] text-amber-800 select-none shrink-0">
{{ iniciaisFor(c.nome) || String(idx+1) }}
</div>
<span class="text-[0.82rem] font-semibold text-[var(--text-color)]">Contato {{ idx+1 }}</span>
<Tag v-if="c.is_primario" value="emergência" severity="danger" class="text-[0.6rem]"/>
</div>
<Button icon="pi pi-trash" severity="danger" text rounded size="small" @click="removeContato(idx)" title="Remover contato"/>
</div>
<!-- Campos -->
<div class="grid grid-cols-1 gap-3 xl:grid-cols-2">
<div class="xl:col-span-2">
<FloatLabel variant="on">
<IconField><InputIcon class="pi pi-user"/>
<InputText :id="`cn_${idx}`" v-model="c.nome" class="w-full" variant="filled"/>
</IconField>
<label :for="`cn_${idx}`">Nome completo</label>
</FloatLabel>
</div>
<div>
<FloatLabel variant="on">
<InputText :id="`cr_${idx}`" v-model="c.relacao" class="w-full" variant="filled" placeholder="Ex: mãe, psiquiatra"/>
<label :for="`cr_${idx}`">Relação / papel</label>
</FloatLabel>
<div class="mt-1 text-[0.63rem]" :class="pal.amber.hint">Subtítulo no card: "Maria Lima · <strong>mãe</strong>".</div>
</div>
<div>
<FloatLabel variant="on">
<Select :id="`ct_${idx}`" v-model="c.tipo" :options="tipoContatoOpts" optionLabel="label" optionValue="value" class="w-full" variant="filled"/>
<label :for="`ct_${idx}`">Tipo</label>
</FloatLabel>
</div>
<div>
<FloatLabel variant="on">
<IconField><InputIcon class="pi pi-phone"/>
<InputMask :id="`ctel_${idx}`" v-model="c.telefone" mask="(99) 99999-9999" :unmask="false" class="w-full" variant="filled"/>
</IconField>
<label :for="`ctel_${idx}`">Telefone</label>
</FloatLabel>
<div class="mt-1 text-[0.63rem]" :class="pal.amber.hint">Exibido abaixo do nome no card.</div>
</div>
<div>
<FloatLabel variant="on">
<IconField><InputIcon class="pi pi-envelope"/>
<InputText :id="`cemail_${idx}`" v-model="c.email" class="w-full" variant="filled"/>
</IconField>
<label :for="`cemail_${idx}`">E-mail</label>
</FloatLabel>
<div class="mt-1 text-[0.63rem]" :class="pal.amber.hint">Exibido ao lado do telefone.</div>
</div>
<!-- Emergência primária -->
<div class="xl:col-span-2">
<div
class="flex items-center gap-3 p-2.5 rounded-lg border cursor-pointer select-none"
:class="c.is_primario ? 'border-red-300 bg-red-50/70' : 'border-[var(--surface-border)] bg-[var(--surface-section,#f8fafc)]'"
@click="c.is_primario = !c.is_primario"
>
<Checkbox :inputId="`cp_${idx}`" v-model="c.is_primario" :binary="true" @click.stop/>
<label :for="`cp_${idx}`" class="cursor-pointer flex-1">
<span class="block text-[0.82rem] font-semibold" :class="c.is_primario ? 'text-red-700' : 'text-[var(--text-color)]'">Contato de emergência primário</span>
<span class="block text-[0.68rem] text-[var(--text-color-secondary)]">Ganha badge vermelho <strong>"emergência"</strong> no card do perfil.</span>
</label>
</div>
</div>
</div>
</div>
</div>
<Button icon="pi pi-plus" label="Adicionar contato" severity="secondary" outlined class="rounded-full w-full" @click="addContato"/>
<p v-if="!contatosSuporte.length" class="text-center text-[0.75rem] text-[var(--text-color-secondary)] mt-3 opacity-50">
Nenhum contato adicionado ainda.
</p>
</div>
</AccordionContent>
</AccordionPanel>
<!--
SEÇÃO 4 RESPONSÁVEL (sky)
-->
<AccordionPanel value="4" :class="`border-b ${pal.sky.panelBorder}`">
<AccordionHeader :ref="el => setPanelHeaderRef(el, 4)" :pt="{ root: { class: pal.sky.bg } }">
<div class="flex items-center gap-3 w-full pr-2">
<span class="flex items-center justify-center w-7 h-7 rounded-lg shrink-0 text-[0.78rem]" :class="pal.sky.iconBox"><i class="pi pi-id-card"/></span>
<div class="flex-1 min-w-0">
<div class="font-semibold text-[0.88rem]">Responsável</div>
<div class="text-[0.68rem] text-[var(--text-color-secondary)] font-normal">Para menores de idade ou cobrança em terceiro</div>
</div>
<Tag v-if="p('4').filled===p('4').total" value="Completo" severity="success" class="text-[0.6rem] shrink-0" />
<Tag v-else value="Opcional" severity="secondary" class="text-[0.6rem] shrink-0"/>
</div>
</AccordionHeader>
<AccordionContent>
<div class="p-4">
<div class="grid grid-cols-1 gap-3.5 xl:grid-cols-2">
<div class="xl:col-span-2">
<FloatLabel variant="on">
<IconField><InputIcon class="pi pi-user"/><InputText id="f_rn" v-model="form.nome_responsavel" class="w-full" variant="filled"/></IconField>
<label for="f_rn">Nome do responsável</label>
</FloatLabel>
</div>
<div>
<FloatLabel variant="on">
<IconField><InputIcon class="pi pi-id-card"/><InputMask id="f_rcpf" v-model="form.cpf_responsavel" mask="999.999.999-99" :unmask="false" class="w-full" variant="filled"/></IconField>
<label for="f_rcpf">CPF do responsável</label>
</FloatLabel>
</div>
<div>
<FloatLabel variant="on">
<IconField><InputIcon class="pi pi-phone"/><InputMask id="f_rtel" v-model="form.telefone_responsavel" mask="(99) 99999-9999" :unmask="false" class="w-full" variant="filled"/></IconField>
<label for="f_rtel">Telefone do responsável</label>
</FloatLabel>
</div>
<div class="xl:col-span-2">
<FloatLabel variant="on">
<Textarea id="f_robs" v-model="form.observacao_responsavel" rows="3" class="w-full" variant="filled"/>
<label for="f_robs">Observações sobre o responsável</label>
</FloatLabel>
</div>
<div class="xl:col-span-2">
<div
class="flex items-center gap-3 p-3 rounded-lg border cursor-pointer select-none"
:class="form.cobranca_no_responsavel ? 'border-sky-300 bg-sky-50/60' : 'border-[var(--surface-border)] bg-[var(--surface-section,#f8fafc)]'"
@click="form.cobranca_no_responsavel = !form.cobranca_no_responsavel"
>
<Checkbox inputId="f_bill" v-model="form.cobranca_no_responsavel" :binary="true" @click.stop/>
<div>
<label for="f_bill" class="block text-[0.85rem] font-semibold text-[var(--text-color)] cursor-pointer">Cobrar no responsável</label>
<div class="text-[0.7rem] text-[var(--text-color-secondary)]">Faturas serão geradas no nome do responsável.</div>
</div>
</div>
</div>
</div>
</div>
</AccordionContent>
</AccordionPanel>
<!--
SEÇÃO 5 ANOTAÇÕES INTERNAS (rose)
-->
<AccordionPanel value="5">
<AccordionHeader :ref="el => setPanelHeaderRef(el, 5)" :pt="{ root: { class: pal.rose.bg } }">
<div class="flex items-center gap-3 w-full pr-2">
<span class="flex items-center justify-center w-7 h-7 rounded-lg shrink-0 text-[0.78rem]" :class="pal.rose.iconBox"><i class="pi pi-lock"/></span>
<div class="flex-1 min-w-0">
<div class="font-semibold text-[0.88rem]">Anotações internas</div>
<div class="text-[0.68rem] text-[var(--text-color-secondary)] font-normal">Visível apenas para a equipe · não vai ao paciente</div>
</div>
<Tag v-if="p('5').filled>0" value="Preenchido" severity="success" class="text-[0.6rem] shrink-0"/>
</div>
</AccordionHeader>
<AccordionContent>
<div class="p-4">
<div :class="`flex items-start gap-2 mb-4 p-2.5 rounded-lg border text-[0.75rem] ${pal.rose.infoBox}`">
<i class="pi pi-shield mt-0.5 shrink-0"/>
<span>Campo interno: <strong>não aparece</strong> no cadastro externo nem é compartilhado com o paciente.</span>
</div>
<FloatLabel variant="on">
<Textarea id="f_notas" v-model="form.notas_internas" rows="8" class="w-full" variant="filled"/>
<label for="f_notas">Notas internas</label>
</FloatLabel>
</div>
</AccordionContent>
</AccordionPanel>
</Accordion>
<!-- Footer salvar -->
<div v-if="!dialogMode" class="flex justify-end gap-2 p-4 border-t border-[var(--surface-border)] bg-[var(--surface-card)]">
<Button label="Cancelar" severity="secondary" outlined class="rounded-full" @click="goBack"/>
<Button label="Salvar paciente" icon="pi pi-check" class="rounded-full" :loading="saving" @click="onSubmit"/>
</div>
</main>
</div>
</div>
<!-- Dialog: Criar grupo -->
<Dialog
v-model:visible="createGroupDialog"
modal
:draggable="false"
:closable="!createGroupSaving"
:dismissableMask="!createGroupSaving"
maximizable
class="dc-dialog w-[36rem]"
:breakpoints="{ '1199px': '90vw', '768px': '94vw' }"
:pt="{
header: { class: '!p-3 !rounded-t-[12px] border-b border-[var(--surface-border)] shadow-[0_1px_0_0_rgba(255,255,255,0.06)] bg-gray-100' },
content: { class: '!p-3' },
footer: { class: '!p-0 !rounded-b-[12px] border-t border-[var(--surface-border)] shadow-[0_1px_0_0_rgba(255,255,255,0.06)] bg-gray-100' },
pcCloseButton: { root: { class: '!rounded-md hover:!text-red-500' } },
pcMaximizeButton: { root: { class: '!rounded-md hover:!text-primary' } },
}"
pt:mask:class="backdrop-blur-xs"
>
<template #header>
<div class="flex w-full items-center justify-between gap-3 px-1">
<div class="flex items-center gap-3 min-w-0">
<span
class="shrink-0 w-3.5 h-3.5 rounded-full border-2 border-white/30 shadow-[0_0_0_3px_rgba(0,0,0,0.08)] transition-colors duration-200"
:style="{ backgroundColor: newGroup.color || '#6366F1' }"
/>
<div class="min-w-0">
<div class="text-base font-semibold truncate">{{ newGroup.name || 'Novo grupo' }}</div>
<div class="text-xs opacity-50">Criando novo grupo de pacientes</div>
</div>
</div>
</div>
</template>
<div class="flex flex-col gap-4">
<div class="flex items-center gap-3">
<label for="gn" class="w-16 text-[0.88rem] font-semibold shrink-0">Nome</label>
<InputText id="gn" v-model="newGroup.name" class="flex-1" variant="filled" autocomplete="off" placeholder="Ex: Crianças"/>
</div>
<div class="flex items-center gap-3">
<label class="w-16 text-[0.88rem] font-semibold shrink-0">Cor</label>
<div class="flex flex-1 items-center gap-2.5">
<input v-model="newGroup.color" type="color" class="h-9 w-12 cursor-pointer rounded-md border border-[var(--surface-border)] bg-transparent"/>
<Chip :label="newGroup.color||'#—'" class="font-semibold" :style="{ backgroundColor:newGroup.color, color:'#fff' }"/>
</div>
</div>
<div v-if="createGroupError" class="text-[0.82rem] text-red-500 flex items-center gap-1.5"><i class="pi pi-exclamation-circle"/>{{ createGroupError }}</div>
</div>
<template #footer>
<div class="flex items-center justify-end gap-2 px-3 py-3">
<Button label="Cancelar" severity="secondary" text class="rounded-full hover:!text-red-500" :disabled="createGroupSaving" @click="createGroupDialog=false"/>
<Button label="Salvar" icon="pi pi-check" class="rounded-full" :loading="createGroupSaving" @click="createGroupPersist"/>
</div>
</template>
</Dialog>
<!-- Dialog: Criar tag -->
<Dialog
v-model:visible="createTagDialog"
modal
:draggable="false"
:closable="!createTagSaving"
:dismissableMask="!createTagSaving"
maximizable
class="dc-dialog w-[36rem]"
:breakpoints="{ '1199px': '90vw', '768px': '94vw' }"
:pt="{
header: { class: '!p-3 !rounded-t-[12px] border-b border-[var(--surface-border)] shadow-[0_1px_0_0_rgba(255,255,255,0.06)] bg-gray-100' },
content: { class: '!p-3' },
footer: { class: '!p-0 !rounded-b-[12px] border-t border-[var(--surface-border)] shadow-[0_1px_0_0_rgba(255,255,255,0.06)] bg-gray-100' },
pcCloseButton: { root: { class: '!rounded-md hover:!text-red-500' } },
pcMaximizeButton: { root: { class: '!rounded-md hover:!text-primary' } },
}"
pt:mask:class="backdrop-blur-xs"
>
<template #header>
<div class="flex w-full items-center justify-between gap-3 px-1">
<div class="flex items-center gap-3 min-w-0">
<span
class="shrink-0 w-3.5 h-3.5 rounded-full border-2 border-white/30 shadow-[0_0_0_3px_rgba(0,0,0,0.08)] transition-colors duration-200"
:style="{ backgroundColor: newTag.color || '#22C55E' }"
/>
<div class="min-w-0">
<div class="text-base font-semibold truncate">{{ newTag.name || 'Nova tag' }}</div>
<div class="text-xs opacity-50">Criando nova tag clínica</div>
</div>
</div>
</div>
</template>
<div class="flex flex-col gap-4">
<div class="flex items-center gap-3">
<label for="tn" class="w-16 text-[0.88rem] font-semibold shrink-0">Nome</label>
<InputText id="tn" v-model="newTag.name" class="flex-1" variant="filled" autocomplete="off" placeholder="Ex: Ansiedade"/>
</div>
<div class="flex items-center gap-3">
<label class="w-16 text-[0.88rem] font-semibold shrink-0">Cor</label>
<div class="flex flex-1 items-center gap-2.5">
<input v-model="newTag.color" type="color" class="h-9 w-12 cursor-pointer rounded-md border border-[var(--surface-border)] bg-transparent"/>
<Chip :label="newTag.color||'#—'" class="font-semibold" :style="{ backgroundColor:newTag.color, color:'#fff' }"/>
</div>
</div>
<div v-if="createTagError" class="text-[0.82rem] text-red-500 flex items-center gap-1.5"><i class="pi pi-exclamation-circle"/>{{ createTagError }}</div>
</div>
<template #footer>
<div class="flex items-center justify-end gap-2 px-3 py-3">
<Button label="Cancelar" severity="secondary" text class="rounded-full hover:!text-red-500" :disabled="createTagSaving" @click="createTagDialog=false"/>
<Button label="Salvar" icon="pi pi-check" class="rounded-full" :loading="createTagSaving" @click="createTagPersist"/>
</div>
</template>
</Dialog>
<!-- Dialog: Convênio (CadastroRapidoConvenio) -->
<CadastroRapidoConvenio
v-model:visible="showConvenioDlg"
:modelValue="convenioId"
@update:modelValue="convenioId = $event"
@selected="onConvenioSelected"
/>
<!-- Dialog: Médico (CadastroRapidoMedico) -->
<CadastroRapidoMedico
v-model:visible="showMedicoDlg"
:editId="medicoEditandoId"
@update:visible="v => { showMedicoDlg = v; if (!v) medicoEditandoId = null }"
@selected="onMedicoSelected"
@created="onMedicoSelected"
/>
</template>