first commit

This commit is contained in:
Leonardo
2026-02-18 22:36:45 -03:00
parent ec6b6ef53a
commit 676042268b
122 changed files with 26354 additions and 1615 deletions

View File

@@ -1,5 +1,5 @@
<script setup>
import { ProductService } from '@/service/ProductService';
import { ProductService } from '@/services/ProductService';
import { FilterMatchMode } from '@primevue/core/api';
import { useToast } from 'primevue/usetoast';
import { onMounted, ref } from 'vue';

View File

@@ -0,0 +1,293 @@
<script setup>
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { supabase } from '../../lib/supabase/client' // ajuste se o caminho for outro
const router = useRouter()
const checking = ref(true)
const userEmail = ref('')
const role = ref(null)
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@' }
}
function roleToPath(r) {
if (r === 'admin') return '/admin'
if (r === 'therapist') return '/therapist'
if (r === 'patient') return '/patient'
return '/'
}
async function fetchMyRole() {
const { data: userData, error: userErr } = await supabase.auth.getUser()
if (userErr) return null
const user = userData?.user
if (!user) return null
userEmail.value = user.email || ''
const { data, error } = await supabase
.from('profiles')
.select('role')
.eq('id', user.id)
.single()
if (error) return null
return data?.role || null
}
async function go(area) {
// Se já estiver logado, respeita role real (não o card)
const { data: sessionData } = await supabase.auth.getSession()
const session = sessionData?.session
if (session) {
const r = role.value || (await fetchMyRole())
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
// ✅ 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)
sessionStorage.setItem('login_prefill_password', acc.password)
} else {
sessionStorage.removeItem('login_prefill_email')
sessionStorage.removeItem('login_prefill_password')
}
}
router.push('/auth/login')
}
async function goMyPanel() {
if (!role.value) return
router.push(roleToPath(role.value))
}
async function logout() {
await supabase.auth.signOut()
role.value = null
userEmail.value = ''
}
onMounted(async () => {
try {
const { data: sessionData } = await supabase.auth.getSession()
const session = sessionData?.session
if (session) {
role.value = await fetchMyRole()
// Se está logado e tem role, manda direto pro painel
if (role.value) {
router.replace(roleToPath(role.value))
return
}
}
} finally {
checking.value = false
}
})
</script>
<template>
<!-- Estado carregando sessão -->
<div
v-if="checking"
class="relative min-h-screen flex items-center justify-center bg-[var(--surface-ground)]"
>
<div class="text-[var(--text-color-secondary)] text-sm animate-pulse">
Verificando sessão
</div>
</div>
<!-- Página -->
<div
v-else
class="relative min-h-screen overflow-hidden bg-[var(--surface-ground)]"
>
<!-- fundo conceitual -->
<div class="pointer-events-none absolute inset-0">
<!-- grid sutil -->
<div
class="absolute inset-0 opacity-70"
style="
background-image:
linear-gradient(to right, rgba(255,255,255,.05) 1px, transparent 1px),
linear-gradient(to bottom, rgba(255,255,255,.05) 1px, transparent 1px);
background-size: 40px 40px;
mask-image: radial-gradient(ellipse at 50% 15%, rgba(0,0,0,.95), transparent 70%);
"
/>
<!-- halos -->
<div class="absolute -top-32 -right-32 h-[28rem] w-[28rem] rounded-full blur-3xl bg-indigo-400/10" />
<div class="absolute top-20 -left-32 h-[32rem] w-[32rem] rounded-full blur-3xl bg-emerald-400/10" />
<div class="absolute -bottom-36 right-24 h-[28rem] w-[28rem] rounded-full blur-3xl bg-fuchsia-400/10" />
</div>
<div class="relative flex items-center justify-center min-h-screen p-6">
<div class="w-full max-w-6xl">
<div class="overflow-hidden rounded-[2.75rem] border border-[var(--surface-border)] bg-[var(--surface-card)] shadow-2xl">
<!-- HEADER -->
<div class="px-8 pt-10 pb-8">
<div class="flex items-start justify-between gap-6">
<div>
<div class="text-3xl md:text-4xl font-semibold text-[var(--text-color)]">
Agência PSI
</div>
<div class="mt-2 text-[var(--text-color-secondary)] text-sm md:text-base">
Ambiente de acesso e testes de perfis
</div>
</div>
<div class="hidden md:flex items-center gap-2 text-xs text-[var(--text-color-secondary)]">
<span class="inline-block h-1.5 w-1.5 rounded-full bg-primary/60" />
Dev Mode
</div>
</div>
<div class="mt-8 h-px w-full bg-[var(--surface-border)] opacity-70" />
</div>
<!-- SE ESTIVER LOGADO -->
<div
v-if="role"
class="mx-8 mb-6 rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-ground)] p-5 flex flex-col md:flex-row md:items-center md:justify-between gap-4"
>
<div>
<div class="font-semibold text-[var(--text-color)]">
Sessão ativa
</div>
<div class="text-sm text-[var(--text-color-secondary)] mt-1">
{{ userEmail }} perfil: <span class="font-medium">{{ role }}</span>
</div>
</div>
<div class="flex gap-3">
<Button
label="Ir para meu painel"
icon="pi pi-arrow-right"
@click="goMyPanel"
/>
<Button
label="Sair"
severity="secondary"
outlined
@click="logout"
/>
</div>
</div>
<!-- CARDS -->
<div class="px-8 pb-10">
<div class="grid grid-cols-12 gap-6">
<!-- ADMIN -->
<div class="col-span-12 md:col-span-4">
<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')"
>
<div class="flex items-center justify-between mb-4">
<div class="text-xl font-semibold text-[var(--text-color)]">
Admin
</div>
<i class="pi pi-building text-sm opacity-70" />
</div>
<div class="text-sm text-[var(--text-color-secondary)] leading-relaxed">
Gestão da clínica, controle de usuários, permissões, planos e configurações globais.
</div>
<div class="mt-6 text-sm font-medium text-primary opacity-80 group-hover:opacity-100 transition">
Acessar painel
</div>
</div>
</div>
<!-- TERAPEUTA -->
<div class="col-span-12 md:col-span-4">
<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')"
>
<div class="flex items-center justify-between mb-4">
<div class="text-xl font-semibold text-[var(--text-color)]">
Terapeuta
</div>
<i class="pi pi-calendar text-sm opacity-70" />
</div>
<div class="text-sm text-[var(--text-color-secondary)] leading-relaxed">
Agenda, prontuários, evolução clínica, gestão de pacientes e atendimentos.
</div>
<div class="mt-6 text-sm font-medium text-primary opacity-80 group-hover:opacity-100 transition">
Acessar painel
</div>
</div>
</div>
<!-- PACIENTE -->
<div class="col-span-12 md:col-span-4">
<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')"
>
<div class="flex items-center justify-between mb-4">
<div class="text-xl font-semibold text-[var(--text-color)]">
Paciente
</div>
<i class="pi pi-user text-sm opacity-70" />
</div>
<div class="text-sm text-[var(--text-color-secondary)] leading-relaxed">
Visualização de informações pessoais, documentos e interações com a clínica.
</div>
<div class="mt-6 text-sm font-medium text-primary opacity-80 group-hover:opacity-100 transition">
Acessar painel
</div>
</div>
</div>
</div>
<!-- Rodapé explicativo -->
<div class="mt-10 text-center text-xs text-[var(--text-color-secondary)] opacity-80">
Você será redirecionado para o login (se necessário) e, após autenticação,
encaminhado automaticamente ao painel correspondente.
</div>
</div>
</div>
<!-- assinatura visual -->
<div class="mt-6 flex items-center justify-center gap-2 text-xs text-[var(--text-color-secondary)] opacity-70">
<span class="inline-block h-1.5 w-1.5 rounded-full bg-primary/60" />
<span>Ambiente de desenvolvimento</span>
<span class="inline-block h-1.5 w-1.5 rounded-full bg-primary/60" />
</div>
</div>
</div>
</div>
</template>

View File

@@ -1,63 +1,121 @@
<script setup>
import FloatingConfigurator from '@/components/FloatingConfigurator.vue';
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import Button from 'primevue/button'
// Se você ainda usa o FloatingConfigurator no template de páginas públicas,
// pode manter. Se não usa, pode remover tranquilamente.
import FloatingConfigurator from '@/components/FloatingConfigurator.vue'
const router = useRouter()
const route = useRoute()
const attemptedPath = computed(() => route.fullPath || '')
function goDashboard () {
// Em muitos projetos, '/' redireciona para o dashboard correto conforme role.
router.push('/admin')
}
</script>
<template>
<FloatingConfigurator />
<div class="flex items-center justify-center min-h-screen overflow-hidden">
<div class="flex flex-col items-center justify-center">
<svg width="54" height="40" viewBox="0 0 54 40" fill="none" xmlns="http://www.w3.org/2000/svg" class="mb-8 w-32 shrink-0">
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M17.1637 19.2467C17.1566 19.4033 17.1529 19.561 17.1529 19.7194C17.1529 25.3503 21.7203 29.915 27.3546 29.915C32.9887 29.915 37.5561 25.3503 37.5561 19.7194C37.5561 19.5572 37.5524 19.3959 37.5449 19.2355C38.5617 19.0801 39.5759 18.9013 40.5867 18.6994L40.6926 18.6782C40.7191 19.0218 40.7326 19.369 40.7326 19.7194C40.7326 27.1036 34.743 33.0896 27.3546 33.0896C19.966 33.0896 13.9765 27.1036 13.9765 19.7194C13.9765 19.374 13.9896 19.0316 14.0154 18.6927L14.0486 18.6994C15.0837 18.9062 16.1223 19.0886 17.1637 19.2467ZM33.3284 11.4538C31.6493 10.2396 29.5855 9.52381 27.3546 9.52381C25.1195 9.52381 23.0524 10.2421 21.3717 11.4603C20.0078 11.3232 18.6475 11.1387 17.2933 10.907C19.7453 8.11308 23.3438 6.34921 27.3546 6.34921C31.36 6.34921 34.9543 8.10844 37.4061 10.896C36.0521 11.1292 34.692 11.3152 33.3284 11.4538ZM43.826 18.0518C43.881 18.6003 43.9091 19.1566 43.9091 19.7194C43.9091 28.8568 36.4973 36.2642 27.3546 36.2642C18.2117 36.2642 10.8 28.8568 10.8 19.7194C10.8 19.1615 10.8276 18.61 10.8816 18.0663L7.75383 17.4411C7.66775 18.1886 7.62354 18.9488 7.62354 19.7194C7.62354 30.6102 16.4574 39.4388 27.3546 39.4388C38.2517 39.4388 47.0855 30.6102 47.0855 19.7194C47.0855 18.9439 47.0407 18.1789 46.9536 17.4267L43.826 18.0518ZM44.2613 9.54743L40.9084 10.2176C37.9134 5.95821 32.9593 3.1746 27.3546 3.1746C21.7442 3.1746 16.7856 5.96385 13.7915 10.2305L10.4399 9.56057C13.892 3.83178 20.1756 0 27.3546 0C34.5281 0 40.8075 3.82591 44.2613 9.54743Z"
fill="var(--primary-color)"
/>
<mask id="mask0_1413_1551" style="mask-type: alpha" maskUnits="userSpaceOnUse" x="0" y="8" width="54" height="11">
<path d="M27 18.3652C10.5114 19.1944 0 8.88892 0 8.88892C0 8.88892 16.5176 14.5866 27 14.5866C37.4824 14.5866 54 8.88892 54 8.88892C54 8.88892 43.4886 17.5361 27 18.3652Z" fill="var(--primary-color)" />
</mask>
<g mask="url(#mask0_1413_1551)">
<path
d="M-4.673e-05 8.88887L3.73084 -1.91434L-8.00806 17.0473L-4.673e-05 8.88887ZM27 18.3652L26.4253 6.95109L27 18.3652ZM54 8.88887L61.2673 17.7127L50.2691 -1.91434L54 8.88887ZM-4.673e-05 8.88887C-8.00806 17.0473 -8.00469 17.0505 -8.00132 17.0538C-8.00018 17.055 -7.99675 17.0583 -7.9944 17.0607C-7.98963 17.0653 -7.98474 17.0701 -7.97966 17.075C-7.96949 17.0849 -7.95863 17.0955 -7.94707 17.1066C-7.92401 17.129 -7.89809 17.1539 -7.86944 17.1812C-7.8122 17.236 -7.74377 17.3005 -7.66436 17.3743C-7.50567 17.5218 -7.30269 17.7063 -7.05645 17.9221C-6.56467 18.3532 -5.89662 18.9125 -5.06089 19.5534C-3.39603 20.83 -1.02575 22.4605 1.98012 24.0457C7.97874 27.2091 16.7723 30.3226 27.5746 29.7793L26.4253 6.95109C20.7391 7.23699 16.0326 5.61231 12.6534 3.83024C10.9703 2.94267 9.68222 2.04866 8.86091 1.41888C8.45356 1.10653 8.17155 0.867278 8.0241 0.738027C7.95072 0.673671 7.91178 0.637576 7.90841 0.634492C7.90682 0.63298 7.91419 0.639805 7.93071 0.65557C7.93897 0.663455 7.94952 0.673589 7.96235 0.686039C7.96883 0.692262 7.97582 0.699075 7.98338 0.706471C7.98719 0.710167 7.99113 0.714014 7.99526 0.718014C7.99729 0.720008 8.00047 0.723119 8.00148 0.724116C8.00466 0.727265 8.00796 0.730446 -4.673e-05 8.88887ZM27.5746 29.7793C37.6904 29.2706 45.9416 26.3684 51.6602 23.6054C54.5296 22.2191 56.8064 20.8465 58.4186 19.7784C59.2265 19.2431 59.873 18.7805 60.3494 18.4257C60.5878 18.2482 60.7841 18.0971 60.9374 17.977C61.014 17.9169 61.0799 17.8645 61.1349 17.8203C61.1624 17.7981 61.1872 17.7781 61.2093 17.7602C61.2203 17.7512 61.2307 17.7427 61.2403 17.7348C61.2452 17.7308 61.2499 17.727 61.2544 17.7233C61.2566 17.7215 61.2598 17.7188 61.261 17.7179C61.2642 17.7153 61.2673 17.7127 54 8.88887C46.7326 0.0650536 46.7357 0.0625219 46.7387 0.0600241C46.7397 0.0592345 46.7427 0.0567658 46.7446 0.0551857C46.7485 0.0520238 46.7521 0.0489887 46.7557 0.0460799C46.7628 0.0402623 46.7694 0.0349487 46.7753 0.0301318C46.7871 0.0204986 46.7966 0.0128495 46.8037 0.00712562C46.818 -0.00431848 46.8228 -0.00808311 46.8184 -0.00463784C46.8096 0.00228345 46.764 0.0378652 46.6828 0.0983779C46.5199 0.219675 46.2165 0.439161 45.7812 0.727519C44.9072 1.30663 43.5257 2.14765 41.7061 3.02677C38.0469 4.79468 32.7981 6.63058 26.4253 6.95109L27.5746 29.7793ZM54 8.88887C50.2691 -1.91433 50.27 -1.91467 50.271 -1.91498C50.2712 -1.91506 50.272 -1.91535 50.2724 -1.9155C50.2733 -1.91581 50.274 -1.91602 50.2743 -1.91616C50.2752 -1.91643 50.275 -1.91636 50.2738 -1.91595C50.2714 -1.91515 50.2652 -1.91302 50.2552 -1.9096C50.2351 -1.90276 50.1999 -1.89078 50.1503 -1.874C50.0509 -1.84043 49.8938 -1.78773 49.6844 -1.71863C49.2652 -1.58031 48.6387 -1.377 47.8481 -1.13035C46.2609 -0.635237 44.0427 0.0249875 41.5325 0.6823C36.215 2.07471 30.6736 3.15796 27 3.15796V26.0151C33.8087 26.0151 41.7672 24.2495 47.3292 22.7931C50.2586 22.026 52.825 21.2618 54.6625 20.6886C55.5842 20.4011 56.33 20.1593 56.8551 19.986C57.1178 19.8993 57.3258 19.8296 57.4735 19.7797C57.5474 19.7548 57.6062 19.7348 57.6493 19.72C57.6709 19.7127 57.6885 19.7066 57.7021 19.7019C57.7089 19.6996 57.7147 19.6976 57.7195 19.696C57.7219 19.6952 57.7241 19.6944 57.726 19.6938C57.7269 19.6934 57.7281 19.693 57.7286 19.6929C57.7298 19.6924 57.7309 19.692 54 8.88887ZM27 3.15796C23.3263 3.15796 17.7849 2.07471 12.4674 0.6823C9.95717 0.0249875 7.73904 -0.635237 6.15184 -1.13035C5.36118 -1.377 4.73467 -1.58031 4.3155 -1.71863C4.10609 -1.78773 3.94899 -1.84043 3.84961 -1.874C3.79994 -1.89078 3.76474 -1.90276 3.74471 -1.9096C3.73469 -1.91302 3.72848 -1.91515 3.72613 -1.91595C3.72496 -1.91636 3.72476 -1.91643 3.72554 -1.91616C3.72593 -1.91602 3.72657 -1.91581 3.72745 -1.9155C3.72789 -1.91535 3.72874 -1.91506 3.72896 -1.91498C3.72987 -1.91467 3.73084 -1.91433 -4.673e-05 8.88887C-3.73093 19.692 -3.72983 19.6924 -3.72868 19.6929C-3.72821 19.693 -3.72698 19.6934 -3.72603 19.6938C-3.72415 19.6944 -3.72201 19.6952 -3.71961 19.696C-3.71482 19.6976 -3.70901 19.6996 -3.7022 19.7019C-3.68858 19.7066 -3.67095 19.7127 -3.6494 19.72C-3.60629 19.7348 -3.54745 19.7548 -3.47359 19.7797C-3.32589 19.8296 -3.11788 19.8993 -2.85516 19.986C-2.33008 20.1593 -1.58425 20.4011 -0.662589 20.6886C1.17485 21.2618 3.74125 22.026 6.67073 22.7931C12.2327 24.2495 20.1913 26.0151 27 26.0151V3.15796Z"
fill="var(--primary-color)"
/>
</g>
</svg>
<div style="border-radius: 56px; padding: 0.3rem; background: linear-gradient(180deg, color-mix(in srgb, var(--primary-color), transparent 60%) 10%, var(--surface-ground) 30%)">
<div class="w-full bg-surface-0 dark:bg-surface-900 py-20 px-8 sm:px-20 flex flex-col items-center" style="border-radius: 53px">
<span class="text-primary font-bold text-3xl">404</span>
<h1 class="text-surface-900 dark:text-surface-0 font-bold text-3xl lg:text-5xl mb-2">Not Found</h1>
<div class="text-surface-600 dark:text-surface-200 mb-8">Requested resource is not available.</div>
<router-link to="/" class="w-full flex items-center py-8 border-surface-300 dark:border-surface-500 border-b">
<span class="flex justify-center items-center border-2 border-primary text-primary rounded-border" style="height: 3.5rem; width: 3.5rem">
<i class="pi pi-fw pi-table text-2xl!"></i>
</span>
<span class="ml-6 flex flex-col">
<span class="text-surface-900 dark:text-surface-0 lg:text-xl font-medium mb-0 block">Frequently Asked Questions</span>
<span class="text-surface-600 dark:text-surface-200 lg:text-xl">Ultricies mi quis hendrerit dolor.</span>
</span>
</router-link>
<router-link to="/" class="w-full flex items-center py-8 border-surface-300 dark:border-surface-500 border-b">
<span class="flex justify-center items-center border-2 border-primary text-primary rounded-border" style="height: 3.5rem; width: 3.5rem">
<i class="pi pi-fw pi-question-circle text-2xl!"></i>
</span>
<span class="ml-6 flex flex-col">
<span class="text-surface-900 dark:text-surface-0 lg:text-xl font-medium mb-0">Solution Center</span>
<span class="text-surface-600 dark:text-surface-200 lg:text-xl">Phasellus faucibus scelerisque eleifend.</span>
</span>
</router-link>
<router-link to="/" class="w-full flex items-center mb-8 py-8 border-surface-300 dark:border-surface-500 border-b">
<span class="flex justify-center items-center border-2 border-primary text-primary rounded-border" style="height: 3.5rem; width: 3.5rem">
<i class="pi pi-fw pi-unlock text-2xl!"></i>
</span>
<span class="ml-6 flex flex-col">
<span class="text-surface-900 dark:text-surface-0 lg:text-xl font-medium mb-0">Permission Manager</span>
<span class="text-surface-600 dark:text-surface-200 lg:text-xl">Accumsan in nisl nisi scelerisque</span>
</span>
</router-link>
<Button as="router-link" label="Go to Dashboard" to="/" />
</div>
</div>
</div>
<FloatingConfigurator />
<div class="relative min-h-screen overflow-hidden bg-[var(--surface-ground)] text-[var(--text-color)]">
<!-- fundo conceitual: grid + halos -->
<div class="pointer-events-none absolute inset-0 opacity-80">
<!-- grid sutil -->
<div
class="absolute inset-0"
style="
background-image:
linear-gradient(to right, rgba(255,255,255,.05) 1px, transparent 1px),
linear-gradient(to bottom, rgba(255,255,255,.05) 1px, transparent 1px);
background-size: 36px 36px;
mask-image: radial-gradient(ellipse at center, rgba(0,0,0,.9), transparent 70%);
"
/>
<!-- halos -->
<div class="absolute -top-24 -right-24 h-80 w-80 rounded-full blur-3xl bg-primary/10" />
<div class="absolute -bottom-28 -left-28 h-96 w-96 rounded-full blur-3xl bg-purple-500/10" />
</div>
<div class="relative flex min-h-screen items-center justify-center p-6">
<div class="w-full max-w-xl">
<div
class="overflow-hidden rounded-3xl border border-[var(--surface-border)] bg-[var(--surface-card)] shadow-2xl"
>
<!-- faixa superior -->
<div class="relative px-8 pt-10 pb-7">
<div class="flex items-start justify-between gap-6">
<div class="flex flex-col">
<div class="tracking-tight font-semibold text-5xl sm:text-6xl leading-none">
<span class="text-primary">404</span>
</div>
<div class="mt-3 text-xl sm:text-2xl font-medium text-[var(--text-color)]">
Página não encontrada
</div>
<p class="mt-3 text-[var(--text-color-secondary)] leading-relaxed">
A rota que você tentou acessar não existe (ou foi movida).
Se você chegou aqui por um link interno, vale revisar o caminho.
</p>
</div>
<!-- selo minimalista -->
<div
class="shrink-0 rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-ground)] p-4"
>
<div class="flex items-center gap-3">
<span
class="flex h-11 w-11 items-center justify-center rounded-xl border border-primary/30 bg-primary/10"
aria-hidden="true"
>
<i class="pi pi-compass text-xl text-primary" />
</span>
<div class="leading-tight">
<div class="text-sm font-semibold">Rota</div>
<div class="max-w-[12rem] truncate text-sm text-[var(--text-color-secondary)]" :title="attemptedPath">
{{ attemptedPath || '—' }}
</div>
</div>
</div>
</div>
</div>
<!-- linha suave -->
<div class="mt-8 h-px w-full bg-[var(--surface-border)] opacity-70" />
</div>
<!-- ações -->
<div class="px-8 pb-10">
<div class="flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-3">
<div class="text-sm text-[var(--text-color-secondary)]">
Dica: você pode voltar ao fluxo principal do sistema pelo dashboard.
</div>
<Button
label="Voltar ao Dashboard"
icon="pi pi-home"
class="w-full sm:w-auto"
@click="goDashboard"
/>
</div>
<!-- rodapé noir discreto -->
<div class="mt-6 text-xs text-[var(--text-color-secondary)] opacity-80">
Se isso estiver acontecendo com frequência, pode ser um problema de rota ou permissão.
</div>
</div>
</div>
<!-- assinatura visual sutil -->
<div class="mt-6 flex items-center justify-center gap-2 text-xs text-[var(--text-color-secondary)] opacity-70">
<span class="inline-block h-1.5 w-1.5 rounded-full bg-primary/60" />
<span>Agência Psi Quasar</span>
<span class="inline-block h-1.5 w-1.5 rounded-full bg-primary/60" />
</div>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,23 @@
<script setup>
import BestSellingWidget from '@/components/dashboard/BestSellingWidget.vue';
import NotificationsWidget from '@/components/dashboard/NotificationsWidget.vue';
import RecentSalesWidget from '@/components/dashboard/RecentSalesWidget.vue';
import RevenueStreamWidget from '@/components/dashboard/RevenueStreamWidget.vue';
import StatsWidget from '@/components/dashboard/StatsWidget.vue';
</script>
<template>
ADMIN DASHBOARD
<div class="grid grid-cols-12 gap-8">
<StatsWidget />
<div class="col-span-12 xl:col-span-6">
<RecentSalesWidget />
<BestSellingWidget />
</div>
<div class="col-span-12 xl:col-span-6">
<RevenueStreamWidget />
<NotificationsWidget />
</div>
</div>
</template>

View File

@@ -0,0 +1,9 @@
<template>
<div class="p-4">
<h1>Online Scheduling (Manage)</h1>
</div>
</template>
<script setup>
// placeholder para futura implementação
</script>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,361 @@
<template>
<div class="p-4">
<!-- Top header -->
<div class="flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
<div class="min-w-0">
<div class="flex items-center gap-3">
<div class="h-10 w-10 rounded-2xl bg-slate-900 text-slate-50 grid place-items-center shadow-sm">
<i class="pi pi-link text-lg"></i>
</div>
<div class="min-w-0">
<div class="text-2xl font-semibold text-slate-900 leading-tight">
Cadastro Externo
</div>
<div class="text-slate-600 mt-1">
Gere um link para o paciente preencher o pré-cadastro com calma e segurança.
</div>
</div>
</div>
</div>
<div class="flex flex-wrap gap-2 justify-start md:justify-end">
<Button
label="Gerar novo link"
icon="pi pi-refresh"
severity="secondary"
outlined
:loading="rotating"
@click="rotateLink"
/>
</div>
</div>
<!-- Main grid -->
<div class="mt-5 grid grid-cols-1 lg:grid-cols-12 gap-4">
<!-- Left: Link card -->
<div class="lg:col-span-7">
<div class="rounded-2xl border border-slate-200 bg-white shadow-sm overflow-hidden">
<!-- Card head -->
<div class="p-5 border-b border-slate-200">
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<div class="text-lg font-semibold text-slate-900">Seu link</div>
<div class="text-slate-600 text-sm mt-1">
Envie este link ao paciente. Ele abre a página de cadastro externo.
</div>
</div>
<div class="hidden md:flex items-center gap-2">
<span
class="inline-flex items-center gap-2 text-xs px-2.5 py-1 rounded-full border"
:class="inviteToken ? 'border-emerald-200 text-emerald-700 bg-emerald-50' : 'border-slate-200 text-slate-600 bg-slate-50'"
>
<span
class="h-2 w-2 rounded-full"
:class="inviteToken ? 'bg-emerald-500' : 'bg-slate-400'"
></span>
{{ inviteToken ? 'Ativo' : 'Gerando...' }}
</span>
</div>
</div>
</div>
<!-- Card content -->
<div class="p-5">
<!-- Skeleton while loading -->
<div v-if="!inviteToken" class="space-y-3">
<div class="h-10 rounded-xl bg-slate-100 animate-pulse"></div>
<div class="h-10 rounded-xl bg-slate-100 animate-pulse"></div>
<Message severity="info" :closable="false">
Gerando seu link...
</Message>
</div>
<div v-else class="space-y-4">
<!-- Link display + quick actions -->
<div class="flex flex-col gap-2">
<label class="text-sm font-medium text-slate-700">Link público</label>
<div class="flex flex-col gap-2 sm:flex-row sm:items-stretch">
<div class="flex-1 min-w-0">
<InputText
readonly
:value="publicUrl"
class="w-full"
/>
<div class="mt-1 text-xs text-slate-500 break-words">
Token: <span class="font-mono">{{ inviteToken }}</span>
</div>
</div>
<div class="flex gap-2 sm:flex-col sm:w-[140px]">
<Button
class="w-full"
icon="pi pi-copy"
label="Copiar"
severity="secondary"
outlined
@click="copyLink"
/>
<Button
class="w-full"
icon="pi pi-external-link"
label="Abrir"
severity="secondary"
outlined
@click="openLink"
/>
</div>
</div>
</div>
<!-- Big CTA -->
<div class="rounded-2xl border border-slate-200 bg-slate-50 p-4">
<div class="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div class="min-w-0">
<div class="font-semibold text-slate-900">Envio rápido</div>
<div class="text-sm text-slate-600 mt-1">
Copie e mande por WhatsApp / e-mail. O paciente preenche e você recebe o cadastro no sistema.
</div>
</div>
<Button
icon="pi pi-copy"
label="Copiar link agora"
class="md:shrink-0"
@click="copyLink"
/>
</div>
</div>
<!-- Safety note -->
<Message severity="warn" :closable="false">
<b>Dica:</b> ao gerar um novo link, o anterior deve deixar de funcionar. Use isso quando você quiser revogar um link que foi compartilhado.
</Message>
</div>
</div>
</div>
</div>
<!-- Right: Concept / Instructions -->
<div class="lg:col-span-5">
<div class="rounded-2xl border border-slate-200 bg-white shadow-sm overflow-hidden">
<div class="p-5 border-b border-slate-200">
<div class="text-lg font-semibold text-slate-900">Como funciona</div>
<div class="text-slate-600 text-sm mt-1">
Um fluxo simples, mas com cuidado clínico: menos fricção, mais adesão.
</div>
</div>
<div class="p-5">
<ol class="space-y-4">
<li class="flex gap-3">
<div class="h-8 w-8 rounded-xl bg-slate-900 text-slate-50 grid place-items-center text-sm font-semibold">1</div>
<div class="min-w-0">
<div class="font-semibold text-slate-900">Você envia o link</div>
<div class="text-sm text-slate-600 mt-1">
Pode ser WhatsApp, e-mail ou mensagem direta. O link abre a página de cadastro externo.
</div>
</div>
</li>
<li class="flex gap-3">
<div class="h-8 w-8 rounded-xl bg-slate-900 text-slate-50 grid place-items-center text-sm font-semibold">2</div>
<div class="min-w-0">
<div class="font-semibold text-slate-900">O paciente preenche</div>
<div class="text-sm text-slate-600 mt-1">
Campos opcionais podem ser deixados em branco. A ideia é reduzir ansiedade e acelerar o início.
</div>
</div>
</li>
<li class="flex gap-3">
<div class="h-8 w-8 rounded-xl bg-slate-900 text-slate-50 grid place-items-center text-sm font-semibold">3</div>
<div class="min-w-0">
<div class="font-semibold text-slate-900">Você recebe no admin</div>
<div class="text-sm text-slate-600 mt-1">
Os dados entram como cadastro recebido. Você revisa, completa e transforma em paciente quando quiser.
</div>
</div>
</li>
</ol>
<div class="mt-6 rounded-2xl border border-slate-200 bg-slate-50 p-4">
<div class="font-semibold text-slate-900 flex items-center gap-2">
<i class="pi pi-shield text-slate-700"></i>
Boas práticas
</div>
<ul class="mt-2 space-y-2 text-sm text-slate-700">
<li class="flex gap-2">
<i class="pi pi-check text-emerald-600 mt-0.5"></i>
<span>Gere um novo link se você suspeitar que ele foi repassado indevidamente.</span>
</li>
<li class="flex gap-2">
<i class="pi pi-check text-emerald-600 mt-0.5"></i>
<span>Envie junto uma mensagem curta: preencha com calma; campos opcionais podem ficar em branco.</span>
</li>
<li class="flex gap-2">
<i class="pi pi-check text-emerald-600 mt-0.5"></i>
<span>Evite divulgar em público; é um link pensado para compartilhamento individual.</span>
</li>
</ul>
</div>
<div class="mt-4 text-xs text-slate-500">
Se você quiser, eu deixo este card ainda mais noir (contraste, microtextos, ícones, sombras) sem perder legibilidade.
</div>
</div>
</div>
<!-- Small helper card -->
<div class="mt-4 rounded-2xl border border-slate-200 bg-white shadow-sm p-5">
<div class="font-semibold text-slate-900">Mensagem pronta (copiar/colar)</div>
<div class="text-sm text-slate-600 mt-1">
Se quiser, use este texto ao enviar o link:
</div>
<div class="mt-3 rounded-xl bg-slate-50 border border-slate-200 p-3 text-sm text-slate-800">
Olá! Segue o link para seu pré-cadastro. Preencha com calma campos opcionais podem ficar em branco:
<span class="block mt-2 font-mono break-words">{{ publicUrl || '…' }}</span>
</div>
<div class="mt-3 flex gap-2">
<Button
icon="pi pi-copy"
label="Copiar mensagem"
severity="secondary"
outlined
:disabled="!publicUrl"
@click="copyInviteMessage"
/>
</div>
</div>
</div>
</div>
<!-- Toast is global in layout usually; if not, add <Toast /> -->
</div>
</template>
<script setup>
import { computed, onMounted, ref } from 'vue'
import Button from 'primevue/button'
import Card from 'primevue/card'
import InputText from 'primevue/inputtext'
import Message from 'primevue/message'
import { useToast } from 'primevue/usetoast'
import { supabase } from '@/lib/supabase/client'
const toast = useToast()
const inviteToken = ref('')
const rotating = ref(false)
/**
* Se o cadastro externo estiver em outro domínio, fixe aqui:
* ex.: const PUBLIC_BASE_URL = 'https://seusite.com'
* se vazio, usa window.location.origin
*/
const PUBLIC_BASE_URL = '' // opcional
const origin = computed(() => PUBLIC_BASE_URL || window.location.origin)
const publicUrl = computed(() => {
if (!inviteToken.value) return ''
return `${origin.value}/cadastro/paciente?t=${inviteToken.value}`
})
function newToken () {
if (globalThis.crypto?.randomUUID) return globalThis.crypto.randomUUID()
return 'tok_' + Math.random().toString(36).slice(2) + Date.now().toString(36)
}
async function requireUserId () {
const { data, error } = await supabase.auth.getUser()
if (error) throw error
const uid = data?.user?.id
if (!uid) throw new Error('Usuário não autenticado')
return uid
}
async function loadOrCreateInvite () {
const uid = await requireUserId()
const { data, error } = await supabase
.from('patient_invites')
.select('token, active')
.eq('owner_id', uid)
.eq('active', true)
.order('created_at', { ascending: false })
.limit(1)
if (error) throw error
const token = data?.[0]?.token
if (token) {
inviteToken.value = token
return
}
const t = newToken()
const { error: insErr } = await supabase
.from('patient_invites')
.insert({ owner_id: uid, token: t, active: true })
if (insErr) throw insErr
inviteToken.value = t
}
async function rotateLink () {
rotating.value = true
try {
const t = newToken()
const { error } = await supabase.rpc('rotate_patient_invite_token', { p_new_token: t })
if (error) throw error
inviteToken.value = t
toast.add({ severity: 'success', summary: 'Pronto', detail: 'Novo link gerado.', life: 2000 })
} catch (err) {
toast.add({ severity: 'error', summary: 'Erro', detail: err?.message || 'Falha ao gerar novo link.', life: 3500 })
} finally {
rotating.value = false
}
}
async function copyLink () {
try {
if (!publicUrl.value) return
await navigator.clipboard.writeText(publicUrl.value)
toast.add({ severity: 'success', summary: 'Copiado', detail: 'Link copiado.', life: 1500 })
} catch {
toast.add({ severity: 'warn', summary: 'Atenção', detail: 'Não foi possível copiar automaticamente.', life: 2500 })
}
}
function openLink () {
if (!publicUrl.value) return
window.open(publicUrl.value, '_blank', 'noopener')
}
async function copyInviteMessage () {
try {
if (!publicUrl.value) return
const msg =
`Olá! Segue o link para seu pré-cadastro. Preencha com calma — campos opcionais podem ficar em branco:
${publicUrl.value}`
await navigator.clipboard.writeText(msg)
toast.add({ severity: 'success', summary: 'Copiado', detail: 'Mensagem copiada.', life: 1500 })
} catch {
toast.add({ severity: 'warn', summary: 'Atenção', detail: 'Não foi possível copiar automaticamente.', life: 2500 })
}
}
onMounted(async () => {
try {
await loadOrCreateInvite()
} catch (err) {
toast.add({ severity: 'error', summary: 'Erro', detail: err?.message || 'Falha ao carregar link.', life: 3500 })
}
})
</script>

View File

