269b531158
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>
2195 lines
121 KiB
Vue
2195 lines
121 KiB
Vue
<!--
|
||
|--------------------------------------------------------------------------
|
||
| 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 ••••456••••90
|
||
| 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: 08h–18h" [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(['08h–12h','13h–18h','08h–20h','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) — só 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) — só 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 (só 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: 08h–18h"/></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 & 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 & 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>
|