Ajuste em Massa - Paciente, Terapeuta, Clinica e Admin - Inicio agenda
This commit is contained in:
@@ -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">
|
||||
|
||||
9
src/views/pages/admin/agenda/MyAppointmentsPage.vue
Normal file
9
src/views/pages/admin/agenda/MyAppointmentsPage.vue
Normal file
@@ -0,0 +1,9 @@
|
||||
<template>
|
||||
<div class="p-4">
|
||||
<h1>My Appointments</h1>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
// temporary placeholder
|
||||
</script>
|
||||
9
src/views/pages/admin/agenda/NewAppointmentPage.vue
Normal file
9
src/views/pages/admin/agenda/NewAppointmentPage.vue
Normal file
@@ -0,0 +1,9 @@
|
||||
<template>
|
||||
<div class="p-4">
|
||||
<h1>Add New Appointments</h1>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
// temporary placeholder
|
||||
</script>
|
||||
330
src/views/pages/admin/clinic/ClinicFeaturesPage.vue
Normal file
330
src/views/pages/admin/clinic/ClinicFeaturesPage.vue
Normal 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 cá.</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 é só 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ê já 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>
|
||||
1113
src/views/pages/admin/clinic/ClinicProfessionalsPage.vue
Normal file
1113
src/views/pages/admin/clinic/ClinicProfessionalsPage.vue
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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 já foi compartilhado.
|
||||
</Message>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right: Concept / Instructions -->
|
||||
<div class="lg:col-span-5">
|
||||
<div class="rounded-2xl border border-slate-200 bg-white shadow-sm overflow-hidden">
|
||||
<div class="p-5 border-b border-slate-200">
|
||||
<div class="text-lg font-semibold text-slate-900">Como funciona</div>
|
||||
<div class="text-slate-600 text-sm mt-1">
|
||||
Um fluxo simples, mas com cuidado clínico: menos fricção, mais adesão.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-5">
|
||||
<ol class="space-y-4">
|
||||
<li class="flex gap-3">
|
||||
<div class="h-8 w-8 rounded-xl bg-slate-900 text-slate-50 grid place-items-center text-sm font-semibold">1</div>
|
||||
<div class="min-w-0">
|
||||
<div class="font-semibold text-slate-900">Você envia o link</div>
|
||||
<div class="text-sm text-slate-600 mt-1">
|
||||
Pode ser WhatsApp, e-mail ou mensagem direta. O link abre a página de cadastro externo.
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li class="flex gap-3">
|
||||
<div class="h-8 w-8 rounded-xl bg-slate-900 text-slate-50 grid place-items-center text-sm font-semibold">2</div>
|
||||
<div class="min-w-0">
|
||||
<div class="font-semibold text-slate-900">O paciente preenche</div>
|
||||
<div class="text-sm text-slate-600 mt-1">
|
||||
Campos opcionais podem ser deixados em branco. A ideia é reduzir ansiedade e acelerar o início.
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li class="flex gap-3">
|
||||
<div class="h-8 w-8 rounded-xl bg-slate-900 text-slate-50 grid place-items-center text-sm font-semibold">3</div>
|
||||
<div class="min-w-0">
|
||||
<div class="font-semibold text-slate-900">Você recebe no admin</div>
|
||||
<div class="text-sm text-slate-600 mt-1">
|
||||
Os dados entram como “cadastro recebido”. Você revisa, completa e transforma em paciente quando quiser.
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<div class="mt-6 rounded-2xl border border-slate-200 bg-slate-50 p-4">
|
||||
<div class="font-semibold text-slate-900 flex items-center gap-2">
|
||||
<i class="pi pi-shield text-slate-700"></i>
|
||||
Boas práticas
|
||||
</div>
|
||||
<ul class="mt-2 space-y-2 text-sm text-slate-700">
|
||||
<li class="flex gap-2">
|
||||
<i class="pi pi-check text-emerald-600 mt-0.5"></i>
|
||||
<span>Gere um novo link se você suspeitar que ele foi repassado indevidamente.</span>
|
||||
</li>
|
||||
<li class="flex gap-2">
|
||||
<i class="pi pi-check text-emerald-600 mt-0.5"></i>
|
||||
<span>Envie junto uma mensagem curta: “preencha com calma; campos opcionais podem ficar em branco”.</span>
|
||||
</li>
|
||||
<li class="flex gap-2">
|
||||
<i class="pi pi-check text-emerald-600 mt-0.5"></i>
|
||||
<span>Evite divulgar em público; é um link pensado para compartilhamento individual.</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 text-xs text-slate-500">
|
||||
Se você quiser, eu deixo este card ainda mais “noir” (contraste, microtextos, ícones, sombras) sem perder legibilidade.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Small helper card -->
|
||||
<div class="mt-4 rounded-2xl border border-slate-200 bg-white shadow-sm p-5">
|
||||
<div class="font-semibold text-slate-900">Mensagem pronta (copiar/colar)</div>
|
||||
<div class="text-sm text-slate-600 mt-1">
|
||||
Se quiser, use este texto ao enviar o link:
|
||||
</div>
|
||||
|
||||
<div class="mt-3 rounded-xl bg-slate-50 border border-slate-200 p-3 text-sm text-slate-800">
|
||||
Olá! Segue o link para seu pré-cadastro. Preencha com calma — campos opcionais podem ficar em branco:
|
||||
<span class="block mt-2 font-mono break-words">{{ publicUrl || '…' }}</span>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 flex gap-2">
|
||||
<Button
|
||||
icon="pi pi-copy"
|
||||
label="Copiar mensagem"
|
||||
severity="secondary"
|
||||
outlined
|
||||
:disabled="!publicUrl"
|
||||
@click="copyInviteMessage"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Toast is global in layout usually; if not, add <Toast /> -->
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import Button from 'primevue/button'
|
||||
import Card from 'primevue/card'
|
||||
import InputText from 'primevue/inputtext'
|
||||
import Message from 'primevue/message'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { supabase } from '@/lib/supabase/client'
|
||||
|
||||
const toast = useToast()
|
||||
|
||||
const inviteToken = ref('')
|
||||
const rotating = ref(false)
|
||||
|
||||
/**
|
||||
* Se o cadastro externo estiver em outro domínio, fixe aqui:
|
||||
* ex.: const PUBLIC_BASE_URL = 'https://seusite.com'
|
||||
* se vazio, usa window.location.origin
|
||||
*/
|
||||
const PUBLIC_BASE_URL = '' // opcional
|
||||
|
||||
const origin = computed(() => 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>
|
||||
@@ -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>
|
||||
@@ -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 há 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
9
src/views/pages/portal/agenda/MyAppointmentsPage.vue
Normal file
9
src/views/pages/portal/agenda/MyAppointmentsPage.vue
Normal file
@@ -0,0 +1,9 @@
|
||||
<template>
|
||||
<div class="p-4">
|
||||
<h1>My Appointments</h1>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
// temporary placeholder
|
||||
</script>
|
||||
9
src/views/pages/portal/agenda/NewAppointmentPage.vue
Normal file
9
src/views/pages/portal/agenda/NewAppointmentPage.vue
Normal file
@@ -0,0 +1,9 @@
|
||||
<template>
|
||||
<div class="p-4">
|
||||
<h1>Add New Appointments</h1>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
// temporary placeholder
|
||||
</script>
|
||||
270
src/views/pages/public/AcceptInvitePage.vue
Normal file
270
src/views/pages/public/AcceptInvitePage.vue
Normal 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 v1–v5 (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>
|
||||
9
src/views/pages/therapist/agenda/MyAppointmentsPage.vue
Normal file
9
src/views/pages/therapist/agenda/MyAppointmentsPage.vue
Normal file
@@ -0,0 +1,9 @@
|
||||
<template>
|
||||
<div class="p-4">
|
||||
<h1>My Appointments</h1>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
// temporary placeholder
|
||||
</script>
|
||||
9
src/views/pages/therapist/agenda/NewAppointmentPage.vue
Normal file
9
src/views/pages/therapist/agenda/NewAppointmentPage.vue
Normal file
@@ -0,0 +1,9 @@
|
||||
<template>
|
||||
<div class="p-4">
|
||||
<h1>Add New Appointments</h1>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
// temporary placeholder
|
||||
</script>
|
||||
Reference in New Issue
Block a user