@@ -0,0 +1,834 @@
<script setup>
import { ref, computed, onMounted } from 'vue'
import { supabase } from '@/lib/supabase/client'
import { useToast } from 'primevue/usetoast'
import { useConfirm } from 'primevue/useconfirm'
import DataTable from 'primevue/datatable'
import Column from 'primevue/column'
import Button from 'primevue/button'
import Dialog from 'primevue/dialog'
import Tag from 'primevue/tag'
import InputText from 'primevue/inputtext'
import ConfirmDialog from 'primevue/confirmdialog'
import ProgressSpinner from 'primevue/progressspinner'
import Textarea from 'primevue/textarea'
import Avatar from 'primevue/avatar'
const toast = useToast()
const confirm = useConfirm()
const converting = ref(false)
const loading = ref(false)
const rows = ref([])
const q = ref('')
const dlg = ref({
open: false,
saving: false,
mode: 'view',
item: null,
reject_note: ''
})
function statusSeverity (s) {
if (s === 'new') return 'info'
if (s === 'converted') return 'success'
if (s === 'rejected') return 'danger'
return 'secondary'
}
function statusLabel (s) {
if (s === 'new') return 'Novo'
if (s === 'converted') return 'Convertido'
if (s === 'rejected') return 'Rejeitado'
return s || '—'
}
// -----------------------------
// Helpers de campo: PT primeiro, fallback EN
// -----------------------------
function pickField (obj, keys) {
for (const k of keys) {
const v = obj?.[k]
if (v !== undefined && v !== null && String(v).trim() !== '') return v
}
return null
}
const fNome = (i) => pickField(i, ['nome_completo', 'name'])
const fEmail = (i) => pickField(i, ['email_principal', 'email'])
const fEmailAlt = (i) => pickField(i, ['email_alternativo', 'email_alt'])
const fTel = (i) => pickField(i, ['telefone', 'phone'])
const fTelAlt = (i) => pickField(i, ['telefone_alternativo', 'phone_alt'])
const fNasc = (i) => pickField(i, ['data_nascimento', 'birth_date'])
const fGenero = (i) => pickField(i, ['genero', 'gender'])
const fEstadoCivil = (i) => pickField(i, ['estado_civil', 'marital_status'])
const fProf = (i) => pickField(i, ['profissao', 'profession'])
const fNacionalidade = (i) => pickField(i, ['nacionalidade', 'nationality'])
const fNaturalidade = (i) => pickField(i, ['naturalidade', 'place_of_birth'])
const fEscolaridade = (i) => pickField(i, ['escolaridade', 'education_level'])
const fOndeConheceu = (i) => pickField(i, ['onde_nos_conheceu', 'lead_source'])
const fEncaminhado = (i) => pickField(i, ['encaminhado_por', 'referred_by'])
const fCep = (i) => pickField(i, ['cep'])
const fEndereco = (i) => pickField(i, ['endereco', 'address_street'])
const fNumero = (i) => pickField(i, ['numero', 'address_number'])
const fComplemento = (i) => pickField(i, ['complemento', 'address_complement'])
const fBairro = (i) => pickField(i, ['bairro', 'address_neighborhood'])
const fCidade = (i) => pickField(i, ['cidade', 'address_city'])
const fEstado = (i) => pickField(i, ['estado', 'address_state'])
const fPais = (i) => pickField(i, ['pais', 'country']) || 'Brasil'
const fObs = (i) => pickField(i, ['observacoes', 'notes_short'])
const fNotas = (i) => pickField(i, ['notas_internas', 'notes'])
// -----------------------------
// Filtro
// -----------------------------
const statusFilter = ref('')
function toggleStatusFilter (s) {
statusFilter.value = (statusFilter.value === s) ? '' : s
}
const filteredRows = computed(() => {
const term = String(q.value || '').trim().toLowerCase()
let list = rows.value
// filtro por status (se ativado)
if (statusFilter.value) {
list = list.filter(r => r.status === statusFilter.value)
}
if (!term) return list
return list.filter(r => {
const nome = String(fNome(r) || '').toLowerCase()
const email = String(fEmail(r) || '').toLowerCase()
const tel = String(fTel(r) || '').toLowerCase()
return nome.includes(term) || email.includes(term) || tel.includes(term)
})
})
// -----------------------------
// Avatar
// -----------------------------
const AVATAR_BUCKET = 'avatars'
function firstNonEmpty (...vals) {
for (const v of vals) {
const s = String(v ?? '').trim()
if (s) return s
}
return ''
}
function looksLikeUrl (s) {
return /^https?:\/\//i.test(String(s || ''))
}
function getAvatarUrlFromItem (i) {
// 0) tenta achar foto em vários lugares (raiz e payload jsonb)
const p = i?.payload || i?.data || i?.form || null
const direct = firstNonEmpty(
i?.avatar_url, i?.foto_url, i?.photo_url,
p?.avatar_url, p?.foto_url, p?.photo_url
)
// Se já for URL completa, usa direto
if (direct && looksLikeUrl(direct)) return direct
// 1) se for path de storage, monta publicUrl
const path = firstNonEmpty(
i?.avatar_path, i?.photo_path, i?.foto_path, i?.avatar_file_path,
p?.avatar_path, p?.photo_path, p?.foto_path, p?.avatar_file_path,
// às vezes guardam o path dentro de "direct"
direct
)
if (!path) return null
// se o "path" veio como URL, devolve
if (looksLikeUrl(path)) return path
const { data } = supabase.storage.from(AVATAR_BUCKET).getPublicUrl(path)
return data?.publicUrl || null
}
// -----------------------------
// Formatters
// -----------------------------
function dash (v) {
const s = String(v ?? '').trim()
return s ? s : '—'
}
function onlyDigits (v) {
return String(v ?? '').replace(/\D/g, '')
}
function fmtPhoneBR (v) {
const d = onlyDigits(v)
if (!d) return '—'
if (d.length === 11) return `(${d.slice(0,2)}) ${d.slice(2,7)}-${d.slice(7,11)}`
if (d.length === 10) return `(${d.slice(0,2)}) ${d.slice(2,6)}-${d.slice(6,10)}`
return d
}
function fmtCPF (v) {
const d = onlyDigits(v)
if (!d) return '—'
if (d.length !== 11) return d
return `${d.slice(0,3)}.${d.slice(3,6)}.${d.slice(6,9)}-${d.slice(9,11)}`
}
function fmtRG (v) {
const s = String(v ?? '').trim()
return s ? s : '—'
}
function fmtBirth (v) {
if (!v) return '—'
return String(v)
}
function fmtDate (iso) {
if (!iso) return '—'
const d = new Date(iso)
if (Number.isNaN(d.getTime())) return String(iso)
return d.toLocaleString('pt-BR')
}
// -----------------------------
// Seções do modal
// -----------------------------
const intakeSections = computed(() => {
const i = dlg.value.item
if (!i) return []
const avatarUrl = getAvatarUrlFromItem(i)
const section = (title, rows) => ({
title,
rows: (rows || []).filter(r => r && r.value !== undefined)
})
const row = (label, value, opts = {}) => ({
label,
value,
pre: !!opts.pre
})
return [
section('Identificação', [
row('Nome completo', dash(fNome(i))),
row('Email principal', dash(fEmail(i))),
row('Email alternativo', dash(fEmailAlt(i))),
row('Telefone', fmtPhoneBR(fTel(i))),
row('Telefone alternativo', fmtPhoneBR(fTelAlt(i)))
]),
section('Informações pessoais', [
row('Data de nascimento', fmtBirth(fNasc(i))),
row('Gênero', dash(fGenero(i))),
row('Estado civil', dash(fEstadoCivil(i))),
row('Profissão', dash(fProf(i))),
row('Nacionalidade', dash(fNacionalidade(i))),
row('Naturalidade', dash(fNaturalidade(i))),
row('Escolaridade', dash(fEscolaridade(i))),
row('Onde nos conheceu?', dash(fOndeConheceu(i))),
row('Encaminhado por', dash(fEncaminhado(i)))
]),
section('Documentos', [
row('CPF', fmtCPF(i.cpf)),
row('RG', fmtRG(i.rg))
]),
section('Endereço', [
row('CEP', dash(fCep(i))),
row('Endereço', dash(fEndereco(i))),
row('Número', dash(fNumero(i))),
row('Complemento', dash(fComplemento(i))),
row('Bairro', dash(fBairro(i))),
row('Cidade', dash(fCidade(i))),
row('Estado', dash(fEstado(i))),
row('País', dash(fPais(i)))
]),
section('Observações', [
row('Observações', dash(fObs(i)), { pre: true }),
row('Notas internas', dash(fNotas(i)), { pre: true })
]),
section('Administração', [
row('Status', statusLabel(i.status)),
row('Consentimento', i.consent ? 'Aceito' : 'Não aceito'),
row('Motivo da rejeição', dash(i.rejected_reason), { pre: true }),
row('Paciente convertido (ID)', dash(i.converted_patient_id))
]),
section('Metadados', [
row('Owner ID', dash(i.owner_id)),
row('Token', dash(i.token)),
row('Criado em', fmtDate(i.created_at)),
row('Atualizado em', fmtDate(i.updated_at)),
row('ID do intake', dash(i.id))
])
].map(s => ({ ...s, avatarUrl }))
})
// -----------------------------
// Fetch
// -----------------------------
async function fetchIntakes () {
loading.value = true
try {
const { data, error } = await supabase
.from('patient_intake_requests')
.select('*')
.order('created_at', { ascending: false })
if (error) throw error
const weight = (s) => (s === 'new' ? 0 : s === 'converted' ? 1 : s === 'rejected' ? 2 : 9)
rows.value = (data || []).slice().sort((a, b) => {
const wa = weight(a.status)
const wb = weight(b.status)
if (wa !== wb) return wa - wb
const da = new Date(a.created_at || 0).getTime()
const db = new Date(b.created_at || 0).getTime()
return db - da
})
} catch (e) {
console.error(e)
toast.add({ severity: 'error', summary: 'Erro ao carregar', detail: e.message || String(e), life: 3500 })
} finally {
loading.value = false
}
}
// -----------------------------
// Dialog
// -----------------------------
function openDetails (row) {
dlg.value.open = true
dlg.value.mode = 'view'
dlg.value.item = row
dlg.value.reject_note = row?.rejected_reason || ''
}
function closeDlg () {
dlg.value.open = false
dlg.value.saving = false
dlg.value.item = null
dlg.value.reject_note = ''
}
// -----------------------------
// Rejeitar
// -----------------------------
async function markRejected () {
const item = dlg.value.item
if (!item) return
confirm.require({
message: 'Marcar este cadastro como rejeitado?',
header: 'Confirmar rejeição',
icon: 'pi pi-exclamation-triangle',
acceptLabel: 'Rejeitar',
rejectLabel: 'Cancelar',
accept: async () => {
dlg.value.saving = true
try {
const reason = String(dlg.value.reject_note || '').trim() || null
const { error } = await supabase
.from('patient_intake_requests')
.update({
status: 'rejected',
rejected_reason: reason,
updated_at: new Date().toISOString()
})
.eq('id', item.id)
if (error) throw error
toast.add({ severity: 'success', summary: 'Rejeitado', detail: 'Solicitação rejeitada.', life: 2500 })
await fetchIntakes()
const updated = rows.value.find(r => r.id === item.id)
if (updated) openDetails(updated)
} catch (e) {
console.error(e)
toast.add({ severity: 'error', summary: 'Erro', detail: e.message || String(e), life: 3500 })
} finally {
dlg.value.saving = false
}
}
})
}
// -----------------------------
// Converter
// -----------------------------
async function convertToPatient () {
const item = dlg.value?.item
if (!item?.id) return
if (converting.value) return
converting.value = true
try {
const { data: userData, error: userErr } = await supabase.auth.getUser()
if (userErr) throw userErr
const ownerId = userData?.user?.id
if (!ownerId) throw new Error('Sessão inválida.')
const cleanStr = (v) => {
const s = String(v ?? '').trim()
return s ? s : null
}
const digitsOnly = (v) => {
const d = String(v ?? '').replace(/\D/g, '')
return d ? d : null
}
// ✅ tenta reaproveitar a foto do intake, se existir
const intakeAvatar = cleanStr(item.avatar_url) || cleanStr(item.foto_url) || null
const patientPayload = {
owner_id: ownerId,
// identificação/contato
nome_completo: cleanStr(fNome(item)),
email_principal: cleanStr(fEmail(item))?.toLowerCase() || null,
email_alternativo: cleanStr(fEmailAlt(item))?.toLowerCase() || null,
telefone: digitsOnly(fTel(item)),
telefone_alternativo: digitsOnly(fTelAlt(item)),
// pessoais
data_nascimento: fNasc(item) || null, // date
naturalidade: cleanStr(fNaturalidade(item)),
genero: cleanStr(fGenero(item)),
estado_civil: cleanStr(fEstadoCivil(item)),
// docs
cpf: digitsOnly(item.cpf),
rg: cleanStr(item.rg),
// endereço (PT)
pais: cleanStr(fPais(item)) || 'Brasil',
cep: digitsOnly(fCep(item)),
cidade: cleanStr(fCidade(item)),
estado: cleanStr(fEstado(item)) || 'SP',
endereco: cleanStr(fEndereco(item)),
numero: cleanStr(fNumero(item)),
bairro: cleanStr(fBairro(item)),
complemento: cleanStr(fComplemento(item)),
// adicionais (PT)
escolaridade: cleanStr(fEscolaridade(item)),
profissao: cleanStr(fProf(item)),
onde_nos_conheceu: cleanStr(fOndeConheceu(item)),
encaminhado_por: cleanStr(fEncaminhado(item)),
// observações (PT)
observacoes: cleanStr(fObs(item)),
notas_internas: cleanStr(fNotas(item)),
// avatar
avatar_url: intakeAvatar
}
// limpa undefined
Object.keys(patientPayload).forEach(k => {
if (patientPayload[k] === undefined) delete patientPayload[k]
})
const { data: created, error: insErr } = await supabase
.from('patients')
.insert(patientPayload)
.select('id')
.single()
if (insErr) throw insErr
const patientId = created?.id
if (!patientId) throw new Error('Falha ao obter ID do paciente criado.')
const { error: upErr } = await supabase
.from('patient_intake_requests')
.update({
status: 'converted',
converted_patient_id: patientId,
updated_at: new Date().toISOString()
})
.eq('id', item.id)
.eq('owner_id', ownerId)
if (upErr) throw upErr
toast.add({ severity: 'success', summary: 'Convertido', detail: 'Cadastro convertido em paciente.', life: 2500 })
dlg.value.open = false
await fetchIntakes()
} catch (err) {
toast.add({
severity: 'error',
summary: 'Falha ao converter',
detail: err?.message || 'Não foi possível converter o cadastro.',
life: 4500
})
} finally {
converting.value = false
}
}
const totals = computed(() => {
const all = rows.value || []
const total = all.length
const nNew = all.filter(r => r.status === 'new').length
const nConv = all.filter(r => r.status === 'converted').length
const nRej = all.filter(r => r.status === 'rejected').length
return { total, nNew, nConv, nRej }
})
onMounted(fetchIntakes)
</script>
<template>
<div class="p-4">
<ConfirmDialog />
<!-- HEADER -->
<div class="mb-4 overflow-hidden rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)]">
<div class="relative px-5 py-5">
<!-- faixa de cor -->
<div class="pointer-events-none absolute inset-0 opacity-80">
<div class="absolute -top-10 -right-12 h-40 w-40 rounded-full bg-emerald-400/20 blur-3xl" />
<div class="absolute top-10 -left-16 h-44 w-44 rounded-full bg-indigo-400/20 blur-3xl" />
</div>
<div class="relative flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
<div class="min-w-0">
<div class="flex items-center gap-3">
<div class="grid h-11 w-11 place-items-center rounded-2xl bg-[var(--primary-color)]/10 text-[var(--primary-color)]">
<i class="pi pi-inbox text-lg"></i>
</div>
<div class="min-w-0">
<div class="flex items-center gap-2">
<div class="text-xl font-semibold leading-none">Cadastros recebidos</div>
<Tag :value="`${totals.total}`" severity="secondary" />
</div>
<div class="text-color-secondary mt-1">
Solicitações de pré-cadastro (cadastro externo) para avaliar e converter.
</div>
</div>
</div>
<!-- filtros (chips clicáveis) -->
<div class="mt-4 flex flex-wrap gap-2">
<Button
type="button"
class="!rounded-full"
:outlined="statusFilter !== 'new'"
:severity="statusFilter === 'new' ? 'info' : 'secondary'"
@click="toggleStatusFilter('new')"
>
<span class="flex items-center gap-2">
<i class="pi pi-sparkles" />
Novos: <b>{{ totals.nNew }}</b>
</span>
</Button>
<Button
type="button"
class="!rounded-full"
:outlined="statusFilter !== 'converted'"
:severity="statusFilter === 'converted' ? 'success' : 'secondary'"
@click="toggleStatusFilter('converted')"
>
<span class="flex items-center gap-2">
<i class="pi pi-check" />
Convertidos: <b>{{ totals.nConv }}</b>
</span>
</Button>
<Button
type="button"
class="!rounded-full"
:outlined="statusFilter !== 'rejected'"
:severity="statusFilter === 'rejected' ? 'danger' : 'secondary'"
@click="toggleStatusFilter('rejected')"
>
<span class="flex items-center gap-2">
<i class="pi pi-times" />
Rejeitados: <b>{{ totals.nRej }}</b>
</span>
</Button>
<Button
v-if="statusFilter"
type="button"
class="!rounded-full"
severity="secondary"
outlined
icon="pi pi-filter-slash"
label="Limpar filtro"
@click="statusFilter = ''"
/>
</div>
</div>
<div class="flex flex-col sm:flex-row gap-2 sm:items-center">
<span class="p-input-icon-left w-full sm:w-[360px]">
<InputText
v-model="q"
class="w-full"
placeholder="Buscar por nome, e-mail ou telefone…"
/>
</span>
<div class="flex gap-2">
<Button
icon="pi pi-refresh"
label="Atualizar"
severity="secondary"
outlined
:loading="loading"
@click="fetchIntakes"
/>
</div>
</div>
</div>
</div>
</div>
<!-- TABLE WRAPPER -->
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] overflow-hidden">
<div v-if="loading" class="flex items-center justify-center py-10">
<ProgressSpinner style="width: 38px; height: 38px" />
</div>
<DataTable
v-else
:value="filteredRows"
dataKey="id"
paginator
:rows="10"
:rowsPerPageOptions="[10, 20, 50]"
responsiveLayout="scroll"
stripedRows
class="!border-0"
>
<Column header="Status" style="width: 10rem">
<template #body="{ data }">
<Tag :value="statusLabel(data.status)" :severity="statusSeverity(data.status)" />
</template>
</Column>
<Column header="Paciente">
<template #body="{ data }">
<div class="flex items-center gap-3 min-w-0">
<Avatar
v-if="getAvatarUrlFromItem(data)"
:image="getAvatarUrlFromItem(data)"
shape="circle"
/>
<Avatar v-else icon="pi pi-user" shape="circle" />
<div class="min-w-0">
<div class="font-medium truncate">{{ fNome(data) || '—' }}</div>
<div class="text-color-secondary text-sm truncate">{{ fEmail(data) || '—' }}</div>
</div>
</div>
</template>
</Column>
<Column header="Contato" style="width: 14rem">
<template #body="{ data }">
<div class="text-sm">
<div class="font-medium">{{ fmtPhoneBR(fTel(data)) }}</div>
<div class="text-color-secondary">{{ fTelAlt(data) ? fmtPhoneBR(fTelAlt(data)) : '—' }}</div>
</div>
</template>
</Column>
<Column header="Criado em" style="width: 14rem">
<template #body="{ data }">
<span class="text-color-secondary">{{ fmtDate(data.created_at) }}</span>
</template>
</Column>
<Column header="" style="width: 10rem; text-align: right">
<template #body="{ data }">
<Button
icon="pi pi-eye"
label="Ver"
severity="secondary"
outlined
@click="openDetails(data)"
/>
</template>
</Column>
<template #empty>
<div class="text-color-secondary py-6 text-center">
Nenhum cadastro encontrado.
</div>
</template>
</DataTable>
</div>
<!-- MODAL -->
<Dialog
v-model:visible="dlg.open"
modal
:header="null"
:style="{ width: 'min(940px, 96vw)' }"
:contentStyle="{ padding: 0 }"
@hide="closeDlg"
>
<div v-if="dlg.item" class="relative">
<div class="max-h-[70vh] overflow-auto p-5 bg-[var(--surface-ground)]">
<!-- topo conceitual -->
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-4 mb-4">
<div class="flex flex-col items-center text-center gap-3">
<div class="relative">
<div class="absolute inset-0 blur-2xl opacity-30 rounded-full bg-slate-300"></div>
<div class="relative">
<template v-if="(dlgAvatar = getAvatarUrlFromItem(dlg.item))">
<Avatar :image="dlgAvatar" alt="avatar" shape="circle" size="xlarge" />
</template>
<Avatar v-else icon="pi pi-user" shape="circle" size="xlarge" />
</div>
</div>
<div class="min-w-0">
<div class="text-xl font-semibold text-slate-900 truncate">
{{ fNome(dlg.item) || '—' }}
</div>
<div class="text-slate-500 text-sm truncate">
{{ fEmail(dlg.item) || '—' }} · {{ fmtPhoneBR(fTel(dlg.item)) }}
</div>
</div>
<div class="flex flex-wrap justify-center gap-2">
<Tag :value="statusLabel(dlg.item.status)" :severity="statusSeverity(dlg.item.status)" />
<Tag
:value="dlg.item.consent ? 'Consentimento OK' : 'Sem consentimento'"
:severity="dlg.item.consent ? 'success' : 'danger'"
/>
<Tag :value="`Criado: ${fmtDate(dlg.item.created_at)}`" severity="secondary" />
</div>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div
v-for="(sec, sidx) in intakeSections"
:key="sidx"
class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-4"
>
<div class="font-semibold text-slate-900 mb-3">
{{ sec.title }}
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div
v-for="(r, ridx) in sec.rows"
:key="ridx"
class="min-w-0"
>
<div class="text-xs text-slate-500 mb-1">
{{ r.label }}
</div>
<div
class="text-sm text-slate-900"
:class="r.pre ? 'whitespace-pre-wrap leading-relaxed' : 'truncate'"
>
{{ r.value }}
</div>
</div>
</div>
</div>
</div>
<!-- rejeição: nota -->
<div class="mt-5 rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-4">
<div class="flex items-center justify-between gap-2 flex-wrap">
<div class="font-semibold text-slate-900">Rejeição</div>
<Tag
:value="dlg.item.status === 'rejected' ? 'Este cadastro já foi rejeitado' : 'Opcional'"
:severity="dlg.item.status === 'rejected' ? 'danger' : 'secondary'"
/>
</div>
<div class="mt-3">
<label class="block text-sm text-slate-600 mb-2">Motivo (anotação interna)</label>
<Textarea
v-model="dlg.reject_note"
autoResize
rows="2"
class="w-full"
:disabled="dlg.saving || converting"
placeholder="Ex.: dados incompletos, pediu para não seguir, duplicado…"
/>
</div>
</div>
<div class="h-24"></div>
</div>
<!-- ações fixas -->
<div class="sticky bottom-0 z-10 border-t border-[var(--surface-border)] bg-[var(--surface-card)]">
<div class="px-5 py-4 flex flex-col sm:flex-row gap-3 sm:items-center sm:justify-between">
<div class="flex items-center gap-2">
<Tag :value="statusLabel(dlg.item.status)" :severity="statusSeverity(dlg.item.status)" />
</div>
<div class="flex gap-2 justify-end flex-wrap">
<Button
label="Rejeitar"
icon="pi pi-times"
severity="danger"
outlined
:disabled="dlg.saving || dlg.item.status === 'rejected' || converting"
@click="markRejected"
/>
<Button
label="Converter"
icon="pi pi-check"
severity="success"
:loading="converting"
:disabled="dlg.item.status === 'converted' || dlg.saving || converting"
@click="convertToPatient"
/>
<Button
label="Fechar"
icon="pi pi-times-circle"
severity="secondary"
outlined
:disabled="dlg.saving || converting"
@click="closeDlg"
/>
</div>
</div>
</div>
</div>
</Dialog>
</div>
</template>

View File

@@ -0,0 +1,617 @@
<template>
<div class="p-4">
<!-- TOOLBAR (padrão Sakai CRUD) -->
<Toolbar class="mb-4">
<template #start>
<div class="flex flex-col">
<div class="text-xl font-semibold leading-none">Grupos de Pacientes</div>
<small class="text-color-secondary mt-1">
Organize seus pacientes por grupos. Alguns grupos são padrões do sistema e não podem ser alterados.
</small>
</div>
</template>
<template #end>
<div class="flex items-center gap-2">
<Button
label="Excluir selecionados"
icon="pi pi-trash"
severity="danger"
outlined
:disabled="!selectedGroups || !selectedGroups.length"
@click="confirmDeleteSelected"
/>
<Button label="Adicionar" icon="pi pi-plus" @click="openCreate" />
</div>
</template>
</Toolbar>
<div class="flex flex-col lg:flex-row gap-4">
<!-- LEFT: TABLE -->
<div class="w-full lg:basis-[70%] lg:max-w-[70%]">
<Card class="h-full">
<template #content>
<DataTable
ref="dt"
v-model:selection="selectedGroups"
:value="groups"
dataKey="id"
:loading="loading"
paginator
:rows="10"
:rowsPerPageOptions="[5, 10, 25]"
stripedRows
responsiveLayout="scroll"
:filters="filters"
filterDisplay="menu"
paginatorTemplate="FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink CurrentPageReport RowsPerPageDropdown"
currentPageReportTemplate="Mostrando {first} a {last} de {totalRecords} grupos"
>
<template #header>
<div class="flex flex-wrap gap-2 items-center justify-between">
<div class="flex items-center gap-2">
<span class="font-medium">Lista de Grupos</span>
<Tag :value="`${groups.length} grupos`" severity="secondary" />
</div>
<IconField>
<InputIcon><i class="pi pi-search" /></InputIcon>
<InputText v-model="filters.global.value" placeholder="Buscar grupos..." class="w-64" />
</IconField>
</div>
</template>
<!-- seleção (desabilita grupos do sistema) -->
<Column selectionMode="multiple" style="width: 3rem" :exportable="false">
<template #body="{ data }">
<Checkbox
:binary="true"
:disabled="!!data.is_system"
:modelValue="isSelected(data)"
@update:modelValue="toggleRowSelection(data, $event)"
/>
</template>
</Column>
<Column field="nome" header="Nome" sortable style="min-width: 16rem" />
<Column header="Origem" sortable sortField="is_system" style="min-width: 12rem">
<template #body="{ data }">
<Tag
:value="data.is_system ? 'Padrão' : 'Criado por você'"
:severity="data.is_system ? 'info' : 'success'"
/>
</template>
</Column>
<Column header="Pacientes" sortable sortField="patients_count" style="min-width: 10rem">
<template #body="{ data }">
<span class="text-color-secondary">
{{ patientsLabel(Number(data.patients_count ?? data.patient_count ?? 0)) }}
</span>
</template>
</Column>
<Column :exportable="false" header="Ações" style="min-width: 12rem">
<template #body="{ data }">
<div class="flex justify-end gap-2">
<Button
v-if="!data.is_system"
icon="pi pi-pencil"
outlined
rounded
@click="openEdit(data)"
/>
<Button
v-if="!data.is_system"
icon="pi pi-trash"
outlined
rounded
severity="danger"
@click="confirmDeleteOne(data)"
/>
<Button
v-if="data.is_system"
icon="pi pi-lock"
outlined
rounded
disabled
v-tooltip.top="'Grupo padrão do sistema (inalterável)'"
/>
</div>
</template>
</Column>
<template #empty>
Nenhum grupo encontrado.
</template>
</DataTable>
</template>
</Card>
</div>
<!-- RIGHT: CARDS -->
<div class="w-full lg:basis-[30%] lg:max-w-[30%]">
<Card class="h-full">
<template #title>Pacientes por grupo</template>
<template #subtitle>Os cards aparecem apenas quando pacientes associados.</template>
<template #content>
<div v-if="cards.length === 0" class="min-h-[150px] flex flex-col items-center justify-center text-center gap-2">
<i class="pi pi-users text-3xl"></i>
<div class="mt-1 font-medium">Sem pacientes associados</div>
<small class="text-color-secondary">
Quando um grupo tiver pacientes vinculados, ele aparecerá aqui.
</small>
</div>
<div v-else class="flex flex-col gap-3">
<div
v-for="g in cards"
:key="g.id"
class="relative p-4 rounded-xl border border-[var(--surface-border)] transition-all duration-150 hover:-translate-y-1 hover:shadow-[var(--card-shadow)]"
@mouseenter="hovered = g.id"
@mouseleave="hovered = null"
>
<div class="flex justify-between items-start gap-3">
<div class="min-w-0">
<div class="font-bold truncate max-w-[230px]">
{{ g.nome }}
</div>
<small class="text-color-secondary">
{{ patientsLabel(Number(g.patients_count ?? g.patient_count ?? 0)) }}
</small>
</div>
<Tag
:value="g.is_system ? 'Padrão' : 'Criado por você'"
:severity="g.is_system ? 'info' : 'success'"
/>
</div>
<Transition name="fade">
<div
v-if="hovered === g.id"
class="absolute inset-0 rounded-xl bg-emerald-500/15 backdrop-blur-sm flex items-center justify-center"
>
<Button
label="Ver pacientes"
icon="pi pi-users"
severity="success"
:disabled="!(g.patients_count ?? g.patient_count)"
@click="openGroupPatientsModal(g)"
/>
</div>
</Transition>
</div>
</div>
</template>
</Card>
</div>
</div>
<!-- DIALOG CREATE / EDIT -->
<Dialog
v-model:visible="dlg.open"
:header="dlg.mode === 'create' ? 'Criar Grupo' : 'Editar Grupo'"
modal
:style="{ width: '520px', maxWidth: '92vw' }"
>
<div class="flex flex-col gap-3">
<div>
<label class="block mb-2">Nome do Grupo</label>
<InputText v-model="dlg.nome" class="w-full" :disabled="dlg.saving" />
<small class="text-color-secondary">
Grupos Padrão são do sistema e não podem ser editados.
</small>
</div>
</div>
<template #footer>
<Button label="Cancelar" text :disabled="dlg.saving" @click="dlg.open = false" />
<Button
:label="dlg.mode === 'create' ? 'Criar' : 'Salvar'"
:loading="dlg.saving"
@click="saveDialog"
:disabled="!String(dlg.nome || '').trim()"
/>
</template>
</Dialog>
<!-- DIALOG PACIENTES (com botão Abrir) -->
<Dialog
v-model:visible="patientsDialog.open"
:header="patientsDialog.group?.nome ? `Pacientes do grupo: ${patientsDialog.group.nome}` : 'Pacientes do grupo'"
modal
:style="{ width: '900px', maxWidth: '95vw' }"
>
<div class="flex flex-col gap-3">
<div class="text-color-secondary">
Grupo: <span class="font-medium text-color">{{ patientsDialog.group?.nome || '—' }}</span>
</div>
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-3">
<IconField class="w-full md:w-80">
<InputIcon><i class="pi pi-search" /></InputIcon>
<InputText
v-model="patientsDialog.search"
placeholder="Buscar paciente..."
class="w-full"
:disabled="patientsDialog.loading"
/>
</IconField>
<div class="flex items-center gap-2 justify-end">
<Tag v-if="!patientsDialog.loading" :value="`${patientsDialog.items.length} paciente(s)`" severity="secondary" />
</div>
</div>
<div v-if="patientsDialog.loading" class="text-color-secondary">Carregando</div>
<Message v-else-if="patientsDialog.error" severity="error">
{{ patientsDialog.error }}
</Message>
<div v-else>
<div v-if="patientsDialog.items.length === 0" class="text-color-secondary">
Nenhum paciente associado a este grupo.
</div>
<div v-else>
<DataTable
:value="patientsDialogFiltered"
dataKey="id"
stripedRows
responsiveLayout="scroll"
paginator
:rows="8"
:rowsPerPageOptions="[8, 15, 30]"
>
<Column header="Paciente" sortable>
<template #body="{ data }">
<div class="flex items-center gap-3">
<Avatar v-if="data.avatar_url" :image="data.avatar_url" shape="circle" />
<Avatar v-else :label="initials(data.full_name)" shape="circle" />
<div class="min-w-0">
<div class="font-medium truncate max-w-[420px]">{{ data.full_name }}</div>
<small class="text-color-secondary truncate">{{ data.email || '—' }}</small>
</div>
</div>
</template>
</Column>
<Column header="Telefone" style="min-width: 12rem">
<template #body="{ data }">
<span class="text-color-secondary">{{ fmtPhone(data.phone) }}</span>
</template>
</Column>
<Column header="Ações" style="width: 12rem">
<template #body="{ data }">
<Button
label="Abrir"
icon="pi pi-external-link"
size="small"
outlined
@click="abrirPaciente(data)"
/>
</template>
</Column>
<template #empty>
<div class="text-color-secondary py-5">Nenhum resultado para "{{ patientsDialog.search }}".</div>
</template>
</DataTable>
</div>
</div>
</div>
<template #footer>
<Button label="Fechar" icon="pi pi-times" text @click="patientsDialog.open = false" />
</template>
</Dialog>
<ConfirmDialog />
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useToast } from 'primevue/usetoast'
import { useConfirm } from 'primevue/useconfirm'
import Checkbox from 'primevue/checkbox'
import { supabase } from '@/lib/supabase/client'
import {
listGroupsWithCounts,
createGroup,
updateGroup,
deleteGroup
} from '@/services/GruposPacientes.service.js'
const router = useRouter()
const toast = useToast()
const confirm = useConfirm()
const dt = ref(null)
const loading = ref(false)
const groups = ref([])
const selectedGroups = ref([])
const hovered = ref(null)
const filters = ref({
global: { value: null, matchMode: 'contains' }
})
const dlg = reactive({
open: false,
mode: 'create', // 'create' | 'edit'
id: '',
nome: '',
saving: false
})
const patientsDialog = reactive({
open: false,
loading: false,
error: '',
group: null,
items: [],
search: ''
})
const cards = computed(() =>
(groups.value || [])
.filter(g => Number(g.patients_count ?? g.patient_count ?? 0) > 0)
.sort(
(a, b) =>
Number(b.patients_count ?? b.patient_count ?? 0) -
Number(a.patients_count ?? a.patient_count ?? 0)
)
)
const patientsDialogFiltered = computed(() => {
const s = String(patientsDialog.search || '').trim().toLowerCase()
if (!s) return patientsDialog.items || []
return (patientsDialog.items || []).filter(p => {
const name = String(p.full_name || '').toLowerCase()
const email = String(p.email || '').toLowerCase()
const phone = String(p.phone || '').toLowerCase()
return name.includes(s) || email.includes(s) || phone.includes(s)
})
})
function patientsLabel (n) {
return n === 1 ? '1 paciente' : `${n} pacientes`
}
function humanizeError (err) {
const msg = err?.message || err?.error_description || String(err) || 'Erro inesperado.'
const code = err?.code
if (code === '23505' || /duplicate key value/i.test(msg)) {
return 'Já existe um grupo com esse nome (para você). Tente outro nome.'
}
if (/Grupo padrão/i.test(msg)) {
return 'Esse é um grupo padrão do sistema e não pode ser alterado.'
}
return msg
}
async function fetchAll () {
loading.value = true
try {
groups.value = await listGroupsWithCounts()
} catch (err) {
toast.add({ severity: 'error', summary: 'Erro', detail: humanizeError(err), life: 3500 })
} finally {
loading.value = false
}
}
/* -------------------------------
Seleção: ignora grupos do sistema
-------------------------------- */
function isSelected (row) {
return (selectedGroups.value || []).some(s => s.id === row.id)
}
function toggleRowSelection (row, checked) {
if (row.is_system) return
const sel = selectedGroups.value || []
if (checked) {
if (!sel.some(s => s.id === row.id)) selectedGroups.value = [...sel, row]
} else {
selectedGroups.value = sel.filter(s => s.id !== row.id)
}
}
/* -------------------------------
CRUD
-------------------------------- */
function openCreate () {
dlg.open = true
dlg.mode = 'create'
dlg.id = ''
dlg.nome = ''
}
function openEdit (row) {
dlg.open = true
dlg.mode = 'edit'
dlg.id = row.id
dlg.nome = row.nome
}
async function saveDialog () {
const nome = String(dlg.nome || '').trim()
if (!nome) {
toast.add({ severity: 'warn', summary: 'Atenção', detail: 'Informe um nome.', life: 2500 })
return
}
if (nome.length < 2) {
toast.add({ severity: 'warn', summary: 'Atenção', detail: 'Nome muito curto.', life: 2500 })
return
}
dlg.saving = true
try {
if (dlg.mode === 'create') {
await createGroup(nome)
toast.add({ severity: 'success', summary: 'Sucesso', detail: 'Grupo criado.', life: 2500 })
} else {
await updateGroup(dlg.id, nome)
toast.add({ severity: 'success', summary: 'Sucesso', detail: 'Grupo atualizado.', life: 2500 })
}
dlg.open = false
await fetchAll()
} catch (err) {
toast.add({ severity: 'error', summary: 'Erro', detail: humanizeError(err), life: 3500 })
} finally {
dlg.saving = false
}
}
function confirmDeleteOne (row) {
confirm.require({
message: `Excluir "${row.nome}"?`,
header: 'Excluir grupo',
icon: 'pi pi-exclamation-triangle',
acceptClass: 'p-button-danger',
acceptLabel: 'Excluir',
rejectLabel: 'Cancelar',
accept: async () => {
try {
await deleteGroup(row.id)
toast.add({ severity: 'success', summary: 'Sucesso', detail: 'Grupo excluído.', life: 2500 })
await fetchAll()
} catch (err) {
toast.add({ severity: 'error', summary: 'Erro', detail: humanizeError(err), life: 3500 })
}
}
})
}
function confirmDeleteSelected () {
const sel = selectedGroups.value || []
if (!sel.length) return
const deletables = sel.filter(g => !g.is_system)
const blocked = sel.filter(g => g.is_system)
if (!deletables.length) {
toast.add({
severity: 'warn',
summary: 'Atenção',
detail: 'Os itens selecionados são grupos do sistema e não podem ser excluídos.',
life: 3500
})
return
}
const msgBlocked = blocked.length ? ` (${blocked.length} grupo(s) padrão serão ignorados)` : ''
confirm.require({
message: `Excluir ${deletables.length} grupo(s) selecionado(s)?${msgBlocked}`,
header: 'Excluir selecionados',
icon: 'pi pi-exclamation-triangle',
acceptClass: 'p-button-danger',
acceptLabel: 'Excluir',
rejectLabel: 'Cancelar',
accept: async () => {
try {
for (const g of deletables) await deleteGroup(g.id)
selectedGroups.value = []
toast.add({ severity: 'success', summary: 'Sucesso', detail: 'Exclusão concluída.', life: 2500 })
await fetchAll()
} catch (err) {
toast.add({ severity: 'error', summary: 'Erro', detail: humanizeError(err), life: 3500 })
}
}
})
}
/* -------------------------------
Helpers (avatar/telefone)
-------------------------------- */
function initials (name) {
const parts = String(name || '').trim().split(/\s+/).filter(Boolean)
if (!parts.length) return '—'
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase()
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase()
}
function onlyDigits (v) {
return String(v ?? '').replace(/\D/g, '')
}
function fmtPhone (v) {
const d = onlyDigits(v)
if (!d) return '—'
if (d.length === 11) return `(${d.slice(0, 2)}) ${d.slice(2, 7)}-${d.slice(7)}`
if (d.length === 10) return `(${d.slice(0, 2)}) ${d.slice(2, 6)}-${d.slice(6)}`
return d
}
/* -------------------------------
Modal: Pacientes do Grupo
-------------------------------- */
async function openGroupPatientsModal (groupRow) {
patientsDialog.open = true
patientsDialog.loading = true
patientsDialog.error = ''
patientsDialog.group = groupRow
patientsDialog.items = []
patientsDialog.search = ''
try {
const { data, error } = await supabase
.from('patient_group_patient')
.select(`
patient_id,
patient:patients (
id,
nome_completo,
email_principal,
telefone,
avatar_url
)
`)
.eq('patient_group_id', groupRow.id)
if (error) throw error
const patients = (data || [])
.map(r => r.patient)
.filter(Boolean)
patientsDialog.items = patients
.map(p => ({
id: p.id,
full_name: p.nome_completo || '—',
email: p.email_principal || '—',
phone: p.telefone || '—',
avatar_url: p.avatar_url || null
}))
.sort((a, b) => String(a.full_name).localeCompare(String(b.full_name), 'pt-BR'))
} catch (err) {
patientsDialog.error = humanizeError(err)
} finally {
patientsDialog.loading = false
}
}
function abrirPaciente (patient) {
router.push(`/admin/pacientes/cadastro/${patient.id}`)
}
onMounted(fetchAll)
</script>
<style scoped>
.fade-enter-active,
.fade-leave-active { transition: opacity .14s ease; }
.fade-enter-from,
.fade-leave-to { opacity: 0; }
</style>

