Files
agenciapsilmno/src/features/patients/cadastro/PatientsCadastroPage.vue
T
Leonardo 269b531158 Melissa: blueprint tabular + Cadastros/Agendamentos/Pacientes + restore
Sprint E (05-05). Blueprint tabular oficial pras paginas Melissa de
listagem (DataTable + sidebar com stats e filtros coloridos, view
toggle list/grade, subheader explicativo, mobile pencil+popover).

Novo arquivo:
- blueprints/melissa-table-page-blueprint.md (~530L, 18 secoes) —
  referencia canonica MelissaCadastrosRecebidos

Paginas refatoradas/criadas:
- MelissaCadastrosRecebidos: refator pra blueprint (DataTable + frozen
  action + view toggle + subheader)
- MelissaAgendamentosRecebidos (NOVO): substitui o embed via
  MelissaEmbed; 4 status coloridos (Pendente/Autorizado/Convertido/
  Recusado), 3 acoes condicionais (Recusar/Autorizar/Converter em
  sessao), wired com AgendaEventDialog
- MelissaPacientes: refator parcial (subheader, sombras, status pills
  coloridas, email/phone colunas proprias, mobile pencil+popover, fix
  scroll mobile com min-height:0 na .mp-list, view toggle persistido,
  tags/grupos color fix g.cor->g.color, restore de arquivados)
- MelissaEmbed: agendamentos-recebidos removido do EMBED_MAP
- MelissaLayout: wire-up MelissaAgendamentosRecebidos nativo
- composables/useMelissaPacientes + useMelissaPacientesAside ajustes

Restore de pacientes arquivados:
- patientsRepository: novo restorePatient(id, { tenantId })
- PatientsCadastroPage statusOpts: +Arquivado (fecha gap de
  inconsistencia ao editar paciente arquivado)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 09:13:53 -03:00

