Ajuste em Massa - Paciente, Terapeuta, Clinica e Admin - Inicio agenda

This commit is contained in:
Leonardo
2026-02-22 17:56:01 -03:00
parent 6eff67bf22
commit 89b4ecaba1
77 changed files with 9433 additions and 1995 deletions
File diff suppressed because it is too large Load Diff
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 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>
@@ -0,0 +1,664 @@
<template>
<div class="p-4">
<!-- TOOLBAR (padrão Sakai CRUD) -->
<Toolbar class="mb-4">
<template #start>
<div class="flex flex-col">
<div class="text-xl font-semibold leading-none">Grupos de Pacientes</div>
<small class="text-color-secondary mt-1">
Organize seus pacientes por grupos. Alguns grupos são padrões do sistema e não podem ser alterados.
</small>
</div>
</template>
<template #end>
<div class="flex items-center gap-2">
<Button
label="Excluir selecionados"
icon="pi pi-trash"
severity="danger"
outlined
:disabled="!selectedGroups || !selectedGroups.length"
@click="confirmDeleteSelected"
/>
<Button label="Adicionar" icon="pi pi-plus" @click="openCreate" />
</div>
</template>
</Toolbar>
<div class="flex flex-col lg:flex-row gap-4">
<!-- LEFT: TABLE -->
<div class="w-full lg:basis-[70%] lg:max-w-[70%]">
<Card class="h-full">
<template #content>
<DataTable
ref="dt"
v-model:selection="selectedGroups"
:value="groups"
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} grupos"
>
<template #header>
<div class="flex flex-wrap gap-2 items-center justify-between">
<div class="flex items-center gap-2">
<span class="font-medium">Lista de Grupos</span>
<Tag :value="`${groups.length} grupos`" severity="secondary" />
</div>
<IconField>
<InputIcon><i class="pi pi-search" /></InputIcon>
<InputText v-model="filters.global.value" placeholder="Buscar grupos..." class="w-64" />
</IconField>
</div>
</template>
<!-- seleção (desabilita grupos do sistema) -->
<Column selectionMode="multiple" style="width: 3rem" :exportable="false">
<template #body="{ data }">
<Checkbox
:binary="true"
:disabled="!!data.is_system"
:modelValue="isSelected(data)"
@update:modelValue="toggleRowSelection(data, $event)"
/>
</template>
</Column>
<Column field="nome" header="Nome" sortable style="min-width: 16rem" />
<Column header="Origem" sortable sortField="is_system" style="min-width: 12rem">
<template #body="{ data }">
<Tag
:value="data.is_system ? 'Padrão' : 'Criado por você'"
:severity="data.is_system ? 'info' : 'success'"
/>
</template>
</Column>
<Column header="Pacientes" sortable sortField="patients_count" style="min-width: 10rem">
<template #body="{ data }">
<span class="text-color-secondary">
{{ patientsLabel(Number(data.patients_count ?? data.patient_count ?? 0)) }}
</span>
</template>
</Column>
<Column :exportable="false" header="Ações" style="min-width: 12rem">
<template #body="{ data }">
<div class="flex justify-end gap-2">
<Button
v-if="!data.is_system"
icon="pi pi-pencil"
outlined
rounded
@click="openEdit(data)"
/>
<Button
v-if="!data.is_system"
icon="pi pi-trash"
outlined
rounded
severity="danger"
@click="confirmDeleteOne(data)"
/>
<Button
v-if="data.is_system"
icon="pi pi-lock"
outlined
rounded
disabled
v-tooltip.top="'Grupo padrão do sistema (inalterável)'"
/>
</div>
</template>
</Column>
<template #empty>
Nenhum grupo encontrado.
</template>
</DataTable>
</template>
</Card>
</div>
<!-- RIGHT: CARDS -->
<div class="w-full lg:basis-[30%] lg:max-w-[30%]">
<Card class="h-full">
<template #title>Pacientes por grupo</template>
<template #subtitle>Os cards aparecem apenas quando pacientes associados.</template>
<template #content>
<div v-if="cards.length === 0" class="min-h-[150px] flex flex-col items-center justify-center text-center gap-2">
<i class="pi pi-users text-3xl"></i>
<div class="mt-1 font-medium">Sem pacientes associados</div>
<small class="text-color-secondary">
Quando um grupo tiver pacientes vinculados, ele aparecerá aqui.
</small>
</div>
<div v-else class="flex flex-col gap-3">
<div
v-for="g in cards"
:key="g.id"
class="relative p-4 rounded-xl border border-[var(--surface-border)] transition-all duration-150 hover:-translate-y-1 hover:shadow-[var(--card-shadow)]"
@mouseenter="hovered = g.id"
@mouseleave="hovered = null"
>
<div class="flex justify-between items-start gap-3">
<div class="min-w-0">
<div class="font-bold truncate max-w-[230px]">
{{ g.nome }}
</div>
<small class="text-color-secondary">
{{ patientsLabel(Number(g.patients_count ?? g.patient_count ?? 0)) }}
</small>
</div>
<Tag
:value="g.is_system ? 'Padrão' : 'Criado por você'"
:severity="g.is_system ? 'info' : 'success'"
/>
</div>
<Transition name="fade">
<div
v-if="hovered === g.id"
class="absolute inset-0 rounded-xl bg-emerald-500/15 backdrop-blur-sm flex items-center justify-center"
>
<Button
label="Ver pacientes"
icon="pi pi-users"
severity="success"
:disabled="!(g.patients_count ?? g.patient_count)"
@click="openGroupPatientsModal(g)"
/>
</div>
</Transition>
</div>
</div>
</template>
</Card>
</div>
</div>
<!-- DIALOG CREATE / EDIT -->
<Dialog
v-model:visible="dlg.open"
:header="dlg.mode === 'create' ? 'Criar Grupo' : 'Editar Grupo'"
modal
:style="{ width: '520px', maxWidth: '92vw' }"
>
<div class="flex flex-col gap-3">
<div>
<label class="block mb-2">Nome do Grupo</label>
<InputText v-model="dlg.nome" class="w-full" :disabled="dlg.saving" />
<small class="text-color-secondary">
Grupos Padrão são do sistema e não podem ser editados.
</small>
</div>
</div>
<template #footer>
<Button label="Cancelar" text :disabled="dlg.saving" @click="dlg.open = false" />
<Button
:label="dlg.mode === 'create' ? 'Criar' : 'Salvar'"
:loading="dlg.saving"
@click="saveDialog"
:disabled="!String(dlg.nome || '').trim()"
/>
</template>
</Dialog>
<!-- DIALOG PACIENTES (com botão Abrir) -->
<Dialog
v-model:visible="patientsDialog.open"
:header="patientsDialog.group?.nome ? `Pacientes do grupo: ${patientsDialog.group.nome}` : 'Pacientes do grupo'"
modal
:style="{ width: '900px', maxWidth: '95vw' }"
>
<div class="flex flex-col gap-3">
<div class="text-color-secondary">
Grupo: <span class="font-medium text-color">{{ patientsDialog.group?.nome || '—' }}</span>
</div>
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-3">
<IconField class="w-full md:w-80">
<InputIcon><i class="pi pi-search" /></InputIcon>
<InputText
v-model="patientsDialog.search"
placeholder="Buscar paciente..."
class="w-full"
:disabled="patientsDialog.loading"
/>
</IconField>
<div class="flex items-center gap-2 justify-end">
<Tag v-if="!patientsDialog.loading" :value="`${patientsDialog.items.length} paciente(s)`" severity="secondary" />
</div>
</div>
<div v-if="patientsDialog.loading" class="text-color-secondary">Carregando</div>
<Message v-else-if="patientsDialog.error" severity="error">
{{ patientsDialog.error }}
</Message>
<div v-else>
<div v-if="patientsDialog.items.length === 0" class="text-color-secondary">
Nenhum paciente associado a este grupo.
</div>
<div v-else>
<DataTable
: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" />
<div class="min-w-0">
<div class="font-medium truncate max-w-[420px]">{{ data.full_name }}</div>
<small class="text-color-secondary truncate">{{ data.email || '—' }}</small>
</div>
</div>
</template>
</Column>
<Column header="Telefone" style="min-width: 12rem">
<template #body="{ data }">
<span class="text-color-secondary">{{ fmtPhone(data.phone) }}</span>
</template>
</Column>
<Column header="Ações" style="width: 12rem">
<template #body="{ data }">
<Button
label="Abrir"
icon="pi pi-external-link"
size="small"
outlined
@click="abrirPaciente(data)"
/>
</template>
</Column>
<template #empty>
<div class="text-color-secondary py-5">Nenhum resultado para "{{ patientsDialog.search }}".</div>
</template>
</DataTable>
</div>
</div>
</div>
<template #footer>
<Button label="Fechar" icon="pi pi-times" text @click="patientsDialog.open = false" />
</template>
</Dialog>
<ConfirmDialog />
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useToast } from 'primevue/usetoast'
import { useConfirm } from 'primevue/useconfirm'
import Checkbox from 'primevue/checkbox'
import { supabase } from '@/lib/supabase/client'
import {
listGroupsWithCounts,
createGroup,
updateGroup,
deleteGroup
} from '@/services/GruposPacientes.service.js'
const router = useRouter()
const toast = useToast()
const confirm = useConfirm()
const dt = ref(null)
const loading = ref(false)
const groups = ref([])
const selectedGroups = ref([])
const hovered = ref(null)
const filters = ref({
global: { value: null, matchMode: 'contains' }
})
const dlg = reactive({
open: false,
mode: 'create', // 'create' | 'edit'
id: '',
nome: '',
saving: false
})
const patientsDialog = reactive({
open: false,
loading: false,
error: '',
group: null,
items: [],
search: ''
})
function applyRealCountsToGroups (groupsArr, countMap) {
return (groupsArr || []).map(g => ({
...g,
patients_count: Number(countMap[g.id] || 0) // força a verdade aqui
}))
}
async function fetchRealGroupCountsForOwner () {
const ownerId = (await supabase.auth.getUser())?.data?.user?.id
if (!ownerId) throw new Error('Sessão inválida.')
// Busca todas as associações (group <-> patient) apenas de pacientes do owner logado
const { data, error } = await supabase
.from('patient_group_patient')
.select(`
patient_group_id,
patient:patients!inner (
id,
owner_id
)
`)
.eq('patient.owner_id', ownerId)
if (error) throw error
// Conta em JS por group_id
const map = Object.create(null)
for (const row of (data || [])) {
const gid = row.patient_group_id
if (!gid) continue
map[gid] = (map[gid] || 0) + 1
}
return map
}
const cards = computed(() => {
const arr = groups.value || []
return arr
.filter(g => {
const raw = g.patients_count ?? g.patient_count ?? 0
const n = Number.parseInt(String(raw), 10)
return Number.isFinite(n) && n > 0
})
.sort((a, b) => {
const na = Number.parseInt(String(a.patients_count ?? a.patient_count ?? 0), 10) || 0
const nb = Number.parseInt(String(b.patients_count ?? b.patient_count ?? 0), 10) || 0
return nb - na
})
})
const patientsDialogFiltered = computed(() => {
const s = String(patientsDialog.search || '').trim().toLowerCase()
if (!s) return patientsDialog.items || []
return (patientsDialog.items || []).filter(p => {
const name = String(p.full_name || '').toLowerCase()
const email = String(p.email || '').toLowerCase()
const phone = String(p.phone || '').toLowerCase()
return name.includes(s) || email.includes(s) || phone.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 grupo com esse nome (para você). Tente outro nome.'
}
if (/Grupo padrão/i.test(msg)) {
return 'Esse é um grupo padrão do sistema e não pode ser alterado.'
}
return msg
}
async function fetchAll () {
loading.value = true
try {
// 1) carrega grupos (com ou sem count vindo do service)
const baseGroups = await listGroupsWithCounts()
// 2) recalcula counts reais (por owner) e sobrescreve
const realCountMap = await fetchRealGroupCountsForOwner()
groups.value = applyRealCountsToGroups(baseGroups, realCountMap)
} catch (err) {
toast.add({ severity: 'error', summary: 'Erro', detail: humanizeError(err), life: 3500 })
} finally {
loading.value = false
}
}
/* -------------------------------
Seleção: ignora grupos do sistema
-------------------------------- */
function isSelected (row) {
return (selectedGroups.value || []).some(s => s.id === row.id)
}
function toggleRowSelection (row, checked) {
if (row.is_system) return
const sel = selectedGroups.value || []
if (checked) {
if (!sel.some(s => s.id === row.id)) selectedGroups.value = [...sel, row]
} else {
selectedGroups.value = sel.filter(s => s.id !== row.id)
}
}
/* -------------------------------
CRUD
-------------------------------- */
function openCreate () {
dlg.open = true
dlg.mode = 'create'
dlg.id = ''
dlg.nome = ''
}
function openEdit (row) {
dlg.open = true
dlg.mode = 'edit'
dlg.id = row.id
dlg.nome = row.nome
}
async function saveDialog () {
const nome = String(dlg.nome || '').trim()
if (!nome) {
toast.add({ severity: 'warn', summary: 'Atenção', detail: 'Informe um nome.', life: 2500 })
return
}
if (nome.length < 2) {
toast.add({ severity: 'warn', summary: 'Atenção', detail: 'Nome muito curto.', life: 2500 })
return
}
dlg.saving = true
try {
if (dlg.mode === 'create') {
await createGroup(nome)
toast.add({ severity: 'success', summary: 'Sucesso', detail: 'Grupo criado.', life: 2500 })
} else {
await updateGroup(dlg.id, nome)
toast.add({ severity: 'success', summary: 'Sucesso', detail: 'Grupo atualizado.', life: 2500 })
}
dlg.open = false
await fetchAll()
} catch (err) {
toast.add({ severity: 'error', summary: 'Erro', detail: humanizeError(err), life: 3500 })
} finally {
dlg.saving = false
}
}
function confirmDeleteOne (row) {
confirm.require({
message: `Excluir "${row.nome}"?`,
header: 'Excluir grupo',
icon: 'pi pi-exclamation-triangle',
acceptClass: 'p-button-danger',
acceptLabel: 'Excluir',
rejectLabel: 'Cancelar',
accept: async () => {
try {
await deleteGroup(row.id)
toast.add({ severity: 'success', summary: 'Sucesso', detail: 'Grupo excluído.', life: 2500 })
await fetchAll()
} catch (err) {
toast.add({ severity: 'error', summary: 'Erro', detail: humanizeError(err), life: 3500 })
}
}
})
}
function confirmDeleteSelected () {
const sel = selectedGroups.value || []
if (!sel.length) return
const deletables = sel.filter(g => !g.is_system)
const blocked = sel.filter(g => g.is_system)
if (!deletables.length) {
toast.add({
severity: 'warn',
summary: 'Atenção',
detail: 'Os itens selecionados são grupos do sistema e não podem ser excluídos.',
life: 3500
})
return
}
const msgBlocked = blocked.length ? ` (${blocked.length} grupo(s) padrão serão ignorados)` : ''
confirm.require({
message: `Excluir ${deletables.length} grupo(s) selecionado(s)?${msgBlocked}`,
header: 'Excluir selecionados',
icon: 'pi pi-exclamation-triangle',
acceptClass: 'p-button-danger',
acceptLabel: 'Excluir',
rejectLabel: 'Cancelar',
accept: async () => {
try {
for (const g of deletables) await deleteGroup(g.id)
selectedGroups.value = []
toast.add({ severity: 'success', summary: 'Sucesso', detail: 'Exclusão concluída.', life: 2500 })
await fetchAll()
} catch (err) {
toast.add({ severity: 'error', summary: 'Erro', detail: humanizeError(err), life: 3500 })
}
}
})
}
/* -------------------------------
Helpers (avatar/telefone)
-------------------------------- */
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 onlyDigits (v) {
return String(v ?? '').replace(/\D/g, '')
}
function fmtPhone (v) {
const d = onlyDigits(v)
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 do Grupo
-------------------------------- */
async function openGroupPatientsModal (groupRow) {
patientsDialog.open = true
patientsDialog.loading = true
patientsDialog.error = ''
patientsDialog.group = groupRow
patientsDialog.items = []
patientsDialog.search = ''
try {
const { data, error } = await supabase
.from('patient_group_patient')
.select(`
patient_id,
patient:patients (
id,
nome_completo,
email_principal,
telefone,
avatar_url
)
`)
.eq('patient_group_id', groupRow.id)
if (error) throw error
const patients = (data || [])
.map(r => r.patient)
.filter(Boolean)
patientsDialog.items = patients
.map(p => ({
id: p.id,
full_name: p.nome_completo || '—',
email: p.email_principal || '—',
phone: p.telefone || '—',
avatar_url: p.avatar_url || null
}))
.sort((a, b) => String(a.full_name).localeCompare(String(b.full_name), 'pt-BR'))
} catch (err) {
patientsDialog.error = humanizeError(err)
} finally {
patientsDialog.loading = false
}
}
function abrirPaciente (patient) {
router.push(`/features/patients/cadastro/${patient.id}`)
}
onMounted(fetchAll)
</script>
<style scoped>
.fade-enter-active,
.fade-leave-active { transition: opacity .14s ease; }
.fade-enter-from,
.fade-leave-to { opacity: 0; }
</style>
File diff suppressed because it is too large Load Diff
+815
View File
@@ -0,0 +1,815 @@
<template>
<div class="p-4">
<!-- TOOLBAR -->
<Toolbar class="mb-4">
<template #start>
<div class="flex flex-col">
<div class="text-xl font-semibold leading-none">Tags de Pacientes</div>
<small class="text-color-secondary mt-1">
Classifique pacientes por temas (ex.: Burnout, Ansiedade, Triagem). Clique em Pacientes para ver a lista.
</small>
</div>
</template>
<template #end>
<div class="flex items-center gap-2">
<Button
label="Excluir selecionados"
icon="pi pi-trash"
severity="danger"
outlined
:disabled="!etiquetasSelecionadas?.length"
@click="confirmarExclusaoSelecionadas"
/>
<Button label="Adicionar" icon="pi pi-plus" @click="abrirCriar" />
</div>
</template>
</Toolbar>
<div class="flex flex-col lg:flex-row gap-4">
<!-- LEFT: tabela -->
<div class="w-full lg:basis-[70%] lg:max-w-[70%]">
<Card class="h-full">
<template #content>
<DataTable
ref="dt"
:value="etiquetas"
dataKey="id"
:loading="carregando"
paginator
:rows="10"
:rowsPerPageOptions="[10, 20, 50]"
stripedRows
responsiveLayout="scroll"
:filters="filtros"
filterDisplay="menu"
paginatorTemplate="FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink CurrentPageReport RowsPerPageDropdown"
currentPageReportTemplate="Mostrando {first} a {last} de {totalRecords} tags"
>
<template #header>
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-3">
<div class="flex items-center gap-2">
<span class="font-medium">Lista de Tags</span>
<Tag :value="`${etiquetas.length} tags`" severity="secondary" />
</div>
<div class="flex items-center gap-2 w-full md:w-auto">
<IconField class="w-full md:w-80">
<InputIcon><i class="pi pi-search" /></InputIcon>
<InputText
v-model="filtros.global.value"
placeholder="Buscar tag..."
class="w-full"
/>
</IconField>
<Button
icon="pi pi-refresh"
severity="secondary"
outlined
v-tooltip.top="'Atualizar'"
@click="buscarEtiquetas"
/>
</div>
</div>
</template>
<!-- Seleção (bloqueia tags padrão) -->
<Column :exportable="false" headerStyle="width: 3rem">
<template #body="{ data }">
<Checkbox
:binary="true"
:disabled="!!data.is_padrao"
:modelValue="estaSelecionada(data)"
@update:modelValue="alternarSelecao(data, $event)"
/>
</template>
</Column>
<Column field="nome" header="Tag" sortable style="min-width: 18rem;">
<template #body="{ data }">
<div class="flex items-center gap-2 min-w-0">
<span
class="inline-block rounded-full"
:style="{
width: '10px',
height: '10px',
background: data.cor || '#94a3b8'
}"
/>
<span class="font-medium truncate">{{ data.nome }}</span>
<span v-if="data.is_padrao" class="text-xs text-color-secondary">(padrão)</span>
</div>
</template>
</Column>
<Column header="Pacientes" sortable sortField="pacientes_count" style="width: 10rem;">
<template #body="{ data }">
<Button
class="p-0"
link
:label="String(data.pacientes_count ?? 0)"
:disabled="Number(data.pacientes_count ?? 0) <= 0"
@click="abrirModalPacientesDaTag(data)"
/>
</template>
</Column>
<Column header="Ações" style="width: 10.5rem;">
<template #body="{ data }">
<div class="flex gap-2 justify-end">
<Button
icon="pi pi-pencil"
severity="secondary"
outlined
size="small"
:disabled="data.is_padrao"
v-tooltip.top="data.is_padrao ? 'Tags padrão não podem ser editadas' : 'Editar'"
@click="abrirEditar(data)"
/>
<Button
icon="pi pi-trash"
severity="danger"
outlined
size="small"
:disabled="data.is_padrao"
v-tooltip.top="data.is_padrao ? 'Tags padrão não podem ser excluídas' : 'Excluir'"
@click="confirmarExclusaoUma(data)"
/>
</div>
</template>
</Column>
<template #empty>
<div class="text-color-secondary py-5">Nenhuma tag encontrada.</div>
</template>
</DataTable>
</template>
</Card>
</div>
<!-- RIGHT: cards -->
<div class="w-full lg:basis-[30%] lg:max-w-[30%]">
<Card class="h-full">
<template #title>Mais usadas</template>
<template #subtitle>As tags aparecem aqui quando houver pacientes associados.</template>
<template #content>
<div v-if="cards.length === 0" class="min-h-[150px] flex flex-col items-center justify-center text-center gap-2">
<i class="pi pi-tags text-3xl"></i>
<div class="font-medium">Sem dados ainda</div>
<small class="text-color-secondary">
Quando você associar pacientes às tags, elas aparecem aqui.
</small>
</div>
<div v-else class="flex flex-col gap-3">
<div
v-for="t in cards"
:key="t.id"
class="relative p-4 rounded-xl border border-[var(--surface-border)] transition-all duration-150 hover:-translate-y-1 hover:shadow-[var(--card-shadow)]"
@mouseenter="hovered = t.id"
@mouseleave="hovered = null"
>
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<div class="flex items-center gap-2 min-w-0">
<span
class="inline-block rounded-full"
:style="{
width: '10px',
height: '10px',
background: t.cor || '#94a3b8'
}"
/>
<div class="font-semibold truncate">{{ t.nome }}</div>
<span v-if="t.is_padrao" class="text-xs text-color-secondary">(padrão)</span>
</div>
<div class="text-sm text-color-secondary mt-1">
{{ Number(t.pacientes_count ?? 0) }} paciente(s)
</div>
</div>
<Transition name="fade">
<div v-if="hovered === t.id" class="flex items-center justify-content-center">
<Button
label="Ver pacientes"
icon="pi pi-users"
severity="success"
:disabled="Number(t.pacientes_count ?? 0) <= 0"
@click.stop="abrirModalPacientesDaTag(t)"
/>
</div>
</Transition>
</div>
</div>
</div>
</template>
</Card>
</div>
</div>
<!-- DIALOG CREATE / EDIT -->
<Dialog
v-model:visible="dlg.open"
:header="dlg.mode === 'create' ? 'Criar Tag' : 'Editar Tag'"
modal
:style="{ width: '520px', maxWidth: '92vw' }"
>
<div class="flex flex-col gap-4">
<div>
<label class="block mb-2">Nome da Tag</label>
<InputText v-model="dlg.nome" class="w-full" :disabled="dlg.saving" />
<small class="text-color-secondary">Ex.: Burnout, Ansiedade, Triagem.</small>
</div>
<div>
<label class="block mb-2">Cor (opcional)</label>
<div class="flex flex-wrap items-center gap-3">
<ColorPicker v-model="dlg.cor" format="hex" :disabled="dlg.saving" />
<InputText
v-model="dlg.cor"
class="w-44"
placeholder="#22c55e"
:disabled="dlg.saving"
/>
<span
class="inline-block rounded-lg"
:style="{
width: '34px',
height: '34px',
border: '1px solid var(--surface-border)',
background: corPreview(dlg.cor)
}"
/>
</div>
<small class="text-color-secondary">
Pode usar HEX (#rrggbb). Se vazio, usamos uma cor neutra.
</small>
</div>
</div>
<template #footer>
<Button label="Cancelar" icon="pi pi-times" text @click="fecharDlg" :disabled="dlg.saving" />
<Button
:label="dlg.mode === 'create' ? 'Criar' : 'Salvar'"
icon="pi pi-check"
@click="salvarDlg"
:loading="dlg.saving"
:disabled="!String(dlg.nome || '').trim()"
/>
</template>
</Dialog>
<!-- MODAL: pacientes da tag -->
<Dialog
v-model:visible="modalPacientes.open"
:header="modalPacientes.tag ? `Pacientes — ${modalPacientes.tag.nome}` : 'Pacientes'"
modal
:style="{ width: '900px', maxWidth: '96vw' }"
>
<div class="flex flex-col gap-3">
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-3">
<IconField class="w-full md:w-80">
<InputIcon><i class="pi pi-search" /></InputIcon>
<InputText v-model="modalPacientes.search" placeholder="Buscar paciente..." class="w-full" />
</IconField>
<div class="flex items-center gap-2 justify-end">
<Button icon="pi pi-refresh" severity="secondary" outlined @click="recarregarModalPacientes" />
</div>
</div>
<Message v-if="modalPacientes.error" severity="error">
{{ modalPacientes.error }}
</Message>
<DataTable
:value="modalPacientesFiltrado"
:loading="modalPacientes.loading"
dataKey="id"
paginator
:rows="8"
:rowsPerPageOptions="[8, 15, 30]"
stripedRows
responsiveLayout="scroll"
>
<Column field="name" 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 icon="pi pi-user" shape="circle" />
<div class="flex flex-col min-w-0">
<span class="font-medium truncate">{{ data.name }}</span>
<small class="text-color-secondary truncate">{{ data.email || '—' }}</small>
</div>
</div>
</template>
</Column>
<Column header="Telefone" style="width: 14rem;">
<template #body="{ data }">
<span class="text-color-secondary">{{ fmtPhoneBR(data.phone) }}</span>
</template>
</Column>
<Column header="Ações" style="width: 12rem;">
<template #body="{ data }">
<Button
label="Abrir"
icon="pi pi-external-link"
size="small"
outlined
@click="abrirPaciente(data)"
/>
</template>
</Column>
<template #empty>
<div class="text-color-secondary py-5">Nenhum paciente encontrado.</div>
</template>
</DataTable>
</div>
<template #footer>
<Button label="Fechar" icon="pi pi-times" text @click="modalPacientes.open = false" />
</template>
</Dialog>
<ConfirmDialog />
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useToast } from 'primevue/usetoast'
import { useConfirm } from 'primevue/useconfirm'
import ColorPicker from 'primevue/colorpicker'
import Checkbox from 'primevue/checkbox'
import { supabase } from '@/lib/supabase/client'
const router = useRouter()
const toast = useToast()
const confirm = useConfirm()
const dt = ref(null)
const carregando = ref(false)
const etiquetas = ref([])
const etiquetasSelecionadas = ref([])
const hovered = ref(null)
const filtros = ref({
global: { value: null, matchMode: 'contains' }
})
const dlg = reactive({
open: false,
mode: 'create', // 'create' | 'edit'
id: '',
nome: '',
cor: '',
saving: false
})
const modalPacientes = reactive({
open: false,
loading: false,
error: '',
tag: null,
items: [],
search: ''
})
const cards = computed(() =>
(etiquetas.value || [])
.filter(t => Number(t.pacientes_count ?? 0) > 0)
.sort((a, b) => Number(b.pacientes_count ?? 0) - Number(a.pacientes_count ?? 0))
)
const modalPacientesFiltrado = computed(() => {
const s = String(modalPacientes.search || '').trim().toLowerCase()
if (!s) return modalPacientes.items || []
return (modalPacientes.items || []).filter(p => {
const name = String(p.name || '').toLowerCase()
const email = String(p.email || '').toLowerCase()
const phone = String(p.phone || '').toLowerCase()
return name.includes(s) || email.includes(s) || phone.includes(s)
})
})
onMounted(() => {
buscarEtiquetas()
})
async function getOwnerId() {
const { data, error } = await supabase.auth.getUser()
if (error) throw error
const user = data?.user
if (!user) throw new Error('Você precisa estar logado.')
return user.id
}
function normalizarEtiquetaRow(r) {
// Compatível com banco antigo (name/color/is_native/patient_count)
// e com banco pt-BR (nome/cor/is_padrao/patients_count)
const nome = r?.nome ?? r?.name ?? ''
const cor = r?.cor ?? r?.color ?? null
const is_padrao = Boolean(r?.is_padrao ?? r?.is_native ?? false)
const pacientes_count = Number(
r?.pacientes_count ?? r?.patient_count ?? r?.patients_count ?? 0
)
return {
...r,
nome,
cor,
is_padrao,
pacientes_count
}
}
function isUniqueViolation(e) {
return e?.code === '23505' || /duplicate key value/i.test(String(e?.message || ''))
}
function friendlyDupMessage(nome) {
return `Já existe uma tag chamada “${nome}”. Tente outro nome.`
}
function corPreview(raw) {
const r = String(raw || '').trim()
if (!r) return '#94a3b8'
const hex = r.replace('#', '')
return `#${hex}`
}
/* -------------------------------
Seleção (bloqueia tags padrão)
-------------------------------- */
function estaSelecionada(row) {
return (etiquetasSelecionadas.value || []).some(s => s.id === row.id)
}
function alternarSelecao(row, checked) {
if (row.is_padrao) return
const sel = etiquetasSelecionadas.value || []
if (checked) {
if (!sel.some(s => s.id === row.id)) etiquetasSelecionadas.value = [...sel, row]
} else {
etiquetasSelecionadas.value = sel.filter(s => s.id !== row.id)
}
}
/* -------------------------------
Fetch tags
-------------------------------- */
async function buscarEtiquetas() {
carregando.value = true
try {
const ownerId = await getOwnerId()
// 1) tenta view (contagem pronta)
const v = await supabase
.from('v_tag_patient_counts')
.select('*')
.eq('owner_id', ownerId)
.order('nome', { ascending: true })
if (!v.error) {
etiquetas.value = (v.data || []).map(normalizarEtiquetaRow)
return
}
// 2) fallback tabela
const t = await supabase
.from('patient_tags')
.select('id, owner_id, nome, cor, is_padrao, name, color, is_native, created_at, updated_at')
.eq('owner_id', ownerId)
.order('nome', { ascending: true })
// se der erro porque ainda não tem 'nome', tenta por 'name'
if (t.error && /column .*nome/i.test(String(t.error.message || ''))) {
const t2 = await supabase
.from('patient_tags')
.select('id, owner_id, name, color, is_native, created_at, updated_at')
.eq('owner_id', ownerId)
.order('name', { ascending: true })
if (t2.error) throw t2.error
etiquetas.value = (t2.data || []).map(r => normalizarEtiquetaRow({ ...r, patient_count: 0 }))
return
}
if (t.error) throw t.error
etiquetas.value = (t.data || []).map(r => normalizarEtiquetaRow({ ...r, pacientes_count: 0 }))
} catch (e) {
console.error('[TagsPacientesPage] buscarEtiquetas error', e)
toast.add({
severity: 'error',
summary: 'Erro ao carregar tags',
detail: e?.message || 'Não consegui carregar as tags. Verifique se as tabelas/views existem no Supabase local.',
life: 6000
})
} finally {
carregando.value = false
}
}
/* -------------------------------
Dialog create/edit
-------------------------------- */
function abrirCriar() {
dlg.open = true
dlg.mode = 'create'
dlg.id = ''
dlg.nome = ''
dlg.cor = ''
}
function abrirEditar(row) {
dlg.open = true
dlg.mode = 'edit'
dlg.id = row.id
dlg.nome = row.nome || ''
dlg.cor = row.cor || ''
}
function fecharDlg() {
dlg.open = false
}
async function salvarDlg() {
const nome = String(dlg.nome || '').trim()
if (!nome) return
dlg.saving = true
try {
const ownerId = await getOwnerId()
// salva sempre "#rrggbb" ou null
const raw = String(dlg.cor || '').trim()
const hex = raw ? raw.replace('#', '') : ''
const cor = hex ? `#${hex}` : null
if (dlg.mode === 'create') {
// tenta pt-BR
let res = await supabase.from('patient_tags').insert({
owner_id: ownerId,
nome,
cor
})
// se colunas pt-BR não existem ainda, cai pra legado
if (res.error && /column .*nome/i.test(String(res.error.message || ''))) {
res = await supabase.from('patient_tags').insert({
owner_id: ownerId,
name: nome,
color: cor
})
}
if (res.error) throw res.error
toast.add({ severity: 'success', summary: 'Tag criada', detail: nome, life: 2500 })
} else {
// update pt-BR
let res = await supabase
.from('patient_tags')
.update({
nome,
cor,
updated_at: new Date().toISOString()
})
.eq('id', dlg.id)
.eq('owner_id', ownerId)
// legado
if (res.error && /column .*nome/i.test(String(res.error.message || ''))) {
res = await supabase
.from('patient_tags')
.update({
name: nome,
color: cor,
updated_at: new Date().toISOString()
})
.eq('id', dlg.id)
.eq('owner_id', ownerId)
}
if (res.error) throw res.error
toast.add({ severity: 'success', summary: 'Tag atualizada', detail: nome, life: 2500 })
}
dlg.open = false
await buscarEtiquetas()
} catch (e) {
console.error('[TagsPacientesPage] salvarDlg error', e)
const nome = String(dlg.nome || '').trim()
if (isUniqueViolation(e)) {
toast.add({
severity: 'warn',
summary: 'Tag já existe',
detail: friendlyDupMessage(nome),
life: 4500
})
return
}
toast.add({
severity: 'error',
summary: 'Não consegui salvar',
detail: e?.message || 'Erro ao salvar a tag.',
life: 6000
})
} finally {
dlg.saving = false
}
}
/* -------------------------------
Delete
-------------------------------- */
function confirmarExclusaoUma(row) {
confirm.require({
message: `Excluir a tag “${row.nome}”? (Isso remove também os vínculos com pacientes)`,
header: 'Confirmar exclusão',
icon: 'pi pi-exclamation-triangle',
acceptLabel: 'Excluir',
rejectLabel: 'Cancelar',
accept: async () => excluirTags([row])
})
}
function confirmarExclusaoSelecionadas() {
const rows = etiquetasSelecionadas.value || []
if (!rows.length) return
const nomes = rows.slice(0, 5).map(r => r.nome).join(', ')
confirm.require({
message:
rows.length <= 5
? `Excluir: ${nomes}? (remove também os vínculos)`
: `Excluir ${rows.length} tags selecionadas? (remove também os vínculos)`,
header: 'Confirmar exclusão',
icon: 'pi pi-exclamation-triangle',
acceptLabel: 'Excluir',
rejectLabel: 'Cancelar',
accept: async () => excluirTags(rows)
})
}
async function excluirTags(rows) {
if (!rows?.length) return
try {
const ownerId = await getOwnerId()
const ids = rows.filter(r => !r.is_padrao).map(r => r.id)
if (!ids.length) {
toast.add({
severity: 'warn',
summary: 'Nada para excluir',
detail: 'Tags padrão não podem ser removidas.',
life: 4000
})
return
}
// 1) apaga pivots
const pivotDel = await supabase
.from('patient_patient_tag')
.delete()
.eq('owner_id', ownerId)
.in('tag_id', ids)
if (pivotDel.error) throw pivotDel.error
// 2) apaga tags
const tagDel = await supabase
.from('patient_tags')
.delete()
.eq('owner_id', ownerId)
.in('id', ids)
if (tagDel.error) throw tagDel.error
etiquetasSelecionadas.value = []
toast.add({ severity: 'success', summary: 'Excluído', detail: `${ids.length} tag(s) removida(s).`, life: 3000 })
await buscarEtiquetas()
} catch (e) {
console.error('[TagsPacientesPage] excluirTags error', e)
toast.add({
severity: 'error',
summary: 'Não consegui excluir',
detail: e?.message || 'Erro ao excluir tags.',
life: 6000
})
}
}
/* -------------------------------
Modal pacientes
-------------------------------- */
async function abrirModalPacientesDaTag(tag) {
modalPacientes.open = true
modalPacientes.tag = tag
modalPacientes.items = []
modalPacientes.search = ''
modalPacientes.error = ''
await carregarPacientesDaTag(tag)
}
async function recarregarModalPacientes() {
if (!modalPacientes.tag) return
await carregarPacientesDaTag(modalPacientes.tag)
}
async function carregarPacientesDaTag(tag) {
modalPacientes.loading = true
modalPacientes.error = ''
try {
const ownerId = await getOwnerId()
const { data, error } = await supabase
.from('patient_patient_tag')
.select(`
patient_id,
patients:patients(
id,
nome_completo,
email_principal,
telefone,
avatar_url
)
`)
.eq('owner_id', ownerId)
.eq('tag_id', tag.id)
if (error) throw error
const normalizados = (data || [])
.map(r => r.patients)
.filter(Boolean)
.map(p => ({
id: p.id,
name: p.nome_completo || '—',
email: p.email_principal || '—',
phone: p.telefone || '—',
avatar_url: p.avatar_url || null
}))
.sort((a, b) => String(a.name).localeCompare(String(b.name), 'pt-BR'))
modalPacientes.items = normalizados
} catch (e) {
console.error('[TagsPacientesPage] carregarPacientesDaTag error', e)
modalPacientes.error =
e?.message ||
'Não consegui carregar os pacientes desta tag. Verifique RLS/policies e se as tabelas existem.'
} finally {
modalPacientes.loading = false
}
}
function onlyDigits(v) {
return String(v ?? '').replace(/\D/g, '')
}
function fmtPhoneBR(v) {
const d = onlyDigits(v)
if (!d) return '—'
// opcional: se vier com DDI 55 grudado (ex.: 5511999999999)
if ((d.length === 12 || d.length === 13) && d.startsWith('55')) {
return fmtPhoneBR(d.slice(2))
}
// (11) 9xxxx-xxxx
if (d.length === 11) return `(${d.slice(0, 2)}) ${d.slice(2, 7)}-${d.slice(7, 11)}`
// (11) xxxx-xxxx
if (d.length === 10) return `(${d.slice(0, 2)}) ${d.slice(2, 6)}-${d.slice(6, 10)}`
return d
}
function abrirPaciente (patient) {
router.push(`/features/patients/cadastro/${patient.id}`)
}
</script>
<style scoped>
/* Mantido apenas porque Transition name="fade" precisa das classes */
.fade-enter-active,
.fade-leave-active { transition: opacity 0.15s ease; }
.fade-enter-from,
.fade-leave-to { opacity: 0; }
</style>