View File

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

View File

@@ -0,0 +1,816 @@
<template>
<div class="p-4">
<!-- TOOLBAR -->
<Toolbar class="mb-4">
<template #start>
<div class="flex flex-col">
<div class="text-xl font-semibold leading-none">Tags de Pacientes</div>
<small class="text-color-secondary mt-1">
Classifique pacientes por temas (ex.: Burnout, Ansiedade, Triagem). Clique em Pacientes para ver a lista.
</small>
</div>
</template>
<template #end>
<div class="flex items-center gap-2">
<Button
label="Excluir selecionados"
icon="pi pi-trash"
severity="danger"
outlined
:disabled="!etiquetasSelecionadas?.length"
@click="confirmarExclusaoSelecionadas"
/>
<Button label="Adicionar" icon="pi pi-plus" @click="abrirCriar" />
</div>
</template>
</Toolbar>
<div class="flex flex-col lg:flex-row gap-4">
<!-- LEFT: tabela -->
<div class="w-full lg:basis-[70%] lg:max-w-[70%]">
<Card class="h-full">
<template #content>
<DataTable
ref="dt"
:value="etiquetas"
dataKey="id"
:loading="carregando"
paginator
:rows="10"
:rowsPerPageOptions="[10, 20, 50]"
stripedRows
responsiveLayout="scroll"
:filters="filtros"
filterDisplay="menu"
paginatorTemplate="FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink CurrentPageReport RowsPerPageDropdown"
currentPageReportTemplate="Mostrando {first} a {last} de {totalRecords} tags"
>
<template #header>
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-3">
<div class="flex items-center gap-2">
<span class="font-medium">Lista de Tags</span>
<Tag :value="`${etiquetas.length} tags`" severity="secondary" />
</div>
<div class="flex items-center gap-2 w-full md:w-auto">
<IconField class="w-full md:w-80">
<InputIcon><i class="pi pi-search" /></InputIcon>
<InputText
v-model="filtros.global.value"
placeholder="Buscar tag..."
class="w-full"
/>
</IconField>
<Button
icon="pi pi-refresh"
severity="secondary"
outlined
v-tooltip.top="'Atualizar'"
@click="buscarEtiquetas"
/>
</div>
</div>
</template>
<!-- Seleção (bloqueia tags padrão) -->
<Column :exportable="false" headerStyle="width: 3rem">
<template #body="{ data }">
<Checkbox
:binary="true"
:disabled="!!data.is_padrao"
:modelValue="estaSelecionada(data)"
@update:modelValue="alternarSelecao(data, $event)"
/>
</template>
</Column>
<Column field="nome" header="Tag" sortable style="min-width: 18rem;">
<template #body="{ data }">
<div class="flex items-center gap-2 min-w-0">
<span
class="inline-block rounded-full"
:style="{
width: '10px',
height: '10px',
background: data.cor || '#94a3b8'
}"
/>
<span class="font-medium truncate">{{ data.nome }}</span>
<span v-if="data.is_padrao" class="text-xs text-color-secondary">(padrão)</span>
</div>
</template>
</Column>
<Column header="Pacientes" sortable sortField="pacientes_count" style="width: 10rem;">
<template #body="{ data }">
<Button
class="p-0"
link
:label="String(data.pacientes_count ?? 0)"
:disabled="Number(data.pacientes_count ?? 0) <= 0"
@click="abrirModalPacientesDaTag(data)"
/>
</template>
</Column>
<Column header="Ações" style="width: 10.5rem;">
<template #body="{ data }">
<div class="flex gap-2 justify-end">
<Button
icon="pi pi-pencil"
severity="secondary"
outlined
size="small"
:disabled="data.is_padrao"
v-tooltip.top="data.is_padrao ? 'Tags padrão não podem ser editadas' : 'Editar'"
@click="abrirEditar(data)"
/>
<Button
icon="pi pi-trash"
severity="danger"
outlined
size="small"
:disabled="data.is_padrao"
v-tooltip.top="data.is_padrao ? 'Tags padrão não podem ser excluídas' : 'Excluir'"
@click="confirmarExclusaoUma(data)"
/>
</div>
</template>
</Column>
<template #empty>
<div class="text-color-secondary py-5">Nenhuma tag encontrada.</div>
</template>
</DataTable>
</template>
</Card>
</div>
<!-- RIGHT: cards -->
<div class="w-full lg:basis-[30%] lg:max-w-[30%]">
<Card class="h-full">
<template #title>Mais usadas</template>
<template #subtitle>As tags aparecem aqui quando houver pacientes associados.</template>
<template #content>
<div v-if="cards.length === 0" class="min-h-[150px] flex flex-col items-center justify-center text-center gap-2">
<i class="pi pi-tags text-3xl"></i>
<div class="font-medium">Sem dados ainda</div>
<small class="text-color-secondary">
Quando você associar pacientes às tags, elas aparecem aqui.
</small>
</div>
<div v-else class="flex flex-col gap-3">
<div
v-for="t in cards"
:key="t.id"
class="relative p-4 rounded-xl border border-[var(--surface-border)] transition-all duration-150 hover:-translate-y-1 hover:shadow-[var(--card-shadow)]"
@mouseenter="hovered = t.id"
@mouseleave="hovered = null"
>
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<div class="flex items-center gap-2 min-w-0">
<span
class="inline-block rounded-full"
:style="{
width: '10px',
height: '10px',
background: t.cor || '#94a3b8'
}"
/>
<div class="font-semibold truncate">{{ t.nome }}</div>
<span v-if="t.is_padrao" class="text-xs text-color-secondary">(padrão)</span>
</div>
<div class="text-sm text-color-secondary mt-1">
{{ Number(t.pacientes_count ?? 0) }} paciente(s)
</div>
</div>
<Transition name="fade">
<div v-if="hovered === t.id" class="flex items-center justify-content-center">
<Button
label="Ver pacientes"
icon="pi pi-users"
severity="success"
:disabled="Number(t.pacientes_count ?? 0) <= 0"
@click.stop="abrirModalPacientesDaTag(t)"
/>
</div>
</Transition>
</div>
</div>
</div>
</template>
</Card>
</div>
</div>
<!-- DIALOG CREATE / EDIT -->
<Dialog
v-model:visible="dlg.open"
:header="dlg.mode === 'create' ? 'Criar Tag' : 'Editar Tag'"
modal
:style="{ width: '520px', maxWidth: '92vw' }"
>
<div class="flex flex-col gap-4">
<div>
<label class="block mb-2">Nome da Tag</label>
<InputText v-model="dlg.nome" class="w-full" :disabled="dlg.saving" />
<small class="text-color-secondary">Ex.: Burnout, Ansiedade, Triagem.</small>
</div>
<div>
<label class="block mb-2">Cor (opcional)</label>
<div class="flex flex-wrap items-center gap-3">
<ColorPicker v-model="dlg.cor" format="hex" :disabled="dlg.saving" />
<InputText
v-model="dlg.cor"
class="w-44"
placeholder="#22c55e"
:disabled="dlg.saving"
/>
<span
class="inline-block rounded-lg"
:style="{
width: '34px',
height: '34px',
border: '1px solid var(--surface-border)',
background: corPreview(dlg.cor)
}"
/>
</div>
<small class="text-color-secondary">
Pode usar HEX (#rrggbb). Se vazio, usamos uma cor neutra.
</small>
</div>
</div>
<template #footer>
<Button label="Cancelar" icon="pi pi-times" text @click="fecharDlg" :disabled="dlg.saving" />
<Button
:label="dlg.mode === 'create' ? 'Criar' : 'Salvar'"
icon="pi pi-check"
@click="salvarDlg"
:loading="dlg.saving"
:disabled="!String(dlg.nome || '').trim()"
/>
</template>
</Dialog>
<!-- MODAL: pacientes da tag -->
<Dialog
v-model:visible="modalPacientes.open"
:header="modalPacientes.tag ? `Pacientes — ${modalPacientes.tag.nome}` : 'Pacientes'"
modal
:style="{ width: '900px', maxWidth: '96vw' }"
>
<div class="flex flex-col gap-3">
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-3">
<IconField class="w-full md:w-80">
<InputIcon><i class="pi pi-search" /></InputIcon>
<InputText v-model="modalPacientes.search" placeholder="Buscar paciente..." class="w-full" />
</IconField>
<div class="flex items-center gap-2 justify-end">
<Button icon="pi pi-refresh" severity="secondary" outlined @click="recarregarModalPacientes" />
</div>
</div>
<Message v-if="modalPacientes.error" severity="error">
{{ modalPacientes.error }}
</Message>
<DataTable
:value="modalPacientesFiltrado"
:loading="modalPacientes.loading"
dataKey="id"
paginator
:rows="8"
:rowsPerPageOptions="[8, 15, 30]"
stripedRows
responsiveLayout="scroll"
>
<Column field="name" header="Paciente" sortable>
<template #body="{ data }">
<div class="flex items-center gap-3">
<Avatar v-if="data.avatar_url" :image="data.avatar_url" shape="circle" />
<Avatar v-else icon="pi pi-user" shape="circle" />
<div class="flex flex-col min-w-0">
<span class="font-medium truncate">{{ data.name }}</span>
<small class="text-color-secondary truncate">{{ data.email || '—' }}</small>
</div>
</div>
</template>
</Column>
<Column header="Telefone" style="width: 14rem;">
<template #body="{ data }">
<span class="text-color-secondary">{{ fmtPhoneBR(data.phone) }}</span>
</template>
</Column>
<Column header="Ações" style="width: 12rem;">
<template #body="{ data }">
<Button
label="Abrir"
icon="pi pi-external-link"
size="small"
outlined
@click="abrirPaciente(data)"
/>
</template>
</Column>
<template #empty>
<div class="text-color-secondary py-5">Nenhum paciente encontrado.</div>
</template>
</DataTable>
</div>
<template #footer>
<Button label="Fechar" icon="pi pi-times" text @click="modalPacientes.open = false" />
</template>
</Dialog>
<ConfirmDialog />
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useToast } from 'primevue/usetoast'
import { useConfirm } from 'primevue/useconfirm'
import ColorPicker from 'primevue/colorpicker'
import Checkbox from 'primevue/checkbox'
import { supabase } from '@/lib/supabase/client'
const router = useRouter()
const toast = useToast()
const confirm = useConfirm()
const dt = ref(null)
const carregando = ref(false)
const etiquetas = ref([])
const etiquetasSelecionadas = ref([])
const hovered = ref(null)
const filtros = ref({
global: { value: null, matchMode: 'contains' }
})
const dlg = reactive({
open: false,
mode: 'create', // 'create' | 'edit'
id: '',
nome: '',
cor: '',
saving: false
})
const modalPacientes = reactive({
open: false,
loading: false,
error: '',
tag: null,
items: [],
search: ''
})
const cards = computed(() =>
(etiquetas.value || [])
.filter(t => Number(t.pacientes_count ?? 0) > 0)
.sort((a, b) => Number(b.pacientes_count ?? 0) - Number(a.pacientes_count ?? 0))
)
const modalPacientesFiltrado = computed(() => {
const s = String(modalPacientes.search || '').trim().toLowerCase()
if (!s) return modalPacientes.items || []
return (modalPacientes.items || []).filter(p => {
const name = String(p.name || '').toLowerCase()
const email = String(p.email || '').toLowerCase()
const phone = String(p.phone || '').toLowerCase()
return name.includes(s) || email.includes(s) || phone.includes(s)
})
})
onMounted(() => {
buscarEtiquetas()
})
async function getOwnerId() {
const { data, error } = await supabase.auth.getUser()
if (error) throw error
const user = data?.user
if (!user) throw new Error('Você precisa estar logado.')
return user.id
}
function normalizarEtiquetaRow(r) {
// Compatível com banco antigo (name/color/is_native/patient_count)
// e com banco pt-BR (nome/cor/is_padrao/pacientes_count)
const nome = r?.nome ?? r?.name ?? ''
const cor = r?.cor ?? r?.color ?? null
const is_padrao = Boolean(r?.is_padrao ?? r?.is_native ?? false)
const pacientes_count = Number(
r?.pacientes_count ?? r?.patient_count ?? r?.patients_count ?? 0
)
return {
...r,
nome,
cor,
is_padrao,
pacientes_count
}
}
function isUniqueViolation(e) {
return e?.code === '23505' || /duplicate key value/i.test(String(e?.message || ''))
}
function friendlyDupMessage(nome) {
return `Já existe uma tag chamada “${nome}”. Tente outro nome.`
}
function corPreview(raw) {
const r = String(raw || '').trim()
if (!r) return '#94a3b8'
const hex = r.replace('#', '')
return `#${hex}`
}
/* -------------------------------
Seleção (bloqueia tags padrão)
-------------------------------- */
function estaSelecionada(row) {
return (etiquetasSelecionadas.value || []).some(s => s.id === row.id)
}
function alternarSelecao(row, checked) {
if (row.is_padrao) return
const sel = etiquetasSelecionadas.value || []
if (checked) {
if (!sel.some(s => s.id === row.id)) etiquetasSelecionadas.value = [...sel, row]
} else {
etiquetasSelecionadas.value = sel.filter(s => s.id !== row.id)
}
}
/* -------------------------------
Fetch tags
-------------------------------- */
async function buscarEtiquetas() {
carregando.value = true
try {
const ownerId = await getOwnerId()
// 1) tenta view (contagem pronta)
const v = await supabase
.from('v_tag_patient_counts')
.select('*')
.eq('owner_id', ownerId)
.order('nome', { ascending: true })
if (!v.error) {
etiquetas.value = (v.data || []).map(normalizarEtiquetaRow)
return
}
// 2) fallback tabela
const t = await supabase
.from('patient_tags')
.select('id, owner_id, nome, cor, is_padrao, name, color, is_native, created_at, updated_at')
.eq('owner_id', ownerId)
.order('nome', { ascending: true })
// se der erro porque ainda não tem 'nome', tenta por 'name'
if (t.error && /column .*nome/i.test(String(t.error.message || ''))) {
const t2 = await supabase
.from('patient_tags')
.select('id, owner_id, name, color, is_native, created_at, updated_at')
.eq('owner_id', ownerId)
.order('name', { ascending: true })
if (t2.error) throw t2.error
etiquetas.value = (t2.data || []).map(r => normalizarEtiquetaRow({ ...r, patient_count: 0 }))
return
}
if (t.error) throw t.error
etiquetas.value = (t.data || []).map(r => normalizarEtiquetaRow({ ...r, pacientes_count: 0 }))
} catch (e) {
console.error('[TagsPacientesPage] buscarEtiquetas error', e)
toast.add({
severity: 'error',
summary: 'Erro ao carregar tags',
detail: e?.message || 'Não consegui carregar as tags. Verifique se as tabelas/views existem no Supabase local.',
life: 6000
})
} finally {
carregando.value = false
}
}
/* -------------------------------
Dialog create/edit
-------------------------------- */
function abrirCriar() {
dlg.open = true
dlg.mode = 'create'
dlg.id = ''
dlg.nome = ''
dlg.cor = ''
}
function abrirEditar(row) {
dlg.open = true
dlg.mode = 'edit'
dlg.id = row.id
dlg.nome = row.nome || ''
dlg.cor = row.cor || ''
}
function fecharDlg() {
dlg.open = false
}
async function salvarDlg() {
const nome = String(dlg.nome || '').trim()
if (!nome) return
dlg.saving = true
try {
const ownerId = await getOwnerId()
// salva sempre "#rrggbb" ou null
const raw = String(dlg.cor || '').trim()
const hex = raw ? raw.replace('#', '') : ''
const cor = hex ? `#${hex}` : null
if (dlg.mode === 'create') {
// tenta pt-BR
let res = await supabase.from('patient_tags').insert({
owner_id: ownerId,
nome,
cor
})
// se colunas pt-BR não existem ainda, cai pra legado
if (res.error && /column .*nome/i.test(String(res.error.message || ''))) {
res = await supabase.from('patient_tags').insert({
owner_id: ownerId,
name: nome,
color: cor
})
}
if (res.error) throw res.error
toast.add({ severity: 'success', summary: 'Tag criada', detail: nome, life: 2500 })
} else {
// update pt-BR
let res = await supabase
.from('patient_tags')
.update({
nome,
cor,
updated_at: new Date().toISOString()
})
.eq('id', dlg.id)
.eq('owner_id', ownerId)
// legado
if (res.error && /column .*nome/i.test(String(res.error.message || ''))) {
res = await supabase
.from('patient_tags')
.update({
name: nome,
color: cor,
updated_at: new Date().toISOString()
})
.eq('id', dlg.id)
.eq('owner_id', ownerId)
}
if (res.error) throw res.error
toast.add({ severity: 'success', summary: 'Tag atualizada', detail: nome, life: 2500 })
}
dlg.open = false
await buscarEtiquetas()
} catch (e) {
console.error('[TagsPacientesPage] salvarDlg error', e)
const nome = String(dlg.nome || '').trim()
if (isUniqueViolation(e)) {
toast.add({
severity: 'warn',
summary: 'Tag já existe',
detail: friendlyDupMessage(nome),
life: 4500
})
return
}
toast.add({
severity: 'error',
summary: 'Não consegui salvar',
detail: e?.message || 'Erro ao salvar a tag.',
life: 6000
})
} finally {
dlg.saving = false
}
}
/* -------------------------------
Delete
-------------------------------- */
function confirmarExclusaoUma(row) {
confirm.require({
message: `Excluir a tag “${row.nome}”? (Isso remove também os vínculos com pacientes)`,
header: 'Confirmar exclusão',
icon: 'pi pi-exclamation-triangle',
acceptLabel: 'Excluir',
rejectLabel: 'Cancelar',
accept: async () => excluirTags([row])
})
}
function confirmarExclusaoSelecionadas() {
const rows = etiquetasSelecionadas.value || []
if (!rows.length) return
const nomes = rows.slice(0, 5).map(r => r.nome).join(', ')
confirm.require({
message:
rows.length <= 5
? `Excluir: ${nomes}? (remove também os vínculos)`
: `Excluir ${rows.length} tags selecionadas? (remove também os vínculos)`,
header: 'Confirmar exclusão',
icon: 'pi pi-exclamation-triangle',
acceptLabel: 'Excluir',
rejectLabel: 'Cancelar',
accept: async () => excluirTags(rows)
})
}
async function excluirTags(rows) {
if (!rows?.length) return
try {
const ownerId = await getOwnerId()
const ids = rows.filter(r => !r.is_padrao).map(r => r.id)
if (!ids.length) {
toast.add({
severity: 'warn',
summary: 'Nada para excluir',
detail: 'Tags padrão não podem ser removidas.',
life: 4000
})
return
}
// 1) apaga pivots
const pivotDel = await supabase
.from('patient_patient_tag')
.delete()
.eq('owner_id', ownerId)
.in('tag_id', ids)
if (pivotDel.error) throw pivotDel.error
// 2) apaga tags
const tagDel = await supabase
.from('patient_tags')
.delete()
.eq('owner_id', ownerId)
.in('id', ids)
if (tagDel.error) throw tagDel.error
etiquetasSelecionadas.value = []
toast.add({ severity: 'success', summary: 'Excluído', detail: `${ids.length} tag(s) removida(s).`, life: 3000 })
await buscarEtiquetas()
} catch (e) {
console.error('[TagsPacientesPage] excluirTags error', e)
toast.add({
severity: 'error',
summary: 'Não consegui excluir',
detail: e?.message || 'Erro ao excluir tags.',
life: 6000
})
}
}
/* -------------------------------
Modal pacientes
-------------------------------- */
async function abrirModalPacientesDaTag(tag) {
modalPacientes.open = true
modalPacientes.tag = tag
modalPacientes.items = []
modalPacientes.search = ''
modalPacientes.error = ''
await carregarPacientesDaTag(tag)
}
async function recarregarModalPacientes() {
if (!modalPacientes.tag) return
await carregarPacientesDaTag(modalPacientes.tag)
}
async function carregarPacientesDaTag(tag) {
modalPacientes.loading = true
modalPacientes.error = ''
try {
const ownerId = await getOwnerId()
const { data, error } = await supabase
.from('patient_patient_tag')
.select(`
patient_id,
patients:patients(
id,
nome_completo,
email_principal,
telefone,
avatar_url
)
`)
.eq('owner_id', ownerId)
.eq('tag_id', tag.id)
if (error) throw error
const normalizados = (data || [])
.map(r => r.patients)
.filter(Boolean)
.map(p => ({
id: p.id,
name: p.nome_completo || '—',
email: p.email_principal || '—',
phone: p.telefone || '—',
avatar_url: p.avatar_url || null
}))
.sort((a, b) => String(a.name).localeCompare(String(b.name), 'pt-BR'))
modalPacientes.items = normalizados
} catch (e) {
console.error('[TagsPacientesPage] carregarPacientesDaTag error', e)
modalPacientes.error =
e?.message ||
'Não consegui carregar os pacientes desta tag. Verifique RLS/policies e se as tabelas existem.'
} finally {
modalPacientes.loading = false
}
}
function onlyDigits(v) {
return String(v ?? '').replace(/\D/g, '')
}
function fmtPhoneBR(v) {
const d = onlyDigits(v)
if (!d) return '—'
// opcional: se vier com DDI 55 grudado (ex.: 5511999999999)
if ((d.length === 12 || d.length === 13) && d.startsWith('55')) {
return fmtPhoneBR(d.slice(2))
}
// (11) 9xxxx-xxxx
if (d.length === 11) return `(${d.slice(0, 2)}) ${d.slice(2, 7)}-${d.slice(7, 11)}`
// (11) xxxx-xxxx
if (d.length === 10) return `(${d.slice(0, 2)}) ${d.slice(2, 6)}-${d.slice(6, 10)}`
return d
}
function abrirPaciente(patient) {
// no teu router, a rota de edição é /admin/pacientes/cadastro/:id
router.push(`/admin/pacientes/cadastro/${patient.id}`)
}
</script>
<style scoped>
/* Mantido apenas porque Transition name="fade" precisa das classes */
.fade-enter-active,
.fade-leave-active { transition: opacity 0.15s ease; }
.fade-enter-from,
.fade-leave-to { opacity: 0; }
</style>

View File

@@ -1,70 +1,453 @@
<script setup>
import FloatingConfigurator from '@/components/FloatingConfigurator.vue';
import { ref } from 'vue';
import FloatingConfigurator from '@/components/FloatingConfigurator.vue'
import { useTenantStore } from '@/stores/tenantStore'
const email = ref('');
const password = ref('');
const checked = ref(false);
import { ref, onMounted, computed } from 'vue'
import { useRouter } from 'vue-router'
import { supabase } from '../../../lib/supabase/client'
import InputText from 'primevue/inputtext'
import Password from 'primevue/password'
import Checkbox from 'primevue/checkbox'
import Button from 'primevue/button'
import Dialog from 'primevue/dialog'
import { useToast } from 'primevue/usetoast'
const tenant = useTenantStore()
const toast = useToast()
const router = useRouter()
const email = ref('')
const password = ref('')
const checked = ref(false)
const loading = ref(false)
const authError = ref('')
// recovery
const openRecovery = ref(false)
const recoveryEmail = ref('')
const loadingRecovery = ref(false)
const recoverySent = ref(false)
const canSubmit = computed(() => {
return !!email.value?.trim() && !!password.value && !loading.value && !loadingRecovery.value
})
function isEmail (v) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(String(v || '').trim())
}
function roleToPath (role) {
if (role === 'tenant_admin') return '/admin'
if (role === 'therapist') return '/therapist'
if (role === 'patient') return '/patient'
return '/'
}
function persistRememberedEmail () {
const mail = String(email.value || '').trim()
try {
if (checked.value && mail) localStorage.setItem('remember_login_email', mail)
else localStorage.removeItem('remember_login_email')
} catch {
// ignora storage bloqueado
}
}
async function onSubmit () {
authError.value = ''
loading.value = true
try {
const mail = String(email.value || '').trim()
const res = await supabase.auth.signInWithPassword({
email: mail,
password: password.value
})
if (res.error) throw res.error
// ✅ agora que está autenticado, garante tenant pessoal (Modelo B)
try {
await supabase.rpc('ensure_personal_tenant')
} catch (e) {
console.warn('[Login] ensure_personal_tenant falhou:', e)
// não aborta login por isso
}
// ✅ fonte de verdade: tenant_members (rpc my_tenants)
await tenant.loadSessionAndTenant()
if (!tenant.user) {
authError.value = 'Não foi possível obter a sessão após login.'
return
}
if (!tenant.activeRole) {
authError.value = 'Sua conta não tem vínculo ativo com uma clínica (tenant_members).'
await supabase.auth.signOut()
return
}
// lembrar e-mail (não senha)
persistRememberedEmail()
const redirect = sessionStorage.getItem('redirect_after_login')
if (redirect) {
sessionStorage.removeItem('redirect_after_login')
router.push(redirect)
return
}
const intended = sessionStorage.getItem('intended_area')
sessionStorage.removeItem('intended_area')
const target = roleToPath(tenant.activeRole)
if (intended && intended !== tenant.activeRole) {
router.push(target)
return
}
router.push(target)
} catch (e) {
authError.value = e?.message || 'Não foi possível entrar.'
} finally {
loading.value = false
}
}
function openForgot () {
recoverySent.value = false
recoveryEmail.value = email.value?.trim() || ''
openRecovery.value = true
}
async function sendRecoveryEmail () {
const mail = String(recoveryEmail.value || '').trim()
if (!mail || !isEmail(mail)) {
toast.add({ severity: 'warn', summary: 'E-mail', detail: 'Digite um e-mail válido.', life: 3000 })
return
}
loadingRecovery.value = true
recoverySent.value = false
try {
const redirectTo = `${window.location.origin}/auth/reset-password`
const { error } = await supabase.auth.resetPasswordForEmail(mail, { redirectTo })
if (error) throw error
recoverySent.value = true
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao enviar e-mail.', life: 4500 })
} finally {
loadingRecovery.value = false
}
}
onMounted(() => {
// legado: prefill via sessionStorage (mantive)
const preEmail = sessionStorage.getItem('login_prefill_email')
const prePass = sessionStorage.getItem('login_prefill_password')
// lembrar e-mail via localStorage (novo)
let remembered = ''
try {
remembered = localStorage.getItem('remember_login_email') || ''
} catch {}
if (preEmail) email.value = preEmail
else if (remembered) email.value = remembered
if (prePass) password.value = prePass
checked.value = !!remembered
sessionStorage.removeItem('login_prefill_email')
sessionStorage.removeItem('login_prefill_password')
})
</script>
<template>
<FloatingConfigurator />
<div class="bg-surface-50 dark:bg-surface-950 flex items-center justify-center min-h-screen min-w-[100vw] overflow-hidden">
<div class="flex flex-col items-center justify-center">
<div style="border-radius: 56px; padding: 0.3rem; background: linear-gradient(180deg, var(--primary-color) 10%, rgba(33, 150, 243, 0) 30%)">
<div class="w-full bg-surface-0 dark:bg-surface-900 py-20 px-8 sm:px-20" style="border-radius: 53px">
<div class="text-center mb-8">
<svg viewBox="0 0 54 40" fill="none" xmlns="http://www.w3.org/2000/svg" class="mb-8 w-16 shrink-0 mx-auto">
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M17.1637 19.2467C17.1566 19.4033 17.1529 19.561 17.1529 19.7194C17.1529 25.3503 21.7203 29.915 27.3546 29.915C32.9887 29.915 37.5561 25.3503 37.5561 19.7194C37.5561 19.5572 37.5524 19.3959 37.5449 19.2355C38.5617 19.0801 39.5759 18.9013 40.5867 18.6994L40.6926 18.6782C40.7191 19.0218 40.7326 19.369 40.7326 19.7194C40.7326 27.1036 34.743 33.0896 27.3546 33.0896C19.966 33.0896 13.9765 27.1036 13.9765 19.7194C13.9765 19.374 13.9896 19.0316 14.0154 18.6927L14.0486 18.6994C15.0837 18.9062 16.1223 19.0886 17.1637 19.2467ZM33.3284 11.4538C31.6493 10.2396 29.5855 9.52381 27.3546 9.52381C25.1195 9.52381 23.0524 10.2421 21.3717 11.4603C20.0078 11.3232 18.6475 11.1387 17.2933 10.907C19.7453 8.11308 23.3438 6.34921 27.3546 6.34921C31.36 6.34921 34.9543 8.10844 37.4061 10.896C36.0521 11.1292 34.692 11.3152 33.3284 11.4538ZM43.826 18.0518C43.881 18.6003 43.9091 19.1566 43.9091 19.7194C43.9091 28.8568 36.4973 36.2642 27.3546 36.2642C18.2117 36.2642 10.8 28.8568 10.8 19.7194C10.8 19.1615 10.8276 18.61 10.8816 18.0663L7.75383 17.4411C7.66775 18.1886 7.62354 18.9488 7.62354 19.7194C7.62354 30.6102 16.4574 39.4388 27.3546 39.4388C38.2517 39.4388 47.0855 30.6102 47.0855 19.7194C47.0855 18.9439 47.0407 18.1789 46.9536 17.4267L43.826 18.0518ZM44.2613 9.54743L40.9084 10.2176C37.9134 5.95821 32.9593 3.1746 27.3546 3.1746C21.7442 3.1746 16.7856 5.96385 13.7915 10.2305L10.4399 9.56057C13.892 3.83178 20.1756 0 27.3546 0C34.5281 0 40.8075 3.82591 44.2613 9.54743Z"
fill="var(--primary-color)"
/>
<mask id="mask0_1413_1551" style="mask-type: alpha" maskUnits="userSpaceOnUse" x="0" y="8" width="54" height="11">
<path d="M27 18.3652C10.5114 19.1944 0 8.88892 0 8.88892C0 8.88892 16.5176 14.5866 27 14.5866C37.4824 14.5866 54 8.88892 54 8.88892C54 8.88892 43.4886 17.5361 27 18.3652Z" fill="var(--primary-color)" />
</mask>
<g mask="url(#mask0_1413_1551)">
<path
d="M-4.673e-05 8.88887L3.73084 -1.91434L-8.00806 17.0473L-4.673e-05 8.88887ZM27 18.3652L26.4253 6.95109L27 18.3652ZM54 8.88887L61.2673 17.7127L50.2691 -1.91434L54 8.88887ZM-4.673e-05 8.88887C-8.00806 17.0473 -8.00469 17.0505 -8.00132 17.0538C-8.00018 17.055 -7.99675 17.0583 -7.9944 17.0607C-7.98963 17.0653 -7.98474 17.0701 -7.97966 17.075C-7.96949 17.0849 -7.95863 17.0955 -7.94707 17.1066C-7.92401 17.129 -7.89809 17.1539 -7.86944 17.1812C-7.8122 17.236 -7.74377 17.3005 -7.66436 17.3743C-7.50567 17.5218 -7.30269 17.7063 -7.05645 17.9221C-6.56467 18.3532 -5.89662 18.9125 -5.06089 19.5534C-3.39603 20.83 -1.02575 22.4605 1.98012 24.0457C7.97874 27.2091 16.7723 30.3226 27.5746 29.7793L26.4253 6.95109C20.7391 7.23699 16.0326 5.61231 12.6534 3.83024C10.9703 2.94267 9.68222 2.04866 8.86091 1.41888C8.45356 1.10653 8.17155 0.867278 8.0241 0.738027C7.95072 0.673671 7.91178 0.637576 7.90841 0.634492C7.90682 0.63298 7.91419 0.639805 7.93071 0.65557C7.93897 0.663455 7.94952 0.673589 7.96235 0.686039C7.96883 0.692262 7.97582 0.699075 7.98338 0.706471C7.98719 0.710167 7.99113 0.714014 7.99526 0.718014C7.99729 0.720008 8.00047 0.723119 8.00148 0.724116C8.00466 0.727265 8.00796 0.730446 -4.673e-05 8.88887ZM27.5746 29.7793C37.6904 29.2706 45.9416 26.3684 51.6602 23.6054C54.5296 22.2191 56.8064 20.8465 58.4186 19.7784C59.2265 19.2431 59.873 18.7805 60.3494 18.4257C60.5878 18.2482 60.7841 18.0971 60.9374 17.977C61.014 17.9169 61.0799 17.8645 61.1349 17.8203C61.1624 17.7981 61.1872 17.7781 61.2093 17.7602C61.2203 17.7512 61.2307 17.7427 61.2403 17.7348C61.2452 17.7308 61.2499 17.727 61.2544 17.7233C61.2566 17.7215 61.2598 17.7188 61.261 17.7179C61.2642 17.7153 61.2673 17.7127 54 8.88887C46.7326 0.0650536 46.7357 0.0625219 46.7387 0.0600241C46.7397 0.0592345 46.7427 0.0567658 46.7446 0.0551857C46.7485 0.0520238 46.7521 0.0489887 46.7557 0.0460799C46.7628 0.0402623 46.7694 0.0349487 46.7753 0.0301318C46.7871 0.0204986 46.7966 0.0128495 46.8037 0.00712562C46.818 -0.00431848 46.8228 -0.00808311 46.8184 -0.00463784C46.8096 0.00228345 46.764 0.0378652 46.6828 0.0983779C46.5199 0.219675 46.2165 0.439161 45.7812 0.727519C44.9072 1.30663 43.5257 2.14765 41.7061 3.02677C38.0469 4.79468 32.7981 6.63058 26.4253 6.95109L27.5746 29.7793ZM54 8.88887C50.2691 -1.91433 50.27 -1.91467 50.271 -1.91498C50.2712 -1.91506 50.272 -1.91535 50.2724 -1.9155C50.2733 -1.91581 50.274 -1.91602 50.2743 -1.91616C50.2752 -1.91643 50.275 -1.91636 50.2738 -1.91595C50.2714 -1.91515 50.2652 -1.91302 50.2552 -1.9096C50.2351 -1.90276 50.1999 -1.89078 50.1503 -1.874C50.0509 -1.84043 49.8938 -1.78773 49.6844 -1.71863C49.2652 -1.58031 48.6387 -1.377 47.8481 -1.13035C46.2609 -0.635237 44.0427 0.0249875 41.5325 0.6823C36.215 2.07471 30.6736 3.15796 27 3.15796V26.0151C33.8087 26.0151 41.7672 24.2495 47.3292 22.7931C50.2586 22.026 52.825 21.2618 54.6625 20.6886C55.5842 20.4011 56.33 20.1593 56.8551 19.986C57.1178 19.8993 57.3258 19.8296 57.4735 19.7797C57.5474 19.7548 57.6062 19.7348 57.6493 19.72C57.6709 19.7127 57.6885 19.7066 57.7021 19.7019C57.7089 19.6996 57.7147 19.6976 57.7195 19.696C57.7219 19.6952 57.7241 19.6944 57.726 19.6938C57.7269 19.6934 57.7281 19.693 57.7286 19.6929C57.7298 19.6924 57.7309 19.692 54 8.88887ZM27 3.15796C23.3263 3.15796 17.7849 2.07471 12.4674 0.6823C9.95717 0.0249875 7.73904 -0.635237 6.15184 -1.13035C5.36118 -1.377 4.73467 -1.58031 4.3155 -1.71863C4.10609 -1.78773 3.94899 -1.84043 3.84961 -1.874C3.79994 -1.89078 3.76474 -1.90276 3.74471 -1.9096C3.73469 -1.91302 3.72848 -1.91515 3.72613 -1.91595C3.72496 -1.91636 3.72476 -1.91643 3.72554 -1.91616C3.72593 -1.91602 3.72657 -1.91581 3.72745 -1.9155C3.72789 -1.91535 3.72874 -1.91506 3.72896 -1.91498C3.72987 -1.91467 3.73084 -1.91433 -4.673e-05 8.88887C-3.73093 19.692 -3.72983 19.6924 -3.72868 19.6929C-3.72821 19.693 -3.72698 19.6934 -3.72603 19.6938C-3.72415 19.6944 -3.72201 19.6952 -3.71961 19.696C-3.71482 19.6976 -3.70901 19.6996 -3.7022 19.7019C-3.68858 19.7066 -3.67095 19.7127 -3.6494 19.72C-3.60629 19.7348 -3.54745 19.7548 -3.47359 19.7797C-3.32589 19.8296 -3.11788 19.8993 -2.85516 19.986C-2.33008 20.1593 -1.58425 20.4011 -0.662589 20.6886C1.17485 21.2618 3.74125 22.026 6.67073 22.7931C12.2327 24.2495 20.1913 26.0151 27 26.0151V3.15796Z"
fill="var(--primary-color)"
/>
</g>
</svg>
<div class="text-surface-900 dark:text-surface-0 text-3xl font-medium mb-4">Welcome to PrimeLand!</div>
<span class="text-muted-color font-medium">Sign in to continue</span>
</div>
<FloatingConfigurator />
<div>
<label for="email1" class="block text-surface-900 dark:text-surface-0 text-xl font-medium mb-2">Email</label>
<InputText id="email1" type="text" placeholder="Email address" class="w-full md:w-[30rem] mb-8" v-model="email" />
<label for="password1" class="block text-surface-900 dark:text-surface-0 font-medium text-xl mb-2">Password</label>
<Password id="password1" v-model="password" placeholder="Password" :toggleMask="true" class="mb-4" fluid :feedback="false"></Password>
<div class="flex items-center justify-between mt-2 mb-8 gap-8">
<div class="flex items-center">
<Checkbox v-model="checked" id="rememberme1" binary class="mr-2"></Checkbox>
<label for="rememberme1">Remember me</label>
</div>
<span class="font-medium no-underline ml-2 text-right cursor-pointer text-primary">Forgot password?</span>
</div>
<Button label="Sign In" class="w-full" as="router-link" to="/"></Button>
</div>
</div>
</div>
</div>
<div class="relative min-h-screen w-full overflow-hidden bg-[var(--surface-ground)]">
<!-- fundo conceitual -->
<div class="pointer-events-none absolute inset-0">
<!-- grid muito sutil -->
<div
class="absolute inset-0 opacity-70"
style="
background-image:
linear-gradient(to right, rgba(255,255,255,.05) 1px, transparent 1px),
linear-gradient(to bottom, rgba(255,255,255,.05) 1px, transparent 1px);
background-size: 38px 38px;
mask-image: radial-gradient(ellipse at 50% 20%, rgba(0,0,0,.95), transparent 70%);
"
/>
<!-- halos -->
<div class="absolute -top-28 -right-28 h-[26rem] w-[26rem] rounded-full blur-3xl bg-indigo-400/10" />
<div class="absolute top-20 -left-28 h-[30rem] w-[30rem] rounded-full blur-3xl bg-emerald-400/10" />
<div class="absolute -bottom-32 right-24 h-[26rem] w-[26rem] rounded-full blur-3xl bg-fuchsia-400/10" />
</div>
<div class="relative grid min-h-screen place-items-center p-4 md:p-8">
<div class="w-full max-w-5xl">
<div class="relative overflow-hidden rounded-[2.75rem] border border-[var(--surface-border)] bg-[var(--surface-card)] shadow-2xl">
<!-- header -->
<div class="relative px-6 pt-7 pb-5 md:px-10 md:pt-10 md:pb-6">
<div class="flex items-start justify-between gap-4">
<div class="flex items-start gap-3">
<div class="grid h-12 w-12 place-items-center rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-ground)]">
<i class="pi pi-eye text-lg opacity-80" />
</div>
<div class="min-w-0">
<div class="text-2xl md:text-3xl font-semibold leading-tight text-[var(--text-color)]">
Entrar
</div>
<div class="mt-1 text-sm md:text-base text-[var(--text-color-secondary)]">
Acesso seguro ao seu painel.
</div>
</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>
<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>
<!-- corpo -->
<div class="relative px-6 pb-7 md:px-10 md:pb-10">
<div class="grid grid-cols-12 gap-4 md:gap-6">
<!-- FORM -->
<div class="col-span-12 md:col-span-7">
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-ground)] p-4 md:p-6">
<form class="grid grid-cols-12 gap-4" @submit.prevent="onSubmit">
<!-- email -->
<div class="col-span-12">
<label class="block text-sm font-semibold text-[var(--text-color)] mb-2">
E-mail
</label>
<InputText
v-model="email"
class="w-full"
placeholder="seuemail@dominio.com"
autocomplete="email"
:disabled="loading || loadingRecovery"
/>
</div>
<!-- senha -->
<div class="col-span-12">
<label class="block text-sm font-semibold text-[var(--text-color)] mb-2">
Senha
</label>
<Password
v-model="password"
placeholder="Sua senha"
toggleMask
:feedback="false"
class="w-full"
inputClass="w-full"
autocomplete="current-password"
:disabled="loading || loadingRecovery"
/>
</div>
<!-- lembrar + esqueci -->
<div class="col-span-12 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div class="flex items-center">
<Checkbox
v-model="checked"
inputId="rememberme1"
binary
class="mr-2"
:disabled="loading || loadingRecovery"
/>
<label for="rememberme1" class="text-sm text-[var(--text-color-secondary)]">
Lembrar meu e-mail neste dispositivo
</label>
</div>
<button
type="button"
class="text-sm font-medium text-[var(--primary-color)] hover:opacity-80 text-left"
:disabled="loading || loadingRecovery"
@click="openForgot"
>
Esqueceu sua senha?
</button>
</div>
<!-- erro -->
<div v-if="authError" class="col-span-12">
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] px-4 py-3 text-sm text-red-500">
<i class="pi pi-exclamation-triangle mr-2 opacity-80" />
{{ authError }}
</div>
</div>
<!-- submit -->
<div class="col-span-12">
<Button
type="submit"
label="Entrar"
class="w-full"
icon="pi pi-sign-in"
:loading="loading"
:disabled="!canSubmit"
/>
</div>
<div class="col-span-12 text-xs text-[var(--text-color-secondary)]">
Ao entrar, você será direcionado para sua área conforme seu perfil e vínculo com a clínica.
</div>
<!-- detalhe minimalista -->
<div class="col-span-12">
<div class="h-px w-full bg-[var(--surface-border)] opacity-70" />
</div>
<div class="col-span-12 text-xs text-[var(--text-color-secondary)]">
Se você estiver testando perfis e cair na mensagem de vínculo, é porque o acesso depende de <span class="font-semibold">tenant_members</span>.
</div>
</form>
</div>
</div>
<!-- LADO DIREITO: editorial / conceito -->
<div class="col-span-12 md:col-span-5">
<div class="h-full rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-ground)] p-4 md:p-6">
<div class="flex items-start justify-between gap-3">
<div>
<div class="text-sm font-semibold text-[var(--text-color)]">Acesso com lastro</div>
<div class="mt-1 text-xs text-[var(--text-color-secondary)]">
A sessão é validada e o vínculo com a clínica define sua área.
</div>
</div>
<i class="pi pi-shield text-sm opacity-70" />
</div>
<div class="mt-5 rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-4">
<div class="text-xs text-[var(--text-color-secondary)] leading-relaxed">
<span class="font-semibold text-[var(--text-color)]">Como funciona:</span>
você autentica, o sistema carrega seu tenant ativo e então libera o painel correspondente.
Isso evita acesso solto e organiza permissões no lugar certo.
</div>
</div>
<ul class="mt-5 space-y-2 text-xs text-[var(--text-color-secondary)]">
<li class="flex gap-2">
<span class="mt-1 h-1.5 w-1.5 rounded-full bg-emerald-400/70"></span>
Recuperação de senha via link (e-mail).
</li>
<li class="flex gap-2">
<span class="mt-1 h-1.5 w-1.5 rounded-full bg-indigo-400/70"></span>
Se o link não chegar, cheque spam/lixo eletrônico.
</li>
<li class="flex gap-2">
<span class="mt-1 h-1.5 w-1.5 rounded-full bg-fuchsia-400/70"></span>
O redirecionamento depende da role ativa: admin/therapist/patient.
</li>
</ul>
<div class="mt-5 rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-4 text-xs text-[var(--text-color-secondary)]">
<i class="pi pi-info-circle mr-2 opacity-70" />
Garanta que o Supabase tenha Redirect URLs incluindo
<span class="font-semibold">/auth/reset-password</span>.
</div>
<div class="mt-6 hidden md:flex items-center justify-between text-xs text-[var(--text-color-secondary)] opacity-80">
<span class="inline-flex items-center gap-2">
<span class="h-1.5 w-1.5 rounded-full bg-primary/60" />
Agência Psi Quasar
</span>
<span class="opacity-80">Acesso clínico</span>
</div>
</div>
</div>
</div>
<!-- Dialog recovery -->
<Dialog
v-model:visible="openRecovery"
modal
header="Recuperar acesso"
:draggable="false"
:style="{ width: '28rem', maxWidth: '92vw' }"
>
<div class="space-y-3">
<div class="text-sm text-[var(--text-color-secondary)]">
Informe seu e-mail. Vamos enviar um link para redefinir sua senha.
</div>
<div class="space-y-2">
<label class="text-sm font-semibold">E-mail</label>
<InputText
v-model="recoveryEmail"
class="w-full"
placeholder="seuemail@dominio.com"
:disabled="loadingRecovery"
/>
</div>
<div class="flex flex-col gap-2 sm:flex-row sm:justify-end pt-2">
<Button
label="Cancelar"
severity="secondary"
outlined
:disabled="loadingRecovery"
@click="openRecovery = false"
/>
<Button
label="Enviar link"
icon="pi pi-envelope"
:loading="loadingRecovery"
@click="sendRecoveryEmail"
/>
</div>
<div
v-if="recoverySent"
class="rounded-xl border border-[var(--surface-border)] bg-[var(--surface-ground)] p-3 text-xs text-[var(--text-color-secondary)]"
>
<i class="pi pi-check mr-2 text-emerald-500"></i>
Se o e-mail existir, você receberá o link em instantes. Verifique também spam/lixo eletrônico.
</div>
</div>
</Dialog>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.pi-eye {
transform: scale(1.6);
margin-right: 1rem;
}
.pi-eye-slash {
transform: scale(1.6);
margin-right: 1rem;
}
</style>

