Correcao Sidebar Classico e Rail, Correcao Layout, Ajuste de Breakpoint para Tailwind, Ajuste AppTopbar, Ajuste Menu PopOver, Recriado Paleta de Cores, Inserido algumas animações leves, Reajuste Cor items NOVOS da tabela, Drawer Ajuda Corrigido no Logout, Whatsapp, sms, email, recursos extras

This commit is contained in:
Leonardo
2026-03-24 21:26:58 -03:00
parent a89d1f5560
commit 53a4980396
453 changed files with 121427 additions and 174407 deletions
File diff suppressed because it is too large Load Diff
+74 -98
View File
@@ -15,116 +15,92 @@
|--------------------------------------------------------------------------
-->
<script setup>
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { computed } from 'vue';
import { useRoute, useRouter } from 'vue-router';
const router = useRouter();
const route = useRoute();
const router = useRouter()
const route = useRoute()
const attemptedPath = computed(() => route.fullPath || '');
const attemptedPath = computed(() => route.fullPath || '')
function goDashboard () {
// Em muitos projetos, '/' redireciona para o dashboard correto conforme role.
router.push('/admin')
function goDashboard() {
// Em muitos projetos, '/' redireciona para o dashboard correto conforme role.
router.push('/admin');
}
</script>
<template>
<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 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, 0.05) 1px, transparent 1px), linear-gradient(to bottom, rgba(255, 255, 255, 0.05) 1px, transparent 1px);
background-size: 36px 36px;
mask-image: radial-gradient(ellipse at center, rgba(0, 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>
<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 || '—' }}
<!-- "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>
</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>
<!-- 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>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+250 -317
View File
@@ -15,365 +15,298 @@
|--------------------------------------------------------------------------
-->
<script setup>
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { useRouter } from 'vue-router'
import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
import { useRouter } from 'vue-router';
import Password from 'primevue/password'
import { useToast } from 'primevue/usetoast'
import Password from 'primevue/password';
import { useToast } from 'primevue/usetoast';
import { supabase } from '@/lib/supabase/client'
import { supabase } from '@/lib/supabase/client';
const toast = useToast()
const router = useRouter()
const toast = useToast();
const router = useRouter();
const newPassword = ref('')
const confirmPassword = ref('')
const loading = ref(false)
const done = ref(false)
const newPassword = ref('');
const confirmPassword = ref('');
const loading = ref(false);
const done = ref(false);
// estado do link de recovery
const recoveryReady = ref(false)
const linkInvalid = ref(false)
const recoveryReady = ref(false);
const linkInvalid = ref(false);
function isStrongEnough (p) {
return /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).{8,}$/.test(p || '')
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)
const strengthOk = computed(() => isStrongEnough(newPassword.value));
const matchOk = computed(() => !!confirmPassword.value && newPassword.value === confirmPassword.value);
const canSubmit = computed(() =>
recoveryReady.value && strengthOk.value && matchOk.value && !loading.value
)
const canSubmit = computed(() => recoveryReady.value && strengthOk.value && matchOk.value && !loading.value);
// barra de força: 04
const strengthScore = computed(() => {
const p = newPassword.value
if (!p) return 0
let s = 0
if (p.length >= 8) s++
if (/[A-Z]/.test(p)) s++
if (/[a-z]/.test(p)) s++
if (/\d/.test(p)) s++
return s
})
const p = newPassword.value;
if (!p) return 0;
let s = 0;
if (p.length >= 8) s++;
if (/[A-Z]/.test(p)) s++;
if (/[a-z]/.test(p)) s++;
if (/\d/.test(p)) s++;
return s;
});
const strengthLabel = computed(() => {
if (!newPassword.value) return ''
const labels = ['', 'Muito fraca', 'Fraca', 'Boa', 'Forte']
return labels[strengthScore.value] || 'Forte'
})
if (!newPassword.value) return '';
const labels = ['', 'Muito fraca', 'Fraca', 'Boa', 'Forte'];
return labels[strengthScore.value] || 'Forte';
});
const strengthColor = computed(() => {
const colors = ['', 'bg-red-500', 'bg-yellow-500', 'bg-blue-500', 'bg-emerald-500']
return colors[strengthScore.value] || 'bg-emerald-500'
})
const colors = ['', 'bg-red-500', 'bg-yellow-500', 'bg-blue-500', 'bg-emerald-500'];
return colors[strengthScore.value] || 'bg-emerald-500';
});
let unsubscribeFn = null
let unsubscribeFn = null;
async function syncRecoveryState () {
try {
const { data, error } = await supabase.auth.getSession()
if (error) throw error
if (data?.session) {
recoveryReady.value = true
} else {
linkInvalid.value = true
async function syncRecoveryState() {
try {
const { data, error } = await supabase.auth.getSession();
if (error) throw error;
if (data?.session) {
recoveryReady.value = true;
} else {
linkInvalid.value = true;
}
} catch {
linkInvalid.value = true;
}
} catch {
linkInvalid.value = true
}
}
onMounted(async () => {
await syncRecoveryState()
await syncRecoveryState();
const { data: listener } = supabase.auth.onAuthStateChange((event) => {
if (event === 'PASSWORD_RECOVERY' || event === 'SIGNED_IN') {
recoveryReady.value = true
linkInvalid.value = false
}
if (event === 'SIGNED_OUT') {
recoveryReady.value = false
}
})
const { data: listener } = supabase.auth.onAuthStateChange((event) => {
if (event === 'PASSWORD_RECOVERY' || event === 'SIGNED_IN') {
recoveryReady.value = true;
linkInvalid.value = false;
}
if (event === 'SIGNED_OUT') {
recoveryReady.value = false;
}
});
unsubscribeFn = () => listener?.subscription?.unsubscribe()
})
unsubscribeFn = () => listener?.subscription?.unsubscribe();
});
onBeforeUnmount(() => {
try { unsubscribeFn?.() } catch {}
})
try {
unsubscribeFn?.();
} catch {}
});
function goLogin () {
router.replace('/auth/login')
function goLogin() {
router.replace('/auth/login');
}
async function submit () {
if (!recoveryReady.value) {
toast.add({ severity: 'warn', summary: 'Link inválido', detail: 'Solicite um novo e-mail de redefinição.', life: 3500 })
return
}
if (!matchOk.value) {
toast.add({ severity: 'warn', summary: 'Confirmação', detail: 'As senhas não conferem.', life: 3000 })
return
}
if (!strengthOk.value) {
toast.add({ severity: 'warn', summary: 'Senha fraca', detail: 'Use no mínimo 8 caracteres com maiúscula, minúscula e número.', life: 4000 })
return
}
async function submit() {
if (!recoveryReady.value) {
toast.add({ severity: 'warn', summary: 'Link inválido', detail: 'Solicite um novo e-mail de redefinição.', life: 3500 });
return;
}
if (!matchOk.value) {
toast.add({ severity: 'warn', summary: 'Confirmação', detail: 'As senhas não conferem.', life: 3000 });
return;
}
if (!strengthOk.value) {
toast.add({ severity: 'warn', summary: 'Senha fraca', detail: 'Use no mínimo 8 caracteres com maiúscula, minúscula e número.', life: 4000 });
return;
}
loading.value = true
try {
const { error } = await supabase.auth.updateUser({ password: newPassword.value })
if (error) throw error
loading.value = true;
try {
const { error } = await supabase.auth.updateUser({ password: newPassword.value });
if (error) throw error;
done.value = true
try { await supabase.auth.signOut({ scope: 'global' }) } catch {}
} 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
}
done.value = true;
try {
await supabase.auth.signOut({ scope: 'global' });
} catch {}
} 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>
<template>
<div class="min-h-screen w-full flex">
<div class="min-h-screen w-full flex">
<!-- ===== ESQUERDA: Painel de segurança ===== -->
<div class="hidden lg:flex lg:w-1/2 relative overflow-hidden flex-col">
<!-- Fundo gradiente -->
<div class="absolute inset-0 bg-gradient-to-br from-indigo-600 via-violet-600 to-purple-700" />
<!-- ===== ESQUERDA: Painel de segurança ===== -->
<div class="hidden lg:flex lg:w-1/2 relative overflow-hidden flex-col">
<!-- Fundo gradiente -->
<div class="absolute inset-0 bg-gradient-to-br from-indigo-600 via-violet-600 to-purple-700" />
<!-- Grade decorativa -->
<div class="absolute inset-0 opacity-[0.08]" style="background-image: linear-gradient(to right, white 1px, transparent 1px), linear-gradient(to bottom, white 1px, transparent 1px); background-size: 48px 48px" />
<!-- Grade decorativa -->
<div
class="absolute inset-0 opacity-[0.08]"
style="background-image: linear-gradient(to right, white 1px, transparent 1px), linear-gradient(to bottom, white 1px, transparent 1px); background-size: 48px 48px;"
/>
<!-- Orbs -->
<div class="absolute -top-40 -left-40 h-[32rem] w-[32rem] rounded-full bg-white/10 blur-3xl pointer-events-none" />
<div class="absolute bottom-0 right-0 h-80 w-80 rounded-full bg-violet-300/20 blur-3xl pointer-events-none" />
<!-- Orbs -->
<div class="absolute -top-40 -left-40 h-[32rem] w-[32rem] rounded-full bg-white/10 blur-3xl pointer-events-none" />
<div class="absolute bottom-0 right-0 h-80 w-80 rounded-full bg-violet-300/20 blur-3xl pointer-events-none" />
<div class="relative z-10 flex flex-col h-full p-10 xl:p-14">
<!-- Brand -->
<div class="flex items-center gap-3">
<div class="grid h-10 w-10 place-items-center rounded-xl bg-white/20 backdrop-blur-sm border border-white/20 shadow-lg">
<i class="pi pi-heart-fill text-white text-sm" />
</div>
<span class="text-white font-bold text-lg tracking-tight">Agência PSI</span>
</div>
<!-- Conteúdo central -->
<div class="flex-1 flex flex-col justify-center space-y-8">
<div class="grid h-16 w-16 place-items-center rounded-2xl bg-white/15 backdrop-blur-sm border border-white/20 shadow-lg">
<i class="pi pi-lock text-white text-2xl" />
</div>
<div class="space-y-3">
<h2 class="text-3xl xl:text-4xl font-bold text-white leading-tight">
Crie uma senha<br>que você não vai<br>esquecer.
</h2>
<p class="text-base text-white/70 leading-relaxed max-w-sm">
Escolha algo único e seguro. Uma boa senha protege seus dados e os de seus pacientes.
</p>
</div>
<!-- Dicas -->
<ul class="space-y-3">
<li class="flex items-start gap-3 text-sm text-white/70">
<span class="mt-1 h-1.5 w-1.5 rounded-full bg-emerald-400 flex-shrink-0" />
Mínimo de 8 caracteres
</li>
<li class="flex items-start gap-3 text-sm text-white/70">
<span class="mt-1 h-1.5 w-1.5 rounded-full bg-indigo-300 flex-shrink-0" />
Combine letras maiúsculas, minúsculas e números
</li>
<li class="flex items-start gap-3 text-sm text-white/70">
<span class="mt-1 h-1.5 w-1.5 rounded-full bg-fuchsia-300 flex-shrink-0" />
Não reutilize a mesma senha de outros serviços
</li>
<li class="flex items-start gap-3 text-sm text-white/70">
<span class="mt-1 h-1.5 w-1.5 rounded-full bg-amber-300 flex-shrink-0" />
Exemplo seguro: <span class="font-semibold text-white/90">"Noite#Calma7"</span>
</li>
</ul>
</div>
<!-- Rodapé esquerdo -->
<div class="flex items-center justify-between text-xs text-white/40">
<span>Agência PSI</span>
<span>Acesso seguro</span>
</div>
</div>
</div>
<!-- ===== DIREITA: Formulário ===== -->
<div class="flex-1 lg:w-1/2 flex flex-col min-h-screen bg-[var(--surface-ground)] overflow-y-auto relative">
<!-- Halos sutis -->
<div class="pointer-events-none absolute inset-0">
<div class="absolute top-10 right-10 h-64 w-64 rounded-full blur-3xl bg-indigo-400/5" />
<div class="absolute bottom-10 left-10 h-56 w-56 rounded-full blur-3xl bg-violet-400/5" />
</div>
<div class="relative flex flex-col flex-1 justify-center px-6 py-10 sm:px-10 lg:px-12 xl:px-16 w-full max-w-lg mx-auto">
<!-- Mobile: Brand -->
<div class="flex lg:hidden items-center gap-2 mb-8">
<div class="grid h-8 w-8 place-items-center rounded-lg bg-indigo-500/10 border border-indigo-500/20">
<i class="pi pi-heart-fill text-indigo-500 text-xs" />
</div>
<span class="text-[var(--text-color)] font-bold text-base tracking-tight">Agência PSI</span>
</div>
<!-- Estado: SUCESSO -->
<template v-if="done">
<div class="text-center space-y-6">
<div class="mx-auto grid h-20 w-20 place-items-center rounded-full bg-emerald-500/10 border border-emerald-500/20">
<i class="pi pi-check text-emerald-500 text-3xl" />
</div>
<div>
<h1 class="text-2xl font-bold text-[var(--text-color)]">Senha redefinida!</h1>
<p class="mt-2 text-sm text-[var(--text-color-secondary)] leading-relaxed">
Sua senha foi atualizada com sucesso.<br>Faça login novamente para continuar.
</p>
</div>
<Button
label="Ir para o login"
icon="pi pi-sign-in"
class="w-full"
@click="goLogin"
/>
</div>
</template>
<!-- Estado: LINK INVÁLIDO -->
<template v-else-if="linkInvalid">
<div class="text-center space-y-6">
<div class="mx-auto grid h-20 w-20 place-items-center rounded-full bg-red-500/10 border border-red-500/20">
<i class="pi pi-times text-red-500 text-3xl" />
</div>
<div>
<h1 class="text-2xl font-bold text-[var(--text-color)]">Link inválido</h1>
<p class="mt-2 text-sm text-[var(--text-color-secondary)] leading-relaxed">
Este link expirou ou foi utilizado.<br>Solicite um novo e-mail de redefinição.
</p>
</div>
<Button
label="Voltar para o login"
icon="pi pi-arrow-left"
severity="secondary"
outlined
class="w-full"
@click="goLogin"
/>
</div>
</template>
<!-- Estado: FORMULÁRIO -->
<template v-else>
<!-- Cabeçalho -->
<div class="mb-7">
<h1 class="text-3xl font-bold text-[var(--text-color)] leading-tight">Redefinir senha</h1>
<p class="mt-1.5 text-sm text-[var(--text-color-secondary)]">
Escolha uma senha nova e segura para sua conta.
</p>
</div>
<form class="space-y-5" @submit.prevent="submit">
<!-- Nova senha -->
<div>
<label class="block text-sm font-semibold text-[var(--text-color)] mb-1.5">Nova senha</label>
<Password
v-model="newPassword"
placeholder="Mínimo 8 caracteres"
toggleMask
:feedback="false"
class="w-full"
inputClass="w-full"
:disabled="loading"
/>
<!-- Barra de força -->
<div v-if="newPassword" class="mt-2 space-y-1">
<div class="flex gap-1">
<div
v-for="i in 4"
:key="i"
class="h-1 flex-1 rounded-full transition-all duration-300"
:class="i <= strengthScore ? strengthColor : 'bg-[var(--surface-border)]'"
/>
<div class="relative z-10 flex flex-col h-full p-10 xl:p-14">
<!-- Brand -->
<div class="flex items-center gap-3">
<div class="grid h-10 w-10 place-items-center rounded-xl bg-white/20 backdrop-blur-sm border border-white/20 shadow-lg">
<i class="pi pi-heart-fill text-white text-sm" />
</div>
<span class="text-white font-bold text-lg tracking-tight">Agência PSI</span>
</div>
<span class="text-xs" :class="{
'text-red-500': strengthScore === 1,
'text-yellow-500': strengthScore === 2,
'text-blue-500': strengthScore === 3,
'text-emerald-500': strengthScore === 4,
}">
{{ strengthLabel }}
</span>
</div>
<p v-else class="mt-1.5 text-xs text-[var(--text-color-secondary)]">
Dica: combine letras, números e símbolos.
</p>
<!-- Conteúdo central -->
<div class="flex-1 flex flex-col justify-center space-y-8">
<div class="grid h-16 w-16 place-items-center rounded-2xl bg-white/15 backdrop-blur-sm border border-white/20 shadow-lg">
<i class="pi pi-lock text-white text-2xl" />
</div>
<div class="space-y-3">
<h2 class="text-3xl xl:text-4xl font-bold text-white leading-tight">Crie uma senha<br />que você não vai<br />esquecer.</h2>
<p class="text-base text-white/70 leading-relaxed max-w-sm">Escolha algo único e seguro. Uma boa senha protege seus dados e os de seus pacientes.</p>
</div>
<!-- Dicas -->
<ul class="space-y-3">
<li class="flex items-start gap-3 text-sm text-white/70">
<span class="mt-1 h-1.5 w-1.5 rounded-full bg-emerald-400 flex-shrink-0" />
Mínimo de 8 caracteres
</li>
<li class="flex items-start gap-3 text-sm text-white/70">
<span class="mt-1 h-1.5 w-1.5 rounded-full bg-indigo-300 flex-shrink-0" />
Combine letras maiúsculas, minúsculas e números
</li>
<li class="flex items-start gap-3 text-sm text-white/70">
<span class="mt-1 h-1.5 w-1.5 rounded-full bg-fuchsia-300 flex-shrink-0" />
Não reutilize a mesma senha de outros serviços
</li>
<li class="flex items-start gap-3 text-sm text-white/70">
<span class="mt-1 h-1.5 w-1.5 rounded-full bg-amber-300 flex-shrink-0" />
Exemplo seguro: <span class="font-semibold text-white/90">"Noite#Calma7"</span>
</li>
</ul>
</div>
<!-- Rodapé esquerdo -->
<div class="flex items-center justify-between text-xs text-white/40">
<span>Agência PSI</span>
<span>Acesso seguro</span>
</div>
</div>
</div>
<!-- ===== DIREITA: Formulário ===== -->
<div class="flex-1 lg:w-1/2 flex flex-col min-h-screen bg-[var(--surface-ground)] overflow-y-auto relative">
<!-- Halos sutis -->
<div class="pointer-events-none absolute inset-0">
<div class="absolute top-10 right-10 h-64 w-64 rounded-full blur-3xl bg-indigo-400/5" />
<div class="absolute bottom-10 left-10 h-56 w-56 rounded-full blur-3xl bg-violet-400/5" />
</div>
<!-- Confirmar senha -->
<div>
<label class="block text-sm font-semibold text-[var(--text-color)] mb-1.5">Confirmar senha</label>
<Password
v-model="confirmPassword"
placeholder="Repita a nova senha"
toggleMask
:feedback="false"
class="w-full"
inputClass="w-full"
:disabled="loading"
/>
<div v-if="confirmPassword" class="mt-1.5 flex items-center gap-1.5 text-xs"
:class="matchOk ? 'text-emerald-500' : 'text-yellow-500'"
>
<i :class="matchOk ? 'pi pi-check' : 'pi pi-times'" />
{{ matchOk ? 'As senhas conferem.' : 'As senhas não conferem.' }}
</div>
<div class="relative flex flex-col flex-1 justify-center px-6 py-10 sm:px-10 lg:px-12 xl:px-16 w-full max-w-lg mx-auto">
<!-- Mobile: Brand -->
<div class="flex lg:hidden items-center gap-2 mb-8">
<div class="grid h-8 w-8 place-items-center rounded-lg bg-indigo-500/10 border border-indigo-500/20">
<i class="pi pi-heart-fill text-indigo-500 text-xs" />
</div>
<span class="text-[var(--text-color)] font-bold text-base tracking-tight">Agência PSI</span>
</div>
<!-- Estado: SUCESSO -->
<template v-if="done">
<div class="text-center space-y-6">
<div class="mx-auto grid h-20 w-20 place-items-center rounded-full bg-emerald-500/10 border border-emerald-500/20">
<i class="pi pi-check text-emerald-500 text-3xl" />
</div>
<div>
<h1 class="text-2xl font-bold text-[var(--text-color)]">Senha redefinida!</h1>
<p class="mt-2 text-sm text-[var(--text-color-secondary)] leading-relaxed">Sua senha foi atualizada com sucesso.<br />Faça login novamente para continuar.</p>
</div>
<Button label="Ir para o login" icon="pi pi-sign-in" class="w-full" @click="goLogin" />
</div>
</template>
<!-- Estado: LINK INVÁLIDO -->
<template v-else-if="linkInvalid">
<div class="text-center space-y-6">
<div class="mx-auto grid h-20 w-20 place-items-center rounded-full bg-red-500/10 border border-red-500/20">
<i class="pi pi-times text-red-500 text-3xl" />
</div>
<div>
<h1 class="text-2xl font-bold text-[var(--text-color)]">Link inválido</h1>
<p class="mt-2 text-sm text-[var(--text-color-secondary)] leading-relaxed">Este link expirou ou foi utilizado.<br />Solicite um novo e-mail de redefinição.</p>
</div>
<Button label="Voltar para o login" icon="pi pi-arrow-left" severity="secondary" outlined class="w-full" @click="goLogin" />
</div>
</template>
<!-- Estado: FORMULÁRIO -->
<template v-else>
<!-- Cabeçalho -->
<div class="mb-7">
<h1 class="text-3xl font-bold text-[var(--text-color)] leading-tight">Redefinir senha</h1>
<p class="mt-1.5 text-sm text-[var(--text-color-secondary)]">Escolha uma senha nova e segura para sua conta.</p>
</div>
<form class="space-y-5" @submit.prevent="submit">
<!-- Nova senha -->
<div>
<label class="block text-sm font-semibold text-[var(--text-color)] mb-1.5">Nova senha</label>
<Password v-model="newPassword" placeholder="Mínimo 8 caracteres" toggleMask :feedback="false" class="w-full" inputClass="w-full" :disabled="loading" />
<!-- Barra de força -->
<div v-if="newPassword" class="mt-2 space-y-1">
<div class="flex gap-1">
<div v-for="i in 4" :key="i" class="h-1 flex-1 rounded-full transition-all duration-300" :class="i <= strengthScore ? strengthColor : 'bg-[var(--surface-border)]'" />
</div>
<span
class="text-xs"
:class="{
'text-red-500': strengthScore === 1,
'text-yellow-500': strengthScore === 2,
'text-blue-500': strengthScore === 3,
'text-emerald-500': strengthScore === 4
}"
>
{{ strengthLabel }}
</span>
</div>
<p v-else class="mt-1.5 text-xs text-[var(--text-color-secondary)]">Dica: combine letras, números e símbolos.</p>
</div>
<!-- Confirmar senha -->
<div>
<label class="block text-sm font-semibold text-[var(--text-color)] mb-1.5">Confirmar senha</label>
<Password v-model="confirmPassword" placeholder="Repita a nova senha" toggleMask :feedback="false" class="w-full" inputClass="w-full" :disabled="loading" />
<div v-if="confirmPassword" class="mt-1.5 flex items-center gap-1.5 text-xs" :class="matchOk ? 'text-emerald-500' : 'text-yellow-500'">
<i :class="matchOk ? 'pi pi-check' : 'pi pi-times'" />
{{ matchOk ? 'As senhas conferem.' : 'As senhas não conferem.' }}
</div>
</div>
<!-- Botão -->
<Button type="submit" label="Atualizar senha" icon="pi pi-check" class="w-full" :loading="loading" :disabled="!canSubmit" />
</form>
<!-- Rodapé -->
<div class="mt-8 pt-6 border-t border-[var(--surface-border)]">
<button type="button" class="flex items-center gap-2 text-sm text-[var(--text-color-secondary)] hover:text-[var(--text-color)] transition-colors" @click="goLogin">
<i class="pi pi-arrow-left text-xs" />
Voltar para o login
</button>
<p class="mt-4 text-xs text-[var(--text-color-secondary)] leading-relaxed">Se você não solicitou essa redefinição, ignore o e-mail e certifique-se de que sua conta está segura.</p>
</div>
</template>
</div>
<!-- Botão -->
<Button
type="submit"
label="Atualizar senha"
icon="pi pi-check"
class="w-full"
:loading="loading"
:disabled="!canSubmit"
/>
</form>
<!-- Rodapé -->
<div class="mt-8 pt-6 border-t border-[var(--surface-border)]">
<button
type="button"
class="flex items-center gap-2 text-sm text-[var(--text-color-secondary)] hover:text-[var(--text-color)] transition-colors"
@click="goLogin"
>
<i class="pi pi-arrow-left text-xs" />
Voltar para o login
</button>
<p class="mt-4 text-xs text-[var(--text-color-secondary)] leading-relaxed">
Se você não solicitou essa redefinição, ignore o e-mail e certifique-se de que sua conta está segura.
</p>
</div>
</template>
</div>
</div>
</div>
</div>
</template>
+254 -313
View File
@@ -15,364 +15,305 @@
|--------------------------------------------------------------------------
-->
<script setup>
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
import Password from 'primevue/password'
import Button from 'primevue/button'
import { useToast } from 'primevue/usetoast'
import Password from 'primevue/password';
import Button from 'primevue/button';
import { useToast } from 'primevue/usetoast';
import { supabase } from '@/lib/supabase/client'
import { supabase } from '@/lib/supabase/client';
const toast = useToast()
const toast = useToast();
// ── Hero sticky ────────────────────────────────────────────
const headerEl = ref(null)
const headerSentinelRef = ref(null)
const headerStuck = ref(false)
let _observer = null
const headerEl = ref(null);
const headerSentinelRef = ref(null);
const headerStuck = ref(false);
let _observer = null;
const currentPassword = ref('')
const newPassword = ref('')
const confirmPassword = ref('')
const loading = ref(false)
const loadingReset = ref(false)
const mounted = ref(false)
const done = ref(false)
const currentPassword = ref('');
const newPassword = ref('');
const confirmPassword = ref('');
const loading = ref(false);
const loadingReset = ref(false);
const mounted = ref(false);
const done = ref(false);
// ── validações ────────────────────────────────────────────────────────────
function isStrongEnough (p) {
return /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).{8,}$/.test(p || '')
function isStrongEnough(p) {
return /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).{8,}$/.test(p || '');
}
const strengthScore = computed(() => {
const p = newPassword.value
if (!p) return 0
let s = 0
if (p.length >= 8) s++
if (/[A-Z]/.test(p)) s++
if (/[a-z]/.test(p)) s++
if (/\d/.test(p)) s++
return s
})
const p = newPassword.value;
if (!p) return 0;
let s = 0;
if (p.length >= 8) s++;
if (/[A-Z]/.test(p)) s++;
if (/[a-z]/.test(p)) s++;
if (/\d/.test(p)) s++;
return s;
});
const strengthLabel = computed(() => {
const labels = ['', 'Muito fraca', 'Fraca', 'Boa', 'Forte']
return labels[strengthScore.value] || ''
})
const labels = ['', 'Muito fraca', 'Fraca', 'Boa', 'Forte'];
return labels[strengthScore.value] || '';
});
const strengthColor = computed(() => {
const colors = ['', 'bg-red-500', 'bg-yellow-500', 'bg-blue-500', 'bg-emerald-500']
return colors[strengthScore.value] || ''
})
const colors = ['', 'bg-red-500', 'bg-yellow-500', 'bg-blue-500', 'bg-emerald-500'];
return colors[strengthScore.value] || '';
});
const strengthTextColor = computed(() => {
const colors = ['', 'text-red-500', 'text-yellow-500', 'text-blue-500', 'text-emerald-500']
return colors[strengthScore.value] || ''
})
const colors = ['', 'text-red-500', 'text-yellow-500', 'text-blue-500', 'text-emerald-500'];
return colors[strengthScore.value] || '';
});
const strengthOk = computed(() => isStrongEnough(newPassword.value))
const matchOk = computed(() => !!confirmPassword.value && newPassword.value === confirmPassword.value)
const strengthOk = computed(() => isStrongEnough(newPassword.value));
const matchOk = computed(() => !!confirmPassword.value && newPassword.value === confirmPassword.value);
const canSubmit = computed(() =>
!!currentPassword.value && strengthOk.value && matchOk.value && !loading.value && !loadingReset.value
)
const canSubmit = computed(() => !!currentPassword.value && strengthOk.value && matchOk.value && !loading.value && !loadingReset.value);
// ── ações ─────────────────────────────────────────────────────────────────
function clearFields () {
currentPassword.value = ''
newPassword.value = ''
confirmPassword.value = ''
function clearFields() {
currentPassword.value = '';
newPassword.value = '';
confirmPassword.value = '';
}
async function hardLogout () {
try { await supabase.auth.signOut({ scope: 'global' }) } catch {}
try {
const keys = []
for (let i = 0; i < localStorage.length; i++) {
const k = localStorage.key(i)
if (k?.startsWith('sb-') && k.includes('auth-token')) keys.push(k)
async function hardLogout() {
try {
await supabase.auth.signOut({ scope: 'global' });
} catch {}
try {
const keys = [];
for (let i = 0; i < localStorage.length; i++) {
const k = localStorage.key(i);
if (k?.startsWith('sb-') && k.includes('auth-token')) keys.push(k);
}
keys.forEach((k) => localStorage.removeItem(k));
} catch {}
try {
sessionStorage.removeItem('redirect_after_login');
} catch {}
window.location.replace('/auth/login');
}
async function changePassword() {
loading.value = true;
try {
const { data: uData, error: uErr } = await supabase.auth.getUser();
if (uErr) throw uErr;
const email = uData?.user?.email;
if (!email) throw new Error('Sessão inválida. Faça login novamente.');
// reautentica para confirmar senha atual
const { error: signError } = await supabase.auth.signInWithPassword({ email, password: currentPassword.value });
if (signError) throw new Error('Senha atual incorreta.');
// atualiza
const { error: upError } = await supabase.auth.updateUser({ password: newPassword.value });
if (upError) throw upError;
clearFields();
done.value = true;
toast.add({ severity: 'success', summary: 'Senha atualizada', detail: 'Por segurança, você será deslogado.', life: 2500 });
setTimeout(() => hardLogout(), 2600);
} 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;
}
keys.forEach(k => localStorage.removeItem(k))
} catch {}
try { sessionStorage.removeItem('redirect_after_login') } catch {}
window.location.replace('/auth/login')
}
async function changePassword () {
loading.value = true
try {
const { data: uData, error: uErr } = await supabase.auth.getUser()
if (uErr) throw uErr
const email = uData?.user?.email
if (!email) throw new Error('Sessão inválida. Faça login novamente.')
// reautentica para confirmar senha atual
const { error: signError } = await supabase.auth.signInWithPassword({ email, password: currentPassword.value })
if (signError) throw new Error('Senha atual incorreta.')
// atualiza
const { error: upError } = await supabase.auth.updateUser({ password: newPassword.value })
if (upError) throw upError
clearFields()
done.value = true
toast.add({ severity: 'success', summary: 'Senha atualizada', detail: 'Por segurança, você será deslogado.', life: 2500 })
setTimeout(() => hardLogout(), 2600)
} 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
}
}
onMounted(() => {
const rootMargin = `${document.querySelector('.l2-main') ? '0px' : '-56px'} 0px 0px 0px`
_observer = new IntersectionObserver(
([entry]) => { headerStuck.value = !entry.isIntersecting },
{ threshold: 0, rootMargin }
)
if (headerSentinelRef.value) _observer.observe(headerSentinelRef.value)
mounted.value = true
})
const rootMargin = `${document.querySelector('.l2-main') ? '0px' : '-56px'} 0px 0px 0px`;
_observer = new IntersectionObserver(
([entry]) => {
headerStuck.value = !entry.isIntersecting;
},
{ threshold: 0, rootMargin }
);
if (headerSentinelRef.value) _observer.observe(headerSentinelRef.value);
mounted.value = true;
});
onBeforeUnmount(() => { _observer?.disconnect() })
onBeforeUnmount(() => {
_observer?.disconnect();
});
async function sendResetEmail () {
loadingReset.value = true
try {
const { data: uData, error: uErr } = await supabase.auth.getUser()
if (uErr) throw uErr
async function sendResetEmail() {
loadingReset.value = true;
try {
const { data: uData, error: uErr } = await supabase.auth.getUser();
if (uErr) throw uErr;
const email = uData?.user?.email
if (!email) throw new Error('Sessão inválida. Faça login novamente.')
const email = uData?.user?.email;
if (!email) throw new Error('Sessão inválida. Faça login novamente.');
const redirectTo = `${window.location.origin}/auth/reset-password`
const { error } = await supabase.auth.resetPasswordForEmail(email, { redirectTo })
if (error) throw error
const redirectTo = `${window.location.origin}/auth/reset-password`;
const { error } = await supabase.auth.resetPasswordForEmail(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
}
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>
<template>
<!-- Sentinel -->
<div ref="headerSentinelRef" class="h-px" />
<!-- Sentinel -->
<div ref="headerSentinelRef" class="h-px" />
<!-- Hero sticky -->
<div
ref="headerEl"
class="sticky mx-auto max-w-2xl px-3 md:px-5 mb-4 z-20 overflow-hidden rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5"
:style="{ top: 'var(--layout-sticky-top, 56px)' }"
:class="{ 'rounded-tl-none rounded-tr-none': headerStuck }"
>
<!-- Blobs -->
<div class="absolute inset-0 pointer-events-none overflow-hidden" aria-hidden="true">
<div class="absolute rounded-full blur-[70px] w-72 h-72 -top-16 -right-12 bg-indigo-500/10" />
<div class="absolute rounded-full blur-[70px] w-80 h-80 top-2 -left-20 bg-emerald-500/[0.08]" />
</div>
<div class="relative z-10 flex items-center gap-4">
<div class="flex items-center gap-3 flex-1 min-w-0">
<div
class="grid place-items-center w-10 h-10 rounded-[0.875rem] shrink-0"
style="background: color-mix(in srgb, var(--p-primary-500, #6366f1) 12%, transparent); color: var(--p-primary-500, #6366f1)"
>
<i class="pi pi-shield text-lg" />
<!-- Hero sticky -->
<div
ref="headerEl"
class="sticky mx-auto max-w-2xl px-3 md:px-5 mb-4 z-20 overflow-hidden rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5"
:style="{ top: 'var(--layout-sticky-top, 56px)' }"
:class="{ 'rounded-tl-none rounded-tr-none': headerStuck }"
>
<!-- Blobs -->
<div class="absolute inset-0 pointer-events-none overflow-hidden" aria-hidden="true">
<div class="absolute rounded-full blur-[70px] w-72 h-72 -top-16 -right-12 bg-indigo-500/10" />
<div class="absolute rounded-full blur-[70px] w-80 h-80 top-2 -left-20 bg-emerald-500/[0.08]" />
</div>
<div class="min-w-0">
<div class="text-[1rem] font-bold tracking-tight text-[var(--text-color)]">Segurança</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-0.5">Gerencie o acesso e a senha da sua conta</div>
</div>
</div>
<span class="hidden xl:inline-flex items-center gap-2 text-[1rem] px-3 py-1.5 rounded-full border border-emerald-200 text-emerald-700 bg-emerald-50 shrink-0">
<span class="h-2 w-2 rounded-full bg-emerald-500 animate-pulse" />
Sessão ativa
</span>
</div>
</div>
<div class="px-3 md:px-5 pb-8 flex justify-center">
<div class="w-full max-w-2xl space-y-4">
<!-- Card principal -->
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] overflow-hidden">
<!-- Seção: Trocar senha -->
<div class="px-6 py-5 border-b border-[var(--surface-border)]">
<div class="flex items-center justify-between gap-4">
<div>
<div class="text-[1rem] font-semibold text-[var(--text-color)]">Trocar senha</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-0.5">
Confirme sua senha atual e defina uma nova.
</div>
<div class="relative z-10 flex items-center gap-4">
<div class="flex items-center gap-3 flex-1 min-w-0">
<div class="grid place-items-center w-10 h-10 rounded-[0.875rem] shrink-0" style="background: color-mix(in srgb, var(--p-primary-500, #6366f1) 12%, transparent); color: var(--p-primary-500, #6366f1)">
<i class="pi pi-shield text-lg" />
</div>
<div class="min-w-0">
<div class="text-[1rem] font-bold tracking-tight text-[var(--text-color)]">Segurança</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-0.5">Gerencie o acesso e a senha da sua conta</div>
</div>
</div>
<span class="hidden sm:inline-flex items-center gap-1.5 rounded-full border border-[var(--surface-border)] bg-[var(--surface-ground)] px-3 py-1 text-[1rem] text-[var(--text-color-secondary)]">
<span class="h-1.5 w-1.5 rounded-full bg-emerald-400" />
sessão ativa
<span class="hidden xl:inline-flex items-center gap-2 text-[1rem] px-3 py-1.5 rounded-full border border-emerald-200 text-emerald-700 bg-emerald-50 shrink-0">
<span class="h-2 w-2 rounded-full bg-emerald-500 animate-pulse" />
Sessão ativa
</span>
</div>
</div>
<!-- Estado: concluído -->
<div v-if="done" class="px-6 py-10 text-center space-y-4">
<div class="mx-auto grid h-16 w-16 place-items-center rounded-full bg-emerald-500/10 border border-emerald-500/20">
<i class="pi pi-check text-emerald-500 text-2xl" />
</div>
<div>
<div class="font-semibold text-[var(--text-color)]">Senha atualizada!</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-1">Redirecionando para o login</div>
</div>
</div>
<!-- Estado: formulário -->
<div v-else class="px-6 py-6 space-y-5">
<!-- Senha atual -->
<div>
<div class="text-[1rem] font-semibold text-[var(--text-color)] mb-1.5">Senha atual</div>
<Password
v-model="currentPassword"
placeholder="Digite sua senha atual"
toggleMask
:feedback="false"
class="w-full"
inputClass="w-full"
:disabled="loading || loadingReset"
/>
<div class="mt-1.5 text-[1rem] text-[var(--text-color-secondary)]">
Necessária para confirmar que é você.
</div>
</div>
<div class="h-px bg-[var(--surface-border)]" />
<!-- Nova senha -->
<div>
<div class="text-[1rem] font-semibold text-[var(--text-color)] mb-1.5">Nova senha</div>
<Password
v-model="newPassword"
placeholder="Mínimo 8 caracteres"
toggleMask
:feedback="false"
class="w-full"
inputClass="w-full"
:disabled="loading || loadingReset"
/>
<!-- Barra de força -->
<div v-if="newPassword" class="mt-2 space-y-1">
<div class="flex gap-1">
<div
v-for="i in 4"
:key="i"
class="h-1 flex-1 rounded-full transition-all duration-300"
:class="i <= strengthScore ? strengthColor : 'bg-[var(--surface-border)]'"
/>
</div>
<span class="text-[1rem]" :class="strengthTextColor">{{ strengthLabel }}</span>
</div>
<div v-else class="mt-1.5 text-[1rem] text-[var(--text-color-secondary)]">
Critérios: 8+ caracteres, maiúscula, minúscula e número.
</div>
</div>
<!-- Confirmar senha -->
<div>
<div class="text-[1rem] font-semibold text-[var(--text-color)] mb-1.5">Confirmar nova senha</div>
<Password
v-model="confirmPassword"
placeholder="Repita a nova senha"
toggleMask
:feedback="false"
class="w-full"
inputClass="w-full"
:disabled="loading || loadingReset"
/>
<div
v-if="confirmPassword"
class="mt-1.5 flex items-center gap-1.5 text-[1rem]"
:class="matchOk ? 'text-emerald-500' : 'text-yellow-500'"
>
<i :class="matchOk ? 'pi pi-check' : 'pi pi-times'" />
{{ matchOk ? 'As senhas conferem.' : 'As senhas não conferem.' }}
</div>
</div>
<!-- Aviso -->
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-ground)] px-4 py-3 flex items-start gap-3">
<i class="pi pi-info-circle text-[var(--text-color-secondary)] mt-0.5 flex-shrink-0" />
<div class="text-[1rem] text-[var(--text-color-secondary)] leading-relaxed">
Ao trocar sua senha, você será desconectado de todos os dispositivos por segurança.
</div>
</div>
<!-- Ações -->
<div class="flex flex-col-reverse gap-2 sm:flex-row sm:justify-between sm:items-center pt-1">
<Button
label="Enviar link por e-mail"
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="!canSubmit"
@click="changePassword"
/>
</div>
</div>
</div>
<!-- Card informativo: dicas -->
<div class="mt-4 rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] px-6 py-5">
<div class="flex items-center gap-2 mb-3">
<i class="pi pi-lightbulb text-[var(--text-color-secondary)]" />
<span class="text-[1rem] font-semibold text-[var(--text-color)]">Boas práticas</span>
</div>
<ul class="space-y-2">
<li class="flex items-start gap-2.5 text-[1rem] text-[var(--text-color-secondary)]">
<span class="mt-1 h-1.5 w-1.5 rounded-full bg-indigo-400 flex-shrink-0" />
Use pelo menos 8 caracteres com maiúscula, minúscula e número.
</li>
<li class="flex items-start gap-2.5 text-[1rem] text-[var(--text-color-secondary)]">
<span class="mt-1 h-1.5 w-1.5 rounded-full bg-emerald-400 flex-shrink-0" />
Evite datas, nomes e sequências óbvias (1234, qwerty).
</li>
<li class="flex items-start gap-2.5 text-[1rem] text-[var(--text-color-secondary)]">
<span class="mt-1 h-1.5 w-1.5 rounded-full bg-fuchsia-400 flex-shrink-0" />
Se estiver em computador compartilhado, encerre a sessão depois.
</li>
<li class="flex items-start gap-2.5 text-[1rem] text-[var(--text-color-secondary)]">
<span class="mt-1 h-1.5 w-1.5 rounded-full bg-amber-400 flex-shrink-0" />
Não reutilize a mesma senha de outros serviços.
</li>
</ul>
</div>
</div>
</div>
<div class="px-3 md:px-4 pb-3">
<LoadedPhraseBlock v-if="mounted" />
</div>
<div class="px-3 md:px-5 pb-8 flex justify-center">
<div class="w-full max-w-2xl space-y-4">
<!-- Card principal -->
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] overflow-hidden">
<!-- Seção: Trocar senha -->
<div class="px-6 py-5 border-b border-[var(--surface-border)]">
<div class="flex items-center justify-between gap-4">
<div>
<div class="text-[1rem] font-semibold text-[var(--text-color)]">Trocar senha</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-0.5">Confirme sua senha atual e defina uma nova.</div>
</div>
<span class="hidden sm:inline-flex items-center gap-1.5 rounded-full border border-[var(--surface-border)] bg-[var(--surface-ground)] px-3 py-1 text-[1rem] text-[var(--text-color-secondary)]">
<span class="h-1.5 w-1.5 rounded-full bg-emerald-400" />
sessão ativa
</span>
</div>
</div>
<!-- Estado: concluído -->
<div v-if="done" class="px-6 py-10 text-center space-y-4">
<div class="mx-auto grid h-16 w-16 place-items-center rounded-full bg-emerald-500/10 border border-emerald-500/20">
<i class="pi pi-check text-emerald-500 text-2xl" />
</div>
<div>
<div class="font-semibold text-[var(--text-color)]">Senha atualizada!</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-1">Redirecionando para o login</div>
</div>
</div>
<!-- Estado: formulário -->
<div v-else class="px-6 py-6 space-y-5">
<!-- Senha atual -->
<div>
<div class="text-[1rem] font-semibold text-[var(--text-color)] mb-1.5">Senha atual</div>
<Password v-model="currentPassword" placeholder="Digite sua senha atual" toggleMask :feedback="false" class="w-full" inputClass="w-full" :disabled="loading || loadingReset" />
<div class="mt-1.5 text-[1rem] text-[var(--text-color-secondary)]">Necessária para confirmar que é você.</div>
</div>
<div class="h-px bg-[var(--surface-border)]" />
<!-- Nova senha -->
<div>
<div class="text-[1rem] font-semibold text-[var(--text-color)] mb-1.5">Nova senha</div>
<Password v-model="newPassword" placeholder="Mínimo 8 caracteres" toggleMask :feedback="false" class="w-full" inputClass="w-full" :disabled="loading || loadingReset" />
<!-- Barra de força -->
<div v-if="newPassword" class="mt-2 space-y-1">
<div class="flex gap-1">
<div v-for="i in 4" :key="i" class="h-1 flex-1 rounded-full transition-all duration-300" :class="i <= strengthScore ? strengthColor : 'bg-[var(--surface-border)]'" />
</div>
<span class="text-[1rem]" :class="strengthTextColor">{{ strengthLabel }}</span>
</div>
<div v-else class="mt-1.5 text-[1rem] text-[var(--text-color-secondary)]">Critérios: 8+ caracteres, maiúscula, minúscula e número.</div>
</div>
<!-- Confirmar senha -->
<div>
<div class="text-[1rem] font-semibold text-[var(--text-color)] mb-1.5">Confirmar nova senha</div>
<Password v-model="confirmPassword" placeholder="Repita a nova senha" toggleMask :feedback="false" class="w-full" inputClass="w-full" :disabled="loading || loadingReset" />
<div v-if="confirmPassword" class="mt-1.5 flex items-center gap-1.5 text-[1rem]" :class="matchOk ? 'text-emerald-500' : 'text-yellow-500'">
<i :class="matchOk ? 'pi pi-check' : 'pi pi-times'" />
{{ matchOk ? 'As senhas conferem.' : 'As senhas não conferem.' }}
</div>
</div>
<!-- Aviso -->
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-ground)] px-4 py-3 flex items-start gap-3">
<i class="pi pi-info-circle text-[var(--text-color-secondary)] mt-0.5 flex-shrink-0" />
<div class="text-[1rem] text-[var(--text-color-secondary)] leading-relaxed">Ao trocar sua senha, você será desconectado de todos os dispositivos por segurança.</div>
</div>
<!-- Ações -->
<div class="flex flex-col-reverse gap-2 sm:flex-row sm:justify-between sm:items-center pt-1">
<Button label="Enviar link por e-mail" 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="!canSubmit" @click="changePassword" />
</div>
</div>
</div>
<!-- Card informativo: dicas -->
<div class="mt-4 rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] px-6 py-5">
<div class="flex items-center gap-2 mb-3">
<i class="pi pi-lightbulb text-[var(--text-color-secondary)]" />
<span class="text-[1rem] font-semibold text-[var(--text-color)]">Boas práticas</span>
</div>
<ul class="space-y-2">
<li class="flex items-start gap-2.5 text-[1rem] text-[var(--text-color-secondary)]">
<span class="mt-1 h-1.5 w-1.5 rounded-full bg-indigo-400 flex-shrink-0" />
Use pelo menos 8 caracteres com maiúscula, minúscula e número.
</li>
<li class="flex items-start gap-2.5 text-[1rem] text-[var(--text-color-secondary)]">
<span class="mt-1 h-1.5 w-1.5 rounded-full bg-emerald-400 flex-shrink-0" />
Evite datas, nomes e sequências óbvias (1234, qwerty).
</li>
<li class="flex items-start gap-2.5 text-[1rem] text-[var(--text-color-secondary)]">
<span class="mt-1 h-1.5 w-1.5 rounded-full bg-fuchsia-400 flex-shrink-0" />
Se estiver em computador compartilhado, encerre a sessão depois.
</li>
<li class="flex items-start gap-2.5 text-[1rem] text-[var(--text-color-secondary)]">
<span class="mt-1 h-1.5 w-1.5 rounded-full bg-amber-400 flex-shrink-0" />
Não reutilize a mesma senha de outros serviços.
</li>
</ul>
</div>
</div>
</div>
<div class="px-3 md:px-4 pb-3">
<LoadedPhraseBlock v-if="mounted" />
</div>
</template>
<style scoped>
</style>
<style scoped></style>
+237 -279
View File
@@ -15,100 +15,109 @@
|--------------------------------------------------------------------------
-->
<script setup>
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { supabase } from '@/lib/supabase/client'
import { computed, onMounted, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { supabase } from '@/lib/supabase/client';
import Message from 'primevue/message'
import Chip from 'primevue/chip'
import ProgressSpinner from 'primevue/progressspinner'
import Message from 'primevue/message';
import Chip from 'primevue/chip';
import ProgressSpinner from 'primevue/progressspinner';
const route = useRoute()
const router = useRouter()
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())
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
function normalizeInterval(v) {
if (v === 'monthly') return 'month';
if (v === 'annual' || v === 'yearly') return 'year';
return v;
}
const intervalNormalized = computed(() => normalizeInterval(intervalFromQuery.value))
const intervalNormalized = computed(() => normalizeInterval(intervalFromQuery.value));
const intervalLabel = computed(() => {
if (intervalNormalized.value === 'year') return 'Anual'
if (intervalNormalized.value === 'month') return 'Mensal'
return ''
})
if (intervalNormalized.value === 'year') return 'Anual';
if (intervalNormalized.value === 'month') return 'Mensal';
return '';
});
const hasPlanQuery = computed(() => !!planFromQuery.value)
const hasPlanQuery = computed(() => !!planFromQuery.value);
// ============================
// Session (opcional para CTA melhor)
// ============================
const hasSession = ref(false)
const hasSession = ref(false);
async function checkSession () {
try {
const { data } = await supabase.auth.getSession()
hasSession.value = !!data?.session
} catch {
hasSession.value = false
}
async function checkSession() {
try {
const { data } = await supabase.auth.getSession();
hasSession.value = !!data?.session;
} catch {
hasSession.value = false;
}
}
// ============================
// Pricing
// ============================
const loading = ref(false)
const planRow = ref(null)
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 planName = computed(() => planRow.value?.public_name || planRow.value?.plan_name || null);
const planDescription = computed(() => planRow.value?.public_description || null);
function amountForInterval (row, interval) {
if (!row) return null
const cents = interval === 'year' ? row.yearly_cents : row.monthly_cents
// fallback inteligente: se não houver preço nesse intervalo, tenta o outro
if (cents == null) return interval === 'year' ? row.monthly_cents : row.yearly_cents
return cents
function amountForInterval(row, interval) {
if (!row) return null;
const cents = interval === 'year' ? row.yearly_cents : row.monthly_cents;
// fallback inteligente: se não houver preço nesse intervalo, tenta o outro
if (cents == null) return interval === 'year' ? row.monthly_cents : row.yearly_cents;
return cents;
}
function currencyForInterval (row, interval) {
if (!row) return 'BRL'
const cur = interval === 'year' ? (row.yearly_currency || 'BRL') : (row.monthly_currency || 'BRL')
return cur || 'BRL'
function currencyForInterval(row, interval) {
if (!row) return 'BRL';
const cur = interval === 'year' ? row.yearly_currency || 'BRL' : row.monthly_currency || 'BRL';
return cur || 'BRL';
}
const amountCents = computed(() => amountForInterval(planRow.value, intervalNormalized.value))
const currency = computed(() => currencyForInterval(planRow.value, intervalNormalized.value))
const amountCents = computed(() => amountForInterval(planRow.value, intervalNormalized.value));
const currency = computed(() => currencyForInterval(planRow.value, intervalNormalized.value));
const formattedPrice = computed(() => {
if (amountCents.value == null) return null
try {
return new Intl.NumberFormat('pt-BR', {
style: 'currency',
currency: currency.value || 'BRL'
}).format(Number(amountCents.value) / 100)
} catch {
return null
}
})
if (amountCents.value == null) return null;
try {
return new Intl.NumberFormat('pt-BR', {
style: 'currency',
currency: currency.value || 'BRL'
}).format(Number(amountCents.value) / 100);
} catch {
return null;
}
});
async function loadPlan () {
planRow.value = null
if (!planFromQuery.value) return
async function loadPlan() {
planRow.value = null;
if (!planFromQuery.value) return;
loading.value = true
try {
const { data, error } = await supabase
.from('v_public_pricing')
.select(`
loading.value = true;
try {
const { data, error } = await supabase
.from('v_public_pricing')
.select(
`
plan_key,
plan_name,
public_name,
@@ -120,239 +129,188 @@ async function loadPlan () {
monthly_currency,
yearly_currency,
is_visible
`)
.eq('plan_key', planFromQuery.value)
.eq('is_visible', true)
.maybeSingle()
`
)
.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
}
if (error) throw error;
if (data) planRow.value = data;
} catch (err) {
console.error('[Welcome] loadPlan:', err);
} finally {
loading.value = false;
}
}
function goLogin () {
router.push('/auth/login')
function goLogin() {
router.push('/auth/login');
}
function goBackLanding () {
router.push('/lp')
function goBackLanding() {
router.push('/lp');
}
function goDashboard () {
router.push('/admin')
function goDashboard() {
router.push('/admin');
}
function goPricing () {
router.push('/lp#pricing')
function goPricing() {
router.push('/lp#pricing');
}
onMounted(async () => {
await checkSession()
await loadPlan()
})
await checkSession();
await loadPlan();
});
watch(
() => planFromQuery.value,
() => loadPlan()
)
() => planFromQuery.value,
() => loadPlan()
);
</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 -->
<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 leading-relaxed">
Sua conta foi criada e 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 confirmado
</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="Gateway depois, sem retrabalho" />
</div>
<div class="mt-6 text-xs text-[var(--text-color-secondary)]">
* Boas-vindas inspirada em layouts PrimeBlocks.
</div>
</div>
<!-- RIGHT -->
<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 leading-relaxed">
Você pode entrar. Se o seu plano for PRO, ele será ativado após confirmação do pagamento.
</div>
<div class="mt-5">
<Message severity="success" class="mb-3">
Intenção de assinatura 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 rounded-[2rem]">
<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</div>
<div v-if="hasPlanQuery" 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 v-else class="mt-1">
<div class="text-lg font-semibold">Sem plano selecionado</div>
<div class="text-sm text-[var(--text-color-secondary)] mt-1">
Você pode escolher um plano agora ou seguir no FREE.
</div>
</div>
<div v-if="hasPlanQuery" 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)] leading-relaxed">
{{ planDescription }}
</div>
<Message v-if="hasPlanQuery && !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/boleto).
Assim que confirmado, sua assinatura será ativada.
</Message>
<div v-if="!hasPlanQuery" class="mt-3">
<Button
label="Escolher um plano"
icon="pi pi-credit-card"
severity="secondary"
outlined
class="w-full"
@click="goPricing"
/>
</div>
</template>
</Card>
</div>
<div class="mt-5 gap-2">
<Button
v-if="hasSession"
label="Ir para o painel"
class="w-full mb-2"
icon="pi pi-arrow-right"
@click="goDashboard"
/>
<Button
v-else
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 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 -->
<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 leading-relaxed">
Sua conta foi criada e 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 confirmado</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="Gateway depois, sem retrabalho" />
</div>
<div class="mt-6 text-xs text-[var(--text-color-secondary)]">* Boas-vindas inspirada em layouts PrimeBlocks.</div>
</div>
<!-- RIGHT -->
<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 leading-relaxed">Você pode entrar. Se o seu plano for PRO, ele será ativado após confirmação do pagamento.</div>
<div class="mt-5">
<Message severity="success" class="mb-3"> Intenção de assinatura 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 rounded-[2rem]">
<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</div>
<div v-if="hasPlanQuery" 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 v-else class="mt-1">
<div class="text-lg font-semibold">Sem plano selecionado</div>
<div class="text-sm text-[var(--text-color-secondary)] mt-1">Você pode escolher um plano agora ou seguir no FREE.</div>
</div>
<div v-if="hasPlanQuery" 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)] leading-relaxed">
{{ planDescription }}
</div>
<Message v-if="hasPlanQuery && !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/boleto). Assim que confirmado, sua assinatura será ativada. </Message>
<div v-if="!hasPlanQuery" class="mt-3">
<Button label="Escolher um plano" icon="pi pi-credit-card" severity="secondary" outlined class="w-full" @click="goPricing" />
</div>
</template>
</Card>
</div>
<div class="mt-5 gap-2">
<Button v-if="hasSession" label="Ir para o painel" class="w-full mb-2" icon="pi pi-arrow-right" @click="goDashboard" />
<Button v-else 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>
</div>
</div>
</template>
</template>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+350 -395
View File
@@ -15,163 +15,156 @@
|--------------------------------------------------------------------------
-->
<script setup>
import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { computed, onMounted, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useToast } from 'primevue/usetoast'
import { useToast } from 'primevue/usetoast';
import { supabase } from '@/lib/supabase/client'
import { supabase } from '@/lib/supabase/client';
const toast = useToast()
const route = useRoute()
const router = useRouter()
const toast = useToast();
const route = useRoute();
const router = useRouter();
const loading = ref(false)
const saving = ref(false)
const isFetching = ref(false)
const loading = ref(false);
const saving = ref(false);
const isFetching = ref(false);
const uid = ref(null)
const currentSub = ref(null)
const uid = ref(null);
const currentSub = ref(null);
const plans = ref([]) // plans (therapist)
const prices = ref([]) // plan_prices ativos do momento
const plans = ref([]); // plans (therapist)
const prices = ref([]); // plan_prices ativos do momento
const q = ref('')
const q = ref('');
const billingInterval = ref('month') // 'month' | 'year'
const billingInterval = ref('month'); // 'month' | 'year'
const intervalOptions = [
{ label: 'Mensal', value: 'month' },
{ label: 'Anual', value: 'year' }
]
{ label: 'Mensal', value: 'month' },
{ label: 'Anual', value: 'year' }
];
const redirectTo = computed(() => route.query.redirectTo || '/therapist/meu-plano')
const redirectTo = computed(() => route.query.redirectTo || '/therapist/meu-plano');
function money (currency, amountCents) {
if (amountCents == null) return null
const value = Number(amountCents) / 100
try {
return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: currency || 'BRL' }).format(value)
} catch (_) {
return `${value.toFixed(2)} ${currency || ''}`.trim()
}
function money(currency, amountCents) {
if (amountCents == null) return null;
const value = Number(amountCents) / 100;
try {
return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: currency || 'BRL' }).format(value);
} catch (_) {
return `${value.toFixed(2)} ${currency || ''}`.trim();
}
}
function intervalLabel (i) {
if (i === 'month') return 'mês'
if (i === 'year') return 'ano'
return i || '-'
function intervalLabel(i) {
if (i === 'month') return 'mês';
if (i === 'year') return 'ano';
return i || '-';
}
function priceFor (planId, interval) {
return (prices.value || []).find(p => String(p.plan_id) === String(planId) && String(p.interval) === String(interval)) || null
function priceFor(planId, interval) {
return (prices.value || []).find((p) => String(p.plan_id) === String(planId) && String(p.interval) === String(interval)) || null;
}
function priceLabelForCard (planRow) {
const pp = priceFor(planRow?.id, billingInterval.value)
if (!pp) return '—'
return `${money(pp.currency, pp.amount_cents)} / ${intervalLabel(billingInterval.value)}`
function priceLabelForCard(planRow) {
const pp = priceFor(planRow?.id, billingInterval.value);
if (!pp) return '—';
return `${money(pp.currency, pp.amount_cents)} / ${intervalLabel(billingInterval.value)}`;
}
const filteredPlans = computed(() => {
const term = String(q.value || '').trim().toLowerCase()
if (!term) return plans.value || []
return (plans.value || []).filter(p => {
const a = String(p.key || '').toLowerCase()
const b = String(p.name || '').toLowerCase()
const c = String(p.description || '').toLowerCase()
return a.includes(term) || b.includes(term) || c.includes(term)
})
})
const term = String(q.value || '')
.trim()
.toLowerCase();
if (!term) return plans.value || [];
return (plans.value || []).filter((p) => {
const a = String(p.key || '').toLowerCase();
const b = String(p.name || '').toLowerCase();
const c = String(p.description || '').toLowerCase();
return a.includes(term) || b.includes(term) || c.includes(term);
});
});
async function loadData () {
if (isFetching.value) return
isFetching.value = true
loading.value = true
async function loadData() {
if (isFetching.value) return;
isFetching.value = true;
loading.value = true;
try {
const { data: authData, error: authError } = await supabase.auth.getUser()
if (authError) throw authError
try {
const { data: authData, error: authError } = await supabase.auth.getUser();
if (authError) throw authError;
uid.value = authData?.user?.id
if (!uid.value) throw new Error('Sessão não encontrada.')
uid.value = authData?.user?.id;
if (!uid.value) throw new Error('Sessão não encontrada.');
// assinatura pessoal atual (Modelo A): busca por user_id, prioriza status ativo
const sRes = await supabase
.from('subscriptions')
.select('*')
.eq('user_id', uid.value)
.order('created_at', { ascending: false })
.limit(10)
// assinatura pessoal atual (Modelo A): busca por user_id, prioriza status ativo
const sRes = await supabase.from('subscriptions').select('*').eq('user_id', uid.value).order('created_at', { ascending: false }).limit(10);
if (sRes.error) throw sRes.error
if (sRes.error) throw sRes.error;
const subList = sRes.data || []
const subPriority = (st) => {
const s = String(st || '').toLowerCase()
if (s === 'active') return 1
if (s === 'trialing') return 2
if (s === 'past_due') return 3
if (s === 'unpaid') return 4
if (s === 'incomplete') return 5
if (s === 'canceled' || s === 'cancelled') return 9
return 8
const subList = sRes.data || [];
const subPriority = (st) => {
const s = String(st || '').toLowerCase();
if (s === 'active') return 1;
if (s === 'trialing') return 2;
if (s === 'past_due') return 3;
if (s === 'unpaid') return 4;
if (s === 'incomplete') return 5;
if (s === 'canceled' || s === 'cancelled') return 9;
return 8;
};
currentSub.value = subList.length
? subList.slice().sort((a, b) => {
const pa = subPriority(a?.status);
const pb = subPriority(b?.status);
if (pa !== pb) return pa - pb;
return new Date(b?.created_at || 0) - new Date(a?.created_at || 0);
})[0]
: null;
// planos do terapeuta (target = therapist)
const pRes = await supabase.from('plans').select('id, key, name, description, target, is_active').eq('target', 'therapist').order('created_at', { ascending: true });
if (pRes.error) throw pRes.error;
plans.value = (pRes.data || []).filter((p) => p?.is_active !== false);
// preços ativos (janela de vigência)
const nowIso = new Date().toISOString();
const ppRes = await supabase
.from('plan_prices')
.select('plan_id, currency, interval, amount_cents, is_active, active_from, active_to')
.eq('is_active', true)
.lte('active_from', nowIso)
.or(`active_to.is.null,active_to.gte.${nowIso}`)
.order('active_from', { ascending: false });
if (ppRes.error) throw ppRes.error;
prices.value = ppRes.data || [];
} catch (e) {
console.error(e);
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || String(e), life: 5000 });
} finally {
loading.value = false;
isFetching.value = false;
}
currentSub.value = subList.length
? subList.slice().sort((a, b) => {
const pa = subPriority(a?.status)
const pb = subPriority(b?.status)
if (pa !== pb) return pa - pb
return new Date(b?.created_at || 0) - new Date(a?.created_at || 0)
})[0]
: null
// planos do terapeuta (target = therapist)
const pRes = await supabase
.from('plans')
.select('id, key, name, description, target, is_active')
.eq('target', 'therapist')
.order('created_at', { ascending: true })
if (pRes.error) throw pRes.error
plans.value = (pRes.data || []).filter(p => p?.is_active !== false)
// preços ativos (janela de vigência)
const nowIso = new Date().toISOString()
const ppRes = await supabase
.from('plan_prices')
.select('plan_id, currency, interval, amount_cents, is_active, active_from, active_to')
.eq('is_active', true)
.lte('active_from', nowIso)
.or(`active_to.is.null,active_to.gte.${nowIso}`)
.order('active_from', { ascending: false })
if (ppRes.error) throw ppRes.error
prices.value = ppRes.data || []
} catch (e) {
console.error(e)
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || String(e), life: 5000 })
} finally {
loading.value = false
isFetching.value = false
}
}
function preflight (planRow, interval) {
if (!uid.value) return { ok: false, msg: 'Sessão não encontrada.' }
if (!planRow?.id) return { ok: false, msg: 'Plano inválido.' }
if (!['month', 'year'].includes(String(interval))) return { ok: false, msg: 'Intervalo inválido.' }
function preflight(planRow, interval) {
if (!uid.value) return { ok: false, msg: 'Sessão não encontrada.' };
if (!planRow?.id) return { ok: false, msg: 'Plano inválido.' };
if (!['month', 'year'].includes(String(interval))) return { ok: false, msg: 'Intervalo inválido.' };
const pp = priceFor(planRow.id, interval)
if (!pp) {
return { ok: false, msg: `Este plano não tem preço ativo para ${intervalLabel(interval)}.` }
}
const pp = priceFor(planRow.id, interval);
if (!pp) {
return { ok: false, msg: `Este plano não tem preço ativo para ${intervalLabel(interval)}.` };
}
// se já estiver nesse plano+intervalo, evita ação
if (currentSub.value?.plan_id === planRow.id && String(currentSub.value?.interval) === String(interval)) {
return { ok: false, msg: 'Você já está nesse plano/intervalo.' }
}
// se já estiver nesse plano+intervalo, evita ação
if (currentSub.value?.plan_id === planRow.id && String(currentSub.value?.interval) === String(interval)) {
return { ok: false, msg: 'Você já está nesse plano/intervalo.' };
}
return { ok: true, msg: '' }
return { ok: true, msg: '' };
}
/**
@@ -184,301 +177,263 @@ function preflight (planRow, interval) {
* e que interval pode ser alterado por update simples ou outro RPC.
* - se você tiver um RPC específico para intervalo, só troca abaixo.
*/
async function choosePlan (planRow, interval) {
const pf = preflight(planRow, interval)
if (!pf.ok) {
toast.add({ severity: 'warn', summary: 'Atenção', detail: pf.msg, life: 4200 })
return
}
saving.value = true
try {
const nowIso = new Date().toISOString()
if (currentSub.value?.id) {
// 1) troca plano via RPC (auditoria)
const { error: e1 } = await supabase.rpc('change_subscription_plan', {
p_subscription_id: currentSub.value.id,
p_new_plan_id: planRow.id
})
if (e1) throw e1
// 2) atualiza intervalo (se o seu RPC já faz isso, pode remover)
const { error: e2 } = await supabase
.from('subscriptions')
.update({
interval,
updated_at: nowIso,
cancel_at_period_end: false,
status: 'active'
})
.eq('id', currentSub.value.id)
if (e2) throw e2
} else {
// cria subscription pessoal
const { data: ins, error: eIns } = await supabase
.from('subscriptions')
.insert({
user_id: uid.value,
tenant_id: null,
plan_id: planRow.id,
plan_key: planRow.key,
interval,
status: 'active',
cancel_at_period_end: false,
provider: 'manual',
source: 'therapist_upgrade',
started_at: nowIso,
current_period_start: nowIso
})
.select('*')
.maybeSingle()
if (eIns) throw eIns
currentSub.value = ins || null
async function choosePlan(planRow, interval) {
const pf = preflight(planRow, interval);
if (!pf.ok) {
toast.add({ severity: 'warn', summary: 'Atenção', detail: pf.msg, life: 4200 });
return;
}
// (opcional) se sua regra de entitlements depende disso, você pode rebuildar aqui:
// await supabase.rpc('rebuild_owner_entitlements', { p_owner_id: uid.value })
saving.value = true;
try {
const nowIso = new Date().toISOString();
toast.add({
severity: 'success',
summary: 'Plano atualizado',
detail: 'Seu plano foi atualizado com sucesso.',
life: 3200
})
if (currentSub.value?.id) {
// 1) troca plano via RPC (auditoria)
const { error: e1 } = await supabase.rpc('change_subscription_plan', {
p_subscription_id: currentSub.value.id,
p_new_plan_id: planRow.id
});
if (e1) throw e1;
// ✅ garante refletir estado real
await loadData()
// 2) atualiza intervalo (se o seu RPC já faz isso, pode remover)
const { error: e2 } = await supabase
.from('subscriptions')
.update({
interval,
updated_at: nowIso,
cancel_at_period_end: false,
status: 'active'
})
.eq('id', currentSub.value.id);
if (e2) throw e2;
} else {
// cria subscription pessoal
const { data: ins, error: eIns } = await supabase
.from('subscriptions')
.insert({
user_id: uid.value,
tenant_id: null,
plan_id: planRow.id,
plan_key: planRow.key,
interval,
status: 'active',
cancel_at_period_end: false,
provider: 'manual',
source: 'therapist_upgrade',
started_at: nowIso,
current_period_start: nowIso
})
.select('*')
.maybeSingle();
// redirect
await router.push(String(redirectTo.value))
} catch (e) {
console.error(e)
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || String(e), life: 5000 })
} finally {
saving.value = false
}
if (eIns) throw eIns;
currentSub.value = ins || null;
}
// (opcional) se sua regra de entitlements depende disso, você pode rebuildar aqui:
// await supabase.rpc('rebuild_owner_entitlements', { p_owner_id: uid.value })
toast.add({
severity: 'success',
summary: 'Plano atualizado',
detail: 'Seu plano foi atualizado com sucesso.',
life: 3200
});
// ✅ garante refletir estado real
await loadData();
// redirect
await router.push(String(redirectTo.value));
} catch (e) {
console.error(e);
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || String(e), life: 5000 });
} finally {
saving.value = false;
}
}
function goBack () {
router.push(String(redirectTo.value))
function goBack() {
router.push(String(redirectTo.value));
}
onMounted(loadData)
onMounted(loadData);
</script>
<template>
<!-- Sentinel -->
<div class="h-px" />
<!-- Sentinel -->
<div class="h-px" />
<!--
<!--
HERO sticky
-->
<section
class="sticky mx-3 md:mx-4 mb-3 z-20 overflow-hidden rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] px-3 py-2.5"
:style="{ top: 'var(--layout-sticky-top, 56px)' }"
>
<!-- Blobs -->
<div class="absolute inset-0 pointer-events-none overflow-hidden" aria-hidden="true">
<div class="absolute w-64 h-64 -top-16 -right-8 rounded-full blur-[60px] bg-indigo-500/10" />
<div class="absolute w-72 h-72 top-0 -left-16 rounded-full blur-[60px] bg-emerald-400/[0.08]" />
</div>
<div class="relative z-[1] flex flex-col gap-2.5">
<!-- Linha 1: brand + busca + ações -->
<div class="flex items-center gap-3 flex-wrap">
<!-- Brand -->
<div class="flex items-center gap-2 flex-shrink-0">
<div class="grid place-items-center w-9 h-9 rounded-md flex-shrink-0 bg-indigo-500/10 text-indigo-500">
<i class="pi pi-arrow-up-right text-base" />
</div>
<div class="min-w-0 hidden sm:block">
<div class="text-[1rem] font-bold tracking-tight text-[var(--text-color)]">Upgrade do Terapeuta</div>
<div class="text-[1rem] text-[var(--text-color-secondary)]">Escolha seu plano pessoal (Modelo A)</div>
</div>
<section class="sticky mx-3 md:mx-4 mb-3 z-20 overflow-hidden rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] px-3 py-2.5" :style="{ top: 'var(--layout-sticky-top, 56px)' }">
<!-- Blobs -->
<div class="absolute inset-0 pointer-events-none overflow-hidden" aria-hidden="true">
<div class="absolute w-64 h-64 -top-16 -right-8 rounded-full blur-[60px] bg-indigo-500/10" />
<div class="absolute w-72 h-72 top-0 -left-16 rounded-full blur-[60px] bg-emerald-400/[0.08]" />
</div>
<!-- Busca desktop -->
<div class="hidden md:flex flex-1 min-w-[180px] max-w-xs">
<IconField class="w-full">
<InputIcon class="pi pi-search" />
<InputText v-model="q" placeholder="Buscar plano..." class="w-full" autocomplete="off" :disabled="loading || saving" />
</IconField>
<div class="relative z-[1] flex flex-col gap-2.5">
<!-- Linha 1: brand + busca + ações -->
<div class="flex items-center gap-3 flex-wrap">
<!-- Brand -->
<div class="flex items-center gap-2 flex-shrink-0">
<div class="grid place-items-center w-9 h-9 rounded-md flex-shrink-0 bg-indigo-500/10 text-indigo-500">
<i class="pi pi-arrow-up-right text-base" />
</div>
<div class="min-w-0 hidden sm:block">
<div class="text-[1rem] font-bold tracking-tight text-[var(--text-color)]">Upgrade do Terapeuta</div>
<div class="text-[1rem] text-[var(--text-color-secondary)]">Escolha seu plano pessoal (Modelo A)</div>
</div>
</div>
<!-- Busca desktop -->
<div class="hidden md:flex flex-1 min-w-[180px] max-w-xs">
<IconField class="w-full">
<InputIcon class="pi pi-search" />
<InputText v-model="q" placeholder="Buscar plano..." class="w-full" autocomplete="off" :disabled="loading || saving" />
</IconField>
</div>
<!-- Ações -->
<div class="flex items-center gap-1 flex-shrink-0 ml-auto">
<Button icon="pi pi-refresh" severity="secondary" outlined class="h-9 w-9 rounded-full flex-shrink-0" :loading="loading" :disabled="saving" title="Atualizar" @click="loadData" />
<Button label="Voltar" icon="pi pi-arrow-left" severity="secondary" outlined class="rounded-full" :disabled="saving" @click="goBack" />
</div>
</div>
<!-- Linha 2: busca mobile + seletor de intervalo -->
<div class="flex flex-wrap items-center gap-2">
<!-- Busca mobile -->
<div class="flex md:hidden flex-1 min-w-[160px]">
<IconField class="w-full">
<InputIcon class="pi pi-search" />
<InputText v-model="q" placeholder="Buscar plano..." class="w-full" autocomplete="off" :disabled="loading || saving" />
</IconField>
</div>
<!-- Intervalo chips -->
<div class="flex items-center gap-1.5 flex-shrink-0">
<span class="text-[1rem] text-[var(--text-color-secondary)] opacity-70 hidden sm:inline">Preço:</span>
<button
v-for="opt in intervalOptions"
:key="opt.value"
class="inline-flex items-center gap-1.5 px-3.5 py-1 rounded-full text-[1rem] font-semibold border-[1.5px] cursor-pointer transition-all duration-150 select-none"
:class="
billingInterval === opt.value
? 'bg-[var(--primary-color,#6366f1)] border-[var(--primary-color,#6366f1)] text-white'
: 'border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-ground,#f8fafc)] text-[var(--text-color-secondary)] hover:border-indigo-300 hover:text-[var(--text-color)]'
"
:disabled="loading || saving"
@click="billingInterval = opt.value"
>
{{ opt.label }}
</button>
</div>
<!-- Plano atual -->
<Tag v-if="currentSub" :value="`Atual: ${currentSub.plan_key} · ${intervalLabel(currentSub.interval)}`" severity="success" />
<Tag v-else value="Sem plano pessoal" severity="warning" />
</div>
</div>
</section>
<!-- Ações -->
<div class="flex items-center gap-1 flex-shrink-0 ml-auto">
<Button icon="pi pi-refresh" severity="secondary" outlined class="h-9 w-9 rounded-full flex-shrink-0" :loading="loading" :disabled="saving" title="Atualizar" @click="loadData" />
<Button label="Voltar" icon="pi pi-arrow-left" severity="secondary" outlined class="rounded-full" :disabled="saving" @click="goBack" />
</div>
</div>
<!-- Linha 2: busca mobile + seletor de intervalo -->
<div class="flex flex-wrap items-center gap-2">
<!-- Busca mobile -->
<div class="flex md:hidden flex-1 min-w-[160px]">
<IconField class="w-full">
<InputIcon class="pi pi-search" />
<InputText v-model="q" placeholder="Buscar plano..." class="w-full" autocomplete="off" :disabled="loading || saving" />
</IconField>
</div>
<!-- Intervalo chips -->
<div class="flex items-center gap-1.5 flex-shrink-0">
<span class="text-[1rem] text-[var(--text-color-secondary)] opacity-70 hidden sm:inline">Preço:</span>
<button
v-for="opt in intervalOptions"
:key="opt.value"
class="inline-flex items-center gap-1.5 px-3.5 py-1 rounded-full text-[1rem] font-semibold border-[1.5px] cursor-pointer transition-all duration-150 select-none"
:class="billingInterval === opt.value
? 'bg-[var(--primary-color,#6366f1)] border-[var(--primary-color,#6366f1)] text-white'
: 'border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-ground,#f8fafc)] text-[var(--text-color-secondary)] hover:border-indigo-300 hover:text-[var(--text-color)]'"
:disabled="loading || saving"
@click="billingInterval = opt.value"
>{{ opt.label }}</button>
</div>
<!-- Plano atual -->
<Tag
v-if="currentSub"
:value="`Atual: ${currentSub.plan_key} · ${intervalLabel(currentSub.interval)}`"
severity="success"
/>
<Tag v-else value="Sem plano pessoal" severity="warning" />
</div>
</div>
</section>
<!--
<!--
QUICK-STATS
-->
<div class="flex flex-wrap gap-2 px-3 md:px-4 mb-3">
<div class="flex flex-col gap-0.5 px-4 py-2.5 rounded-md border flex-1 min-w-[80px] border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)]">
<div class="text-[1.35rem] font-bold leading-none text-[var(--text-color)]">{{ filteredPlans.length }}</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-75">Planos disponíveis</div>
<div class="flex flex-wrap gap-2 px-3 md:px-4 mb-3">
<div class="flex flex-col gap-0.5 px-4 py-2.5 rounded-md border flex-1 min-w-[80px] border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)]">
<div class="text-[1.35rem] font-bold leading-none text-[var(--text-color)]">{{ filteredPlans.length }}</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-75">Planos disponíveis</div>
</div>
<div class="flex flex-col gap-0.5 px-4 py-2.5 rounded-md border flex-1 min-w-[80px] border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)]">
<div class="text-[1.1rem] font-bold leading-none text-[var(--text-color)] truncate">{{ currentSub?.plan_key || '—' }}</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-75">Plano atual</div>
</div>
<div class="flex flex-col gap-0.5 px-4 py-2.5 rounded-md border flex-1 min-w-[80px] border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)]">
<div class="text-[1.1rem] font-bold leading-none text-[var(--text-color)]">{{ billingInterval === 'month' ? 'Mensal' : 'Anual' }}</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-75">Exibição de preço</div>
</div>
</div>
<div class="flex flex-col gap-0.5 px-4 py-2.5 rounded-md border flex-1 min-w-[80px] border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)]">
<div class="text-[1.1rem] font-bold leading-none text-[var(--text-color)] truncate">{{ currentSub?.plan_key || '—' }}</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-75">Plano atual</div>
</div>
<div class="flex flex-col gap-0.5 px-4 py-2.5 rounded-md border flex-1 min-w-[80px] border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)]">
<div class="text-[1.1rem] font-bold leading-none text-[var(--text-color)]">{{ billingInterval === 'month' ? 'Mensal' : 'Anual' }}</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-75">Exibição de preço</div>
</div>
</div>
<!--
<!--
PLANOS
-->
<div class="px-3 md:px-4 pb-8">
<!-- Loading skeleton -->
<div v-if="loading" class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-3">
<div
v-for="n in 3"
:key="n"
class="rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] p-4"
>
<div class="flex flex-col gap-3">
<div class="h-4 w-2/5 rounded-md bg-[var(--surface-border,#e2e8f0)] animate-pulse" />
<div class="h-3 w-3/5 rounded-md bg-[var(--surface-border,#e2e8f0)] animate-pulse" />
<div class="h-8 w-1/3 rounded-md bg-[var(--surface-border,#e2e8f0)] animate-pulse" />
</div>
</div>
</div>
<!-- Empty state -->
<div
v-else-if="!filteredPlans.length"
class="flex flex-col items-center justify-center gap-4 rounded-md border-2 border-dashed border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-ground,#f8fafc)] py-16 px-6 text-center"
>
<div class="relative">
<div class="grid place-items-center w-16 h-16 rounded-2xl bg-[var(--surface-card,#fff)] border border-[var(--surface-border,#e2e8f0)] shadow-sm text-[var(--text-color-secondary)]">
<i class="pi pi-box text-3xl opacity-30" />
</div>
<div class="absolute -top-2 -right-2 w-7 h-7 rounded-full bg-[var(--surface-card,#fff)] border border-[var(--surface-border,#e2e8f0)] shadow-sm grid place-items-center">
<i class="pi pi-times text-[0.65rem] text-[var(--text-color-secondary)] opacity-50" />
</div>
</div>
<div>
<div class="font-bold text-[1rem] text-[var(--text-color)] mb-1">Nenhum plano encontrado</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] max-w-xs leading-relaxed">Tente limpar o filtro de busca.</div>
</div>
<Button label="Limpar busca" icon="pi pi-filter-slash" severity="secondary" outlined class="rounded-full mt-1" @click="q = ''" />
</div>
<!-- Grid de planos -->
<div v-else class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-3">
<div
v-for="p in filteredPlans"
:key="p.id"
class="rounded-md border bg-[var(--surface-card,#fff)] overflow-hidden flex flex-col transition-shadow duration-150 hover:shadow-[0_4px_18px_rgba(0,0,0,0.07)]"
:class="currentSub?.plan_id === p.id
? 'border-emerald-400/40 ring-1 ring-emerald-500/20'
: 'border-[var(--surface-border,#e2e8f0)]'"
>
<!-- Cabeçalho do card -->
<div class="flex items-center justify-between px-4 py-3 border-b border-[var(--surface-border,#e2e8f0)]">
<div class="min-w-0">
<div class="font-bold text-[0.9rem] text-[var(--text-color)] truncate">{{ p.name || p.key }}</div>
<div class="text-[1rem] text-[var(--text-color-secondary)]">{{ p.key }}</div>
</div>
<Tag v-if="currentSub?.plan_id === p.id" severity="success" value="Atual" />
</div>
<!-- Corpo do card -->
<div class="p-4 flex flex-col gap-4 flex-1">
<!-- Descrição -->
<div v-if="p.description" class="text-[1rem] text-[var(--text-color-secondary)]">{{ p.description }}</div>
<!-- Preço -->
<div>
<div class="text-[2rem] font-bold leading-none text-[var(--text-color)]">{{ priceLabelForCard(p) }}</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-1 opacity-70">Alternar mensal/anual no topo para comparar.</div>
</div>
<!-- Ações -->
<div class="flex flex-col gap-2 mt-auto">
<Button
:label="billingInterval === 'month' ? 'Escolher mensal' : 'Escolher anual'"
icon="pi pi-check"
class="rounded-full w-full"
:loading="saving"
:disabled="loading || saving"
@click="choosePlan(p, billingInterval)"
/>
<div class="flex gap-2">
<Button
label="Mensal"
severity="secondary"
outlined
class="rounded-full flex-1"
:disabled="loading || saving"
@click="choosePlan(p, 'month')"
/>
<Button
label="Anual"
severity="secondary"
outlined
class="rounded-full flex-1"
:disabled="loading || saving"
@click="choosePlan(p, 'year')"
/>
<div class="px-3 md:px-4 pb-8">
<!-- Loading skeleton -->
<div v-if="loading" class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-3">
<div v-for="n in 3" :key="n" class="rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] p-4">
<div class="flex flex-col gap-3">
<div class="h-4 w-2/5 rounded-md bg-[var(--surface-border,#e2e8f0)] animate-pulse" />
<div class="h-3 w-3/5 rounded-md bg-[var(--surface-border,#e2e8f0)] animate-pulse" />
<div class="h-8 w-1/3 rounded-md bg-[var(--surface-border,#e2e8f0)] animate-pulse" />
</div>
</div>
</div>
<!-- Status do preço -->
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-60">
<span v-if="priceFor(p.id, billingInterval)">Preço ativo encontrado para {{ intervalLabel(billingInterval) }}.</span>
<span v-else>Sem preço ativo para {{ intervalLabel(billingInterval) }}.</span>
</div>
</div>
</div>
</div>
</div>
</template>
<!-- Empty state -->
<div v-else-if="!filteredPlans.length" class="flex flex-col items-center justify-center gap-4 rounded-md border-2 border-dashed border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-ground,#f8fafc)] py-16 px-6 text-center">
<div class="relative">
<div class="grid place-items-center w-16 h-16 rounded-2xl bg-[var(--surface-card,#fff)] border border-[var(--surface-border,#e2e8f0)] shadow-sm text-[var(--text-color-secondary)]">
<i class="pi pi-box text-3xl opacity-30" />
</div>
<div class="absolute -top-2 -right-2 w-7 h-7 rounded-full bg-[var(--surface-card,#fff)] border border-[var(--surface-border,#e2e8f0)] shadow-sm grid place-items-center">
<i class="pi pi-times text-[0.65rem] text-[var(--text-color-secondary)] opacity-50" />
</div>
</div>
<div>
<div class="font-bold text-[1rem] text-[var(--text-color)] mb-1">Nenhum plano encontrado</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] max-w-xs leading-relaxed">Tente limpar o filtro de busca.</div>
</div>
<Button label="Limpar busca" icon="pi pi-filter-slash" severity="secondary" outlined class="rounded-full mt-1" @click="q = ''" />
</div>
<!-- Grid de planos -->
<div v-else class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-3">
<div
v-for="p in filteredPlans"
:key="p.id"
class="rounded-md border bg-[var(--surface-card,#fff)] overflow-hidden flex flex-col transition-shadow duration-150 hover:shadow-[0_4px_18px_rgba(0,0,0,0.07)]"
:class="currentSub?.plan_id === p.id ? 'border-emerald-400/40 ring-1 ring-emerald-500/20' : 'border-[var(--surface-border,#e2e8f0)]'"
>
<!-- Cabeçalho do card -->
<div class="flex items-center justify-between px-4 py-3 border-b border-[var(--surface-border,#e2e8f0)]">
<div class="min-w-0">
<div class="font-bold text-[0.9rem] text-[var(--text-color)] truncate">{{ p.name || p.key }}</div>
<div class="text-[1rem] text-[var(--text-color-secondary)]">{{ p.key }}</div>
</div>
<Tag v-if="currentSub?.plan_id === p.id" severity="success" value="Atual" />
</div>
<!-- Corpo do card -->
<div class="p-4 flex flex-col gap-4 flex-1">
<!-- Descrição -->
<div v-if="p.description" class="text-[1rem] text-[var(--text-color-secondary)]">{{ p.description }}</div>
<!-- Preço -->
<div>
<div class="text-[2rem] font-bold leading-none text-[var(--text-color)]">{{ priceLabelForCard(p) }}</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-1 opacity-70">Alternar mensal/anual no topo para comparar.</div>
</div>
<!-- Ações -->
<div class="flex flex-col gap-2 mt-auto">
<Button :label="billingInterval === 'month' ? 'Escolher mensal' : 'Escolher anual'" icon="pi pi-check" class="rounded-full w-full" :loading="saving" :disabled="loading || saving" @click="choosePlan(p, billingInterval)" />
<div class="flex gap-2">
<Button label="Mensal" severity="secondary" outlined class="rounded-full flex-1" :disabled="loading || saving" @click="choosePlan(p, 'month')" />
<Button label="Anual" severity="secondary" outlined class="rounded-full flex-1" :disabled="loading || saving" @click="choosePlan(p, 'year')" />
</div>
</div>
<!-- Status do preço -->
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-60">
<span v-if="priceFor(p.id, billingInterval)">Preço ativo encontrado para {{ intervalLabel(billingInterval) }}.</span>
<span v-else>Sem preço ativo para {{ intervalLabel(billingInterval) }}.</span>
</div>
</div>
</div>
</div>
</div>
</template>
File diff suppressed because it is too large Load Diff
+13 -13
View File
@@ -26,18 +26,18 @@ const router = useRouter();
</script>
<template>
<div class="card mb-0">
<div class="flex justify-between mb-4">
<div>
<span class="text-primary font-medium">Área</span>
<span class="text-muted-color"> da Clínica</span>
</div>
<div class="flex items-center gap-2">
<Button icon="pi pi-refresh" severity="secondary" outlined class="h-9 w-9 rounded-full" title="Recarregar" @click="$router.go(0)" />
<Button icon="pi pi-cog" severity="secondary" outlined class="h-9 w-9 rounded-full" title="Configurações" @click="router.push('/configuracoes')" />
</div>
</div>
</div>
<div class="card mb-0">
<div class="flex justify-between mb-4">
<div>
<span class="text-primary font-medium">Área</span>
<span class="text-muted-color"> da Clínica</span>
</div>
<div class="flex items-center gap-2">
<Button icon="pi pi-refresh" severity="secondary" outlined class="h-9 w-9 rounded-full" title="Recarregar" @click="$router.go(0)" />
<Button icon="pi pi-cog" severity="secondary" outlined class="h-9 w-9 rounded-full" title="Configurações" @click="router.push('/configuracoes')" />
</div>
</div>
</div>
<div class="grid grid-cols-12 gap-8">
<StatsWidget />
@@ -50,4 +50,4 @@ const router = useRouter();
<NotificationsWidget />
</div>
</div>
</template>
</template>
@@ -14,12 +14,12 @@
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
-->
<template>
<div class="p-4">
<h1>Online Scheduling (Manage)</h1>
</div>
</template>
<script setup>
// placeholder para futura implementação
</script>
<template>
<div class="p-4">
<h1>Online Scheduling (Manage)</h1>
</div>
</template>
@@ -15,579 +15,492 @@
|--------------------------------------------------------------------------
-->
<script setup>
import { computed, onMounted, ref, watch, nextTick } from 'vue'
import { useRoute } from 'vue-router'
import { computed, onMounted, ref, watch, nextTick } from 'vue';
import { useRoute } from 'vue-router';
import { useToast } from 'primevue/usetoast'
import { useToast } from 'primevue/usetoast';
import ModuleRow from '@/features/clinic/components/ModuleRow.vue'
import ModuleRow from '@/features/clinic/components/ModuleRow.vue';
import { useTenantStore } from '@/stores/tenantStore'
import { useTenantFeaturesStore } from '@/stores/tenantFeaturesStore'
import { useMenuStore } from '@/stores/menuStore'
import { useTenantStore } from '@/stores/tenantStore';
import { useTenantFeaturesStore } from '@/stores/tenantFeaturesStore';
import { useMenuStore } from '@/stores/menuStore';
const toast = useToast()
const route = useRoute()
const toast = useToast();
const route = useRoute();
const tenantStore = useTenantStore()
const tf = useTenantFeaturesStore()
const menuStore = useMenuStore()
const tenantStore = useTenantStore();
const tf = useTenantFeaturesStore();
const menuStore = useMenuStore();
const savingKey = ref(null)
const applyingPreset = ref(false)
const savingKey = ref(null);
const applyingPreset = ref(false);
// evita cliques enquanto o contexto inicial ainda tá montando
const booting = ref(true)
const booting = ref(true);
// guarda features que o plano bloqueou (pra não ficar "clicando e errando")
const planDenied = ref(new Set())
const planDenied = ref(new Set());
const tenantId = computed(() =>
tenantStore.activeTenantId ||
tenantStore.tenantId ||
tenantStore.currentTenantId ||
tenantStore.tenant?.id ||
null
)
const tenantId = computed(() => tenantStore.activeTenantId || tenantStore.tenantId || tenantStore.currentTenantId || tenantStore.tenant?.id || null);
const role = computed(() => tenantStore.activeRole || tenantStore.role || null)
const role = computed(() => tenantStore.activeRole || tenantStore.role || null);
// ✅ Somente owners/admins da clínica podem alterar features.
// Terapeutas enxergam a página em modo somente-leitura (sem toggles, sem presets).
const isOwner = computed(() =>
role.value === 'owner' || role.value === 'admin'
)
const isOwner = computed(() => role.value === 'owner' || role.value === 'admin');
const loading = computed(() => tf.loading || tenantStore.loading || booting.value)
const loading = computed(() => tf.loading || tenantStore.loading || booting.value);
const tenantReady = computed(() => !!tenantId.value && tenantStore.loaded)
const tenantReady = computed(() => !!tenantId.value && tenantStore.loaded);
function isOn (key) {
if (!tenantId.value) return false
try { return !!tf.isEnabled(key, tenantId.value) } catch { return false }
function isOn(key) {
if (!tenantId.value) return false;
try {
return !!tf.isEnabled(key, tenantId.value);
} catch {
return false;
}
}
function labelOf (key) {
if (key === 'patients') return 'Pacientes'
if (key === 'shared_reception') return 'Recepção / Secretária'
if (key === 'rooms') return 'Salas / Coworking'
if (key === 'intake_public') return 'Link externo de cadastro'
return key
function labelOf(key) {
if (key === 'patients') return 'Pacientes';
if (key === 'shared_reception') return 'Recepção / Secretária';
if (key === 'rooms') return 'Salas / Coworking';
if (key === 'intake_public') return 'Link externo de cadastro';
return key;
}
function isPlanDeniedError (e) {
const msg = String(e?.message || e || '')
return msg.toLowerCase().includes('não permitida') && msg.toLowerCase().includes('plano')
function isPlanDeniedError(e) {
const msg = String(e?.message || e || '');
return msg.toLowerCase().includes('não permitida') && msg.toLowerCase().includes('plano');
}
function markPlanDenied (key, e) {
if (!key) return
if (!isPlanDeniedError(e)) return
const s = new Set(planDenied.value)
s.add(key)
planDenied.value = s
function markPlanDenied(key, e) {
if (!key) return;
if (!isPlanDeniedError(e)) return;
const s = new Set(planDenied.value);
s.add(key);
planDenied.value = s;
}
function clearPlanDenied () {
planDenied.value = new Set()
function clearPlanDenied() {
planDenied.value = new Set();
}
function isLocked (key) {
return (
!isOwner.value ||
!tenantReady.value ||
loading.value ||
applyingPreset.value ||
!!savingKey.value ||
planDenied.value.has(key)
)
function isLocked(key) {
return !isOwner.value || !tenantReady.value || loading.value || applyingPreset.value || !!savingKey.value || planDenied.value.has(key);
}
// ===============================
// 🧠 Menu refresh (debounced)
// evita "menu sumindo" ao resetar durante loading
// ===============================
let menuRefreshT = null
function requestMenuRefresh () {
if (menuRefreshT) clearTimeout(menuRefreshT)
menuRefreshT = setTimeout(() => {
if (tf.loading || tenantStore.loading || booting.value) {
return requestMenuRefresh()
}
if (typeof menuStore.reset === 'function') menuStore.reset()
}, 150)
let menuRefreshT = null;
function requestMenuRefresh() {
if (menuRefreshT) clearTimeout(menuRefreshT);
menuRefreshT = setTimeout(() => {
if (tf.loading || tenantStore.loading || booting.value) {
return requestMenuRefresh();
}
if (typeof menuStore.reset === 'function') menuStore.reset();
}, 150);
}
/**
* ✅ Recalcular menu SEM router.replace().
* O menu some quando o reset acontece enquanto stores ainda carregam.
*/
async function afterFeaturesChanged () {
if (!tenantId.value) return
async function afterFeaturesChanged() {
if (!tenantId.value) return;
// ✅ refresh suave (evita "pisca vazio")
await tf.fetchForTenant(tenantId.value, { force: false })
// ✅ refresh suave (evita "pisca vazio")
await tf.fetchForTenant(tenantId.value, { force: false });
// ✅ nunca navegar/replace aqui
requestMenuRefresh()
// ✅ nunca navegar/replace aqui
requestMenuRefresh();
await nextTick()
await nextTick();
}
async function reload () {
if (!tenantId.value) return
clearPlanDenied()
async function reload() {
if (!tenantId.value) return;
clearPlanDenied();
await tf.fetchForTenant(tenantId.value, { force: true })
requestMenuRefresh()
await tf.fetchForTenant(tenantId.value, { force: true });
requestMenuRefresh();
toast.add({
severity: 'info',
summary: 'Atualizado',
detail: 'Módulos recarregados.',
life: 2000
})
toast.add({
severity: 'info',
summary: 'Atualizado',
detail: 'Módulos recarregados.',
life: 2000
});
}
async function toggle (key) {
if (!isOwner.value) {
toast.add({
severity: 'warn',
summary: 'Acesso restrito',
detail: 'Apenas o administrador da clínica pode alterar módulos.',
life: 3000
})
return
}
if (!tenantId.value) {
toast.add({
severity: 'warn',
summary: 'Sem tenant ativo',
detail: 'Selecione/ative um tenant primeiro.',
life: 2500
})
return
}
if (planDenied.value.has(key)) {
toast.add({
severity: 'warn',
summary: 'Indisponível no plano',
detail: `${labelOf(key)} não está disponível no plano atual.`,
life: 2800
})
return
}
if (savingKey.value) return
savingKey.value = key
try {
const next = !isOn(key)
await tf.setForTenant(tenantId.value, key, next)
await afterFeaturesChanged()
toast.add({
severity: 'success',
summary: 'Atualizado',
detail: `${labelOf(key)}: ${next ? 'Ativado' : 'Desativado'}`,
life: 2500
})
} catch (e) {
markPlanDenied(key, e)
toast.add({
severity: isPlanDeniedError(e) ? 'warn' : 'error',
summary: isPlanDeniedError(e) ? 'Bloqueado pelo plano' : 'Erro',
detail: e?.message || 'Falha ao atualizar módulo',
life: 3800
})
} finally {
savingKey.value = null
}
}
async function applyPreset (preset) {
if (!isOwner.value) {
toast.add({
severity: 'warn',
summary: 'Acesso restrito',
detail: 'Apenas o administrador da clínica pode aplicar presets.',
life: 3000
})
return
}
if (!tenantId.value) return
if (applyingPreset.value) return
clearPlanDenied()
applyingPreset.value = true
try {
const map = {
coworking: {
patients: false,
shared_reception: false,
rooms: true,
intake_public: false
},
reception: {
patients: false,
shared_reception: true,
rooms: false,
intake_public: false
},
full: {
patients: true,
shared_reception: true,
rooms: true,
intake_public: true
}
}
const cfg = map[preset]
if (!cfg) return
for (const [k, v] of Object.entries(cfg)) {
try {
await tf.setForTenant(tenantId.value, k, v)
} catch (e) {
markPlanDenied(k, e)
async function toggle(key) {
if (!isOwner.value) {
toast.add({
severity: isPlanDeniedError(e) ? 'warn' : 'error',
summary: isPlanDeniedError(e) ? 'Bloqueado pelo plano' : 'Erro',
detail: `${labelOf(k)}: ${e?.message || 'falha ao aplicar'}`,
life: 4200
})
}
severity: 'warn',
summary: 'Acesso restrito',
detail: 'Apenas o administrador da clínica pode alterar módulos.',
life: 3000
});
return;
}
await afterFeaturesChanged()
if (!tenantId.value) {
toast.add({
severity: 'warn',
summary: 'Sem tenant ativo',
detail: 'Selecione/ative um tenant primeiro.',
life: 2500
});
return;
}
toast.add({
severity: 'success',
summary: 'Preset aplicado',
detail: 'Configuração atualizada.',
life: 2500
})
} catch (e) {
toast.add({
severity: 'error',
summary: 'Erro',
detail: e?.message || 'Falha ao aplicar preset',
life: 3500
})
} finally {
applyingPreset.value = false
}
if (planDenied.value.has(key)) {
toast.add({
severity: 'warn',
summary: 'Indisponível no plano',
detail: `${labelOf(key)} não está disponível no plano atual.`,
life: 2800
});
return;
}
if (savingKey.value) return;
savingKey.value = key;
try {
const next = !isOn(key);
await tf.setForTenant(tenantId.value, key, next);
await afterFeaturesChanged();
toast.add({
severity: 'success',
summary: 'Atualizado',
detail: `${labelOf(key)}: ${next ? 'Ativado' : 'Desativado'}`,
life: 2500
});
} catch (e) {
markPlanDenied(key, e);
toast.add({
severity: isPlanDeniedError(e) ? 'warn' : 'error',
summary: isPlanDeniedError(e) ? 'Bloqueado pelo plano' : 'Erro',
detail: e?.message || 'Falha ao atualizar módulo',
life: 3800
});
} finally {
savingKey.value = null;
}
}
async function applyPreset(preset) {
if (!isOwner.value) {
toast.add({
severity: 'warn',
summary: 'Acesso restrito',
detail: 'Apenas o administrador da clínica pode aplicar presets.',
life: 3000
});
return;
}
if (!tenantId.value) return;
if (applyingPreset.value) return;
clearPlanDenied();
applyingPreset.value = true;
try {
const map = {
coworking: {
patients: false,
shared_reception: false,
rooms: true,
intake_public: false
},
reception: {
patients: false,
shared_reception: true,
rooms: false,
intake_public: false
},
full: {
patients: true,
shared_reception: true,
rooms: true,
intake_public: true
}
};
const cfg = map[preset];
if (!cfg) return;
for (const [k, v] of Object.entries(cfg)) {
try {
await tf.setForTenant(tenantId.value, k, v);
} catch (e) {
markPlanDenied(k, e);
toast.add({
severity: isPlanDeniedError(e) ? 'warn' : 'error',
summary: isPlanDeniedError(e) ? 'Bloqueado pelo plano' : 'Erro',
detail: `${labelOf(k)}: ${e?.message || 'falha ao aplicar'}`,
life: 4200
});
}
}
await afterFeaturesChanged();
toast.add({
severity: 'success',
summary: 'Preset aplicado',
detail: 'Configuração atualizada.',
life: 2500
});
} catch (e) {
toast.add({
severity: 'error',
summary: 'Erro',
detail: e?.message || 'Falha ao aplicar preset',
life: 3500
});
} finally {
applyingPreset.value = false;
}
}
// Carrega tenant/session se necessário
onMounted(async () => {
try {
if (!tenantStore.loaded && !tenantStore.loading) {
await tenantStore.loadSessionAndTenant()
try {
if (!tenantStore.loaded && !tenantStore.loading) {
await tenantStore.loadSessionAndTenant();
}
} finally {
// o watch do tenantId fará o fetch; aqui só destrava a tela
booting.value = false;
}
} finally {
// o watch do tenantId fará o fetch; aqui só destrava a tela
booting.value = false
}
})
});
// Busca features sempre que o tenant ficar disponível (e no mount)
watch(
() => tenantId.value,
async (id) => {
if (!id) return
() => tenantId.value,
async (id) => {
if (!id) return;
booting.value = true
clearPlanDenied()
booting.value = true;
clearPlanDenied();
try {
// ✅ não force no mount para evitar "pisca"
await tf.fetchForTenant(id, { force: false })
try {
// ✅ não force no mount para evitar "pisca"
await tf.fetchForTenant(id, { force: false });
// ✅ reset só quando estiver estável (debounced)
requestMenuRefresh()
// ✅ reset só quando estiver estável (debounced)
requestMenuRefresh();
await nextTick()
} catch (e) {
toast.add({
severity: 'error',
summary: 'Erro ao carregar módulos',
detail: e?.message || 'Falha ao buscar tenant_features',
life: 4000
})
} finally {
booting.value = false
}
},
{ immediate: true }
)
await nextTick();
} catch (e) {
toast.add({
severity: 'error',
summary: 'Erro ao carregar módulos',
detail: e?.message || 'Falha ao buscar tenant_features',
life: 4000
});
} finally {
booting.value = false;
}
},
{ immediate: true }
);
// blindagem: se a rota mudar dentro da área admin e o menu tiver resetado,
// solicita refresh leve (sem navigation)
watch(
() => route.fullPath,
async () => {
requestMenuRefresh()
}
)
() => route.fullPath,
async () => {
requestMenuRefresh();
}
);
</script>
<template>
<!-- Sentinel -->
<div class="h-px" />
<!-- Sentinel -->
<div class="h-px" />
<!-- Hero sticky -->
<div
class="sticky mx-3 md:mx-4 mb-4 z-20 overflow-hidden rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5"
:style="{ top: 'var(--layout-sticky-top, 56px)' }"
>
<div class="absolute inset-0 pointer-events-none overflow-hidden" aria-hidden="true">
<div class="absolute rounded-full blur-[70px] w-72 h-72 -top-16 -right-20 bg-indigo-400/10" />
<div class="absolute rounded-full blur-[70px] w-80 h-80 top-10 -left-24 bg-emerald-400/10" />
<div class="absolute rounded-full blur-[70px] w-72 h-72 -bottom-20 right-24 bg-fuchsia-400/10" />
</div>
<div class="relative z-10 flex flex-col gap-2">
<div class="flex items-center justify-between gap-3 flex-wrap">
<div class="min-w-0">
<div class="text-[1rem] font-bold tracking-tight text-[var(--text-color)]">Tipos de Clínica</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-0.5">
Ative/desative recursos por clínica. Controla menu, rotas e acesso no banco (RLS).
</div>
<!-- Hero sticky -->
<div class="sticky mx-3 md:mx-4 mb-4 z-20 overflow-hidden rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5" :style="{ top: 'var(--layout-sticky-top, 56px)' }">
<div class="absolute inset-0 pointer-events-none overflow-hidden" aria-hidden="true">
<div class="absolute rounded-full blur-[70px] w-72 h-72 -top-16 -right-20 bg-indigo-400/10" />
<div class="absolute rounded-full blur-[70px] w-80 h-80 top-10 -left-24 bg-emerald-400/10" />
<div class="absolute rounded-full blur-[70px] w-72 h-72 -bottom-20 right-24 bg-fuchsia-400/10" />
</div>
<div class="shrink-0 flex items-center gap-2">
<Button
label="Recarregar"
icon="pi pi-refresh"
severity="secondary"
outlined
:loading="loading"
:disabled="applyingPreset || !!savingKey"
@click="reload"
/>
</div>
</div>
<div class="relative z-10 flex flex-col gap-2">
<div class="flex items-center justify-between gap-3 flex-wrap">
<div class="min-w-0">
<div class="text-[1rem] font-bold tracking-tight text-[var(--text-color)]">Tipos de Clínica</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-0.5">Ative/desative recursos por clínica. Controla menu, rotas e acesso no banco (RLS).</div>
</div>
<div class="flex flex-wrap items-center gap-2">
<span class="inline-flex items-center gap-2 rounded-full border border-[var(--surface-border)] px-3 py-1 text-[1rem] text-[var(--text-color-secondary)]">
<i class="pi pi-building" />
Tenant: <b class="font-mono">{{ tenantId || '"' }}</b>
</span>
<span class="inline-flex items-center gap-2 rounded-full border border-[var(--surface-border)] px-3 py-1 text-[1rem] text-[var(--text-color-secondary)]">
<i class="pi pi-user" />
Role: <b>{{ role || '"' }}</b>
</span>
<span
v-if="!tenantReady"
class="inline-flex items-center gap-2 rounded-full border border-[var(--surface-border)] px-3 py-1 text-[1rem] text-[var(--text-color-secondary)] opacity-70"
>
<i class="pi pi-spin pi-spinner" />
Carregando contexto
</span>
<span
v-else-if="loading"
class="inline-flex items-center gap-2 rounded-full border border-[var(--surface-border)] px-3 py-1 text-[1rem] text-[var(--text-color-secondary)] opacity-70"
>
<i class="pi pi-spin pi-spinner" />
Atualizando módulos
</span>
</div>
</div>
</div>
<div class="px-3 md:px-4 pb-8 flex flex-col gap-4">
<!-- Banner: somente leitura -->
<div
v-if="!isOwner && tenantReady"
class="flex items-center gap-3 rounded-md border border-amber-400/40 bg-amber-400/10 px-5 py-4 text-[1rem]"
>
<i class="pi pi-lock text-amber-400 shrink-0" />
<span class="text-[1rem] text-[var(--text-color)] opacity-90">
Você está visualizando as configurações da clínica em <b>modo somente leitura</b>.
Apenas o administrador pode ativar ou desativar módulos.
</span>
</div>
<!-- Presets -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-3">
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5">
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<div class="text-[1rem] font-semibold text-[var(--text-color)]">Preset: Coworking</div>
<div class="mt-1 text-[1rem] text-[var(--text-color-secondary)]">
Para aluguel de salas: sem pacientes, com salas.
<div class="shrink-0 flex items-center gap-2">
<Button label="Recarregar" icon="pi pi-refresh" severity="secondary" outlined :loading="loading" :disabled="applyingPreset || !!savingKey" @click="reload" />
</div>
</div>
</div>
<Button
size="small"
label="Aplicar"
severity="secondary"
outlined
:loading="applyingPreset"
:disabled="!isOwner || !tenantReady || loading || !!savingKey"
@click="applyPreset('coworking')"
/>
</div>
</div>
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5">
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<div class="text-[1rem] font-semibold text-[var(--text-color)]">Preset: Clínica com recepção</div>
<div class="mt-1 text-[1rem] text-[var(--text-color-secondary)]">
Para secretária gerenciar agenda (pacientes opcional).
<div class="flex flex-wrap items-center gap-2">
<span class="inline-flex items-center gap-2 rounded-full border border-[var(--surface-border)] px-3 py-1 text-[1rem] text-[var(--text-color-secondary)]">
<i class="pi pi-building" />
Tenant: <b class="font-mono">{{ tenantId || '"' }}</b>
</span>
<span class="inline-flex items-center gap-2 rounded-full border border-[var(--surface-border)] px-3 py-1 text-[1rem] text-[var(--text-color-secondary)]">
<i class="pi pi-user" />
Role: <b>{{ role || '"' }}</b>
</span>
<span v-if="!tenantReady" class="inline-flex items-center gap-2 rounded-full border border-[var(--surface-border)] px-3 py-1 text-[1rem] text-[var(--text-color-secondary)] opacity-70">
<i class="pi pi-spin pi-spinner" />
Carregando contexto
</span>
<span v-else-if="loading" class="inline-flex items-center gap-2 rounded-full border border-[var(--surface-border)] px-3 py-1 text-[1rem] text-[var(--text-color-secondary)] opacity-70">
<i class="pi pi-spin pi-spinner" />
Atualizando módulos
</span>
</div>
</div>
<Button
size="small"
label="Aplicar"
severity="secondary"
outlined
:loading="applyingPreset"
:disabled="!isOwner || !tenantReady || loading || !!savingKey"
@click="applyPreset('reception')"
/>
</div>
</div>
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5">
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<div class="text-[1rem] font-semibold text-[var(--text-color)]">Preset: Clínica completa</div>
<div class="mt-1 text-[1rem] text-[var(--text-color-secondary)]">
Pacientes + recepção + salas (se quiser).
</div>
</div>
<Button
size="small"
label="Aplicar"
severity="secondary"
outlined
:loading="applyingPreset"
:disabled="!isOwner || !tenantReady || loading || !!savingKey"
@click="applyPreset('full')"
/>
</div>
</div>
</div>
<!-- Modules -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-3">
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5">
<ModuleRow
title="Pacientes"
desc="Habilita gestão de pacientes por clínica. Todo paciente tem um responsável (therapist)."
icon="pi pi-users"
:enabled="isOn('patients')"
:loading="savingKey === 'patients'"
:disabled="isLocked('patients')"
@toggle="toggle('patients')"
/>
<div
v-if="planDenied.has('patients')"
class="mt-3 text-[1rem] rounded-md border border-[var(--surface-border)] p-3 opacity-90"
>
<i class="pi pi-lock mr-2" />
Este módulo foi bloqueado pelo plano atual do tenant.
<div class="px-3 md:px-4 pb-8 flex flex-col gap-4">
<!-- Banner: somente leitura -->
<div v-if="!isOwner && tenantReady" class="flex items-center gap-3 rounded-md border border-amber-400/40 bg-amber-400/10 px-5 py-4 text-[1rem]">
<i class="pi pi-lock text-amber-400 shrink-0" />
<span class="text-[1rem] text-[var(--text-color)] opacity-90"> Você está visualizando as configurações da clínica em <b>modo somente leitura</b>. Apenas o administrador pode ativar ou desativar módulos. </span>
</div>
<Divider class="my-4" />
<div class="text-[1rem] text-[var(--text-color-secondary)] leading-relaxed">
Quando desligado:
<ul class="mt-2 list-disc pl-5 space-y-1">
<li>Menu "Pacientes" some.</li>
<li>Rotas com <span class="font-mono">meta.tenantFeature = 'patients'</span> redirecionam pra .</li>
<li>RLS bloqueia acesso direto no banco.</li>
</ul>
</div>
</div>
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5">
<ModuleRow
title="Recepção / Secretária"
desc="Permite um papel de secretária gerenciar a agenda dos profissionais (sem precisar ver tudo do paciente)."
icon="pi pi-briefcase"
:enabled="isOn('shared_reception')"
:loading="savingKey === 'shared_reception'"
:disabled="isLocked('shared_reception')"
@toggle="toggle('shared_reception')"
/>
<div
v-if="planDenied.has('shared_reception')"
class="mt-3 text-[1rem] rounded-md border border-[var(--surface-border)] p-3 opacity-90"
>
<i class="pi pi-lock mr-2" />
Este módulo foi bloqueado pelo plano atual do tenant.
</div>
<Divider class="my-4" />
<div class="text-[1rem] text-[var(--text-color-secondary)] leading-relaxed">
Observação: este módulo é "produto" (UX + permissões). A base aqui é o toggle.
Depois a gente cria:
<ul class="mt-2 list-disc pl-5 space-y-1">
<li>role <span class="font-mono">secretary</span> em <span class="font-mono">tenant_members</span></li>
<li>policies e telas para a secretária</li>
<li>nível de visibilidade do paciente na agenda</li>
</ul>
</div>
</div>
<!-- Presets -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-3">
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5">
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<div class="text-[1rem] font-semibold text-[var(--text-color)]">Preset: Coworking</div>
<div class="mt-1 text-[1rem] text-[var(--text-color-secondary)]">Para aluguel de salas: sem pacientes, com salas.</div>
</div>
<Button size="small" label="Aplicar" severity="secondary" outlined :loading="applyingPreset" :disabled="!isOwner || !tenantReady || loading || !!savingKey" @click="applyPreset('coworking')" />
</div>
</div>
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5">
<ModuleRow
title="Salas / Coworking"
desc="Habilita cadastro e reserva de salas/recursos no agendamento."
icon="pi pi-building"
:enabled="isOn('rooms')"
:loading="savingKey === 'rooms'"
:disabled="isLocked('rooms')"
@toggle="toggle('rooms')"
/>
<div
v-if="planDenied.has('rooms')"
class="mt-3 text-[1rem] rounded-md border border-[var(--surface-border)] p-3 opacity-90"
>
<i class="pi pi-lock mr-2" />
Este módulo foi bloqueado pelo plano atual do tenant.
</div>
<Divider class="my-4" />
<div class="text-[1rem] text-[var(--text-color-secondary)] leading-relaxed">
Isso prepara o terreno para a clínica operar como locação de sala, com agenda vinculando sala + profissional.
</div>
</div>
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5">
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<div class="text-[1rem] font-semibold text-[var(--text-color)]">Preset: Clínica com recepção</div>
<div class="mt-1 text-[1rem] text-[var(--text-color-secondary)]">Para secretária gerenciar agenda (pacientes opcional).</div>
</div>
<Button size="small" label="Aplicar" severity="secondary" outlined :loading="applyingPreset" :disabled="!isOwner || !tenantReady || loading || !!savingKey" @click="applyPreset('reception')" />
</div>
</div>
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5">
<ModuleRow
title="Link externo de cadastro"
desc="Libera fluxo público de intake/cadastro externo para a clínica."
icon="pi pi-link"
:enabled="isOn('intake_public')"
:loading="savingKey === 'intake_public'"
:disabled="isLocked('intake_public')"
@toggle="toggle('intake_public')"
/>
<div
v-if="planDenied.has('intake_public')"
class="mt-3 text-[1rem] rounded-md border border-[var(--surface-border)] p-3 opacity-90"
>
<i class="pi pi-lock mr-2" />
Este módulo foi bloqueado pelo plano atual do tenant.
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5">
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<div class="text-[1rem] font-semibold text-[var(--text-color)]">Preset: Clínica completa</div>
<div class="mt-1 text-[1rem] text-[var(--text-color-secondary)]">Pacientes + recepção + salas (se quiser).</div>
</div>
<Button size="small" label="Aplicar" severity="secondary" outlined :loading="applyingPreset" :disabled="!isOwner || !tenantReady || loading || !!savingKey" @click="applyPreset('full')" />
</div>
</div>
</div>
<Divider class="my-4" />
<div class="text-[1rem] text-[var(--text-color-secondary)] leading-relaxed">
Você tem páginas de link externo. Isso vira o controle fino: a clínica decide se usa ou não.
<!-- Modules -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-3">
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5">
<ModuleRow
title="Pacientes"
desc="Habilita gestão de pacientes por clínica. Todo paciente tem um responsável (therapist)."
icon="pi pi-users"
:enabled="isOn('patients')"
:loading="savingKey === 'patients'"
:disabled="isLocked('patients')"
@toggle="toggle('patients')"
/>
<div v-if="planDenied.has('patients')" class="mt-3 text-[1rem] rounded-md border border-[var(--surface-border)] p-3 opacity-90">
<i class="pi pi-lock mr-2" />
Este módulo foi bloqueado pelo plano atual do tenant.
</div>
<Divider class="my-4" />
<div class="text-[1rem] text-[var(--text-color-secondary)] leading-relaxed">
Quando desligado:
<ul class="mt-2 list-disc pl-5 space-y-1">
<li>Menu "Pacientes" some.</li>
<li>Rotas com <span class="font-mono">meta.tenantFeature = 'patients'</span> redirecionam pra .</li>
<li>RLS bloqueia acesso direto no banco.</li>
</ul>
</div>
</div>
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5">
<ModuleRow
title="Recepção / Secretária"
desc="Permite um papel de secretária gerenciar a agenda dos profissionais (sem precisar ver tudo do paciente)."
icon="pi pi-briefcase"
:enabled="isOn('shared_reception')"
:loading="savingKey === 'shared_reception'"
:disabled="isLocked('shared_reception')"
@toggle="toggle('shared_reception')"
/>
<div v-if="planDenied.has('shared_reception')" class="mt-3 text-[1rem] rounded-md border border-[var(--surface-border)] p-3 opacity-90">
<i class="pi pi-lock mr-2" />
Este módulo foi bloqueado pelo plano atual do tenant.
</div>
<Divider class="my-4" />
<div class="text-[1rem] text-[var(--text-color-secondary)] leading-relaxed">
Observação: este módulo é "produto" (UX + permissões). A base aqui é o toggle. Depois a gente cria:
<ul class="mt-2 list-disc pl-5 space-y-1">
<li>role <span class="font-mono">secretary</span> em <span class="font-mono">tenant_members</span></li>
<li>policies e telas para a secretária</li>
<li>nível de visibilidade do paciente na agenda</li>
</ul>
</div>
</div>
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5">
<ModuleRow
title="Salas / Coworking"
desc="Habilita cadastro e reserva de salas/recursos no agendamento."
icon="pi pi-building"
:enabled="isOn('rooms')"
:loading="savingKey === 'rooms'"
:disabled="isLocked('rooms')"
@toggle="toggle('rooms')"
/>
<div v-if="planDenied.has('rooms')" class="mt-3 text-[1rem] rounded-md border border-[var(--surface-border)] p-3 opacity-90">
<i class="pi pi-lock mr-2" />
Este módulo foi bloqueado pelo plano atual do tenant.
</div>
<Divider class="my-4" />
<div class="text-[1rem] text-[var(--text-color-secondary)] leading-relaxed">Isso prepara o terreno para a clínica operar como locação de sala, com agenda vinculando sala + profissional.</div>
</div>
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5">
<ModuleRow
title="Link externo de cadastro"
desc="Libera fluxo público de intake/cadastro externo para a clínica."
icon="pi pi-link"
:enabled="isOn('intake_public')"
:loading="savingKey === 'intake_public'"
:disabled="isLocked('intake_public')"
@toggle="toggle('intake_public')"
/>
<div v-if="planDenied.has('intake_public')" class="mt-3 text-[1rem] rounded-md border border-[var(--surface-border)] p-3 opacity-90">
<i class="pi pi-lock mr-2" />
Este módulo foi bloqueado pelo plano atual do tenant.
</div>
<Divider class="my-4" />
<div class="text-[1rem] text-[var(--text-color-secondary)] leading-relaxed">Você tem páginas de link externo. Isso vira o controle fino: a clínica decide se usa ou não.</div>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
</style>
<style scoped></style>
File diff suppressed because it is too large Load Diff
+46 -48
View File
@@ -19,54 +19,52 @@ import NotificationsWidget from '@/components/dashboard/NotificationsWidget.vue'
</script>
<template>
<div class="card mb-0">
<div class="flex justify-between mb-4">
<div>
<span class="text-primary font-medium">Área</span>
<span class="text-muted-color"> do Editor</span>
</div>
<div class="flex items-center justify-center bg-orange-100 dark:bg-orange-400/10 rounded-border" style="width: 2.5rem; height: 2.5rem">
<i class="pi pi-pencil text-orange-500 text-xl!"></i>
</div>
</div>
<p class="text-muted-color text-sm mt-0">
Crie e gerencie cursos, módulos e conteúdos da plataforma de microlearning.
</p>
</div>
<div class="card mb-0">
<div class="flex justify-between mb-4">
<div>
<span class="text-primary font-medium">Área</span>
<span class="text-muted-color"> do Editor</span>
</div>
<div class="flex items-center justify-center bg-orange-100 dark:bg-orange-400/10 rounded-border" style="width: 2.5rem; height: 2.5rem">
<i class="pi pi-pencil text-orange-500 text-xl!"></i>
</div>
</div>
<p class="text-muted-color text-sm mt-0">Crie e gerencie cursos, módulos e conteúdos da plataforma de microlearning.</p>
</div>
<div class="grid grid-cols-12 gap-8">
<!-- Estatísticas de conteúdo -->
<div class="col-span-12">
<div class="card">
<div class="flex items-center gap-3 mb-4">
<i class="pi pi-book text-orange-500 text-2xl"></i>
<span class="font-semibold text-lg">Conteúdo da Plataforma</span>
</div>
<div class="grid grid-cols-12 gap-4">
<div class="col-span-12 md:col-span-4">
<div class="surface-100 dark:surface-700 rounded-xl p-4 text-center">
<div class="text-3xl font-bold text-orange-500 mb-1">0</div>
<div class="text-muted-color text-sm">Cursos publicados</div>
</div>
</div>
<div class="col-span-12 md:col-span-4">
<div class="surface-100 dark:surface-700 rounded-xl p-4 text-center">
<div class="text-3xl font-bold text-orange-500 mb-1">0</div>
<div class="text-muted-color text-sm">Módulos criados</div>
</div>
</div>
<div class="col-span-12 md:col-span-4">
<div class="surface-100 dark:surface-700 rounded-xl p-4 text-center">
<div class="text-3xl font-bold text-orange-500 mb-1">0</div>
<div class="text-muted-color text-sm">Alunos inscritos</div>
</div>
</div>
</div>
</div>
</div>
<div class="grid grid-cols-12 gap-8">
<!-- Estatísticas de conteúdo -->
<div class="col-span-12">
<div class="card">
<div class="flex items-center gap-3 mb-4">
<i class="pi pi-book text-orange-500 text-2xl"></i>
<span class="font-semibold text-lg">Conteúdo da Plataforma</span>
</div>
<div class="grid grid-cols-12 gap-4">
<div class="col-span-12 md:col-span-4">
<div class="surface-100 dark:surface-700 rounded-xl p-4 text-center">
<div class="text-3xl font-bold text-orange-500 mb-1">0</div>
<div class="text-muted-color text-sm">Cursos publicados</div>
</div>
</div>
<div class="col-span-12 md:col-span-4">
<div class="surface-100 dark:surface-700 rounded-xl p-4 text-center">
<div class="text-3xl font-bold text-orange-500 mb-1">0</div>
<div class="text-muted-color text-sm">Módulos criados</div>
</div>
</div>
<div class="col-span-12 md:col-span-4">
<div class="surface-100 dark:surface-700 rounded-xl p-4 text-center">
<div class="text-3xl font-bold text-orange-500 mb-1">0</div>
<div class="text-muted-color text-sm">Alunos inscritos</div>
</div>
</div>
</div>
</div>
</div>
<div class="col-span-12 xl:col-span-6">
<NotificationsWidget />
</div>
</div>
<div class="col-span-12 xl:col-span-6">
<NotificationsWidget />
</div>
</div>
</template>
+28 -39
View File
@@ -15,53 +15,42 @@
|--------------------------------------------------------------------------
-->
<script setup>
import { useRouter } from 'vue-router'
import { useTenantStore } from '@/stores/tenantStore'
import { useRouter } from 'vue-router';
import { useTenantStore } from '@/stores/tenantStore';
const router = useRouter()
const tenant = useTenantStore()
const router = useRouter();
const tenant = useTenantStore();
function goHome () {
const role = tenant.activeRole
function goHome() {
const role = tenant.activeRole;
if (role === 'tenant_admin' || role === 'clinic_admin' || role === 'admin') {
router.push('/admin')
return
}
if (role === 'tenant_admin' || role === 'clinic_admin' || role === 'admin') {
router.push('/admin');
return;
}
if (role === 'therapist') {
router.push('/therapist')
return
}
if (role === 'therapist') {
router.push('/therapist');
return;
}
if (role === 'patient') {
router.push('/portal')
return
}
if (role === 'patient') {
router.push('/portal');
return;
}
router.push('/')
router.push('/');
}
</script>
<template>
<div class="flex flex-col items-center justify-center min-h-[60vh] text-center px-6">
<div class="text-6xl font-bold text-[var(--primary-color)] mb-4">
403
<div class="flex flex-col items-center justify-center min-h-[60vh] text-center px-6">
<div class="text-6xl font-bold text-[var(--primary-color)] mb-4">403</div>
<h1 class="text-2xl font-semibold mb-2">Acesso negado</h1>
<p class="text-[var(--text-color-secondary)] max-w-md mb-6">Você está autenticado, mas não possui permissão para acessar esta área. Caso acredite que isso seja um erro, entre em contato com o administrador da clínica.</p>
<Button label="Voltar para minha área" icon="pi pi-home" @click="goHome" />
</div>
<h1 class="text-2xl font-semibold mb-2">
Acesso negado
</h1>
<p class="text-[var(--text-color-secondary)] max-w-md mb-6">
Você está autenticado, mas não possui permissão para acessar esta área.
Caso acredite que isso seja um erro, entre em contato com o administrador da clínica.
</p>
<Button
label="Voltar para minha área"
icon="pi pi-home"
@click="goHome"
/>
</div>
</template>
</template>
@@ -0,0 +1,470 @@
<!--
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/views/pages/notifications/SmsChannelSetupPage.vue
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
-->
<script setup>
import { ref, computed, onMounted } from 'vue';
import { useToast } from 'primevue/usetoast';
import { supabase } from '@/lib/supabase/client';
const toast = useToast();
// ── Contexto ──────────────────────────────────────────────────
const userId = ref(null);
const tenantId = ref(null);
const loading = ref(true);
// ── Canal SMS (notification_channels) ─────────────────────────
const channel = ref(null);
const saving = ref(false);
const testing = ref(false);
const testResult = ref(null); // { success, message, sid? }
const credentials = ref({
account_sid: '',
auth_token: '',
from_number: ''
});
// ── Status ────────────────────────────────────────────────────
const statusInfo = computed(() => {
if (!channel.value) {
return { label: 'Não configurado', severity: 'secondary', icon: 'pi pi-minus-circle', color: 'var(--p-gray-400)' };
}
switch (channel.value.connection_status) {
case 'connected':
return { label: 'Conectado', severity: 'success', icon: 'pi pi-check-circle', color: 'var(--p-green-500)' };
case 'error':
return { label: 'Erro na conexão', severity: 'danger', icon: 'pi pi-times-circle', color: 'var(--p-red-500)' };
default:
return { label: 'Desconectado', severity: 'secondary', icon: 'pi pi-minus-circle', color: 'var(--p-gray-400)' };
}
});
const isConfigured = computed(() => !!channel.value);
// ── Logs recentes ─────────────────────────────────────────────
const logs = ref([]);
const logsLoading = ref(false);
// ── Load ──────────────────────────────────────────────────────
async function init() {
loading.value = true;
try {
const { data: { user } } = await supabase.auth.getUser();
if (!user) return;
userId.value = user.id;
const { data: profile } = await supabase
.from('profiles')
.select('tenant_id')
.eq('id', user.id)
.single();
tenantId.value = profile?.tenant_id || user.id;
await Promise.all([loadChannel(), loadLogs()]);
} finally {
loading.value = false;
}
}
async function loadChannel() {
const { data } = await supabase
.from('notification_channels')
.select('*')
.eq('owner_id', userId.value)
.eq('channel', 'sms')
.is('deleted_at', null)
.maybeSingle();
channel.value = data;
if (data?.credentials) {
credentials.value = {
account_sid: data.credentials.account_sid || '',
auth_token: data.credentials.auth_token || '',
from_number: data.credentials.from_number || ''
};
}
}
async function loadLogs() {
logsLoading.value = true;
const { data } = await supabase
.from('notification_logs')
.select('id, template_key, recipient_address, status, failure_reason, provider_message_id, sent_at, failed_at, created_at')
.eq('owner_id', userId.value)
.eq('channel', 'sms')
.order('created_at', { ascending: false })
.limit(10);
logsLoading.value = false;
if (data) logs.value = data;
}
// ── Salvar credenciais ─────────────────────────────────────────
async function saveCredentials() {
if (saving.value) return;
if (!credentials.value.account_sid.trim()) {
toast.add({ severity: 'warn', summary: 'Account SID é obrigatório', life: 3000 });
return;
}
if (!credentials.value.auth_token.trim()) {
toast.add({ severity: 'warn', summary: 'Auth Token é obrigatório', life: 3000 });
return;
}
if (!credentials.value.from_number.trim()) {
toast.add({ severity: 'warn', summary: 'Número de origem é obrigatório', life: 3000 });
return;
}
saving.value = true;
try {
const creds = { ...credentials.value };
if (channel.value?.id) {
const { error } = await supabase
.from('notification_channels')
.update({
credentials: creds,
is_active: true,
connection_status: 'disconnected'
})
.eq('id', channel.value.id);
if (error) throw error;
} else {
const { data, error } = await supabase
.from('notification_channels')
.insert({
owner_id: userId.value,
tenant_id: tenantId.value,
channel: 'sms',
provider: 'twilio',
display_name: 'SMS via Twilio',
is_active: true,
connection_status: 'disconnected',
credentials: creds
})
.select('*')
.single();
if (error) throw error;
channel.value = data;
}
toast.add({ severity: 'success', summary: 'Credenciais salvas', detail: 'Canal SMS configurado com sucesso.', life: 3000 });
await loadChannel();
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro ao salvar', detail: e.message, life: 5000 });
} finally {
saving.value = false;
}
}
// ── Testar conexão ─────────────────────────────────────────────
async function testConnection() {
if (testing.value) return;
testing.value = true;
testResult.value = null;
try {
const { data, error } = await supabase.functions.invoke('test-sms-channel', {
body: { owner_id: userId.value }
});
if (error) throw error;
testResult.value = data;
if (data?.success) {
toast.add({ severity: 'success', summary: 'SMS enviado!', detail: data.message, life: 5000 });
// Atualiza connection_status para connected
if (channel.value?.id) {
await supabase
.from('notification_channels')
.update({ connection_status: 'connected' })
.eq('id', channel.value.id);
channel.value.connection_status = 'connected';
}
await loadLogs();
} else {
toast.add({ severity: 'warn', summary: 'Falha no teste', detail: data?.message, life: 6000 });
if (channel.value?.id) {
await supabase
.from('notification_channels')
.update({ connection_status: 'error' })
.eq('id', channel.value.id);
channel.value.connection_status = 'error';
}
}
} catch (e) {
testResult.value = { success: false, message: e.message };
toast.add({ severity: 'error', summary: 'Erro ao testar', detail: e.message, life: 5000 });
} finally {
testing.value = false;
}
}
// ── Helpers ───────────────────────────────────────────────────
function maskPhone(phone) {
if (!phone || phone.length < 8) return phone || '—';
return phone.slice(0, 5) + '****' + phone.slice(-4);
}
function logStatusSeverity(status) {
if (status === 'sent') return 'success';
if (status === 'failed') return 'danger';
return 'secondary';
}
function logStatusLabel(status) {
const map = { sent: 'Enviado', failed: 'Falhou', pending: 'Pendente', processing: 'Processando' };
return map[status] || status;
}
function formatDate(iso) {
if (!iso) return '—';
return new Date(iso).toLocaleString('pt-BR', { dateStyle: 'short', timeStyle: 'short' });
}
onMounted(init);
</script>
<template>
<Toast />
<div class="flex flex-col gap-5 p-4 md:p-6 max-w-[760px] mx-auto">
<!-- Loading inicial -->
<div v-if="loading" class="flex justify-center py-12">
<ProgressSpinner style="width: 40px; height: 40px" />
</div>
<template v-else>
<!-- Status do canal -->
<Card>
<template #content>
<div class="flex items-center justify-between gap-4 flex-wrap">
<div class="flex items-center gap-4">
<div
class="grid place-items-center w-12 h-12 rounded-full flex-shrink-0"
:style="{ background: `color-mix(in srgb, ${statusInfo.color} 15%, transparent)` }"
>
<i :class="statusInfo.icon" class="text-xl" :style="{ color: statusInfo.color }" />
</div>
<div>
<div class="font-semibold text-sm mb-1">Canal SMS</div>
<Tag :value="statusInfo.label" :severity="statusInfo.severity" />
</div>
</div>
<Button
v-if="isConfigured"
label="Testar conexão"
icon="pi pi-send"
size="small"
severity="secondary"
outlined
:loading="testing"
@click="testConnection"
/>
</div>
<!-- Resultado do teste -->
<div v-if="testResult" class="mt-4">
<Message
:severity="testResult.success ? 'success' : 'error'"
:closable="false"
class="!mt-0"
>
<div class="flex flex-col gap-0.5">
<span>{{ testResult.message }}</span>
<span v-if="testResult.sid" class="text-xs opacity-70 font-mono">SID: {{ testResult.sid }}</span>
</div>
</Message>
</div>
<!-- Call-to-action quando não configurado -->
<div v-if="!isConfigured" class="mt-4 p-4 rounded-lg border border-dashed border-[var(--surface-border)] bg-[var(--surface-ground)] text-sm text-[var(--text-color-secondary)] text-center">
<i class="pi pi-info-circle block text-2xl opacity-40 mb-2" />
Canal não configurado. Preencha as credenciais abaixo para ativar o envio de SMS.
</div>
</template>
</Card>
<!-- Formulário de credenciais -->
<Card>
<template #title>
<div class="flex items-center gap-2 text-base">
<i class="pi pi-key opacity-60" />
Credenciais Twilio
</div>
</template>
<template #subtitle>Configure sua conta Twilio para envio de SMS.</template>
<template #content>
<div class="flex flex-col gap-4">
<!-- Account SID -->
<div class="flex flex-col gap-1.5">
<label class="text-xs font-semibold">Account SID</label>
<InputText
v-model="credentials.account_sid"
placeholder="ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
class="w-full font-mono"
autocomplete="off"
/>
<span class="text-xs text-[var(--text-color-secondary)]">Encontre em: Console Twilio Account Info Account SID</span>
</div>
<!-- Auth Token -->
<div class="flex flex-col gap-1.5">
<label class="text-xs font-semibold">Auth Token</label>
<Password
v-model="credentials.auth_token"
placeholder="••••••••••••••••••••••••••••••••"
:feedback="false"
toggle-mask
class="w-full"
input-class="w-full font-mono"
autocomplete="new-password"
/>
<span class="text-xs text-[var(--text-color-secondary)]">Encontre em: Console Twilio Account Info Auth Token</span>
</div>
<!-- Número de origem -->
<div class="flex flex-col gap-1.5">
<label class="text-xs font-semibold">Número de origem</label>
<InputText
v-model="credentials.from_number"
placeholder="+5511999999999"
class="w-full font-mono"
autocomplete="off"
/>
<span class="text-xs text-[var(--text-color-secondary)]">
Use um número Twilio verificado ou o número do seu trial.
Formato: +55 (código do país) + DDD + número.
</span>
</div>
<!-- Ações -->
<div class="flex items-center justify-between gap-2 pt-1">
<Button
v-if="isConfigured"
label="Testar agora"
icon="pi pi-send"
size="small"
severity="secondary"
text
:loading="testing"
:disabled="saving"
@click="testConnection"
/>
<div class="ml-auto">
<Button
label="Salvar credenciais"
icon="pi pi-save"
:loading="saving"
:disabled="saving"
@click="saveCredentials"
/>
</div>
</div>
</div>
</template>
</Card>
<!-- Informativo trial/sandbox -->
<Panel toggleable collapsed header="Usando o trial do Twilio?">
<div class="flex flex-col gap-3 text-sm text-[var(--text-color-secondary)]">
<p class="m-0">
No plano trial do Twilio, você pode enviar SMS para números <strong>verificados</strong> na sua conta.
Para adicionar um número de teste:
</p>
<ol class="m-0 pl-5 flex flex-col gap-1">
<li>Acesse o Console Twilio</li>
<li> em <strong>Phone Numbers Verified Caller IDs</strong></li>
<li>Adicione o número do paciente/teste e siga a verificação por chamada ou SMS</li>
<li>Apenas números verificados receberão mensagens no trial</li>
</ol>
<Message severity="info" :closable="false" class="!mt-1">
Quando fizer upgrade para conta paga, essa restrição é removida e qualquer número pode receber SMS.
</Message>
</div>
</Panel>
<!-- Histórico recente -->
<Card>
<template #title>
<div class="flex items-center justify-between">
<span class="text-base">Últimos envios</span>
<Button icon="pi pi-refresh" text rounded size="small" :loading="logsLoading" @click="loadLogs" />
</div>
</template>
<template #content>
<DataTable
:value="logs"
:loading="logsLoading"
size="small"
striped-rows
empty-message="Nenhum registro de SMS encontrado."
>
<Column header="Data" style="width: 130px">
<template #body="{ data }">
<span class="text-xs">{{ formatDate(data.sent_at || data.failed_at || data.created_at) }}</span>
</template>
</Column>
<Column field="recipient_address" header="Destinatário" style="width: 140px">
<template #body="{ data }">
<span class="font-mono text-xs">{{ maskPhone(data.recipient_address) }}</span>
</template>
</Column>
<Column field="template_key" header="Template">
<template #body="{ data }">
<span class="font-mono text-xs text-[var(--text-color-secondary)]">{{ data.template_key }}</span>
</template>
</Column>
<Column header="Status" style="width: 100px">
<template #body="{ data }">
<Tag
:value="logStatusLabel(data.status)"
:severity="logStatusSeverity(data.status)"
class="text-[0.65rem]"
/>
</template>
</Column>
<Column header="SID" style="width: 130px">
<template #body="{ data }">
<span v-if="data.provider_message_id" class="font-mono text-xs opacity-60 truncate block max-w-[120px]" :title="data.provider_message_id">
{{ data.provider_message_id }}
</span>
<span v-else class="text-xs opacity-40"></span>
</template>
</Column>
</DataTable>
</template>
</Card>
</template>
</div>
</template>
+40 -52
View File
@@ -14,61 +14,49 @@
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
-->
<template>
<div class="p-4 md:p-6">
<!-- HEADER CONCEITUAL -->
<div class="mb-6 overflow-hidden rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)]">
<div class="relative p-6">
<!-- blobs sutis -->
<div class="pointer-events-none absolute inset-0 opacity-80">
<div class="absolute -top-16 -right-16 h-52 w-52 rounded-full bg-indigo-400/10 blur-3xl" />
<div class="absolute bottom-0 left-10 h-44 w-44 rounded-full bg-emerald-400/10 blur-3xl" />
</div>
<div class="relative flex items-center gap-4">
<div class="grid h-14 w-14 place-items-center rounded-2xl bg-[var(--primary-color)]/10 text-[var(--primary-color)]">
<i class="pi pi-calendar text-2xl" />
</div>
<div>
<div class="text-2xl font-semibold leading-none">
Minhas Sessões
</div>
<div class="mt-2 text-sm text-color-secondary">
Visualize e acompanhe suas sessões agendadas e realizadas.
</div>
</div>
</div>
</div>
</div>
<!-- CONTEÚDO PLACEHOLDER -->
<div class="flex items-center justify-center rounded-2xl border border-dashed border-[var(--surface-border)] bg-[var(--surface-card)] py-16">
<div class="text-center max-w-md">
<div class="mx-auto mb-4 grid h-16 w-16 place-items-center rounded-2xl bg-[var(--primary-color)]/10 text-[var(--primary-color)]">
<i class="pi pi-hourglass text-2xl" />
</div>
<div class="text-lg font-semibold">
Em desenvolvimento
</div>
<div class="mt-2 text-sm text-color-secondary">
A área de acompanhamento das sessões será exibida aqui.
Em breve você poderá visualizar histórico, próximas sessões e detalhes clínicos.
</div>
</div>
</div>
</div>
</template>
<script setup>
// Placeholder simples — lógica será implementada futuramente.
</script>
<template>
<div class="p-4 md:p-6">
<!-- HEADER CONCEITUAL -->
<div class="mb-6 overflow-hidden rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)]">
<div class="relative p-6">
<!-- blobs sutis -->
<div class="pointer-events-none absolute inset-0 opacity-80">
<div class="absolute -top-16 -right-16 h-52 w-52 rounded-full bg-indigo-400/10 blur-3xl" />
<div class="absolute bottom-0 left-10 h-44 w-44 rounded-full bg-emerald-400/10 blur-3xl" />
</div>
<div class="relative flex items-center gap-4">
<div class="grid h-14 w-14 place-items-center rounded-2xl bg-[var(--primary-color)]/10 text-[var(--primary-color)]">
<i class="pi pi-calendar text-2xl" />
</div>
<div>
<div class="text-2xl font-semibold leading-none">Minhas Sessões</div>
<div class="mt-2 text-sm text-color-secondary">Visualize e acompanhe suas sessões agendadas e realizadas.</div>
</div>
</div>
</div>
</div>
<!-- CONTEÚDO PLACEHOLDER -->
<div class="flex items-center justify-center rounded-2xl border border-dashed border-[var(--surface-border)] bg-[var(--surface-card)] py-16">
<div class="text-center max-w-md">
<div class="mx-auto mb-4 grid h-16 w-16 place-items-center rounded-2xl bg-[var(--primary-color)]/10 text-[var(--primary-color)]">
<i class="pi pi-hourglass text-2xl" />
</div>
<div class="text-lg font-semibold">Em desenvolvimento</div>
<div class="mt-2 text-sm text-color-secondary">A área de acompanhamento das sessões será exibida aqui. Em breve você poderá visualizar histórico, próximas sessões e detalhes clínicos.</div>
</div>
</div>
</div>
</template>
<style scoped>
/* mantém padrão visual consistente */
</style>
</style>
+11 -11
View File
@@ -23,17 +23,17 @@ import StatsWidget from '@/components/dashboard/StatsWidget.vue';
</script>
<template>
<div class="card mb-0">
<div class="flex justify-between mb-4">
<div>
<span class="text-primary font-medium">Portal</span>
<span class="text-muted-color"> = Área do Paciente</span>
</div>
<div class="flex items-center justify-center bg-blue-100 dark:bg-blue-400/10 rounded-border" style="width: 2.5rem; height: 2.5rem">
<i class="pi pi-user text-blue-500 text-xl!"></i>
</div>
</div>
</div>
<div class="card mb-0">
<div class="flex justify-between mb-4">
<div>
<span class="text-primary font-medium">Portal</span>
<span class="text-muted-color"> = Área do Paciente</span>
</div>
<div class="flex items-center justify-center bg-blue-100 dark:bg-blue-400/10 rounded-border" style="width: 2.5rem; height: 2.5rem">
<i class="pi pi-user text-blue-500 text-xl!"></i>
</div>
</div>
</div>
<div class="grid grid-cols-12 gap-8">
<StatsWidget />
+236 -261
View File
@@ -14,327 +14,302 @@
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
-->
<template>
<Toast />
<div class="min-h-screen flex items-center justify-center p-6 bg-[var(--surface-ground)] text-[var(--text-color)]">
<div class="w-full max-w-lg overflow-hidden rounded-[2rem] border border-[var(--surface-border)] bg-[var(--surface-card)] shadow-sm">
<!-- Header / Hero -->
<div class="relative p-6">
<div class="pointer-events-none absolute inset-0 opacity-80">
<div class="absolute -top-16 -right-16 h-64 w-64 rounded-full bg-indigo-400/10 blur-3xl" />
<div class="absolute top-10 -left-20 h-64 w-64 rounded-full bg-emerald-400/10 blur-3xl" />
<div class="absolute -bottom-16 right-10 h-64 w-64 rounded-full bg-fuchsia-400/10 blur-3xl" />
</div>
<div class="relative">
<h1 class="text-xl font-semibold mb-2">Aceitar convite</h1>
<p class="text-sm opacity-80">
Vamos validar seu convite e ativar seu acesso ao tenant.
</p>
<div class="mt-3 flex flex-wrap items-center gap-2 text-xs opacity-80">
<span class="inline-flex items-center gap-2 rounded-full border border-[var(--surface-border)] px-3 py-1">
<i class="pi pi-ticket" />
Token:
<b class="font-mono">{{ shortToken }}</b>
</span>
<span
v-if="state.loading"
class="inline-flex items-center gap-2 rounded-full border border-[var(--surface-border)] px-3 py-1"
>
<i class="pi pi-spin pi-spinner" />
Processando
</span>
<span
v-else-if="state.success"
class="inline-flex items-center gap-2 rounded-full border border-[var(--surface-border)] px-3 py-1"
>
<i class="pi pi-check" />
Confirmado
</span>
</div>
</div>
</div>
<!-- Body -->
<div class="p-6 border-t border-[var(--surface-border)]">
<!-- Loading -->
<div v-if="state.loading" class="text-sm">
Processando convite
</div>
<!-- Success -->
<div v-else-if="state.success" class="space-y-3">
<div class="text-sm">
Convite aceito com sucesso. Redirecionando
</div>
<div class="text-xs opacity-70">
Se você não for redirecionado, clique abaixo.
</div>
<div class="flex gap-2">
<Button
label="Ir para o painel"
icon="pi pi-arrow-right"
@click="goAdmin"
/>
</div>
</div>
<!-- Error -->
<div v-else-if="state.error" class="space-y-4">
<div class="rounded-2xl border border-red-500/30 bg-red-500/10 p-4 text-sm">
<div class="font-semibold mb-1">Não foi possível aceitar o convite</div>
<div class="opacity-90">{{ state.error }}</div>
<div v-if="state.debugDetails" class="mt-3 text-xs opacity-70">
<div class="font-semibold mb-1">Detalhes (debug)</div>
<pre class="m-0 whitespace-pre-wrap break-words">{{ state.debugDetails }}</pre>
</div>
</div>
<div class="flex flex-wrap gap-2">
<Button
label="Tentar novamente"
icon="pi pi-refresh"
severity="secondary"
outlined
@click="retry"
/>
<Button
label="Ir para login"
icon="pi pi-sign-in"
@click="goLogin"
/>
</div>
<p class="text-xs opacity-70 leading-relaxed">
Se você recebeu um convite, confirme se está logado com o mesmo e-mail do convite.
</p>
</div>
<!-- Idle -->
<div v-else class="text-sm opacity-80">
Preparando
</div>
</div>
</div>
</div>
</template>
<script setup>
import { reactive, onMounted, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { reactive, onMounted, computed } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useToast } from 'primevue/usetoast'
import { useToast } from 'primevue/usetoast';
import { supabase } from '@/lib/supabase/client'
import { useTenantStore } from '@/stores/tenantStore'
import { supabase } from '@/lib/supabase/client';
import { useTenantStore } from '@/stores/tenantStore';
/**
* Token persistence (antes do login)
* - sessionStorage: some ao fechar a aba (bom para convite)
* - se você preferir cross-tab, use localStorage
*/
const PENDING_INVITE_TOKEN_KEY = 'pending_invite_token_v1'
const PENDING_INVITE_TOKEN_KEY = 'pending_invite_token_v1';
function persistPendingToken (token) {
try { sessionStorage.setItem(PENDING_INVITE_TOKEN_KEY, token) } catch (_) {}
function persistPendingToken(token) {
try {
sessionStorage.setItem(PENDING_INVITE_TOKEN_KEY, token);
} catch (_) {}
}
function readPendingToken () {
try { return sessionStorage.getItem(PENDING_INVITE_TOKEN_KEY) } catch (_) { return null }
function readPendingToken() {
try {
return sessionStorage.getItem(PENDING_INVITE_TOKEN_KEY);
} catch (_) {
return null;
}
}
function clearPendingToken () {
try { sessionStorage.removeItem(PENDING_INVITE_TOKEN_KEY) } catch (_) {}
function clearPendingToken() {
try {
sessionStorage.removeItem(PENDING_INVITE_TOKEN_KEY);
} catch (_) {}
}
const route = useRoute()
const router = useRouter()
const toast = useToast()
const tenantStore = useTenantStore()
const route = useRoute();
const router = useRouter();
const toast = useToast();
const tenantStore = useTenantStore();
const state = reactive({
loading: true,
success: false,
error: '',
debugDetails: '' // mantém vazio por padrão (pode ativar quando precisar)
})
loading: true,
success: false,
error: '',
debugDetails: '' // mantém vazio por padrão (pode ativar quando precisar)
});
const tokenFromQuery = computed(() => {
const t = route.query?.token
return typeof t === 'string' ? t.trim() : ''
})
const t = route.query?.token;
return typeof t === 'string' ? t.trim() : '';
});
const tokenEffective = computed(() => tokenFromQuery.value || readPendingToken() || '')
const tokenEffective = computed(() => tokenFromQuery.value || readPendingToken() || '');
const shortToken = computed(() => {
const t = tokenEffective.value
if (!t) return '—'
if (t.length <= 14) return t
return `${t.slice(0, 8)}${t.slice(-4)}`
})
const t = tokenEffective.value;
if (!t) return '—';
if (t.length <= 14) return t;
return `${t.slice(0, 8)}${t.slice(-4)}`;
});
function isUuid (v) {
// UUID v1v5 (aceita maiúsculas/minúsculas)
return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(v)
function isUuid(v) {
// UUID v1v5 (aceita maiúsculas/minúsculas)
return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(v);
}
function friendlyError (err) {
const msg = (err?.message || err || '').toString()
function friendlyError(err) {
const msg = (err?.message || err || '').toString();
if (/expired|expirad/i.test(msg)) return 'Este convite expirou. Peça para a clínica reenviar o convite.'
if (/invalid|inval/i.test(msg)) return 'Token inválido. Verifique se você copiou o link corretamente.'
if (/not found|não encontrado|nao encontrado|used|utilizad/i.test(msg)) return 'Convite não encontrado ou já utilizado.'
if (/email/i.test(msg) && /mismatch|diferent|different|bate|match/i.test(msg)) {
return 'Você está logado com um e-mail diferente do convite. Faça login com o e-mail correto.'
}
if (/not_authenticated|not authenticated|jwt|auth/i.test(msg)) {
return 'Você precisa estar logado para aceitar este convite.'
}
return 'Não foi possível concluir o aceite. Tente novamente ou peça para reenviar o convite.'
if (/expired|expirad/i.test(msg)) return 'Este convite expirou. Peça para a clínica reenviar o convite.';
if (/invalid|inval/i.test(msg)) return 'Token inválido. Verifique se você copiou o link corretamente.';
if (/not found|não encontrado|nao encontrado|used|utilizad/i.test(msg)) return 'Convite não encontrado ou já utilizado.';
if (/email/i.test(msg) && /mismatch|diferent|different|bate|match/i.test(msg)) {
return 'Você está logado com um e-mail diferente do convite. Faça login com o e-mail correto.';
}
if (/not_authenticated|not authenticated|jwt|auth/i.test(msg)) {
return 'Você precisa estar logado para aceitar este convite.';
}
return 'Não foi possível concluir o aceite. Tente novamente ou peça para reenviar o convite.';
}
function safeRpcError (rpcError) {
const raw = (rpcError?.message || '').toString().trim()
// Por padrão: mensagem amigável. Se quiser ver a "real", coloque em debugDetails.
const friendly = friendlyError(rpcError)
return { friendly, raw }
function safeRpcError(rpcError) {
const raw = (rpcError?.message || '').toString().trim();
// Por padrão: mensagem amigável. Se quiser ver a "real", coloque em debugDetails.
const friendly = friendlyError(rpcError);
return { friendly, raw };
}
async function goAdmin () {
await router.replace('/admin')
async function goAdmin() {
await router.replace('/admin');
}
async function goLogin () {
const token = tokenEffective.value
if (token) persistPendingToken(token)
async function goLogin() {
const token = tokenEffective.value;
if (token) persistPendingToken(token);
// ✅ garante troca de conta (somente quando usuário clica)
try {
await supabase.auth.signOut()
} catch (_) {}
// ✅ garante troca de conta (somente quando usuário clica)
try {
await supabase.auth.signOut();
} catch (_) {}
// ✅ volta para o accept com token (ou storage pendente)
const returnTo = token ? `/accept-invite?token=${encodeURIComponent(token)}` : '/accept-invite'
await router.replace({ path: '/auth/login', query: { redirect: returnTo } })
// ✅ volta para o accept com token (ou storage pendente)
const returnTo = token ? `/accept-invite?token=${encodeURIComponent(token)}` : '/accept-invite';
await router.replace({ path: '/auth/login', query: { redirect: returnTo } });
}
async function acceptInvite (token) {
state.loading = true
state.error = ''
state.success = false
state.debugDetails = ''
async function acceptInvite(token) {
state.loading = true;
state.error = '';
state.success = false;
state.debugDetails = '';
// 1) sessão
const { data: sessionData, error: sessionErr } = await supabase.auth.getSession()
if (sessionErr) {
state.loading = false
state.error = friendlyError(sessionErr)
return
}
// 1) sessão
const { data: sessionData, error: sessionErr } = await supabase.auth.getSession();
if (sessionErr) {
state.loading = false;
state.error = friendlyError(sessionErr);
return;
}
const session = sessionData?.session
if (!session) {
// não logado → salva token e vai pro login
persistPendingToken(token)
const session = sessionData?.session;
if (!session) {
// não logado → salva token e vai pro login
persistPendingToken(token);
const returnTo = route.fullPath || `/accept-invite?token=${encodeURIComponent(token)}`
await router.replace({ path: '/auth/login', query: { redirect: returnTo } })
const returnTo = route.fullPath || `/accept-invite?token=${encodeURIComponent(token)}`;
await router.replace({ path: '/auth/login', query: { redirect: returnTo } });
state.loading = false
return
}
state.loading = false;
return;
}
// 2) chama RPC
const { data, error } = await supabase.rpc('tenant_accept_invite', { p_token: token })
// 2) chama RPC
const { data, error } = await supabase.rpc('tenant_accept_invite', { p_token: token });
if (error) {
state.loading = false
if (error) {
state.loading = false;
const { friendly, raw } = safeRpcError(error)
state.error = friendly
const { friendly, raw } = safeRpcError(error);
state.error = friendly;
// Se você quiser ver a mensagem "crua" para debug, descomente a linha abaixo:
// state.debugDetails = raw
// Se você quiser ver a mensagem "crua" para debug, descomente a linha abaixo:
// state.debugDetails = raw
// Opcional: toast discreto
toast.add({
severity: 'error',
summary: 'Falha no convite',
detail: friendly,
life: 4000
})
return
}
// Opcional: toast discreto
toast.add({
severity: 'error',
summary: 'Falha no convite',
detail: friendly,
life: 4000
});
return;
}
// 3) sucesso → limpa token pendente
clearPendingToken()
// 3) sucesso → limpa token pendente
clearPendingToken();
// 4) atualiza tenantStore
const acceptedTenantId = data?.tenant_id || data?.tenantId || null
try {
await refreshTenantContextAfterInvite(acceptedTenantId)
} catch (_) {
// Silencioso: aceite ocorreu; não vamos quebrar o fluxo.
}
// 4) atualiza tenantStore
const acceptedTenantId = data?.tenant_id || data?.tenantId || null;
try {
await refreshTenantContextAfterInvite(acceptedTenantId);
} catch (_) {
// Silencioso: aceite ocorreu; não vamos quebrar o fluxo.
}
state.loading = false
state.success = true
state.loading = false;
state.success = true;
// 5) redireciona
await router.replace('/admin')
// 5) redireciona
await router.replace('/admin');
}
async function refreshTenantContextAfterInvite (acceptedTenantId) {
if (typeof tenantStore.refreshMyTenantsAndMemberships === 'function') {
await tenantStore.refreshMyTenantsAndMemberships()
} else {
if (typeof tenantStore.fetchMyTenants === 'function') await tenantStore.fetchMyTenants()
if (typeof tenantStore.fetchMyMemberships === 'function') await tenantStore.fetchMyMemberships()
}
async function refreshTenantContextAfterInvite(acceptedTenantId) {
if (typeof tenantStore.refreshMyTenantsAndMemberships === 'function') {
await tenantStore.refreshMyTenantsAndMemberships();
} else {
if (typeof tenantStore.fetchMyTenants === 'function') await tenantStore.fetchMyTenants();
if (typeof tenantStore.fetchMyMemberships === 'function') await tenantStore.fetchMyMemberships();
}
if (acceptedTenantId && typeof tenantStore.setActiveTenantId === 'function') {
tenantStore.setActiveTenantId(acceptedTenantId)
}
if (acceptedTenantId && typeof tenantStore.setActiveTenantId === 'function') {
tenantStore.setActiveTenantId(acceptedTenantId);
}
if (typeof tenantStore.hydrateActiveTenantContext === 'function') {
await tenantStore.hydrateActiveTenantContext()
} else if (typeof tenantStore.refreshActiveTenant === 'function') {
await tenantStore.refreshActiveTenant()
}
if (typeof tenantStore.hydrateActiveTenantContext === 'function') {
await tenantStore.hydrateActiveTenantContext();
} else if (typeof tenantStore.refreshActiveTenant === 'function') {
await tenantStore.refreshActiveTenant();
}
}
async function run () {
state.loading = true
state.error = ''
state.success = false
state.debugDetails = ''
async function run() {
state.loading = true;
state.error = '';
state.success = false;
state.debugDetails = '';
const token = tokenEffective.value
const token = tokenEffective.value;
if (!token) {
state.loading = false
state.error = 'Token ausente. Abra novamente o link do convite.'
return
}
if (!token) {
state.loading = false;
state.error = 'Token ausente. Abra novamente o link do convite.';
return;
}
if (!isUuid(token)) {
state.loading = false
state.error = 'Token inválido. Verifique se o link está completo.'
return
}
if (!isUuid(token)) {
state.loading = false;
state.error = 'Token inválido. Verifique se o link está completo.';
return;
}
// Se veio da query, persiste (caso precise atravessar login)
if (tokenFromQuery.value) persistPendingToken(token)
// Se veio da query, persiste (caso precise atravessar login)
if (tokenFromQuery.value) persistPendingToken(token);
await acceptInvite(token)
await acceptInvite(token);
}
async function retry () {
await run()
async function retry() {
await run();
}
onMounted(run)
</script>
onMounted(run);
</script>
<template>
<Toast />
<div class="min-h-screen flex items-center justify-center p-6 bg-[var(--surface-ground)] text-[var(--text-color)]">
<div class="w-full max-w-lg overflow-hidden rounded-[2rem] border border-[var(--surface-border)] bg-[var(--surface-card)] shadow-sm">
<!-- Header / Hero -->
<div class="relative p-6">
<div class="pointer-events-none absolute inset-0 opacity-80">
<div class="absolute -top-16 -right-16 h-64 w-64 rounded-full bg-indigo-400/10 blur-3xl" />
<div class="absolute top-10 -left-20 h-64 w-64 rounded-full bg-emerald-400/10 blur-3xl" />
<div class="absolute -bottom-16 right-10 h-64 w-64 rounded-full bg-fuchsia-400/10 blur-3xl" />
</div>
<div class="relative">
<h1 class="text-xl font-semibold mb-2">Aceitar convite</h1>
<p class="text-sm opacity-80">Vamos validar seu convite e ativar seu acesso ao tenant.</p>
<div class="mt-3 flex flex-wrap items-center gap-2 text-xs opacity-80">
<span class="inline-flex items-center gap-2 rounded-full border border-[var(--surface-border)] px-3 py-1">
<i class="pi pi-ticket" />
Token:
<b class="font-mono">{{ shortToken }}</b>
</span>
<span v-if="state.loading" class="inline-flex items-center gap-2 rounded-full border border-[var(--surface-border)] px-3 py-1">
<i class="pi pi-spin pi-spinner" />
Processando
</span>
<span v-else-if="state.success" class="inline-flex items-center gap-2 rounded-full border border-[var(--surface-border)] px-3 py-1">
<i class="pi pi-check" />
Confirmado
</span>
</div>
</div>
</div>
<!-- Body -->
<div class="p-6 border-t border-[var(--surface-border)]">
<!-- Loading -->
<div v-if="state.loading" class="text-sm">Processando convite</div>
<!-- Success -->
<div v-else-if="state.success" class="space-y-3">
<div class="text-sm"> Convite aceito com sucesso. Redirecionando</div>
<div class="text-xs opacity-70">Se você não for redirecionado, clique abaixo.</div>
<div class="flex gap-2">
<Button label="Ir para o painel" icon="pi pi-arrow-right" @click="goAdmin" />
</div>
</div>
<!-- Error -->
<div v-else-if="state.error" class="space-y-4">
<div class="rounded-2xl border border-red-500/30 bg-red-500/10 p-4 text-sm">
<div class="font-semibold mb-1">Não foi possível aceitar o convite</div>
<div class="opacity-90">{{ state.error }}</div>
<div v-if="state.debugDetails" class="mt-3 text-xs opacity-70">
<div class="font-semibold mb-1">Detalhes (debug)</div>
<pre class="m-0 whitespace-pre-wrap break-words">{{ state.debugDetails }}</pre>
</div>
</div>
<div class="flex flex-wrap gap-2">
<Button label="Tentar novamente" icon="pi pi-refresh" severity="secondary" outlined @click="retry" />
<Button label="Ir para login" icon="pi pi-sign-in" @click="goLogin" />
</div>
<p class="text-xs opacity-70 leading-relaxed">Se você recebeu um convite, confirme se está logado com o mesmo e-mail do convite.</p>
</div>
<!-- Idle -->
<div v-else class="text-sm opacity-80">Preparando</div>
</div>
</div>
</div>
</template>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+389 -485
View File
@@ -1,519 +1,423 @@
<template>
<div class="min-h-screen bg-[var(--surface-ground)] text-[var(--text-color)]">
<!-- TOPBAR -->
<div
class="sticky top-0 z-40 border-b border-[var(--surface-border)] bg-[color-mix(in_srgb,var(--surface-card),transparent_12%)] backdrop-blur"
>
<div class="mx-auto max-w-7xl px-4 md:px-6 py-3 flex items-center justify-between gap-3">
<div class="flex items-center gap-3 min-w-0">
<div
class="h-10 w-10 rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] grid place-items-center shadow-sm"
>
<i class="pi pi-sparkles text-lg opacity-80" />
</div>
<div class="min-w-0">
<div class="font-semibold leading-tight truncate">{{ brandName }}</div>
<div class="text-xs text-[var(--text-color-secondary)] truncate">Gestão clínica sem ruído.</div>
</div>
</div>
<div class="flex items-center gap-2">
<Button label="Entrar" icon="pi pi-sign-in" severity="secondary" outlined @click="go('/auth/login')" />
<Button label="Começar" icon="pi pi-bolt" @click="goStart()" />
</div>
</div>
</div>
<!-- HERO -->
<section class="relative overflow-hidden">
<!-- blobs / noir glow -->
<div class="pointer-events-none absolute inset-0">
<div class="absolute -top-28 -left-28 h-96 w-96 rounded-full blur-3xl opacity-60 bg-indigo-400/10" />
<div class="absolute top-24 -right-24 h-[28rem] w-[28rem] rounded-full blur-3xl opacity-60 bg-emerald-400/10" />
<div class="absolute -bottom-40 left-1/3 h-[34rem] w-[34rem] rounded-full blur-3xl opacity-60 bg-fuchsia-400/10" />
</div>
<div class="mx-auto max-w-7xl px-4 md:px-6 pt-10 md:pt-16 pb-8 md:pb-14 relative">
<div class="grid grid-cols-12 gap-6 items-center">
<div class="col-span-12 lg:col-span-7">
<Chip class="mb-4" label="Para psicólogos e clínicas" icon="pi pi-shield" />
<h1 class="text-3xl md:text-5xl font-semibold leading-tight">
Uma agenda inteligente, um prontuário organizado, um financeiro respirável.
</h1>
<p class="mt-4 text-base md:text-lg text-[var(--text-color-secondary)] max-w-2xl">
Centralize a rotina clínica em um lugar : pacientes, sessões, lembretes e indicadores. Menos dispersão.
Mais presença.
</p>
<div class="mt-6 flex flex-col sm:flex-row gap-2">
<Button
label="Criar conta grátis"
icon="pi pi-arrow-right"
class="w-full sm:w-auto"
@click="goStart()"
/>
<Button
label="Ver planos"
icon="pi pi-credit-card"
severity="secondary"
outlined
class="w-full sm:w-auto"
@click="scrollTo('pricing')"
/>
</div>
<div class="mt-6 flex flex-wrap gap-2">
<Tag severity="secondary" value="Agenda online (PRO)" />
<Tag severity="secondary" value="Controle de sessões" />
<Tag severity="secondary" value="Financeiro integrado" />
<Tag severity="secondary" value="Clínica / multi-profissional" />
</div>
</div>
<div class="col-span-12 lg:col-span-5">
<Card class="overflow-hidden">
<template #content>
<div class="p-1">
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-ground)] p-4">
<div class="flex items-start justify-between gap-3">
<div>
<div class="font-semibold text-lg">Painel de hoje</div>
<div class="text-sm text-[var(--text-color-secondary)]">Um recorte: o essencial, sem excesso.</div>
</div>
<i class="pi pi-chart-line opacity-70" />
</div>
<Divider class="my-4" />
<div class="grid grid-cols-12 gap-3">
<div class="col-span-12 md:col-span-6">
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-3">
<div class="text-xs text-[var(--text-color-secondary)]">Sessões</div>
<div class="text-2xl font-semibold mt-1">6</div>
<div class="text-xs text-[var(--text-color-secondary)] mt-1">com lembretes automáticos</div>
</div>
</div>
<div class="col-span-12 md:col-span-6">
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-3">
<div class="text-xs text-[var(--text-color-secondary)]">Recebimentos</div>
<div class="text-2xl font-semibold mt-1">R$ 840</div>
<div class="text-xs text-[var(--text-color-secondary)] mt-1">visão clara do mês</div>
</div>
</div>
<div class="col-span-12">
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-3">
<div class="flex items-center justify-between">
<div>
<div class="text-xs text-[var(--text-color-secondary)]">Prontuário</div>
<div class="font-semibold mt-1">Anotações e histórico</div>
<div class="text-xs text-[var(--text-color-secondary)] mt-1">
organizado por paciente, sessão e linha do tempo
</div>
</div>
<i class="pi pi-file-edit opacity-70" />
</div>
</div>
</div>
</div>
<div class="mt-4 text-xs text-[var(--text-color-secondary)]">
* Ilustração conceitual do produto.
</div>
</div>
</div>
</template>
</Card>
</div>
</div>
</div>
</section>
<!-- TRUST / VALUE STRIP -->
<section class="mx-auto max-w-7xl px-4 md:px-6 pb-10">
<div class="grid grid-cols-12 gap-4">
<div class="col-span-12 md:col-span-4">
<Card class="h-full">
<template #content>
<div class="flex items-start gap-3">
<div
class="h-10 w-10 rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-ground)] grid place-items-center"
>
<i class="pi pi-calendar opacity-80" />
</div>
<div>
<div class="font-semibold">Agenda e autoagendamento</div>
<div class="text-sm text-[var(--text-color-secondary)] mt-1">
O paciente confirma, agenda e reagenda com autonomia (PRO).
</div>
</div>
</div>
</template>
</Card>
</div>
<div class="col-span-12 md:col-span-4">
<Card class="h-full">
<template #content>
<div class="flex items-start gap-3">
<div
class="h-10 w-10 rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-ground)] grid place-items-center"
>
<i class="pi pi-wallet opacity-80" />
</div>
<div>
<div class="font-semibold">Financeiro integrado</div>
<div class="text-sm text-[var(--text-color-secondary)] mt-1">
Receita/despesa junto da agenda sem planilhas espalhadas.
</div>
</div>
</div>
</template>
</Card>
</div>
<div class="col-span-12 md:col-span-4">
<Card class="h-full">
<template #content>
<div class="flex items-start gap-3">
<div
class="h-10 w-10 rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-ground)] grid place-items-center"
>
<i class="pi pi-lock opacity-80" />
</div>
<div>
<div class="font-semibold">Prontuário e controle de sessões</div>
<div class="text-sm text-[var(--text-color-secondary)] mt-1">
Registro clínico e histórico acessíveis, com backups e organização.
</div>
</div>
</div>
</template>
</Card>
</div>
</div>
<div class="mt-3 text-xs text-[var(--text-color-secondary)]">
Inspirações de módulos comuns no mercado: agenda online, financeiro, prontuário/controle de sessões e gestão de
clínica.
</div>
</section>
<!-- FEATURES -->
<section class="mx-auto max-w-7xl px-4 md:px-6 pb-12">
<div class="flex items-end justify-between gap-3 mb-4">
<div>
<div class="text-2xl md:text-3xl font-semibold">Recursos que sustentam a rotina</div>
<div class="text-sm text-[var(--text-color-secondary)] mt-1">
O foco é tirar o excesso de fricção sem invadir o que é do seu método.
</div>
</div>
<Button label="Ver planos" severity="secondary" outlined icon="pi pi-arrow-down" @click="scrollTo('pricing')" />
</div>
<div class="grid grid-cols-12 gap-4">
<div v-for="f in features" :key="f.title" class="col-span-12 md:col-span-6 lg:col-span-4">
<Card class="h-full">
<template #content>
<div class="flex items-start gap-3">
<div
class="h-10 w-10 rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-ground)] grid place-items-center"
>
<i :class="f.icon" class="opacity-80" />
</div>
<div class="min-w-0">
<div class="font-semibold">{{ f.title }}</div>
<div class="text-sm text-[var(--text-color-secondary)] mt-1">
{{ f.desc }}
</div>
<div v-if="f.pro" class="mt-2">
<Tag severity="warning" value="PRO" />
</div>
</div>
</div>
</template>
</Card>
</div>
</div>
<Divider class="my-8" />
<Accordion :activeIndex="0">
<AccordionTab header="Como fica o fluxo na prática?">
<div class="text-sm text-[var(--text-color-secondary)] leading-relaxed">
Você abre a agenda, a sessão acontece, o registro fica no prontuário, e o financeiro acompanha o movimento.
O sistema existe para manter o consultório respirando não para virar uma burocracia nova.
</div>
</AccordionTab>
<AccordionTab header="E para clínica (multi-profissionais)?">
<div class="text-sm text-[var(--text-color-secondary)] leading-relaxed">
Perfis por função, agendas separadas, repasses e visão gerencial quando você estiver pronto para crescer.
</div>
</AccordionTab>
<AccordionTab header="Privacidade e segurança">
<div class="text-sm text-[var(--text-color-secondary)] leading-relaxed">
Controle de acesso por conta, separação por clínica/tenant, e políticas de storage por usuário. (Os detalhes
de conformidade você pode expor numa página própria de segurança/LGPD.)
</div>
</AccordionTab>
</Accordion>
</section>
<!-- PRICING (dinâmico do SaaS) -->
<section id="pricing" class="mx-auto max-w-7xl px-4 md:px-6 pb-14 scroll-mt-24">
<div class="text-5xl md:text-4xl font-semibold text-center">Planos</div>
<div class="text-2xl md:text-2xl text-[var(--text-color-secondary)] mt-1 text-center">
Comece simples. Suba para PRO quando a agenda pedir automação.
</div>
<!-- header conceitual + toggle -->
<div class="flex flex-col items-center text-center mt-6">
<div class="flex items-center gap-3 mb-4">
<AvatarGroup>
<Avatar
image="https://fqjltiegiezfetthbags.supabase.co/storage/v1/render/image/public/block.images/blocks/avatars/circle/avatar-m-1.png"
shape="circle"
/>
<Avatar
image="https://fqjltiegiezfetthbags.supabase.co/storage/v1/render/image/public/block.images/blocks/avatars/circle/avatar-f-21.png"
shape="circle"
/>
<Avatar
image="https://fqjltiegiezfetthbags.supabase.co/storage/v1/render/image/public/block.images/blocks/avatars/circle/avatar-f-1.png"
shape="circle"
/>
<Avatar
image="https://fqjltiegiezfetthbags.supabase.co/storage/v1/render/image/public/block.images/blocks/avatars/circle/avatar-m-3.png"
shape="circle"
/>
</AvatarGroup>
<Divider layout="vertical" />
<span class="text-sm text-[var(--text-color-secondary)] font-medium">Happy Customers</span>
</div>
<div class="inline-flex items-center rounded-xl border border-[var(--surface-border)] bg-[var(--surface-50)] p-1">
<Button
label="Mensal"
size="small"
:severity="billingInterval === 'month' ? 'success' : 'secondary'"
:outlined="billingInterval !== 'month'"
@click="billingInterval = 'month'"
/>
<Button
label="Anual"
size="small"
:severity="billingInterval === 'year' ? 'success' : 'secondary'"
:outlined="billingInterval !== 'year'"
class="ml-1"
@click="billingInterval = 'year'"
/>
</div>
<div v-if="billingInterval === 'year'" class="mt-2">
<Tag severity="success" value="Economize até 20%" />
</div>
</div>
<div v-if="loadingPricing" class="mt-8 text-sm text-[var(--text-color-secondary)]">
Carregando planos...
</div>
<div v-else class="mt-8 grid grid-cols-12 gap-4">
<div v-for="p in pricing" :key="p.plan_id" class="col-span-12 md:col-span-4">
<Card
class="h-full overflow-hidden transition-transform"
:class="p.is_featured ? 'ring-1 ring-emerald-500/30 md:-translate-y-2 md:scale-[1.02]' : ''"
>
<template #content>
<div class="flex items-start justify-between gap-3">
<div>
<div class="text-sm text-[var(--text-color-secondary)]">
{{ p.badge || 'Plano' }}
</div>
<div class="text-xl font-semibold">
{{ p.public_name || p.plan_name || p.plan_key }}
</div>
</div>
<Tag v-if="p.is_featured" severity="success" value="Popular" />
</div>
<div class="mt-4 text-3xl font-semibold leading-none">
{{ formatBRLFromCents(priceFor(p)) }}
<span class="text-sm font-normal text-[var(--text-color-secondary)]">
/{{ billingInterval === 'month' ? 'mês' : 'ano' }}
</span>
</div>
<div
v-if="billingInterval === 'year'"
class="text-xs text-emerald-500 mt-1 font-medium"
>
Melhor custo-benefício
</div>
<div class="mt-2 text-sm text-[var(--text-color-secondary)] min-h-[44px]">
{{ p.public_description }}
</div>
<Divider class="my-4" />
<ul v-if="p.bullets?.length" class="space-y-2 text-sm">
<li v-for="b in p.bullets" :key="b.id" class="flex items-start gap-2">
<i class="pi pi-check mt-1 text-emerald-500"></i>
<span :class="b.highlight ? 'font-semibold' : 'text-[var(--text-color-secondary)]'">
{{ b.text }}
</span>
</li>
</ul>
<div v-else class="text-sm text-[var(--text-color-secondary)]">Benefícios em breve.</div>
<div class="mt-5">
<Button
label="Começar"
class="w-full"
:severity="p.is_featured ? 'success' : 'secondary'"
:outlined="!p.is_featured"
icon="pi pi-arrow-right"
@click="go(`/auth/signup?plan=${p.plan_key}&interval=${billingInterval}`)"
/>
</div>
</template>
</Card>
</div>
</div>
<div class="mt-6 text-xs text-[var(--text-color-secondary)]">
Dica: estes planos vêm do painel SaaS (vitrine pública) e podem mapear diretamente para entitlements (FREE/PRO)
sem mexer no código.
</div>
</section>
<!-- FOOTER -->
<footer class="border-t border-[var(--surface-border)]">
<div
class="mx-auto max-w-7xl px-4 md:px-6 py-8 flex flex-col md:flex-row items-start md:items-center justify-between gap-4"
>
<div>
<div class="font-semibold">{{ brandName }}</div>
<div class="text-xs text-[var(--text-color-secondary)] mt-1">© {{ year }} Todos os direitos reservados.</div>
</div>
<div class="flex flex-wrap gap-2">
<Button label="Entrar" severity="secondary" outlined icon="pi pi-sign-in" @click="go('/auth/login')" />
<Button label="Criar conta" icon="pi pi-bolt" @click="goStart()" />
</div>
</div>
</footer>
</div>
</template>
<script setup>
import { computed, ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { supabase } from '@/lib/supabase/client'
import { computed, ref, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import { supabase } from '@/lib/supabase/client';
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'
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 router = useRouter();
const brandName = 'Psi Quasar' // ajuste para o nome final do produto
const year = computed(() => new Date().getFullYear())
const brandName = 'Psi Quasar'; // ajuste para o nome final do produto
const year = computed(() => new Date().getFullYear());
function go(path) {
router.push(path)
router.push(path);
}
function scrollTo(id) {
const el = document.getElementById(id)
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' })
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
})
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
}
if (featuredPlanKey.value) {
router.push(`/auth/signup?plan=${featuredPlanKey.value}&interval=${billingInterval.value}`);
return;
}
router.push('/auth/signup')
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'
}
])
{
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)
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' })
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
return billingInterval.value === 'year' ? p.yearly_cents : p.monthly_cents;
}
async function fetchPricing() {
loadingPricing.value = true
loadingPricing.value = true;
const { data, error } = await supabase
.from('v_public_pricing')
.select('*')
.eq('is_visible', true)
.order('sort_order', { ascending: true })
const { data, error } = await supabase.from('v_public_pricing').select('*').eq('is_visible', true).order('sort_order', { ascending: true });
loadingPricing.value = false
loadingPricing.value = false;
if (!error) pricing.value = data || []
if (!error) pricing.value = data || [];
}
onMounted(fetchPricing)
onMounted(fetchPricing);
</script>
<template>
<div class="min-h-screen bg-[var(--surface-ground)] text-[var(--text-color)]">
<!-- TOPBAR -->
<div class="sticky top-0 z-40 border-b border-[var(--surface-border)] bg-[color-mix(in_srgb,var(--surface-card),transparent_12%)] backdrop-blur">
<div class="mx-auto max-w-7xl px-4 md:px-6 py-3 flex items-center justify-between gap-3">
<div class="flex items-center gap-3 min-w-0">
<div class="h-10 w-10 rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] grid place-items-center shadow-sm">
<i class="pi pi-sparkles text-lg opacity-80" />
</div>
<div class="min-w-0">
<div class="font-semibold leading-tight truncate">{{ brandName }}</div>
<div class="text-xs text-[var(--text-color-secondary)] truncate">Gestão clínica sem ruído.</div>
</div>
</div>
<div class="flex items-center gap-2">
<Button label="Entrar" icon="pi pi-sign-in" severity="secondary" outlined @click="go('/auth/login')" />
<Button label="Começar" icon="pi pi-bolt" @click="goStart()" />
</div>
</div>
</div>
<!-- HERO -->
<section class="relative overflow-hidden">
<!-- blobs / noir glow -->
<div class="pointer-events-none absolute inset-0">
<div class="absolute -top-28 -left-28 h-96 w-96 rounded-full blur-3xl opacity-60 bg-indigo-400/10" />
<div class="absolute top-24 -right-24 h-[28rem] w-[28rem] rounded-full blur-3xl opacity-60 bg-emerald-400/10" />
<div class="absolute -bottom-40 left-1/3 h-[34rem] w-[34rem] rounded-full blur-3xl opacity-60 bg-fuchsia-400/10" />
</div>
<div class="mx-auto max-w-7xl px-4 md:px-6 pt-10 md:pt-16 pb-8 md:pb-14 relative">
<div class="grid grid-cols-12 gap-6 items-center">
<div class="col-span-12 lg:col-span-7">
<Chip class="mb-4" label="Para psicólogos e clínicas" icon="pi pi-shield" />
<h1 class="text-3xl md:text-5xl font-semibold leading-tight">Uma agenda inteligente, um prontuário organizado, um financeiro respirável.</h1>
<p class="mt-4 text-base md:text-lg text-[var(--text-color-secondary)] max-w-2xl">Centralize a rotina clínica em um lugar : pacientes, sessões, lembretes e indicadores. Menos dispersão. Mais presença.</p>
<div class="mt-6 flex flex-col sm:flex-row gap-2">
<Button label="Criar conta grátis" icon="pi pi-arrow-right" class="w-full sm:w-auto" @click="goStart()" />
<Button label="Ver planos" icon="pi pi-credit-card" severity="secondary" outlined class="w-full sm:w-auto" @click="scrollTo('pricing')" />
</div>
<div class="mt-6 flex flex-wrap gap-2">
<Tag severity="secondary" value="Agenda online (PRO)" />
<Tag severity="secondary" value="Controle de sessões" />
<Tag severity="secondary" value="Financeiro integrado" />
<Tag severity="secondary" value="Clínica / multi-profissional" />
</div>
</div>
<div class="col-span-12 lg:col-span-5">
<Card class="overflow-hidden">
<template #content>
<div class="p-1">
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-ground)] p-4">
<div class="flex items-start justify-between gap-3">
<div>
<div class="font-semibold text-lg">Painel de hoje</div>
<div class="text-sm text-[var(--text-color-secondary)]">Um recorte: o essencial, sem excesso.</div>
</div>
<i class="pi pi-chart-line opacity-70" />
</div>
<Divider class="my-4" />
<div class="grid grid-cols-12 gap-3">
<div class="col-span-12 md:col-span-6">
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-3">
<div class="text-xs text-[var(--text-color-secondary)]">Sessões</div>
<div class="text-2xl font-semibold mt-1">6</div>
<div class="text-xs text-[var(--text-color-secondary)] mt-1">com lembretes automáticos</div>
</div>
</div>
<div class="col-span-12 md:col-span-6">
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-3">
<div class="text-xs text-[var(--text-color-secondary)]">Recebimentos</div>
<div class="text-2xl font-semibold mt-1">R$ 840</div>
<div class="text-xs text-[var(--text-color-secondary)] mt-1">visão clara do mês</div>
</div>
</div>
<div class="col-span-12">
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-3">
<div class="flex items-center justify-between">
<div>
<div class="text-xs text-[var(--text-color-secondary)]">Prontuário</div>
<div class="font-semibold mt-1">Anotações e histórico</div>
<div class="text-xs text-[var(--text-color-secondary)] mt-1">organizado por paciente, sessão e linha do tempo</div>
</div>
<i class="pi pi-file-edit opacity-70" />
</div>
</div>
</div>
</div>
<div class="mt-4 text-xs text-[var(--text-color-secondary)]">* Ilustração conceitual do produto.</div>
</div>
</div>
</template>
</Card>
</div>
</div>
</div>
</section>
<!-- TRUST / VALUE STRIP -->
<section class="mx-auto max-w-7xl px-4 md:px-6 pb-10">
<div class="grid grid-cols-12 gap-4">
<div class="col-span-12 md:col-span-4">
<Card class="h-full">
<template #content>
<div class="flex items-start gap-3">
<div class="h-10 w-10 rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-ground)] grid place-items-center">
<i class="pi pi-calendar opacity-80" />
</div>
<div>
<div class="font-semibold">Agenda e autoagendamento</div>
<div class="text-sm text-[var(--text-color-secondary)] mt-1">O paciente confirma, agenda e reagenda com autonomia (PRO).</div>
</div>
</div>
</template>
</Card>
</div>
<div class="col-span-12 md:col-span-4">
<Card class="h-full">
<template #content>
<div class="flex items-start gap-3">
<div class="h-10 w-10 rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-ground)] grid place-items-center">
<i class="pi pi-wallet opacity-80" />
</div>
<div>
<div class="font-semibold">Financeiro integrado</div>
<div class="text-sm text-[var(--text-color-secondary)] mt-1">Receita/despesa junto da agenda sem planilhas espalhadas.</div>
</div>
</div>
</template>
</Card>
</div>
<div class="col-span-12 md:col-span-4">
<Card class="h-full">
<template #content>
<div class="flex items-start gap-3">
<div class="h-10 w-10 rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-ground)] grid place-items-center">
<i class="pi pi-lock opacity-80" />
</div>
<div>
<div class="font-semibold">Prontuário e controle de sessões</div>
<div class="text-sm text-[var(--text-color-secondary)] mt-1">Registro clínico e histórico acessíveis, com backups e organização.</div>
</div>
</div>
</template>
</Card>
</div>
</div>
<div class="mt-3 text-xs text-[var(--text-color-secondary)]">Inspirações de módulos comuns no mercado: agenda online, financeiro, prontuário/controle de sessões e gestão de clínica.</div>
</section>
<!-- FEATURES -->
<section class="mx-auto max-w-7xl px-4 md:px-6 pb-12">
<div class="flex items-end justify-between gap-3 mb-4">
<div>
<div class="text-2xl md:text-3xl font-semibold">Recursos que sustentam a rotina</div>
<div class="text-sm text-[var(--text-color-secondary)] mt-1">O foco é tirar o excesso de fricção sem invadir o que é do seu método.</div>
</div>
<Button label="Ver planos" severity="secondary" outlined icon="pi pi-arrow-down" @click="scrollTo('pricing')" />
</div>
<div class="grid grid-cols-12 gap-4">
<div v-for="f in features" :key="f.title" class="col-span-12 md:col-span-6 lg:col-span-4">
<Card class="h-full">
<template #content>
<div class="flex items-start gap-3">
<div class="h-10 w-10 rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-ground)] grid place-items-center">
<i :class="f.icon" class="opacity-80" />
</div>
<div class="min-w-0">
<div class="font-semibold">{{ f.title }}</div>
<div class="text-sm text-[var(--text-color-secondary)] mt-1">
{{ f.desc }}
</div>
<div v-if="f.pro" class="mt-2">
<Tag severity="warning" value="PRO" />
</div>
</div>
</div>
</template>
</Card>
</div>
</div>
<Divider class="my-8" />
<Accordion :activeIndex="0">
<AccordionTab header="Como fica o fluxo na prática?">
<div class="text-sm text-[var(--text-color-secondary)] leading-relaxed">
Você abre a agenda, a sessão acontece, o registro fica no prontuário, e o financeiro acompanha o movimento. O sistema existe para manter o consultório respirando não para virar uma burocracia nova.
</div>
</AccordionTab>
<AccordionTab header="E para clínica (multi-profissionais)?">
<div class="text-sm text-[var(--text-color-secondary)] leading-relaxed">Perfis por função, agendas separadas, repasses e visão gerencial quando você estiver pronto para crescer.</div>
</AccordionTab>
<AccordionTab header="Privacidade e segurança">
<div class="text-sm text-[var(--text-color-secondary)] leading-relaxed">
Controle de acesso por conta, separação por clínica/tenant, e políticas de storage por usuário. (Os detalhes de conformidade você pode expor numa página própria de segurança/LGPD.)
</div>
</AccordionTab>
</Accordion>
</section>
<!-- PRICING (dinâmico do SaaS) -->
<section id="pricing" class="mx-auto max-w-7xl px-4 md:px-6 pb-14 scroll-mt-24">
<div class="text-5xl md:text-4xl font-semibold text-center">Planos</div>
<div class="text-2xl md:text-2xl text-[var(--text-color-secondary)] mt-1 text-center">Comece simples. Suba para PRO quando a agenda pedir automação.</div>
<!-- header conceitual + toggle -->
<div class="flex flex-col items-center text-center mt-6">
<div class="flex items-center gap-3 mb-4">
<AvatarGroup>
<Avatar image="https://fqjltiegiezfetthbags.supabase.co/storage/v1/render/image/public/block.images/blocks/avatars/circle/avatar-m-1.png" shape="circle" />
<Avatar image="https://fqjltiegiezfetthbags.supabase.co/storage/v1/render/image/public/block.images/blocks/avatars/circle/avatar-f-21.png" shape="circle" />
<Avatar image="https://fqjltiegiezfetthbags.supabase.co/storage/v1/render/image/public/block.images/blocks/avatars/circle/avatar-f-1.png" shape="circle" />
<Avatar image="https://fqjltiegiezfetthbags.supabase.co/storage/v1/render/image/public/block.images/blocks/avatars/circle/avatar-m-3.png" shape="circle" />
</AvatarGroup>
<Divider layout="vertical" />
<span class="text-sm text-[var(--text-color-secondary)] font-medium">Happy Customers</span>
</div>
<div class="inline-flex items-center rounded-xl border border-[var(--surface-border)] bg-[var(--surface-50)] p-1">
<Button label="Mensal" size="small" :severity="billingInterval === 'month' ? 'success' : 'secondary'" :outlined="billingInterval !== 'month'" @click="billingInterval = 'month'" />
<Button label="Anual" size="small" :severity="billingInterval === 'year' ? 'success' : 'secondary'" :outlined="billingInterval !== 'year'" class="ml-1" @click="billingInterval = 'year'" />
</div>
<div v-if="billingInterval === 'year'" class="mt-2">
<Tag severity="success" value="Economize até 20%" />
</div>
</div>
<div v-if="loadingPricing" class="mt-8 text-sm text-[var(--text-color-secondary)]">Carregando planos...</div>
<div v-else class="mt-8 grid grid-cols-12 gap-4">
<div v-for="p in pricing" :key="p.plan_id" class="col-span-12 md:col-span-4">
<Card class="h-full overflow-hidden transition-transform" :class="p.is_featured ? 'ring-1 ring-emerald-500/30 md:-translate-y-2 md:scale-[1.02]' : ''">
<template #content>
<div class="flex items-start justify-between gap-3">
<div>
<div class="text-sm text-[var(--text-color-secondary)]">
{{ p.badge || 'Plano' }}
</div>
<div class="text-xl font-semibold">
{{ p.public_name || p.plan_name || p.plan_key }}
</div>
</div>
<Tag v-if="p.is_featured" severity="success" value="Popular" />
</div>
<div class="mt-4 text-3xl font-semibold leading-none">
{{ formatBRLFromCents(priceFor(p)) }}
<span class="text-sm font-normal text-[var(--text-color-secondary)]"> /{{ billingInterval === 'month' ? 'mês' : 'ano' }} </span>
</div>
<div v-if="billingInterval === 'year'" class="text-xs text-emerald-500 mt-1 font-medium">Melhor custo-benefício</div>
<div class="mt-2 text-sm text-[var(--text-color-secondary)] min-h-[44px]">
{{ p.public_description }}
</div>
<Divider class="my-4" />
<ul v-if="p.bullets?.length" class="space-y-2 text-sm">
<li v-for="b in p.bullets" :key="b.id" class="flex items-start gap-2">
<i class="pi pi-check mt-1 text-emerald-500"></i>
<span :class="b.highlight ? 'font-semibold' : 'text-[var(--text-color-secondary)]'">
{{ b.text }}
</span>
</li>
</ul>
<div v-else class="text-sm text-[var(--text-color-secondary)]">Benefícios em breve.</div>
<div class="mt-5">
<Button
label="Começar"
class="w-full"
:severity="p.is_featured ? 'success' : 'secondary'"
:outlined="!p.is_featured"
icon="pi pi-arrow-right"
@click="go(`/auth/signup?plan=${p.plan_key}&interval=${billingInterval}`)"
/>
</div>
</template>
</Card>
</div>
</div>
<div class="mt-6 text-xs text-[var(--text-color-secondary)]">Dica: estes planos vêm do painel SaaS (vitrine pública) e podem mapear diretamente para entitlements (FREE/PRO) sem mexer no código.</div>
</section>
<!-- FOOTER -->
<footer class="border-t border-[var(--surface-border)]">
<div class="mx-auto max-w-7xl px-4 md:px-6 py-8 flex flex-col md:flex-row items-start md:items-center justify-between gap-4">
<div>
<div class="font-semibold">{{ brandName }}</div>
<div class="text-xs text-[var(--text-color-secondary)] mt-1">© {{ year }} Todos os direitos reservados.</div>
</div>
<div class="flex flex-wrap gap-2">
<Button label="Entrar" severity="secondary" outlined icon="pi pi-sign-in" @click="go('/auth/login')" />
<Button label="Criar conta" icon="pi pi-bolt" @click="goStart()" />
</div>
</div>
</footer>
</div>
</template>
File diff suppressed because it is too large Load Diff
@@ -14,166 +14,129 @@
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
-->
<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 Message from 'primevue/message'
import { useToast } from 'primevue/usetoast'
import { computed, onMounted, ref } from 'vue';
import Message from 'primevue/message';
import { useToast } from 'primevue/usetoast';
import { supabase } from '@/lib/supabase/client' // ajuste se seu caminho for diferente
import { supabase } from '@/lib/supabase/client'; // ajuste se seu caminho for diferente
const toast = useToast()
const toast = useToast();
const inviteToken = ref('')
const rotating = ref(false)
const inviteToken = ref('');
const rotating = ref(false);
const origin = computed(() => window.location.origin)
const origin = computed(() => window.location.origin);
const publicUrl = computed(() => {
if (!inviteToken.value) return ''
return `${origin.value}/cadastro/paciente?t=${inviteToken.value}`
})
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)
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 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()
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)
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
if (error) throw error;
const token = data?.[0]?.token
if (token) {
inviteToken.value = token
return
}
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 })
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
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
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
}
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 })
}
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')
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 })
}
})
try {
await loadOrCreateInvite();
} catch (err) {
toast.add({ severity: 'error', summary: 'Erro', detail: err?.message || 'Falha ao carregar link.', life: 3500 });
}
});
</script>
<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>
+396 -454
View File
@@ -15,538 +15,480 @@
|--------------------------------------------------------------------------
-->
<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 { computed, onMounted, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useToast } from 'primevue/usetoast';
import { supabase } from '@/lib/supabase/client';
import Password from 'primevue/password'
import Chip from 'primevue/chip'
import Message from 'primevue/message'
import ProgressSpinner from 'primevue/progressspinner'
import Password from 'primevue/password';
import Chip from 'primevue/chip';
import Message from 'primevue/message';
import ProgressSpinner from 'primevue/progressspinner';
const route = useRoute()
const router = useRouter()
const toast = useToast()
const route = useRoute();
const router = useRouter();
const toast = useToast();
// ============================
// Form
// ============================
const email = ref('')
const password = ref('')
const loading = ref(false)
const email = ref('');
const password = ref('');
const loading = ref(false);
// validação simples (sem "viajar")
const emailOk = computed(() => /\S+@\S+\.\S+/.test(String(email.value || '').trim()))
const passwordOk = computed(() => String(password.value || '').length >= 6)
const canSubmit = computed(() => !loading.value && emailOk.value && passwordOk.value)
const emailOk = computed(() => /\S+@\S+\.\S+/.test(String(email.value || '').trim()));
const passwordOk = computed(() => String(password.value || '').length >= 6);
const canSubmit = computed(() => !loading.value && emailOk.value && passwordOk.value);
// ============================
// Query (plan / interval)
// ============================
const planFromQuery = computed(() => String(route.query.plan || '').trim().toLowerCase())
const intervalFromQuery = computed(() => String(route.query.interval || '').trim().toLowerCase())
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
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))
const intervalNormalized = computed(() => normalizeInterval(intervalFromQuery.value));
function isValidInterval (v) {
return v === 'month' || v === 'year'
function isValidInterval(v) {
return v === 'month' || v === 'year';
}
const hasPlanQuery = computed(() => !!planFromQuery.value && isValidInterval(intervalNormalized.value))
const hasPlanQuery = computed(() => !!planFromQuery.value && isValidInterval(intervalNormalized.value));
const intervalLabel = computed(() => {
if (intervalNormalized.value === 'year') return 'Anual'
if (intervalNormalized.value === 'month') return 'Mensal'
return ''
})
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 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 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 b = selectedPlanRow.value?.bullets;
return Array.isArray(b) ? b : [];
});
function amountForInterval (row, interval) {
if (!row) return null
const cents = interval === 'year' ? row.yearly_cents : row.monthly_cents
// fallback (se não existir preço no intervalo escolhido)
if (cents == null) return interval === 'year' ? row.monthly_cents : row.yearly_cents
return cents
function amountForInterval(row, interval) {
if (!row) return null;
const cents = interval === 'year' ? row.yearly_cents : row.monthly_cents;
// fallback (se não existir preço no intervalo escolhido)
if (cents == null) return interval === 'year' ? row.monthly_cents : row.yearly_cents;
return cents;
}
function currencyForInterval (row, interval) {
if (!row) return 'BRL'
const cur = interval === 'year' ? (row.yearly_currency || 'BRL') : (row.monthly_currency || 'BRL')
return cur || 'BRL'
function currencyForInterval(row, interval) {
if (!row) return 'BRL';
const cur = interval === 'year' ? row.yearly_currency || 'BRL' : row.monthly_currency || 'BRL';
return cur || 'BRL';
}
const amountCents = computed(() => amountForInterval(selectedPlanRow.value, intervalNormalized.value))
const currency = computed(() => currencyForInterval(selectedPlanRow.value, intervalNormalized.value))
const amountCents = computed(() => amountForInterval(selectedPlanRow.value, intervalNormalized.value));
const currency = computed(() => currencyForInterval(selectedPlanRow.value, intervalNormalized.value));
const formattedPrice = computed(() => {
if (amountCents.value == null) return null
try {
return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: currency.value || 'BRL' })
.format(Number(amountCents.value) / 100)
} catch {
return null
}
})
if (amountCents.value == null) return null;
try {
return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: currency.value || 'BRL' }).format(Number(amountCents.value) / 100);
} catch {
return null;
}
});
const showPlanCard = computed(() => hasPlanQuery.value && !!selectedPlanRow.value)
const showPlanCard = computed(() => hasPlanQuery.value && !!selectedPlanRow.value);
async function loadSelectedPlanRow () {
selectedPlanRow.value = null
if (!planFromQuery.value) return
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()
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
}
if (error) throw error;
if (!data) return;
selectedPlanRow.value = data;
} catch (err) {
console.error('[Signup] loadSelectedPlanRow:', err);
} finally {
pricingLoading.value = false;
}
}
onMounted(loadSelectedPlanRow)
onMounted(loadSelectedPlanRow);
watch(
() => [planFromQuery.value, intervalNormalized.value],
() => loadSelectedPlanRow()
)
() => [planFromQuery.value, intervalNormalized.value],
() => loadSelectedPlanRow()
);
// ============================
// subscription_intent (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()
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
if (error) throw error;
return data?.tenant_id || null;
}
async function createSubscriptionIntentAfterSignup (userId, preferredTenantId = null) {
if (!hasPlanQuery.value) return
if (!selectedPlanRow.value) return
if (amountCents.value == null) return
async function createSubscriptionIntentAfterSignup(userId, preferredTenantId = null) {
if (!hasPlanQuery.value) return;
if (!selectedPlanRow.value) return;
if (amountCents.value == null) return;
const tenantId = preferredTenantId || (await getActiveTenantIdForUser(userId))
if (!tenantId) throw new Error('Não encontrei tenant ativo para este usuário.')
const tenantId = preferredTenantId || (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,
const payload = {
tenant_id: tenantId,
created_by_user_id: userId,
// opcional (se sua tabela ainda tem user_id)
user_id: userId,
// opcional (se sua tabela ainda tem user_id)
user_id: userId,
email: String(email.value || '').trim().toLowerCase() || null,
plan_key: selectedPlanRow.value.plan_key,
interval: intervalNormalized.value,
amount_cents: amountCents.value,
currency: currency.value || 'BRL',
status: 'new',
source: 'landing'
}
email:
String(email.value || '')
.trim()
.toLowerCase() || 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
const { error } = await supabase.from('subscription_intents').insert(payload);
if (error) throw error;
}
// ============================
// Nav
// ============================
function goLogin () {
router.push({
path: '/auth/login',
query: email.value ? { email: String(email.value).trim() } : undefined
})
function goLogin() {
router.push({
path: '/auth/login',
query: email.value ? { email: String(email.value).trim() } : undefined
});
}
function goBackPricing () {
// você usa /lp#pricing — mantive
router.push('/lp#pricing')
function goBackPricing() {
// você usa /lp#pricing — mantive
router.push('/lp#pricing');
}
// ============================
// Signup
// ============================
async function onSignup () {
if (!canSubmit.value) return
async function onSignup() {
if (!canSubmit.value) return;
loading.value = true
try {
const cleanEmail = String(email.value || '').trim().toLowerCase()
loading.value = true;
try {
const cleanEmail = String(email.value || '')
.trim()
.toLowerCase();
const { data, error } = await supabase.auth.signUp({
email: cleanEmail,
password: password.value
})
const { data, error } = await supabase.auth.signUp({
email: cleanEmail,
password: password.value
});
if (error) throw error
if (error) throw error;
const userId = data?.user?.id || null
const userId = data?.user?.id || null;
// ✅ Modelo B: garante tenant pessoal (não aborta se falhar)
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)
}
// ✅ Modelo B: garante tenant pessoal (não aborta se falhar)
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);
}
// ✅ 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
});
}
}
// ✅ 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
})
}
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;
}
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 -->
<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 leading-relaxed">
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 -->
<div class="col-span-12 lg:col-span-6 p-6 md:p-10">
<div class="max-w-md mx-auto">
<div class="text-2xl font-semibold">Criar conta</div>
<div class="text-sm text-[var(--text-color-secondary)] mt-1">
tem conta?
<a class="cursor-pointer font-medium" @click.prevent="goLogin">Entrar</a>
</div>
<!-- Plano -->
<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 rounded-[2rem]">
<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 v-if="intervalLabel" :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)] leading-relaxed">
{{ 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>
<Divider class="my-6" />
<!-- Form -->
<div class="space-y-4">
<div>
<FloatLabel variant="on">
<InputText
id="signup_email"
v-model="email"
class="w-full"
autocomplete="email"
:disabled="loading"
@keydown.enter.prevent="onSignup"
/>
<label for="signup_email">Seu melhor e-mail</label>
</FloatLabel>
<div v-if="email && !emailOk" class="mt-2 text-xs text-orange-600">
Informe um e-mail válido.
</div>
</div>
<div>
<FloatLabel variant="on">
<Password
v-model="password"
inputId="signup_password"
toggleMask
:feedback="true"
autocomplete="new-password"
:disabled="loading"
@keydown.enter.prevent="onSignup"
:pt="{
root: { class: 'w-full' },
input: { class: 'w-full' }
}"
/>
<label for="signup_password">Senha</label>
</FloatLabel>
<div v-if="password && !passwordOk" class="mt-2 text-xs text-orange-600">
Use pelo menos 6 caracteres.
</div>
</div>
<Button
label="CRIAR CONTA"
class="w-full"
severity="success"
:loading="loading"
:disabled="!canSubmit"
icon="pi pi-arrow-right"
@click="onSignup"
/>
<div class="text-xs text-center text-[var(--text-color-secondary)]">
Ao criar a conta, registramos sua intenção de assinatura.
Pagamento é manual (PIX/boleto) por enquanto.
</div>
<div class="text-xs text-center">
<a class="cursor-pointer text-[var(--text-color-secondary)] hover:underline" @click.prevent="goLogin">
tenho conta entrar
</a>
</div>
</div>
</div>
</div>
<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>
<div class="text-center text-xs text-[var(--text-color-secondary)] mt-4">
Agência PSI gestão clínica sem ruído.
</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 -->
<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 leading-relaxed">
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 -->
<div class="col-span-12 lg:col-span-6 p-6 md:p-10">
<div class="max-w-md mx-auto">
<div class="text-2xl font-semibold">Criar conta</div>
<div class="text-sm text-[var(--text-color-secondary)] mt-1">
tem conta?
<a class="cursor-pointer font-medium" @click.prevent="goLogin">Entrar</a>
</div>
<!-- Plano -->
<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 rounded-[2rem]">
<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 v-if="intervalLabel" :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)] leading-relaxed">
{{ 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>
<Divider class="my-6" />
<!-- Form -->
<div class="space-y-4">
<div>
<FloatLabel variant="on">
<InputText id="signup_email" v-model="email" class="w-full" autocomplete="email" :disabled="loading" @keydown.enter.prevent="onSignup" />
<label for="signup_email">Seu melhor e-mail</label>
</FloatLabel>
<div v-if="email && !emailOk" class="mt-2 text-xs text-orange-600">Informe um e-mail válido.</div>
</div>
<div>
<FloatLabel variant="on">
<Password
v-model="password"
inputId="signup_password"
toggleMask
:feedback="true"
autocomplete="new-password"
:disabled="loading"
@keydown.enter.prevent="onSignup"
:pt="{
root: { class: 'w-full' },
input: { class: 'w-full' }
}"
/>
<label for="signup_password">Senha</label>
</FloatLabel>
<div v-if="password && !passwordOk" class="mt-2 text-xs text-orange-600">Use pelo menos 6 caracteres.</div>
</div>
<Button label="CRIAR CONTA" class="w-full" severity="success" :loading="loading" :disabled="!canSubmit" icon="pi pi-arrow-right" @click="onSignup" />
<div class="text-xs text-center text-[var(--text-color-secondary)]">Ao criar a conta, registramos sua intenção de assinatura. Pagamento é manual (PIX/boleto) por enquanto.</div>
<div class="text-xs text-center">
<a class="cursor-pointer text-[var(--text-color-secondary)] hover:underline" @click.prevent="goLogin"> tenho conta entrar </a>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="text-center text-xs text-[var(--text-color-secondary)] mt-4">Agência PSI gestão clínica sem ruído.</div>
</div>
</div>
</div>
</template>
</template>
+745
View File
@@ -0,0 +1,745 @@
<!--
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/views/pages/saas/SaasAddonsPage.vue
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
-->
<script setup>
import { ref, computed, onMounted } from 'vue';
import { useToast } from 'primevue/usetoast';
import { useConfirm } from 'primevue/useconfirm';
import { supabase } from '@/lib/supabase/client';
const toast = useToast();
const confirm = useConfirm();
const activeTab = ref(0);
// ══════════════════════════════════════════════════════════════
// Tenants (para selecionar ao adicionar créditos)
// ══════════════════════════════════════════════════════════════
const tenants = ref([]);
const tenantMap = ref({});
const loadingTenants = ref(false);
async function loadTenants() {
loadingTenants.value = true;
try {
const { data, error } = await supabase.from('tenants').select('id, name, kind').order('name', { ascending: true });
if (error) throw error;
const list = data || [];
tenantMap.value = Object.fromEntries(list.map((t) => [t.id, t.name || t.id]));
tenants.value = list.map((t) => ({
value: t.id,
label: `${t.name} (${t.kind ?? 'tenant'})`
}));
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro ao carregar tenants', detail: e?.message, life: 4000 });
} finally {
loadingTenants.value = false;
}
}
function tenantName(id) {
return tenantMap.value[id] || id?.slice(0, 8) + '…';
}
// ══════════════════════════════════════════════════════════════
// ABA 1 — Produtos (CRUD addon_products)
// ══════════════════════════════════════════════════════════════
const products = ref([]);
const productsLoading = ref(false);
const productDialog = ref(false);
const editingProduct = ref(null);
const emptyProduct = () => ({
slug: '',
name: '',
description: '',
addon_type: 'sms',
icon: 'pi pi-comment',
credits_amount: 0,
price_reais: 0,
is_active: true,
is_visible: true,
sort_order: 0
});
const productForm = ref(emptyProduct());
const addonTypes = [
{ label: 'SMS', value: 'sms' },
{ label: 'E-mail', value: 'email' },
{ label: 'Servidor', value: 'server' },
{ label: 'Domínio', value: 'domain' }
];
async function loadProducts() {
productsLoading.value = true;
const { data } = await supabase.from('addon_products').select('*').is('deleted_at', null).order('addon_type').order('sort_order');
productsLoading.value = false;
if (data) products.value = data;
}
function openNewProduct() {
editingProduct.value = null;
productForm.value = emptyProduct();
productDialog.value = true;
}
function openEditProduct(p) {
editingProduct.value = p;
productForm.value = { ...p, price_reais: (p.price_cents || 0) / 100 };
productDialog.value = true;
}
async function saveProduct() {
const f = productForm.value;
if (!f.slug || !f.name || !f.addon_type) {
toast.add({ severity: 'warn', summary: 'Preencha slug, nome e tipo', life: 3000 });
return;
}
const payload = {
slug: f.slug,
name: f.name,
description: f.description,
addon_type: f.addon_type,
icon: f.icon,
credits_amount: f.credits_amount || 0,
price_cents: Math.round((f.price_reais || 0) * 100),
is_active: f.is_active,
is_visible: f.is_visible,
sort_order: f.sort_order || 0,
updated_at: new Date().toISOString()
};
let error;
if (editingProduct.value) {
({ error } = await supabase.from('addon_products').update(payload).eq('id', editingProduct.value.id));
} else {
({ error } = await supabase.from('addon_products').insert(payload));
}
if (error) {
toast.add({ severity: 'error', summary: 'Erro ao salvar', detail: error.message, life: 4000 });
return;
}
toast.add({ severity: 'success', summary: 'Produto salvo', life: 3000 });
productDialog.value = false;
await loadProducts();
}
function deleteProduct(p) {
confirm.require({
group: 'headless',
header: 'Remover produto',
message: `Deseja remover "${p.name}"?`,
icon: 'pi-trash',
color: '#ef4444',
accept: async () => {
await supabase.from('addon_products').update({ deleted_at: new Date().toISOString() }).eq('id', p.id);
toast.add({ severity: 'success', summary: 'Produto removido', life: 3000 });
await loadProducts();
}
});
}
function formatPrice(cents) {
if (!cents) return '—';
return (cents / 100).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' });
}
// ══════════════════════════════════════════════════════════════
// ABA 2 — Créditos por Tenant
// ══════════════════════════════════════════════════════════════
const credits = ref([]);
const creditsLoading = ref(false);
// Dialog para adicionar créditos
const creditDialog = ref(false);
const creditForm = ref({ tenant_id: null, addon_type: 'sms', amount: 0, product_id: null, description: '', price_reais: 0 });
async function loadCredits() {
creditsLoading.value = true;
const { data } = await supabase
.from('addon_credits')
.select(
`
id, tenant_id, addon_type, balance, total_purchased, total_consumed,
low_balance_threshold, daily_limit, hourly_limit,
from_number_override, expires_at, is_active, created_at
`
)
.order('addon_type')
.order('balance', { ascending: true });
creditsLoading.value = false;
if (data) credits.value = data;
}
const productOptions = computed(() => products.value.filter((p) => p.is_active && p.addon_type === creditForm.value.addon_type).map((p) => ({ value: p.id, label: `${p.name} (${p.credits_amount} créd · ${formatPrice(p.price_cents)})`, product: p })));
function onProductSelect(evt) {
const opt = productOptions.value.find((o) => o.value === creditForm.value.product_id);
if (opt?.product) {
creditForm.value.amount = opt.product.credits_amount;
creditForm.value.price_reais = opt.product.price_cents / 100;
creditForm.value.description = opt.product.name;
}
}
function openAddCredit() {
creditForm.value = { tenant_id: null, addon_type: 'sms', amount: 100, product_id: null, description: 'Crédito manual', price_reais: 0 };
creditDialog.value = true;
}
function openAddCreditFor(tenantId) {
creditForm.value = { tenant_id: tenantId, addon_type: 'sms', amount: 100, product_id: null, description: 'Crédito manual', price_reais: 0 };
creditDialog.value = true;
}
// Agrupa créditos por tenant e vincula as transações de cada um
const tenantGroups = computed(() => {
const groups = {};
for (const c of credits.value) {
if (!groups[c.tenant_id]) {
groups[c.tenant_id] = { tenant_id: c.tenant_id, credits: [], transactions: [] };
}
groups[c.tenant_id].credits.push(c);
}
for (const tx of transactions.value) {
if (groups[tx.tenant_id]) {
groups[tx.tenant_id].transactions.push(tx);
}
}
// Ordena por nome do tenant
return Object.values(groups).sort((a, b) =>
tenantName(a.tenant_id).localeCompare(tenantName(b.tenant_id))
);
});
async function addCredit() {
const f = creditForm.value;
if (!f.tenant_id || !f.amount || f.amount <= 0) {
toast.add({ severity: 'warn', summary: 'Selecione um tenant e informe a quantidade', life: 3000 });
return;
}
const { data, error } = await supabase.rpc('admin_credit_addon', {
p_tenant_id: f.tenant_id,
p_addon_type: f.addon_type,
p_amount: f.amount,
p_product_id: f.product_id || null,
p_description: f.description || 'Crédito manual',
p_payment_method: 'manual',
p_price_cents: Math.round((f.price_reais || 0) * 100)
});
if (error) {
toast.add({ severity: 'error', summary: 'Erro ao creditar', detail: error.message, life: 5000 });
return;
}
toast.add({
severity: 'success',
summary: 'Créditos adicionados!',
detail: `Saldo: ${data?.balance_before}${data?.balance_after}`,
life: 5000
});
creditDialog.value = false;
await Promise.all([loadCredits(), loadTransactions()]);
}
// ══════════════════════════════════════════════════════════════
// ABA 3 — Transações recentes
// ══════════════════════════════════════════════════════════════
const transactions = ref([]);
const txLoading = ref(false);
async function loadTransactions() {
txLoading.value = true;
const { data } = await supabase.from('addon_transactions').select('*').order('created_at', { ascending: false }).limit(500);
txLoading.value = false;
if (data) transactions.value = data;
}
function txTypeLabel(type) {
const map = { purchase: 'Compra', consume: 'Consumo', adjustment: 'Ajuste', refund: 'Reembolso', expiration: 'Expiração' };
return map[type] || type;
}
function txTypeSeverity(type) {
const map = { purchase: 'success', consume: 'secondary', adjustment: 'info', refund: 'warn', expiration: 'danger' };
return map[type] || 'secondary';
}
function formatDate(iso) {
if (!iso) return '—';
return new Date(iso).toLocaleString('pt-BR', { dateStyle: 'short', timeStyle: 'short' });
}
// ══════════════════════════════════════════════════════════════
// Init
// ══════════════════════════════════════════════════════════════
onMounted(() => {
loadTenants();
loadProducts();
loadCredits();
loadTransactions();
});
</script>
<template>
<div class="flex flex-col gap-4">
<ConfirmDialog group="headless">
<template #container="{ message, acceptCallback, rejectCallback }">
<div class="flex flex-col items-center p-8 bg-surface-0 dark:bg-surface-900 rounded-xl shadow-xl">
<div class="rounded-full inline-flex justify-center items-center h-24 w-24 -mt-20" :style="{ background: message.color || 'var(--p-primary-color)', color: '#fff' }">
<i :class="`pi ${message.icon || 'pi-question'} !text-4xl`"></i>
</div>
<span class="font-bold text-2xl block mb-2 mt-6">{{ message.header }}</span>
<p class="mb-0 text-center text-[var(--text-color-secondary)]">{{ message.message }}</p>
<div class="flex items-center gap-2 mt-6">
<Button label="Confirmar" class="rounded-full" :style="{ background: message.color || 'var(--p-primary-color)', borderColor: message.color || 'var(--p-primary-color)' }" @click="acceptCallback" />
<Button label="Cancelar" variant="outlined" class="rounded-full" @click="rejectCallback" />
</div>
</div>
</template>
</ConfirmDialog>
<div class="flex items-center justify-between">
<h2 class="text-2xl font-bold m-0">Recursos Extras (Add-ons)</h2>
</div>
<!-- Próximos passos -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
<Card class="border-l-4" style="border-left-color: var(--p-yellow-500)">
<template #content>
<div class="flex items-start gap-3">
<i class="pi pi-bell text-2xl" style="color: var(--p-yellow-500)" />
<div>
<h4 class="font-semibold m-0 mb-1">Alerta de saldo baixo</h4>
<p class="text-sm text-surface-500 m-0">
Próximo passo: Notificar tenants automaticamente quando o saldo de créditos estiver abaixo do limite configurado. O campo <code>low_balance_threshold</code> existe no banco falta a Edge Function de verificação
periódica.
</p>
<Tag value="Planejado" severity="warn" class="mt-2" />
</div>
</div>
</template>
</Card>
<Card class="border-l-4" style="border-left-color: var(--p-blue-500)">
<template #content>
<div class="flex items-start gap-3">
<i class="pi pi-credit-card text-2xl" style="color: var(--p-blue-500)" />
<div>
<h4 class="font-semibold m-0 mb-1">Compra online (Gateway)</h4>
<p class="text-sm text-surface-500 m-0">
Próximo passo: Integrar gateway de pagamento (PIX, cartão) para que tenants comprem créditos diretamente pela plataforma. Os campos <code>payment_method</code> e <code>payment_reference</code> estão prontos no
banco.
</p>
<Tag value="Planejado" severity="info" class="mt-2" />
</div>
</div>
</template>
</Card>
</div>
<Tabs v-model:value="activeTab">
<TabList>
<Tab :value="0">Produtos</Tab>
<Tab :value="1">Recursos Extras por Tenant</Tab>
<Tab :value="2">Transações</Tab>
</TabList>
<TabPanels>
<!-- ABA 1: Produtos -->
<TabPanel :value="0">
<div class="flex justify-end mb-3">
<Button label="Novo produto" icon="pi pi-plus" size="small" @click="openNewProduct" />
</div>
<DataTable :value="products" :loading="productsLoading" size="small" stripedRows emptyMessage="Nenhum produto cadastrado.">
<Column field="slug" header="Slug" style="width: 130px" />
<Column field="name" header="Nome" />
<Column field="addon_type" header="Tipo" style="width: 90px">
<template #body="{ data }">
<Tag :value="data.addon_type.toUpperCase()" />
</template>
</Column>
<Column field="credits_amount" header="Créditos" style="width: 90px" />
<Column field="price_cents" header="Preço" style="width: 110px">
<template #body="{ data }">{{ formatPrice(data.price_cents) }}</template>
</Column>
<Column field="is_active" header="Ativo" style="width: 70px">
<template #body="{ data }">
<i :class="data.is_active ? 'pi pi-check text-green-500' : 'pi pi-times text-red-500'" />
</template>
</Column>
<Column field="is_visible" header="Visível" style="width: 70px">
<template #body="{ data }">
<i :class="data.is_visible ? 'pi pi-eye text-green-500' : 'pi pi-eye-slash text-surface-400'" />
</template>
</Column>
<Column header="Ações" style="width: 100px">
<template #body="{ data }">
<div class="flex gap-1">
<Button icon="pi pi-pencil" text rounded size="small" @click="openEditProduct(data)" />
<Button icon="pi pi-trash" text rounded size="small" severity="danger" @click="deleteProduct(data)" />
</div>
</template>
</Column>
</DataTable>
</TabPanel>
<!-- ABA 2: Créditos por Tenant -->
<TabPanel :value="1">
<div class="flex justify-between items-center mb-3">
<span class="text-sm text-surface-500">
{{ tenantGroups.length }} tenant(s) com recursos extras
</span>
<div class="flex gap-2">
<Button icon="pi pi-refresh" severity="secondary" outlined size="small" :loading="creditsLoading" @click="() => { loadCredits(); loadTransactions(); }" />
<Button label="Adicionar créditos" icon="pi pi-plus" size="small" @click="openAddCredit" />
</div>
</div>
<!-- Loading -->
<div v-if="creditsLoading" class="flex justify-center py-10">
<ProgressSpinner style="width:36px;height:36px" />
</div>
<!-- Vazio -->
<div v-else-if="!tenantGroups.length" class="text-center py-10 text-surface-400 text-sm">
<i class="pi pi-inbox block text-3xl opacity-30 mb-2" />
Nenhum tenant com recursos extras ainda.
</div>
<!-- Accordion por tenant -->
<Accordion v-else>
<AccordionPanel
v-for="group in tenantGroups"
:key="group.tenant_id"
:value="group.tenant_id"
>
<AccordionHeader>
<div class="flex items-center gap-3 w-full min-w-0 pr-3">
<!-- Nome do tenant -->
<span class="font-semibold text-sm truncate flex-1">
{{ tenantName(group.tenant_id) }}
</span>
<!-- Chips de saldo por addon_type -->
<div class="flex gap-1.5 flex-wrap shrink-0">
<Tag
v-for="c in group.credits"
:key="c.id"
:value="`${c.addon_type.toUpperCase()} · ${c.balance}`"
:severity="c.balance <= 0 ? 'danger' : c.balance <= (c.low_balance_threshold || 10) ? 'warn' : 'success'"
class="text-[0.68rem]"
/>
</div>
<!-- Botão + créditos inline -->
<Button
icon="pi pi-plus"
label="Creditar"
size="small"
severity="secondary"
outlined
class="shrink-0 text-xs !py-1"
@click.stop="openAddCreditFor(group.tenant_id)"
/>
</div>
</AccordionHeader>
<AccordionContent>
<div class="pt-1 pb-2">
<!-- Resumo do saldo por addon_type -->
<div class="flex flex-wrap gap-3 mb-4">
<div
v-for="c in group.credits"
:key="c.id"
class="flex flex-col gap-0.5 border border-[var(--surface-border)] rounded-lg px-3 py-2 min-w-[120px]"
>
<span class="text-[0.65rem] font-bold uppercase text-[var(--text-color-secondary)]">{{ c.addon_type }}</span>
<span
class="text-2xl font-bold leading-none"
:class="c.balance <= 0 ? 'text-red-500' : c.balance <= (c.low_balance_threshold || 10) ? 'text-yellow-500' : 'text-green-500'"
>{{ c.balance }}</span>
<span class="text-[0.68rem] text-[var(--text-color-secondary)]">
comprado {{ c.total_purchased }} · consumido {{ c.total_consumed }}
</span>
</div>
</div>
<!-- Histórico de adições -->
<div class="text-xs font-semibold text-[var(--text-color-secondary)] uppercase tracking-wide mb-2">
Histórico de movimentações
</div>
<DataTable
:value="group.transactions"
size="small"
striped-rows
empty-message="Nenhuma movimentação registrada."
class="text-sm"
>
<Column header="Data" style="width: 135px">
<template #body="{ data }">
<span class="text-xs">{{ formatDate(data.created_at) }}</span>
</template>
</Column>
<Column field="addon_type" header="Tipo" style="width: 65px">
<template #body="{ data }">
<span class="text-[0.65rem] font-semibold uppercase">{{ data.addon_type }}</span>
</template>
</Column>
<Column header="Operação" style="width: 100px">
<template #body="{ data }">
<Tag
:value="txTypeLabel(data.type)"
:severity="txTypeSeverity(data.type)"
class="text-[0.65rem]"
/>
</template>
</Column>
<Column header="Qtd" style="width: 70px">
<template #body="{ data }">
<span
class="font-semibold text-sm"
:class="data.amount > 0 ? 'text-green-500' : 'text-red-400'"
>
{{ data.amount > 0 ? '+' : '' }}{{ data.amount }}
</span>
</template>
</Column>
<Column header="Saldo após" style="width: 85px">
<template #body="{ data }">
<span class="text-sm font-semibold">{{ data.balance_after }}</span>
</template>
</Column>
<Column field="description" header="Descrição" />
<Column header="Valor pago" style="width: 100px">
<template #body="{ data }">
{{ data.price_cents ? formatPrice(data.price_cents) : '—' }}
</template>
</Column>
</DataTable>
</div>
</AccordionContent>
</AccordionPanel>
</Accordion>
</TabPanel>
<!-- ABA 3: Transações -->
<TabPanel :value="2">
<div class="flex justify-end mb-3">
<Button icon="pi pi-refresh" text rounded size="small" @click="loadTransactions" :loading="txLoading" />
</div>
<DataTable :value="transactions" :loading="txLoading" size="small" stripedRows emptyMessage="Nenhuma transação encontrada.">
<Column field="created_at" header="Data" style="width: 140px">
<template #body="{ data }">{{ formatDate(data.created_at) }}</template>
</Column>
<Column field="tenant_id" header="Tenant" style="max-width: 160px">
<template #body="{ data }">
<span class="text-sm" :title="data.tenant_id">{{ tenantName(data.tenant_id) }}</span>
</template>
</Column>
<Column field="addon_type" header="Tipo" style="width: 70px">
<template #body="{ data }"><Tag :value="data.addon_type" /></template>
</Column>
<Column field="type" header="Operação" style="width: 100px">
<template #body="{ data }">
<Tag :value="txTypeLabel(data.type)" :severity="txTypeSeverity(data.type)" />
</template>
</Column>
<Column field="amount" header="Qtd" style="width: 70px">
<template #body="{ data }">
<span :class="data.amount > 0 ? 'text-green-500 font-semibold' : 'text-red-500'"> {{ data.amount > 0 ? '+' : '' }}{{ data.amount }} </span>
</template>
</Column>
<Column field="balance_after" header="Saldo" style="width: 70px" />
<Column field="description" header="Descrição" />
<Column field="payment_method" header="Pgto" style="width: 80px">
<template #body="{ data }">{{ data.payment_method || '—' }}</template>
</Column>
</DataTable>
</TabPanel>
</TabPanels>
</Tabs>
<!-- Dialog: Novo/Editar Produto -->
<Dialog
v-model:visible="productDialog"
modal
:draggable="false"
:closable="true"
:dismissableMask="true"
maximizable
class="dc-dialog w-[36rem]"
:breakpoints="{ '1199px': '90vw', '768px': '94vw' }"
:pt="{
header: { class: '!p-3 !rounded-t-[12px] border-b border-[var(--surface-border)] shadow-[0_1px_0_0_rgba(255,255,255,0.06)] bg-gray-100' },
content: { class: '!p-3' },
footer: { class: '!p-0 !rounded-b-[12px] border-t border-[var(--surface-border)] shadow-[0_1px_0_0_rgba(255,255,255,0.06)] bg-gray-100' },
pcCloseButton: { root: { class: '!rounded-md hover:!text-red-500' } },
pcMaximizeButton: { root: { class: '!rounded-md hover:!text-primary' } }
}"
pt:mask:class="backdrop-blur-xs"
>
<template #header>
<div class="flex w-full items-center justify-between gap-3 px-1">
<div class="flex items-center gap-3 min-w-0">
<div class="min-w-0">
<div class="text-base font-semibold truncate">{{ editingProduct ? 'Editar Produto' : 'Novo Produto' }}</div>
<div class="text-xs opacity-50">Configurar produto de recurso extra</div>
</div>
</div>
</div>
</template>
<div class="flex flex-col gap-3">
<div class="flex flex-col gap-1">
<label class="font-medium text-sm">Slug</label>
<InputText v-model="productForm.slug" placeholder="sms_100" :disabled="!!editingProduct" class="w-full" />
</div>
<div class="flex flex-col gap-1">
<label class="font-medium text-sm">Nome</label>
<InputText v-model="productForm.name" placeholder="SMS 100 créditos" class="w-full" />
</div>
<div class="flex flex-col gap-1">
<label class="font-medium text-sm">Descrição</label>
<Textarea v-model="productForm.description" rows="2" class="w-full" />
</div>
<div class="grid grid-cols-2 gap-3">
<div class="flex flex-col gap-1">
<label class="font-medium text-sm">Tipo</label>
<Select v-model="productForm.addon_type" :options="addonTypes" optionLabel="label" optionValue="value" class="w-full" />
</div>
<div class="flex flex-col gap-1">
<label class="font-medium text-sm">Ícone</label>
<InputText v-model="productForm.icon" placeholder="pi pi-comment" class="w-full" />
</div>
</div>
<div class="grid grid-cols-2 gap-3">
<div class="flex flex-col gap-1">
<label class="font-medium text-sm">Créditos</label>
<InputNumber v-model="productForm.credits_amount" :min="0" fluid />
</div>
<div class="flex flex-col gap-1">
<label class="font-medium text-sm">Preço (R$)</label>
<InputNumber
v-model="productForm.price_reais"
:min="0"
:min-fraction-digits="2"
:max-fraction-digits="2"
mode="currency"
currency="BRL"
locale="pt-BR"
fluid
/>
</div>
</div>
<div class="flex flex-col gap-1 w-32">
<label class="font-medium text-sm">Ordem de exibição</label>
<InputNumber v-model="productForm.sort_order" :min="0" fluid />
</div>
<div class="flex gap-4">
<div class="flex items-center gap-2">
<ToggleSwitch v-model="productForm.is_active" />
<label class="text-sm">Ativo</label>
</div>
<div class="flex items-center gap-2">
<ToggleSwitch v-model="productForm.is_visible" />
<label class="text-sm">Visível na vitrine</label>
</div>
</div>
</div>
<template #footer>
<div class="flex items-center justify-end gap-2 px-3 py-3">
<Button label="Cancelar" severity="secondary" text class="rounded-full hover:!text-red-500" @click="productDialog = false" />
<Button label="Salvar" icon="pi pi-save" class="rounded-full" @click="saveProduct" />
</div>
</template>
</Dialog>
<!-- Dialog: Adicionar Créditos -->
<Dialog
v-model:visible="creditDialog"
modal
:draggable="false"
:closable="true"
:dismissableMask="true"
maximizable
class="dc-dialog w-[36rem]"
:breakpoints="{ '1199px': '90vw', '768px': '94vw' }"
:pt="{
header: { class: '!p-3 !rounded-t-[12px] border-b border-[var(--surface-border)] shadow-[0_1px_0_0_rgba(255,255,255,0.06)] bg-gray-100' },
content: { class: '!p-3' },
footer: { class: '!p-0 !rounded-b-[12px] border-t border-[var(--surface-border)] shadow-[0_1px_0_0_rgba(255,255,255,0.06)] bg-gray-100' },
pcCloseButton: { root: { class: '!rounded-md hover:!text-red-500' } },
pcMaximizeButton: { root: { class: '!rounded-md hover:!text-primary' } }
}"
pt:mask:class="backdrop-blur-xs"
>
<template #header>
<div class="flex w-full items-center justify-between gap-3 px-1">
<div class="flex items-center gap-3 min-w-0">
<div class="min-w-0">
<div class="text-base font-semibold truncate">Adicionar Créditos</div>
<div class="text-xs opacity-50">Creditar recursos extras para um tenant</div>
</div>
</div>
</div>
</template>
<div class="flex flex-col gap-3">
<div class="flex flex-col gap-1">
<label class="font-medium text-sm">Selecionar terapeuta / clínica</label>
<Select v-model="creditForm.tenant_id" :options="tenants" optionLabel="label" optionValue="value" placeholder="Escolha um tenant..." filter :loading="loadingTenants" class="w-full" />
</div>
<div class="flex flex-col gap-1">
<label class="font-medium text-sm">Tipo de recurso</label>
<Select v-model="creditForm.addon_type" :options="addonTypes" optionLabel="label" optionValue="value" class="w-full" />
</div>
<div class="flex flex-col gap-1">
<label class="font-medium text-sm">Pacote (opcional preenche automaticamente)</label>
<Select v-model="creditForm.product_id" :options="productOptions" optionLabel="label" optionValue="value" placeholder="Selecione um pacote ou preencha manualmente" showClear class="w-full" @change="onProductSelect" />
</div>
<div class="grid grid-cols-2 gap-3">
<div class="flex flex-col gap-1">
<label class="font-medium text-sm">Quantidade de créditos</label>
<InputNumber v-model="creditForm.amount" :min="1" class="w-full" />
</div>
<div class="flex flex-col gap-1">
<label class="font-medium text-sm">Valor pago (R$)</label>
<InputNumber v-model="creditForm.price_reais" :min="0" :minFractionDigits="2" :maxFractionDigits="2" mode="currency" currency="BRL" locale="pt-BR" class="w-full" />
</div>
</div>
<div class="flex flex-col gap-1">
<label class="font-medium text-sm">Descrição</label>
<InputText v-model="creditForm.description" placeholder="Crédito manual" class="w-full" />
</div>
</div>
<template #footer>
<div class="flex items-center justify-end gap-2 px-3 py-3">
<Button label="Cancelar" severity="secondary" text class="rounded-full hover:!text-red-500" @click="creditDialog = false" />
<Button label="Creditar" icon="pi pi-plus" class="rounded-full" @click="addCredit" />
</div>
</template>
</Dialog>
</div>
</template>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+397 -292
View File
@@ -15,354 +15,459 @@
|--------------------------------------------------------------------------
-->
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useToast } from 'primevue/usetoast'
import Editor from 'primevue/editor'
import { supabase } from '@/lib/supabase/client'
import { renderEmail } from '@/lib/email/emailTemplateService'
import { MOCK_DATA, TEMPLATE_DOMAINS } from '@/lib/email/emailTemplateConstants'
import { ref, computed, onMounted } from 'vue';
import { useToast } from 'primevue/usetoast';
import Editor from 'primevue/editor';
import { supabase } from '@/lib/supabase/client';
import { renderEmail } from '@/lib/email/emailTemplateService';
import { MOCK_DATA, TEMPLATE_DOMAINS } from '@/lib/email/emailTemplateConstants';
const toast = useToast()
const toast = useToast();
// ── Perfil (logo no preview) ───────────────────────────────────
const profileLogoUrl = ref(null)
const profileLogoUrl = ref(null);
async function loadProfile() {
const { data: { user } } = await supabase.auth.getUser()
if (!user) return
const { data } = await supabase
.from('profiles')
.select('avatar_url')
.eq('id', user.id)
.maybeSingle()
profileLogoUrl.value = data?.avatar_url || null
const {
data: { user }
} = await supabase.auth.getUser();
if (!user) return;
const { data } = await supabase.from('profiles').select('avatar_url').eq('id', user.id).maybeSingle();
profileLogoUrl.value = data?.avatar_url || null;
}
// ── Lista ──────────────────────────────────────────────────────
const templates = ref([])
const loading = ref(false)
const filterDomain = ref(null)
const templates = ref([]);
const loading = ref(false);
const filterDomain = ref(null);
async function load() {
loading.value = true
try {
const { data, error } = await supabase
.from('email_templates_global')
.select('*')
.order('domain')
.order('key')
if (error) throw error
templates.value = data
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro ao carregar', detail: e.message, life: 4000 })
} finally {
loading.value = false
}
loading.value = true;
try {
const { data, error } = await supabase.from('email_templates_global').select('*').order('domain').order('key');
if (error) throw error;
templates.value = data;
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro ao carregar', detail: e.message, life: 4000 });
} finally {
loading.value = false;
}
}
const filtered = computed(() => {
if (!filterDomain.value) return templates.value
return templates.value.filter(t => t.domain === filterDomain.value)
})
if (!filterDomain.value) return templates.value;
return templates.value.filter((t) => t.domain === filterDomain.value);
});
const DOMAIN_OPTIONS = [
{ label: 'Todos', value: null },
{ label: 'Sessão', value: TEMPLATE_DOMAINS.SESSION },
{ label: 'Triagem', value: TEMPLATE_DOMAINS.INTAKE },
{ label: 'Sistema', value: TEMPLATE_DOMAINS.SYSTEM },
{ label: 'Financeiro',value: TEMPLATE_DOMAINS.BILLING },
]
{ label: 'Todos', value: null },
{ label: 'Sessão', value: TEMPLATE_DOMAINS.SESSION },
{ label: 'Triagem', value: TEMPLATE_DOMAINS.INTAKE },
{ label: 'Sistema', value: TEMPLATE_DOMAINS.SYSTEM },
{ label: 'Financeiro', value: TEMPLATE_DOMAINS.BILLING }
];
const DOMAIN_LABEL = { session: 'Sessão', intake: 'Triagem', billing: 'Financeiro', system: 'Sistema' }
const DOMAIN_SEVERITY = { session: 'info', intake: 'warning', billing: 'success', system: 'secondary' }
const DOMAIN_SELECT_OPTIONS = [
{ label: 'Sessão', value: TEMPLATE_DOMAINS.SESSION },
{ label: 'Triagem', value: TEMPLATE_DOMAINS.INTAKE },
{ label: 'Sistema', value: TEMPLATE_DOMAINS.SYSTEM },
{ label: 'Financeiro', value: TEMPLATE_DOMAINS.BILLING }
];
// ── Dialog edição ──────────────────────────────────────────────
const dlg = ref({ open: false, saving: false, id: null })
const form = ref({})
const editorRef = ref(null)
const DOMAIN_LABEL = { session: 'Sessão', intake: 'Triagem', billing: 'Financeiro', system: 'Sistema' };
const DOMAIN_SEVERITY = { session: 'info', intake: 'warning', billing: 'success', system: 'secondary' };
function openEdit(t) {
form.value = {
key: t.key,
subject: t.subject,
body_html: t.body_html,
body_text: t.body_text ?? '',
version: t.version,
is_active: t.is_active,
variables: t.variables || {},
}
dlg.value = { open: true, saving: false, id: t.id }
// ── Variables editor helpers ───────────────────────────────────
// Trabalha internamente como array de {key, description} para o editor dinâmico
const varRows = ref([]); // [{ key: string, description: string }]
function _objectToRows(obj) {
return Object.entries(obj || {}).map(([key, description]) => ({ key, description: String(description) }));
}
function closeDlg() { dlg.value.open = false }
function _rowsToObject(rows) {
const obj = {};
rows.forEach((r) => {
const k = r.key?.trim();
if (k) obj[k] = r.description || '';
});
return obj;
}
function addVarRow() {
varRows.value.push({ key: '', description: '' });
}
function removeVarRow(idx) {
varRows.value.splice(idx, 1);
}
// ── Dialog edição / criação ────────────────────────────────────
const dlg = ref({ open: false, saving: false, id: null, isNew: false });
const form = ref({});
const editorRef = ref(null);
function openNew() {
form.value = {
key: '',
domain: TEMPLATE_DOMAINS.SESSION,
subject: '',
body_html: '',
body_text: '',
is_active: true
};
varRows.value = [];
dlg.value = { open: true, saving: false, id: null, isNew: true };
}
function openEdit(t) {
form.value = {
key: t.key,
domain: t.domain,
subject: t.subject,
body_html: t.body_html,
body_text: t.body_text ?? '',
version: t.version,
is_active: t.is_active
};
varRows.value = _objectToRows(t.variables);
dlg.value = { open: true, saving: false, id: t.id, isNew: false };
}
function closeDlg() {
dlg.value.open = false;
}
const dlgHeader = computed(() => (dlg.value.isNew ? 'Novo Template Global' : `Editar — ${form.value.key}`));
// Chaves disponíveis para inserção rápida no editor
const formVariables = computed(() => {
const keys = Object.keys(form.value.variables || {})
if (!keys.includes('clinic_logo_url')) keys.push('clinic_logo_url')
return keys
})
const keys = varRows.value.map((r) => r.key).filter(Boolean);
if (!keys.includes('clinic_logo_url')) keys.push('clinic_logo_url');
return keys;
});
// Insere {{varName}} na posição do cursor no Editor (Quill)
function insertVar(varName) {
const snippet = `{{${varName}}}`
const quill = editorRef.value?.quill
if (!quill) {
form.value.body_html = (form.value.body_html || '') + snippet
return
}
const range = quill.getSelection(true)
const index = range ? range.index : quill.getLength() - 1
quill.insertText(index, snippet, 'user')
quill.setSelection(index + snippet.length, 0)
const snippet = `{{${varName}}}`;
const quill = editorRef.value?.quill;
if (!quill) {
form.value.body_html = (form.value.body_html || '') + snippet;
return;
}
const range = quill.getSelection(true);
const index = range ? range.index : quill.getLength() - 1;
quill.insertText(index, snippet, 'user');
quill.setSelection(index + snippet.length, 0);
}
async function save() {
if (!form.value.subject?.trim() || !form.value.body_html?.trim()) {
toast.add({ severity: 'warn', summary: 'Subject e body são obrigatórios', life: 3000 })
return
}
dlg.value.saving = true
try {
const { error } = await supabase
.from('email_templates_global')
.update({
subject: form.value.subject,
body_html: form.value.body_html,
body_text: form.value.body_text || null,
version: form.value.version,
is_active: form.value.is_active,
})
.eq('id', dlg.value.id)
if (error) throw error
toast.add({ severity: 'success', summary: 'Template salvo', life: 3000 })
closeDlg()
await load()
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro ao salvar', detail: e.message, life: 5000 })
} finally {
dlg.value.saving = false
}
if (!form.value.subject?.trim()) {
toast.add({ severity: 'warn', summary: 'Subject é obrigatório', life: 3000 });
return;
}
if (!form.value.body_html?.trim()) {
toast.add({ severity: 'warn', summary: 'Body HTML é obrigatório', life: 3000 });
return;
}
if (dlg.value.isNew && !form.value.key?.trim()) {
toast.add({ severity: 'warn', summary: 'Key é obrigatória', life: 3000 });
return;
}
const variables = _rowsToObject(varRows.value);
dlg.value.saving = true;
try {
if (dlg.value.isNew) {
// INSERT — version começa em 1
const { error } = await supabase.from('email_templates_global').insert({
key: form.value.key.trim(),
domain: form.value.domain,
channel: 'email',
subject: form.value.subject.trim(),
body_html: form.value.body_html,
body_text: form.value.body_text || null,
version: 1,
is_active: form.value.is_active,
variables
});
if (error) throw error;
toast.add({ severity: 'success', summary: 'Template criado', detail: 'v1', life: 3000 });
} else {
// UPDATE — incrementa version automaticamente
const nextVersion = (form.value.version || 1) + 1;
const { error } = await supabase
.from('email_templates_global')
.update({
subject: form.value.subject.trim(),
body_html: form.value.body_html,
body_text: form.value.body_text || null,
version: nextVersion,
is_active: form.value.is_active,
variables
})
.eq('id', dlg.value.id);
if (error) throw error;
toast.add({ severity: 'success', summary: 'Template salvo', detail: `Versão ${nextVersion}`, life: 3000 });
}
closeDlg();
await load();
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro ao salvar', detail: e.message, life: 5000 });
} finally {
dlg.value.saving = false;
}
}
async function toggleActive(t) {
try {
const { error } = await supabase
.from('email_templates_global')
.update({ is_active: !t.is_active })
.eq('id', t.id)
if (error) throw error
await load()
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e.message, life: 4000 })
}
try {
const { error } = await supabase.from('email_templates_global').update({ is_active: !t.is_active }).eq('id', t.id);
if (error) throw error;
await load();
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e.message, life: 4000 });
}
}
// ── Dialog preview ─────────────────────────────────────────────
const preview = ref({ open: false, subject: '', body_html: '', key: '' })
const preview = ref({ open: false, subject: '', body_html: '', key: '' });
function openPreview(t) {
const mock = {
..._mockForDomain(t.domain),
clinic_logo_url: profileLogoUrl.value || null,
}
const rendered = renderEmail(t, mock)
preview.value = { open: true, ...rendered, key: t.key }
const mock = {
..._mockForDomain(t.domain),
clinic_logo_url: profileLogoUrl.value || null
};
const rendered = renderEmail(t, mock);
preview.value = { open: true, ...rendered, key: t.key };
}
function _mockForDomain(domain) {
if (domain === TEMPLATE_DOMAINS.SESSION) return { ...MOCK_DATA.session }
if (domain === TEMPLATE_DOMAINS.INTAKE) return { ...MOCK_DATA.intake }
return { ...MOCK_DATA.system }
if (domain === TEMPLATE_DOMAINS.SESSION) return { ...MOCK_DATA.session };
if (domain === TEMPLATE_DOMAINS.INTAKE) return { ...MOCK_DATA.intake };
return { ...MOCK_DATA.system };
}
onMounted(() => {
load()
loadProfile()
})
load();
loadProfile();
});
</script>
<template>
<div class="p-4 md:p-6 max-w-[1100px] mx-auto">
<!-- Header -->
<div class="flex items-center justify-between mb-6 gap-4 flex-wrap">
<div>
<h1 class="text-xl font-bold m-0">Templates de E-mail Globais</h1>
<p class="text-sm text-[var(--text-color-secondary)] mt-1 mb-0">
Templates base do sistema. Tenants podem criar overrides sem alterar estes.
</p>
</div>
<Button icon="pi pi-refresh" severity="secondary" text :loading="loading" @click="load" />
</div>
<!-- Filtro -->
<div class="flex gap-2 mb-4 flex-wrap">
<Button
v-for="opt in DOMAIN_OPTIONS"
:key="String(opt.value)"
:label="opt.label"
size="small"
:severity="filterDomain === opt.value ? 'primary' : 'secondary'"
:outlined="filterDomain !== opt.value"
@click="filterDomain = opt.value"
/>
</div>
<!-- Loading -->
<div v-if="loading" class="flex justify-center py-12">
<ProgressSpinner />
</div>
<!-- Lista -->
<div v-else class="flex flex-col gap-2">
<div
v-for="t in filtered"
:key="t.id"
class="border border-[var(--surface-border)] rounded-xl bg-[var(--surface-card)] px-4 py-3 flex gap-3 items-center"
:class="{ 'opacity-50': !t.is_active }"
>
<Tag
:value="DOMAIN_LABEL[t.domain] ?? t.domain"
:severity="DOMAIN_SEVERITY[t.domain] ?? 'secondary'"
class="text-[0.7rem] shrink-0"
/>
<div class="flex-1 min-w-0">
<div class="font-mono text-xs text-[var(--text-color-secondary)] mb-0.5">{{ t.key }}</div>
<div class="text-sm truncate font-medium">{{ t.subject }}</div>
</div>
<div class="hidden md:flex items-center gap-3 text-xs text-[var(--text-color-secondary)] shrink-0">
<span>v{{ t.version }}</span>
<Tag :value="t.channel" severity="secondary" class="text-[0.65rem]" />
</div>
<div class="flex items-center gap-1 shrink-0">
<Button icon="pi pi-eye" text rounded size="small" severity="secondary" title="Preview" @click="openPreview(t)" />
<Button icon="pi pi-pencil" text rounded size="small" title="Editar" @click="openEdit(t)" />
<Button
:icon="t.is_active ? 'pi pi-eye-slash' : 'pi pi-eye'"
text rounded size="small"
:severity="t.is_active ? 'secondary' : 'success'"
:title="t.is_active ? 'Desativar' : 'Ativar'"
@click="toggleActive(t)"
/>
</div>
</div>
<div v-if="!filtered.length" class="text-center py-12 text-[var(--text-color-secondary)]">
<i class="pi pi-envelope text-4xl opacity-30 block mb-3" />
Nenhum template neste domínio.
</div>
</div>
<!-- Dialog Edição -->
<Dialog
v-model:visible="dlg.open"
:header="`Editar — ${form.key}`"
modal
:style="{ width: '860px', maxWidth: '96vw' }"
:draggable="false"
>
<div class="flex flex-col gap-4 py-2">
<!-- Subject -->
<div class="flex flex-col gap-1.5">
<label class="text-xs font-semibold">Subject *</label>
<InputText v-model="form.subject" class="w-full" />
</div>
<!-- Body HTML Editor Quill -->
<div class="flex flex-col gap-2">
<label class="text-xs font-semibold">Body HTML *</label>
<Editor
ref="editorRef"
v-model="form.body_html"
editor-style="min-height: 260px; font-size: 0.85rem;"
/>
<!-- Botões de variáveis -->
<div class="flex flex-col gap-1.5 mt-1">
<span class="text-xs text-[var(--text-color-secondary)]">Inserir variável no cursor:</span>
<div class="flex flex-wrap gap-1.5">
<Button
v-for="v in formVariables"
:key="v"
:label="`{{${v}}}`"
size="small"
severity="secondary"
outlined
class="font-mono !text-[0.68rem] !py-1 !px-2"
@click="insertVar(v)"
/>
<div class="p-4 md:p-6 max-w-[1100px] mx-auto">
<!-- Header -->
<div class="flex items-center justify-between mb-6 gap-4 flex-wrap">
<div>
<h1 class="text-xl font-bold m-0">Templates de E-mail Globais</h1>
<p class="text-sm text-[var(--text-color-secondary)] mt-1 mb-0">Templates base do sistema. Tenants podem criar overrides sem alterar estes.</p>
</div>
<div class="flex gap-2">
<Button icon="pi pi-refresh" severity="secondary" text :loading="loading" @click="load" />
<Button label="Novo template" icon="pi pi-plus" @click="openNew" />
</div>
</div>
<p class="text-xs text-[var(--text-color-secondary)] m-0">
Suporta <code>&#123;&#123;variavel&#125;&#125;</code> e
<code>&#123;&#123;#if variavel&#125;&#125;...&#123;&#123;/if&#125;&#125;</code>
</p>
</div>
<!-- Body Text -->
<div class="flex flex-col gap-1.5">
<label class="text-xs font-semibold">
Body Text
<span class="font-normal opacity-60">(opcional gerado do HTML se vazio)</span>
</label>
<Textarea v-model="form.body_text" rows="2" class="w-full text-xs" auto-resize />
<!-- Filtro por domain -->
<div class="flex gap-2 mb-4 flex-wrap">
<Button
v-for="opt in DOMAIN_OPTIONS"
:key="String(opt.value)"
:label="opt.label"
size="small"
:severity="filterDomain === opt.value ? 'primary' : 'secondary'"
:outlined="filterDomain !== opt.value"
@click="filterDomain = opt.value"
/>
</div>
<!-- Versão (esquerda) + Ativo (direita) -->
<div class="flex items-end justify-between">
<div class="flex flex-col gap-1.5">
<label class="text-xs font-semibold">Versão</label>
<InputNumber v-model="form.version" :min="1" style="width:110px" />
</div>
<div class="flex items-center gap-2">
<ToggleSwitch v-model="form.is_active" inputId="sw-active-global" />
<label for="sw-active-global" class="text-sm cursor-pointer select-none">Ativo</label>
</div>
<!-- Loading -->
<div v-if="loading" class="flex justify-center py-12">
<ProgressSpinner />
</div>
</div>
<!-- Lista -->
<div v-else class="flex flex-col gap-2">
<div
v-for="t in filtered"
:key="t.id"
class="border border-(--surface-border) rounded-xl bg-(--surface-card) px-4 py-3 flex gap-3 items-center"
:class="{ 'opacity-50': !t.is_active }"
>
<Tag :value="DOMAIN_LABEL[t.domain] ?? t.domain" :severity="DOMAIN_SEVERITY[t.domain] ?? 'secondary'" class="text-[0.7rem] shrink-0" />
<div class="flex-1 min-w-0">
<div class="font-mono text-xs text-(--text-color-secondary) mb-0.5">{{ t.key }}</div>
<div class="text-sm truncate font-medium">{{ t.subject }}</div>
</div>
<div class="hidden md:flex items-center gap-3 text-xs text-(--text-color-secondary) shrink-0">
<span>v{{ t.version }}</span>
<Tag :value="t.channel" severity="secondary" class="text-[0.65rem]" />
</div>
<div class="flex items-center gap-1 shrink-0">
<Button icon="pi pi-eye" text rounded size="small" severity="secondary" title="Preview" @click="openPreview(t)" />
<Button icon="pi pi-pencil" text rounded size="small" title="Editar" @click="openEdit(t)" />
<Button
:icon="t.is_active ? 'pi pi-eye-slash' : 'pi pi-check-circle'"
text
rounded
size="small"
:severity="t.is_active ? 'secondary' : 'success'"
:title="t.is_active ? 'Desativar' : 'Ativar'"
@click="toggleActive(t)"
/>
</div>
</div>
<template #footer>
<Button label="Cancelar" text @click="closeDlg" :disabled="dlg.saving" />
<Button label="Salvar" icon="pi pi-check" :loading="dlg.saving" @click="save" />
</template>
</Dialog>
<!-- Dialog Preview -->
<Dialog
v-model:visible="preview.open"
:header="`Preview — ${preview.key}`"
modal
:style="{ width: '700px', maxWidth: '96vw' }"
:draggable="false"
>
<div class="flex flex-col gap-3">
<div class="border border-[var(--surface-border)] rounded-lg p-3 bg-[var(--surface-ground)]">
<span class="text-xs font-semibold text-[var(--text-color-secondary)] uppercase tracking-widest">Subject</span>
<p class="mt-1 mb-0 font-medium">{{ preview.subject }}</p>
<div v-if="!filtered.length" class="text-center py-12 text-(--text-color-secondary)">
<i class="pi pi-envelope text-4xl opacity-30 block mb-3" />
Nenhum template neste domínio.
</div>
</div>
<div class="border border-[var(--surface-border)] rounded-lg p-5 bg-white text-gray-800">
<div v-if="profileLogoUrl" class="mb-4 text-center">
<img :src="profileLogoUrl" alt="Logo" class="h-10 object-contain inline-block" />
</div>
<!-- eslint-disable-next-line vue/no-v-html -->
<div class="text-sm leading-relaxed" v-html="preview.body_html" />
</div>
<!-- Dialog Edição / Criação -->
<Dialog v-model:visible="dlg.open" :header="dlgHeader" modal :style="{ width: '880px', maxWidth: '96vw' }" :draggable="false">
<div class="flex flex-col gap-5 py-2">
<p class="text-xs text-[var(--text-color-secondary)] m-0 text-center">
Preview com dados de exemplo.
<span v-if="profileLogoUrl"> Logo do seu perfil.</span>
<span v-else> Sem logo configure em <b>Meu Perfil Avatar</b>.</span>
</p>
</div>
<!-- Key + Domain (linha no modo criação; no edit: readonly labels) -->
<div v-if="dlg.isNew" class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="flex flex-col gap-1.5">
<label class="text-xs font-semibold">Key *<span class="font-normal opacity-60 ml-1">(ex: session.reminder.email)</span></label>
<InputText v-model="form.key" placeholder="domain.nome.email" class="w-full font-mono" />
</div>
<div class="flex flex-col gap-1.5">
<label class="text-xs font-semibold">Domain *</label>
<Select v-model="form.domain" :options="DOMAIN_SELECT_OPTIONS" option-label="label" option-value="value" class="w-full" />
</div>
</div>
<template #footer>
<Button label="Fechar" @click="preview.open = false" />
</template>
</Dialog>
<div v-else class="flex items-center gap-3 text-sm">
<Tag :value="DOMAIN_LABEL[form.domain] ?? form.domain" :severity="DOMAIN_SEVERITY[form.domain] ?? 'secondary'" />
<span class="font-mono text-(--text-color-secondary)">{{ form.key }}</span>
<span class="ml-auto text-xs text-(--text-color-secondary) opacity-70">v{{ form.version }} v{{ (form.version || 1) + 1 }} ao salvar</span>
</div>
</div>
<!-- Subject -->
<div class="flex flex-col gap-1.5">
<label class="text-xs font-semibold">Subject *</label>
<InputText v-model="form.subject" class="w-full" placeholder="Assunto do e-mail — suporta {{variavel}}" />
</div>
<!-- Body HTML -->
<div class="flex flex-col gap-2">
<label class="text-xs font-semibold">Body HTML *</label>
<Editor ref="editorRef" v-model="form.body_html" editor-style="min-height: 240px; font-size: 0.85rem;" />
<!-- Botões de inserção de variáveis -->
<div v-if="formVariables.length" class="flex flex-col gap-1.5">
<span class="text-xs text-(--text-color-secondary)">Inserir variável no cursor:</span>
<div class="flex flex-wrap gap-1.5">
<Button
v-for="v in formVariables"
:key="v"
:label="`{{${v}}}`"
size="small"
severity="secondary"
outlined
class="font-mono text-[0.68rem]! py-1! px-2!"
@click="insertVar(v)"
/>
</div>
</div>
<p class="text-xs text-(--text-color-secondary) m-0">
Suporta <code>&#123;&#123;variavel&#125;&#125;</code> e
<code>&#123;&#123;#if variavel&#125;&#125;...&#123;&#123;/if&#125;&#125;</code>
</p>
</div>
<!-- Body Text -->
<div class="flex flex-col gap-1.5">
<label class="text-xs font-semibold">
Body Text
<span class="font-normal opacity-60">(opcional gerado do HTML se vazio)</span>
</label>
<Textarea v-model="form.body_text" rows="2" class="w-full text-xs" auto-resize />
</div>
<!-- Variables editor -->
<div class="flex flex-col gap-2">
<div class="flex items-center justify-between">
<label class="text-xs font-semibold">Variáveis disponíveis</label>
<Button label="Adicionar variável" icon="pi pi-plus" size="small" severity="secondary" text @click="addVarRow" />
</div>
<div v-if="varRows.length === 0" class="text-xs text-(--text-color-secondary) italic py-1">
Nenhuma variável definida. Clique em "Adicionar variável" para incluir.
</div>
<div v-else class="flex flex-col gap-2">
<div v-for="(row, idx) in varRows" :key="idx" class="flex gap-2 items-center">
<InputText
v-model="row.key"
placeholder="chave"
class="font-mono flex-[0_0_200px] text-sm"
size="small"
/>
<InputText
v-model="row.description"
placeholder="Descrição da variável"
class="flex-1 text-sm"
size="small"
/>
<Button icon="pi pi-times" text rounded size="small" severity="danger" @click="removeVarRow(idx)" />
</div>
</div>
<p class="text-xs text-(--text-color-secondary) m-0 mt-1">
Estas chaves ficam salvas no campo <code>variables</code> e guiam os tenants ao customizar o template.
</p>
</div>
<!-- Ativo toggle -->
<div class="flex items-center gap-2 pt-1 border-t border-(--surface-border)">
<ToggleSwitch v-model="form.is_active" inputId="sw-active-global" />
<label for="sw-active-global" class="text-sm cursor-pointer select-none">Template ativo</label>
</div>
</div>
<template #footer>
<Button label="Cancelar" text @click="closeDlg" :disabled="dlg.saving" />
<Button
:label="dlg.isNew ? 'Criar template' : 'Salvar'"
icon="pi pi-check"
:loading="dlg.saving"
@click="save"
/>
</template>
</Dialog>
<!-- Dialog Preview -->
<Dialog v-model:visible="preview.open" :header="`Preview — ${preview.key}`" modal :style="{ width: '700px', maxWidth: '96vw' }" :draggable="false">
<div class="flex flex-col gap-3">
<div class="border border-(--surface-border) rounded-lg p-3 bg-(--surface-ground)">
<span class="text-xs font-semibold text-(--text-color-secondary) uppercase tracking-widest">Subject</span>
<p class="mt-1 mb-0 font-medium">{{ preview.subject }}</p>
</div>
<div class="border border-(--surface-border) rounded-lg p-5 bg-white text-gray-800">
<div v-if="profileLogoUrl" class="mb-4 text-center">
<img :src="profileLogoUrl" alt="Logo" class="h-10 object-contain inline-block" />
</div>
<!-- eslint-disable-next-line vue/no-v-html -->
<div class="text-sm leading-relaxed" v-html="preview.body_html" />
</div>
<p class="text-xs text-(--text-color-secondary) m-0 text-center">
Preview com dados de exemplo.
<span v-if="profileLogoUrl"> Logo do seu perfil.</span>
<span v-else> Sem logo configure em <b>Meu Perfil Avatar</b>.</span>
</p>
</div>
<template #footer>
<Button label="Fechar" @click="preview.open = false" />
</template>
</Dialog>
</div>
</template>
+226 -238
View File
@@ -15,307 +15,295 @@
|--------------------------------------------------------------------------
-->
<script setup>
import { ref, computed, onMounted, watch } from 'vue'
import { useRouter } from 'vue-router'
import { supabase } from '@/lib/supabase/client'
import { useDocsAdmin } from '@/composables/useDocsAdmin'
import { ref, computed, onMounted, watch } from 'vue';
import { useRouter } from 'vue-router';
import { supabase } from '@/lib/supabase/client';
import { useDocsAdmin } from '@/composables/useDocsAdmin';
const router = useRouter()
const { requestEditDoc } = useDocsAdmin()
const router = useRouter();
const { requestEditDoc } = useDocsAdmin();
function editarDoc (docId) {
requestEditDoc(docId)
router.push('/saas/docs')
function editarDoc(docId) {
requestEditDoc(docId);
router.push('/saas/docs');
}
// ── Estado ────────────────────────────────────────────────────
const loading = ref(false)
const docs = ref([]) // docs com exibir_no_faq = true
const faqItens = ref([]) // todos os itens FAQ dos docs acima
const loading = ref(false);
const docs = ref([]); // docs com exibir_no_faq = true
const faqItens = ref([]); // todos os itens FAQ dos docs acima
const busca = ref('')
const catAtiva = ref(null) // categoria selecionada no sidebar
const busca = ref('');
const catAtiva = ref(null); // categoria selecionada no sidebar
// Controla quais perguntas estão abertas { [itemId]: boolean }
const abertos = ref({})
const abertos = ref({});
// ── Load ──────────────────────────────────────────────────────
async function load () {
loading.value = true
try {
// Busca docs habilitados no FAQ
const { data: docsData, error: docsErr } = await supabase
.from('saas_docs')
.select('id, titulo, categoria, ordem, pagina_path')
.eq('ativo', true)
.eq('exibir_no_faq', true)
.order('categoria')
.order('ordem')
if (docsErr) throw docsErr
async function load() {
loading.value = true;
try {
// Busca docs habilitados no FAQ
const { data: docsData, error: docsErr } = await supabase.from('saas_docs').select('id, titulo, categoria, ordem, pagina_path').eq('ativo', true).eq('exibir_no_faq', true).order('categoria').order('ordem');
if (docsErr) throw docsErr;
docs.value = docsData || []
docs.value = docsData || [];
if (!docs.value.length) return
if (!docs.value.length) return;
// Busca todos os itens FAQ desses docs
const docIds = docs.value.map(d => d.id)
const { data: itensData, error: itensErr } = await supabase
.from('saas_faq_itens')
.select('id, doc_id, pergunta, resposta, ordem')
.in('doc_id', docIds)
.eq('ativo', true)
.order('ordem')
if (itensErr) throw itensErr
// Busca todos os itens FAQ desses docs
const docIds = docs.value.map((d) => d.id);
const { data: itensData, error: itensErr } = await supabase.from('saas_faq_itens').select('id, doc_id, pergunta, resposta, ordem').in('doc_id', docIds).eq('ativo', true).order('ordem');
if (itensErr) throw itensErr;
faqItens.value = itensData || []
} finally {
loading.value = false
}
faqItens.value = itensData || [];
} finally {
loading.value = false;
}
}
onMounted(load)
onMounted(load);
// ── Categorias disponíveis ────────────────────────────────────
const categorias = computed(() => {
const set = new Set(docs.value.map(d => d.categoria).filter(Boolean))
return [...set].sort()
})
const set = new Set(docs.value.map((d) => d.categoria).filter(Boolean));
return [...set].sort();
});
// ── Docs filtrados pela categoria ativa ───────────────────────
const docsFiltrados = computed(() => {
if (!catAtiva.value) return docs.value
return docs.value.filter(d => d.categoria === catAtiva.value)
})
if (!catAtiva.value) return docs.value;
return docs.value.filter((d) => d.categoria === catAtiva.value);
});
// ── Itens de um doc, aplicando busca ─────────────────────────
function itensDo (docId) {
const q = busca.value.trim().toLowerCase()
return faqItens.value.filter(f => {
if (f.doc_id !== docId) return false
if (!q) return true
return (
f.pergunta.toLowerCase().includes(q) ||
(f.resposta || '').toLowerCase().includes(q)
)
})
function itensDo(docId) {
const q = busca.value.trim().toLowerCase();
return faqItens.value.filter((f) => {
if (f.doc_id !== docId) return false;
if (!q) return true;
return f.pergunta.toLowerCase().includes(q) || (f.resposta || '').toLowerCase().includes(q);
});
}
// ── Docs que têm resultado na busca ──────────────────────────
const docsComResultado = computed(() => {
return docsFiltrados.value.filter(d => itensDo(d.id).length > 0)
})
return docsFiltrados.value.filter((d) => itensDo(d.id).length > 0);
});
// Total de resultados para feedback
const totalResultados = computed(() => {
if (!busca.value.trim()) return null
return docsComResultado.value.reduce((acc, d) => acc + itensDo(d.id).length, 0)
})
if (!busca.value.trim()) return null;
return docsComResultado.value.reduce((acc, d) => acc + itensDo(d.id).length, 0);
});
// ── Toggle pergunta ───────────────────────────────────────────
function toggle (id) {
abertos.value[id] = !abertos.value[id]
function toggle(id) {
abertos.value[id] = !abertos.value[id];
}
// Abre todas as perguntas dos resultados quando há busca ativa
function expandirResultados () {
docsComResultado.value.forEach(d => {
itensDo(d.id).forEach(item => {
abertos.value[item.id] = true
})
})
function expandirResultados() {
docsComResultado.value.forEach((d) => {
itensDo(d.id).forEach((item) => {
abertos.value[item.id] = true;
});
});
}
// Observa busca: expande automaticamente quando tem busca
watch(busca, (val) => {
if (val.trim()) expandirResultados()
})
if (val.trim()) expandirResultados();
});
// ── Selecionar categoria ──────────────────────────────────────
function selecionarCat (cat) {
catAtiva.value = catAtiva.value === cat ? null : cat
busca.value = ''
abertos.value = {}
function selecionarCat(cat) {
catAtiva.value = catAtiva.value === cat ? null : cat;
busca.value = '';
abertos.value = {};
}
</script>
<template>
<!-- Sentinel -->
<div class="h-px" />
<!-- Sentinel -->
<div class="h-px" />
<!-- Hero sticky -->
<div
class="sticky mx-3 md:mx-4 mb-4 z-20 overflow-hidden rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5"
:style="{ top: 'var(--layout-sticky-top, 56px)' }"
>
<div class="absolute inset-0 pointer-events-none overflow-hidden" aria-hidden="true">
<div class="absolute rounded-full blur-[70px] w-72 h-72 -top-16 -right-20 bg-indigo-400/10" />
<div class="absolute rounded-full blur-[70px] w-80 h-80 top-10 -left-24 bg-emerald-400/10" />
</div>
<div class="relative z-10 flex flex-col gap-3">
<div class="flex items-center gap-3">
<div class="w-12 h-12 rounded-md border border-[var(--surface-border)] bg-[var(--surface-ground)] flex items-center justify-center flex-shrink-0">
<i class="pi pi-comments text-xl text-[var(--text-color)]" />
<!-- Hero sticky -->
<div class="sticky mx-3 md:mx-4 mb-4 z-20 overflow-hidden rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5" :style="{ top: 'var(--layout-sticky-top, 56px)' }">
<div class="absolute inset-0 pointer-events-none overflow-hidden" aria-hidden="true">
<div class="absolute rounded-full blur-[70px] w-72 h-72 -top-16 -right-20 bg-indigo-400/10" />
<div class="absolute rounded-full blur-[70px] w-80 h-80 top-10 -left-24 bg-emerald-400/10" />
</div>
<div>
<div class="text-[1rem] font-bold tracking-tight text-[var(--text-color)]">Central de Ajuda</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-0.5">Encontre respostas para as dúvidas mais comuns</div>
</div>
</div>
<!-- Busca -->
<div>
<IconField class="w-full">
<InputIcon class="pi pi-search" />
<InputText
v-model="busca"
placeholder="Buscar pergunta…"
class="w-full"
/>
<InputIcon v-if="busca" class="pi pi-times cursor-pointer" @click="busca = ''" />
</IconField>
<div v-if="totalResultados !== null" class="text-[1rem] text-[var(--text-color-secondary)] opacity-70 mt-1 ml-1">
{{ totalResultados }} resultado{{ totalResultados !== 1 ? 's' : '' }} encontrado{{ totalResultados !== 1 ? 's' : '' }}
</div>
</div>
</div>
</div>
<!-- content -->
<div class="px-3 md:px-4 pb-8 flex flex-col gap-4">
<!-- Loading -->
<div v-if="loading" class="flex justify-center py-16">
<i class="pi pi-spinner pi-spin text-2xl opacity-30" />
</div>
<template v-else>
<div class="flex gap-4 items-start flex-col sm:flex-row">
<!-- Sidebar de categorias -->
<aside v-if="categorias.length" class="w-full sm:w-48 flex-shrink-0 sticky top-[calc(var(--layout-sticky-top,56px)+8rem)] flex flex-col sm:flex-col flex-row flex-wrap gap-1">
<div class="text-[1rem] font-bold uppercase tracking-wider text-[var(--text-color-secondary)] opacity-60 px-2 mb-1 hidden sm:block">Categorias</div>
<button
class="flex items-center w-full px-2.5 py-1.5 rounded-md text-[1rem] text-[var(--text-color-secondary)] bg-transparent border-none cursor-pointer text-left transition-colors hover:bg-[var(--surface-hover)] hover:text-[var(--text-color)]"
:class="{ 'bg-[color-mix(in_srgb,var(--primary-color)_10%,transparent)] text-[var(--primary-color)] font-semibold': !catAtiva }"
@click="selecionarCat(null)"
>
<i class="pi pi-th-large mr-2 opacity-60" />
Todas
<span class="ml-auto opacity-50 text-[1rem]">{{ faqItens.length }}</span>
</button>
<button
v-for="cat in categorias"
:key="cat"
class="flex items-center w-full px-2.5 py-1.5 rounded-md text-[1rem] text-[var(--text-color-secondary)] bg-transparent border-none cursor-pointer text-left transition-colors hover:bg-[var(--surface-hover)] hover:text-[var(--text-color)]"
:class="{ 'bg-[color-mix(in_srgb,var(--primary-color)_10%,transparent)] text-[var(--primary-color)] font-semibold': catAtiva === cat }"
@click="selecionarCat(cat)"
>
<i class="pi pi-tag mr-2 opacity-60" />
{{ cat }}
<span class="ml-auto opacity-50 text-[1rem]">
{{ faqItens.filter(f => docs.find(d => d.id === f.doc_id && d.categoria === cat)).length }}
</span>
</button>
</aside>
<!-- Conteúdo principal -->
<div class="flex-1 min-w-0 flex flex-col gap-4">
<!-- Sem resultados -->
<div v-if="docsComResultado.length === 0" class="flex flex-col items-center py-12 text-center">
<i class="pi pi-search text-3xl opacity-20 mb-3" />
<div class="text-[var(--text-color-secondary)] text-[1rem]">Nenhuma pergunta encontrada.</div>
<button v-if="busca || catAtiva" class="text-[var(--primary-color)] text-[1rem] mt-2 underline" @click="busca = ''; catAtiva = null; abertos = {}">
Limpar filtros
</button>
</div>
<!-- Grupos de docs -->
<div
v-for="doc in docsComResultado"
:key="doc.id"
class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] overflow-hidden"
>
<!-- Cabeçalho do grupo (doc) -->
<div class="group flex items-center gap-3 px-5 py-3.5 border-b border-[var(--surface-border)] bg-[var(--surface-ground)]">
<div class="w-8 h-8 rounded-md bg-[color-mix(in_srgb,var(--primary-color)_10%,transparent)] text-[var(--primary-color)] flex items-center justify-center flex-shrink-0">
<i class="pi pi-file-edit" />
</div>
<div class="flex-1 min-w-0">
<div class="text-[1rem] font-semibold text-[var(--text-color)] leading-tight">{{ doc.titulo }}</div>
<div v-if="doc.categoria" class="text-[1rem] text-[var(--text-color-secondary)] opacity-60 mt-px">{{ doc.categoria }}</div>
</div>
<button
class="flex items-center justify-center w-7 h-7 rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] text-[var(--text-color-secondary)] cursor-pointer flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity hover:bg-[var(--surface-hover)] hover:text-[var(--primary-color)]"
v-tooltip.top="'Editar documento'"
@click="editarDoc(doc.id)"
>
<i class="pi pi-pencil" />
</button>
<div class="relative z-10 flex flex-col gap-3">
<div class="flex items-center gap-3">
<div class="w-12 h-12 rounded-md border border-[var(--surface-border)] bg-[var(--surface-ground)] flex items-center justify-center flex-shrink-0">
<i class="pi pi-comments text-xl text-[var(--text-color)]" />
</div>
<div>
<div class="text-[1rem] font-bold tracking-tight text-[var(--text-color)]">Central de Ajuda</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-0.5">Encontre respostas para as dúvidas mais comuns</div>
</div>
</div>
<!-- Itens FAQ do grupo -->
<div class="flex flex-col">
<div
v-for="item in itensDo(doc.id)"
:key="item.id"
class="border-b border-[var(--surface-border)] last:border-b-0 transition-colors"
:class="abertos[item.id] ? 'bg-[color-mix(in_srgb,var(--primary-color)_3%,transparent)]' : ''"
>
<button
class="w-full flex items-center justify-between gap-4 px-5 py-3.5 bg-transparent border-none cursor-pointer text-left transition-colors hover:bg-[var(--surface-hover)]"
@click="toggle(item.id)"
>
<span class="text-[1rem] font-medium text-[var(--text-color)] leading-snug">{{ item.pergunta }}</span>
<i
class="pi shrink-0 opacity-40 transition-transform duration-200"
:class="abertos[item.id] ? 'pi-chevron-up' : 'pi-chevron-down'"
/>
</button>
<Transition name="faq-expand">
<div
v-if="abertos[item.id] && item.resposta"
class="px-5 pb-4 text-[1rem] text-[var(--text-color-secondary)] leading-relaxed break-words ql-content"
v-html="item.resposta"
/>
</Transition>
</div>
<!-- Busca -->
<div>
<IconField class="w-full">
<InputIcon class="pi pi-search" />
<InputText v-model="busca" placeholder="Buscar pergunta…" class="w-full" />
<InputIcon v-if="busca" class="pi pi-times cursor-pointer" @click="busca = ''" />
</IconField>
<div v-if="totalResultados !== null" class="text-[1rem] text-[var(--text-color-secondary)] opacity-70 mt-1 ml-1">
{{ totalResultados }} resultado{{ totalResultados !== 1 ? 's' : '' }} encontrado{{ totalResultados !== 1 ? 's' : '' }}
</div>
</div>
</div>
</div>
</div>
</template>
</div>
</div>
<!-- content -->
<div class="px-3 md:px-4 pb-8 flex flex-col gap-4">
<!-- Loading -->
<div v-if="loading" class="flex justify-center py-16">
<i class="pi pi-spinner pi-spin text-2xl opacity-30" />
</div>
<template v-else>
<div class="flex gap-4 items-start flex-col sm:flex-row">
<!-- Sidebar de categorias -->
<aside v-if="categorias.length" class="w-full sm:w-48 flex-shrink-0 sticky top-[calc(var(--layout-sticky-top,56px)+8rem)] flex flex-col sm:flex-col flex-row flex-wrap gap-1">
<div class="text-[1rem] font-bold uppercase tracking-wider text-[var(--text-color-secondary)] opacity-60 px-2 mb-1 hidden sm:block">Categorias</div>
<button
class="flex items-center w-full px-2.5 py-1.5 rounded-md text-[1rem] text-[var(--text-color-secondary)] bg-transparent border-none cursor-pointer text-left transition-colors hover:bg-[var(--surface-hover)] hover:text-[var(--text-color)]"
:class="{ 'bg-[color-mix(in_srgb,var(--primary-color)_10%,transparent)] text-[var(--primary-color)] font-semibold': !catAtiva }"
@click="selecionarCat(null)"
>
<i class="pi pi-th-large mr-2 opacity-60" />
Todas
<span class="ml-auto opacity-50 text-[1rem]">{{ faqItens.length }}</span>
</button>
<button
v-for="cat in categorias"
:key="cat"
class="flex items-center w-full px-2.5 py-1.5 rounded-md text-[1rem] text-[var(--text-color-secondary)] bg-transparent border-none cursor-pointer text-left transition-colors hover:bg-[var(--surface-hover)] hover:text-[var(--text-color)]"
:class="{ 'bg-[color-mix(in_srgb,var(--primary-color)_10%,transparent)] text-[var(--primary-color)] font-semibold': catAtiva === cat }"
@click="selecionarCat(cat)"
>
<i class="pi pi-tag mr-2 opacity-60" />
{{ cat }}
<span class="ml-auto opacity-50 text-[1rem]">
{{ faqItens.filter((f) => docs.find((d) => d.id === f.doc_id && d.categoria === cat)).length }}
</span>
</button>
</aside>
<!-- Conteúdo principal -->
<div class="flex-1 min-w-0 flex flex-col gap-4">
<!-- Sem resultados -->
<div v-if="docsComResultado.length === 0" class="flex flex-col items-center py-12 text-center">
<i class="pi pi-search text-3xl opacity-20 mb-3" />
<div class="text-[var(--text-color-secondary)] text-[1rem]">Nenhuma pergunta encontrada.</div>
<button
v-if="busca || catAtiva"
class="text-[var(--primary-color)] text-[1rem] mt-2 underline"
@click="
busca = '';
catAtiva = null;
abertos = {};
"
>
Limpar filtros
</button>
</div>
<!-- Grupos de docs -->
<div v-for="doc in docsComResultado" :key="doc.id" class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] overflow-hidden">
<!-- Cabeçalho do grupo (doc) -->
<div class="group flex items-center gap-3 px-5 py-3.5 border-b border-[var(--surface-border)] bg-[var(--surface-ground)]">
<div class="w-8 h-8 rounded-md bg-[color-mix(in_srgb,var(--primary-color)_10%,transparent)] text-[var(--primary-color)] flex items-center justify-center flex-shrink-0">
<i class="pi pi-file-edit" />
</div>
<div class="flex-1 min-w-0">
<div class="text-[1rem] font-semibold text-[var(--text-color)] leading-tight">{{ doc.titulo }}</div>
<div v-if="doc.categoria" class="text-[1rem] text-[var(--text-color-secondary)] opacity-60 mt-px">{{ doc.categoria }}</div>
</div>
<button
class="flex items-center justify-center w-7 h-7 rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] text-[var(--text-color-secondary)] cursor-pointer flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity hover:bg-[var(--surface-hover)] hover:text-[var(--primary-color)]"
v-tooltip.top="'Editar documento'"
@click="editarDoc(doc.id)"
>
<i class="pi pi-pencil" />
</button>
</div>
<!-- Itens FAQ do grupo -->
<div class="flex flex-col">
<div
v-for="item in itensDo(doc.id)"
:key="item.id"
class="border-b border-[var(--surface-border)] last:border-b-0 transition-colors"
:class="abertos[item.id] ? 'bg-[color-mix(in_srgb,var(--primary-color)_3%,transparent)]' : ''"
>
<button class="w-full flex items-center justify-between gap-4 px-5 py-3.5 bg-transparent border-none cursor-pointer text-left transition-colors hover:bg-[var(--surface-hover)]" @click="toggle(item.id)">
<span class="text-[1rem] font-medium text-[var(--text-color)] leading-snug">{{ item.pergunta }}</span>
<i class="pi shrink-0 opacity-40 transition-transform duration-200" :class="abertos[item.id] ? 'pi-chevron-up' : 'pi-chevron-down'" />
</button>
<Transition name="faq-expand">
<div v-if="abertos[item.id] && item.resposta" class="px-5 pb-4 text-[1rem] text-[var(--text-color-secondary)] leading-relaxed break-words ql-content" v-html="item.resposta" />
</Transition>
</div>
</div>
</div>
</div>
</div>
</template>
</div>
</template>
<style scoped>
/* Quill content */
.ql-content :deep(p) { margin: 0 0 0.5rem; }
.ql-content :deep(p:last-child) { margin-bottom: 0; }
.ql-content :deep(strong) { font-weight: 600; color: var(--text-color); }
.ql-content :deep(em) { font-style: italic; }
.ql-content :deep(p) {
margin: 0 0 0.5rem;
}
.ql-content :deep(p:last-child) {
margin-bottom: 0;
}
.ql-content :deep(strong) {
font-weight: 600;
color: var(--text-color);
}
.ql-content :deep(em) {
font-style: italic;
}
.ql-content :deep(ul),
.ql-content :deep(ol) { padding-left: 1.25rem; margin: 0.4rem 0; }
.ql-content :deep(li) { margin-bottom: 0.2rem; }
.ql-content :deep(a) { color: var(--primary-color); text-decoration: underline; }
.ql-content :deep(blockquote) {
border-left: 3px solid var(--surface-border);
margin: 0.5rem 0;
padding: 0.25rem 0.75rem;
font-style: italic;
.ql-content :deep(ol) {
padding-left: 1.25rem;
margin: 0.4rem 0;
}
.ql-content :deep(li) {
margin-bottom: 0.2rem;
}
.ql-content :deep(a) {
color: var(--primary-color);
text-decoration: underline;
}
.ql-content :deep(blockquote) {
border-left: 3px solid var(--surface-border);
margin: 0.5rem 0;
padding: 0.25rem 0.75rem;
font-style: italic;
}
/* Animação expand */
.faq-expand-enter-active,
.faq-expand-leave-active {
transition: opacity 0.2s ease, max-height 0.25s ease;
max-height: 800px;
overflow: hidden;
transition:
opacity 0.2s ease,
max-height 0.25s ease;
max-height: 800px;
overflow: hidden;
}
.faq-expand-enter-from,
.faq-expand-leave-to {
opacity: 0;
max-height: 0;
opacity: 0;
max-height: 0;
}
</style>
</style>
+311 -356
View File
@@ -15,416 +15,371 @@
|--------------------------------------------------------------------------
-->
<script setup>
import { ref, onMounted, onBeforeUnmount, computed } from 'vue'
import { supabase } from '@/lib/supabase/client'
import { ref, onMounted, onBeforeUnmount, computed } from 'vue';
import { supabase } from '@/lib/supabase/client';
import Textarea from 'primevue/textarea'
import { useToast } from 'primevue/usetoast'
import { useConfirm } from 'primevue/useconfirm'
import Textarea from 'primevue/textarea';
import { useToast } from 'primevue/usetoast';
import { useConfirm } from 'primevue/useconfirm';
const toast = useToast()
const confirm = useConfirm()
const toast = useToast();
const confirm = useConfirm();
const loading = ref(false)
const rows = ref([])
const loading = ref(false);
const rows = ref([]);
const showDlg = ref(false)
const saving = ref(false)
const isEdit = ref(false)
const showDlg = ref(false);
const saving = ref(false);
const isEdit = ref(false);
const q = ref('')
const q = ref('');
const form = ref({
id: null,
key: '',
name: '',
descricao: ''
})
id: null,
key: '',
name: '',
descricao: ''
});
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')
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');
}
function isFkViolation (err) {
if (!err) return false
if (err.code === '23503') return true
const msg = String(err.message || '').toLowerCase()
return msg.includes('foreign key') || msg.includes('violates foreign key')
function isFkViolation(err) {
if (!err) return false;
if (err.code === '23503') return true;
const msg = String(err.message || '').toLowerCase();
return msg.includes('foreign key') || msg.includes('violates foreign key');
}
function slugifyKey (s) {
return String(s || '')
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
.trim()
.replace(/\s+/g, '_')
.replace(/[^a-z0-9._]/g, '')
function slugifyKey(s) {
return String(s || '')
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
.trim()
.replace(/\s+/g, '_')
.replace(/[^a-z0-9._]/g, '');
}
function featureDomain (key) {
const k = String(key || '').trim()
if (!k) return 'geral'
if (k.includes('.')) return k.split('.')[0]
if (k.includes('_')) return k.split('_')[0]
return k
function featureDomain(key) {
const k = String(key || '').trim();
if (!k) return 'geral';
if (k.includes('.')) return k.split('.')[0];
if (k.includes('_')) return k.split('_')[0];
return k;
}
function domainSeverity (domain) {
const d = String(domain || '').toLowerCase()
if (d.includes('agenda') || d.includes('scheduling')) return 'info'
if (d.includes('billing') || d.includes('assin') || d.includes('plano')) return 'success'
if (d.includes('portal') || d.includes('patient')) return 'warn'
if (d.includes('admin') || d.includes('saas')) return 'secondary'
return 'secondary'
function domainSeverity(domain) {
const d = String(domain || '').toLowerCase();
if (d.includes('agenda') || d.includes('scheduling')) return 'info';
if (d.includes('billing') || d.includes('assin') || d.includes('plano')) return 'success';
if (d.includes('portal') || d.includes('patient')) return 'warn';
if (d.includes('admin') || d.includes('saas')) return 'secondary';
return 'secondary';
}
const filteredRows = computed(() => {
const term = String(q.value || '').trim().toLowerCase()
if (!term) return rows.value
return (rows.value || []).filter(r => {
return [r.key, r.name, r.descricao].some(s => String(s || '').toLowerCase().includes(term))
})
})
const term = String(q.value || '')
.trim()
.toLowerCase();
if (!term) return rows.value;
return (rows.value || []).filter((r) => {
return [r.key, r.name, r.descricao].some((s) =>
String(s || '')
.toLowerCase()
.includes(term)
);
});
});
async function fetchAll () {
loading.value = true
try {
const { data, error } = await supabase
.from('features')
.select('id, key, name, descricao, created_at')
.order('key', { ascending: true })
async function fetchAll() {
loading.value = true;
try {
const { data, error } = await supabase.from('features').select('id, key, name, descricao, created_at').order('key', { ascending: true });
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
}
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;
}
}
function openCreate () {
isEdit.value = false
form.value = { id: null, key: '', name: '', descricao: '' }
showDlg.value = true
function openCreate() {
isEdit.value = false;
form.value = { id: null, key: '', name: '', descricao: '' };
showDlg.value = true;
}
function openEdit (row) {
isEdit.value = true
form.value = {
id: row.id,
key: row.key ?? '',
name: row.name ?? '',
descricao: row.descricao ?? ''
}
showDlg.value = true
function openEdit(row) {
isEdit.value = true;
form.value = {
id: row.id,
key: row.key ?? '',
name: row.name ?? '',
descricao: row.descricao ?? ''
};
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 recurso.', life: 3000 })
return false
}
if (!String(form.value.name || '').trim()) {
toast.add({ severity: 'warn', summary: 'Atenção', detail: 'Informe o nome do recurso.', life: 3000 })
return false
}
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 recurso com essa key.', life: 3000 })
return false
}
form.value.key = k
form.value.name = String(form.value.name || '').trim()
form.value.descricao = String(form.value.descricao || '').trim()
return true
}
async function save () {
if (saving.value) return
if (!validate()) return
saving.value = true
try {
const payload = {
key: form.value.key,
name: form.value.name,
descricao: form.value.descricao
function validate() {
const k = slugifyKey(form.value.key);
if (!k) {
toast.add({ severity: 'warn', summary: 'Atenção', detail: 'Informe a key do recurso.', life: 3000 });
return false;
}
if (!String(form.value.name || '').trim()) {
toast.add({ severity: 'warn', summary: 'Atenção', detail: 'Informe o nome do recurso.', life: 3000 });
return false;
}
if (isEdit.value) {
const { error } = await supabase.from('features').update(payload).eq('id', form.value.id)
if (error) throw error
toast.add({ severity: 'success', summary: 'Ok', detail: 'Recurso atualizado.', life: 2500 })
} else {
const { error } = await supabase.from('features').insert(payload)
if (error) throw error
toast.add({ severity: 'success', summary: 'Ok', detail: 'Recurso criado.', life: 2500 })
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 recurso com essa key.', life: 3000 });
return false;
}
showDlg.value = false
await fetchAll()
} catch (e) {
if (isUniqueViolation(e)) {
toast.add({ severity: 'warn', summary: 'Key já existente', detail: 'Já existe um recurso com essa key.', life: 3500 })
} else {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || String(e), life: 4500 })
form.value.key = k;
form.value.name = String(form.value.name || '').trim();
form.value.descricao = String(form.value.descricao || '').trim();
return true;
}
async function save() {
if (saving.value) return;
if (!validate()) return;
saving.value = true;
try {
const payload = {
key: form.value.key,
name: form.value.name,
descricao: form.value.descricao
};
if (isEdit.value) {
const { error } = await supabase.from('features').update(payload).eq('id', form.value.id);
if (error) throw error;
toast.add({ severity: 'success', summary: 'Ok', detail: 'Recurso atualizado.', life: 2500 });
} else {
const { error } = await supabase.from('features').insert(payload);
if (error) throw error;
toast.add({ severity: 'success', summary: 'Ok', detail: 'Recurso 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 recurso com essa key.', life: 3500 });
} else {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || String(e), life: 4500 });
}
} finally {
saving.value = false;
}
} finally {
saving.value = false
}
}
function askDelete (row) {
confirm.require({
message: `Excluir o recurso "${row.key}"?`,
header: 'Confirmar exclusão',
icon: 'pi pi-exclamation-triangle',
acceptClass: 'p-button-danger',
accept: () => doDelete(row)
})
function askDelete(row) {
confirm.require({
message: `Excluir o recurso "${row.key}"?`,
header: 'Confirmar exclusão',
icon: 'pi pi-exclamation-triangle',
acceptClass: 'p-button-danger',
accept: () => doDelete(row)
});
}
async function doDelete (row) {
try {
const { error } = await supabase.from('features').delete().eq('id', row.id)
if (error) throw error
toast.add({ severity: 'success', summary: 'Ok', detail: 'Recurso excluído.', life: 2500 })
await fetchAll()
} catch (e) {
const hint = isFkViolation(e)
? 'Este recurso está vinculado a planos ou módulos. Remova o vínculo antes de excluir.'
: ''
toast.add({
severity: 'error',
summary: 'Erro',
detail: hint ? `${e?.message}${hint}` : (e?.message || String(e)),
life: 5200
})
}
async function doDelete(row) {
try {
const { error } = await supabase.from('features').delete().eq('id', row.id);
if (error) throw error;
toast.add({ severity: 'success', summary: 'Ok', detail: 'Recurso excluído.', life: 2500 });
await fetchAll();
} catch (e) {
const hint = isFkViolation(e) ? 'Este recurso está vinculado a planos ou módulos. Remova o vínculo antes de excluir.' : '';
toast.add({
severity: 'error',
summary: 'Erro',
detail: hint ? `${e?.message}${hint}` : e?.message || String(e),
life: 5200
});
}
}
// ── Hero sticky ───────────────────────────────────────────
const heroEl = ref(null)
const heroSentinelRef = ref(null)
const heroMenuRef = ref(null)
const heroStuck = ref(false)
let disconnectStickyObserver = null
const heroEl = ref(null);
const heroSentinelRef = ref(null);
const heroMenuRef = ref(null);
const heroStuck = ref(false);
let disconnectStickyObserver = null;
const heroMenuItems = computed(() => [
{ label: 'Atualizar', icon: 'pi pi-refresh', command: fetchAll, disabled: loading.value || saving.value },
{ label: 'Adicionar recurso', icon: 'pi pi-plus', command: openCreate, disabled: saving.value }
])
{ label: 'Atualizar', icon: 'pi pi-refresh', command: fetchAll, disabled: loading.value || saving.value },
{ label: 'Adicionar recurso', icon: 'pi pi-plus', command: openCreate, disabled: saving.value }
]);
onMounted(async () => {
await fetchAll()
await fetchAll();
const sentinel = heroSentinelRef.value
if (sentinel) {
const io = new IntersectionObserver(
([entry]) => { heroStuck.value = !entry.isIntersecting },
{ rootMargin: `${document.querySelector('.l2-main') ? '0px' : '-56px'} 0px 0px 0px` }
)
io.observe(sentinel)
disconnectStickyObserver = () => io.disconnect()
}
})
const sentinel = heroSentinelRef.value;
if (sentinel) {
const io = new IntersectionObserver(
([entry]) => {
heroStuck.value = !entry.isIntersecting;
},
{ rootMargin: `${document.querySelector('.l2-main') ? '0px' : '-56px'} 0px 0px 0px` }
);
io.observe(sentinel);
disconnectStickyObserver = () => io.disconnect();
}
});
onBeforeUnmount(() => {
try { disconnectStickyObserver?.() } catch {}
})
try {
disconnectStickyObserver?.();
} catch {}
});
</script>
<template>
<ConfirmDialog />
<ConfirmDialog />
<!-- Sentinel -->
<div ref="heroSentinelRef" class="h-px" />
<!-- Sentinel -->
<div ref="heroSentinelRef" class="h-px" />
<!-- Hero sticky -->
<div
ref="heroEl"
class="sticky mx-3 md:mx-4 mb-4 z-20 overflow-hidden rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5"
:style="{ top: 'var(--layout-sticky-top, 56px)' }"
>
<div class="absolute inset-0 pointer-events-none overflow-hidden" aria-hidden="true">
<div class="absolute rounded-full blur-[70px] w-72 h-72 -top-16 -right-20 bg-fuchsia-400/10" />
<div class="absolute rounded-full blur-[70px] w-80 h-80 top-10 -left-24 bg-indigo-400/10" />
</div>
<div class="relative z-10 flex items-center justify-between gap-3 flex-wrap">
<div class="min-w-0">
<div class="text-[1rem] font-bold tracking-tight text-[var(--text-color)]">Recursos do Sistema</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-0.5">Cadastre os recursos (features) que os planos podem habilitar.</div>
</div>
<!-- Ações desktop ( 1200px) -->
<div class="hidden xl:flex items-center gap-2 flex-wrap">
<Button label="Atualizar" icon="pi pi-refresh" severity="secondary" outlined size="small" :loading="loading" :disabled="saving" @click="fetchAll" />
<Button label="Adicionar recurso" icon="pi pi-plus" size="small" :disabled="saving" @click="openCreate" />
</div>
<!-- Ações mobile (< 1200px) -->
<div class="flex xl:hidden">
<Button
label="Ações"
icon="pi pi-ellipsis-v"
severity="warn"
size="small"
aria-haspopup="true"
aria-controls="features_hero_menu"
@click="(e) => heroMenuRef.toggle(e)"
/>
<Menu ref="heroMenuRef" id="features_hero_menu" :model="heroMenuItems" :popup="true" />
</div>
</div>
</div>
<!-- content -->
<div class="px-3 md:px-4 pb-8 flex flex-col gap-4">
<!-- Search -->
<div>
<FloatLabel variant="on" class="w-full md:w-[380px]">
<IconField class="w-full">
<InputIcon class="pi pi-search" />
<InputText v-model="q" id="features_search" class="w-full pr-10" variant="filled" autocomplete="off" :disabled="loading || saving" />
</IconField>
<label for="features_search">Buscar por key, nome ou descrição</label>
</FloatLabel>
</div>
<DataTable :value="filteredRows" dataKey="id" :loading="loading" stripedRows responsiveLayout="scroll">
<Column header="Domínio" style="width: 9rem">
<template #body="{ data }">
<Tag :value="featureDomain(data.key)" :severity="domainSeverity(featureDomain(data.key))" rounded />
</template>
</Column>
<Column field="key" header="Key" sortable style="min-width: 18rem">
<template #body="{ data }">
<div class="flex flex-col">
<span class="font-medium font-mono text-[1rem]">{{ data.key }}</span>
<div class="text-[1rem] text-[var(--text-color-secondary)]">ID: {{ data.id }}</div>
</div>
</template>
</Column>
<Column field="name" header="Nome" sortable style="min-width: 16rem">
<template #body="{ data }">
<span>{{ data.name || '—' }}</span>
</template>
</Column>
<Column field="descricao" header="Descrição" sortable style="min-width: 22rem">
<template #body="{ data }">
<div class="max-w-[600px] whitespace-nowrap overflow-hidden text-ellipsis text-[var(--text-color-secondary)]" :title="data.descricao || ''">
{{ data.descricao || '—' }}
</div>
</template>
</Column>
<Column field="created_at" header="Criado em" sortable style="width: 13rem" />
<Column header="Ações" style="width: 10rem">
<template #body="{ data }">
<div class="flex gap-2 justify-end">
<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>
</div>
<Dialog
v-model:visible="showDlg"
modal
:header="isEdit ? 'Editar recurso' : 'Novo recurso'"
:style="{ width: '640px' }"
:closable="!saving"
:dismissableMask="!saving"
:draggable="false"
>
<div class="flex flex-col gap-4">
<!-- Key -->
<div>
<FloatLabel variant="on">
<IconField>
<InputIcon class="pi pi-tag" />
<InputText
id="cr-key"
v-model.trim="form.key"
class="w-full"
variant="filled"
:disabled="saving"
autocomplete="off"
autofocus
@blur="form.key = slugifyKey(form.key)"
@keydown.enter.prevent="save"
/>
</IconField>
<label for="cr-key">Key *</label>
</FloatLabel>
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-1">
Ex.: <span class="font-mono">agenda.view</span> ou <span class="font-mono">online_scheduling.manage</span>.
Espaços e acentos são normalizados automaticamente.
<!-- Hero sticky -->
<div ref="heroEl" class="sticky mx-3 md:mx-4 mb-4 z-20 overflow-hidden rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5" :style="{ top: 'var(--layout-sticky-top, 56px)' }">
<div class="absolute inset-0 pointer-events-none overflow-hidden" aria-hidden="true">
<div class="absolute rounded-full blur-[70px] w-72 h-72 -top-16 -right-20 bg-fuchsia-400/10" />
<div class="absolute rounded-full blur-[70px] w-80 h-80 top-10 -left-24 bg-indigo-400/10" />
</div>
</div>
<!-- Nome -->
<div>
<FloatLabel variant="on">
<IconField>
<InputIcon class="pi pi-bookmark" />
<InputText
id="cr-name"
v-model.trim="form.name"
class="w-full"
variant="filled"
:disabled="saving"
autocomplete="off"
@keydown.enter.prevent="save"
/>
</IconField>
<label for="cr-name">Nome *</label>
</FloatLabel>
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-1">
Nome exibido para o usuário na página de upgrade e nas listagens.
</div>
</div>
<div class="relative z-10 flex items-center justify-between gap-3 flex-wrap">
<div class="min-w-0">
<div class="text-[1rem] font-bold tracking-tight text-[var(--text-color)]">Recursos do Sistema</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-0.5">Cadastre os recursos (features) que os planos podem habilitar.</div>
</div>
<!-- Descrição PT-BR -->
<div>
<FloatLabel variant="on">
<Textarea
id="cr-desc-pt"
v-model.trim="form.descricao"
class="w-full"
rows="3"
autoResize
:disabled="saving"
/>
<label for="cr-desc-pt">Descrição</label>
</FloatLabel>
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-1">
Explique o que o recurso habilita e para quem se aplica.
<!-- Ações desktop ( 1200px) -->
<div class="hidden xl:flex items-center gap-2 flex-wrap">
<Button label="Atualizar" icon="pi pi-refresh" severity="secondary" outlined size="small" :loading="loading" :disabled="saving" @click="fetchAll" />
<Button label="Adicionar recurso" icon="pi pi-plus" size="small" :disabled="saving" @click="openCreate" />
</div>
<!-- Ações mobile (< 1200px) -->
<div class="flex xl:hidden">
<Button label="Ações" icon="pi pi-ellipsis-v" severity="warn" size="small" aria-haspopup="true" aria-controls="features_hero_menu" @click="(e) => heroMenuRef.toggle(e)" />
<Menu ref="heroMenuRef" id="features_hero_menu" :model="heroMenuItems" :popup="true" />
</div>
</div>
</div>
</div>
<template #footer>
<Button label="Cancelar" severity="secondary" outlined @click="showDlg = false" :disabled="saving" />
<Button :label="isEdit ? 'Salvar' : 'Criar'" icon="pi pi-check" :loading="saving" @click="save" />
</template>
</Dialog>
</template>
<!-- content -->
<div class="px-3 md:px-4 pb-8 flex flex-col gap-4">
<!-- Search -->
<div>
<FloatLabel variant="on" class="w-full md:w-[380px]">
<IconField class="w-full">
<InputIcon class="pi pi-search" />
<InputText v-model="q" id="features_search" class="w-full pr-10" variant="filled" autocomplete="off" :disabled="loading || saving" />
</IconField>
<label for="features_search">Buscar por key, nome ou descrição</label>
</FloatLabel>
</div>
<DataTable :value="filteredRows" dataKey="id" :loading="loading" stripedRows responsiveLayout="scroll">
<Column header="Domínio" style="width: 9rem">
<template #body="{ data }">
<Tag :value="featureDomain(data.key)" :severity="domainSeverity(featureDomain(data.key))" rounded />
</template>
</Column>
<Column field="key" header="Key" sortable style="min-width: 18rem">
<template #body="{ data }">
<div class="flex flex-col">
<span class="font-medium font-mono text-[1rem]">{{ data.key }}</span>
<div class="text-[1rem] text-[var(--text-color-secondary)]">ID: {{ data.id }}</div>
</div>
</template>
</Column>
<Column field="name" header="Nome" sortable style="min-width: 16rem">
<template #body="{ data }">
<span>{{ data.name || '—' }}</span>
</template>
</Column>
<Column field="descricao" header="Descrição" sortable style="min-width: 22rem">
<template #body="{ data }">
<div class="max-w-[600px] whitespace-nowrap overflow-hidden text-ellipsis text-[var(--text-color-secondary)]" :title="data.descricao || ''">
{{ data.descricao || '—' }}
</div>
</template>
</Column>
<Column field="created_at" header="Criado em" sortable style="width: 13rem" />
<Column header="Ações" style="width: 10rem">
<template #body="{ data }">
<div class="flex gap-2 justify-end">
<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>
</div>
<Dialog v-model:visible="showDlg" modal :header="isEdit ? 'Editar recurso' : 'Novo recurso'" :style="{ width: '640px' }" :closable="!saving" :dismissableMask="!saving" :draggable="false">
<div class="flex flex-col gap-4">
<!-- Key -->
<div>
<FloatLabel variant="on">
<IconField>
<InputIcon class="pi pi-tag" />
<InputText id="cr-key" v-model.trim="form.key" class="w-full" variant="filled" :disabled="saving" autocomplete="off" autofocus @blur="form.key = slugifyKey(form.key)" @keydown.enter.prevent="save" />
</IconField>
<label for="cr-key">Key *</label>
</FloatLabel>
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-1">Ex.: <span class="font-mono">agenda.view</span> ou <span class="font-mono">online_scheduling.manage</span>. Espaços e acentos são normalizados automaticamente.</div>
</div>
<!-- Nome -->
<div>
<FloatLabel variant="on">
<IconField>
<InputIcon class="pi pi-bookmark" />
<InputText id="cr-name" v-model.trim="form.name" class="w-full" variant="filled" :disabled="saving" autocomplete="off" @keydown.enter.prevent="save" />
</IconField>
<label for="cr-name">Nome *</label>
</FloatLabel>
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-1">Nome exibido para o usuário na página de upgrade e nas listagens.</div>
</div>
<!-- Descrição PT-BR -->
<div>
<FloatLabel variant="on">
<Textarea id="cr-desc-pt" v-model.trim="form.descricao" class="w-full" rows="3" autoResize :disabled="saving" />
<label for="cr-desc-pt">Descrição</label>
</FloatLabel>
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-1">Explique o que o recurso habilita e para quem se aplica.</div>
</div>
</div>
<template #footer>
<Button label="Cancelar" severity="secondary" outlined @click="showDlg = false" :disabled="saving" />
<Button :label="isEdit ? 'Salvar' : 'Criar'" icon="pi pi-check" :loading="saving" @click="save" />
</template>
</Dialog>
</template>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+403 -477
View File
@@ -15,410 +15,361 @@
|--------------------------------------------------------------------------
-->
<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 Editor from 'primevue/editor'
import { ref, computed, onMounted } from 'vue';
import { supabase } from '@/lib/supabase/client';
import { useToast } from 'primevue/usetoast';
import { useConfirm } from 'primevue/useconfirm';
import Editor from 'primevue/editor';
const toast = useToast()
const confirm = useConfirm()
const toast = useToast();
const confirm = useConfirm();
// Estado
const slides = ref([])
const loading = ref(false)
const saving = ref(false)
const previewIdx = ref(0)
const slides = ref([]);
const loading = ref(false);
const saving = ref(false);
const previewIdx = ref(0);
const dialogOpen = ref(false)
const editingSlide = ref(null) // null = novo
const dialogOpen = ref(false);
const editingSlide = ref(null); // null = novo
const form = ref({ title: '', body: '', icon: '', ordem: 0, ativo: true })
const form = ref({ title: '', body: '', icon: '', ordem: 0, ativo: true });
// Ícones disponíveis (subset PrimeIcons relevantes)
const ICONS = [
{ value: 'pi-calendar-clock', label: 'Agenda' },
{ value: 'pi-users', label: 'Equipe' },
{ value: 'pi-globe', label: 'Online' },
{ value: 'pi-shield', label: 'Segurança' },
{ value: 'pi-heart-fill', label: 'Saúde' },
{ value: 'pi-chart-line', label: 'Estatísticas' },
{ value: 'pi-bell', label: 'Notificações' },
{ value: 'pi-lock', label: 'Privacidade' },
{ value: 'pi-mobile', label: 'Mobile' },
{ value: 'pi-sync', label: 'Sincronização' },
{ value: 'pi-star', label: 'Destaque' },
{ value: 'pi-check-circle', label: 'Aprovação' },
{ value: 'pi-comments', label: 'Comunicação' },
{ value: 'pi-file-edit', label: 'Prontuário' },
{ value: 'pi-briefcase', label: 'Profissional' },
{ value: 'pi-bolt', label: 'Performance' },
]
{ value: 'pi-calendar-clock', label: 'Agenda' },
{ value: 'pi-users', label: 'Equipe' },
{ value: 'pi-globe', label: 'Online' },
{ value: 'pi-shield', label: 'Segurança' },
{ value: 'pi-heart-fill', label: 'Saúde' },
{ value: 'pi-chart-line', label: 'Estatísticas' },
{ value: 'pi-bell', label: 'Notificações' },
{ value: 'pi-lock', label: 'Privacidade' },
{ value: 'pi-mobile', label: 'Mobile' },
{ value: 'pi-sync', label: 'Sincronização' },
{ value: 'pi-star', label: 'Destaque' },
{ value: 'pi-check-circle', label: 'Aprovação' },
{ value: 'pi-comments', label: 'Comunicação' },
{ value: 'pi-file-edit', label: 'Prontuário' },
{ value: 'pi-briefcase', label: 'Profissional' },
{ value: 'pi-bolt', label: 'Performance' }
];
// Computed
const slidesAtivos = computed(() => slides.value.filter(s => s.ativo).sort((a, b) => a.ordem - b.ordem))
const previewSlide = computed(() => slidesAtivos.value[previewIdx.value] ?? slidesAtivos.value[0] ?? null)
const slidesAtivos = computed(() => slides.value.filter((s) => s.ativo).sort((a, b) => a.ordem - b.ordem));
const previewSlide = computed(() => slidesAtivos.value[previewIdx.value] ?? slidesAtivos.value[0] ?? null);
// Supabase
async function load () {
loading.value = true
try {
const { data, error } = await supabase
.from('login_carousel_slides')
.select('*')
.order('ordem', { ascending: true })
if (error) throw error
slides.value = data || []
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao carregar slides.', life: 4000 })
} finally {
loading.value = false
}
}
function stripHtml (s) {
return String(s || '').replace(/<[^>]+>/g, '').trim()
}
async function saveSlide () {
if (!stripHtml(form.value.title) || !stripHtml(form.value.body)) {
toast.add({ severity: 'warn', summary: 'Atenção', detail: 'Título e conteúdo são obrigatórios.', life: 3000 })
return
}
saving.value = true
try {
const payload = {
title: form.value.title,
body: form.value.body,
icon: form.value.icon || 'pi-star',
ordem: form.value.ordem,
ativo: form.value.ativo,
async function load() {
loading.value = true;
try {
const { data, error } = await supabase.from('login_carousel_slides').select('*').order('ordem', { ascending: true });
if (error) throw error;
slides.value = data || [];
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao carregar slides.', life: 4000 });
} finally {
loading.value = false;
}
if (editingSlide.value) {
const { error } = await supabase
.from('login_carousel_slides')
.update(payload)
.eq('id', editingSlide.value.id)
if (error) throw error
toast.add({ severity: 'success', summary: 'Salvo', detail: 'Slide atualizado com sucesso.', life: 3000 })
} else {
const maxOrdem = slides.value.length ? Math.max(...slides.value.map(s => s.ordem)) + 1 : 0
payload.ordem = maxOrdem
const { error } = await supabase
.from('login_carousel_slides')
.insert(payload)
if (error) throw error
toast.add({ severity: 'success', summary: 'Criado', detail: 'Slide adicionado com sucesso.', life: 3000 })
}
function stripHtml(s) {
return String(s || '')
.replace(/<[^>]+>/g, '')
.trim();
}
async function saveSlide() {
if (!stripHtml(form.value.title) || !stripHtml(form.value.body)) {
toast.add({ severity: 'warn', summary: 'Atenção', detail: 'Título e conteúdo são obrigatórios.', life: 3000 });
return;
}
saving.value = true;
try {
const payload = {
title: form.value.title,
body: form.value.body,
icon: form.value.icon || 'pi-star',
ordem: form.value.ordem,
ativo: form.value.ativo
};
if (editingSlide.value) {
const { error } = await supabase.from('login_carousel_slides').update(payload).eq('id', editingSlide.value.id);
if (error) throw error;
toast.add({ severity: 'success', summary: 'Salvo', detail: 'Slide atualizado com sucesso.', life: 3000 });
} else {
const maxOrdem = slides.value.length ? Math.max(...slides.value.map((s) => s.ordem)) + 1 : 0;
payload.ordem = maxOrdem;
const { error } = await supabase.from('login_carousel_slides').insert(payload);
if (error) throw error;
toast.add({ severity: 'success', summary: 'Criado', detail: 'Slide adicionado com sucesso.', life: 3000 });
}
dialogOpen.value = false;
await load();
previewIdx.value = 0;
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao salvar.', life: 4000 });
} finally {
saving.value = false;
}
dialogOpen.value = false
await load()
previewIdx.value = 0
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao salvar.', life: 4000 })
} finally {
saving.value = false
}
}
async function toggleAtivo (slide) {
try {
const { error } = await supabase
.from('login_carousel_slides')
.update({ ativo: !slide.ativo })
.eq('id', slide.id)
if (error) throw error
slide.ativo = !slide.ativo
toast.add({ severity: 'info', summary: slide.ativo ? 'Ativado' : 'Desativado', detail: `"${slide.title}"`, life: 2500 })
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message, life: 3000 })
}
async function toggleAtivo(slide) {
try {
const { error } = await supabase.from('login_carousel_slides').update({ ativo: !slide.ativo }).eq('id', slide.id);
if (error) throw error;
slide.ativo = !slide.ativo;
toast.add({ severity: 'info', summary: slide.ativo ? 'Ativado' : 'Desativado', detail: `"${slide.title}"`, life: 2500 });
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message, life: 3000 });
}
}
async function deleteSlide (slide) {
confirm.require({
message: `Remover o slide "${slide.title}"? Esta ação não pode ser desfeita.`,
header: 'Confirmar remoção',
icon: 'pi pi-exclamation-triangle',
acceptClass: 'p-button-danger',
acceptLabel: 'Remover',
rejectLabel: 'Cancelar',
accept: async () => {
try {
const { error } = await supabase.from('login_carousel_slides').delete().eq('id', slide.id)
if (error) throw error
toast.add({ severity: 'success', summary: 'Removido', detail: `Slide removido.`, life: 2500 })
await load()
previewIdx.value = 0
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message, life: 3000 })
}
},
})
async function deleteSlide(slide) {
confirm.require({
message: `Remover o slide "${slide.title}"? Esta ação não pode ser desfeita.`,
header: 'Confirmar remoção',
icon: 'pi pi-exclamation-triangle',
acceptClass: 'p-button-danger',
acceptLabel: 'Remover',
rejectLabel: 'Cancelar',
accept: async () => {
try {
const { error } = await supabase.from('login_carousel_slides').delete().eq('id', slide.id);
if (error) throw error;
toast.add({ severity: 'success', summary: 'Removido', detail: `Slide removido.`, life: 2500 });
await load();
previewIdx.value = 0;
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message, life: 3000 });
}
}
});
}
async function moveSlide (slide, dir) {
const sorted = [...slides.value].sort((a, b) => a.ordem - b.ordem)
const idx = sorted.findIndex(s => s.id === slide.id)
const swapIdx = idx + dir
if (swapIdx < 0 || swapIdx >= sorted.length) return
async function moveSlide(slide, dir) {
const sorted = [...slides.value].sort((a, b) => a.ordem - b.ordem);
const idx = sorted.findIndex((s) => s.id === slide.id);
const swapIdx = idx + dir;
if (swapIdx < 0 || swapIdx >= sorted.length) return;
const a = sorted[idx]
const b = sorted[swapIdx]
const tempOrdem = a.ordem
const a = sorted[idx];
const b = sorted[swapIdx];
const tempOrdem = a.ordem;
try {
await supabase.from('login_carousel_slides').update({ ordem: b.ordem }).eq('id', a.id)
await supabase.from('login_carousel_slides').update({ ordem: tempOrdem }).eq('id', b.id)
await load()
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message, life: 3000 })
}
try {
await supabase.from('login_carousel_slides').update({ ordem: b.ordem }).eq('id', a.id);
await supabase.from('login_carousel_slides').update({ ordem: tempOrdem }).eq('id', b.id);
await load();
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message, life: 3000 });
}
}
// Dialog helpers
function openNew () {
editingSlide.value = null
form.value = { title: '', body: '', icon: 'pi-calendar-clock', ordem: 0, ativo: true }
dialogOpen.value = true
function openNew() {
editingSlide.value = null;
form.value = { title: '', body: '', icon: 'pi-calendar-clock', ordem: 0, ativo: true };
dialogOpen.value = true;
}
function openEdit (slide) {
editingSlide.value = slide
form.value = { title: slide.title, body: slide.body, icon: slide.icon || 'pi-star', ordem: slide.ordem, ativo: slide.ativo }
dialogOpen.value = true
function openEdit(slide) {
editingSlide.value = slide;
form.value = { title: slide.title, body: slide.body, icon: slide.icon || 'pi-star', ordem: slide.ordem, ativo: slide.ativo };
dialogOpen.value = true;
}
onMounted(load)
onMounted(load);
</script>
<template>
<ConfirmDialog />
<ConfirmDialog />
<!-- Sentinel -->
<div class="h-px" />
<!-- Sentinel -->
<div class="h-px" />
<!-- Hero sticky -->
<div
class="sticky mx-3 md:mx-4 mb-4 z-20 overflow-hidden rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5"
:style="{ top: 'var(--layout-sticky-top, 56px)' }"
>
<div class="absolute inset-0 pointer-events-none overflow-hidden" aria-hidden="true">
<div class="absolute rounded-full blur-[70px] w-72 h-72 -top-16 -right-20 bg-indigo-400/10" />
<div class="absolute rounded-full blur-[70px] w-80 h-80 top-10 -left-24 bg-emerald-400/10" />
<div class="absolute rounded-full blur-[70px] w-72 h-72 -bottom-20 right-24 bg-fuchsia-400/10" />
</div>
<div class="relative z-10 flex items-center justify-between gap-3 flex-wrap">
<div class="min-w-0">
<div class="text-[1rem] font-bold tracking-tight text-[var(--text-color)] flex items-center gap-2">
<i class="pi pi-images text-indigo-500" />
Carrossel do Login
<!-- Hero sticky -->
<div class="sticky mx-3 md:mx-4 mb-4 z-20 overflow-hidden rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5" :style="{ top: 'var(--layout-sticky-top, 56px)' }">
<div class="absolute inset-0 pointer-events-none overflow-hidden" aria-hidden="true">
<div class="absolute rounded-full blur-[70px] w-72 h-72 -top-16 -right-20 bg-indigo-400/10" />
<div class="absolute rounded-full blur-[70px] w-80 h-80 top-10 -left-24 bg-emerald-400/10" />
<div class="absolute rounded-full blur-[70px] w-72 h-72 -bottom-20 right-24 bg-fuchsia-400/10" />
</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-0.5">
Gerencie os slides exibidos na tela de login do sistema
</div>
</div>
<div class="flex items-center gap-2 shrink-0">
<Button
icon="pi pi-refresh"
severity="secondary"
outlined
title="Recarregar"
:loading="loading"
@click="load"
/>
<Button
icon="pi pi-plus"
label="Novo slide"
@click="openNew"
/>
</div>
</div>
</div>
<div class="px-3 md:px-4 pb-8 flex flex-col gap-4">
<div class="grid grid-cols-1 xl:grid-cols-[1fr_340px] gap-6 items-start">
<!-- Tabela de slides -->
<div class="bg-[var(--surface-card,#fff)] rounded-md border border-[var(--surface-border)] overflow-hidden">
<!-- Loading skeleton -->
<div v-if="loading" class="flex flex-col divide-y divide-[var(--surface-border)]">
<div v-for="i in 4" :key="i" class="flex items-center gap-4 px-5 py-4 animate-pulse">
<div class="w-10 h-10 rounded-md bg-[var(--surface-ground)] flex-shrink-0" />
<div class="flex-1 space-y-2">
<div class="h-3.5 w-40 rounded bg-[var(--surface-ground)]" />
<div class="h-3 w-64 rounded bg-[var(--surface-ground)]" />
</div>
</div>
</div>
<!-- Lista vazia -->
<div v-else-if="!slides.length" class="flex flex-col items-center justify-center py-16 gap-3 text-[var(--text-color-secondary)]">
<i class="pi pi-images text-4xl opacity-30" />
<span class="text-[1rem]">Nenhum slide cadastrado ainda.</span>
<Button label="Criar primeiro slide" size="small" @click="openNew" />
</div>
<!-- Rows -->
<div v-else class="divide-y divide-[var(--surface-border)]">
<!-- Header -->
<div class="grid grid-cols-[auto_1fr_auto_auto] items-center gap-4 px-5 py-2.5 bg-[var(--surface-ground)] text-xs font-bold uppercase tracking-widest text-[var(--text-color-secondary)]">
<span class="w-10" />
<span>Slide</span>
<span class="text-center w-[60px]">Status</span>
<span class="w-[96px]" />
</div>
<div
v-for="(slide, i) in [...slides].sort((a,b) => a.ordem - b.ordem)"
:key="slide.id"
class="grid grid-cols-[auto_1fr_auto_auto] items-center gap-4 px-5 py-3.5 transition-colors duration-100 hover:bg-[var(--surface-ground,#f8fafc)] group"
>
<!-- Ícone + ordem -->
<div class="relative flex-shrink-0">
<div
class="w-10 h-10 rounded-md flex items-center justify-center text-lg"
:class="slide.ativo ? 'bg-indigo-500/10 text-indigo-500' : 'bg-[var(--surface-ground)] text-[var(--text-color-secondary)]'"
>
<i :class="['pi', slide.icon || 'pi-star']" />
</div>
<span class="absolute -top-1 -right-1 w-4 h-4 rounded-full bg-[var(--surface-border)] text-[0.58rem] font-bold flex items-center justify-center text-[var(--text-color-secondary)]">
{{ slide.ordem + 1 }}
</span>
</div>
<!-- Conteúdo -->
<div class="relative z-10 flex items-center justify-between gap-3 flex-wrap">
<div class="min-w-0">
<div class="text-[1rem] font-semibold text-[var(--text-color)] truncate [&_*]:inline" :class="!slide.ativo && 'opacity-40 line-through'" v-html="slide.title" />
<div class="text-[1rem] text-[var(--text-color-secondary)] truncate mt-0.5 [&_*]:inline" :class="!slide.ativo && 'opacity-40'" v-html="slide.body" />
</div>
<!-- Toggle ativo -->
<div class="flex justify-center w-[60px]">
<InputSwitch
:modelValue="slide.ativo"
@update:modelValue="() => toggleAtivo(slide)"
/>
</div>
<!-- Ações -->
<div class="flex items-center gap-1 w-[96px] justify-end opacity-0 group-hover:opacity-100 transition-opacity duration-150">
<button
class="w-7 h-7 rounded-md flex items-center justify-center text-[var(--text-color-secondary)] hover:bg-[var(--surface-border)] hover:text-[var(--text-color)] transition-colors duration-100 disabled:opacity-30"
:disabled="i === 0"
title="Mover para cima"
@click="moveSlide(slide, -1)"
>
<i class="pi pi-chevron-up text-xs" />
</button>
<button
class="w-7 h-7 rounded-md flex items-center justify-center text-[var(--text-color-secondary)] hover:bg-[var(--surface-border)] hover:text-[var(--text-color)] transition-colors duration-100 disabled:opacity-30"
:disabled="i === slides.length - 1"
title="Mover para baixo"
@click="moveSlide(slide, 1)"
>
<i class="pi pi-chevron-down text-xs" />
</button>
<button
class="w-7 h-7 rounded-md flex items-center justify-center text-[var(--text-color-secondary)] hover:bg-indigo-50 hover:text-indigo-600 transition-colors duration-100"
title="Editar"
@click="openEdit(slide)"
>
<i class="pi pi-pencil text-xs" />
</button>
<button
class="w-7 h-7 rounded-md flex items-center justify-center text-[var(--text-color-secondary)] hover:bg-red-50 hover:text-red-500 transition-colors duration-100"
title="Remover"
@click="deleteSlide(slide)"
>
<i class="pi pi-trash text-xs" />
</button>
</div>
</div>
</div>
</div>
<!-- Preview -->
<div class="sticky top-6 flex flex-col gap-3">
<div class="text-xs font-bold uppercase tracking-widest text-[var(--text-color-secondary)] flex items-center gap-1.5 px-1">
<i class="pi pi-eye" /> Pré-visualização
</div>
<!-- Mock da tela de login lado esquerdo -->
<div class="relative overflow-hidden rounded-md aspect-[9/16] max-h-[480px] w-full select-none shadow-xl">
<div class="absolute inset-0 bg-gradient-to-br from-indigo-600 via-violet-600 to-purple-700" />
<!-- Grade decorativa -->
<div
class="absolute inset-0 opacity-[0.08]"
style="background-image: linear-gradient(to right, white 1px, transparent 1px), linear-gradient(to bottom, white 1px, transparent 1px); background-size: 32px 32px;"
/>
<!-- Orbs -->
<div class="absolute -top-20 -left-20 h-64 w-64 rounded-full bg-white/10 blur-3xl pointer-events-none" />
<div class="absolute bottom-0 right-0 h-48 w-48 rounded-full bg-violet-300/20 blur-3xl pointer-events-none" />
<div class="relative z-10 flex flex-col h-full p-6">
<!-- Brand mock -->
<div class="flex items-center gap-2">
<div class="grid h-7 w-7 place-items-center rounded-lg bg-white/20 border border-white/20">
<i class="pi pi-heart-fill text-white text-[0.6rem]" />
</div>
<span class="text-white/90 font-bold text-xs tracking-tight">Agência PSI</span>
</div>
<!-- Slide content -->
<div class="flex-1 flex flex-col justify-center gap-4">
<Transition name="prev-fade" mode="out-in">
<div v-if="previewSlide" :key="previewSlide.id ?? previewIdx" class="space-y-4">
<div class="grid h-11 w-11 place-items-center rounded-md bg-white/15 border border-white/20 shadow-lg">
<i :class="['pi', previewSlide.icon || 'pi-star', 'text-white text-lg']" />
</div>
<div class="space-y-2">
<div class="text-xl font-bold text-white leading-tight prose prose-invert prose-sm max-w-none" v-html="previewSlide.title" />
<div class="text-[1rem] text-white/70 leading-relaxed prose prose-invert prose-sm max-w-none" v-html="previewSlide.body" />
</div>
<div class="text-[1rem] font-bold tracking-tight text-[var(--text-color)] flex items-center gap-2">
<i class="pi pi-images text-indigo-500" />
Carrossel do Login
</div>
<div v-else class="flex flex-col items-center justify-center gap-2 text-white/30 text-xs">
<i class="pi pi-ban text-2xl" />
Nenhum slide ativo
</div>
</Transition>
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-0.5">Gerencie os slides exibidos na tela de login do sistema</div>
</div>
<!-- Dots -->
<div class="flex items-center gap-1.5">
<button
v-for="(s, i) in slidesAtivos"
:key="s.id"
class="transition-all duration-300 rounded-full"
:class="i === previewIdx ? 'w-5 h-1.5 bg-white shadow' : 'w-1.5 h-1.5 bg-white/35 hover:bg-white/60'"
@click="previewIdx = i"
/>
<span v-if="slidesAtivos.length" class="ml-2 text-[0.6rem] text-white/40 tabular-nums">
{{ previewIdx + 1 }}/{{ slidesAtivos.length }}
</span>
<div class="flex items-center gap-2 shrink-0">
<Button icon="pi pi-refresh" severity="secondary" outlined title="Recarregar" :loading="loading" @click="load" />
<Button icon="pi pi-plus" label="Novo slide" @click="openNew" />
</div>
</div>
</div>
<!-- Info -->
<div class="rounded-lg border border-[var(--surface-border)] bg-[var(--surface-card)] px-4 py-3 text-xs text-[var(--text-color-secondary)] flex items-start gap-2">
<i class="pi pi-info-circle text-indigo-500 mt-px flex-shrink-0" />
<span>Clique nos pontos para navegar entre os slides ativos. A ordem e visibilidade refletem o que o usuário verá no login.</span>
</div>
</div>
</div>
<!-- SQL Helper -->
<div class="bg-[var(--surface-card)] rounded-md border border-[var(--surface-border)] px-5 py-4">
<div class="flex items-center gap-2 mb-3">
<i class="pi pi-database text-amber-500 text-[1rem]" />
<span class="text-xs font-bold text-[var(--text-color)] uppercase tracking-widest">SQL de referência</span>
<span class="ml-auto text-xs text-[var(--text-color-secondary)]">Execute no Supabase caso a tabela não exista</span>
</div>
<pre class="text-[0.7rem] bg-[var(--surface-ground)] rounded-lg p-3.5 overflow-x-auto text-[var(--text-color-secondary)] leading-relaxed whitespace-pre-wrap"><code>create table if not exists public.login_carousel_slides (
<div class="px-3 md:px-4 pb-8 flex flex-col gap-4">
<div class="grid grid-cols-1 xl:grid-cols-[1fr_340px] gap-6 items-start">
<!-- Tabela de slides -->
<div class="bg-[var(--surface-card,#fff)] rounded-md border border-[var(--surface-border)] overflow-hidden">
<!-- Loading skeleton -->
<div v-if="loading" class="flex flex-col divide-y divide-[var(--surface-border)]">
<div v-for="i in 4" :key="i" class="flex items-center gap-4 px-5 py-4 animate-pulse">
<div class="w-10 h-10 rounded-md bg-[var(--surface-ground)] flex-shrink-0" />
<div class="flex-1 space-y-2">
<div class="h-3.5 w-40 rounded bg-[var(--surface-ground)]" />
<div class="h-3 w-64 rounded bg-[var(--surface-ground)]" />
</div>
</div>
</div>
<!-- Lista vazia -->
<div v-else-if="!slides.length" class="flex flex-col items-center justify-center py-16 gap-3 text-[var(--text-color-secondary)]">
<i class="pi pi-images text-4xl opacity-30" />
<span class="text-[1rem]">Nenhum slide cadastrado ainda.</span>
<Button label="Criar primeiro slide" size="small" @click="openNew" />
</div>
<!-- Rows -->
<div v-else class="divide-y divide-[var(--surface-border)]">
<!-- Header -->
<div class="grid grid-cols-[auto_1fr_auto_auto] items-center gap-4 px-5 py-2.5 bg-[var(--surface-ground)] text-xs font-bold uppercase tracking-widest text-[var(--text-color-secondary)]">
<span class="w-10" />
<span>Slide</span>
<span class="text-center w-[60px]">Status</span>
<span class="w-[96px]" />
</div>
<div
v-for="(slide, i) in [...slides].sort((a, b) => a.ordem - b.ordem)"
:key="slide.id"
class="grid grid-cols-[auto_1fr_auto_auto] items-center gap-4 px-5 py-3.5 transition-colors duration-100 hover:bg-[var(--surface-ground,#f8fafc)] group"
>
<!-- Ícone + ordem -->
<div class="relative flex-shrink-0">
<div class="w-10 h-10 rounded-md flex items-center justify-center text-lg" :class="slide.ativo ? 'bg-indigo-500/10 text-indigo-500' : 'bg-[var(--surface-ground)] text-[var(--text-color-secondary)]'">
<i :class="['pi', slide.icon || 'pi-star']" />
</div>
<span class="absolute -top-1 -right-1 w-4 h-4 rounded-full bg-[var(--surface-border)] text-[0.58rem] font-bold flex items-center justify-center text-[var(--text-color-secondary)]">
{{ slide.ordem + 1 }}
</span>
</div>
<!-- Conteúdo -->
<div class="min-w-0">
<div class="text-[1rem] font-semibold text-[var(--text-color)] truncate [&_*]:inline" :class="!slide.ativo && 'opacity-40 line-through'" v-html="slide.title" />
<div class="text-[1rem] text-[var(--text-color-secondary)] truncate mt-0.5 [&_*]:inline" :class="!slide.ativo && 'opacity-40'" v-html="slide.body" />
</div>
<!-- Toggle ativo -->
<div class="flex justify-center w-[60px]">
<InputSwitch :modelValue="slide.ativo" @update:modelValue="() => toggleAtivo(slide)" />
</div>
<!-- Ações -->
<div class="flex items-center gap-1 w-[96px] justify-end opacity-0 group-hover:opacity-100 transition-opacity duration-150">
<button
class="w-7 h-7 rounded-md flex items-center justify-center text-[var(--text-color-secondary)] hover:bg-[var(--surface-border)] hover:text-[var(--text-color)] transition-colors duration-100 disabled:opacity-30"
:disabled="i === 0"
title="Mover para cima"
@click="moveSlide(slide, -1)"
>
<i class="pi pi-chevron-up text-xs" />
</button>
<button
class="w-7 h-7 rounded-md flex items-center justify-center text-[var(--text-color-secondary)] hover:bg-[var(--surface-border)] hover:text-[var(--text-color)] transition-colors duration-100 disabled:opacity-30"
:disabled="i === slides.length - 1"
title="Mover para baixo"
@click="moveSlide(slide, 1)"
>
<i class="pi pi-chevron-down text-xs" />
</button>
<button class="w-7 h-7 rounded-md flex items-center justify-center text-[var(--text-color-secondary)] hover:bg-indigo-50 hover:text-indigo-600 transition-colors duration-100" title="Editar" @click="openEdit(slide)">
<i class="pi pi-pencil text-xs" />
</button>
<button class="w-7 h-7 rounded-md flex items-center justify-center text-[var(--text-color-secondary)] hover:bg-red-50 hover:text-red-500 transition-colors duration-100" title="Remover" @click="deleteSlide(slide)">
<i class="pi pi-trash text-xs" />
</button>
</div>
</div>
</div>
</div>
<!-- Preview -->
<div class="sticky top-6 flex flex-col gap-3">
<div class="text-xs font-bold uppercase tracking-widest text-[var(--text-color-secondary)] flex items-center gap-1.5 px-1"><i class="pi pi-eye" /> Pré-visualização</div>
<!-- Mock da tela de login lado esquerdo -->
<div class="relative overflow-hidden rounded-md aspect-[9/16] max-h-[480px] w-full select-none shadow-xl">
<div class="absolute inset-0 bg-gradient-to-br from-indigo-600 via-violet-600 to-purple-700" />
<!-- Grade decorativa -->
<div class="absolute inset-0 opacity-[0.08]" style="background-image: linear-gradient(to right, white 1px, transparent 1px), linear-gradient(to bottom, white 1px, transparent 1px); background-size: 32px 32px" />
<!-- Orbs -->
<div class="absolute -top-20 -left-20 h-64 w-64 rounded-full bg-white/10 blur-3xl pointer-events-none" />
<div class="absolute bottom-0 right-0 h-48 w-48 rounded-full bg-violet-300/20 blur-3xl pointer-events-none" />
<div class="relative z-10 flex flex-col h-full p-6">
<!-- Brand mock -->
<div class="flex items-center gap-2">
<div class="grid h-7 w-7 place-items-center rounded-lg bg-white/20 border border-white/20">
<i class="pi pi-heart-fill text-white text-[0.6rem]" />
</div>
<span class="text-white/90 font-bold text-xs tracking-tight">Agência PSI</span>
</div>
<!-- Slide content -->
<div class="flex-1 flex flex-col justify-center gap-4">
<Transition name="prev-fade" mode="out-in">
<div v-if="previewSlide" :key="previewSlide.id ?? previewIdx" class="space-y-4">
<div class="grid h-11 w-11 place-items-center rounded-md bg-white/15 border border-white/20 shadow-lg">
<i :class="['pi', previewSlide.icon || 'pi-star', 'text-white text-lg']" />
</div>
<div class="space-y-2">
<div class="text-xl font-bold text-white leading-tight prose prose-invert prose-sm max-w-none" v-html="previewSlide.title" />
<div class="text-[1rem] text-white/70 leading-relaxed prose prose-invert prose-sm max-w-none" v-html="previewSlide.body" />
</div>
</div>
<div v-else class="flex flex-col items-center justify-center gap-2 text-white/30 text-xs">
<i class="pi pi-ban text-2xl" />
Nenhum slide ativo
</div>
</Transition>
</div>
<!-- Dots -->
<div class="flex items-center gap-1.5">
<button
v-for="(s, i) in slidesAtivos"
:key="s.id"
class="transition-all duration-300 rounded-full"
:class="i === previewIdx ? 'w-5 h-1.5 bg-white shadow' : 'w-1.5 h-1.5 bg-white/35 hover:bg-white/60'"
@click="previewIdx = i"
/>
<span v-if="slidesAtivos.length" class="ml-2 text-[0.6rem] text-white/40 tabular-nums"> {{ previewIdx + 1 }}/{{ slidesAtivos.length }} </span>
</div>
</div>
</div>
<!-- Info -->
<div class="rounded-lg border border-[var(--surface-border)] bg-[var(--surface-card)] px-4 py-3 text-xs text-[var(--text-color-secondary)] flex items-start gap-2">
<i class="pi pi-info-circle text-indigo-500 mt-px flex-shrink-0" />
<span>Clique nos pontos para navegar entre os slides ativos. A ordem e visibilidade refletem o que o usuário verá no login.</span>
</div>
</div>
</div>
<!-- SQL Helper -->
<div class="bg-[var(--surface-card)] rounded-md border border-[var(--surface-border)] px-5 py-4">
<div class="flex items-center gap-2 mb-3">
<i class="pi pi-database text-amber-500 text-[1rem]" />
<span class="text-xs font-bold text-[var(--text-color)] uppercase tracking-widest">SQL de referência</span>
<span class="ml-auto text-xs text-[var(--text-color-secondary)]">Execute no Supabase caso a tabela não exista</span>
</div>
<pre class="text-[0.7rem] bg-[var(--surface-ground)] rounded-lg p-3.5 overflow-x-auto text-[var(--text-color-secondary)] leading-relaxed whitespace-pre-wrap"><code>create table if not exists public.login_carousel_slides (
id uuid primary key default gen_random_uuid(),
title text not null,
body text not null,
@@ -443,137 +394,112 @@ create policy "saas_admin_full" on public.login_carousel_slides
-- Leitura pública (login não tem usuário autenticado)
create policy "public_read" on public.login_carousel_slides
for select using (ativo = true);</code></pre>
</div>
</div>
</div>
<!-- /px-3 content wrapper -->
<!-- /px-3 content wrapper -->
<!-- Dialog: Criar / Editar slide -->
<Dialog v-model:visible="dialogOpen" modal :header="editingSlide ? 'Editar slide' : 'Novo slide'" :draggable="false" :style="{ width: '46rem', maxWidth: '96vw' }">
<div class="flex flex-col gap-4 pt-1">
<!-- Título -->
<div class="flex flex-col gap-1.5">
<label class="text-[1rem] font-semibold text-[var(--text-color)]">Título <span class="text-red-500">*</span></label>
<Editor v-model="form.title" :pt="{ toolbar: { style: 'display:none' } }" style="height: 72px" editorStyle="font-size: 1rem; font-weight: 600;" placeholder="Ex: Gestão clínica simplificada">
<template #toolbar>
<span class="ql-formats">
<button class="ql-bold" />
<button class="ql-italic" />
<button class="ql-underline" />
</span>
</template>
</Editor>
</div>
<!-- Dialog: Criar / Editar slide -->
<Dialog
v-model:visible="dialogOpen"
modal
:header="editingSlide ? 'Editar slide' : 'Novo slide'"
:draggable="false"
:style="{ width: '46rem', maxWidth: '96vw' }"
>
<div class="flex flex-col gap-4 pt-1">
<!-- Conteúdo -->
<div class="flex flex-col gap-1.5">
<label class="text-[1rem] font-semibold text-[var(--text-color)]">Conteúdo <span class="text-red-500">*</span></label>
<Editor v-model="form.body" style="height: 160px" editorStyle="font-size: 1rem;">
<template #toolbar>
<span class="ql-formats">
<button class="ql-bold" />
<button class="ql-italic" />
<button class="ql-underline" />
</span>
<span class="ql-formats">
<button class="ql-list" value="ordered" />
<button class="ql-list" value="bullet" />
</span>
<span class="ql-formats">
<button class="ql-link" />
<button class="ql-clean" />
</span>
</template>
</Editor>
</div>
<!-- Título -->
<div class="flex flex-col gap-1.5">
<label class="text-[1rem] font-semibold text-[var(--text-color)]">Título <span class="text-red-500">*</span></label>
<Editor
v-model="form.title"
:pt="{ toolbar: { style: 'display:none' } }"
style="height: 72px"
editorStyle="font-size: 1rem; font-weight: 600;"
placeholder="Ex: Gestão clínica simplificada"
>
<template #toolbar>
<span class="ql-formats">
<button class="ql-bold" />
<button class="ql-italic" />
<button class="ql-underline" />
</span>
</template>
</Editor>
</div>
<!-- Ícone -->
<div class="flex flex-col gap-1.5">
<label class="text-[1rem] font-semibold text-[var(--text-color)]">Ícone</label>
<div class="grid grid-cols-4 sm:grid-cols-8 gap-1.5">
<button
v-for="ic in ICONS"
:key="ic.value"
type="button"
class="flex flex-col items-center justify-center gap-1 py-2 rounded-lg border text-xs transition-all duration-100"
:class="
form.icon === ic.value
? 'border-indigo-500 bg-indigo-50 text-indigo-600 shadow-sm'
: 'border-[var(--surface-border)] bg-[var(--surface-ground)] text-[var(--text-color-secondary)] hover:border-indigo-300 hover:text-indigo-500'
"
:title="ic.label"
@click="form.icon = ic.value"
>
<i :class="['pi', ic.value, 'text-base']" />
<span class="text-[0.6rem] leading-none">{{ ic.label }}</span>
</button>
</div>
</div>
<!-- Conteúdo -->
<div class="flex flex-col gap-1.5">
<label class="text-[1rem] font-semibold text-[var(--text-color)]">Conteúdo <span class="text-red-500">*</span></label>
<Editor
v-model="form.body"
style="height: 160px"
editorStyle="font-size: 1rem;"
>
<template #toolbar>
<span class="ql-formats">
<button class="ql-bold" />
<button class="ql-italic" />
<button class="ql-underline" />
</span>
<span class="ql-formats">
<button class="ql-list" value="ordered" />
<button class="ql-list" value="bullet" />
</span>
<span class="ql-formats">
<button class="ql-link" />
<button class="ql-clean" />
</span>
</template>
</Editor>
</div>
<!-- Ativo -->
<div class="flex items-center gap-3">
<InputSwitch v-model="form.ativo" inputId="slide-ativo" />
<label for="slide-ativo" class="text-[1rem] text-[var(--text-color)] cursor-pointer select-none"> Slide ativo (visível no carrossel) </label>
</div>
<!-- Ícone -->
<div class="flex flex-col gap-1.5">
<label class="text-[1rem] font-semibold text-[var(--text-color)]">Ícone</label>
<div class="grid grid-cols-4 sm:grid-cols-8 gap-1.5">
<button
v-for="ic in ICONS"
:key="ic.value"
type="button"
class="flex flex-col items-center justify-center gap-1 py-2 rounded-lg border text-xs transition-all duration-100"
:class="form.icon === ic.value
? 'border-indigo-500 bg-indigo-50 text-indigo-600 shadow-sm'
: 'border-[var(--surface-border)] bg-[var(--surface-ground)] text-[var(--text-color-secondary)] hover:border-indigo-300 hover:text-indigo-500'"
:title="ic.label"
@click="form.icon = ic.value"
>
<i :class="['pi', ic.value, 'text-base']" />
<span class="text-[0.6rem] leading-none">{{ ic.label }}</span>
</button>
<!-- Mini preview -->
<div class="relative overflow-hidden rounded-md p-5 flex items-center gap-4" style="background: linear-gradient(135deg, #4f46e5 0%, #7c3aed 100%)">
<div class="grid h-12 w-12 flex-shrink-0 place-items-center rounded-md bg-white/15 border border-white/20 shadow">
<i :class="['pi', form.icon || 'pi-star', 'text-white text-xl']" />
</div>
<div class="min-w-0 overflow-hidden">
<div class="text-[1rem] font-bold text-white line-clamp-2 prose prose-invert prose-sm max-w-none" v-html="stripHtml(form.title) ? form.title : 'Título do slide'" />
<div class="text-[1rem] text-white/70 mt-0.5 line-clamp-3 prose prose-invert prose-sm max-w-none" v-html="stripHtml(form.body) ? form.body : 'Conteúdo descritivo...'" />
</div>
</div>
<!-- Ações -->
<div class="flex justify-end gap-2 pt-1">
<Button label="Cancelar" severity="secondary" outlined :disabled="saving" @click="dialogOpen = false" />
<Button :label="editingSlide ? 'Salvar alterações' : 'Criar slide'" icon="pi pi-check" :loading="saving" @click="saveSlide" />
</div>
</div>
</div>
<!-- Ativo -->
<div class="flex items-center gap-3">
<InputSwitch v-model="form.ativo" inputId="slide-ativo" />
<label for="slide-ativo" class="text-[1rem] text-[var(--text-color)] cursor-pointer select-none">
Slide ativo (visível no carrossel)
</label>
</div>
<!-- Mini preview -->
<div
class="relative overflow-hidden rounded-md p-5 flex items-center gap-4"
style="background: linear-gradient(135deg, #4f46e5 0%, #7c3aed 100%)"
>
<div class="grid h-12 w-12 flex-shrink-0 place-items-center rounded-md bg-white/15 border border-white/20 shadow">
<i :class="['pi', form.icon || 'pi-star', 'text-white text-xl']" />
</div>
<div class="min-w-0 overflow-hidden">
<div class="text-[1rem] font-bold text-white line-clamp-2 prose prose-invert prose-sm max-w-none" v-html="stripHtml(form.title) ? form.title : 'Título do slide'" />
<div class="text-[1rem] text-white/70 mt-0.5 line-clamp-3 prose prose-invert prose-sm max-w-none" v-html="stripHtml(form.body) ? form.body : 'Conteúdo descritivo...'" />
</div>
</div>
<!-- Ações -->
<div class="flex justify-end gap-2 pt-1">
<Button label="Cancelar" severity="secondary" outlined :disabled="saving" @click="dialogOpen = false" />
<Button
:label="editingSlide ? 'Salvar alterações' : 'Criar slide'"
icon="pi pi-check"
:loading="saving"
@click="saveSlide"
/>
</div>
</div>
</Dialog>
</Dialog>
</template>
<style scoped>
.prev-fade-enter-active,
.prev-fade-leave-active {
transition: opacity 0.3s ease, transform 0.3s ease;
transition:
opacity 0.3s ease,
transform 0.3s ease;
}
.prev-fade-enter-from {
opacity: 0;
transform: translateY(12px);
opacity: 0;
transform: translateY(12px);
}
.prev-fade-leave-to {
opacity: 0;
transform: translateY(-8px);
opacity: 0;
transform: translateY(-8px);
}
</style>
@@ -0,0 +1,443 @@
<!--
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/views/pages/saas/SaasNotificationTemplatesPage.vue
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
-->
<script setup>
import { ref, computed, onMounted, nextTick } from 'vue';
import { useToast } from 'primevue/usetoast';
import { useConfirm } from 'primevue/useconfirm';
import { supabase } from '@/lib/supabase/client';
const toast = useToast();
const confirm = useConfirm();
// Constantes
const CHANNELS = [
{ label: 'WhatsApp', value: 'whatsapp', icon: 'pi pi-whatsapp' },
{ label: 'SMS', value: 'sms', icon: 'pi pi-mobile' }
];
const DOMAIN_OPTIONS = [
{ label: 'Sessão', value: 'session' },
{ label: 'Triagem', value: 'intake' },
{ label: 'Financeiro', value: 'billing' },
{ label: 'Sistema', value: 'system' }
];
const EVENT_TYPE_OPTIONS = [
{ label: 'Lembrete de sessão', value: 'lembrete_sessao' },
{ label: 'Confirmação de sessão', value: 'confirmacao_sessao' },
{ label: 'Cancelamento de sessão', value: 'cancelamento_sessao' },
{ label: 'Reagendamento', value: 'reagendamento' },
{ label: 'Cobrança pendente', value: 'cobranca_pendente' },
{ label: 'Boas-vindas paciente', value: 'boas_vindas_paciente' },
{ label: 'Intake recebido', value: 'intake_recebido' },
{ label: 'Intake aprovado', value: 'intake_aprovado' },
{ label: 'Intake rejeitado', value: 'intake_rejeitado' }
];
const EVENT_TYPE_LABELS = Object.fromEntries(EVENT_TYPE_OPTIONS.map((e) => [e.value, e.label]));
const DOMAIN_LABELS = { session: 'Sessão', intake: 'Triagem', billing: 'Financeiro', system: 'Sistema' };
const DOMAIN_SEVERITY = { session: 'info', intake: 'warning', billing: 'success', system: 'secondary' };
const VARS_BY_EVENT = {
lembrete_sessao: ['patient_name', 'session_date', 'session_time', 'therapist_name', 'session_modality', 'session_link'],
confirmacao_sessao: ['patient_name', 'session_date', 'session_time', 'therapist_name', 'session_modality'],
cancelamento_sessao: ['patient_name', 'session_date', 'session_time', 'therapist_name', 'cancellation_reason'],
reagendamento: ['patient_name', 'session_date', 'session_time', 'therapist_name', 'session_modality'],
cobranca_pendente: ['patient_name', 'therapist_name', 'valor', 'vencimento'],
boas_vindas_paciente: ['patient_name', 'clinic_name', 'therapist_name', 'portal_link'],
intake_recebido: ['patient_name', 'clinic_name', 'therapist_name'],
intake_aprovado: ['patient_name', 'therapist_name', 'session_date', 'session_time'],
intake_rejeitado: ['patient_name', 'therapist_name', 'rejection_reason']
};
// Estado
const activeChannel = ref('whatsapp');
const templates = ref([]);
const loading = ref(false);
// Load
async function load() {
loading.value = true;
try {
const { data, error } = await supabase.from('notification_templates').select('*').is('tenant_id', null).eq('is_default', true).is('deleted_at', null).order('domain').order('event_type');
if (error) throw error;
templates.value = data || [];
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro ao carregar', detail: e.message, life: 4000 });
} finally {
loading.value = false;
}
}
const filtered = computed(() => templates.value.filter((t) => t.channel === activeChannel.value));
// Dialog
const dlg = ref({ open: false, saving: false, id: null, isNew: false });
const form = ref({});
const bodyTextareaRef = ref(null);
function _emptyForm() {
return {
key: '',
domain: 'session',
channel: activeChannel.value,
event_type: 'lembrete_sessao',
body_text: '',
is_active: true
};
}
function openNew() {
form.value = _emptyForm();
dlg.value = { open: true, saving: false, id: null, isNew: true };
}
function openEdit(t) {
form.value = {
key: t.key,
domain: t.domain,
channel: t.channel,
event_type: t.event_type,
body_text: t.body_text,
is_active: t.is_active
};
dlg.value = { open: true, saving: false, id: t.id, isNew: false };
}
function closeDlg() {
dlg.value.open = false;
}
// Variáveis disponíveis para o event_type selecionado
const availableVars = computed(() => VARS_BY_EVENT[form.value.event_type] || []);
// Variáveis detectadas no body_text
const detectedVars = computed(() => {
const matches = (form.value.body_text || '').matchAll(/\{\{(\w+)\}\}/g);
return [...new Set([...matches].map((m) => m[1]))];
});
// Insere variável no cursor do textarea
function insertVar(varName) {
const snippet = `{{${varName}}}`;
const ta = bodyTextareaRef.value?.$el?.querySelector('textarea');
if (!ta) {
form.value.body_text = (form.value.body_text || '') + snippet;
return;
}
const start = ta.selectionStart ?? ta.value.length;
const end = ta.selectionEnd ?? start;
const val = form.value.body_text || '';
form.value.body_text = val.slice(0, start) + snippet + val.slice(end);
nextTick(() => {
const pos = start + snippet.length;
ta.focus();
ta.setSelectionRange(pos, pos);
});
}
// Save
async function save() {
if (!form.value.body_text?.trim()) {
toast.add({ severity: 'warn', summary: 'Mensagem é obrigatória', life: 3000 });
return;
}
if (dlg.value.isNew && !form.value.key?.trim()) {
toast.add({ severity: 'warn', summary: 'Key é obrigatória', life: 3000 });
return;
}
dlg.value.saving = true;
try {
if (dlg.value.isNew) {
// Detecta variáveis usadas
const vars = [...(form.value.body_text || '').matchAll(/\{\{(\w+)\}\}/g)].map((m) => m[1]);
const { error } = await supabase.from('notification_templates').insert({
tenant_id: null,
owner_id: null,
key: form.value.key,
domain: form.value.domain,
channel: form.value.channel,
event_type: form.value.event_type,
body_text: form.value.body_text,
variables: vars,
is_default: true,
is_active: form.value.is_active
});
if (error) throw error;
toast.add({ severity: 'success', summary: 'Template criado', life: 3000 });
} else {
const vars = [...(form.value.body_text || '').matchAll(/\{\{(\w+)\}\}/g)].map((m) => m[1]);
const currentVersion = templates.value.find((t) => t.id === dlg.value.id)?.version || 0;
const { error } = await supabase
.from('notification_templates')
.update({
body_text: form.value.body_text,
domain: form.value.domain,
event_type: form.value.event_type,
variables: vars,
is_active: form.value.is_active,
version: currentVersion + 1
})
.eq('id', dlg.value.id);
if (error) throw error;
toast.add({ severity: 'success', summary: 'Template atualizado', life: 3000 });
}
closeDlg();
await load();
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro ao salvar', detail: e.message, life: 5000 });
} finally {
dlg.value.saving = false;
}
}
// Toggle ativo
async function toggleActive(t) {
try {
const { error } = await supabase.from('notification_templates').update({ is_active: !t.is_active }).eq('id', t.id);
if (error) throw error;
t.is_active = !t.is_active;
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e.message, life: 4000 });
}
}
// Soft delete
function deleteTemplate(t) {
confirm.require({
group: 'headless',
message: `Excluir o template "${t.key}"?`,
header: 'Confirmar exclusão',
icon: 'pi-trash',
color: '#ef4444',
accept: async () => {
try {
const { error } = await supabase.from('notification_templates').update({ deleted_at: new Date().toISOString() }).eq('id', t.id);
if (error) throw error;
toast.add({ severity: 'success', summary: 'Template excluído', life: 3000 });
await load();
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e.message, life: 4000 });
}
}
});
}
// Truncate
function truncate(str, len = 80) {
if (!str) return '';
return str.length > len ? str.slice(0, len) + '…' : str;
}
onMounted(load);
</script>
<template>
<ConfirmDialog group="headless">
<template #container="{ message, acceptCallback, rejectCallback }">
<div class="flex flex-col items-center p-8 bg-surface-0 dark:bg-surface-900 rounded-xl shadow-xl">
<div class="rounded-full inline-flex justify-center items-center h-24 w-24 -mt-20" :style="{ background: message.color || 'var(--p-primary-color)', color: '#fff' }">
<i :class="`pi ${message.icon || 'pi-question'} !text-4xl`"></i>
</div>
<span class="font-bold text-2xl block mb-2 mt-6">{{ message.header }}</span>
<p class="mb-0 text-center text-[var(--text-color-secondary)]">{{ message.message }}</p>
<div class="flex items-center gap-2 mt-6">
<Button label="Confirmar" class="rounded-full" :style="{ background: message.color || 'var(--p-primary-color)', borderColor: message.color || 'var(--p-primary-color)' }" @click="acceptCallback" />
<Button label="Cancelar" variant="outlined" class="rounded-full" @click="rejectCallback" />
</div>
</div>
</template>
</ConfirmDialog>
<div class="p-4 md:p-6 max-w-[1200px] mx-auto">
<!-- Header -->
<div class="flex items-center justify-between mb-6 gap-4 flex-wrap">
<div>
<h1 class="text-xl font-bold m-0">Templates de Notificação</h1>
<p class="text-sm text-[var(--text-color-secondary)] mt-1 mb-0">Templates globais de WhatsApp e SMS. Tenants herdam automaticamente.</p>
</div>
<div class="flex gap-2">
<Button label="Novo template" icon="pi pi-plus" size="small" @click="openNew" />
<Button icon="pi pi-refresh" severity="secondary" text :loading="loading" @click="load" />
</div>
</div>
<!-- Tabs canal -->
<div class="flex gap-2 mb-5">
<Button v-for="ch in CHANNELS" :key="ch.value" :label="ch.label" :icon="ch.icon" size="small" :severity="activeChannel === ch.value ? 'primary' : 'secondary'" :outlined="activeChannel !== ch.value" @click="activeChannel = ch.value" />
</div>
<!-- Loading -->
<div v-if="loading" class="flex justify-center py-12">
<ProgressSpinner />
</div>
<!-- DataTable -->
<DataTable v-else :value="filtered" stripedRows size="small" class="text-sm" :rowClass="(data) => (!data.is_active ? 'opacity-50' : '')">
<Column field="key" header="Key" sortable style="min-width: 200px">
<template #body="{ data }">
<code class="font-mono text-xs">{{ data.key }}</code>
</template>
</Column>
<Column field="domain" header="Domínio" sortable style="width: 110px">
<template #body="{ data }">
<Tag :value="DOMAIN_LABELS[data.domain] ?? data.domain" :severity="DOMAIN_SEVERITY[data.domain] ?? 'secondary'" class="text-[0.65rem]" />
</template>
</Column>
<Column field="event_type" header="Evento" sortable style="min-width: 160px">
<template #body="{ data }">
<span class="text-xs">{{ EVENT_TYPE_LABELS[data.event_type] ?? data.event_type }}</span>
</template>
</Column>
<Column field="body_text" header="Mensagem" style="min-width: 200px">
<template #body="{ data }">
<span class="text-xs text-[var(--text-color-secondary)]">{{ truncate(data.body_text) }}</span>
</template>
</Column>
<Column field="version" header="v" sortable style="width: 50px" class="text-center">
<template #body="{ data }">
<span class="text-xs text-[var(--text-color-secondary)]">{{ data.version }}</span>
</template>
</Column>
<Column header="Ativo" style="width: 70px" class="text-center">
<template #body="{ data }">
<ToggleSwitch :modelValue="data.is_active" @update:modelValue="() => toggleActive(data)" />
</template>
</Column>
<Column header="" style="width: 90px">
<template #body="{ data }">
<div class="flex gap-1">
<Button icon="pi pi-pencil" text rounded size="small" @click="openEdit(data)" />
<Button icon="pi pi-trash" text rounded size="small" severity="danger" @click="deleteTemplate(data)" />
</div>
</template>
</Column>
<template #empty>
<div class="text-center py-8 text-[var(--text-color-secondary)]">
<i class="pi pi-comment text-3xl opacity-30 block mb-2" />
Nenhum template {{ activeChannel === 'sms' ? 'SMS' : 'WhatsApp' }} cadastrado.
</div>
</template>
</DataTable>
<!-- Dialog Cadastro / Edição -->
<Dialog
v-model:visible="dlg.open"
modal
:draggable="false"
:closable="!dlg.saving"
:dismissableMask="!dlg.saving"
maximizable
class="dc-dialog w-[50rem]"
:breakpoints="{ '1199px': '90vw', '768px': '94vw' }"
:pt="{
header: { class: '!p-3 !rounded-t-[12px] border-b border-[var(--surface-border)] shadow-[0_1px_0_0_rgba(255,255,255,0.06)] bg-gray-100' },
content: { class: '!p-3' },
footer: { class: '!p-0 !rounded-b-[12px] border-t border-[var(--surface-border)] shadow-[0_1px_0_0_rgba(255,255,255,0.06)] bg-gray-100' },
pcCloseButton: { root: { class: '!rounded-md hover:!text-red-500' } },
pcMaximizeButton: { root: { class: '!rounded-md hover:!text-primary' } }
}"
pt:mask:class="backdrop-blur-xs"
>
<template #header>
<div class="flex w-full items-center justify-between gap-3 px-1">
<div class="flex items-center gap-3 min-w-0">
<div class="min-w-0">
<div class="text-base font-semibold truncate">{{ dlg.isNew ? 'Novo Template' : 'Editar Template' }}</div>
<div class="text-xs opacity-50">{{ dlg.isNew ? 'Cadastrar novo template de notificação' : form.key }}</div>
</div>
</div>
</div>
</template>
<div class="flex flex-col gap-4 py-2">
<!-- Key + Channel (somente no cadastro) -->
<div v-if="dlg.isNew" class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="flex flex-col gap-1.5">
<label class="text-xs font-semibold">Key *</label>
<InputText v-model="form.key" class="w-full font-mono text-sm" placeholder="ex: session.reminder.sms" />
</div>
<div class="flex flex-col gap-1.5">
<label class="text-xs font-semibold">Canal</label>
<Select v-model="form.channel" :options="CHANNELS" option-label="label" option-value="value" class="w-full" />
</div>
</div>
<div v-else class="flex flex-col gap-1.5">
<label class="text-xs font-semibold">Key</label>
<InputText :model-value="form.key" class="w-full font-mono text-sm" disabled />
</div>
<!-- Domain + Event Type -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="flex flex-col gap-1.5">
<label class="text-xs font-semibold">Domínio</label>
<Select v-model="form.domain" :options="DOMAIN_OPTIONS" option-label="label" option-value="value" class="w-full" />
</div>
<div class="flex flex-col gap-1.5">
<label class="text-xs font-semibold">Tipo de evento</label>
<Select v-model="form.event_type" :options="EVENT_TYPE_OPTIONS" option-label="label" option-value="value" class="w-full" />
</div>
</div>
<!-- Body text -->
<div class="flex flex-col gap-2">
<label class="text-xs font-semibold">Mensagem *</label>
<Textarea ref="bodyTextareaRef" v-model="form.body_text" rows="6" auto-resize class="w-full text-sm" />
<!-- Chips de variáveis -->
<div v-if="availableVars.length" class="flex flex-col gap-1.5">
<span class="text-xs text-[var(--text-color-secondary)]">Inserir variável no cursor:</span>
<div class="flex flex-wrap gap-1.5">
<Button v-for="v in availableVars" :key="v" :label="`{{${v}}}`" size="small" severity="secondary" outlined class="font-mono !text-[0.68rem] !py-1 !px-2" @click="insertVar(v)" />
</div>
</div>
<!-- Variáveis detectadas -->
<div v-if="detectedVars.length" class="flex items-center gap-1.5 flex-wrap">
<span class="text-xs text-[var(--text-color-secondary)]">Variáveis usadas:</span>
<Tag v-for="v in detectedVars" :key="v" :value="v" severity="info" class="!text-[0.6rem] font-mono" />
</div>
</div>
<!-- Ativo -->
<div class="flex items-center gap-2 pt-1">
<ToggleSwitch v-model="form.is_active" inputId="sw-active-tpl" />
<label for="sw-active-tpl" class="text-sm cursor-pointer select-none">Ativo</label>
</div>
</div>
<template #footer>
<div class="flex items-center justify-end gap-2 px-3 py-3">
<Button label="Cancelar" severity="secondary" text class="rounded-full hover:!text-red-500" :disabled="dlg.saving" @click="closeDlg" />
<Button :label="dlg.isNew ? 'Criar template' : 'Salvar'" icon="pi pi-check" class="rounded-full" :loading="dlg.saving" @click="save" />
</div>
</template>
</Dialog>
</div>
</template>
+18 -21
View File
@@ -15,27 +15,24 @@
|--------------------------------------------------------------------------
-->
<template>
<!-- Sentinel -->
<div class="h-px" />
<!-- Hero sticky -->
<div
class="sticky mx-3 md:mx-4 mb-4 z-20 overflow-hidden rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5"
:style="{ top: 'var(--layout-sticky-top, 56px)' }"
>
<div class="absolute inset-0 pointer-events-none overflow-hidden" aria-hidden="true">
<div class="absolute rounded-full blur-[70px] w-72 h-72 -top-16 -right-20 bg-indigo-400/10" />
<div class="absolute rounded-full blur-[70px] w-80 h-80 top-10 -left-24 bg-emerald-400/10" />
<div class="absolute rounded-full blur-[70px] w-72 h-72 -bottom-20 right-24 bg-fuchsia-400/10" />
<!-- Sentinel -->
<div class="h-px" />
<!-- Hero sticky -->
<div class="sticky mx-3 md:mx-4 mb-4 z-20 overflow-hidden rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5" :style="{ top: 'var(--layout-sticky-top, 56px)' }">
<div class="absolute inset-0 pointer-events-none overflow-hidden" aria-hidden="true">
<div class="absolute rounded-full blur-[70px] w-72 h-72 -top-16 -right-20 bg-indigo-400/10" />
<div class="absolute rounded-full blur-[70px] w-80 h-80 top-10 -left-24 bg-emerald-400/10" />
<div class="absolute rounded-full blur-[70px] w-72 h-72 -bottom-20 right-24 bg-fuchsia-400/10" />
</div>
<div class="relative z-10 flex items-center justify-between gap-3 flex-wrap">
<div class="min-w-0">
<div class="text-[1rem] font-bold tracking-tight text-[var(--text-color)]">Em construção</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-0.5">Esta área do Admin SaaS ainda será implementada.</div>
</div>
</div>
</div>
<div class="relative z-10 flex items-center justify-between gap-3 flex-wrap">
<div class="min-w-0">
<div class="text-[1rem] font-bold tracking-tight text-[var(--text-color)]">Em construção</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-0.5">Esta área do Admin SaaS ainda será implementada.</div>
</div>
</div>
</div>
<div class="px-3 md:px-4 pb-8 flex flex-col gap-4">
<!-- conteúdo futuro -->
</div>
<div class="px-3 md:px-4 pb-8 flex flex-col gap-4">
<!-- conteúdo futuro -->
</div>
</template>
@@ -15,230 +15,232 @@
|--------------------------------------------------------------------------
-->
<script setup>
import { ref, onMounted, onBeforeUnmount, computed } from 'vue'
import { supabase } from '@/lib/supabase/client'
import { ref, onMounted, onBeforeUnmount, computed } from 'vue';
import { supabase } from '@/lib/supabase/client';
import Checkbox from 'primevue/checkbox'
import { useToast } from 'primevue/usetoast'
import { useConfirm } from 'primevue/useconfirm'
import Checkbox from 'primevue/checkbox';
import { useToast } from 'primevue/usetoast';
import { useConfirm } from 'primevue/useconfirm';
const toast = useToast()
const confirm = useConfirm()
const toast = useToast();
const confirm = useConfirm();
const loading = ref(false) // carregamento geral (fetch)
const saving = ref(false) // salvando pendências
const hasPending = ref(false)
const loading = ref(false); // carregamento geral (fetch)
const saving = ref(false); // salvando pendências
const hasPending = ref(false);
const plans = ref([])
const features = ref([])
const links = ref([]) // estado atual (reflete UI)
const originalLinks = ref([]) // snapshot do banco (para diff / cancelar)
const plans = ref([]);
const features = ref([]);
const links = ref([]); // estado atual (reflete UI)
const originalLinks = ref([]); // snapshot do banco (para diff / cancelar)
const q = ref('')
const q = ref('');
const targetFilter = ref('all') // 'all' | 'clinic' | 'therapist' | 'supervisor'
const targetFilter = ref('all'); // 'all' | 'clinic' | 'therapist' | 'supervisor'
const targetOptions = [
{ label: 'Todos', value: 'all' },
{ label: 'Clínica', value: 'clinic' },
{ label: 'Terapeuta', value: 'therapist' },
{ label: 'Supervisor', value: 'supervisor' }
]
{ label: 'Todos', value: 'all' },
{ label: 'Clínica', value: 'clinic' },
{ label: 'Terapeuta', value: 'therapist' },
{ label: 'Supervisor', value: 'supervisor' }
];
// trava por célula (evita corrida)
const busySet = ref(new Set())
const busySet = ref(new Set());
function cellKey (planId, featureId) {
return `${planId}::${featureId}`
function cellKey(planId, featureId) {
return `${planId}::${featureId}`;
}
function isBusy (planId, featureId) {
return busySet.value.has(cellKey(planId, featureId))
function isBusy(planId, featureId) {
return busySet.value.has(cellKey(planId, featureId));
}
function setBusy (planId, featureId, v) {
const k = cellKey(planId, featureId)
const next = new Set(busySet.value)
if (v) next.add(k)
else next.delete(k)
busySet.value = next
function setBusy(planId, featureId, v) {
const k = cellKey(planId, featureId);
const next = new Set(busySet.value);
if (v) next.add(k);
else next.delete(k);
busySet.value = next;
}
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')
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');
}
// set de enablement (usa links do estado da UI)
const enabledSet = computed(() => {
const s = new Set()
for (const r of links.value) s.add(`${r.plan_id}::${r.feature_id}`)
return s
})
const s = new Set();
for (const r of links.value) s.add(`${r.plan_id}::${r.feature_id}`);
return s;
});
const originalSet = computed(() => {
const s = new Set()
for (const r of originalLinks.value) s.add(`${r.plan_id}::${r.feature_id}`)
return s
})
const s = new Set();
for (const r of originalLinks.value) s.add(`${r.plan_id}::${r.feature_id}`);
return s;
});
const filteredPlans = computed(() => {
const t = targetFilter.value
if (t === 'all') return plans.value
return plans.value.filter(p => p.target === t)
})
const t = targetFilter.value;
if (t === 'all') return plans.value;
return plans.value.filter((p) => p.target === t);
});
const filteredFeatures = computed(() => {
const term = String(q.value || '').trim().toLowerCase()
if (!term) return features.value
return features.value.filter(f => {
const key = String(f.key || '').toLowerCase()
const desc = String(f.descricao || '').toLowerCase()
const descEn = String(f.description || '').toLowerCase()
return key.includes(term) || desc.includes(term) || descEn.includes(term)
})
})
const term = String(q.value || '')
.trim()
.toLowerCase();
if (!term) return features.value;
return features.value.filter((f) => {
const key = String(f.key || '').toLowerCase();
const desc = String(f.descricao || '').toLowerCase();
const descEn = String(f.description || '').toLowerCase();
return key.includes(term) || desc.includes(term) || descEn.includes(term);
});
});
function targetLabel (t) {
if (t === 'clinic') return 'Clínica'
if (t === 'therapist') return 'Terapeuta'
if (t === 'supervisor') return 'Supervisor'
return '—'
function targetLabel(t) {
if (t === 'clinic') return 'Clínica';
if (t === 'therapist') return 'Terapeuta';
if (t === 'supervisor') return 'Supervisor';
return '—';
}
function targetSeverity (t) {
if (t === 'clinic') return 'info'
if (t === 'therapist') return 'success'
if (t === 'supervisor') return 'warn'
return 'secondary'
function targetSeverity(t) {
if (t === 'clinic') return 'info';
if (t === 'therapist') return 'success';
if (t === 'supervisor') return 'warn';
return 'secondary';
}
function planTitle (p) {
// Mostrar nome do plano; fallback para key
return p?.name || p?.plan_name || p?.public_name || p?.key || 'Plano'
function planTitle(p) {
// Mostrar nome do plano; fallback para key
return p?.name || p?.plan_name || p?.public_name || p?.key || 'Plano';
}
function markDirtyIfNeeded () {
// compara tamanhos e conteúdo (set diff)
const a = enabledSet.value
const b = originalSet.value
function markDirtyIfNeeded() {
// compara tamanhos e conteúdo (set diff)
const a = enabledSet.value;
const b = originalSet.value;
if (a.size !== b.size) {
hasPending.value = true
return
}
for (const k of a) {
if (!b.has(k)) {
hasPending.value = true
return
if (a.size !== b.size) {
hasPending.value = true;
return;
}
}
hasPending.value = false
for (const k of a) {
if (!b.has(k)) {
hasPending.value = true;
return;
}
}
hasPending.value = false;
}
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')
])
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
if (ep) throw ep;
if (ef) throw ef;
if (epf) throw epf;
plans.value = p || []
features.value = f || []
links.value = pf || []
originalLinks.value = pf || []
hasPending.value = false
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || String(e), life: 5000 })
} finally {
loading.value = false
busySet.value = new Set()
}
plans.value = p || [];
features.value = f || [];
links.value = pf || [];
originalLinks.value = pf || [];
hasPending.value = false;
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || String(e), life: 5000 });
} finally {
loading.value = false;
busySet.value = new Set();
}
}
function isEnabled (planId, featureId) {
return enabledSet.value.has(`${planId}::${featureId}`)
function isEnabled(planId, featureId) {
return enabledSet.value.has(`${planId}::${featureId}`);
}
/**
* Toggle agora NÃO salva no banco.
* Apenas altera o estado local (links) e marca como "pendente".
*/
function toggleLocal (planId, featureId, nextValue) {
if (loading.value || saving.value) return
if (isBusy(planId, featureId)) return
function toggleLocal(planId, featureId, nextValue) {
if (loading.value || saving.value) return;
if (isBusy(planId, featureId)) return;
setBusy(planId, featureId, true)
setBusy(planId, featureId, true);
try {
if (nextValue) {
if (!enabledSet.value.has(`${planId}::${featureId}`)) {
links.value = [...links.value, { plan_id: planId, feature_id: featureId }]
}
} else {
links.value = links.value.filter(x => !(x.plan_id === planId && x.feature_id === featureId))
try {
if (nextValue) {
if (!enabledSet.value.has(`${planId}::${featureId}`)) {
links.value = [...links.value, { plan_id: planId, feature_id: featureId }];
}
} else {
links.value = links.value.filter((x) => !(x.plan_id === planId && x.feature_id === featureId));
}
markDirtyIfNeeded();
} finally {
setBusy(planId, featureId, false);
}
markDirtyIfNeeded()
} finally {
setBusy(planId, featureId, false)
}
}
/**
* Ação em massa local (sem salvar)
*/
function setAllForPlanLocal (planId, mode) {
if (!planId) return
if (loading.value || saving.value) return
function setAllForPlanLocal(planId, mode) {
if (!planId) return;
if (loading.value || saving.value) return;
const feats = filteredFeatures.value || []
if (!feats.length) return
const feats = filteredFeatures.value || [];
if (!feats.length) return;
if (mode === 'enable') {
const next = links.value.slice()
const exists = new Set(next.map(x => `${x.plan_id}::${x.feature_id}`))
if (mode === 'enable') {
const next = links.value.slice();
const exists = new Set(next.map((x) => `${x.plan_id}::${x.feature_id}`));
let changed = 0
for (const f of feats) {
const k = `${planId}::${f.id}`
if (!exists.has(k)) {
next.push({ plan_id: planId, feature_id: f.id })
exists.add(k)
changed++
}
let changed = 0;
for (const f of feats) {
const k = `${planId}::${f.id}`;
if (!exists.has(k)) {
next.push({ plan_id: planId, feature_id: f.id });
exists.add(k);
changed++;
}
}
links.value = next;
markDirtyIfNeeded();
if (!changed) {
toast.add({ severity: 'info', summary: 'Sem mudanças', detail: 'Todos os recursos filtrados já estavam marcados.', life: 2200 });
}
return;
}
links.value = next
markDirtyIfNeeded()
if (mode === 'disable') {
const toRemove = new Set(feats.map((f) => `${planId}::${f.id}`));
const before = links.value.length;
links.value = links.value.filter((x) => !toRemove.has(`${x.plan_id}::${x.feature_id}`));
const changed = before - links.value.length;
if (!changed) {
toast.add({ severity: 'info', summary: 'Sem mudanças', detail: 'Todos os recursos filtrados já estavam marcados.', life: 2200 })
markDirtyIfNeeded();
if (!changed) {
toast.add({ severity: 'info', summary: 'Sem mudanças', detail: 'Nenhum recurso filtrado estava marcado para remover.', life: 2200 });
}
}
return
}
if (mode === 'disable') {
const toRemove = new Set(feats.map(f => `${planId}::${f.id}`))
const before = links.value.length
links.value = links.value.filter(x => !toRemove.has(`${x.plan_id}::${x.feature_id}`))
const changed = before - links.value.length
markDirtyIfNeeded()
if (!changed) {
toast.add({ severity: 'info', summary: 'Sem mudanças', detail: 'Nenhum recurso filtrado estava marcado para remover.', life: 2200 })
}
}
}
/**
@@ -246,55 +248,55 @@ function setAllForPlanLocal (planId, mode) {
* (1) confirma ação
* (2) confirma impacto (quantidade)
*/
function confirmMassAction (plan, mode) {
if (!plan?.id) return
function confirmMassAction(plan, mode) {
if (!plan?.id) return;
const feats = filteredFeatures.value || []
const qtd = feats.length
if (!qtd) {
toast.add({ severity: 'info', summary: 'Nada a fazer', detail: 'Não há recursos no filtro atual.', life: 2200 })
return
}
const modeLabel = mode === 'enable' ? 'marcar' : 'desmarcar'
const modeLabel2 = mode === 'enable' ? 'MARCAR' : 'DESMARCAR'
confirm.require({
header: 'Confirmação',
icon: 'pi pi-exclamation-triangle',
message: `Você quer realmente ${modeLabel} TODOS os recursos filtrados para o plano "${planTitle(plan)}"?`,
acceptClass: mode === 'disable' ? 'p-button-danger' : 'p-button-success',
accept: () => {
// importante: deixa o primeiro confirm fechar antes de abrir o segundo
setTimeout(() => {
confirm.require({
header: 'Confirmação final',
icon: 'pi pi-exclamation-triangle',
message: `Isso vai ${modeLabel} ${qtd} recurso(s) (apenas na tela) e ficará como "alterações pendentes". Confirmar ${modeLabel2}?`,
acceptClass: mode === 'disable' ? 'p-button-danger' : 'p-button-success',
accept: () => {
setAllForPlanLocal(plan.id, mode) // aplica local
}
})
}, 0)
const feats = filteredFeatures.value || [];
const qtd = feats.length;
if (!qtd) {
toast.add({ severity: 'info', summary: 'Nada a fazer', detail: 'Não há recursos no filtro atual.', life: 2200 });
return;
}
})
const modeLabel = mode === 'enable' ? 'marcar' : 'desmarcar';
const modeLabel2 = mode === 'enable' ? 'MARCAR' : 'DESMARCAR';
confirm.require({
header: 'Confirmação',
icon: 'pi pi-exclamation-triangle',
message: `Você quer realmente ${modeLabel} TODOS os recursos filtrados para o plano "${planTitle(plan)}"?`,
acceptClass: mode === 'disable' ? 'p-button-danger' : 'p-button-success',
accept: () => {
// importante: deixa o primeiro confirm fechar antes de abrir o segundo
setTimeout(() => {
confirm.require({
header: 'Confirmação final',
icon: 'pi pi-exclamation-triangle',
message: `Isso vai ${modeLabel} ${qtd} recurso(s) (apenas na tela) e ficará como "alterações pendentes". Confirmar ${modeLabel2}?`,
acceptClass: mode === 'disable' ? 'p-button-danger' : 'p-button-success',
accept: () => {
setAllForPlanLocal(plan.id, mode); // aplica local
}
});
}, 0);
}
});
}
function confirmReset () {
if (!hasPending.value || saving.value || loading.value) return
function confirmReset() {
if (!hasPending.value || saving.value || loading.value) return;
confirm.require({
header: 'Descartar alterações?',
icon: 'pi pi-exclamation-triangle',
message: 'Você quer descartar as alterações pendentes e voltar ao estado do banco?',
acceptClass: 'p-button-danger',
accept: () => {
links.value = (originalLinks.value || []).slice()
markDirtyIfNeeded()
toast.add({ severity: 'info', summary: 'Ok', detail: 'Alterações descartadas.', life: 2200 })
}
})
confirm.require({
header: 'Descartar alterações?',
icon: 'pi pi-exclamation-triangle',
message: 'Você quer descartar as alterações pendentes e voltar ao estado do banco?',
acceptClass: 'p-button-danger',
accept: () => {
links.value = (originalLinks.value || []).slice();
markDirtyIfNeeded();
toast.add({ severity: 'info', summary: 'Ok', detail: 'Alterações descartadas.', life: 2200 });
}
});
}
/**
@@ -302,259 +304,236 @@ function confirmReset () {
* - inserts: (UI tem e original não tinha)
* - deletes: (original tinha e UI removeu)
*/
async function saveChanges () {
if (loading.value || saving.value) return
if (!hasPending.value) {
toast.add({ severity: 'info', summary: 'Nada a salvar', detail: 'Não há alterações pendentes.', life: 2200 })
return
}
saving.value = true
try {
const nowSet = enabledSet.value
const wasSet = originalSet.value
const inserts = []
const deletes = []
for (const k of nowSet) {
if (!wasSet.has(k)) {
const [plan_id, feature_id] = k.split('::')
inserts.push({ plan_id, feature_id })
}
async function saveChanges() {
if (loading.value || saving.value) return;
if (!hasPending.value) {
toast.add({ severity: 'info', summary: 'Nada a salvar', detail: 'Não há alterações pendentes.', life: 2200 });
return;
}
for (const k of wasSet) {
if (!nowSet.has(k)) {
const [plan_id, feature_id] = k.split('::')
deletes.push({ plan_id, feature_id })
}
saving.value = true;
try {
const nowSet = enabledSet.value;
const wasSet = originalSet.value;
const inserts = [];
const deletes = [];
for (const k of nowSet) {
if (!wasSet.has(k)) {
const [plan_id, feature_id] = k.split('::');
inserts.push({ plan_id, feature_id });
}
}
for (const k of wasSet) {
if (!nowSet.has(k)) {
const [plan_id, feature_id] = k.split('::');
deletes.push({ plan_id, feature_id });
}
}
// aplica inserts
if (inserts.length) {
const { error } = await supabase.from('plan_features').insert(inserts);
if (error && !isUniqueViolation(error)) throw error;
}
// aplica deletes (em lote por plano)
if (deletes.length) {
const byPlan = new Map();
for (const d of deletes) {
const arr = byPlan.get(d.plan_id) || [];
arr.push(d.feature_id);
byPlan.set(d.plan_id, arr);
}
for (const [planId, featureIds] of byPlan.entries()) {
const { error } = await supabase.from('plan_features').delete().eq('plan_id', planId).in('feature_id', featureIds);
if (error) throw error;
}
}
// snapshot novo
originalLinks.value = links.value.slice();
markDirtyIfNeeded();
toast.add({ severity: 'success', summary: 'Salvo', detail: 'Alterações aplicadas com sucesso.', life: 2600 });
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro ao salvar', detail: e?.message || String(e), life: 5200 });
} finally {
saving.value = false;
}
// aplica inserts
if (inserts.length) {
const { error } = await supabase.from('plan_features').insert(inserts)
if (error && !isUniqueViolation(error)) throw error
}
// aplica deletes (em lote por plano)
if (deletes.length) {
const byPlan = new Map()
for (const d of deletes) {
const arr = byPlan.get(d.plan_id) || []
arr.push(d.feature_id)
byPlan.set(d.plan_id, arr)
}
for (const [planId, featureIds] of byPlan.entries()) {
const { error } = await supabase
.from('plan_features')
.delete()
.eq('plan_id', planId)
.in('feature_id', featureIds)
if (error) throw error
}
}
// snapshot novo
originalLinks.value = links.value.slice()
markDirtyIfNeeded()
toast.add({ severity: 'success', summary: 'Salvo', detail: 'Alterações aplicadas com sucesso.', life: 2600 })
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro ao salvar', detail: e?.message || String(e), life: 5200 })
} finally {
saving.value = false
}
}
// Hero sticky
const heroEl = ref(null)
const heroSentinelRef = ref(null)
const heroMenuRef = ref(null)
const heroStuck = ref(false)
let disconnectStickyObserver = null
const heroEl = ref(null);
const heroSentinelRef = ref(null);
const heroMenuRef = ref(null);
const heroStuck = ref(false);
let disconnectStickyObserver = null;
const heroMenuItems = computed(() => [
{ label: 'Recarregar', icon: 'pi pi-refresh', command: fetchAll, disabled: loading.value || saving.value || hasPending.value },
{ label: 'Descartar', icon: 'pi pi-undo', command: confirmReset, disabled: loading.value || saving.value || !hasPending.value },
{ label: 'Salvar alterações', icon: 'pi pi-save', command: saveChanges, disabled: loading.value || !hasPending.value },
{ separator: true },
{
label: 'Filtrar por público',
items: targetOptions.map(o => ({
label: o.label,
command: () => { targetFilter.value = o.value }
}))
}
])
{ label: 'Recarregar', icon: 'pi pi-refresh', command: fetchAll, disabled: loading.value || saving.value || hasPending.value },
{ label: 'Descartar', icon: 'pi pi-undo', command: confirmReset, disabled: loading.value || saving.value || !hasPending.value },
{ label: 'Salvar alterações', icon: 'pi pi-save', command: saveChanges, disabled: loading.value || !hasPending.value },
{ separator: true },
{
label: 'Filtrar por público',
items: targetOptions.map((o) => ({
label: o.label,
command: () => {
targetFilter.value = o.value;
}
}))
}
]);
onMounted(async () => {
await fetchAll()
await fetchAll();
const sentinel = heroSentinelRef.value
if (sentinel) {
const io = new IntersectionObserver(
([entry]) => { heroStuck.value = !entry.isIntersecting },
{ rootMargin: `${document.querySelector('.l2-main') ? '0px' : '-56px'} 0px 0px 0px` }
)
io.observe(sentinel)
disconnectStickyObserver = () => io.disconnect()
}
})
const sentinel = heroSentinelRef.value;
if (sentinel) {
const io = new IntersectionObserver(
([entry]) => {
heroStuck.value = !entry.isIntersecting;
},
{ rootMargin: `${document.querySelector('.l2-main') ? '0px' : '-56px'} 0px 0px 0px` }
);
io.observe(sentinel);
disconnectStickyObserver = () => io.disconnect();
}
});
onBeforeUnmount(() => {
try { disconnectStickyObserver?.() } catch {}
})
try {
disconnectStickyObserver?.();
} catch {}
});
</script>
<template>
<ConfirmDialog />
<ConfirmDialog />
<!-- Sentinel -->
<div ref="heroSentinelRef" class="h-px" />
<!-- Sentinel -->
<div ref="heroSentinelRef" class="h-px" />
<!-- Hero sticky -->
<div
ref="heroEl"
class="sticky mx-3 md:mx-4 mb-4 z-20 overflow-hidden rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5"
:style="{ top: 'var(--layout-sticky-top, 56px)' }"
>
<div class="absolute inset-0 pointer-events-none overflow-hidden" aria-hidden="true">
<div class="absolute rounded-full blur-[70px] w-72 h-72 -top-16 -right-20 bg-emerald-400/10" />
<div class="absolute rounded-full blur-[70px] w-80 h-80 top-10 -left-24 bg-indigo-400/10" />
</div>
<div class="relative z-10 flex items-center justify-between gap-3 flex-wrap">
<div class="min-w-0">
<div class="text-[1rem] font-bold tracking-tight text-[var(--text-color)]">Controle de Recursos</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-0.5">Defina quais recursos cada plano habilita. Mudanças ficam pendentes até salvar.</div>
</div>
<!-- Ações desktop ( 1200px) -->
<div class="hidden xl:flex items-center gap-2 flex-wrap">
<SelectButton v-model="targetFilter" :options="targetOptions" optionLabel="label" optionValue="value" size="small" :disabled="loading || saving" />
<Button label="Recarregar" icon="pi pi-refresh" severity="secondary" outlined size="small" :loading="loading" :disabled="saving || hasPending" v-tooltip.top="hasPending ? 'Salve ou descarte antes de recarregar.' : ''" @click="fetchAll" />
<Button label="Descartar" icon="pi pi-undo" severity="secondary" outlined size="small" :disabled="loading || saving || !hasPending" @click="confirmReset" />
<Button label="Salvar alterações" icon="pi pi-save" size="small" :loading="saving" :disabled="loading || !hasPending" @click="saveChanges" />
</div>
<!-- Ações mobile (< 1200px) -->
<div class="flex xl:hidden">
<Button
label="Ações"
icon="pi pi-ellipsis-v"
severity="warn"
size="small"
aria-haspopup="true"
aria-controls="matrix_hero_menu"
@click="(e) => heroMenuRef.toggle(e)"
/>
<Menu ref="heroMenuRef" id="matrix_hero_menu" :model="heroMenuItems" :popup="true" />
</div>
</div>
</div>
<!-- content -->
<div class="px-3 md:px-4 pb-8 flex flex-col gap-4">
<!-- Search -->
<div>
<FloatLabel variant="on" class="w-full md:w-[340px]">
<IconField class="w-full">
<InputIcon class="pi pi-search" />
<InputText v-model="q" id="features_search" class="w-full pr-10" variant="filled" :disabled="loading || saving" autocomplete="off" />
</IconField>
<label for="features_search">Filtrar recursos (key ou descrição)</label>
</FloatLabel>
</div>
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-3">
<div class="flex flex-wrap items-center justify-between gap-2">
<div class="flex gap-2 items-center flex-wrap">
<Tag :value="`Planos: ${filteredPlans.length}`" severity="info" icon="pi pi-list" rounded />
<Tag :value="`Recursos: ${filteredFeatures.length}`" severity="success" icon="pi pi-bolt" rounded />
<Tag v-if="hasPending" value="Alterações pendentes" severity="warn" icon="pi pi-clock" rounded />
<!-- Hero sticky -->
<div ref="heroEl" class="sticky mx-3 md:mx-4 mb-4 z-20 overflow-hidden rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5" :style="{ top: 'var(--layout-sticky-top, 56px)' }">
<div class="absolute inset-0 pointer-events-none overflow-hidden" aria-hidden="true">
<div class="absolute rounded-full blur-[70px] w-72 h-72 -top-16 -right-20 bg-emerald-400/10" />
<div class="absolute rounded-full blur-[70px] w-80 h-80 top-10 -left-24 bg-indigo-400/10" />
</div>
<div class="text-[1rem] text-[var(--text-color-secondary)]">
Dica: use a busca para reduzir a lista e aplique ações em massa com confirmação.
<div class="relative z-10 flex items-center justify-between gap-3 flex-wrap">
<div class="min-w-0">
<div class="text-[1rem] font-bold tracking-tight text-[var(--text-color)]">Controle de Recursos</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-0.5">Defina quais recursos cada plano habilita. Mudanças ficam pendentes até salvar.</div>
</div>
<!-- Ações desktop ( 1200px) -->
<div class="hidden xl:flex items-center gap-2 flex-wrap">
<SelectButton v-model="targetFilter" :options="targetOptions" optionLabel="label" optionValue="value" size="small" :disabled="loading || saving" />
<Button
label="Recarregar"
icon="pi pi-refresh"
severity="secondary"
outlined
size="small"
:loading="loading"
:disabled="saving || hasPending"
v-tooltip.top="hasPending ? 'Salve ou descarte antes de recarregar.' : ''"
@click="fetchAll"
/>
<Button label="Descartar" icon="pi pi-undo" severity="secondary" outlined size="small" :disabled="loading || saving || !hasPending" @click="confirmReset" />
<Button label="Salvar alterações" icon="pi pi-save" size="small" :loading="saving" :disabled="loading || !hasPending" @click="saveChanges" />
</div>
<!-- Ações mobile (< 1200px) -->
<div class="flex xl:hidden">
<Button label="Ações" icon="pi pi-ellipsis-v" severity="warn" size="small" aria-haspopup="true" aria-controls="matrix_hero_menu" @click="(e) => heroMenuRef.toggle(e)" />
<Menu ref="heroMenuRef" id="matrix_hero_menu" :model="heroMenuItems" :popup="true" />
</div>
</div>
</div>
</div>
<Divider class="my-0" />
<!-- content -->
<div class="px-3 md:px-4 pb-8 flex flex-col gap-4">
<!-- Search -->
<div>
<FloatLabel variant="on" class="w-full md:w-[340px]">
<IconField class="w-full">
<InputIcon class="pi pi-search" />
<InputText v-model="q" id="features_search" class="w-full pr-10" variant="filled" :disabled="loading || saving" autocomplete="off" />
</IconField>
<label for="features_search">Filtrar recursos (key ou descrição)</label>
</FloatLabel>
</div>
<DataTable
:value="filteredFeatures"
dataKey="id"
:loading="loading"
stripedRows
responsiveLayout="scroll"
:scrollable="true"
scrollHeight="70vh"
>
<Column header="" frozen style="min-width: 28rem">
<template #body="{ data }">
<div class="flex flex-col">
<span class="font-medium">{{ data.key }}</span>
<div class="text-[1rem] text-[var(--text-color-secondary)] leading-snug mt-1">
{{ data.descricao || data.description || '—' }}
</div>
</div>
</template>
</Column>
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-3">
<div class="flex flex-wrap items-center justify-between gap-2">
<div class="flex gap-2 items-center flex-wrap">
<Tag :value="`Planos: ${filteredPlans.length}`" severity="info" icon="pi pi-list" rounded />
<Tag :value="`Recursos: ${filteredFeatures.length}`" severity="success" icon="pi pi-bolt" rounded />
<Tag v-if="hasPending" value="Alterações pendentes" severity="warn" icon="pi pi-clock" rounded />
</div>
<Column
v-for="p in filteredPlans"
:key="p.id"
:style="{ minWidth: '14rem' }"
>
<template #header>
<div class="flex flex-col items-center gap-2 w-full text-center">
<div class="font-semibold truncate w-full" :title="planTitle(p)">
{{ planTitle(p) }}
<div class="text-[1rem] text-[var(--text-color-secondary)]">Dica: use a busca para reduzir a lista e aplique ações em massa com confirmação.</div>
</div>
<div class="flex items-center justify-center gap-1 flex-wrap">
<div class="text-[1rem] text-[var(--text-color-secondary)] truncate" :title="p.key">{{ p.key }}</div>
<Tag :value="targetLabel(p.target)" :severity="targetSeverity(p.target)" rounded />
</div>
<div class="flex gap-2 justify-center">
<Button
icon="pi pi-check"
severity="success"
size="small"
outlined
:disabled="loading || saving"
v-tooltip.top="'Marcar todas as features filtradas (fica pendente até salvar)'"
@click="confirmMassAction(p, 'enable')"
/>
<Button
icon="pi pi-times"
severity="danger"
size="small"
outlined
:disabled="loading || saving"
v-tooltip.top="'Desmarcar todas as features filtradas (fica pendente até salvar)'"
@click="confirmMassAction(p, 'disable')"
/>
</div>
</div>
</template>
</div>
<template #body="{ data }">
<div class="flex justify-center">
<Checkbox
:binary="true"
:modelValue="isEnabled(p.id, data.id)"
:disabled="loading || saving || isBusy(p.id, data.id)"
:aria-label="`Alternar ${p.key} -> ${data.key}`"
@update:modelValue="(val) => toggleLocal(p.id, data.id, val)"
/>
</div>
</template>
</Column>
</DataTable>
</div>
</template>
<Divider class="my-0" />
<DataTable :value="filteredFeatures" dataKey="id" :loading="loading" stripedRows responsiveLayout="scroll" :scrollable="true" scrollHeight="70vh">
<Column header="" frozen style="min-width: 28rem">
<template #body="{ data }">
<div class="flex flex-col">
<span class="font-medium">{{ data.key }}</span>
<div class="text-[1rem] text-[var(--text-color-secondary)] leading-snug mt-1">
{{ data.descricao || data.description || '—' }}
</div>
</div>
</template>
</Column>
<Column v-for="p in filteredPlans" :key="p.id" :style="{ minWidth: '14rem' }">
<template #header>
<div class="flex flex-col items-center gap-2 w-full text-center">
<div class="font-semibold truncate w-full" :title="planTitle(p)">
{{ planTitle(p) }}
</div>
<div class="flex items-center justify-center gap-1 flex-wrap">
<div class="text-[1rem] text-[var(--text-color-secondary)] truncate" :title="p.key">{{ p.key }}</div>
<Tag :value="targetLabel(p.target)" :severity="targetSeverity(p.target)" rounded />
</div>
<div class="flex gap-2 justify-center">
<Button icon="pi pi-check" severity="success" size="small" outlined :disabled="loading || saving" v-tooltip.top="'Marcar todas as features filtradas (fica pendente até salvar)'" @click="confirmMassAction(p, 'enable')" />
<Button
icon="pi pi-times"
severity="danger"
size="small"
outlined
:disabled="loading || saving"
v-tooltip.top="'Desmarcar todas as features filtradas (fica pendente até salvar)'"
@click="confirmMassAction(p, 'disable')"
/>
</div>
</div>
</template>
<template #body="{ data }">
<div class="flex justify-center">
<Checkbox
:binary="true"
:modelValue="isEnabled(p.id, data.id)"
:disabled="loading || saving || isBusy(p.id, data.id)"
:aria-label="`Alternar ${p.key} -> ${data.key}`"
@update:modelValue="(val) => toggleLocal(p.id, data.id, val)"
/>
</div>
</template>
</Column>
</DataTable>
</div>
</template>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -15,527 +15,462 @@
|--------------------------------------------------------------------------
-->
<script setup>
import { ref, onMounted, onBeforeUnmount, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { supabase } from '@/lib/supabase/client'
import { ref, onMounted, onBeforeUnmount, computed } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { supabase } from '@/lib/supabase/client';
import { useToast } from 'primevue/usetoast'
import { useToast } from 'primevue/usetoast';
const route = useRoute()
const router = useRouter()
const toast = useToast()
const route = useRoute();
const router = useRouter();
const toast = useToast();
const loading = ref(false)
const isFetching = ref(false)
const loading = ref(false);
const isFetching = ref(false);
const events = ref([])
const plans = ref([])
const profiles = ref([])
const events = ref([]);
const plans = ref([]);
const profiles = ref([]);
const q = ref('')
const q = ref('');
// filtro por tipo de owner (clinic/therapist/all)
const ownerType = ref('all') // 'all' | 'clinic' | 'therapist'
const ownerType = ref('all'); // 'all' | 'clinic' | 'therapist'
const ownerTypeOptions = [
{ label: 'Todos', value: 'all' },
{ label: 'Clínica', value: 'clinic' },
{ label: 'Terapeuta', value: 'therapist' }
]
{ label: 'Todos', value: 'all' },
{ label: 'Clínica', value: 'clinic' },
{ label: 'Terapeuta', value: 'therapist' }
];
const isFocused = computed(() => {
return typeof route.query?.q === 'string' && route.query.q.trim().length > 0
})
return typeof route.query?.q === 'string' && route.query.q.trim().length > 0;
});
// ---------- helpers: plano ----------
const planKeyById = computed(() => {
const m = new Map()
for (const p of plans.value) m.set(p.id, p.key)
return m
})
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
function planKey(planId) {
if (!planId) return '—';
return planKeyById.value.get(planId) || planId;
}
// ---------- helpers: datas ----------
function formatWhen (iso) {
if (!iso) return '—'
const d = new Date(iso)
if (Number.isNaN(d.getTime())) return String(iso)
return d.toLocaleString('pt-BR')
function formatWhen(iso) {
if (!iso) return '—';
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return String(iso);
return d.toLocaleString('pt-BR');
}
// ---------- helpers: owner ----------
function normalizeOwnerType (t) {
const k = String(t || '').toLowerCase()
if (k === 'clinic' || k === 'therapist') return k
return 'unknown'
function normalizeOwnerType(t) {
const k = String(t || '').toLowerCase();
if (k === 'clinic' || k === 'therapist') return k;
return 'unknown';
}
function ownerKeyFromEvent (ev) {
const t = normalizeOwnerType(ev.owner_type)
const r = String(ev.owner_ref || '').trim()
if ((t === 'clinic' || t === 'therapist') && r) return `${t}:${r}`
const legacy = String(ev.owner_id || '').trim()
return legacy || 'unknown'
function ownerKeyFromEvent(ev) {
const t = normalizeOwnerType(ev.owner_type);
const r = String(ev.owner_ref || '').trim();
if ((t === 'clinic' || t === 'therapist') && r) return `${t}:${r}`;
const legacy = String(ev.owner_id || '').trim();
return legacy || 'unknown';
}
function parseOwnerKey (raw) {
const s = String(raw || '').trim()
if (!s) return { kind: 'unknown', id: null, raw: '' }
function parseOwnerKey(raw) {
const s = String(raw || '').trim();
if (!s) return { kind: 'unknown', id: null, raw: '' };
const m = s.match(/^(clinic|therapist)\s*:\s*([0-9a-fA-F-]{8,})$/)
if (m) return { kind: m[1].toLowerCase(), id: m[2], raw: s }
const m = s.match(/^(clinic|therapist)\s*:\s*([0-9a-fA-F-]{8,})$/);
if (m) return { kind: m[1].toLowerCase(), id: m[2], raw: s };
return { kind: 'unknown', id: s, raw: s }
return { kind: 'unknown', id: s, raw: s };
}
function ownerTagLabel (t) {
if (t === 'clinic') return 'Clínica'
if (t === 'therapist') return 'Terapeuta'
return '—'
function ownerTagLabel(t) {
if (t === 'clinic') return 'Clínica';
if (t === 'therapist') return 'Terapeuta';
return '—';
}
function ownerTagSeverity (t) {
if (t === 'clinic') return 'info'
if (t === 'therapist') return 'success'
return 'secondary'
function ownerTagSeverity(t) {
if (t === 'clinic') return 'info';
if (t === 'therapist') return 'success';
return 'secondary';
}
// ---------- helpers: profiles ----------
const profileById = computed(() => {
const m = new Map()
for (const p of profiles.value) m.set(p.id, p)
return m
})
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
function displayUser(userId) {
if (!userId) return '—';
const p = profileById.value.get(userId);
if (!p) return userId;
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
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
if (name && email) return `${name} <${email}>`;
if (name) return name;
if (email) return email;
return userId;
}
function displayOwner (ev) {
const t = normalizeOwnerType(ev.owner_type)
const ref = String(ev.owner_ref || '').trim()
if (t === 'therapist' && ref) return displayUser(ref)
return ownerKeyFromEvent(ev)
function displayOwner(ev) {
const t = normalizeOwnerType(ev.owner_type);
const ref = String(ev.owner_ref || '').trim();
if (t === 'therapist' && ref) return displayUser(ref);
return ownerKeyFromEvent(ev);
}
// ---------- evento ----------
function eventLabel (t) {
const k = String(t || '').toLowerCase()
if (k === 'plan_changed') return 'Plano alterado'
if (k === 'canceled') return 'Cancelada'
if (k === 'reactivated') return 'Reativada'
return t || '—'
function eventLabel(t) {
const k = String(t || '').toLowerCase();
if (k === 'plan_changed') return 'Plano alterado';
if (k === 'canceled') return 'Cancelada';
if (k === 'reactivated') return 'Reativada';
return t || '—';
}
function eventSeverity (t) {
const k = String(t || '').toLowerCase()
if (k === 'plan_changed') return 'info'
if (k === 'canceled') return 'danger'
if (k === 'reactivated') return 'success'
return 'secondary'
function eventSeverity(t) {
const k = String(t || '').toLowerCase();
if (k === 'plan_changed') return 'info';
if (k === 'canceled') return 'danger';
if (k === 'reactivated') return 'success';
return 'secondary';
}
// ---------- navegação ----------
function goToSubscriptions (ev) {
const key = ownerKeyFromEvent(ev)
if (!key || key === 'unknown') return
router.push({ path: '/saas/subscriptions', query: { q: key } })
function goToSubscriptions(ev) {
const key = ownerKeyFromEvent(ev);
if (!key || key === 'unknown') return;
router.push({ path: '/saas/subscriptions', query: { q: key } });
}
// ---------- fetch ----------
async function fetchAll () {
if (isFetching.value) return
isFetching.value = true
loading.value = true
async function fetchAll() {
if (isFetching.value) return;
isFetching.value = true;
loading.value = true;
try {
const [{ data: p, error: ep }, { data: e, error: ee }] = await Promise.all([
supabase.from('plans').select('id,key').order('key', { ascending: true }),
supabase
.from('subscription_events')
.select('*')
.order('created_at', { ascending: false })
.limit(500)
])
try {
const [{ data: p, error: ep }, { data: e, error: ee }] = await Promise.all([
supabase.from('plans').select('id,key').order('key', { ascending: true }),
supabase.from('subscription_events').select('*').order('created_at', { ascending: false }).limit(500)
]);
if (ep) throw ep
if (ee) throw ee
if (ep) throw ep;
if (ee) throw ee;
plans.value = p || []
events.value = e || []
plans.value = p || [];
events.value = e || [];
// profiles: created_by e owners therapist
const ids = new Set()
for (const ev of (events.value || [])) {
const createdBy = String(ev.created_by || '').trim()
if (createdBy) ids.add(createdBy)
// profiles: created_by e owners therapist
const ids = new Set();
for (const ev of events.value || []) {
const createdBy = String(ev.created_by || '').trim();
if (createdBy) ids.add(createdBy);
const t = normalizeOwnerType(ev.owner_type)
const ref = String(ev.owner_ref || '').trim()
if (t === 'therapist' && ref) ids.add(ref)
const t = normalizeOwnerType(ev.owner_type);
const ref = String(ev.owner_ref || '').trim();
if (t === 'therapist' && ref) ids.add(ref);
}
if (ids.size) {
const { data: pr, error: epr } = await supabase.from('profiles').select('id,nome,name,full_name,display_name,username,email,email_principal,user_email').in('id', Array.from(ids));
profiles.value = epr ? [] : pr || [];
} else {
profiles.value = [];
}
} catch (err) {
toast.add({ severity: 'error', summary: 'Erro', detail: err.message || String(err), life: 5000 });
} finally {
loading.value = false;
isFetching.value = false;
}
if (ids.size) {
const { data: pr, error: epr } = await supabase
.from('profiles')
.select('id,nome,name,full_name,display_name,username,email,email_principal,user_email')
.in('id', Array.from(ids))
profiles.value = epr ? [] : (pr || [])
} else {
profiles.value = []
}
} catch (err) {
toast.add({ severity: 'error', summary: 'Erro', detail: err.message || String(err), life: 5000 })
} finally {
loading.value = false
isFetching.value = false
}
}
// ---------- filtro ----------
const filtered = computed(() => {
const term = String(q.value || '').trim().toLowerCase()
let list = events.value || []
const term = String(q.value || '')
.trim()
.toLowerCase();
let list = events.value || [];
if (ownerType.value !== 'all') {
list = list.filter(ev => normalizeOwnerType(ev.owner_type) === ownerType.value)
}
// foco por query (?q=clinic:... / therapist:...)
const focus = String(route.query?.q || '').trim()
if (focus) {
const parsed = parseOwnerKey(focus)
if (parsed.kind === 'clinic' || parsed.kind === 'therapist') {
list = list.filter(ev =>
normalizeOwnerType(ev.owner_type) === parsed.kind &&
String(ev.owner_ref || '') === String(parsed.id || '')
)
} else {
const f = focus.toLowerCase()
list = list.filter(ev =>
ownerKeyFromEvent(ev).toLowerCase().includes(f) ||
String(ev.owner_id || '').toLowerCase().includes(f)
)
if (ownerType.value !== 'all') {
list = list.filter((ev) => normalizeOwnerType(ev.owner_type) === ownerType.value);
}
}
if (!term) return list
// foco por query (?q=clinic:... / therapist:...)
const focus = String(route.query?.q || '').trim();
if (focus) {
const parsed = parseOwnerKey(focus);
if (parsed.kind === 'clinic' || parsed.kind === 'therapist') {
list = list.filter((ev) => normalizeOwnerType(ev.owner_type) === parsed.kind && String(ev.owner_ref || '') === String(parsed.id || ''));
} else {
const f = focus.toLowerCase();
list = list.filter(
(ev) =>
ownerKeyFromEvent(ev).toLowerCase().includes(f) ||
String(ev.owner_id || '')
.toLowerCase()
.includes(f)
);
}
}
return list.filter(ev => {
const oldKey = planKey(ev.old_plan_id)
const newKey = planKey(ev.new_plan_id)
if (!term) return list;
const ok = ownerKeyFromEvent(ev)
const ownerDisp = String(displayOwner(ev) || '')
const subId = String(ev.subscription_id || '')
const eventType = String(ev.event_type || '')
const reason = String(ev.reason || '')
return list.filter((ev) => {
const oldKey = planKey(ev.old_plan_id);
const newKey = planKey(ev.new_plan_id);
const meta = ev.metadata ? JSON.stringify(ev.metadata) : ''
const ok = ownerKeyFromEvent(ev);
const ownerDisp = String(displayOwner(ev) || '');
const subId = String(ev.subscription_id || '');
const eventType = String(ev.event_type || '');
const reason = String(ev.reason || '');
const by = String(ev.created_by || '')
const byDisp = String(displayUser(by) || '')
const meta = ev.metadata ? JSON.stringify(ev.metadata) : '';
return (
ok.toLowerCase().includes(term) ||
ownerDisp.toLowerCase().includes(term) ||
subId.toLowerCase().includes(term) ||
eventType.toLowerCase().includes(term) ||
reason.toLowerCase().includes(term) ||
meta.toLowerCase().includes(term) ||
String(oldKey || '').toLowerCase().includes(term) ||
String(newKey || '').toLowerCase().includes(term) ||
by.toLowerCase().includes(term) ||
byDisp.toLowerCase().includes(term)
)
})
})
const by = String(ev.created_by || '');
const byDisp = String(displayUser(by) || '');
const totalCount = computed(() => (filtered.value || []).length)
const changedCount = computed(() => (filtered.value || []).filter(x => String(x?.event_type || '').toLowerCase() === 'plan_changed').length)
return (
ok.toLowerCase().includes(term) ||
ownerDisp.toLowerCase().includes(term) ||
subId.toLowerCase().includes(term) ||
eventType.toLowerCase().includes(term) ||
reason.toLowerCase().includes(term) ||
meta.toLowerCase().includes(term) ||
String(oldKey || '')
.toLowerCase()
.includes(term) ||
String(newKey || '')
.toLowerCase()
.includes(term) ||
by.toLowerCase().includes(term) ||
byDisp.toLowerCase().includes(term)
);
});
});
function clearFocus () {
router.push({ path: route.path, query: {} })
const totalCount = computed(() => (filtered.value || []).length);
const changedCount = computed(() => (filtered.value || []).filter((x) => String(x?.event_type || '').toLowerCase() === 'plan_changed').length);
function clearFocus() {
router.push({ path: route.path, query: {} });
}
// -------------------------
// Hero sticky
// -------------------------
const heroRef = ref(null)
const sentinelRef = ref(null)
const heroStuck = ref(false)
let heroObserver = null
const mobileMenuRef = ref(null)
const heroRef = ref(null);
const sentinelRef = ref(null);
const heroStuck = ref(false);
let heroObserver = null;
const mobileMenuRef = ref(null);
const heroMenuItems = computed(() => [
{
label: 'Voltar para assinaturas',
icon: 'pi pi-arrow-left',
command: () => router.push('/saas/subscriptions'),
disabled: loading.value
},
{
label: 'Recarregar',
icon: 'pi pi-refresh',
command: fetchAll,
disabled: loading.value
},
{ separator: true },
{
label: 'Filtros',
items: ownerTypeOptions.map(o => ({
label: o.label,
icon: ownerType.value === o.value ? 'pi pi-check' : 'pi pi-circle',
command: () => { ownerType.value = o.value }
}))
}
])
{
label: 'Voltar para assinaturas',
icon: 'pi pi-arrow-left',
command: () => router.push('/saas/subscriptions'),
disabled: loading.value
},
{
label: 'Recarregar',
icon: 'pi pi-refresh',
command: fetchAll,
disabled: loading.value
},
{ separator: true },
{
label: 'Filtros',
items: ownerTypeOptions.map((o) => ({
label: o.label,
icon: ownerType.value === o.value ? 'pi pi-check' : 'pi pi-circle',
command: () => {
ownerType.value = o.value;
}
}))
}
]);
onMounted(async () => {
const initialQ = route.query?.q
if (typeof initialQ === 'string' && initialQ.trim()) {
q.value = initialQ.trim()
const parsed = parseOwnerKey(initialQ)
if (parsed.kind === 'clinic') ownerType.value = 'clinic'
if (parsed.kind === 'therapist') ownerType.value = 'therapist'
}
await fetchAll()
const initialQ = route.query?.q;
if (typeof initialQ === 'string' && initialQ.trim()) {
q.value = initialQ.trim();
const parsed = parseOwnerKey(initialQ);
if (parsed.kind === 'clinic') ownerType.value = 'clinic';
if (parsed.kind === 'therapist') ownerType.value = 'therapist';
}
await fetchAll();
if (sentinelRef.value) {
heroObserver = new IntersectionObserver(
([entry]) => { heroStuck.value = !entry.isIntersecting },
{ rootMargin: `${document.querySelector('.l2-main') ? '0px' : '-56px'} 0px 0px 0px`, threshold: 0 }
)
heroObserver.observe(sentinelRef.value)
}
})
if (sentinelRef.value) {
heroObserver = new IntersectionObserver(
([entry]) => {
heroStuck.value = !entry.isIntersecting;
},
{ rootMargin: `${document.querySelector('.l2-main') ? '0px' : '-56px'} 0px 0px 0px`, threshold: 0 }
);
heroObserver.observe(sentinelRef.value);
}
});
onBeforeUnmount(() => {
heroObserver?.disconnect()
})
heroObserver?.disconnect();
});
</script>
<template>
<!-- Sentinel -->
<div ref="sentinelRef" class="h-px" />
<!-- Sentinel -->
<div ref="sentinelRef" class="h-px" />
<!-- Hero sticky -->
<div
ref="heroRef"
class="sticky mx-3 md:mx-4 mb-4 z-20 overflow-hidden rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5"
:style="{ top: 'var(--layout-sticky-top, 56px)' }"
>
<div class="absolute inset-0 pointer-events-none overflow-hidden" aria-hidden="true">
<div class="absolute rounded-full blur-[70px] w-72 h-72 -top-16 -right-20 bg-amber-400/10" />
<div class="absolute rounded-full blur-[70px] w-80 h-80 top-10 -left-24 bg-orange-400/10" />
</div>
<div class="relative z-10 flex items-center justify-between gap-3 flex-wrap">
<div class="min-w-0">
<div class="text-[1rem] font-bold tracking-tight text-[var(--text-color)]">Histórico de assinaturas</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-0.5">Auditoria read-only das mudanças de plano e status.</div>
</div>
<!-- Ações desktop ( 1200px) -->
<div class="hidden xl:flex items-center gap-2 flex-wrap">
<Button
label="Voltar para assinaturas"
icon="pi pi-arrow-left"
severity="secondary"
outlined
size="small"
:disabled="loading"
@click="router.push('/saas/subscriptions')"
/>
<SelectButton
v-model="ownerType"
:options="ownerTypeOptions"
optionLabel="label"
optionValue="value"
size="small"
:disabled="loading"
/>
<Button
label="Recarregar"
icon="pi pi-refresh"
severity="secondary"
outlined
size="small"
:loading="loading"
@click="fetchAll"
/>
</div>
<!-- Ações mobile (< 1200px) -->
<div class="flex xl:hidden">
<Button
label="Ações"
icon="pi pi-ellipsis-v"
severity="warn"
size="small"
@click="(e) => mobileMenuRef.toggle(e)"
/>
<Menu ref="mobileMenuRef" :model="heroMenuItems" popup />
</div>
</div>
</div>
<!-- content -->
<div class="px-3 md:px-4 pb-8 flex flex-col gap-4">
<!-- Card foco -->
<div
v-if="isFocused"
class="overflow-hidden rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)]"
>
<div class="flex flex-wrap items-center justify-between gap-3 p-5">
<div class="min-w-0">
<div class="text-[1rem] font-semibold leading-none text-[var(--text-color)]">Eventos em foco</div>
<div class="mt-1 flex flex-wrap items-center gap-2">
<Tag value="Filtro ativo" severity="warning" rounded />
<div class="text-[1rem] text-[var(--text-color-secondary)] break-all">
{{ route.query.q }}
</div>
</div>
<!-- Hero sticky -->
<div ref="heroRef" class="sticky mx-3 md:mx-4 mb-4 z-20 overflow-hidden rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5" :style="{ top: 'var(--layout-sticky-top, 56px)' }">
<div class="absolute inset-0 pointer-events-none overflow-hidden" aria-hidden="true">
<div class="absolute rounded-full blur-[70px] w-72 h-72 -top-16 -right-20 bg-amber-400/10" />
<div class="absolute rounded-full blur-[70px] w-80 h-80 top-10 -left-24 bg-orange-400/10" />
</div>
<Button
label="Limpar filtro"
icon="pi pi-times"
severity="danger"
class="font-semibold"
raised
:disabled="loading"
@click="clearFocus"
/>
</div>
</div>
<!-- busca -->
<div>
<FloatLabel variant="on" class="w-full">
<IconField class="w-full">
<InputIcon class="pi pi-search" />
<InputText
v-model="q"
id="events_search"
class="w-full pr-10"
variant="filled"
:disabled="loading"
/>
</IconField>
<label for="events_search">Buscar owner, subscription, plano, tipo, usuário</label>
</FloatLabel>
</div>
<DataTable
:value="filtered"
dataKey="id"
:loading="loading"
stripedRows
responsiveLayout="scroll"
:rowHover="true"
paginator
:rows="15"
:rowsPerPageOptions="[10,15,25,50]"
currentPageReportTemplate="{first}{last} de {totalRecords}"
paginatorTemplate="RowsPerPageDropdown FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink CurrentPageReport"
>
<Column header="Quando" style="min-width: 14rem">
<template #body="{ data }">
{{ formatWhen(data.created_at) }}
</template>
</Column>
<!-- Tipo = tipo do OWNER (clínica/terapeuta) -->
<Column header="Owner tipo" style="width: 11rem">
<template #body="{ data }">
<Tag
:value="ownerTagLabel(normalizeOwnerType(data.owner_type))"
:severity="ownerTagSeverity(normalizeOwnerType(data.owner_type))"
rounded
/>
</template>
</Column>
<Column header="Owner" style="min-width: 24rem">
<template #body="{ data }">
<div class="flex flex-col">
<span class="font-medium">{{ ownerKeyFromEvent(data) }}</span>
<div class="text-[1rem] text-[var(--text-color-secondary)]">
{{ displayOwner(data) }}
<div class="relative z-10 flex items-center justify-between gap-3 flex-wrap">
<div class="min-w-0">
<div class="text-[1rem] font-bold tracking-tight text-[var(--text-color)]">Histórico de assinaturas</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-0.5">Auditoria read-only das mudanças de plano e status.</div>
</div>
</div>
</template>
</Column>
<!-- Evento = event_type (plan_changed/canceled/reactivated) -->
<Column header="Evento" style="min-width: 12rem">
<template #body="{ data }">
<Tag :value="eventLabel(data.event_type)" :severity="eventSeverity(data.event_type)" />
</template>
</Column>
<!-- Ações desktop ( 1200px) -->
<div class="hidden xl:flex items-center gap-2 flex-wrap">
<Button label="Voltar para assinaturas" icon="pi pi-arrow-left" severity="secondary" outlined size="small" :disabled="loading" @click="router.push('/saas/subscriptions')" />
<SelectButton v-model="ownerType" :options="ownerTypeOptions" optionLabel="label" optionValue="value" size="small" :disabled="loading" />
<Button label="Recarregar" icon="pi pi-refresh" severity="secondary" outlined size="small" :loading="loading" @click="fetchAll" />
</div>
<Column header="De → Para" style="min-width: 18rem">
<template #body="{ data }">
<div class="flex items-center gap-2 flex-wrap">
<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">
<template #body="{ data }">
<span class="font-mono text-[1rem]">{{ data.subscription_id }}</span>
</template>
</Column>
<Column header="Alterado por" style="min-width: 22rem">
<template #body="{ data }">
{{ displayUser(data.created_by) }}
</template>
</Column>
<Column header="Ações" style="width: 12rem">
<template #body="{ data }">
<Button
label="Ver assinatura"
icon="pi pi-external-link"
size="small"
severity="secondary"
outlined
class="w-full md:w-auto"
:disabled="ownerKeyFromEvent(data) === 'unknown'"
@click="goToSubscriptions(data)"
/>
</template>
</Column>
<template #empty>
<div class="p-4 text-[var(--text-color-secondary)]">
Nenhum evento encontrado com os filtros atuais.
<!-- Ações mobile (< 1200px) -->
<div class="flex xl:hidden">
<Button label="Ações" icon="pi pi-ellipsis-v" severity="warn" size="small" @click="(e) => mobileMenuRef.toggle(e)" />
<Menu ref="mobileMenuRef" :model="heroMenuItems" popup />
</div>
</div>
</template>
</DataTable>
<div class="text-[1rem] text-[var(--text-color-secondary)]">
Mostrando até 500 eventos mais recentes.
</div>
</div>
<!-- content -->
<div class="px-3 md:px-4 pb-8 flex flex-col gap-4">
<!-- Card foco -->
<div v-if="isFocused" class="overflow-hidden rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)]">
<div class="flex flex-wrap items-center justify-between gap-3 p-5">
<div class="min-w-0">
<div class="text-[1rem] font-semibold leading-none text-[var(--text-color)]">Eventos em foco</div>
<div class="mt-1 flex flex-wrap items-center gap-2">
<Tag value="Filtro ativo" severity="warning" rounded />
<div class="text-[1rem] text-[var(--text-color-secondary)] break-all">
{{ route.query.q }}
</div>
</div>
</div>
<Button label="Limpar filtro" icon="pi pi-times" severity="danger" class="font-semibold" raised :disabled="loading" @click="clearFocus" />
</div>
</div>
<!-- busca -->
<div>
<FloatLabel variant="on" class="w-full">
<IconField class="w-full">
<InputIcon class="pi pi-search" />
<InputText v-model="q" id="events_search" class="w-full pr-10" variant="filled" :disabled="loading" />
</IconField>
<label for="events_search">Buscar owner, subscription, plano, tipo, usuário</label>
</FloatLabel>
</div>
<DataTable
:value="filtered"
dataKey="id"
:loading="loading"
stripedRows
responsiveLayout="scroll"
:rowHover="true"
paginator
:rows="15"
:rowsPerPageOptions="[10, 15, 25, 50]"
currentPageReportTemplate="{first}{last} de {totalRecords}"
paginatorTemplate="RowsPerPageDropdown FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink CurrentPageReport"
>
<Column header="Quando" style="min-width: 14rem">
<template #body="{ data }">
{{ formatWhen(data.created_at) }}
</template>
</Column>
<!-- Tipo = tipo do OWNER (clínica/terapeuta) -->
<Column header="Owner tipo" style="width: 11rem">
<template #body="{ data }">
<Tag :value="ownerTagLabel(normalizeOwnerType(data.owner_type))" :severity="ownerTagSeverity(normalizeOwnerType(data.owner_type))" rounded />
</template>
</Column>
<Column header="Owner" style="min-width: 24rem">
<template #body="{ data }">
<div class="flex flex-col">
<span class="font-medium">{{ ownerKeyFromEvent(data) }}</span>
<div class="text-[1rem] text-[var(--text-color-secondary)]">
{{ displayOwner(data) }}
</div>
</div>
</template>
</Column>
<!-- Evento = event_type (plan_changed/canceled/reactivated) -->
<Column header="Evento" style="min-width: 12rem">
<template #body="{ data }">
<Tag :value="eventLabel(data.event_type)" :severity="eventSeverity(data.event_type)" />
</template>
</Column>
<Column header="De → Para" style="min-width: 18rem">
<template #body="{ data }">
<div class="flex items-center gap-2 flex-wrap">
<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">
<template #body="{ data }">
<span class="font-mono text-[1rem]">{{ data.subscription_id }}</span>
</template>
</Column>
<Column header="Alterado por" style="min-width: 22rem">
<template #body="{ data }">
{{ displayUser(data.created_by) }}
</template>
</Column>
<Column header="Ações" style="width: 12rem">
<template #body="{ data }">
<Button label="Ver assinatura" icon="pi pi-external-link" size="small" severity="secondary" outlined class="w-full md:w-auto" :disabled="ownerKeyFromEvent(data) === 'unknown'" @click="goToSubscriptions(data)" />
</template>
</Column>
<template #empty>
<div class="p-4 text-[var(--text-color-secondary)]">Nenhum evento encontrado com os filtros atuais.</div>
</template>
</DataTable>
<div class="text-[1rem] text-[var(--text-color-secondary)]">Mostrando até 500 eventos mais recentes.</div>
</div>
</template>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+407 -481
View File
@@ -15,558 +15,484 @@
|--------------------------------------------------------------------------
-->
<script setup>
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { useToast } from 'primevue/usetoast'
import { supabase } from '@/lib/supabase/client'
import {
createSupportSession,
listActiveSupportSessions,
listSessionHistory,
revokeSupportSession,
buildSupportUrl,
} from '@/support/supportSessionService'
import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
import { useToast } from 'primevue/usetoast';
import { supabase } from '@/lib/supabase/client';
import { createSupportSession, listActiveSupportSessions, listSessionHistory, revokeSupportSession, buildSupportUrl } from '@/support/supportSessionService';
const TAG = '[SaasSupportPage]'
const toast = useToast()
const TAG = '[SaasSupportPage]';
const toast = useToast();
// Tabs
const activeTab = ref(0)
const activeTab = ref(0);
// Estado Nova Sessão
const selectedTenantId = ref(null)
const ttlMinutes = ref(60)
const sessionNote = ref('')
const creating = ref(false)
const generatedUrl = ref(null)
const generatedData = ref(null)
const selectedTenantId = ref(null);
const ttlMinutes = ref(60);
const sessionNote = ref('');
const creating = ref(false);
const generatedUrl = ref(null);
const generatedData = ref(null);
// Estado Listas
const loadingTenants = ref(false)
const loadingSessions = ref(false)
const loadingHistory = ref(false)
const revokingToken = ref(null)
const loadingTenants = ref(false);
const loadingSessions = ref(false);
const loadingHistory = ref(false);
const revokingToken = ref(null);
const tenants = ref([])
const tenantMap = ref({})
const activeSessions = ref([])
const sessionHistory = ref([])
const tenants = ref([]);
const tenantMap = ref({});
const activeSessions = ref([]);
const sessionHistory = ref([]);
// TTL Options
const ttlOptions = [
{ label: '15 minutos', value: 15 },
{ label: '30 minutos', value: 30 },
{ label: '1 hora', value: 60 },
{ label: '2 horas', value: 120 },
]
{ label: '15 minutos', value: 15 },
{ label: '30 minutos', value: 30 },
{ label: '1 hora', value: 60 },
{ label: '2 horas', value: 120 }
];
// Countdown tick
const _now = ref(Date.now())
let _tickTimer = null
const _now = ref(Date.now());
let _tickTimer = null;
function startTick () {
if (_tickTimer) return
_tickTimer = setInterval(() => { _now.value = Date.now() }, 10_000)
function startTick() {
if (_tickTimer) return;
_tickTimer = setInterval(() => {
_now.value = Date.now();
}, 10_000);
}
onBeforeUnmount(() => { if (_tickTimer) clearInterval(_tickTimer) })
onBeforeUnmount(() => {
if (_tickTimer) clearInterval(_tickTimer);
});
// Computed
const expiresLabel = computed(() => {
if (!generatedData.value?.expires_at) return ''
return new Date(generatedData.value.expires_at).toLocaleString('pt-BR')
})
if (!generatedData.value?.expires_at) return '';
return new Date(generatedData.value.expires_at).toLocaleString('pt-BR');
});
const tokenPreview = computed(() => {
if (!generatedData.value?.token) return ''
const t = generatedData.value.token
return `${t.slice(0, 8)}${t.slice(-8)}`
})
if (!generatedData.value?.token) return '';
const t = generatedData.value.token;
return `${t.slice(0, 8)}${t.slice(-8)}`;
});
const activeSessionCount = computed(() => activeSessions.value.length)
const activeSessionCount = computed(() => activeSessions.value.length);
// Lifecycle
onMounted(async () => {
console.log(`${TAG} montado`)
await loadTenants()
await loadActiveSessions()
startTick()
})
console.log(`${TAG} montado`);
await loadTenants();
await loadActiveSessions();
startTick();
});
// Tenants
async function loadTenants () {
loadingTenants.value = true
console.log(`${TAG} loadTenants`)
try {
const { data, error } = await supabase
.from('tenants')
.select('id, name, kind')
.order('name', { ascending: true })
async function loadTenants() {
loadingTenants.value = true;
console.log(`${TAG} loadTenants`);
try {
const { data, error } = await supabase.from('tenants').select('id, name, kind').order('name', { ascending: true });
if (error) throw error
if (error) throw error;
const list = data || []
console.log(`${TAG} ${list.length} tenant(s) carregado(s)`)
const list = data || [];
console.log(`${TAG} ${list.length} tenant(s) carregado(s)`);
tenantMap.value = Object.fromEntries(list.map(t => [t.id, t.name || t.id]))
tenants.value = list.map(t => ({
value: t.id,
label: `${t.name} (${t.kind ?? 'tenant'})`,
}))
} catch (e) {
console.error(`${TAG} loadTenants ERRO`, e)
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message, life: 4000 })
} finally {
loadingTenants.value = false
}
tenantMap.value = Object.fromEntries(list.map((t) => [t.id, t.name || t.id]));
tenants.value = list.map((t) => ({
value: t.id,
label: `${t.name} (${t.kind ?? 'tenant'})`
}));
} catch (e) {
console.error(`${TAG} loadTenants ERRO`, e);
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message, life: 4000 });
} finally {
loadingTenants.value = false;
}
}
// Sessões ativas
async function loadActiveSessions () {
loadingSessions.value = true
console.log(`${TAG} loadActiveSessions`)
try {
activeSessions.value = await listActiveSupportSessions()
console.log(`${TAG} ${activeSessions.value.length} sessão(ões) ativa(s)`)
} catch (e) {
console.error(`${TAG} loadActiveSessions ERRO`, e)
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message, life: 4000 })
} finally {
loadingSessions.value = false
}
async function loadActiveSessions() {
loadingSessions.value = true;
console.log(`${TAG} loadActiveSessions`);
try {
activeSessions.value = await listActiveSupportSessions();
console.log(`${TAG} ${activeSessions.value.length} sessão(ões) ativa(s)`);
} catch (e) {
console.error(`${TAG} loadActiveSessions ERRO`, e);
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message, life: 4000 });
} finally {
loadingSessions.value = false;
}
}
// Histórico
async function loadHistory () {
if (loadingHistory.value) return
loadingHistory.value = true
console.log(`${TAG} loadHistory`)
try {
sessionHistory.value = await listSessionHistory(100)
console.log(`${TAG} histórico: ${sessionHistory.value.length} registro(s)`)
} catch (e) {
console.error(`${TAG} loadHistory ERRO`, e)
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message, life: 4000 })
} finally {
loadingHistory.value = false
}
async function loadHistory() {
if (loadingHistory.value) return;
loadingHistory.value = true;
console.log(`${TAG} loadHistory`);
try {
sessionHistory.value = await listSessionHistory(100);
console.log(`${TAG} histórico: ${sessionHistory.value.length} registro(s)`);
} catch (e) {
console.error(`${TAG} loadHistory ERRO`, e);
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message, life: 4000 });
} finally {
loadingHistory.value = false;
}
}
// Criar sessão
async function handleCreate () {
if (!selectedTenantId.value) return
creating.value = true
generatedUrl.value = null
generatedData.value = null
async function handleCreate() {
if (!selectedTenantId.value) return;
creating.value = true;
generatedUrl.value = null;
generatedData.value = null;
console.log(`${TAG} handleCreate`, { tenantId: selectedTenantId.value, ttlMinutes: ttlMinutes.value, note: sessionNote.value || '(sem nota)' })
console.log(`${TAG} handleCreate`, { tenantId: selectedTenantId.value, ttlMinutes: ttlMinutes.value, note: sessionNote.value || '(sem nota)' });
try {
const result = await createSupportSession(selectedTenantId.value, ttlMinutes.value, sessionNote.value)
generatedData.value = result
generatedUrl.value = buildSupportUrl(result.token)
try {
const result = await createSupportSession(selectedTenantId.value, ttlMinutes.value, sessionNote.value);
generatedData.value = result;
generatedUrl.value = buildSupportUrl(result.token);
console.log(`${TAG} sessão criada com sucesso`, { token: `${result.token.slice(0,8)}`, expires_at: result.expires_at })
console.log(`${TAG} sessão criada com sucesso`, { token: `${result.token.slice(0, 8)}`, expires_at: result.expires_at });
toast.add({ severity: 'success', summary: 'Sessão criada', detail: 'URL de suporte gerada com sucesso.', life: 4000 })
await loadActiveSessions()
} catch (e) {
console.error(`${TAG} handleCreate ERRO`, e)
toast.add({ severity: 'error', summary: 'Erro ao criar sessão', detail: e?.message, life: 5000 })
} finally {
creating.value = false
}
toast.add({ severity: 'success', summary: 'Sessão criada', detail: 'URL de suporte gerada com sucesso.', life: 4000 });
await loadActiveSessions();
} catch (e) {
console.error(`${TAG} handleCreate ERRO`, e);
toast.add({ severity: 'error', summary: 'Erro ao criar sessão', detail: e?.message, life: 5000 });
} finally {
creating.value = false;
}
}
// Revogar
async function handleRevoke (token) {
revokingToken.value = token
console.log(`${TAG} handleRevoke token=${token.slice(0, 8)}`)
try {
await revokeSupportSession(token)
toast.add({ severity: 'success', summary: 'Sessão revogada', life: 3000 })
if (generatedData.value?.token === token) {
generatedUrl.value = null
generatedData.value = null
async function handleRevoke(token) {
revokingToken.value = token;
console.log(`${TAG} handleRevoke token=${token.slice(0, 8)}`);
try {
await revokeSupportSession(token);
toast.add({ severity: 'success', summary: 'Sessão revogada', life: 3000 });
if (generatedData.value?.token === token) {
generatedUrl.value = null;
generatedData.value = null;
}
await loadActiveSessions();
if (sessionHistory.value.length) await loadHistory();
} catch (e) {
console.error(`${TAG} handleRevoke ERRO`, e);
toast.add({ severity: 'error', summary: 'Erro ao revogar', detail: e?.message, life: 4000 });
} finally {
revokingToken.value = null;
}
await loadActiveSessions()
if (sessionHistory.value.length) await loadHistory()
} catch (e) {
console.error(`${TAG} handleRevoke ERRO`, e)
toast.add({ severity: 'error', summary: 'Erro ao revogar', detail: e?.message, life: 4000 })
} finally {
revokingToken.value = null
}
}
// Copiar
function copyUrl (url) {
if (!url) return
navigator.clipboard.writeText(url)
console.log(`${TAG} URL copiada`)
toast.add({ severity: 'info', summary: 'Copiado!', detail: 'URL copiada para a área de transferência.', life: 2000 })
function copyUrl(url) {
if (!url) return;
navigator.clipboard.writeText(url);
console.log(`${TAG} URL copiada`);
toast.add({ severity: 'info', summary: 'Copiado!', detail: 'URL copiada para a área de transferência.', life: 2000 });
}
// Tab change
function onTabChange (e) {
const idx = e.index ?? e
activeTab.value = idx
console.log(`${TAG} tab mudou para ${idx}`)
if (idx === 2 && sessionHistory.value.length === 0) loadHistory()
function onTabChange(e) {
const idx = e.index ?? e;
activeTab.value = idx;
console.log(`${TAG} tab mudou para ${idx}`);
if (idx === 2 && sessionHistory.value.length === 0) loadHistory();
}
// Helpers
function tenantName (id) {
return tenantMap.value[id] || id
function tenantName(id) {
return tenantMap.value[id] || id;
}
function formatDate (iso) {
if (!iso) return '-'
return new Date(iso).toLocaleString('pt-BR')
function formatDate(iso) {
if (!iso) return '-';
return new Date(iso).toLocaleString('pt-BR');
}
function remainingLabel (iso) {
_now.value // dependência reativa
if (!iso) return '-'
const diff = new Date(iso) - Date.now()
if (diff <= 0) return 'Expirada'
const min = Math.floor(diff / 60000)
const h = Math.floor(min / 60)
const m = min % 60
if (h > 0) return `${h}h ${m}min`
return `${min} min`
function remainingLabel(iso) {
_now.value; // dependência reativa
if (!iso) return '-';
const diff = new Date(iso) - Date.now();
if (diff <= 0) return 'Expirada';
const min = Math.floor(diff / 60000);
const h = Math.floor(min / 60);
const m = min % 60;
if (h > 0) return `${h}h ${m}min`;
return `${min} min`;
}
function isExpiringSoon (iso) {
if (!iso) return false
const diff = (new Date(iso) - Date.now()) / 60000
return diff > 0 && diff < 15
function isExpiringSoon(iso) {
if (!iso) return false;
const diff = (new Date(iso) - Date.now()) / 60000;
return diff > 0 && diff < 15;
}
function sessionStatusSeverity (session) {
if (session._expired) return 'danger'
if (isExpiringSoon(session.expires_at)) return 'warning'
return 'success'
function sessionStatusSeverity(session) {
if (session._expired) return 'danger';
if (isExpiringSoon(session.expires_at)) return 'warning';
return 'success';
}
function sessionStatusLabel (session) {
if (session._expired) return 'Expirada'
if (isExpiringSoon(session.expires_at)) return 'Expirando'
return 'Ativa'
function sessionStatusLabel(session) {
if (session._expired) return 'Expirada';
if (isExpiringSoon(session.expires_at)) return 'Expirando';
return 'Ativa';
}
</script>
<template>
<!-- Sentinel -->
<div class="h-px" />
<!-- Sentinel -->
<div class="h-px" />
<!-- Hero sticky -->
<div
class="sticky mx-3 md:mx-4 mb-4 z-20 overflow-hidden rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5"
:style="{ top: 'var(--layout-sticky-top, 56px)' }"
>
<div class="absolute inset-0 pointer-events-none overflow-hidden" aria-hidden="true">
<div class="absolute rounded-full blur-[70px] w-72 h-72 -top-16 -right-20 bg-orange-400/10" />
<div class="absolute rounded-full blur-[70px] w-80 h-80 top-10 -left-24 bg-fuchsia-400/10" />
<!-- Hero sticky -->
<div class="sticky mx-3 md:mx-4 mb-4 z-20 overflow-hidden rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5" :style="{ top: 'var(--layout-sticky-top, 56px)' }">
<div class="absolute inset-0 pointer-events-none overflow-hidden" aria-hidden="true">
<div class="absolute rounded-full blur-[70px] w-72 h-72 -top-16 -right-20 bg-orange-400/10" />
<div class="absolute rounded-full blur-[70px] w-80 h-80 top-10 -left-24 bg-fuchsia-400/10" />
</div>
<div class="relative z-10 flex items-center justify-between gap-3 flex-wrap">
<div class="flex items-center gap-3 min-w-0">
<div class="flex items-center justify-center w-10 h-10 rounded-md border border-[var(--surface-border)] bg-[var(--surface-ground)] flex-shrink-0">
<i class="pi pi-headphones text-[var(--text-color)]" />
</div>
<div class="min-w-0">
<div class="text-[1rem] font-bold tracking-tight text-[var(--text-color)]">Suporte Técnico</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-0.5">Gere e gerencie links seguros de acesso em modo debug</div>
</div>
</div>
<Tag v-if="activeSessionCount > 0" :value="`${activeSessionCount} sessão${activeSessionCount > 1 ? 'ões' : ''} ativa${activeSessionCount > 1 ? 's' : ''}`" severity="warning" />
</div>
</div>
<div class="relative z-10 flex items-center justify-between gap-3 flex-wrap">
<div class="flex items-center gap-3 min-w-0">
<div class="flex items-center justify-center w-10 h-10 rounded-md border border-[var(--surface-border)] bg-[var(--surface-ground)] flex-shrink-0">
<i class="pi pi-headphones text-[var(--text-color)]" />
</div>
<div class="min-w-0">
<div class="text-[1rem] font-bold tracking-tight text-[var(--text-color)]">Suporte Técnico</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-0.5">Gere e gerencie links seguros de acesso em modo debug</div>
</div>
</div>
<Tag
v-if="activeSessionCount > 0"
:value="`${activeSessionCount} sessão${activeSessionCount > 1 ? 'ões' : ''} ativa${activeSessionCount > 1 ? 's' : ''}`"
severity="warning"
/>
<!-- content -->
<div class="px-3 md:px-4 pb-8 flex flex-col gap-4">
<!-- Tabs -->
<TabView @tab-change="onTabChange">
<!-- Tab 0: Nova Sessão -->
<TabPanel header="Nova Sessão">
<div class="grid grid-cols-1 lg:grid-cols-2 gap-5 pt-2">
<!-- Formulário -->
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5">
<div class="text-[1rem] font-semibold flex items-center gap-2 mb-4">
<i class="pi pi-plus-circle text-[var(--primary-color)]" />
Configurar acesso de suporte
</div>
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-1">
<label class="text-[1rem] font-medium">Selecionar Cliente (Tenant)</label>
<Select
v-model="selectedTenantId"
:options="tenants"
option-label="label"
option-value="value"
placeholder="Buscar tenant..."
filter
:loading="loadingTenants"
class="w-full"
empty-filter-message="Nenhum tenant encontrado"
/>
</div>
<div class="flex flex-col gap-1">
<label class="text-[1rem] font-medium">Duração do Acesso</label>
<Select v-model="ttlMinutes" :options="ttlOptions" option-label="label" option-value="value" class="w-full" />
</div>
<div class="flex flex-col gap-1">
<label class="text-[1rem] font-medium">
Nota / Motivo
<span class="text-[var(--text-color-secondary)] font-normal">(opcional)</span>
</label>
<InputText v-model="sessionNote" placeholder="Ex: cliente reportou erro na agenda de recorrência" class="w-full" />
</div>
<Button label="Ativar Modo Suporte" icon="pi pi-shield" severity="warning" :loading="creating" :disabled="!selectedTenantId" class="w-full" @click="handleCreate" />
</div>
</div>
<!-- URL Gerada -->
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5">
<div class="text-[1rem] font-semibold flex items-center gap-2 mb-4">
<i class="pi pi-link text-[var(--primary-color)]" />
URL Gerada
</div>
<div v-if="generatedUrl" class="flex flex-col gap-4">
<div class="flex flex-col gap-1">
<label class="text-[1rem] font-medium">Link de Acesso</label>
<div class="flex gap-2">
<InputText :value="generatedUrl" readonly class="flex-1 font-mono text-[1rem]" />
<Button icon="pi pi-copy" severity="secondary" outlined v-tooltip.top="'Copiar URL'" @click="copyUrl(generatedUrl)" />
</div>
</div>
<div class="flex items-center gap-2 text-[1rem]">
<i class="pi pi-clock text-orange-500" />
<span class="text-[var(--text-color-secondary)]">Expira em:</span>
<strong>{{ expiresLabel }}</strong>
</div>
<div class="flex items-center gap-2 text-[1rem] text-[var(--text-color-secondary)] font-mono">
<i class="pi pi-key" />
<span>{{ tokenPreview }}</span>
</div>
<div v-if="sessionNote" class="flex items-start gap-2 text-[1rem] text-[var(--text-color-secondary)]">
<i class="pi pi-comment mt-0.5 flex-shrink-0" />
<span class="italic">{{ sessionNote }}</span>
</div>
<Message severity="info" :closable="false">
<div class="text-[1rem]">Envie este link ao terapeuta ou acesse diretamente para monitorar os logs da agenda em tempo real.</div>
</Message>
</div>
<div v-else class="flex flex-col items-center justify-center py-12 text-[var(--text-color-secondary)] gap-3">
<i class="pi pi-shield text-4xl opacity-25" />
<div class="text-[1rem]">Nenhuma sessão gerada ainda</div>
</div>
</div>
</div>
</TabPanel>
<!-- Tab 1: Sessões Ativas -->
<TabPanel>
<template #header>
<span class="flex items-center gap-2">
Sessões Ativas
<Badge v-if="activeSessionCount > 0" :value="String(activeSessionCount)" severity="warning" />
</span>
</template>
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5 mt-2">
<div class="flex items-center justify-between mb-4">
<div class="text-[1rem] font-semibold flex items-center gap-2">
<i class="pi pi-circle-fill text-green-500" />
Sessões em vigor
</div>
<Button icon="pi pi-refresh" severity="secondary" outlined size="small" :loading="loadingSessions" label="Atualizar" @click="loadActiveSessions" />
</div>
<DataTable :value="activeSessions" :loading="loadingSessions" empty-message="Nenhuma sessão ativa no momento" size="small" striped-rows>
<Column header="Tenant" style="min-width: 200px">
<template #body="{ data }">
<div class="flex flex-col gap-0.5">
<span class="font-medium text-[1rem]">{{ tenantName(data.tenant_id) }}</span>
<span class="font-mono text-[1rem] text-[var(--text-color-secondary)]">{{ data.tenant_id }}</span>
</div>
</template>
</Column>
<Column header="Token">
<template #body="{ data }">
<span class="font-mono text-[1rem] text-[var(--text-color-secondary)]">{{ data.token.slice(0, 12) }}</span>
</template>
</Column>
<Column header="Restante" style="min-width: 100px">
<template #body="{ data }">
<span :class="isExpiringSoon(data.expires_at) ? 'text-orange-500 font-semibold' : ''">
{{ remainingLabel(data.expires_at) }}
</span>
</template>
</Column>
<Column header="Criada em">
<template #body="{ data }">
<span class="text-[1rem] text-[var(--text-color-secondary)]">{{ formatDate(data.created_at) }}</span>
</template>
</Column>
<Column header="Nota">
<template #body="{ data }">
<span v-if="data._note" class="text-[1rem] italic text-[var(--text-color-secondary)]">{{ data._note }}</span>
<span v-else class="text-[1rem] text-[var(--text-color-secondary)]"></span>
</template>
</Column>
<Column header="Ações" style="width: 110px">
<template #body="{ data }">
<div class="flex gap-2">
<Button icon="pi pi-copy" size="small" severity="secondary" outlined v-tooltip.top="'Copiar URL'" @click="copyUrl(buildSupportUrl(data.token))" />
<Button icon="pi pi-trash" size="small" severity="danger" outlined v-tooltip.top="'Revogar'" :loading="revokingToken === data.token" @click="handleRevoke(data.token)" />
</div>
</template>
</Column>
</DataTable>
</div>
</TabPanel>
<!-- Tab 2: Histórico -->
<TabPanel header="Histórico">
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5 mt-2">
<div class="flex items-center justify-between mb-4">
<div class="text-[1rem] font-semibold flex items-center gap-2">
<i class="pi pi-history text-[var(--primary-color)]" />
Últimas 100 sessões
</div>
<Button icon="pi pi-refresh" severity="secondary" outlined size="small" :loading="loadingHistory" label="Carregar" @click="loadHistory" />
</div>
<DataTable :value="sessionHistory" :loading="loadingHistory" empty-message="Clique em Carregar para ver o histórico" size="small" striped-rows paginator :rows="20">
<Column header="Status" style="width: 110px">
<template #body="{ data }">
<Tag :value="sessionStatusLabel(data)" :severity="sessionStatusSeverity(data)" />
</template>
</Column>
<Column header="Tenant" style="min-width: 180px">
<template #body="{ data }">
<div class="flex flex-col gap-0.5">
<span class="font-medium text-[1rem]">{{ tenantName(data.tenant_id) }}</span>
<span class="font-mono text-[1rem] text-[var(--text-color-secondary)]">{{ data.tenant_id }}</span>
</div>
</template>
</Column>
<Column header="Token">
<template #body="{ data }">
<span class="font-mono text-[1rem] text-[var(--text-color-secondary)]">{{ data.token.slice(0, 12) }}</span>
</template>
</Column>
<Column header="Criada em" sortable field="created_at">
<template #body="{ data }">
<span class="text-[1rem] text-[var(--text-color-secondary)]">{{ formatDate(data.created_at) }}</span>
</template>
</Column>
<Column header="Expirava em">
<template #body="{ data }">
<span class="text-[1rem] text-[var(--text-color-secondary)]">{{ formatDate(data.expires_at) }}</span>
</template>
</Column>
<Column header="Nota">
<template #body="{ data }">
<span v-if="data._note" class="text-[1rem] italic text-[var(--text-color-secondary)]">{{ data._note }}</span>
<span v-else class="text-[1rem] text-[var(--text-color-secondary)]"></span>
</template>
</Column>
<Column header="" style="width: 60px">
<template #body="{ data }">
<Button v-if="!data._expired" icon="pi pi-trash" size="small" severity="danger" outlined v-tooltip.top="'Revogar'" :loading="revokingToken === data.token" @click="handleRevoke(data.token)" />
</template>
</Column>
</DataTable>
</div>
</TabPanel>
</TabView>
</div>
</div>
<!-- content -->
<div class="px-3 md:px-4 pb-8 flex flex-col gap-4">
<!-- Tabs -->
<TabView @tab-change="onTabChange">
<!-- Tab 0: Nova Sessão -->
<TabPanel header="Nova Sessão">
<div class="grid grid-cols-1 lg:grid-cols-2 gap-5 pt-2">
<!-- Formulário -->
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5">
<div class="text-[1rem] font-semibold flex items-center gap-2 mb-4">
<i class="pi pi-plus-circle text-[var(--primary-color)]" />
Configurar acesso de suporte
</div>
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-1">
<label class="text-[1rem] font-medium">Selecionar Cliente (Tenant)</label>
<Select
v-model="selectedTenantId"
:options="tenants"
option-label="label"
option-value="value"
placeholder="Buscar tenant..."
filter
:loading="loadingTenants"
class="w-full"
empty-filter-message="Nenhum tenant encontrado"
/>
</div>
<div class="flex flex-col gap-1">
<label class="text-[1rem] font-medium">Duração do Acesso</label>
<Select
v-model="ttlMinutes"
:options="ttlOptions"
option-label="label"
option-value="value"
class="w-full"
/>
</div>
<div class="flex flex-col gap-1">
<label class="text-[1rem] font-medium">
Nota / Motivo
<span class="text-[var(--text-color-secondary)] font-normal">(opcional)</span>
</label>
<InputText
v-model="sessionNote"
placeholder="Ex: cliente reportou erro na agenda de recorrência"
class="w-full"
/>
</div>
<Button
label="Ativar Modo Suporte"
icon="pi pi-shield"
severity="warning"
:loading="creating"
:disabled="!selectedTenantId"
class="w-full"
@click="handleCreate"
/>
</div>
</div>
<!-- URL Gerada -->
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5">
<div class="text-[1rem] font-semibold flex items-center gap-2 mb-4">
<i class="pi pi-link text-[var(--primary-color)]" />
URL Gerada
</div>
<div v-if="generatedUrl" class="flex flex-col gap-4">
<div class="flex flex-col gap-1">
<label class="text-[1rem] font-medium">Link de Acesso</label>
<div class="flex gap-2">
<InputText :value="generatedUrl" readonly class="flex-1 font-mono text-[1rem]" />
<Button icon="pi pi-copy" severity="secondary" outlined v-tooltip.top="'Copiar URL'" @click="copyUrl(generatedUrl)" />
</div>
</div>
<div class="flex items-center gap-2 text-[1rem]">
<i class="pi pi-clock text-orange-500" />
<span class="text-[var(--text-color-secondary)]">Expira em:</span>
<strong>{{ expiresLabel }}</strong>
</div>
<div class="flex items-center gap-2 text-[1rem] text-[var(--text-color-secondary)] font-mono">
<i class="pi pi-key" />
<span>{{ tokenPreview }}</span>
</div>
<div v-if="sessionNote" class="flex items-start gap-2 text-[1rem] text-[var(--text-color-secondary)]">
<i class="pi pi-comment mt-0.5 flex-shrink-0" />
<span class="italic">{{ sessionNote }}</span>
</div>
<Message severity="info" :closable="false">
<div class="text-[1rem]">Envie este link ao terapeuta ou acesse diretamente para monitorar os logs da agenda em tempo real.</div>
</Message>
</div>
<div v-else class="flex flex-col items-center justify-center py-12 text-[var(--text-color-secondary)] gap-3">
<i class="pi pi-shield text-4xl opacity-25" />
<div class="text-[1rem]">Nenhuma sessão gerada ainda</div>
</div>
</div>
</div>
</TabPanel>
<!-- Tab 1: Sessões Ativas -->
<TabPanel>
<template #header>
<span class="flex items-center gap-2">
Sessões Ativas
<Badge v-if="activeSessionCount > 0" :value="String(activeSessionCount)" severity="warning" />
</span>
</template>
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5 mt-2">
<div class="flex items-center justify-between mb-4">
<div class="text-[1rem] font-semibold flex items-center gap-2">
<i class="pi pi-circle-fill text-green-500" />
Sessões em vigor
</div>
<Button
icon="pi pi-refresh"
severity="secondary"
outlined
size="small"
:loading="loadingSessions"
label="Atualizar"
@click="loadActiveSessions"
/>
</div>
<DataTable
:value="activeSessions"
:loading="loadingSessions"
empty-message="Nenhuma sessão ativa no momento"
size="small"
striped-rows
>
<Column header="Tenant" style="min-width: 200px">
<template #body="{ data }">
<div class="flex flex-col gap-0.5">
<span class="font-medium text-[1rem]">{{ tenantName(data.tenant_id) }}</span>
<span class="font-mono text-[1rem] text-[var(--text-color-secondary)]">{{ data.tenant_id }}</span>
</div>
</template>
</Column>
<Column header="Token">
<template #body="{ data }">
<span class="font-mono text-[1rem] text-[var(--text-color-secondary)]">{{ data.token.slice(0, 12) }}</span>
</template>
</Column>
<Column header="Restante" style="min-width: 100px">
<template #body="{ data }">
<span :class="isExpiringSoon(data.expires_at) ? 'text-orange-500 font-semibold' : ''">
{{ remainingLabel(data.expires_at) }}
</span>
</template>
</Column>
<Column header="Criada em">
<template #body="{ data }">
<span class="text-[1rem] text-[var(--text-color-secondary)]">{{ formatDate(data.created_at) }}</span>
</template>
</Column>
<Column header="Nota">
<template #body="{ data }">
<span v-if="data._note" class="text-[1rem] italic text-[var(--text-color-secondary)]">{{ data._note }}</span>
<span v-else class="text-[1rem] text-[var(--text-color-secondary)]"></span>
</template>
</Column>
<Column header="Ações" style="width: 110px">
<template #body="{ data }">
<div class="flex gap-2">
<Button icon="pi pi-copy" size="small" severity="secondary" outlined v-tooltip.top="'Copiar URL'" @click="copyUrl(buildSupportUrl(data.token))" />
<Button icon="pi pi-trash" size="small" severity="danger" outlined v-tooltip.top="'Revogar'" :loading="revokingToken === data.token" @click="handleRevoke(data.token)" />
</div>
</template>
</Column>
</DataTable>
</div>
</TabPanel>
<!-- Tab 2: Histórico -->
<TabPanel header="Histórico">
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5 mt-2">
<div class="flex items-center justify-between mb-4">
<div class="text-[1rem] font-semibold flex items-center gap-2">
<i class="pi pi-history text-[var(--primary-color)]" />
Últimas 100 sessões
</div>
<Button
icon="pi pi-refresh"
severity="secondary"
outlined
size="small"
:loading="loadingHistory"
label="Carregar"
@click="loadHistory"
/>
</div>
<DataTable
:value="sessionHistory"
:loading="loadingHistory"
empty-message="Clique em Carregar para ver o histórico"
size="small"
striped-rows
paginator
:rows="20"
>
<Column header="Status" style="width: 110px">
<template #body="{ data }">
<Tag :value="sessionStatusLabel(data)" :severity="sessionStatusSeverity(data)" />
</template>
</Column>
<Column header="Tenant" style="min-width: 180px">
<template #body="{ data }">
<div class="flex flex-col gap-0.5">
<span class="font-medium text-[1rem]">{{ tenantName(data.tenant_id) }}</span>
<span class="font-mono text-[1rem] text-[var(--text-color-secondary)]">{{ data.tenant_id }}</span>
</div>
</template>
</Column>
<Column header="Token">
<template #body="{ data }">
<span class="font-mono text-[1rem] text-[var(--text-color-secondary)]">{{ data.token.slice(0, 12) }}</span>
</template>
</Column>
<Column header="Criada em" sortable field="created_at">
<template #body="{ data }">
<span class="text-[1rem] text-[var(--text-color-secondary)]">{{ formatDate(data.created_at) }}</span>
</template>
</Column>
<Column header="Expirava em">
<template #body="{ data }">
<span class="text-[1rem] text-[var(--text-color-secondary)]">{{ formatDate(data.expires_at) }}</span>
</template>
</Column>
<Column header="Nota">
<template #body="{ data }">
<span v-if="data._note" class="text-[1rem] italic text-[var(--text-color-secondary)]">{{ data._note }}</span>
<span v-else class="text-[1rem] text-[var(--text-color-secondary)]"></span>
</template>
</Column>
<Column header="" style="width: 60px">
<template #body="{ data }">
<Button
v-if="!data._expired"
icon="pi pi-trash"
size="small"
severity="danger"
outlined
v-tooltip.top="'Revogar'"
:loading="revokingToken === data.token"
@click="handleRevoke(data.token)"
/>
</template>
</Column>
</DataTable>
</div>
</TabPanel>
</TabView>
</div>
</template>
+605
View File
@@ -0,0 +1,605 @@
<!--
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/views/pages/saas/SaasWhatsappPage.vue
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
-->
<script setup>
import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
import { useToast } from 'primevue/usetoast';
import { useConfirm } from 'primevue/useconfirm';
import { supabase } from '@/lib/supabase/client';
const toast = useToast();
const confirm = useConfirm();
// Tenants
const tenants = ref([]);
const tenantMap = ref({});
const loadingTenants = ref(false);
const selectedTenantId = ref(null);
async function loadTenants() {
loadingTenants.value = true;
try {
const { data, error } = await supabase.from('tenants').select('id, name, kind').order('name', { ascending: true });
if (error) throw error;
const list = data || [];
tenantMap.value = Object.fromEntries(list.map((t) => [t.id, t.name || t.id]));
tenants.value = list.map((t) => ({
value: t.id,
label: `${t.name} (${t.kind ?? 'tenant'})`
}));
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro ao carregar tenants', detail: e?.message, life: 4000 });
} finally {
loadingTenants.value = false;
}
}
// Canal WhatsApp do tenant selecionado
const channel = ref(null);
const credentials = ref({ api_url: '', api_key: '', instance_name: '' });
const hasCredentials = ref(false);
const loadingChannel = ref(false);
const savingCredentials = ref(false);
// Status de conexão
const connectionStatus = ref(null);
const connectionLoading = ref(false);
// QR Code
const qrDialog = ref(false);
const qrCodeBase64 = ref(null);
const qrLoading = ref(false);
const qrCountdown = ref(0);
let qrTimer = null;
let isMounted = true;
const connectionTag = computed(() => {
if (!hasCredentials.value) return { label: 'Não configurado', severity: 'secondary' };
if (channel.value && !channel.value.is_active) return { label: 'Desativado', severity: 'secondary' };
if (connectionLoading.value) return { label: 'Verificando...', severity: 'secondary' };
switch (connectionStatus.value) {
case 'open':
return { label: 'Conectado', severity: 'success' };
case 'connecting':
return { label: 'Conectando...', severity: 'warn' };
default:
return { label: 'Desconectado', severity: 'danger' };
}
});
const selectedTenantName = computed(() => {
if (!selectedTenantId.value) return '';
return tenantMap.value[selectedTenantId.value] || selectedTenantId.value;
});
// Ao selecionar um tenant, carregar canal
async function onTenantSelect() {
resetChannelState();
if (!selectedTenantId.value) return;
await loadChannel();
}
function resetChannelState() {
channel.value = null;
credentials.value = { api_url: '', api_key: '', instance_name: '' };
hasCredentials.value = false;
connectionStatus.value = null;
clearQrTimer();
qrCodeBase64.value = null;
}
async function loadChannel() {
if (!selectedTenantId.value) return;
loadingChannel.value = true;
try {
// Buscar pelo owner_id do tenant (que é o user_id do dono)
const { data, error } = await supabase.from('notification_channels').select('*').eq('tenant_id', selectedTenantId.value).eq('channel', 'whatsapp').is('deleted_at', null).maybeSingle();
if (error) throw error;
channel.value = data;
if (data?.credentials) {
credentials.value = {
api_url: data.credentials.api_url || '',
api_key: data.credentials.api_key || '',
instance_name: data.credentials.instance_name || ''
};
hasCredentials.value = true;
// Só verificar conexão se o canal estiver ativo
if (data.is_active) await checkConnectionStatus();
}
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro ao carregar canal', detail: e.message, life: 4000 });
} finally {
loadingChannel.value = false;
}
}
// Salvar credenciais
async function saveCredentials() {
if (!selectedTenantId.value || savingCredentials.value) return;
if (!credentials.value.api_url || !credentials.value.api_key || !credentials.value.instance_name) {
toast.add({ severity: 'warn', summary: 'Preencha todos os campos', life: 3000 });
return;
}
// Validação básica de URL
try {
new URL(credentials.value.api_url);
} catch {
toast.add({ severity: 'warn', summary: 'URL inválida', detail: 'A URL deve começar com http:// ou https://', life: 4000 });
return;
}
savingCredentials.value = true;
try {
// Buscar o owner_id (dono do tenant)
const { data: members, error: memErr } = await supabase.from('tenant_members').select('user_id').eq('tenant_id', selectedTenantId.value).in('role', ['tenant_admin', 'admin']).limit(1).single();
if (memErr) throw memErr;
const ownerId = members.user_id;
const creds = { ...credentials.value };
// Remover barra final da URL
creds.api_url = creds.api_url.replace(/\/+$/, '');
if (channel.value?.id) {
// Atualizar existente
const { error } = await supabase
.from('notification_channels')
.update({
credentials: creds,
display_name: `WhatsApp — ${selectedTenantName.value}`,
is_active: true
})
.eq('id', channel.value.id);
if (error) throw error;
} else {
// Inserir novo recarregar para evitar duplicata por race condition
const { data: existing } = await supabase.from('notification_channels').select('id').eq('owner_id', ownerId).eq('channel', 'whatsapp').is('deleted_at', null).maybeSingle();
if (existing?.id) {
// Já existe (criado por outra aba/sessão) atualizar
const { error } = await supabase
.from('notification_channels')
.update({
credentials: creds,
display_name: `WhatsApp — ${selectedTenantName.value}`,
is_active: true,
provider: 'evolution_api'
})
.eq('id', existing.id);
if (error) throw error;
} else {
const { data, error } = await supabase
.from('notification_channels')
.insert({
owner_id: ownerId,
tenant_id: selectedTenantId.value,
channel: 'whatsapp',
provider: 'evolution_api',
is_active: true,
display_name: `WhatsApp — ${selectedTenantName.value}`,
credentials: creds
})
.select('*')
.single();
if (error) throw error;
channel.value = data;
}
}
hasCredentials.value = true;
toast.add({ severity: 'success', summary: 'Credenciais salvas', detail: `WhatsApp configurado para ${selectedTenantName.value}`, life: 3000 });
// Recarregar canal para sincronizar estado
await loadChannel();
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro ao salvar', detail: e.message, life: 5000 });
} finally {
savingCredentials.value = false;
}
}
// Desativar WhatsApp do tenant
function confirmDeactivate() {
confirm.require({
message: `Desativar o WhatsApp de "${selectedTenantName.value}"? O terapeuta não poderá mais enviar mensagens.`,
header: 'Desativar WhatsApp',
icon: 'pi pi-exclamation-triangle',
acceptClass: 'p-button-danger',
accept: async () => {
try {
const { error } = await supabase.from('notification_channels').update({ is_active: false }).eq('id', channel.value.id);
if (error) throw error;
toast.add({ severity: 'success', summary: 'WhatsApp desativado', life: 3000 });
await loadChannel();
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e.message, life: 4000 });
}
}
});
}
// Status da conexão via Evolution API
async function checkConnectionStatus() {
if (!hasCredentials.value) return;
connectionLoading.value = true;
try {
const res = await fetch(`${credentials.value.api_url}/instance/fetchInstances`, {
headers: { apikey: credentials.value.api_key }
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const instances = await res.json();
const inst = Array.isArray(instances) ? instances.find((i) => i.instance?.instanceName === credentials.value.instance_name) : null;
connectionStatus.value = inst?.instance?.status || 'close';
} catch (e) {
connectionStatus.value = 'close';
toast.add({
severity: 'warn',
summary: 'Não foi possível conectar à Evolution API',
detail: 'Verifique a URL e a chave de API.',
life: 5000
});
} finally {
connectionLoading.value = false;
}
}
// QR Code
async function fetchQrCode() {
if (!isMounted) return;
qrLoading.value = true;
qrCodeBase64.value = null;
clearQrTimer();
try {
const res = await fetch(`${credentials.value.api_url}/instance/connect/${credentials.value.instance_name}`, { headers: { apikey: credentials.value.api_key } });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
const base64 = data?.base64;
if (!base64) {
if (data?.instance?.status === 'open') {
connectionStatus.value = 'open';
toast.add({ severity: 'success', summary: 'WhatsApp já está conectado!', life: 3000 });
qrDialog.value = false;
return;
}
throw new Error('QR Code não retornado pela API.');
}
qrCodeBase64.value = base64.startsWith('data:') ? base64 : `data:image/png;base64,${base64}`;
startQrCountdown();
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro ao gerar QR Code', detail: e.message, life: 5000 });
} finally {
qrLoading.value = false;
}
}
function startQrCountdown() {
qrCountdown.value = 30;
qrTimer = setInterval(() => {
qrCountdown.value--;
if (qrCountdown.value <= 0) {
clearQrTimer();
fetchQrCode();
}
}, 1000);
}
function clearQrTimer() {
if (qrTimer) {
clearInterval(qrTimer);
qrTimer = null;
}
qrCountdown.value = 0;
}
function openQrDialog() {
qrDialog.value = true;
fetchQrCode();
}
function closeQrDialog() {
qrDialog.value = false;
clearQrTimer();
qrCodeBase64.value = null;
checkConnectionStatus();
}
// Visão geral: todos os canais WhatsApp
const allChannels = ref([]);
const loadingAll = ref(false);
async function loadAllChannels() {
loadingAll.value = true;
try {
const { data, error } = await supabase.from('notification_channels').select('*').eq('channel', 'whatsapp').is('deleted_at', null).order('created_at', { ascending: false });
if (error) throw error;
allChannels.value = data || [];
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro ao carregar canais', detail: e.message, life: 4000 });
} finally {
loadingAll.value = false;
}
}
function channelStatusTag(ch) {
if (!ch.is_active) return { label: 'Desativado', severity: 'secondary' };
switch (ch.connection_status) {
case 'connected':
return { label: 'Conectado', severity: 'success' };
case 'connecting':
return { label: 'Conectando', severity: 'warn' };
case 'qr_pending':
return { label: 'Aguardando QR', severity: 'warn' };
case 'error':
return { label: 'Erro', severity: 'danger' };
default:
return { label: 'Desconectado', severity: 'danger' };
}
}
function formatDate(dt) {
if (!dt) return '—';
const d = new Date(dt);
return d.toLocaleDateString('pt-BR', { day: '2-digit', month: '2-digit', year: 'numeric' }) + ' ' + d.toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' });
}
function selectTenantFromTable(tenantId) {
selectedTenantId.value = tenantId;
activeTab.value = 1;
onTenantSelect();
}
// Tabs
const activeTab = ref(0);
// Inicialização
onMounted(async () => {
await Promise.all([loadTenants(), loadAllChannels()]);
});
onBeforeUnmount(() => {
isMounted = false;
clearQrTimer();
});
</script>
<template>
<div class="flex flex-col gap-4 p-4">
<!-- Header -->
<div class="cfg-subheader">
<div class="cfg-subheader__icon"><i class="pi pi-comments" /></div>
<div class="min-w-0">
<div class="cfg-subheader__title">Gerenciar WhatsApp</div>
<div class="cfg-subheader__sub">Configure a integração WhatsApp para cada terapeuta ou clínica</div>
</div>
</div>
<!-- Abas -->
<Tabs :value="activeTab" @update:value="activeTab = $event">
<TabList>
<Tab :value="0"><i class="pi pi-list mr-2" />Visão geral</Tab>
<Tab :value="1"><i class="pi pi-cog mr-2" />Configurar tenant</Tab>
</TabList>
<TabPanels>
<!-- ABA 1 Visão geral -->
<TabPanel :value="0">
<div class="flex flex-col gap-3 pt-3">
<div class="flex items-center gap-2">
<span class="text-sm text-[var(--text-color-secondary)]"> {{ allChannels.length }} canal(is) WhatsApp configurado(s) </span>
<Button icon="pi pi-refresh" size="small" severity="secondary" outlined :loading="loadingAll" v-tooltip.bottom="'Atualizar'" class="ml-auto" @click="loadAllChannels" />
</div>
<DataTable :value="allChannels" :loading="loadingAll" responsive-layout="scroll" striped-rows class="text-sm">
<Column header="Tenant" style="min-width: 180px">
<template #body="{ data }">
<div class="flex flex-col">
<span class="font-semibold">{{ tenantMap[data.tenant_id] || '—' }}</span>
<span class="text-xs text-[var(--text-color-secondary)] font-mono">{{ data.tenant_id?.slice(0, 8) }}</span>
</div>
</template>
</Column>
<Column header="Instância" style="min-width: 140px">
<template #body="{ data }">
<span class="font-mono text-xs">{{ data.credentials?.instance_name || '—' }}</span>
</template>
</Column>
<Column header="Status" style="min-width: 120px">
<template #body="{ data }">
<Tag :value="channelStatusTag(data).label" :severity="channelStatusTag(data).severity" class="text-[0.7rem]" />
</template>
</Column>
<Column header="Ativo" style="min-width: 80px">
<template #body="{ data }">
<Tag :value="data.is_active ? 'Sim' : 'Não'" :severity="data.is_active ? 'success' : 'secondary'" class="text-[0.7rem]" />
</template>
</Column>
<Column header="Criado em" style="min-width: 140px">
<template #body="{ data }">
{{ formatDate(data.created_at) }}
</template>
</Column>
<Column header="" style="width: 60px">
<template #body="{ data }">
<Button icon="pi pi-pencil" text rounded size="small" severity="secondary" v-tooltip.left="'Configurar'" @click="selectTenantFromTable(data.tenant_id)" />
</template>
</Column>
<template #empty>
<div class="text-center py-6 text-sm text-[var(--text-color-secondary)]">Nenhum canal WhatsApp configurado ainda.</div>
</template>
</DataTable>
</div>
</TabPanel>
<!-- ABA 2 Configurar tenant -->
<TabPanel :value="1">
<div class="flex flex-col gap-4 pt-3">
<!-- Seletor de tenant -->
<div class="border border-[var(--surface-border)] rounded-lg p-4 bg-[var(--surface-card)]">
<div class="flex flex-col gap-2">
<label class="text-xs font-semibold">Selecionar terapeuta / clínica</label>
<div class="flex gap-2">
<Select v-model="selectedTenantId" :options="tenants" option-label="label" option-value="value" placeholder="Escolha um tenant..." filter :loading="loadingTenants" class="flex-1" @change="onTenantSelect" />
</div>
</div>
</div>
<!-- Nenhum tenant selecionado -->
<div v-if="!selectedTenantId" class="border border-dashed border-[var(--surface-border)] rounded-lg p-6 text-center bg-[var(--surface-ground)]">
<i class="pi pi-arrow-up text-2xl text-[var(--text-color-secondary)] opacity-40 mb-2" />
<p class="text-sm text-[var(--text-color-secondary)] m-0">Selecione um tenant acima para configurar o WhatsApp.</p>
</div>
<!-- Loading -->
<div v-else-if="loadingChannel" class="flex justify-center py-8">
<ProgressSpinner style="width: 40px; height: 40px" />
</div>
<!-- Painel do tenant selecionado -->
<template v-else>
<!-- Status da conexão -->
<div class="border border-[var(--surface-border)] rounded-lg p-4 bg-[var(--surface-card)]">
<div class="flex items-center justify-between gap-3 flex-wrap">
<div class="flex items-center gap-3">
<div class="grid place-items-center w-10 h-10 rounded-full" :class="connectionStatus === 'open' ? 'bg-green-100 text-green-600' : 'bg-gray-100 text-gray-500'">
<i class="pi pi-comments text-lg" />
</div>
<div>
<div class="font-semibold text-sm">{{ selectedTenantName }}</div>
<Tag :value="connectionTag.label" :severity="connectionTag.severity" class="text-xs mt-1" />
</div>
</div>
<div class="flex gap-2">
<Button :label="connectionStatus === 'open' ? 'Reconectar' : 'Conectar'" icon="pi pi-qrcode" size="small" :disabled="!hasCredentials" @click="openQrDialog" />
<Button icon="pi pi-refresh" size="small" severity="secondary" outlined :loading="connectionLoading" :disabled="!hasCredentials" v-tooltip.bottom="'Verificar status'" @click="checkConnectionStatus" />
<Button v-if="hasCredentials && channel?.is_active" icon="pi pi-power-off" size="small" severity="danger" outlined v-tooltip.bottom="'Desativar'" @click="confirmDeactivate" />
</div>
</div>
</div>
<!-- Formulário de credenciais -->
<div class="border border-[var(--surface-border)] rounded-lg overflow-hidden">
<div class="flex items-center gap-2 px-4 py-3 bg-[var(--surface-ground)]">
<i class="pi pi-key text-sm opacity-60" />
<span class="text-sm font-semibold">Credenciais da Evolution API</span>
</div>
<div class="px-4 py-4 flex flex-col gap-4">
<div class="flex flex-col gap-1.5">
<label class="text-xs font-semibold">URL da Evolution API</label>
<InputText v-model="credentials.api_url" placeholder="https://evolution.seudominio.com" class="w-full" />
</div>
<div class="flex flex-col gap-1.5">
<label class="text-xs font-semibold">API Key</label>
<InputText v-model="credentials.api_key" placeholder="Chave de API da Evolution" class="w-full" type="password" />
</div>
<div class="flex flex-col gap-1.5">
<label class="text-xs font-semibold">Nome da instância</label>
<InputText v-model="credentials.instance_name" placeholder="nome-da-instancia" class="w-full" />
</div>
<div class="flex justify-end">
<Button label="Salvar credenciais" icon="pi pi-save" size="small" :loading="savingCredentials" :disabled="savingCredentials" @click="saveCredentials" />
</div>
</div>
</div>
</template>
</div>
</TabPanel>
</TabPanels>
</Tabs>
<!-- Dialog QR Code -->
<Dialog v-model:visible="qrDialog" :header="`Conectar WhatsApp — ${selectedTenantName}`" modal :style="{ width: '420px', maxWidth: '96vw' }" :draggable="false" @hide="closeQrDialog">
<div class="flex flex-col items-center gap-4 py-2">
<p class="text-sm text-[var(--text-color-secondary)] text-center m-0">Escaneie o QR Code com o WhatsApp do celular do terapeuta para conectar.</p>
<div v-if="qrLoading" class="flex flex-col items-center gap-3 py-6">
<ProgressSpinner style="width: 48px; height: 48px" />
<span class="text-xs text-[var(--text-color-secondary)]">Gerando QR Code...</span>
</div>
<div v-else-if="qrCodeBase64" class="flex flex-col items-center gap-3">
<img :src="qrCodeBase64" alt="QR Code WhatsApp" class="w-64 h-64 rounded-lg border border-[var(--surface-border)]" />
<div class="flex items-center gap-2 text-xs text-[var(--text-color-secondary)]">
<i class="pi pi-clock" />
<span
>Atualiza automaticamente em <strong>{{ qrCountdown }}s</strong></span
>
</div>
</div>
<div v-else class="text-center py-6">
<i class="pi pi-exclamation-circle text-3xl text-[var(--text-color-secondary)] opacity-40 mb-2" />
<p class="text-sm text-[var(--text-color-secondary)] m-0">Não foi possível gerar o QR Code.</p>
</div>
<Button label="Atualizar QR Code" icon="pi pi-refresh" size="small" severity="secondary" outlined :loading="qrLoading" @click="fetchQrCode" />
</div>
<template #footer>
<Button label="Fechar" text @click="closeQrDialog" />
</template>
</Dialog>
<ConfirmDialog />
</div>
</template>
<style scoped>
.cfg-subheader {
display: flex;
align-items: center;
gap: 0.65rem;
padding: 0.875rem 1rem;
border-radius: 6px;
border: 1px solid color-mix(in srgb, var(--primary-color, #6366f1) 30%, transparent);
background: linear-gradient(135deg, color-mix(in srgb, var(--primary-color, #6366f1) 12%, var(--surface-card)) 0%, color-mix(in srgb, var(--primary-color, #6366f1) 4%, var(--surface-card)) 60%, var(--surface-card) 100%);
position: relative;
overflow: hidden;
}
.cfg-subheader::before {
content: '';
position: absolute;
top: -20px;
right: -20px;
width: 80px;
height: 80px;
border-radius: 50%;
background: color-mix(in srgb, var(--primary-color, #6366f1) 15%, transparent);
filter: blur(20px);
pointer-events: none;
}
.cfg-subheader__icon {
display: grid;
place-items: center;
width: 2rem;
height: 2rem;
border-radius: 6px;
flex-shrink: 0;
background: color-mix(in srgb, var(--primary-color, #6366f1) 20%, transparent);
color: var(--primary-color, #6366f1);
font-size: 0.85rem;
}
.cfg-subheader__title {
font-size: 0.95rem;
font-weight: 700;
letter-spacing: -0.01em;
color: var(--primary-color, #6366f1);
}
.cfg-subheader__sub {
font-size: 0.75rem;
color: var(--text-color-secondary);
opacity: 0.85;
}
</style>
File diff suppressed because it is too large Load Diff
@@ -20,37 +20,23 @@
</script>
<template>
<div class="p-6">
<div class="max-w-2xl mx-auto text-center py-16">
<div
class="w-20 h-20 mx-auto mb-6 rounded-2xl border-2 border-dashed border-[var(--surface-border)]
flex items-center justify-center"
>
<i class="pi pi-users text-4xl opacity-40" />
</div>
<div class="p-6">
<div class="max-w-2xl mx-auto text-center py-16">
<div class="w-20 h-20 mx-auto mb-6 rounded-2xl border-2 border-dashed border-[var(--surface-border)] flex items-center justify-center">
<i class="pi pi-users text-4xl opacity-40" />
</div>
<h1 class="text-2xl font-bold mb-2">Sala de Supervisão</h1>
<p class="text-[var(--text-color-secondary)] mb-8 leading-relaxed">
Aqui você gerenciará seus supervisionados, enviará convites e
acompanhará o progresso de cada terapeuta.
<br />
<span class="opacity-60 text-sm">Em construção disponível em breve.</span>
</p>
<h1 class="text-2xl font-bold mb-2">Sala de Supervisão</h1>
<p class="text-[var(--text-color-secondary)] mb-8 leading-relaxed">
Aqui você gerenciará seus supervisionados, enviará convites e acompanhará o progresso de cada terapeuta.
<br />
<span class="opacity-60 text-sm">Em construção disponível em breve.</span>
</p>
<div class="flex flex-col sm:flex-row gap-3 justify-center">
<Button
label="Convidar terapeuta"
icon="pi pi-user-plus"
disabled
/>
<Button
label="Ver supervisionados"
icon="pi pi-list"
severity="secondary"
outlined
disabled
/>
</div>
<div class="flex flex-col sm:flex-row gap-3 justify-center">
<Button label="Convidar terapeuta" icon="pi pi-user-plus" disabled />
<Button label="Ver supervisionados" icon="pi pi-list" severity="secondary" outlined disabled />
</div>
</div>
</div>
</div>
</template>
@@ -21,29 +21,27 @@ import RevenueStreamWidget from '@/components/dashboard/RevenueStreamWidget.vue'
</script>
<template>
<div class="card mb-0">
<div class="flex justify-between mb-4">
<div>
<span class="text-primary font-medium">Área</span>
<span class="text-muted-color"> do Supervisor</span>
</div>
<div class="flex items-center justify-center bg-purple-100 dark:bg-purple-400/10 rounded-border" style="width: 2.5rem; height: 2.5rem">
<i class="pi pi-eye text-purple-500 text-xl!"></i>
</div>
</div>
<p class="text-muted-color text-sm mt-0">
Supervisione sessões, evolução dos pacientes e indicadores da clínica.
</p>
</div>
<div class="card mb-0">
<div class="flex justify-between mb-4">
<div>
<span class="text-primary font-medium">Área</span>
<span class="text-muted-color"> do Supervisor</span>
</div>
<div class="flex items-center justify-center bg-purple-100 dark:bg-purple-400/10 rounded-border" style="width: 2.5rem; height: 2.5rem">
<i class="pi pi-eye text-purple-500 text-xl!"></i>
</div>
</div>
<p class="text-muted-color text-sm mt-0">Supervisione sessões, evolução dos pacientes e indicadores da clínica.</p>
</div>
<div class="grid grid-cols-12 gap-8">
<StatsWidget />
<div class="grid grid-cols-12 gap-8">
<StatsWidget />
<div class="col-span-12 xl:col-span-6">
<RevenueStreamWidget />
</div>
<div class="col-span-12 xl:col-span-6">
<NotificationsWidget />
</div>
</div>
<div class="col-span-12 xl:col-span-6">
<RevenueStreamWidget />
</div>
<div class="col-span-12 xl:col-span-6">
<NotificationsWidget />
</div>
</div>
</template>
@@ -14,12 +14,12 @@
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
-->
<template>
<div class="p-4">
<h1>Online Scheduling Terapeuta (Therapist) (Manage)</h1>
</div>
</template>
<script setup>
// placeholder para futura implementação
</script>
<template>
<div class="p-4">
<h1>Online Scheduling Terapeuta (Therapist) (Manage)</h1>
</div>
</template>
+386 -396
View File
@@ -15,459 +15,449 @@
|--------------------------------------------------------------------------
-->
<script setup>
import { ref, computed, watch, onMounted } from 'vue'
import { supabase } from '@/lib/supabase/client'
import { useTenantStore } from '@/stores/tenantStore'
import { useLayout } from '@/layout/composables/layout'
import { ref, computed, watch, onMounted } from 'vue';
import { supabase } from '@/lib/supabase/client';
import { useTenantStore } from '@/stores/tenantStore';
import { useLayout } from '@/layout/composables/layout';
const { layoutConfig, isDarkTheme } = useLayout()
const tenantStore = useTenantStore()
const { layoutConfig, isDarkTheme } = useLayout();
const tenantStore = useTenantStore();
// Período
const PERIODS = [
{ label: 'Esta semana', value: 'week' },
{ label: 'Este mês', value: 'month' },
{ label: 'Últimos 3 meses', value: '3months' },
{ label: 'Últimos 6 meses', value: '6months' },
]
{ label: 'Esta semana', value: 'week' },
{ label: 'Este mês', value: 'month' },
{ label: 'Últimos 3 meses', value: '3months' },
{ label: 'Últimos 6 meses', value: '6months' }
];
const selectedPeriod = ref('month')
const selectedPeriod = ref('month');
function periodRange (period) {
const now = new Date()
let start, end
if (period === 'week') {
start = new Date(now); start.setDate(now.getDate() - now.getDay()); start.setHours(0, 0, 0, 0)
end = new Date(now); end.setHours(23, 59, 59, 999)
} else if (period === 'month') {
start = new Date(now.getFullYear(), now.getMonth(), 1, 0, 0, 0, 0)
end = new Date(now.getFullYear(), now.getMonth() + 1, 0, 23, 59, 59, 999)
} else if (period === '3months') {
start = new Date(now.getFullYear(), now.getMonth() - 2, 1, 0, 0, 0, 0)
end = new Date(now.getFullYear(), now.getMonth() + 1, 0, 23, 59, 59, 999)
} else if (period === '6months') {
start = new Date(now.getFullYear(), now.getMonth() - 5, 1, 0, 0, 0, 0)
end = new Date(now.getFullYear(), now.getMonth() + 1, 0, 23, 59, 59, 999)
}
return { start, end }
function periodRange(period) {
const now = new Date();
let start, end;
if (period === 'week') {
start = new Date(now);
start.setDate(now.getDate() - now.getDay());
start.setHours(0, 0, 0, 0);
end = new Date(now);
end.setHours(23, 59, 59, 999);
} else if (period === 'month') {
start = new Date(now.getFullYear(), now.getMonth(), 1, 0, 0, 0, 0);
end = new Date(now.getFullYear(), now.getMonth() + 1, 0, 23, 59, 59, 999);
} else if (period === '3months') {
start = new Date(now.getFullYear(), now.getMonth() - 2, 1, 0, 0, 0, 0);
end = new Date(now.getFullYear(), now.getMonth() + 1, 0, 23, 59, 59, 999);
} else if (period === '6months') {
start = new Date(now.getFullYear(), now.getMonth() - 5, 1, 0, 0, 0, 0);
end = new Date(now.getFullYear(), now.getMonth() + 1, 0, 23, 59, 59, 999);
}
return { start, end };
}
// Dados
const loading = ref(false)
const hasLoaded = ref(false)
const sessions = ref([])
const loadError = ref('')
const loading = ref(false);
const hasLoaded = ref(false);
const sessions = ref([]);
const loadError = ref('');
async function loadSessions () {
const uid = tenantStore.user?.id || null
const tenantId = tenantStore.activeTenantId || null
if (!uid || !tenantId) return
async function loadSessions() {
const uid = tenantStore.user?.id || null;
const tenantId = tenantStore.activeTenantId || null;
if (!uid || !tenantId) return;
const { start, end } = periodRange(selectedPeriod.value)
loading.value = true
loadError.value = ''
sessions.value = []
const { start, end } = periodRange(selectedPeriod.value);
loading.value = true;
loadError.value = '';
sessions.value = [];
try {
const { data, error } = await supabase
.from('agenda_eventos')
.select('id, inicio_em, fim_em, status, modalidade, tipo, titulo, titulo_custom, patient_id, patients(nome_completo)')
.eq('tenant_id', tenantId)
.eq('owner_id', uid)
.gte('inicio_em', start.toISOString())
.lte('inicio_em', end.toISOString())
.order('inicio_em', { ascending: false })
.limit(500)
if (error) throw error
sessions.value = data || []
} catch (e) {
loadError.value = e?.message || 'Falha ao carregar relatório.'
} finally {
loading.value = false
hasLoaded.value = true
}
try {
const { data, error } = await supabase
.from('agenda_eventos')
.select('id, inicio_em, fim_em, status, modalidade, tipo, titulo, titulo_custom, patient_id, patients(nome_completo)')
.eq('tenant_id', tenantId)
.eq('owner_id', uid)
.gte('inicio_em', start.toISOString())
.lte('inicio_em', end.toISOString())
.order('inicio_em', { ascending: false })
.limit(500);
if (error) throw error;
sessions.value = data || [];
} catch (e) {
loadError.value = e?.message || 'Falha ao carregar relatório.';
} finally {
loading.value = false;
hasLoaded.value = true;
}
}
// Métricas
const total = computed(() => sessions.value.length)
const realizadas = computed(() => sessions.value.filter(s => s.status === 'realizado').length)
const faltas = computed(() => sessions.value.filter(s => s.status === 'faltou').length)
const canceladas = computed(() => sessions.value.filter(s => s.status === 'cancelado').length)
const agendadas = computed(() => sessions.value.filter(s => !s.status || s.status === 'agendado').length)
const remarcadas = computed(() => sessions.value.filter(s => s.status === 'remarcado').length)
const total = computed(() => sessions.value.length);
const realizadas = computed(() => sessions.value.filter((s) => s.status === 'realizado').length);
const faltas = computed(() => sessions.value.filter((s) => s.status === 'faltou').length);
const canceladas = computed(() => sessions.value.filter((s) => s.status === 'cancelado').length);
const agendadas = computed(() => sessions.value.filter((s) => !s.status || s.status === 'agendado').length);
const remarcadas = computed(() => sessions.value.filter((s) => s.status === 'remarcado').length);
const taxaRealizacao = computed(() => {
const denom = realizadas.value + faltas.value + canceladas.value
if (!denom) return null
return Math.round((realizadas.value / denom) * 100)
})
const denom = realizadas.value + faltas.value + canceladas.value;
if (!denom) return null;
return Math.round((realizadas.value / denom) * 100);
});
// Filtro de status na tabela
const filtroTabela = ref(null) // null = todos
const filtroTabela = ref(null); // null = todos
const sessionsFiltradas = computed(() => {
if (!filtroTabela.value) return sessions.value
if (filtroTabela.value === 'agendado') return sessions.value.filter(s => !s.status || s.status === 'agendado')
return sessions.value.filter(s => s.status === filtroTabela.value)
})
if (!filtroTabela.value) return sessions.value;
if (filtroTabela.value === 'agendado') return sessions.value.filter((s) => !s.status || s.status === 'agendado');
return sessions.value.filter((s) => s.status === filtroTabela.value);
});
function toggleFiltroTabela (val) {
filtroTabela.value = filtroTabela.value === val ? null : val
function toggleFiltroTabela(val) {
filtroTabela.value = filtroTabela.value === val ? null : val;
}
// Quick-stats config
const quickStats = computed(() => [
{ label: 'Total', value: total.value, filter: null, cls: '', valCls: 'text-[var(--text-color)]' },
{ label: 'Realizadas', value: realizadas.value, filter: 'realizado', cls: 'qs-ok', valCls: 'text-green-500' },
{ label: 'Faltas', value: faltas.value, filter: 'faltou', cls: 'qs-danger', valCls: 'text-red-500' },
{ label: 'Canceladas', value: canceladas.value, filter: 'cancelado', cls: 'qs-warn', valCls: 'text-orange-500' },
{ label: 'Agendadas', value: agendadas.value, filter: 'agendado', cls: 'qs-info', valCls: 'text-sky-500' },
{ label: 'Taxa realização', value: taxaRealizacao.value != null ? `${taxaRealizacao.value}%` : '—', filter: null, cls: taxaRealizacao.value != null && taxaRealizacao.value >= 85 ? 'qs-ok' : '', valCls: taxaRealizacao.value != null && taxaRealizacao.value >= 85 ? 'text-green-500' : 'text-[var(--text-color)]' },
])
{ label: 'Total', value: total.value, filter: null, cls: '', valCls: 'text-[var(--text-color)]' },
{ label: 'Realizadas', value: realizadas.value, filter: 'realizado', cls: 'qs-ok', valCls: 'text-green-500' },
{ label: 'Faltas', value: faltas.value, filter: 'faltou', cls: 'qs-danger', valCls: 'text-red-500' },
{ label: 'Canceladas', value: canceladas.value, filter: 'cancelado', cls: 'qs-warn', valCls: 'text-orange-500' },
{ label: 'Agendadas', value: agendadas.value, filter: 'agendado', cls: 'qs-info', valCls: 'text-sky-500' },
{
label: 'Taxa realização',
value: taxaRealizacao.value != null ? `${taxaRealizacao.value}%` : '—',
filter: null,
cls: taxaRealizacao.value != null && taxaRealizacao.value >= 85 ? 'qs-ok' : '',
valCls: taxaRealizacao.value != null && taxaRealizacao.value >= 85 ? 'text-green-500' : 'text-[var(--text-color)]'
}
]);
// Gráfico
function isoWeek (d) {
const dt = new Date(d)
const day = dt.getDay() || 7
dt.setDate(dt.getDate() + 4 - day)
const yearStart = new Date(dt.getFullYear(), 0, 1)
const wk = Math.ceil((((dt - yearStart) / 86400000) + 1) / 7)
return `${dt.getFullYear()}-S${String(wk).padStart(2, '0')}`
function isoWeek(d) {
const dt = new Date(d);
const day = dt.getDay() || 7;
dt.setDate(dt.getDate() + 4 - day);
const yearStart = new Date(dt.getFullYear(), 0, 1);
const wk = Math.ceil(((dt - yearStart) / 86400000 + 1) / 7);
return `${dt.getFullYear()}-S${String(wk).padStart(2, '0')}`;
}
function isoMonth (d) {
const dt = new Date(d)
return `${dt.getFullYear()}-${String(dt.getMonth() + 1).padStart(2, '0')}`
function isoMonth(d) {
const dt = new Date(d);
return `${dt.getFullYear()}-${String(dt.getMonth() + 1).padStart(2, '0')}`;
}
function monthLabel (key) {
const [y, m] = key.split('-')
const names = ['Jan','Fev','Mar','Abr','Mai','Jun','Jul','Ago','Set','Out','Nov','Dez']
return `${names[Number(m) - 1]}/${y}`
function monthLabel(key) {
const [y, m] = key.split('-');
const names = ['Jan', 'Fev', 'Mar', 'Abr', 'Mai', 'Jun', 'Jul', 'Ago', 'Set', 'Out', 'Nov', 'Dez'];
return `${names[Number(m) - 1]}/${y}`;
}
const chartData = computed(() => {
const groupBy = selectedPeriod.value === 'week' ? isoWeek : isoMonth
const labelFn = selectedPeriod.value === 'week' ? k => k : monthLabel
const buckets = {}
for (const s of sessions.value) {
const key = groupBy(s.inicio_em)
if (!buckets[key]) buckets[key] = { realizado: 0, faltou: 0, cancelado: 0, outros: 0 }
const st = s.status || 'agendado'
if (st === 'realizado') buckets[key].realizado++
else if (st === 'faltou') buckets[key].faltou++
else if (st === 'cancelado') buckets[key].cancelado++
else buckets[key].outros++
}
const keys = Object.keys(buckets).sort()
return {
labels: keys.map(labelFn),
datasets: [
{ label: 'Realizadas', backgroundColor: '#22c55e', data: keys.map(k => buckets[k].realizado), barThickness: 20 },
{ label: 'Faltas', backgroundColor: '#ef4444', data: keys.map(k => buckets[k].faltou), barThickness: 20 },
{ label: 'Canceladas', backgroundColor: '#f97316', data: keys.map(k => buckets[k].cancelado), barThickness: 20 },
{ label: 'Outros', backgroundColor: '#93c5fd', data: keys.map(k => buckets[k].outros), barThickness: 20 },
]
}
})
const groupBy = selectedPeriod.value === 'week' ? isoWeek : isoMonth;
const labelFn = selectedPeriod.value === 'week' ? (k) => k : monthLabel;
const buckets = {};
for (const s of sessions.value) {
const key = groupBy(s.inicio_em);
if (!buckets[key]) buckets[key] = { realizado: 0, faltou: 0, cancelado: 0, outros: 0 };
const st = s.status || 'agendado';
if (st === 'realizado') buckets[key].realizado++;
else if (st === 'faltou') buckets[key].faltou++;
else if (st === 'cancelado') buckets[key].cancelado++;
else buckets[key].outros++;
}
const keys = Object.keys(buckets).sort();
return {
labels: keys.map(labelFn),
datasets: [
{ label: 'Realizadas', backgroundColor: '#22c55e', data: keys.map((k) => buckets[k].realizado), barThickness: 20 },
{ label: 'Faltas', backgroundColor: '#ef4444', data: keys.map((k) => buckets[k].faltou), barThickness: 20 },
{ label: 'Canceladas', backgroundColor: '#f97316', data: keys.map((k) => buckets[k].cancelado), barThickness: 20 },
{ label: 'Outros', backgroundColor: '#93c5fd', data: keys.map((k) => buckets[k].outros), barThickness: 20 }
]
};
});
const chartOptions = computed(() => {
const ds = getComputedStyle(document.documentElement)
const borderColor = ds.getPropertyValue('--surface-border') || '#e2e8f0'
const textMutedColor = ds.getPropertyValue('--text-color-secondary') || '#64748b'
return {
maintainAspectRatio: false,
plugins: { legend: { labels: { color: textMutedColor } } },
scales: {
x: { stacked: true, ticks: { color: textMutedColor }, grid: { color: 'transparent' } },
y: { stacked: true, ticks: { color: textMutedColor, precision: 0 }, grid: { color: borderColor, drawTicks: false } }
}
}
})
const ds = getComputedStyle(document.documentElement);
const borderColor = ds.getPropertyValue('--surface-border') || '#e2e8f0';
const textMutedColor = ds.getPropertyValue('--text-color-secondary') || '#64748b';
return {
maintainAspectRatio: false,
plugins: { legend: { labels: { color: textMutedColor } } },
scales: {
x: { stacked: true, ticks: { color: textMutedColor }, grid: { color: 'transparent' } },
y: { stacked: true, ticks: { color: textMutedColor, precision: 0 }, grid: { color: borderColor, drawTicks: false } }
}
};
});
// Tabela helpers
const STATUS_LABEL = {
agendado: 'Agendado', realizado: 'Realizado', faltou: 'Faltou',
cancelado: 'Cancelado', remarcado: 'Remarcado', bloqueado: 'Bloqueado',
}
agendado: 'Agendado',
realizado: 'Realizado',
faltou: 'Faltou',
cancelado: 'Cancelado',
remarcado: 'Remarcado',
bloqueado: 'Bloqueado'
};
const STATUS_SEVERITY = {
agendado: 'info', realizado: 'success', faltou: 'danger',
cancelado: 'warn', remarcado: 'secondary', bloqueado: 'secondary',
}
agendado: 'info',
realizado: 'success',
faltou: 'danger',
cancelado: 'warn',
remarcado: 'secondary',
bloqueado: 'secondary'
};
function fmtDateTimeBR (iso) {
if (!iso) return '—'
const d = new Date(iso)
if (Number.isNaN(d.getTime())) return iso
const dd = String(d.getDate()).padStart(2, '0')
const mm = String(d.getMonth() + 1).padStart(2, '0')
const yy = d.getFullYear()
const hh = String(d.getHours()).padStart(2, '0')
const mi = String(d.getMinutes()).padStart(2, '0')
return `${dd}/${mm}/${yy} ${hh}:${mi}`
function fmtDateTimeBR(iso) {
if (!iso) return '—';
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
const dd = String(d.getDate()).padStart(2, '0');
const mm = String(d.getMonth() + 1).padStart(2, '0');
const yy = d.getFullYear();
const hh = String(d.getHours()).padStart(2, '0');
const mi = String(d.getMinutes()).padStart(2, '0');
return `${dd}/${mm}/${yy} ${hh}:${mi}`;
}
function sessionTitle(s) {
return s.titulo_custom || s.titulo || (s.tipo ? s.tipo : 'Sessão');
}
function patientName(s) {
return s.patients?.nome_completo || '—';
}
function sessionTitle (s) { return s.titulo_custom || s.titulo || (s.tipo ? s.tipo : 'Sessão') }
function patientName (s) { return s.patients?.nome_completo || '—' }
// Watch & mount
watch(selectedPeriod, () => { filtroTabela.value = null; loadSessions() })
watch([() => layoutConfig.primary, () => layoutConfig.surface, isDarkTheme], () => {})
onMounted(loadSessions)
watch(selectedPeriod, () => {
filtroTabela.value = null;
loadSessions();
});
watch([() => layoutConfig.primary, () => layoutConfig.surface, isDarkTheme], () => {});
onMounted(loadSessions);
</script>
<template>
<!-- Sentinel -->
<div class="h-px" />
<!-- Sentinel -->
<div class="h-px" />
<!--
<!--
HERO sticky
-->
<section
class="sticky mx-3 md:mx-4 mb-3 z-20 overflow-hidden rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] px-3 py-2.5 transition-[border-radius] duration-200"
:style="{ top: 'var(--layout-sticky-top, 56px)' }"
>
<!-- Blobs -->
<div class="absolute inset-0 pointer-events-none overflow-hidden" aria-hidden="true">
<div class="absolute w-64 h-64 -top-16 -right-8 rounded-full blur-[60px] bg-indigo-500/10" />
<div class="absolute w-72 h-72 top-0 -left-16 rounded-full blur-[60px] bg-emerald-400/[0.08]" />
</div>
<div class="relative z-[1] flex items-center gap-3 flex-wrap">
<!-- Brand -->
<div class="flex items-center gap-2 flex-shrink-0">
<div class="grid place-items-center w-9 h-9 rounded-md flex-shrink-0 bg-indigo-500/10 text-indigo-500">
<i class="pi pi-chart-bar text-base" />
<section
class="sticky mx-3 md:mx-4 mb-3 z-20 overflow-hidden rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] px-3 py-2.5 transition-[border-radius] duration-200"
:style="{ top: 'var(--layout-sticky-top, 56px)' }"
>
<!-- Blobs -->
<div class="absolute inset-0 pointer-events-none overflow-hidden" aria-hidden="true">
<div class="absolute w-64 h-64 -top-16 -right-8 rounded-full blur-[60px] bg-indigo-500/10" />
<div class="absolute w-72 h-72 top-0 -left-16 rounded-full blur-[60px] bg-emerald-400/[0.08]" />
</div>
<div class="min-w-0 hidden lg:block">
<div class="text-[1rem] font-bold tracking-tight text-[var(--text-color)]">Relatórios</div>
<div class="text-[1rem] text-[var(--text-color-secondary)]">Visão geral das suas sessões</div>
<div class="relative z-[1] flex items-center gap-3 flex-wrap">
<!-- Brand -->
<div class="flex items-center gap-2 flex-shrink-0">
<div class="grid place-items-center w-9 h-9 rounded-md flex-shrink-0 bg-indigo-500/10 text-indigo-500">
<i class="pi pi-chart-bar text-base" />
</div>
<div class="min-w-0 hidden lg:block">
<div class="text-[1rem] font-bold tracking-tight text-[var(--text-color)]">Relatórios</div>
<div class="text-[1rem] text-[var(--text-color-secondary)]">Visão geral das suas sessões</div>
</div>
</div>
<!-- Seletor de período -->
<div class="flex-1 min-w-0 hidden xl:flex items-center mx-2">
<SelectButton v-model="selectedPeriod" :options="PERIODS" option-label="label" option-value="value" :allow-empty="false" size="small" />
</div>
<!-- Refresh -->
<div class="flex items-center gap-1.5 flex-shrink-0 ml-auto">
<Button icon="pi pi-refresh" severity="secondary" outlined class="h-9 w-9 rounded-full" :loading="loading" title="Recarregar" @click="loadSessions" />
</div>
</div>
</div>
<!-- Seletor de período -->
<div class="flex-1 min-w-0 hidden xl:flex items-center mx-2">
<SelectButton
v-model="selectedPeriod"
:options="PERIODS"
option-label="label"
option-value="value"
:allow-empty="false"
size="small"
/>
</div>
<!-- Seletor de período mobile (abaixo da linha principal) -->
<div class="xl:hidden relative z-[1] mt-2.5 flex flex-wrap gap-1.5">
<button
v-for="p in PERIODS"
:key="p.value"
class="inline-flex items-center px-3 py-1 rounded-full text-[1rem] font-semibold border-[1.5px] cursor-pointer transition-all duration-150 select-none"
:class="
selectedPeriod === p.value
? 'bg-[var(--primary-color,#6366f1)] border-[var(--primary-color,#6366f1)] text-white'
: 'border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-ground,#f8fafc)] text-[var(--text-color-secondary)] hover:border-indigo-300 hover:text-[var(--text-color)]'
"
@click="selectedPeriod = p.value"
>
{{ p.label }}
</button>
</div>
</section>
<!-- Refresh -->
<div class="flex items-center gap-1.5 flex-shrink-0 ml-auto">
<Button
icon="pi pi-refresh"
severity="secondary"
outlined
class="h-9 w-9 rounded-full"
:loading="loading"
title="Recarregar"
@click="loadSessions"
/>
</div>
</div>
<!-- Seletor de período mobile (abaixo da linha principal) -->
<div class="xl:hidden relative z-[1] mt-2.5 flex flex-wrap gap-1.5">
<button
v-for="p in PERIODS"
:key="p.value"
class="inline-flex items-center px-3 py-1 rounded-full text-[1rem] font-semibold border-[1.5px] cursor-pointer transition-all duration-150 select-none"
:class="selectedPeriod === p.value
? 'bg-[var(--primary-color,#6366f1)] border-[var(--primary-color,#6366f1)] text-white'
: 'border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-ground,#f8fafc)] text-[var(--text-color-secondary)] hover:border-indigo-300 hover:text-[var(--text-color)]'"
@click="selectedPeriod = p.value"
>
{{ p.label }}
</button>
</div>
</section>
<!--
<!--
CONTEÚDO
-->
<div class="px-3 md:px-4 pb-8 flex flex-col gap-3">
<div class="px-3 md:px-4 pb-8 flex flex-col gap-3">
<!-- Erro -->
<Message v-if="loadError" severity="error">{{ loadError }}</Message>
<!-- Erro -->
<Message v-if="loadError" severity="error">{{ loadError }}</Message>
<!-- Loading skeleton -->
<div v-if="loading" class="flex flex-col gap-3">
<!-- Stats skeleton -->
<div class="flex flex-wrap gap-2">
<div v-for="n in 6" :key="n" class="flex-1 min-w-[80px] h-[72px] rounded-md bg-[var(--surface-border,#e2e8f0)] animate-pulse" />
</div>
<!-- Chart skeleton -->
<div class="rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] h-[280px] animate-pulse" />
</div>
<!-- Loading skeleton -->
<div v-if="loading" class="flex flex-col gap-3">
<!-- Stats skeleton -->
<div class="flex flex-wrap gap-2">
<div v-for="n in 6" :key="n" class="flex-1 min-w-[80px] h-[72px] rounded-md bg-[var(--surface-border,#e2e8f0)] animate-pulse" />
</div>
<!-- Chart skeleton -->
<div class="rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] h-[280px] animate-pulse" />
<template v-else>
<!-- QUICK-STATS clicáveis -->
<div class="flex flex-wrap gap-2">
<div
v-for="s in quickStats"
:key="s.label"
class="flex flex-col gap-0.5 px-4 py-2.5 rounded-md border flex-1 min-w-[80px] transition-[border-color,box-shadow,background] duration-150"
:class="[
s.filter !== null ? 'cursor-pointer select-none' : '',
s.filter !== null && filtroTabela === s.filter
? 'border-[var(--primary-color,#6366f1)] shadow-[0_0_0_3px_rgba(99,102,241,0.15)] bg-[var(--surface-card,#fff)]'
: s.cls === 'qs-ok'
? 'border-green-500/25 bg-green-500/5 hover:border-green-500/40'
: s.cls === 'qs-danger'
? 'border-red-500/25 bg-red-500/5 hover:border-red-500/40'
: s.cls === 'qs-warn'
? 'border-orange-500/25 bg-orange-500/5 hover:border-orange-500/40'
: s.cls === 'qs-info'
? 'border-sky-500/25 bg-sky-500/5 hover:border-sky-500/40'
: 'border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] hover:border-indigo-300/50'
]"
@click="s.filter !== null ? toggleFiltroTabela(s.filter) : null"
>
<div class="text-[1.35rem] font-bold leading-none" :class="s.valCls">{{ s.value }}</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-75 whitespace-nowrap">{{ s.label }}</div>
</div>
</div>
<!-- Chip de filtro ativo na tabela -->
<div v-if="filtroTabela" class="flex items-center gap-2">
<span class="text-[1rem] text-[var(--text-color-secondary)]">Filtrando por:</span>
<span class="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-[0.75rem] font-semibold bg-[var(--primary-color,#6366f1)] text-white">
{{ STATUS_LABEL[filtroTabela] || filtroTabela }}
<button class="ml-0.5 opacity-70 hover:opacity-100" @click="filtroTabela = null">
<i class="pi pi-times text-[0.6rem]" />
</button>
</span>
</div>
<!-- GRÁFICO -->
<div v-if="total > 0" class="rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] overflow-hidden">
<div class="flex items-center justify-between px-4 py-3 border-b border-[var(--surface-border,#e2e8f0)]">
<div class="flex items-center gap-2">
<i class="pi pi-chart-bar text-[var(--text-color-secondary)] opacity-60" />
<span class="font-semibold text-[1rem] text-[var(--text-color)]"> Sessões por {{ selectedPeriod === 'week' ? 'semana' : 'mês' }} </span>
</div>
<span class="text-[0.72rem] text-[var(--text-color-secondary)] opacity-60">{{ total }} sessão{{ total !== 1 ? 'ões' : '' }}</span>
</div>
<div class="p-4">
<Chart type="bar" :data="chartData" :options="chartOptions" class="h-64" />
</div>
</div>
<!-- TABELA -->
<div class="rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] overflow-hidden">
<!-- Cabeçalho da seção -->
<div class="flex items-center justify-between px-4 py-3 border-b border-[var(--surface-border,#e2e8f0)]">
<div class="flex items-center gap-2">
<i class="pi pi-table text-[var(--text-color-secondary)] opacity-60" />
<span class="font-semibold text-[1rem] text-[var(--text-color)]">Sessões no período</span>
<span v-if="filtroTabela" class="text-[0.72rem] text-[var(--text-color-secondary)] opacity-70">(filtrado)</span>
</div>
<span class="inline-flex items-center justify-center min-w-[22px] h-[22px] px-1.5 rounded-full bg-[var(--primary-color,#6366f1)] text-white text-[1rem] font-bold">
{{ sessionsFiltradas.length }}
</span>
</div>
<!-- Empty state (sem dados no período) -->
<div v-if="!sessions.length" class="flex flex-col items-center justify-center gap-3 py-14 px-6 text-center border-2 border-dashed border-[var(--surface-border,#e2e8f0)] mx-4 my-4 rounded-md bg-[var(--surface-ground,#f8fafc)]">
<div class="relative">
<div class="grid place-items-center w-16 h-16 rounded-2xl bg-[var(--surface-card,#fff)] border border-[var(--surface-border,#e2e8f0)] shadow-sm text-[var(--text-color-secondary)]">
<i class="pi pi-chart-bar text-3xl opacity-25" />
</div>
<div class="absolute -top-1.5 -right-1.5 w-6 h-6 rounded-full bg-[var(--surface-card,#fff)] border border-[var(--surface-border,#e2e8f0)] shadow-sm grid place-items-center">
<i class="pi pi-times text-[0.58rem] text-[var(--text-color-secondary)] opacity-50" />
</div>
</div>
<div>
<div class="font-bold text-[0.9rem] text-[var(--text-color)] mb-0.5">Nenhuma sessão no período</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] max-w-xs">Tente selecionar um período diferente.</div>
</div>
<div class="flex flex-wrap gap-2 mt-1 justify-center">
<button
v-for="p in PERIODS"
:key="p.value"
class="inline-flex items-center px-3 py-1 rounded-full text-[0.75rem] font-semibold border cursor-pointer transition-colors duration-150"
:class="
selectedPeriod === p.value ? 'bg-[var(--primary-color,#6366f1)] border-[var(--primary-color,#6366f1)] text-white' : 'border-[var(--surface-border,#e2e8f0)] text-[var(--text-color-secondary)] hover:border-indigo-300'
"
@click="selectedPeriod = p.value"
>
{{ p.label }}
</button>
</div>
</div>
<!-- Empty state (filtro sem resultado) -->
<div v-else-if="!sessionsFiltradas.length" class="flex flex-col items-center gap-2 py-10 text-center text-[var(--text-color-secondary)]">
<i class="pi pi-filter-slash text-2xl opacity-30" />
<div class="font-semibold text-[0.88rem]">Nenhuma sessão com este status</div>
<Button label="Limpar filtro" icon="pi pi-times" severity="secondary" outlined size="small" class="rounded-full mt-1" @click="filtroTabela = null" />
</div>
<!-- DataTable -->
<DataTable v-else :value="sessionsFiltradas" :rows="20" paginator :rows-per-page-options="[10, 20, 50]" scrollable scroll-height="480px" class="rel-datatable">
<Column field="inicio_em" header="Data / Hora" :sortable="true" style="min-width: 140px">
<template #body="{ data }">
<span class="font-medium">{{ fmtDateTimeBR(data.inicio_em) }}</span>
</template>
</Column>
<Column header="Paciente" style="min-width: 160px">
<template #body="{ data }">{{ patientName(data) }}</template>
</Column>
<Column header="Sessão" style="min-width: 160px">
<template #body="{ data }">{{ sessionTitle(data) }}</template>
</Column>
<Column field="modalidade" header="Modalidade" style="min-width: 110px">
<template #body="{ data }">
{{ data.modalidade === 'online' ? 'Online' : data.modalidade === 'presencial' ? 'Presencial' : data.modalidade || '—' }}
</template>
</Column>
<Column field="status" header="Status" style="min-width: 110px">
<template #body="{ data }">
<Tag :value="STATUS_LABEL[data.status] || data.status || 'Agendado'" :severity="STATUS_SEVERITY[data.status] || 'info'" />
</template>
</Column>
</DataTable>
</div>
<LoadedPhraseBlock v-if="hasLoaded" />
</template>
</div>
<template v-else>
<!-- QUICK-STATS clicáveis -->
<div class="flex flex-wrap gap-2">
<div
v-for="s in quickStats"
:key="s.label"
class="flex flex-col gap-0.5 px-4 py-2.5 rounded-md border flex-1 min-w-[80px] transition-[border-color,box-shadow,background] duration-150"
:class="[
s.filter !== null ? 'cursor-pointer select-none' : '',
s.filter !== null && filtroTabela === s.filter
? 'border-[var(--primary-color,#6366f1)] shadow-[0_0_0_3px_rgba(99,102,241,0.15)] bg-[var(--surface-card,#fff)]'
: s.cls === 'qs-ok'
? 'border-green-500/25 bg-green-500/5 hover:border-green-500/40'
: s.cls === 'qs-danger'
? 'border-red-500/25 bg-red-500/5 hover:border-red-500/40'
: s.cls === 'qs-warn'
? 'border-orange-500/25 bg-orange-500/5 hover:border-orange-500/40'
: s.cls === 'qs-info'
? 'border-sky-500/25 bg-sky-500/5 hover:border-sky-500/40'
: 'border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] hover:border-indigo-300/50'
]"
@click="s.filter !== null ? toggleFiltroTabela(s.filter) : null"
>
<div class="text-[1.35rem] font-bold leading-none" :class="s.valCls">{{ s.value }}</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-75 whitespace-nowrap">{{ s.label }}</div>
</div>
</div>
<!-- Chip de filtro ativo na tabela -->
<div v-if="filtroTabela" class="flex items-center gap-2">
<span class="text-[1rem] text-[var(--text-color-secondary)]">Filtrando por:</span>
<span class="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-[0.75rem] font-semibold bg-[var(--primary-color,#6366f1)] text-white">
{{ STATUS_LABEL[filtroTabela] || filtroTabela }}
<button class="ml-0.5 opacity-70 hover:opacity-100" @click="filtroTabela = null">
<i class="pi pi-times text-[0.6rem]" />
</button>
</span>
</div>
<!-- GRÁFICO -->
<div v-if="total > 0" class="rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] overflow-hidden">
<div class="flex items-center justify-between px-4 py-3 border-b border-[var(--surface-border,#e2e8f0)]">
<div class="flex items-center gap-2">
<i class="pi pi-chart-bar text-[var(--text-color-secondary)] opacity-60" />
<span class="font-semibold text-[1rem] text-[var(--text-color)]">
Sessões por {{ selectedPeriod === 'week' ? 'semana' : 'mês' }}
</span>
</div>
<span class="text-[0.72rem] text-[var(--text-color-secondary)] opacity-60">{{ total }} sessão{{ total !== 1 ? 'ões' : '' }}</span>
</div>
<div class="p-4">
<Chart type="bar" :data="chartData" :options="chartOptions" class="h-64" />
</div>
</div>
<!-- TABELA -->
<div class="rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] overflow-hidden">
<!-- Cabeçalho da seção -->
<div class="flex items-center justify-between px-4 py-3 border-b border-[var(--surface-border,#e2e8f0)]">
<div class="flex items-center gap-2">
<i class="pi pi-table text-[var(--text-color-secondary)] opacity-60" />
<span class="font-semibold text-[1rem] text-[var(--text-color)]">Sessões no período</span>
<span
v-if="filtroTabela"
class="text-[0.72rem] text-[var(--text-color-secondary)] opacity-70"
>(filtrado)</span>
</div>
<span class="inline-flex items-center justify-center min-w-[22px] h-[22px] px-1.5 rounded-full bg-[var(--primary-color,#6366f1)] text-white text-[1rem] font-bold">
{{ sessionsFiltradas.length }}
</span>
</div>
<!-- Empty state (sem dados no período) -->
<div
v-if="!sessions.length"
class="flex flex-col items-center justify-center gap-3 py-14 px-6 text-center border-2 border-dashed border-[var(--surface-border,#e2e8f0)] mx-4 my-4 rounded-md bg-[var(--surface-ground,#f8fafc)]"
>
<div class="relative">
<div class="grid place-items-center w-16 h-16 rounded-2xl bg-[var(--surface-card,#fff)] border border-[var(--surface-border,#e2e8f0)] shadow-sm text-[var(--text-color-secondary)]">
<i class="pi pi-chart-bar text-3xl opacity-25" />
</div>
<div class="absolute -top-1.5 -right-1.5 w-6 h-6 rounded-full bg-[var(--surface-card,#fff)] border border-[var(--surface-border,#e2e8f0)] shadow-sm grid place-items-center">
<i class="pi pi-times text-[0.58rem] text-[var(--text-color-secondary)] opacity-50" />
</div>
</div>
<div>
<div class="font-bold text-[0.9rem] text-[var(--text-color)] mb-0.5">Nenhuma sessão no período</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] max-w-xs">Tente selecionar um período diferente.</div>
</div>
<div class="flex flex-wrap gap-2 mt-1 justify-center">
<button
v-for="p in PERIODS"
:key="p.value"
class="inline-flex items-center px-3 py-1 rounded-full text-[0.75rem] font-semibold border cursor-pointer transition-colors duration-150"
:class="selectedPeriod === p.value
? 'bg-[var(--primary-color,#6366f1)] border-[var(--primary-color,#6366f1)] text-white'
: 'border-[var(--surface-border,#e2e8f0)] text-[var(--text-color-secondary)] hover:border-indigo-300'"
@click="selectedPeriod = p.value"
>
{{ p.label }}
</button>
</div>
</div>
<!-- Empty state (filtro sem resultado) -->
<div
v-else-if="!sessionsFiltradas.length"
class="flex flex-col items-center gap-2 py-10 text-center text-[var(--text-color-secondary)]"
>
<i class="pi pi-filter-slash text-2xl opacity-30" />
<div class="font-semibold text-[0.88rem]">Nenhuma sessão com este status</div>
<Button label="Limpar filtro" icon="pi pi-times" severity="secondary" outlined size="small" class="rounded-full mt-1" @click="filtroTabela = null" />
</div>
<!-- DataTable -->
<DataTable
v-else
:value="sessionsFiltradas"
:rows="20"
paginator
:rows-per-page-options="[10, 20, 50]"
scrollable
scroll-height="480px"
class="rel-datatable"
>
<Column field="inicio_em" header="Data / Hora" :sortable="true" style="min-width: 140px">
<template #body="{ data }">
<span class="font-medium">{{ fmtDateTimeBR(data.inicio_em) }}</span>
</template>
</Column>
<Column header="Paciente" style="min-width: 160px">
<template #body="{ data }">{{ patientName(data) }}</template>
</Column>
<Column header="Sessão" style="min-width: 160px">
<template #body="{ data }">{{ sessionTitle(data) }}</template>
</Column>
<Column field="modalidade" header="Modalidade" style="min-width: 110px">
<template #body="{ data }">
{{ data.modalidade === 'online' ? 'Online' : data.modalidade === 'presencial' ? 'Presencial' : data.modalidade || '—' }}
</template>
</Column>
<Column field="status" header="Status" style="min-width: 110px">
<template #body="{ data }">
<Tag
:value="STATUS_LABEL[data.status] || data.status || 'Agendado'"
:severity="STATUS_SEVERITY[data.status] || 'info'"
/>
</template>
</Column>
</DataTable>
</div>
<LoadedPhraseBlock v-if="hasLoaded" />
</template>
</div>
</template>
<style scoped>
.rel-datatable :deep(.p-datatable-table-container) { border-radius: 0; }
.rel-datatable :deep(th) { background: var(--surface-ground) !important; font-size: 0.82rem; }
.rel-datatable :deep(td) { font-size: 0.85rem; }
</style>
.rel-datatable :deep(.p-datatable-table-container) {
border-radius: 0;
}
.rel-datatable :deep(th) {
background: var(--surface-ground) !important;
font-size: 0.82rem;
}
.rel-datatable :deep(td) {
font-size: 0.85rem;
}
</style>
File diff suppressed because it is too large Load Diff
@@ -14,12 +14,12 @@
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
-->
<template>
<div class="p-4">
<h1>My Appointments</h1>
</div>
</template>
<script setup>
// temporary placeholder
</script>
</script>
<template>
<div class="p-4">
<h1>My Appointments</h1>
</div>
</template>
@@ -14,12 +14,12 @@
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
-->
<template>
<div class="p-4">
<h1>Add New Appointments</h1>
</div>
</template>
<script setup>
// temporary placeholder
</script>
</script>
<template>
<div class="p-4">
<h1>Add New Appointments</h1>
</div>
</template>