2195 lines
121 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'
import ContactPhonesEditor from '@/components/ui/ContactPhonesEditor.vue'
import ContactEmailsEditor from '@/components/ui/ContactEmailsEditor.vue'
// V#9 — composables/repo da feature pacientes (extração da página gigante)
import { useCep } from '@/features/patients/composables/useCep'
import { usePatientSupportContacts } from '@/features/patients/composables/usePatientSupportContacts'
// Fase 2b — LGPD export (Art. 18, II)
import { useLgpdExport } from '@/composables/useLgpdExport'
// Fase 5b-7 — drawer global de conversa
import { useConversationDrawerStore } from '@/stores/conversationDrawerStore'
import {
listGroups as repoListGroups,
listTags as repoListTags,
getPatientById as repoGetPatientById,
createPatient as repoCreatePatient,
updatePatient as repoUpdatePatient,
getPatientRelations as repoGetPatientRelations,
replacePatientGroup as repoReplacePatientGroup,
replacePatientTags as repoReplacePatientTags
} from '@/features/patients/services/patientsRepository'
// ─────────────────────────────────────────────────────────
// 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)
// ─────────────────────────────────────────────────────────
// LGPD export (Art. 18, II - portabilidade)
// ─────────────────────────────────────────────────────────
const lgpdDialog = ref(false)
const { loading: lgpdExporting, exportJSON: lgpdExportJSON, exportPDF: lgpdExportPDF } = useLgpdExport()
async function onLgpdExport(format) {
if (!isEdit.value || !patientId.value) return
const name = form.nome_completo || 'paciente'
try {
if (format === 'json') {
await lgpdExportJSON(patientId.value, name)
} else {
await lgpdExportPDF(patientId.value, name, '')
}
toast.add({ severity: 'success', summary: 'Export LGPD gerado', detail: `Arquivo ${format.toUpperCase()} baixado. O evento foi registrado na auditoria.`, life: 5000 })
lgpdDialog.value = false
} catch (err) {
const msg = err?.message || String(err)
toast.add({ severity: 'error', summary: 'Falha no export LGPD', detail: msg, life: 6000 })
logError('PatientsCadastroPage.onLgpdExport', msg, err)
}
}
// Fase 5b-7 — Abre drawer global (não navega)
const conversationDrawer = useConversationDrawerStore()
function goToConversation() {
if (!isEdit.value || !patientId.value) return
conversationDrawer.openForPatient(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
// View mode: 'vertical' (Accordion) | 'horizontal' (Tabs)
const VIEW_MODE_KEY = 'pcd.viewMode.v1'
const viewMode = ref('vertical')
try {
const saved = localStorage.getItem(VIEW_MODE_KEY)
if (saved === 'vertical' || saved === 'horizontal') viewMode.value = saved
} catch (_) {}
watch(viewMode, (v) => { try { localStorage.setItem(VIEW_MODE_KEY, v) } catch (_) {} })
function setViewMode (m) { if (m === 'vertical' || m === 'horizontal') viewMode.value = m }
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, telefone_alternativo, email_principal, email_alternativo agora são
// gerenciados pelos editors polimórficos (contact_phones / contact_emails);
// triggers sincronizam de volta pra essas colunas legadas.
'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
// ─────────────────────────────────────────────────────────
// V#9 — contatos de suporte agora vêm do composable (script -300 linhas)
const _supportContacts = usePatientSupportContacts()
const contatosSuporte = _supportContacts.contatos
const addContato = () => _supportContacts.add()
const removeContato = (i) => _supportContacts.remove(i)
const iniciaisFor = (n) => _supportContacts.iniciaisFor(n)
const loadContatosSuporte = (pid) => _supportContacts.load(pid)
const saveContatosSuporte = (pid, tenantId, ownerId) => _supportContacts.save(pid, tenantId, ownerId)
// V#9 — DB calls delegadas ao patientsRepository (V#3 fundação)
async function listGroups () {
return repoListGroups({ tenantId: currentTenantId() })
}
async function listTags () {
return repoListTags({ tenantId: currentTenantId() })
}
async function getPatientById (id) {
return repoGetPatientById(id, { tenantId: currentTenantId() })
}
async function getPatientRelations (id) {
return repoGetPatientRelations(id)
}
async function createPatient (payload) {
return repoCreatePatient(payload)
}
async function updatePatient (id, payload) {
return repoUpdatePatient(id, { ...payload, updated_at: new Date().toISOString() }, { tenantId: currentTenantId() })
}
const groups = ref([])
const tags = ref([])
const grupoIdSelecionado = ref(null)
const tagIdsSelecionadas = ref([])
// V#9 — replace de grupos/tags via repo
async function replacePatientGroups (patient_id, groupId) {
const { tenantId } = await resolveTenantContextOrFail()
return repoReplacePatientGroup(patient_id, groupId, { tenantId })
}
async function replacePatientTags (patient_id, tagIds) {
const ownerId = await getOwnerId()
const { tenantId } = await resolveTenantContextOrFail()
return repoReplacePatientTags(patient_id, tagIds, { tenantId, ownerId })
}
// V#9 — CEP via composable useCep (reutilizável)
const _cep = useCep()
async function onCepBlur () {
const result = await _cep.fetchCep(form.value.cep)
if (!result) return
form.value.cidade = result.cidade || form.value.cidade
form.value.estado = result.uf || form.value.estado
form.value.bairro = result.bairro || form.value.bairro
form.value.endereco = result.endereco || form.value.endereco
if (!form.value.complemento) form.value.complemento = result.complemento || ''
toast.add({ severity:'success', summary:'CEP', detail:`${result.cidade} / ${result.uf}`, life:2000 })
}
// ─────────────────────────────────────────────────────────
// UI state + fetch
// ─────────────────────────────────────────────────────────
const loading = ref(false)
const saving = ref(false)
const deleting = ref(false)
// `submitted` é true depois da primeira tentativa de salvar — usado pra
// mostrar borda vermelha + msg "Campo obrigatório" embaixo dos inputs
// sem incomodar o usuário antes da primeira interação.
const submitted = ref(false)
// Counts dos editores polimórficos (telefones/emails) — atualizados via
// @change. Telefone e email são obrigatórios: pelo menos 1 cada.
const phonesCount = ref(0)
const emailsCount = ref(0)
// Refs pros editores — usados pra chamar `flushPending` depois que o
// paciente é criado (telefones/emails inseridos antes do save ficam
// em modo pendente até a entidade existir no DB).
const phonesEditorRef = ref(null)
const emailsEditorRef = ref(null)
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 () {
// Marca pra que :invalid + mensagens de erro fiquem visíveis nos inputs
// exigidos. Reseta no sucesso (logo abaixo) ou na próxima edição válida
// (não reseta automaticamente — só atrapalharia o feedback visual).
submitted.value = true
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
}
// Telefone e email são obrigatórios: pelo menos 1 cada. Toast aponta
// pro campo faltando + abre a seção Identidade (onde os editores ficam).
if (phonesCount.value === 0) {
toast.add({ severity:'warn', summary:'Telefone obrigatório', detail:'Adicione pelo menos um telefone.', life:3500 })
await openPanel(0); return
}
if (emailsCount.value === 0) {
toast.add({ severity:'warn', summary:'E-mail obrigatório', detail:'Adicione pelo menos um e-mail.', 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 })
submitted.value = false
if (props.dialogMode) { emit('created', { id:patientId.value }); return }
return
}
const created = await createPatient(payload)
// Telefones/emails podem ter sido adicionados ANTES do paciente existir
// (modo pendente — id 'pending_*' em memória). Agora que temos `created.id`,
// gravamos tudo em lote no DB. Roda antes de avatar/grupos/tags pra que
// qualquer falha aqui aborte o resto do fluxo.
await phonesEditorRef.value?.flushPending('patient', created.id)
await emailsEditorRef.value?.flushPending('patient', created.id)
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 })
submitted.value = false
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' },{ label:'Arquivado',value:'Arquivado' }]
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 })
}
function openLgpdDialog() { lgpdDialog.value = true }
defineExpose({ fillRandomPatient, onSubmit, confirmDelete, saving, deleting, canSee, isEdit, openLgpdDialog, lgpdExporting, goToConversation })
</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 my-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-whatsapp" severity="success" outlined class="h-9 w-9 rounded-full" title="Conversar no WhatsApp" @click="goToConversation" />
<Button v-if="isEdit" icon="pi pi-shield" severity="secondary" outlined class="h-9 w-9 rounded-full" title="Exportar dados do paciente (LGPD)" @click="lgpdDialog = true" />
<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] xl:items-start 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>
<!-- Toggle layout vertical/horizontal -->
<div class="flex items-center gap-1 mb-3 p-0.5 rounded-lg bg-[var(--surface-ground)] border border-[var(--surface-border)]">
<button
type="button"
class="flex-1 flex items-center justify-center gap-1.5 px-2 py-1 rounded-md text-[0.68rem] font-medium transition-all duration-150"
:class="viewMode === 'vertical' ? 'bg-[var(--surface-card)] text-[var(--text-color)] shadow-sm' : 'text-[var(--text-color-secondary)] hover:text-[var(--text-color)]'"
title="Layout vertical (acordeão)"
@click="setViewMode('vertical')"
>
<i class="pi pi-bars text-[0.68rem]" />
<span>Vertical</span>
</button>
<button
type="button"
class="flex-1 flex items-center justify-center gap-1.5 px-2 py-1 rounded-md text-[0.68rem] font-medium transition-all duration-150"
:class="viewMode === 'horizontal' ? 'bg-[var(--surface-card)] text-[var(--text-color)] shadow-sm' : 'text-[var(--text-color-secondary)] hover:text-[var(--text-color)]'"
title="Layout horizontal (abas)"
@click="setViewMode('horizontal')"
>
<i class="pi pi-th-large text-[0.68rem] rotate-90" />
<span>Abas</span>
</button>
</div>
<!-- Nav desktop ( xl) em vertical (em horizontal as tabs ficam acima do form) -->
<div v-if="!isCompact && viewMode === 'vertical'" 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) em vertical -->
<div v-if="isCompact && viewMode === 'vertical'">
<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"
:class="{ 'pcd-horizontal': viewMode === 'horizontal' }"
>
<!-- Tab list ( em horizontal) -->
<div
v-if="viewMode === 'horizontal'"
class="flex gap-0.5 overflow-x-auto px-2 pt-2 border-b border-[var(--surface-border)] bg-[var(--surface-ground)]/40"
role="tablist"
>
<button
v-for="s in sections" :key="s.value"
type="button" role="tab"
:aria-selected="activeValue === s.value"
class="flex items-center gap-1.5 px-3 py-2 rounded-t-lg text-[0.78rem] font-medium border-b-2 transition-all duration-150 shrink-0 whitespace-nowrap"
:class="activeValue === s.value
? `${pal[s.accent].activeBtn} !rounded-b-none`
: 'text-[var(--text-color-secondary)] border-transparent hover:bg-[var(--surface-card)]/60 hover:text-[var(--text-color)]'"
@click="activeValue = s.value"
>
<span class="flex items-center justify-center w-5 h-5 rounded-md text-[0.62rem] shrink-0" :class="pal[s.accent].iconBox">
<i :class="s.icon"/>
</span>
<span>{{ s.label }}</span>
<i v-if="p(s.value).filled === p(s.value).total"
class="pi pi-check-circle text-emerald-500 text-[0.7rem] shrink-0" />
<span v-else-if="p(s.value).filled > 0"
class="text-[0.6rem] text-amber-600 font-bold shrink-0">
{{ p(s.value).filled }}/{{ p(s.value).total }}
</span>
</button>
</div>
<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-5">
<!-- Nome & identidade -->
<div class="flex items-center gap-2 mb-5">
<span class="text-[0.7rem] 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-6 xl:grid-cols-2 mb-7">
<!-- 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"
:invalid="submitted && !String(form.nome_completo || '').trim()"
/>
</IconField>
<label for="f_nome">Nome completo *</label>
</FloatLabel>
<small
v-if="submitted && !String(form.nome_completo || '').trim()"
class="mt-2 text-[0.85rem] text-red-500 flex items-center gap-1.5"
>
<i class="pi pi-exclamation-circle text-[0.78rem]"/>
<span>Campo obrigatório.</span>
</small>
</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>
<!-- 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>
<!-- Data de nascimento InputGroup com idade calculada como addon à direita -->
<div>
<FloatLabel variant="on">
<InputGroup>
<InputGroupAddon><i class="pi pi-calendar"/></InputGroupAddon>
<InputMask id="f_nasc" v-model="form.data_nascimento" mask="99-99-9999" :unmask="false" variant="filled"/>
<InputGroupAddon v-if="ageLabel !== '—'" class="font-semibold text-[var(--primary-color)]">{{ ageLabel }}</InputGroupAddon>
</InputGroup>
<label for="f_nasc">Data de nascimento</label>
</FloatLabel>
</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>
<!-- 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>
<!-- 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>
<!-- 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>
</div>
<!-- Contato alimenta card "Contato" -->
<div class="flex items-center gap-2 mb-5">
<span class="text-[0.7rem] font-bold uppercase tracking-widest" :class="pal.indigo.divTxt">Contato</span>
<div class="flex-1 h-px" :class="pal.indigo.divLine"/>
</div>
<!-- Telefones (polimórfico tipo/número/principal/vinculado) -->
<div class="col-span-full mb-7">
<div
class="flex items-start gap-3 p-4 mb-5 rounded-xl border transition-colors"
:class="submitted && phonesCount === 0
? 'border-red-300 bg-red-50/60 text-red-700'
: pal.indigo.infoBox"
>
<span
class="flex items-center justify-center w-9 h-9 rounded-lg shrink-0"
:class="submitted && phonesCount === 0
? 'bg-red-100 text-red-600'
: pal.indigo.iconBox"
>
<i class="pi pi-phone text-[0.95rem]"/>
</span>
<div class="flex-1 min-w-0">
<div class="text-[0.95rem] font-semibold leading-tight">Telefones *</div>
<div class="text-[0.85rem] mt-1 leading-snug opacity-90">
Marque um telefone como <strong>principal</strong> ele é usado pra cobranças, lembretes automáticos e contato padrão. Número vindo do CRM WhatsApp recebe a etiqueta <strong>"vinculado"</strong>.
</div>
<div
v-if="submitted && phonesCount === 0"
class="mt-2 text-[0.85rem] flex items-center gap-1.5 font-semibold"
>
<i class="pi pi-exclamation-circle text-[0.78rem]"/>
<span>Adicione pelo menos um telefone.</span>
</div>
</div>
</div>
<ContactPhonesEditor
ref="phonesEditorRef"
entity-type="patient"
:entity-id="patientId || null"
@change="(arr) => phonesCount = (arr || []).length"
/>
</div>
<!-- Emails (polimórfico tipo/endereço/principal) -->
<div class="col-span-full mb-7">
<div
class="flex items-start gap-3 p-4 mb-5 rounded-xl border transition-colors"
:class="submitted && emailsCount === 0
? 'border-red-300 bg-red-50/60 text-red-700'
: pal.indigo.infoBox"
>
<span
class="flex items-center justify-center w-9 h-9 rounded-lg shrink-0"
:class="submitted && emailsCount === 0
? 'bg-red-100 text-red-600'
: pal.indigo.iconBox"
>
<i class="pi pi-envelope text-[0.95rem]"/>
</span>
<div class="flex-1 min-w-0">
<div class="text-[0.95rem] font-semibold leading-tight">E-mails *</div>
<div class="text-[0.85rem] mt-1 leading-snug opacity-90">
Marque um e-mail como <strong>principal</strong> ele é usado pra envio de recibos, comprovantes e comunicações oficiais.
</div>
<div
v-if="submitted && emailsCount === 0"
class="mt-2 text-[0.85rem] flex items-center gap-1.5 font-semibold"
>
<i class="pi pi-exclamation-circle text-[0.78rem]"/>
<span>Adicione pelo menos um e-mail.</span>
</div>
</div>
</div>
<ContactEmailsEditor
ref="emailsEditorRef"
entity-type="patient"
:entity-id="patientId || null"
@change="(arr) => emailsCount = (arr || []).length"
/>
</div>
<div class="grid grid-cols-1 gap-4 xl:grid-cols-2">
<!-- 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>
<!-- 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>
<!-- 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-2 text-[0.85rem] text-[var(--primary-color)] flex items-center gap-1.5">
<i class="pi pi-info-circle text-[0.78rem]"/>
<span>Ex: Próximo ao posto, portão azul, sem interfone.</span>
</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-5">
<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-4 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>
<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>
<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>
<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-5">
<!-- 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-5">
<span class="text-[0.7rem] font-bold uppercase tracking-widest" :class="pal.violet.divTxt">Situação clínica</span>
<div class="flex-1 h-px" :class="pal.violet.divLine"/>
</div>
<div class="grid grid-cols-1 gap-4 xl:grid-cols-3 mb-7">
<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>
<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>
<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>
</div>
<!-- Organização: grupo + tags -->
<div class="flex items-center gap-2 mb-5">
<span class="text-[0.7rem] font-bold uppercase tracking-widest" :class="pal.violet.divTxt">Organização</span>
<div class="flex-1 h-px" :class="pal.violet.divLine"/>
</div>
<div class="grid grid-cols-1 gap-6 xl:grid-cols-2 mb-7">
<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="name" optionValue="id" class="w-full pl-[25px]" showClear filter variant="filled"/>
</IconField>
<label for="f_grupo">Grupo</label>
</FloatLabel>
<div class="mt-2 text-[0.85rem] text-[var(--primary-color)] flex items-center gap-1.5">
<i class="pi pi-info-circle text-[0.78rem]"/>
<span>Define o modelo de anamnese aplicado ao paciente.</span>
</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>
<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-5">
<span class="text-[0.7rem] font-bold uppercase tracking-widest" :class="pal.violet.divTxt">Origem</span>
<div class="flex-1 h-px" :class="pal.violet.divLine"/>
</div>
<div class="grid grid-cols-1 gap-4 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>
<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-2 text-[0.85rem] text-[var(--primary-color)] flex items-center gap-1.5">
<i class="pi pi-info-circle text-[0.78rem]"/>
<span>Você pode adicionar mais de um profissional de referência.</span>
</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>
</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-5">
<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>
<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>
<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>
<!-- 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-5">
<div class="grid grid-cols-1 gap-4 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-5">
<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)] bg-[var(--surface-ground)]' },
content: { class: '!p-3' },
footer: { class: '!p-0 !rounded-b-[12px] border-t border-[var(--surface-border)] bg-[var(--surface-ground)]' },
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)] bg-[var(--surface-ground)]' },
content: { class: '!p-3' },
footer: { class: '!p-0 !rounded-b-[12px] border-t border-[var(--surface-border)] bg-[var(--surface-ground)]' },
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"
/>
<!-- Dialog: Export LGPD (Art. 18, II) -->
<Dialog
v-model:visible="lgpdDialog"
modal
:draggable="false"
:closable="!lgpdExporting"
:dismissableMask="!lgpdExporting"
class="w-[34rem]"
:breakpoints="{ '768px': '94vw' }"
header="Exportar dados do paciente (LGPD)"
>
<div class="flex flex-col gap-3 text-sm">
<Message severity="info" :closable="false" class="text-xs">
Atendendo ao <strong>art. 18, II da LGPD</strong> direito de portabilidade do titular.
O evento será registrado na auditoria da clínica.
</Message>
<p>Serão exportados: cadastro, contatos, histórico de status, eventos de agenda, registros financeiros, documentos (metadados), notificações enviadas e auditoria de alterações.</p>
<p class="text-xs text-surface-500">Escolha o formato:</p>
<div class="grid grid-cols-2 gap-2">
<Button label="JSON (completo)" icon="pi pi-code" severity="secondary" outlined :loading="lgpdExporting" @click="onLgpdExport('json')" />
<Button label="PDF (relatório)" icon="pi pi-file-pdf" :loading="lgpdExporting" @click="onLgpdExport('pdf')" />
</div>
<p class="text-[0.7rem] text-surface-400 mt-2">JSON é o formato canônico portável. PDF é legível e adequado pra entregar ao titular.</p>
</div>
<template #footer>
<Button label="Fechar" text :disabled="lgpdExporting" @click="lgpdDialog = false" />
</template>
</Dialog>
</template>
<style scoped>
/* Em modo horizontal, esconde os headers do Accordion (a navegação fica nas tabs em cima do main) */
.pcd-horizontal :deep(.p-accordionheader) {
display: none !important;
}
.pcd-horizontal :deep(.p-accordion-header) {
display: none !important;
}
.pcd-horizontal :deep(.p-accordioncontent),
.pcd-horizontal :deep(.p-accordion-content) {
border: none !important;
}
/* Tira o padding interno do wrapper do AccordionContent — o conteúdo já tem
o próprio padding (.p-5) por seção, então o do PrimeVue duplicava o
espaçamento e dava sensação de elementos descolados. */
:deep(.p-accordioncontent-content) {
padding: 0 !important;
}
</style>