View File

@@ -0,0 +1,253 @@
<template>
<div class="min-h-screen p-4 md:p-6 grid place-items-center">
<div class="w-full max-w-lg">
<Card class="overflow-hidden rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] shadow-sm">
<template #title>
<div class="relative">
<!-- blobs sutis -->
<div class="pointer-events-none absolute inset-0 opacity-90">
<div class="absolute -top-20 -right-16 h-60 w-60 rounded-full bg-indigo-400/10 blur-3xl" />
<div class="absolute top-10 -left-20 h-72 w-72 rounded-full bg-emerald-400/10 blur-3xl" />
<div class="absolute -bottom-24 right-10 h-64 w-64 rounded-full bg-fuchsia-400/10 blur-3xl" />
</div>
<div class="relative p-5 md:p-6">
<div class="flex items-start gap-3">
<div
class="grid h-10 w-10 place-items-center rounded-xl border border-[var(--surface-border)] bg-[var(--surface-ground)]"
>
<i class="pi pi-key text-lg opacity-80" />
</div>
<div class="min-w-0">
<div class="text-xl md:text-2xl font-semibold leading-tight">
Redefinir senha
</div>
<div class="mt-1 text-sm text-[var(--text-color-secondary)]">
Escolha uma nova senha para sua conta. Depois, você fará login novamente.
</div>
<div
v-if="bannerText"
class="mt-3 rounded-xl border border-[var(--surface-border)] bg-[var(--surface-ground)] px-3 py-2 text-xs text-[var(--text-color-secondary)]"
>
<i class="pi pi-info-circle mr-2 opacity-70" />
{{ bannerText }}
</div>
</div>
</div>
</div>
</div>
</template>
<template #content>
<div class="p-5 md:p-6 pt-0">
<div class="grid grid-cols-12 gap-4">
<!-- Nova senha -->
<div class="col-span-12">
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-ground)] p-4">
<div class="flex items-start justify-between gap-3">
<div>
<div class="text-sm font-semibold">Nova senha</div>
<div class="mt-1 text-xs text-[var(--text-color-secondary)]">
Mínimo: 8 caracteres, maiúscula, minúscula e número.
</div>
</div>
<i class="pi pi-lock text-sm opacity-70" />
</div>
<div class="mt-3">
<Password
v-model="newPassword"
toggleMask
:feedback="false"
inputClass="w-full"
:disabled="loading"
placeholder="Crie uma nova senha"
/>
</div>
<div class="mt-2 text-xs">
<span v-if="!newPassword" class="text-[var(--text-color-secondary)]">
Dica: use uma frase curta + número (ex.: NoiteCalma7).
</span>
<span v-else :class="strengthOk ? 'text-emerald-500' : 'text-yellow-500'">
{{ strengthOk ? 'Senha forte o suficiente.' : 'Ainda está fraca — ajuste os critérios.' }}
</span>
</div>
</div>
</div>
<!-- Confirmar -->
<div class="col-span-12">
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-ground)] p-4">
<div class="flex items-start justify-between gap-3">
<div>
<div class="text-sm font-semibold">Confirmar nova senha</div>
<div class="mt-1 text-xs text-[var(--text-color-secondary)]">
Evita erro de digitação.
</div>
</div>
<i class="pi pi-check-circle text-sm opacity-70" />
</div>
<div class="mt-3">
<Password
v-model="confirmPassword"
toggleMask
:feedback="false"
inputClass="w-full"
:disabled="loading"
placeholder="Repita a nova senha"
/>
</div>
<div class="mt-2 text-xs">
<span v-if="!confirmPassword" class="text-[var(--text-color-secondary)]">
Digite novamente para confirmar.
</span>
<span v-else :class="matchOk ? 'text-emerald-500' : 'text-yellow-500'">
{{ matchOk ? 'Confere.' : 'Não confere com a nova senha.' }}
</span>
</div>
</div>
</div>
</div>
<!-- Ações -->
<div class="mt-5 flex flex-col gap-2">
<Button
class="w-full"
label="Atualizar senha"
icon="pi pi-check"
:loading="loading"
:disabled="loading"
@click="submit"
/>
<button
type="button"
class="w-full rounded-xl border border-[var(--surface-border)] bg-[var(--surface-ground)] px-4 py-2.5 text-sm font-medium text-[var(--text-color-secondary)] hover:opacity-90"
:disabled="loading"
@click="goLogin"
>
Voltar para login
</button>
<div class="mt-1 text-center text-xs text-[var(--text-color-secondary)]">
Se você não solicitou essa redefinição, ignore o e-mail e faça logout em dispositivos desconhecidos.
</div>
</div>
</div>
</template>
</Card>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import Card from 'primevue/card'
import Password from 'primevue/password'
import Button from 'primevue/button'
import { useToast } from 'primevue/usetoast'
import { useRouter } from 'vue-router'
import { supabase } from '@/lib/supabase/client'
const toast = useToast()
const router = useRouter()
const newPassword = ref('')
const confirmPassword = ref('')
const loading = ref(false)
const bannerText = ref('')
function isStrongEnough (p) {
return /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).{8,}$/.test(p || '')
}
const strengthOk = computed(() => isStrongEnough(newPassword.value))
const matchOk = computed(() => !!confirmPassword.value && newPassword.value === confirmPassword.value)
onMounted(async () => {
try {
// 1) força leitura da sessão (supabase-js já captura hash automaticamente)
const { data } = await supabase.auth.getSession()
if (!data?.session) {
bannerText.value =
'Este link parece inválido ou expirado. Solicite um novo e-mail de redefinição.'
} else {
bannerText.value =
'Link validado. Defina sua nova senha abaixo.'
}
// 2) escuta evento específico de recovery
const { data: listener } = supabase.auth.onAuthStateChange((event) => {
if (event === 'PASSWORD_RECOVERY') {
bannerText.value =
'Link validado. Defina sua nova senha abaixo.'
}
})
return () => {
listener?.subscription?.unsubscribe()
}
} catch {
bannerText.value =
'Erro ao validar o link. Solicite um novo e-mail.'
}
})
function goLogin () {
router.replace('/auth/login')
}
async function submit () {
if (!newPassword.value || !confirmPassword.value) {
toast.add({ severity: 'warn', summary: 'Campos', detail: 'Preencha todos os campos.', life: 3000 })
return
}
if (newPassword.value !== confirmPassword.value) {
toast.add({ severity: 'warn', summary: 'Confirmação', detail: 'A confirmação não confere.', life: 3000 })
return
}
if (!isStrongEnough(newPassword.value)) {
toast.add({
severity: 'warn',
summary: 'Senha fraca',
detail: 'Use no mínimo 8 caracteres com maiúscula, minúscula e número.',
life: 4500
})
return
}
loading.value = true
try {
const { error } = await supabase.auth.updateUser({ password: newPassword.value })
if (error) throw error
toast.add({
severity: 'success',
summary: 'Pronto',
detail: 'Senha redefinida. Faça login novamente.',
life: 3500
})
// encerra sessão do recovery
await supabase.auth.signOut()
router.replace('/auth/login')
} catch (e) {
toast.add({
severity: 'error',
summary: 'Erro',
detail: e?.message || 'Não foi possível redefinir a senha.',
life: 4500
})
} finally {
loading.value = false
}
}
</script>

View File

@@ -0,0 +1,374 @@
<template>
<div class="min-h-[calc(100vh-8rem)] p-4 md:p-6">
<div class="mx-auto w-full max-w-4xl">
<Card class="overflow-hidden rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] shadow-sm">
<template #title>
<div class="relative">
<!-- blobs sutis -->
<div class="pointer-events-none absolute inset-0 opacity-90">
<div class="absolute -top-20 -right-16 h-60 w-60 rounded-full bg-indigo-400/10 blur-3xl" />
<div class="absolute top-6 -left-20 h-72 w-72 rounded-full bg-emerald-400/10 blur-3xl" />
<div class="absolute -bottom-24 right-10 h-64 w-64 rounded-full bg-fuchsia-400/10 blur-3xl" />
</div>
<div class="relative flex flex-col gap-2 p-5 md:p-6">
<div class="flex items-start justify-between gap-4">
<div class="min-w-0">
<div class="flex items-center gap-3">
<div
class="grid h-10 w-10 place-items-center rounded-xl border border-[var(--surface-border)] bg-[var(--surface-ground)]"
>
<i class="pi pi-shield text-lg opacity-80" />
</div>
<div class="min-w-0">
<div class="text-xl md:text-2xl font-semibold leading-tight">
Segurança
</div>
<div class="mt-0.5 text-sm md:text-base text-[var(--text-color-secondary)]">
Troque sua senha com cuidado. Depois, você será deslogado por segurança.
</div>
</div>
</div>
</div>
<div class="hidden md:flex items-center gap-2">
<span
class="inline-flex items-center gap-2 rounded-full border border-[var(--surface-border)] bg-[var(--surface-ground)] px-3 py-1 text-xs text-[var(--text-color-secondary)]"
>
<span class="h-1.5 w-1.5 rounded-full bg-emerald-400/70" />
sessão ativa
</span>
</div>
</div>
</div>
</div>
</template>
<template #content>
<div class="p-5 md:p-6 pt-0">
<!-- GRID -->
<div class="grid grid-cols-12 gap-4">
<!-- Senha atual -->
<div class="col-span-12 md:col-span-6">
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-ground)] p-4">
<div class="flex items-start justify-between gap-3">
<div>
<div class="text-sm font-semibold">Senha atual</div>
<div class="mt-1 text-xs text-[var(--text-color-secondary)]">
Necessária para confirmar que é você.
</div>
</div>
<i class="pi pi-lock text-sm opacity-70" />
</div>
<div class="mt-3">
<Password
v-model="currentPassword"
toggleMask
:feedback="false"
inputClass="w-full"
:disabled="loading || loadingReset"
placeholder="Digite sua senha atual"
/>
</div>
</div>
</div>
<!-- Dica lateral -->
<div class="col-span-12 md:col-span-6">
<div class="h-full rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-ground)] p-4">
<div class="flex items-start justify-between gap-3">
<div>
<div class="text-sm font-semibold">Boas práticas</div>
<div class="mt-1 text-xs text-[var(--text-color-secondary)]">
Senhas fortes são menos lembráveis, mas mais seguras.
</div>
</div>
<i class="pi pi-info-circle text-sm opacity-70" />
</div>
<ul class="mt-3 space-y-2 text-xs text-[var(--text-color-secondary)]">
<li class="flex gap-2">
<span class="mt-1 h-1.5 w-1.5 rounded-full bg-indigo-400/70"></span>
Use pelo menos 8 caracteres, com maiúscula, minúscula e número.
</li>
<li class="flex gap-2">
<span class="mt-1 h-1.5 w-1.5 rounded-full bg-emerald-400/70"></span>
Evite datas, nomes e padrões (1234, qwerty).
</li>
<li class="flex gap-2">
<span class="mt-1 h-1.5 w-1.5 rounded-full bg-fuchsia-400/70"></span>
Se estiver em computador público, finalize a sessão depois.
</li>
</ul>
</div>
</div>
<!-- Nova senha -->
<div class="col-span-12 md:col-span-6">
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-ground)] p-4">
<div class="flex items-start justify-between gap-3">
<div>
<div class="text-sm font-semibold">Nova senha</div>
<div class="mt-1 text-xs text-[var(--text-color-secondary)]">
Deve atender aos critérios mínimos.
</div>
</div>
<i class="pi pi-key text-sm opacity-70" />
</div>
<div class="mt-3">
<Password
v-model="newPassword"
toggleMask
:feedback="false"
inputClass="w-full"
:disabled="loading || loadingReset"
placeholder="Crie uma nova senha"
/>
</div>
<div class="mt-2 text-xs">
<span
v-if="!newPassword"
class="text-[var(--text-color-secondary)]"
>
Critérios: 8+ caracteres, maiúscula, minúscula e número.
</span>
<span
v-else
:class="passwordStrengthOk ? 'text-emerald-500' : 'text-yellow-500'"
>
{{ passwordStrengthOk ? 'Senha forte o suficiente.' : 'Ainda está fraca — ajuste os critérios.' }}
</span>
</div>
</div>
</div>
<!-- Confirmar -->
<div class="col-span-12 md:col-span-6">
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-ground)] p-4">
<div class="flex items-start justify-between gap-3">
<div>
<div class="text-sm font-semibold">Confirmar nova senha</div>
<div class="mt-1 text-xs text-[var(--text-color-secondary)]">
Evita erro de digitação.
</div>
</div>
<i class="pi pi-check-circle text-sm opacity-70" />
</div>
<div class="mt-3">
<Password
v-model="confirmPassword"
toggleMask
:feedback="false"
inputClass="w-full"
:disabled="loading || loadingReset"
placeholder="Repita a nova senha"
/>
</div>
<div class="mt-2 text-xs">
<span v-if="!confirmPassword" class="text-[var(--text-color-secondary)]">
Digite novamente para confirmar.
</span>
<span
v-else
:class="passwordMatchOk ? 'text-emerald-500' : 'text-yellow-500'"
>
{{ passwordMatchOk ? 'Confere.' : 'Não confere com a nova senha.' }}
</span>
</div>
</div>
</div>
</div>
<!-- Ações -->
<div class="mt-5 flex flex-col-reverse gap-2 md:flex-row md:items-center md:justify-between">
<div class="text-xs text-[var(--text-color-secondary)]">
Ao trocar sua senha, você será desconectado de forma global.
</div>
<div class="flex flex-col gap-2 sm:flex-row sm:justify-end">
<Button
label="Esqueci minha senha"
severity="secondary"
outlined
icon="pi pi-envelope"
:loading="loadingReset"
:disabled="loading || loadingReset"
@click="sendResetEmail"
/>
<Button
label="Trocar senha"
icon="pi pi-check"
:loading="loading"
:disabled="loading || loadingReset"
@click="changePassword"
/>
</div>
</div>
</div>
</template>
</Card>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
import Card from 'primevue/card'
import Password from 'primevue/password'
import Button from 'primevue/button'
import { useToast } from 'primevue/usetoast'
import { supabase } from '@/lib/supabase/client'
import { sessionUser, sessionRole } from '@/app/session'
const toast = useToast()
const currentPassword = ref('')
const newPassword = ref('')
const confirmPassword = ref('')
const loading = ref(false)
const loadingReset = ref(false)
function isStrongEnough (p) {
return /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).{8,}$/.test(p || '')
}
const passwordStrengthOk = computed(() => isStrongEnough(newPassword.value))
const passwordMatchOk = computed(() => !!confirmPassword.value && newPassword.value === confirmPassword.value)
function clearFields () {
currentPassword.value = ''
newPassword.value = ''
confirmPassword.value = ''
}
async function hardLogout () {
// 1) tenta logout normal (se falhar, seguimos)
try {
// DEBUG LOGOUT
console.log('ANTES', (await supabase.auth.getSession()).data.session)
await supabase.auth.signOut({ scope: 'global' })
console.log('DEPOIS', (await supabase.auth.getSession()).data.session)
} catch (e) {
console.warn('[signOut failed]', e)
}
// 2) zera estado reativo global
sessionUser.value = null
sessionRole.value = null
// 3) remove token persistido do supabase-js v2 (sb-*-auth-token)
try {
const keysToRemove = []
for (let i = 0; i < localStorage.length; i++) {
const k = localStorage.key(i)
if (!k) continue
if (k.startsWith('sb-') && k.includes('auth-token')) keysToRemove.push(k)
}
keysToRemove.forEach((k) => localStorage.removeItem(k))
} catch (e) {
console.warn('[storage cleanup failed]', e)
}
// 4) remove redirect pendente
try {
sessionStorage.removeItem('redirect_after_login')
} catch {}
// 5) redireciona de forma "hard"
window.location.replace('/auth/login')
}
async function changePassword () {
const user = sessionUser.value
if (!user?.email) {
toast.add({ severity: 'error', summary: 'Sessão', detail: 'Usuário não encontrado na sessão.', life: 3500 })
return
}
if (!currentPassword.value || !newPassword.value || !confirmPassword.value) {
toast.add({ severity: 'warn', summary: 'Campos', detail: 'Preencha todos os campos.', life: 3000 })
return
}
if (newPassword.value !== confirmPassword.value) {
toast.add({ severity: 'warn', summary: 'Confirmação', detail: 'A confirmação não confere.', life: 3000 })
return
}
if (!isStrongEnough(newPassword.value)) {
toast.add({
severity: 'warn',
summary: 'Senha fraca',
detail: 'Use no mínimo 8 caracteres com maiúscula, minúscula e número.',
life: 4500
})
return
}
loading.value = true
try {
// Reautentica (padrão mais previsível)
const { error: signError } = await supabase.auth.signInWithPassword({
email: user.email,
password: currentPassword.value
})
if (signError) throw signError
const { error: upError } = await supabase.auth.updateUser({
password: newPassword.value
})
if (upError) throw upError
toast.add({
severity: 'success',
summary: 'Senha atualizada',
detail: 'Por segurança, você será deslogado.',
life: 2500
})
clearFields()
await hardLogout()
} catch (e) {
toast.add({
severity: 'error',
summary: 'Erro',
detail: e?.message || 'Não foi possível trocar a senha.',
life: 4000
})
} finally {
loading.value = false
}
}
async function sendResetEmail () {
const user = sessionUser.value
if (!user?.email) {
toast.add({ severity: 'error', summary: 'Sessão', detail: 'Usuário não encontrado na sessão.', life: 3500 })
return
}
loadingReset.value = true
try {
const redirectTo = `${window.location.origin}/auth/reset-password`
const { error } = await supabase.auth.resetPasswordForEmail(user.email, { redirectTo })
if (error) throw error
toast.add({
severity: 'info',
summary: 'E-mail enviado',
detail: 'Verifique sua caixa de entrada para redefinir a senha.',
life: 5000
})
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao enviar e-mail.', life: 4500 })
} finally {
loadingReset.value = false
}
}
</script>

View File

