first commit
This commit is contained in:
@@ -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';
|
||||
|
||||
293
src/views/pages/HomeCards.vue
Normal file
293
src/views/pages/HomeCards.vue
Normal 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 JÁ 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>
|
||||
@@ -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>
|
||||
|
||||
23
src/views/pages/admin/AdminDashboard.vue
Normal file
23
src/views/pages/admin/AdminDashboard.vue
Normal 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>
|
||||
9
src/views/pages/admin/OnlineSchedulingAdminPage.vue
Normal file
9
src/views/pages/admin/OnlineSchedulingAdminPage.vue
Normal 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>
|
||||
1233
src/views/pages/admin/pacientes/PatientsIndexPage.vue
Normal file
1233
src/views/pages/admin/pacientes/PatientsIndexPage.vue
Normal file
File diff suppressed because it is too large
Load Diff
1722
src/views/pages/admin/pacientes/cadastro/PatientsCadastroPage.vue
Normal file
1722
src/views/pages/admin/pacientes/cadastro/PatientsCadastroPage.vue
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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 já foi compartilhado.
|
||||
</Message>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right: Concept / Instructions -->
|
||||
<div class="lg:col-span-5">
|
||||
<div class="rounded-2xl border border-slate-200 bg-white shadow-sm overflow-hidden">
|
||||
<div class="p-5 border-b border-slate-200">
|
||||
<div class="text-lg font-semibold text-slate-900">Como funciona</div>
|
||||
<div class="text-slate-600 text-sm mt-1">
|
||||
Um fluxo simples, mas com cuidado clínico: menos fricção, mais adesão.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-5">
|
||||
<ol class="space-y-4">
|
||||
<li class="flex gap-3">
|
||||
<div class="h-8 w-8 rounded-xl bg-slate-900 text-slate-50 grid place-items-center text-sm font-semibold">1</div>
|
||||
<div class="min-w-0">
|
||||
<div class="font-semibold text-slate-900">Você envia o link</div>
|
||||
<div class="text-sm text-slate-600 mt-1">
|
||||
Pode ser WhatsApp, e-mail ou mensagem direta. O link abre a página de cadastro externo.
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li class="flex gap-3">
|
||||
<div class="h-8 w-8 rounded-xl bg-slate-900 text-slate-50 grid place-items-center text-sm font-semibold">2</div>
|
||||
<div class="min-w-0">
|
||||
<div class="font-semibold text-slate-900">O paciente preenche</div>
|
||||
<div class="text-sm text-slate-600 mt-1">
|
||||
Campos opcionais podem ser deixados em branco. A ideia é reduzir ansiedade e acelerar o início.
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li class="flex gap-3">
|
||||
<div class="h-8 w-8 rounded-xl bg-slate-900 text-slate-50 grid place-items-center text-sm font-semibold">3</div>
|
||||
<div class="min-w-0">
|
||||
<div class="font-semibold text-slate-900">Você recebe no admin</div>
|
||||
<div class="text-sm text-slate-600 mt-1">
|
||||
Os dados entram como “cadastro recebido”. Você revisa, completa e transforma em paciente quando quiser.
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<div class="mt-6 rounded-2xl border border-slate-200 bg-slate-50 p-4">
|
||||
<div class="font-semibold text-slate-900 flex items-center gap-2">
|
||||
<i class="pi pi-shield text-slate-700"></i>
|
||||
Boas práticas
|
||||
</div>
|
||||
<ul class="mt-2 space-y-2 text-sm text-slate-700">
|
||||
<li class="flex gap-2">
|
||||
<i class="pi pi-check text-emerald-600 mt-0.5"></i>
|
||||
<span>Gere um novo link se você suspeitar que ele foi repassado indevidamente.</span>
|
||||
</li>
|
||||
<li class="flex gap-2">
|
||||
<i class="pi pi-check text-emerald-600 mt-0.5"></i>
|
||||
<span>Envie junto uma mensagem curta: “preencha com calma; campos opcionais podem ficar em branco”.</span>
|
||||
</li>
|
||||
<li class="flex gap-2">
|
||||
<i class="pi pi-check text-emerald-600 mt-0.5"></i>
|
||||
<span>Evite divulgar em público; é um link pensado para compartilhamento individual.</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 text-xs text-slate-500">
|
||||
Se você quiser, eu deixo este card ainda mais “noir” (contraste, microtextos, ícones, sombras) sem perder legibilidade.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Small helper card -->
|
||||
<div class="mt-4 rounded-2xl border border-slate-200 bg-white shadow-sm p-5">
|
||||
<div class="font-semibold text-slate-900">Mensagem pronta (copiar/colar)</div>
|
||||
<div class="text-sm text-slate-600 mt-1">
|
||||
Se quiser, use este texto ao enviar o link:
|
||||
</div>
|
||||
|
||||
<div class="mt-3 rounded-xl bg-slate-50 border border-slate-200 p-3 text-sm text-slate-800">
|
||||
Olá! Segue o link para seu pré-cadastro. Preencha com calma — campos opcionais podem ficar em branco:
|
||||
<span class="block mt-2 font-mono break-words">{{ publicUrl || '…' }}</span>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 flex gap-2">
|
||||
<Button
|
||||
icon="pi pi-copy"
|
||||
label="Copiar mensagem"
|
||||
severity="secondary"
|
||||
outlined
|
||||
:disabled="!publicUrl"
|
||||
@click="copyInviteMessage"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Toast is global in layout usually; if not, add <Toast /> -->
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import Button from 'primevue/button'
|
||||
import Card from 'primevue/card'
|
||||
import InputText from 'primevue/inputtext'
|
||||
import Message from 'primevue/message'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { supabase } from '@/lib/supabase/client'
|
||||
|
||||
const toast = useToast()
|
||||
|
||||
const inviteToken = ref('')
|
||||
const rotating = ref(false)
|
||||
|
||||
/**
|
||||
* Se o cadastro externo estiver em outro domínio, fixe aqui:
|
||||
* ex.: const PUBLIC_BASE_URL = 'https://seusite.com'
|
||||
* se vazio, usa window.location.origin
|
||||
*/
|
||||
const PUBLIC_BASE_URL = '' // opcional
|
||||
|
||||
const origin = computed(() => PUBLIC_BASE_URL || window.location.origin)
|
||||
|
||||
const publicUrl = computed(() => {
|
||||
if (!inviteToken.value) return ''
|
||||
return `${origin.value}/cadastro/paciente?t=${inviteToken.value}`
|
||||
})
|
||||
|
||||
function newToken () {
|
||||
if (globalThis.crypto?.randomUUID) return globalThis.crypto.randomUUID()
|
||||
return 'tok_' + Math.random().toString(36).slice(2) + Date.now().toString(36)
|
||||
}
|
||||
|
||||
async function requireUserId () {
|
||||
const { data, error } = await supabase.auth.getUser()
|
||||
if (error) throw error
|
||||
const uid = data?.user?.id
|
||||
if (!uid) throw new Error('Usuário não autenticado')
|
||||
return uid
|
||||
}
|
||||
|
||||
async function loadOrCreateInvite () {
|
||||
const uid = await requireUserId()
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('patient_invites')
|
||||
.select('token, active')
|
||||
.eq('owner_id', uid)
|
||||
.eq('active', true)
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(1)
|
||||
|
||||
if (error) throw error
|
||||
|
||||
const token = data?.[0]?.token
|
||||
if (token) {
|
||||
inviteToken.value = token
|
||||
return
|
||||
}
|
||||
|
||||
const t = newToken()
|
||||
const { error: insErr } = await supabase
|
||||
.from('patient_invites')
|
||||
.insert({ owner_id: uid, token: t, active: true })
|
||||
|
||||
if (insErr) throw insErr
|
||||
inviteToken.value = t
|
||||
}
|
||||
|
||||
async function rotateLink () {
|
||||
rotating.value = true
|
||||
try {
|
||||
const t = newToken()
|
||||
const { error } = await supabase.rpc('rotate_patient_invite_token', { p_new_token: t })
|
||||
if (error) throw error
|
||||
|
||||
inviteToken.value = t
|
||||
toast.add({ severity: 'success', summary: 'Pronto', detail: 'Novo link gerado.', life: 2000 })
|
||||
} catch (err) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: err?.message || 'Falha ao gerar novo link.', life: 3500 })
|
||||
} finally {
|
||||
rotating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function copyLink () {
|
||||
try {
|
||||
if (!publicUrl.value) return
|
||||
await navigator.clipboard.writeText(publicUrl.value)
|
||||
toast.add({ severity: 'success', summary: 'Copiado', detail: 'Link copiado.', life: 1500 })
|
||||
} catch {
|
||||
toast.add({ severity: 'warn', summary: 'Atenção', detail: 'Não foi possível copiar automaticamente.', life: 2500 })
|
||||
}
|
||||
}
|
||||
|
||||
function openLink () {
|
||||
if (!publicUrl.value) return
|
||||
window.open(publicUrl.value, '_blank', 'noopener')
|
||||
}
|
||||
|
||||
async function copyInviteMessage () {
|
||||
try {
|
||||
if (!publicUrl.value) return
|
||||
const msg =
|
||||
`Olá! Segue o link para seu pré-cadastro. Preencha com calma — campos opcionais podem ficar em branco:
|
||||
${publicUrl.value}`
|
||||
await navigator.clipboard.writeText(msg)
|
||||
toast.add({ severity: 'success', summary: 'Copiado', detail: 'Mensagem copiada.', life: 1500 })
|
||||
} catch {
|
||||
toast.add({ severity: 'warn', summary: 'Atenção', detail: 'Não foi possível copiar automaticamente.', life: 2500 })
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
await loadOrCreateInvite()
|
||||
} catch (err) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: err?.message || 'Falha ao carregar link.', life: 3500 })
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -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>
|
||||
617
src/views/pages/admin/pacientes/grupos/GruposPacientesPage.vue
Normal file
617
src/views/pages/admin/pacientes/grupos/GruposPacientesPage.vue
Normal 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 há pacientes associados.</template>
|
||||
|
||||
<template #content>
|
||||
<div v-if="cards.length === 0" class="min-h-[150px] flex flex-col items-center justify-center text-center gap-2">
|
||||
<i class="pi pi-users text-3xl"></i>
|
||||
<div class="mt-1 font-medium">Sem pacientes associados</div>
|
||||
<small class="text-color-secondary">
|
||||
Quando um grupo tiver pacientes vinculados, ele aparecerá aqui.
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div v-else class="flex flex-col gap-3">
|
||||
<div
|
||||
v-for="g in cards"
|
||||
:key="g.id"
|
||||
class="relative p-4 rounded-xl border border-[var(--surface-border)] transition-all duration-150 hover:-translate-y-1 hover:shadow-[var(--card-shadow)]"
|
||||
@mouseenter="hovered = g.id"
|
||||
@mouseleave="hovered = null"
|
||||
>
|
||||
<div class="flex justify-between items-start gap-3">
|
||||
<div class="min-w-0">
|
||||
<div class="font-bold truncate max-w-[230px]">
|
||||
{{ g.nome }}
|
||||
</div>
|
||||
<small class="text-color-secondary">
|
||||
{{ patientsLabel(Number(g.patients_count ?? g.patient_count ?? 0)) }}
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<Tag
|
||||
:value="g.is_system ? 'Padrão' : 'Criado por você'"
|
||||
:severity="g.is_system ? 'info' : 'success'"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Transition name="fade">
|
||||
<div
|
||||
v-if="hovered === g.id"
|
||||
class="absolute inset-0 rounded-xl bg-emerald-500/15 backdrop-blur-sm flex items-center justify-center"
|
||||
>
|
||||
<Button
|
||||
label="Ver pacientes"
|
||||
icon="pi pi-users"
|
||||
severity="success"
|
||||
:disabled="!(g.patients_count ?? g.patient_count)"
|
||||
@click="openGroupPatientsModal(g)"
|
||||
/>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- DIALOG CREATE / EDIT -->
|
||||
<Dialog
|
||||
v-model:visible="dlg.open"
|
||||
:header="dlg.mode === 'create' ? 'Criar Grupo' : 'Editar Grupo'"
|
||||
modal
|
||||
:style="{ width: '520px', maxWidth: '92vw' }"
|
||||
>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div>
|
||||
<label class="block mb-2">Nome do Grupo</label>
|
||||
<InputText v-model="dlg.nome" class="w-full" :disabled="dlg.saving" />
|
||||
<small class="text-color-secondary">
|
||||
Grupos “Padrão” são do sistema e não podem ser editados.
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<Button label="Cancelar" text :disabled="dlg.saving" @click="dlg.open = false" />
|
||||
<Button
|
||||
:label="dlg.mode === 'create' ? 'Criar' : 'Salvar'"
|
||||
:loading="dlg.saving"
|
||||
@click="saveDialog"
|
||||
:disabled="!String(dlg.nome || '').trim()"
|
||||
/>
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
<!-- ✅ DIALOG PACIENTES (com botão Abrir) -->
|
||||
<Dialog
|
||||
v-model:visible="patientsDialog.open"
|
||||
:header="patientsDialog.group?.nome ? `Pacientes do grupo: ${patientsDialog.group.nome}` : 'Pacientes do grupo'"
|
||||
modal
|
||||
:style="{ width: '900px', maxWidth: '95vw' }"
|
||||
>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="text-color-secondary">
|
||||
Grupo: <span class="font-medium text-color">{{ patientsDialog.group?.nome || '—' }}</span>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-3">
|
||||
<IconField class="w-full md:w-80">
|
||||
<InputIcon><i class="pi pi-search" /></InputIcon>
|
||||
<InputText
|
||||
v-model="patientsDialog.search"
|
||||
placeholder="Buscar paciente..."
|
||||
class="w-full"
|
||||
:disabled="patientsDialog.loading"
|
||||
/>
|
||||
</IconField>
|
||||
|
||||
<div class="flex items-center gap-2 justify-end">
|
||||
<Tag v-if="!patientsDialog.loading" :value="`${patientsDialog.items.length} paciente(s)`" severity="secondary" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="patientsDialog.loading" class="text-color-secondary">Carregando…</div>
|
||||
|
||||
<Message v-else-if="patientsDialog.error" severity="error">
|
||||
{{ patientsDialog.error }}
|
||||
</Message>
|
||||
|
||||
<div v-else>
|
||||
<div v-if="patientsDialog.items.length === 0" class="text-color-secondary">
|
||||
Nenhum paciente associado a este grupo.
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<DataTable
|
||||
:value="patientsDialogFiltered"
|
||||
dataKey="id"
|
||||
stripedRows
|
||||
responsiveLayout="scroll"
|
||||
paginator
|
||||
:rows="8"
|
||||
:rowsPerPageOptions="[8, 15, 30]"
|
||||
>
|
||||
<Column header="Paciente" sortable>
|
||||
<template #body="{ data }">
|
||||
<div class="flex items-center gap-3">
|
||||
<Avatar v-if="data.avatar_url" :image="data.avatar_url" shape="circle" />
|
||||
<Avatar v-else :label="initials(data.full_name)" shape="circle" />
|
||||
<div class="min-w-0">
|
||||
<div class="font-medium truncate max-w-[420px]">{{ data.full_name }}</div>
|
||||
<small class="text-color-secondary truncate">{{ data.email || '—' }}</small>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column header="Telefone" style="min-width: 12rem">
|
||||
<template #body="{ data }">
|
||||
<span class="text-color-secondary">{{ fmtPhone(data.phone) }}</span>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column header="Ações" style="width: 12rem">
|
||||
<template #body="{ data }">
|
||||
<Button
|
||||
label="Abrir"
|
||||
icon="pi pi-external-link"
|
||||
size="small"
|
||||
outlined
|
||||
@click="abrirPaciente(data)"
|
||||
/>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<template #empty>
|
||||
<div class="text-color-secondary py-5">Nenhum resultado para "{{ patientsDialog.search }}".</div>
|
||||
</template>
|
||||
</DataTable>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<Button label="Fechar" icon="pi pi-times" text @click="patientsDialog.open = false" />
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
<ConfirmDialog />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { useConfirm } from 'primevue/useconfirm'
|
||||
|
||||
import Checkbox from 'primevue/checkbox'
|
||||
import { supabase } from '@/lib/supabase/client'
|
||||
|
||||
import {
|
||||
listGroupsWithCounts,
|
||||
createGroup,
|
||||
updateGroup,
|
||||
deleteGroup
|
||||
} from '@/services/GruposPacientes.service.js'
|
||||
|
||||
const router = useRouter()
|
||||
const toast = useToast()
|
||||
const confirm = useConfirm()
|
||||
|
||||
const dt = ref(null)
|
||||
const loading = ref(false)
|
||||
const groups = ref([])
|
||||
const selectedGroups = ref([])
|
||||
const hovered = ref(null)
|
||||
|
||||
const filters = ref({
|
||||
global: { value: null, matchMode: 'contains' }
|
||||
})
|
||||
|
||||
const dlg = reactive({
|
||||
open: false,
|
||||
mode: 'create', // 'create' | 'edit'
|
||||
id: '',
|
||||
nome: '',
|
||||
saving: false
|
||||
})
|
||||
|
||||
const patientsDialog = reactive({
|
||||
open: false,
|
||||
loading: false,
|
||||
error: '',
|
||||
group: null,
|
||||
items: [],
|
||||
search: ''
|
||||
})
|
||||
|
||||
const cards = computed(() =>
|
||||
(groups.value || [])
|
||||
.filter(g => Number(g.patients_count ?? g.patient_count ?? 0) > 0)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
Number(b.patients_count ?? b.patient_count ?? 0) -
|
||||
Number(a.patients_count ?? a.patient_count ?? 0)
|
||||
)
|
||||
)
|
||||
|
||||
const patientsDialogFiltered = computed(() => {
|
||||
const s = String(patientsDialog.search || '').trim().toLowerCase()
|
||||
if (!s) return patientsDialog.items || []
|
||||
return (patientsDialog.items || []).filter(p => {
|
||||
const name = String(p.full_name || '').toLowerCase()
|
||||
const email = String(p.email || '').toLowerCase()
|
||||
const phone = String(p.phone || '').toLowerCase()
|
||||
return name.includes(s) || email.includes(s) || phone.includes(s)
|
||||
})
|
||||
})
|
||||
|
||||
function patientsLabel (n) {
|
||||
return n === 1 ? '1 paciente' : `${n} pacientes`
|
||||
}
|
||||
|
||||
function humanizeError (err) {
|
||||
const msg = err?.message || err?.error_description || String(err) || 'Erro inesperado.'
|
||||
const code = err?.code
|
||||
if (code === '23505' || /duplicate key value/i.test(msg)) {
|
||||
return 'Já existe um grupo com esse nome (para você). Tente outro nome.'
|
||||
}
|
||||
if (/Grupo padrão/i.test(msg)) {
|
||||
return 'Esse é um grupo padrão do sistema e não pode ser alterado.'
|
||||
}
|
||||
return msg
|
||||
}
|
||||
|
||||
async function fetchAll () {
|
||||
loading.value = true
|
||||
try {
|
||||
groups.value = await listGroupsWithCounts()
|
||||
} catch (err) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: humanizeError(err), life: 3500 })
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------
|
||||
Seleção: ignora grupos do sistema
|
||||
-------------------------------- */
|
||||
function isSelected (row) {
|
||||
return (selectedGroups.value || []).some(s => s.id === row.id)
|
||||
}
|
||||
|
||||
function toggleRowSelection (row, checked) {
|
||||
if (row.is_system) return
|
||||
const sel = selectedGroups.value || []
|
||||
if (checked) {
|
||||
if (!sel.some(s => s.id === row.id)) selectedGroups.value = [...sel, row]
|
||||
} else {
|
||||
selectedGroups.value = sel.filter(s => s.id !== row.id)
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------
|
||||
CRUD
|
||||
-------------------------------- */
|
||||
function openCreate () {
|
||||
dlg.open = true
|
||||
dlg.mode = 'create'
|
||||
dlg.id = ''
|
||||
dlg.nome = ''
|
||||
}
|
||||
|
||||
function openEdit (row) {
|
||||
dlg.open = true
|
||||
dlg.mode = 'edit'
|
||||
dlg.id = row.id
|
||||
dlg.nome = row.nome
|
||||
}
|
||||
|
||||
async function saveDialog () {
|
||||
const nome = String(dlg.nome || '').trim()
|
||||
if (!nome) {
|
||||
toast.add({ severity: 'warn', summary: 'Atenção', detail: 'Informe um nome.', life: 2500 })
|
||||
return
|
||||
}
|
||||
if (nome.length < 2) {
|
||||
toast.add({ severity: 'warn', summary: 'Atenção', detail: 'Nome muito curto.', life: 2500 })
|
||||
return
|
||||
}
|
||||
|
||||
dlg.saving = true
|
||||
try {
|
||||
if (dlg.mode === 'create') {
|
||||
await createGroup(nome)
|
||||
toast.add({ severity: 'success', summary: 'Sucesso', detail: 'Grupo criado.', life: 2500 })
|
||||
} else {
|
||||
await updateGroup(dlg.id, nome)
|
||||
toast.add({ severity: 'success', summary: 'Sucesso', detail: 'Grupo atualizado.', life: 2500 })
|
||||
}
|
||||
dlg.open = false
|
||||
await fetchAll()
|
||||
} catch (err) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: humanizeError(err), life: 3500 })
|
||||
} finally {
|
||||
dlg.saving = false
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDeleteOne (row) {
|
||||
confirm.require({
|
||||
message: `Excluir "${row.nome}"?`,
|
||||
header: 'Excluir grupo',
|
||||
icon: 'pi pi-exclamation-triangle',
|
||||
acceptClass: 'p-button-danger',
|
||||
acceptLabel: 'Excluir',
|
||||
rejectLabel: 'Cancelar',
|
||||
accept: async () => {
|
||||
try {
|
||||
await deleteGroup(row.id)
|
||||
toast.add({ severity: 'success', summary: 'Sucesso', detail: 'Grupo excluído.', life: 2500 })
|
||||
await fetchAll()
|
||||
} catch (err) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: humanizeError(err), life: 3500 })
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function confirmDeleteSelected () {
|
||||
const sel = selectedGroups.value || []
|
||||
if (!sel.length) return
|
||||
|
||||
const deletables = sel.filter(g => !g.is_system)
|
||||
const blocked = sel.filter(g => g.is_system)
|
||||
|
||||
if (!deletables.length) {
|
||||
toast.add({
|
||||
severity: 'warn',
|
||||
summary: 'Atenção',
|
||||
detail: 'Os itens selecionados são grupos do sistema e não podem ser excluídos.',
|
||||
life: 3500
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const msgBlocked = blocked.length ? ` (${blocked.length} grupo(s) padrão serão ignorados)` : ''
|
||||
|
||||
confirm.require({
|
||||
message: `Excluir ${deletables.length} grupo(s) selecionado(s)?${msgBlocked}`,
|
||||
header: 'Excluir selecionados',
|
||||
icon: 'pi pi-exclamation-triangle',
|
||||
acceptClass: 'p-button-danger',
|
||||
acceptLabel: 'Excluir',
|
||||
rejectLabel: 'Cancelar',
|
||||
accept: async () => {
|
||||
try {
|
||||
for (const g of deletables) await deleteGroup(g.id)
|
||||
selectedGroups.value = []
|
||||
toast.add({ severity: 'success', summary: 'Sucesso', detail: 'Exclusão concluída.', life: 2500 })
|
||||
await fetchAll()
|
||||
} catch (err) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: humanizeError(err), life: 3500 })
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/* -------------------------------
|
||||
Helpers (avatar/telefone)
|
||||
-------------------------------- */
|
||||
function initials (name) {
|
||||
const parts = String(name || '').trim().split(/\s+/).filter(Boolean)
|
||||
if (!parts.length) return '—'
|
||||
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase()
|
||||
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase()
|
||||
}
|
||||
|
||||
function onlyDigits (v) {
|
||||
return String(v ?? '').replace(/\D/g, '')
|
||||
}
|
||||
|
||||
function fmtPhone (v) {
|
||||
const d = onlyDigits(v)
|
||||
if (!d) return '—'
|
||||
if (d.length === 11) return `(${d.slice(0, 2)}) ${d.slice(2, 7)}-${d.slice(7)}`
|
||||
if (d.length === 10) return `(${d.slice(0, 2)}) ${d.slice(2, 6)}-${d.slice(6)}`
|
||||
return d
|
||||
}
|
||||
|
||||
/* -------------------------------
|
||||
Modal: Pacientes do Grupo
|
||||
-------------------------------- */
|
||||
async function openGroupPatientsModal (groupRow) {
|
||||
patientsDialog.open = true
|
||||
patientsDialog.loading = true
|
||||
patientsDialog.error = ''
|
||||
patientsDialog.group = groupRow
|
||||
patientsDialog.items = []
|
||||
patientsDialog.search = ''
|
||||
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('patient_group_patient')
|
||||
.select(`
|
||||
patient_id,
|
||||
patient:patients (
|
||||
id,
|
||||
nome_completo,
|
||||
email_principal,
|
||||
telefone,
|
||||
avatar_url
|
||||
)
|
||||
`)
|
||||
.eq('patient_group_id', groupRow.id)
|
||||
|
||||
if (error) throw error
|
||||
|
||||
const patients = (data || [])
|
||||
.map(r => r.patient)
|
||||
.filter(Boolean)
|
||||
|
||||
patientsDialog.items = patients
|
||||
.map(p => ({
|
||||
id: p.id,
|
||||
full_name: p.nome_completo || '—',
|
||||
email: p.email_principal || '—',
|
||||
phone: p.telefone || '—',
|
||||
avatar_url: p.avatar_url || null
|
||||
}))
|
||||
.sort((a, b) => String(a.full_name).localeCompare(String(b.full_name), 'pt-BR'))
|
||||
} catch (err) {
|
||||
patientsDialog.error = humanizeError(err)
|
||||
} finally {
|
||||
patientsDialog.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
function abrirPaciente (patient) {
|
||||
router.push(`/admin/pacientes/cadastro/${patient.id}`)
|
||||
}
|
||||
|
||||
onMounted(fetchAll)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fade-enter-active,
|
||||
.fade-leave-active { transition: opacity .14s ease; }
|
||||
.fade-enter-from,
|
||||
.fade-leave-to { opacity: 0; }
|
||||
</style>
|
||||
899
src/views/pages/admin/pacientes/prontuario/PatientProntuario.vue
Normal file
899
src/views/pages/admin/pacientes/prontuario/PatientProntuario.vue
Normal 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>
|
||||
816
src/views/pages/admin/pacientes/tags/TagsPage.vue
Normal file
816
src/views/pages/admin/pacientes/tags/TagsPage.vue
Normal 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>
|
||||
@@ -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 só 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>
|
||||
|
||||
253
src/views/pages/auth/ResetPasswordPage.vue
Normal file
253
src/views/pages/auth/ResetPasswordPage.vue
Normal 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>
|
||||
374
src/views/pages/auth/SecurityPage.vue
Normal file
374
src/views/pages/auth/SecurityPage.vue
Normal 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>
|
||||
274
src/views/pages/auth/Welcome.vue
Normal file
274
src/views/pages/auth/Welcome.vue
Normal 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ê já 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>
|
||||
396
src/views/pages/billing/UpgradePage.vue
Normal file
396
src/views/pages/billing/UpgradePage.vue
Normal 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>
|
||||
954
src/views/pages/me/MeuPerfilPage.vue
Normal file
954
src/views/pages/me/MeuPerfilPage.vue
Normal 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>
|
||||
23
src/views/pages/patient/PatientDashboard.vue
Normal file
23
src/views/pages/patient/PatientDashboard.vue
Normal 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>
|
||||
1387
src/views/pages/public/CadastroPacienteExterno.vue
Normal file
1387
src/views/pages/public/CadastroPacienteExterno.vue
Normal file
File diff suppressed because it is too large
Load Diff
523
src/views/pages/public/Landingpage-v1.vue
Normal file
523
src/views/pages/public/Landingpage-v1.vue
Normal 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 só: 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>
|
||||
166
src/views/pages/public/PatientsExternalLinkPage.vue
Normal file
166
src/views/pages/public/PatientsExternalLinkPage.vue
Normal 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>
|
||||
575
src/views/pages/public/Signup.vue
Normal file
575
src/views/pages/public/Signup.vue
Normal 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">
|
||||
Já 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>
|
||||
288
src/views/pages/saas/SaasDashboard.vue
Normal file
288
src/views/pages/saas/SaasDashboard.vue
Normal 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>
|
||||
271
src/views/pages/saas/SaasFeaturesPage.vue
Normal file
271
src/views/pages/saas/SaasFeaturesPage.vue
Normal 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, é só 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>
|
||||
8
src/views/pages/saas/SaasPlaceholder.vue
Normal file
8
src/views/pages/saas/SaasPlaceholder.vue
Normal 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>
|
||||
178
src/views/pages/saas/SaasPlanFeaturesMatrixPage.vue
Normal file
178
src/views/pages/saas/SaasPlanFeaturesMatrixPage.vue
Normal 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>
|
||||
412
src/views/pages/saas/SaasPlansPage.vue
Normal file
412
src/views/pages/saas/SaasPlansPage.vue
Normal 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>
|
||||
637
src/views/pages/saas/SaasPlansPublicPage.vue
Normal file
637
src/views/pages/saas/SaasPlansPublicPage.vue
Normal 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>
|
||||
217
src/views/pages/saas/SaasSubscriptionEventsPage.vue
Normal file
217
src/views/pages/saas/SaasSubscriptionEventsPage.vue
Normal 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>
|
||||
231
src/views/pages/saas/SaasSubscriptionHealthPage.vue
Normal file
231
src/views/pages/saas/SaasSubscriptionHealthPage.vue
Normal 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>
|
||||
271
src/views/pages/saas/SaasSubscriptionsPage.vue
Normal file
271
src/views/pages/saas/SaasSubscriptionsPage.vue
Normal 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>
|
||||
290
src/views/pages/saas/SubscriptionIntentsPage.vue
Normal file
290
src/views/pages/saas/SubscriptionIntentsPage.vue
Normal 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>
|
||||
9
src/views/pages/therapist/OnlineSchedulingPage.vue
Normal file
9
src/views/pages/therapist/OnlineSchedulingPage.vue
Normal 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>
|
||||
23
src/views/pages/therapist/TherapistDashboard.vue
Normal file
23
src/views/pages/therapist/TherapistDashboard.vue
Normal 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>
|
||||
Reference in New Issue
Block a user