Documentos Pacientes, Template Documentos Pacientes Saas, Documentos prontuários, Documentos Externos, Visualização Externa, Permissão de Visualização, Render Otimização
This commit is contained in:
@@ -771,7 +771,7 @@ function isRecent(row) {
|
||||
<Button icon="pi pi-refresh" severity="secondary" outlined class="h-9 w-9 rounded-full" :loading="loading" @click="fetchAll" />
|
||||
<Button icon="pi pi-percentage" severity="secondary" outlined class="h-9 w-9 rounded-full" v-tooltip.top="'Descontos'" @click="router.push('/configuracoes/descontos')" />
|
||||
<Button label="Novo" icon="pi pi-user-plus" class="rounded-full" @click="(e) => createPopoverRef?.toggle(e)" />
|
||||
<PatientCreatePopover ref="createPopoverRef" @quick-create="openQuickCreate" />
|
||||
<PatientCreatePopover ref="createPopoverRef" @quick-create="openQuickCreate" @go-complete="goCreateFull" />
|
||||
<PatientCadastroDialog v-model="cadastroFullDialog" :patient-id="editPatientId" @created="onPatientCreated" />
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,965 @@
|
||||
<!--
|
||||
|--------------------------------------------------------------------------
|
||||
| Agência PSI
|
||||
|--------------------------------------------------------------------------
|
||||
| Criado e desenvolvido por Leonardo Nohama
|
||||
|
|
||||
| Tecnologia aplicada à escuta.
|
||||
| Estrutura para o cuidado.
|
||||
|
|
||||
| Arquivo: src/features/patients/cadastro/PatientsCadastroPage.vue
|
||||
| Data: 2026
|
||||
| Local: São Carlos/SP — Brasil
|
||||
|--------------------------------------------------------------------------
|
||||
| © 2026 — Todos os direitos reservados
|
||||
|--------------------------------------------------------------------------
|
||||
-->
|
||||
<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'
|
||||
import { digitsOnly, fmtCPF, fmtRG, fmtPhone, sanitizeDigits, toISODate, generateCPF } from '@/utils/validators'
|
||||
|
||||
const props = defineProps({
|
||||
dialogMode: { type: Boolean, default: false },
|
||||
patientId: { type: String, default: null }
|
||||
})
|
||||
const emit = defineEmits(['cancel', 'created'])
|
||||
|
||||
const { canSee } = useRoleGuard()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const toast = useToast()
|
||||
const confirm = useConfirm()
|
||||
const tenantStore = useTenantStore()
|
||||
|
||||
// ── 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 ─────────────────────────────────────────────
|
||||
const activeValue = ref('0')
|
||||
const panelHeaderRefs = ref([])
|
||||
|
||||
function setPanelHeaderRef (el, idx) { if (!el) return; panelHeaderRefs.value[idx] = el }
|
||||
|
||||
async function openPanel (i) {
|
||||
activeValue.value = String(i)
|
||||
await nextTick()
|
||||
const headerRef = panelHeaderRefs.value?.[i]
|
||||
const el = headerRef?.$el ?? headerRef
|
||||
if (!el) return
|
||||
const scrollContainer = el.closest('.l2-main') || document.querySelector('.l2-main')
|
||||
if (scrollContainer) {
|
||||
const containerRect = scrollContainer.getBoundingClientRect()
|
||||
const elRect = el.getBoundingClientRect()
|
||||
const offset = elRect.top - containerRect.top + scrollContainer.scrollTop - 16
|
||||
scrollContainer.scrollTo({ top: Math.max(0, offset), behavior: 'smooth' })
|
||||
} else if (typeof el.scrollIntoView === 'function') {
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
|
||||
}
|
||||
}
|
||||
|
||||
// ── Nav items ─────────────────────────────────────────────
|
||||
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(() =>
|
||||
props.dialogMode
|
||||
? (props.patientId || null)
|
||||
: (String(route.params?.id || '').trim() || null)
|
||||
)
|
||||
const isEdit = computed(() => !!patientId.value)
|
||||
|
||||
function getAreaKey () {
|
||||
const seg = String(route.path || '').split('/').filter(Boolean)[0] || 'admin'
|
||||
return seg === 'therapist' ? 'therapist' : 'admin'
|
||||
}
|
||||
|
||||
function getPatientsRoutes () {
|
||||
const area = getAreaKey()
|
||||
if (area === 'therapist') return {
|
||||
listName: 'therapist-patients',
|
||||
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 () {
|
||||
if (props.dialogMode) { emit('cancel'); return }
|
||||
const { listName, listPath } = getPatientsRoutes()
|
||||
if (window.history.length > 1) router.back()
|
||||
else safePush({ name: listName }, listPath)
|
||||
}
|
||||
|
||||
// ── Avatar ────────────────────────────────────────────────
|
||||
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() : ''; return String(ext || '').toLowerCase().replace(/[^a-z0-9]/g, '') || 'png' }
|
||||
function revokePreview () { if (avatarPreviewUrl.value?.startsWith('blob:')) { try { URL.revokeObjectURL(avatarPreviewUrl.value) } catch (_) {} } avatarPreviewUrl.value = '' }
|
||||
|
||||
function onAvatarPicked (ev) {
|
||||
const file = ev?.target?.files?.[0] || null
|
||||
avatarFile.value = null; revokePreview()
|
||||
if (!file) return
|
||||
if (!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 })
|
||||
}
|
||||
|
||||
async function getReadableAvatarUrl (path) {
|
||||
try { const { data: pub } = supabase.storage.from(AVATAR_BUCKET).getPublicUrl(path); if (pub?.publicUrl) return pub.publicUrl } catch (_) {}
|
||||
const { data, error } = await supabase.storage.from(AVATAR_BUCKET).createSignedUrl(path, 60 * 60 * 24 * 7)
|
||||
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).')
|
||||
if (file.size > 3 * 1024 * 1024) 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
|
||||
return { publicUrl: await getReadableAvatarUrl(path), path }
|
||||
}
|
||||
|
||||
async function maybeUploadAvatar (ownerId, id) {
|
||||
if (!avatarFile.value) return null
|
||||
avatarUploading.value = true
|
||||
try {
|
||||
const { publicUrl } = await uploadAvatarToStorage({ ownerId, patientId: id, file: avatarFile.value })
|
||||
form.value.avatar_url = publicUrl; avatarFile.value = null; revokePreview(); avatarPreviewUrl.value = publicUrl
|
||||
await updatePatient(id, { avatar_url: publicUrl })
|
||||
return publicUrl
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'warn', summary: 'Avatar', detail: e?.message || 'Falha 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 ───────────────────────────────────────────────
|
||||
|
||||
function parseDDMMYYYY (s) {
|
||||
const str = String(s || '').trim(); const m = /^(\d{2})-(\d{2})-(\d{4})$/.exec(str); if (!m) return null
|
||||
const dd = Number(m[1]), mm = Number(m[2]), yyyy = Number(m[3]); const dt = new Date(yyyy, mm - 1, dd)
|
||||
if (Number.isNaN(dt.getTime())) return null
|
||||
if (dt.getFullYear() !== yyyy || dt.getMonth() !== mm - 1 || dt.getDate() !== dd) return null
|
||||
return dt
|
||||
}
|
||||
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 ''
|
||||
return `${String(d.getDate()).padStart(2,'0')}-${String(d.getMonth()+1).padStart(2,'0')}-${d.getFullYear()}`
|
||||
}
|
||||
|
||||
const ageLabel = computed(() => {
|
||||
const dt = parseDDMMYYYY(form.value?.data_nascimento); if (!dt) return '—'
|
||||
const now = new Date(); let age = now.getFullYear() - dt.getFullYear()
|
||||
const mm = now.getMonth() - dt.getMonth()
|
||||
if (mm < 0 || (mm === 0 && now.getDate() < dt.getDate())) age--
|
||||
if (age < 0 || age > 130) return '—'
|
||||
return `${age} anos`
|
||||
})
|
||||
|
||||
// ── DB map ────────────────────────────────────────────────
|
||||
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 ?? '' }
|
||||
}
|
||||
|
||||
// ── 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
|
||||
}
|
||||
|
||||
// ── Sanitize ──────────────────────────────────────────────
|
||||
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 }
|
||||
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 ? (toISODate(payload.data_nascimento) || null) : null
|
||||
const filtrado = {}; Object.keys(payload).forEach(k => { if (PACIENTES_COLUNAS_PERMITIDAS.has(k)) filtrado[k] = payload[k] })
|
||||
return filtrado
|
||||
}
|
||||
|
||||
// ── DB calls ──────────────────────────────────────────────
|
||||
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 ─────────────────────────────────────────────
|
||||
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 ───────────────────────────────────────────────────
|
||||
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 ─────────────────────────────────────────────────
|
||||
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 = []; 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 = []; 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)
|
||||
avatarPreviewUrl.value = form.value.avatar_url || ''
|
||||
const rel = await getPatientRelations(patientId.value)
|
||||
grupoIdSelecionado.value = rel.groupIds?.[0] || null
|
||||
tagIdsSelecionadas.value = rel.tagIds || []
|
||||
} else {
|
||||
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(patientId, fetchAll, { immediate: true })
|
||||
|
||||
// ── Tenant resolve ────────────────────────────────────────
|
||||
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.')
|
||||
const storeTid = await getCurrentTenantId()
|
||||
if (storeTid) { try { const mid = await getCurrentMemberId(storeTid); return { tenantId: storeTid, memberId: mid } } catch (_) {} }
|
||||
const { data, error } = await supabase.from('tenant_members').select('id, tenant_id').eq('user_id', uid).eq('status', 'active').order('created_at', { ascending: false }).limit(1).single()
|
||||
if (error) throw error
|
||||
if (!data?.tenant_id || !data?.id) throw new Error('Responsible member not found')
|
||||
return { tenantId: data.tenant_id, memberId: data.id }
|
||||
}
|
||||
|
||||
// ── Submit ────────────────────────────────────────────────
|
||||
async function onSubmit () {
|
||||
try {
|
||||
saving.value = true
|
||||
const ownerId = await getOwnerId()
|
||||
const { tenantId, memberId } = await resolveTenantContextOrFail()
|
||||
const payload = sanitizePayload(form.value, ownerId)
|
||||
payload.tenant_id = tenantId; payload.responsible_member_id = memberId
|
||||
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 }); await openPanel(0); return }
|
||||
if (isEdit.value) {
|
||||
await updatePatient(patientId.value, payload)
|
||||
await maybeUploadAvatar(ownerId, patientId.value)
|
||||
await replacePatientGroups(patientId.value, grupoIdSelecionado.value)
|
||||
await replacePatientTags(patientId.value, tagIdsSelecionadas.value)
|
||||
toast.add({ severity: 'success', summary: 'Salvo', detail: 'Paciente atualizado.', life: 2500 })
|
||||
if (props.dialogMode) { emit('created', { id: patientId.value }); return }
|
||||
return
|
||||
}
|
||||
const created = await createPatient(payload)
|
||||
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 })
|
||||
if (props.dialogMode) { emit('created', created); return }
|
||||
form.value = resetForm(); grupoIdSelecionado.value = null; tagIdsSelecionadas.value = []
|
||||
avatarFile.value = null; revokePreview(); avatarPreviewUrl.value = ''
|
||||
await openPanel(0)
|
||||
} 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 })
|
||||
if (props.dialogMode) { emit('created', null); return }
|
||||
goBack()
|
||||
} catch (err) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: err?.message || 'Falha ao excluir paciente', life: 4000 })
|
||||
} finally { deleting.value = false }
|
||||
}
|
||||
|
||||
// ── Fake fill ─────────────────────────────────────────────
|
||||
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); return `${pad2(randInt(1,28))}-${pad2(randInt(1,12))}-${now.getFullYear() - age}` }
|
||||
function randomPhoneBR () { return `+55 (${randInt(11,99)}) ${maybe(0.8)?'9':''}${randInt(1000,9999)}-${randInt(1000,9999)}` }
|
||||
function randomCEP () { return `${randInt(10000,99999)}-${randInt(100,999)}` }
|
||||
function randomEmailFromName (name) { return `${String(name||'paciente').normalize('NFD').replace(/[\u0300-\u036f]/g,'').toLowerCase().replace(/[^a-z0-9]+/g,'.').replace(/(^\.)|(\.$)/g,'')}.${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 nomeCompleto = `${pick(first)} ${pick(last)} ${pick(last)}`
|
||||
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)','Viúvo(a)']), cpf: fmtCPF(generateCPF()), rg: fmtRG(String(randInt(10000000,999999999))), naturalidade: pick(cities), observacoes: 'Paciente relata ansiedade e sobrecarga emocional.', onde_nos_conheceu: pick(['Instagram','Google','Indicação','Site','Threads','Outro']), encaminhado_por: `${pick(first)} ${pick(last)}`, cep: randomCEP(), pais: 'Brasil', cidade: pick(cities), estado: pick(['SP','RJ','MG','PR','SC','RS','BA']), endereco: pick(['Rua das Flores','Av. Brasil','Rua XV de Novembro']), numero: String(randInt(10,9999)), bairro: pick(['Centro','Jardim Paulista','Vila Prado','Santa Felícia']), complemento: `Apto ${randInt(10,999)}`, escolaridade: pick(['Ensino Médio','Superior incompleto','Superior completo','Pós-graduação']), profissao: pick(['Estudante','Professora','Desenvolvedor','Enfermeira','Autônomo']), nome_parente: `${pick(first)} ${pick(last)}`, grau_parentesco: pick(['Mãe','Pai','Irmã','Irmão','Cônjuge']), 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 sh = [...tags.value].sort(() => Math.random()-0.5); tagIdsSelecionadas.value = sh.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 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()
|
||||
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
|
||||
groups.value = await listGroups()
|
||||
if (data?.id) grupoIdSelecionado.value = data.id
|
||||
toast.add({ severity: 'success', summary: 'Grupo', detail: 'Grupo criado.', life: 2500 }); createGroupDialog.value = false
|
||||
} catch (e) {
|
||||
const msg = e?.message || ''
|
||||
createGroupError.value = (e?.code === '23505' || /duplicate key value/i.test(msg)) ? 'Já existe um grupo com esse nome.' : (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()
|
||||
const { data, error } = await supabase.from('patient_tags').insert({ owner_id: ownerId, tenant_id: tenantId, nome: name, cor: color }).select('id').single()
|
||||
if (error) throw error
|
||||
tags.value = await listTags()
|
||||
if (data?.id) { const set = new Set([...(tagIdsSelecionadas.value || []), data.id]); 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 || ''
|
||||
createTagError.value = (e?.code === '23505' || /duplicate key value/i.test(msg)) ? 'Já existe uma tag com esse nome.' : (msg || 'Falha ao criar tag.')
|
||||
} finally { createTagSaving.value = false }
|
||||
}
|
||||
|
||||
defineExpose({ fillRandomPatient, onSubmit, confirmDelete, saving, deleting, canSee, isEdit })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ConfirmDialog v-if="!dialogMode" />
|
||||
|
||||
<!-- Sentinel -->
|
||||
<div ref="headerSentinelRef" class="h-px" />
|
||||
|
||||
<!-- ══════════════════════════════════════════════════════
|
||||
HERO sticky (oculto no modo dialog)
|
||||
═══════════════════════════════════════════════════════ -->
|
||||
<section
|
||||
v-if="!dialogMode"
|
||||
class="sticky mx-3 md:mx-4 mb-3 z-20 overflow-hidden rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] px-3 py-2.5 transition-[border-radius] duration-200"
|
||||
:style="{ top: 'var(--layout-sticky-top, 56px)' }"
|
||||
>
|
||||
<!-- Blobs -->
|
||||
<div class="absolute inset-0 pointer-events-none overflow-hidden" aria-hidden="true">
|
||||
<div class="absolute w-64 h-64 -top-16 -right-8 rounded-full blur-[60px] bg-indigo-500/10" />
|
||||
<div class="absolute w-72 h-72 top-0 -left-16 rounded-full blur-[60px] bg-emerald-400/[0.08]" />
|
||||
</div>
|
||||
|
||||
<div class="relative z-1 flex items-center gap-3">
|
||||
|
||||
<!-- Brand -->
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<div class="grid place-items-center w-9 h-9 rounded-md shrink-0 bg-indigo-500/10 text-indigo-500">
|
||||
<i class="pi pi-user-plus text-base" />
|
||||
</div>
|
||||
<div class="min-w-0 hidden lg:block">
|
||||
<div class="text-[1rem] font-bold tracking-tight text-[var(--text-color)]">
|
||||
{{ isEdit ? 'Editar paciente' : 'Cadastrar paciente' }}
|
||||
</div>
|
||||
<div class="text-[1rem] text-[var(--text-color-secondary)]">
|
||||
<template v-if="isEdit">Idade: <b class="text-[var(--text-color)]">{{ ageLabel }}</b></template>
|
||||
<template v-else">Preencha as informações do novo paciente</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Espaçador -->
|
||||
<div class="flex-1" />
|
||||
|
||||
<!-- Ações (ocultas no modo dialog — o Dialog tem seu próprio footer) -->
|
||||
<div v-if="!dialogMode" class="flex items-center gap-1.5 shrink-0">
|
||||
<Button
|
||||
v-if="canSee('testMODE')"
|
||||
label="Preencher tudo"
|
||||
icon="pi pi-bolt"
|
||||
severity="secondary"
|
||||
outlined
|
||||
size="small"
|
||||
class="rounded-full hidden xl:flex"
|
||||
@click="fillRandomPatient"
|
||||
/>
|
||||
<Button icon="pi pi-arrow-left" severity="secondary" outlined class="h-9 w-9 rounded-full" title="Voltar" @click="goBack" />
|
||||
<Button
|
||||
v-if="isEdit"
|
||||
icon="pi pi-trash"
|
||||
severity="danger"
|
||||
outlined
|
||||
class="h-9 w-9 rounded-full"
|
||||
title="Excluir paciente"
|
||||
:loading="deleting"
|
||||
@click="confirmDelete"
|
||||
/>
|
||||
<Button label="Salvar" icon="pi pi-check" class="rounded-full" :loading="saving" @click="onSubmit" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ══════════════════════════════════════════════════════
|
||||
CORPO
|
||||
═══════════════════════════════════════════════════════ -->
|
||||
<div class="px-3 md:px-4 pb-6">
|
||||
|
||||
<!-- Loading -->
|
||||
<div v-if="loading" class="flex items-center justify-center py-16 text-[var(--text-color-secondary)] gap-2">
|
||||
<i class="pi pi-spin pi-spinner" /> Carregando…
|
||||
</div>
|
||||
|
||||
<div v-else class="grid grid-cols-1 gap-3 xl:grid-cols-[260px_1fr] max-w-[1100px] mx-auto">
|
||||
|
||||
<!-- ── SIDEBAR ──────────────────────────────────── -->
|
||||
<aside
|
||||
class="rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] p-3.5 xl:sticky xl:self-start"
|
||||
:class="dialogMode ? 'xl:top-4' : 'xl:top-[calc(var(--layout-sticky-top,56px)+3.5rem)]'"
|
||||
>
|
||||
|
||||
<!-- Avatar -->
|
||||
<div class="flex items-center gap-3 pb-3.5 mb-3.5 border-b border-[var(--surface-border,#e2e8f0)] xl:flex-col xl:items-center xl:gap-2">
|
||||
<!-- Foto -->
|
||||
<div class="w-16 h-16 xl:w-20 xl:h-20 rounded-full overflow-hidden border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-ground,#f8fafc)] shrink-0">
|
||||
<img
|
||||
v-if="avatarPreviewUrl || form.avatar_url"
|
||||
:src="avatarPreviewUrl || form.avatar_url"
|
||||
alt="Avatar do paciente"
|
||||
class="w-full h-full object-cover"
|
||||
/>
|
||||
<div v-else class="grid w-full h-full place-items-center">
|
||||
<i class="pi pi-user text-2xl text-[var(--text-color-secondary)] opacity-40" />
|
||||
</div>
|
||||
</div>
|
||||
<!-- Upload -->
|
||||
<div class="flex-1 xl:w-full">
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
class="block w-full text-[1rem] text-[var(--text-color-secondary)]
|
||||
file:mr-2 file:rounded-full file:border file:border-[var(--surface-border,#e2e8f0)]
|
||||
file:bg-[var(--surface-ground,#f8fafc)] file:px-3 file:py-1 file:text-[0.75rem]
|
||||
file:text-[var(--text-color)] file:cursor-pointer
|
||||
hover:file:bg-[var(--surface-hover,#f1f5f9)] hover:file:border-indigo-300"
|
||||
@change="onAvatarPicked"
|
||||
/>
|
||||
<div class="mt-1 text-[1rem] text-[var(--text-color-secondary)] opacity-60">
|
||||
Avatar opcional · máx 3 MB
|
||||
<span v-if="avatarUploading" class="ml-1 text-indigo-500">(enviando…)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Nav — desktop (≥ xl) -->
|
||||
<div v-if="!isCompact" class="flex flex-col gap-1">
|
||||
<button
|
||||
v-for="item in navItems"
|
||||
:key="item.value"
|
||||
type="button"
|
||||
class="flex items-center gap-2.5 rounded-md px-3 py-2 text-left text-[1rem] border transition-colors duration-100"
|
||||
:class="activeValue === item.value
|
||||
? 'bg-indigo-500/8 border-indigo-300/40 text-indigo-700 font-semibold'
|
||||
: 'border-transparent text-[var(--text-color)] hover:bg-[var(--surface-ground,#f8fafc)] font-medium'"
|
||||
@click="openPanel(Number(item.value))"
|
||||
>
|
||||
<i :class="item.icon" class="text-[1rem] opacity-70 shrink-0" />
|
||||
<span>{{ item.label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- ── MAIN ──────────────────────────────────────── -->
|
||||
<main class="rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] overflow-hidden">
|
||||
|
||||
<!-- Nav compacto (<xl) -->
|
||||
<div v-if="isCompact" class="sticky top-[calc(var(--layout-sticky-top,56px)+3.5rem)] z-30 border-b border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] px-3.5 py-2">
|
||||
<Button
|
||||
type="button"
|
||||
class="w-full !rounded-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-[240px] flex-col gap-1 p-1">
|
||||
<button
|
||||
v-for="item in navItems"
|
||||
:key="item.value"
|
||||
type="button"
|
||||
class="flex items-center gap-2.5 rounded-md px-3 py-2 text-left text-[1rem] border border-transparent cursor-pointer"
|
||||
:class="activeValue === item.value ? 'bg-indigo-500/8 border-indigo-300/40 text-indigo-700 font-semibold' : 'text-[var(--text-color)] hover:bg-[var(--surface-ground,#f8fafc)] font-medium'"
|
||||
@click="selectNav(item)"
|
||||
>
|
||||
<i :class="item.icon" class="text-[1rem] opacity-70 shrink-0" />
|
||||
<span>{{ item.label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
<div class="p-4">
|
||||
<Accordion :multiple="false" v-model:value="activeValue">
|
||||
|
||||
<!-- ─── 0: Informações pessoais ──────────── -->
|
||||
<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 pt-1">
|
||||
<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>
|
||||
<!-- Grupo -->
|
||||
<div>
|
||||
<div class="flex gap-2">
|
||||
<div class="flex-1 min-w-0">
|
||||
<FloatLabel variant="on">
|
||||
<IconField><InputIcon class="pi pi-folder-open" /><Select id="f_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>
|
||||
<div class="mt-1 text-[0.72rem] text-[var(--text-color-secondary)] opacity-70">Usado para puxar um modelo de anamnese.</div>
|
||||
</div>
|
||||
<Button icon="pi pi-plus" severity="secondary" outlined class="shrink-0" @click="openGroupDlg" />
|
||||
</div>
|
||||
</div>
|
||||
<!-- Tags -->
|
||||
<div>
|
||||
<div class="flex gap-2">
|
||||
<div class="flex-1 min-w-0">
|
||||
<FloatLabel variant="on">
|
||||
<IconField><InputIcon class="pi pi-tag" /><MultiSelect id="f_tags" v-model="tagIdsSelecionadas" :options="tags" optionLabel="name" optionValue="id" class="w-full pl-[25px]" display="chip" filter variant="filled" /></IconField>
|
||||
<label for="f_tags">Tags</label>
|
||||
</FloatLabel>
|
||||
</div>
|
||||
<Button icon="pi pi-plus" severity="secondary" outlined class="shrink-0" @click="openTagDlg" />
|
||||
</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: Endereço ──────────────────────── -->
|
||||
<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 pt-1">
|
||||
<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: Dados adicionais ──────────────── -->
|
||||
<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 pt-1">
|
||||
<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 class="xl:col-span-2">
|
||||
<Button icon="pi pi-plus" label="Adicionar mais parentes (em breve)" severity="secondary" outlined disabled />
|
||||
<div class="mt-1.5 text-[0.72rem] text-[var(--text-color-secondary)] opacity-60">Se você quiser, isso vira uma lista (1:N) depois.</div>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionPanel>
|
||||
|
||||
<!-- ─── 3: Responsável ───────────────────── -->
|
||||
<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 pt-1">
|
||||
<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-[1rem] text-[var(--text-color)] cursor-pointer">Cobrança no responsável</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionPanel>
|
||||
|
||||
<!-- ─── 4: Anotações internas ────────────── -->
|
||||
<AccordionPanel value="4">
|
||||
<AccordionHeader :ref="el => setPanelHeaderRef(el, 4)">5. Anotações internas</AccordionHeader>
|
||||
<AccordionContent>
|
||||
<div class="mb-2.5 text-[0.75rem] text-[var(--text-color-secondary)] opacity-70 flex items-center gap-1.5">
|
||||
<i class="pi pi-lock text-[1rem]" />
|
||||
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>
|
||||
|
||||
<!-- Botão salvar bottom (oculto no modo dialog — o footer cuida disso) -->
|
||||
<div v-if="!dialogMode" class="mt-4 flex justify-center">
|
||||
<Button label="Salvar" icon="pi pi-check" :loading="saving" class="min-w-[200px] rounded-full" @click="onSubmit" />
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</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"
|
||||
>
|
||||
<div class="flex flex-col gap-4 pt-1">
|
||||
<span class="text-[1rem] text-[var(--text-color-secondary)]">Crie um grupo para organizar seus pacientes.</span>
|
||||
<div class="flex items-center gap-3">
|
||||
<label for="group-name" class="w-20 text-[1rem] font-semibold shrink-0">Nome</label>
|
||||
<InputText id="group-name" v-model="newGroup.name" class="flex-1" autocomplete="off" placeholder="Ex: Crianças" />
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<label class="w-20 text-[1rem] font-semibold shrink-0">Cor</label>
|
||||
<div class="flex flex-1 items-center gap-2.5">
|
||||
<input v-model="newGroup.color" type="color" class="h-9 w-12 cursor-pointer rounded-md border border-[var(--surface-border,#e2e8f0)] bg-transparent" />
|
||||
<Chip :label="newGroup.color || '#—'" class="font-semibold" :style="{ backgroundColor: newGroup.color, color: '#fff' }" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="createGroupError" class="text-[1rem] text-red-500">{{ createGroupError }}</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-2">
|
||||
<Button label="Cancelar" severity="secondary" outlined class="rounded-full" :disabled="createGroupSaving" @click="createGroupDialog = false" />
|
||||
<Button label="Criar" icon="pi pi-check" class="rounded-full" :loading="createGroupSaving" @click="createGroupPersist" />
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
<!-- ══════════════════════════════════════════════════════
|
||||
Dialog: Criar tag
|
||||
═══════════════════════════════════════════════════════ -->
|
||||
<Dialog
|
||||
v-model:visible="createTagDialog"
|
||||
modal
|
||||
:draggable="false"
|
||||
header="Criar tag"
|
||||
:style="{ width: '26rem' }"
|
||||
:closable="!createTagSaving"
|
||||
pt:mask:class="backdrop-blur-sm"
|
||||
>
|
||||
<div class="flex flex-col gap-4 pt-1">
|
||||
<span class="text-[1rem] text-[var(--text-color-secondary)]">Crie uma tag para facilitar filtros e organização.</span>
|
||||
<div class="flex items-center gap-3">
|
||||
<label for="tag-name" class="w-20 text-[1rem] font-semibold shrink-0">Nome</label>
|
||||
<InputText id="tag-name" v-model="newTag.name" class="flex-1" autocomplete="off" placeholder="Ex: Ansiedade" />
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<label class="w-20 text-[1rem] font-semibold shrink-0">Cor</label>
|
||||
<div class="flex flex-1 items-center gap-2.5">
|
||||
<input v-model="newTag.color" type="color" class="h-9 w-12 cursor-pointer rounded-md border border-[var(--surface-border,#e2e8f0)] bg-transparent" />
|
||||
<Chip :label="newTag.color || '#—'" class="font-semibold" :style="{ backgroundColor: newTag.color, color: '#fff' }" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="createTagError" class="text-[1rem] text-red-500">{{ createTagError }}</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-2">
|
||||
<Button label="Cancelar" severity="secondary" outlined class="rounded-full" :disabled="createTagSaving" @click="createTagDialog = false" />
|
||||
<Button label="Criar" icon="pi pi-check" class="rounded-full" :loading="createTagSaving" @click="createTagPersist" />
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
@@ -0,0 +1,679 @@
|
||||
<!--
|
||||
|--------------------------------------------------------------------------
|
||||
| Agência PSI
|
||||
|--------------------------------------------------------------------------
|
||||
| Criado e desenvolvido por Leonardo Nohama
|
||||
|
|
||||
| Tecnologia aplicada à escuta.
|
||||
| Estrutura para o cuidado.
|
||||
|
|
||||
| Arquivo: src/features/patients/detail/PatientsDetailPage.vue
|
||||
| Data: 2026
|
||||
| Local: São Carlos/SP — Brasil
|
||||
|--------------------------------------------------------------------------
|
||||
| © 2026 — Todos os direitos reservados
|
||||
|--------------------------------------------------------------------------
|
||||
-->
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
// ── Mock data ─────────────────────────────────────────────
|
||||
const patient = ref({
|
||||
nome_completo: 'Mariana Lima',
|
||||
nome_social: null,
|
||||
pronomes: 'ela/dela',
|
||||
data_nascimento: '1992-06-14',
|
||||
cpf: '12345678900',
|
||||
genero: 'Feminino',
|
||||
estado_civil: 'Solteira',
|
||||
escolaridade: 'Superior completo',
|
||||
profissao: 'Desenvolvedora',
|
||||
etnia: null,
|
||||
telefone: '(16) 99123-4567',
|
||||
email: 'mariana@email.com',
|
||||
canal_preferido: 'WhatsApp',
|
||||
horario_contato: '08h–20h',
|
||||
cep: '13560-000',
|
||||
cidade: 'São Carlos',
|
||||
estado: 'SP',
|
||||
status: 'Ativo',
|
||||
risco_elevado: true,
|
||||
risco_nota: 'Ideação passiva relatada em 12/03',
|
||||
risco_sinalizado_por: 'Dra. Ana Lima',
|
||||
risco_sinalizado_em: '2025-03-12',
|
||||
tags: [{ nome: 'Ansiedade', cor: '#7F77DD' }, { nome: 'TCC', cor: '#1D9E75' }],
|
||||
convenio: 'Unimed',
|
||||
patient_scope: 'Clínica',
|
||||
origem: 'Indicação',
|
||||
encaminhado_por: 'Dr. Roberto (psiq.)',
|
||||
metodo_pagamento_preferido: 'PIX',
|
||||
motivo_saida: null,
|
||||
metricas: {
|
||||
total_sessoes: 47,
|
||||
taxa_comparecimento: 92,
|
||||
ltv_total: 8460,
|
||||
dias_sem_sessao: 18,
|
||||
taxa_pagamentos: 100,
|
||||
taxa_tarefas: 60,
|
||||
engajamento_score: 84,
|
||||
duracao_meses: 14,
|
||||
proxima_sessao: '27/03 às 14h'
|
||||
}
|
||||
})
|
||||
|
||||
const contatos = ref([
|
||||
{ nome: 'Maria Lima', tipo: 'emergencia', relacao: 'mãe', telefone: '(16) 98888-0001', email: 'maria@email.com', is_primario: true },
|
||||
{ nome: 'Dr. Roberto Oliveira', tipo: 'profissional_saude', relacao: 'psiquiatra', telefone: '(16) 3322-1100', email: null, is_primario: false }
|
||||
])
|
||||
|
||||
const timeline = ref([
|
||||
{ tipo: 'risco_sinalizado', titulo: 'Risco elevado sinalizado', descricao: 'Ideação passiva relatada', cor: 'red', data: '12/03/2025', autor: 'Dra. Ana Lima' },
|
||||
{ tipo: 'escala_respondida', titulo: 'GAD-7 respondido', descricao: 'Score 12 — ansiedade moderada', cor: 'green', data: '10/03/2025', autor: 'via portal' },
|
||||
{ tipo: 'documento_assinado', titulo: 'TCLE assinado digitalmente', descricao: null, cor: 'blue', data: '02/01/2024', autor: 'via portal' },
|
||||
{ tipo: 'primeira_sessao', titulo: 'Primeira sessão realizada', descricao: 'Presencial · 50min', cor: 'green', data: '15/01/2024', autor: null }
|
||||
])
|
||||
|
||||
// ── Computed helpers ──────────────────────────────────────
|
||||
const idade = computed(() => {
|
||||
if (!patient.value.data_nascimento) return null
|
||||
const birth = new Date(patient.value.data_nascimento)
|
||||
const now = new Date()
|
||||
let age = now.getFullYear() - birth.getFullYear()
|
||||
const m = now.getMonth() - birth.getMonth()
|
||||
if (m < 0 || (m === 0 && now.getDate() < birth.getDate())) age--
|
||||
return age
|
||||
})
|
||||
|
||||
const cpfMascarado = computed(() => {
|
||||
const cpf = patient.value.cpf || ''
|
||||
if (cpf.length < 2) return cpf
|
||||
const visible = cpf.slice(-2)
|
||||
const hidden = '•'.repeat(cpf.length - 2)
|
||||
return hidden + visible
|
||||
})
|
||||
|
||||
const iniciais = computed(() => {
|
||||
return (patient.value.nome_completo || '')
|
||||
.split(' ')
|
||||
.filter(Boolean)
|
||||
.map(w => w[0].toUpperCase())
|
||||
.slice(0, 2)
|
||||
.join('')
|
||||
})
|
||||
|
||||
function initiaisFor(nome) {
|
||||
return (nome || '')
|
||||
.split(' ')
|
||||
.filter(Boolean)
|
||||
.map(w => w[0].toUpperCase())
|
||||
.slice(0, 2)
|
||||
.join('')
|
||||
}
|
||||
|
||||
function dataNascFormatada(iso) {
|
||||
if (!iso) return '—'
|
||||
const [y, m, d] = iso.split('-')
|
||||
return `${d}/${m}/${y}`
|
||||
}
|
||||
|
||||
function progressSeverity(val) {
|
||||
if (val >= 80) return 'success'
|
||||
if (val >= 60) return 'warning'
|
||||
return 'danger'
|
||||
}
|
||||
|
||||
function progressColor(val) {
|
||||
if (val >= 80) return 'var(--p-green-500)'
|
||||
if (val >= 60) return 'var(--p-yellow-500)'
|
||||
return 'var(--p-red-500)'
|
||||
}
|
||||
|
||||
function scoreClass(val) {
|
||||
if (val >= 80) return 'text-green-500'
|
||||
if (val >= 60) return 'text-yellow-500'
|
||||
return 'text-red-500'
|
||||
}
|
||||
|
||||
function timelineMarkerStyle(cor) {
|
||||
const map = {
|
||||
red: 'var(--p-red-500)',
|
||||
green: 'var(--p-green-500)',
|
||||
blue: 'var(--p-blue-500)',
|
||||
gray: 'var(--p-surface-400)'
|
||||
}
|
||||
return { background: map[cor] || map.gray }
|
||||
}
|
||||
|
||||
function timelineIcon(tipo) {
|
||||
const map = {
|
||||
risco_sinalizado: 'pi pi-exclamation-triangle',
|
||||
escala_respondida: 'pi pi-chart-bar',
|
||||
documento_assinado: 'pi pi-file-check',
|
||||
primeira_sessao: 'pi pi-star'
|
||||
}
|
||||
return map[tipo] || 'pi pi-circle'
|
||||
}
|
||||
|
||||
function val(v) {
|
||||
return v ?? '—'
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
if (window.history.length > 1) router.back()
|
||||
else router.push('/admin/pacientes')
|
||||
}
|
||||
|
||||
// ── Tabs ─────────────────────────────────────────────────
|
||||
const activeTab = ref(0)
|
||||
const tabs = [
|
||||
{ label: 'Perfil', icon: 'pi pi-user' },
|
||||
{ label: 'Prontuário', icon: 'pi pi-clipboard' },
|
||||
{ label: 'Agenda', icon: 'pi pi-calendar' },
|
||||
{ label: 'Financeiro', icon: 'pi pi-wallet' },
|
||||
{ label: 'Documentos', icon: 'pi pi-folder' }
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col min-h-screen bg-[var(--surface-ground)]">
|
||||
|
||||
<!-- ── Alerta de risco elevado ─────────────────────── -->
|
||||
<Message
|
||||
v-if="patient.risco_elevado"
|
||||
severity="error"
|
||||
:closable="false"
|
||||
class="rounded-none border-0 border-b border-red-400 m-0"
|
||||
pt:root:class="rounded-none"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<i class="pi pi-exclamation-circle text-xl mt-0.5 shrink-0" />
|
||||
<div>
|
||||
<div class="font-bold text-[1rem]">Atenção — paciente com risco elevado sinalizado</div>
|
||||
<div class="text-[0.85rem] opacity-90 mt-0.5">
|
||||
Sinalizado em {{ patient.risco_sinalizado_em?.split('-').reverse().join('/') }}
|
||||
por {{ patient.risco_sinalizado_por }}
|
||||
<span v-if="patient.risco_nota"> · {{ patient.risco_nota }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Message>
|
||||
|
||||
<!-- ── Barra superior ─────────────────────────────── -->
|
||||
<div class="flex items-center justify-between gap-3 px-4 py-3 bg-[var(--surface-card)] border-b border-[var(--surface-border)]">
|
||||
<Button
|
||||
icon="pi pi-arrow-left"
|
||||
label="Pacientes"
|
||||
severity="secondary"
|
||||
text
|
||||
class="font-semibold"
|
||||
@click="goBack"
|
||||
/>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
icon="pi pi-pencil"
|
||||
label="Editar"
|
||||
severity="secondary"
|
||||
outlined
|
||||
class="rounded-full"
|
||||
/>
|
||||
<Button
|
||||
icon="pi pi-plus"
|
||||
label="Sessão"
|
||||
class="rounded-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Card cabeçalho ─────────────────────────────── -->
|
||||
<div class="px-4 pt-4 pb-0">
|
||||
<Card class="shadow-none border border-[var(--surface-border)]">
|
||||
<template #content>
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-start sm:gap-6">
|
||||
|
||||
<!-- Avatar -->
|
||||
<Avatar
|
||||
:label="iniciais"
|
||||
size="xlarge"
|
||||
shape="circle"
|
||||
class="shrink-0 text-white font-bold text-xl"
|
||||
style="background: var(--p-primary-500); width: 4.5rem; height: 4.5rem; font-size: 1.4rem;"
|
||||
/>
|
||||
|
||||
<!-- Nome + badges + métricas -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<!-- Nome + info rápida -->
|
||||
<div class="flex flex-wrap items-baseline gap-x-3 gap-y-1 mb-2">
|
||||
<span class="text-2xl font-bold text-[var(--text-color)] leading-tight">
|
||||
{{ patient.nome_completo }}
|
||||
</span>
|
||||
<span class="text-[var(--text-color-secondary)] text-[0.95rem]">
|
||||
{{ idade }} anos · {{ patient.pronomes }} · {{ patient.cidade }}/{{ patient.estado }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Badges -->
|
||||
<div class="flex flex-wrap items-center gap-2 mb-4">
|
||||
<Tag :value="patient.status" severity="success" />
|
||||
<Tag :value="patient.convenio" severity="info" />
|
||||
<Tag :value="patient.patient_scope" severity="secondary" />
|
||||
<Tag
|
||||
v-for="tag in patient.tags"
|
||||
:key="tag.nome"
|
||||
:value="tag.nome"
|
||||
:style="{ background: tag.cor, color: '#fff', border: 'none' }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Métricas em linha -->
|
||||
<div class="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||
<div class="flex flex-col items-center p-3 rounded-xl bg-[var(--surface-section)] border border-[var(--surface-border)]">
|
||||
<span class="text-2xl font-bold text-[var(--text-color)]">{{ patient.metricas.total_sessoes }}</span>
|
||||
<span class="text-[0.75rem] text-[var(--text-color-secondary)] mt-0.5 text-center">Total sessões</span>
|
||||
</div>
|
||||
<div class="flex flex-col items-center p-3 rounded-xl bg-[var(--surface-section)] border border-[var(--surface-border)]">
|
||||
<span class="text-2xl font-bold" :class="scoreClass(patient.metricas.taxa_comparecimento)">{{ patient.metricas.taxa_comparecimento }}%</span>
|
||||
<span class="text-[0.75rem] text-[var(--text-color-secondary)] mt-0.5 text-center">Comparecimento</span>
|
||||
</div>
|
||||
<div class="flex flex-col items-center p-3 rounded-xl bg-[var(--surface-section)] border border-[var(--surface-border)]">
|
||||
<span class="text-2xl font-bold text-[var(--text-color)]">R$ {{ patient.metricas.ltv_total.toLocaleString('pt-BR') }}</span>
|
||||
<span class="text-[0.75rem] text-[var(--text-color-secondary)] mt-0.5 text-center">LTV total</span>
|
||||
</div>
|
||||
<div class="flex flex-col items-center p-3 rounded-xl bg-[var(--surface-section)] border border-[var(--surface-border)]">
|
||||
<span class="text-2xl font-bold text-[var(--text-color)]">{{ patient.metricas.dias_sem_sessao }}</span>
|
||||
<span class="text-[0.75rem] text-[var(--text-color-secondary)] mt-0.5 text-center">Dias s/ sessão</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<!-- ── Tabs ───────────────────────────────────────── -->
|
||||
<div class="px-4 pt-3 pb-6 flex-1">
|
||||
<TabView v-model:activeIndex="activeTab" class="shadow-none">
|
||||
|
||||
<!-- ══ Aba: Perfil ════════════════════════════ -->
|
||||
<TabPanel>
|
||||
<template #header>
|
||||
<span class="flex items-center gap-2">
|
||||
<i class="pi pi-user" />
|
||||
Perfil
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<div class="grid grid-cols-1 xl:grid-cols-2 gap-4 mt-2">
|
||||
|
||||
<!-- ─── Coluna esquerda ─────────────────── -->
|
||||
<div class="flex flex-col gap-4">
|
||||
|
||||
<!-- Dados pessoais -->
|
||||
<Card class="shadow-none border border-[var(--surface-border)]">
|
||||
<template #title>
|
||||
<span class="text-[1rem] font-semibold flex items-center gap-2">
|
||||
<i class="pi pi-id-card text-[var(--p-primary-500)]" />
|
||||
Dados pessoais
|
||||
</span>
|
||||
</template>
|
||||
<template #content>
|
||||
<table class="w-full text-[0.9rem]">
|
||||
<tbody>
|
||||
<tr v-for="row in [
|
||||
{ label: 'Nome completo', value: patient.nome_completo },
|
||||
{ label: 'Nome social', value: patient.nome_social },
|
||||
{ label: 'Pronomes', value: patient.pronomes },
|
||||
{ label: 'Nascimento', value: `${dataNascFormatada(patient.data_nascimento)} (${idade} anos)` },
|
||||
{ label: 'CPF', value: cpfMascarado },
|
||||
{ label: 'Gênero', value: patient.genero },
|
||||
{ label: 'Estado civil', value: patient.estado_civil },
|
||||
{ label: 'Escolaridade', value: patient.escolaridade },
|
||||
{ label: 'Profissão', value: patient.profissao },
|
||||
{ label: 'Etnia', value: patient.etnia },
|
||||
]" :key="row.label" class="border-b border-[var(--surface-border)] last:border-0">
|
||||
<td class="py-2 pr-4 w-[38%] text-[var(--text-color-secondary)] font-medium align-top">{{ row.label }}</td>
|
||||
<td class="py-2 align-top" :class="{ 'text-[var(--text-color-secondary)] italic': !row.value }">
|
||||
{{ row.value || '—' }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</template>
|
||||
</Card>
|
||||
|
||||
<!-- Contato -->
|
||||
<Card class="shadow-none border border-[var(--surface-border)]">
|
||||
<template #title>
|
||||
<span class="text-[1rem] font-semibold flex items-center gap-2">
|
||||
<i class="pi pi-phone text-[var(--p-primary-500)]" />
|
||||
Contato
|
||||
</span>
|
||||
</template>
|
||||
<template #content>
|
||||
<table class="w-full text-[0.9rem]">
|
||||
<tbody>
|
||||
<tr class="border-b border-[var(--surface-border)]">
|
||||
<td class="py-2 pr-4 w-[38%] text-[var(--text-color-secondary)] font-medium">Telefone</td>
|
||||
<td class="py-2">
|
||||
<a :href="`tel:${patient.telefone}`" class="text-[var(--p-primary-500)] hover:underline">{{ patient.telefone }}</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="border-b border-[var(--surface-border)]">
|
||||
<td class="py-2 pr-4 text-[var(--text-color-secondary)] font-medium">E-mail</td>
|
||||
<td class="py-2">
|
||||
<a :href="`mailto:${patient.email}`" class="text-[var(--p-primary-500)] hover:underline">{{ patient.email }}</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="border-b border-[var(--surface-border)]">
|
||||
<td class="py-2 pr-4 text-[var(--text-color-secondary)] font-medium">Canal preferido</td>
|
||||
<td class="py-2">{{ val(patient.canal_preferido) }}</td>
|
||||
</tr>
|
||||
<tr class="border-b border-[var(--surface-border)]">
|
||||
<td class="py-2 pr-4 text-[var(--text-color-secondary)] font-medium">Horário</td>
|
||||
<td class="py-2">{{ val(patient.horario_contato) }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="py-2 pr-4 text-[var(--text-color-secondary)] font-medium">Cidade</td>
|
||||
<td class="py-2">{{ patient.cep ? patient.cep + ' · ' : '' }}{{ patient.cidade }}/{{ patient.estado }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</template>
|
||||
</Card>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- ─── Coluna direita ──────────────────── -->
|
||||
<div class="flex flex-col gap-4">
|
||||
|
||||
<!-- Origem -->
|
||||
<Card class="shadow-none border border-[var(--surface-border)]">
|
||||
<template #title>
|
||||
<span class="text-[1rem] font-semibold flex items-center gap-2">
|
||||
<i class="pi pi-send text-[var(--p-primary-500)]" />
|
||||
Origem
|
||||
</span>
|
||||
</template>
|
||||
<template #content>
|
||||
<table class="w-full text-[0.9rem]">
|
||||
<tbody>
|
||||
<tr v-for="row in [
|
||||
{ label: 'Como chegou', value: patient.origem },
|
||||
{ label: 'Encaminhado por', value: patient.encaminhado_por },
|
||||
{ label: 'Pagamento', value: patient.metodo_pagamento_preferido },
|
||||
{ label: 'Motivo de saída', value: patient.motivo_saida },
|
||||
]" :key="row.label" class="border-b border-[var(--surface-border)] last:border-0">
|
||||
<td class="py-2 pr-4 w-[40%] text-[var(--text-color-secondary)] font-medium align-top">{{ row.label }}</td>
|
||||
<td class="py-2 align-top" :class="{ 'text-[var(--text-color-secondary)] italic': !row.value }">
|
||||
{{ row.value || '—' }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</template>
|
||||
</Card>
|
||||
|
||||
<!-- Contatos & rede de suporte -->
|
||||
<Card class="shadow-none border border-[var(--surface-border)]">
|
||||
<template #title>
|
||||
<span class="text-[1rem] font-semibold flex items-center gap-2">
|
||||
<i class="pi pi-users text-[var(--p-primary-500)]" />
|
||||
Contatos & rede de suporte
|
||||
</span>
|
||||
</template>
|
||||
<template #content>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div
|
||||
v-for="contato in contatos"
|
||||
:key="contato.nome"
|
||||
class="flex items-start gap-3 p-3 rounded-xl border border-[var(--surface-border)] bg-[var(--surface-section)]"
|
||||
>
|
||||
<Avatar
|
||||
:label="initiaisFor(contato.nome)"
|
||||
shape="circle"
|
||||
class="shrink-0 text-white font-bold"
|
||||
style="background: var(--p-primary-300); width: 2.5rem; height: 2.5rem;"
|
||||
/>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex flex-wrap items-center gap-2 mb-1">
|
||||
<span class="font-semibold text-[0.92rem]">{{ contato.nome }}</span>
|
||||
<Tag
|
||||
:value="contato.relacao"
|
||||
severity="secondary"
|
||||
class="text-[0.72rem]"
|
||||
/>
|
||||
<Tag
|
||||
v-if="contato.is_primario"
|
||||
value="emergência"
|
||||
severity="danger"
|
||||
class="text-[0.72rem]"
|
||||
/>
|
||||
</div>
|
||||
<div class="text-[0.82rem] text-[var(--text-color-secondary)] flex flex-wrap gap-x-3 gap-y-0.5">
|
||||
<span v-if="contato.telefone">
|
||||
<i class="pi pi-phone mr-1" />
|
||||
<a :href="`tel:${contato.telefone}`" class="hover:underline">{{ contato.telefone }}</a>
|
||||
</span>
|
||||
<span v-if="contato.email">
|
||||
<i class="pi pi-envelope mr-1" />
|
||||
<a :href="`mailto:${contato.email}`" class="hover:underline">{{ contato.email }}</a>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
icon="pi pi-plus"
|
||||
label="Adicionar contato"
|
||||
severity="secondary"
|
||||
outlined
|
||||
class="rounded-full w-full mt-1"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
|
||||
<!-- Engajamento -->
|
||||
<Card class="shadow-none border border-[var(--surface-border)]">
|
||||
<template #title>
|
||||
<span class="text-[1rem] font-semibold flex items-center gap-2">
|
||||
<i class="pi pi-chart-line text-[var(--p-primary-500)]" />
|
||||
Engajamento
|
||||
</span>
|
||||
</template>
|
||||
<template #content>
|
||||
<!-- Barras de progresso -->
|
||||
<div class="flex flex-col gap-4 mb-5">
|
||||
<div>
|
||||
<div class="flex justify-between text-[0.82rem] mb-1">
|
||||
<span class="text-[var(--text-color-secondary)]">Comparecimento</span>
|
||||
<span class="font-semibold" :style="{ color: progressColor(patient.metricas.taxa_comparecimento) }">
|
||||
{{ patient.metricas.taxa_comparecimento }}%
|
||||
</span>
|
||||
</div>
|
||||
<ProgressBar
|
||||
:value="patient.metricas.taxa_comparecimento"
|
||||
:showValue="false"
|
||||
:class="`progress-${progressSeverity(patient.metricas.taxa_comparecimento)}`"
|
||||
style="height: 8px; border-radius: 99px;"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div class="flex justify-between text-[0.82rem] mb-1">
|
||||
<span class="text-[var(--text-color-secondary)]">Pagamentos em dia</span>
|
||||
<span class="font-semibold" :style="{ color: progressColor(patient.metricas.taxa_pagamentos) }">
|
||||
{{ patient.metricas.taxa_pagamentos }}%
|
||||
</span>
|
||||
</div>
|
||||
<ProgressBar
|
||||
:value="patient.metricas.taxa_pagamentos"
|
||||
:showValue="false"
|
||||
:class="`progress-${progressSeverity(patient.metricas.taxa_pagamentos)}`"
|
||||
style="height: 8px; border-radius: 99px;"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div class="flex justify-between text-[0.82rem] mb-1">
|
||||
<span class="text-[var(--text-color-secondary)]">Tarefas concluídas</span>
|
||||
<span class="font-semibold" :style="{ color: progressColor(patient.metricas.taxa_tarefas) }">
|
||||
{{ patient.metricas.taxa_tarefas }}%
|
||||
</span>
|
||||
</div>
|
||||
<ProgressBar
|
||||
:value="patient.metricas.taxa_tarefas"
|
||||
:showValue="false"
|
||||
:class="`progress-${progressSeverity(patient.metricas.taxa_tarefas)}`"
|
||||
style="height: 8px; border-radius: 99px;"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Score + info -->
|
||||
<div class="flex items-center gap-4 p-4 rounded-xl bg-[var(--surface-section)] border border-[var(--surface-border)]">
|
||||
<div class="flex flex-col items-center shrink-0">
|
||||
<span
|
||||
class="text-4xl font-black leading-none"
|
||||
:class="scoreClass(patient.metricas.engajamento_score)"
|
||||
>{{ patient.metricas.engajamento_score }}</span>
|
||||
<span class="text-[0.7rem] text-[var(--text-color-secondary)] mt-1 uppercase tracking-wide">Score</span>
|
||||
</div>
|
||||
<div class="flex-1 flex flex-col gap-1 text-[0.85rem]">
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="pi pi-clock text-[var(--text-color-secondary)]" />
|
||||
<span>{{ patient.metricas.duracao_meses }} meses em tratamento</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="pi pi-calendar text-[var(--p-primary-500)]" />
|
||||
<span>Próxima sessão: <strong>{{ patient.metricas.proxima_sessao }}</strong></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ─── Linha do tempo (full width) ─────────── -->
|
||||
<Card class="shadow-none border border-[var(--surface-border)] mt-4">
|
||||
<template #title>
|
||||
<span class="text-[1rem] font-semibold flex items-center gap-2">
|
||||
<i class="pi pi-history text-[var(--p-primary-500)]" />
|
||||
Linha do tempo
|
||||
</span>
|
||||
</template>
|
||||
<template #content>
|
||||
<Timeline :value="timeline" class="customized-timeline">
|
||||
<template #marker="{ item }">
|
||||
<span
|
||||
class="flex items-center justify-center w-8 h-8 rounded-full text-white text-[0.8rem] shadow"
|
||||
:style="timelineMarkerStyle(item.cor)"
|
||||
>
|
||||
<i :class="timelineIcon(item.tipo)" />
|
||||
</span>
|
||||
</template>
|
||||
<template #content="{ item }">
|
||||
<div class="pb-5">
|
||||
<div class="flex flex-wrap items-baseline gap-x-3 gap-y-0.5 mb-0.5">
|
||||
<span class="font-semibold text-[0.92rem]">{{ item.titulo }}</span>
|
||||
<span class="text-[0.78rem] text-[var(--text-color-secondary)]">{{ item.data }}</span>
|
||||
<span v-if="item.autor" class="text-[0.78rem] text-[var(--text-color-secondary)]">· {{ item.autor }}</span>
|
||||
</div>
|
||||
<p v-if="item.descricao" class="text-[0.85rem] text-[var(--text-color-secondary)] mt-0.5 m-0">
|
||||
{{ item.descricao }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
</Timeline>
|
||||
</template>
|
||||
</Card>
|
||||
|
||||
</TabPanel>
|
||||
|
||||
<!-- ══ Aba: Prontuário ════════════════════════ -->
|
||||
<TabPanel>
|
||||
<template #header>
|
||||
<span class="flex items-center gap-2">
|
||||
<i class="pi pi-clipboard" />
|
||||
Prontuário
|
||||
</span>
|
||||
</template>
|
||||
<div class="flex flex-col items-center justify-center py-20 text-[var(--text-color-secondary)] gap-3">
|
||||
<i class="pi pi-clipboard text-5xl opacity-30" />
|
||||
<span class="text-[1rem]">Prontuário — em breve</span>
|
||||
</div>
|
||||
</TabPanel>
|
||||
|
||||
<!-- ══ Aba: Agenda ════════════════════════════ -->
|
||||
<TabPanel>
|
||||
<template #header>
|
||||
<span class="flex items-center gap-2">
|
||||
<i class="pi pi-calendar" />
|
||||
Agenda
|
||||
</span>
|
||||
</template>
|
||||
<div class="flex flex-col items-center justify-center py-20 text-[var(--text-color-secondary)] gap-3">
|
||||
<i class="pi pi-calendar text-5xl opacity-30" />
|
||||
<span class="text-[1rem]">Agenda — em breve</span>
|
||||
</div>
|
||||
</TabPanel>
|
||||
|
||||
<!-- ══ Aba: Financeiro ════════════════════════ -->
|
||||
<TabPanel>
|
||||
<template #header>
|
||||
<span class="flex items-center gap-2">
|
||||
<i class="pi pi-wallet" />
|
||||
Financeiro
|
||||
</span>
|
||||
</template>
|
||||
<div class="flex flex-col items-center justify-center py-20 text-[var(--text-color-secondary)] gap-3">
|
||||
<i class="pi pi-wallet text-5xl opacity-30" />
|
||||
<span class="text-[1rem]">Financeiro — em breve</span>
|
||||
</div>
|
||||
</TabPanel>
|
||||
|
||||
<!-- ══ Aba: Documentos ════════════════════════ -->
|
||||
<TabPanel>
|
||||
<template #header>
|
||||
<span class="flex items-center gap-2">
|
||||
<i class="pi pi-folder" />
|
||||
Documentos
|
||||
</span>
|
||||
</template>
|
||||
<div class="flex flex-col items-center justify-center py-20 text-[var(--text-color-secondary)] gap-3">
|
||||
<i class="pi pi-folder text-5xl opacity-30" />
|
||||
<span class="text-[1rem]">Documentos — em breve</span>
|
||||
</div>
|
||||
</TabPanel>
|
||||
|
||||
</TabView>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* ProgressBar color overrides via severity class */
|
||||
:deep(.progress-success .p-progressbar-value) {
|
||||
background: var(--p-green-500) !important;
|
||||
}
|
||||
:deep(.progress-warning .p-progressbar-value) {
|
||||
background: var(--p-yellow-500) !important;
|
||||
}
|
||||
:deep(.progress-danger .p-progressbar-value) {
|
||||
background: var(--p-red-500) !important;
|
||||
}
|
||||
|
||||
/* Timeline connector line */
|
||||
:deep(.p-timeline-event-connector) {
|
||||
background: var(--surface-border);
|
||||
}
|
||||
|
||||
/* Remove TabView shadow */
|
||||
:deep(.p-tabview .p-tabview-panels) {
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,971 @@
|
||||
<!--
|
||||
|--------------------------------------------------------------------------
|
||||
| Agência PSI
|
||||
|--------------------------------------------------------------------------
|
||||
| Criado e desenvolvido por Leonardo Nohama
|
||||
|
|
||||
| Tecnologia aplicada à escuta.
|
||||
| Estrutura para o cuidado.
|
||||
|
|
||||
| Arquivo: src/features/patients/medicos/MedicosPage.vue
|
||||
| Data: 2026
|
||||
| Local: São Carlos/SP — Brasil
|
||||
|--------------------------------------------------------------------------
|
||||
| © 2026 — Todos os direitos reservados
|
||||
|--------------------------------------------------------------------------
|
||||
-->
|
||||
<script setup>
|
||||
import { useConfirm } from 'primevue/useconfirm';
|
||||
import { useToast } from 'primevue/usetoast';
|
||||
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
import Checkbox from 'primevue/checkbox';
|
||||
import Menu from 'primevue/menu';
|
||||
|
||||
import {
|
||||
listMedicosWithPatientCounts,
|
||||
createMedico,
|
||||
updateMedico,
|
||||
deleteMedico,
|
||||
fetchPatientsByMedicoNome
|
||||
} from '@/services/Medicos.service.js';
|
||||
|
||||
import PatientCadastroDialog from '@/components/ui/PatientCadastroDialog.vue';
|
||||
|
||||
const router = useRouter();
|
||||
const toast = useToast();
|
||||
const confirm = useConfirm();
|
||||
|
||||
// ── Hero sticky ───────────────────────────────────────────
|
||||
const headerEl = ref(null);
|
||||
const headerSentinelRef = ref(null);
|
||||
const headerStuck = ref(false);
|
||||
let _observer = null;
|
||||
|
||||
// ── Mobile ────────────────────────────────────────────────
|
||||
const mobileMenuRef = ref(null);
|
||||
const searchDlgOpen = ref(false);
|
||||
|
||||
const mobileMenuItems = computed(() => [
|
||||
{ label: 'Adicionar médico', icon: 'pi pi-plus', command: () => openCreate() },
|
||||
{ label: 'Buscar', icon: 'pi pi-search', command: () => { searchDlgOpen.value = true; } },
|
||||
{ separator: true },
|
||||
...(selectedMedicos.value?.length
|
||||
? [{ label: 'Excluir selecionados', icon: 'pi pi-trash', command: () => confirmDeleteSelected() }, { separator: true }]
|
||||
: []),
|
||||
{ label: 'Recarregar', icon: 'pi pi-refresh', command: () => fetchAll() }
|
||||
]);
|
||||
|
||||
const dt = ref(null);
|
||||
const loading = ref(false);
|
||||
const hasLoaded = ref(false);
|
||||
const medicos = ref([]);
|
||||
const selectedMedicos = ref([]);
|
||||
|
||||
const filters = ref({ global: { value: null, matchMode: 'contains' } });
|
||||
|
||||
// ── Especialidades ────────────────────────────────────────
|
||||
const especialidadesOpts = [
|
||||
{ label: 'Psiquiatria', value: 'Psiquiatria' },
|
||||
{ label: 'Neurologia', value: 'Neurologia' },
|
||||
{ label: 'Neuropsiquiatria infantil', value: 'Neuropsiquiatria infantil' },
|
||||
{ label: 'Clínica geral', value: 'Clínica geral' },
|
||||
{ label: 'Pediatria', value: 'Pediatria' },
|
||||
{ label: 'Geriatria', value: 'Geriatria' },
|
||||
{ label: 'Endocrinologia', value: 'Endocrinologia' },
|
||||
{ label: 'Psicologia (encaminhador)', value: 'Psicologia (encaminhador)' },
|
||||
{ label: 'Assistência social', value: 'Assistência social' },
|
||||
{ label: 'Fonoaudiologia', value: 'Fonoaudiologia' },
|
||||
{ label: 'Terapia ocupacional', value: 'Terapia ocupacional' },
|
||||
{ label: 'Fisioterapia', value: 'Fisioterapia' },
|
||||
{ label: 'Outra', value: '__outra__' },
|
||||
];
|
||||
|
||||
// ── Quick-stats ───────────────────────────────────────────
|
||||
const quickStats = computed(() => {
|
||||
const all = medicos.value || [];
|
||||
const comPacs = cards.value.length;
|
||||
const totalPacs = all.reduce((s, m) => s + Number(m.patients_count ?? 0), 0);
|
||||
const especialidades = new Set(all.map((m) => m.especialidade).filter(Boolean)).size;
|
||||
return [
|
||||
{ label: 'Total de médicos', value: all.length, cls: '' },
|
||||
{ label: 'Especialidades', value: especialidades, cls: '' },
|
||||
{ label: 'Com pacientes', value: comPacs, cls: comPacs > 0 ? 'qs-ok' : '' },
|
||||
{ label: 'Total encaminhados', value: totalPacs, cls: totalPacs > 0 ? 'qs-ok' : '' }
|
||||
];
|
||||
});
|
||||
|
||||
// ── Dialog Criar/Editar ──────────────────────────────────
|
||||
const dlg = reactive({
|
||||
open: false,
|
||||
mode: 'create', // 'create' | 'edit'
|
||||
id: '',
|
||||
nome: '',
|
||||
crm: '',
|
||||
especialidade: '',
|
||||
especialidade_outra: '',
|
||||
telefone_profissional: '',
|
||||
telefone_pessoal: '',
|
||||
email: '',
|
||||
clinica: '',
|
||||
cidade: '',
|
||||
estado: 'SP',
|
||||
observacoes: '',
|
||||
saving: false,
|
||||
error: ''
|
||||
});
|
||||
|
||||
const especialidadeFinal = computed(() =>
|
||||
dlg.especialidade === '__outra__'
|
||||
? (dlg.especialidade_outra.trim() || null)
|
||||
: (dlg.especialidade || null)
|
||||
);
|
||||
|
||||
// ── Dialog pacientes ──────────────────────────────────────
|
||||
const patientsDialog = reactive({ open: false, loading: false, error: '', medico: null, items: [], search: '' });
|
||||
|
||||
// ── Cards painel lateral ──────────────────────────────────
|
||||
const cards = computed(() =>
|
||||
(medicos.value || [])
|
||||
.filter((m) => Number(m.patients_count ?? 0) > 0)
|
||||
.sort((a, b) => Number(b.patients_count ?? 0) - Number(a.patients_count ?? 0))
|
||||
);
|
||||
|
||||
const patientsDialogFiltered = computed(() => {
|
||||
const s = String(patientsDialog.search || '').trim().toLowerCase();
|
||||
if (!s) return patientsDialog.items || [];
|
||||
return (patientsDialog.items || []).filter(
|
||||
(p) =>
|
||||
String(p.full_name || '').toLowerCase().includes(s) ||
|
||||
String(p.email || '').toLowerCase().includes(s) ||
|
||||
String(p.phone || '').toLowerCase().includes(s)
|
||||
);
|
||||
});
|
||||
|
||||
function patientsLabel(n) {
|
||||
return n === 1 ? '1 paciente' : `${n} pacientes`;
|
||||
}
|
||||
|
||||
function humanizeError(err) {
|
||||
const msg = err?.message || err?.error_description || String(err) || 'Erro inesperado.';
|
||||
const code = err?.code;
|
||||
if (code === '23505' || /duplicate key value/i.test(msg)) return 'Já existe um médico com este CRM.';
|
||||
return msg;
|
||||
}
|
||||
|
||||
// ── Fetch ─────────────────────────────────────────────────
|
||||
async function fetchAll() {
|
||||
loading.value = true;
|
||||
try {
|
||||
medicos.value = await listMedicosWithPatientCounts();
|
||||
} catch (err) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: humanizeError(err), life: 3500 });
|
||||
} finally {
|
||||
loading.value = false;
|
||||
hasLoaded.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Seleção ───────────────────────────────────────────────
|
||||
function isSelected(row) {
|
||||
return (selectedMedicos.value || []).some((s) => s.id === row.id);
|
||||
}
|
||||
function toggleRowSelection(row, checked) {
|
||||
const sel = selectedMedicos.value || [];
|
||||
selectedMedicos.value = checked
|
||||
? (sel.some((s) => s.id === row.id) ? sel : [...sel, row])
|
||||
: sel.filter((s) => s.id !== row.id);
|
||||
}
|
||||
|
||||
// ── CRUD ──────────────────────────────────────────────────
|
||||
function openCreate() {
|
||||
dlg.open = true;
|
||||
dlg.mode = 'create';
|
||||
dlg.id = '';
|
||||
dlg.nome = '';
|
||||
dlg.crm = '';
|
||||
dlg.especialidade = '';
|
||||
dlg.especialidade_outra = '';
|
||||
dlg.telefone_profissional = '';
|
||||
dlg.telefone_pessoal = '';
|
||||
dlg.email = '';
|
||||
dlg.clinica = '';
|
||||
dlg.cidade = '';
|
||||
dlg.estado = 'SP';
|
||||
dlg.observacoes = '';
|
||||
dlg.error = '';
|
||||
}
|
||||
|
||||
function openEdit(row) {
|
||||
dlg.open = true;
|
||||
dlg.mode = 'edit';
|
||||
dlg.id = row.id;
|
||||
dlg.nome = row.nome || '';
|
||||
dlg.crm = row.crm || '';
|
||||
dlg.especialidade = row.especialidade || '';
|
||||
dlg.especialidade_outra = '';
|
||||
dlg.telefone_profissional = fmtPhone(row.telefone_profissional);
|
||||
dlg.telefone_pessoal = fmtPhone(row.telefone_pessoal);
|
||||
dlg.email = row.email || '';
|
||||
dlg.clinica = row.clinica || '';
|
||||
dlg.cidade = row.cidade || '';
|
||||
dlg.estado = row.estado || 'SP';
|
||||
dlg.observacoes = row.observacoes || '';
|
||||
dlg.error = '';
|
||||
}
|
||||
|
||||
async function saveDialog() {
|
||||
const nome = String(dlg.nome || '').trim();
|
||||
if (!nome) {
|
||||
dlg.error = 'Informe o nome do médico.';
|
||||
return;
|
||||
}
|
||||
if (dlg.especialidade === '__outra__' && !dlg.especialidade_outra.trim()) {
|
||||
dlg.error = 'Informe a especialidade.';
|
||||
return;
|
||||
}
|
||||
|
||||
dlg.saving = true;
|
||||
dlg.error = '';
|
||||
|
||||
const payload = {
|
||||
nome,
|
||||
crm: dlg.crm.trim() || null,
|
||||
especialidade: especialidadeFinal.value,
|
||||
telefone_profissional: dlg.telefone_profissional ? digitsOnly(dlg.telefone_profissional) : null,
|
||||
telefone_pessoal: dlg.telefone_pessoal ? digitsOnly(dlg.telefone_pessoal) : null,
|
||||
email: dlg.email.trim() || null,
|
||||
clinica: dlg.clinica.trim() || null,
|
||||
cidade: dlg.cidade.trim() || null,
|
||||
estado: dlg.estado.trim() || null,
|
||||
observacoes: dlg.observacoes.trim() || null
|
||||
};
|
||||
|
||||
try {
|
||||
if (dlg.mode === 'create') {
|
||||
await createMedico(payload);
|
||||
toast.add({ severity: 'success', summary: 'Sucesso', detail: 'Médico cadastrado.', life: 2500 });
|
||||
} else {
|
||||
await updateMedico(dlg.id, payload);
|
||||
toast.add({ severity: 'success', summary: 'Sucesso', detail: 'Médico atualizado.', life: 2500 });
|
||||
}
|
||||
dlg.open = false;
|
||||
await fetchAll();
|
||||
} catch (err) {
|
||||
dlg.error = humanizeError(err);
|
||||
} finally {
|
||||
dlg.saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDeleteOne(row) {
|
||||
confirm.require({
|
||||
message: `Desativar "Dr(a). ${row.nome}"? O registro será ocultado da listagem.`,
|
||||
header: 'Desativar médico',
|
||||
icon: 'pi pi-exclamation-triangle',
|
||||
acceptClass: 'p-button-danger',
|
||||
acceptLabel: 'Desativar',
|
||||
rejectLabel: 'Cancelar',
|
||||
accept: async () => {
|
||||
try {
|
||||
await deleteMedico(row.id);
|
||||
toast.add({ severity: 'success', summary: 'Sucesso', detail: 'Médico desativado.', life: 2500 });
|
||||
await fetchAll();
|
||||
} catch (err) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: humanizeError(err), life: 3500 });
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function confirmDeleteSelected() {
|
||||
const sel = selectedMedicos.value || [];
|
||||
if (!sel.length) return;
|
||||
confirm.require({
|
||||
message: `Desativar ${sel.length} médico(s)?`,
|
||||
header: 'Desativar selecionados',
|
||||
icon: 'pi pi-exclamation-triangle',
|
||||
acceptClass: 'p-button-danger',
|
||||
acceptLabel: 'Desativar',
|
||||
rejectLabel: 'Cancelar',
|
||||
accept: async () => {
|
||||
try {
|
||||
for (const m of sel) await deleteMedico(m.id);
|
||||
selectedMedicos.value = [];
|
||||
toast.add({ severity: 'success', summary: 'Sucesso', detail: 'Médicos desativados.', life: 2500 });
|
||||
await fetchAll();
|
||||
} catch (err) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: humanizeError(err), life: 3500 });
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────
|
||||
function initials(name) {
|
||||
const parts = String(name || '').trim().split(/\s+/).filter(Boolean);
|
||||
if (!parts.length) return '—';
|
||||
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
|
||||
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
|
||||
}
|
||||
|
||||
function digitsOnly(v) {
|
||||
return String(v ?? '').replace(/\D/g, '');
|
||||
}
|
||||
|
||||
function fmtPhone(v) {
|
||||
const d = String(v ?? '').replace(/\D/g, '');
|
||||
if (!d) return '';
|
||||
if (d.length === 11) return `(${d.slice(0, 2)}) ${d.slice(2, 7)}-${d.slice(7)}`;
|
||||
if (d.length === 10) return `(${d.slice(0, 2)}) ${d.slice(2, 6)}-${d.slice(6)}`;
|
||||
return d;
|
||||
}
|
||||
|
||||
function fmtPhoneDash(v) {
|
||||
const d = String(v ?? '').replace(/\D/g, '');
|
||||
if (!d) return '—';
|
||||
if (d.length === 11) return `(${d.slice(0, 2)}) ${d.slice(2, 7)}-${d.slice(7)}`;
|
||||
if (d.length === 10) return `(${d.slice(0, 2)}) ${d.slice(2, 6)}-${d.slice(6)}`;
|
||||
return d;
|
||||
}
|
||||
|
||||
// ── Modal pacientes ───────────────────────────────────────
|
||||
async function openMedicoPatientsModal(medicoRow) {
|
||||
patientsDialog.open = true;
|
||||
patientsDialog.loading = true;
|
||||
patientsDialog.error = '';
|
||||
patientsDialog.medico = medicoRow;
|
||||
patientsDialog.items = [];
|
||||
patientsDialog.search = '';
|
||||
try {
|
||||
patientsDialog.items = await fetchPatientsByMedicoNome(medicoRow.nome);
|
||||
} catch (err) {
|
||||
patientsDialog.error = humanizeError(err);
|
||||
} finally {
|
||||
patientsDialog.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
const editPatientId = ref(null);
|
||||
const editPatientDialog = ref(false);
|
||||
function abrirPaciente(patient) {
|
||||
if (!patient?.id) return;
|
||||
editPatientId.value = String(patient.id);
|
||||
editPatientDialog.value = true;
|
||||
}
|
||||
watch(editPatientDialog, (isOpen) => {
|
||||
if (!isOpen) editPatientId.value = null;
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
const rootMargin = `${document.querySelector('.l2-main') ? '0px' : '-56px'} 0px 0px 0px`;
|
||||
_observer = new IntersectionObserver(
|
||||
([entry]) => { headerStuck.value = !entry.isIntersecting; },
|
||||
{ threshold: 0, rootMargin }
|
||||
);
|
||||
if (headerSentinelRef.value) _observer.observe(headerSentinelRef.value);
|
||||
fetchAll();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => { _observer?.disconnect(); });
|
||||
|
||||
const HIGHLIGHT_MS = 24 * 60 * 60 * 1000;
|
||||
function isRecent(row) {
|
||||
if (!row?.created_at) return false;
|
||||
return Date.now() - new Date(row.created_at).getTime() < HIGHLIGHT_MS;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PatientCadastroDialog v-model="editPatientDialog" :patient-id="editPatientId" />
|
||||
|
||||
<!-- Sentinel -->
|
||||
<div ref="headerSentinelRef" class="h-px" />
|
||||
|
||||
<!-- ══════════════════════════════════════════════════════
|
||||
HERO sticky
|
||||
═══════════════════════════════════════════════════════ -->
|
||||
<section
|
||||
ref="headerEl"
|
||||
class="sticky mx-3 md:mx-4 mb-3 z-20 overflow-hidden rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] px-3 py-2.5 transition-[border-radius] duration-200"
|
||||
:class="{ 'rounded-tl-none rounded-tr-none': headerStuck }"
|
||||
:style="{ top: 'var(--layout-sticky-top, 56px)' }"
|
||||
>
|
||||
<!-- Blobs -->
|
||||
<div class="absolute inset-0 pointer-events-none overflow-hidden" aria-hidden="true">
|
||||
<div class="absolute w-64 h-64 -top-16 -right-8 rounded-full blur-[60px] bg-teal-400/10" />
|
||||
<div class="absolute w-72 h-72 top-0 -left-16 rounded-full blur-[60px] bg-indigo-500/[0.09]" />
|
||||
</div>
|
||||
|
||||
<div class="relative z-1 flex items-center gap-3">
|
||||
<!-- Brand -->
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<div class="grid place-items-center w-9 h-9 rounded-md shrink-0 bg-teal-500/10 text-teal-600">
|
||||
<i class="pi pi-heart text-base" />
|
||||
</div>
|
||||
<div class="min-w-0 hidden lg:block">
|
||||
<div class="text-[1rem] font-bold tracking-tight text-[var(--text-color)]">Médicos & Referências</div>
|
||||
<div class="text-[1rem] text-[var(--text-color-secondary)]">Gerencie os profissionais de referência que encaminham seus pacientes</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Busca desktop -->
|
||||
<div class="hidden xl:flex flex-1 min-w-0 mx-2">
|
||||
<div class="w-64">
|
||||
<FloatLabel variant="on">
|
||||
<IconField class="w-full">
|
||||
<InputIcon class="pi pi-search" />
|
||||
<InputText id="medSearch" v-model="filters.global.value" class="w-full" :disabled="loading" />
|
||||
</IconField>
|
||||
<label for="medSearch">Buscar médico...</label>
|
||||
</FloatLabel>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Ações desktop -->
|
||||
<div class="hidden xl:flex items-center gap-1 shrink-0">
|
||||
<Button v-if="selectedMedicos?.length" label="Desativar selecionados" icon="pi pi-trash" severity="danger" outlined class="rounded-full" @click="confirmDeleteSelected" />
|
||||
<Button label="Novo médico" icon="pi pi-plus" class="rounded-full" @click="openCreate" />
|
||||
<Button icon="pi pi-refresh" severity="secondary" outlined class="h-9 w-9 rounded-full" :loading="loading" @click="fetchAll" />
|
||||
</div>
|
||||
|
||||
<!-- Mobile -->
|
||||
<div class="flex xl:hidden items-center gap-1 shrink-0 ml-auto">
|
||||
<Button icon="pi pi-search" severity="secondary" outlined class="h-9 w-9 rounded-full" @click="searchDlgOpen = true" />
|
||||
<Button icon="pi pi-plus" class="h-9 w-9 rounded-full" @click="openCreate" />
|
||||
<Button label="Ações" icon="pi pi-ellipsis-v" severity="secondary" size="small" class="rounded-full" @click="(e) => mobileMenuRef.toggle(e)" />
|
||||
<Menu ref="mobileMenuRef" :model="mobileMenuItems" :popup="true" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Dialog busca mobile -->
|
||||
<Dialog v-model:visible="searchDlgOpen" modal :draggable="false" pt:mask:class="backdrop-blur-xs" header="Buscar médico" class="w-[94vw] max-w-sm">
|
||||
<div class="pt-1">
|
||||
<InputGroup>
|
||||
<InputGroupAddon><i class="pi pi-search" /></InputGroupAddon>
|
||||
<InputText v-model="filters.global.value" placeholder="Nome, CRM, especialidade..." autofocus />
|
||||
<Button v-if="filters.global.value" icon="pi pi-times" severity="secondary" @click="filters.global.value = null" />
|
||||
</InputGroup>
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button label="Fechar" severity="secondary" outlined class="rounded-full" @click="searchDlgOpen = false" />
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
<!-- ══════════════════════════════════════════════════════
|
||||
QUICK-STATS
|
||||
═══════════════════════════════════════════════════════ -->
|
||||
<div class="flex flex-wrap gap-2 px-3 md:px-4 mb-3">
|
||||
<template v-if="loading">
|
||||
<Skeleton v-for="n in 4" :key="n" height="3.5rem" class="flex-1 min-w-[80px] rounded-md" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<div
|
||||
v-for="s in quickStats"
|
||||
:key="s.label"
|
||||
class="flex flex-col gap-0.5 px-4 py-2.5 rounded-md border flex-1 min-w-[80px] transition-colors duration-150"
|
||||
:class="{
|
||||
'border-green-500/25 bg-green-500/5': s.cls === 'qs-ok',
|
||||
'border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)]': !s.cls
|
||||
}"
|
||||
>
|
||||
<div
|
||||
class="text-[1.35rem] font-bold leading-none"
|
||||
:class="{
|
||||
'text-green-500': s.cls === 'qs-ok',
|
||||
'text-[var(--text-color)]': !s.cls
|
||||
}"
|
||||
>
|
||||
{{ s.value }}
|
||||
</div>
|
||||
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-75 whitespace-nowrap">{{ s.label }}</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- ══════════════════════════════════════════════════════
|
||||
CONTEÚDO: tabela (esq.) + painel lateral (dir.)
|
||||
═══════════════════════════════════════════════════════ -->
|
||||
<div class="flex flex-col lg:flex-row gap-3 px-3 md:px-4 pb-5">
|
||||
<!-- ── TABELA ──────────────────────────────────────── -->
|
||||
<div class="w-full lg:flex-1 min-w-0">
|
||||
<div class="rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] overflow-hidden">
|
||||
<!-- Cabeçalho da seção -->
|
||||
<div class="flex items-center justify-between px-4 py-3 border-b border-[var(--surface-border,#e2e8f0)]">
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="pi pi-table text-[var(--text-color-secondary)] opacity-60" />
|
||||
<span class="font-semibold text-[1rem]">Lista de médicos</span>
|
||||
</div>
|
||||
<span class="inline-flex items-center justify-center min-w-[22px] h-[22px] px-1.5 rounded-full bg-teal-500 text-white text-[1rem] font-bold">
|
||||
{{ medicos.length }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
ref="dt"
|
||||
v-model:selection="selectedMedicos"
|
||||
:value="medicos"
|
||||
dataKey="id"
|
||||
:loading="loading"
|
||||
paginator
|
||||
:rows="10"
|
||||
:rowsPerPageOptions="[5, 10, 25]"
|
||||
stripedRows
|
||||
responsiveLayout="scroll"
|
||||
:filters="filters"
|
||||
filterDisplay="menu"
|
||||
paginatorTemplate="FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink CurrentPageReport RowsPerPageDropdown"
|
||||
currentPageReportTemplate="Mostrando {first} a {last} de {totalRecords} médicos"
|
||||
class="med-datatable"
|
||||
:rowClass="(r) => (isRecent(r) ? 'row-new-highlight' : '')"
|
||||
>
|
||||
<!-- Seleção -->
|
||||
<Column selectionMode="multiple" style="width: 3rem" :exportable="false">
|
||||
<template #body="{ data }">
|
||||
<Checkbox :binary="true" :modelValue="isSelected(data)" @update:modelValue="toggleRowSelection(data, $event)" />
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column field="nome" header="Nome" sortable style="min-width: 14rem">
|
||||
<template #body="{ data }">
|
||||
<div class="flex items-center gap-2.5">
|
||||
<div class="w-8 h-8 rounded-full bg-teal-100 flex items-center justify-center font-black text-[0.7rem] text-teal-700 shrink-0">
|
||||
{{ initials(data.nome) }}
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<div class="font-medium truncate">Dr(a). {{ data.nome }}</div>
|
||||
<div v-if="data.crm" class="text-[0.72rem] text-[var(--text-color-secondary)]">CRM {{ data.crm }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column field="especialidade" header="Especialidade" sortable style="min-width: 10rem">
|
||||
<template #body="{ data }">
|
||||
<Tag v-if="data.especialidade" :value="data.especialidade" severity="info" />
|
||||
<span v-else class="text-[var(--text-color-secondary)] opacity-50">—</span>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column header="Contato" style="min-width: 10rem">
|
||||
<template #body="{ data }">
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<div v-if="data.telefone_profissional" class="flex items-center gap-1 text-[0.78rem]">
|
||||
<i class="pi pi-phone text-[0.65rem] text-teal-500" />
|
||||
<span>{{ fmtPhoneDash(data.telefone_profissional) }}</span>
|
||||
</div>
|
||||
<div v-if="data.email" class="flex items-center gap-1 text-[0.78rem] text-[var(--text-color-secondary)]">
|
||||
<i class="pi pi-envelope text-[0.65rem]" />
|
||||
<span class="truncate max-w-[160px]">{{ data.email }}</span>
|
||||
</div>
|
||||
<span v-if="!data.telefone_profissional && !data.email" class="text-[var(--text-color-secondary)] opacity-50">—</span>
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column header="Local" style="min-width: 9rem">
|
||||
<template #body="{ data }">
|
||||
<div class="flex flex-col gap-0.5 text-[0.78rem]">
|
||||
<div v-if="data.clinica" class="font-medium truncate max-w-[160px]">{{ data.clinica }}</div>
|
||||
<div v-if="data.cidade" class="text-[var(--text-color-secondary)]">
|
||||
{{ data.cidade }}<template v-if="data.estado">/{{ data.estado }}</template>
|
||||
</div>
|
||||
<span v-if="!data.clinica && !data.cidade" class="text-[var(--text-color-secondary)] opacity-50">—</span>
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column header="Pacientes" sortable sortField="patients_count" style="min-width: 8rem">
|
||||
<template #body="{ data }">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="font-semibold text-[var(--text-color)]">{{ Number(data.patients_count ?? 0) }}</span>
|
||||
<span class="text-[var(--text-color-secondary)] opacity-60 text-[0.73rem]">paciente(s)</span>
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column :exportable="false" header="Ações" style="width: 10rem">
|
||||
<template #body="{ data }">
|
||||
<div class="flex gap-1.5 justify-end">
|
||||
<Button icon="pi pi-pencil" severity="secondary" outlined rounded size="small" v-tooltip.top="'Editar'" @click="openEdit(data)" />
|
||||
<Button icon="pi pi-trash" severity="danger" outlined rounded size="small" v-tooltip.top="'Desativar'" @click="confirmDeleteOne(data)" />
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<template #empty>
|
||||
<div class="py-10 text-center">
|
||||
<div class="mx-auto mb-3 grid h-12 w-12 place-items-center rounded-md bg-teal-500/10 text-teal-600">
|
||||
<i class="pi pi-search text-xl" />
|
||||
</div>
|
||||
<div class="font-semibold text-[var(--text-color)]">Nenhum médico encontrado</div>
|
||||
<div class="mt-1 text-[1rem] text-[var(--text-color-secondary)]">Tente limpar o filtro ou cadastre um novo médico.</div>
|
||||
<div class="mt-4 flex justify-center gap-2">
|
||||
<Button severity="secondary" outlined icon="pi pi-filter-slash" label="Limpar filtro" @click="filters.global.value = null" />
|
||||
<Button icon="pi pi-plus" label="Novo médico" @click="openCreate" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</DataTable>
|
||||
</div>
|
||||
<LoadedPhraseBlock v-if="hasLoaded" class="mt-3" />
|
||||
</div>
|
||||
|
||||
<!-- ── PAINEL LATERAL: médicos com pacientes ─────────── -->
|
||||
<div class="w-full lg:w-[272px] lg:shrink-0">
|
||||
<div class="rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] overflow-hidden">
|
||||
<!-- Header do painel -->
|
||||
<div class="flex items-center gap-2.5 px-3.5 pt-3 pb-2.5 border-b border-[var(--surface-border,#f1f5f9)]">
|
||||
<div class="w-8 h-8 rounded-md flex items-center justify-center shrink-0 bg-teal-500/10 text-teal-600">
|
||||
<i class="pi pi-users text-[0.9rem]" />
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<span class="block text-[1rem] font-bold text-[var(--text-color)]">Pacientes por médico</span>
|
||||
<span class="block text-[0.72rem] text-[var(--text-color-secondary)]">Médicos com encaminhamentos</span>
|
||||
</div>
|
||||
<span v-if="cards.length" class="inline-flex items-center justify-center min-w-[20px] h-5 px-1 rounded-full bg-teal-500 text-white text-[0.65rem] font-bold shrink-0">{{ cards.length }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Skeleton -->
|
||||
<div v-if="loading" class="flex flex-col gap-2 p-3">
|
||||
<Skeleton v-for="n in 4" :key="n" height="2.75rem" class="rounded-md" />
|
||||
</div>
|
||||
|
||||
<!-- Empty -->
|
||||
<div v-else-if="cards.length === 0" class="flex flex-col items-center justify-center gap-2 px-4 py-8 text-center text-[var(--text-color-secondary)]">
|
||||
<i class="pi pi-heart text-2xl opacity-20" />
|
||||
<div class="font-semibold text-[0.8rem]">Nenhum encaminhamento</div>
|
||||
<div class="text-[0.72rem] opacity-70 leading-relaxed">Quando um médico tiver pacientes encaminhados, ele aparecerá aqui.</div>
|
||||
</div>
|
||||
|
||||
<!-- Lista de médicos com pacientes -->
|
||||
<div v-else class="flex flex-col max-h-[480px] overflow-y-auto divide-y divide-[var(--surface-border,#f1f5f9)]">
|
||||
<button
|
||||
v-for="m in cards"
|
||||
:key="m.id"
|
||||
class="flex items-center gap-2.5 px-3.5 py-2.5 text-left w-full bg-transparent border-none hover:bg-[var(--surface-ground,#f8fafc)] transition-colors duration-100 cursor-pointer group"
|
||||
@click="openMedicoPatientsModal(m)"
|
||||
>
|
||||
<!-- Avatar iniciais -->
|
||||
<div class="w-7 h-7 rounded-full bg-teal-100 flex items-center justify-center font-black text-[0.6rem] text-teal-700 shrink-0 group-hover:bg-teal-200 transition-colors">
|
||||
{{ initials(m.nome) }}
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="font-semibold text-[0.8rem] truncate text-[var(--text-color)]">Dr(a). {{ m.nome }}</div>
|
||||
<div class="text-[1rem] text-[var(--text-color-secondary)]">
|
||||
{{ patientsLabel(Number(m.patients_count ?? 0)) }}
|
||||
</div>
|
||||
</div>
|
||||
<!-- Badge contagem -->
|
||||
<span class="inline-flex items-center justify-center min-w-[22px] h-[22px] px-1 rounded-full font-bold text-[0.68rem] shrink-0 bg-teal-500/10 text-teal-600">
|
||||
{{ Number(m.patients_count ?? 0) }}
|
||||
</span>
|
||||
<i class="pi pi-chevron-right text-[0.6rem] text-[var(--text-color-secondary)] opacity-30 group-hover:opacity-100 group-hover:text-teal-600 transition-all duration-150 shrink-0" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Footer hint -->
|
||||
<div v-if="cards.length" class="px-3.5 py-2 text-[1rem] text-[var(--text-color-secondary)] opacity-50 border-t border-[var(--surface-border,#f1f5f9)] text-center">
|
||||
Clique para ver os pacientes encaminhados
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ══════════════════════════════════════════════════════
|
||||
Dialog: Criar / Editar médico
|
||||
═══════════════════════════════════════════════════════ -->
|
||||
<Dialog
|
||||
v-model:visible="dlg.open"
|
||||
modal
|
||||
:draggable="false"
|
||||
:closable="!dlg.saving"
|
||||
:dismissableMask="!dlg.saving"
|
||||
maximizable
|
||||
class="w-[96vw] max-w-2xl"
|
||||
:pt="{
|
||||
header: { class: '!p-3 !rounded-t-[12px] border-b border-[var(--surface-border)] shadow-[0_1px_0_0_rgba(255,255,255,0.06)] bg-gray-100' },
|
||||
content: { class: '!p-4' },
|
||||
footer: { class: '!p-0 !rounded-b-[12px] border-t border-[var(--surface-border)] shadow-[0_1px_0_0_rgba(255,255,255,0.06)] bg-gray-100' },
|
||||
pcCloseButton: { root: { class: '!rounded-md hover:!text-red-500' } },
|
||||
pcMaximizeButton: { root: { class: '!rounded-md hover:!text-primary' } },
|
||||
}"
|
||||
pt:mask:class="backdrop-blur-xs"
|
||||
>
|
||||
<template #header>
|
||||
<div class="flex w-full items-center justify-between gap-3 px-1">
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<span class="flex items-center justify-center w-7 h-7 rounded-lg bg-teal-100 text-teal-600 text-[0.8rem] shrink-0">
|
||||
<i class="pi pi-heart" />
|
||||
</span>
|
||||
<div class="min-w-0">
|
||||
<div class="text-base font-semibold truncate">
|
||||
{{ dlg.mode === 'create' ? 'Novo médico' : `Editar — Dr(a). ${dlg.nome || ''}` }}
|
||||
</div>
|
||||
<div class="text-xs opacity-50">
|
||||
{{ dlg.mode === 'create' ? 'Cadastrar profissional de referência' : 'Atualizar dados do profissional' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="flex flex-col gap-3.5">
|
||||
<!-- Nome + CRM -->
|
||||
<div class="grid grid-cols-1 gap-3.5 sm:grid-cols-[1fr_150px]">
|
||||
<div>
|
||||
<FloatLabel variant="on">
|
||||
<IconField>
|
||||
<InputIcon class="pi pi-user" />
|
||||
<InputText id="dlg_nome" v-model="dlg.nome" class="w-full" variant="filled" :disabled="dlg.saving" @keydown.enter.prevent="saveDialog" />
|
||||
</IconField>
|
||||
<label for="dlg_nome">Nome completo *</label>
|
||||
</FloatLabel>
|
||||
</div>
|
||||
<div>
|
||||
<FloatLabel variant="on">
|
||||
<InputText id="dlg_crm" v-model="dlg.crm" class="w-full" variant="filled" :disabled="dlg.saving" />
|
||||
<label for="dlg_crm">CRM (ex: 123456/SP)</label>
|
||||
</FloatLabel>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Especialidade -->
|
||||
<div>
|
||||
<FloatLabel variant="on">
|
||||
<Select
|
||||
id="dlg_esp"
|
||||
v-model="dlg.especialidade"
|
||||
:options="especialidadesOpts"
|
||||
optionLabel="label"
|
||||
optionValue="value"
|
||||
class="w-full"
|
||||
variant="filled"
|
||||
filter
|
||||
filterPlaceholder="Buscar especialidade..."
|
||||
:disabled="dlg.saving"
|
||||
/>
|
||||
<label for="dlg_esp">Especialidade</label>
|
||||
</FloatLabel>
|
||||
</div>
|
||||
|
||||
<!-- Especialidade "Outra" -->
|
||||
<Transition
|
||||
enter-active-class="transition-all duration-150 ease-out"
|
||||
enter-from-class="opacity-0 -translate-y-1"
|
||||
leave-active-class="transition-all duration-100 ease-in"
|
||||
leave-to-class="opacity-0 -translate-y-1"
|
||||
>
|
||||
<div v-if="dlg.especialidade === '__outra__'">
|
||||
<FloatLabel variant="on">
|
||||
<InputText id="dlg_esp_outra" v-model="dlg.especialidade_outra" class="w-full" variant="filled" placeholder="Descreva a especialidade" :disabled="dlg.saving" />
|
||||
<label for="dlg_esp_outra">Qual especialidade? *</label>
|
||||
</FloatLabel>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- Divider contatos -->
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-[0.63rem] font-bold uppercase tracking-widest text-teal-500">Contatos</span>
|
||||
<div class="flex-1 h-px bg-teal-200/50" />
|
||||
</div>
|
||||
|
||||
<!-- Telefone profissional -->
|
||||
<div>
|
||||
<FloatLabel variant="on">
|
||||
<InputMask id="dlg_tel_prof" v-model="dlg.telefone_profissional" mask="(99) 99999-9999" :unmask="false" class="w-full" variant="filled" placeholder="(00) 00000-0000" :disabled="dlg.saving" />
|
||||
<label for="dlg_tel_prof">Telefone profissional</label>
|
||||
</FloatLabel>
|
||||
<div class="mt-1 text-[0.63rem] text-[var(--text-color-secondary)] opacity-60">Consultório ou clínica.</div>
|
||||
</div>
|
||||
|
||||
<!-- Telefone pessoal -->
|
||||
<div>
|
||||
<FloatLabel variant="on">
|
||||
<InputMask id="dlg_tel_pes" v-model="dlg.telefone_pessoal" mask="(99) 99999-9999" :unmask="false" class="w-full" variant="filled" placeholder="(00) 00000-0000" :disabled="dlg.saving" />
|
||||
<label for="dlg_tel_pes">Telefone pessoal / WhatsApp</label>
|
||||
</FloatLabel>
|
||||
<div class="mt-1 text-[0.63rem] text-[var(--text-color-secondary)] opacity-60">Pessoal / WhatsApp.</div>
|
||||
</div>
|
||||
|
||||
<!-- Email -->
|
||||
<div>
|
||||
<FloatLabel variant="on">
|
||||
<IconField>
|
||||
<InputIcon class="pi pi-envelope" />
|
||||
<InputText id="dlg_email" v-model="dlg.email" class="w-full" variant="filled" :disabled="dlg.saving" />
|
||||
</IconField>
|
||||
<label for="dlg_email">E-mail profissional</label>
|
||||
</FloatLabel>
|
||||
</div>
|
||||
|
||||
<!-- Divider localização -->
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-[0.63rem] font-bold uppercase tracking-widest text-teal-500">Localização</span>
|
||||
<div class="flex-1 h-px bg-teal-200/50" />
|
||||
</div>
|
||||
|
||||
<!-- Clínica -->
|
||||
<div>
|
||||
<FloatLabel variant="on">
|
||||
<IconField>
|
||||
<InputIcon class="pi pi-building" />
|
||||
<InputText id="dlg_clinica" v-model="dlg.clinica" class="w-full" variant="filled" :disabled="dlg.saving" />
|
||||
</IconField>
|
||||
<label for="dlg_clinica">Clínica / Hospital</label>
|
||||
</FloatLabel>
|
||||
</div>
|
||||
|
||||
<!-- Cidade + UF -->
|
||||
<div class="grid grid-cols-[1fr_90px] gap-3">
|
||||
<div>
|
||||
<FloatLabel variant="on">
|
||||
<IconField>
|
||||
<InputIcon class="pi pi-map-marker" />
|
||||
<InputText id="dlg_cidade" v-model="dlg.cidade" class="w-full" variant="filled" :disabled="dlg.saving" />
|
||||
</IconField>
|
||||
<label for="dlg_cidade">Cidade</label>
|
||||
</FloatLabel>
|
||||
</div>
|
||||
<div>
|
||||
<FloatLabel variant="on">
|
||||
<InputText id="dlg_uf" v-model="dlg.estado" class="w-full" variant="filled" :disabled="dlg.saving" />
|
||||
<label for="dlg_uf">UF</label>
|
||||
</FloatLabel>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Observações -->
|
||||
<div>
|
||||
<FloatLabel variant="on">
|
||||
<Textarea id="dlg_obs" v-model="dlg.observacoes" rows="2" class="w-full" variant="filled" :disabled="dlg.saving" />
|
||||
<label for="dlg_obs">Observações internas</label>
|
||||
</FloatLabel>
|
||||
<div class="mt-1 text-[0.63rem] text-[var(--text-color-secondary)] opacity-60">Ex: aceita WhatsApp, convênios atendidos, melhor horário.</div>
|
||||
</div>
|
||||
|
||||
<!-- Erro -->
|
||||
<div v-if="dlg.error" class="flex items-start gap-1.5 text-[0.82rem] text-red-500 font-medium">
|
||||
<i class="pi pi-exclamation-circle mt-0.5 shrink-0" /> {{ dlg.error }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex items-center justify-end gap-2 px-3 py-3">
|
||||
<Button label="Cancelar" severity="secondary" text class="rounded-full hover:!text-red-500" :disabled="dlg.saving" @click="dlg.open = false" />
|
||||
<Button
|
||||
:label="dlg.mode === 'create' ? 'Salvar médico' : 'Salvar alterações'"
|
||||
icon="pi pi-check"
|
||||
class="rounded-full"
|
||||
:loading="dlg.saving"
|
||||
:disabled="!String(dlg.nome || '').trim()"
|
||||
@click="saveDialog"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
<!-- ══════════════════════════════════════════════════════
|
||||
Dialog: Pacientes do médico
|
||||
═══════════════════════════════════════════════════════ -->
|
||||
<Dialog
|
||||
v-model:visible="patientsDialog.open"
|
||||
modal
|
||||
:draggable="false"
|
||||
:style="{ width: '860px', maxWidth: '95vw' }"
|
||||
:pt="{
|
||||
root: { style: 'border: 4px solid #14b8a6' },
|
||||
header: { style: 'border-bottom: 1px solid rgba(20,184,166,0.19)' }
|
||||
}"
|
||||
pt:mask:class="backdrop-blur-xs"
|
||||
>
|
||||
<template #header>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-9 h-9 rounded-lg flex items-center justify-center text-white font-bold text-base shrink-0 bg-teal-500">
|
||||
{{ initials(patientsDialog.medico?.nome) }}
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-[1rem] font-bold text-teal-600">Dr(a). {{ patientsDialog.medico?.nome }}</div>
|
||||
<div class="text-[0.72rem] text-[var(--text-color-secondary)]">
|
||||
<template v-if="patientsDialog.medico?.especialidade">{{ patientsDialog.medico.especialidade }} · </template>
|
||||
{{ patientsDialog.items.length }} paciente{{ patientsDialog.items.length !== 1 ? 's' : '' }} encaminhado{{ patientsDialog.items.length !== 1 ? 's' : '' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="flex flex-col gap-3">
|
||||
<!-- Busca + contador -->
|
||||
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2">
|
||||
<IconField class="w-full sm:w-72">
|
||||
<InputIcon><i class="pi pi-search" /></InputIcon>
|
||||
<InputText v-model="patientsDialog.search" placeholder="Buscar paciente..." class="w-full" :disabled="patientsDialog.loading" />
|
||||
</IconField>
|
||||
<span v-if="!patientsDialog.loading" class="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-semibold bg-teal-500/10 text-teal-600">
|
||||
{{ patientsDialog.items.length }} paciente(s)
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Loading -->
|
||||
<div v-if="patientsDialog.loading" class="flex items-center gap-2 py-4 text-teal-600"><i class="pi pi-spin pi-spinner" /> Carregando...</div>
|
||||
|
||||
<Message v-else-if="patientsDialog.error" severity="error">{{ patientsDialog.error }}</Message>
|
||||
|
||||
<div v-else>
|
||||
<!-- Empty -->
|
||||
<div v-if="patientsDialog.items.length === 0" class="py-10 text-center">
|
||||
<div class="mx-auto mb-3 grid h-12 w-12 place-items-center rounded-md bg-teal-500/10 text-teal-600">
|
||||
<i class="pi pi-users text-xl" />
|
||||
</div>
|
||||
<div class="font-semibold">Nenhum paciente encaminhado</div>
|
||||
<div class="mt-1 text-[1rem] text-[var(--text-color-secondary)]">Associe pacientes a este médico no cadastro de pacientes.</div>
|
||||
</div>
|
||||
|
||||
<!-- Tabela -->
|
||||
<DataTable v-else :value="patientsDialogFiltered" dataKey="id" stripedRows responsiveLayout="scroll" paginator :rows="8" :rowsPerPageOptions="[8, 15, 30]">
|
||||
<Column header="Paciente" sortable>
|
||||
<template #body="{ data }">
|
||||
<div class="flex items-center gap-3">
|
||||
<Avatar v-if="data.avatar_url" :image="data.avatar_url" shape="circle" />
|
||||
<Avatar v-else :label="initials(data.full_name)" shape="circle" style="background: rgba(20,184,166,0.15); color: #14b8a6" />
|
||||
<div class="min-w-0">
|
||||
<div class="font-medium truncate">{{ data.full_name }}</div>
|
||||
<div class="text-[0.75rem] text-[var(--text-color-secondary)]">{{ data.email || '—' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column header="Telefone" style="min-width: 11rem">
|
||||
<template #body="{ data }">
|
||||
<span class="text-[var(--text-color-secondary)]">{{ fmtPhoneDash(data.phone) }}</span>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column header="Ação" style="width: 9rem">
|
||||
<template #body="{ data }">
|
||||
<Button label="Abrir" icon="pi pi-external-link" size="small" outlined class="!border-teal-500 !text-teal-600" @click="abrirPaciente(data)" />
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<template #empty>
|
||||
<div class="py-8 text-center">
|
||||
<i class="pi pi-search text-2xl opacity-20 mb-2 block" />
|
||||
<div class="font-semibold text-[1rem]">Nenhum resultado</div>
|
||||
<div class="text-[0.75rem] opacity-60 mt-1">Nenhum paciente corresponde à busca.</div>
|
||||
<Button class="mt-3" severity="secondary" outlined icon="pi pi-filter-slash" label="Limpar" size="small" @click="patientsDialog.search = ''" />
|
||||
</div>
|
||||
</template>
|
||||
</DataTable>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<Button label="Fechar" icon="pi pi-times" outlined class="rounded-full !border-teal-500 !text-teal-600" @click="patientsDialog.open = false" />
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
<ConfirmDialog />
|
||||
</template>
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,667 @@
|
||||
<!--
|
||||
|--------------------------------------------------------------------------
|
||||
| Agência PSI
|
||||
|--------------------------------------------------------------------------
|
||||
| Criado e desenvolvido por Leonardo Nohama
|
||||
|
|
||||
| Tecnologia aplicada à escuta.
|
||||
| Estrutura para o cuidado.
|
||||
|
|
||||
| Arquivo: src/features/patients/PatientsDetailPage.vue
|
||||
| Data: 2026
|
||||
| Local: São Carlos/SP — Brasil
|
||||
|--------------------------------------------------------------------------
|
||||
| © 2026 — Todos os direitos reservados
|
||||
|--------------------------------------------------------------------------
|
||||
-->
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||
|
||||
// ── DADOS MOCKADOS ──────────────────────────────────────────────
|
||||
|
||||
const patient = ref({
|
||||
nome_completo: 'Mariana Lima',
|
||||
nome_social: null,
|
||||
pronomes: 'ela/dela',
|
||||
data_nascimento: '1992-06-14',
|
||||
cpf: '12345678990',
|
||||
genero: 'Feminino',
|
||||
estado_civil: 'Solteira',
|
||||
escolaridade: 'Superior completo',
|
||||
profissao: 'Desenvolvedora',
|
||||
etnia: null,
|
||||
naturalidade: 'São Carlos',
|
||||
telefone: '16991234567',
|
||||
email_principal: 'mariana@email.com',
|
||||
canal_preferido: 'WhatsApp',
|
||||
horario_contato: '08h–18h',
|
||||
cep: '13560-000',
|
||||
cidade: 'São Carlos',
|
||||
estado: 'SP',
|
||||
status: 'Ativa',
|
||||
convenio: 'Unimed',
|
||||
patient_scope: 'Clínica',
|
||||
risco_elevado: true,
|
||||
onde_nos_conheceu: 'Indicação',
|
||||
encaminhado_por: 'Dr. Roberto (psiq.)',
|
||||
motivo_saida: null,
|
||||
avatar_url: null,
|
||||
})
|
||||
|
||||
const tags = ref([
|
||||
{ id: '1', name: 'Ansiedade', color: '#8B5CF6' },
|
||||
{ id: '2', name: 'TCC', color: '#10B981' },
|
||||
])
|
||||
|
||||
const metricas = ref({
|
||||
total_sessoes: 47,
|
||||
comparecimento_pct: 92,
|
||||
ltv_total: 8460,
|
||||
dias_ultima_sessao: 18,
|
||||
})
|
||||
|
||||
const contatos = ref([
|
||||
{
|
||||
id: '1', nome: 'Maria Lima', relacao: 'mãe',
|
||||
telefone: '16988880001', email: 'maria@email.com', is_primario: true,
|
||||
},
|
||||
{
|
||||
id: '2', nome: 'Dr. Roberto Oliveira', relacao: 'psiquiatra',
|
||||
telefone: '1633221100', email: null, crm: 'CRM 54321', is_primario: false,
|
||||
},
|
||||
])
|
||||
|
||||
const engajamento = ref({
|
||||
comparecimento_pct: 92,
|
||||
pagamentos_em_dia_pct: 100,
|
||||
tarefas_concluidas_pct: 60,
|
||||
score_geral: 84,
|
||||
em_tratamento_meses: 14,
|
||||
proxima_sessao: '2025-03-27T14:00:00',
|
||||
})
|
||||
|
||||
const timeline = ref([
|
||||
{ id: '1', titulo: 'Risco elevado sinalizado', subtitulo: 'Atenção', data: '2025-03-12', autor: 'Dra. Ana Lima', cor: '#EF4444' },
|
||||
{ id: '2', titulo: 'GAD-7 respondido · Score 12 (ansiedade moderada)', data: '2025-03-10', canal: 'via portal', cor: '#10B981' },
|
||||
{ id: '3', titulo: 'TCLE assinado digitalmente', data: '2024-01-02', canal: 'via portal', cor: '#3B82F6' },
|
||||
{ id: '4', titulo: 'Primeira sessão realizada', data: '2024-01-15', canal: 'presencial', cor: '#10B981' },
|
||||
])
|
||||
|
||||
// ── NAVEGAÇÃO ────────────────────────────────────────────────────
|
||||
const activeTab = ref('perfil')
|
||||
const tabs = [
|
||||
{ key: 'perfil', label: 'Perfil' },
|
||||
{ key: 'prontuario', label: 'Prontuário' },
|
||||
{ key: 'agenda', label: 'Agenda' },
|
||||
{ key: 'financeiro', label: 'Financeiro' },
|
||||
{ key: 'documentos', label: 'Documentos' },
|
||||
]
|
||||
|
||||
const sideNavItems = [
|
||||
{ key: 'dados', label: 'Dados pessoais', icon: 'pi pi-user' },
|
||||
{ key: 'contato', label: 'Contato & origem', icon: 'pi pi-phone' },
|
||||
{ key: 'rede', label: 'Rede de suporte', icon: 'pi pi-users' },
|
||||
{ key: 'engajamento', label: 'Engajamento', icon: 'pi pi-chart-bar' },
|
||||
{ key: 'timeline', label: 'Linha do tempo', icon: 'pi pi-history' },
|
||||
]
|
||||
const activeSideNav = ref('dados')
|
||||
|
||||
const isCompact = ref(false)
|
||||
let mql = null, mqlHandler = null
|
||||
function syncCompact() { isCompact.value = !!mql?.matches }
|
||||
onMounted(() => {
|
||||
mql = window.matchMedia('(max-width: 1023px)')
|
||||
mqlHandler = () => syncCompact()
|
||||
mql.addEventListener?.('change', mqlHandler)
|
||||
mql.addListener?.(mqlHandler)
|
||||
syncCompact()
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
mql?.removeEventListener?.('change', mqlHandler)
|
||||
mql?.removeListener?.(mqlHandler)
|
||||
})
|
||||
|
||||
function scrollToSection(key) {
|
||||
activeSideNav.value = key
|
||||
const el = document.getElementById(`section-${key}`)
|
||||
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
}
|
||||
|
||||
// ── FORMATADORES ─────────────────────────────────────────────────
|
||||
function parseDateLoose(v) {
|
||||
if (!v) return null
|
||||
const s = String(v).trim()
|
||||
if (/^\d{4}-\d{2}-\d{2}/.test(s)) {
|
||||
const d = new Date(s.slice(0, 10))
|
||||
return isNaN(d) ? null : d
|
||||
}
|
||||
const d = new Date(s)
|
||||
return isNaN(d) ? null : d
|
||||
}
|
||||
|
||||
function calcAge(v) {
|
||||
const d = parseDateLoose(v)
|
||||
if (!d) return null
|
||||
const now = new Date()
|
||||
let age = now.getFullYear() - d.getFullYear()
|
||||
const m = now.getMonth() - d.getMonth()
|
||||
if (m < 0 || (m === 0 && now.getDate() < d.getDate())) age--
|
||||
return age
|
||||
}
|
||||
|
||||
function fmtDateBR(v) {
|
||||
const d = parseDateLoose(v)
|
||||
if (!d) return '—'
|
||||
return `${String(d.getDate()).padStart(2,'0')}/${String(d.getMonth()+1).padStart(2,'0')}/${d.getFullYear()}`
|
||||
}
|
||||
|
||||
function fmtPhone(v) {
|
||||
const d = String(v ?? '').replace(/\D/g, '')
|
||||
if (d.length === 11) return `(${d.slice(0,2)}) ${d.slice(2,7)}-${d.slice(7)}`
|
||||
if (d.length === 10) return `(${d.slice(0,2)}) ${d.slice(2,6)}-${d.slice(6)}`
|
||||
return v || '—'
|
||||
}
|
||||
|
||||
function maskCPF(v) {
|
||||
if (!v) return '—'
|
||||
const d = String(v).replace(/\D/g, '')
|
||||
return `•••${d.slice(3,6)}••••${d.slice(9)}`
|
||||
}
|
||||
|
||||
function fmtCurrency(v) {
|
||||
return `R$ ${Number(v).toLocaleString('pt-BR')}`
|
||||
}
|
||||
|
||||
function fmtProximaSessao(iso) {
|
||||
if (!iso) return '—'
|
||||
const dt = new Date(iso)
|
||||
return `${String(dt.getDate()).padStart(2,'0')}/${String(dt.getMonth()+1).padStart(2,'0')} às ${String(dt.getHours()).padStart(2,'0')}h`
|
||||
}
|
||||
|
||||
const ageLabel = computed(() => calcAge(patient.value.data_nascimento))
|
||||
const birthLabel = computed(() => {
|
||||
const age = calcAge(patient.value.data_nascimento)
|
||||
return `${fmtDateBR(patient.value.data_nascimento)}${age != null ? ` (${age} a)` : ''}`
|
||||
})
|
||||
|
||||
function nameInitials(name) {
|
||||
if (!name) return '?'
|
||||
return String(name).split(' ').filter(Boolean).slice(0,2).map(w => w[0].toUpperCase()).join('')
|
||||
}
|
||||
const initials = computed(() => nameInitials(patient.value.nome_completo))
|
||||
|
||||
function hexToRgb(hex) {
|
||||
const h = String(hex ?? '').replace('#','').trim()
|
||||
if (h.length !== 6 && h.length !== 3) return null
|
||||
const full = h.length === 3 ? h.split('').map(c=>c+c).join('') : h
|
||||
return { r: parseInt(full.slice(0,2),16), g: parseInt(full.slice(2,4),16), b: parseInt(full.slice(4,6),16) }
|
||||
}
|
||||
function bestTextColor(hex) {
|
||||
const rgb = hexToRgb(hex)
|
||||
if (!rgb) return '#0f172a'
|
||||
const lum = 0.2126*(rgb.r/255) + 0.7152*(rgb.g/255) + 0.0722*(rgb.b/255)
|
||||
return lum < 0.45 ? '#ffffff' : '#0f172a'
|
||||
}
|
||||
function tagStyle(t) {
|
||||
const bg = t?.color || t?.cor || ''
|
||||
return bg ? { backgroundColor: bg, color: bestTextColor(bg) } : {}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- ── BARRA SUPERIOR ───────────────────────────────────────── -->
|
||||
<div class="sticky top-0 z-20 flex items-center justify-between
|
||||
px-4 py-2.5 bg-[var(--surface-0)]
|
||||
border-b border-[var(--surface-border)]">
|
||||
<Button icon="pi pi-arrow-left" label="Pacientes"
|
||||
severity="secondary" text size="small" />
|
||||
<div class="flex gap-2">
|
||||
<Button label="Editar" outlined size="small" />
|
||||
<Button label="+ Sessão" size="small" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── LAYOUT PRINCIPAL ─────────────────────────────────────── -->
|
||||
<div class="min-h-screen bg-[var(--surface-ground)]">
|
||||
<div class="max-w-6xl mx-auto px-4 py-5">
|
||||
<div class="grid grid-cols-1 lg:grid-cols-[220px_1fr] gap-4 items-start">
|
||||
|
||||
<!-- ════════════════════════════════════════════════
|
||||
SIDEBAR ESQUERDA
|
||||
════════════════════════════════════════════════ -->
|
||||
<aside class="lg:sticky lg:top-[57px] space-y-3">
|
||||
|
||||
<!-- Bloco avatar + nome + badges + métricas -->
|
||||
<div class="rounded-xl border border-[var(--surface-border)]
|
||||
bg-[var(--surface-card)] p-4 shadow-sm">
|
||||
|
||||
<div class="flex flex-col items-center text-center gap-2.5">
|
||||
<!-- Avatar ou iniciais -->
|
||||
<div v-if="patient.avatar_url"
|
||||
class="w-16 h-16 rounded-full overflow-hidden">
|
||||
<img :src="patient.avatar_url" class="w-full h-full object-cover" alt="avatar" />
|
||||
</div>
|
||||
<div v-else
|
||||
class="w-16 h-16 rounded-full bg-indigo-100
|
||||
flex items-center justify-center">
|
||||
<span class="text-xl font-bold text-indigo-700 tracking-tight">{{ initials }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Nome e sub-linha -->
|
||||
<div>
|
||||
<p class="text-sm font-bold text-[var(--text-color)] leading-tight">
|
||||
{{ patient.nome_completo }}
|
||||
</p>
|
||||
<p class="text-xs text-[var(--text-color-secondary)] mt-0.5">
|
||||
{{ ageLabel }} anos · {{ patient.pronomes }}
|
||||
</p>
|
||||
<p class="text-xs text-[var(--text-color-secondary)]">
|
||||
{{ patient.naturalidade }}, {{ patient.estado }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Status + convenio + scope -->
|
||||
<div class="flex flex-wrap justify-center gap-1">
|
||||
<Tag value="Ativa" severity="success" class="!text-[0.7rem]" />
|
||||
<Tag :value="patient.convenio" severity="info" class="!text-[0.7rem]" />
|
||||
<Tag :value="patient.patient_scope" severity="secondary" class="!text-[0.7rem]" />
|
||||
</div>
|
||||
|
||||
<!-- Tags com cor -->
|
||||
<div class="flex flex-wrap justify-center gap-1">
|
||||
<span v-for="tag in tags" :key="tag.id"
|
||||
class="inline-flex items-center px-2 py-0.5 rounded-full text-[0.7rem] font-medium"
|
||||
:style="tagStyle(tag)">
|
||||
{{ tag.name }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Divider class="!my-3" />
|
||||
|
||||
<!-- Métricas 2x2 -->
|
||||
<div class="grid grid-cols-2 gap-3 text-center">
|
||||
<div>
|
||||
<p class="text-xl font-bold text-[var(--text-color)]">{{ metricas.total_sessoes }}</p>
|
||||
<p class="text-[0.68rem] text-[var(--text-color-secondary)] mt-0.5">Sessões</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xl font-bold text-emerald-600">{{ metricas.comparecimento_pct }}%</p>
|
||||
<p class="text-[0.68rem] text-[var(--text-color-secondary)] mt-0.5">Comparec.</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-base font-bold text-[var(--text-color)]">{{ fmtCurrency(metricas.ltv_total) }}</p>
|
||||
<p class="text-[0.68rem] text-[var(--text-color-secondary)] mt-0.5">LTV total</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xl font-bold text-amber-500">{{ metricas.dias_ultima_sessao }}d</p>
|
||||
<p class="text-[0.68rem] text-[var(--text-color-secondary)] mt-0.5">Últ. sessão</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Nav lateral (desktop + aba perfil) -->
|
||||
<div v-if="!isCompact && activeTab === 'perfil'"
|
||||
class="rounded-xl border border-[var(--surface-border)]
|
||||
bg-[var(--surface-card)] p-2 shadow-sm">
|
||||
<button
|
||||
v-for="item in sideNavItems" :key="item.key"
|
||||
type="button"
|
||||
class="flex w-full items-center gap-2.5 rounded-lg px-3 py-2
|
||||
text-left text-sm border transition-colors duration-100"
|
||||
:class="activeSideNav === item.key
|
||||
? 'bg-indigo-50 border-indigo-200 text-indigo-700 font-semibold'
|
||||
: 'border-transparent text-[var(--text-color)] hover:bg-[var(--surface-ground)] font-medium'"
|
||||
@click="scrollToSection(item.key)"
|
||||
>
|
||||
<i :class="item.icon" class="text-sm opacity-60 shrink-0" />
|
||||
<span>{{ item.label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</aside>
|
||||
|
||||
<!-- ════════════════════════════════════════════════
|
||||
CONTEÚDO DIREITA
|
||||
════════════════════════════════════════════════ -->
|
||||
<div class="min-w-0 space-y-4">
|
||||
|
||||
<!-- Banner risco elevado -->
|
||||
<div v-if="patient.risco_elevado"
|
||||
class="flex items-start gap-3 rounded-xl border border-red-200
|
||||
bg-red-50 px-4 py-3">
|
||||
<i class="pi pi-circle-fill text-red-500 mt-0.5 text-[0.5rem]" />
|
||||
<div>
|
||||
<p class="text-sm font-semibold text-red-700">
|
||||
Atenção — paciente com risco elevado sinalizado
|
||||
</p>
|
||||
<p class="text-xs text-red-500 mt-0.5">
|
||||
Ideação passiva relatada em 12/03 · Sinalizado por Dra. Ana Lima
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── PAINEL COM TABS ─────────────────────────── -->
|
||||
<div class="rounded-xl border border-[var(--surface-border)]
|
||||
bg-[var(--surface-card)] shadow-sm overflow-hidden">
|
||||
|
||||
<!-- Tab bar -->
|
||||
<div class="flex border-b border-[var(--surface-border)] overflow-x-auto">
|
||||
<button
|
||||
v-for="tab in tabs" :key="tab.key"
|
||||
type="button"
|
||||
class="shrink-0 px-5 py-3 text-sm font-medium border-b-2
|
||||
transition-colors duration-100 whitespace-nowrap"
|
||||
:class="activeTab === tab.key
|
||||
? 'border-[var(--primary-color)] text-[var(--primary-color)]'
|
||||
: 'border-transparent text-[var(--text-color-secondary)] hover:text-[var(--text-color)]'"
|
||||
@click="activeTab = tab.key"
|
||||
>
|
||||
{{ tab.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- ════ ABA PERFIL ════════════════════════════ -->
|
||||
<div v-if="activeTab === 'perfil'" class="p-4 space-y-4">
|
||||
|
||||
<!-- DADOS PESSOAIS + CONTATO/ORIGEM -->
|
||||
<div id="section-dados" class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
|
||||
<!-- Dados pessoais -->
|
||||
<div class="rounded-xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-4">
|
||||
<p class="text-[0.65rem] font-bold uppercase tracking-widest text-[var(--text-color-secondary)] mb-3">
|
||||
DADOS PESSOAIS
|
||||
</p>
|
||||
|
||||
<div class="flex items-baseline justify-between gap-4 py-1.5 border-b border-[var(--surface-border)]">
|
||||
<span class="text-[0.82rem] text-[var(--text-color-secondary)] shrink-0">Nome completo</span>
|
||||
<span class="text-[0.82rem] text-[var(--text-color)] font-medium text-right">{{ patient.nome_completo }}</span>
|
||||
</div>
|
||||
<div class="flex items-baseline justify-between gap-4 py-1.5 border-b border-[var(--surface-border)]">
|
||||
<span class="text-[0.82rem] text-[var(--text-color-secondary)] shrink-0">Nome social</span>
|
||||
<span class="text-[0.82rem] text-[var(--text-color)] font-medium text-right flex items-center gap-1.5">
|
||||
<span class="text-[var(--text-color-secondary)]">—</span>
|
||||
<span class="text-[0.65rem] font-semibold text-amber-600 bg-amber-50 border border-amber-200 px-1.5 py-0.5 rounded-full">novo</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-baseline justify-between gap-4 py-1.5 border-b border-[var(--surface-border)]">
|
||||
<span class="text-[0.82rem] text-[var(--text-color-secondary)] shrink-0">Pronomes</span>
|
||||
<span class="text-[0.82rem] text-[var(--text-color)] font-medium text-right flex items-center gap-1.5">
|
||||
{{ patient.pronomes }}
|
||||
<span class="text-[0.65rem] font-semibold text-amber-600 bg-amber-50 border border-amber-200 px-1.5 py-0.5 rounded-full">novo</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-baseline justify-between gap-4 py-1.5 border-b border-[var(--surface-border)]">
|
||||
<span class="text-[0.82rem] text-[var(--text-color-secondary)] shrink-0">Data de nascimento</span>
|
||||
<span class="text-[0.82rem] text-[var(--text-color)] font-medium text-right">{{ birthLabel }}</span>
|
||||
</div>
|
||||
<div class="flex items-baseline justify-between gap-4 py-1.5 border-b border-[var(--surface-border)]">
|
||||
<span class="text-[0.82rem] text-[var(--text-color-secondary)] shrink-0">CPF</span>
|
||||
<span class="text-[0.82rem] text-[var(--text-color)] font-medium text-right font-mono">{{ maskCPF(patient.cpf) }}</span>
|
||||
</div>
|
||||
<div class="flex items-baseline justify-between gap-4 py-1.5 border-b border-[var(--surface-border)]">
|
||||
<span class="text-[0.82rem] text-[var(--text-color-secondary)] shrink-0">Gênero</span>
|
||||
<span class="text-[0.82rem] text-[var(--text-color)] font-medium text-right">{{ patient.genero }}</span>
|
||||
</div>
|
||||
<div class="flex items-baseline justify-between gap-4 py-1.5 border-b border-[var(--surface-border)]">
|
||||
<span class="text-[0.82rem] text-[var(--text-color-secondary)] shrink-0">Estado civil</span>
|
||||
<span class="text-[0.82rem] text-[var(--text-color)] font-medium text-right">{{ patient.estado_civil }}</span>
|
||||
</div>
|
||||
<div class="flex items-baseline justify-between gap-4 py-1.5 border-b border-[var(--surface-border)]">
|
||||
<span class="text-[0.82rem] text-[var(--text-color-secondary)] shrink-0">Escolaridade</span>
|
||||
<span class="text-[0.82rem] text-[var(--text-color)] font-medium text-right">{{ patient.escolaridade }}</span>
|
||||
</div>
|
||||
<div class="flex items-baseline justify-between gap-4 py-1.5 border-b border-[var(--surface-border)]">
|
||||
<span class="text-[0.82rem] text-[var(--text-color-secondary)] shrink-0">Profissão</span>
|
||||
<span class="text-[0.82rem] text-[var(--text-color)] font-medium text-right">{{ patient.profissao }}</span>
|
||||
</div>
|
||||
<div class="flex items-baseline justify-between gap-4 py-1.5">
|
||||
<span class="text-[0.82rem] text-[var(--text-color-secondary)] shrink-0">Etnia</span>
|
||||
<span class="text-[0.82rem] text-[var(--text-color)] font-medium text-right flex items-center gap-1.5">
|
||||
<span class="italic text-[var(--text-color-secondary)]">Não informado</span>
|
||||
<span class="text-[0.65rem] font-semibold text-amber-600 bg-amber-50 border border-amber-200 px-1.5 py-0.5 rounded-full">novo</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Coluna direita: Contato + Origem -->
|
||||
<div id="section-contato" class="space-y-4">
|
||||
|
||||
<!-- Contato -->
|
||||
<div class="rounded-xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-4">
|
||||
<p class="text-[0.65rem] font-bold uppercase tracking-widest text-[var(--text-color-secondary)] mb-3">
|
||||
CONTATO
|
||||
</p>
|
||||
<div class="flex items-baseline justify-between gap-4 py-1.5 border-b border-[var(--surface-border)]">
|
||||
<span class="text-[0.82rem] text-[var(--text-color-secondary)] shrink-0">WhatsApp</span>
|
||||
<a :href="`tel:${patient.telefone}`"
|
||||
class="text-[0.82rem] font-medium text-right text-[var(--primary-color)] hover:underline">
|
||||
{{ fmtPhone(patient.telefone) }}
|
||||
</a>
|
||||
</div>
|
||||
<div class="flex items-baseline justify-between gap-4 py-1.5 border-b border-[var(--surface-border)]">
|
||||
<span class="text-[0.82rem] text-[var(--text-color-secondary)] shrink-0">Email</span>
|
||||
<a :href="`mailto:${patient.email_principal}`"
|
||||
class="text-[0.82rem] font-medium text-right text-[var(--primary-color)] hover:underline truncate max-w-[180px] inline-block">
|
||||
{{ patient.email_principal }}
|
||||
</a>
|
||||
</div>
|
||||
<div class="flex items-baseline justify-between gap-4 py-1.5 border-b border-[var(--surface-border)]">
|
||||
<span class="text-[0.82rem] text-[var(--text-color-secondary)] shrink-0">Canal preferido</span>
|
||||
<span class="text-[0.82rem] text-[var(--text-color)] font-medium text-right flex items-center gap-1.5">
|
||||
{{ patient.canal_preferido }}
|
||||
<span class="text-[0.65rem] font-semibold text-amber-600 bg-amber-50 border border-amber-200 px-1.5 py-0.5 rounded-full">novo</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-baseline justify-between gap-4 py-1.5 border-b border-[var(--surface-border)]">
|
||||
<span class="text-[0.82rem] text-[var(--text-color-secondary)] shrink-0">Horário de contato</span>
|
||||
<span class="text-[0.82rem] text-[var(--text-color)] font-medium text-right flex items-center gap-1.5">
|
||||
{{ patient.horario_contato }}
|
||||
<span class="text-[0.65rem] font-semibold text-amber-600 bg-amber-50 border border-amber-200 px-1.5 py-0.5 rounded-full">novo</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-baseline justify-between gap-4 py-1.5">
|
||||
<span class="text-[0.82rem] text-[var(--text-color-secondary)] shrink-0">CEP</span>
|
||||
<span class="text-[0.82rem] text-[var(--text-color)] font-medium text-right">
|
||||
{{ patient.cep }} · {{ patient.cidade }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Origem -->
|
||||
<div class="rounded-xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-4">
|
||||
<p class="text-[0.65rem] font-bold uppercase tracking-widest text-[var(--text-color-secondary)] mb-3">
|
||||
ORIGEM
|
||||
</p>
|
||||
<div class="flex items-baseline justify-between gap-4 py-1.5 border-b border-[var(--surface-border)]">
|
||||
<span class="text-[0.82rem] text-[var(--text-color-secondary)] shrink-0">Como chegou</span>
|
||||
<span class="text-[0.82rem] text-[var(--text-color)] font-medium text-right">{{ patient.onde_nos_conheceu }}</span>
|
||||
</div>
|
||||
<div class="flex items-baseline justify-between gap-4 py-1.5 border-b border-[var(--surface-border)]">
|
||||
<span class="text-[0.82rem] text-[var(--text-color-secondary)] shrink-0">Encaminhado por</span>
|
||||
<span class="text-[0.82rem] text-[var(--text-color)] font-medium text-right">{{ patient.encaminhado_por }}</span>
|
||||
</div>
|
||||
<div class="flex items-baseline justify-between gap-4 py-1.5 border-b border-[var(--surface-border)]">
|
||||
<span class="text-[0.82rem] text-[var(--text-color-secondary)] shrink-0">Método de pag.</span>
|
||||
<span class="text-[0.82rem] text-[var(--text-color)] font-medium text-right flex items-center gap-1.5">
|
||||
PIX
|
||||
<span class="text-[0.65rem] font-semibold text-amber-600 bg-amber-50 border border-amber-200 px-1.5 py-0.5 rounded-full">novo</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-baseline justify-between gap-4 py-1.5">
|
||||
<span class="text-[0.82rem] text-[var(--text-color-secondary)] shrink-0">Motivo de saída</span>
|
||||
<span class="text-[0.82rem] text-[var(--text-color)] font-medium text-right flex items-center gap-1.5">
|
||||
<span class="text-[var(--text-color-secondary)]">—</span>
|
||||
<span class="text-[0.65rem] font-semibold text-amber-600 bg-amber-50 border border-amber-200 px-1.5 py-0.5 rounded-full">novo</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- REDE DE SUPORTE + ENGAJAMENTO -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
|
||||
<!-- Contatos & rede -->
|
||||
<div id="section-rede"
|
||||
class="rounded-xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-4">
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<p class="text-[0.65rem] font-bold uppercase tracking-widest text-[var(--text-color-secondary)]">
|
||||
CONTATOS & REDE DE SUPORTE
|
||||
</p>
|
||||
<span class="text-[0.65rem] font-semibold text-amber-600 bg-amber-50 border border-amber-200 px-1.5 py-0.5 rounded-full">NOVO</span>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<div v-for="c in contatos" :key="c.id"
|
||||
class="flex items-start gap-3 rounded-lg border
|
||||
border-[var(--surface-border)] p-3
|
||||
bg-[var(--surface-ground)]">
|
||||
<div class="w-8 h-8 rounded-full bg-indigo-100 flex items-center justify-center shrink-0">
|
||||
<span class="text-[0.65rem] font-bold text-indigo-700">{{ nameInitials(c.nome) }}</span>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-1.5 flex-wrap">
|
||||
<span class="text-[0.82rem] font-semibold text-[var(--text-color)]">{{ c.nome }}</span>
|
||||
<span class="text-[0.75rem] text-[var(--text-color-secondary)]">· {{ c.relacao }}</span>
|
||||
<Tag v-if="c.is_primario" value="emergência" severity="danger"
|
||||
class="!text-[0.65rem] !py-0 !px-1.5 shrink-0" />
|
||||
</div>
|
||||
<p class="text-[0.76rem] text-[var(--text-color-secondary)] mt-0.5">
|
||||
<a :href="`tel:${c.telefone}`" class="text-[var(--primary-color)] hover:underline">{{ fmtPhone(c.telefone) }}</a>
|
||||
<template v-if="c.email"> · {{ c.email }}</template>
|
||||
<template v-if="c.crm"> · {{ c.crm }}</template>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="button"
|
||||
class="mt-3 w-full flex items-center gap-2.5 px-3 py-2 rounded-lg
|
||||
border border-dashed border-[var(--surface-border)]
|
||||
text-[0.82rem] text-[var(--text-color-secondary)]
|
||||
hover:bg-[var(--surface-ground)] transition-colors">
|
||||
<span class="w-7 h-7 rounded-full bg-[var(--surface-ground)] border border-[var(--surface-border)] flex items-center justify-center">
|
||||
<i class="pi pi-plus text-[0.65rem]" />
|
||||
</span>
|
||||
Adicionar contato
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Engajamento -->
|
||||
<div id="section-engajamento"
|
||||
class="rounded-xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-4">
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<p class="text-[0.65rem] font-bold uppercase tracking-widest text-[var(--text-color-secondary)]">
|
||||
ENGAJAMENTO
|
||||
</p>
|
||||
<span class="text-[0.65rem] font-semibold text-amber-600 bg-amber-50 border border-amber-200 px-1.5 py-0.5 rounded-full">NOVO</span>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<div class="flex justify-between text-[0.78rem] mb-1.5">
|
||||
<span class="text-[var(--text-color-secondary)]">Comparecimento</span>
|
||||
<span class="font-semibold text-emerald-600">{{ engajamento.comparecimento_pct }}%</span>
|
||||
</div>
|
||||
<ProgressBar :value="engajamento.comparecimento_pct" :showValue="false" class="progress-success" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="flex justify-between text-[0.78rem] mb-1.5">
|
||||
<span class="text-[var(--text-color-secondary)]">Pagamentos em dia</span>
|
||||
<span class="font-semibold text-emerald-600">{{ engajamento.pagamentos_em_dia_pct }}%</span>
|
||||
</div>
|
||||
<ProgressBar :value="engajamento.pagamentos_em_dia_pct" :showValue="false" class="progress-success" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="flex justify-between text-[0.78rem] mb-1.5">
|
||||
<span class="text-[var(--text-color-secondary)]">Tarefas concluídas</span>
|
||||
<span class="font-semibold text-amber-500">{{ engajamento.tarefas_concluidas_pct }}%</span>
|
||||
</div>
|
||||
<ProgressBar :value="engajamento.tarefas_concluidas_pct" :showValue="false" class="progress-warning" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Divider class="!my-3" />
|
||||
|
||||
<div class="flex items-baseline justify-between gap-4 py-1.5 border-b border-[var(--surface-border)]">
|
||||
<span class="text-[0.82rem] text-[var(--text-color-secondary)] shrink-0">Score geral</span>
|
||||
<span class="text-[0.82rem] text-[var(--text-color)] font-medium text-right">
|
||||
<span class="text-lg font-bold">{{ engajamento.score_geral }}</span> / 100
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-baseline justify-between gap-4 py-1.5 border-b border-[var(--surface-border)]">
|
||||
<span class="text-[0.82rem] text-[var(--text-color-secondary)] shrink-0">Em tratamento há</span>
|
||||
<span class="text-[0.82rem] text-[var(--text-color)] font-medium text-right">{{ engajamento.em_tratamento_meses }} meses</span>
|
||||
</div>
|
||||
<div class="flex items-baseline justify-between gap-4 py-1.5">
|
||||
<span class="text-[0.82rem] text-[var(--text-color-secondary)] shrink-0">Próxima sessão</span>
|
||||
<span class="text-[0.82rem] text-[var(--text-color)] font-medium text-right">{{ fmtProximaSessao(engajamento.proxima_sessao) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- LINHA DO TEMPO -->
|
||||
<div id="section-timeline"
|
||||
class="rounded-xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-4">
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<p class="text-[0.65rem] font-bold uppercase tracking-widest text-[var(--text-color-secondary)]">
|
||||
LINHA DO TEMPO
|
||||
</p>
|
||||
<span class="text-[0.65rem] font-semibold text-amber-600 bg-amber-50 border border-amber-200 px-1.5 py-0.5 rounded-full">NOVO</span>
|
||||
</div>
|
||||
|
||||
<div class="space-y-0">
|
||||
<div v-for="(item, idx) in timeline" :key="item.id" class="flex gap-4">
|
||||
<!-- Dot + linha vertical -->
|
||||
<div class="flex flex-col items-center">
|
||||
<div class="w-3 h-3 rounded-full mt-1 shrink-0 ring-2 ring-[var(--surface-card)] shadow-sm"
|
||||
:style="{ backgroundColor: item.cor }" />
|
||||
<div v-if="idx < timeline.length - 1"
|
||||
class="w-px flex-1 bg-[var(--surface-border)] my-1" />
|
||||
</div>
|
||||
<!-- Conteúdo -->
|
||||
<div class="pb-5 min-w-0">
|
||||
<p class="text-[0.85rem] font-semibold text-[var(--text-color)] leading-snug">
|
||||
{{ item.titulo }}
|
||||
<span v-if="item.subtitulo" class="font-normal text-[var(--text-color-secondary)]"> · {{ item.subtitulo }}</span>
|
||||
</p>
|
||||
<p class="text-[0.75rem] text-[var(--text-color-secondary)] mt-0.5">
|
||||
{{ fmtDateBR(item.data) }}
|
||||
<template v-if="item.autor"> · {{ item.autor }}</template>
|
||||
<template v-else-if="item.canal"> · {{ item.canal }}</template>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<!-- ── FIM ABA PERFIL ── -->
|
||||
|
||||
<!-- Placeholder outras abas -->
|
||||
<div v-if="activeTab !== 'perfil'" class="p-10 text-center text-[var(--text-color-secondary)]">
|
||||
<i class="pi pi-clock text-4xl mb-3 block opacity-20" />
|
||||
<p class="text-sm">Em breve</p>
|
||||
</div>
|
||||
|
||||
</div><!-- /painel tabs -->
|
||||
|
||||
</div><!-- /conteúdo direita -->
|
||||
</div><!-- /grid -->
|
||||
</div><!-- /max-w -->
|
||||
</div><!-- /wrapper -->
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
:deep(.progress-success .p-progressbar-value) { background: #22c55e; }
|
||||
:deep(.progress-warning .p-progressbar-value) { background: #f59e0b; }
|
||||
:deep(.p-progressbar) {
|
||||
height: 0.45rem;
|
||||
border-radius: 9999px;
|
||||
overflow: hidden;
|
||||
}
|
||||
:deep(.p-progressbar-value) { border-radius: 9999px; }
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user