@@ -0,0 +1,274 @@
<script setup>
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { supabase } from '@/lib/supabase/client'
import Card from 'primevue/card'
import Message from 'primevue/message'
import Button from 'primevue/button'
import Divider from 'primevue/divider'
import Tag from 'primevue/tag'
import Chip from 'primevue/chip'
import ProgressSpinner from 'primevue/progressspinner'
const route = useRoute()
const router = useRouter()
// ============================
// Query
// ============================
const planFromQuery = computed(() => String(route.query.plan || '').trim().toLowerCase())
const intervalFromQuery = computed(() => String(route.query.interval || '').trim().toLowerCase())
function normalizeInterval(v) {
if (v === 'monthly') return 'month'
if (v === 'annual' || v === 'yearly') return 'year'
return v
}
const intervalNormalized = computed(() => normalizeInterval(intervalFromQuery.value))
const intervalLabel = computed(() => {
if (intervalNormalized.value === 'year') return 'Anual'
if (intervalNormalized.value === 'month') return 'Mensal'
return ''
})
// ============================
// Pricing
// ============================
const loading = ref(false)
const planRow = ref(null)
const planName = computed(() => planRow.value?.public_name || planRow.value?.plan_name || null)
const planDescription = computed(() => planRow.value?.public_description || null)
const amountCents = computed(() => {
if (!planRow.value) return null
return intervalNormalized.value === 'year'
? planRow.value.yearly_cents
: planRow.value.monthly_cents
})
const currency = computed(() => {
if (!planRow.value) return 'BRL'
return intervalNormalized.value === 'year'
? (planRow.value.yearly_currency || 'BRL')
: (planRow.value.monthly_currency || 'BRL')
})
const formattedPrice = computed(() => {
if (amountCents.value == null) return null
return new Intl.NumberFormat('pt-BR', {
style: 'currency',
currency: currency.value || 'BRL'
}).format(amountCents.value / 100)
})
async function loadPlan() {
planRow.value = null
if (!planFromQuery.value) return
loading.value = true
try {
const { data, error } = await supabase
.from('v_public_pricing')
.select(`
plan_key,
plan_name,
public_name,
public_description,
badge,
is_featured,
monthly_cents,
yearly_cents,
monthly_currency,
yearly_currency,
is_visible
`)
.eq('plan_key', planFromQuery.value)
.eq('is_visible', true)
.maybeSingle()
if (error) throw error
if (data) planRow.value = data
} catch (err) {
console.error('[Welcome] loadPlan:', err)
} finally {
loading.value = false
}
}
onMounted(loadPlan)
watch(() => planFromQuery.value, () => loadPlan())
function goLogin() {
router.push('/auth/login')
}
function goBackLanding() {
router.push('/lp')
}
</script>
<template>
<div class="min-h-screen bg-[var(--surface-ground)] text-[var(--text-color)] flex items-center justify-center p-4">
<!-- fundo suave (noir glow) -->
<div class="pointer-events-none absolute inset-0 overflow-hidden">
<div class="absolute -top-28 -left-28 h-96 w-96 rounded-full blur-3xl opacity-60 bg-indigo-400/10" />
<div class="absolute top-24 -right-24 h-[28rem] w-[28rem] rounded-full blur-3xl opacity-60 bg-emerald-400/10" />
<div class="absolute -bottom-40 left-1/3 h-[34rem] w-[34rem] rounded-full blur-3xl opacity-60 bg-fuchsia-400/10" />
</div>
<div class="relative w-full max-w-6xl">
<div class="rounded-3xl border border-[var(--surface-border)] bg-[var(--surface-card)] shadow-sm overflow-hidden">
<div class="grid grid-cols-12">
<!-- LEFT: boas-vindas (PrimeBlocks-like) -->
<div
class="col-span-12 lg:col-span-6 p-6 md:p-10 bg-[color-mix(in_srgb,var(--surface-card),transparent_6%)] border-b lg:border-b-0 lg:border-r border-[var(--surface-border)]"
>
<div class="flex items-center gap-3">
<div
class="h-11 w-11 rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-ground)] grid place-items-center"
>
<i class="pi pi-sparkles opacity-80 text-lg" />
</div>
<div class="min-w-0">
<div class="font-semibold leading-tight truncate">Psi Quasar</div>
<div class="text-xs text-[var(--text-color-secondary)] truncate">Gestão clínica sem ruído.</div>
</div>
</div>
<Divider class="my-6" />
<div class="text-3xl md:text-4xl font-semibold leading-tight">
Bem-vindo(a).
</div>
<div class="mt-3 text-sm md:text-base text-[var(--text-color-secondary)] max-w-lg">
Sua conta foi criada e a sua intenção de assinatura foi registrada.
Agora o caminho é simples: instruções de pagamento confirmação ativação do plano.
</div>
<div class="mt-6 grid grid-cols-12 gap-3">
<div class="col-span-12 md:col-span-6">
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-ground)] p-4">
<div class="text-xs text-[var(--text-color-secondary)]">1) Pagamento</div>
<div class="text-xl font-semibold mt-1">Manual</div>
<div class="text-xs text-[var(--text-color-secondary)] mt-1">PIX ou boleto</div>
</div>
</div>
<div class="col-span-12 md:col-span-6">
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-ground)] p-4">
<div class="text-xs text-[var(--text-color-secondary)]">2) Confirmação</div>
<div class="text-xl font-semibold mt-1">Rápida</div>
<div class="text-xs text-[var(--text-color-secondary)] mt-1">verificação e liberação</div>
</div>
</div>
<div class="col-span-12">
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-ground)] p-4">
<div class="flex items-start justify-between gap-3">
<div>
<div class="text-xs text-[var(--text-color-secondary)]">3) Plano ativo</div>
<div class="font-semibold mt-1">Recursos liberados</div>
<div class="text-xs text-[var(--text-color-secondary)] mt-1">entitlements PRO quando pago</div>
</div>
<i class="pi pi-verified opacity-60" />
</div>
</div>
</div>
</div>
<div class="mt-6 flex flex-wrap gap-2">
<Tag severity="secondary" value="Sem cobrança automática" />
<Tag severity="secondary" value="Ativação após confirmação" />
<Tag severity="secondary" value="Fluxo pronto para gateway depois" />
</div>
<div class="mt-6 text-xs text-[var(--text-color-secondary)]">
* Página de boas-vindas inspirada em layouts PrimeBlocks.
</div>
</div>
<!-- RIGHT: resumo + botões -->
<div class="col-span-12 lg:col-span-6 p-6 md:p-10">
<div class="max-w-md mx-auto">
<div class="text-2xl font-semibold">Conta criada 🎉</div>
<div class="text-sm text-[var(--text-color-secondary)] mt-1">
Você pode entrar. Se o seu plano for PRO, ele será ativado após confirmação do pagamento.
</div>
<div class="mt-5">
<Message severity="success" class="mb-3">
Sua intenção de assinatura foi registrada.
</Message>
<div v-if="loading" class="flex items-center gap-2 text-sm text-[var(--text-color-secondary)]">
<ProgressSpinner style="width: 18px; height: 18px" strokeWidth="4" />
Carregando detalhes do plano
</div>
<Card v-else class="overflow-hidden">
<template #content>
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<div class="text-xs text-[var(--text-color-secondary)]">Resumo do plano</div>
<div class="mt-1 flex items-center gap-2 flex-wrap">
<div class="text-lg font-semibold truncate">
{{ planName || 'Plano' }}
</div>
<Tag v-if="planRow?.is_featured" severity="success" value="Popular" />
<Tag v-if="planRow?.badge" severity="secondary" :value="planRow.badge" />
<Chip v-if="intervalLabel" :label="intervalLabel" />
</div>
<div class="mt-2 text-2xl font-semibold leading-none">
{{ formattedPrice || '—' }}
<span v-if="intervalLabel" class="text-sm font-normal text-[var(--text-color-secondary)]">
/{{ intervalNormalized === 'month' ? 'mês' : 'ano' }}
</span>
</div>
<div v-if="planDescription" class="mt-2 text-sm text-[var(--text-color-secondary)]">
{{ planDescription }}
</div>
<Message v-if="planFromQuery && !planRow" severity="warn" class="mt-3">
Não encontrei esse plano na vitrine pública. Você pode continuar normalmente.
</Message>
</div>
</div>
<Divider class="my-4" />
<Message severity="info" class="mb-0">
Próximo passo: você receberá instruções de pagamento (PIX ou boleto).
Assim que confirmado, sua assinatura será ativada.
</Message>
</template>
</Card>
</div>
<div class="mt-5 gap-2">
<Button label="Ir para login" class="w-full mb-2" icon="pi pi-sign-in" @click="goLogin" />
<Button
label="Voltar para a página inicial"
severity="secondary"
outlined
class="w-full"
icon="pi pi-arrow-left"
@click="goBackLanding"
/>
</div>
<div class="text-center text-xs text-[var(--text-color-secondary)] mt-4">
Psi Quasar gestão clínica sem ruído.
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,396 @@
<!-- src/views/pages/upgrade/UpgradePage.vue (ajuste o caminho conforme seu projeto) -->
<script setup>
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import Card from 'primevue/card'
import Button from 'primevue/button'
import Divider from 'primevue/divider'
import Tag from 'primevue/tag'
import Toast from 'primevue/toast'
import { useToast } from 'primevue/usetoast'
import { supabase } from '@/lib/supabase/client'
import { useTenantStore } from '@/stores/tenantStore'
import { useEntitlementsStore } from '@/stores/entitlementsStore'
const toast = useToast()
const route = useRoute()
const router = useRouter()
const tenantStore = useTenantStore()
const entitlementsStore = useEntitlementsStore()
// Feature que motivou o redirecionamento
const requestedFeature = computed(() => route.query.feature || null)
// nomes amigáveis (fallback se não achar)
const featureLabels = {
'online_scheduling.manage': 'Agendamento Online',
'online_scheduling.public': 'Página pública de agendamento',
'advanced_reports': 'Relatórios avançados',
'sms_reminder': 'Lembretes por SMS',
'intakes_pro': 'Formulários PRO'
}
const requestedFeatureLabel = computed(() => {
if (!requestedFeature.value) return null
return featureLabels[requestedFeature.value] || requestedFeature.value
})
// estado
const loading = ref(false)
const upgrading = ref(false)
const plans = ref([]) // plans reais
const features = ref([]) // features reais
const planFeatures = ref([]) // links reais plan_features
const subscription = ref(null) // subscription ativa do tenant
// ✅ Modelo B: plano é do TENANT
const tenantId = computed(() => tenantStore.activeTenantId || null)
const planById = computed(() => {
const m = new Map()
for (const p of plans.value) m.set(p.id, p)
return m
})
const enabledFeatureIdsByPlanId = computed(() => {
// Map planId -> Set(featureId)
const m = new Map()
for (const row of planFeatures.value) {
const set = m.get(row.plan_id) || new Set()
set.add(row.feature_id)
m.set(row.plan_id, set)
}
return m
})
const currentPlanId = computed(() => subscription.value?.plan_id || null)
function planKeyById(id) {
return planById.value.get(id)?.key || null
}
const currentPlanKey = computed(() => planKeyById(currentPlanId.value))
function friendlyFeatureLabel(featureKey) {
return featureLabels[featureKey] || featureKey
}
const sortedPlans = computed(() => {
// ordena free primeiro, pro segundo, resto por key
const arr = [...plans.value]
const rank = (k) => (k === 'free' ? 0 : k === 'pro' ? 1 : 10)
arr.sort((a, b) => {
const ra = rank(a.key), rb = rank(b.key)
if (ra !== rb) return ra - rb
return String(a.key).localeCompare(String(b.key))
})
return arr
})
function planBenefits(planId) {
const set = enabledFeatureIdsByPlanId.value.get(planId) || new Set()
const list = features.value.map((f) => ({
ok: set.has(f.id),
key: f.key,
text: friendlyFeatureLabel(f.key)
}))
// coloca as “ok” em cima
list.sort((a, b) => Number(b.ok) - Number(a.ok) || a.text.localeCompare(b.text))
return list
}
function goBack() {
router.back()
}
function goBilling() {
router.push('/admin/billing')
}
function contactSupport() {
router.push('/admin/billing')
}
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('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')
.eq('tenant_id', tid)
.eq('status', 'active')
.order('updated_at', { ascending: false })
.limit(1)
.maybeSingle()
])
if (pRes.error) throw pRes.error
if (fRes.error) throw fRes.error
if (pfRes.error) throw pfRes.error
// ✅ subscription pode ser null sem quebrar a página
if (sRes.error) {
console.warn('[Upgrade] sem subscription ativa (ok):', sRes.error)
}
plans.value = pRes.data || []
features.value = fRes.data || []
planFeatures.value = pfRes.data || []
subscription.value = sRes.data || null
} catch (e) {
console.error(e)
toast.add({ severity: 'error', summary: 'Erro', detail: e.message || String(e), life: 5000 })
} finally {
loading.value = false
}
}
async function changePlan(targetPlanId) {
if (!subscription.value?.id) {
toast.add({
severity: 'warn',
summary: 'Sem assinatura ativa',
detail: 'Não encontrei uma assinatura ativa para este tenant. Ative via pagamento manual primeiro.',
life: 4500
})
return
}
if (!targetPlanId) return
if (upgrading.value) return
upgrading.value = true
try {
const tid = tenantId.value
if (!tid) throw new Error('Tenant ativo não encontrado.')
const current = subscription.value.plan_id
if (current === targetPlanId) return
// ✅ usa o mesmo RPC do seu painel SaaS (transação + histórico)
const { data, error } = await supabase.rpc('change_subscription_plan', {
p_subscription_id: subscription.value.id,
p_new_plan_id: targetPlanId
})
if (error) throw error
// atualiza estado local
subscription.value.plan_id = data?.plan_id || targetPlanId
// ✅ recarrega entitlements (sem reload)
entitlementsStore.clear?.()
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(),
life: 3000
})
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e.message || String(e), life: 5000 })
} finally {
upgrading.value = false
}
}
onMounted(fetchAll)
// se trocar tenant ativo, recarrega
watch(
() => tenantId.value,
() => {
if (tenantId.value) fetchAll()
}
)
</script>
<template>
<Toast />
<div class="p-4 md:p-6 lg:p-8">
<!-- HERO CONCEITUAL -->
<div class="mb-6 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-20 -right-24 h-80 w-80 rounded-full bg-indigo-400/10 blur-3xl" />
<div class="absolute top-12 -left-24 h-96 w-96 rounded-full bg-emerald-400/10 blur-3xl" />
<div class="absolute -bottom-20 right-32 h-80 w-80 rounded-full bg-fuchsia-400/10 blur-3xl" />
</div>
<div class="relative flex flex-col gap-4">
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<div class="text-2xl md:text-3xl font-semibold leading-tight">
Atualize seu plano
</div>
<div class="mt-1 text-sm md:text-base text-[var(--text-color-secondary)]">
Tenant ativo:
<b>{{ tenantId || '—' }}</b>
<span class="mx-2 opacity-50"></span>
Você está no plano:
<b>{{ currentPlanKey || '—' }}</b>
</div>
</div>
<div class="flex items-center gap-2">
<Button label="Voltar" icon="pi pi-arrow-left" severity="secondary" outlined @click="goBack" />
<Button label="Assinatura" icon="pi pi-credit-card" severity="secondary" outlined @click="goBilling" />
<Button label="Recarregar" icon="pi pi-refresh" severity="secondary" outlined :loading="loading" @click="fetchAll" />
</div>
</div>
<!-- BLOCO: RECURSO BLOQUEADO -->
<div
v-if="requestedFeatureLabel"
class="relative overflow-hidden rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-ground)] p-4"
>
<div class="absolute inset-0 opacity-60 pointer-events-none">
<div class="absolute -top-10 -right-12 h-40 w-40 rounded-full bg-amber-400/10 blur-2xl" />
<div class="absolute -bottom-10 left-16 h-40 w-40 rounded-full bg-rose-400/10 blur-2xl" />
</div>
<div class="relative flex flex-col md:flex-row md:items-center md:justify-between gap-3">
<div class="min-w-0">
<div class="flex items-center gap-2">
<Tag severity="warning" value="Recurso bloqueado" />
<div class="font-semibold truncate">
{{ requestedFeatureLabel }}
</div>
</div>
<div class="mt-1 text-sm text-[var(--text-color-secondary)]">
Esse recurso depende do plano que inclui a feature <b>{{ requestedFeature }}</b>.
</div>
</div>
<div class="flex items-center gap-2">
<Button
label="Ver planos"
icon="pi pi-arrow-down"
severity="secondary"
outlined
@click="() => document.getElementById('plans-grid')?.scrollIntoView({ behavior: 'smooth', block: 'start' })"
/>
</div>
</div>
</div>
<div class="text-xs md:text-sm text-[var(--text-color-secondary)]">
A diferença entre ter uma agenda e ter um sistema mora nos detalhes.
</div>
</div>
</div>
</div>
<!-- PLANOS (DINÂMICOS) -->
<div id="plans-grid" class="grid grid-cols-12 gap-4 md:gap-6">
<div v-for="p in sortedPlans" :key="p.id" class="col-span-12 lg:col-span-6">
<!-- card destaque pro PRO -->
<div
:id="p.key === 'pro' ? 'plan-pro' : null"
:class="p.key === 'pro'
? 'relative overflow-hidden rounded-[1.75rem] border border-primary/40 bg-[var(--surface-card)]'
: ''"
>
<div v-if="p.key === 'pro'" class="pointer-events-none absolute inset-0 opacity-80">
<div class="absolute -top-24 -right-28 h-96 w-96 rounded-full bg-primary/10 blur-3xl" />
<div class="absolute -bottom-28 left-12 h-96 w-96 rounded-full bg-emerald-400/10 blur-3xl" />
</div>
<Card :class="p.key === 'pro' ? 'relative border-0' : 'overflow-hidden'">
<template #title>
<div class="flex items-center justify-between gap-2">
<div class="flex items-center gap-2">
<i :class="p.key === 'pro' ? 'pi pi-sparkles opacity-80' : 'pi pi-leaf opacity-70'" />
<span class="text-xl font-semibold">Plano {{ String(p.key || '').toUpperCase() }}</span>
</div>
<div class="flex items-center gap-2">
<Tag v-if="currentPlanId === p.id" value="Atual" severity="secondary" />
<Tag v-else-if="p.key === 'pro'" value="Recomendado" severity="success" />
</div>
</div>
</template>
<template #subtitle>
<span v-if="p.key === 'free'">O essencial para começar, sem travar seu fluxo.</span>
<span v-else-if="p.key === 'pro'">Para quem quer automatizar, reduzir ruído e ganhar previsibilidade.</span>
<span v-else>Plano personalizado: {{ p.key }}</span>
</template>
<template #content>
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-ground)] p-4">
<ul class="list-none p-0 m-0 flex flex-col gap-3">
<li v-for="(b, i) in planBenefits(p.id)" :key="i" class="flex items-start gap-2">
<i
:class="b.ok ? 'pi pi-check-circle text-emerald-500' : 'pi pi-times-circle opacity-50'"
class="mt-0.5"
/>
<span :class="b.ok ? '' : 'text-[var(--text-color-secondary)]'">
{{ b.text }}
</span>
</li>
</ul>
<Divider class="my-4" />
<div class="flex flex-col gap-3">
<Button
v-if="currentPlanId !== p.id"
:label="`Mudar para ${String(p.key || '').toUpperCase()}`"
icon="pi pi-arrow-up"
size="large"
class="w-full"
:loading="upgrading"
:disabled="upgrading || loading"
@click="changePlan(p.id)"
/>
<Button
v-else
label="Você já está neste plano"
icon="pi pi-check"
severity="secondary"
outlined
class="w-full"
disabled
/>
<Button
v-if="p.key !== 'free'"
label="Falar com suporte"
icon="pi pi-comments"
severity="secondary"
outlined
class="w-full"
@click="contactSupport"
/>
<div class="text-center text-xs text-[var(--text-color-secondary)]">
Cancele quando quiser. Sem burocracia.
</div>
</div>
</div>
</template>
</Card>
</div>
</div>
</div>
<div class="mt-6 text-xs text-[var(--text-color-secondary)]">
Observação: alguns recursos PRO podem depender de configuração inicial (ex.: SMS exige provedor).
</div>
</div>
</template>

View File

