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

View File

@@ -2,68 +2,76 @@
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { supabase } from '../../lib/supabase/client' // ajuste se o caminho for outro
import { useTenantStore } from '@/stores/tenantStore'
const router = useRouter()
const tenant = useTenantStore()
const checking = ref(true)
const userEmail = ref('')
const role = ref(null)
const role = ref(null) // aqui vai guardar o role REAL do tenant: clinic_admin/therapist/patient
const TEST_ACCOUNTS = {
admin: { email: 'admin@agenciapsi.com.br', password: '123Mudar@' },
clinic_admin: { email: 'clinic@agenciapsi.com.br', password: '123Mudar@' },
therapist: { email: 'therapist@agenciapsi.com.br', password: '123Mudar@' },
patient: { email: 'patient@agenciapsi.com.br', password: '123Mudar@' }
patient: { email: 'patient@agenciapsi.com.br', password: '123Mudar@' },
saas: { email: 'saas@agenciapsi.com.br', password: '123Mudar@' }
}
function roleToPath(r) {
if (r === 'admin') return '/admin'
function roleToPath (r) {
// ✅ role REAL (tenant_members via my_tenants)
if (r === 'clinic_admin' || r === 'tenant_admin' || r === 'admin') return '/admin'
if (r === 'therapist') return '/therapist'
if (r === 'patient') return '/patient'
if (r === 'patient') return '/portal'
return '/'
}
async function fetchMyRole() {
async function isSaasAdmin () {
const { data: userData, error: userErr } = await supabase.auth.getUser()
if (userErr) return null
if (userErr) return false
const user = userData?.user
if (!user) return null
userEmail.value = user.email || ''
if (!user?.id) return false
const { data, error } = await supabase
.from('profiles')
.select('role')
.eq('id', user.id)
.single()
.from('saas_admins')
.select('user_id')
.eq('user_id', user.id)
.maybeSingle()
if (error) return null
return data?.role || null
if (error) return false
return !!data
}
async function go(area) {
// Se já estiver logado, respeita role real (não o card)
// ✅ carrega tenant/role real (my_tenants) e atualiza UI
async function syncTenantRole () {
await tenant.loadSessionAndTenant()
role.value = tenant.activeRole || null
return role.value
}
async function go (area) {
// Se já estiver logado:
const { data: sessionData } = await supabase.auth.getSession()
const session = sessionData?.session
if (session) {
const r = role.value || (await fetchMyRole())
userEmail.value = session.user?.email || userEmail.value || ''
// ✅ se for SaaS master, SEMPRE manda pra /saas (independente do card clicado)
const saas = await isSaasAdmin()
if (saas) return router.push('/saas')
const r = role.value || (await syncTenantRole())
if (!r) return router.push('/auth/login')
return router.push(roleToPath(r))
}
// Se não estiver logado, manda pro login guardando a intenção
sessionStorage.setItem('intended_area', area) // admin/therapist/patient
sessionStorage.setItem('intended_area', area) // clinic_admin/therapist/patient/saas
// ✅ Prefill de login (apenas DEV)
const DEV_PREFILL = import.meta.env.DEV
if (DEV_PREFILL) {
const TEST_ACCOUNTS = {
admin: { email: 'admin@agenciapsi.com.br', password: '123Mudar@' },
therapist: { email: 'therapist@agenciapsi.com.br', password: '123Mudar@' },
patient: { email: 'patient@agenciapsi.com.br', password: '123Mudar@' }
}
const acc = TEST_ACCOUNTS[area]
if (acc) {
sessionStorage.setItem('login_prefill_email', acc.email)
@@ -77,15 +85,32 @@ async function go(area) {
router.push('/auth/login')
}
async function goMyPanel() {
async function goMyPanel () {
if (!role.value) return
// ✅ se for SaaS master, sempre /saas
const saas = await isSaasAdmin()
if (saas) return router.push('/saas')
router.push(roleToPath(role.value))
}
async function logout() {
await supabase.auth.signOut()
role.value = null
userEmail.value = ''
async function logout () {
try {
await supabase.auth.signOut()
} finally {
role.value = null
userEmail.value = ''
// limpa qualquer intenção pendente
sessionStorage.removeItem('redirect_after_login')
sessionStorage.removeItem('intended_area')
// ✅ força redirecionamento para HomeCards (/)
router.replace('/')
// Use router.replace('/') e não push,
// assim o usuário não consegue voltar com o botão "voltar" para uma rota protegida.
}
}
onMounted(async () => {
@@ -94,7 +119,17 @@ onMounted(async () => {
const session = sessionData?.session
if (session) {
role.value = await fetchMyRole()
userEmail.value = session.user?.email || ''
// ✅ se for SaaS master, manda direto pro SaaS
const saas = await isSaasAdmin()
if (saas) {
router.replace('/saas')
return
}
// ✅ role REAL vem do tenantStore (my_tenants)
role.value = await syncTenantRole()
// Se está logado e tem role, manda direto pro painel
if (role.value) {
@@ -201,15 +236,15 @@ onMounted(async () => {
<div class="px-8 pb-10">
<div class="grid grid-cols-12 gap-6">
<!-- ADMIN -->
<div class="col-span-12 md:col-span-4">
<!-- CLÍNICA (antigo ADMIN) -->
<div class="col-span-12 md:col-span-3">
<div
class="group h-full cursor-pointer rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-ground)] p-6 transition-all hover:shadow-xl hover:-translate-y-1"
@click="go('admin')"
@click="go('clinic_admin')"
>
<div class="flex items-center justify-between mb-4">
<div class="text-xl font-semibold text-[var(--text-color)]">
Admin
Clínica
</div>
<i class="pi pi-building text-sm opacity-70" />
</div>
@@ -225,7 +260,7 @@ onMounted(async () => {
</div>
<!-- TERAPEUTA -->
<div class="col-span-12 md:col-span-4">
<div class="col-span-12 md:col-span-3">
<div
class="group h-full cursor-pointer rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-ground)] p-6 transition-all hover:shadow-xl hover:-translate-y-1"
@click="go('therapist')"
@@ -248,7 +283,7 @@ onMounted(async () => {
</div>
<!-- PACIENTE -->
<div class="col-span-12 md:col-span-4">
<div class="col-span-12 md:col-span-3">
<div
class="group h-full cursor-pointer rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-ground)] p-6 transition-all hover:shadow-xl hover:-translate-y-1"
@click="go('patient')"
@@ -270,7 +305,98 @@ onMounted(async () => {
</div>
</div>
<!-- SAAS MASTER -->
<div class="col-span-12 md:col-span-3">
<div
class="group h-full cursor-pointer rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-ground)] p-6 transition-all hover:shadow-xl hover:-translate-y-1"
@click="go('saas')"
>
<div class="flex items-center justify-between mb-4">
<div class="text-xl font-semibold text-[var(--text-color)]">
SaaS (Master)
</div>
<i class="pi pi-shield text-sm opacity-70" />
</div>
<div class="text-sm text-[var(--text-color-secondary)] leading-relaxed">
Acesso global: planos, assinaturas, tenants e saúde da plataforma.
</div>
<div class="mt-6 text-sm font-medium text-primary opacity-80 group-hover:opacity-100 transition">
Acessar painel
</div>
</div>
</div>
</div>
<!-- DEV Usuários cadastrados -->
<div class="mt-10 w-full rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-ground)] p-6">
<div class="flex items-center justify-between mb-4">
<div class="text-sm font-semibold text-[var(--text-color)]">
Usuários do ambiente (Desenvolvimento)
</div>
<span class="text-xs text-[var(--text-color-secondary)] opacity-70">
Identificadores internos
</span>
</div>
<div class="overflow-x-auto">
<table class="w-full text-xs md:text-sm">
<thead>
<tr class="text-left border-b border-[var(--surface-border)]">
<th class="py-2 pr-4 font-medium text-[var(--text-color-secondary)]">ID</th>
<th class="py-2 font-medium text-[var(--text-color-secondary)]">E-mail</th>
</tr>
</thead>
<tbody class="text-[var(--text-color)]">
<tr class="border-b border-[var(--surface-border)]/60">
<td class="py-2 pr-4 font-mono opacity-80">
40a4b683-a0c9-4890-a201-20faf41fca06
</td>
<td class="py-2">
saas@agenciapsi.com.br
</td>
</tr>
<tr class="border-b border-[var(--surface-border)]/60">
<td class="py-2 pr-4 font-mono opacity-80">
523003e7-17ab-4375-b912-040027a75c22
</td>
<td class="py-2">
patient@agenciapsi.com.br
</td>
</tr>
<tr class="border-b border-[var(--surface-border)]/60">
<td class="py-2 pr-4 font-mono opacity-80">
816b24fe-a0c3-4409-b79b-c6c0a6935d03
</td>
<td class="py-2">
clinic@agenciapsi.com.br
</td>
</tr>
<tr>
<td class="py-2 pr-4 font-mono opacity-80">
824f125c-55bb-40f5-a8c4-7a33618b91c7
</td>
<td class="py-2">
therapist@agenciapsi.com.br
</td>
</tr>
</tbody>
</table>
</div>
<div class="mt-4 text-[11px] text-[var(--text-color-secondary)] opacity-70">
Estes usuários existem apenas para fins de teste no ambiente de desenvolvimento.
</div>
</div>
<!-- Rodapé explicativo -->
<div class="mt-10 text-center text-xs text-[var(--text-color-secondary)] opacity-80">

View File

@@ -0,0 +1,9 @@
<template>
<div class="p-4">
<h1>My Appointments</h1>
</div>
</template>
<script setup>
// temporary placeholder
</script>

View File

@@ -0,0 +1,9 @@
<template>
<div class="p-4">
<h1>Add New Appointments</h1>
</div>
</template>
<script setup>
// temporary placeholder
</script>

View File

@@ -0,0 +1,330 @@
<template>
<div class="p-4 md:p-6">
<Toast />
<!-- Header -->
<div class="mb-4 overflow-hidden rounded-[2rem] border border-[var(--surface-border)] bg-[var(--surface-card)]">
<div class="relative p-5 md:p-7">
<!-- blobs sutis -->
<div class="pointer-events-none absolute inset-0 opacity-80">
<div class="absolute -top-16 -right-20 h-72 w-72 rounded-full bg-indigo-400/10 blur-3xl" />
<div class="absolute top-10 -left-24 h-80 w-80 rounded-full bg-emerald-400/10 blur-3xl" />
<div class="absolute -bottom-20 right-24 h-72 w-72 rounded-full bg-fuchsia-400/10 blur-3xl" />
</div>
<div class="relative flex flex-col gap-2">
<div class="flex items-center justify-between gap-3">
<div class="min-w-0">
<h1 class="text-xl md:text-2xl font-semibold leading-tight">Módulos da Clínica</h1>
<p class="mt-1 text-sm opacity-80">
Ative/desative recursos por clínica. Isso controla menu, rotas (guard) e acesso no banco (RLS).
</p>
</div>
<div class="shrink-0 flex items-center gap-2">
<Button
label="Recarregar"
icon="pi pi-refresh"
severity="secondary"
:loading="loading"
@click="reload"
/>
</div>
</div>
<div class="mt-2 flex flex-wrap items-center gap-2 text-xs opacity-80">
<span class="inline-flex items-center gap-2 rounded-full border border-[var(--surface-border)] px-3 py-1">
<i class="pi pi-building" />
Tenant: <b class="font-mono">{{ tenantId || '—' }}</b>
</span>
<span class="inline-flex items-center gap-2 rounded-full border border-[var(--surface-border)] px-3 py-1">
<i class="pi pi-user" />
Role: <b>{{ role || '—' }}</b>
</span>
</div>
</div>
</div>
</div>
<!-- Presets -->
<div class="mb-4 grid grid-cols-1 md:grid-cols-3 gap-3">
<Card class="rounded-[2rem]">
<template #content>
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<div class="text-sm font-semibold">Preset: Coworking</div>
<div class="mt-1 text-xs opacity-80">
Para aluguel de salas: sem pacientes, com salas.
</div>
</div>
<Button size="small" label="Aplicar" severity="secondary" :loading="applyingPreset" @click="applyPreset('coworking')" />
</div>
</template>
</Card>
<Card class="rounded-[2rem]">
<template #content>
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<div class="text-sm font-semibold">Preset: Clínica com recepção</div>
<div class="mt-1 text-xs opacity-80">
Para secretária gerenciar agenda (pacientes opcional).
</div>
</div>
<Button size="small" label="Aplicar" severity="secondary" :loading="applyingPreset" @click="applyPreset('reception')" />
</div>
</template>
</Card>
<Card class="rounded-[2rem]">
<template #content>
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<div class="text-sm font-semibold">Preset: Clínica completa</div>
<div class="mt-1 text-xs opacity-80">
Pacientes + recepção + salas (se quiser).
</div>
</div>
<Button size="small" label="Aplicar" severity="secondary" :loading="applyingPreset" @click="applyPreset('full')" />
</div>
</template>
</Card>
</div>
<!-- Modules -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-3">
<Card class="rounded-[2rem]">
<template #content>
<ModuleRow
title="Pacientes"
desc="Habilita gestão de pacientes por clínica. Todo paciente tem um responsável (therapist)."
icon="pi pi-users"
:enabled="isOn('patients')"
:loading="savingKey === 'patients'"
@toggle="toggle('patients')"
/>
<Divider class="my-4" />
<div class="text-xs opacity-80 leading-relaxed">
Quando desligado:
<ul class="mt-2 list-disc pl-5 space-y-1">
<li>Menu Pacientes some.</li>
<li>Rotas com <span class="font-mono">meta.tenantFeature = 'patients'</span> redirecionam pra .</li>
<li>RLS bloqueia acesso direto no banco.</li>
</ul>
</div>
</template>
</Card>
<Card class="rounded-[2rem]">
<template #content>
<ModuleRow
title="Recepção / Secretária"
desc="Permite um papel de secretária gerenciar a agenda dos profissionais (sem precisar ver tudo do paciente)."
icon="pi pi-briefcase"
:enabled="isOn('shared_reception')"
:loading="savingKey === 'shared_reception'"
@toggle="toggle('shared_reception')"
/>
<Divider class="my-4" />
<div class="text-xs opacity-80 leading-relaxed">
Observação: este módulo é produto (UX + permissões). A base aqui é o toggle.
Depois a gente cria:
<ul class="mt-2 list-disc pl-5 space-y-1">
<li>role <span class="font-mono">secretary</span> em <span class="font-mono">tenant_members</span></li>
<li>policies e telas para a secretária</li>
<li>nível de visibilidade do paciente na agenda</li>
</ul>
</div>
</template>
</Card>
<Card class="rounded-[2rem]">
<template #content>
<ModuleRow
title="Salas / Coworking"
desc="Habilita cadastro e reserva de salas/recursos no agendamento."
icon="pi pi-building"
:enabled="isOn('rooms')"
:loading="savingKey === 'rooms'"
@toggle="toggle('rooms')"
/>
<Divider class="my-4" />
<div class="text-xs opacity-80 leading-relaxed">
Isso prepara o terreno para a clínica operar como locação de sala, com agenda vinculando sala + profissional.
</div>
</template>
</Card>
<Card class="rounded-[2rem]">
<template #content>
<ModuleRow
title="Link externo de cadastro"
desc="Libera fluxo público de intake/cadastro externo para a clínica."
icon="pi pi-link"
:enabled="isOn('intake_public')"
:loading="savingKey === 'intake_public'"
@toggle="toggle('intake_public')"
/>
<Divider class="my-4" />
<div class="text-xs opacity-80 leading-relaxed">
Você tem páginas de link externo. Isso vira o controle fino: a clínica decide se usa ou não.
</div>
</template>
</Card>
</div>
</div>
</template>
<script setup>
import { computed, onMounted, ref } from 'vue'
import Toast from 'primevue/toast'
import { useToast } from 'primevue/usetoast'
import Card from 'primevue/card'
import Button from 'primevue/button'
import Divider from 'primevue/divider'
import ToggleButton from 'primevue/togglebutton'
import { useTenantStore } from '@/stores/tenantStore'
import { useTenantFeaturesStore } from '@/stores/tenantFeaturesStore'
const toast = useToast()
const tenantStore = useTenantStore()
const tf = useTenantFeaturesStore()
const loading = computed(() => tf.loading)
const tenantId = computed(() => tenantStore.activeTenantId || null)
const role = computed(() => tenantStore.activeRole || null)
const savingKey = ref(null)
const applyingPreset = ref(false)
function isOn (key) {
return tf.isEnabled(key)
}
async function reload () {
if (!tenantId.value) return
await tf.fetchForTenant(tenantId.value, { force: true })
}
async function toggle (key) {
if (!tenantId.value) {
toast.add({ severity: 'warn', summary: 'Sem tenant ativo', detail: 'Selecione/ative um tenant primeiro.', life: 2500 })
return
}
savingKey.value = key
try {
const next = !tf.isEnabled(key)
await tf.setForTenant(tenantId.value, key, next)
toast.add({
severity: 'success',
summary: 'Atualizado',
detail: `${labelOf(key)}: ${next ? 'Ativado' : 'Desativado'}`,
life: 2500
})
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao atualizar módulo', life: 3500 })
} finally {
savingKey.value = null
}
}
function labelOf (key) {
if (key === 'patients') return 'Pacientes'
if (key === 'shared_reception') return 'Recepção / Secretária'
if (key === 'rooms') return 'Salas / Coworking'
if (key === 'intake_public') return 'Link externo de cadastro'
return key
}
async function applyPreset (preset) {
if (!tenantId.value) return
applyingPreset.value = true
try {
// Presets = sets mínimos (nada destrói dados; só liga/desliga acesso/UX)
const map = {
coworking: {
patients: false,
shared_reception: false,
rooms: true,
intake_public: false
},
reception: {
patients: false,
shared_reception: true,
rooms: false,
intake_public: false
},
full: {
patients: true,
shared_reception: true,
rooms: true,
intake_public: true
}
}
const cfg = map[preset]
if (!cfg) return
// aplica sequencialmente (simples e previsível)
for (const [k, v] of Object.entries(cfg)) {
await tf.setForTenant(tenantId.value, k, v)
}
toast.add({ severity: 'success', summary: 'Preset aplicado', detail: 'Configuração atualizada.', life: 2500 })
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao aplicar preset', life: 3500 })
} finally {
applyingPreset.value = false
}
}
onMounted(async () => {
if (!tenantStore.loaded && !tenantStore.loading) {
await tenantStore.loadSessionAndTenant()
}
if (tenantId.value) {
await tf.fetchForTenant(tenantId.value, { force: false })
}
})
/**
* Sub-componente local: linha de módulo
* (mantive aqui pra você visualizar rápido sem criar pasta de components)
*/
const ModuleRow = {
props: {
title: String,
desc: String,
icon: String,
enabled: Boolean,
loading: Boolean
},
emits: ['toggle'],
components: { ToggleButton },
template: `
<div class="flex items-start justify-between gap-4">
<div class="min-w-0">
<div class="flex items-center gap-2">
<i :class="icon" class="opacity-80" />
<div class="text-base font-semibold">{{ title }}</div>
</div>
<div class="mt-1 text-sm opacity-80">{{ desc }}</div>
</div>
<div class="shrink-0">
<ToggleButton
:modelValue="enabled"
onLabel="Ativo"
offLabel="Inativo"
:loading="loading"
@update:modelValue="$emit('toggle')"
/>
</div>
</div>
`
}
</script>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,361 +0,0 @@
<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(() => PUBLIC_BASE_URL || window.location.origin)
const publicUrl = computed(() => {
if (!inviteToken.value) return ''
return `${origin.value}/cadastro/paciente?t=${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 t = newToken()
const { error } = await supabase.rpc('rotate_patient_invite_token', { p_new_token: t })
if (error) throw error
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 {
toast.add({ severity: 'warn', summary: 'Atenção', detail: 'Não foi possível copiar automaticamente.', life: 2500 })
}
}
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>

View File

@@ -1,834 +0,0 @@
<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'
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
// filtro por status (se ativado)
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) {
// 0) tenta achar foto em vários lugares (raiz e payload jsonb)
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
)
// Se já for URL completa, usa direto
if (direct && looksLikeUrl(direct)) return direct
// 1) se for path de storage, monta publicUrl
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,
// às vezes guardam o path dentro de "direct"
direct
)
if (!path) return null
// se o "path" veio como URL, devolve
if (looksLikeUrl(path)) return path
const { data } = supabase.storage.from(AVATAR_BUCKET).getPublicUrl(path)
return data?.publicUrl || null
}
// -----------------------------
// 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 : '—'
}
function fmtBirth (v) {
if (!v) return '—'
return String(v)
}
function fmtDate (iso) {
if (!iso) return '—'
const d = new Date(iso)
if (Number.isNaN(d.getTime())) return String(iso)
return d.toLocaleString('pt-BR')
}
// -----------------------------
// Seções do modal
// -----------------------------
const intakeSections = computed(() => {
const i = dlg.value.item
if (!i) return []
const avatarUrl = getAvatarUrlFromItem(i)
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))
])
].map(s => ({ ...s, avatarUrl }))
})
// -----------------------------
// 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
})
} 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
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 a foto do intake, se existir
const intakeAvatar = cleanStr(item.avatar_url) || cleanStr(item.foto_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: fNasc(item) || null, // 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
}
// limpa 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 (chips clicáveis) -->
<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 WRAPPER -->
<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="getAvatarUrlFromItem(data)"
:image="getAvatarUrlFromItem(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 conceitual -->
<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">
<template v-if="(dlgAvatar = getAvatarUrlFromItem(dlg.item))">
<Avatar :image="dlgAvatar" alt="avatar" shape="circle" size="xlarge" />
</template>
<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>

View File

@@ -1,617 +0,0 @@
<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: ''
})
const cards = computed(() =>
(groups.value || [])
.filter(g => Number(g.patients_count ?? g.patient_count ?? 0) > 0)
.sort(
(a, b) =>
Number(b.patients_count ?? b.patient_count ?? 0) -
Number(a.patients_count ?? a.patient_count ?? 0)
)
)
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 {
groups.value = await listGroupsWithCounts()
} 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(`/admin/pacientes/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>

View File

@@ -1,899 +0,0 @@
<script setup>
import { ref, computed, watch, nextTick, onMounted, onBeforeUnmount } from 'vue'
import Dialog from 'primevue/dialog'
import Card from 'primevue/card'
import Button from 'primevue/button'
import Toast from 'primevue/toast'
import InputText from 'primevue/inputtext'
import Textarea from 'primevue/textarea'
import Chip from 'primevue/chip'
import FloatLabel from 'primevue/floatlabel'
import IconField from 'primevue/iconfield'
import InputIcon from 'primevue/inputicon'
import Accordion from 'primevue/accordion'
import AccordionPanel from 'primevue/accordionpanel'
import AccordionHeader from 'primevue/accordionheader'
import AccordionContent from 'primevue/accordioncontent'
import Popover from 'primevue/popover'
import { useToast } from 'primevue/usetoast'
import { supabase } from '@/lib/supabase/client'
const toast = useToast()
const loadError = ref('')
const props = defineProps({
modelValue: { type: Boolean, default: false },
patient: { type: Object, default: () => ({}) } // precisa ter id
})
const emit = defineEmits(['update:modelValue', 'close'])
const model = computed({
get: () => props.modelValue,
set: (v) => emit('update:modelValue', v)
})
function isEmpty(v) {
if (v === null || v === undefined) return true
const s = String(v).trim()
return !s
}
function dash(v) {
const s = String(v ?? '').trim()
return s ? s : '—'
}
/**
* Pega o primeiro campo "existente e não-vazio" em ordem.
* Útil pra transição EN -> PT sem quebrar o prontuário.
*/
function pick(obj, keys = []) {
for (const k of keys) {
const v = obj?.[k]
if (!isEmpty(v)) return v
}
return null
}
// ------------------------------------------------------
// accordion (pode abrir vários) + scroll
// ------------------------------------------------------
const accordionValues = ['0', '1', '2', '3', '4']
const activeValues = ref(['0']) // começa com o primeiro aberto
const activeValue = computed(() => activeValues.value?.[0] ?? null)
const panelHeaderRefs = ref([])
function setPanelHeaderRef(el, idx) {
if (!el) return
panelHeaderRefs.value[idx] = el
}
const allOpen = computed(() => accordionValues.every(v => activeValues.value.includes(v)))
function toggleAllAccordions() {
activeValues.value = allOpen.value ? [] : [...accordionValues]
}
/**
* Abre o painel clicado (e fecha os outros).
*/
async function openPanel(i) {
const v = String(i)
activeValues.value = [v]
await nextTick()
const headerRef = panelHeaderRefs.value?.[i]
const el = headerRef?.$el ?? headerRef
if (el && typeof el.scrollIntoView === 'function') {
el.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
}
const navItems = [
{ value: '0', label: 'Cadastro', icon: 'pi pi-pencil' },
{ value: '1', label: 'Endereço', icon: 'pi pi-map-marker' },
{ value: '2', label: 'Dados adicionais', icon: 'pi pi-tags' },
{ value: '3', label: 'Responsável', icon: 'pi pi-users' },
{ value: '4', label: 'Anotações', icon: 'pi pi-file-edit' }
]
const navPopover = ref(null)
function toggleNav(event) { navPopover.value?.toggle(event) }
function selectNav(item) { openPanel(Number(item.value)); navPopover.value?.hide() }
const selectedNav = computed(() => navItems.find(i => i.value === activeValue.value) || null)
// Responsivo < 1200px
const isCompact = ref(false)
let mql = null
let mqlHandler = null
function syncCompact() { isCompact.value = !!mql?.matches }
onMounted(() => {
mql = window.matchMedia('(max-width: 1199px)')
mqlHandler = () => syncCompact()
mql.addEventListener?.('change', mqlHandler)
mql.addListener?.(mqlHandler)
syncCompact()
})
onBeforeUnmount(() => {
mql?.removeEventListener?.('change', mqlHandler)
mql?.removeListener?.(mqlHandler)
})
// ------------------------------------------------------
// Data load (read-only)
// ------------------------------------------------------
const loading = ref(false)
const patientFull = ref(null)
const groupName = ref(null)
const tags = ref([])
const patientData = computed(() => patientFull.value || props.patient || {})
const fallbackAvatar =
'https://images.unsplash.com/photo-1524504388940-b1c1722653e1?auto=format&fit=crop&w=600&q=60'
const avatarUrl = computed(() => patientData.value?.avatar_url || patientData.value?.avatar || fallbackAvatar)
function onlyDigits(v) { return String(v ?? '').replace(/\D/g, '') }
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 onlyRgChars(v) {
return String(v ?? '').toUpperCase().replace(/[^0-9X]/g, '')
}
function fmtRG(v) {
const s = onlyRgChars(v)
if (!s) return '—'
if (s.length === 9) return `${s.slice(0, 2)}.${s.slice(2, 5)}.${s.slice(5, 8)}-${s.slice(8)}`
if (s.length === 8) return `${s.slice(0, 2)}.${s.slice(2, 5)}.${s.slice(5, 8)}`
return s
}
function fmtDateBR(isoOrDate) {
if (!isoOrDate) return '—'
const d = new Date(isoOrDate)
if (Number.isNaN(d.getTime())) return dash(isoOrDate)
const dd = String(d.getDate()).padStart(2, '0')
const mm = String(d.getMonth() + 1).padStart(2, '0')
const yy = d.getFullYear()
return `${dd}/${mm}/${yy}`
}
function calcAge(isoOrDate) {
if (!isoOrDate) return null
const d = new Date(isoOrDate)
if (Number.isNaN(d.getTime())) return null
const now = new Date()
let age = now.getFullYear() - d.getFullYear()
const m = now.getMonth() - d.getMonth()
if (m < 0 || (m === 0 && now.getDate() < d.getDate())) age--
return age
}
function fmtGender(v) {
const s = String(v ?? '').trim()
if (!s) return '—'
const x = s.toLowerCase()
if (['m', 'masc', 'masculino', 'male', 'man', 'homem'].includes(x)) return 'Masculino'
if (['f', 'fem', 'feminino', 'female', 'woman', 'mulher'].includes(x)) return 'Feminino'
if (['nb', 'nao-binario', 'não-binário', 'nonbinary', 'non-binary', 'non_binary', 'genderqueer'].includes(x)) return 'Não-binário'
if (['outro', 'other'].includes(x)) return 'Outro'
if (['na', 'n/a', 'none', 'unknown'].includes(x)) return 'Não informado'
return s
}
function fmtMarital(v) {
const s = String(v ?? '').trim()
if (!s) return '—'
const x = s.toLowerCase()
if (['solteiro', 'solteira', 'single'].includes(x)) return 'Solteiro(a)'
if (['casado', 'casada', 'married'].includes(x)) return 'Casado(a)'
if (['divorciado', 'divorciada', 'divorced'].includes(x)) return 'Divorciado(a)'
if (['viuvo', 'viúva', 'viuvo(a)', 'widowed'].includes(x)) return 'Viúvo(a)'
if (['uniao estavel', 'união estável', 'civil union'].includes(x)) return 'União estável'
if (['na', 'n/a', 'none', 'unknown'].includes(x)) return 'Não informado'
return s
}
function onlyDigitsPhone(v) { return String(v ?? '').replace(/\D/g, '') }
function fmtPhoneMobile(v) {
const d = onlyDigitsPhone(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
}
const birthValue = computed(() => pick(patientData.value, ['data_nascimento', 'birth_date']))
const ageLabel = computed(() => {
const age = calcAge(birthValue.value)
return age == null ? '—' : `${age} anos`
})
// cadastro (fallback PT/EN)
const nomeCompleto = computed(() => pick(patientData.value, ['nome_completo', 'name']))
const telefone = computed(() => pick(patientData.value, ['telefone', 'phone']))
const emailPrincipal = computed(() => pick(patientData.value, ['email_principal', 'email']))
const emailAlternativo = computed(() => pick(patientData.value, ['email_alternativo', 'email_alt', 'emailAlt']))
const telefoneAlternativo = computed(() => pick(patientData.value, ['telefone_alternativo', 'phone_alt', 'phoneAlt']))
const genero = computed(() => pick(patientData.value, ['genero', 'gender']))
const estadoCivil = computed(() => pick(patientData.value, ['estado_civil', 'marital_status']))
const naturalidade = computed(() => pick(patientData.value, ['naturalidade', 'birthplace', 'place_of_birth']))
const observacoes = computed(() => pick(patientData.value, ['observacoes', 'notes_short']))
const ondeNosConheceu = computed(() => pick(patientData.value, ['onde_nos_conheceu', 'lead_source']))
const encaminhadoPor = computed(() => pick(patientData.value, ['encaminhado_por', 'referred_by']))
// endereço
const cep = computed(() => pick(patientData.value, ['cep', 'postal_code']))
const pais = computed(() => pick(patientData.value, ['pais', 'country']))
const cidade = computed(() => pick(patientData.value, ['cidade', 'city']))
const estado = computed(() => pick(patientData.value, ['estado', 'state']))
const endereco = computed(() => pick(patientData.value, ['endereco', 'address_line']))
const numero = computed(() => pick(patientData.value, ['numero', 'address_number']))
const bairro = computed(() => pick(patientData.value, ['bairro', 'neighborhood']))
const complemento = computed(() => pick(patientData.value, ['complemento', 'address_complement']))
// dados adicionais
const escolaridade = computed(() => pick(patientData.value, ['escolaridade', 'education', 'education_level']))
const profissao = computed(() => pick(patientData.value, ['profissao', 'profession']))
const nomeParente = computed(() => pick(patientData.value, ['nome_parente', 'relative_name']))
const grauParentesco = computed(() => pick(patientData.value, ['grau_parentesco', 'relative_relation']))
const telefoneParente = computed(() => pick(patientData.value, ['telefone_parente', 'relative_phone']))
// responsável
const nomeResponsavel = computed(() => pick(patientData.value, ['nome_responsavel', 'guardian_name']))
const cpfResponsavel = computed(() => pick(patientData.value, ['cpf_responsavel', 'guardian_cpf']))
const telefoneResponsavel = computed(() => pick(patientData.value, ['telefone_responsavel', 'guardian_phone']))
const observacaoResponsavel = computed(() => pick(patientData.value, ['observacao_responsavel', 'guardian_note']))
// notas internas
const notasInternas = computed(() => pick(patientData.value, ['notas_internas', 'notes']))
async function getPatientById(id) {
const { data, error } = await supabase
.from('patients')
.select('*')
.eq('id', id)
.maybeSingle()
if (error) throw error
return data
}
async function getPatientRelations(id) {
const { data: g, error: ge } = await supabase
.from('patient_group_patient')
.select('patient_group_id')
.eq('patient_id', id)
if (ge) throw ge
const { data: t, error: te } = await supabase
.from('patient_patient_tag')
.select('tag_id')
.eq('patient_id', id)
if (te) throw te
return {
groupIds: (g || []).map(x => x.patient_group_id).filter(Boolean),
tagIds: (t || []).map(x => x.tag_id).filter(Boolean)
}
}
/**
* ✅ AQUI estava o erro:
* patient_groups NÃO tem "name", tem "nome"
*/
async function getGroupsByIds(ids) {
if (!ids?.length) return []
const { data, error } = await supabase
.from('patient_groups')
.select('id, nome')
.in('id', ids)
if (error) throw error
return (data || []).map(g => ({
id: g.id,
name: g.nome
}))
}
/**
* ✅ AQUI estava o erro:
* patient_tags NÃO tem "name/color", tem "nome/cor"
*/
async function getTagsByIds(ids) {
if (!ids?.length) return []
const { data, error } = await supabase
.from('patient_tags')
.select('id, nome, cor')
.in('id', ids)
.order('nome', { ascending: true })
if (error) throw error
return (data || []).map(t => ({
id: t.id,
name: t.nome,
color: t.cor
}))
}
async function loadProntuario(id) {
loadError.value = ''
loading.value = true
patientFull.value = null
groupName.value = null
tags.value = []
try {
const p = await getPatientById(id)
if (!p) throw new Error('Paciente não retornou dados (RLS bloqueando ou ID não existe no banco).')
patientFull.value = p
const rel = await getPatientRelations(id)
const groups = await getGroupsByIds(rel.groupIds || [])
groupName.value = groups?.[0]?.name || null
tags.value = await getTagsByIds(rel.tagIds || [])
} catch (e) {
loadError.value = e?.message || 'Falha ao buscar dados no Supabase.'
toast.add({ severity: 'error', summary: 'Erro ao carregar prontuário', detail: loadError.value, life: 4500 })
} finally {
loading.value = false
}
}
watch(
[() => props.modelValue, () => props.patient?.id],
async ([open, id], [prevOpen, prevId]) => {
if (!open || !id) return
if (open === prevOpen && id === prevId) return
activeValues.value = ['0']
await loadProntuario(id)
},
{ immediate: true }
)
function close() {
model.value = false
emit('close')
}
async function copyResumo() {
const txt =
`Paciente: ${dash(nomeCompleto.value)}
Idade: ${ageLabel.value}
Grupo: ${dash(groupName.value)}
Telefone: ${dash(telefone.value)}
Email: ${dash(emailPrincipal.value)}
CPF: ${dash(patientData.value?.cpf)}
RG: ${dash(patientData.value?.rg)}
Nascimento: ${fmtDateBR(birthValue.value)}
Tags: ${(tags.value || []).map(t => t.name).filter(Boolean).join(', ') || '—'}
`
try {
await navigator.clipboard.writeText(txt)
toast.add({ severity: 'success', summary: 'Copiado', detail: 'Resumo copiado para a área de transferência.', life: 2200 })
} catch {
toast.add({ severity: 'error', summary: 'Falha', detail: 'Não foi possível copiar.', life: 3000 })
}
}
</script>
<template>
<Dialog
v-model:visible="model"
modal
maximizable
:style="{ width: '96vw', maxWidth: '1400px' }"
:contentStyle="{ padding: 0 }"
@hide="close"
>
<Toast />
<div class="bg-gray-100">
<div class="p-3">
<Card class="shadow-sm rounded-2xl overflow-hidden">
<template #title>
<div class="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div>
<div class="text-lg font-semibold leading-none">Prontuário</div>
<div class="mt-1 text-sm text-slate-600">
Paciente: <b>{{ dash(nomeCompleto) }}</b> · Idade: <b>{{ ageLabel }}</b>
</div>
</div>
<div class="flex flex-wrap gap-2">
<Button
:label="allOpen ? 'Fechar seções' : 'Abrir seções'"
:icon="allOpen ? 'pi pi-angle-double-up' : 'pi pi-angle-double-down'"
severity="secondary"
outlined
@click="toggleAllAccordions"
/>
<Button label="Copiar resumo" icon="pi pi-copy" severity="secondary" outlined @click="copyResumo" />
<Button label="Fechar" icon="pi pi-times" severity="secondary" outlined @click="close" />
</div>
</div>
</template>
<template #content>
<div v-if="loadError" class="m-3 rounded-xl border border-red-200 bg-white p-3">
<div class="font-semibold text-red-600">Falha ao carregar</div>
<div class="mt-1 text-sm text-slate-700">{{ loadError }}</div>
</div>
<div v-if="loading" class="p-4 text-slate-600">Carregando</div>
<div v-else class="grid grid-cols-1 xl:grid-cols-[260px_1fr] gap-4 p-3">
<!-- sidebar -->
<aside class="xl:sticky xl:top-2 self-start">
<div class="rounded-2xl border border-slate-200 bg-white p-3">
<div class="flex flex-col items-center gap-3">
<div class="h-24 w-24 rounded-full overflow-hidden border border-slate-200 bg-slate-50">
<img :src="avatarUrl" alt="avatar" class="h-full w-full object-cover" />
</div>
<div class="w-full text-sm text-slate-700">
<div><b>Grupo:</b> {{ dash(groupName) }}</div>
<div class="mt-3">
<b>Tags:</b>
<div class="mt-2 flex flex-wrap gap-2">
<Chip v-for="t in tags" :key="t.id" :label="t.name" />
<span v-if="!tags?.length" class="text-slate-500"></span>
</div>
</div>
</div>
</div>
</div>
<!-- >=1200px -->
<div v-if="!isCompact" class="mt-3 flex flex-col gap-2">
<button
v-for="item in navItems"
:key="item.value"
type="button"
class="w-full rounded-xl border bg-white px-3 py-2 text-left flex items-center gap-2 transition
hover:-translate-y-[1px] hover:bg-slate-50"
:class="activeValue === item.value ? 'border-primary-300 bg-primary-50' : 'border-slate-200'"
@click="openPanel(Number(item.value))"
>
<i :class="item.icon" class="opacity-80"></i>
<span class="font-medium">{{ item.label }}</span>
</button>
</div>
</aside>
<!-- main -->
<main class="min-w-0">
<!-- <1200px -->
<div v-if="isCompact" class="sticky top-2 z-10 rounded-2xl border border-slate-200 bg-white p-3 mb-3">
<Button
type="button"
class="w-full"
icon="pi pi-chevron-down"
iconPos="right"
:label="selectedNav ? selectedNav.label : 'Selecionar seção'"
@click="toggleNav($event)"
/>
<Popover ref="navPopover">
<div class="min-w-[260px] flex flex-col gap-3">
<span class="font-medium block">Seções</span>
<ul class="list-none p-0 m-0 flex flex-col gap-1">
<li
v-for="item in navItems"
:key="item.value"
class="flex items-center gap-2 px-2 py-2 cursor-pointer rounded-lg hover:bg-slate-100"
:class="activeValue === item.value ? 'bg-slate-100' : ''"
@click="selectNav(item)"
>
<i :class="item.icon" class="opacity-85"></i>
<span class="font-medium">{{ item.label }}</span>
</li>
</ul>
</div>
</Popover>
</div>
<Accordion multiple v-model:value="activeValues">
<AccordionPanel value="0">
<AccordionHeader :ref="el => setPanelHeaderRef(el, 0)">1. INFORMAÇÕES PESSOAIS</AccordionHeader>
<AccordionContent>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
<div class="md:col-span-2 min-w-0">
<FloatLabel variant="on">
<IconField>
<InputIcon class="pi pi-user" />
<InputText :modelValue="dash(nomeCompleto)" class="w-full" variant="filled" readonly />
</IconField>
<label>Nome completo</label>
</FloatLabel>
</div>
<div class="min-w-0">
<FloatLabel variant="on">
<IconField>
<InputIcon class="pi pi-phone" />
<InputText :modelValue="fmtPhoneMobile(telefone)" class="w-full" variant="filled" readonly />
</IconField>
<label>Telefone / Celular</label>
</FloatLabel>
</div>
<div class="min-w-0">
<FloatLabel variant="on">
<IconField>
<InputIcon class="pi pi-envelope" />
<InputText :modelValue="dash(emailPrincipal)" class="w-full" variant="filled" readonly />
</IconField>
<label>Email principal</label>
</FloatLabel>
</div>
<div class="min-w-0">
<FloatLabel variant="on">
<IconField>
<InputIcon class="pi pi-envelope" />
<InputText :modelValue="dash(emailAlternativo)" class="w-full" variant="filled" readonly />
</IconField>
<label>Email alternativo</label>
</FloatLabel>
</div>
<div class="min-w-0">
<FloatLabel variant="on">
<IconField>
<InputIcon class="pi pi-phone" />
<InputText :modelValue="fmtPhoneMobile(telefoneAlternativo)" class="w-full" variant="filled" readonly />
</IconField>
<label>Telefone alternativo</label>
</FloatLabel>
</div>
<div class="min-w-0">
<FloatLabel variant="on">
<IconField>
<InputIcon class="pi pi-calendar" />
<InputText :modelValue="fmtDateBR(birthValue)" class="w-full" variant="filled" readonly />
</IconField>
<label>Data de nascimento</label>
</FloatLabel>
</div>
<div class="min-w-0">
<FloatLabel variant="on">
<IconField>
<InputIcon class="pi pi-user" />
<InputText :modelValue="fmtGender(genero)" class="w-full" variant="filled" readonly />
</IconField>
<label>Gênero</label>
</FloatLabel>
</div>
<div class="min-w-0">
<FloatLabel variant="on">
<IconField>
<InputIcon class="pi pi-heart" />
<InputText :modelValue="fmtMarital(estadoCivil)" class="w-full" variant="filled" readonly />
</IconField>
<label>Estado civil</label>
</FloatLabel>
</div>
<div class="min-w-0">
<FloatLabel variant="on">
<IconField>
<InputIcon class="pi pi-id-card" />
<InputText :modelValue="fmtCPF(patientData.cpf)" class="w-full" variant="filled" readonly />
</IconField>
<label>CPF</label>
</FloatLabel>
</div>
<div class="min-w-0">
<FloatLabel variant="on">
<IconField>
<InputIcon class="pi pi-id-card" />
<InputText :modelValue="fmtRG(patientData.rg)" class="w-full" variant="filled" readonly />
</IconField>
<label>RG</label>
</FloatLabel>
</div>
<div class="md:col-span-2 min-w-0">
<FloatLabel variant="on">
<IconField>
<InputIcon class="pi pi-map" />
<InputText :modelValue="dash(naturalidade)" class="w-full" variant="filled" readonly />
</IconField>
<label>Naturalidade</label>
</FloatLabel>
</div>
<div class="md:col-span-2 min-w-0">
<FloatLabel variant="on">
<Textarea :modelValue="dash(observacoes)" rows="3" class="w-full" variant="filled" readonly />
<label>Observações</label>
</FloatLabel>
</div>
<div class="min-w-0">
<FloatLabel variant="on">
<IconField>
<InputIcon class="pi pi-folder-open" />
<InputText :modelValue="dash(groupName)" class="w-full" variant="filled" readonly />
</IconField>
<label>Grupos</label>
</FloatLabel>
<small class="text-slate-500">Utilizado para importar o formulário de anamnese</small>
</div>
<div class="min-w-0">
<FloatLabel variant="on">
<IconField>
<InputIcon class="pi pi-tag" />
<InputText
:modelValue="(tags || []).map(t => t.name).filter(Boolean).join(', ') || '—'"
class="w-full"
variant="filled"
readonly
/>
</IconField>
<label>Tags</label>
</FloatLabel>
</div>
<div class="min-w-0">
<FloatLabel variant="on">
<IconField>
<InputIcon class="pi pi-megaphone" />
<InputText :modelValue="dash(ondeNosConheceu)" class="w-full" variant="filled" readonly />
</IconField>
<label>Onde nos conheceu?</label>
</FloatLabel>
</div>
<div class="min-w-0">
<FloatLabel variant="on">
<IconField>
<InputIcon class="pi pi-share-alt" />
<InputText :modelValue="dash(encaminhadoPor)" class="w-full" variant="filled" readonly />
</IconField>
<label>Encaminhado por</label>
</FloatLabel>
</div>
</div>
</AccordionContent>
</AccordionPanel>
<AccordionPanel value="1">
<AccordionHeader :ref="el => setPanelHeaderRef(el, 1)">2. ENDEREÇO</AccordionHeader>
<AccordionContent>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
<div class="min-w-0">
<FloatLabel variant="on">
<IconField>
<InputIcon class="pi pi-map-marker" />
<InputText :modelValue="dash(cep)" class="w-full" variant="filled" readonly />
</IconField>
<label>CEP</label>
</FloatLabel>
</div>
<div class="min-w-0">
<FloatLabel variant="on">
<IconField>
<InputIcon class="pi pi-map" />
<InputText :modelValue="dash(pais)" class="w-full" variant="filled" readonly />
</IconField>
<label>País</label>
</FloatLabel>
</div>
<div class="min-w-0">
<FloatLabel variant="on">
<IconField>
<InputIcon class="pi pi-building" />
<InputText :modelValue="dash(cidade)" class="w-full" variant="filled" readonly />
</IconField>
<label>Cidade</label>
</FloatLabel>
</div>
<div class="min-w-0">
<FloatLabel variant="on">
<IconField>
<InputIcon class="pi pi-compass" />
<InputText :modelValue="dash(estado)" class="w-full" variant="filled" readonly />
</IconField>
<label>Estado</label>
</FloatLabel>
</div>
<div class="min-w-0">
<FloatLabel variant="on">
<IconField>
<InputIcon class="pi pi-home" />
<InputText :modelValue="dash(endereco)" class="w-full" variant="filled" readonly />
</IconField>
<label>Endereço</label>
</FloatLabel>
</div>
<div class="min-w-0">
<FloatLabel variant="on">
<IconField>
<InputIcon class="pi pi-sort-numeric-up" />
<InputText :modelValue="dash(numero)" class="w-full" variant="filled" readonly />
</IconField>
<label>Número</label>
</FloatLabel>
</div>
<div class="min-w-0">
<FloatLabel variant="on">
<IconField>
<InputIcon class="pi pi-map-marker" />
<InputText :modelValue="dash(bairro)" class="w-full" variant="filled" readonly />
</IconField>
<label>Bairro</label>
</FloatLabel>
</div>
<div class="min-w-0">
<FloatLabel variant="on">
<IconField>
<InputIcon class="pi pi-align-left" />
<InputText :modelValue="dash(complemento)" class="w-full" variant="filled" readonly />
</IconField>
<label>Complemento</label>
</FloatLabel>
</div>
</div>
</AccordionContent>
</AccordionPanel>
<AccordionPanel value="2">
<AccordionHeader :ref="el => setPanelHeaderRef(el, 2)">3. DADOS ADICIONAIS</AccordionHeader>
<AccordionContent>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
<div class="min-w-0">
<FloatLabel variant="on">
<IconField>
<InputIcon class="pi pi-book" />
<InputText :modelValue="dash(escolaridade)" class="w-full" variant="filled" readonly />
</IconField>
<label>Escolaridade</label>
</FloatLabel>
</div>
<div class="min-w-0">
<FloatLabel variant="on">
<IconField>
<InputIcon class="pi pi-briefcase" />
<InputText :modelValue="dash(profissao)" class="w-full" variant="filled" readonly />
</IconField>
<label>Profissão</label>
</FloatLabel>
</div>
<div class="min-w-0">
<FloatLabel variant="on">
<IconField>
<InputIcon class="pi pi-user" />
<InputText :modelValue="dash(nomeParente)" class="w-full" variant="filled" readonly />
</IconField>
<label>Nome de um parente</label>
</FloatLabel>
</div>
<div class="min-w-0">
<FloatLabel variant="on">
<IconField>
<InputIcon class="pi pi-users" />
<InputText :modelValue="dash(grauParentesco)" class="w-full" variant="filled" readonly />
</IconField>
<label>Grau de parentesco</label>
</FloatLabel>
</div>
<div class="min-w-0">
<FloatLabel variant="on">
<IconField>
<InputIcon class="pi pi-phone" />
<InputText :modelValue="fmtPhoneMobile(telefoneParente)" class="w-full" variant="filled" readonly />
</IconField>
<label>Telefone do parente</label>
</FloatLabel>
</div>
</div>
</AccordionContent>
</AccordionPanel>
<AccordionPanel value="3">
<AccordionHeader :ref="el => setPanelHeaderRef(el, 3)">4. RESPONSÁVEL</AccordionHeader>
<AccordionContent>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
<div class="md:col-span-2 min-w-0">
<FloatLabel variant="on">
<IconField>
<InputIcon class="pi pi-user" />
<InputText :modelValue="dash(nomeResponsavel)" class="w-full" variant="filled" readonly />
</IconField>
<label>Nome do responsável</label>
</FloatLabel>
</div>
<div class="min-w-0">
<FloatLabel variant="on">
<IconField>
<InputIcon class="pi pi-id-card" />
<InputText :modelValue="fmtCPF(cpfResponsavel)" class="w-full" variant="filled" readonly />
</IconField>
<label>CPF do responsável</label>
</FloatLabel>
</div>
<div class="min-w-0">
<FloatLabel variant="on">
<IconField>
<InputIcon class="pi pi-phone" />
<InputText :modelValue="fmtPhoneMobile(telefoneResponsavel)" class="w-full" variant="filled" readonly />
</IconField>
<label>Telefone do responsável</label>
</FloatLabel>
</div>
<div class="md:col-span-2 min-w-0">
<FloatLabel variant="on">
<Textarea :modelValue="dash(observacaoResponsavel)" rows="3" class="w-full" variant="filled" readonly />
<label>Observação do responsável</label>
</FloatLabel>
</div>
</div>
</AccordionContent>
</AccordionPanel>
<AccordionPanel value="4">
<AccordionHeader :ref="el => setPanelHeaderRef(el, 4)">5. ANOTAÇÕES INTERNAS</AccordionHeader>
<AccordionContent>
<small class="block mb-3 text-slate-500">
Este campo é interno e NÃO aparece no cadastro externo.
</small>
<FloatLabel variant="on">
<Textarea :modelValue="dash(notasInternas)" rows="7" class="w-full" variant="filled" readonly />
<label>Notas internas</label>
</FloatLabel>
</AccordionContent>
</AccordionPanel>
</Accordion>
</main>
</div>
</template>
</Card>
</div>
</div>
</Dialog>
</template>

View File

@@ -1,816 +0,0 @@
<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/pacientes_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) {
// no teu router, a rota de edição é /admin/pacientes/cadastro/:id
router.push(`/admin/pacientes/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>

View File

@@ -13,6 +13,9 @@ import Button from 'primevue/button'
import Dialog from 'primevue/dialog'
import { useToast } from 'primevue/usetoast'
// ✅ sessão (fonte de verdade p/ saas admin)
import { initSession, sessionIsSaasAdmin } from '@/app/session'
const tenant = useTenantStore()
const toast = useToast()
const router = useRouter()
@@ -39,9 +42,10 @@ function isEmail (v) {
}
function roleToPath (role) {
if (role === 'tenant_admin') return '/admin'
// ✅ aceita os dois nomes (seu banco está devolvendo tenant_admin)
if (role === 'clinic_admin' || role === 'tenant_admin' || role === 'admin') return '/admin'
if (role === 'therapist') return '/therapist'
if (role === 'patient') return '/patient'
if (role === 'patient') return '/portal'
return '/'
}
@@ -69,6 +73,31 @@ async function onSubmit () {
if (res.error) throw res.error
// ✅ garante que sessionIsSaasAdmin esteja hidratado após login
// (evita cair no fluxo de tenant quando o usuário é SaaS master)
try {
await initSession({ initial: false })
} catch (e) {
console.warn('[Login] initSession pós-login falhou:', e)
// não aborta login por isso
}
// lembrar e-mail (não senha)
persistRememberedEmail()
// ✅ prioridade: redirect_after_login (se existir)
// mas antes, se for SaaS admin, NÃO exigir tenant.
const redirect = sessionStorage.getItem('redirect_after_login')
if (sessionIsSaasAdmin.value) {
if (redirect) {
sessionStorage.removeItem('redirect_after_login')
router.push(redirect)
return
}
router.push('/saas')
return
}
// ✅ agora que está autenticado, garante tenant pessoal (Modelo B)
try {
await supabase.rpc('ensure_personal_tenant')
@@ -91,10 +120,7 @@ async function onSubmit () {
return
}
// lembrar e-mail (não senha)
persistRememberedEmail()
const redirect = sessionStorage.getItem('redirect_after_login')
// ✅ se havia redirect, vai pra ele
if (redirect) {
sessionStorage.removeItem('redirect_after_login')
router.push(redirect)
@@ -119,7 +145,6 @@ async function onSubmit () {
}
}
function openForgot () {
recoverySent.value = false
recoveryEmail.value = email.value?.trim() || ''
@@ -214,33 +239,33 @@ onMounted(() => {
</div>
</div>
<div class="hidden md:flex items-center gap-2">
<RouterLink
to="/"
class="inline-flex items-center gap-2 rounded-full border border-[var(--surface-border)] bg-[var(--surface-ground)] px-3 py-1 text-xs font-medium text-[var(--text-color-secondary)] hover:opacity-80"
title="Atalho para a página de logins de desenvolvimento"
>
<i class="pi pi-code text-xs opacity-80" />
Desenvolvedor Logins
</RouterLink>
<div class="hidden md:flex items-center gap-2">
<RouterLink
to="/"
class="inline-flex items-center gap-2 rounded-full border border-[var(--surface-border)] bg-[var(--surface-ground)] px-3 py-1 text-xs font-medium text-[var(--text-color-secondary)] hover:opacity-80"
title="Atalho para a página de logins de desenvolvimento"
>
<i class="pi pi-code text-xs opacity-80" />
Desenvolvedor Logins
</RouterLink>
<RouterLink
:to="{ name: 'resetPassword' }"
class="inline-flex items-center gap-2 rounded-full border border-[var(--surface-border)] bg-[var(--surface-ground)] px-3 py-1 text-xs font-medium text-[var(--text-color-secondary)] hover:opacity-80"
>
<span class="h-1.5 w-1.5 rounded-full bg-emerald-400/70" />
Trocar senha
</RouterLink>
</div>
<div class="col-span-12 md:hidden">
<RouterLink
to="/"
class="inline-flex w-full items-center justify-center gap-2 rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] px-4 py-3 text-sm font-medium text-[var(--text-color-secondary)] hover:opacity-80"
>
<i class="pi pi-code opacity-80" />
Desenvolvedor Logins
</RouterLink>
</div>
<RouterLink
:to="{ name: 'resetPassword' }"
class="inline-flex items-center gap-2 rounded-full border border-[var(--surface-border)] bg-[var(--surface-ground)] px-3 py-1 text-xs font-medium text-[var(--text-color-secondary)] hover:opacity-80"
>
<span class="h-1.5 w-1.5 rounded-full bg-emerald-400/70" />
Trocar senha
</RouterLink>
</div>
<div class="col-span-12 md:hidden">
<RouterLink
to="/"
class="inline-flex w-full items-center justify-center gap-2 rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] px-4 py-3 text-sm font-medium text-[var(--text-color-secondary)] hover:opacity-80"
>
<i class="pi pi-code opacity-80" />
Desenvolvedor Logins
</RouterLink>
</div>
</div>
</div>
@@ -450,4 +475,4 @@ onMounted(() => {
</div>
</div>
</div>
</template>
</template>

View File

@@ -70,13 +70,16 @@ const enabledFeatureIdsByPlanId = computed(() => {
const currentPlanId = computed(() => subscription.value?.plan_id || null)
function planKeyById(id) {
function planKeyById (id) {
return planById.value.get(id)?.key || null
}
const currentPlanKey = computed(() => planKeyById(currentPlanId.value))
const currentPlanKey = computed(() => {
// ✅ fallback: se não carregou plans ainda, usa o plan_key da subscription
return planKeyById(currentPlanId.value) || subscription.value?.plan_key || null
})
function friendlyFeatureLabel(featureKey) {
function friendlyFeatureLabel (featureKey) {
return featureLabels[featureKey] || featureKey
}
@@ -92,7 +95,7 @@ const sortedPlans = computed(() => {
return arr
})
function planBenefits(planId) {
function planBenefits (planId) {
const set = enabledFeatureIdsByPlanId.value.get(planId) || new Set()
const list = features.value.map((f) => ({
ok: set.has(f.id),
@@ -104,34 +107,82 @@ function planBenefits(planId) {
return list
}
function goBack() {
function goBack () {
router.back()
}
function goBilling() {
function goBilling () {
router.push('/admin/billing')
}
function contactSupport() {
function contactSupport () {
router.push('/admin/billing')
}
async function fetchAll() {
// ✅ revalida a rota atual para o guard reavaliar features após troca de plano
async function revalidateCurrentRoute () {
// tenta respeitar um redirectTo (quando usuário veio por recurso bloqueado)
const redirectTo = route.query.redirectTo ? String(route.query.redirectTo) : null
// se existe redirectTo, tente ir para ele (guard decide se entra ou volta ao upgrade)
if (redirectTo) {
try {
await router.replace(redirectTo)
return
} catch (_) {
// se falhar, cai no refresh da rota atual
}
}
// força o vue-router a reprocessar a rota (dispara beforeEach)
try {
await router.replace(router.currentRoute.value.fullPath)
} catch (_) {}
}
async function fetchAll () {
loading.value = true
try {
const tid = tenantId.value
if (!tid) throw new Error('Tenant ativo não encontrado.')
const [pRes, fRes, pfRes, sRes] = await Promise.all([
supabase.from('plans').select('*').order('key', { ascending: true }),
supabase.from('plans').select('*').eq('is_active', true).order('key', { ascending: true }),
supabase.from('features').select('*').order('key', { ascending: true }),
supabase.from('plan_features').select('plan_id, feature_id'),
supabase
.from('subscriptions')
.select('id, tenant_id, plan_id, plan_key, interval, status, created_at, updated_at')
// ✅ pega mais campos úteis e faz join do plano (ajuda a exibir e debugar)
.select(`
id,
tenant_id,
user_id,
plan_id,
plan_key,
"interval",
status,
provider,
source,
started_at,
current_period_start,
current_period_end,
created_at,
updated_at,
plan:plan_id (
id,
key,
name,
description,
price_cents,
currency,
billing_interval,
is_active
)
`)
.eq('tenant_id', tid)
.eq('status', 'active')
.order('updated_at', { ascending: false })
// ✅ created_at é mais confiável que updated_at em assinaturas manuais
.order('created_at', { ascending: false })
.limit(1)
.maybeSingle()
])
@@ -142,13 +193,20 @@ async function fetchAll() {
// ✅ subscription pode ser null sem quebrar a página
if (sRes.error) {
console.warn('[Upgrade] sem subscription ativa (ok):', sRes.error)
console.warn('[Upgrade] erro ao buscar subscription:', sRes.error)
}
plans.value = pRes.data || []
features.value = fRes.data || []
planFeatures.value = pfRes.data || []
subscription.value = sRes.data || null
// pode remover esses logs depois
console.groupCollapsed('[Upgrade] fetchAll')
console.log('tenantId:', tid)
console.log('subscription:', subscription.value)
console.log('currentPlanKey:', currentPlanKey.value)
console.groupEnd()
} catch (e) {
console.error(e)
toast.add({ severity: 'error', summary: 'Erro', detail: e.message || String(e), life: 5000 })
@@ -157,7 +215,7 @@ async function fetchAll() {
}
}
async function changePlan(targetPlanId) {
async function changePlan (targetPlanId) {
if (!subscription.value?.id) {
toast.add({
severity: 'warn',
@@ -187,17 +245,32 @@ async function changePlan(targetPlanId) {
// atualiza estado local
subscription.value.plan_id = data?.plan_id || targetPlanId
subscription.value.plan_key = data?.plan_key || planKeyById(subscription.value.plan_id) || subscription.value.plan_key
// ✅ recarrega entitlements (sem reload)
// (importante pra refletir o plano imediatamente)
entitlementsStore.clear?.()
await entitlementsStore.fetch(tid, { force: true })
// seu store tem loadForTenant no guard; se existir aqui também, use primeiro
if (typeof entitlementsStore.loadForTenant === 'function') {
await entitlementsStore.loadForTenant(tid, { force: true })
} else if (typeof entitlementsStore.fetch === 'function') {
await entitlementsStore.fetch(tid, { force: true })
}
toast.add({
severity: 'success',
summary: 'Plano atualizado',
detail: `Agora você está no plano ${planKeyById(subscription.value.plan_id) || ''}`.trim(),
detail: `Agora você está no plano ${planKeyById(subscription.value.plan_id) || subscription.value.plan_key || ''}`.trim(),
life: 3000
})
// ✅ garante consistência (principalmente se RPC mexer em mais campos)
await fetchAll()
// ✅ dispara o guard novamente: se o usuário perdeu acesso a uma rota PRO,
// ele deve ser redirecionado automaticamente.
await revalidateCurrentRoute()
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e.message || String(e), life: 5000 })
} finally {
@@ -205,7 +278,11 @@ async function changePlan(targetPlanId) {
}
}
onMounted(fetchAll)
onMounted(async () => {
// ✅ garante que o tenant já foi carregado antes de buscar planos
if (!tenantStore.loaded) await tenantStore.loadSessionAndTenant()
await fetchAll()
})
// se trocar tenant ativo, recarrega
watch(
@@ -393,4 +470,4 @@ watch(
Observação: alguns recursos PRO podem depender de configuração inicial (ex.: SMS exige provedor).
</div>
</div>
</template>
</template>

View File

@@ -0,0 +1,9 @@
<template>
<div class="p-4">
<h1>My Appointments</h1>
</div>
</template>
<script setup>
// temporary placeholder
</script>

View File

@@ -0,0 +1,9 @@
<template>
<div class="p-4">
<h1>Add New Appointments</h1>
</div>
</template>
<script setup>
// temporary placeholder
</script>

View File

@@ -0,0 +1,270 @@
<template>
<div class="min-h-screen flex items-center justify-center p-6 bg-[var(--surface-ground)] text-[var(--text-color)]">
<div class="w-full max-w-lg rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-6 shadow-sm">
<h1 class="text-xl font-semibold mb-2">Aceitar convite</h1>
<p class="text-sm opacity-80 mb-6">
Vamos validar seu convite e ativar seu acesso ao tenant.
</p>
<div v-if="state.loading" class="text-sm">
Processando convite
</div>
<div v-else-if="state.success" class="space-y-3">
<div class="text-sm">
Convite aceito com sucesso. Redirecionando
</div>
</div>
<div v-else-if="state.error" class="space-y-4">
<div class="rounded-xl border border-red-500/30 bg-red-500/10 p-4 text-sm">
<div class="font-semibold mb-1">Não foi possível aceitar o convite</div>
<div class="opacity-90">{{ state.error }}</div>
</div>
<div class="flex gap-2">
<button
class="px-4 py-2 rounded-xl border border-[var(--surface-border)] hover:opacity-90 text-sm"
@click="retry"
>
Tentar novamente
</button>
<button
class="px-4 py-2 rounded-xl bg-[var(--primary-color)] text-[var(--primary-color-text)] hover:opacity-90 text-sm"
@click="goLogin"
>
Ir para login
</button>
</div>
<p class="text-xs opacity-70">
Se você recebeu um convite, confirme se está logado com o mesmo e-mail do convite.
</p>
</div>
<div v-else class="text-sm opacity-80">
Preparando
</div>
</div>
</div>
</template>
<script setup>
import { reactive, onMounted, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { supabase } from '@/lib/supabase/client'
import { useTenantStore } from '@/stores/tenantStore'
/**
* Token persistence (antes do login)
* - sessionStorage: some ao fechar a aba (bom para convite)
* - se você preferir cross-tab, use localStorage
*/
const PENDING_INVITE_TOKEN_KEY = 'pending_invite_token_v1'
function persistPendingToken (token) {
try { sessionStorage.setItem(PENDING_INVITE_TOKEN_KEY, token) } catch (_) {}
}
function readPendingToken () {
try { return sessionStorage.getItem(PENDING_INVITE_TOKEN_KEY) } catch (_) { return null }
}
function clearPendingToken () {
try { sessionStorage.removeItem(PENDING_INVITE_TOKEN_KEY) } catch (_) {}
}
const route = useRoute()
const router = useRouter()
const tenantStore = useTenantStore()
const state = reactive({
loading: true,
success: false,
error: ''
})
const tokenFromQuery = computed(() => {
const t = route.query?.token
return typeof t === 'string' ? t.trim() : ''
})
function isUuid (v) {
// UUID v1v5 (aceita maiúsculas/minúsculas)
return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(v)
}
function friendlyError (err) {
const msg = (err?.message || err || '').toString()
// Ajuste esses “match” conforme as mensagens/raises do seu SQL.
if (/expired|expirad/i.test(msg)) return 'Este convite expirou. Peça para a clínica reenviar o convite.'
if (/invalid|inval/i.test(msg)) return 'Token inválido. Verifique se você copiou o link corretamente.'
if (/not found|não encontrado|nao encontrado/i.test(msg)) return 'Convite não encontrado ou já utilizado.'
if (/email/i.test(msg) && /mismatch|diferent|different|bate|match/i.test(msg)) {
return 'Você está logado com um e-mail diferente do convite. Faça login com o e-mail correto.'
}
// cobre Postgres raise not_authenticated (P0001) e mensagens de JWT
if (/not_authenticated|not authenticated|jwt|auth/i.test(msg)) {
return 'Você precisa estar logado para aceitar este convite.'
}
return 'Não foi possível concluir o aceite. Tente novamente ou peça para reenviar o convite.'
}
async function goLogin () {
const token = tokenFromQuery.value || readPendingToken()
if (token) persistPendingToken(token)
// ✅ garante troca de conta
await supabase.auth.signOut()
// ✅ volta para o accept com token (ou com o storage pendente)
// (mantém o link “real” para o login conseguir retornar certo)
const returnTo = token ? `/accept-invite?token=${encodeURIComponent(token)}` : '/accept-invite'
await router.replace({ path: '/auth/login', query: { redirect: returnTo } })
}
async function acceptInvite (token) {
state.loading = true
state.error = ''
state.success = false
// 1) sessão
// Obs: getSession lê do storage; não use pra “autorizar” no client,
// mas aqui é só fluxo/UX; o servidor valida de verdade.
const { data: sessionData, error: sessionErr } = await supabase.auth.getSession()
if (sessionErr) {
state.loading = false
state.error = friendlyError(sessionErr)
return
}
const session = sessionData?.session
if (!session) {
// não logado → salva token e vai pro login
persistPendingToken(token)
// ✅ importante: /login dá 404 no seu projeto; use /auth/login
// ✅ preserve o returnTo com querystring (token)
const returnTo = route.fullPath || `/accept-invite?token=${encodeURIComponent(token)}`
await router.replace({ path: '/auth/login', query: { redirect: returnTo } })
// não seta erro: é fluxo normal
state.loading = false
return
}
// (debug útil: garante que a aba anônima realmente tem user/session)
try {
const s = await supabase.auth.getSession()
const u = await supabase.auth.getUser()
console.log('[accept-invite] session user:', s?.data?.session?.user?.id, s?.data?.session?.user?.email)
console.log('[accept-invite] getUser:', u?.data?.user?.id, u?.data?.user?.email)
} catch (_) {}
// 2) chama RPC
// IMPORTANTÍSSIMO: a função deve validar:
// - token existe, status=invited, não expirou
// - email do invite == auth.email do caller
// - cria/ativa tenant_members (status=active)
// - revoga/consome invite
//
// A assinatura de args depende do seu SQL:
// - se for tenant_accept_invite(token uuid) → { token }
// - se for tenant_accept_invite(p_token uuid) → { p_token: token }
//
// ✅ NO SEU CASO: a assinatura existente é p_token (confirmado no SQL Editor).
const { data, error } = await supabase.rpc('tenant_accept_invite', { p_token: token })
if (error) {
state.loading = false
// mostra o motivo real na tela (e não uma mensagem genérica)
state.error = error?.message ? error.message : friendlyError(error)
return
}
// 3) sucesso → limpa token pendente
clearPendingToken()
// 4) atualiza tenantStore (boa prática: refresh completo do “contexto do usuário”)
// Ideal: sua RPC retorna tenant_id (e opcionalmente role/status)
const acceptedTenantId = data?.tenant_id || data?.tenantId || null
try {
await refreshTenantContextAfterInvite(acceptedTenantId)
} catch (e) {
// mesmo que refresh falhe, o aceite ocorreu; ainda redireciona, mas você pode avisar
// (mantive silencioso para não “quebrar” o fluxo).
}
state.loading = false
state.success = true
// 5) redireciona
await router.replace('/admin')
}
/**
* Melhor prática de atualização do tenantStore após aceite:
* - 1) refetch “meus tenants + memberships” (fonte da verdade)
* - 2) setActiveTenantId (se veio no retorno; senão, escolha um padrão)
* - 3) carregar contexto do tenant ativo (permissões/entitlements/branding/etc)
*/
async function refreshTenantContextAfterInvite (acceptedTenantId) {
// Ajuste para os métodos reais do seu tenantStore:
// Exemplo recomendado de API do store:
// - await tenantStore.fetchMyTenants()
// - await tenantStore.fetchMyMemberships()
// - tenantStore.setActiveTenantId(...)
// - await tenantStore.hydrateActiveTenantContext()
if (typeof tenantStore.refreshMyTenantsAndMemberships === 'function') {
await tenantStore.refreshMyTenantsAndMemberships()
} else {
if (typeof tenantStore.fetchMyTenants === 'function') await tenantStore.fetchMyTenants()
if (typeof tenantStore.fetchMyMemberships === 'function') await tenantStore.fetchMyMemberships()
}
if (acceptedTenantId && typeof tenantStore.setActiveTenantId === 'function') {
tenantStore.setActiveTenantId(acceptedTenantId)
}
if (typeof tenantStore.hydrateActiveTenantContext === 'function') {
await tenantStore.hydrateActiveTenantContext()
} else if (typeof tenantStore.refreshActiveTenant === 'function') {
await tenantStore.refreshActiveTenant()
}
}
async function run () {
state.loading = true
state.error = ''
state.success = false
// 1) token: query > pendente (pós-login)
const token = tokenFromQuery.value || readPendingToken()
if (!token) {
state.loading = false
state.error = 'Token ausente. Abra novamente o link do convite.'
return
}
if (!isUuid(token)) {
state.loading = false
state.error = 'Token inválido. Verifique se o link está completo.'
return
}
// Se veio da query, persiste (caso precise atravessar login)
if (tokenFromQuery.value) persistPendingToken(token)
// 2) tenta aceitar
await acceptInvite(token)
}
async function retry () {
await run()
}
onMounted(run)
</script>

View File

@@ -0,0 +1,9 @@
<template>
<div class="p-4">
<h1>My Appointments</h1>
</div>
</template>
<script setup>
// temporary placeholder
</script>

View File

@@ -0,0 +1,9 @@
<template>
<div class="p-4">
<h1>Add New Appointments</h1>
</div>
</template>
<script setup>
// temporary placeholder
</script>