1959 lines
71 KiB
Vue
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>
|