@@ -0,0 +1,954 @@
<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 items-start justify-between gap-4">
<div class="min-w-0">
<div class="text-2xl md:text-3xl font-semibold leading-tight">Meu Perfil</div>
<div class="mt-1 text-sm md:text-base text-[var(--text-color-secondary)]">
Gerencie suas informações, preferências e segurança da conta.
</div>
</div>
<div class="flex items-center gap-2">
<Button label="Voltar" icon="pi pi-arrow-left" severity="secondary" outlined @click="router.back()" />
<Button label="Salvar" icon="pi pi-check" :loading="saving" :disabled="!dirty" @click="saveAll" />
</div>
</div>
</div>
</div>
<!-- Layout -->
<div class="grid grid-cols-12 gap-4 md:gap-6">
<!-- Sidebar -->
<div class="col-span-12 lg:col-span-4 xl:col-span-3">
<div class="sticky top-4">
<Card class="overflow-hidden">
<template #content>
<div class="flex items-center gap-3">
<div class="relative">
<img
v-if="ui.avatarPreview"
:src="ui.avatarPreview"
class="h-12 w-12 rounded-2xl object-cover border border-[var(--surface-border)]"
alt="avatar"
/>
<div
v-else
class="h-12 w-12 rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-ground)] grid place-items-center font-semibold"
>
{{ initials }}
</div>
<span class="absolute -bottom-1 -right-1 h-4 w-4 rounded-full border border-[var(--surface-card)] bg-emerald-400/80" />
</div>
<div class="min-w-0">
<div class="font-semibold truncate">{{ form.full_name || userEmail || 'Conta' }}</div>
<div class="text-xs text-[var(--text-color-secondary)] truncate">
{{ userEmail || '—' }}
</div>
</div>
</div>
<div class="mt-4 space-y-1">
<button
v-for="s in sections"
:key="s.id"
type="button"
class="w-full text-left px-3 py-2 rounded-xl border border-transparent hover:border-[var(--surface-border)] hover:bg-[var(--surface-ground)] transition flex items-center gap-2"
:class="activeSection === s.id ? 'bg-[var(--surface-ground)] border-[var(--surface-border)]' : ''"
@click="scrollTo(s.id)"
>
<i :class="s.icon" class="text-sm opacity-80" />
<span class="text-sm font-medium">{{ s.label }}</span>
</button>
</div>
<Divider class="my-4" />
<div class="flex flex-col gap-2">
<Button label="Trocar senha" icon="pi pi-key" severity="secondary" outlined @click="openPasswordDialog" />
<Button label="Sair" icon="pi pi-sign-out" severity="danger" outlined @click="confirmSignOut" />
</div>
<div class="mt-4 text-xs text-[var(--text-color-secondary)]">
Dica: alterações em <b>Nome</b> e <b>Avatar</b> atualizam sua identidade no app.
</div>
</template>
</Card>
</div>
</div>
<!-- Content -->
<div class="col-span-12 lg:col-span-8 xl:col-span-9 space-y-4 md:space-y-6">
<!-- Conta -->
<Card id="conta" class="scroll-mt-24">
<template #title>Conta</template>
<template #subtitle>Informações básicas e identidade.</template>
<template #content>
<div class="grid grid-cols-12 gap-4">
<div class="col-span-12 md:col-span-7">
<label class="block text-sm font-semibold mb-2">Nome</label>
<InputText v-model="form.full_name" class="w-full" placeholder="Seu nome" @input="markDirty" />
<small class="text-[var(--text-color-secondary)]">Aparece no menu, cabeçalhos e registros.</small>
</div>
<div class="col-span-12 md:col-span-5">
<label class="block text-sm font-semibold mb-2">E-mail</label>
<InputText :modelValue="userEmail" class="w-full" disabled />
<small class="text-[var(--text-color-secondary)]">E-mail vem do login (Supabase Auth).</small>
</div>
<div class="col-span-12 md:col-span-7">
<label class="block text-sm font-semibold mb-2">Bio</label>
<Textarea
v-model="form.bio"
class="w-full"
rows="5"
maxlength="2000"
placeholder="Uma breve descrição sobre você…"
@input="markDirty"
/>
<div class="mt-1 flex items-center justify-between text-xs text-[var(--text-color-secondary)]">
<span>Máximo de 2000 caracteres.</span>
<span>{{ (form.bio || '').length }}/2000</span>
</div>
</div>
<div class="col-span-12 md:col-span-5">
<label class="block text-sm font-semibold mb-2">Telefone</label>
<InputMask
v-model="form.phone"
class="w-full"
mask="(99) 99999-9999"
:autoClear="false"
placeholder="(11) 99999-9999"
@update:modelValue="markDirty"
/>
<small class="text-[var(--text-color-secondary)]">Opcional.</small>
</div>
<div class="col-span-12">
<Divider />
<div class="grid grid-cols-12 gap-4 items-start">
<!-- Upload -->
<div class="col-span-12 md:col-span-7">
<label class="block text-sm font-semibold mb-2">Enviar avatar (arquivo)</label>
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-ground)] p-3">
<div class="flex flex-col sm:flex-row sm:items-center gap-3">
<input
ref="fileInput"
type="file"
accept="image/*"
class="block w-full text-sm"
@change="onAvatarFileSelected"
/>
<div class="flex items-center gap-2">
<Button
label="Limpar"
icon="pi pi-times"
severity="secondary"
outlined
size="small"
:disabled="!ui.avatarFile"
@click="clearAvatarFile"
/>
<Button
label="Usar preview"
icon="pi pi-image"
severity="secondary"
outlined
size="small"
:disabled="!ui.avatarFile"
@click="applyFilePreviewOnly"
/>
</div>
</div>
<div class="mt-2 text-xs text-[var(--text-color-secondary)]">
Ao salvar, tentamos subir no Storage (<b>{{ AVATAR_BUCKET }}</b>) e atualizamos o Avatar URL automaticamente.
</div>
</div>
</div>
<!-- Avatar URL -->
<div class="col-span-12 md:col-span-5">
<label class="block text-sm font-semibold mb-2">Avatar (URL)</label>
<InputText v-model="form.avatar_url" class="w-full" placeholder="https://…" @input="onAvatarUrlChange" />
<small class="text-[var(--text-color-secondary)]">
Cole uma URL de imagem (PNG/JPG). Se vazio, usamos iniciais.
</small>
</div>
<!-- Preview -->
<div class="col-span-12">
<div class="mt-1 flex items-center gap-3 rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-ground)] p-3">
<img
v-if="ui.avatarPreview"
:src="ui.avatarPreview"
class="h-12 w-12 rounded-2xl object-cover border border-[var(--surface-border)]"
alt="preview"
/>
<div
v-else
class="h-12 w-12 rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] grid place-items-center font-semibold"
>
{{ initials }}
</div>
<div class="min-w-0">
<div class="font-medium truncate">{{ form.full_name || '—' }}</div>
<div class="text-xs text-[var(--text-color-secondary)] truncate">
Preview atual (será aplicado ao salvar)
</div>
</div>
<div class="ml-auto flex items-center gap-2">
<Button
label="Remover avatar"
icon="pi pi-trash"
severity="danger"
outlined
size="small"
:disabled="!form.avatar_url && !ui.avatarFile && !ui.avatarPreview"
@click="removeAvatar"
/>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
</Card>
<!-- Aparência / Layout -->
<Card id="layout" class="scroll-mt-24">
<template #title>Aparência</template>
<template #subtitle>Tema, cores e modo do menu.</template>
<template #content>
<div class="grid grid-cols-12 gap-4">
<!-- Row 1 -->
<div class="col-span-12 md:col-span-6">
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-ground)] p-4">
<div class="flex items-center justify-between gap-3">
<div>
<div class="text-sm font-semibold">Primary</div>
<div class="text-xs text-[var(--text-color-secondary)]">Cor principal do tema.</div>
</div>
<i class="pi pi-palette opacity-70" />
</div>
<div class="pt-3 flex gap-2 flex-wrap">
<button
v-for="primaryColor of primaryColors"
:key="primaryColor.name"
type="button"
:title="primaryColor.name"
@click="updateColors('primary', primaryColor)"
:class="[
'border-none w-6 h-6 rounded-full p-0 cursor-pointer outline-none outline-offset-1',
{ 'outline-primary': layoutConfig.primary === primaryColor.name }
]"
:style="{ backgroundColor: `${primaryColor.name === 'noir' ? 'var(--text-color)' : primaryColor.palette['500']}` }"
></button>
</div>
</div>
</div>
<div class="col-span-12 md:col-span-6">
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-ground)] p-4">
<div class="flex items-center justify-between gap-3">
<div>
<div class="text-sm font-semibold">Surface</div>
<div class="text-xs text-[var(--text-color-secondary)]">Base de fundo/superfícies.</div>
</div>
<i class="pi pi-circle-fill opacity-70" />
</div>
<div class="pt-3 flex gap-2 flex-wrap">
<button
v-for="surface of surfaces"
:key="surface.name"
type="button"
:title="surface.name"
@click="updateColors('surface', surface)"
:class="[
'border-none w-6 h-6 rounded-full p-0 cursor-pointer outline-none outline-offset-1',
{
'outline-primary': layoutConfig.surface
? layoutConfig.surface === surface.name
: (isDarkNow() ? surface.name === 'zinc' : surface.name === 'slate')
}
]"
:style="{ backgroundColor: `${surface.palette['500']}` }"
></button>
</div>
</div>
</div>
<!-- Row 2 -->
<div class="col-span-12 md:col-span-4">
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-ground)] p-4 h-full">
<div class="flex items-center justify-between gap-3">
<div>
<div class="text-sm font-semibold">Presets</div>
<div class="text-xs text-[var(--text-color-secondary)]">Aura / Lara / Nora.</div>
</div>
<i class="pi pi-sparkles opacity-70" />
</div>
<div class="pt-3">
<SelectButton v-model="presetModel" :options="presetOptions" :allowEmpty="false" />
</div>
</div>
</div>
<div class="col-span-12 md:col-span-4">
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-ground)] p-4 h-full">
<div class="flex items-center justify-between gap-3">
<div>
<div class="text-sm font-semibold">Menu Mode</div>
<div class="text-xs text-[var(--text-color-secondary)]">Static ou Overlay.</div>
</div>
<i class="pi pi-bars opacity-70" />
</div>
<div class="pt-3">
<SelectButton
v-model="menuModeModel"
:options="menuModeOptions"
:allowEmpty="false"
optionLabel="label"
optionValue="value"
/>
</div>
</div>
</div>
<div class="col-span-12 md:col-span-4">
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-ground)] p-4 h-full">
<div class="flex items-center justify-between gap-3">
<div>
<div class="text-sm font-semibold">Tema</div>
<div class="text-xs text-[var(--text-color-secondary)]">Alternar claro/escuro.</div>
</div>
<i class="pi pi-moon opacity-70" />
</div>
<div class="pt-3">
<SelectButton
v-model="themeModeModel"
:options="themeModeOptions"
:allowEmpty="false"
optionLabel="label"
optionValue="value"
/>
</div>
</div>
</div>
<div class="col-span-12 text-xs text-[var(--text-color-secondary)]">
Essas opções aplicam o tema imediatamente (igual ao configurator).
</div>
</div>
</template>
</Card>
</div>
</div>
<!-- Dialog: Trocar senha -->
<Dialog
v-model:visible="openPassword"
modal
header="Trocar senha"
:draggable="false"
:style="{ width: '28rem', maxWidth: '92vw' }"
>
<div class="space-y-3">
<div class="text-sm text-[var(--text-color-secondary)]">
Vamos enviar um link de redefinição para seu e-mail.
</div>
<div class="space-y-2">
<label class="text-sm font-semibold">E-mail</label>
<InputText :modelValue="userEmail" class="w-full" disabled />
</div>
<div
v-if="passwordSent"
class="rounded-xl border border-[var(--surface-border)] bg-[var(--surface-ground)] p-3 text-xs text-[var(--text-color-secondary)]"
>
<i class="pi pi-check mr-2 text-emerald-500"></i>
Se o e-mail existir, você receberá o link em instantes. Verifique também spam.
</div>
</div>
<template #footer>
<Button label="Fechar" icon="pi pi-times" text @click="openPassword = false" />
<Button label="Enviar link" icon="pi pi-envelope" :loading="sendingPassword" @click="sendPasswordReset" />
</template>
</Dialog>
<ConfirmDialog />
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted, onBeforeUnmount, nextTick } from 'vue'
import { useRouter } from 'vue-router'
import { useToast } from 'primevue/usetoast'
import { useConfirm } from 'primevue/useconfirm'
import Toast from 'primevue/toast'
import Card from 'primevue/card'
import Button from 'primevue/button'
import Divider from 'primevue/divider'
import Dialog from 'primevue/dialog'
import ConfirmDialog from 'primevue/confirmdialog'
import InputText from 'primevue/inputtext'
import Textarea from 'primevue/textarea'
import InputMask from 'primevue/inputmask'
import SelectButton from 'primevue/selectbutton'
import { supabase } from '@/lib/supabase/client'
import { useLayout } from '@/layout/composables/layout'
import { $t, updatePreset, updateSurfacePalette } from '@primeuix/themes'
import {
presetsMap,
presetOptions,
primaryColors,
surfaces,
getPresetExt,
getSurfacePalette
} from '@/theme/theme.options'
const router = useRouter()
const toast = useToast()
const confirm = useConfirm()
/** trava para não marcar dirty durante o load */
const silentApplying = ref(true)
/** Storage bucket do avatar */
const AVATAR_BUCKET = 'avatars'
/* ----------------------------
Estado geral
----------------------------- */
const saving = ref(false)
const dirty = ref(false)
const openPassword = ref(false)
const sendingPassword = ref(false)
const passwordSent = ref(false)
const userEmail = ref('')
const userId = ref('')
const fileInput = ref(null)
const ui = reactive({
avatarPreview: '',
avatarFile: null,
avatarFilePreviewUrl: ''
})
// Perfil (MVP)
const form = reactive({
full_name: '',
avatar_url: '',
bio: '',
phone: '',
language: 'pt-BR',
timezone: 'America/Sao_Paulo',
notify_system_email: true,
notify_reminders: true,
notify_news: false
})
const sections = [
{ id: 'conta', label: 'Conta', icon: 'pi pi-user' },
{ id: 'layout', label: 'Aparência', icon: 'pi pi-palette' },
{ id: 'preferencias', label: 'Preferências', icon: 'pi pi-sliders-h' },
{ id: 'seguranca', label: 'Segurança', icon: 'pi pi-shield' }
]
const activeSection = ref('conta')
const initials = computed(() => {
const name = form.full_name || userEmail.value || ''
const parts = String(name).trim().split(/\s+/).filter(Boolean)
const a = parts[0]?.[0] || 'U'
const b = parts.length > 1 ? parts[parts.length - 1][0] : ''
return (a + b).toUpperCase()
})
function markDirty () {
dirty.value = true
}
/* ----------------------------
Navegação (sidebar)
----------------------------- */
function scrollTo (id) {
activeSection.value = id
const el = document.getElementById(id)
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
function observeSections () {
const ids = sections.map(s => s.id)
const els = ids.map(id => document.getElementById(id)).filter(Boolean)
const io = new IntersectionObserver(
entries => {
const visible = entries
.filter(e => e.isIntersecting)
.sort((a, b) => (b.intersectionRatio || 0) - (a.intersectionRatio || 0))[0]
if (visible?.target?.id) activeSection.value = visible.target.id
},
{ root: null, threshold: [0.2, 0.35, 0.5], rootMargin: '-15% 0px -70% 0px' }
)
els.forEach(el => io.observe(el))
return () => io.disconnect()
}
let disconnectObserver = null
/* ----------------------------
Avatar: URL
----------------------------- */
function onAvatarUrlChange () {
ui.avatarPreview = String(form.avatar_url || '').trim()
markDirty()
}
function removeAvatar () {
form.avatar_url = ''
ui.avatarPreview = ''
clearAvatarFile()
markDirty()
}
/* ----------------------------
Avatar: upload arquivo
----------------------------- */
function clearAvatarFile () {
ui.avatarFile = null
if (ui.avatarFilePreviewUrl) {
try { URL.revokeObjectURL(ui.avatarFilePreviewUrl) } catch {}
}
ui.avatarFilePreviewUrl = ''
if (fileInput.value) fileInput.value.value = ''
}
function onAvatarFileSelected (ev) {
const file = ev?.target?.files?.[0]
if (!file) return
if (!file.type?.startsWith('image/')) {
toast.add({ severity: 'warn', summary: 'Arquivo inválido', detail: 'Escolha uma imagem (PNG/JPG/WebP).', life: 3500 })
clearAvatarFile()
return
}
if (file.size > 5 * 1024 * 1024) {
toast.add({ severity: 'warn', summary: 'Arquivo grande', detail: 'Máximo recomendado: 5MB.', life: 3500 })
clearAvatarFile()
return
}
ui.avatarFile = file
if (ui.avatarFilePreviewUrl) {
try { URL.revokeObjectURL(ui.avatarFilePreviewUrl) } catch {}
}
ui.avatarFilePreviewUrl = URL.createObjectURL(file)
ui.avatarPreview = ui.avatarFilePreviewUrl
markDirty()
}
function applyFilePreviewOnly () {
if (!ui.avatarFilePreviewUrl) return
ui.avatarPreview = ui.avatarFilePreviewUrl
markDirty()
}
function extFromMime (mime) {
if (!mime) return 'png'
if (mime.includes('jpeg')) return 'jpg'
if (mime.includes('png')) return 'png'
if (mime.includes('webp')) return 'webp'
return 'png'
}
async function uploadAvatarIfNeeded () {
if (!ui.avatarFile) return null
if (!userId.value) throw new Error('Sessão inválida para upload.')
const file = ui.avatarFile
const ext = extFromMime(file.type)
const path = `${userId.value}/avatar-${Date.now()}.${ext}`
const { error: upErr } = await supabase.storage
.from(AVATAR_BUCKET)
.upload(path, file, { upsert: true, contentType: file.type })
if (upErr) throw upErr
const { data } = supabase.storage.from(AVATAR_BUCKET).getPublicUrl(path)
const url = data?.publicUrl
if (!url) throw new Error('Upload ok, mas não consegui obter a URL pública.')
return url
}
/* ----------------------------
Aparência (SEM duplicar engine)
----------------------------- */
const { layoutConfig, toggleDarkMode, changeMenuMode } = useLayout()
function isDarkNow () {
return document.documentElement.classList.contains('app-dark')
}
function setDarkMode (shouldBeDark) {
if (shouldBeDark !== isDarkNow()) toggleDarkMode()
}
/** ✅ motor único (igual topbar) */
function applyThemeEngine () {
const presetValue = presetsMap?.[layoutConfig.preset] || presetsMap?.Aura
const surfacePalette = getSurfacePalette(layoutConfig.surface) || surfaces.find(s => s.name === layoutConfig.surface)?.palette
$t()
.preset(presetValue)
.preset(getPresetExt(layoutConfig))
.surfacePalette(surfacePalette)
.use({ useDefaultOptions: true })
updatePreset(getPresetExt(layoutConfig))
if (surfacePalette) updateSurfacePalette(surfacePalette)
}
/** v-models diretos no layoutConfig */
const presetModel = computed({
get: () => layoutConfig.preset,
set: (val) => {
if (!val || val === layoutConfig.preset) return
layoutConfig.preset = val
applyThemeEngine()
if (!silentApplying.value) markDirty()
}
})
const menuModeOptions = [
{ label: 'Static', value: 'static' },
{ label: 'Overlay', value: 'overlay' }
]
const menuModeModel = computed({
get: () => layoutConfig.menuMode,
set: (val) => {
if (!val || val === layoutConfig.menuMode) return
layoutConfig.menuMode = val
try { changeMenuMode?.() } catch {}
if (!silentApplying.value) markDirty()
}
})
const themeModeOptions = [
{ label: 'Claro', value: 'light' },
{ label: 'Escuro', value: 'dark' }
]
const themeModeModel = computed({
get: () => (isDarkNow() ? 'dark' : 'light'),
set: async (val) => {
if (!val) return
setDarkMode(val === 'dark')
await nextTick()
if (!silentApplying.value) markDirty()
}
})
function updateColors (type, item) {
if (type === 'primary') {
layoutConfig.primary = item.name
applyThemeEngine()
if (!silentApplying.value) markDirty()
return
}
if (type === 'surface') {
layoutConfig.surface = item.name
applyThemeEngine()
if (!silentApplying.value) markDirty()
}
}
/* ----------------------------
DB: carregar/aplicar settings
----------------------------- */
function safeEq (a, b) {
return String(a || '').trim() === String(b || '').trim()
}
async function loadUserSettings (uid) {
const { data: settings, error } = await supabase
.from('user_settings')
.select('theme_mode, preset, primary_color, surface_color, menu_mode')
.eq('user_id', uid)
.maybeSingle()
if (error) {
const msg = String(error.message || '')
const tolerant =
/does not exist/i.test(msg) ||
/relation .* does not exist/i.test(msg) ||
/permission denied/i.test(msg) ||
/violates row-level security/i.test(msg)
if (!tolerant) throw error
return null
}
if (!settings) return null
// 1) dark/light
if (settings.theme_mode) setDarkMode(settings.theme_mode === 'dark')
// 2) layoutConfig
if (settings.preset && !safeEq(settings.preset, layoutConfig.preset)) layoutConfig.preset = settings.preset
if (settings.primary_color && !safeEq(settings.primary_color, layoutConfig.primary)) layoutConfig.primary = settings.primary_color
if (settings.surface_color && !safeEq(settings.surface_color, layoutConfig.surface)) layoutConfig.surface = settings.surface_color
if (settings.menu_mode && !safeEq(settings.menu_mode, layoutConfig.menuMode)) {
layoutConfig.menuMode = settings.menu_mode
try { changeMenuMode?.() } catch {}
}
// 3) aplica UMA vez
applyThemeEngine()
return settings
}
/* ----------------------------
Load / Save (perfil)
----------------------------- */
async function loadProfile () {
silentApplying.value = true
const { data: u, error: uErr } = await supabase.auth.getUser()
if (uErr) throw uErr
const user = u?.user
if (!user) throw new Error('Você precisa estar logado.')
userId.value = user.id
userEmail.value = user.email || ''
const meta = user.user_metadata || {}
form.full_name = meta.full_name || ''
form.avatar_url = meta.avatar_url || ''
ui.avatarPreview = form.avatar_url
const { data: prof, error: pErr } = await supabase
.from('profiles')
.select('full_name, avatar_url, phone, bio, language, timezone, notify_system_email, notify_reminders, notify_news')
.eq('id', user.id)
.maybeSingle()
if (!pErr && prof) {
form.full_name = prof.full_name ?? form.full_name
form.avatar_url = prof.avatar_url ?? form.avatar_url
form.phone = prof.phone ?? ''
form.bio = prof.bio ?? ''
form.language = prof.language ?? form.language
form.timezone = prof.timezone ?? form.timezone
if (typeof prof.notify_system_email === 'boolean') form.notify_system_email = prof.notify_system_email
if (typeof prof.notify_reminders === 'boolean') form.notify_reminders = prof.notify_reminders
if (typeof prof.notify_news === 'boolean') form.notify_news = prof.notify_news
ui.avatarPreview = form.avatar_url
}
await loadUserSettings(user.id)
silentApplying.value = false
dirty.value = false
}
async function saveAll () {
saving.value = true
try {
if (ui.avatarFile) {
try {
const uploadedUrl = await uploadAvatarIfNeeded()
if (uploadedUrl) {
form.avatar_url = uploadedUrl
ui.avatarPreview = uploadedUrl
}
} catch (e) {
toast.add({
severity: 'warn',
summary: 'Avatar não subiu',
detail: `Não consegui enviar o arquivo (bucket "${AVATAR_BUCKET}"). Você pode usar Avatar URL. (${e?.message || 'erro'})`,
life: 6500
})
}
}
const metaPayload = {
full_name: String(form.full_name || '').trim(),
avatar_url: String(form.avatar_url || '').trim() || null
}
const { error: upErr } = await supabase.auth.updateUser({ data: metaPayload })
if (upErr) throw upErr
const profilePayload = {
id: userId.value,
full_name: metaPayload.full_name,
avatar_url: metaPayload.avatar_url,
phone: String(form.phone || '').trim() || null,
bio: String(form.bio || '').trim() || null,
language: form.language || 'pt-BR',
timezone: form.timezone || 'America/Sao_Paulo',
notify_system_email: !!form.notify_system_email,
notify_reminders: !!form.notify_reminders,
notify_news: !!form.notify_news,
updated_at: new Date().toISOString()
}
const { error: pErr2 } = await supabase
.from('profiles')
.upsert(profilePayload, { onConflict: 'id' })
if (pErr2) {
const msg = String(pErr2.message || '')
const tolerant =
/does not exist/i.test(msg) ||
/column .* does not exist/i.test(msg) ||
/relation .* does not exist/i.test(msg) ||
/permission denied/i.test(msg) ||
/violates row-level security/i.test(msg)
if (!tolerant) throw pErr2
}
const settingsPayload = {
user_id: userId.value,
theme_mode: isDarkNow() ? 'dark' : 'light',
preset: layoutConfig.preset || 'Aura',
primary_color: layoutConfig.primary || 'noir',
surface_color: layoutConfig.surface || 'slate',
menu_mode: layoutConfig.menuMode || 'static',
updated_at: new Date().toISOString()
}
const { error: sErr } = await supabase
.from('user_settings')
.upsert(settingsPayload, { onConflict: 'user_id' })
if (sErr) {
const msg = String(sErr.message || '')
const tolerant =
/does not exist/i.test(msg) ||
/relation .* does not exist/i.test(msg) ||
/permission denied/i.test(msg) ||
/violates row-level security/i.test(msg)
if (!tolerant) throw sErr
}
clearAvatarFile()
dirty.value = false
toast.add({ severity: 'success', summary: 'Salvo', detail: 'Seu perfil foi atualizado.', life: 2500 })
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro ao salvar', detail: e?.message || 'Não consegui salvar.', life: 6000 })
} finally {
saving.value = false
}
}
/* ----------------------------
Segurança: reset + signout
----------------------------- */
function openPasswordDialog () {
passwordSent.value = false
openPassword.value = true
}
async function sendPasswordReset () {
if (!userEmail.value) return
sendingPassword.value = true
passwordSent.value = false
try {
const redirectTo = `${window.location.origin}/auth/reset-password`
const { error } = await supabase.auth.resetPasswordForEmail(userEmail.value, { redirectTo })
if (error) throw error
passwordSent.value = true
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao enviar e-mail.', life: 5000 })
} finally {
sendingPassword.value = false
}
}
function confirmSignOut () {
confirm.require({
header: 'Sair',
message: 'Deseja sair da sua conta neste dispositivo?',
icon: 'pi pi-exclamation-triangle',
acceptLabel: 'Sair',
rejectLabel: 'Cancelar',
acceptClass: 'p-button-danger',
accept: async () => {
try {
await supabase.auth.signOut()
} finally {
router.push('/auth/login')
}
}
})
}
/* ----------------------------
Lifecycle
----------------------------- */
onMounted(async () => {
try {
await loadProfile()
disconnectObserver = observeSections()
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Não consegui carregar o perfil.', life: 6000 })
}
})
onBeforeUnmount(() => {
try { disconnectObserver?.() } catch {}
clearAvatarFile()
})
</script>

View File

@@ -0,0 +1,23 @@
<script setup>
import BestSellingWidget from '@/components/dashboard/BestSellingWidget.vue';
import NotificationsWidget from '@/components/dashboard/NotificationsWidget.vue';
import RecentSalesWidget from '@/components/dashboard/RecentSalesWidget.vue';
import RevenueStreamWidget from '@/components/dashboard/RevenueStreamWidget.vue';
import StatsWidget from '@/components/dashboard/StatsWidget.vue';
</script>
<template>
PATIENT DASHBOARD
<div class="grid grid-cols-12 gap-8">
<StatsWidget />
<div class="col-span-12 xl:col-span-6">
<RecentSalesWidget />
<BestSellingWidget />
</div>
<div class="col-span-12 xl:col-span-6">
<RevenueStreamWidget />
<NotificationsWidget />
</div>
</div>
</template>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,523 @@
<template>
<div class="min-h-screen bg-[var(--surface-ground)] text-[var(--text-color)]">
<!-- TOPBAR -->
<div
class="sticky top-0 z-40 border-b border-[var(--surface-border)] bg-[color-mix(in_srgb,var(--surface-card),transparent_12%)] backdrop-blur"
>
<div class="mx-auto max-w-7xl px-4 md:px-6 py-3 flex items-center justify-between gap-3">
<div class="flex items-center gap-3 min-w-0">
<div
class="h-10 w-10 rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] grid place-items-center shadow-sm"
>
<i class="pi pi-sparkles text-lg opacity-80" />
</div>
<div class="min-w-0">
<div class="font-semibold leading-tight truncate">{{ brandName }}</div>
<div class="text-xs text-[var(--text-color-secondary)] truncate">Gestão clínica sem ruído.</div>
</div>
</div>
<div class="flex items-center gap-2">
<Button label="Entrar" icon="pi pi-sign-in" severity="secondary" outlined @click="go('/auth/login')" />
<Button label="Começar" icon="pi pi-bolt" @click="goStart()" />
</div>
</div>
</div>
<!-- HERO -->
<section class="relative overflow-hidden">
<!-- blobs / noir glow -->
<div class="pointer-events-none absolute inset-0">
<div class="absolute -top-28 -left-28 h-96 w-96 rounded-full blur-3xl opacity-60 bg-indigo-400/10" />
<div class="absolute top-24 -right-24 h-[28rem] w-[28rem] rounded-full blur-3xl opacity-60 bg-emerald-400/10" />
<div class="absolute -bottom-40 left-1/3 h-[34rem] w-[34rem] rounded-full blur-3xl opacity-60 bg-fuchsia-400/10" />
</div>
<div class="mx-auto max-w-7xl px-4 md:px-6 pt-10 md:pt-16 pb-8 md:pb-14 relative">
<div class="grid grid-cols-12 gap-6 items-center">
<div class="col-span-12 lg:col-span-7">
<Chip class="mb-4" label="Para psicólogos e clínicas" icon="pi pi-shield" />
<h1 class="text-3xl md:text-5xl font-semibold leading-tight">
Uma agenda inteligente, um prontuário organizado, um financeiro respirável.
</h1>
<p class="mt-4 text-base md:text-lg text-[var(--text-color-secondary)] max-w-2xl">
Centralize a rotina clínica em um lugar : pacientes, sessões, lembretes e indicadores. Menos dispersão.
Mais presença.
</p>
<div class="mt-6 flex flex-col sm:flex-row gap-2">
<Button
label="Criar conta grátis"
icon="pi pi-arrow-right"
class="w-full sm:w-auto"
@click="goStart()"
/>
<Button
label="Ver planos"
icon="pi pi-credit-card"
severity="secondary"
outlined
class="w-full sm:w-auto"
@click="scrollTo('pricing')"
/>
</div>
<div class="mt-6 flex flex-wrap gap-2">
<Tag severity="secondary" value="Agenda online (PRO)" />
<Tag severity="secondary" value="Controle de sessões" />
<Tag severity="secondary" value="Financeiro integrado" />
<Tag severity="secondary" value="Clínica / multi-profissional" />
</div>
</div>
<div class="col-span-12 lg:col-span-5">
<Card class="overflow-hidden">
<template #content>
<div class="p-1">
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-ground)] p-4">
<div class="flex items-start justify-between gap-3">
<div>
<div class="font-semibold text-lg">Painel de hoje</div>
<div class="text-sm text-[var(--text-color-secondary)]">Um recorte: o essencial, sem excesso.</div>
</div>
<i class="pi pi-chart-line opacity-70" />
</div>
<Divider class="my-4" />
<div class="grid grid-cols-12 gap-3">
<div class="col-span-12 md:col-span-6">
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-3">
<div class="text-xs text-[var(--text-color-secondary)]">Sessões</div>
<div class="text-2xl font-semibold mt-1">6</div>
<div class="text-xs text-[var(--text-color-secondary)] mt-1">com lembretes automáticos</div>
</div>
</div>
<div class="col-span-12 md:col-span-6">
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-3">
<div class="text-xs text-[var(--text-color-secondary)]">Recebimentos</div>
<div class="text-2xl font-semibold mt-1">R$ 840</div>
<div class="text-xs text-[var(--text-color-secondary)] mt-1">visão clara do mês</div>
</div>
</div>
<div class="col-span-12">
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-3">
<div class="flex items-center justify-between">
<div>
<div class="text-xs text-[var(--text-color-secondary)]">Prontuário</div>
<div class="font-semibold mt-1">Anotações e histórico</div>
<div class="text-xs text-[var(--text-color-secondary)] mt-1">
organizado por paciente, sessão e linha do tempo
</div>
</div>
<i class="pi pi-file-edit opacity-70" />
</div>
</div>
</div>
</div>
<div class="mt-4 text-xs text-[var(--text-color-secondary)]">
* Ilustração conceitual do produto.
</div>
</div>
</div>
</template>
</Card>
</div>
</div>
</div>
</section>
<!-- TRUST / VALUE STRIP -->
<section class="mx-auto max-w-7xl px-4 md:px-6 pb-10">
<div class="grid grid-cols-12 gap-4">
<div class="col-span-12 md:col-span-4">
<Card class="h-full">
<template #content>
<div class="flex items-start gap-3">
<div
class="h-10 w-10 rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-ground)] grid place-items-center"
>
<i class="pi pi-calendar opacity-80" />
</div>
<div>
<div class="font-semibold">Agenda e autoagendamento</div>
<div class="text-sm text-[var(--text-color-secondary)] mt-1">
O paciente confirma, agenda e reagenda com autonomia (PRO).
</div>
</div>
</div>
</template>
</Card>
</div>
<div class="col-span-12 md:col-span-4">
<Card class="h-full">
<template #content>
<div class="flex items-start gap-3">
<div
class="h-10 w-10 rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-ground)] grid place-items-center"
>
<i class="pi pi-wallet opacity-80" />
</div>
<div>
<div class="font-semibold">Financeiro integrado</div>
<div class="text-sm text-[var(--text-color-secondary)] mt-1">
Receita/despesa junto da agenda sem planilhas espalhadas.
</div>
</div>
</div>
</template>
</Card>
</div>
<div class="col-span-12 md:col-span-4">
<Card class="h-full">
<template #content>
<div class="flex items-start gap-3">
<div
class="h-10 w-10 rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-ground)] grid place-items-center"
>
<i class="pi pi-lock opacity-80" />
</div>
<div>
<div class="font-semibold">Prontuário e controle de sessões</div>
<div class="text-sm text-[var(--text-color-secondary)] mt-1">
Registro clínico e histórico acessíveis, com backups e organização.
</div>
</div>
</div>
</template>
</Card>
</div>
</div>
<div class="mt-3 text-xs text-[var(--text-color-secondary)]">
Inspirações de módulos comuns no mercado: agenda online, financeiro, prontuário/controle de sessões e gestão de
clínica.
</div>
</section>
<!-- FEATURES -->
<section class="mx-auto max-w-7xl px-4 md:px-6 pb-12">
<div class="flex items-end justify-between gap-3 mb-4">
<div>
<div class="text-2xl md:text-3xl font-semibold">Recursos que sustentam a rotina</div>
<div class="text-sm text-[var(--text-color-secondary)] mt-1">
O foco é tirar o excesso de fricção sem invadir o que é do seu método.
</div>
</div>
<Button label="Ver planos" severity="secondary" outlined icon="pi pi-arrow-down" @click="scrollTo('pricing')" />
</div>
<div class="grid grid-cols-12 gap-4">
<div v-for="f in features" :key="f.title" class="col-span-12 md:col-span-6 lg:col-span-4">
<Card class="h-full">
<template #content>
<div class="flex items-start gap-3">
<div
class="h-10 w-10 rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-ground)] grid place-items-center"
>
<i :class="f.icon" class="opacity-80" />
</div>
<div class="min-w-0">
<div class="font-semibold">{{ f.title }}</div>
<div class="text-sm text-[var(--text-color-secondary)] mt-1">
{{ f.desc }}
</div>
<div v-if="f.pro" class="mt-2">
<Tag severity="warning" value="PRO" />
</div>
</div>
</div>
</template>
</Card>
</div>
</div>
<Divider class="my-8" />
<Accordion :activeIndex="0">
<AccordionTab header="Como fica o fluxo na prática?">
<div class="text-sm text-[var(--text-color-secondary)] leading-relaxed">
Você abre a agenda, a sessão acontece, o registro fica no prontuário, e o financeiro acompanha o movimento.
O sistema existe para manter o consultório respirando não para virar uma burocracia nova.
</div>
</AccordionTab>
<AccordionTab header="E para clínica (multi-profissionais)?">
<div class="text-sm text-[var(--text-color-secondary)] leading-relaxed">
Perfis por função, agendas separadas, repasses e visão gerencial quando você estiver pronto para crescer.
</div>
</AccordionTab>
<AccordionTab header="Privacidade e segurança">
<div class="text-sm text-[var(--text-color-secondary)] leading-relaxed">
Controle de acesso por conta, separação por clínica/tenant, e políticas de storage por usuário. (Os detalhes
de conformidade você pode expor numa página própria de segurança/LGPD.)
</div>
</AccordionTab>
</Accordion>
</section>
<!-- PRICING (dinâmico do SaaS) -->
<section id="pricing" class="mx-auto max-w-7xl px-4 md:px-6 pb-14 scroll-mt-24">
<div class="text-5xl md:text-4xl font-semibold text-center">Planos</div>
<div class="text-2xl md:text-2xl text-[var(--text-color-secondary)] mt-1 text-center">
Comece simples. Suba para PRO quando a agenda pedir automação.
</div>
<!-- header conceitual + toggle -->
<div class="flex flex-col items-center text-center mt-6">
<div class="flex items-center gap-3 mb-4">
<AvatarGroup>
<Avatar
image="https://fqjltiegiezfetthbags.supabase.co/storage/v1/render/image/public/block.images/blocks/avatars/circle/avatar-m-1.png"
shape="circle"
/>
<Avatar
image="https://fqjltiegiezfetthbags.supabase.co/storage/v1/render/image/public/block.images/blocks/avatars/circle/avatar-f-21.png"
shape="circle"
/>
<Avatar
image="https://fqjltiegiezfetthbags.supabase.co/storage/v1/render/image/public/block.images/blocks/avatars/circle/avatar-f-1.png"
shape="circle"
/>
<Avatar
image="https://fqjltiegiezfetthbags.supabase.co/storage/v1/render/image/public/block.images/blocks/avatars/circle/avatar-m-3.png"
shape="circle"
/>
</AvatarGroup>
<Divider layout="vertical" />
<span class="text-sm text-[var(--text-color-secondary)] font-medium">Happy Customers</span>
</div>
<div class="inline-flex items-center rounded-xl border border-[var(--surface-border)] bg-[var(--surface-50)] p-1">
<Button
label="Mensal"
size="small"
:severity="billingInterval === 'month' ? 'success' : 'secondary'"
:outlined="billingInterval !== 'month'"
@click="billingInterval = 'month'"
/>
<Button
label="Anual"
size="small"
:severity="billingInterval === 'year' ? 'success' : 'secondary'"
:outlined="billingInterval !== 'year'"
class="ml-1"
@click="billingInterval = 'year'"
/>
</div>
<div v-if="billingInterval === 'year'" class="mt-2">
<Tag severity="success" value="Economize até 20%" />
</div>
</div>
<div v-if="loadingPricing" class="mt-8 text-sm text-[var(--text-color-secondary)]">
Carregando planos...
</div>
<div v-else class="mt-8 grid grid-cols-12 gap-4">
<div v-for="p in pricing" :key="p.plan_id" class="col-span-12 md:col-span-4">
<Card
class="h-full overflow-hidden transition-transform"
:class="p.is_featured ? 'ring-1 ring-emerald-500/30 md:-translate-y-2 md:scale-[1.02]' : ''"
>
<template #content>
<div class="flex items-start justify-between gap-3">
<div>
<div class="text-sm text-[var(--text-color-secondary)]">
{{ p.badge || 'Plano' }}
</div>
<div class="text-xl font-semibold">
{{ p.public_name || p.plan_name || p.plan_key }}
</div>
</div>
<Tag v-if="p.is_featured" severity="success" value="Popular" />
</div>
<div class="mt-4 text-3xl font-semibold leading-none">
{{ formatBRLFromCents(priceFor(p)) }}
<span class="text-sm font-normal text-[var(--text-color-secondary)]">
/{{ billingInterval === 'month' ? 'mês' : 'ano' }}
</span>
</div>
<div
v-if="billingInterval === 'year'"
class="text-xs text-emerald-500 mt-1 font-medium"
>
Melhor custo-benefício
</div>
<div class="mt-2 text-sm text-[var(--text-color-secondary)] min-h-[44px]">
{{ p.public_description }}
</div>
<Divider class="my-4" />
<ul v-if="p.bullets?.length" class="space-y-2 text-sm">
<li v-for="b in p.bullets" :key="b.id" class="flex items-start gap-2">
<i class="pi pi-check mt-1 text-emerald-500"></i>
<span :class="b.highlight ? 'font-semibold' : 'text-[var(--text-color-secondary)]'">
{{ b.text }}
</span>
</li>
</ul>
<div v-else class="text-sm text-[var(--text-color-secondary)]">Benefícios em breve.</div>
<div class="mt-5">
<Button
label="Começar"
class="w-full"
:severity="p.is_featured ? 'success' : 'secondary'"
:outlined="!p.is_featured"
icon="pi pi-arrow-right"
@click="go(`/auth/signup?plan=${p.plan_key}&interval=${billingInterval}`)"
/>
</div>
</template>
</Card>
</div>
</div>
<div class="mt-6 text-xs text-[var(--text-color-secondary)]">
Dica: estes planos vêm do painel SaaS (vitrine pública) e podem mapear diretamente para entitlements (FREE/PRO)
sem mexer no código.
</div>
</section>
<!-- FOOTER -->
<footer class="border-t border-[var(--surface-border)]">
<div
class="mx-auto max-w-7xl px-4 md:px-6 py-8 flex flex-col md:flex-row items-start md:items-center justify-between gap-4"
>
<div>
<div class="font-semibold">{{ brandName }}</div>
<div class="text-xs text-[var(--text-color-secondary)] mt-1">© {{ year }} Todos os direitos reservados.</div>
</div>
<div class="flex flex-wrap gap-2">
<Button label="Entrar" severity="secondary" outlined icon="pi pi-sign-in" @click="go('/auth/login')" />
<Button label="Criar conta" icon="pi pi-bolt" @click="goStart()" />
</div>
</div>
</footer>
</div>
</template>
<script setup>
import { computed, ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { supabase } from '@/lib/supabase/client'
import Button from 'primevue/button'
import Card from 'primevue/card'
import Divider from 'primevue/divider'
import Tag from 'primevue/tag'
import Chip from 'primevue/chip'
import Accordion from 'primevue/accordion'
import AccordionTab from 'primevue/accordiontab'
import Avatar from 'primevue/avatar'
import AvatarGroup from 'primevue/avatargroup'
const router = useRouter()
const brandName = 'Psi Quasar' // ajuste para o nome final do produto
const year = computed(() => new Date().getFullYear())
function go(path) {
router.push(path)
}
function scrollTo(id) {
const el = document.getElementById(id)
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
const featuredPlanKey = computed(() => {
const list = Array.isArray(pricing.value) ? pricing.value : []
const featured = list.find(p => p && p.is_featured && p.is_visible)
return featured?.plan_key || null
})
function goStart() {
if (featuredPlanKey.value) {
router.push(`/auth/signup?plan=${featuredPlanKey.value}&interval=${billingInterval.value}`)
return
}
router.push('/auth/signup')
}
const features = ref([
{
title: 'Agenda inteligente',
desc: 'Configure sua semana, encaixes, bloqueios e visão por dia/semana.',
icon: 'pi pi-calendar'
},
{
title: 'Autoagendamento (PRO)',
desc: 'Página para o paciente confirmar, agendar e reagendar sem fricção.',
icon: 'pi pi-globe',
pro: true
},
{
title: 'Prontuário e sessões',
desc: 'Registro por paciente, histórico por sessão e organização por linha do tempo.',
icon: 'pi pi-file-edit'
},
{
title: 'Financeiro integrado',
desc: 'Receitas, despesas e visão do mês conectadas ao que acontece na agenda.',
icon: 'pi pi-wallet'
},
{
title: 'Pacientes e tags',
desc: 'Segmentação por grupos, etiquetas e filtros práticos para achar rápido.',
icon: 'pi pi-users'
},
{
title: 'Clínica / multi-profissional',
desc: 'Múltiplos profissionais, agendas separadas, papéis e visão gerencial.',
icon: 'pi pi-building'
}
])
/** PRICING dinâmico do SaaS */
const billingInterval = ref('year') // 'month' | 'year'
const pricing = ref([])
const loadingPricing = ref(false)
function formatBRLFromCents(cents) {
if (cents == null) return '—'
const v = Number(cents) / 100
return v.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' })
}
function priceFor(p) {
return billingInterval.value === 'year' ? p.yearly_cents : p.monthly_cents
}
async function fetchPricing() {
loadingPricing.value = true
const { data, error } = await supabase
.from('v_public_pricing')
.select('*')
.eq('is_visible', true)
.order('sort_order', { ascending: true })
loadingPricing.value = false
if (!error) pricing.value = data || []
}
onMounted(fetchPricing)
</script>

View File

@@ -0,0 +1,166 @@
<template>
<div class="p-4">
<!-- HEADER -->
<div class="flex flex-column md:flex-row md:align-items-center md:justify-content-between gap-3">
<div>
<div class="text-900 text-2xl font-semibold">Cadastro Externo</div>
<div class="text-600 mt-1">
Gere um link para o paciente preencher o pré-cadastro.
</div>
</div>
<div class="flex gap-2">
<Button
label="Gerar novo link"
icon="pi pi-refresh"
severity="secondary"
outlined
:loading="rotating"
@click="rotateLink"
/>
</div>
</div>
<!-- CARD -->
<Card class="mt-4">
<template #title>Seu link</template>
<template #subtitle>Envie este link ao paciente.</template>
<template #content>
<div class="flex flex-column gap-3">
<div class="p-inputgroup">
<InputText
readonly
:value="publicUrl"
placeholder="Gerando seu link…"
/>
<Button
icon="pi pi-copy"
severity="secondary"
outlined
:disabled="!publicUrl"
@click="copyLink"
v-tooltip.bottom="'Copiar'"
/>
<Button
icon="pi pi-external-link"
severity="secondary"
outlined
:disabled="!publicUrl"
@click="openLink"
v-tooltip.bottom="'Abrir'"
/>
</div>
<Message v-if="!inviteToken" severity="info" :closable="false">
Gerando seu link...
</Message>
</div>
</template>
</Card>
</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' // ajuste se seu caminho for diferente
const toast = useToast()
const inviteToken = ref('')
const rotating = ref(false)
const origin = computed(() => window.location.origin)
const publicUrl = computed(() => {
if (!inviteToken.value) return ''
return `${origin.value}/cadastro/paciente?t=${inviteToken.value}`
})
function newToken () {
// browsers modernos
if (globalThis.crypto?.randomUUID) return globalThis.crypto.randomUUID()
// fallback simples
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')
}
onMounted(async () => {
try {
await loadOrCreateInvite()
} catch (err) {
toast.add({ severity: 'error', summary: 'Erro', detail: err?.message || 'Falha ao carregar link.', life: 3500 })
}
})
</script>

View File

@@ -0,0 +1,575 @@
<script setup>
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useToast } from 'primevue/usetoast'
import { supabase } from '@/lib/supabase/client'
import Card from 'primevue/card'
import Button from 'primevue/button'
import InputText from 'primevue/inputtext'
import FloatLabel from 'primevue/floatlabel'
import Password from 'primevue/password'
import Divider from 'primevue/divider'
import Tag from 'primevue/tag'
import Chip from 'primevue/chip'
import Message from 'primevue/message'
import ProgressSpinner from 'primevue/progressspinner'
const route = useRoute()
const router = useRouter()
const toast = useToast()
// ============================
// Form
// ============================
const email = ref('')
const password = ref('')
const loading = ref(false)
// ============================
// Query (plan / interval)
// ============================
const planFromQuery = computed(() => String(route.query.plan || '').trim().toLowerCase())
const intervalFromQuery = computed(() => String(route.query.interval || '').trim().toLowerCase())
function normalizeInterval(v) {
if (v === 'monthly') return 'month'
if (v === 'annual' || v === 'annually' || v === 'yearly') return 'year'
return v
}
const intervalNormalized = computed(() => normalizeInterval(intervalFromQuery.value))
function isValidInterval(v) {
return v === 'month' || v === 'year'
}
const hasPlanQuery = computed(() => !!planFromQuery.value && isValidInterval(intervalNormalized.value))
const intervalLabel = computed(() => {
if (intervalNormalized.value === 'year') return 'Anual'
if (intervalNormalized.value === 'month') return 'Mensal'
return ''
})
// ============================
// Fetch pricing from v_public_pricing
// ============================
const selectedPlanRow = ref(null)
const pricingLoading = ref(false)
const selectedPlanName = computed(() => selectedPlanRow.value?.public_name || selectedPlanRow.value?.plan_name || null)
const selectedBadge = computed(() => selectedPlanRow.value?.badge || null)
const selectedDescription = computed(() => selectedPlanRow.value?.public_description || null)
const bullets = computed(() => {
const b = selectedPlanRow.value?.bullets
return Array.isArray(b) ? b : []
})
const amountCents = computed(() => {
if (!selectedPlanRow.value) return null
return intervalNormalized.value === 'year'
? selectedPlanRow.value.yearly_cents
: selectedPlanRow.value.monthly_cents
})
const currency = computed(() => {
if (!selectedPlanRow.value) return 'BRL'
return intervalNormalized.value === 'year'
? (selectedPlanRow.value.yearly_currency || 'BRL')
: (selectedPlanRow.value.monthly_currency || 'BRL')
})
const formattedPrice = computed(() => {
if (amountCents.value == null) return null
return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: currency.value || 'BRL' })
.format(amountCents.value / 100)
})
const showPlanCard = computed(() => hasPlanQuery.value && !!selectedPlanRow.value)
async function loadSelectedPlanRow() {
selectedPlanRow.value = null
if (!planFromQuery.value) return
pricingLoading.value = true
try {
const { data, error } = await supabase
.from('v_public_pricing')
.select(
'plan_key, plan_name, public_name, public_description, badge, is_featured, is_visible, monthly_cents, yearly_cents, monthly_currency, yearly_currency, bullets'
)
.eq('plan_key', planFromQuery.value)
.eq('is_visible', true)
.maybeSingle()
if (error) throw error
if (!data) return
selectedPlanRow.value = data
} catch (err) {
console.error('[Signup] loadSelectedPlanRow:', err)
} finally {
pricingLoading.value = false
}
}
onMounted(loadSelectedPlanRow)
watch(
() => planFromQuery.value,
() => loadSelectedPlanRow()
)
// ============================
// Create subscription_intent after signup
// ============================
/*
async function createSubscriptionIntentAfterSignup(userId, tenantIdFromRpc) {
if (!hasPlanQuery.value) return
if (!selectedPlanRow.value) return
if (amountCents.value == null) return
let tenantId = tenantIdFromRpc || null
// fallback (se a RPC não retornou)
if (!tenantId) {
const { data, error } = await supabase
.from('tenant_members')
.select('tenant_id')
.eq('user_id', userId)
.eq('status', 'active')
.order('created_at', { ascending: false })
.limit(1)
.maybeSingle()
if (error) throw error
tenantId = data?.tenant_id || null
}
if (!tenantId) throw new Error('Não encontrei tenant ativo para este usuário.')
const payload = {
tenant_id: tenantId,
created_by_user_id: userId,
email: email.value || null,
plan_key: selectedPlanRow.value.plan_key,
interval: intervalNormalized.value,
amount_cents: amountCents.value,
currency: currency.value || 'BRL',
status: 'new',
source: 'landing'
}
const { error } = await supabase.from('subscription_intents').insert(payload)
if (error) throw error
}
*/
// ============================
// Create subscription_intent after signup (MODELO B: tenant)
// ============================
async function getActiveTenantIdForUser(userId) {
const { data, error } = await supabase
.from('tenant_members')
.select('tenant_id')
.eq('user_id', userId)
.eq('status', 'active')
.order('created_at', { ascending: false })
.limit(1)
.maybeSingle()
if (error) throw error
return data?.tenant_id || null
}
async function createSubscriptionIntentAfterSignup(userId) {
if (!hasPlanQuery.value) return
if (!selectedPlanRow.value) return
if (amountCents.value == null) return
const tenantId = await getActiveTenantIdForUser(userId)
if (!tenantId) throw new Error('Não encontrei tenant ativo para este usuário.')
const payload = {
tenant_id: tenantId,
created_by_user_id: userId,
// opcional: manter user_id por compat/telemetria (se sua tabela ainda tem a coluna)
user_id: userId,
email: email.value || null,
plan_key: selectedPlanRow.value.plan_key,
interval: intervalNormalized.value,
amount_cents: amountCents.value,
currency: currency.value || 'BRL',
status: 'new',
source: 'landing'
}
const { error } = await supabase.from('subscription_intents').insert(payload)
if (error) throw error
}
// ============================
// Actions
// ============================
function goLogin() {
router.push({
path: '/auth/login',
query: email.value ? { email: email.value } : undefined
})
}
function goBackPricing() {
router.push('/lp#pricing')
}
// ============================
// Signup
// ============================
async function onSignup() {
loading.value = true
try {
const { data, error } = await supabase.auth.signUp({
email: email.value,
password: password.value
})
if (error) throw error
const userId = data?.user?.id || null
// ✅ Modelo B: garante tenant pessoal e captura tenant_id
let tenantId = null
if (userId) {
try {
const resTenant = await supabase.rpc('ensure_personal_tenant')
tenantId = resTenant?.data || null
} catch (e) {
console.warn('[Signup] ensure_personal_tenant falhou:', e)
// não aborta signup por isso
}
// ✅ intent (não quebra signup se falhar)
try {
await createSubscriptionIntentAfterSignup(userId, tenantId)
} catch (e) {
console.error('[Signup] subscription_intent failed:', e)
toast.add({
severity: 'warn',
summary: 'Conta criada',
detail: 'Não consegui registrar a intenção do plano. Você pode seguir normalmente.',
life: 4500
})
}
}
toast.add({
severity: 'success',
summary: 'Conta criada',
detail: 'Agora vamos para os próximos passos.',
life: 2500
})
router.push({
path: '/auth/welcome',
query: {
plan: planFromQuery.value || undefined,
interval: intervalNormalized.value || undefined
}
})
} catch (err) {
console.error(err)
const msg = String(err?.message || '')
const isAlreadyRegistered = err?.name === 'AuthApiError' && /User already registered/i.test(msg)
if (isAlreadyRegistered) {
toast.add({
severity: 'warn',
summary: 'Esse email já tem conta',
detail: 'Faça login para continuar.',
life: 4500
})
goLogin()
return
}
toast.add({
severity: 'error',
summary: 'Erro ao criar conta',
detail: err?.message || 'Tente novamente.',
life: 4500
})
} finally {
loading.value = false
}
}
</script>
<template>
<div class="min-h-screen bg-[var(--surface-ground)] text-[var(--text-color)] flex items-center justify-center p-4">
<!-- fundo suave (noir glow) -->
<div class="pointer-events-none absolute inset-0 overflow-hidden">
<div class="absolute -top-28 -left-28 h-96 w-96 rounded-full blur-3xl opacity-60 bg-indigo-400/10" />
<div class="absolute top-24 -right-24 h-[28rem] w-[28rem] rounded-full blur-3xl opacity-60 bg-emerald-400/10" />
<div class="absolute -bottom-40 left-1/3 h-[34rem] w-[34rem] rounded-full blur-3xl opacity-60 bg-fuchsia-400/10" />
</div>
<div class="relative w-full max-w-6xl">
<div class="rounded-3xl border border-[var(--surface-border)] bg-[var(--surface-card)] shadow-sm overflow-hidden">
<div class="grid grid-cols-12">
<!-- LEFT: conceitual (estilo PrimeBlocks) -->
<div class="col-span-12 lg:col-span-6 p-6 md:p-10 bg-[color-mix(in_srgb,var(--surface-card),transparent_6%)] border-b lg:border-b-0 lg:border-r border-[var(--surface-border)]">
<div class="flex items-center gap-3">
<div class="h-11 w-11 rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-ground)] grid place-items-center">
<i class="pi pi-sparkles opacity-80 text-lg" />
</div>
<div class="min-w-0">
<div class="font-semibold leading-tight truncate">Psi Quasar</div>
<div class="text-xs text-[var(--text-color-secondary)] truncate">Gestão clínica sem ruído.</div>
</div>
</div>
<Divider class="my-6" />
<div class="text-3xl md:text-4xl font-semibold leading-tight">
Menos dispersão. Mais presença.
</div>
<div class="mt-3 text-sm md:text-base text-[var(--text-color-secondary)] max-w-lg">
Crie sua conta e siga para o pagamento manual (PIX/boleto). Assim que confirmado, seu plano é ativado
e as funcionalidades liberadas.
</div>
<div class="mt-6 grid grid-cols-12 gap-3">
<div class="col-span-12 md:col-span-6">
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-ground)] p-4">
<div class="text-xs text-[var(--text-color-secondary)]">Agenda</div>
<div class="text-xl font-semibold mt-1">Organizada</div>
<div class="text-xs text-[var(--text-color-secondary)] mt-1">encaixes, bloqueios e visão clara</div>
</div>
</div>
<div class="col-span-12 md:col-span-6">
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-ground)] p-4">
<div class="text-xs text-[var(--text-color-secondary)]">Financeiro</div>
<div class="text-xl font-semibold mt-1">Respirável</div>
<div class="text-xs text-[var(--text-color-secondary)] mt-1">sem planilhas espalhadas</div>
</div>
</div>
<div class="col-span-12">
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-ground)] p-4">
<div class="flex items-start justify-between gap-3">
<div>
<div class="text-xs text-[var(--text-color-secondary)]">Prontuário</div>
<div class="font-semibold mt-1">Histórico por sessão</div>
<div class="text-xs text-[var(--text-color-secondary)] mt-1">linha do tempo por paciente</div>
</div>
<i class="pi pi-file-edit opacity-60" />
</div>
</div>
</div>
</div>
<div class="mt-6 flex flex-wrap gap-2">
<Tag severity="secondary" value="Agenda online (PRO)" />
<Tag severity="secondary" value="Controle de sessões" />
<Tag severity="secondary" value="Financeiro integrado" />
<Tag severity="secondary" value="Clínica / multi-profissional" />
</div>
<div class="mt-6 text-xs text-[var(--text-color-secondary)]">
* Painel conceitual inspirado em layouts PrimeBlocks.
</div>
</div>
<!-- RIGHT: Card login/signup + plano no topo -->
<div class="col-span-12 lg:col-span-6 p-6 md:p-10">
<div class="max-w-md mx-auto">
<div class="text-2xl font-semibold">Criar conta</div>
<div class="text-sm text-[var(--text-color-secondary)] mt-1">
tem conta?
<a class="cursor-pointer font-medium" @click.prevent="goLogin">Entrar</a>
</div>
<!-- Plano (card único, dentro da direita) -->
<div class="mt-5">
<div v-if="pricingLoading" class="flex items-center gap-2 text-sm text-[var(--text-color-secondary)]">
<ProgressSpinner style="width: 18px; height: 18px" strokeWidth="4" />
Carregando plano
</div>
<Card v-else-if="showPlanCard" class="overflow-hidden">
<template #content>
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<div class="text-xs text-[var(--text-color-secondary)]">Plano selecionado</div>
<div class="mt-1 flex items-center gap-2 flex-wrap">
<div class="text-lg font-semibold truncate">
{{ selectedPlanName }}
</div>
<Tag v-if="selectedPlanRow?.is_featured" severity="success" value="Popular" />
<Tag v-if="selectedBadge" severity="secondary" :value="selectedBadge" />
<Chip :label="intervalLabel" />
</div>
<div class="mt-2 text-2xl font-semibold leading-none">
{{ formattedPrice || '—' }}
<span class="text-sm font-normal text-[var(--text-color-secondary)]">
/{{ intervalNormalized === 'month' ? 'mês' : 'ano' }}
</span>
</div>
<div v-if="selectedDescription" class="mt-2 text-sm text-[var(--text-color-secondary)]">
{{ selectedDescription }}
</div>
</div>
<Button
icon="pi pi-pencil"
label="Trocar"
severity="secondary"
text
rounded
aria-label="Trocar plano"
@click="goBackPricing"
/>
</div>
<Divider class="my-4" />
<ul v-if="bullets.length" class="space-y-2 text-sm">
<li v-for="b in bullets" :key="b.id ?? b.text" class="flex items-start gap-2">
<i class="pi pi-check mt-1 text-emerald-500"></i>
<span :class="b.highlight ? 'font-semibold' : 'text-[var(--text-color-secondary)]'">
{{ b.text }}
</span>
</li>
</ul>
<Message v-else severity="info" class="mt-2">
Benefícios ainda não cadastrados para esse plano.
</Message>
</template>
</Card>
<Message v-else-if="hasPlanQuery && !selectedPlanRow" severity="warn" class="mb-0">
Não encontrei esse plano na vitrine pública. Você ainda pode criar a conta normalmente.
<div class="mt-2">
<Button label="Ver planos" icon="pi pi-credit-card" severity="secondary" outlined class="w-full" @click="goBackPricing" />
</div>
</Message>
<Message v-else severity="info" class="mb-0">
Você está criando a conta sem seleção de plano.
<div class="mt-2">
<Button label="Ver planos" icon="pi pi-credit-card" severity="secondary" outlined class="w-full" @click="goBackPricing" />
</div>
</Message>
<Message
v-if="hasPlanQuery && selectedPlanRow && amountCents == null && !pricingLoading"
severity="warn"
class="mt-3"
>
Esse plano não tem preço configurado para {{ intervalLabel }}. Você ainda pode criar a conta normalmente.
</Message>
</div>
<!-- Form -->
<Divider class="my-6" />
<div class="mt-5">
<!-- Social (opcional: deixa visual, mas desabilitado por enquanto) -->
<div class="grid grid-cols-12 gap-2">
<div class="col-span-12 md:col-span-6">
<Button
label="Sign up with Facebook"
icon="pi pi-facebook"
severity="secondary"
outlined
class="w-full"
disabled
/>
</div>
<div class="col-span-12 md:col-span-6">
<Button
label="Sign up with Google"
icon="pi pi-google"
severity="secondary"
outlined
class="w-full"
disabled
/>
</div>
</div>
<!-- Divider OR -->
<div class="flex items-center gap-3 my-5">
<div class="flex-1 h-px bg-[var(--surface-border)]"></div>
<div class="text-sm text-[var(--text-color-secondary)] font-medium">or</div>
<div class="flex-1 h-px bg-[var(--surface-border)]"></div>
</div>
<!-- Email -->
<div class="mb-4">
<FloatLabel variant="on">
<InputText
id="email"
v-model="email"
class="w-full"
autocomplete="email"
/>
<label for="email">Seu melhor e-mail</label>
</FloatLabel>
</div>
<!-- Password -->
<div class="mb-3">
<FloatLabel variant="on">
<Password
v-model="password"
toggleMask
:feedback="true"
inputClass="w-full pr-5"
autocomplete="current-password"
:disabled="loading || loadingRecovery"
:pt="{
root: { class: 'w-full' },
input: { class: 'w-full' },
icon: { class: 'right-3' }
}"
/>
<label for="password">Password</label>
</FloatLabel>
</div>
<!-- CTA -->
<Button
label="CRIAR CONTA"
class="w-full"
:loading="loading"
@click="onSignup"
style="background: #10b981; border-color: #10b981"
/>
<div class="text-xs text-center text-[var(--text-color-secondary)] mt-3">
Ao criar a conta, registramos sua intenção de assinatura. Pagamento é manual (PIX/boleto) por enquanto.
</div>
</div>
</div>
</div>
</div>
</div>
<div class="text-center text-xs text-[var(--text-color-secondary)] mt-4">
Psi Quasar gestão clínica sem ruído.
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,288 @@
<script setup>
import { ref, computed, onMounted } from 'vue'
import { supabase } from '@/lib/supabase/client'
import { useRouter } from 'vue-router'
import { useToast } from 'primevue/usetoast'
import Card from 'primevue/card'
import Button from 'primevue/button'
import Tag from 'primevue/tag'
import Toast from 'primevue/toast'
import Chart from 'primevue/chart'
import DataTable from 'primevue/datatable'
import Column from 'primevue/column'
const router = useRouter()
const toast = useToast()
const loading = ref(false)
const totalActive = ref(0)
const totalCanceled = ref(0)
const totalMismatches = ref(0)
const plans = ref([])
const subs = ref([])
function moneyBRLFromCents (cents) {
const v = Number(cents || 0) / 100
return v.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' })
}
/**
* MRR = soma de price_cents do plano para subscriptions ativas (intervalo month).
* Se no futuro você tiver anual, dá pra normalizar (annual/12).
*/
const mrrCents = computed(() => {
const planById = new Map(plans.value.map(p => [p.id, p]))
let sum = 0
for (const s of subs.value) {
if (s.status !== 'active') continue
const p = planById.get(s.plan_id)
if (!p) continue
if ((p.billing_interval || 'month') !== 'month') continue
sum += Number(p.price_cents || 0)
}
return sum
})
const arpaCents = computed(() => {
const act = subs.value.filter(s => s.status === 'active').length
return act ? Math.round(mrrCents.value / act) : 0
})
const breakdown = computed(() => {
const planById = new Map(plans.value.map(p => [p.id, p]))
const agg = new Map()
for (const s of subs.value) {
const p = planById.get(s.plan_id)
const key = p?.key || '(sem plano)'
if (!agg.has(key)) {
agg.set(key, {
plan_key: key,
active_count: 0,
canceled_count: 0,
price_cents: Number(p?.price_cents || 0),
mrr_cents: 0
})
}
const row = agg.get(key)
if (s.status === 'active') {
row.active_count += 1
// só soma MRR se for mensal
if ((p?.billing_interval || 'month') === 'month') row.mrr_cents += Number(p?.price_cents || 0)
} else if (s.status === 'canceled') {
row.canceled_count += 1
}
}
return Array.from(agg.values()).sort((a, b) => (b.mrr_cents - a.mrr_cents))
})
const chartData = computed(() => {
const labels = breakdown.value.map(r => r.plan_key)
const data = breakdown.value.map(r => Math.round((r.mrr_cents || 0) / 100))
return {
labels,
datasets: [{ label: 'MRR por plano (R$)', data }]
}
})
const chartOptions = computed(() => ({
maintainAspectRatio: false,
plugins: {
legend: { display: true }
},
scales: {
y: { beginAtZero: true }
}
}))
async function loadStats () {
loading.value = true
try {
const [{ data: p, error: ep }, { data: s, error: es }] = await Promise.all([
supabase.from('plans').select('id,key,price_cents,currency,billing_interval').order('key', { ascending: true }),
supabase.from('subscriptions').select('id,tenant_id,plan_id,status,updated_at').order('updated_at', { ascending: false })
])
if (ep) throw ep
if (es) throw es
plans.value = p || []
subs.value = s || []
totalActive.value = subs.value.filter(x => x.status === 'active').length
totalCanceled.value = subs.value.filter(x => x.status === 'canceled').length
const { data: mismatches, error: em } = await supabase
.from('v_subscription_feature_mismatch')
.select('*')
if (em) throw em
totalMismatches.value = (mismatches || []).length
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e.message || String(e), life: 5000 })
} finally {
loading.value = false
}
}
async function fixAll () {
loading.value = true
try {
const { error } = await supabase.rpc('fix_all_subscription_mismatches')
if (error) throw error
toast.add({
severity: 'success',
summary: 'Sistema corrigido',
detail: 'Entitlements reconstruídos com sucesso.',
life: 3000
})
await loadStats()
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e.message || String(e), life: 5000 })
} finally {
loading.value = false
}
}
onMounted(loadStats)
</script>
<template>
<Toast />
<div class="p-4">
<div class="flex items-center justify-content-between mb-4">
<div>
<div class="text-2xl font-semibold">SaaS Control Center</div>
<small class="text-color-secondary">Visão estratégica + saúde de consistência.</small>
</div>
<div class="flex gap-2">
<Button label="Recarregar" icon="pi pi-refresh" severity="secondary" outlined :loading="loading" @click="loadStats" />
<Button label="Subscriptions" icon="pi pi-credit-card" severity="secondary" outlined @click="router.push('/saas/subscriptions')" />
<Button label="Histórico" icon="pi pi-history" severity="secondary" outlined @click="router.push('/saas/subscription-events')" />
</div>
</div>
<!-- KPIs -->
<div class="grid grid-cols-12 gap-4">
<div class="col-span-12 md:col-span-3">
<Card>
<template #title>Ativas</template>
<template #content>
<div class="text-4xl font-bold text-green-500">{{ totalActive }}</div>
</template>
</Card>
</div>
<div class="col-span-12 md:col-span-3">
<Card>
<template #title>Canceladas</template>
<template #content>
<div class="text-4xl font-bold text-red-400">{{ totalCanceled }}</div>
</template>
</Card>
</div>
<div class="col-span-12 md:col-span-3">
<Card>
<template #title>MRR</template>
<template #content>
<div class="text-3xl font-bold">{{ moneyBRLFromCents(mrrCents) }}</div>
<small class="text-color-secondary">somente planos mensais</small>
</template>
</Card>
</div>
<div class="col-span-12 md:col-span-3">
<Card>
<template #title>ARPA</template>
<template #content>
<div class="text-3xl font-bold">{{ moneyBRLFromCents(arpaCents) }}</div>
<small class="text-color-secondary">receita média por ativa</small>
</template>
</Card>
</div>
</div>
<!-- Health + Actions -->
<div class="grid grid-cols-12 gap-4 mt-4">
<div class="col-span-12 md:col-span-4">
<Card>
<template #title>System Health</template>
<template #content>
<div class="flex items-center justify-content-between">
<div class="text-4xl font-bold" :class="totalMismatches > 0 ? 'text-red-500' : 'text-green-500'">
{{ totalMismatches }}
</div>
<Tag
:severity="totalMismatches > 0 ? 'danger' : 'success'"
:value="totalMismatches > 0 ? 'Inconsistências' : 'Saudável'"
/>
</div>
<div class="mt-3 flex gap-2 flex-wrap">
<Button
v-if="totalMismatches > 0"
label="Fix All"
icon="pi pi-refresh"
severity="danger"
:loading="loading"
@click="fixAll"
/>
<Button
label="Ver divergências"
icon="pi pi-search"
severity="secondary"
outlined
@click="router.push('/saas/subscription-health')"
/>
</div>
</template>
</Card>
</div>
<div class="col-span-12 md:col-span-8">
<Card>
<template #title>MRR por plano</template>
<template #content>
<div style="height: 260px;">
<Chart type="bar" :data="chartData" :options="chartOptions" />
</div>
</template>
</Card>
</div>
</div>
<!-- Breakdown table -->
<div class="mt-4">
<Card>
<template #title>Distribuição por plano</template>
<template #content>
<DataTable :value="breakdown" stripedRows responsiveLayout="scroll">
<Column field="plan_key" header="Plano" style="min-width: 12rem" />
<Column header="Ativas" style="width: 8rem">
<template #body="{ data }">{{ data.active_count }}</template>
</Column>
<Column header="Canceladas" style="width: 10rem">
<template #body="{ data }">{{ data.canceled_count }}</template>
</Column>
<Column header="Preço" style="min-width: 12rem">
<template #body="{ data }">{{ moneyBRLFromCents(data.price_cents) }}</template>
</Column>
<Column header="MRR" style="min-width: 12rem">
<template #body="{ data }">{{ moneyBRLFromCents(data.mrr_cents) }}</template>
</Column>
</DataTable>
</template>
</Card>
</div>
</div>
</template>

