ZERADO
This commit is contained in:
@@ -1,14 +1,16 @@
|
||||
<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()
|
||||
@@ -16,8 +18,17 @@ 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 () {
|
||||
// ajuste para o nome real no seu store
|
||||
return tenantStore.tenantId || tenantStore.currentTenantId || tenantStore.tenant?.id
|
||||
}
|
||||
|
||||
@@ -105,6 +116,48 @@ onBeforeUnmount(() => {
|
||||
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)
|
||||
// ------------------------------------------------------
|
||||
@@ -112,7 +165,7 @@ const avatarFile = ref(null)
|
||||
const avatarPreviewUrl = ref('')
|
||||
const avatarUploading = ref(false)
|
||||
|
||||
const AVATAR_BUCKET = 'avatars' // confirme o nome do bucket no Supabase
|
||||
const AVATAR_BUCKET = 'avatars'
|
||||
|
||||
function isImageFile (file) {
|
||||
return !!file && typeof file.type === 'string' && file.type.startsWith('image/')
|
||||
@@ -149,6 +202,24 @@ function onAvatarPicked (ev) {
|
||||
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.')
|
||||
@@ -171,15 +242,12 @@ async function uploadAvatarToStorage ({ ownerId, patientId, file }) {
|
||||
|
||||
if (upErr) throw upErr
|
||||
|
||||
const { data: pub } = supabase.storage.from(AVATAR_BUCKET).getPublicUrl(path)
|
||||
const publicUrl = pub?.publicUrl || null
|
||||
if (!publicUrl) throw new Error('Não consegui gerar URL pública do avatar.')
|
||||
|
||||
return { publicUrl, path }
|
||||
const readableUrl = await getReadableAvatarUrl(path)
|
||||
return { publicUrl: readableUrl, path }
|
||||
}
|
||||
|
||||
async function maybeUploadAvatar (ownerId, id) {
|
||||
if (!avatarFile.value) return
|
||||
if (!avatarFile.value) return null
|
||||
|
||||
avatarUploading.value = true
|
||||
try {
|
||||
@@ -189,45 +257,40 @@ async function maybeUploadAvatar (ownerId, id) {
|
||||
file: avatarFile.value
|
||||
})
|
||||
|
||||
// 1) atualiza UI IMEDIATAMENTE (não deixa “sumir”)
|
||||
// UI
|
||||
form.value.avatar_url = publicUrl
|
||||
|
||||
// 2) grava no banco
|
||||
await updatePatient(id, { avatar_url: publicUrl })
|
||||
|
||||
// 3) limpa o arquivo selecionado
|
||||
avatarFile.value = null
|
||||
|
||||
// 4) se o preview era blob, pode revogar
|
||||
// MAS NÃO zere o avatarPreviewUrl se o template depende dele
|
||||
// => aqui vamos só revogar e então setar para a própria URL pública.
|
||||
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: 4000
|
||||
life: 4500
|
||||
})
|
||||
return null
|
||||
} finally {
|
||||
avatarUploading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ------------------------------------------------------
|
||||
// Form state (PT-BR)
|
||||
// Form state
|
||||
// ------------------------------------------------------
|
||||
function resetForm () {
|
||||
return {
|
||||
// Sessão 1 — pessoais
|
||||
nome_completo: '',
|
||||
telefone: '',
|
||||
email_principal: '',
|
||||
email_alternativo: '',
|
||||
telefone_alternativo: '',
|
||||
data_nascimento: '', // ✅ SEMPRE DD-MM-AAAA (hífen)
|
||||
data_nascimento: '',
|
||||
genero: '',
|
||||
estado_civil: '',
|
||||
cpf: '',
|
||||
@@ -237,7 +300,6 @@ function resetForm () {
|
||||
onde_nos_conheceu: '',
|
||||
encaminhado_por: '',
|
||||
|
||||
// Sessão 2 — endereço
|
||||
cep: '',
|
||||
pais: 'Brasil',
|
||||
cidade: '',
|
||||
@@ -247,24 +309,19 @@ function resetForm () {
|
||||
bairro: '',
|
||||
complemento: '',
|
||||
|
||||
// Sessão 3 — adicionais
|
||||
escolaridade: '',
|
||||
profissao: '',
|
||||
nome_parente: '',
|
||||
grau_parentesco: '',
|
||||
telefone_parente: '',
|
||||
|
||||
// Sessão 4 — responsável
|
||||
nome_responsavel: '',
|
||||
cpf_responsavel: '',
|
||||
telefone_responsavel: '',
|
||||
observacao_responsavel: '',
|
||||
cobranca_no_responsavel: false,
|
||||
|
||||
// Sessão 5 — internos
|
||||
notas_internas: '',
|
||||
|
||||
// Avatar
|
||||
avatar_url: ''
|
||||
}
|
||||
}
|
||||
@@ -331,7 +388,6 @@ function toISODateFromDDMMYYYY (s) {
|
||||
return `${yyyy}-${mm}-${dd}`
|
||||
}
|
||||
|
||||
// banco (YYYY-MM-DD ou ISO) -> form (DD-MM-YYYY)
|
||||
function isoToDDMMYYYY (value) {
|
||||
if (!value) return ''
|
||||
const s = String(value).trim()
|
||||
@@ -407,7 +463,6 @@ function mapDbToForm (p) {
|
||||
cobranca_no_responsavel: !!p.cobranca_no_responsavel,
|
||||
|
||||
notas_internas: p.notas_internas ?? '',
|
||||
|
||||
avatar_url: p.avatar_url ?? ''
|
||||
}
|
||||
}
|
||||
@@ -430,8 +485,6 @@ const PACIENTES_COLUNAS_PERMITIDAS = new Set([
|
||||
'owner_id',
|
||||
'tenant_id',
|
||||
'responsible_member_id',
|
||||
|
||||
// Sessão 1
|
||||
'nome_completo',
|
||||
'telefone',
|
||||
'email_principal',
|
||||
@@ -446,8 +499,6 @@ const PACIENTES_COLUNAS_PERMITIDAS = new Set([
|
||||
'observacoes',
|
||||
'onde_nos_conheceu',
|
||||
'encaminhado_por',
|
||||
|
||||
// Sessão 2
|
||||
'pais',
|
||||
'cep',
|
||||
'cidade',
|
||||
@@ -456,25 +507,17 @@ const PACIENTES_COLUNAS_PERMITIDAS = new Set([
|
||||
'numero',
|
||||
'bairro',
|
||||
'complemento',
|
||||
|
||||
// Sessão 3
|
||||
'escolaridade',
|
||||
'profissao',
|
||||
'nome_parente',
|
||||
'grau_parentesco',
|
||||
'telefone_parente',
|
||||
|
||||
// Sessão 4
|
||||
'nome_responsavel',
|
||||
'cpf_responsavel',
|
||||
'telefone_responsavel',
|
||||
'observacao_responsavel',
|
||||
'cobranca_no_responsavel',
|
||||
|
||||
// Sessão 5
|
||||
'notas_internas',
|
||||
|
||||
// Avatar
|
||||
'avatar_url'
|
||||
])
|
||||
|
||||
@@ -523,11 +566,10 @@ function sanitizePayload (raw, ownerId) {
|
||||
cobranca_no_responsavel: !!raw.cobranca_no_responsavel,
|
||||
|
||||
notas_internas: raw.notas_internas || null,
|
||||
|
||||
avatar_url: raw.avatar_url || null
|
||||
}
|
||||
|
||||
// strings vazias -> null
|
||||
// strings vazias -> null e trim
|
||||
Object.keys(payload).forEach(k => {
|
||||
if (payload[k] === '') payload[k] = null
|
||||
if (typeof payload[k] === 'string') {
|
||||
@@ -536,23 +578,19 @@ function sanitizePayload (raw, ownerId) {
|
||||
}
|
||||
})
|
||||
|
||||
// docs: só dígitos
|
||||
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
|
||||
|
||||
// fones: só dígitos
|
||||
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
|
||||
|
||||
// ✅ FIX CRÍTICO: DD-MM-YYYY -> YYYY-MM-DD
|
||||
payload.data_nascimento = payload.data_nascimento
|
||||
? (toISODateFromDDMMYYYY(payload.data_nascimento) || null)
|
||||
: null
|
||||
|
||||
// filtra
|
||||
const filtrado = {}
|
||||
Object.keys(payload).forEach(k => {
|
||||
if (PACIENTES_COLUNAS_PERMITIDAS.has(k)) filtrado[k] = payload[k]
|
||||
@@ -565,11 +603,7 @@ function sanitizePayload (raw, ownerId) {
|
||||
// Supabase: lists / get / relations
|
||||
// ------------------------------------------------------
|
||||
async function listGroups () {
|
||||
const probe = await supabase
|
||||
.from('patient_groups')
|
||||
.select('*')
|
||||
.limit(1)
|
||||
|
||||
const probe = await supabase.from('patient_groups').select('*').limit(1)
|
||||
if (probe.error) throw probe.error
|
||||
|
||||
const row = probe.data?.[0] || {}
|
||||
@@ -582,13 +616,8 @@ async function listGroups () {
|
||||
.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
|
||||
}))
|
||||
return (data || []).map(g => ({ ...g, name: g.nome, color: g.cor }))
|
||||
}
|
||||
|
||||
if (hasEN) {
|
||||
@@ -597,93 +626,42 @@ async function listGroups () {
|
||||
.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
|
||||
}))
|
||||
return (data || []).map(g => ({ ...g, nome: g.name, cor: g.color }))
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('patient_groups')
|
||||
.select('*')
|
||||
.order('id', { ascending: true })
|
||||
|
||||
const { data, error } = await supabase.from('patient_groups').select('*').order('id', { ascending: true })
|
||||
if (error) throw error
|
||||
return data || []
|
||||
}
|
||||
|
||||
|
||||
async function listTags () {
|
||||
// 1) Pega 1 registro sem order, só pra descobrir o schema real (sem 400)
|
||||
const probe = await supabase
|
||||
.from('patient_tags')
|
||||
.select('*')
|
||||
.limit(1)
|
||||
|
||||
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)
|
||||
|
||||
// 2) Se não tem nada, a tabela pode estar vazia.
|
||||
// Ainda assim, precisamos decidir por qual coluna ordenar.
|
||||
// Vamos descobrir colunas existentes via select de 0 rows (head) NÃO é suportado bem no client,
|
||||
// então usamos uma estratégia safe:
|
||||
// - tenta EN com order se faz sentido
|
||||
// - senão PT
|
||||
// - e por último sem order.
|
||||
|
||||
if (hasEN) {
|
||||
const { data, error } = await supabase
|
||||
.from('patient_tags')
|
||||
.select('id,name,color')
|
||||
.order('name', { ascending: true })
|
||||
|
||||
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 })
|
||||
|
||||
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
|
||||
}))
|
||||
return (data || []).map(t => ({ ...t, name: t.nome, color: t.cor }))
|
||||
}
|
||||
|
||||
// 3) fallback final: tabela vazia ou schema incomum
|
||||
const { data, error } = await supabase
|
||||
.from('patient_tags')
|
||||
.select('*')
|
||||
.order('id', { ascending: true })
|
||||
|
||||
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
|
||||
}))
|
||||
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()
|
||||
const { data, error } = await supabase.from('patients').select('*').eq('id', id).single()
|
||||
if (error) throw error
|
||||
return data
|
||||
}
|
||||
@@ -708,11 +686,7 @@ async function getPatientRelations (id) {
|
||||
}
|
||||
|
||||
async function createPatient (payload) {
|
||||
const { data, error } = await supabase
|
||||
.from('patients')
|
||||
.insert(payload)
|
||||
.select('id')
|
||||
.single()
|
||||
const { data, error } = await supabase.from('patients').insert(payload).select('id').single()
|
||||
if (error) throw error
|
||||
return data
|
||||
}
|
||||
@@ -720,17 +694,14 @@ async function createPatient (payload) {
|
||||
async function updatePatient (id, payload) {
|
||||
const { error } = await supabase
|
||||
.from('patients')
|
||||
.update({
|
||||
...payload,
|
||||
updated_at: new Date().toISOString()
|
||||
})
|
||||
.update({ ...payload, updated_at: new Date().toISOString() })
|
||||
.eq('id', id)
|
||||
|
||||
if (error) throw error
|
||||
}
|
||||
|
||||
// ------------------------------------------------------
|
||||
// Relations
|
||||
// Relations state
|
||||
// ------------------------------------------------------
|
||||
const groups = ref([])
|
||||
const tags = ref([])
|
||||
@@ -738,17 +709,11 @@ 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)
|
||||
const { error: delErr } = await supabase.from('patient_group_patient').delete().eq('patient_id', patient_id)
|
||||
if (delErr) throw delErr
|
||||
|
||||
if (!groupId) return
|
||||
|
||||
const { error: insErr } = await supabase
|
||||
.from('patient_group_patient')
|
||||
.insert({ patient_id, patient_group_id: groupId })
|
||||
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
|
||||
}
|
||||
|
||||
@@ -765,15 +730,9 @@ async function replacePatientTags (patient_id, tagIds) {
|
||||
const clean = Array.from(new Set([...(tagIds || [])].filter(Boolean)))
|
||||
if (!clean.length) return
|
||||
|
||||
const rows = clean.map(tag_id => ({
|
||||
owner_id: ownerId,
|
||||
patient_id,
|
||||
tag_id
|
||||
}))
|
||||
|
||||
const { error: insErr } = await supabase
|
||||
.from('patient_patient_tag')
|
||||
.insert(rows)
|
||||
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
|
||||
}
|
||||
|
||||
@@ -808,19 +767,6 @@ const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const deleting = ref(false)
|
||||
|
||||
// ------------------------------------------------------
|
||||
// Route base (admin x therapist)
|
||||
// ------------------------------------------------------
|
||||
function getAreaBase () {
|
||||
const seg = String(route.path || '').split('/')[1]
|
||||
return seg === 'therapist' ? '/therapist' : '/admin'
|
||||
}
|
||||
|
||||
function goBack () {
|
||||
if (window.history.length > 1) router.back()
|
||||
else router.push(`${getAreaBase()}/patients`)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------
|
||||
// Fetch (load everything)
|
||||
// ------------------------------------------------------
|
||||
@@ -829,34 +775,32 @@ async function fetchAll () {
|
||||
try {
|
||||
const [gRes, tRes] = await Promise.allSettled([listGroups(), listTags()])
|
||||
|
||||
if (gRes.status === 'fulfilled') {
|
||||
groups.value = gRes.value || []
|
||||
} else {
|
||||
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 {
|
||||
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 })
|
||||
}
|
||||
|
||||
console.log('[groups]', groups.value.length, groups.value[0])
|
||||
console.log('[tags]', tags.value.length, tags.value[0])
|
||||
|
||||
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()
|
||||
//form.value = resetForm()
|
||||
grupoIdSelecionado.value = null
|
||||
tagIdsSelecionadas.value = []
|
||||
avatarFile.value = null
|
||||
@@ -872,19 +816,33 @@ async function fetchAll () {
|
||||
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 }) // se existir
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(1)
|
||||
.single()
|
||||
|
||||
@@ -904,20 +862,69 @@ async function onSubmit () {
|
||||
const ownerId = await getOwnerId()
|
||||
const { tenantId, memberId } = await resolveTenantContextOrFail()
|
||||
|
||||
// depois do sanitize
|
||||
const payload = sanitizePayload(form.value, ownerId)
|
||||
|
||||
// multi-tenant obrigatório
|
||||
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 })
|
||||
} else {
|
||||
const created = await createPatient(payload)
|
||||
toast.add({ severity: 'success', summary: 'Salvo', detail: 'Paciente cadastrado.', life: 2500 })
|
||||
// opcional: router.push(`${getAreaBase()}/patients/${created.id}`)
|
||||
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 })
|
||||
@@ -925,8 +932,6 @@ async function onSubmit () {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ------------------------------------------------------
|
||||
// Delete
|
||||
// ------------------------------------------------------
|
||||
@@ -968,7 +973,7 @@ async function doDelete () {
|
||||
}
|
||||
|
||||
// ------------------------------------------------------
|
||||
// Fake fill (opcional)
|
||||
// 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)] }
|
||||
@@ -1036,7 +1041,6 @@ function fillRandomPatient () {
|
||||
|
||||
form.value = {
|
||||
...resetForm(),
|
||||
|
||||
nome_completo: nomeCompleto,
|
||||
telefone: randomPhoneBR(),
|
||||
email_principal: randomEmailFromName(nomeCompleto),
|
||||
@@ -1076,16 +1080,10 @@ function fillRandomPatient () {
|
||||
cobranca_no_responsavel: true,
|
||||
|
||||
notas_internas: 'Paciente apresenta discurso organizado. Acompanhar evolução clínica.',
|
||||
|
||||
avatar_url: ''
|
||||
}
|
||||
|
||||
// Grupo
|
||||
if (Array.isArray(groups.value) && groups.value.length) {
|
||||
grupoIdSelecionado.value = pick(groups.value).id
|
||||
}
|
||||
|
||||
// Tags
|
||||
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)
|
||||
@@ -1118,137 +1116,88 @@ const maritalStatusOptions = [
|
||||
const createGroupDialog = ref(false)
|
||||
const createGroupSaving = ref(false)
|
||||
const createGroupError = ref('')
|
||||
const newGroup = ref({ name: '', color: '#6366F1' }) // indigo default
|
||||
const newGroup = ref({ name: '', color: '#6366F1' })
|
||||
|
||||
const createTagDialog = ref(false)
|
||||
const createTagSaving = ref(false)
|
||||
const createTagError = ref('')
|
||||
const newTag = ref({ name: '', color: '#22C55E' }) // green default
|
||||
const newTag = ref({ name: '', color: '#22C55E' })
|
||||
|
||||
function openGroupDlg(mode = 'create') {
|
||||
// por enquanto só create
|
||||
function openGroupDlg () {
|
||||
createGroupError.value = ''
|
||||
newGroup.value = { name: '', color: '#6366F1' }
|
||||
createGroupDialog.value = true
|
||||
}
|
||||
|
||||
function openTagDlg(mode = 'create') {
|
||||
// por enquanto só create
|
||||
function openTagDlg () {
|
||||
createTagError.value = ''
|
||||
newTag.value = { name: '', color: '#22C55E' }
|
||||
createTagDialog.value = true
|
||||
}
|
||||
|
||||
// ------------------------------------------------------
|
||||
// Persist: Grupo
|
||||
// ------------------------------------------------------
|
||||
async function createGroupPersist() {
|
||||
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
|
||||
}
|
||||
if (!name) { createGroupError.value = 'Informe um nome para o grupo.'; return }
|
||||
|
||||
createGroupSaving.value = true
|
||||
try {
|
||||
const ownerId = await getOwnerId()
|
||||
|
||||
// Tenta schema PT-BR primeiro (pelo teu listGroups)
|
||||
const { tenantId } = await resolveTenantContextOrFail()
|
||||
let createdId = null
|
||||
{
|
||||
const { data, error } = await supabase
|
||||
.from('patient_groups')
|
||||
.insert({
|
||||
owner_id: ownerId,
|
||||
nome: name,
|
||||
descricao: null,
|
||||
cor: color,
|
||||
is_system: false,
|
||||
is_active: true
|
||||
})
|
||||
.select('id')
|
||||
.single()
|
||||
|
||||
if (!error) createdId = data?.id || null
|
||||
else {
|
||||
// fallback (caso seu schema seja EN)
|
||||
const { data: d2, error: e2 } = await supabase
|
||||
.from('patient_groups')
|
||||
.insert({
|
||||
owner_id: ownerId,
|
||||
name,
|
||||
description: null,
|
||||
color,
|
||||
is_system: false,
|
||||
is_active: true
|
||||
})
|
||||
.select('id')
|
||||
.single()
|
||||
if (e2) throw e2
|
||||
createdId = d2?.id || 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
|
||||
|
||||
// Recarrega lista e seleciona o novo
|
||||
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) {
|
||||
createGroupError.value = e?.message || 'Falha ao criar grupo.'
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------
|
||||
// Persist: Tag
|
||||
// ------------------------------------------------------
|
||||
async function createTagPersist() {
|
||||
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
|
||||
}
|
||||
if (!name) { createTagError.value = 'Informe um nome para a tag.'; return }
|
||||
|
||||
createTagSaving.value = true
|
||||
try {
|
||||
const ownerId = await getOwnerId()
|
||||
|
||||
// Tenta schema EN primeiro (pelo teu listTags)
|
||||
const { tenantId } = await resolveTenantContextOrFail()
|
||||
let createdId = null
|
||||
{
|
||||
const { data, error } = await supabase
|
||||
.from('patient_tags')
|
||||
.insert({ owner_id: ownerId, name, color })
|
||||
.select('id')
|
||||
.single()
|
||||
|
||||
if (!error) createdId = data?.id || null
|
||||
else {
|
||||
// fallback PT-BR
|
||||
const { data: d2, error: e2 } = await supabase
|
||||
.from('patient_tags')
|
||||
.insert({ owner_id: ownerId, nome: name, cor: color })
|
||||
.select('id')
|
||||
.single()
|
||||
if (e2) throw e2
|
||||
createdId = d2?.id || 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
|
||||
|
||||
// Recarrega lista e já marca a nova na seleção
|
||||
tags.value = await listTags()
|
||||
if (createdId) {
|
||||
const set = new Set([...(tagIdsSelecionadas.value || []), createdId])
|
||||
@@ -1258,7 +1207,12 @@ async function createTagPersist() {
|
||||
toast.add({ severity: 'success', summary: 'Tag', detail: 'Tag criada.', life: 2500 })
|
||||
createTagDialog.value = false
|
||||
} catch (e) {
|
||||
createTagError.value = e?.message || 'Falha ao criar tag.'
|
||||
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
|
||||
}
|
||||
@@ -1285,11 +1239,12 @@ async function createTagPersist() {
|
||||
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Button
|
||||
v-if="canSee('testMODE')"
|
||||
label="Preencher tudo"
|
||||
icon="pi pi-bolt"
|
||||
severity="secondary"
|
||||
outlined
|
||||
@click="fillRandomPatient"
|
||||
@click="fillRandomPatient"
|
||||
/>
|
||||
<Button
|
||||
label="Voltar"
|
||||
@@ -1931,6 +1886,7 @@ async function createTagPersist() {
|
||||
<Dialog
|
||||
v-model:visible="createGroupDialog"
|
||||
modal
|
||||
:draggable="false"
|
||||
header="Criar grupo"
|
||||
:style="{ width: '26rem' }"
|
||||
:closable="!createGroupSaving"
|
||||
@@ -1965,6 +1921,7 @@ async function createTagPersist() {
|
||||
<Dialog
|
||||
v-model:visible="createTagDialog"
|
||||
modal
|
||||
:draggable="false"
|
||||
header="Criar tag"
|
||||
:style="{ width: '26rem' }"
|
||||
:closable="!createTagSaving"
|
||||
|
||||
Reference in New Issue
Block a user