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:
+1214
-830
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
|
||||
+1316
-1376
File diff suppressed because it is too large
Load Diff
+431
-522
File diff suppressed because it is too large
Load Diff
@@ -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: 0–4
|
||||
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 já 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 já 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>
|
||||
|
||||
@@ -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
@@ -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ê já pode entrar. Se o seu plano for PRO, ele será ativado após confirmação do pagamento.
|
||||
</div>
|
||||
|
||||
<div class="mt-5">
|
||||
<Message severity="success" class="mb-3">
|
||||
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ê já pode entrar. Se o seu plano for PRO, ele será ativado após confirmação do pagamento.</div>
|
||||
|
||||
<div class="mt-5">
|
||||
<Message severity="success" class="mb-3"> 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
@@ -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
@@ -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 cá.</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 é só o toggle.
|
||||
Depois a gente cria:
|
||||
<ul class="mt-2 list-disc pl-5 space-y-1">
|
||||
<li>role <span class="font-mono">secretary</span> em <span class="font-mono">tenant_members</span></li>
|
||||
<li>policies e telas para a secretária</li>
|
||||
<li>nível de visibilidade do paciente na agenda</li>
|
||||
</ul>
|
||||
</div>
|
||||
</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ê já 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 cá.</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 é só o toggle. Depois a gente cria:
|
||||
<ul class="mt-2 list-disc pl-5 space-y-1">
|
||||
<li>role <span class="font-mono">secretary</span> em <span class="font-mono">tenant_members</span></li>
|
||||
<li>policies e telas para a secretária</li>
|
||||
<li>nível de visibilidade do paciente na agenda</li>
|
||||
</ul>
|
||||
</div>
|
||||
</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ê já 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
@@ -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>
|
||||
|
||||
@@ -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ê só 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>Vá 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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 />
|
||||
|
||||
|
||||
@@ -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 v1–v5 (aceita maiúsculas/minúsculas)
|
||||
return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(v)
|
||||
function isUuid(v) {
|
||||
// UUID v1–v5 (aceita maiúsculas/minúsculas)
|
||||
return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(v);
|
||||
}
|
||||
|
||||
function friendlyError (err) {
|
||||
const msg = (err?.message || err || '').toString()
|
||||
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
@@ -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 só: pacientes, sessões, lembretes e indicadores. Menos dispersão.
|
||||
Mais presença.
|
||||
</p>
|
||||
|
||||
<div class="mt-6 flex flex-col sm:flex-row gap-2">
|
||||
<Button
|
||||
label="Criar conta grátis"
|
||||
icon="pi pi-arrow-right"
|
||||
class="w-full sm:w-auto"
|
||||
@click="goStart()"
|
||||
/>
|
||||
<Button
|
||||
label="Ver planos"
|
||||
icon="pi pi-credit-card"
|
||||
severity="secondary"
|
||||
outlined
|
||||
class="w-full sm:w-auto"
|
||||
@click="scrollTo('pricing')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex flex-wrap gap-2">
|
||||
<Tag severity="secondary" value="Agenda online (PRO)" />
|
||||
<Tag severity="secondary" value="Controle de sessões" />
|
||||
<Tag severity="secondary" value="Financeiro integrado" />
|
||||
<Tag severity="secondary" value="Clínica / multi-profissional" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-span-12 lg:col-span-5">
|
||||
<Card class="overflow-hidden">
|
||||
<template #content>
|
||||
<div class="p-1">
|
||||
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-ground)] p-4">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div class="font-semibold text-lg">Painel de hoje</div>
|
||||
<div class="text-sm text-[var(--text-color-secondary)]">Um recorte: o essencial, sem excesso.</div>
|
||||
</div>
|
||||
<i class="pi pi-chart-line opacity-70" />
|
||||
</div>
|
||||
|
||||
<Divider class="my-4" />
|
||||
|
||||
<div class="grid grid-cols-12 gap-3">
|
||||
<div class="col-span-12 md:col-span-6">
|
||||
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-3">
|
||||
<div class="text-xs text-[var(--text-color-secondary)]">Sessões</div>
|
||||
<div class="text-2xl font-semibold mt-1">6</div>
|
||||
<div class="text-xs text-[var(--text-color-secondary)] mt-1">com lembretes automáticos</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-span-12 md:col-span-6">
|
||||
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-3">
|
||||
<div class="text-xs text-[var(--text-color-secondary)]">Recebimentos</div>
|
||||
<div class="text-2xl font-semibold mt-1">R$ 840</div>
|
||||
<div class="text-xs text-[var(--text-color-secondary)] mt-1">visão clara do mês</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-span-12">
|
||||
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<div class="text-xs text-[var(--text-color-secondary)]">Prontuário</div>
|
||||
<div class="font-semibold mt-1">Anotações e histórico</div>
|
||||
<div class="text-xs text-[var(--text-color-secondary)] mt-1">
|
||||
organizado por paciente, sessão e linha do tempo
|
||||
</div>
|
||||
</div>
|
||||
<i class="pi pi-file-edit opacity-70" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 text-xs text-[var(--text-color-secondary)]">
|
||||
* Ilustração conceitual do produto.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- TRUST / VALUE STRIP -->
|
||||
<section class="mx-auto max-w-7xl px-4 md:px-6 pb-10">
|
||||
<div class="grid grid-cols-12 gap-4">
|
||||
<div class="col-span-12 md:col-span-4">
|
||||
<Card class="h-full">
|
||||
<template #content>
|
||||
<div class="flex items-start gap-3">
|
||||
<div
|
||||
class="h-10 w-10 rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-ground)] grid place-items-center"
|
||||
>
|
||||
<i class="pi pi-calendar opacity-80" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-semibold">Agenda e autoagendamento</div>
|
||||
<div class="text-sm text-[var(--text-color-secondary)] mt-1">
|
||||
O paciente confirma, agenda e reagenda com autonomia (PRO).
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div class="col-span-12 md:col-span-4">
|
||||
<Card class="h-full">
|
||||
<template #content>
|
||||
<div class="flex items-start gap-3">
|
||||
<div
|
||||
class="h-10 w-10 rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-ground)] grid place-items-center"
|
||||
>
|
||||
<i class="pi pi-wallet opacity-80" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-semibold">Financeiro integrado</div>
|
||||
<div class="text-sm text-[var(--text-color-secondary)] mt-1">
|
||||
Receita/despesa junto da agenda — sem planilhas espalhadas.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div class="col-span-12 md:col-span-4">
|
||||
<Card class="h-full">
|
||||
<template #content>
|
||||
<div class="flex items-start gap-3">
|
||||
<div
|
||||
class="h-10 w-10 rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-ground)] grid place-items-center"
|
||||
>
|
||||
<i class="pi pi-lock opacity-80" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-semibold">Prontuário e controle de sessões</div>
|
||||
<div class="text-sm text-[var(--text-color-secondary)] mt-1">
|
||||
Registro clínico e histórico acessíveis, com backups e organização.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 text-xs text-[var(--text-color-secondary)]">
|
||||
Inspirações de módulos comuns no mercado: agenda online, financeiro, prontuário/controle de sessões e gestão de
|
||||
clínica.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- FEATURES -->
|
||||
<section class="mx-auto max-w-7xl px-4 md:px-6 pb-12">
|
||||
<div class="flex items-end justify-between gap-3 mb-4">
|
||||
<div>
|
||||
<div class="text-2xl md:text-3xl font-semibold">Recursos que sustentam a rotina</div>
|
||||
<div class="text-sm text-[var(--text-color-secondary)] mt-1">
|
||||
O foco é tirar o excesso de fricção sem invadir o que é do seu método.
|
||||
</div>
|
||||
</div>
|
||||
<Button label="Ver planos" severity="secondary" outlined icon="pi pi-arrow-down" @click="scrollTo('pricing')" />
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-12 gap-4">
|
||||
<div v-for="f in features" :key="f.title" class="col-span-12 md:col-span-6 lg:col-span-4">
|
||||
<Card class="h-full">
|
||||
<template #content>
|
||||
<div class="flex items-start gap-3">
|
||||
<div
|
||||
class="h-10 w-10 rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-ground)] grid place-items-center"
|
||||
>
|
||||
<i :class="f.icon" class="opacity-80" />
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<div class="font-semibold">{{ f.title }}</div>
|
||||
<div class="text-sm text-[var(--text-color-secondary)] mt-1">
|
||||
{{ f.desc }}
|
||||
</div>
|
||||
<div v-if="f.pro" class="mt-2">
|
||||
<Tag severity="warning" value="PRO" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Divider class="my-8" />
|
||||
|
||||
<Accordion :activeIndex="0">
|
||||
<AccordionTab header="Como fica o fluxo na prática?">
|
||||
<div class="text-sm text-[var(--text-color-secondary)] leading-relaxed">
|
||||
Você abre a agenda, a sessão acontece, o registro fica no prontuário, e o financeiro acompanha o movimento.
|
||||
O sistema existe para manter o consultório respirando — não para virar uma burocracia nova.
|
||||
</div>
|
||||
</AccordionTab>
|
||||
<AccordionTab header="E para clínica (multi-profissionais)?">
|
||||
<div class="text-sm text-[var(--text-color-secondary)] leading-relaxed">
|
||||
Perfis por função, agendas separadas, repasses e visão gerencial — quando você estiver pronto para crescer.
|
||||
</div>
|
||||
</AccordionTab>
|
||||
<AccordionTab header="Privacidade e segurança">
|
||||
<div class="text-sm text-[var(--text-color-secondary)] leading-relaxed">
|
||||
Controle de acesso por conta, separação por clínica/tenant, e políticas de storage por usuário. (Os detalhes
|
||||
de conformidade você pode expor numa página própria de segurança/LGPD.)
|
||||
</div>
|
||||
</AccordionTab>
|
||||
</Accordion>
|
||||
</section>
|
||||
|
||||
<!-- PRICING (dinâmico do SaaS) -->
|
||||
<section id="pricing" class="mx-auto max-w-7xl px-4 md:px-6 pb-14 scroll-mt-24">
|
||||
<div class="text-5xl md:text-4xl font-semibold text-center">Planos</div>
|
||||
<div class="text-2xl md:text-2xl text-[var(--text-color-secondary)] mt-1 text-center">
|
||||
Comece simples. Suba para PRO quando a agenda pedir automação.
|
||||
</div>
|
||||
|
||||
<!-- header conceitual + toggle -->
|
||||
<div class="flex flex-col items-center text-center mt-6">
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<AvatarGroup>
|
||||
<Avatar
|
||||
image="https://fqjltiegiezfetthbags.supabase.co/storage/v1/render/image/public/block.images/blocks/avatars/circle/avatar-m-1.png"
|
||||
shape="circle"
|
||||
/>
|
||||
<Avatar
|
||||
image="https://fqjltiegiezfetthbags.supabase.co/storage/v1/render/image/public/block.images/blocks/avatars/circle/avatar-f-21.png"
|
||||
shape="circle"
|
||||
/>
|
||||
<Avatar
|
||||
image="https://fqjltiegiezfetthbags.supabase.co/storage/v1/render/image/public/block.images/blocks/avatars/circle/avatar-f-1.png"
|
||||
shape="circle"
|
||||
/>
|
||||
<Avatar
|
||||
image="https://fqjltiegiezfetthbags.supabase.co/storage/v1/render/image/public/block.images/blocks/avatars/circle/avatar-m-3.png"
|
||||
shape="circle"
|
||||
/>
|
||||
</AvatarGroup>
|
||||
|
||||
<Divider layout="vertical" />
|
||||
<span class="text-sm text-[var(--text-color-secondary)] font-medium">Happy Customers</span>
|
||||
</div>
|
||||
|
||||
<div class="inline-flex items-center rounded-xl border border-[var(--surface-border)] bg-[var(--surface-50)] p-1">
|
||||
<Button
|
||||
label="Mensal"
|
||||
size="small"
|
||||
:severity="billingInterval === 'month' ? 'success' : 'secondary'"
|
||||
:outlined="billingInterval !== 'month'"
|
||||
@click="billingInterval = 'month'"
|
||||
/>
|
||||
<Button
|
||||
label="Anual"
|
||||
size="small"
|
||||
:severity="billingInterval === 'year' ? 'success' : 'secondary'"
|
||||
:outlined="billingInterval !== 'year'"
|
||||
class="ml-1"
|
||||
@click="billingInterval = 'year'"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="billingInterval === 'year'" class="mt-2">
|
||||
<Tag severity="success" value="Economize até 20%" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loadingPricing" class="mt-8 text-sm text-[var(--text-color-secondary)]">
|
||||
Carregando planos...
|
||||
</div>
|
||||
|
||||
<div v-else class="mt-8 grid grid-cols-12 gap-4">
|
||||
<div v-for="p in pricing" :key="p.plan_id" class="col-span-12 md:col-span-4">
|
||||
<Card
|
||||
class="h-full overflow-hidden transition-transform"
|
||||
:class="p.is_featured ? 'ring-1 ring-emerald-500/30 md:-translate-y-2 md:scale-[1.02]' : ''"
|
||||
>
|
||||
<template #content>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div class="text-sm text-[var(--text-color-secondary)]">
|
||||
{{ p.badge || 'Plano' }}
|
||||
</div>
|
||||
<div class="text-xl font-semibold">
|
||||
{{ p.public_name || p.plan_name || p.plan_key }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tag v-if="p.is_featured" severity="success" value="Popular" />
|
||||
</div>
|
||||
|
||||
<div class="mt-4 text-3xl font-semibold leading-none">
|
||||
{{ formatBRLFromCents(priceFor(p)) }}
|
||||
<span class="text-sm font-normal text-[var(--text-color-secondary)]">
|
||||
/{{ billingInterval === 'month' ? 'mês' : 'ano' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="billingInterval === 'year'"
|
||||
class="text-xs text-emerald-500 mt-1 font-medium"
|
||||
>
|
||||
Melhor custo-benefício
|
||||
</div>
|
||||
|
||||
<div class="mt-2 text-sm text-[var(--text-color-secondary)] min-h-[44px]">
|
||||
{{ p.public_description }}
|
||||
</div>
|
||||
|
||||
<Divider class="my-4" />
|
||||
|
||||
<ul v-if="p.bullets?.length" class="space-y-2 text-sm">
|
||||
<li v-for="b in p.bullets" :key="b.id" class="flex items-start gap-2">
|
||||
<i class="pi pi-check mt-1 text-emerald-500"></i>
|
||||
<span :class="b.highlight ? 'font-semibold' : 'text-[var(--text-color-secondary)]'">
|
||||
{{ b.text }}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div v-else class="text-sm text-[var(--text-color-secondary)]">Benefícios em breve.</div>
|
||||
|
||||
<div class="mt-5">
|
||||
<Button
|
||||
label="Começar"
|
||||
class="w-full"
|
||||
:severity="p.is_featured ? 'success' : 'secondary'"
|
||||
:outlined="!p.is_featured"
|
||||
icon="pi pi-arrow-right"
|
||||
@click="go(`/auth/signup?plan=${p.plan_key}&interval=${billingInterval}`)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 text-xs text-[var(--text-color-secondary)]">
|
||||
Dica: estes planos vêm do painel SaaS (vitrine pública) e podem mapear diretamente para entitlements (FREE/PRO)
|
||||
sem mexer no código.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- FOOTER -->
|
||||
<footer class="border-t border-[var(--surface-border)]">
|
||||
<div
|
||||
class="mx-auto max-w-7xl px-4 md:px-6 py-8 flex flex-col md:flex-row items-start md:items-center justify-between gap-4"
|
||||
>
|
||||
<div>
|
||||
<div class="font-semibold">{{ brandName }}</div>
|
||||
<div class="text-xs text-[var(--text-color-secondary)] mt-1">© {{ year }} — Todos os direitos reservados.</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Button label="Entrar" severity="secondary" outlined icon="pi pi-sign-in" @click="go('/auth/login')" />
|
||||
<Button label="Criar conta" icon="pi pi-bolt" @click="goStart()" />
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { supabase } from '@/lib/supabase/client'
|
||||
import { 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 só: pacientes, sessões, lembretes e indicadores. Menos dispersão. Mais presença.</p>
|
||||
|
||||
<div class="mt-6 flex flex-col sm:flex-row gap-2">
|
||||
<Button label="Criar conta grátis" icon="pi pi-arrow-right" class="w-full sm:w-auto" @click="goStart()" />
|
||||
<Button label="Ver planos" icon="pi pi-credit-card" severity="secondary" outlined class="w-full sm:w-auto" @click="scrollTo('pricing')" />
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex flex-wrap gap-2">
|
||||
<Tag severity="secondary" value="Agenda online (PRO)" />
|
||||
<Tag severity="secondary" value="Controle de sessões" />
|
||||
<Tag severity="secondary" value="Financeiro integrado" />
|
||||
<Tag severity="secondary" value="Clínica / multi-profissional" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-span-12 lg:col-span-5">
|
||||
<Card class="overflow-hidden">
|
||||
<template #content>
|
||||
<div class="p-1">
|
||||
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-ground)] p-4">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div class="font-semibold text-lg">Painel de hoje</div>
|
||||
<div class="text-sm text-[var(--text-color-secondary)]">Um recorte: o essencial, sem excesso.</div>
|
||||
</div>
|
||||
<i class="pi pi-chart-line opacity-70" />
|
||||
</div>
|
||||
|
||||
<Divider class="my-4" />
|
||||
|
||||
<div class="grid grid-cols-12 gap-3">
|
||||
<div class="col-span-12 md:col-span-6">
|
||||
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-3">
|
||||
<div class="text-xs text-[var(--text-color-secondary)]">Sessões</div>
|
||||
<div class="text-2xl font-semibold mt-1">6</div>
|
||||
<div class="text-xs text-[var(--text-color-secondary)] mt-1">com lembretes automáticos</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-span-12 md:col-span-6">
|
||||
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-3">
|
||||
<div class="text-xs text-[var(--text-color-secondary)]">Recebimentos</div>
|
||||
<div class="text-2xl font-semibold mt-1">R$ 840</div>
|
||||
<div class="text-xs text-[var(--text-color-secondary)] mt-1">visão clara do mês</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-span-12">
|
||||
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<div class="text-xs text-[var(--text-color-secondary)]">Prontuário</div>
|
||||
<div class="font-semibold mt-1">Anotações e histórico</div>
|
||||
<div class="text-xs text-[var(--text-color-secondary)] mt-1">organizado por paciente, sessão e linha do tempo</div>
|
||||
</div>
|
||||
<i class="pi pi-file-edit opacity-70" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 text-xs text-[var(--text-color-secondary)]">* Ilustração conceitual do produto.</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- TRUST / VALUE STRIP -->
|
||||
<section class="mx-auto max-w-7xl px-4 md:px-6 pb-10">
|
||||
<div class="grid grid-cols-12 gap-4">
|
||||
<div class="col-span-12 md:col-span-4">
|
||||
<Card class="h-full">
|
||||
<template #content>
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="h-10 w-10 rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-ground)] grid place-items-center">
|
||||
<i class="pi pi-calendar opacity-80" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-semibold">Agenda e autoagendamento</div>
|
||||
<div class="text-sm text-[var(--text-color-secondary)] mt-1">O paciente confirma, agenda e reagenda com autonomia (PRO).</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div class="col-span-12 md:col-span-4">
|
||||
<Card class="h-full">
|
||||
<template #content>
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="h-10 w-10 rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-ground)] grid place-items-center">
|
||||
<i class="pi pi-wallet opacity-80" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-semibold">Financeiro integrado</div>
|
||||
<div class="text-sm text-[var(--text-color-secondary)] mt-1">Receita/despesa junto da agenda — sem planilhas espalhadas.</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div class="col-span-12 md:col-span-4">
|
||||
<Card class="h-full">
|
||||
<template #content>
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="h-10 w-10 rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-ground)] grid place-items-center">
|
||||
<i class="pi pi-lock opacity-80" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-semibold">Prontuário e controle de sessões</div>
|
||||
<div class="text-sm text-[var(--text-color-secondary)] mt-1">Registro clínico e histórico acessíveis, com backups e organização.</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 text-xs text-[var(--text-color-secondary)]">Inspirações de módulos comuns no mercado: agenda online, financeiro, prontuário/controle de sessões e gestão de clínica.</div>
|
||||
</section>
|
||||
|
||||
<!-- FEATURES -->
|
||||
<section class="mx-auto max-w-7xl px-4 md:px-6 pb-12">
|
||||
<div class="flex items-end justify-between gap-3 mb-4">
|
||||
<div>
|
||||
<div class="text-2xl md:text-3xl font-semibold">Recursos que sustentam a rotina</div>
|
||||
<div class="text-sm text-[var(--text-color-secondary)] mt-1">O foco é tirar o excesso de fricção sem invadir o que é do seu método.</div>
|
||||
</div>
|
||||
<Button label="Ver planos" severity="secondary" outlined icon="pi pi-arrow-down" @click="scrollTo('pricing')" />
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-12 gap-4">
|
||||
<div v-for="f in features" :key="f.title" class="col-span-12 md:col-span-6 lg:col-span-4">
|
||||
<Card class="h-full">
|
||||
<template #content>
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="h-10 w-10 rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-ground)] grid place-items-center">
|
||||
<i :class="f.icon" class="opacity-80" />
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<div class="font-semibold">{{ f.title }}</div>
|
||||
<div class="text-sm text-[var(--text-color-secondary)] mt-1">
|
||||
{{ f.desc }}
|
||||
</div>
|
||||
<div v-if="f.pro" class="mt-2">
|
||||
<Tag severity="warning" value="PRO" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Divider class="my-8" />
|
||||
|
||||
<Accordion :activeIndex="0">
|
||||
<AccordionTab header="Como fica o fluxo na prática?">
|
||||
<div class="text-sm text-[var(--text-color-secondary)] leading-relaxed">
|
||||
Você abre a agenda, a sessão acontece, o registro fica no prontuário, e o financeiro acompanha o movimento. O sistema existe para manter o consultório respirando — não para virar uma burocracia nova.
|
||||
</div>
|
||||
</AccordionTab>
|
||||
<AccordionTab header="E para clínica (multi-profissionais)?">
|
||||
<div class="text-sm text-[var(--text-color-secondary)] leading-relaxed">Perfis por função, agendas separadas, repasses e visão gerencial — quando você estiver pronto para crescer.</div>
|
||||
</AccordionTab>
|
||||
<AccordionTab header="Privacidade e segurança">
|
||||
<div class="text-sm text-[var(--text-color-secondary)] leading-relaxed">
|
||||
Controle de acesso por conta, separação por clínica/tenant, e políticas de storage por usuário. (Os detalhes de conformidade você pode expor numa página própria de segurança/LGPD.)
|
||||
</div>
|
||||
</AccordionTab>
|
||||
</Accordion>
|
||||
</section>
|
||||
|
||||
<!-- PRICING (dinâmico do SaaS) -->
|
||||
<section id="pricing" class="mx-auto max-w-7xl px-4 md:px-6 pb-14 scroll-mt-24">
|
||||
<div class="text-5xl md:text-4xl font-semibold text-center">Planos</div>
|
||||
<div class="text-2xl md:text-2xl text-[var(--text-color-secondary)] mt-1 text-center">Comece simples. Suba para PRO quando a agenda pedir automação.</div>
|
||||
|
||||
<!-- header conceitual + toggle -->
|
||||
<div class="flex flex-col items-center text-center mt-6">
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<AvatarGroup>
|
||||
<Avatar image="https://fqjltiegiezfetthbags.supabase.co/storage/v1/render/image/public/block.images/blocks/avatars/circle/avatar-m-1.png" shape="circle" />
|
||||
<Avatar image="https://fqjltiegiezfetthbags.supabase.co/storage/v1/render/image/public/block.images/blocks/avatars/circle/avatar-f-21.png" shape="circle" />
|
||||
<Avatar image="https://fqjltiegiezfetthbags.supabase.co/storage/v1/render/image/public/block.images/blocks/avatars/circle/avatar-f-1.png" shape="circle" />
|
||||
<Avatar image="https://fqjltiegiezfetthbags.supabase.co/storage/v1/render/image/public/block.images/blocks/avatars/circle/avatar-m-3.png" shape="circle" />
|
||||
</AvatarGroup>
|
||||
|
||||
<Divider layout="vertical" />
|
||||
<span class="text-sm text-[var(--text-color-secondary)] font-medium">Happy Customers</span>
|
||||
</div>
|
||||
|
||||
<div class="inline-flex items-center rounded-xl border border-[var(--surface-border)] bg-[var(--surface-50)] p-1">
|
||||
<Button label="Mensal" size="small" :severity="billingInterval === 'month' ? 'success' : 'secondary'" :outlined="billingInterval !== 'month'" @click="billingInterval = 'month'" />
|
||||
<Button label="Anual" size="small" :severity="billingInterval === 'year' ? 'success' : 'secondary'" :outlined="billingInterval !== 'year'" class="ml-1" @click="billingInterval = 'year'" />
|
||||
</div>
|
||||
<div v-if="billingInterval === 'year'" class="mt-2">
|
||||
<Tag severity="success" value="Economize até 20%" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loadingPricing" class="mt-8 text-sm text-[var(--text-color-secondary)]">Carregando planos...</div>
|
||||
|
||||
<div v-else class="mt-8 grid grid-cols-12 gap-4">
|
||||
<div v-for="p in pricing" :key="p.plan_id" class="col-span-12 md:col-span-4">
|
||||
<Card class="h-full overflow-hidden transition-transform" :class="p.is_featured ? 'ring-1 ring-emerald-500/30 md:-translate-y-2 md:scale-[1.02]' : ''">
|
||||
<template #content>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div class="text-sm text-[var(--text-color-secondary)]">
|
||||
{{ p.badge || 'Plano' }}
|
||||
</div>
|
||||
<div class="text-xl font-semibold">
|
||||
{{ p.public_name || p.plan_name || p.plan_key }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tag v-if="p.is_featured" severity="success" value="Popular" />
|
||||
</div>
|
||||
|
||||
<div class="mt-4 text-3xl font-semibold leading-none">
|
||||
{{ formatBRLFromCents(priceFor(p)) }}
|
||||
<span class="text-sm font-normal text-[var(--text-color-secondary)]"> /{{ billingInterval === 'month' ? 'mês' : 'ano' }} </span>
|
||||
</div>
|
||||
|
||||
<div v-if="billingInterval === 'year'" class="text-xs text-emerald-500 mt-1 font-medium">Melhor custo-benefício</div>
|
||||
|
||||
<div class="mt-2 text-sm text-[var(--text-color-secondary)] min-h-[44px]">
|
||||
{{ p.public_description }}
|
||||
</div>
|
||||
|
||||
<Divider class="my-4" />
|
||||
|
||||
<ul v-if="p.bullets?.length" class="space-y-2 text-sm">
|
||||
<li v-for="b in p.bullets" :key="b.id" class="flex items-start gap-2">
|
||||
<i class="pi pi-check mt-1 text-emerald-500"></i>
|
||||
<span :class="b.highlight ? 'font-semibold' : 'text-[var(--text-color-secondary)]'">
|
||||
{{ b.text }}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div v-else class="text-sm text-[var(--text-color-secondary)]">Benefícios em breve.</div>
|
||||
|
||||
<div class="mt-5">
|
||||
<Button
|
||||
label="Começar"
|
||||
class="w-full"
|
||||
:severity="p.is_featured ? 'success' : 'secondary'"
|
||||
:outlined="!p.is_featured"
|
||||
icon="pi pi-arrow-right"
|
||||
@click="go(`/auth/signup?plan=${p.plan_key}&interval=${billingInterval}`)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 text-xs text-[var(--text-color-secondary)]">Dica: estes planos vêm do painel SaaS (vitrine pública) e podem mapear diretamente para entitlements (FREE/PRO) sem mexer no código.</div>
|
||||
</section>
|
||||
|
||||
<!-- FOOTER -->
|
||||
<footer class="border-t border-[var(--surface-border)]">
|
||||
<div class="mx-auto max-w-7xl px-4 md:px-6 py-8 flex flex-col md:flex-row items-start md:items-center justify-between gap-4">
|
||||
<div>
|
||||
<div class="font-semibold">{{ brandName }}</div>
|
||||
<div class="text-xs text-[var(--text-color-secondary)] mt-1">© {{ year }} — Todos os direitos reservados.</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Button label="Entrar" severity="secondary" outlined icon="pi pi-sign-in" @click="go('/auth/login')" />
|
||||
<Button label="Criar conta" icon="pi pi-bolt" @click="goStart()" />
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
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
@@ -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">
|
||||
Já 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">
|
||||
Já 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">
|
||||
Já 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"> Já 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>
|
||||
|
||||
@@ -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> já 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> já 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
+909
-1039
File diff suppressed because it is too large
Load Diff
@@ -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>{{variavel}}</code> e
|
||||
<code>{{#if variavel}}...{{/if}}</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 só 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>{{variavel}}</code> e
|
||||
<code>{{#if variavel}}...{{/if}}</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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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>
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user