View File

@@ -0,0 +1,271 @@
<script setup>
import { ref, onMounted, computed } from 'vue'
import { supabase } from '@/lib/supabase/client'
import Toolbar from 'primevue/toolbar'
import Button from 'primevue/button'
import DataTable from 'primevue/datatable'
import Column from 'primevue/column'
import Dialog from 'primevue/dialog'
import InputText from 'primevue/inputtext'
import Textarea from 'primevue/textarea'
import Toast from 'primevue/toast'
import ConfirmDialog from 'primevue/confirmdialog'
import { useToast } from 'primevue/usetoast'
import { useConfirm } from 'primevue/useconfirm'
const toast = useToast()
const confirm = useConfirm()
const loading = ref(false)
const rows = ref([])
const showDlg = ref(false)
const saving = ref(false)
const isEdit = ref(false)
const form = ref({
id: null,
key: '',
descricao: ''
})
const hasCreatedAt = computed(() => rows.value?.length && 'created_at' in rows.value[0])
async function fetchAll () {
loading.value = true
const { data, error } = await supabase
.from('features')
.select('*')
.order('key', { ascending: true })
loading.value = false
if (error) {
toast.add({ severity: 'error', summary: 'Erro', detail: error.message, life: 4500 })
return
}
rows.value = data || []
}
function openCreate () {
isEdit.value = false
form.value = { id: null, key: '', descricao: '' }
showDlg.value = true
}
function openEdit (row) {
isEdit.value = true
form.value = {
id: row.id,
key: row.key ?? '',
descricao: row.descricao ?? ''
}
showDlg.value = true
}
function validate () {
const k = String(form.value.key || '').trim()
if (!k) {
toast.add({
severity: 'warn',
summary: 'Atenção',
detail: 'Informe a key da feature.',
life: 3000
})
return false
}
const d = String(form.value.descricao || '').trim()
// 🔒 evita duplicidade no frontend (exceto no próprio registro em edição)
const exists = rows.value.some(r =>
String(r.key).trim() === k && r.id !== form.value.id
)
if (exists) {
toast.add({
severity: 'warn',
summary: 'Key já existente',
detail: 'Já existe uma feature com essa key. Use outra.',
life: 3000
})
return false
}
form.value.key = k
form.value.descricao = d
return true
}
function isUniqueViolation (err) {
if (!err) return false
if (err.code === '23505') return true
const msg = String(err.message || '')
return msg.includes('duplicate key value') || msg.includes('unique constraint')
}
async function save () {
if (!validate()) return
saving.value = true
try {
if (isEdit.value) {
const { error } = await supabase
.from('features')
.update({
key: form.value.key,
descricao: form.value.descricao
})
.eq('id', form.value.id)
if (error) throw error
toast.add({ severity: 'success', summary: 'Ok', detail: 'Feature atualizada.', life: 2500 })
} else {
const { error } = await supabase
.from('features')
.insert({
key: form.value.key,
descricao: form.value.descricao
})
if (error) throw error
toast.add({ severity: 'success', summary: 'Ok', detail: 'Feature criada.', life: 2500 })
}
showDlg.value = false
await fetchAll()
} catch (e) {
if (isUniqueViolation(e)) {
toast.add({
severity: 'warn',
summary: 'Key já existente',
detail: 'Já existe uma feature com essa key. Use outra.',
life: 3500
})
} else {
toast.add({
severity: 'error',
summary: 'Erro',
detail: e.message || String(e),
life: 4500
})
}
} finally {
saving.value = false
}
}
function askDelete (row) {
confirm.require({
message: `Excluir a feature "${row.key}"?`,
header: 'Confirmar exclusão',
icon: 'pi pi-exclamation-triangle',
acceptClass: 'p-button-danger',
accept: () => doDelete(row)
})
}
async function doDelete (row) {
const { error } = await supabase
.from('features')
.delete()
.eq('id', row.id)
if (error) {
toast.add({ severity: 'error', summary: 'Erro', detail: error.message, life: 4500 })
return
}
toast.add({ severity: 'success', summary: 'Ok', detail: 'Feature excluída.', life: 2500 })
await fetchAll()
}
onMounted(fetchAll)
</script>
<template>
<Toast />
<ConfirmDialog />
<div class="p-4">
<Toolbar class="mb-4">
<template #start>
<div class="flex flex-col">
<div class="text-xl font-semibold leading-none">Features</div>
<small class="text-color-secondary mt-1">
Funcionalidades que podem ser ativadas por plano.
</small>
</div>
</template>
<template #end>
<div class="flex items-center gap-2">
<Button label="Atualizar" icon="pi pi-refresh" severity="secondary" outlined :loading="loading" @click="fetchAll" />
<Button label="Adicionar" icon="pi pi-plus" @click="openCreate" />
</div>
</template>
</Toolbar>
<DataTable :value="rows" dataKey="id" :loading="loading" stripedRows responsiveLayout="scroll">
<Column field="key" header="Key" sortable />
<Column field="descricao" header="Descrição" sortable style="min-width: 22rem">
<template #body="{ data }">
<!-- Tooltip opcional: se v-tooltip estiver habilitado no app, vai ficar ótimo.
Se não estiver, é remover o v-tooltip que segue normal. -->
<div
class="max-w-[520px] whitespace-nowrap overflow-hidden text-ellipsis text-color-secondary"
v-tooltip.top="data.descricao || ''"
>
{{ data.descricao || '-' }}
</div>
</template>
</Column>
<Column v-if="hasCreatedAt" field="created_at" header="Criado em" sortable />
<Column header="Ações" style="width: 12rem">
<template #body="{ data }">
<div class="flex gap-2">
<Button icon="pi pi-pencil" severity="secondary" outlined @click="openEdit(data)" />
<Button icon="pi pi-trash" severity="danger" outlined @click="askDelete(data)" />
</div>
</template>
</Column>
</DataTable>
<Dialog v-model:visible="showDlg" modal :header="isEdit ? 'Editar feature' : 'Nova feature'" :style="{ width: '620px' }">
<div class="flex flex-col gap-4">
<div>
<label class="block mb-2">Key</label>
<InputText v-model="form.key" class="w-full" placeholder="ex: online_scheduling.manage" />
</div>
<div>
<label class="block mb-2">Descrição</label>
<Textarea
v-model="form.descricao"
class="w-full"
:autoResize="true"
rows="3"
placeholder="Explique em linguagem humana o que essa feature habilita..."
/>
<small class="text-color-secondary block mt-2">
Dica: isso aparece no admin e ajuda a documentar o que cada feature faz.
</small>
</div>
</div>
<template #footer>
<Button label="Cancelar" severity="secondary" outlined @click="showDlg = false" />
<Button :label="isEdit ? 'Salvar' : 'Criar'" icon="pi pi-check" :loading="saving" @click="save" />
</template>
</Dialog>
</div>
</template>

View File

@@ -0,0 +1,8 @@
<template>
<div class="p-4">
<div class="text-xl font-semibold">Em construção</div>
<div class="text-color-secondary mt-2">
Esta área do Admin SaaS ainda será implementada.
</div>
</div>
</template>

View File

@@ -0,0 +1,178 @@
<script setup>
import { ref, onMounted, computed } from 'vue'
import { supabase } from '@/lib/supabase/client'
import Toolbar from 'primevue/toolbar'
import Button from 'primevue/button'
import DataTable from 'primevue/datatable'
import Column from 'primevue/column'
import Checkbox from 'primevue/checkbox'
import Toast from 'primevue/toast'
import { useToast } from 'primevue/usetoast'
import InputText from 'primevue/inputtext'
const toast = useToast()
const loading = ref(false)
const plans = ref([])
const features = ref([])
const links = ref([]) // plan_features rows
const q = ref('')
const planById = computed(() => {
const m = new Map()
for (const p of plans.value) m.set(p.id, p)
return m
})
const enabledSet = computed(() => {
// Set de "planId::featureId" para lookup rápido
const s = new Set()
for (const r of links.value) s.add(`${r.plan_id}::${r.feature_id}`)
return s
})
const filteredFeatures = computed(() => {
const term = String(q.value || '').trim().toLowerCase()
if (!term) return features.value
return features.value.filter(f => String(f.key || '').toLowerCase().includes(term))
})
async function fetchAll () {
loading.value = true
try {
const [{ data: p, error: ep }, { data: f, error: ef }, { data: pf, error: epf }] = await Promise.all([
supabase.from('plans').select('*').order('key', { ascending: true }),
supabase.from('features').select('*').order('key', { ascending: true }),
supabase.from('plan_features').select('plan_id, feature_id')
])
if (ep) throw ep
if (ef) throw ef
if (epf) throw epf
plans.value = p || []
features.value = f || []
links.value = pf || []
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e.message || String(e), life: 5000 })
} finally {
loading.value = false
}
}
function isEnabled (planId, featureId) {
return enabledSet.value.has(`${planId}::${featureId}`)
}
async function toggle (planId, featureId, nextValue) {
// otimista (UI responde rápido) — mas com rollback se falhar
const key = `${planId}::${featureId}`
const prev = links.value.slice()
try {
if (nextValue) {
// adiciona
links.value = [...links.value, { plan_id: planId, feature_id: featureId }]
const { error } = await supabase.from('plan_features').insert({ plan_id: planId, feature_id: featureId })
if (error) throw error
} else {
// remove
links.value = links.value.filter(x => !(x.plan_id === planId && x.feature_id === featureId))
const { error } = await supabase
.from('plan_features')
.delete()
.eq('plan_id', planId)
.eq('feature_id', featureId)
if (error) throw error
}
} catch (e) {
links.value = prev
toast.add({ severity: 'error', summary: 'Erro', detail: e.message || String(e), life: 5000 })
return
}
}
onMounted(fetchAll)
</script>
<template>
<Toast />
<div class="p-4">
<Toolbar class="mb-4">
<template #start>
<div class="flex flex-col">
<div class="text-xl font-semibold leading-none">Plan Features</div>
<small class="text-color-secondary mt-1">
Marque quais features pertencem a cada plano. Isso define FREE/PRO sem mexer no código.
</small>
</div>
</template>
<template #end>
<div class="flex items-center gap-2">
<span class="p-input-icon-left">
<FloatLabel variant="on" class="w-full">
<IconField class="w-full">
<InputIcon class="pi pi-search" />
<InputText
v-model="q"
id="features_search"
class="w-full pr-10"
variant="filled"
/>
</IconField>
<label for="features_search">Filtrar features</label>
</FloatLabel>
</span>
<Button label="Recarregar" icon="pi pi-refresh" severity="secondary" outlined :loading="loading" @click="fetchAll" />
</div>
</template>
</Toolbar>
<DataTable
:value="filteredFeatures"
dataKey="id"
:loading="loading"
stripedRows
responsiveLayout="scroll"
:scrollable="true"
scrollHeight="70vh"
>
<Column header="Feature" frozen style="min-width: 22rem">
<template #body="{ data }">
<div class="flex flex-col">
<span class="font-medium">
{{ data.key }}
</span>
<small class="text-color-secondary leading-snug mt-1">
{{ data.descricao || '—' }}
</small>
</div>
</template>
</Column>
<Column
v-for="p in plans"
:key="p.id"
:header="p.key"
:style="{ minWidth: '10rem' }"
>
<template #body="{ data }">
<div class="flex justify-center">
<Checkbox
:binary="true"
:modelValue="isEnabled(p.id, data.id)"
@update:modelValue="(val) => toggle(p.id, data.id, val)"
/>
</div>
</template>
</Column>
</DataTable>
</div>
</template>

View File

@@ -0,0 +1,412 @@
<script setup>
import { ref, onMounted, computed } from 'vue'
import { supabase } from '@/lib/supabase/client'
import Toolbar from 'primevue/toolbar'
import Button from 'primevue/button'
import DataTable from 'primevue/datatable'
import Column from 'primevue/column'
import Dialog from 'primevue/dialog'
import InputText from 'primevue/inputtext'
import InputNumber from 'primevue/inputnumber'
import Toast from 'primevue/toast'
import ConfirmDialog from 'primevue/confirmdialog'
import { useToast } from 'primevue/usetoast'
import { useConfirm } from 'primevue/useconfirm'
const toast = useToast()
const confirm = useConfirm()
const loading = ref(false)
const rows = ref([])
const showDlg = ref(false)
const saving = ref(false)
const isEdit = ref(false)
const form = ref({
id: null,
key: '',
name: '',
price_monthly: null, // em R$ (UI)
price_yearly: null // em R$ (UI)
})
const hasCreatedAt = computed(() => rows.value?.length && 'created_at' in rows.value[0])
const isSystemKeyLocked = computed(() => {
const k = String(form.value.key || '').trim().toLowerCase()
return isEdit.value && (k === 'free' || k === 'pro')
})
function isUniqueViolation (err) {
if (!err) return false
if (err.code === '23505') return true
const msg = String(err.message || '')
return msg.includes('duplicate key value') || msg.includes('unique constraint')
}
// slug técnico para key (sem acento, sem espaço, lowercase, só [a-z0-9_])
function slugifyKey (s) {
return String(s || '')
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
.trim()
.replace(/\s+/g, '_')
.replace(/[^a-z0-9_]/g, '')
}
function formatBRLFromCents (cents) {
if (cents == null) return '—'
const v = Number(cents) / 100
return v.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' })
}
function toCents (valueReais) {
if (valueReais == null || valueReais === '') return null
const n = Number(valueReais)
if (Number.isNaN(n)) return null
return Math.round(n * 100)
}
function fromCentsToReais (cents) {
if (cents == null) return null
return Number(cents) / 100
}
async function fetchAll () {
loading.value = true
try {
// 1) planos
const { data: p, error: ep } = await supabase.from('plans').select('*').order('key', { ascending: true })
if (ep) throw ep
// 2) preços ativos (view)
const { data: ap, error: eap } = await supabase.from('v_plan_active_prices').select('*')
if (eap) throw eap
const priceMap = new Map()
for (const r of (ap || [])) priceMap.set(r.plan_id, r)
rows.value = (p || []).map(plan => {
const pr = priceMap.get(plan.id) || {}
return {
...plan,
monthly_cents: pr.monthly_cents ?? null,
yearly_cents: pr.yearly_cents ?? null
}
})
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e.message || String(e), life: 4500 })
} finally {
loading.value = false
}
}
function openEdit (row) {
isEdit.value = true
form.value = {
id: row.id,
key: row.key ?? '',
name: row.name ?? '',
price_monthly: fromCentsToReais(row.monthly_cents),
price_yearly: fromCentsToReais(row.yearly_cents)
}
showDlg.value = true
}
function openCreate () {
isEdit.value = false
form.value = {
id: null,
key: '',
name: '',
price_monthly: null,
price_yearly: null
}
showDlg.value = true
}
function validate () {
const k = slugifyKey(form.value.key)
if (!k) {
toast.add({ severity: 'warn', summary: 'Atenção', detail: 'Informe a key do plano.', life: 3000 })
return false
}
const n = String(form.value.name || '').trim()
if (!n) {
toast.add({ severity: 'warn', summary: 'Atenção', detail: 'Informe o nome do plano.', life: 3000 })
return false
}
// preços opcionais — se vierem preenchidos, não podem ser negativos.
const m = form.value.price_monthly
const y = form.value.price_yearly
if (m != null && Number(m) < 0) {
toast.add({ severity: 'warn', summary: 'Atenção', detail: 'Preço mensal não pode ser negativo.', life: 3000 })
return false
}
if (y != null && Number(y) < 0) {
toast.add({ severity: 'warn', summary: 'Atenção', detail: 'Preço anual não pode ser negativo.', life: 3000 })
return false
}
// 🔒 evita key duplicada no frontend (case-insensitive)
const exists = rows.value.some(r => String(r.key || '').trim().toLowerCase() === k && r.id !== form.value.id)
if (exists) {
toast.add({
severity: 'warn',
summary: 'Key já existente',
detail: 'Já existe um plano com essa key. Use outra.',
life: 3000
})
return false
}
form.value.key = k
form.value.name = n
return true
}
async function upsertPlanPrice ({ planId, interval, nextCents, prevCents }) {
const same = (prevCents == null && nextCents == null) || (Number(prevCents) === Number(nextCents))
if (same) return
const nowIso = new Date().toISOString()
// se admin apagou o preço: desativa o ativo
if (nextCents == null) {
const { error } = await supabase
.from('plan_prices')
.update({ is_active: false, active_to: nowIso })
.eq('plan_id', planId)
.eq('interval', interval)
.eq('is_active', true)
if (error) throw error
return
}
// fecha ativo atual (se existir)
if (prevCents != null) {
const { error: eClose } = await supabase
.from('plan_prices')
.update({ is_active: false, active_to: nowIso })
.eq('plan_id', planId)
.eq('interval', interval)
.eq('is_active', true)
if (eClose) throw eClose
}
// cria novo preço ativo
const { error: eIns } = await supabase.from('plan_prices').insert({
plan_id: planId,
interval,
amount_cents: nextCents,
currency: 'BRL',
is_active: true,
active_from: nowIso,
source: 'manual'
})
if (eIns) throw eIns
}
async function save () {
if (!validate()) return
saving.value = true
try {
let planId = form.value.id
if (isEdit.value) {
const { error } = await supabase.from('plans').update({ key: form.value.key, name: form.value.name }).eq('id', form.value.id)
if (error) throw error
} else {
const { data, error } = await supabase.from('plans').insert({ key: form.value.key, name: form.value.name }).select('id').single()
if (error) throw error
planId = data.id
}
// preços atuais (da listagem)
const currentRow = rows.value.find(r => r.id === planId)
const prevMonthly = currentRow?.monthly_cents ?? null
const prevYearly = currentRow?.yearly_cents ?? null
const nextMonthly = toCents(form.value.price_monthly)
const nextYearly = toCents(form.value.price_yearly)
await upsertPlanPrice({ planId, interval: 'month', nextCents: nextMonthly, prevCents: prevMonthly })
await upsertPlanPrice({ planId, interval: 'year', nextCents: nextYearly, prevCents: prevYearly })
toast.add({
severity: 'success',
summary: 'Ok',
detail: isEdit.value ? 'Plano atualizado.' : 'Plano criado.',
life: 2500
})
showDlg.value = false
await fetchAll()
} catch (e) {
if (isUniqueViolation(e)) {
toast.add({
severity: 'warn',
summary: 'Key já existente',
detail: 'Já existe um plano com essa key. Use outra.',
life: 3500
})
} else {
toast.add({ severity: 'error', summary: 'Erro', detail: e.message || String(e), life: 4500 })
}
} finally {
saving.value = false
}
}
function askDelete (row) {
confirm.require({
message: `Excluir o plano "${row.key}"?`,
header: 'Confirmar exclusão',
icon: 'pi pi-exclamation-triangle',
acceptClass: 'p-button-danger',
accept: () => doDelete(row)
})
}
async function doDelete (row) {
const { error } = await supabase.from('plans').delete().eq('id', row.id)
if (error) {
toast.add({ severity: 'error', summary: 'Erro', detail: error.message, life: 4500 })
return
}
toast.add({ severity: 'success', summary: 'Ok', detail: 'Plano excluído.', life: 2500 })
await fetchAll()
}
onMounted(fetchAll)
</script>
<template>
<Toast />
<ConfirmDialog />
<div class="p-4">
<Toolbar class="mb-4">
<template #start>
<div class="flex flex-col">
<div class="text-xl font-semibold leading-none">Plans</div>
<small class="text-color-secondary mt-1">
Catálogo de planos do SaaS. A <b>key</b> é a referência estável usada no sistema.
</small>
</div>
</template>
<template #end>
<div class="flex items-center gap-2">
<Button label="Atualizar" icon="pi pi-refresh" severity="secondary" outlined :loading="loading" @click="fetchAll" />
<Button label="Adicionar" icon="pi pi-plus" @click="openCreate" />
</div>
</template>
</Toolbar>
<DataTable :value="rows" dataKey="id" :loading="loading" stripedRows responsiveLayout="scroll">
<Column field="name" header="Nome" sortable style="min-width: 14rem" />
<Column field="key" header="Key" sortable />
<Column header="Mensal" sortable style="width: 12rem">
<template #body="{ data }">
<span class="font-medium">
{{ formatBRLFromCents(data.monthly_cents) }}
</span>
</template>
</Column>
<Column header="Anual" sortable style="width: 12rem">
<template #body="{ data }">
<span class="font-medium">
{{ formatBRLFromCents(data.yearly_cents) }}
</span>
</template>
</Column>
<Column v-if="hasCreatedAt" field="created_at" header="Criado em" sortable />
<Column header="Ações" style="width: 12rem">
<template #body="{ data }">
<div class="flex gap-2">
<Button icon="pi pi-pencil" severity="secondary" outlined @click="openEdit(data)" />
<Button icon="pi pi-trash" severity="danger" outlined @click="askDelete(data)" />
</div>
</template>
</Column>
</DataTable>
<Dialog v-model:visible="showDlg" modal :header="isEdit ? 'Editar plano' : 'Novo plano'" :style="{ width: '620px' }">
<div class="flex flex-col gap-4">
<div>
<label class="block mb-2">Key</label>
<InputText
v-model="form.key"
class="w-full"
placeholder="ex.: free, pro, clinic_pro"
:disabled="isSystemKeyLocked"
/>
<small class="text-color-secondary">
A key é técnica e estável (slug). Ex.: "Clínica Pro" vira "clinica_pro".
{{ isSystemKeyLocked ? ' Este plano é do sistema (key travada).' : '' }}
</small>
</div>
<div>
<label class="block mb-2">Nome do plano</label>
<InputText v-model="form.name" class="w-full" placeholder="ex.: Free, Pro, Clínica Pro" />
<small class="text-color-secondary">Nome interno para o admin. (O nome público vem depois.)</small>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block mb-2">Preço mensal (R$)</label>
<InputNumber
v-model="form.price_monthly"
class="w-full"
inputClass="w-full"
mode="decimal"
:minFractionDigits="2"
:maxFractionDigits="2"
placeholder="ex.: 39,90"
/>
<small class="text-color-secondary">Deixe vazio para sem preço definido.</small>
</div>
<div>
<label class="block mb-2">Preço anual (R$)</label>
<InputNumber
v-model="form.price_yearly"
class="w-full"
inputClass="w-full"
mode="decimal"
:minFractionDigits="2"
:maxFractionDigits="2"
placeholder="ex.: 399,90"
/>
<small class="text-color-secondary">Deixe vazio para sem preço definido.</small>
</div>
</div>
</div>
<template #footer>
<Button label="Cancelar" severity="secondary" outlined @click="showDlg = false" />
<Button :label="isEdit ? 'Salvar' : 'Criar'" icon="pi pi-check" :loading="saving" @click="save" />
</template>
</Dialog>
</div>
</template>

View File

