Ajuste em Massa - Paciente, Terapeuta, Clinica e Admin - Inicio agenda
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,381 @@
|
||||
<template>
|
||||
<div class="p-4">
|
||||
<!-- Top header -->
|
||||
<div class="flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="h-10 w-10 rounded-2xl bg-slate-900 text-slate-50 grid place-items-center shadow-sm">
|
||||
<i class="pi pi-link text-lg"></i>
|
||||
</div>
|
||||
|
||||
<div class="min-w-0">
|
||||
<div class="text-2xl font-semibold text-slate-900 leading-tight">
|
||||
Cadastro Externo
|
||||
</div>
|
||||
<div class="text-slate-600 mt-1">
|
||||
Gere um link para o paciente preencher o pré-cadastro com calma e segurança.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-2 justify-start md:justify-end">
|
||||
<Button
|
||||
label="Gerar novo link"
|
||||
icon="pi pi-refresh"
|
||||
severity="secondary"
|
||||
outlined
|
||||
:loading="rotating"
|
||||
@click="rotateLink"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main grid -->
|
||||
<div class="mt-5 grid grid-cols-1 lg:grid-cols-12 gap-4">
|
||||
<!-- Left: Link card -->
|
||||
<div class="lg:col-span-7">
|
||||
<div class="rounded-2xl border border-slate-200 bg-white shadow-sm overflow-hidden">
|
||||
<!-- Card head -->
|
||||
<div class="p-5 border-b border-slate-200">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<div class="text-lg font-semibold text-slate-900">Seu link</div>
|
||||
<div class="text-slate-600 text-sm mt-1">
|
||||
Envie este link ao paciente. Ele abre a página de cadastro externo.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hidden md:flex items-center gap-2">
|
||||
<span
|
||||
class="inline-flex items-center gap-2 text-xs px-2.5 py-1 rounded-full border"
|
||||
:class="inviteToken ? 'border-emerald-200 text-emerald-700 bg-emerald-50' : 'border-slate-200 text-slate-600 bg-slate-50'"
|
||||
>
|
||||
<span
|
||||
class="h-2 w-2 rounded-full"
|
||||
:class="inviteToken ? 'bg-emerald-500' : 'bg-slate-400'"
|
||||
></span>
|
||||
{{ inviteToken ? 'Ativo' : 'Gerando...' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Card content -->
|
||||
<div class="p-5">
|
||||
<!-- Skeleton while loading -->
|
||||
<div v-if="!inviteToken" class="space-y-3">
|
||||
<div class="h-10 rounded-xl bg-slate-100 animate-pulse"></div>
|
||||
<div class="h-10 rounded-xl bg-slate-100 animate-pulse"></div>
|
||||
<Message severity="info" :closable="false">
|
||||
Gerando seu link...
|
||||
</Message>
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-4">
|
||||
<!-- Link display + quick actions -->
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="text-sm font-medium text-slate-700">Link público</label>
|
||||
|
||||
<div class="flex flex-col gap-2 sm:flex-row sm:items-stretch">
|
||||
<div class="flex-1 min-w-0">
|
||||
<InputText
|
||||
readonly
|
||||
:value="publicUrl"
|
||||
class="w-full"
|
||||
/>
|
||||
<div class="mt-1 text-xs text-slate-500 break-words">
|
||||
Token: <span class="font-mono">{{ inviteToken }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 sm:flex-col sm:w-[140px]">
|
||||
<Button
|
||||
class="w-full"
|
||||
icon="pi pi-copy"
|
||||
label="Copiar"
|
||||
severity="secondary"
|
||||
outlined
|
||||
@click="copyLink"
|
||||
/>
|
||||
<Button
|
||||
class="w-full"
|
||||
icon="pi pi-external-link"
|
||||
label="Abrir"
|
||||
severity="secondary"
|
||||
outlined
|
||||
@click="openLink"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Big CTA -->
|
||||
<div class="rounded-2xl border border-slate-200 bg-slate-50 p-4">
|
||||
<div class="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<div class="min-w-0">
|
||||
<div class="font-semibold text-slate-900">Envio rápido</div>
|
||||
<div class="text-sm text-slate-600 mt-1">
|
||||
Copie e mande por WhatsApp / e-mail. O paciente preenche e você recebe o cadastro no sistema.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
icon="pi pi-copy"
|
||||
label="Copiar link agora"
|
||||
class="md:shrink-0"
|
||||
@click="copyLink"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Safety note -->
|
||||
<Message severity="warn" :closable="false">
|
||||
<b>Dica:</b> ao gerar um novo link, o anterior deve deixar de funcionar. Use isso quando você quiser “revogar” um link que já foi compartilhado.
|
||||
</Message>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right: Concept / Instructions -->
|
||||
<div class="lg:col-span-5">
|
||||
<div class="rounded-2xl border border-slate-200 bg-white shadow-sm overflow-hidden">
|
||||
<div class="p-5 border-b border-slate-200">
|
||||
<div class="text-lg font-semibold text-slate-900">Como funciona</div>
|
||||
<div class="text-slate-600 text-sm mt-1">
|
||||
Um fluxo simples, mas com cuidado clínico: menos fricção, mais adesão.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-5">
|
||||
<ol class="space-y-4">
|
||||
<li class="flex gap-3">
|
||||
<div class="h-8 w-8 rounded-xl bg-slate-900 text-slate-50 grid place-items-center text-sm font-semibold">1</div>
|
||||
<div class="min-w-0">
|
||||
<div class="font-semibold text-slate-900">Você envia o link</div>
|
||||
<div class="text-sm text-slate-600 mt-1">
|
||||
Pode ser WhatsApp, e-mail ou mensagem direta. O link abre a página de cadastro externo.
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li class="flex gap-3">
|
||||
<div class="h-8 w-8 rounded-xl bg-slate-900 text-slate-50 grid place-items-center text-sm font-semibold">2</div>
|
||||
<div class="min-w-0">
|
||||
<div class="font-semibold text-slate-900">O paciente preenche</div>
|
||||
<div class="text-sm text-slate-600 mt-1">
|
||||
Campos opcionais podem ser deixados em branco. A ideia é reduzir ansiedade e acelerar o início.
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li class="flex gap-3">
|
||||
<div class="h-8 w-8 rounded-xl bg-slate-900 text-slate-50 grid place-items-center text-sm font-semibold">3</div>
|
||||
<div class="min-w-0">
|
||||
<div class="font-semibold text-slate-900">Você recebe no admin</div>
|
||||
<div class="text-sm text-slate-600 mt-1">
|
||||
Os dados entram como “cadastro recebido”. Você revisa, completa e transforma em paciente quando quiser.
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<div class="mt-6 rounded-2xl border border-slate-200 bg-slate-50 p-4">
|
||||
<div class="font-semibold text-slate-900 flex items-center gap-2">
|
||||
<i class="pi pi-shield text-slate-700"></i>
|
||||
Boas práticas
|
||||
</div>
|
||||
<ul class="mt-2 space-y-2 text-sm text-slate-700">
|
||||
<li class="flex gap-2">
|
||||
<i class="pi pi-check text-emerald-600 mt-0.5"></i>
|
||||
<span>Gere um novo link se você suspeitar que ele foi repassado indevidamente.</span>
|
||||
</li>
|
||||
<li class="flex gap-2">
|
||||
<i class="pi pi-check text-emerald-600 mt-0.5"></i>
|
||||
<span>Envie junto uma mensagem curta: “preencha com calma; campos opcionais podem ficar em branco”.</span>
|
||||
</li>
|
||||
<li class="flex gap-2">
|
||||
<i class="pi pi-check text-emerald-600 mt-0.5"></i>
|
||||
<span>Evite divulgar em público; é um link pensado para compartilhamento individual.</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 text-xs text-slate-500">
|
||||
Se você quiser, eu deixo este card ainda mais “noir” (contraste, microtextos, ícones, sombras) sem perder legibilidade.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Small helper card -->
|
||||
<div class="mt-4 rounded-2xl border border-slate-200 bg-white shadow-sm p-5">
|
||||
<div class="font-semibold text-slate-900">Mensagem pronta (copiar/colar)</div>
|
||||
<div class="text-sm text-slate-600 mt-1">
|
||||
Se quiser, use este texto ao enviar o link:
|
||||
</div>
|
||||
|
||||
<div class="mt-3 rounded-xl bg-slate-50 border border-slate-200 p-3 text-sm text-slate-800">
|
||||
Olá! Segue o link para seu pré-cadastro. Preencha com calma — campos opcionais podem ficar em branco:
|
||||
<span class="block mt-2 font-mono break-words">{{ publicUrl || '…' }}</span>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 flex gap-2">
|
||||
<Button
|
||||
icon="pi pi-copy"
|
||||
label="Copiar mensagem"
|
||||
severity="secondary"
|
||||
outlined
|
||||
:disabled="!publicUrl"
|
||||
@click="copyInviteMessage"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Toast is global in layout usually; if not, add <Toast /> -->
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import Button from 'primevue/button'
|
||||
import Card from 'primevue/card'
|
||||
import InputText from 'primevue/inputtext'
|
||||
import Message from 'primevue/message'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { supabase } from '@/lib/supabase/client'
|
||||
|
||||
const toast = useToast()
|
||||
|
||||
const inviteToken = ref('')
|
||||
const rotating = ref(false)
|
||||
|
||||
/**
|
||||
* Se o cadastro externo estiver em outro domínio, fixe aqui:
|
||||
* ex.: const PUBLIC_BASE_URL = 'https://seusite.com'
|
||||
* se vazio, usa window.location.origin
|
||||
*/
|
||||
const PUBLIC_BASE_URL = '' // opcional
|
||||
|
||||
const origin = computed(() => {
|
||||
if (PUBLIC_BASE_URL) return PUBLIC_BASE_URL
|
||||
return typeof window !== 'undefined' ? window.location.origin : ''
|
||||
})
|
||||
|
||||
const publicUrl = computed(() => {
|
||||
if (!inviteToken.value) return ''
|
||||
return `${origin.value}/cadastro/paciente?t=${encodeURIComponent(inviteToken.value)}`
|
||||
})
|
||||
|
||||
function newToken () {
|
||||
if (globalThis.crypto?.randomUUID) return globalThis.crypto.randomUUID()
|
||||
return 'tok_' + Math.random().toString(36).slice(2) + Date.now().toString(36)
|
||||
}
|
||||
|
||||
async function requireUserId () {
|
||||
const { data, error } = await supabase.auth.getUser()
|
||||
if (error) throw error
|
||||
const uid = data?.user?.id
|
||||
if (!uid) throw new Error('Usuário não autenticado')
|
||||
return uid
|
||||
}
|
||||
|
||||
async function loadOrCreateInvite () {
|
||||
const uid = await requireUserId()
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('patient_invites')
|
||||
.select('token, active')
|
||||
.eq('owner_id', uid)
|
||||
.eq('active', true)
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(1)
|
||||
|
||||
if (error) throw error
|
||||
|
||||
const token = data?.[0]?.token
|
||||
if (token) {
|
||||
inviteToken.value = token
|
||||
return
|
||||
}
|
||||
|
||||
const t = newToken()
|
||||
const { error: insErr } = await supabase
|
||||
.from('patient_invites')
|
||||
.insert({ owner_id: uid, token: t, active: true })
|
||||
|
||||
if (insErr) throw insErr
|
||||
inviteToken.value = t
|
||||
}
|
||||
|
||||
async function rotateLink () {
|
||||
rotating.value = true
|
||||
try {
|
||||
const uid = await requireUserId()
|
||||
const t = newToken()
|
||||
|
||||
// tenta RPC primeiro
|
||||
const rpc = await supabase.rpc('rotate_patient_invite_token', { p_new_token: t })
|
||||
if (rpc.error) {
|
||||
// fallback: desativa todos os ativos e cria um novo
|
||||
const { error: e1 } = await supabase
|
||||
.from('patient_invites')
|
||||
.update({ active: false, updated_at: new Date().toISOString() })
|
||||
.eq('owner_id', uid)
|
||||
.eq('active', true)
|
||||
if (e1) throw e1
|
||||
|
||||
const { error: e2 } = await supabase
|
||||
.from('patient_invites')
|
||||
.insert({ owner_id: uid, token: t, active: true })
|
||||
if (e2) throw e2
|
||||
}
|
||||
|
||||
inviteToken.value = t
|
||||
toast.add({ severity: 'success', summary: 'Pronto', detail: 'Novo link gerado.', life: 2000 })
|
||||
} catch (err) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: err?.message || 'Falha ao gerar novo link.', life: 3500 })
|
||||
} finally {
|
||||
rotating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function copyLink () {
|
||||
try {
|
||||
if (!publicUrl.value) return
|
||||
await navigator.clipboard.writeText(publicUrl.value)
|
||||
toast.add({ severity: 'success', summary: 'Copiado', detail: 'Link copiado.', life: 1500 })
|
||||
} catch {
|
||||
// fallback clássico
|
||||
window.prompt('Copie o link:', publicUrl.value)
|
||||
}
|
||||
}
|
||||
|
||||
function openLink () {
|
||||
if (!publicUrl.value) return
|
||||
window.open(publicUrl.value, '_blank', 'noopener')
|
||||
}
|
||||
|
||||
async function copyInviteMessage () {
|
||||
try {
|
||||
if (!publicUrl.value) return
|
||||
const msg =
|
||||
`Olá! Segue o link para seu pré-cadastro. Preencha com calma — campos opcionais podem ficar em branco:
|
||||
${publicUrl.value}`
|
||||
await navigator.clipboard.writeText(msg)
|
||||
toast.add({ severity: 'success', summary: 'Copiado', detail: 'Mensagem copiada.', life: 1500 })
|
||||
} catch {
|
||||
toast.add({ severity: 'warn', summary: 'Atenção', detail: 'Não foi possível copiar automaticamente.', life: 2500 })
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
await loadOrCreateInvite()
|
||||
} catch (err) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: err?.message || 'Falha ao carregar link.', life: 3500 })
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,882 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { supabase } from '@/lib/supabase/client'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { useConfirm } from 'primevue/useconfirm'
|
||||
|
||||
import DataTable from 'primevue/datatable'
|
||||
import Column from 'primevue/column'
|
||||
import Button from 'primevue/button'
|
||||
import Dialog from 'primevue/dialog'
|
||||
import Tag from 'primevue/tag'
|
||||
import InputText from 'primevue/inputtext'
|
||||
import ConfirmDialog from 'primevue/confirmdialog'
|
||||
import ProgressSpinner from 'primevue/progressspinner'
|
||||
import Textarea from 'primevue/textarea'
|
||||
import Avatar from 'primevue/avatar'
|
||||
|
||||
import { brToISO, isoToBR } from '@/utils/dateBR'
|
||||
|
||||
const toast = useToast()
|
||||
const confirm = useConfirm()
|
||||
|
||||
const converting = ref(false)
|
||||
const loading = ref(false)
|
||||
const rows = ref([])
|
||||
const q = ref('')
|
||||
|
||||
const dlg = ref({
|
||||
open: false,
|
||||
saving: false,
|
||||
mode: 'view',
|
||||
item: null,
|
||||
reject_note: ''
|
||||
})
|
||||
|
||||
function statusSeverity (s) {
|
||||
if (s === 'new') return 'info'
|
||||
if (s === 'converted') return 'success'
|
||||
if (s === 'rejected') return 'danger'
|
||||
return 'secondary'
|
||||
}
|
||||
|
||||
function statusLabel (s) {
|
||||
if (s === 'new') return 'Novo'
|
||||
if (s === 'converted') return 'Convertido'
|
||||
if (s === 'rejected') return 'Rejeitado'
|
||||
return s || '—'
|
||||
}
|
||||
|
||||
// -----------------------------
|
||||
// Helpers de campo: PT primeiro, fallback EN
|
||||
// -----------------------------
|
||||
function pickField (obj, keys) {
|
||||
for (const k of keys) {
|
||||
const v = obj?.[k]
|
||||
if (v !== undefined && v !== null && String(v).trim() !== '') return v
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const fNome = (i) => pickField(i, ['nome_completo', 'name'])
|
||||
const fEmail = (i) => pickField(i, ['email_principal', 'email'])
|
||||
const fEmailAlt = (i) => pickField(i, ['email_alternativo', 'email_alt'])
|
||||
const fTel = (i) => pickField(i, ['telefone', 'phone'])
|
||||
const fTelAlt = (i) => pickField(i, ['telefone_alternativo', 'phone_alt'])
|
||||
|
||||
const fNasc = (i) => pickField(i, ['data_nascimento', 'birth_date'])
|
||||
const fGenero = (i) => pickField(i, ['genero', 'gender'])
|
||||
const fEstadoCivil = (i) => pickField(i, ['estado_civil', 'marital_status'])
|
||||
const fProf = (i) => pickField(i, ['profissao', 'profession'])
|
||||
const fNacionalidade = (i) => pickField(i, ['nacionalidade', 'nationality'])
|
||||
const fNaturalidade = (i) => pickField(i, ['naturalidade', 'place_of_birth'])
|
||||
|
||||
const fEscolaridade = (i) => pickField(i, ['escolaridade', 'education_level'])
|
||||
const fOndeConheceu = (i) => pickField(i, ['onde_nos_conheceu', 'lead_source'])
|
||||
const fEncaminhado = (i) => pickField(i, ['encaminhado_por', 'referred_by'])
|
||||
|
||||
const fCep = (i) => pickField(i, ['cep'])
|
||||
const fEndereco = (i) => pickField(i, ['endereco', 'address_street'])
|
||||
const fNumero = (i) => pickField(i, ['numero', 'address_number'])
|
||||
const fComplemento = (i) => pickField(i, ['complemento', 'address_complement'])
|
||||
const fBairro = (i) => pickField(i, ['bairro', 'address_neighborhood'])
|
||||
const fCidade = (i) => pickField(i, ['cidade', 'address_city'])
|
||||
const fEstado = (i) => pickField(i, ['estado', 'address_state'])
|
||||
const fPais = (i) => pickField(i, ['pais', 'country']) || 'Brasil'
|
||||
|
||||
const fObs = (i) => pickField(i, ['observacoes', 'notes_short'])
|
||||
const fNotas = (i) => pickField(i, ['notas_internas', 'notes'])
|
||||
|
||||
// -----------------------------
|
||||
// Filtro
|
||||
// -----------------------------
|
||||
const statusFilter = ref('')
|
||||
|
||||
function toggleStatusFilter (s) {
|
||||
statusFilter.value = (statusFilter.value === s) ? '' : s
|
||||
}
|
||||
|
||||
const filteredRows = computed(() => {
|
||||
const term = String(q.value || '').trim().toLowerCase()
|
||||
let list = rows.value || []
|
||||
|
||||
if (statusFilter.value) {
|
||||
list = list.filter(r => r.status === statusFilter.value)
|
||||
}
|
||||
|
||||
if (!term) return list
|
||||
|
||||
return list.filter(r => {
|
||||
const nome = String(fNome(r) || '').toLowerCase()
|
||||
const email = String(fEmail(r) || '').toLowerCase()
|
||||
const tel = String(fTel(r) || '').toLowerCase()
|
||||
return nome.includes(term) || email.includes(term) || tel.includes(term)
|
||||
})
|
||||
})
|
||||
|
||||
// -----------------------------
|
||||
// Avatar
|
||||
// -----------------------------
|
||||
const AVATAR_BUCKET = 'avatars'
|
||||
|
||||
function firstNonEmpty (...vals) {
|
||||
for (const v of vals) {
|
||||
const s = String(v ?? '').trim()
|
||||
if (s) return s
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
function looksLikeUrl (s) {
|
||||
return /^https?:\/\//i.test(String(s || ''))
|
||||
}
|
||||
|
||||
function getAvatarUrlFromItem (i) {
|
||||
const p = i?.payload || i?.data || i?.form || null
|
||||
|
||||
const direct = firstNonEmpty(
|
||||
i?.avatar_url, i?.foto_url, i?.photo_url,
|
||||
p?.avatar_url, p?.foto_url, p?.photo_url
|
||||
)
|
||||
|
||||
if (direct && looksLikeUrl(direct)) return direct
|
||||
|
||||
const path = firstNonEmpty(
|
||||
i?.avatar_path, i?.photo_path, i?.foto_path, i?.avatar_file_path,
|
||||
p?.avatar_path, p?.photo_path, p?.foto_path, p?.avatar_file_path,
|
||||
direct
|
||||
)
|
||||
|
||||
if (!path) return null
|
||||
if (looksLikeUrl(path)) return path
|
||||
|
||||
const { data } = supabase.storage.from(AVATAR_BUCKET).getPublicUrl(path)
|
||||
return data?.publicUrl || null
|
||||
}
|
||||
|
||||
// cache simples pra não recalcular 2x por linha (render)
|
||||
const avatarCache = new Map()
|
||||
function avatarUrl (row) {
|
||||
const id = row?.id
|
||||
if (!id) return getAvatarUrlFromItem(row)
|
||||
if (avatarCache.has(id)) return avatarCache.get(id)
|
||||
const url = getAvatarUrlFromItem(row)
|
||||
avatarCache.set(id, url)
|
||||
return url
|
||||
}
|
||||
|
||||
const dlgAvatarUrl = computed(() => {
|
||||
const item = dlg.value?.item
|
||||
if (!item) return null
|
||||
return avatarUrl(item)
|
||||
})
|
||||
|
||||
// -----------------------------
|
||||
// Formatters
|
||||
// -----------------------------
|
||||
function dash (v) {
|
||||
const s = String(v ?? '').trim()
|
||||
return s ? s : '—'
|
||||
}
|
||||
|
||||
function onlyDigits (v) {
|
||||
return String(v ?? '').replace(/\D/g, '')
|
||||
}
|
||||
|
||||
function fmtPhoneBR (v) {
|
||||
const d = onlyDigits(v)
|
||||
if (!d) return '—'
|
||||
if (d.length === 11) return `(${d.slice(0,2)}) ${d.slice(2,7)}-${d.slice(7,11)}`
|
||||
if (d.length === 10) return `(${d.slice(0,2)}) ${d.slice(2,6)}-${d.slice(6,10)}`
|
||||
return d
|
||||
}
|
||||
|
||||
function fmtCPF (v) {
|
||||
const d = onlyDigits(v)
|
||||
if (!d) return '—'
|
||||
if (d.length !== 11) return d
|
||||
return `${d.slice(0,3)}.${d.slice(3,6)}.${d.slice(6,9)}-${d.slice(9,11)}`
|
||||
}
|
||||
|
||||
function fmtRG (v) {
|
||||
const s = String(v ?? '').trim()
|
||||
return s ? s : '—'
|
||||
}
|
||||
|
||||
// data nascimento (aceita ISO ou BR)
|
||||
function fmtBirth (v) {
|
||||
if (!v) return '—'
|
||||
const s = String(v).trim()
|
||||
|
||||
// já BR
|
||||
if (/^\d{2}-\d{2}-\d{4}$/.test(s)) return s
|
||||
|
||||
// ISO date/datetime
|
||||
if (/^\d{4}-\d{2}-\d{2}/.test(s)) {
|
||||
const iso = s.slice(0, 10)
|
||||
return isoToBR(iso) || s
|
||||
}
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
function fmtDate (iso) {
|
||||
if (!iso) return '—'
|
||||
const d = new Date(iso)
|
||||
if (Number.isNaN(d.getTime())) return String(iso)
|
||||
return d.toLocaleString('pt-BR')
|
||||
}
|
||||
|
||||
// converte nascimento para ISO date (YYYY-MM-DD) usando teu utils
|
||||
function normalizeBirthToISO (v) {
|
||||
if (!v) return null
|
||||
const s = String(v).trim()
|
||||
if (!s) return null
|
||||
|
||||
// BR -> ISO
|
||||
if (/^\d{2}-\d{2}-\d{4}$/.test(s)) return brToISO(s)
|
||||
|
||||
// ISO date/datetime
|
||||
if (/^\d{4}-\d{2}-\d{2}/.test(s)) return s.slice(0, 10)
|
||||
|
||||
// fallback: tenta Date
|
||||
const d = new Date(s)
|
||||
if (Number.isNaN(d.getTime())) return null
|
||||
const yyyy = String(d.getFullYear()).padStart(4, '0')
|
||||
const mm = String(d.getMonth() + 1).padStart(2, '0')
|
||||
const dd = String(d.getDate()).padStart(2, '0')
|
||||
return `${yyyy}-${mm}-${dd}`
|
||||
}
|
||||
|
||||
// -----------------------------
|
||||
// Seções do modal
|
||||
// -----------------------------
|
||||
const intakeSections = computed(() => {
|
||||
const i = dlg.value.item
|
||||
if (!i) return []
|
||||
|
||||
const section = (title, rows) => ({
|
||||
title,
|
||||
rows: (rows || []).filter(r => r && r.value !== undefined)
|
||||
})
|
||||
|
||||
const row = (label, value, opts = {}) => ({
|
||||
label,
|
||||
value,
|
||||
pre: !!opts.pre
|
||||
})
|
||||
|
||||
return [
|
||||
section('Identificação', [
|
||||
row('Nome completo', dash(fNome(i))),
|
||||
row('Email principal', dash(fEmail(i))),
|
||||
row('Email alternativo', dash(fEmailAlt(i))),
|
||||
row('Telefone', fmtPhoneBR(fTel(i))),
|
||||
row('Telefone alternativo', fmtPhoneBR(fTelAlt(i)))
|
||||
]),
|
||||
|
||||
section('Informações pessoais', [
|
||||
row('Data de nascimento', fmtBirth(fNasc(i))),
|
||||
row('Gênero', dash(fGenero(i))),
|
||||
row('Estado civil', dash(fEstadoCivil(i))),
|
||||
row('Profissão', dash(fProf(i))),
|
||||
row('Nacionalidade', dash(fNacionalidade(i))),
|
||||
row('Naturalidade', dash(fNaturalidade(i))),
|
||||
row('Escolaridade', dash(fEscolaridade(i))),
|
||||
row('Onde nos conheceu?', dash(fOndeConheceu(i))),
|
||||
row('Encaminhado por', dash(fEncaminhado(i)))
|
||||
]),
|
||||
|
||||
section('Documentos', [
|
||||
row('CPF', fmtCPF(i.cpf)),
|
||||
row('RG', fmtRG(i.rg))
|
||||
]),
|
||||
|
||||
section('Endereço', [
|
||||
row('CEP', dash(fCep(i))),
|
||||
row('Endereço', dash(fEndereco(i))),
|
||||
row('Número', dash(fNumero(i))),
|
||||
row('Complemento', dash(fComplemento(i))),
|
||||
row('Bairro', dash(fBairro(i))),
|
||||
row('Cidade', dash(fCidade(i))),
|
||||
row('Estado', dash(fEstado(i))),
|
||||
row('País', dash(fPais(i)))
|
||||
]),
|
||||
|
||||
section('Observações', [
|
||||
row('Observações', dash(fObs(i)), { pre: true }),
|
||||
row('Notas internas', dash(fNotas(i)), { pre: true })
|
||||
]),
|
||||
|
||||
section('Administração', [
|
||||
row('Status', statusLabel(i.status)),
|
||||
row('Consentimento', i.consent ? 'Aceito' : 'Não aceito'),
|
||||
row('Motivo da rejeição', dash(i.rejected_reason), { pre: true }),
|
||||
row('Paciente convertido (ID)', dash(i.converted_patient_id))
|
||||
]),
|
||||
|
||||
section('Metadados', [
|
||||
row('Owner ID', dash(i.owner_id)),
|
||||
row('Token', dash(i.token)),
|
||||
row('Criado em', fmtDate(i.created_at)),
|
||||
row('Atualizado em', fmtDate(i.updated_at)),
|
||||
row('ID do intake', dash(i.id))
|
||||
])
|
||||
]
|
||||
})
|
||||
|
||||
// -----------------------------
|
||||
// Fetch
|
||||
// -----------------------------
|
||||
async function fetchIntakes () {
|
||||
loading.value = true
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('patient_intake_requests')
|
||||
.select('*')
|
||||
.order('created_at', { ascending: false })
|
||||
|
||||
if (error) throw error
|
||||
|
||||
const weight = (s) => (s === 'new' ? 0 : s === 'converted' ? 1 : s === 'rejected' ? 2 : 9)
|
||||
|
||||
rows.value = (data || []).slice().sort((a, b) => {
|
||||
const wa = weight(a.status)
|
||||
const wb = weight(b.status)
|
||||
if (wa !== wb) return wa - wb
|
||||
const da = new Date(a.created_at || 0).getTime()
|
||||
const db = new Date(b.created_at || 0).getTime()
|
||||
return db - da
|
||||
})
|
||||
|
||||
avatarCache.clear()
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
toast.add({ severity: 'error', summary: 'Erro ao carregar', detail: e.message || String(e), life: 3500 })
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------
|
||||
// Dialog
|
||||
// -----------------------------
|
||||
function openDetails (row) {
|
||||
dlg.value.open = true
|
||||
dlg.value.mode = 'view'
|
||||
dlg.value.item = row
|
||||
dlg.value.reject_note = row?.rejected_reason || ''
|
||||
}
|
||||
|
||||
function closeDlg () {
|
||||
dlg.value.open = false
|
||||
dlg.value.saving = false
|
||||
dlg.value.item = null
|
||||
dlg.value.reject_note = ''
|
||||
}
|
||||
|
||||
// -----------------------------
|
||||
// Rejeitar
|
||||
// -----------------------------
|
||||
async function markRejected () {
|
||||
const item = dlg.value.item
|
||||
if (!item) return
|
||||
|
||||
confirm.require({
|
||||
message: 'Marcar este cadastro como rejeitado?',
|
||||
header: 'Confirmar rejeição',
|
||||
icon: 'pi pi-exclamation-triangle',
|
||||
acceptLabel: 'Rejeitar',
|
||||
rejectLabel: 'Cancelar',
|
||||
accept: async () => {
|
||||
dlg.value.saving = true
|
||||
try {
|
||||
const reason = String(dlg.value.reject_note || '').trim() || null
|
||||
|
||||
const { error } = await supabase
|
||||
.from('patient_intake_requests')
|
||||
.update({
|
||||
status: 'rejected',
|
||||
rejected_reason: reason,
|
||||
updated_at: new Date().toISOString()
|
||||
})
|
||||
.eq('id', item.id)
|
||||
|
||||
if (error) throw error
|
||||
|
||||
toast.add({ severity: 'success', summary: 'Rejeitado', detail: 'Solicitação rejeitada.', life: 2500 })
|
||||
await fetchIntakes()
|
||||
|
||||
const updated = rows.value.find(r => r.id === item.id)
|
||||
if (updated) openDetails(updated)
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e.message || String(e), life: 3500 })
|
||||
} finally {
|
||||
dlg.value.saving = false
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// -----------------------------
|
||||
// Converter
|
||||
// -----------------------------
|
||||
async function convertToPatient () {
|
||||
const item = dlg.value?.item
|
||||
if (!item?.id) return
|
||||
if (converting.value) return
|
||||
|
||||
// regra de negócio: só converte "new"
|
||||
if (item.status !== 'new') {
|
||||
toast.add({
|
||||
severity: 'warn',
|
||||
summary: 'Atenção',
|
||||
detail: 'Só é possível converter cadastros com status "Novo".',
|
||||
life: 3000
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
converting.value = true
|
||||
|
||||
try {
|
||||
const { data: userData, error: userErr } = await supabase.auth.getUser()
|
||||
if (userErr) throw userErr
|
||||
|
||||
const ownerId = userData?.user?.id
|
||||
if (!ownerId) throw new Error('Sessão inválida.')
|
||||
|
||||
const cleanStr = (v) => {
|
||||
const s = String(v ?? '').trim()
|
||||
return s ? s : null
|
||||
}
|
||||
const digitsOnly = (v) => {
|
||||
const d = String(v ?? '').replace(/\D/g, '')
|
||||
return d ? d : null
|
||||
}
|
||||
|
||||
// tenta reaproveitar avatar do intake (se vier url/path)
|
||||
const intakeAvatar = cleanStr(item.avatar_url) || cleanStr(item.foto_url) || cleanStr(item.photo_url) || null
|
||||
|
||||
const patientPayload = {
|
||||
owner_id: ownerId,
|
||||
|
||||
// identificação/contato
|
||||
nome_completo: cleanStr(fNome(item)),
|
||||
email_principal: cleanStr(fEmail(item))?.toLowerCase() || null,
|
||||
email_alternativo: cleanStr(fEmailAlt(item))?.toLowerCase() || null,
|
||||
|
||||
telefone: digitsOnly(fTel(item)),
|
||||
telefone_alternativo: digitsOnly(fTelAlt(item)),
|
||||
|
||||
// pessoais
|
||||
data_nascimento: normalizeBirthToISO(fNasc(item)), // ✅ agora é sempre ISO date
|
||||
naturalidade: cleanStr(fNaturalidade(item)),
|
||||
genero: cleanStr(fGenero(item)),
|
||||
estado_civil: cleanStr(fEstadoCivil(item)),
|
||||
|
||||
// docs
|
||||
cpf: digitsOnly(item.cpf),
|
||||
rg: cleanStr(item.rg),
|
||||
|
||||
// endereço (PT)
|
||||
pais: cleanStr(fPais(item)) || 'Brasil',
|
||||
cep: digitsOnly(fCep(item)),
|
||||
cidade: cleanStr(fCidade(item)),
|
||||
estado: cleanStr(fEstado(item)) || 'SP',
|
||||
endereco: cleanStr(fEndereco(item)),
|
||||
numero: cleanStr(fNumero(item)),
|
||||
bairro: cleanStr(fBairro(item)),
|
||||
complemento: cleanStr(fComplemento(item)),
|
||||
|
||||
// adicionais (PT)
|
||||
escolaridade: cleanStr(fEscolaridade(item)),
|
||||
profissao: cleanStr(fProf(item)),
|
||||
onde_nos_conheceu: cleanStr(fOndeConheceu(item)),
|
||||
encaminhado_por: cleanStr(fEncaminhado(item)),
|
||||
|
||||
// observações (PT)
|
||||
observacoes: cleanStr(fObs(item)),
|
||||
notas_internas: cleanStr(fNotas(item)),
|
||||
|
||||
// avatar
|
||||
avatar_url: intakeAvatar
|
||||
}
|
||||
|
||||
// remove undefined
|
||||
Object.keys(patientPayload).forEach(k => {
|
||||
if (patientPayload[k] === undefined) delete patientPayload[k]
|
||||
})
|
||||
|
||||
const { data: created, error: insErr } = await supabase
|
||||
.from('patients')
|
||||
.insert(patientPayload)
|
||||
.select('id')
|
||||
.single()
|
||||
|
||||
if (insErr) throw insErr
|
||||
|
||||
const patientId = created?.id
|
||||
if (!patientId) throw new Error('Falha ao obter ID do paciente criado.')
|
||||
|
||||
const { error: upErr } = await supabase
|
||||
.from('patient_intake_requests')
|
||||
.update({
|
||||
status: 'converted',
|
||||
converted_patient_id: patientId,
|
||||
updated_at: new Date().toISOString()
|
||||
})
|
||||
.eq('id', item.id)
|
||||
.eq('owner_id', ownerId)
|
||||
|
||||
if (upErr) throw upErr
|
||||
|
||||
toast.add({ severity: 'success', summary: 'Convertido', detail: 'Cadastro convertido em paciente.', life: 2500 })
|
||||
|
||||
dlg.value.open = false
|
||||
await fetchIntakes()
|
||||
} catch (err) {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: 'Falha ao converter',
|
||||
detail: err?.message || 'Não foi possível converter o cadastro.',
|
||||
life: 4500
|
||||
})
|
||||
} finally {
|
||||
converting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const totals = computed(() => {
|
||||
const all = rows.value || []
|
||||
const total = all.length
|
||||
const nNew = all.filter(r => r.status === 'new').length
|
||||
const nConv = all.filter(r => r.status === 'converted').length
|
||||
const nRej = all.filter(r => r.status === 'rejected').length
|
||||
return { total, nNew, nConv, nRej }
|
||||
})
|
||||
|
||||
onMounted(fetchIntakes)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-4">
|
||||
<ConfirmDialog />
|
||||
|
||||
<!-- HEADER -->
|
||||
<div class="mb-4 overflow-hidden rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)]">
|
||||
<div class="relative px-5 py-5">
|
||||
<!-- faixa de cor -->
|
||||
<div class="pointer-events-none absolute inset-0 opacity-80">
|
||||
<div class="absolute -top-10 -right-12 h-40 w-40 rounded-full bg-emerald-400/20 blur-3xl" />
|
||||
<div class="absolute top-10 -left-16 h-44 w-44 rounded-full bg-indigo-400/20 blur-3xl" />
|
||||
</div>
|
||||
|
||||
<div class="relative flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="grid h-11 w-11 place-items-center rounded-2xl bg-[var(--primary-color)]/10 text-[var(--primary-color)]">
|
||||
<i class="pi pi-inbox text-lg"></i>
|
||||
</div>
|
||||
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="text-xl font-semibold leading-none">Cadastros recebidos</div>
|
||||
<Tag :value="`${totals.total}`" severity="secondary" />
|
||||
</div>
|
||||
<div class="text-color-secondary mt-1">
|
||||
Solicitações de pré-cadastro (cadastro externo) para avaliar e converter.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- filtros -->
|
||||
<div class="mt-4 flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
class="!rounded-full"
|
||||
:outlined="statusFilter !== 'new'"
|
||||
:severity="statusFilter === 'new' ? 'info' : 'secondary'"
|
||||
@click="toggleStatusFilter('new')"
|
||||
>
|
||||
<span class="flex items-center gap-2">
|
||||
<i class="pi pi-sparkles" />
|
||||
Novos: <b>{{ totals.nNew }}</b>
|
||||
</span>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
class="!rounded-full"
|
||||
:outlined="statusFilter !== 'converted'"
|
||||
:severity="statusFilter === 'converted' ? 'success' : 'secondary'"
|
||||
@click="toggleStatusFilter('converted')"
|
||||
>
|
||||
<span class="flex items-center gap-2">
|
||||
<i class="pi pi-check" />
|
||||
Convertidos: <b>{{ totals.nConv }}</b>
|
||||
</span>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
class="!rounded-full"
|
||||
:outlined="statusFilter !== 'rejected'"
|
||||
:severity="statusFilter === 'rejected' ? 'danger' : 'secondary'"
|
||||
@click="toggleStatusFilter('rejected')"
|
||||
>
|
||||
<span class="flex items-center gap-2">
|
||||
<i class="pi pi-times" />
|
||||
Rejeitados: <b>{{ totals.nRej }}</b>
|
||||
</span>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
v-if="statusFilter"
|
||||
type="button"
|
||||
class="!rounded-full"
|
||||
severity="secondary"
|
||||
outlined
|
||||
icon="pi pi-filter-slash"
|
||||
label="Limpar filtro"
|
||||
@click="statusFilter = ''"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col sm:flex-row gap-2 sm:items-center">
|
||||
<span class="p-input-icon-left w-full sm:w-[360px]">
|
||||
<InputText
|
||||
v-model="q"
|
||||
class="w-full"
|
||||
placeholder="Buscar por nome, e-mail ou telefone…"
|
||||
/>
|
||||
</span>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
icon="pi pi-refresh"
|
||||
label="Atualizar"
|
||||
severity="secondary"
|
||||
outlined
|
||||
:loading="loading"
|
||||
@click="fetchIntakes"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- TABLE -->
|
||||
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] overflow-hidden">
|
||||
<div v-if="loading" class="flex items-center justify-center py-10">
|
||||
<ProgressSpinner style="width: 38px; height: 38px" />
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
v-else
|
||||
:value="filteredRows"
|
||||
dataKey="id"
|
||||
paginator
|
||||
:rows="10"
|
||||
:rowsPerPageOptions="[10, 20, 50]"
|
||||
responsiveLayout="scroll"
|
||||
stripedRows
|
||||
class="!border-0"
|
||||
>
|
||||
<Column header="Status" style="width: 10rem">
|
||||
<template #body="{ data }">
|
||||
<Tag :value="statusLabel(data.status)" :severity="statusSeverity(data.status)" />
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column header="Paciente">
|
||||
<template #body="{ data }">
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<Avatar v-if="avatarUrl(data)" :image="avatarUrl(data)" shape="circle" />
|
||||
<Avatar v-else icon="pi pi-user" shape="circle" />
|
||||
|
||||
<div class="min-w-0">
|
||||
<div class="font-medium truncate">{{ fNome(data) || '—' }}</div>
|
||||
<div class="text-color-secondary text-sm truncate">{{ fEmail(data) || '—' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column header="Contato" style="width: 14rem">
|
||||
<template #body="{ data }">
|
||||
<div class="text-sm">
|
||||
<div class="font-medium">{{ fmtPhoneBR(fTel(data)) }}</div>
|
||||
<div class="text-color-secondary">{{ fTelAlt(data) ? fmtPhoneBR(fTelAlt(data)) : '—' }}</div>
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column header="Criado em" style="width: 14rem">
|
||||
<template #body="{ data }">
|
||||
<span class="text-color-secondary">{{ fmtDate(data.created_at) }}</span>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column header="" style="width: 10rem; text-align: right">
|
||||
<template #body="{ data }">
|
||||
<Button
|
||||
icon="pi pi-eye"
|
||||
label="Ver"
|
||||
severity="secondary"
|
||||
outlined
|
||||
@click="openDetails(data)"
|
||||
/>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<template #empty>
|
||||
<div class="text-color-secondary py-6 text-center">
|
||||
Nenhum cadastro encontrado.
|
||||
</div>
|
||||
</template>
|
||||
</DataTable>
|
||||
</div>
|
||||
|
||||
<!-- MODAL -->
|
||||
<Dialog
|
||||
v-model:visible="dlg.open"
|
||||
modal
|
||||
:header="null"
|
||||
:style="{ width: 'min(940px, 96vw)' }"
|
||||
:contentStyle="{ padding: 0 }"
|
||||
@hide="closeDlg"
|
||||
>
|
||||
<div v-if="dlg.item" class="relative">
|
||||
<div class="max-h-[70vh] overflow-auto p-5 bg-[var(--surface-ground)]">
|
||||
<!-- topo -->
|
||||
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-4 mb-4">
|
||||
<div class="flex flex-col items-center text-center gap-3">
|
||||
<div class="relative">
|
||||
<div class="absolute inset-0 blur-2xl opacity-30 rounded-full bg-slate-300"></div>
|
||||
<div class="relative">
|
||||
<Avatar v-if="dlgAvatarUrl" :image="dlgAvatarUrl" alt="avatar" shape="circle" size="xlarge" />
|
||||
<Avatar v-else icon="pi pi-user" shape="circle" size="xlarge" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="min-w-0">
|
||||
<div class="text-xl font-semibold text-slate-900 truncate">
|
||||
{{ fNome(dlg.item) || '—' }}
|
||||
</div>
|
||||
<div class="text-slate-500 text-sm truncate">
|
||||
{{ fEmail(dlg.item) || '—' }} · {{ fmtPhoneBR(fTel(dlg.item)) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap justify-center gap-2">
|
||||
<Tag :value="statusLabel(dlg.item.status)" :severity="statusSeverity(dlg.item.status)" />
|
||||
<Tag
|
||||
:value="dlg.item.consent ? 'Consentimento OK' : 'Sem consentimento'"
|
||||
:severity="dlg.item.consent ? 'success' : 'danger'"
|
||||
/>
|
||||
<Tag :value="`Criado: ${fmtDate(dlg.item.created_at)}`" severity="secondary" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div
|
||||
v-for="(sec, sidx) in intakeSections"
|
||||
:key="sidx"
|
||||
class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-4"
|
||||
>
|
||||
<div class="font-semibold text-slate-900 mb-3">
|
||||
{{ sec.title }}
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div
|
||||
v-for="(r, ridx) in sec.rows"
|
||||
:key="ridx"
|
||||
class="min-w-0"
|
||||
>
|
||||
<div class="text-xs text-slate-500 mb-1">
|
||||
{{ r.label }}
|
||||
</div>
|
||||
<div
|
||||
class="text-sm text-slate-900"
|
||||
:class="r.pre ? 'whitespace-pre-wrap leading-relaxed' : 'truncate'"
|
||||
>
|
||||
{{ r.value }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- rejeição: nota -->
|
||||
<div class="mt-5 rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-4">
|
||||
<div class="flex items-center justify-between gap-2 flex-wrap">
|
||||
<div class="font-semibold text-slate-900">Rejeição</div>
|
||||
<Tag
|
||||
:value="dlg.item.status === 'rejected' ? 'Este cadastro já foi rejeitado' : 'Opcional'"
|
||||
:severity="dlg.item.status === 'rejected' ? 'danger' : 'secondary'"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mt-3">
|
||||
<label class="block text-sm text-slate-600 mb-2">Motivo (anotação interna)</label>
|
||||
<Textarea
|
||||
v-model="dlg.reject_note"
|
||||
autoResize
|
||||
rows="2"
|
||||
class="w-full"
|
||||
:disabled="dlg.saving || converting"
|
||||
placeholder="Ex.: dados incompletos, pediu para não seguir, duplicado…"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="h-24"></div>
|
||||
</div>
|
||||
|
||||
<!-- ações fixas -->
|
||||
<div class="sticky bottom-0 z-10 border-t border-[var(--surface-border)] bg-[var(--surface-card)]">
|
||||
<div class="px-5 py-4 flex flex-col sm:flex-row gap-3 sm:items-center sm:justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<Tag :value="statusLabel(dlg.item.status)" :severity="statusSeverity(dlg.item.status)" />
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 justify-end flex-wrap">
|
||||
<Button
|
||||
label="Rejeitar"
|
||||
icon="pi pi-times"
|
||||
severity="danger"
|
||||
outlined
|
||||
:disabled="dlg.saving || dlg.item.status === 'rejected' || converting"
|
||||
@click="markRejected"
|
||||
/>
|
||||
|
||||
<Button
|
||||
label="Converter"
|
||||
icon="pi pi-check"
|
||||
severity="success"
|
||||
:loading="converting"
|
||||
:disabled="dlg.item.status === 'converted' || dlg.saving || converting"
|
||||
@click="convertToPatient"
|
||||
/>
|
||||
|
||||
<Button
|
||||
label="Fechar"
|
||||
icon="pi pi-times-circle"
|
||||
severity="secondary"
|
||||
outlined
|
||||
:disabled="dlg.saving || converting"
|
||||
@click="closeDlg"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user