Files
agenciapsilmno/src/features/patients/cadastro/PatientsCadastroPage.vue
Leonardo f733db8436 ZERADO
2026-03-06 06:37:13 -03:00

1959 lines
71 KiB
Vue

<script setup>
import { ref, computed, onMounted, watch, nextTick, 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'
const { canSee } = useRoleGuard()
const route = useRoute()
const router = useRouter()
const toast = useToast()
const confirm = useConfirm()
const tenantStore = useTenantStore()
/**
* ✅ NOTAS IMPORTANTES DO AJUSTE
* - Corrige o 404 (admin usa /pacientes..., therapist usa /patients...).
* - Depois de criar (insert), faz upload do avatar usando o ID recém-criado.
* - Se bucket não for público, troca para signed URL automaticamente (fallback).
*/
// ------------------------------------------------------
// Tenant helpers
// ------------------------------------------------------
async function getCurrentTenantId () {
return tenantStore.tenantId || tenantStore.currentTenantId || tenantStore.tenant?.id
}
async function getCurrentMemberId (tenantId) {
const { data: authData, error: authError } = await supabase.auth.getUser()
if (authError) throw authError
const uid = authData?.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
}
// ------------------------------------------------------
// Accordion: abre 1 por vez + scroll
// ------------------------------------------------------
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 && typeof el.scrollIntoView === 'function') {
el.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
}
// ------------------------------------------------------
// Menu responsivo (>=1200: botões | <1200: popover no MAIN)
// ------------------------------------------------------
const navItems = [
{ value: '0', label: 'Informações pessoais', icon: 'pi pi-user' },
{ value: '1', label: 'Endereço', icon: 'pi pi-map-marker' },
{ value: '2', label: 'Dados adicionais', icon: 'pi pi-briefcase' },
{ value: '3', label: 'Responsável', icon: 'pi pi-users' },
{ value: '4', label: 'Anotações internas', icon: 'pi pi-lock' }
]
const navPopover = ref(null)
function toggleNav (event) { navPopover.value?.toggle(event) }
function selectNav (item) { openPanel(Number(item.value)); navPopover.value?.hide() }
const selectedNav = computed(() => navItems.find(i => i.value === activeValue.value) || null)
// Responsivo < 1200px
const isCompact = ref(false)
let mql = null
let mqlHandler = null
function syncCompact () { isCompact.value = !!mql?.matches }
onMounted(() => {
mql = window.matchMedia('(max-width: 1199px)')
syncCompact()
mqlHandler = () => syncCompact()
if (mql.addEventListener) mql.addEventListener('change', mqlHandler)
else mql.addListener(mqlHandler)
})
onBeforeUnmount(() => {
if (!mql || !mqlHandler) return
if (mql.removeEventListener) mql.removeEventListener('change', mqlHandler)
else mql.removeListener(mqlHandler)
})
// ------------------------------------------------------
// Route helpers
// ------------------------------------------------------
const patientId = computed(() => String(route.params?.id || '').trim() || null)
const isEdit = computed(() => !!patientId.value)
// ------------------------------------------------------
// ✅ FIX 404: base por área + rotas reais (admin: /pacientes | therapist: /patients)
// ------------------------------------------------------
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',
editName: 'therapist-patients-edit',
listPath: '/therapist/patients',
editPath: (id) => `/therapist/patients/cadastro/${id}`
}
}
return {
listName: 'admin-pacientes',
editName: 'admin-pacientes-cadastro-edit',
listPath: '/admin/pacientes',
editPath: (id) => `/admin/pacientes/cadastro/${id}`
}
}
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 () {
const { listName, listPath } = getPatientsRoutes()
if (window.history.length > 1) router.back()
else safePush({ name: listName }, listPath)
}
// ------------------------------------------------------
// Avatar state (TEM que existir no setup)
// ------------------------------------------------------
const avatarFile = ref(null)
const avatarPreviewUrl = ref('')
const avatarUploading = ref(false)
const AVATAR_BUCKET = 'avatars'
function isImageFile (file) {
return !!file && typeof file.type === 'string' && file.type.startsWith('image/')
}
function safeExtFromFile (file) {
const name = String(file?.name || '')
const ext = name.includes('.') ? name.split('.').pop() : ''
const clean = String(ext || '').toLowerCase().replace(/[^a-z0-9]/g, '')
return clean || 'png'
}
function revokePreview () {
if (avatarPreviewUrl.value && 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 (!isImageFile(file)) {
toast.add({ severity: 'warn', summary: 'Avatar', detail: 'Selecione um arquivo de imagem (PNG/JPG/WebP).', life: 3000 })
return
}
avatarFile.value = file
avatarPreviewUrl.value = URL.createObjectURL(file)
toast.add({ severity: 'info', summary: 'Avatar', detail: 'Preview carregado. Clique em “Salvar” para enviar.', life: 2500 })
}
// ✅ Gera URL pública OU signed URL (se o bucket for privado)
async function getReadableAvatarUrl (path) {
// tenta público primeiro
try {
const { data: pub } = supabase.storage.from(AVATAR_BUCKET).getPublicUrl(path)
const publicUrl = pub?.publicUrl || null
if (publicUrl) return publicUrl
} catch (_) {}
// fallback: signed (bucket privado)
const { data, error } = await supabase.storage
.from(AVATAR_BUCKET)
.createSignedUrl(path, 60 * 60 * 24 * 7) // 7 dias
if (error) throw error
if (!data?.signedUrl) throw new Error('Não consegui gerar signed URL do avatar.')
return data.signedUrl
}
async function uploadAvatarToStorage ({ ownerId, patientId, file }) {
if (!ownerId) throw new Error('ownerId ausente.')
if (!patientId) throw new Error('patientId ausente.')
if (!file) throw new Error('Arquivo de avatar ausente.')
if (!isImageFile(file)) throw new Error('Selecione um arquivo de imagem (PNG/JPG/WebP).')
const maxBytes = 3 * 1024 * 1024
if (file.size > maxBytes) throw new Error('Imagem muito grande. Use até 3MB.')
const ext = safeExtFromFile(file)
const path = `owners/${ownerId}/patients/${patientId}/avatar.${ext}`
const { error: upErr } = await supabase.storage
.from(AVATAR_BUCKET)
.upload(path, file, {
upsert: true,
cacheControl: '3600',
contentType: file.type || 'image/*'
})
if (upErr) throw upErr
const readableUrl = await getReadableAvatarUrl(path)
return { publicUrl: readableUrl, 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
})
// UI
form.value.avatar_url = publicUrl
avatarFile.value = null
revokePreview()
avatarPreviewUrl.value = publicUrl
// DB
await updatePatient(id, { avatar_url: publicUrl })
return publicUrl
} catch (e) {
toast.add({
severity: 'warn',
summary: 'Avatar',
detail: e?.message || 'Falha ao enviar avatar.',
life: 4500
})
return null
} finally {
avatarUploading.value = false
}
}
// ------------------------------------------------------
// Form state
// ------------------------------------------------------
function resetForm () {
return {
nome_completo: '',
telefone: '',
email_principal: '',
email_alternativo: '',
telefone_alternativo: '',
data_nascimento: '',
genero: '',
estado_civil: '',
cpf: '',
rg: '',
naturalidade: '',
observacoes: '',
onde_nos_conheceu: '',
encaminhado_por: '',
cep: '',
pais: 'Brasil',
cidade: '',
estado: 'SP',
endereco: '',
numero: '',
bairro: '',
complemento: '',
escolaridade: '',
profissao: '',
nome_parente: '',
grau_parentesco: '',
telefone_parente: '',
nome_responsavel: '',
cpf_responsavel: '',
telefone_responsavel: '',
observacao_responsavel: '',
cobranca_no_responsavel: false,
notas_internas: '',
avatar_url: ''
}
}
const form = ref(resetForm())
// ------------------------------------------------------
// Helpers: dígitos / formatação
// ------------------------------------------------------
function digitsOnly (v) {
const s = String(v ?? '').replace(/\D/g, '')
return s || ''
}
function fmtCPF (v) {
const digits = digitsOnly(v).slice(0, 11)
if (!digits) return ''
return digits
.replace(/^(\d{3})(\d)/, '$1.$2')
.replace(/^(\d{3})\.(\d{3})(\d)/, '$1.$2.$3')
.replace(/\.(\d{3})(\d)/, '.$1-$2')
}
function fmtRG (v) {
if (!v) return ''
const digits = digitsOnly(v).slice(0, 9)
if (!digits) return ''
return digits
.replace(/^(\d{2})(\d)/, '$1.$2')
.replace(/^(\d{2})\.(\d{3})(\d)/, '$1.$2.$3')
.replace(/\.(\d{3})(\d)/, '.$1-$2')
}
function fmtPhone (digitsOrFormatted) {
const digits = digitsOnly(digitsOrFormatted)
if (!digits) return ''
if (digits.length === 11) return digits.replace(/^(\d{2})(\d{5})(\d{4})$/, '($1) $2-$3')
if (digits.length === 10) return digits.replace(/^(\d{2})(\d{4})(\d{4})$/, '($1) $2-$3')
return digits
}
// ------------------------------------------------------
// Helpers: data DD-MM-AAAA <-> ISO
// ------------------------------------------------------
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])
const mm = Number(m[2])
const yyyy = Number(m[3])
const dt = new Date(yyyy, mm - 1, dd)
if (Number.isNaN(dt.getTime())) return null
if (dt.getFullYear() !== yyyy || dt.getMonth() !== mm - 1 || dt.getDate() !== dd) return null
return dt
}
function toISODateFromDDMMYYYY (s) {
const dt = parseDDMMYYYY(s)
if (!dt) return null
const yyyy = String(dt.getFullYear()).padStart(4, '0')
const mm = String(dt.getMonth() + 1).padStart(2, '0')
const dd = String(dt.getDate()).padStart(2, '0')
return `${yyyy}-${mm}-${dd}`
}
function isoToDDMMYYYY (value) {
if (!value) return ''
const s = String(value).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 (Number.isNaN(d.getTime())) return ''
const dd = String(d.getDate()).padStart(2, '0')
const mm = String(d.getMonth() + 1).padStart(2, '0')
const yyyy = d.getFullYear()
return `${dd}-${mm}-${yyyy}`
}
const ageLabel = computed(() => {
const dt = parseDDMMYYYY(form.value?.data_nascimento)
if (!dt) return '—'
const now = new Date()
let age = now.getFullYear() - dt.getFullYear()
const mm = now.getMonth() - dt.getMonth()
if (mm < 0 || (mm === 0 && now.getDate() < dt.getDate())) age--
if (age < 0 || age > 130) return '—'
return `${age} anos`
})
// ------------------------------------------------------
// map DB -> Form
// ------------------------------------------------------
function mapDbToForm (p) {
return {
...resetForm(),
nome_completo: p.nome_completo ?? '',
telefone: fmtPhone(p.telefone ?? ''),
email_principal: p.email_principal ?? '',
email_alternativo: p.email_alternativo ?? '',
telefone_alternativo: fmtPhone(p.telefone_alternativo ?? ''),
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 ?? '',
observacoes: p.observacoes ?? '',
onde_nos_conheceu: p.onde_nos_conheceu ?? '',
encaminhado_por: p.encaminhado_por ?? '',
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 ?? '',
escolaridade: p.escolaridade ?? '',
profissao: p.profissao ?? '',
nome_parente: p.nome_parente ?? '',
grau_parentesco: p.grau_parentesco ?? '',
telefone_parente: fmtPhone(p.telefone_parente ?? ''),
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,
notas_internas: p.notas_internas ?? '',
avatar_url: p.avatar_url ?? ''
}
}
// ------------------------------------------------------
// Owner (auth)
// ------------------------------------------------------
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 (auth.getUser).')
return uid
}
// ------------------------------------------------------
// Sanitização
// ------------------------------------------------------
const PACIENTES_COLUNAS_PERMITIDAS = new Set([
'owner_id',
'tenant_id',
'responsible_member_id',
'nome_completo',
'telefone',
'email_principal',
'email_alternativo',
'telefone_alternativo',
'data_nascimento',
'genero',
'estado_civil',
'cpf',
'rg',
'naturalidade',
'observacoes',
'onde_nos_conheceu',
'encaminhado_por',
'pais',
'cep',
'cidade',
'estado',
'endereco',
'numero',
'bairro',
'complemento',
'escolaridade',
'profissao',
'nome_parente',
'grau_parentesco',
'telefone_parente',
'nome_responsavel',
'cpf_responsavel',
'telefone_responsavel',
'observacao_responsavel',
'cobranca_no_responsavel',
'notas_internas',
'avatar_url'
])
function sanitizePayload (raw, ownerId) {
const payload = {
owner_id: ownerId,
nome_completo: raw.nome_completo,
telefone: raw.telefone,
email_principal: raw.email_principal,
email_alternativo: raw.email_alternativo || null,
telefone_alternativo: raw.telefone_alternativo || null,
data_nascimento: raw.data_nascimento || null,
genero: raw.genero || null,
estado_civil: raw.estado_civil || null,
cpf: raw.cpf || null,
rg: raw.rg || null,
naturalidade: raw.naturalidade || null,
observacoes: raw.observacoes || null,
onde_nos_conheceu: raw.onde_nos_conheceu || null,
encaminhado_por: raw.encaminhado_por || null,
cep: raw.cep || null,
pais: raw.pais || null,
cidade: raw.cidade || null,
estado: raw.estado || null,
endereco: raw.endereco || null,
numero: raw.numero || null,
bairro: raw.bairro || null,
complemento: raw.complemento || null,
escolaridade: raw.escolaridade || null,
profissao: raw.profissao || null,
nome_parente: raw.nome_parente || null,
grau_parentesco: raw.grau_parentesco || null,
telefone_parente: raw.telefone_parente || null,
nome_responsavel: raw.nome_responsavel || null,
cpf_responsavel: raw.cpf_responsavel || null,
telefone_responsavel: raw.telefone_responsavel || null,
observacao_responsavel: raw.observacao_responsavel || null,
cobranca_no_responsavel: !!raw.cobranca_no_responsavel,
notas_internas: raw.notas_internas || null,
avatar_url: raw.avatar_url || null
}
// strings vazias -> null e trim
Object.keys(payload).forEach(k => {
if (payload[k] === '') payload[k] = null
if (typeof payload[k] === 'string') {
const t = payload[k].trim()
payload[k] = t === '' ? null : t
}
})
payload.cpf = payload.cpf ? digitsOnly(payload.cpf) : null
payload.rg = payload.rg ? digitsOnly(payload.rg) : null
payload.cpf_responsavel = payload.cpf_responsavel ? digitsOnly(payload.cpf_responsavel) : null
payload.telefone = payload.telefone ? digitsOnly(payload.telefone) : null
payload.telefone_alternativo = payload.telefone_alternativo ? digitsOnly(payload.telefone_alternativo) : null
payload.telefone_parente = payload.telefone_parente ? digitsOnly(payload.telefone_parente) : null
payload.telefone_responsavel = payload.telefone_responsavel ? digitsOnly(payload.telefone_responsavel) : null
payload.data_nascimento = payload.data_nascimento
? (toISODateFromDDMMYYYY(payload.data_nascimento) || null)
: null
const filtrado = {}
Object.keys(payload).forEach(k => {
if (PACIENTES_COLUNAS_PERMITIDAS.has(k)) filtrado[k] = payload[k]
})
return filtrado
}
// ------------------------------------------------------
// Supabase: lists / get / relations
// ------------------------------------------------------
async function listGroups () {
const probe = await supabase.from('patient_groups').select('*').limit(1)
if (probe.error) throw probe.error
const row = probe.data?.[0] || {}
const hasPT = ('nome' in row) || ('cor' in row)
const hasEN = ('name' in row) || ('color' in row)
if (hasPT) {
const { data, error } = await supabase
.from('patient_groups')
.select('id,nome,descricao,cor,is_system,is_active')
.eq('is_active', true)
.order('nome', { ascending: true })
if (error) throw error
return (data || []).map(g => ({ ...g, name: g.nome, color: g.cor }))
}
if (hasEN) {
const { data, error } = await supabase
.from('patient_groups')
.select('id,name,description,color,is_system,is_active')
.eq('is_active', true)
.order('name', { ascending: true })
if (error) throw error
return (data || []).map(g => ({ ...g, nome: g.name, cor: g.color }))
}
const { data, error } = await supabase.from('patient_groups').select('*').order('id', { ascending: true })
if (error) throw error
return data || []
}
async function listTags () {
const probe = await supabase.from('patient_tags').select('*').limit(1)
if (probe.error) throw probe.error
const row = probe.data?.[0] || {}
const hasEN = ('name' in row) || ('color' in row)
const hasPT = ('nome' in row) || ('cor' in row)
if (hasEN) {
const { data, error } = await supabase.from('patient_tags').select('id,name,color').order('name', { ascending: true })
if (error) throw error
return data || []
}
if (hasPT) {
const { data, error } = await supabase.from('patient_tags').select('id,nome,cor').order('nome', { ascending: true })
if (error) throw error
return (data || []).map(t => ({ ...t, name: t.nome, color: t.cor }))
}
const { data, error } = await supabase.from('patient_tags').select('*').order('id', { ascending: true })
if (error) throw error
return (data || []).map(t => ({ ...t, name: t.name ?? t.nome ?? '', color: t.color ?? t.cor ?? null }))
}
async function getPatientById (id) {
const { data, error } = await supabase.from('patients').select('*').eq('id', id).single()
if (error) throw error
return data
}
async function getPatientRelations (id) {
const { data: g, error: ge } = await supabase
.from('patient_group_patient')
.select('patient_group_id')
.eq('patient_id', id)
if (ge) throw ge
const { data: t, error: te } = await supabase
.from('patient_patient_tag')
.select('tag_id')
.eq('patient_id', id)
if (te) throw te
return {
groupIds: (g || []).map(x => x.patient_group_id).filter(Boolean),
tagIds: (t || []).map(x => x.tag_id).filter(Boolean)
}
}
async function createPatient (payload) {
const { data, error } = await supabase.from('patients').insert(payload).select('id').single()
if (error) throw error
return data
}
async function updatePatient (id, payload) {
const { error } = await supabase
.from('patients')
.update({ ...payload, updated_at: new Date().toISOString() })
.eq('id', id)
if (error) throw error
}
// ------------------------------------------------------
// Relations state
// ------------------------------------------------------
const groups = ref([])
const tags = ref([])
const grupoIdSelecionado = ref(null)
const tagIdsSelecionadas = ref([])
async function replacePatientGroups (patient_id, groupId) {
const { error: delErr } = await supabase.from('patient_group_patient').delete().eq('patient_id', patient_id)
if (delErr) throw delErr
if (!groupId) return
const { tenantId } = await resolveTenantContextOrFail()
const { error: insErr } = await supabase.from('patient_group_patient').insert({ patient_id, patient_group_id: groupId, tenant_id: tenantId })
if (insErr) throw insErr
}
async function replacePatientTags (patient_id, tagIds) {
const ownerId = await getOwnerId()
const { error: delErr } = await supabase
.from('patient_patient_tag')
.delete()
.eq('patient_id', patient_id)
.eq('owner_id', ownerId)
if (delErr) throw delErr
const clean = Array.from(new Set([...(tagIds || [])].filter(Boolean)))
if (!clean.length) return
const { tenantId } = await resolveTenantContextOrFail()
const rows = clean.map(tag_id => ({ owner_id: ownerId, patient_id, tag_id, tenant_id: tenantId }))
const { error: insErr } = await supabase.from('patient_patient_tag').insert(rows)
if (insErr) throw insErr
}
// ------------------------------------------------------
// CEP (ViaCEP)
// ------------------------------------------------------
async function fetchCep (cepRaw) {
const cep = digitsOnly(cepRaw)
if (cep.length !== 8) return null
const res = await fetch(`https://viacep.com.br/ws/${cep}/json/`)
const data = await res.json()
if (!data || data.erro) return null
return data
}
async function onCepBlur () {
try {
const d = await fetchCep(form.value.cep)
if (!d) return
form.value.cidade = d.localidade || form.value.cidade
form.value.estado = d.uf || form.value.estado
form.value.bairro = d.bairro || form.value.bairro
form.value.endereco = d.logradouro || form.value.endereco
if (!form.value.complemento) form.value.complemento = d.complemento || ''
} catch (_) {}
}
// ------------------------------------------------------
// UI state
// ------------------------------------------------------
const loading = ref(false)
const saving = ref(false)
const deleting = ref(false)
// ------------------------------------------------------
// Fetch (load everything)
// ------------------------------------------------------
async function fetchAll () {
loading.value = true
try {
const [gRes, tRes] = await Promise.allSettled([listGroups(), listTags()])
if (gRes.status === 'fulfilled') groups.value = gRes.value || []
else {
groups.value = []
console.warn('[listGroups error]', gRes.reason)
toast.add({ severity: 'warn', summary: 'Grupos', detail: gRes.reason?.message || 'Falha ao carregar grupos', life: 3500 })
}
if (tRes.status === 'fulfilled') tags.value = tRes.value || []
else {
tags.value = []
console.warn('[listTags error]', tRes.reason)
toast.add({ severity: 'warn', summary: 'Tags', detail: tRes.reason?.message || 'Falha ao carregar tags', life: 3500 })
}
if (isEdit.value) {
const p = await getPatientById(patientId.value)
form.value = mapDbToForm(p)
// se já tinha avatar no banco, garante preview
avatarPreviewUrl.value = form.value.avatar_url || ''
const rel = await getPatientRelations(patientId.value)
grupoIdSelecionado.value = rel.groupIds?.[0] || null
tagIdsSelecionadas.value = rel.tagIds || []
} else {
//form.value = resetForm()
grupoIdSelecionado.value = null
tagIdsSelecionadas.value = []
avatarFile.value = null
revokePreview()
}
} catch (err) {
toast.add({ severity: 'error', summary: 'Erro', detail: err?.message || 'Falha ao carregar cadastro', life: 3500 })
} finally {
loading.value = false
}
}
watch(() => route.params?.id, fetchAll, { immediate: true })
onMounted(fetchAll)
// ------------------------------------------------------
// Tenant resolve (robusto)
// ------------------------------------------------------
async function resolveTenantContextOrFail () {
const { data: authData, error: authError } = await supabase.auth.getUser()
if (authError) throw authError
const uid = authData?.user?.id
if (!uid) throw new Error('Sessão inválida.')
// 1) tenta pelo store
const storeTid = await getCurrentTenantId()
if (storeTid) {
try {
const mid = await getCurrentMemberId(storeTid)
return { tenantId: storeTid, memberId: mid }
} catch (_) {
// cai pro fallback (último membership active)
}
}
// 2) fallback
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 }
}
// ------------------------------------------------------
// Submit
// ------------------------------------------------------
async function onSubmit () {
try {
saving.value = true
const ownerId = await getOwnerId()
const { tenantId, memberId } = await resolveTenantContextOrFail()
// depois do sanitize
const payload = sanitizePayload(form.value, ownerId)
payload.tenant_id = tenantId
payload.responsible_member_id = memberId
// ✅ validações mínimas (NÃO DEIXA CHEGAR NO BANCO)
const nome = String(form.value?.nome_completo || '').trim()
if (!nome) {
toast.add({
severity: 'warn',
summary: 'Nome obrigatório',
detail: 'Preencha “Nome completo” para salvar o paciente.',
life: 3500
})
// abre o painel certo (você já tem navItems: "Informações pessoais" é o 0)
await openPanel(0)
return
}
// ---------------------------
// EDIT
// ---------------------------
if (isEdit.value) {
await updatePatient(patientId.value, payload)
// ✅ Se houver avatar selecionado, sobe e grava avatar_url
await maybeUploadAvatar(ownerId, patientId.value)
await replacePatientGroups(patientId.value, grupoIdSelecionado.value)
await replacePatientTags(patientId.value, tagIdsSelecionadas.value)
toast.add({ severity: 'success', summary: 'Salvo', detail: 'Paciente atualizado.', life: 2500 })
return
}
// ---------------------------
// CREATE
// ---------------------------
const created = await createPatient(payload)
// ✅ upload do avatar usando ID recém-criado
await maybeUploadAvatar(ownerId, created.id)
await replacePatientGroups(created.id, grupoIdSelecionado.value)
await replacePatientTags(created.id, tagIdsSelecionadas.value)
toast.add({ severity: 'success', summary: 'Salvo', detail: 'Paciente cadastrado.', life: 2500 })
// ✅ NÃO navega para /cadastro/:id (fica em /admin/pacientes/cadastro)
// Em vez disso, reseta o formulário para novo cadastro:
form.value = resetForm()
grupoIdSelecionado.value = null
tagIdsSelecionadas.value = []
avatarFile.value = null
revokePreview()
avatarPreviewUrl.value = ''
// volta pro primeiro painel (UX boa)
await openPanel(0)
return
} catch (e) {
console.error(e)
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao salvar paciente.', life: 4000 })
} finally {
saving.value = false
}
}
// ------------------------------------------------------
// Delete
// ------------------------------------------------------
function confirmDelete () {
if (!isEdit.value) return
confirm.require({
header: 'Excluir paciente',
message: 'Tem certeza que deseja excluir este paciente? Essa ação não pode ser desfeita.',
icon: 'pi pi-exclamation-triangle',
acceptLabel: 'Excluir',
rejectLabel: 'Cancelar',
acceptClass: 'p-button-danger',
accept: async () => doDelete()
})
}
async function doDelete () {
if (!isEdit.value) return
deleting.value = true
try {
const pid = patientId.value
const { error: e1 } = await supabase.from('patient_group_patient').delete().eq('patient_id', pid)
if (e1) throw e1
const { error: e2 } = await supabase.from('patient_patient_tag').delete().eq('patient_id', pid)
if (e2) throw e2
const { error: e3 } = await supabase.from('patients').delete().eq('id', pid)
if (e3) throw e3
toast.add({ severity: 'success', summary: 'Excluído', detail: 'Paciente excluído.', life: 2500 })
goBack()
} catch (err) {
toast.add({ severity: 'error', summary: 'Erro', detail: err?.message || 'Falha ao excluir paciente', life: 4000 })
} finally {
deleting.value = false
}
}
// ------------------------------------------------------
// Fake fill (opcional) — mantive como você tinha
// ------------------------------------------------------
function randInt (min, max) { return Math.floor(Math.random() * (max - min + 1)) + min }
function pick (arr) { return arr[randInt(0, arr.length - 1)] }
function maybe (p = 0.5) { return Math.random() < p }
function pad2 (n) { return String(n).padStart(2, '0') }
function randomDateDDMMYYYY (minAge = 6, maxAge = 75) {
const now = new Date()
const age = randInt(minAge, maxAge)
const year = now.getFullYear() - age
const month = randInt(1, 12)
const day = randInt(1, 28)
return `${pad2(day)}-${pad2(month)}-${year}`
}
function randomPhoneBR () {
const ddd = randInt(11, 99)
const nine = maybe(0.8) ? '9' : ''
const p1 = randInt(1000, 9999)
const p2 = randInt(1000, 9999)
return `+55 (${ddd}) ${nine}${p1}-${p2}`
}
function randomCEP () { return `${randInt(10000, 99999)}-${randInt(100, 999)}` }
function generateCPF () {
const n = Array.from({ length: 9 }, () => randInt(0, 9))
const calcDV = (base) => {
let sum = 0
for (let i = 0; i < base.length; i++) sum += base[i] * (base.length + 1 - i)
const mod = sum % 11
return mod < 2 ? 0 : 11 - mod
}
const d1 = calcDV(n)
const d2 = calcDV([...n, d1])
const cpf = [...n, d1, d2].join('')
if (/^(\d)\1+$/.test(cpf)) return generateCPF()
return cpf.replace(/^(\d{3})(\d{3})(\d{3})(\d{2})$/, '$1.$2.$3-$4')
}
function randomEmailFromName (name) {
const slug = String(name || 'paciente')
.normalize('NFD').replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '.')
.replace(/(^\.)|(\.$)/g, '')
return `${slug}.${randInt(10, 999)}@email.com`
}
function fillRandomPatient () {
const first = ['Ana','Bruno','Carla','Daniel','Eduarda','Felipe','Giovana','Henrique','Isabela','João','Larissa','Marcos','Nathalia','Otávio','Paula','Rafael','Sabrina','Thiago','Vanessa','Yasmin']
const last = ['Silva','Santos','Oliveira','Souza','Pereira','Lima','Ferreira','Almeida','Costa','Gomes','Ribeiro','Carvalho','Martins','Araújo','Barbosa']
const cities = ['São Carlos','Ribeirão Preto','Campinas','São Paulo','Araraquara','Bauru','Sorocaba','Santos']
const states = ['SP','RJ','MG','PR','SC','RS','BA','PE']
const streets = ['Rua das Flores','Av. Brasil','Rua XV de Novembro','Av. São Carlos','Rua das Acácias']
const bairros = ['Centro','Jardim Paulista','Vila Prado','Santa Felícia','Cidade Jardim']
const escolaridades = ['Ensino Fundamental','Ensino Médio','Superior incompleto','Superior completo','Pós-graduação']
const profissoes = ['Estudante','Professora','Desenvolvedor','Vendedor','Enfermeira','Autônomo','Servidor público']
const fontes = ['Instagram','Google','Indicação','Site','Threads','Outro']
const parentescos = ['Mãe','Pai','Irmã','Irmão','Cônjuge','Tia','Avó','Avô']
const nomeCompleto = `${pick(first)} ${pick(last)} ${pick(last)}`
const cidade = pick(cities)
const estado = pick(states)
form.value = {
...resetForm(),
nome_completo: nomeCompleto,
telefone: randomPhoneBR(),
email_principal: randomEmailFromName(nomeCompleto),
email_alternativo: `alt.${randInt(10,999)}@email.com`,
telefone_alternativo: randomPhoneBR(),
data_nascimento: randomDateDDMMYYYY(6, 78),
genero: pick(['Feminino','Masculino','Não-binário','Prefere não informar','Outro']),
estado_civil: pick(['Solteiro(a)','Casado(a)','União estável','Divorciado(a)','Separado(a)','Viúvo(a)']),
cpf: fmtCPF(generateCPF()),
rg: fmtRG(String(randInt(10000000, 999999999))),
naturalidade: cidade,
observacoes: 'Paciente relata ansiedade e sobrecarga emocional.',
onde_nos_conheceu: pick(fontes),
encaminhado_por: `${pick(first)} ${pick(last)}`,
cep: randomCEP(),
pais: 'Brasil',
cidade: cidade,
estado: estado,
endereco: pick(streets),
numero: String(randInt(10, 9999)),
bairro: pick(bairros),
complemento: `Apto ${randInt(10, 999)}`,
escolaridade: pick(escolaridades),
profissao: pick(profissoes),
nome_parente: `${pick(first)} ${pick(last)}`,
grau_parentesco: pick(parentescos),
telefone_parente: randomPhoneBR(),
nome_responsavel: `${pick(first)} ${pick(last)} ${pick(last)}`,
cpf_responsavel: fmtCPF(generateCPF()),
telefone_responsavel: randomPhoneBR(),
observacao_responsavel: 'Responsável ciente do contrato.',
cobranca_no_responsavel: true,
notas_internas: 'Paciente apresenta discurso organizado. Acompanhar evolução clínica.',
avatar_url: ''
}
if (Array.isArray(groups.value) && groups.value.length) grupoIdSelecionado.value = pick(groups.value).id
if (Array.isArray(tags.value) && tags.value.length) {
const shuffled = [...tags.value].sort(() => Math.random() - 0.5)
tagIdsSelecionadas.value = shuffled.slice(0, randInt(1, Math.min(3, tags.value.length))).map(t => t.id)
}
toast.add({ severity: 'info', summary: 'Preenchido', detail: 'Paciente preenchido com dados fictícios.', life: 2500 })
}
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 maritalStatusOptions = [
{ 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' }
]
// ------------------------------------------------------
// Dialogs: criar 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 para o grupo.'; return }
createGroupSaving.value = true
try {
const ownerId = await getOwnerId()
const { tenantId } = await resolveTenantContextOrFail()
let createdId = null
const { data, error } = await supabase
.from('patient_groups')
.insert({ owner_id: ownerId, tenant_id: tenantId, nome: name, descricao: null, cor: color, is_system: false, is_active: true })
.select('id')
.single()
if (error) throw error
createdId = data?.id || null
groups.value = await listGroups()
if (createdId) grupoIdSelecionado.value = createdId
toast.add({ severity: 'success', summary: 'Grupo', detail: 'Grupo criado.', life: 2500 })
createGroupDialog.value = false
} catch (e) {
const msg = e?.message || ''
if (e?.code === '23505' || /duplicate key value/i.test(msg)) {
createGroupError.value = 'Já existe um grupo com esse nome.'
} else {
createGroupError.value = msg || 'Falha ao criar grupo.'
}
} 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 para a tag.'; return }
createTagSaving.value = true
try {
const ownerId = await getOwnerId()
const { tenantId } = await resolveTenantContextOrFail()
let createdId = null
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
createdId = data?.id || null
tags.value = await listTags()
if (createdId) {
const set = new Set([...(tagIdsSelecionadas.value || []), createdId])
tagIdsSelecionadas.value = Array.from(set)
}
toast.add({ severity: 'success', summary: 'Tag', detail: 'Tag criada.', life: 2500 })
createTagDialog.value = false
} catch (e) {
const msg = e?.message || ''
if (e?.code === '23505' || /duplicate key value/i.test(msg)) {
createTagError.value = 'Já existe uma tag com esse nome.'
} else {
createTagError.value = msg || 'Falha ao criar tag.'
}
} finally {
createTagSaving.value = false
}
}
</script>
<template>
<div class="min-h-screen bg-surface-50 dark:bg-surface-950">
<Toast />
<ConfirmDialog />
<div class="px-3 py-3 md:px-4">
<Card class="w-full max-w-[1100px] mx-auto">
<template #title>
<div class="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div class="min-w-0">
<div class="text-xl font-semibold text-surface-900 dark:text-surface-0">
{{ isEdit ? 'Editar paciente' : 'Cadastrar paciente' }}
</div>
<div class="mt-1 text-sm text-surface-600 dark:text-surface-300">
Idade: <b class="text-surface-900 dark:text-surface-0">{{ ageLabel }}</b>
</div>
</div>
<div class="flex flex-wrap gap-2">
<Button
v-if="canSee('testMODE')"
label="Preencher tudo"
icon="pi pi-bolt"
severity="secondary"
outlined
@click="fillRandomPatient"
/>
<Button
label="Voltar"
icon="pi pi-arrow-left"
severity="secondary"
outlined
@click="goBack"
/>
<Button
v-if="isEdit"
label="Excluir"
icon="pi pi-trash"
severity="danger"
outlined
:loading="deleting"
@click="confirmDelete"
/>
<Button
label="Salvar"
icon="pi pi-check"
:loading="saving"
@click="onSubmit"
/>
</div>
</div>
</template>
<template #content>
<div v-if="loading" class="p-4 text-sm text-surface-600 dark:text-surface-300">
Carregando
</div>
<div v-else class="grid grid-cols-1 gap-4 xl:grid-cols-[260px_1fr]">
<!-- SIDEBAR -->
<aside
class="rounded-2xl border border-surface-200 bg-white p-3 shadow-sm
dark:border-surface-800 dark:bg-surface-900
xl:sticky xl:top-3"
>
<!-- Avatar -->
<div class="flex items-center justify-between gap-3 border-b border-surface-200 pb-3 dark:border-surface-800 xl:flex-col xl:items-center xl:justify-start">
<div class="h-16 w-16 overflow-hidden rounded-full border border-surface-200 bg-surface-100 dark:border-surface-800 dark:bg-surface-800 xl:h-20 xl:w-20">
<img
v-if="avatarPreviewUrl || form.avatar_url"
:src="avatarPreviewUrl || form.avatar_url"
alt="Avatar do paciente"
class="h-full w-full object-cover"
/>
<div v-else class="grid h-full w-full place-items-center">
<i class="pi pi-user text-2xl opacity-60"></i>
</div>
</div>
<div class="flex-1 xl:w-full">
<input
type="file"
accept="image/*"
class="block w-full text-sm file:mr-3 file:rounded-xl file:border-0 file:bg-surface-100 file:px-3 file:py-2 file:text-surface-900
hover:file:bg-surface-200
dark:file:bg-surface-800 dark:file:text-surface-0 dark:hover:file:bg-surface-700"
@change="onAvatarPicked"
/>
<div class="mt-1 text-xs text-surface-600 dark:text-surface-300">
Avatar (opcional)
<span v-if="avatarUploading" class="ml-2">(enviando)</span>
</div>
</div>
</div>
<!-- NAV (>=1200px) -->
<div v-if="!isCompact" class="mt-3 flex flex-col gap-2">
<button
v-for="item in navItems"
:key="item.value"
type="button"
class="flex items-center gap-3 rounded-xl border px-3 py-2 text-left text-sm
border-transparent hover:bg-surface-100
dark:hover:bg-surface-800"
:class="activeValue === item.value
? 'bg-primary-50 border-primary-200 text-primary-800 dark:bg-primary-900/20 dark:border-primary-700/40 dark:text-primary-200'
: 'text-surface-800 dark:text-surface-0'"
@click="openPanel(Number(item.value))"
>
<i :class="item.icon" class="opacity-80"></i>
<span class="font-medium">{{ item.label }}</span>
</button>
</div>
</aside>
<!-- MAIN -->
<main class="rounded-2xl border border-surface-200 bg-white p-3 shadow-sm dark:border-surface-800 dark:bg-surface-900">
<!-- NAV COMPACT (<1200px): sticky + popover -->
<div v-if="isCompact" class="sticky top-14 z-50 mb-3 border-b border-surface-200 bg-white py-2 dark:border-surface-800 dark:bg-surface-900">
<Button
type="button"
class="w-full"
icon="pi pi-chevron-down"
iconPos="right"
:label="selectedNav ? selectedNav.label : 'Selecionar seção'"
@click="toggleNav($event)"
/>
<Popover
ref="navPopover"
:pt="{
root: { class: 'z-[9999999]' }
}"
>
<div class="flex min-w-[260px] flex-col gap-3">
<span class="block text-sm font-semibold text-surface-900 dark:text-surface-0">Seções</span>
<ul class="m-0 flex list-none flex-col gap-1 p-0">
<li
v-for="item in navItems"
:key="item.value"
class="flex cursor-pointer items-center gap-2 rounded-xl px-3 py-2 text-sm hover:bg-surface-100 dark:hover:bg-surface-800"
:class="activeValue === item.value ? 'bg-surface-100 dark:bg-surface-800' : ''"
@click="selectNav(item)"
>
<i :class="item.icon" class="opacity-80"></i>
<span class="font-medium">{{ item.label }}</span>
</li>
</ul>
</div>
</Popover>
</div>
<Accordion :multiple="false" v-model:value="activeValue">
<!-- 0 -->
<AccordionPanel value="0">
<AccordionHeader :ref="el => setPanelHeaderRef(el, 0)">
1. Informações pessoais
</AccordionHeader>
<AccordionContent>
<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_nome" v-model="form.nome_completo" class="w-full" variant="filled" />
</IconField>
<label for="f_nome">Nome completo *</label>
</FloatLabel>
</div>
<div>
<FloatLabel variant="on">
<IconField>
<InputIcon class="pi pi-phone" />
<InputMask
id="f_telefone"
v-model="form.telefone"
mask="(99) 99999-9999"
:unmask="false"
class="w-full"
variant="filled"
/>
</IconField>
<label for="f_telefone">Telefone / celular *</label>
</FloatLabel>
</div>
<div>
<FloatLabel variant="on">
<IconField>
<InputIcon class="pi pi-envelope" />
<InputText id="f_email" v-model="form.email_principal" class="w-full" variant="filled" />
</IconField>
<label for="f_email">E-mail principal *</label>
</FloatLabel>
</div>
<div>
<FloatLabel variant="on">
<IconField>
<InputIcon class="pi pi-envelope" />
<InputText id="f_email_alt" v-model="form.email_alternativo" class="w-full" variant="filled" />
</IconField>
<label for="f_email_alt">E-mail alternativo</label>
</FloatLabel>
</div>
<div>
<FloatLabel variant="on">
<IconField>
<InputIcon class="pi pi-phone" />
<InputMask
id="f_tel_alt"
v-model="form.telefone_alternativo"
mask="(99) 99999-9999"
:unmask="false"
class="w-full"
variant="filled"
/>
</IconField>
<label for="f_tel_alt">Telefone alternativo</label>
</FloatLabel>
</div>
<div>
<FloatLabel variant="on">
<IconField>
<InputIcon class="pi pi-calendar" />
<InputMask
id="f_nasc"
v-model="form.data_nascimento"
mask="99-99-9999"
:unmask="false"
class="w-full"
variant="filled"
/>
</IconField>
<label for="f_nasc">Data de nascimento</label>
</FloatLabel>
</div>
<div>
<FloatLabel variant="on">
<IconField>
<InputIcon class="pi pi-user" />
<Select
id="f_genero"
v-model="form.genero"
:options="genderOptions"
optionLabel="label"
optionValue="value"
class="w-full pl-[25px]"
variant="filled"
/>
</IconField>
<label for="f_genero">Gênero</label>
</FloatLabel>
</div>
<div>
<FloatLabel variant="on">
<IconField>
<InputIcon class="pi pi-heart" />
<Select
id="f_estado_civil"
v-model="form.estado_civil"
:options="maritalStatusOptions"
optionLabel="label"
optionValue="value"
class="w-full pl-[25px]"
variant="filled"
/>
</IconField>
<label for="f_estado_civil">Estado civil</label>
</FloatLabel>
</div>
<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>
<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>
<div class="xl:col-span-2">
<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>
<div class="xl:col-span-2">
<FloatLabel variant="on">
<Textarea id="f_obs" v-model="form.observacoes" rows="3" class="w-full" variant="filled" />
<label for="f_obs">Observações</label>
</FloatLabel>
</div>
<!-- Grupos -->
<div>
<div class="flex gap-2">
<div class="w-full">
<FloatLabel variant="on">
<IconField>
<InputIcon class="pi pi-folder-open" />
<Select
id="f_group"
v-model="grupoIdSelecionado"
:options="groups"
optionLabel="nome"
optionValue="id"
class="w-full pl-[25px]"
showClear
filter
variant="filled"
/>
</IconField>
<label for="f_group">Grupo</label>
</FloatLabel>
<small class="mt-1 block text-xs text-surface-600 dark:text-surface-300">
Usado para puxar um modelo de anamnese.
</small>
</div>
<Button icon="pi pi-plus" severity="secondary" outlined @click="openGroupDlg('create')" />
</div>
</div>
<!-- Tags -->
<div>
<div class="flex gap-2">
<div class="w-full">
<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</label>
</FloatLabel>
</div>
<Button icon="pi pi-plus" severity="secondary" outlined @click="openTagDlg('create')" />
</div>
</div>
<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>
<FloatLabel variant="on">
<IconField>
<InputIcon class="pi pi-share-alt" />
<InputText id="f_ref" v-model="form.encaminhado_por" class="w-full" variant="filled" />
</IconField>
<label for="f_ref">Encaminhado por</label>
</FloatLabel>
</div>
</div>
</AccordionContent>
</AccordionPanel>
<!-- 1 -->
<AccordionPanel value="1">
<AccordionHeader :ref="el => setPanelHeaderRef(el, 1)">2. Endereço</AccordionHeader>
<AccordionContent>
<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" />
</IconField>
<label for="f_cep">CEP</label>
</FloatLabel>
</div>
<div>
<FloatLabel variant="on">
<IconField>
<InputIcon class="pi pi-globe" />
<InputText id="f_country" v-model="form.pais" class="w-full" variant="filled" />
</IconField>
<label for="f_country">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_state" v-model="form.estado" class="w-full" variant="filled" />
</IconField>
<label for="f_state">Estado</label>
</FloatLabel>
</div>
<div>
<FloatLabel variant="on">
<IconField>
<InputIcon class="pi pi-map" />
<InputText id="f_address" v-model="form.endereco" class="w-full" variant="filled" />
</IconField>
<label for="f_address">Endereço</label>
</FloatLabel>
</div>
<div>
<FloatLabel variant="on">
<IconField>
<InputIcon class="pi pi-hashtag" />
<InputText id="f_number" v-model="form.numero" class="w-full" variant="filled" />
</IconField>
<label for="f_number">Número</label>
</FloatLabel>
</div>
<div>
<FloatLabel variant="on">
<IconField>
<InputIcon class="pi pi-map-marker" />
<InputText id="f_neighborhood" v-model="form.bairro" class="w-full" variant="filled" />
</IconField>
<label for="f_neighborhood">Bairro</label>
</FloatLabel>
</div>
<div>
<FloatLabel variant="on">
<IconField>
<InputIcon class="pi pi-align-left" />
<InputText id="f_complement" v-model="form.complemento" class="w-full" variant="filled" />
</IconField>
<label for="f_complement">Complemento</label>
</FloatLabel>
</div>
</div>
</AccordionContent>
</AccordionPanel>
<!-- 2 -->
<AccordionPanel value="2">
<AccordionHeader :ref="el => setPanelHeaderRef(el, 2)">3. Dados adicionais</AccordionHeader>
<AccordionContent>
<div class="grid grid-cols-1 gap-4 xl:grid-cols-2">
<div>
<FloatLabel variant="on">
<IconField>
<InputIcon class="pi pi-book" />
<InputText id="f_escolaridade" v-model="form.escolaridade" class="w-full" variant="filled" />
</IconField>
<label for="f_escolaridade">Escolaridade</label>
</FloatLabel>
</div>
<div>
<FloatLabel variant="on">
<IconField>
<InputIcon class="pi pi-briefcase" />
<InputText id="f_profissao" v-model="form.profissao" class="w-full" variant="filled" />
</IconField>
<label for="f_profissao">Profissão</label>
</FloatLabel>
</div>
<div>
<FloatLabel variant="on">
<IconField>
<InputIcon class="pi pi-user" />
<InputText id="f_parente_nome" v-model="form.nome_parente" class="w-full" variant="filled" />
</IconField>
<label for="f_parente_nome">Nome de um parente</label>
</FloatLabel>
</div>
<div>
<FloatLabel variant="on">
<IconField>
<InputIcon class="pi pi-users" />
<InputText id="f_parentesco" v-model="form.grau_parentesco" class="w-full" variant="filled" />
</IconField>
<label for="f_parentesco">Grau de parentesco</label>
</FloatLabel>
</div>
<div>
<FloatLabel variant="on">
<IconField>
<InputIcon class="pi pi-phone" />
<InputMask
id="f_parente_tel"
v-model="form.telefone_parente"
mask="(99) 99999-9999"
:unmask="false"
class="w-full"
variant="filled"
/>
</IconField>
<label for="f_parente_tel">Telefone do parente</label>
</FloatLabel>
</div>
</div>
<div class="mt-4">
<Button
icon="pi pi-plus"
label="Adicionar mais parentes (em breve)"
severity="secondary"
outlined
disabled
/>
<small class="mt-2 block text-xs text-surface-600 dark:text-surface-300">
Se você quiser, isso vira uma lista (1:N) depois.
</small>
</div>
</AccordionContent>
</AccordionPanel>
<!-- 3 -->
<AccordionPanel value="3">
<AccordionHeader :ref="el => setPanelHeaderRef(el, 3)">4. Responsável</AccordionHeader>
<AccordionContent>
<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_resp_nome" v-model="form.nome_responsavel" class="w-full" variant="filled" />
</IconField>
<label for="f_resp_nome">Nome do responsável</label>
</FloatLabel>
</div>
<div>
<FloatLabel variant="on">
<IconField>
<InputIcon class="pi pi-id-card" />
<InputMask
id="f_resp_cpf"
v-model="form.cpf_responsavel"
mask="999.999.999-99"
:unmask="false"
class="w-full"
variant="filled"
/>
</IconField>
<label for="f_resp_cpf">CPF do responsável</label>
</FloatLabel>
</div>
<div>
<FloatLabel variant="on">
<IconField>
<InputIcon class="pi pi-phone" />
<InputMask
id="f_resp_tel"
v-model="form.telefone_responsavel"
mask="(99) 99999-9999"
:unmask="false"
class="w-full"
variant="filled"
/>
</IconField>
<label for="f_resp_tel">Telefone do responsável</label>
</FloatLabel>
</div>
<div class="xl:col-span-2">
<FloatLabel variant="on">
<Textarea id="f_resp_obs" v-model="form.observacao_responsavel" rows="3" class="w-full" variant="filled" />
<label for="f_resp_obs">Observações sobre o responsável</label>
</FloatLabel>
</div>
<div class="xl:col-span-2">
<div class="flex items-center gap-2">
<Checkbox inputId="f_bill" v-model="form.cobranca_no_responsavel" :binary="true" />
<label for="f_bill" class="text-sm text-surface-800 dark:text-surface-0">Cobrança no responsável</label>
</div>
</div>
</div>
</AccordionContent>
</AccordionPanel>
<!-- 4 -->
<AccordionPanel value="4">
<AccordionHeader :ref="el => setPanelHeaderRef(el, 4)">5. Anotações internas</AccordionHeader>
<AccordionContent>
<div class="mb-3 text-xs text-surface-600 dark:text-surface-300">
Campo interno: não aparece no cadastro externo.
</div>
<FloatLabel variant="on">
<Textarea id="f_notas" v-model="form.notas_internas" rows="7" class="w-full" variant="filled" />
<label for="f_notas">Notas internas</label>
</FloatLabel>
</AccordionContent>
</AccordionPanel>
</Accordion>
<div class="mt-4 flex justify-center">
<Button
label="Salvar"
icon="pi pi-check"
:loading="saving"
@click="onSubmit"
class="min-w-[220px]"
/>
</div>
</main>
</div>
<!-- Dialog Criar Grupo -->
<Dialog
v-model:visible="createGroupDialog"
modal
:draggable="false"
header="Criar grupo"
:style="{ width: '26rem' }"
:closable="!createGroupSaving"
pt:mask:class="backdrop-blur-sm"
>
<span class="mb-6 block text-sm text-surface-600 dark:text-surface-300">
Crie um grupo para organizar seus pacientes.
</span>
<div class="mb-4 flex items-center gap-4">
<label for="group-name" class="w-24 text-sm font-semibold">Nome</label>
<InputText id="group-name" v-model="newGroup.name" class="flex-auto" autocomplete="off" placeholder="Ex: Crianças" />
</div>
<div class="mb-6 flex items-center gap-4">
<label class="w-24 text-sm font-semibold">Cor</label>
<div class="flex flex-auto items-center gap-3">
<input v-model="newGroup.color" type="color" class="h-9 w-12 cursor-pointer rounded-lg border border-surface-200 bg-transparent dark:border-surface-800" />
<Chip :label="newGroup.color || '#—'" class="font-semibold" :style="{ backgroundColor: newGroup.color, color: '#fff' }" />
</div>
</div>
<div v-if="createGroupError" class="mb-4 text-sm text-red-500">{{ createGroupError }}</div>
<div class="flex justify-end gap-2">
<Button label="Cancelar" severity="secondary" outlined :disabled="createGroupSaving" @click="createGroupDialog = false" />
<Button label="Criar" icon="pi pi-check" :loading="createGroupSaving" @click="createGroupPersist" />
</div>
</Dialog>
<!-- Dialog Criar Tag -->
<Dialog
v-model:visible="createTagDialog"
modal
:draggable="false"
header="Criar tag"
:style="{ width: '26rem' }"
:closable="!createTagSaving"
pt:mask:class="backdrop-blur-sm"
>
<span class="mb-6 block text-sm text-surface-600 dark:text-surface-300">
Crie uma tag para facilitar filtros e organização.
</span>
<div class="mb-4 flex items-center gap-4">
<label for="tag-name" class="w-24 text-sm font-semibold">Nome</label>
<InputText id="tag-name" v-model="newTag.name" class="flex-auto" autocomplete="off" placeholder="Ex: Ansiedade" />
</div>
<div class="mb-6 flex items-center gap-4">
<label class="w-24 text-sm font-semibold">Cor</label>
<div class="flex flex-auto items-center gap-3">
<input v-model="newTag.color" type="color" class="h-9 w-12 cursor-pointer rounded-lg border border-surface-200 bg-transparent dark:border-surface-800" />
<Chip :label="newTag.color || '#—'" class="font-semibold" :style="{ backgroundColor: newTag.color, color: '#fff' }" />
</div>
</div>
<div v-if="createTagError" class="mb-4 text-sm text-red-500">{{ createTagError }}</div>
<div class="flex justify-end gap-2">
<Button label="Cancelar" severity="secondary" outlined :disabled="createTagSaving" @click="createTagDialog = false" />
<Button label="Criar" icon="pi pi-check" :loading="createTagSaving" @click="createTagPersist" />
</div>
</Dialog>
</template>
</Card>
</div>
</div>
</template>