@@ -0,0 +1,637 @@
<script setup>
import { ref, onMounted, computed } from 'vue'
import { supabase } from '@/lib/supabase/client'
import Toolbar from 'primevue/toolbar'
import Button from 'primevue/button'
import DataTable from 'primevue/datatable'
import Column from 'primevue/column'
import Dialog from 'primevue/dialog'
import InputText from 'primevue/inputtext'
import Textarea from 'primevue/textarea'
import InputNumber from 'primevue/inputnumber'
import Checkbox from 'primevue/checkbox'
import Toast from 'primevue/toast'
import ConfirmDialog from 'primevue/confirmdialog'
import Popover from 'primevue/popover'
import { useToast } from 'primevue/usetoast'
import { useConfirm } from 'primevue/useconfirm'
import Avatar from 'primevue/avatar'
import AvatarGroup from 'primevue/avatargroup'
import Divider from 'primevue/divider'
import Badge from 'primevue/badge'
const billingInterval = ref('month') // 'month' | 'year'
function priceCentsFor (p, interval) {
return interval === 'year' ? p.yearly_cents : p.monthly_cents
}
// busca com FloatLabel (padrão)
import FloatLabel from 'primevue/floatlabel'
import IconField from 'primevue/iconfield'
import InputIcon from 'primevue/inputicon'
const toast = useToast()
const confirm = useConfirm()
const loading = ref(false)
const rows = ref([])
const q = ref('')
const showDlg = ref(false)
const saving = ref(false)
const showBulletDlg = ref(false)
const bulletSaving = ref(false)
const bulletIsEdit = ref(false)
const selected = ref(null)
const form = ref({
plan_id: null,
public_name: '',
public_description: '',
badge: '',
is_featured: false,
is_visible: true,
sort_order: 0
})
const bullets = ref([])
const bulletForm = ref({
id: null,
text: '',
sort_order: 0,
highlight: false
})
/* popover bullets (na tabela) */
const bulletsPop = ref(null)
const popPlanTitle = ref('')
const popBullets = ref([])
function openBulletsPopover (event, row) {
popPlanTitle.value = row.public_name || row.plan_name || row.plan_key || 'Benefícios'
popBullets.value = Array.isArray(row.bullets) ? row.bullets : []
bulletsPop.value?.toggle(event)
}
function formatBRLFromCents (cents) {
if (cents == null) return '—'
const v = Number(cents) / 100
return v.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' })
}
const filteredRows = computed(() => {
const term = String(q.value || '').trim().toLowerCase()
if (!term) return rows.value
return rows.value.filter(r => {
const a = String(r.public_name || '').toLowerCase()
const b = String(r.plan_key || '').toLowerCase()
const c = String(r.plan_name || '').toLowerCase()
return a.includes(term) || b.includes(term) || c.includes(term)
})
})
const previewPlans = computed(() => {
// preview: só visíveis, ordenados. Featured primeiro dentro da mesma ordem.
const list = (rows.value || [])
.filter(r => r.is_visible !== false)
.slice()
.sort((a, b) => {
const ao = Number(a.sort_order ?? 0)
const bo = Number(b.sort_order ?? 0)
if (ao !== bo) return ao - bo
const af = a.is_featured ? 0 : 1
const bf = b.is_featured ? 0 : 1
if (af !== bf) return af - bf
return String(a.plan_key || '').localeCompare(String(b.plan_key || ''))
})
return list
})
async function fetchAll () {
loading.value = true
try {
const { data, error } = await supabase
.from('v_public_pricing')
.select('*')
if (error) throw error
rows.value = data || []
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e.message || String(e), life: 4500 })
} finally {
loading.value = false
}
}
async function openEdit (row) {
selected.value = row
form.value = {
plan_id: row.plan_id,
public_name: row.public_name || row.plan_name || row.plan_key || '',
public_description: row.public_description || '',
badge: row.badge || '',
is_featured: !!row.is_featured,
is_visible: row.is_visible !== false,
sort_order: Number(row.sort_order ?? 0)
}
await fetchBullets(row.plan_id)
showDlg.value = true
}
async function fetchBullets (planId) {
const { data, error } = await supabase
.from('plan_public_bullets')
.select('*')
.eq('plan_id', planId)
.order('sort_order', { ascending: true })
.order('created_at', { ascending: true })
if (error) {
toast.add({ severity: 'error', summary: 'Erro', detail: error.message, life: 4500 })
bullets.value = []
return
}
bullets.value = data || []
}
function validate () {
const name = String(form.value.public_name || '').trim()
if (!name) {
toast.add({ severity: 'warn', summary: 'Atenção', detail: 'Informe o nome público do plano.', life: 3000 })
return false
}
form.value.public_name = name
form.value.public_description = String(form.value.public_description || '').trim()
form.value.badge = String(form.value.badge || '').trim() || null
form.value.sort_order = Number(form.value.sort_order ?? 0)
return true
}
async function save () {
if (!validate()) return
saving.value = true
try {
const payload = { ...form.value }
const { error } = await supabase
.from('plan_public')
.upsert(payload, { onConflict: 'plan_id' })
if (error) throw error
toast.add({ severity: 'success', summary: 'Ok', detail: 'Vitrine atualizada.', life: 2500 })
showDlg.value = false
await fetchAll()
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e.message || String(e), life: 4500 })
} finally {
saving.value = false
}
}
/* ---------- bullets (CRUD no dialog) ---------- */
function openBulletCreate () {
bulletIsEdit.value = false
bulletForm.value = { id: null, text: '', sort_order: (bullets.value?.length || 0) + 1, highlight: false }
showBulletDlg.value = true
}
function openBulletEdit (row) {
bulletIsEdit.value = true
bulletForm.value = {
id: row.id,
text: row.text ?? '',
sort_order: Number(row.sort_order ?? 0),
highlight: !!row.highlight
}
showBulletDlg.value = true
}
function validateBullet () {
const t = String(bulletForm.value.text || '').trim()
if (!t) {
toast.add({ severity: 'warn', summary: 'Atenção', detail: 'Informe o texto do benefício.', life: 3000 })
return false
}
bulletForm.value.text = t
bulletForm.value.sort_order = Number(bulletForm.value.sort_order ?? 0)
return true
}
async function saveBullet () {
if (!selected.value?.plan_id) return
if (!validateBullet()) return
bulletSaving.value = true
try {
const payload = {
plan_id: selected.value.plan_id,
text: bulletForm.value.text,
sort_order: bulletForm.value.sort_order,
highlight: !!bulletForm.value.highlight
}
if (bulletIsEdit.value) {
const { error } = await supabase
.from('plan_public_bullets')
.update(payload)
.eq('id', bulletForm.value.id)
if (error) throw error
toast.add({ severity: 'success', summary: 'Ok', detail: 'Benefício atualizado.', life: 2200 })
} else {
const { error } = await supabase
.from('plan_public_bullets')
.insert(payload)
if (error) throw error
toast.add({ severity: 'success', summary: 'Ok', detail: 'Benefício adicionado.', life: 2200 })
}
showBulletDlg.value = false
await fetchBullets(selected.value.plan_id)
await fetchAll()
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e.message || String(e), life: 4500 })
} finally {
bulletSaving.value = false
}
}
function askDeleteBullet (row) {
confirm.require({
message: 'Excluir este benefício?',
header: 'Confirmar exclusão',
icon: 'pi pi-exclamation-triangle',
acceptClass: 'p-button-danger',
accept: () => doDeleteBullet(row)
})
}
async function doDeleteBullet (row) {
const { error } = await supabase
.from('plan_public_bullets')
.delete()
.eq('id', row.id)
if (error) {
toast.add({ severity: 'error', summary: 'Erro', detail: error.message, life: 4500 })
return
}
toast.add({ severity: 'success', summary: 'Ok', detail: 'Benefício removido.', life: 2200 })
await fetchBullets(selected.value.plan_id)
await fetchAll()
}
onMounted(fetchAll)
</script>
<template>
<Toast />
<ConfirmDialog />
<div class="p-4">
<Toolbar class="mb-4">
<template #start>
<div class="flex flex-col">
<div class="text-xl font-semibold leading-none">Vitrine de Planos</div>
<small class="text-color-secondary mt-1">
Configure como os planos aparecem na página pública (nome, descrição, badge, ordem e benefícios).
</small>
</div>
</template>
<template #end>
<div class="flex items-center gap-2 w-full md:w-auto">
<div class="w-full md:w-80">
<FloatLabel variant="on" class="w-full">
<IconField class="w-full">
<InputIcon class="pi pi-search" />
<InputText v-model="q" id="plans_public_search" class="w-full pr-10" variant="filled" />
</IconField>
<label for="plans_public_search">Buscar plano</label>
</FloatLabel>
</div>
<Button label="Recarregar" icon="pi pi-refresh" severity="secondary" outlined :loading="loading" @click="fetchAll" />
</div>
</template>
</Toolbar>
<!-- Popover global (reutilizado) -->
<Popover ref="bulletsPop">
<div class="w-[340px] max-w-[80vw]">
<div class="text-sm font-semibold mb-2">{{ popPlanTitle }}</div>
<div v-if="!popBullets?.length" class="text-sm text-color-secondary">
Nenhum benefício configurado.
</div>
<ul v-else class="m-0 pl-4 space-y-2">
<li v-for="b in popBullets" :key="b.id" class="text-sm leading-snug">
<span :class="b.highlight ? 'font-semibold' : ''">
{{ b.text }}
</span>
<small v-if="b.highlight" class="ml-2 text-color-secondary">(destaque)</small>
</li>
</ul>
</div>
</Popover>
<DataTable :value="filteredRows" dataKey="plan_id" :loading="loading" stripedRows responsiveLayout="scroll">
<Column header="Plano" style="min-width: 18rem">
<template #body="{ data }">
<div class="flex flex-col">
<span class="font-semibold">{{ data.public_name || data.plan_name || data.plan_key }}</span>
<small class="text-color-secondary">
{{ data.plan_key }} {{ data.plan_name || '—' }}
</small>
</div>
</template>
</Column>
<Column header="Mensal" style="width: 12rem">
<template #body="{ data }">
<span class="font-medium">{{ formatBRLFromCents(data.monthly_cents) }}</span>
</template>
</Column>
<Column header="Anual" style="width: 12rem">
<template #body="{ data }">
<span class="font-medium">{{ formatBRLFromCents(data.yearly_cents) }}</span>
</template>
</Column>
<Column field="badge" header="Badge" style="min-width: 12rem" />
<Column header="Visível" style="width: 8rem">
<template #body="{ data }">
<span>{{ data.is_visible ? 'Sim' : 'Não' }}</span>
</template>
</Column>
<Column header="Destaque" style="width: 9rem">
<template #body="{ data }">
<span>{{ data.is_featured ? 'Sim' : 'Não' }}</span>
</template>
</Column>
<Column field="sort_order" header="Ordem" style="width: 8rem" />
<Column header="Ações" style="width: 14rem">
<template #body="{ data }">
<div class="flex gap-2 justify-end">
<!-- Popover bullets: ícone + quantidade -->
<Button
severity="secondary"
outlined
size="small"
@click="(e) => openBulletsPopover(e, data)"
>
<i class="pi pi-list mr-2" />
<span class="font-medium">{{ data.bullets?.length || 0 }}</span>
</Button>
<Button icon="pi pi-pencil" severity="secondary" outlined size="small" @click="openEdit(data)" />
</div>
</template>
</Column>
</DataTable>
<!-- PREVIEW PÚBLICO (estilo PrimeBlocks) -->
<div class="mt-10">
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-6 md:p-10 overflow-hidden">
<!-- topo "happy customers" + título -->
<div class="flex flex-col items-center text-center">
<div class="flex items-center gap-3 mb-4">
<AvatarGroup>
<Avatar image="https://fqjltiegiezfetthbags.supabase.co/storage/v1/render/image/public/block.images/blocks/avatars/circle/avatar-m-1.png" shape="circle" />
<Avatar image="https://fqjltiegiezfetthbags.supabase.co/storage/v1/render/image/public/block.images/blocks/avatars/circle/avatar-f-21.png" shape="circle" />
<Avatar image="https://fqjltiegiezfetthbags.supabase.co/storage/v1/render/image/public/block.images/blocks/avatars/circle/avatar-f-1.png" shape="circle" />
<Avatar image="https://fqjltiegiezfetthbags.supabase.co/storage/v1/render/image/public/block.images/blocks/avatars/circle/avatar-m-3.png" shape="circle" />
</AvatarGroup>
<Divider layout="vertical" />
<span class="text-sm text-color-secondary font-medium">Happy Customers</span>
</div>
<h2 class="text-3xl md:text-5xl font-semibold leading-tight">
Get a plan and<br />
increase your efficiency
</h2>
<p class="text-color-secondary mt-3 max-w-2xl">
Optimize your workflow and boost productivity by choosing the right plan tailored to your business needs.
</p>
<!-- Toggle Monthly / Yearly -->
<div class="mt-6 inline-flex items-center rounded-xl border border-[var(--surface-border)] bg-[var(--surface-50)] p-1">
<Button
label="Mensal"
size="small"
:severity="billingInterval === 'month' ? 'success' : 'secondary'"
:outlined="billingInterval !== 'month'"
@click="billingInterval = 'month'"
/>
<Button
label="Anual"
size="small"
:severity="billingInterval === 'year' ? 'success' : 'secondary'"
:outlined="billingInterval !== 'year'"
class="ml-1"
@click="billingInterval = 'year'"
/>
</div>
</div>
<!-- cards -->
<div class="mt-10 grid grid-cols-1 md:grid-cols-3 gap-6">
<div
v-for="p in previewPlans"
:key="p.plan_id"
:class="[
'relative rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] overflow-hidden',
'shadow-sm transition-transform',
p.is_featured ? 'md:-translate-y-2 md:scale-[1.02] ring-1 ring-emerald-500/25' : ''
]"
>
<!-- franja/topo decorativo (bem leve) -->
<div class="h-3 w-full opacity-40 bg-[var(--surface-100)]" />
<div class="p-6">
<!-- badge do plano -->
<div class="flex items-center justify-between gap-3">
<Badge
:value="p.badge || (p.is_featured ? 'Popular' : '')"
:severity="p.is_featured ? 'success' : 'secondary'"
v-if="p.badge || p.is_featured"
/>
<span class="text-xs text-color-secondary">{{ p.plan_key }}</span>
</div>
<!-- preço -->
<div class="mt-4 text-4xl font-semibold leading-none">
{{ formatBRLFromCents(priceCentsFor(p, billingInterval)) }}
</div>
<p class="text-color-secondary mt-3 min-h-[44px]">
{{ p.public_description || '—' }}
</p>
<Button
class="mt-5 w-full"
:label="p.is_featured ? 'Começar agora!' : 'Selecionar este'"
:severity="p.is_featured ? 'success' : 'secondary'"
:outlined="!p.is_featured"
/>
<!-- divisória pontilhada conceitual -->
<div class="mt-6">
<Divider type="dashed" />
</div>
<!-- bullets -->
<ul v-if="p.bullets?.length" class="mt-4 space-y-2">
<li v-for="b in p.bullets" :key="b.id" class="flex items-start gap-2">
<i class="pi pi-check mt-1 text-sm"></i>
<span :class="['text-sm leading-snug', b.highlight ? 'font-semibold' : '']">
{{ b.text }}
</span>
</li>
</ul>
<div v-else class="mt-4 text-sm text-color-secondary">
Nenhum benefício configurado.
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Dialog principal -->
<Dialog v-model:visible="showDlg" modal header="Editar vitrine" :style="{ width: '820px' }">
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="flex flex-col gap-4">
<div>
<label class="block mb-2">Nome público</label>
<InputText v-model="form.public_name" class="w-full" placeholder="ex.: Profissional" />
</div>
<div>
<label class="block mb-2">Descrição pública</label>
<Textarea
v-model="form.public_description"
class="w-full"
:autoResize="true"
rows="3"
placeholder="Uma frase curta e clara..."
/>
</div>
<div>
<label class="block mb-2">Badge</label>
<InputText v-model="form.badge" class="w-full" placeholder="ex.: Mais popular (opcional)" />
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block mb-2">Ordem</label>
<InputNumber v-model="form.sort_order" class="w-full" inputClass="w-full" />
</div>
<div class="flex flex-col gap-3 pt-2">
<div class="flex items-center gap-2">
<Checkbox v-model="form.is_visible" :binary="true" />
<label>Visível no público</label>
</div>
<div class="flex items-center gap-2">
<Checkbox v-model="form.is_featured" :binary="true" />
<label>Destaque</label>
</div>
</div>
</div>
</div>
<!-- bullets -->
<div>
<div class="flex items-center justify-between mb-3">
<div class="font-semibold">Benefícios (bullets)</div>
<Button label="Adicionar" icon="pi pi-plus" size="small" @click="openBulletCreate" />
</div>
<DataTable :value="bullets" dataKey="id" stripedRows responsiveLayout="scroll">
<Column field="text" header="Texto" />
<Column field="sort_order" header="Ordem" style="width: 7rem" />
<Column header="Destaque" style="width: 8rem">
<template #body="{ data }">
<span>{{ data.highlight ? 'Sim' : 'Não' }}</span>
</template>
</Column>
<Column header="Ações" style="width: 9rem">
<template #body="{ data }">
<div class="flex gap-2">
<Button icon="pi pi-pencil" severity="secondary" outlined size="small" @click="openBulletEdit(data)" />
<Button icon="pi pi-trash" severity="danger" outlined size="small" @click="askDeleteBullet(data)" />
</div>
</template>
</Column>
</DataTable>
</div>
</div>
<template #footer>
<Button label="Cancelar" severity="secondary" outlined @click="showDlg = false" />
<Button label="Salvar" icon="pi pi-check" :loading="saving" @click="save" />
</template>
</Dialog>
<!-- Dialog bullet -->
<Dialog v-model:visible="showBulletDlg" modal :header="bulletIsEdit ? 'Editar benefício' : 'Novo benefício'" :style="{ width: '560px' }">
<div class="flex flex-col gap-4">
<div>
<label class="block mb-2">Texto</label>
<Textarea
v-model="bulletForm.text"
class="w-full"
:autoResize="true"
rows="3"
placeholder="ex.: Agendamento online com página pública"
/>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block mb-2">Ordem</label>
<InputNumber v-model="bulletForm.sort_order" class="w-full" inputClass="w-full" />
</div>
<div class="flex items-center gap-2 pt-7">
<Checkbox v-model="bulletForm.highlight" :binary="true" />
<label>Destaque</label>
</div>
</div>
</div>
<template #footer>
<Button label="Cancelar" severity="secondary" outlined @click="showBulletDlg = false" />
<Button :label="bulletIsEdit ? 'Salvar' : 'Criar'" icon="pi pi-check" :loading="bulletSaving" @click="saveBullet" />
</template>
</Dialog>
</div>
</template>

View File

@@ -0,0 +1,217 @@
<script setup>
import { ref, onMounted, computed } from 'vue'
import { useRoute } from 'vue-router'
import { supabase } from '@/lib/supabase/client'
import Toolbar from 'primevue/toolbar'
import Button from 'primevue/button'
import DataTable from 'primevue/datatable'
import Column from 'primevue/column'
import Toast from 'primevue/toast'
import { useToast } from 'primevue/usetoast'
import InputText from 'primevue/inputtext'
import Tag from 'primevue/tag'
const route = useRoute()
const toast = useToast()
const loading = ref(false)
const events = ref([])
const plans = ref([])
const q = ref('')
const planKeyById = computed(() => {
const m = new Map()
for (const p of plans.value) m.set(p.id, p.key)
return m
})
function planKey (planId) {
if (!planId) return '—'
return planKeyById.value.get(planId) || planId // fallback pro uuid se não achou
}
function typeLabel (t) {
if (t === 'plan_changed') return 'Plano alterado'
return t || '—'
}
function typeSeverity (t) {
if (t === 'plan_changed') return 'info'
return 'secondary'
}
function formatWhen (iso) {
if (!iso) return '—'
try {
return new Date(iso).toLocaleString('pt-BR')
} catch {
return iso
}
}
const profiles = ref([])
const profileById = computed(() => {
const m = new Map()
for (const p of profiles.value) m.set(p.id, p)
return m
})
function displayUser (userId) {
if (!userId) return '—'
const p = profileById.value.get(userId)
if (!p) return userId
// tenta achar campos comuns sem assumir
const name =
p.nome || p.name || p.full_name || p.display_name || p.username || null
const email =
p.email || p.email_principal || p.user_email || null
if (name && email) return `${name} <${email}>`
if (name) return name
if (email) return email
return userId
}
async function fetchAll () {
loading.value = true
try {
const [{ data: p, error: ep }, { data: e, error: ee }] = await Promise.all([
supabase.from('plans').select('id,key'),
supabase
.from('subscription_events')
.select('*')
.order('created_at', { ascending: false })
.limit(500)
])
if (ep) throw ep
if (ee) throw ee
plans.value = p || []
events.value = e || []
// pega ids únicos para buscar profiles
const ids = new Set()
for (const ev of events.value) {
if (ev.owner_id) ids.add(ev.owner_id)
if (ev.created_by) ids.add(ev.created_by)
}
if (ids.size) {
const { data: pr, error: epr } = await supabase
.from('profiles')
.select('*')
.in('id', Array.from(ids))
// se profiles tiver RLS restrito, pode falhar; aí só cai no fallback UUID
if (!epr) profiles.value = pr || []
} else {
profiles.value = []
}
} catch (err) {
toast.add({ severity: 'error', summary: 'Erro', detail: err.message || String(err), life: 5000 })
} finally {
loading.value = false
}
}
const filtered = computed(() => {
const term = String(q.value || '').trim().toLowerCase()
if (!term) return events.value
return events.value.filter(ev => {
const oldKey = planKey(ev.old_plan_id)
const newKey = planKey(ev.new_plan_id)
return (
String(ev.owner_id || '').toLowerCase().includes(term) ||
String(ev.subscription_id || '').toLowerCase().includes(term) ||
String(ev.event_type || '').toLowerCase().includes(term) ||
String(oldKey || '').toLowerCase().includes(term) ||
String(newKey || '').toLowerCase().includes(term)
)
})
})
onMounted(async () => {
const initialQ = route.query?.q
if (typeof initialQ === 'string' && initialQ.trim()) q.value = initialQ.trim()
await fetchAll()
})
</script>
<template>
<Toast />
<div class="p-4">
<Toolbar class="mb-4">
<template #start>
<div class="flex flex-col">
<div class="text-xl font-semibold leading-none">Histórico de Planos</div>
<small class="text-color-secondary mt-1">
Auditoria das mudanças de plano (eventos). Read-only.
</small>
</div>
</template>
<template #end>
<div class="flex items-center gap-2">
<span class="p-input-icon-left">
<i class="pi pi-search" />
<InputText v-model="q" placeholder="Buscar owner, subscription, plano, tipo..." />
</span>
<Button label="Recarregar" icon="pi pi-refresh" severity="secondary" outlined :loading="loading" @click="fetchAll" />
</div>
</template>
</Toolbar>
<DataTable :value="filtered" dataKey="id" :loading="loading" stripedRows responsiveLayout="scroll">
<Column header="Quando" style="min-width: 14rem">
<template #body="{ data }">
{{ formatWhen(data.created_at) }}
</template>
</Column>
<Column header="Owner" style="min-width: 22rem">
<template #body="{ data }">
{{ displayUser(data.owner_id) }}
</template>
</Column>
<Column header="Evento" style="min-width: 12rem">
<template #body="{ data }">
<Tag :value="typeLabel(data.event_type)" :severity="typeSeverity(data.event_type)" />
</template>
</Column>
<Column header="De → Para" style="min-width: 18rem">
<template #body="{ data }">
<div class="flex items-center gap-2">
<Tag :value="planKey(data.old_plan_id)" severity="secondary" />
<i class="pi pi-arrow-right text-color-secondary" />
<Tag :value="planKey(data.new_plan_id)" severity="success" />
</div>
</template>
</Column>
<Column field="subscription_id" header="Subscription" style="min-width: 22rem" />
<Column header="Alterado por" style="min-width: 22rem">
<template #body="{ data }">
{{ displayUser(data.created_by) }}
</template>
</Column>
</DataTable>
<div class="text-color-secondary mt-3 text-sm">
Mostrando até 500 eventos mais recentes.
</div>
</div>
</template>

View File

@@ -0,0 +1,231 @@
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { supabase } from '@/lib/supabase/client'
import { useToast } from 'primevue/usetoast'
import Toolbar from 'primevue/toolbar'
import Button from 'primevue/button'
import Toast from 'primevue/toast'
import DataTable from 'primevue/datatable'
import Column from 'primevue/column'
import Tag from 'primevue/tag'
import InputText from 'primevue/inputtext'
import Divider from 'primevue/divider'
const router = useRouter()
const toast = useToast()
const loading = ref(false)
const fixing = ref(false)
const fixingOwner = ref(null)
const rows = ref([])
const q = ref('')
const total = computed(() => rows.value.length)
const totalMissing = computed(() => rows.value.filter(r => r.mismatch_type === 'missing_entitlement').length)
const totalUnexpected = computed(() => rows.value.filter(r => r.mismatch_type === 'unexpected_entitlement').length)
function severityForMismatch (t) {
if (t === 'missing_entitlement') return 'danger'
if (t === 'unexpected_entitlement') return 'warning'
return 'secondary'
}
function labelForMismatch (t) {
if (t === 'missing_entitlement') return 'Missing'
if (t === 'unexpected_entitlement') return 'Unexpected'
return t || '-'
}
async function fetchAll () {
loading.value = true
try {
const { data, error } = await supabase
.from('v_subscription_feature_mismatch')
.select('*')
if (error) throw error
rows.value = data || []
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e.message || String(e), life: 5000 })
} finally {
loading.value = false
}
}
function filteredRows () {
const term = String(q.value || '').trim().toLowerCase()
if (!term) return rows.value
return rows.value.filter(r =>
String(r.owner_id || '').toLowerCase().includes(term) ||
String(r.feature_key || '').toLowerCase().includes(term) ||
String(r.mismatch_type || '').toLowerCase().includes(term)
)
}
function openOwnerSubscriptions (ownerId) {
if (!ownerId) return
router.push({ path: '/saas/subscriptions', query: { q: ownerId } })
}
async function fixOwner (ownerId) {
if (!ownerId) return
fixing.value = true
fixingOwner.value = ownerId
try {
const { error } = await supabase.rpc('rebuild_owner_entitlements', {
p_owner_id: ownerId
})
if (error) throw error
toast.add({
severity: 'success',
summary: 'Corrigido',
detail: 'Entitlements reconstruídos para este owner.',
life: 2500
})
await fetchAll()
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e.message || String(e), life: 5000 })
} finally {
fixing.value = false
fixingOwner.value = null
}
}
async function fixAll () {
fixing.value = true
try {
const { error } = await supabase.rpc('fix_all_subscription_mismatches')
if (error) throw error
toast.add({
severity: 'success',
summary: 'Sistema corrigido',
detail: 'Entitlements reconstruídos para todos os owners com divergência.',
life: 3000
})
await fetchAll()
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e.message || String(e), life: 5000 })
} finally {
fixing.value = false
}
}
onMounted(fetchAll)
</script>
<template>
<Toast />
<div class="p-4">
<Toolbar class="mb-4">
<template #start>
<div class="flex flex-col">
<div class="text-xl font-semibold leading-none">Subscription Health</div>
<small class="text-color-secondary mt-1">
Divergências entre Plano (esperado) e Entitlements (atual). Use Fix para reconstruir.
</small>
</div>
</template>
<template #end>
<div class="flex items-center gap-2 flex-wrap justify-end">
<span class="p-input-icon-left">
<i class="pi pi-search" />
<InputText v-model="q" placeholder="Buscar owner_id, feature_key..." />
</span>
<Button
label="Recarregar"
icon="pi pi-refresh"
severity="secondary"
outlined
:loading="loading"
@click="fetchAll"
/>
<Button
v-if="total > 0"
label="Fix All"
icon="pi pi-refresh"
severity="danger"
:loading="fixing"
@click="fixAll"
/>
</div>
</template>
</Toolbar>
<!-- resumo conceitual -->
<div class="surface-100 border-round p-3 mb-4">
<div class="flex flex-wrap gap-2 items-center justify-content-between">
<div class="flex gap-2 items-center flex-wrap">
<Tag :value="`Total: ${total}`" severity="secondary" />
<Tag :value="`Missing: ${totalMissing}`" severity="danger" />
<Tag :value="`Unexpected: ${totalUnexpected}`" severity="warning" />
</div>
<div class="text-color-secondary text-sm">
Missing = plano exige, mas não está ativo · Unexpected = ativo sem constar no plano
</div>
</div>
</div>
<DataTable
:value="filteredRows()"
dataKey="owner_id"
:loading="loading"
stripedRows
responsiveLayout="scroll"
sortField="owner_id"
:sortOrder="1"
>
<Column field="owner_id" header="Owner" style="min-width: 22rem" />
<Column field="feature_key" header="Feature" style="min-width: 18rem" />
<Column header="Tipo" style="width: 12rem">
<template #body="{ data }">
<Tag :severity="severityForMismatch(data.mismatch_type)" :value="labelForMismatch(data.mismatch_type)" />
</template>
</Column>
<Column header="Ações" style="min-width: 18rem">
<template #body="{ data }">
<div class="flex gap-2 flex-wrap">
<Button
icon="pi pi-external-link"
severity="secondary"
outlined
v-tooltip.top="'Abrir subscriptions deste owner (filtro)'"
@click="openOwnerSubscriptions(data.owner_id)"
/>
<Button
label="Fix owner"
icon="pi pi-wrench"
severity="danger"
outlined
:loading="fixing && fixingOwner === data.owner_id"
@click="fixOwner(data.owner_id)"
/>
</div>
</template>
</Column>
</DataTable>
<Divider class="my-5" />
<div class="text-color-secondary text-sm">
Dica: se você trocar plano e o cliente não refletir de imediato, essa página te mostra exatamente o que ficou divergente.
</div>
</div>
</template>

View File

@@ -0,0 +1,271 @@
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { supabase } from '@/lib/supabase/client'
import Toolbar from 'primevue/toolbar'
import Button from 'primevue/button'
import DataTable from 'primevue/datatable'
import Column from 'primevue/column'
import Select from 'primevue/select'
import Toast from 'primevue/toast'
import { useToast } from 'primevue/usetoast'
import InputText from 'primevue/inputtext'
import Tag from 'primevue/tag'
const toast = useToast()
const router = useRouter()
const route = useRoute()
const loading = ref(false)
const savingId = ref(null)
const plans = ref([])
const subs = ref([])
const q = ref('')
const isFocused = computed(() => {
return typeof route.query?.q === 'string' && route.query.q.trim().length > 0
})
async function fetchAll () {
loading.value = true
try {
const [{ data: p, error: ep }, { data: s, error: es }] = await Promise.all([
supabase.from('plans').select('*').order('key', { ascending: true }),
supabase.from('subscriptions').select('*').order('updated_at', { ascending: false })
])
if (ep) throw ep
if (es) throw es
plans.value = p || []
subs.value = s || []
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e.message || String(e), life: 5000 })
} finally {
loading.value = false
}
}
function planKey (planId) {
const p = plans.value.find(x => x.id === planId)
return p?.key || '(sem plano)'
}
function severityForPlan (key) {
if (!key) return 'secondary'
if (key === 'free') return 'secondary'
if (key === 'pro') return 'success'
return 'info'
}
async function updatePlan (subRow, nextPlanId) {
const prev = subRow.plan_id
subRow.plan_id = nextPlanId
savingId.value = subRow.id
try {
const { data, error } = await supabase.rpc('change_subscription_plan', {
p_subscription_id: subRow.id,
p_new_plan_id: nextPlanId
})
if (error) throw error
if (data?.plan_id) subRow.plan_id = data.plan_id
toast.add({
severity: 'success',
summary: 'Ok',
detail: 'Plano atualizado (transação + histórico).',
life: 2500
})
} catch (e) {
subRow.plan_id = prev
toast.add({ severity: 'error', summary: 'Erro', detail: e.message || String(e), life: 5000 })
} finally {
savingId.value = null
}
}
async function cancelSubscription(row) {
savingId.value = row.id
try {
const { error } = await supabase.rpc('cancel_subscription', {
p_subscription_id: row.id
})
if (error) throw error
toast.add({ severity: 'success', summary: 'Cancelada', life: 2500 })
await fetchAll()
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e.message })
} finally {
savingId.value = null
}
}
async function reactivateSubscription(row) {
savingId.value = row.id
try {
const { error } = await supabase.rpc('reactivate_subscription', {
p_subscription_id: row.id
})
if (error) throw error
toast.add({ severity: 'success', summary: 'Reativada', life: 2500 })
await fetchAll()
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e.message })
} finally {
savingId.value = null
}
}
function goToSubscriptionsForOwner (ownerId) {
if (!ownerId) return
router.push({ path: '/saas/subscriptions', query: { q: ownerId } })
}
function filteredSubs () {
const term = String(q.value || '').trim().toLowerCase()
if (!term) return subs.value
return subs.value.filter(s =>
String(s.owner_id || '').toLowerCase().includes(term) ||
String(s.status || '').toLowerCase().includes(term) ||
String(planKey(s.plan_id)).toLowerCase().includes(term)
)
}
onMounted(async () => {
const initialQ = route.query?.q
if (typeof initialQ === 'string' && initialQ.trim()) {
q.value = initialQ.trim()
}
await fetchAll()
})
</script>
<template>
<Toast />
<div class="p-4">
<!-- Header foco -->
<div v-if="isFocused" class="mb-3 p-3 surface-100 border-round">
<div class="flex align-items-center justify-content-between">
<div>
<div class="text-lg font-semibold">
Subscription em foco
</div>
<small class="text-color-secondary">
Owner: {{ route.query.q }}
</small>
</div>
<Button
label="Limpar filtro"
icon="pi pi-times"
severity="secondary"
outlined
@click="$router.push('/saas/subscriptions')"
/>
</div>
</div>
<Toolbar class="mb-4">
<template #start>
<div class="flex flex-col">
<div class="text-xl font-semibold leading-none">Subscriptions</div>
<small class="text-color-secondary mt-1">
Painel operacional SaaS.
</small>
</div>
</template>
<template #end>
<div class="flex items-center gap-2">
<span class="p-input-icon-left">
<i class="pi pi-search" />
<InputText v-model="q" placeholder="Buscar por owner_id, status, plano..." />
</span>
<Button label="Recarregar" icon="pi pi-refresh" severity="secondary" outlined :loading="loading" @click="fetchAll" />
</div>
</template>
</Toolbar>
<DataTable :value="filteredSubs()" dataKey="id" :loading="loading" stripedRows responsiveLayout="scroll">
<Column field="owner_id" header="Owner" style="min-width: 22rem" />
<Column header="Plano" style="min-width: 14rem">
<template #body="{ data }">
<div class="flex items-center gap-2">
<Tag :value="planKey(data.plan_id)" :severity="severityForPlan(planKey(data.plan_id))" />
<Select
:modelValue="data.plan_id"
:options="plans"
optionLabel="key"
optionValue="id"
placeholder="Selecione..."
class="w-14rem"
:disabled="savingId === data.id"
@update:modelValue="(val) => updatePlan(data, val)"
/>
</div>
</template>
</Column>
<Column header="Período" style="min-width: 16rem">
<template #body="{ data }">
<div>
<div>{{ data.current_period_start || '-' }}</div>
<small class="text-color-secondary">
até {{ data.current_period_end || '-' }}
</small>
</div>
</template>
</Column>
<Column field="status" header="Status" style="width: 10rem" />
<Column field="updated_at" header="Atualizado" style="min-width: 14rem" />
<Column header="Ações" style="min-width: 14rem">
<template #body="{ data }">
<div class="flex gap-2 flex-wrap">
<Button
icon="pi pi-history"
severity="secondary"
outlined
@click="$router.push('/saas/subscription-events?q=' + data.owner_id)"
/>
<Button
v-if="data.status === 'active'"
icon="pi pi-ban"
severity="danger"
outlined
:loading="savingId === data.id"
@click="cancelSubscription(data)"
/>
<Button
v-if="data.status !== 'active'"
icon="pi pi-refresh"
severity="success"
outlined
:loading="savingId === data.id"
@click="reactivateSubscription(data)"
/>
</div>
</template>
</Column>
</DataTable>
</div>
</template>

View File

@@ -0,0 +1,290 @@
<script setup>
import { computed, onMounted, ref } from 'vue'
import { useToast } from 'primevue/usetoast'
import Card from 'primevue/card'
import Button from 'primevue/button'
import DataTable from 'primevue/datatable'
import Column from 'primevue/column'
import Tag from 'primevue/tag'
import Dialog from 'primevue/dialog'
import InputText from 'primevue/inputtext'
import Dropdown from 'primevue/dropdown'
import FloatLabel from 'primevue/floatlabel'
import Textarea from 'primevue/textarea'
import Divider from 'primevue/divider'
import {
listSubscriptionIntents,
markIntentPaid,
cancelIntent
} from '@/services/subscriptionIntents'
const toast = useToast()
const loading = ref(false)
const rows = ref([])
// filtros (FloatLabel)
const q = ref('')
const status = ref(null)
const planKey = ref(null)
const interval = ref(null)
const statusOptions = [
{ label: 'Novo', value: 'new' },
{ label: 'Aguardando pagamento', value: 'waiting_payment' },
{ label: 'Pago', value: 'paid' },
{ label: 'Cancelado', value: 'canceled' }
]
const intervalOptions = [
{ label: 'Mensal', value: 'month' },
{ label: 'Anual', value: 'year' }
]
const planOptions = computed(() => {
const keys = Array.from(new Set((rows.value || []).map(r => r.plan_key).filter(Boolean)))
return keys.map(k => ({ label: k, value: k }))
})
function moneyBRL(cents) {
if (cents == null) return '—'
return (Number(cents) / 100).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' })
}
function fmtDate(iso) {
if (!iso) return '—'
const d = new Date(iso)
return d.toLocaleString('pt-BR')
}
function statusSeverity(s) {
if (s === 'paid') return 'success'
if (s === 'new') return 'info'
if (s === 'waiting_payment') return 'warning'
if (s === 'canceled') return 'danger'
return 'secondary'
}
async function refresh() {
loading.value = true
try {
rows.value = await listSubscriptionIntents({
q: q.value,
status: status.value,
planKey: planKey.value,
interval: interval.value
})
} catch (e) {
console.error(e)
toast.add({ severity: 'error', summary: 'Erro', detail: e.message || 'Falha ao carregar.', life: 4000 })
} finally {
loading.value = false
}
}
onMounted(refresh)
// dialog detalhes/ação
const showDialog = ref(false)
const selected = ref(null)
const actionMode = ref(null) // 'paid' | 'canceled'
const notes = ref('')
function openAction(row, mode) {
selected.value = row
actionMode.value = mode
notes.value = ''
showDialog.value = true
}
async function confirmAction() {
if (!selected.value) return
try {
if (actionMode.value === 'paid') {
const res = await markIntentPaid(selected.value.id, notes.value)
const plan = res?.subscription?.plan_key || selected.value.plan_key || '—'
const intervalLabel = (res?.subscription?.interval || selected.value.interval) === 'year'
? 'Anual'
: (res?.subscription?.interval || selected.value.interval) === 'month'
? 'Mensal'
: (res?.subscription?.interval || selected.value.interval)
toast.add({
severity: 'success',
summary: 'OK',
detail: `Pago confirmado. Assinatura ativada: ${plan} (${intervalLabel}).`,
life: 3000
})
} else if (actionMode.value === 'canceled') {
await cancelIntent(selected.value.id, notes.value)
toast.add({
severity: 'warn',
summary: 'OK',
detail: 'Cancelado.',
life: 2500
})
}
showDialog.value = false
await refresh()
} catch (e) {
console.error(e)
toast.add({
severity: 'error',
summary: 'Erro',
detail: e?.message || 'Falha ao atualizar.',
life: 4000
})
}
}
</script>
<template>
<div class="p-4 md:p-6 lg:p-8">
<Card class="overflow-hidden">
<template #content>
<div class="flex items-start justify-between gap-3">
<div>
<div class="text-2xl font-semibold">Intenções de assinatura</div>
<div class="text-sm text-[var(--text-color-secondary)] mt-1">
Caixa de entrada do pagamento manual (PIX/boleto). Aqui você marca como pago e avança o fluxo.
</div>
</div>
<div class="flex gap-2">
<Button icon="pi pi-refresh" severity="secondary" outlined label="Atualizar" @click="refresh" :loading="loading" />
</div>
</div>
<Divider class="my-4" />
<!-- filtros (FloatLabel) -->
<div class="grid grid-cols-12 gap-3">
<div class="col-span-12 md:col-span-5">
<FloatLabel>
<InputText v-model="q" class="w-full" @keyup.enter="refresh" />
<label>Buscar por email</label>
</FloatLabel>
</div>
<div class="col-span-12 md:col-span-3">
<FloatLabel>
<Dropdown v-model="status" :options="statusOptions" optionLabel="label" optionValue="value" class="w-full" showClear />
<label>Status</label>
</FloatLabel>
</div>
<div class="col-span-12 md:col-span-2">
<FloatLabel>
<Dropdown v-model="interval" :options="intervalOptions" optionLabel="label" optionValue="value" class="w-full" showClear />
<label>Intervalo</label>
</FloatLabel>
</div>
<div class="col-span-12 md:col-span-2">
<FloatLabel>
<Dropdown v-model="planKey" :options="planOptions" optionLabel="label" optionValue="value" class="w-full" showClear />
<label>Plano</label>
</FloatLabel>
</div>
<div class="col-span-12">
<Button label="Aplicar filtros" icon="pi pi-filter" class="w-full md:w-auto" @click="refresh" />
</div>
</div>
<Divider class="my-4" />
<DataTable :value="rows" :loading="loading" paginator :rows="20" class="text-sm">
<Column field="created_at" header="Criado em" :sortable="true">
<template #body="{ data }">{{ fmtDate(data.created_at) }}</template>
</Column>
<Column field="email" header="Email" :sortable="true" />
<Column field="plan_key" header="Plano" :sortable="true" />
<Column field="interval" header="Intervalo" :sortable="true">
<template #body="{ data }">
<span>{{ data.interval === 'year' ? 'Anual' : data.interval === 'month' ? 'Mensal' : data.interval }}</span>
</template>
</Column>
<Column field="amount_cents" header="Valor" :sortable="true" style="width: 160px">
<template #body="{ data }">{{ moneyBRL(data.amount_cents) }}</template>
</Column>
<Column field="status" header="Status" :sortable="true" style="width: 140px">
<template #body="{ data }">
<Tag :severity="statusSeverity(data.status)" :value="data.status" />
</template>
</Column>
<Column header="Ações" style="width: 220px">
<template #body="{ data }">
<div class="flex gap-2">
<Button
label="Pago"
icon="pi pi-check"
size="small"
severity="success"
:disabled="data.status === 'paid'"
@click="openAction(data, 'paid')"
/>
<Button
label="Cancelar"
icon="pi pi-times"
size="small"
severity="danger"
outlined
:disabled="data.status === 'canceled'"
@click="openAction(data, 'canceled')"
/>
</div>
</template>
</Column>
</DataTable>
</template>
</Card>
<Dialog v-model:visible="showDialog" modal header="Confirmar ação" :style="{ width: '520px' }">
<div v-if="selected" class="text-sm">
<div class="mb-3">
<div class="font-semibold">{{ selected.email }}</div>
<div class="text-[var(--text-color-secondary)]">
Plano: {{ selected.plan_key }} Intervalo: {{ selected.interval }} Valor: {{ moneyBRL(selected.amount_cents) }}
</div>
</div>
<FloatLabel class="w-full">
<Textarea v-model="notes" rows="3" class="w-full" autoResize />
<label>Notas (opcional)</label>
</FloatLabel>
<div class="flex justify-end gap-2 mt-4">
<Button label="Voltar" severity="secondary" outlined @click="showDialog = false" />
<Button
v-if="actionMode === 'paid'"
label="Marcar como pago"
icon="pi pi-check"
severity="success"
@click="confirmAction"
/>
<Button
v-else
label="Cancelar"
icon="pi pi-times"
severity="danger"
@click="confirmAction"
/>
</div>
</div>
</Dialog>
</div>
</template>

View File

@@ -0,0 +1,9 @@
<template>
<div class="p-4">
<h1>Online Scheduling Terapeuta (Therapist) (Manage)</h1>
</div>
</template>
<script setup>
// placeholder para futura implementação
</script>

View File

@@ -0,0 +1,23 @@
<script setup>
import BestSellingWidget from '@/components/dashboard/BestSellingWidget.vue';
import NotificationsWidget from '@/components/dashboard/NotificationsWidget.vue';
import RecentSalesWidget from '@/components/dashboard/RecentSalesWidget.vue';
import RevenueStreamWidget from '@/components/dashboard/RevenueStreamWidget.vue';
import StatsWidget from '@/components/dashboard/StatsWidget.vue';
</script>
<template>
THERAPIST DASHBOARD
<div class="grid grid-cols-12 gap-8">
<StatsWidget />
<div class="col-span-12 xl:col-span-6">
<RecentSalesWidget />
<BestSellingWidget />
</div>
<div class="col-span-12 xl:col-span-6">
<RevenueStreamWidget />
<NotificationsWidget />
</div>
</div>
</template>