Layout 100%, Notificações, SetupWizard

This commit is contained in:
Leonardo
2026-03-17 21:08:14 -03:00
parent 84d65e49c0
commit 66f67cd40f
77 changed files with 35823 additions and 15023 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -30,7 +30,7 @@ const loadingRecovery = ref(false)
const recoverySent = ref(false)
// carrossel
const slides = [
const SLIDES_FALLBACK = [
{
title: 'Gestão clínica simplificada',
body: 'Agendamentos, prontuários e sessões em um único painel. Foco no que importa: seus pacientes.',
@@ -53,6 +53,8 @@ const slides = [
},
]
const slides = ref(SLIDES_FALLBACK)
const currentSlide = ref(0)
let slideInterval = null
@@ -62,7 +64,7 @@ function goToSlide (i) {
function startCarousel () {
slideInterval = setInterval(() => {
currentSlide.value = (currentSlide.value + 1) % slides.length
currentSlide.value = (currentSlide.value + 1) % slides.value.length
}, 4500)
}
@@ -70,6 +72,21 @@ function stopCarousel () {
if (slideInterval) clearInterval(slideInterval)
}
async function loadCarouselSlides () {
try {
const { data, error } = await supabase
.from('login_carousel_slides')
.select('title, body, icon')
.eq('ativo', true)
.order('ordem', { ascending: true })
if (!error && data && data.length > 0) {
slides.value = data
}
} catch {
// mantém fallback
}
}
const canSubmit = computed(() => {
return !!email.value?.trim() && !!password.value && !loading.value && !loadingRecovery.value
})
@@ -266,7 +283,9 @@ async function sendRecoveryEmail () {
}
}
onMounted(() => {
onMounted(async () => {
await loadCarouselSlides()
const preEmail = sessionStorage.getItem('login_prefill_email')
const prePass = sessionStorage.getItem('login_prefill_password')
@@ -332,12 +351,12 @@ onBeforeUnmount(() => {
</div>
<div class="space-y-4">
<h2 class="text-3xl xl:text-4xl font-bold text-white leading-tight">
{{ slides[currentSlide].title }}
</h2>
<p class="text-base xl:text-lg text-white/70 leading-relaxed max-w-sm">
{{ slides[currentSlide].body }}
</p>
<div class="text-3xl xl:text-4xl font-bold text-white leading-tight prose prose-invert prose-xl max-w-none"
v-html="slides[currentSlide].title"
/>
<div class="text-base xl:text-lg text-white/70 leading-relaxed max-w-sm prose prose-invert max-w-none"
v-html="slides[currentSlide].body"
/>
</div>
</div>
</Transition>

View File

@@ -149,25 +149,36 @@ async function sendResetEmail () {
<Toast />
<!-- Sentinel -->
<div ref="headerSentinelRef" class="sec-sentinel" />
<div ref="headerSentinelRef" class="h-px" />
<!-- Hero sticky -->
<div ref="headerEl" class="sec-hero w-full max-w-2xl mx-auto px-3 md:px-5 mb-4" :class="{ 'sec-hero--stuck': headerStuck }">
<div class="sec-hero__blobs" aria-hidden="true">
<div class="sec-hero__blob sec-hero__blob--1" />
<div class="sec-hero__blob sec-hero__blob--2" />
<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="sec-hero__row1">
<div class="sec-hero__brand">
<div class="sec-hero__icon"><i class="pi pi-shield text-lg" /></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="sec-hero__title">Segurança</div>
<div class="sec-hero__sub">Gerencie o acesso e a senha da sua conta</div>
<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-xs px-3 py-1.5 rounded-full border border-emerald-200 text-emerald-700 bg-emerald-50 shrink-0">
<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>
@@ -178,18 +189,18 @@ async function sendResetEmail () {
<div class="w-full max-w-2xl space-y-4">
<!-- Card principal -->
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] overflow-hidden">
<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>
<p class="text-sm font-semibold text-[var(--text-color)]">Trocar senha</p>
<p class="text-xs text-[var(--text-color-secondary)] mt-0.5">
<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.
</p>
</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-xs text-[var(--text-color-secondary)]">
<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>
@@ -202,8 +213,8 @@ async function sendResetEmail () {
<i class="pi pi-check text-emerald-500 text-2xl" />
</div>
<div>
<p class="font-semibold text-[var(--text-color)]">Senha atualizada!</p>
<p class="text-sm text-[var(--text-color-secondary)] mt-1">Redirecionando para o login</p>
<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>
@@ -212,9 +223,7 @@ async function sendResetEmail () {
<!-- Senha atual -->
<div>
<label class="block text-sm font-semibold text-[var(--text-color)] mb-1.5">
Senha atual
</label>
<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"
@@ -224,18 +233,16 @@ async function sendResetEmail () {
inputClass="w-full"
:disabled="loading || loadingReset"
/>
<p class="mt-1.5 text-xs text-[var(--text-color-secondary)]">
<div class="mt-1.5 text-[1rem] text-[var(--text-color-secondary)]">
Necessária para confirmar que é você.
</p>
</div>
</div>
<div class="h-px bg-[var(--surface-border)]" />
<!-- Nova senha -->
<div>
<label class="block text-sm font-semibold text-[var(--text-color)] mb-1.5">
Nova senha
</label>
<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"
@@ -256,18 +263,16 @@ async function sendResetEmail () {
:class="i <= strengthScore ? strengthColor : 'bg-[var(--surface-border)]'"
/>
</div>
<span class="text-xs" :class="strengthTextColor">{{ strengthLabel }}</span>
<span class="text-[1rem]" :class="strengthTextColor">{{ strengthLabel }}</span>
</div>
<p v-else class="mt-1.5 text-xs text-[var(--text-color-secondary)]">
<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.
</p>
</div>
</div>
<!-- Confirmar senha -->
<div>
<label class="block text-sm font-semibold text-[var(--text-color)] mb-1.5">
Confirmar nova senha
</label>
<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"
@@ -277,7 +282,9 @@ async function sendResetEmail () {
inputClass="w-full"
:disabled="loading || loadingReset"
/>
<div v-if="confirmPassword" class="mt-1.5 flex items-center gap-1.5 text-xs"
<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'" />
@@ -286,11 +293,11 @@ async function sendResetEmail () {
</div>
<!-- Aviso -->
<div class="rounded-xl 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)] text-sm mt-0.5 flex-shrink-0" />
<p class="text-xs text-[var(--text-color-secondary)] leading-relaxed">
<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.
</p>
</div>
</div>
<!-- Ações -->
@@ -317,25 +324,25 @@ async function sendResetEmail () {
</div>
<!-- Card informativo: dicas -->
<div class="mt-4 rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] px-6 py-5">
<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-sm text-[var(--text-color-secondary)]" />
<span class="text-sm font-semibold text-[var(--text-color)]">Boas práticas</span>
<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-xs text-[var(--text-color-secondary)]">
<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-xs text-[var(--text-color-secondary)]">
<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-xs text-[var(--text-color-secondary)]">
<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-xs text-[var(--text-color-secondary)]">
<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>
@@ -347,41 +354,4 @@ async function sendResetEmail () {
</template>
<style scoped>
.sec-sentinel { height: 1px; }
.sec-hero {
position: sticky;
top: var(--layout-sticky-top, 56px);
z-index: 20;
overflow: hidden;
border-radius: 1.75rem;
border: 1px solid var(--surface-border);
background: var(--surface-card);
padding: 1.25rem 1.5rem;
}
.sec-hero--stuck {
border-top-left-radius: 0; border-top-right-radius: 0;
}
.sec-hero__blobs { position: absolute; inset: 0; pointer-events: none; overflow: hidden; }
.sec-hero__blob { position: absolute; border-radius: 50%; filter: blur(70px); }
.sec-hero__blob--1 { width: 18rem; height: 18rem; top: -4rem; right: -3rem; background: rgba(99,102,241,0.10); }
.sec-hero__blob--2 { width: 20rem; height: 20rem; top: 0.5rem; left: -5rem; background: rgba(16,185,129,0.08); }
.sec-hero__row1 {
position: relative; z-index: 1;
display: flex; align-items: center; gap: 1rem;
}
.sec-hero__brand {
display: flex; align-items: center; gap: 0.75rem;
flex: 1; min-width: 0;
}
.sec-hero__icon {
display: grid; place-items: center;
width: 2.5rem; height: 2.5rem; border-radius: 0.875rem; flex-shrink: 0;
background: color-mix(in srgb, var(--p-primary-500, #6366f1) 12%, transparent);
color: var(--p-primary-500, #6366f1);
}
.sec-hero__title { font-size: 1.1rem; font-weight: 700; letter-spacing: -0.02em; color: var(--text-color); }
.sec-hero__sub { font-size: 0.78rem; color: var(--text-color-secondary); margin-top: 2px; }
</style>

View File

@@ -422,215 +422,272 @@ onMounted(fetchMeuPlanoClinic)
</script>
<template>
<div class="p-4 md:p-6">
<Toast />
<Toast />
<!-- Topbar padrão -->
<div class="mb-4 rounded-3xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-4 shadow-sm">
<div class="flex flex-wrap items-center justify-between gap-3">
<div class="flex flex-col">
<div class="text-2xl font-semibold leading-none">Meu plano</div>
<small class="text-color-secondary mt-1">
Plano da clínica (tenant) e recursos habilitados.
</small>
<!-- 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 items-center gap-3">
<!-- 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-credit-card text-base" />
</div>
<div class="min-w-0 hidden sm:block">
<div class="text-[1rem] font-bold tracking-tight text-[var(--text-color)]">Meu Plano</div>
<div class="text-[1rem] text-[var(--text-color-secondary)]">Plano da clínica (tenant) e recursos habilitados</div>
</div>
</div>
<div class="flex items-center gap-2 flex-wrap justify-end">
<Button
label="Alterar plano"
icon="pi pi-arrow-up-right"
:loading="loading"
@click="goUpgradeClinic"
/>
<Button
label="Atualizar"
icon="pi pi-refresh"
severity="secondary"
outlined
:loading="loading"
@click="fetchMeuPlanoClinic"
/>
<!-- Ações desktop -->
<div class="hidden sm: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" title="Atualizar" @click="fetchMeuPlanoClinic" />
<Button label="Alterar plano" icon="pi pi-arrow-up-right" class="rounded-full" @click="goUpgradeClinic" />
</div>
<!-- Ações mobile -->
<div class="flex sm:hidden 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" @click="fetchMeuPlanoClinic" />
<Button label="Upgrade" icon="pi pi-arrow-up-right" size="small" class="rounded-full" @click="goUpgradeClinic" />
</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.1rem] font-bold leading-none text-[var(--text-color)] truncate">{{ planName }}</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] transition-colors duration-150"
:class="subscription?.status === 'active' ? 'border-green-500/25 bg-green-500/5' : 'border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)]'"
>
<div
class="text-[1.1rem] font-bold leading-none truncate"
:class="subscription?.status === 'active' ? 'text-green-500' : 'text-[var(--text-color)]'"
>{{ statusLabelPrettyComputed }}</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-75">Status</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.35rem] font-bold leading-none text-[var(--text-color)]">{{ features.length }}</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-75">Recursos</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.35rem] font-bold leading-none text-[var(--text-color)]">{{ events.length }}</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-75">Eventos</div>
</div>
</div>
<!--
CONTEÚDO
-->
<div class="px-3 md:px-4 pb-8">
<!-- Loading skeleton -->
<div v-if="loading" class="flex flex-col gap-3">
<div
v-for="n in 3"
:key="n"
class="flex items-center gap-4 p-4 rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)]"
>
<div class="w-10 h-10 rounded-full flex-shrink-0 bg-[var(--surface-border,#e2e8f0)] animate-pulse" />
<div class="flex flex-col gap-2 flex-1">
<div class="h-3.5 w-3/5 rounded-md bg-[var(--surface-border,#e2e8f0)] animate-pulse" />
<div class="h-2.5 w-2/5 rounded-md bg-[var(--surface-border,#e2e8f0)] animate-pulse" />
</div>
</div>
</div>
<!-- Card resumo -->
<Card class="rounded-[2rem] overflow-hidden">
<template #content>
<div class="flex flex-wrap items-start justify-between gap-4">
<div class="min-w-0">
<div class="text-xl md:text-2xl font-semibold leading-tight">
{{ planName }}
</div>
<!-- Empty state: sem assinatura -->
<div
v-else-if="!subscription"
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-credit-card 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">Nenhuma assinatura encontrada</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] max-w-xs leading-relaxed">Nenhuma assinatura foi encontrada para este tenant.</div>
</div>
<Button label="Ver planos" icon="pi pi-arrow-up-right" class="rounded-full mt-1" @click="goUpgradeClinic" />
</div>
<div class="text-sm text-color-secondary mt-1">
<span v-if="priceLabel">{{ priceLabel }}</span>
<span v-else>Preço não encontrado para este intervalo.</span>
</div>
<!-- Conteúdo com assinatura -->
<div v-else class="flex flex-col gap-3">
<div class="mt-3 flex flex-wrap gap-2">
<Tag :value="statusLabelPrettyComputed" :severity="statusSeverity(subscription?.status)" />
<Tag
v-if="subscription?.cancel_at_period_end"
severity="warning"
value="Cancelamento agendado"
rounded
/>
<Tag
v-else-if="subscription"
severity="success"
value="Renovação automática"
rounded
/>
</div>
<div class="mt-3 text-sm text-color-secondary">
<b>Período:</b> {{ periodLabel }}
</div>
<div v-if="cancelHint" class="mt-2 text-sm text-color-secondary">
{{ cancelHint }}
</div>
<div v-if="plan?.description" class="mt-3 text-sm opacity-80 max-w-3xl">
{{ plan.description }}
</div>
<!-- Assinatura atual -->
<div 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-credit-card text-[var(--text-color-secondary)] opacity-60" />
<span class="font-semibold text-[1rem]">Assinatura atual</span>
</div>
<div v-if="subscription" class="flex flex-col items-end gap-2">
<small class="text-color-secondary">subscription_id</small>
<code class="text-xs opacity-80 break-all">
{{ subscription.id }}
</code>
<div class="flex items-center gap-2">
<Tag :value="statusLabelPrettyComputed" :severity="statusSeverity(subscription?.status)" />
<Tag v-if="subscription?.cancel_at_period_end" severity="warning" value="Cancelamento agendado" />
<Tag v-else severity="success" value="Renovação automática" />
</div>
</div>
<div v-if="!subscription" class="mt-4 rounded-2xl border border-[var(--surface-border)] p-4 text-sm text-color-secondary">
Nenhuma assinatura encontrada para este tenant.
<div class="px-4 py-3 flex flex-wrap gap-x-8 gap-y-3">
<div class="flex flex-col gap-0.5">
<span class="text-[0.68rem] font-bold uppercase tracking-[0.06em] text-[var(--text-color-secondary)] opacity-60">Plano</span>
<span class="text-[0.9rem] font-semibold text-[var(--text-color)]">{{ planName }}</span>
</div>
<div class="flex flex-col gap-0.5">
<span class="text-[0.68rem] font-bold uppercase tracking-[0.06em] text-[var(--text-color-secondary)] opacity-60">Valor</span>
<span class="text-[1rem] text-[var(--text-color)]">{{ priceLabel || 'Preço não encontrado para este intervalo.' }}</span>
</div>
<div class="flex flex-col gap-0.5">
<span class="text-[0.68rem] font-bold uppercase tracking-[0.06em] text-[var(--text-color-secondary)] opacity-60">Período</span>
<span class="text-[1rem] text-[var(--text-color)]">{{ periodLabel }}</span>
</div>
<div v-if="cancelHint" class="flex flex-col gap-0.5 w-full">
<span class="text-[0.68rem] font-bold uppercase tracking-[0.06em] text-[var(--text-color-secondary)] opacity-60">Atenção</span>
<span class="text-[1rem] text-[var(--text-color-secondary)]">{{ cancelHint }}</span>
</div>
<div v-if="plan?.description" class="flex flex-col gap-0.5 w-full">
<span class="text-[0.68rem] font-bold uppercase tracking-[0.06em] text-[var(--text-color-secondary)] opacity-60">Descrição</span>
<span class="text-[1rem] text-[var(--text-color-secondary)]">{{ plan.description }}</span>
</div>
<div class="flex flex-col gap-0.5">
<span class="text-[0.68rem] font-bold uppercase tracking-[0.06em] text-[var(--text-color-secondary)] opacity-60">ID da assinatura</span>
<code class="text-[0.75rem] text-[var(--text-color-secondary)] break-all font-mono select-all">{{ subscription.id }}</code>
</div>
</div>
</template>
</Card>
</div>
<Divider class="my-6" />
<div class="grid grid-cols-1 lg:grid-cols-2 gap-3">
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
<!-- Features agrupadas -->
<Card class="rounded-[2rem] overflow-hidden">
<template #title>Seu plano inclui</template>
<template #content>
<div v-if="!subscription" class="text-color-secondary">
Sem assinatura.
<!-- Features agrupadas -->
<div 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-check-circle text-[var(--text-color-secondary)] opacity-60" />
<span class="font-semibold text-[1rem]">Seu plano inclui</span>
</div>
<span
v-if="features.length"
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"
>{{ features.length }}</span>
</div>
<div v-else-if="!features.length" class="text-color-secondary">
Nenhuma feature vinculada a este plano.
</div>
<div class="p-4">
<div v-if="!features.length" class="text-[1rem] text-[var(--text-color-secondary)]">
Nenhuma feature vinculada a este plano.
</div>
<div v-else class="space-y-5">
<div
v-for="g in groupedFeatures"
:key="g.module"
class="rounded-2xl border border-[var(--surface-border)] overflow-hidden"
>
<div class="px-4 py-3 bg-[var(--surface-50)] border-b border-[var(--surface-border)] flex items-center justify-between">
<div class="font-semibold">
{{ moduleLabel(g.module) }}
<div v-else class="flex flex-col gap-5">
<div v-for="g in groupedFeatures" :key="g.module">
<!-- Cabeçalho do módulo -->
<div class="flex items-center gap-2 mb-2">
<span class="text-[0.68rem] font-bold uppercase tracking-[0.07em] text-[var(--text-color-secondary)] opacity-50">{{ moduleLabel(g.module) }}</span>
<div class="flex-1 h-px bg-[var(--surface-border,#e2e8f0)]" />
<span class="text-[1rem] text-[var(--text-color-secondary)]">{{ g.items.length }}</span>
</div>
<Tag :value="`${g.items.length}`" severity="secondary" rounded />
</div>
<div class="p-4">
<ul class="m-0 p-0 list-none space-y-3">
<li
<!-- Grid de features -->
<div class="grid grid-cols-1 sm:grid-cols-2 gap-x-4 gap-y-1">
<div
v-for="f in g.items"
:key="f.key"
class="rounded-2xl border border-[var(--surface-border)] p-3"
class="flex items-start gap-2 py-1 px-2 rounded-md hover:bg-[var(--surface-ground,#f8fafc)] transition-colors"
:title="f.description || f.key"
>
<div class="flex items-start gap-3">
<i class="pi pi-check mt-1 text-sm text-color-secondary"></i>
<div class="min-w-0">
<div class="font-medium break-words">{{ f.key }}</div>
<div class="text-sm text-color-secondary mt-1" v-if="f.description">
{{ f.description }}
</div>
</div>
<i class="pi pi-check-circle text-emerald-500 text-[1rem] mt-0.5 flex-shrink-0" />
<div class="min-w-0">
<div class="text-[1rem] font-medium truncate text-[var(--text-color)]">{{ f.key }}</div>
<div v-if="f.description" class="text-[1rem] text-[var(--text-color-secondary)] leading-snug truncate">{{ f.description }}</div>
</div>
</li>
</ul>
</div>
</div>
<div class="text-xs text-color-secondary">
Agrupamento automático por prefixo da key (ex.: <b>agenda.*</b>, <b>patients.*</b>, etc.).
</div>
</div>
</template>
</Card>
<!-- Histórico auditável -->
<Card class="rounded-[2rem] overflow-hidden">
<template #title>Histórico</template>
<template #content>
<div v-if="!subscription" class="text-color-secondary">
Sem histórico (não assinatura).
</div>
<div v-else-if="!events.length" class="text-color-secondary">
Sem eventos registrados.
</div>
<div v-else class="space-y-3">
<div
v-for="ev in events"
:key="ev.id"
class="rounded-2xl border border-[var(--surface-border)] p-3"
>
<div class="flex items-start justify-between gap-3 flex-wrap">
<div class="min-w-0">
<div class="flex items-center gap-2 flex-wrap">
<Tag
:value="eventLabel(ev.event_type)"
:severity="eventSeverity(ev.event_type)"
rounded
/>
<span class="text-sm text-color-secondary">
por <b>{{ displayUser(ev.created_by) }}</b>
</span>
</div>
<!-- De Para (quando existir) -->
<div v-if="ev.old_plan_id || ev.new_plan_id" class="mt-2 text-sm">
<span class="text-color-secondary">Plano:</span>
<span class="font-medium ml-2">{{ planKeyOrName(ev.old_plan_id) }}</span>
<i class="pi pi-arrow-right text-color-secondary mx-2" />
<span class="font-medium">{{ planKeyOrName(ev.new_plan_id) }}</span>
</div>
<div v-if="ev.reason" class="mt-2 text-sm opacity-80">
{{ ev.reason }}
</div>
</div>
<div class="text-sm text-color-secondary">
{{ fmtDate(ev.created_at) }}
</div>
</div>
<div class="mt-2 text-xs text-color-secondary" v-if="ev.metadata">
<pre class="m-0 whitespace-pre-wrap break-words">{{ prettyMeta(ev.metadata) }}</pre>
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-60">
Agrupamento automático por prefixo da key (ex.: <b>agenda.*</b>, <b>patients.*</b>).
</div>
</div>
</div>
</div>
<div class="mt-4 text-xs text-color-secondary">
Mostrando até 50 eventos (mais recentes).
<!-- Histórico -->
<div 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-history text-[var(--text-color-secondary)] opacity-60" />
<span class="font-semibold text-[1rem]">Histórico</span>
</div>
<span
v-if="events.length"
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"
>{{ events.length }}</span>
</div>
</template>
</Card>
<div class="p-4">
<div v-if="!events.length" class="py-8 text-center">
<i class="pi pi-history text-2xl text-[var(--text-color-secondary)] opacity-20 mb-2 block" />
<div class="text-[1rem] text-[var(--text-color-secondary)]">Sem eventos registrados.</div>
</div>
<div v-else class="flex flex-col gap-2">
<div
v-for="ev in events"
:key="ev.id"
class="rounded-md border border-[var(--surface-border,#e2e8f0)] p-3 hover:bg-[var(--surface-ground,#f8fafc)] transition-colors duration-100"
>
<div class="flex items-start justify-between gap-3 flex-wrap">
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2 flex-wrap">
<Tag :value="eventLabel(ev.event_type)" :severity="eventSeverity(ev.event_type)" />
<span class="text-[1rem] text-[var(--text-color-secondary)]">
por <b>{{ displayUser(ev.created_by) }}</b>
</span>
</div>
<div v-if="ev.old_plan_id || ev.new_plan_id" class="mt-1.5 text-[1rem] text-[var(--text-color-secondary)] flex items-center gap-1.5 flex-wrap">
<span class="font-medium text-[var(--text-color)]">{{ planKeyOrName(ev.old_plan_id) }}</span>
<i class="pi pi-arrow-right text-[1rem] opacity-50" />
<span class="font-medium text-[var(--text-color)]">{{ planKeyOrName(ev.new_plan_id) }}</span>
</div>
<div v-if="ev.reason" class="mt-1 text-[1rem] text-[var(--text-color-secondary)] opacity-70">{{ ev.reason }}</div>
<div v-if="ev.metadata" class="mt-1.5">
<pre class="m-0 text-[1rem] text-[var(--text-color-secondary)] whitespace-pre-wrap break-words opacity-60">{{ prettyMeta(ev.metadata) }}</pre>
</div>
</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] flex-shrink-0">{{ fmtDate(ev.created_at) }}</div>
</div>
</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-60 mt-1">
Mostrando até 50 eventos (mais recentes).
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
/* (intencionalmente vazio) */
</style>
</style>

View File

@@ -302,236 +302,261 @@ onBeforeUnmount(() => { _observer?.disconnect() })
<Toast />
<!-- Sentinel -->
<div ref="headerSentinelRef" class="mplan-sentinel" />
<div ref="headerSentinelRef" class="h-px" />
<!-- Hero sticky -->
<div ref="headerEl" class="mplan-hero mx-3 md:mx-5 mb-4" :class="{ 'mplan-hero--stuck': headerStuck }">
<div class="mplan-hero__blobs" aria-hidden="true">
<div class="mplan-hero__blob mplan-hero__blob--1" />
<div class="mplan-hero__blob mplan-hero__blob--2" />
<!--
HERO sticky
-->
<section
ref="headerEl"
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"
:class="{ 'rounded-tl-none rounded-tr-none': headerStuck }"
: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>
<!-- Row 1 -->
<div class="mplan-hero__row1">
<div class="mplan-hero__brand">
<div class="mplan-hero__icon"><i class="pi pi-credit-card text-lg" /></div>
<div class="min-w-0">
<div class="relative z-[1] flex items-center gap-3">
<!-- 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-credit-card text-base" />
</div>
<div class="min-w-0 hidden sm:block">
<div class="flex items-center gap-2 flex-wrap">
<div class="mplan-hero__title">Meu Plano</div>
<div class="text-[1rem] font-bold tracking-tight text-[var(--text-color)]">Meu Plano</div>
<Tag v-if="subscription" :value="statusLabel(subscription.status)" :severity="statusSeverity(subscription.status)" />
</div>
<div class="mplan-hero__sub">Plano pessoal do terapeuta gerencie sua assinatura</div>
<div class="text-[1rem] text-[var(--text-color-secondary)]">Plano pessoal do terapeuta gerencie sua assinatura</div>
</div>
</div>
<!-- Desktop (1200px) -->
<div class="hidden xl:flex items-center gap-2 shrink-0">
<!-- Ações desktop ( xl) -->
<div class="hidden xl: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" :loading="loading" title="Atualizar" @click="fetchMeuPlanoTherapist" />
<Button label="Alterar plano" icon="pi pi-arrow-up-right" class="rounded-full" @click="goUpgrade" />
</div>
<!-- Mobile (<1200px) -->
<div class="flex xl:hidden items-center gap-2 shrink-0">
<!-- Ações mobile (< xl) -->
<div class="flex xl:hidden items-center gap-1 flex-shrink-0 ml-auto">
<Button icon="pi pi-refresh" severity="secondary" outlined class="h-9 w-9 rounded-full" :loading="loading" @click="fetchMeuPlanoTherapist" />
<Button label="Alterar plano" icon="pi pi-arrow-up-right" size="small" class="rounded-full" @click="goUpgrade" />
</div>
</div>
</section>
<!-- Divider -->
<Divider class="mplan-hero__divider my-2" />
<!-- Row 2: resumo rápido (oculto no mobile) -->
<div class="mplan-hero__row2">
<div v-if="loading" class="flex items-center gap-2 text-sm text-[var(--text-color-secondary)]">
<i class="pi pi-spin pi-spinner text-xs" /> Carregando
</div>
<template v-else-if="subscription">
<div class="flex flex-wrap items-center gap-3">
<span class="font-semibold text-sm text-[var(--text-color)]">{{ planName }}</span>
<span v-if="priceLabel" class="text-sm text-[var(--text-color-secondary)]">{{ priceLabel }}</span>
<span class="text-xs text-[var(--text-color-secondary)] border border-[var(--surface-border)] rounded-full px-3 py-1">
Período: {{ periodLabel }}
</span>
<Tag v-if="subscription.cancel_at_period_end" severity="warning" value="Cancelamento agendado" />
<Tag v-else severity="success" value="Renovação automática" />
</div>
</template>
<div v-else class="text-sm text-[var(--text-color-secondary)]">Nenhuma assinatura ativa.</div>
<!--
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.1rem] font-bold leading-none text-[var(--text-color)] truncate">{{ planName }}</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] transition-colors duration-150"
:class="subscription?.status === 'active' ? 'border-green-500/25 bg-green-500/5' : 'border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)]'"
>
<div
class="text-[1.1rem] font-bold leading-none truncate"
:class="subscription?.status === 'active' ? 'text-green-500' : 'text-[var(--text-color)]'"
>{{ subscription ? statusLabel(subscription.status) : '—' }}</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-75">Status</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.35rem] font-bold leading-none text-[var(--text-color)]">{{ features.length }}</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-75">Recursos</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.35rem] font-bold leading-none text-[var(--text-color)]">{{ events.length }}</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-75">Eventos</div>
</div>
</div>
<!-- Conteúdo -->
<div class="px-3 md:px-5 mb-5 flex flex-col gap-4">
<!--
CONTEÚDO
-->
<div class="px-3 md:px-4 pb-8 flex flex-col gap-3">
<!-- Loading skeleton -->
<div v-if="loading" class="flex flex-col gap-3">
<div
v-for="n in 3"
:key="n"
class="flex items-center gap-4 p-4 rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)]"
>
<div class="w-10 h-10 rounded-full flex-shrink-0 bg-[var(--surface-border,#e2e8f0)] animate-pulse" />
<div class="flex flex-col gap-2 flex-1">
<div class="h-3.5 w-3/5 rounded-md bg-[var(--surface-border,#e2e8f0)] animate-pulse" />
<div class="h-2.5 w-2/5 rounded-md bg-[var(--surface-border,#e2e8f0)] animate-pulse" />
</div>
</div>
</div>
<!-- Sem assinatura -->
<div v-if="!loading && !subscription" class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-10 text-center">
<div class="mx-auto mb-3 grid h-12 w-12 place-items-center rounded-2xl bg-[var(--primary-color)]/10 text-[var(--primary-color)]">
<i class="pi pi-credit-card text-xl" />
<div
v-else-if="!subscription"
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-credit-card 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 class="font-semibold">Nenhuma assinatura encontrada</div>
<div class="mt-1 text-sm text-[var(--text-color-secondary)]">Escolha um plano para começar a usar todos os recursos.</div>
<div class="mt-4">
<Button label="Ver planos" icon="pi pi-arrow-up-right" class="rounded-full" @click="goUpgrade" />
<div>
<div class="font-bold text-[1rem] text-[var(--text-color)] mb-1">Nenhuma assinatura encontrada</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] max-w-xs leading-relaxed">Escolha um plano para começar a usar todos os recursos.</div>
</div>
<Button label="Ver planos" icon="pi pi-arrow-up-right" class="rounded-full mt-1" @click="goUpgrade" />
</div>
<div v-else class="grid grid-cols-1 lg:grid-cols-2 gap-4">
<template v-else>
<!-- Seu plano inclui: features compactas -->
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] overflow-hidden">
<div class="px-5 py-4 border-b border-[var(--surface-border)] flex items-center justify-between gap-3">
<div>
<div class="font-semibold text-[var(--text-color)]">Seu plano inclui</div>
<div class="text-xs text-[var(--text-color-secondary)] mt-0.5">Recursos disponíveis na sua assinatura atual</div>
<!-- Assinatura atual -->
<div 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-credit-card text-[var(--text-color-secondary)] opacity-60" />
<span class="font-semibold text-[1rem]">Assinatura atual</span>
</div>
<div class="flex items-center gap-2">
<Tag :value="statusLabel(subscription.status)" :severity="statusSeverity(subscription.status)" />
<Tag v-if="subscription.cancel_at_period_end" severity="warning" value="Cancelamento agendado" />
<Tag v-else severity="success" value="Renovação automática" />
</div>
<Tag v-if="features.length" :value="`${features.length}`" severity="secondary" />
</div>
<div class="px-4 py-3 flex flex-wrap gap-x-8 gap-y-3">
<div class="flex flex-col gap-0.5">
<span class="text-[0.68rem] font-bold uppercase tracking-[0.06em] text-[var(--text-color-secondary)] opacity-60">Plano</span>
<span class="text-[0.9rem] font-semibold text-[var(--text-color)]">{{ planName }}</span>
</div>
<div class="flex flex-col gap-0.5">
<span class="text-[0.68rem] font-bold uppercase tracking-[0.06em] text-[var(--text-color-secondary)] opacity-60">Valor</span>
<span class="text-[1rem] text-[var(--text-color)]">{{ priceLabel || 'Preço não encontrado para este intervalo.' }}</span>
</div>
<div class="flex flex-col gap-0.5">
<span class="text-[0.68rem] font-bold uppercase tracking-[0.06em] text-[var(--text-color-secondary)] opacity-60">Período</span>
<span class="text-[1rem] text-[var(--text-color)]">{{ periodLabel }}</span>
</div>
<div v-if="plan?.description" class="flex flex-col gap-0.5 w-full">
<span class="text-[0.68rem] font-bold uppercase tracking-[0.06em] text-[var(--text-color-secondary)] opacity-60">Descrição</span>
<span class="text-[1rem] text-[var(--text-color-secondary)]">{{ plan.description }}</span>
</div>
<div class="flex flex-col gap-0.5">
<span class="text-[0.68rem] font-bold uppercase tracking-[0.06em] text-[var(--text-color-secondary)] opacity-60">ID da assinatura</span>
<code class="text-[0.75rem] text-[var(--text-color-secondary)] break-all font-mono select-all">{{ subscription.id }}</code>
</div>
</div>
</div>
<div class="p-5">
<div v-if="!subscription" class="text-sm text-[var(--text-color-secondary)]">Sem assinatura.</div>
<div v-else-if="!features.length" class="text-sm text-[var(--text-color-secondary)]">Nenhuma feature vinculada a este plano.</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-3">
<div v-else class="space-y-5">
<div v-for="g in groupedFeatures" :key="g.module">
<!-- Cabeçalho do módulo -->
<div class="flex items-center gap-2 mb-2">
<span class="text-xs font-bold uppercase tracking-wider text-[var(--text-color-secondary)] opacity-50">
{{ moduleLabel(g.module) }}
</span>
<div class="flex-1 h-px bg-[var(--surface-border)]" />
<span class="text-xs text-[var(--text-color-secondary)]">{{ g.items.length }}</span>
</div>
<!-- Seu plano inclui -->
<div 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-check-circle text-[var(--text-color-secondary)] opacity-60" />
<span class="font-semibold text-[1rem]">Seu plano inclui</span>
</div>
<span
v-if="features.length"
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"
>{{ features.length }}</span>
</div>
<!-- Grid compacto de features -->
<div class="grid grid-cols-1 sm:grid-cols-2 gap-x-4 gap-y-1">
<div
v-for="f in g.items"
:key="f.key"
class="flex items-start gap-2 py-1 px-2 rounded-lg hover:bg-[var(--surface-ground)] transition-colors"
:title="f.description || f.key"
>
<i class="pi pi-check-circle text-emerald-500 text-sm mt-0.5 shrink-0" />
<div class="min-w-0">
<div class="text-sm font-medium truncate text-[var(--text-color)]">{{ f.key }}</div>
<div v-if="f.description" class="text-xs text-[var(--text-color-secondary)] leading-snug truncate">{{ f.description }}</div>
<div class="p-4">
<div v-if="!features.length" class="text-[1rem] text-[var(--text-color-secondary)]">Nenhuma feature vinculada a este plano.</div>
<div v-else class="flex flex-col gap-5">
<div v-for="g in groupedFeatures" :key="g.module">
<div class="flex items-center gap-2 mb-2">
<span class="text-[0.68rem] font-bold uppercase tracking-[0.07em] text-[var(--text-color-secondary)] opacity-50">{{ moduleLabel(g.module) }}</span>
<div class="flex-1 h-px bg-[var(--surface-border,#e2e8f0)]" />
<span class="text-[1rem] text-[var(--text-color-secondary)]">{{ g.items.length }}</span>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-x-4 gap-y-1">
<div
v-for="f in g.items"
:key="f.key"
class="flex items-start gap-2 py-1 px-2 rounded-md hover:bg-[var(--surface-ground,#f8fafc)] transition-colors"
:title="f.description || f.key"
>
<i class="pi pi-check-circle text-emerald-500 text-[1rem] mt-0.5 flex-shrink-0" />
<div class="min-w-0">
<div class="text-[1rem] font-medium truncate text-[var(--text-color)]">{{ f.key }}</div>
<div v-if="f.description" class="text-[1rem] text-[var(--text-color-secondary)] leading-snug truncate">{{ f.description }}</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Histórico -->
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] overflow-hidden">
<div class="px-5 py-4 border-b border-[var(--surface-border)] flex items-center justify-between gap-3">
<div>
<div class="font-semibold text-[var(--text-color)]">Histórico</div>
<div class="text-xs text-[var(--text-color-secondary)] mt-0.5">Últimos 50 eventos da assinatura</div>
</div>
<Tag v-if="events.length" :value="`${events.length}`" severity="secondary" />
</div>
<div class="p-5">
<div v-if="!subscription" class="text-sm text-[var(--text-color-secondary)]">Sem histórico (não assinatura).</div>
<div v-else-if="!events.length" class="py-8 text-center">
<i class="pi pi-history text-2xl text-[var(--text-color-secondary)] opacity-30 mb-2 block" />
<div class="text-sm text-[var(--text-color-secondary)]">Sem eventos registrados.</div>
<!-- Histórico -->
<div 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-history text-[var(--text-color-secondary)] opacity-60" />
<span class="font-semibold text-[1rem]">Histórico</span>
</div>
<span
v-if="events.length"
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"
>{{ events.length }}</span>
</div>
<div v-else class="space-y-2">
<div
v-for="ev in events"
:key="ev.id"
class="rounded-xl border border-[var(--surface-border)] p-3 hover:bg-[var(--surface-ground)] transition-colors"
>
<div class="flex items-start justify-between gap-3 flex-wrap">
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2 flex-wrap">
<Tag :value="eventLabel(ev.event_type)" :severity="eventSeverity(ev.event_type)" />
<span class="text-xs text-[var(--text-color-secondary)]">
por <b>{{ displayUser(ev.created_by) }}</b>
</span>
</div>
<div class="p-4">
<div v-if="!events.length" class="py-8 text-center">
<i class="pi pi-history text-2xl text-[var(--text-color-secondary)] opacity-20 mb-2 block" />
<div class="text-[1rem] text-[var(--text-color-secondary)]">Sem eventos registrados.</div>
</div>
<div v-if="ev.old_plan_id || ev.new_plan_id" class="mt-1.5 text-xs text-[var(--text-color-secondary)] flex items-center gap-1.5 flex-wrap">
<span class="font-medium text-[var(--text-color)]">{{ planKeyOrName(ev.old_plan_id) }}</span>
<i class="pi pi-arrow-right text-xs opacity-50" />
<span class="font-medium text-[var(--text-color)]">{{ planKeyOrName(ev.new_plan_id) }}</span>
</div>
<div v-if="ev.reason" class="mt-1 text-xs text-[var(--text-color-secondary)] opacity-70">{{ ev.reason }}</div>
<div v-if="ev.metadata" class="mt-1.5">
<pre class="m-0 text-xs text-[var(--text-color-secondary)] whitespace-pre-wrap break-words opacity-60">{{ prettyMeta(ev.metadata) }}</pre>
<div v-else class="flex flex-col gap-2">
<div
v-for="ev in events"
:key="ev.id"
class="rounded-md border border-[var(--surface-border,#e2e8f0)] p-3 hover:bg-[var(--surface-ground,#f8fafc)] transition-colors duration-100"
>
<div class="flex items-start justify-between gap-3 flex-wrap">
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2 flex-wrap">
<Tag :value="eventLabel(ev.event_type)" :severity="eventSeverity(ev.event_type)" />
<span class="text-[1rem] text-[var(--text-color-secondary)]">
por <b>{{ displayUser(ev.created_by) }}</b>
</span>
</div>
<div v-if="ev.old_plan_id || ev.new_plan_id" class="mt-1.5 text-[1rem] text-[var(--text-color-secondary)] flex items-center gap-1.5 flex-wrap">
<span class="font-medium text-[var(--text-color)]">{{ planKeyOrName(ev.old_plan_id) }}</span>
<i class="pi pi-arrow-right text-[1rem] opacity-50" />
<span class="font-medium text-[var(--text-color)]">{{ planKeyOrName(ev.new_plan_id) }}</span>
</div>
<div v-if="ev.reason" class="mt-1 text-[1rem] text-[var(--text-color-secondary)] opacity-70">{{ ev.reason }}</div>
<div v-if="ev.metadata" class="mt-1.5">
<pre class="m-0 text-[1rem] text-[var(--text-color-secondary)] whitespace-pre-wrap break-words opacity-60">{{ prettyMeta(ev.metadata) }}</pre>
</div>
</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] flex-shrink-0">{{ fmtDate(ev.created_at) }}</div>
</div>
<div class="text-xs text-[var(--text-color-secondary)] shrink-0">{{ fmtDate(ev.created_at) }}</div>
</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-60 mt-1">
Mostrando até 50 eventos (mais recentes).
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Rodapé: subscription ID -->
<div v-if="subscription" class="text-xs text-[var(--text-color-secondary)] flex items-center gap-2 flex-wrap">
<span>ID da assinatura:</span>
<code class="font-mono select-all">{{ subscription.id }}</code>
</div>
</template>
</div>
</template>
<style scoped>
.mplan-sentinel { height: 1px; }
.mplan-hero {
position: sticky;
top: var(--layout-sticky-top, 56px);
z-index: 20;
overflow: hidden;
border-radius: 1.75rem;
border: 1px solid var(--surface-border);
background: var(--surface-card);
padding: 1.25rem 1.5rem;
}
.mplan-hero--stuck {
margin-left: 0; margin-right: 0;
border-top-left-radius: 0; border-top-right-radius: 0;
}
.mplan-hero__blobs { position: absolute; inset: 0; pointer-events: none; overflow: hidden; }
.mplan-hero__blob { position: absolute; border-radius: 50%; filter: blur(70px); }
.mplan-hero__blob--1 { width: 18rem; height: 18rem; top: -4rem; right: -3rem; background: rgba(99,102,241,0.10); }
.mplan-hero__blob--2 { width: 20rem; height: 20rem; top: 0.5rem; left: -5rem; background: rgba(16,185,129,0.08); }
.mplan-hero__row1 {
position: relative; z-index: 1;
display: flex; align-items: center; gap: 1rem;
}
.mplan-hero__brand {
display: flex; align-items: center; gap: 0.75rem;
flex: 1; min-width: 0;
}
.mplan-hero__icon {
display: grid; place-items: center;
width: 2.5rem; height: 2.5rem; border-radius: 0.875rem; flex-shrink: 0;
background: color-mix(in srgb, var(--p-primary-500, #6366f1) 12%, transparent);
color: var(--p-primary-500, #6366f1);
}
.mplan-hero__title { font-size: 1.1rem; font-weight: 700; letter-spacing: -0.02em; color: var(--text-color); }
.mplan-hero__sub { font-size: 0.78rem; color: var(--text-color-secondary); margin-top: 2px; }
.mplan-hero__row2 {
position: relative; z-index: 1;
display: flex; align-items: center; gap: 0.75rem; flex-wrap: wrap;
}
@media (max-width: 767px) {
.mplan-hero__divider,
.mplan-hero__row2 { display: none; }
}
/* (intencionalmente vazio) */
</style>

View File

@@ -254,169 +254,221 @@ onMounted(loadData)
</script>
<template>
<div class="p-4 md:p-6">
<Toast />
<Toast />
<!-- Topbar padrão -->
<div class="mb-4 rounded-3xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-4 shadow-sm">
<div class="flex flex-wrap items-center justify-between gap-3">
<div class="flex flex-col">
<div class="text-2xl font-semibold leading-none">Upgrade do terapeuta</div>
<small class="text-color-secondary mt-1">
Escolha seu plano pessoal (Modelo A).
</small>
<!-- 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>
</div>
<div class="flex items-center gap-2 flex-wrap justify-end">
<Button
label="Voltar"
icon="pi pi-arrow-left"
severity="secondary"
outlined
:disabled="saving"
@click="goBack"
/>
<Button
label="Atualizar"
icon="pi pi-refresh"
severity="secondary"
outlined
:loading="loading"
:disabled="saving"
@click="loadData"
/>
<!-- 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>
<div class="mt-3 flex flex-wrap items-center gap-3">
<!-- 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="`Plano atual: ${currentSub.plan_key} ${intervalLabel(currentSub.interval)} • ${currentSub.status}`"
:value="`Atual: ${currentSub.plan_key} · ${intervalLabel(currentSub.interval)}`"
severity="success"
rounded
/>
<Tag
v-else
value="Você ainda não tem um plano pessoal."
severity="warning"
rounded
/>
<Tag v-else value="Sem plano pessoal" severity="warning" />
</div>
<div class="flex items-center gap-2 ml-auto">
<small class="text-color-secondary">Exibição de preço</small>
<SelectButton
v-model="billingInterval"
:options="intervalOptions"
optionLabel="label"
optionValue="value"
:disabled="loading || saving"
/>
</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>
<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>
<!-- Busca padrão FloatLabel -->
<Card class="rounded-[2rem] overflow-hidden mb-4">
<template #content>
<div class="flex flex-wrap items-center gap-3 justify-between">
<div class="flex flex-col">
<div class="font-semibold">Planos disponíveis</div>
<small class="text-color-secondary">
Filtre por nome/key/descrição e selecione.
</small>
</div>
<div class="w-full md:w-[420px]">
<FloatLabel variant="on" class="w-full">
<IconField class="w-full">
<InputIcon class="pi pi-search" />
<InputText v-model="q" id="therapist_upgrade_search" class="w-full pr-10" variant="filled" />
</IconField>
<label for="therapist_upgrade_search">Buscar plano...</label>
</FloatLabel>
</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>
</template>
</Card>
<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>
<Divider />
<!-- Cards estilo vitrine -->
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4 mt-4">
<Card
<!-- 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-[2rem] overflow-hidden border border-[var(--surface-border)]',
currentSub?.plan_id === p.id ? 'ring-1 ring-emerald-500/25 md:-translate-y-1 md:scale-[1.01]' : ''
]"
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)]'"
>
<template #title>
<div class="flex items-center justify-between gap-2">
<div class="min-w-0">
<div class="font-semibold truncate">{{ p.name || p.key }}</div>
<small class="text-color-secondary">{{ p.key }}</small>
</div>
<Tag v-if="currentSub?.plan_id === p.id" severity="success" value="Atual" rounded />
<!-- 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>
</template>
<Tag v-if="currentSub?.plan_id === p.id" severity="success" value="Atual" />
</div>
<template #content>
<div class="text-sm text-color-secondary" v-if="p.description">
{{ p.description }}
<!-- 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>
<div class="mt-4">
<div class="text-4xl font-semibold leading-none">
{{ priceLabelForCard(p) }}
</div>
<div class="text-xs text-color-secondary mt-1">
Alternar mensal/anual no topo para comparar.
</div>
</div>
<div class="mt-5 flex gap-2 flex-wrap">
<!-- 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)"
/>
<Button
label="Mensal"
severity="secondary"
outlined
:disabled="loading || saving"
@click="choosePlan(p, 'month')"
/>
<Button
label="Anual"
severity="secondary"
outlined
:disabled="loading || saving"
@click="choosePlan(p, 'year')"
/>
<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>
<div class="mt-3 text-xs text-color-secondary">
<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>
<!-- 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>
</template>
</Card>
</div>
</div>
</div>
<div v-if="!filteredPlans.length && !loading" class="mt-4 text-sm text-color-secondary">
Nenhum plano encontrado.
</div>
</div>
</template>
</template>
<style scoped>
/* (intencionalmente vazio) */
</style>

View File

@@ -1,4 +1,4 @@
<!-- src/views/pages/upgrade/UpgradePage.vue -->
<!-- src/views/pages/billing/UpgradePage.vue -->
<script setup>
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
@@ -311,174 +311,292 @@ watch(() => tenantStore.user?.id, () => { fetchAll() })
<template>
<Toast />
<div class="p-4 md:p-6 lg:p-8">
<!-- HERO -->
<div class="mb-5 overflow-hidden rounded-[2rem] border border-[var(--surface-border)] bg-[var(--surface-card)]">
<div class="relative p-5 md:p-7">
<div class="pointer-events-none absolute inset-0 opacity-80">
<div class="absolute -top-20 -right-24 h-80 w-80 rounded-full bg-indigo-400/10 blur-3xl" />
<div class="absolute top-12 -left-24 h-96 w-96 rounded-full bg-emerald-400/10 blur-3xl" />
<div class="absolute -bottom-20 right-32 h-80 w-80 rounded-full bg-fuchsia-400/10 blur-3xl" />
</div>
<!-- Sentinel -->
<div class="h-px" />
<div class="relative flex flex-col gap-4">
<div class="flex items-start justify-between gap-3 flex-wrap">
<div class="min-w-0">
<div class="text-2xl md:text-3xl font-semibold leading-tight">Atualize seu plano</div>
<div class="mt-1 text-sm md:text-base text-[var(--text-color-secondary)]">
Contexto: <b>{{ isTherapist ? 'Terapeuta' : 'Clínica' }}</b>
<span class="mx-2 opacity-50"></span>
Plano atual: <b>{{ currentPlanKey || '—' }}</b>
</div>
</div>
<!--
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 class="absolute w-56 h-56 -bottom-8 right-1/4 rounded-full blur-[55px] bg-fuchsia-400/[0.07]" />
</div>
<div class="flex items-center gap-2 flex-wrap">
<Button label="Voltar" icon="pi pi-arrow-left" severity="secondary" outlined :disabled="upgrading" @click="goBack" />
<Button label="Assinatura" icon="pi pi-credit-card" severity="secondary" outlined :disabled="upgrading" @click="goBilling" />
<Button label="Recarregar" icon="pi pi-refresh" severity="secondary" outlined :loading="loading" :disabled="upgrading" @click="fetchAll" />
<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-sparkles text-base" />
</div>
<div class="min-w-0 hidden sm:block">
<div class="text-[1rem] font-bold tracking-tight text-[var(--text-color)]">Atualize seu plano</div>
<div class="text-[1rem] text-[var(--text-color-secondary)]">
Contexto: <b>{{ isTherapist ? 'Terapeuta' : 'Clínica' }}</b>
<span class="mx-1.5 opacity-40">·</span>
Plano atual: <b>{{ currentPlanKey || '—' }}</b>
</div>
</div>
</div>
<!-- recurso bloqueado -->
<div
v-if="requestedFeatureLabel"
class="relative overflow-hidden rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-ground)] p-4"
>
<div class="relative flex flex-col md:flex-row md:items-center md:justify-between gap-3">
<div class="min-w-0">
<div class="flex items-center gap-2">
<Tag severity="warning" value="Recurso bloqueado" />
<div class="font-semibold truncate">{{ requestedFeatureLabel }}</div>
</div>
<div class="mt-1 text-sm text-[var(--text-color-secondary)]">
Esse recurso depende da feature <b>{{ requestedFeature }}</b>.
</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 || upgrading" />
</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="upgrading" title="Recarregar" @click="fetchAll" />
<Button label="Assinatura" icon="pi pi-credit-card" severity="secondary" outlined class="rounded-full hidden sm:inline-flex" :disabled="upgrading" @click="goBilling" />
<Button label="Voltar" icon="pi pi-arrow-left" severity="secondary" outlined class="rounded-full" :disabled="upgrading" @click="goBack" />
</div>
</div>
<!-- Linha 2: busca mobile + chips 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 || upgrading" />
</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 || upgrading"
@click="billingInterval = opt.value"
>{{ opt.label }}</button>
</div>
</div>
</div>
</section>
<!--
BANNER: recurso bloqueado
-->
<Transition name="up-banner">
<div
v-if="requestedFeatureLabel && !loading"
class="mx-3 md:mx-4 mb-3 flex items-center gap-3 px-4 py-3 rounded-md border border-amber-300/60 bg-amber-50"
>
<div class="grid place-items-center w-8 h-8 rounded-md bg-amber-400/20 text-amber-600 flex-shrink-0">
<i class="pi pi-lock text-[0.95rem]" />
</div>
<div class="flex-1 min-w-0">
<span class="font-semibold text-[1rem] text-amber-800">Recurso bloqueado: {{ requestedFeatureLabel }}</span>
<span class="hidden sm:inline text-[1rem] text-amber-700 opacity-80 ml-1">Esse recurso depende da feature <b>{{ requestedFeature }}</b>. Escolha um plano que a inclua.</span>
</div>
<Button
label="Ver planos"
icon="pi pi-arrow-down"
severity="secondary"
outlined
size="small"
class="rounded-full flex-shrink-0"
@click="() => document.getElementById('plans-grid')?.scrollIntoView({ behavior: 'smooth', block: 'start' })"
/>
</div>
</Transition>
<!--
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)]">{{ sortedPlans.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">{{ currentPlanKey || '—' }}</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)]">{{ isTherapist ? 'Terapeuta' : 'Clínica' }}</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-75">Contexto</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.35rem] font-bold leading-none text-[var(--text-color)]">{{ features.length }}</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-75">Features no sistema</div>
</div>
</div>
<!--
PLANOS
-->
<div class="px-3 md:px-4 pb-8">
<!-- Loading skeleton -->
<div v-if="loading" class="grid grid-cols-1 lg:grid-cols-2 gap-3">
<div
v-for="n in 2"
: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-5 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="flex flex-col gap-2 mt-2">
<div v-for="i in 4" :key="i" class="h-3 w-4/5 rounded-md bg-[var(--surface-border,#e2e8f0)] animate-pulse" />
</div>
</div>
</div>
</div>
<!-- Empty state -->
<div
v-else-if="!sortedPlans.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 id="plans-grid" class="grid grid-cols-1 lg:grid-cols-2 gap-3">
<div
v-for="p in sortedPlans"
:key="p.id"
class="relative overflow-hidden rounded-md border bg-[var(--surface-card,#fff)] flex flex-col"
:class="String(p.key).toLowerCase() === 'pro'
? 'border-[var(--primary-color,#6366f1)]/30'
: 'border-[var(--surface-border,#e2e8f0)]'"
>
<!-- Blobs decorativos (plano PRO) -->
<div v-if="String(p.key).toLowerCase() === 'pro'" class="pointer-events-none absolute inset-0 overflow-hidden" aria-hidden="true">
<div class="absolute -top-20 -right-16 w-72 h-72 rounded-full bg-[var(--primary-color,#6366f1)]/10 blur-[60px]" />
<div class="absolute -bottom-20 left-8 w-72 h-72 rounded-full bg-emerald-400/[0.08] blur-[60px]" />
</div>
<!-- Cabeçalho do card -->
<div class="relative z-[1] 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="text-[0.9rem]"
:class="String(p.key).toLowerCase() === 'pro' ? 'pi pi-sparkles text-[var(--primary-color,#6366f1)]' : 'pi pi-leaf text-emerald-500 opacity-70'"
/>
<span class="font-bold text-[0.95rem] text-[var(--text-color)]">Plano {{ String(p.key || '').toUpperCase() }}</span>
</div>
<div class="flex items-center gap-2">
<Tag v-if="currentPlanId === p.id" value="Atual" severity="secondary" />
<Tag v-else-if="String(p.key).toLowerCase() === 'pro'" value="Recomendado" severity="success" />
</div>
</div>
<!-- Corpo do card -->
<div class="relative z-[1] p-4 flex flex-col gap-4 flex-1">
<!-- Descrição + preço -->
<div>
<div class="text-[1rem] text-[var(--text-color-secondary)] mb-2">
<template v-if="String(p.key).toLowerCase() === 'free'">O essencial para começar, sem travar seu fluxo.</template>
<template v-else-if="String(p.key).toLowerCase() === 'pro'">Para automatizar, reduzir ruído e ganhar previsibilidade.</template>
<template v-else>{{ p.description || p.key }}</template>
</div>
<div class="text-[1.6rem] font-bold leading-none text-[var(--text-color)]">{{ priceLabelForPlan(p.id) }}</div>
</div>
<!-- Benefits list -->
<div class="rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-ground,#f8fafc)] p-3">
<ul class="list-none p-0 m-0 flex flex-col gap-2.5">
<li v-for="(b, i) in planBenefits(p.id)" :key="i" class="flex items-start gap-2">
<i
class="text-[1rem] mt-0.5 flex-shrink-0"
:class="b.ok ? 'pi pi-check-circle text-emerald-500' : 'pi pi-times-circle text-[var(--text-color-secondary)] opacity-40'"
/>
<span
class="text-[1rem]"
:class="b.ok ? 'text-[var(--text-color)]' : 'text-[var(--text-color-secondary)]'"
>{{ b.text }}</span>
</li>
</ul>
<div class="h-px bg-[var(--surface-border,#e2e8f0)] my-3" />
<!-- Ações -->
<div class="flex flex-col gap-2">
<Button
label="Ver planos"
icon="pi pi-arrow-down"
v-if="currentPlanId !== p.id"
:label="`Mudar para ${String(p.key || '').toUpperCase()}`"
icon="pi pi-arrow-up"
class="w-full rounded-full"
:loading="upgrading"
:disabled="upgrading || loading"
@click="changePlan(p.id)"
/>
<Button
v-else
label="Você já está neste plano"
icon="pi pi-check"
severity="secondary"
outlined
@click="() => document.getElementById('plans-grid')?.scrollIntoView({ behavior: 'smooth', block: 'start' })"
class="w-full rounded-full"
disabled
/>
</div>
</div>
<!-- busca + intervalo -->
<div class="flex flex-col md:flex-row md:items-end md:justify-between gap-3">
<div class="w-full md:w-[420px]">
<FloatLabel variant="on" class="w-full">
<IconField class="w-full">
<InputIcon class="pi pi-search" />
<InputText v-model="q" id="upgrade_search" class="w-full pr-10" variant="filled" :disabled="loading || upgrading" />
</IconField>
<label for="upgrade_search">Buscar plano...</label>
</FloatLabel>
</div>
<div class="flex flex-col items-start md:items-end gap-2">
<small class="text-[var(--text-color-secondary)]">Exibição de preço</small>
<SelectButton v-model="billingInterval" :options="intervalOptions" optionLabel="label" optionValue="value" :disabled="loading || upgrading" />
<Button
v-if="String(p.key).toLowerCase() !== 'free'"
label="Falar com suporte"
icon="pi pi-comments"
severity="secondary"
outlined
class="w-full rounded-full"
:disabled="upgrading"
@click="contactSupport"
/>
<div class="text-center text-[1rem] text-[var(--text-color-secondary)] opacity-60">
Cancele quando quiser. Sem burocracia.
</div>
<div v-if="!subscription?.id" class="text-center text-[1rem] text-amber-500">
Sem assinatura ativa clique em <b>Assinatura</b> para ativar/criar.
</div>
</div>
</div>
</div>
</div>
</div>
<!-- PLANOS -->
<div id="plans-grid" class="grid grid-cols-12 gap-4 md:gap-6">
<div v-for="p in sortedPlans" :key="p.id" class="col-span-12 lg:col-span-6">
<div
:class="String(p.key).toLowerCase() === 'pro'
? 'relative overflow-hidden rounded-[1.75rem] border border-primary/40 bg-[var(--surface-card)]'
: 'relative overflow-hidden rounded-[1.75rem] border border-[var(--surface-border)] bg-[var(--surface-card)]'"
>
<div v-if="String(p.key).toLowerCase() === 'pro'" class="pointer-events-none absolute inset-0 opacity-80">
<div class="absolute -top-24 -right-28 h-96 w-96 rounded-full bg-primary/10 blur-3xl" />
<div class="absolute -bottom-28 left-12 h-96 w-96 rounded-full bg-emerald-400/10 blur-3xl" />
</div>
<Card class="relative border-0">
<template #title>
<div class="flex items-center justify-between gap-2">
<div class="flex items-center gap-2">
<i :class="String(p.key).toLowerCase() === 'pro' ? 'pi pi-sparkles opacity-80' : 'pi pi-leaf opacity-70'" />
<span class="text-xl font-semibold">Plano {{ String(p.key || '').toUpperCase() }}</span>
</div>
<div class="flex items-center gap-2">
<Tag v-if="currentPlanId === p.id" value="Atual" severity="secondary" />
<Tag v-else-if="String(p.key).toLowerCase() === 'pro'" value="Recomendado" severity="success" />
</div>
</div>
</template>
<template #subtitle>
<div class="flex items-center justify-between gap-3 flex-wrap">
<span class="text-[var(--text-color-secondary)]">
<template v-if="String(p.key).toLowerCase() === 'free'">O essencial para começar, sem travar seu fluxo.</template>
<template v-else-if="String(p.key).toLowerCase() === 'pro'">Para automatizar, reduzir ruído e ganhar previsibilidade.</template>
<template v-else>Plano: {{ p.key }}</template>
</span>
<span class="text-sm font-semibold">{{ priceLabelForPlan(p.id) }}</span>
</div>
</template>
<template #content>
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-ground)] p-4">
<ul class="list-none p-0 m-0 flex flex-col gap-3">
<li v-for="(b, i) in planBenefits(p.id)" :key="i" class="flex items-start gap-2">
<i :class="b.ok ? 'pi pi-check-circle text-emerald-500' : 'pi pi-times-circle opacity-50'" class="mt-0.5" />
<span :class="b.ok ? '' : 'text-[var(--text-color-secondary)]'">{{ b.text }}</span>
</li>
</ul>
<Divider class="my-4" />
<div class="flex flex-col gap-3">
<Button
v-if="currentPlanId !== p.id"
:label="`Mudar para ${String(p.key || '').toUpperCase()}`"
icon="pi pi-arrow-up"
size="large"
class="w-full"
:loading="upgrading"
:disabled="upgrading || loading"
@click="changePlan(p.id)"
/>
<Button
v-else
label="Você já está neste plano"
icon="pi pi-check"
severity="secondary"
outlined
class="w-full"
disabled
/>
<Button
v-if="String(p.key).toLowerCase() !== 'free'"
label="Falar com suporte"
icon="pi pi-comments"
severity="secondary"
outlined
class="w-full"
:disabled="upgrading"
@click="contactSupport"
/>
<div class="text-center text-xs text-[var(--text-color-secondary)]">
Cancele quando quiser. Sem burocracia.
</div>
<div v-if="!subscription?.id" class="text-center text-xs text-amber-500">
Sem assinatura ativa clique em <b>Assinatura</b> para ativar/criar.
</div>
</div>
</div>
</template>
</Card>
</div>
</div>
</div>
<div class="mt-6 text-xs text-[var(--text-color-secondary)]">
<div class="mt-4 text-[1rem] text-[var(--text-color-secondary)] opacity-60">
Alguns recursos PRO podem depender de configuração inicial (ex.: SMS exige provedor).
</div>
</div>
</template>
</template>
<style scoped>
.up-banner-enter-active,
.up-banner-leave-active { transition: all 0.25s ease; overflow: hidden; }
.up-banner-enter-from,
.up-banner-leave-to { opacity: 0; max-height: 0; margin-bottom: 0; }
.up-banner-enter-to,
.up-banner-leave-from { opacity: 1; max-height: 80px; }
</style>

View File

@@ -4,6 +4,9 @@ import NotificationsWidget from '@/components/dashboard/NotificationsWidget.vue'
import RecentSalesWidget from '@/components/dashboard/RecentSalesWidget.vue';
import RevenueStreamWidget from '@/components/dashboard/RevenueStreamWidget.vue';
import StatsWidget from '@/components/dashboard/StatsWidget.vue';
import { useRouter } from 'vue-router';
const router = useRouter();
</script>
<template>
@@ -13,8 +16,9 @@ import StatsWidget from '@/components/dashboard/StatsWidget.vue';
<span class="text-primary font-medium">Área</span>
<span class="text-muted-color"> da Clínica</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 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>
@@ -30,4 +34,4 @@ import StatsWidget from '@/components/dashboard/StatsWidget.vue';
<NotificationsWidget />
</div>
</div>
</template>
</template>

View File

@@ -324,268 +324,256 @@ watch(
</script>
<template>
<div class="p-4 md:p-6">
<Toast />
<Toast />
<!-- Header -->
<div class="mb-4 overflow-hidden rounded-[2rem] border border-[var(--surface-border)] bg-[var(--surface-card)]">
<div class="relative p-5 md:p-7">
<!-- blobs sutis -->
<div class="pointer-events-none absolute inset-0 opacity-80">
<div class="absolute -top-16 -right-20 h-72 w-72 rounded-full bg-indigo-400/10 blur-3xl" />
<div class="absolute top-10 -left-24 h-80 w-80 rounded-full bg-emerald-400/10 blur-3xl" />
<div class="absolute -bottom-20 right-24 h-72 w-72 rounded-full bg-fuchsia-400/10 blur-3xl" />
</div>
<!-- Sentinel -->
<div class=h-px />
<div class="relative flex flex-col gap-2">
<div class="flex items-center justify-between gap-3 flex-wrap">
<div class="min-w-0">
<h1 class="text-xl md:text-2xl font-semibold leading-tight">Tipos de Clínica</h1>
<p class="mt-1 text-sm opacity-80">
Ative/desative recursos por clínica. Isso controla menu, rotas (guard) e acesso no banco (RLS).
</p>
</div>
<div class="shrink-0 flex items-center gap-2">
<Button
label="Recarregar"
icon="pi pi-refresh"
severity="secondary"
outlined
:loading="loading"
:disabled="applyingPreset || !!savingKey"
@click="reload"
/>
</div>
</div>
<div class="mt-2 flex flex-wrap items-center gap-2 text-xs opacity-80">
<span class="inline-flex items-center gap-2 rounded-full border border-[var(--surface-border)] px-3 py-1">
<i class="pi pi-building" />
Tenant: <b class="font-mono">{{ tenantId || '—' }}</b>
</span>
<span class="inline-flex items-center gap-2 rounded-full border border-[var(--surface-border)] px-3 py-1">
<i class="pi pi-user" />
Role: <b>{{ role || '—' }}</b>
</span>
<span
v-if="!tenantReady"
class="inline-flex items-center gap-2 rounded-full border border-[var(--surface-border)] px-3 py-1 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 opacity-70"
>
<i class="pi pi-spin pi-spinner" />
Atualizando módulos
</span>
</div>
</div>
</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>
<!-- Banner: acesso somente leitura para terapeutas -->
<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=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=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="mb-4 flex items-center gap-3 rounded-[2rem] border border-amber-400/40 bg-amber-400/10 px-5 py-4 text-sm"
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 text-base shrink-0" />
<span class="opacity-90">
<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="mb-4 grid grid-cols-1 md:grid-cols-3 gap-3">
<Card class="rounded-[2rem]">
<template #content>
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<div class="text-sm font-semibold">Preset: Coworking</div>
<div class="mt-1 text-xs opacity-80">
Para aluguel de salas: sem pacientes, com salas.
</div>
<div 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>
<Button
size="small"
label="Aplicar"
severity="secondary"
outlined
:loading="applyingPreset"
:disabled="!isOwner || !tenantReady || loading || !!savingKey"
@click="applyPreset('coworking')"
/>
</div>
</template>
</Card>
<Button
size=small
label=Aplicar
severity=secondary
outlined
:loading=applyingPreset
:disabled=!isOwner || !tenantReady || loading || !!savingKey
@click=applyPreset('coworking')
/>
</div>
</div>
<Card class="rounded-[2rem]">
<template #content>
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<div class="text-sm font-semibold">Preset: Clínica com recepção</div>
<div class="mt-1 text-xs opacity-80">
Para secretária gerenciar agenda (pacientes opcional).
</div>
<div 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>
<Button
size="small"
label="Aplicar"
severity="secondary"
outlined
:loading="applyingPreset"
:disabled="!isOwner || !tenantReady || loading || !!savingKey"
@click="applyPreset('reception')"
/>
</div>
</template>
</Card>
<Button
size=small
label=Aplicar
severity=secondary
outlined
:loading=applyingPreset
:disabled=!isOwner || !tenantReady || loading || !!savingKey
@click=applyPreset('reception')
/>
</div>
</div>
<Card class="rounded-[2rem]">
<template #content>
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<div class="text-sm font-semibold">Preset: Clínica completa</div>
<div class="mt-1 text-xs opacity-80">
Pacientes + recepção + salas (se quiser).
</div>
<div 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>
<Button
size="small"
label="Aplicar"
severity="secondary"
outlined
:loading="applyingPreset"
:disabled="!isOwner || !tenantReady || loading || !!savingKey"
@click="applyPreset('full')"
/>
</div>
</template>
</Card>
<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">
<Card class="rounded-[2rem]">
<template #content>
<ModuleRow
title="Pacientes"
desc="Habilita gestão de pacientes por clínica. Todo paciente tem um responsável (therapist)."
icon="pi pi-users"
:enabled="isOn('patients')"
:loading="savingKey === 'patients'"
:disabled="isLocked('patients')"
@toggle="toggle('patients')"
/>
<div
v-if="planDenied.has('patients')"
class="mt-3 text-xs rounded-2xl 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-xs opacity-80 leading-relaxed">
Quando desligado:
<ul class="mt-2 list-disc pl-5 space-y-1">
<li>Menu Pacientes some.</li>
<li>Rotas com <span class="font-mono">meta.tenantFeature = 'patients'</span> redirecionam pra .</li>
<li>RLS bloqueia acesso direto no banco.</li>
</ul>
</div>
</template>
</Card>
<div class=grid grid-cols-1 lg:grid-cols-2 gap-3>
<div class=rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5>
<ModuleRow
title=Pacientes
desc=Habilita gestão de pacientes por clínica. Todo paciente tem um responsável (therapist).
icon=pi pi-users
:enabled=isOn('patients')
:loading=savingKey === 'patients'
:disabled=isLocked('patients')
@toggle=toggle('patients')
/>
<div
v-if=planDenied.has('patients')
class=mt-3 text-[1rem] rounded-md border border-[var(--surface-border)] p-3 opacity-90
>
<i class=pi pi-lock mr-2 />
Este módulo foi bloqueado pelo plano atual do tenant.
</div>
<Divider class=my-4 />
<div class=text-[1rem] text-[var(--text-color-secondary)] leading-relaxed>
Quando desligado:
<ul class=mt-2 list-disc pl-5 space-y-1>
<li>Menu Pacientes some.</li>
<li>Rotas com <span class=font-mono>meta.tenantFeature = 'patients'</span> redirecionam pra .</li>
<li>RLS bloqueia acesso direto no banco.</li>
</ul>
</div>
</div>
<Card class="rounded-[2rem]">
<template #content>
<ModuleRow
title="Recepção / Secretária"
desc="Permite um papel de secretária gerenciar a agenda dos profissionais (sem precisar ver tudo do paciente)."
icon="pi pi-briefcase"
:enabled="isOn('shared_reception')"
:loading="savingKey === 'shared_reception'"
:disabled="isLocked('shared_reception')"
@toggle="toggle('shared_reception')"
/>
<div
v-if="planDenied.has('shared_reception')"
class="mt-3 text-xs rounded-2xl 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-xs opacity-80 leading-relaxed">
Observação: este módulo é produto (UX + permissões). A base aqui é o toggle.
Depois a gente cria:
<ul class="mt-2 list-disc pl-5 space-y-1">
<li>role <span class="font-mono">secretary</span> em <span class="font-mono">tenant_members</span></li>
<li>policies e telas para a secretária</li>
<li>nível de visibilidade do paciente na agenda</li>
</ul>
</div>
</template>
</Card>
<div class=rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5>
<ModuleRow
title=Recepção / Secretária
desc=Permite um papel de secretária gerenciar a agenda dos profissionais (sem precisar ver tudo do paciente).
icon=pi pi-briefcase
:enabled=isOn('shared_reception')
:loading=savingKey === 'shared_reception'
:disabled=isLocked('shared_reception')
@toggle=toggle('shared_reception')
/>
<div
v-if=planDenied.has('shared_reception')
class=mt-3 text-[1rem] rounded-md border border-[var(--surface-border)] p-3 opacity-90
>
<i class=pi pi-lock mr-2 />
Este módulo foi bloqueado pelo plano atual do tenant.
</div>
<Divider class=my-4 />
<div class=text-[1rem] text-[var(--text-color-secondary)] leading-relaxed>
Observação: este módulo é produto (UX + permissões). A base aqui é o toggle.
Depois a gente cria:
<ul class=mt-2 list-disc pl-5 space-y-1>
<li>role <span class=font-mono>secretary</span> em <span class=font-mono>tenant_members</span></li>
<li>policies e telas para a secretária</li>
<li>nível de visibilidade do paciente na agenda</li>
</ul>
</div>
</div>
<Card class="rounded-[2rem]">
<template #content>
<ModuleRow
title="Salas / Coworking"
desc="Habilita cadastro e reserva de salas/recursos no agendamento."
icon="pi pi-building"
:enabled="isOn('rooms')"
:loading="savingKey === 'rooms'"
:disabled="isLocked('rooms')"
@toggle="toggle('rooms')"
/>
<div
v-if="planDenied.has('rooms')"
class="mt-3 text-xs rounded-2xl 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-xs opacity-80 leading-relaxed">
Isso prepara o terreno para a clínica operar como locação de sala, com agenda vinculando sala + profissional.
</div>
</template>
</Card>
<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>
<Card class="rounded-[2rem]">
<template #content>
<ModuleRow
title="Link externo de cadastro"
desc="Libera fluxo público de intake/cadastro externo para a clínica."
icon="pi pi-link"
:enabled="isOn('intake_public')"
:loading="savingKey === 'intake_public'"
:disabled="isLocked('intake_public')"
@toggle="toggle('intake_public')"
/>
<div
v-if="planDenied.has('intake_public')"
class="mt-3 text-xs rounded-2xl 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-xs opacity-80 leading-relaxed">
Você tem páginas de link externo. Isso vira o controle fino: a clínica decide se usa ou não.
</div>
</template>
</Card>
<div class=rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5>
<ModuleRow
title=Link externo de cadastro
desc=Libera fluxo público de intake/cadastro externo para a clínica.
icon=pi pi-link
:enabled=isOn('intake_public')
:loading=savingKey === 'intake_public'
:disabled=isLocked('intake_public')
@toggle=toggle('intake_public')
/>
<div
v-if=planDenied.has('intake_public')
class=mt-3 text-[1rem] rounded-md border border-[var(--surface-border)] p-3 opacity-90
>
<i class=pi pi-lock mr-2 />
Este módulo foi bloqueado pelo plano atual do tenant.
</div>
<Divider class=my-4 />
<div class=text-[1rem] text-[var(--text-color-secondary)] leading-relaxed>
Você tem páginas de link externo. Isso vira o controle fino: a clínica decide se usa ou não.
</div>
</div>
</div>
</div>
</template>
<style scoped>
/* (sem estilos adicionais por enquanto) */
</style>

View File

@@ -51,6 +51,12 @@ const loadingHistory = ref(false)
const loadHistoryError = ref('')
const historySearch = ref('')
const HIGHLIGHT_MS = 24 * 60 * 60 * 1000
function isRecent (row) {
if (!row?.created_at) return false
return Date.now() - new Date(row.created_at).getTime() < HIGHLIGHT_MS
}
const filteredHistory = computed(() => {
const q = (historySearch.value || '').trim().toLowerCase()
const base = history.value || []
@@ -96,8 +102,12 @@ const activeTenantKind = ref(null)
const canManage = computed(() => {
const r = (effectiveRole.value || '').toString()
const isAdmin = r === 'clinic_admin' || r === 'tenant_admin'
// só pode gerenciar se for admin E o tenant for uma clínica (não pessoal/saas)
return isAdmin && activeTenantKind.value === 'clinic'
if (!isAdmin) return false
// Aceita qualquer kind de clínica: 'clinic', 'clinic_coworking', 'clinic_reception', 'clinic_full'
// Se activeTenantKind ainda não carregou (null), confia no role já normalizado
const k = String(activeTenantKind.value || '')
if (!k) return true
return k === 'clinic' || k.startsWith('clinic_')
})
const DEV_TEST_EMAILS = [
@@ -733,91 +743,76 @@ onMounted(async () => {
</script>
<template>
<div class="p-4 md:p-6">
<Toast />
<ConfirmDialog />
<Toast />
<ConfirmDialog />
<!-- Header -->
<div class="mb-4 overflow-hidden rounded-[2rem] border border-[var(--surface-border)] bg-[var(--surface-card)]">
<div class="relative p-5 md:p-7">
<!-- blobs sutis -->
<div class="pointer-events-none absolute inset-0 opacity-80">
<div class="absolute -top-16 -right-20 h-72 w-72 rounded-full bg-indigo-400/10 blur-3xl" />
<div class="absolute top-10 -left-24 h-80 w-80 rounded-full bg-emerald-400/10 blur-3xl" />
<div class="absolute -bottom-20 right-24 h-72 w-72 rounded-full bg-fuchsia-400/10 blur-3xl" />
<!-- 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)]">Profissionais da clínica</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-0.5">
Gerencie terapeutas e secretarias vinculados ao seu tenant.
</div>
</div>
<div class="relative flex flex-col gap-2">
<div class="flex items-center justify-between gap-3 flex-wrap">
<div class="min-w-0">
<div class="text-xl md:text-2xl font-semibold leading-tight">
Profissionais da clínica
</div>
<div class="opacity-70 text-sm">
Gerencie terapeutas e secretarias vinculados ao seu tenant.
</div>
</div>
<div class="flex items-center gap-2 flex-wrap">
<Button
label="Convidar terapeuta"
icon="pi pi-user-plus"
@click="openInvite('therapist')"
:disabled="!tenantReady || !canManage"
/>
<Button
label="Adicionar secretária"
icon="pi pi-users"
severity="secondary"
outlined
@click="openInvite('secretary')"
:disabled="!tenantReady || !canManage"
/>
</div>
</div>
<!-- Aviso (regra futura) -->
<div class="mt-3 rounded-2xl border border-[var(--surface-border)] bg-[color-mix(in_srgb,var(--surface-card),transparent_30%)] p-3">
<div class="flex gap-3">
<i class="pi pi-info-circle mt-0.5 opacity-70" />
<div class="text-sm">
<div class="font-semibold">Atenção</div>
<div class="opacity-80 leading-relaxed">
A regra impedir desvincular terapeuta com atendimentos agendados será ativada quando a agenda
registrar o terapeuta no evento (ex.: <span class="font-mono">agenda_eventos.terapeuta_id</span>)
ou quando existir a tabela de sessões/appointments. Por enquanto, a ação de desvincular apenas
desativa o vínculo.
</div>
</div>
</div>
</div>
<!-- Loading leve do tenant -->
<div v-if="!tenantReady" class="mt-2 text-sm opacity-70">
Carregando permissões da clínica
</div>
<!-- Aviso de permissão -->
<div v-else-if="!canManage" class="mt-2 text-sm text-orange-600">
Sua conta não tem permissão para gerenciar profissionais (apenas <b>clinic_admin</b>).
</div>
<!-- Debug (opcional) -->
<div v-if="debug" class="mt-2 text-xs opacity-70">
tenantId={{ tenantId }} | role={{ effectiveRole || '(vazio)' }} | canManage={{ canManage }}
</div>
<div class="flex items-center gap-2 flex-wrap shrink-0">
<Button
label="Convidar terapeuta"
icon="pi pi-user-plus"
@click="openInvite('therapist')"
:disabled="!tenantReady || !canManage"
/>
<Button
label="Adicionar secretária"
icon="pi pi-users"
severity="secondary"
outlined
@click="openInvite('secretary')"
:disabled="!tenantReady || !canManage"
/>
</div>
</div>
<!-- Loading leve do tenant -->
<div v-if="!tenantReady" class="text-[1rem] text-[var(--text-color-secondary)] opacity-70">
Carregando permissões da clínica
</div>
<!-- Aviso de permissão (somente se carregou e não tem permissão) -->
<div v-else-if="!canManage" class="text-[1rem] text-orange-600">
Sua conta não tem permissão para gerenciar profissionais (apenas <b>clinic_admin</b>).
</div>
<!-- Debug (opcional) -->
<div v-if="debug" class="text-[1rem] opacity-70">
tenantId={{ tenantId }} | role={{ effectiveRole || '(vazio)' }} | canManage={{ canManage }}
</div>
</div>
</div>
<div class="px-3 md:px-4 pb-8 flex flex-col gap-4">
<!-- 🔎 Aviso sobre logins de teste -->
<!-- 🔎 Aviso sobre logins de teste + atalhos de convite -->
<div
class="mb-4 overflow-hidden rounded-[2rem] border border-[var(--surface-border)] bg-[color-mix(in_srgb,var(--surface-card),transparent_18%)]"
>
<!-- 🔎 Aviso sobre logins de teste + atalhos de convite -->
<div
class="overflow-hidden rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)]"
>
<div class="p-5 md:p-6">
<div class="flex items-start gap-3">
<div class="h-10 w-10 rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] grid place-items-center shrink-0">
<div class="h-10 w-10 rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] grid place-items-center shrink-0">
<i class="pi pi-info-circle opacity-70" />
</div>
@@ -826,18 +821,18 @@ onMounted(async () => {
Logins de teste (ambiente de desenvolvimento)
</div>
<div class="text-sm opacity-80 mt-1 leading-relaxed">
<div class="text-[1rem] opacity-80 mt-1 leading-relaxed">
As credenciais fixas para testes (clinic, therapist, therapist2, therapist3, secretary, patient e saas)
estão disponíveis na tela inicial do sistema (<span class="font-mono">HomeCards</span>).
</div>
<div class="text-sm opacity-80 mt-2 leading-relaxed">
<div class="text-[1rem] opacity-80 mt-2 leading-relaxed">
Para utilizá-las, basta realizar <b>logout da sessão atual</b> e selecionar o perfil desejado na tela inicial.
</div>
<!-- Atalhos: abrir dialog e preencher o email -->
<div class="mt-4 flex flex-wrap items-center gap-2">
<span class="text-xs opacity-70 mr-1">Atalhos (DEV):</span>
<span class="text-[1rem] opacity-70 mr-1">Atalhos (DEV):</span>
<Button
label="Convidar therapist2"
@@ -872,13 +867,13 @@ onMounted(async () => {
<!-- Links de convite pendentes para testes -->
<div class="mt-3 flex flex-col gap-1">
<span class="text-xs opacity-70 mb-1">Links de convite pendentes (DEV):</span>
<span class="text-[1rem] opacity-70 mb-1">Links de convite pendentes (DEV):</span>
<div
v-for="item in devInviteLinks"
:key="item.email"
class="flex items-center gap-2 flex-wrap"
>
<span class="text-xs font-mono opacity-70 w-56 truncate">{{ item.email }}</span>
<span class="text-[1rem] font-mono opacity-70 w-56 truncate">{{ item.email }}</span>
<Button
label="Copiar link de convite"
icon="pi pi-copy"
@@ -888,7 +883,7 @@ onMounted(async () => {
:disabled="!item.token"
@click="copyInviteLink(item)"
/>
<span v-if="!item.token" class="text-xs opacity-50 italic">sem convite pendente</span>
<span v-if="!item.token" class="text-[1rem] opacity-50 italic">sem convite pendente</span>
</div>
</div>
</div>
@@ -897,16 +892,16 @@ onMounted(async () => {
</div>
<!-- DOCUMENTAÇÃO INTERNA (visível na tela, para QA) -->
<div class="mb-4 overflow-hidden rounded-[2rem] border border-[var(--surface-border)] bg-[color-mix(in_srgb,var(--surface-card),transparent_12%)]">
<div class="overflow-hidden rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)]">
<div class="p-5 md:p-6">
<div class="flex items-start gap-3">
<div class="h-10 w-10 rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] grid place-items-center shrink-0">
<div class="h-10 w-10 rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] grid place-items-center shrink-0">
<i class="pi pi-book opacity-70" />
</div>
<div class="min-w-0">
<div class="font-semibold text-lg leading-tight">Guia rápido Convites (Modelo B) e como testar</div>
<div class="text-sm opacity-80 mt-1 leading-relaxed">
<div class="text-[1rem] opacity-80 mt-1 leading-relaxed">
Esta área existe para facilitar o QA/validação do fluxo de convites no SaaS multi-tenant.
A tela pública de aceite está em:
<span class="font-mono">/accept-invite?token=&lt;uuid&gt;</span>.
@@ -914,9 +909,9 @@ onMounted(async () => {
</div>
<div class="mt-4 grid gap-3">
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-4">
<div class="font-semibold text-sm mb-2">Rotas e comportamento esperado</div>
<ul class="text-sm opacity-80 leading-relaxed list-disc pl-5 space-y-1">
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-4">
<div class="font-semibold text-[1rem] mb-2">Rotas e comportamento esperado</div>
<ul class="text-[1rem] opacity-80 leading-relaxed list-disc pl-5 space-y-1">
<li><b>Aceite público:</b> <span class="font-mono">/accept-invite?token=&lt;uuid&gt;</span></li>
<li><b>Login:</b> <span class="font-mono">/auth/login</span></li>
<li>
@@ -930,16 +925,16 @@ onMounted(async () => {
<li><b>Erros esperados:</b> token inválido/expirado, convite usado, e-mail diferente (mismatch).</li>
</ul>
<div class="mt-3 text-xs opacity-70 leading-relaxed">
<div class="mt-3 text-[1rem] opacity-70 leading-relaxed">
<b>Nota:</b> o backend foi corrigido para não depender do claim de email no JWT
(erro antigo <span class="font-mono">missing_email_claim</span>). O email é resolvido via
<span class="font-mono">auth.users</span> usando <span class="font-mono">SECURITY DEFINER</span>.
</div>
</div>
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-4">
<div class="font-semibold text-sm mb-2">Como testar (prático)</div>
<ol class="text-sm opacity-80 leading-relaxed list-decimal pl-5 space-y-1">
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-4">
<div class="font-semibold text-[1rem] mb-2">Como testar (prático)</div>
<ol class="text-[1rem] opacity-80 leading-relaxed list-decimal pl-5 space-y-1">
<li>Convidar alguém nesta tela (botões acima).</li>
<li>Abrir a aba <b>Convites</b> e copiar o link.</li>
<li>Abrir o link em aba anônima logar com o mesmo email aceitar.</li>
@@ -965,7 +960,7 @@ onMounted(async () => {
/>
</div>
<div class="mt-2 text-xs opacity-70">
<div class="mt-2 text-[1rem] opacity-70">
Dica: use aba anônima para testar o fluxo completo sem interferência de sessão.
</div>
</div>
@@ -974,18 +969,18 @@ onMounted(async () => {
</div>
<!-- Abas -->
<TabView class="rounded-[2rem] overflow-hidden">
<TabView class="rounded-md overflow-hidden">
<!-- =========================
ABA 1: EQUIPE
========================= -->
<TabPanel header="Equipe">
<div class="grid grid-cols-1 gap-4">
<Card class="rounded-[2rem]">
<Card class="rounded-md">
<template #title>
<div class="flex items-center justify-between gap-3 flex-wrap">
<div class="min-w-0">
<div class="font-semibold">Equipe</div>
<div class="text-sm opacity-70">
<div class="text-[1rem] opacity-70">
Membros ativos/inativos do tenant (somente <b>tenant_members</b>).
</div>
</div>
@@ -1015,7 +1010,7 @@ onMounted(async () => {
</template>
<template #content>
<div v-if="loadError" class="mb-3 rounded-2xl border border-red-200 bg-red-50 p-3 text-red-700 text-sm">
<div v-if="loadError" class="mb-3 rounded-md border border-red-200 bg-red-50 p-3 text-red-700 text-[1rem]">
{{ loadError }}
</div>
@@ -1024,14 +1019,15 @@ onMounted(async () => {
:loading="loading"
dataKey="user_id"
responsiveLayout="scroll"
class="p-datatable-sm"
class="p-datatable-sm prof-datatable"
sortField="role_sort"
:sortOrder="1"
:rowClass="(r) => isRecent(r) ? 'row-new-highlight' : ''"
>
<Column header="Pessoa" style="min-width: 18rem">
<template #body="{ data }">
<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">
<div class="h-10 w-10 rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] grid place-items-center">
<i class="pi pi-user opacity-70" />
</div>
@@ -1040,15 +1036,15 @@ onMounted(async () => {
{{ data.full_name || 'Sem nome' }}
</div>
<div class="text-sm opacity-70 truncate">
<div class="text-[1rem] opacity-70 truncate">
{{ data.email || 'Sem email' }}
</div>
<div class="text-xs opacity-60 font-mono truncate">
<div class="text-[1rem] opacity-60 font-mono truncate">
tenant: {{ data.tenant_id }}
</div>
<div class="text-xs opacity-60 font-mono truncate">
<div class="text-[1rem] opacity-60 font-mono truncate">
uid: {{ data.user_id }}
</div>
</div>
@@ -1074,16 +1070,16 @@ onMounted(async () => {
<Column header="Vínculo" style="min-width: 12rem">
<template #body="{ data }">
<span v-if="canManage" class="text-sm opacity-70 italic">vinculado nesta clínica</span>
<span v-if="canManage" class="text-[1rem] opacity-70 italic">vinculado nesta clínica</span>
<template v-else>
<div v-if="myLinks.length > 0" class="flex flex-col gap-1">
<span
v-for="link in myLinks"
:key="link.tenant_id"
class="text-sm"
class="text-[1rem]"
>{{ link.clinic_name }}</span>
</div>
<span v-else class="text-sm opacity-50"></span>
<span v-else class="text-[1rem] opacity-50"></span>
</template>
</template>
</Column>
@@ -1113,7 +1109,7 @@ onMounted(async () => {
/>
</div>
<div v-if="data.is_self" class="text-xs opacity-60 mt-1 text-right">
<div v-if="data.is_self" class="text-[1rem] opacity-60 mt-1 text-right">
Você
</div>
</template>
@@ -1126,26 +1122,26 @@ onMounted(async () => {
</template>
</DataTable>
<small class="block mt-3 opacity-70">
<div class="text-[1rem] block mt-3 opacity-70">
Papel real salvo em <span class="font-mono">tenant_members.role</span>:
<b>tenant_admin</b>, <b>therapist</b>, <b>secretary</b>, <b>patient</b>.
No front, normalizamos <b>tenant_admin clinic_admin</b> (apenas para UI).
</small>
</div>
<small class="block mt-2 opacity-70">
<div class="text-[1rem] block mt-2 opacity-70">
<b>Status:</b> <span class="font-mono">active</span> = acesso liberado.
<span class="font-mono">inactive</span> = vínculo desativado (histórico).
</small>
</div>
</template>
</Card>
<!-- Meus Vínculos (visível apenas para terapeutas e secretárias) -->
<Card v-if="(effectiveRole === 'therapist' || effectiveRole === 'secretary') && (myLinks.length > 0 || loadingMyLinks)" class="rounded-[2rem]">
<Card v-if="(effectiveRole === 'therapist' || effectiveRole === 'secretary') && (myLinks.length > 0 || loadingMyLinks)" class="rounded-md">
<template #title>
<div class="flex items-center justify-between gap-3 flex-wrap">
<div class="min-w-0">
<div class="font-semibold">Meus vínculos</div>
<div class="text-sm opacity-70">
<div class="text-[1rem] opacity-70">
Clínicas às quais sua conta está associada.
</div>
</div>
@@ -1161,7 +1157,7 @@ onMounted(async () => {
</template>
<template #content>
<div v-if="loadMyLinksError" class="mb-3 rounded-2xl border border-red-200 bg-red-50 p-3 text-red-700 text-sm">
<div v-if="loadMyLinksError" class="mb-3 rounded-md border border-red-200 bg-red-50 p-3 text-red-700 text-[1rem]">
{{ loadMyLinksError }}
</div>
@@ -1169,15 +1165,15 @@ onMounted(async () => {
<div
v-for="link in myLinks"
:key="link.tenant_id"
class="flex items-center justify-between gap-4 rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-4"
class="flex items-center justify-between gap-4 rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-4"
>
<div class="flex items-center gap-3 min-w-0">
<div class="h-10 w-10 rounded-2xl border border-[var(--surface-border)] bg-[color-mix(in_srgb,var(--surface-card),transparent_30%)] grid place-items-center shrink-0">
<div class="h-10 w-10 rounded-md border border-[var(--surface-border)] bg-[color-mix(in_srgb,var(--surface-card),transparent_30%)] grid place-items-center shrink-0">
<i class="pi pi-building opacity-70" />
</div>
<div class="min-w-0">
<div class="font-semibold truncate">{{ link.clinic_name }}</div>
<div class="text-xs font-mono opacity-50 truncate">{{ link.tenant_id }}</div>
<div class="text-[1rem] font-mono opacity-50 truncate">{{ link.tenant_id }}</div>
</div>
</div>
@@ -1192,9 +1188,9 @@ onMounted(async () => {
</div>
</div>
<small class="block mt-3 opacity-70">
<div class="text-[1rem] block mt-3 opacity-70">
Um profissional pode estar vinculado a múltiplas clínicas simultaneamente.
</small>
</div>
</template>
</Card>
</div>
@@ -1202,12 +1198,12 @@ onMounted(async () => {
========================= -->
<TabPanel header="Convites">
<div class="grid grid-cols-1 gap-4">
<Card class="rounded-[2rem]">
<Card class="rounded-md">
<template #title>
<div class="flex items-center justify-between gap-3 flex-wrap">
<div class="min-w-0">
<div class="font-semibold">Convites pendentes</div>
<div class="text-sm opacity-70">
<div class="text-[1rem] opacity-70">
Convites do tenant que ainda não foram aceitos (tabela <b>tenant_invites</b>).
</div>
</div>
@@ -1237,7 +1233,7 @@ onMounted(async () => {
</template>
<template #content>
<div v-if="loadInvitesError" class="mb-3 rounded-2xl border border-red-200 bg-red-50 p-3 text-red-700 text-sm">
<div v-if="loadInvitesError" class="mb-3 rounded-md border border-red-200 bg-red-50 p-3 text-red-700 text-[1rem]">
{{ loadInvitesError }}
</div>
@@ -1246,18 +1242,19 @@ onMounted(async () => {
:loading="loadingInvites"
dataKey="token"
responsiveLayout="scroll"
class="p-datatable-sm"
class="p-datatable-sm invites-datatable"
sortField="created_at"
:sortOrder="-1"
:rowClass="(r) => isRecent(r) ? 'row-new-highlight' : ''"
>
<Column header="Email" style="min-width: 18rem">
<template #body="{ data }">
<div class="min-w-0">
<div class="font-semibold truncate">{{ data.email }}</div>
<div class="text-xs opacity-60 font-mono truncate">
<div class="text-[1rem] opacity-60 font-mono truncate">
token: {{ data.token }}
</div>
<div class="text-xs opacity-60 font-mono truncate">
<div class="text-[1rem] opacity-60 font-mono truncate">
tenant: {{ data.tenant_id }}
</div>
</div>
@@ -1272,13 +1269,13 @@ onMounted(async () => {
<Column header="Expira" style="width: 14rem">
<template #body="{ data }">
<span class="text-sm opacity-80">{{ formatDate(data.expires_at) }}</span>
<span class="text-[1rem] opacity-80">{{ formatDate(data.expires_at) }}</span>
</template>
</Column>
<Column header="Criado em" style="width: 14rem">
<template #body="{ data }">
<span class="text-sm opacity-80">{{ formatDate(data.created_at) }}</span>
<span class="text-[1rem] opacity-80">{{ formatDate(data.created_at) }}</span>
</template>
</Column>
@@ -1325,15 +1322,15 @@ onMounted(async () => {
</template>
</DataTable>
<small class="block mt-3 opacity-70">
<div class="text-[1rem] block mt-3 opacity-70">
<b>Modelo B:</b> convidar não cria membership. O membership aparece na aba <b>Equipe</b> após o aceite em
<span class="font-mono">/accept-invite?token=...</span>.
</small>
</div>
</template>
</Card>
<!-- QA TOOL: Auth Users (TEMPORÁRIO) -->
<Card class="rounded-[2rem] mt-2">
<Card class="rounded-md mt-2">
<template #title>
<div class="flex items-center justify-between">
<div class="font-semibold">Usuários cadastrados (Auth)</div>
@@ -1349,10 +1346,10 @@ onMounted(async () => {
</template>
<template #content>
<div class="mb-4 rounded-2xl border border-orange-200 bg-orange-50 p-4">
<div class="mb-4 rounded-md border border-orange-200 bg-orange-50 p-4">
<div class="flex gap-3">
<i class="pi pi-exclamation-triangle mt-0.5 text-orange-600" />
<div class="text-sm text-orange-800 leading-relaxed">
<div class="text-[1rem] text-orange-800 leading-relaxed">
<div class="font-semibold mb-1">
Aviso técnico View temporária para testes (QA)
</div>
@@ -1372,14 +1369,14 @@ onMounted(async () => {
Remover após validação:
</div>
<div class="mt-2 font-mono text-xs bg-white/60 border border-orange-200 rounded-xl p-2">
<div class="mt-2 font-mono text-[1rem] bg-white/60 border border-orange-200 rounded-md p-2">
drop view if exists public.v_auth_users_public;
</div>
</div>
</div>
</div>
<div v-if="loadUsersError" class="mb-3 rounded-2xl border border-red-200 bg-red-50 p-3 text-red-700 text-sm">
<div v-if="loadUsersError" class="mb-3 rounded-md border border-red-200 bg-red-50 p-3 text-red-700 text-[1rem]">
{{ loadUsersError }}
</div>
@@ -1393,7 +1390,7 @@ onMounted(async () => {
<Column field="email" header="Email" style="min-width: 18rem" />
<Column header="User ID" style="min-width: 22rem">
<template #body="{ data }">
<span class="text-xs font-mono opacity-70 break-all">{{ data.user_id }}</span>
<span class="text-[1rem] font-mono opacity-70 break-all">{{ data.user_id }}</span>
</template>
</Column>
<Column field="created_at" header="Criado em" />
@@ -1409,12 +1406,12 @@ onMounted(async () => {
========================= -->
<TabPanel header="Histórico">
<div class="grid grid-cols-1 gap-4">
<Card class="rounded-[2rem]">
<Card class="rounded-md">
<template #title>
<div class="flex items-center justify-between gap-3 flex-wrap">
<div class="min-w-0">
<div class="font-semibold">Histórico de desvinculados</div>
<div class="text-sm opacity-70">
<div class="text-[1rem] opacity-70">
Membros inativos e convites revogados ou expirados deste tenant.
</div>
</div>
@@ -1444,7 +1441,7 @@ onMounted(async () => {
</template>
<template #content>
<div v-if="loadHistoryError" class="mb-3 rounded-2xl border border-red-200 bg-red-50 p-3 text-red-700 text-sm">
<div v-if="loadHistoryError" class="mb-3 rounded-md border border-red-200 bg-red-50 p-3 text-red-700 text-[1rem]">
{{ loadHistoryError }}
</div>
@@ -1460,13 +1457,13 @@ onMounted(async () => {
<Column header="Pessoa / Email" style="min-width: 18rem">
<template #body="{ data }">
<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 shrink-0">
<div class="h-10 w-10 rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] grid place-items-center shrink-0">
<i :class="data.kind === 'member' ? 'pi pi-user opacity-70' : 'pi pi-envelope opacity-70'" />
</div>
<div class="min-w-0">
<div class="font-semibold truncate">{{ data.full_name || data.email || 'Sem identificação' }}</div>
<div v-if="data.full_name" class="text-sm opacity-70 truncate">{{ data.email }}</div>
<div class="text-xs opacity-50 font-mono truncate">
<div v-if="data.full_name" class="text-[1rem] opacity-70 truncate">{{ data.email }}</div>
<div class="text-[1rem] opacity-50 font-mono truncate">
{{ data.kind === 'member' ? 'uid: ' + data.user_id : 'token: ' + data.token }}
</div>
</div>
@@ -1502,10 +1499,10 @@ onMounted(async () => {
<Column header="Data" style="width: 14rem">
<template #body="{ data }">
<div class="text-sm opacity-80">
<div class="text-[1rem] opacity-80">
<div>Criado: {{ formatDate(data.created_at) }}</div>
<div v-if="data.revoked_at" class="text-xs text-red-500 mt-0.5">Revogado: {{ formatDate(data.revoked_at) }}</div>
<div v-else-if="data.expires_at && data.kind !== 'member'" class="text-xs opacity-60 mt-0.5">Expirou: {{ formatDate(data.expires_at) }}</div>
<div v-if="data.revoked_at" class="text-[1rem] text-red-500 mt-0.5">Revogado: {{ formatDate(data.revoked_at) }}</div>
<div v-else-if="data.expires_at && data.kind !== 'member'" class="text-[1rem] opacity-60 mt-0.5">Expirou: {{ formatDate(data.expires_at) }}</div>
</div>
</template>
</Column>
@@ -1532,9 +1529,9 @@ onMounted(async () => {
</template>
</DataTable>
<small class="block mt-3 opacity-70">
<div class="text-[1rem] block mt-3 opacity-70">
Membros inativos podem ser reativados a qualquer momento. Convites revogados/expirados são apenas registro histórico.
</small>
</div>
</template>
</Card>
</div>
@@ -1548,10 +1545,9 @@ onMounted(async () => {
dismissableMask
:style="{ width: 'min(520px, 94vw)' }"
:header="inviteHeader"
class="rounded-[2rem] overflow-hidden"
>
<div class="space-y-4">
<div class="text-sm opacity-80 leading-relaxed">
<div class="text-[1rem] text-[var(--text-color-secondary)] leading-relaxed">
Informe o email. Este fluxo cria um convite pendente (Modelo B) e ativa o vínculo após o aceite em
<span class="font-mono">/accept-invite</span>.
</div>
@@ -1577,7 +1573,7 @@ onMounted(async () => {
/>
</div>
<div v-if="invite.error" class="rounded-2xl border border-red-200 bg-red-50 p-3 text-red-700 text-sm">
<div v-if="invite.error" class="rounded-md border border-red-200 bg-red-50 p-3 text-[1rem] text-red-700">
{{ invite.error }}
</div>
</div>
@@ -1586,4 +1582,8 @@ onMounted(async () => {
</template>
<style scoped>
.prof-datatable :deep(tr.row-new-highlight td) { background-color: #f0fdf4 !important; }
.invites-datatable :deep(tr.row-new-highlight td) { background-color: #f0fdf4 !important; }
:global(.app-dark) .prof-datatable :deep(tr.row-new-highlight td) { background-color: rgba(16,185,129,0.08) !important; }
:global(.app-dark) .invites-datatable :deep(tr.row-new-highlight td) { background-color: rgba(16,185,129,0.08) !important; }
</style>

View File

@@ -407,424 +407,341 @@ onBeforeUnmount(() => {
<Toast />
<ConfirmDialog />
<!-- Info decorativa (scrolls away naturalmente) -->
<div class="flex items-center gap-3 px-4 pb-3">
<div class="dash-hero__icon shrink-0">
<i class="pi pi-chart-bar text-2xl" />
</div>
<small class="text-color-secondary">
Visão estratégica (receita e distribuição) + saúde de consistência (entitlements).
</small>
</div>
<!-- Sentinel -->
<div ref="sentinelRef" class="h-px" />
<!-- sentinel -->
<div ref="sentinelRef" style="height: 1px; pointer-events: none;" />
<!-- hero -->
<!-- Hero sticky -->
<div
ref="heroRef"
class="dash-hero"
:class="{ 'dash-hero--stuck': heroStuck }"
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="dash-hero__blob dash-hero__blob--1" />
<div class="dash-hero__blob dash-hero__blob--2" />
<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)]">Central de Controle do SaaS</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-0.5">Visão estratégica (receita e distribuição) + saúde de consistência (entitlements).</div>
</div>
<div class="flex items-center gap-3 min-w-0">
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2 flex-wrap">
<span class="text-xl font-bold leading-none">Central de Controle do SaaS</span>
<!-- desktop actions -->
<div class="hidden xl:flex items-center gap-2">
<SelectButton
v-model="intervalView"
:options="intervalOptions"
optionLabel="label"
optionValue="value"
:disabled="loading"
/>
<Button
label="Recarregar"
icon="pi pi-refresh"
severity="secondary"
outlined
:loading="loading"
@click="loadStats"
/>
<Button
label="Assinaturas"
icon="pi pi-credit-card"
severity="secondary"
outlined
:disabled="loading"
@click="router.push('/saas/subscriptions')"
/>
<Button
label="Eventos"
icon="pi pi-history"
severity="secondary"
outlined
:disabled="loading"
@click="router.push('/saas/subscription-events')"
/>
</div>
<!-- mobile -->
<div class="flex xl:hidden">
<Button
label="Ações"
icon="pi pi-ellipsis-v"
severity="warn"
outlined
@click="(e) => mobileMenuRef.toggle(e)"
/>
<Menu ref="mobileMenuRef" :model="heroMenuItems" popup />
</div>
</div>
<!-- desktop actions -->
<div class="hidden xl:flex items-center gap-2 shrink-0">
<SelectButton
v-model="intervalView"
:options="intervalOptions"
optionLabel="label"
optionValue="value"
:disabled="loading"
/>
<Button
label="Recarregar"
icon="pi pi-refresh"
severity="secondary"
outlined
:loading="loading"
@click="loadStats"
/>
<Button
label="Assinaturas"
icon="pi pi-credit-card"
severity="secondary"
outlined
:disabled="loading"
@click="router.push('/saas/subscriptions')"
/>
<Button
label="Eventos"
icon="pi pi-history"
severity="secondary"
outlined
:disabled="loading"
@click="router.push('/saas/subscription-events')"
/>
</div>
<!-- mobile -->
<div class="flex xl:hidden shrink-0">
<Button
label="Ações"
icon="pi pi-ellipsis-v"
severity="warn"
outlined
@click="(e) => mobileMenuRef.toggle(e)"
/>
<Menu ref="mobileMenuRef" :model="heroMenuItems" popup />
</div>
</div>
</div>
<!-- content -->
<div class="px-4 pb-4">
<div class="px-3 md:px-4 pb-8 flex flex-col gap-4">
<!-- KPIs -->
<div class="grid grid-cols-12 gap-4">
<div class="col-span-12 md:col-span-3">
<Card class="h-full">
<template #title>
<div class="flex items-center justify-between">
<span>Ativas</span>
<Tag value="active" severity="success" rounded />
</div>
</template>
<template #content>
<div class="text-4xl font-semibold">{{ totalActive }}</div>
<small class="text-color-secondary">assinaturas em status <b>active</b></small>
</template>
</Card>
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5 h-full">
<div class="flex items-center justify-between mb-3">
<div class="text-[1rem] font-semibold text-[var(--text-color)]">Ativas</div>
<Tag value="active" severity="success" rounded />
</div>
<div class="text-4xl font-semibold">{{ totalActive }}</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-1">assinaturas em status <b>active</b></div>
</div>
</div>
<div class="col-span-12 md:col-span-3">
<Card class="h-full">
<template #title>
<div class="flex items-center justify-between">
<span>Canceladas</span>
<Tag value="canceled" severity="danger" rounded />
</div>
</template>
<template #content>
<div class="text-4xl font-semibold">{{ totalCanceled }}</div>
<small class="text-color-secondary">assinaturas em status <b>canceled</b></small>
</template>
</Card>
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5 h-full">
<div class="flex items-center justify-between mb-3">
<div class="text-[1rem] font-semibold text-[var(--text-color)]">Canceladas</div>
<Tag value="canceled" severity="danger" rounded />
</div>
<div class="text-4xl font-semibold">{{ totalCanceled }}</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-1">assinaturas em status <b>canceled</b></div>
</div>
</div>
<div class="col-span-12 md:col-span-3">
<Card class="h-full">
<template #title>
<div class="flex items-center justify-between">
<span>{{ intervalView === 'year' ? 'ARR' : 'MRR' }}</span>
<Tag :value="intervalView === 'year' ? 'anual' : 'mensal'" severity="secondary" rounded />
</div>
</template>
<template #content>
<div class="text-3xl font-semibold">{{ moneyBRLFromCents(revenueCents) }}</div>
<small class="text-color-secondary">normalizado (mensal anual)</small>
</template>
</Card>
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5 h-full">
<div class="flex items-center justify-between mb-3">
<div class="text-[1rem] font-semibold text-[var(--text-color)]">{{ intervalView === 'year' ? 'ARR' : 'MRR' }}</div>
<Tag :value="intervalView === 'year' ? 'anual' : 'mensal'" severity="secondary" rounded />
</div>
<div class="text-3xl font-semibold">{{ moneyBRLFromCents(revenueCents) }}</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-1">normalizado (mensal anual)</div>
</div>
</div>
<div class="col-span-12 md:col-span-3">
<Card class="h-full">
<template #title>
<div class="flex items-center justify-between">
<span>ARPA</span>
<Tag value="média" severity="secondary" rounded />
</div>
</template>
<template #content>
<div class="text-3xl font-semibold">{{ moneyBRLFromCents(arpaCents) }}</div>
<small class="text-color-secondary">média por assinatura ativa</small>
</template>
</Card>
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5 h-full">
<div class="flex items-center justify-between mb-3">
<div class="text-[1rem] font-semibold text-[var(--text-color)]">ARPA</div>
<Tag value="média" severity="secondary" rounded />
</div>
<div class="text-3xl font-semibold">{{ moneyBRLFromCents(arpaCents) }}</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-1">média por assinatura ativa</div>
</div>
</div>
</div>
<!-- Intenções + Health + Chart -->
<div class="grid grid-cols-12 gap-4 mt-4">
<div class="grid grid-cols-12 gap-4">
<!-- Intenções -->
<div class="col-span-12 md:col-span-4">
<Card class="h-full">
<template #title>
<div class="flex items-center justify-between">
<span>Intenções de assinatura</span>
<Tag :value="intentsLoading ? 'carregando' : 'últimas'" severity="secondary" rounded />
</div>
</template>
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5 h-full">
<div class="flex items-center justify-between mb-3">
<div class="text-[1rem] font-semibold text-[var(--text-color)]">Intenções de assinatura</div>
<Tag :value="intentsLoading ? 'carregando' : 'últimas'" severity="secondary" rounded />
</div>
<template #content>
<div class="grid grid-cols-12 gap-3">
<div class="col-span-4 rounded-xl border border-[var(--surface-border)] p-3">
<div class="text-xs text-color-secondary">Total</div>
<div class="text-2xl font-semibold">{{ totalIntents }}</div>
</div>
<div class="col-span-4 rounded-xl border border-[var(--surface-border)] p-3">
<div class="text-xs text-color-secondary">New</div>
<div class="text-2xl font-semibold">{{ totalIntentsNew }}</div>
</div>
<div class="col-span-4 rounded-xl border border-[var(--surface-border)] p-3">
<div class="text-xs text-color-secondary">Paid</div>
<div class="text-2xl font-semibold">{{ totalIntentsPaid }}</div>
</div>
<div class="grid grid-cols-12 gap-3">
<div class="col-span-4 rounded-md border border-[var(--surface-border)] p-3">
<div class="text-[1rem] text-[var(--text-color-secondary)]">Total</div>
<div class="text-2xl font-semibold">{{ totalIntents }}</div>
</div>
<div class="col-span-4 rounded-md border border-[var(--surface-border)] p-3">
<div class="text-[1rem] text-[var(--text-color-secondary)]">New</div>
<div class="text-2xl font-semibold">{{ totalIntentsNew }}</div>
</div>
<div class="col-span-4 rounded-md border border-[var(--surface-border)] p-3">
<div class="text-[1rem] text-[var(--text-color-secondary)]">Paid</div>
<div class="text-2xl font-semibold">{{ totalIntentsPaid }}</div>
</div>
</div>
<Divider class="my-3" />
<div v-if="intentsLoading" class="text-[1rem] text-[var(--text-color-secondary)]">
Carregando intenções
</div>
<div v-else>
<div v-if="!intents.length" class="text-[1rem] text-[var(--text-color-secondary)]">
Nenhuma intenção encontrada.
</div>
<Divider class="my-3" />
<div v-if="intentsLoading" class="text-color-secondary text-sm">
Carregando intenções
</div>
<div v-else>
<div v-if="!intents.length" class="text-color-secondary text-sm">
Nenhuma intenção encontrada.
</div>
<div v-else class="space-y-2">
<div
v-for="(it, idx) in intents"
:key="idx"
class="flex items-start justify-between gap-3 rounded-xl border border-[var(--surface-border)] p-3"
>
<div class="min-w-0">
<div class="font-medium truncate">
{{ maskEmail(it.email) }}
</div>
<div class="text-xs text-color-secondary mt-1">
{{ it.plan_key || '—' }} {{ intervalLabel(it.interval) }}
<span class="font-mono">{{ it.tenant_id ? String(it.tenant_id).slice(0, 8) + '…' : '—' }}</span>
</div>
<div class="text-xs text-color-secondary mt-1">
{{ fmtDate(it.created_at) }}
</div>
<div v-else class="space-y-2">
<div
v-for="(it, idx) in intents"
:key="idx"
class="flex items-start justify-between gap-3 rounded-md border border-[var(--surface-border)] p-3"
>
<div class="min-w-0">
<div class="font-medium truncate">
{{ maskEmail(it.email) }}
</div>
<div class="shrink-0">
<Tag :value="it.status || '—'" :severity="statusSeverity(it.status)" rounded />
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-1">
{{ it.plan_key || '—' }} {{ intervalLabel(it.interval) }}
<span class="font-mono">{{ it.tenant_id ? String(it.tenant_id).slice(0, 8) + '…' : '—' }}</span>
</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-1">
{{ fmtDate(it.created_at) }}
</div>
</div>
</div>
<div class="flex gap-2 flex-wrap mt-3">
<Button
label="Atualizar"
icon="pi pi-refresh"
severity="secondary"
outlined
size="small"
:loading="intentsLoading || loading"
@click="loadIntents"
/>
<Button
label="Ver eventos"
icon="pi pi-history"
severity="secondary"
outlined
size="small"
:disabled="loading"
@click="openIntentEvents"
/>
</div>
<div class="text-color-secondary text-xs mt-3">
Mostrando {{ intentsLimit }} itens mais recentes de <span class="font-mono">subscription_intents</span>.
<div class="shrink-0">
<Tag :value="it.status || '—'" :severity="statusSeverity(it.status)" rounded />
</div>
</div>
</div>
</template>
</Card>
<div class="flex gap-2 flex-wrap mt-3">
<Button
label="Atualizar"
icon="pi pi-refresh"
severity="secondary"
outlined
size="small"
:loading="intentsLoading || loading"
@click="loadIntents"
/>
<Button
label="Ver eventos"
icon="pi pi-history"
severity="secondary"
outlined
size="small"
:disabled="loading"
@click="openIntentEvents"
/>
</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-3">
Mostrando {{ intentsLimit }} itens mais recentes de <span class="font-mono">subscription_intents</span>.
</div>
</div>
</div>
</div>
<!-- Health -->
<div class="col-span-12 md:col-span-4">
<Card class="h-full">
<template #title>
<div class="flex items-center justify-between">
<span>Saúde do sistema</span>
<Tag :severity="healthSeverity" :value="healthLabel" rounded />
</div>
</template>
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5 h-full">
<div class="flex items-center justify-between mb-3">
<div class="text-[1rem] font-semibold text-[var(--text-color)]">Saúde do sistema</div>
<Tag :severity="healthSeverity" :value="healthLabel" rounded />
</div>
<template #content>
<div class="flex items-center justify-between gap-2">
<div class="text-4xl font-semibold">{{ totalMismatches }}</div>
<small class="text-color-secondary text-right">
divergências entre plano (esperado) e entitlements (atual)
</small>
<div class="flex items-center justify-between gap-2">
<div class="text-4xl font-semibold">{{ totalMismatches }}</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] text-right">
divergências entre plano (esperado) e entitlements (atual)
</div>
</div>
<div class="text-color-secondary text-sm mt-2">
{{ healthHint }}
</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-2">
{{ healthHint }}
</div>
<Divider class="my-3" />
<Divider class="my-3" />
<div class="flex gap-2 flex-wrap">
<Button
v-if="totalMismatches > 0"
label="Corrigir tudo"
icon="pi pi-refresh"
severity="danger"
:loading="loading"
@click="askFixAll"
/>
<Button
label="Ver divergências"
icon="pi pi-search"
severity="secondary"
outlined
:disabled="loading"
@click="router.push('/saas/subscription-health')"
/>
</div>
<div class="flex gap-2 flex-wrap">
<Button
v-if="totalMismatches > 0"
label="Corrigir tudo"
icon="pi pi-refresh"
severity="danger"
:loading="loading"
@click="askFixAll"
/>
<Button
label="Ver divergências"
icon="pi pi-search"
severity="secondary"
outlined
:disabled="loading"
@click="router.push('/saas/subscription-health')"
/>
</div>
<div class="text-color-secondary text-xs mt-3" v-if="lastUpdatedAt">
Atualizado em {{ fmtDate(lastUpdatedAt) }}
</div>
</template>
</Card>
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-3" v-if="lastUpdatedAt">
Atualizado em {{ fmtDate(lastUpdatedAt) }}
</div>
</div>
</div>
<!-- Chart -->
<div class="col-span-12 md:col-span-4">
<Card class="h-full">
<template #title>{{ intervalView === 'year' ? 'ARR por plano' : 'MRR por plano' }}</template>
<template #content>
<div style="height: 260px;">
<Chart type="bar" :data="chartData" :options="chartOptions" />
</div>
</template>
</Card>
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5 h-full">
<div class="text-[1rem] font-semibold text-[var(--text-color)] mb-3">{{ intervalView === 'year' ? 'ARR por plano' : 'MRR por plano' }}</div>
<div style="height: 260px;">
<Chart type="bar" :data="chartData" :options="chartOptions" />
</div>
</div>
</div>
</div>
<!-- Breakdown table (com ações) -->
<div class="mt-4">
<Card>
<template #title>Distribuição por plano</template>
<template #content>
<DataTable :value="breakdown" stripedRows responsiveLayout="scroll" emptyMessage="Sem dados para exibir.">
<Column field="plan_key" header="Plano" style="min-width: 14rem">
<template #body="{ data }">
<div class="flex flex-col">
<span class="font-medium">{{ data.plan_key }}</span>
<small class="text-color-secondary">
{{ data.plan_active ? 'ativo no catálogo' : 'inativo no catálogo' }}
</small>
</div>
</template>
</Column>
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5">
<div class="text-[1rem] font-semibold text-[var(--text-color)] mb-3">Distribuição por plano</div>
<DataTable :value="breakdown" stripedRows responsiveLayout="scroll" emptyMessage="Sem dados para exibir.">
<Column field="plan_key" header="Plano" style="min-width: 14rem">
<template #body="{ data }">
<div class="flex flex-col">
<span class="font-medium">{{ data.plan_key }}</span>
<div class="text-[1rem] text-[var(--text-color-secondary)]">
{{ data.plan_active ? 'ativo no catálogo' : 'inativo no catálogo' }}
</div>
</div>
</template>
</Column>
<Column header="Público" style="width: 12rem">
<template #body="{ data }">
<Tag
:value="planTargetLabel(data.plan_target)"
:severity="planTargetSeverity(data.plan_target)"
rounded
/>
</template>
</Column>
<Column header="Público" style="width: 12rem">
<template #body="{ data }">
<Tag
:value="planTargetLabel(data.plan_target)"
:severity="planTargetSeverity(data.plan_target)"
rounded
/>
</template>
</Column>
<Column header="Ativas" style="width: 8rem">
<template #body="{ data }">{{ data.active_count }}</template>
</Column>
<Column header="Ativas" style="width: 8rem">
<template #body="{ data }">{{ data.active_count }}</template>
</Column>
<Column header="Canceladas" style="width: 10rem">
<template #body="{ data }">{{ data.canceled_count }}</template>
</Column>
<Column header="Canceladas" style="width: 10rem">
<template #body="{ data }">{{ data.canceled_count }}</template>
</Column>
<Column header="Preço (ref.)" style="min-width: 12rem">
<template #body="{ data }">{{ moneyBRLFromCents(data.price_cents) }}</template>
</Column>
<Column header="Preço (ref.)" style="min-width: 12rem">
<template #body="{ data }">{{ moneyBRLFromCents(data.price_cents) }}</template>
</Column>
<Column :header="intervalView === 'year' ? 'ARR' : 'MRR'" style="min-width: 12rem">
<template #body="{ data }">{{ moneyBRLFromCents(data.revenue_cents) }}</template>
</Column>
<Column :header="intervalView === 'year' ? 'ARR' : 'MRR'" style="min-width: 12rem">
<template #body="{ data }">{{ moneyBRLFromCents(data.revenue_cents) }}</template>
</Column>
<Column header="Ações" style="width: 16rem">
<template #body="{ data }">
<div class="flex gap-2 justify-end flex-wrap">
<Button
label="Abrir vitrine"
icon="pi pi-external-link"
severity="secondary"
outlined
size="small"
:disabled="!data.plan_key || data.plan_key === '(sem plano)'"
@click="openPlanPublic(data.plan_key)"
/>
<Button
icon="pi pi-pencil"
severity="secondary"
outlined
size="small"
:disabled="!data.plan_key || data.plan_key === '(sem plano)'"
v-tooltip.top="'Abrir catálogo interno do plano'"
@click="openPlanCatalog(data.plan_key)"
/>
</div>
</template>
</Column>
</DataTable>
<Column header="Ações" style="width: 16rem">
<template #body="{ data }">
<div class="flex gap-2 justify-end flex-wrap">
<Button
label="Abrir vitrine"
icon="pi pi-external-link"
severity="secondary"
outlined
size="small"
:disabled="!data.plan_key || data.plan_key === '(sem plano)'"
@click="openPlanPublic(data.plan_key)"
/>
<Button
icon="pi pi-pencil"
severity="secondary"
outlined
size="small"
:disabled="!data.plan_key || data.plan_key === '(sem plano)'"
v-tooltip.top="'Abrir catálogo interno do plano'"
@click="openPlanCatalog(data.plan_key)"
/>
</div>
</template>
</Column>
</DataTable>
<div class="text-color-secondary text-sm mt-3">
Nota: "Preço (ref.)" e "MRR/ARR" são normalizados usando o preço ativo.
Se existir anual, MRR = anual/12; se existir mensal, ARR = mensal*12.
</div>
</template>
</Card>
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-3">
Nota: "Preço (ref.)" e "MRR/ARR" são normalizados usando o preço ativo.
Se existir anual, MRR = anual/12; se existir mensal, ARR = mensal*12.
</div>
</div>
</div>
</template>
<style scoped>
/* Hero */
.dash-hero {
position: sticky;
top: var(--layout-sticky-top, 56px);
z-index: 20;
overflow: hidden;
border-radius: 1rem;
margin: 1rem;
padding: 1.25rem 1.5rem;
background: linear-gradient(135deg, var(--surface-card) 0%, var(--surface-section) 100%);
border: 1px solid var(--surface-border);
box-shadow: 0 2px 12px rgba(0, 0, 0, .08);
}
.dash-hero--stuck {
border-top-left-radius: 0;
border-top-right-radius: 0;
}
.dash-hero__blob {
position: absolute;
border-radius: 50%;
opacity: .15;
pointer-events: none;
}
.dash-hero__blob--1 {
width: 220px; height: 220px;
top: -60px; right: 80px;
background: radial-gradient(circle, #2dd4bf, transparent 70%);
}
.dash-hero__blob--2 {
width: 160px; height: 160px;
bottom: -40px; right: 260px;
background: radial-gradient(circle, #60a5fa, transparent 70%);
}
.dash-hero__icon {
width: 2.75rem; height: 2.75rem;
display: flex; align-items: center; justify-content: center;
border-radius: .75rem;
background: var(--primary-100, rgba(99,102,241,.1));
color: var(--primary-color, #6366f1);
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -125,84 +125,93 @@ function selecionarCat (cat) {
</script>
<template>
<div class="faq-page">
<!-- Sentinel -->
<div class="h-px" />
<!-- Cabeçalho -->
<div class="faq-header">
<div class="faq-header-inner">
<div class="flex items-center gap-3 mb-3">
<div class="faq-icon-wrap">
<i class="pi pi-comments text-xl" />
</div>
<div>
<h1 class="faq-title">Central de Ajuda</h1>
<p class="faq-subtitle">Encontre respostas para as dúvidas mais comuns</p>
</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>
<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>
<!-- Busca -->
<div class="faq-search-wrap">
<IconField class="w-full">
<InputIcon class="pi pi-search" />
<InputText
v-model="busca"
placeholder="Buscar pergunta…"
class="faq-search-input"
/>
<InputIcon v-if="busca" class="pi pi-times cursor-pointer" @click="busca = ''" />
</IconField>
<div v-if="totalResultados !== null" class="faq-search-result">
{{ totalResultados }} resultado{{ totalResultados !== 1 ? 's' : '' }} encontrado{{ totalResultados !== 1 ? 's' : '' }}
</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>
<!-- Corpo -->
<div class="faq-body">
<!-- 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>
<!-- 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>
<template v-else>
<div class="flex gap-4 items-start flex-col sm:flex-row">
<!-- Sidebar de categorias -->
<aside v-if="categorias.length" class="faq-sidebar">
<div class="faq-sidebar-title">Categorias</div>
<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="faq-cat-btn"
:class="{ 'faq-cat-btn--active': !catAtiva }"
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 text-xs mr-2" />
<i class="pi pi-th-large mr-2 opacity-60" />
Todas
<span class="faq-cat-count">{{ faqItens.length }}</span>
<span class="ml-auto opacity-50 text-[1rem]">{{ faqItens.length }}</span>
</button>
<button
v-for="cat in categorias"
:key="cat"
class="faq-cat-btn"
:class="{ 'faq-cat-btn--active': catAtiva === 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 text-xs mr-2 opacity-60" />
<i class="pi pi-tag mr-2 opacity-60" />
{{ cat }}
<span class="faq-cat-count">
<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 -->
<main class="faq-main">
<div class="flex-1 min-w-0 flex flex-col gap-4">
<!-- Sem resultados -->
<div v-if="docsComResultado.length === 0" class="faq-empty">
<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" />
<p class="text-[var(--text-color-secondary)]">Nenhuma pergunta encontrada.</p>
<button v-if="busca || catAtiva" class="text-[var(--primary-color)] text-sm mt-2 underline" @click="busca = ''; catAtiva = null; abertos = {}">
<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>
@@ -211,45 +220,48 @@ function selecionarCat (cat) {
<div
v-for="doc in docsComResultado"
:key="doc.id"
class="faq-group"
class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] overflow-hidden"
>
<!-- Cabeçalho do grupo (doc) -->
<div class="faq-group-header">
<div class="faq-group-icon">
<i class="pi pi-file-edit text-sm" />
<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">
<h2 class="faq-group-title">{{ doc.titulo }}</h2>
<span v-if="doc.categoria" class="faq-group-cat">{{ doc.categoria }}</span>
<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="edit-doc-btn"
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 text-xs" />
<i class="pi pi-pencil" />
</button>
</div>
<!-- Itens FAQ do grupo -->
<div class="faq-items">
<div class="flex flex-col">
<div
v-for="item in itensDo(doc.id)"
:key="item.id"
class="faq-item"
:class="{ 'faq-item--open': abertos[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="faq-pergunta" @click="toggle(item.id)">
<span class="faq-pergunta-text">{{ item.pergunta }}</span>
<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 text-sm opacity-40 transition-transform duration-200"
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="faq-resposta ql-content"
class="px-5 pb-4 text-[1rem] text-[var(--text-color-secondary)] leading-relaxed break-words ql-content"
v-html="item.resposta"
/>
</Transition>
@@ -257,270 +269,23 @@ function selecionarCat (cat) {
</div>
</div>
</main>
</template>
</div>
</div>
</div>
</template>
</div>
</template>
<style scoped>
/* ── Layout ──────────────────────────────────────────────────── */
.faq-page {
display: flex;
flex-direction: column;
min-height: 100%;
}
/* ── Header ─────────────────────────────────────────────────── */
.faq-header {
background: var(--surface-card);
border-bottom: 1px solid var(--surface-border);
padding: 2rem 1.5rem 1.5rem;
}
.faq-header-inner {
max-width: 720px;
margin: 0 auto;
}
.faq-icon-wrap {
width: 48px;
height: 48px;
border-radius: 14px;
background: color-mix(in srgb, var(--primary-color) 12%, transparent);
color: var(--primary-color);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.faq-title {
font-size: 1.35rem;
font-weight: 700;
color: var(--text-color);
margin: 0;
line-height: 1.2;
}
.faq-subtitle {
font-size: 0.875rem;
color: var(--text-color-secondary);
margin: 2px 0 0;
}
.faq-search-wrap {
position: relative;
}
.faq-search-input {
width: 100%;
border-radius: 0.75rem !important;
font-size: 0.9rem;
}
.faq-search-result {
font-size: 0.75rem;
color: var(--text-color-secondary);
opacity: 0.7;
margin-top: 0.375rem;
margin-left: 0.25rem;
}
/* ── Corpo ──────────────────────────────────────────────────── */
.faq-body {
display: flex;
gap: 1.5rem;
padding: 1.5rem;
flex: 1;
max-width: 1100px;
margin: 0 auto;
width: 100%;
align-items: flex-start;
}
/* ── Sidebar ─────────────────────────────────────────────────── */
.faq-sidebar {
width: 200px;
flex-shrink: 0;
position: sticky;
top: 1.5rem;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.faq-sidebar-title {
font-size: 0.7rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.07em;
color: var(--text-color-secondary);
opacity: 0.6;
padding: 0 0.5rem;
margin-bottom: 0.25rem;
}
.faq-cat-btn {
display: flex;
align-items: center;
width: 100%;
padding: 0.45rem 0.625rem;
border-radius: 0.5rem;
font-size: 0.82rem;
color: var(--text-color-secondary);
background: transparent;
border: none;
cursor: pointer;
text-align: left;
transition: background 0.15s, color 0.15s;
}
.faq-cat-btn:hover {
background: var(--surface-hover);
color: var(--text-color);
}
.faq-cat-btn--active {
background: color-mix(in srgb, var(--primary-color) 10%, transparent);
color: var(--primary-color);
font-weight: 600;
}
.faq-cat-count {
margin-left: auto;
font-size: 0.7rem;
opacity: 0.5;
font-weight: 500;
}
/* ── Main ─────────────────────────────────────────────────────── */
.faq-main {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.faq-empty {
display: flex;
flex-direction: column;
align-items: center;
padding: 3rem 1rem;
text-align: center;
}
/* ── Grupo (doc) ─────────────────────────────────────────────── */
.faq-group {
border: 1px solid var(--surface-border);
border-radius: 1rem;
overflow: hidden;
background: var(--surface-card);
}
.faq-group-header {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.875rem 1.25rem;
border-bottom: 1px solid var(--surface-border);
background: var(--surface-ground);
}
.faq-group-icon {
width: 32px;
height: 32px;
border-radius: 8px;
background: color-mix(in srgb, var(--primary-color) 10%, transparent);
color: var(--primary-color);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.faq-group-title {
font-size: 0.9rem;
font-weight: 600;
color: var(--text-color);
margin: 0;
line-height: 1.3;
}
.faq-group-cat {
font-size: 0.7rem;
color: var(--text-color-secondary);
opacity: 0.6;
display: block;
margin-top: 1px;
}
.edit-doc-btn {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 6px;
border: 1px solid var(--surface-border);
background: var(--surface-card);
color: var(--text-color-secondary);
cursor: pointer;
flex-shrink: 0;
opacity: 0;
transition: opacity 0.15s, background 0.15s, color 0.15s;
}
.faq-group-header:hover .edit-doc-btn {
opacity: 1;
}
.edit-doc-btn:hover {
background: var(--surface-hover);
color: var(--primary-color);
border-color: color-mix(in srgb, var(--primary-color) 30%, transparent);
}
/* ── Itens FAQ ───────────────────────────────────────────────── */
.faq-items {
display: flex;
flex-direction: column;
}
.faq-item {
border-bottom: 1px solid var(--surface-border);
transition: background 0.15s;
}
.faq-item:last-child { border-bottom: none; }
.faq-item--open { background: color-mix(in srgb, var(--primary-color) 3%, transparent); }
.faq-pergunta {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.875rem 1.25rem;
background: transparent;
border: none;
cursor: pointer;
text-align: left;
transition: background 0.15s;
}
.faq-pergunta:hover { background: var(--surface-hover); }
.faq-pergunta-text {
font-size: 0.9rem;
font-weight: 500;
color: var(--text-color);
line-height: 1.4;
}
.faq-resposta {
padding: 0 1.25rem 1rem;
font-size: 0.875rem;
color: var(--text-color-secondary);
line-height: 1.65;
word-break: break-word;
}
/* Quill content */
.faq-resposta.ql-content :deep(p) { margin: 0 0 0.5rem; }
.faq-resposta.ql-content :deep(p:last-child) { margin-bottom: 0; }
.faq-resposta.ql-content :deep(strong) { font-weight: 600; color: var(--text-color); }
.faq-resposta.ql-content :deep(em) { font-style: italic; }
.faq-resposta.ql-content :deep(ul),
.faq-resposta.ql-content :deep(ol) { padding-left: 1.25rem; margin: 0.4rem 0; }
.faq-resposta.ql-content :deep(li) { margin-bottom: 0.2rem; }
.faq-resposta.ql-content :deep(a) { color: var(--primary-color); text-decoration: underline; }
.faq-resposta.ql-content :deep(blockquote) {
.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;
@@ -539,12 +304,4 @@ function selecionarCat (cat) {
opacity: 0;
max-height: 0;
}
/* ── Responsivo ─────────────────────────────────────────────── */
@media (max-width: 640px) {
.faq-body { flex-direction: column; padding: 1rem; }
.faq-sidebar { width: 100%; position: static; flex-direction: row; flex-wrap: wrap; gap: 0.375rem; }
.faq-sidebar-title { display: none; }
.faq-cat-btn { width: auto; padding: 0.3rem 0.625rem; font-size: 0.75rem; }
}
</style>

View File

@@ -233,56 +233,53 @@ onBeforeUnmount(() => {
<Toast />
<ConfirmDialog />
<div class="features-root">
<!-- Sentinel -->
<div ref="heroSentinelRef" class="h-px" />
<!-- Info decorativa (scrolls away naturalmente) -->
<div class="flex items-start gap-4 px-4 pb-3">
<div class="features-hero__icon-wrap">
<i class="pi pi-bolt features-hero__icon" />
</div>
<div class="features-hero__sub">
Cadastre os recursos (features) que os planos podem habilitar.
A <b>key</b> é o identificador técnico; o <b>nome</b> é exibido para o usuário.
</div>
<!-- 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>
<!-- HERO -->
<div ref="heroSentinelRef" class="features-hero-sentinel" />
<div ref="heroEl" class="features-hero mb-4" :class="{ 'features-hero--stuck': heroStuck }">
<div class="features-hero__blobs" aria-hidden="true">
<div class="features-hero__blob features-hero__blob--1" />
<div class="features-hero__blob features-hero__blob--2" />
<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>
<div class="features-hero__inner">
<div class="features-hero__info min-w-0">
<div class="features-hero__title">Recursos do Sistema</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 desktop ( 1200px) -->
<div class="features-hero__actions features-hero__actions--desktop">
<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="features-hero__actions--mobile">
<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>
<!-- 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>
<!-- Search sempre visível, fora do hero sticky -->
<div class="px-4 mb-4">
<!-- 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" />
@@ -292,7 +289,6 @@ onBeforeUnmount(() => {
</FloatLabel>
</div>
<div class="px-4 pb-4">
<DataTable :value="filteredRows" dataKey="id" :loading="loading" stripedRows responsiveLayout="scroll">
<Column header="Domínio" style="width: 9rem">
<template #body="{ data }">
@@ -303,8 +299,8 @@ onBeforeUnmount(() => {
<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-sm">{{ data.key }}</span>
<small class="text-color-secondary">ID: {{ data.id }}</small>
<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>
@@ -317,7 +313,7 @@ onBeforeUnmount(() => {
<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-color-secondary" :title="data.descricao || ''">
<div class="max-w-[600px] whitespace-nowrap overflow-hidden text-ellipsis text-[var(--text-color-secondary)]" :title="data.descricao || ''">
{{ data.descricao || '—' }}
</div>
</template>
@@ -334,151 +330,87 @@ onBeforeUnmount(() => {
</template>
</Column>
</DataTable>
</div>
</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>
<small class="text-color-secondary block 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.
</small>
</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>
<small class="text-color-secondary block mt-1">
Nome exibido para o usuário na página de upgrade e nas listagens.
</small>
</div>
<!-- Descrição PT-BR -->
<div>
<FloatLabel variant="on">
<Textarea
id="cr-desc-pt"
v-model.trim="form.descricao"
<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"
rows="3"
autoResize
variant="filled"
:disabled="saving"
autocomplete="off"
autofocus
@blur="form.key = slugifyKey(form.key)"
@keydown.enter.prevent="save"
/>
<label for="cr-desc-pt">Descrição</label>
</FloatLabel>
<small class="text-color-secondary block mt-1">
Explique o que o recurso habilita e para quem se aplica.
</small>
</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>
<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>
</div>
</template>
<!-- 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>
<style scoped>
.features-root { padding: 1rem; }
@media (min-width: 768px) { .features-root { padding: 1.5rem; } }
<!-- 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>
.features-hero-sentinel { height: 1px; }
.features-hero {
position: sticky;
top: var(--layout-sticky-top, 56px);
z-index: 20;
overflow: hidden;
border-radius: 1.75rem;
border: 1px solid var(--surface-border);
background: var(--surface-card);
padding: 1.5rem;
}
.features-hero--stuck {
border-top-left-radius: 0;
border-top-right-radius: 0;
}
.features-hero__blobs {
position: absolute; inset: 0; pointer-events: none; overflow: hidden;
}
.features-hero__blob {
position: absolute; border-radius: 50%; filter: blur(70px);
}
.features-hero__blob--1 { width: 20rem; height: 20rem; top: -5rem; right: -4rem; background: rgba(217,70,239,0.10); }
.features-hero__blob--2 { width: 18rem; height: 18rem; top: 1rem; left: -5rem; background: rgba(99,102,241,0.10); }
.features-hero__inner {
position: relative; z-index: 1;
display: flex; align-items: center; gap: 1.25rem; flex-wrap: wrap;
}
.features-hero__icon-wrap {
flex-shrink: 0;
width: 4rem; height: 4rem; border-radius: 1.125rem;
border: 2px solid var(--surface-border);
background: var(--surface-ground);
display: grid; place-items: center;
}
.features-hero__icon { font-size: 1.5rem; color: var(--text-color); }
.features-hero__info { flex: 1; min-width: 0; }
.features-hero__title {
font-size: 1.25rem; font-weight: 700; letter-spacing: -0.025em;
color: var(--text-color); line-height: 1.2;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.features-hero__sub {
font-size: 0.78rem; color: var(--text-color-secondary); margin-top: 4px; line-height: 1.5;
}
.features-hero__actions--desktop {
display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap;
}
.features-hero__actions--mobile { display: none; }
@media (max-width: 1199px) {
.features-hero__actions--desktop { display: none; }
.features-hero__actions--mobile { display: flex; }
}
</style>
<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>

View File

@@ -177,46 +177,59 @@ async function excluir (id) {
<template>
<Toast />
<div class="flex flex-col gap-4 p-4">
<!-- Sentinel -->
<div class="h-px" />
<!-- Header -->
<div class="flex flex-wrap items-center justify-between gap-3 rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] px-5 py-4">
<!-- 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-amber-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-wrap items-center justify-between gap-3">
<div>
<div class="font-bold text-lg flex items-center gap-2">
<div class="text-[1rem] font-bold tracking-tight text-[var(--text-color)] flex items-center gap-2">
<i class="pi pi-star text-amber-500" />
Feriados Municipais
</div>
<div class="text-sm text-[var(--text-color-secondary)] mt-0.5">
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-0.5">
Feriados cadastrados pelos tenants alimentam o banco central de feriados do SAAS.
</div>
</div>
<div class="flex items-center gap-2">
<Button icon="pi pi-chevron-left" text rounded severity="secondary" @click="anoAnterior" />
<span class="font-bold text-lg w-14 text-center">{{ ano }}</span>
<span class="font-bold text-[1rem] w-14 text-center">{{ ano }}</span>
<Button icon="pi pi-chevron-right" text rounded severity="secondary" @click="anoProximo" />
<Button icon="pi pi-refresh" severity="secondary" outlined rounded :loading="loading" @click="load" />
<Button icon="pi pi-plus" label="Cadastrar feriado" class="rounded-full" @click="abrirDialog" />
<Button icon="pi pi-plus" label="Cadastrar feriado" @click="abrirDialog" />
</div>
</div>
</div>
<!-- content -->
<div class="px-3 md:px-4 pb-8 flex flex-col gap-4">
<!-- Stats -->
<div class="grid grid-cols-3 gap-3">
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-4 text-center">
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-4 text-center">
<div class="text-2xl font-bold text-amber-500">{{ totalFeriados }}</div>
<div class="text-xs text-[var(--text-color-secondary)] mt-1">Total de feriados</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-1">Total de feriados</div>
</div>
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-4 text-center">
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-4 text-center">
<div class="text-2xl font-bold text-blue-500">{{ totalTenants }}</div>
<div class="text-xs text-[var(--text-color-secondary)] mt-1">Tenants contribuintes</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-1">Tenants contribuintes</div>
</div>
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-4 text-center">
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-4 text-center">
<div class="text-2xl font-bold text-green-500">{{ totalMunicipios }}</div>
<div class="text-xs text-[var(--text-color-secondary)] mt-1">Municípios</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-1">Municípios</div>
</div>
</div>
<!-- Filtros -->
<div class="flex flex-wrap gap-3 rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] px-4 py-3">
<div class="flex flex-wrap gap-3 rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] px-4 py-3">
<div class="flex-1 min-w-[160px]">
<IconField>
<InputIcon class="pi pi-search" />
@@ -247,34 +260,42 @@ async function excluir (id) {
<template v-else>
<div v-if="!agrupados.length" class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-8 text-center text-[var(--text-color-secondary)]">
<div v-if="!agrupados.length" class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-8 text-center text-[var(--text-color-secondary)]">
Nenhum feriado municipal cadastrado para {{ ano }}.
</div>
<!-- Lista agrupada por data -->
<div v-for="[data, lista] in agrupados" :key="data" class="blk-group">
<div class="blk-group__head">
<span class="font-mono text-sm">{{ fmtDate(data) }}</span>
<span class="blk-group__count">{{ lista.length }}</span>
<div
v-for="[data, lista] in agrupados"
:key="data"
class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] overflow-hidden"
>
<div class="flex items-center gap-2 px-5 py-3 border-b border-[var(--surface-border)] font-semibold bg-[var(--surface-ground)]">
<span class="font-mono text-[1rem]">{{ fmtDate(data) }}</span>
<span class="text-[1rem] bg-[var(--surface-card)] border border-[var(--surface-border)] rounded-full px-2 py-px text-[var(--text-color-secondary)]">{{ lista.length }}</span>
</div>
<div class="blk-list">
<div v-for="f in lista" :key="f.id" class="blk-item">
<div class="blk-item__name">{{ f.nome }}</div>
<div class="flex flex-col">
<div
v-for="f in lista"
:key="f.id"
class="flex items-center gap-3 px-5 py-2.5 border-b border-[var(--surface-border)] last:border-b-0 flex-wrap hover:bg-[var(--surface-hover)]"
>
<div class="font-medium text-[1rem] flex-1 min-w-[180px]">{{ f.nome }}</div>
<div class="flex items-center gap-2 flex-wrap">
<Tag v-if="f.cidade" :value="f.cidade" severity="secondary" class="text-xs" />
<Tag v-if="f.estado" :value="f.estado" severity="info" class="text-xs" />
<Tag v-if="f.bloqueia_sessoes" value="Bloqueia" severity="danger" class="text-xs" />
<Tag v-if="f.cidade" :value="f.cidade" severity="secondary" />
<Tag v-if="f.estado" :value="f.estado" severity="info" />
<Tag v-if="f.bloqueia_sessoes" value="Bloqueia" severity="danger" />
</div>
<div v-if="f.tenants?.name" class="blk-item__tenant">
<i class="pi pi-building text-xs" /> {{ f.tenants.name }}
<div v-if="f.tenants?.name" class="text-[1rem] text-[var(--text-color-secondary)] w-full flex items-center gap-1">
<i class="pi pi-building" /> {{ f.tenants.name }}
</div>
<div v-if="f.observacao" class="blk-item__obs">{{ f.observacao }}</div>
<div v-if="f.observacao" class="text-[1rem] text-[var(--text-color-secondary)] w-full italic">{{ f.observacao }}</div>
<div class="blk-item__actions">
<div class="ml-auto">
<Button icon="pi pi-trash" text rounded size="small" severity="danger" @click="excluir(f.id)" />
</div>
</div>
@@ -295,12 +316,12 @@ async function excluir (id) {
<div class="flex flex-col gap-4 pt-1">
<div>
<label class="dlg-label">Nome do feriado *</label>
<label class="text-[1rem] text-[var(--text-color-secondary)] font-medium">Nome do feriado *</label>
<InputText v-model="form.nome" class="w-full mt-1" placeholder="Ex.: Padroeiro Municipal, Aniversário da cidade…" />
</div>
<div>
<label class="dlg-label">Data *</label>
<label class="text-[1rem] text-[var(--text-color-secondary)] font-medium">Data *</label>
<DatePicker
v-model="form.data"
showIcon fluid iconDisplay="input"
@@ -314,17 +335,17 @@ async function excluir (id) {
<div class="flex gap-3">
<div class="flex-1">
<label class="dlg-label">Cidade</label>
<label class="text-[1rem] text-[var(--text-color-secondary)] font-medium">Cidade</label>
<InputText v-model="form.cidade" class="w-full mt-1" placeholder="Ex.: São Paulo" />
</div>
<div class="w-24">
<label class="dlg-label">Estado (UF)</label>
<label class="text-[1rem] text-[var(--text-color-secondary)] font-medium">Estado (UF)</label>
<InputText v-model="form.estado" class="w-full mt-1" placeholder="SP" maxlength="2" />
</div>
</div>
<div>
<label class="dlg-label">Vincular a um tenant <span class="opacity-60">(opcional)</span></label>
<label class="text-[1rem] text-[var(--text-color-secondary)] font-medium">Vincular a um tenant <span class="opacity-60">(opcional)</span></label>
<Select
v-model="form.tenant_id"
:options="tenantOptions"
@@ -336,13 +357,13 @@ async function excluir (id) {
</div>
<div>
<label class="dlg-label">Observação <span class="opacity-60">(opcional)</span></label>
<label class="text-[1rem] text-[var(--text-color-secondary)] font-medium">Observação <span class="opacity-60">(opcional)</span></label>
<Textarea v-model="form.observacao" class="w-full mt-1" rows="2" autoResize placeholder="Nota interna…" />
</div>
<div class="flex items-center gap-2">
<Checkbox v-model="form.bloqueia_sessoes" :binary="true" inputId="bloqueia" />
<label for="bloqueia" class="text-sm cursor-pointer">Bloqueia sessões neste dia</label>
<label for="bloqueia" class="text-[1rem] cursor-pointer">Bloqueia sessões neste dia</label>
</div>
</div>
@@ -359,67 +380,3 @@ async function excluir (id) {
</template>
</Dialog>
</template>
<style scoped>
.blk-group {
border-radius: 1.25rem;
border: 1px solid var(--surface-border);
background: var(--surface-card);
overflow: hidden;
}
.blk-group__head {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1.25rem;
border-bottom: 1px solid var(--surface-border);
font-weight: 600;
background: var(--surface-ground);
}
.blk-group__count {
font-size: 0.75rem;
background: var(--surface-card);
border: 1px solid var(--surface-border);
border-radius: 999px;
padding: 1px 8px;
color: var(--text-color-secondary);
}
.blk-list { display: flex; flex-direction: column; }
.blk-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.625rem 1.25rem;
border-bottom: 1px solid var(--surface-border);
flex-wrap: wrap;
}
.blk-item:last-child { border-bottom: none; }
.blk-item:hover { background: var(--surface-hover); }
.blk-item__name {
font-weight: 500;
font-size: 0.875rem;
flex: 1;
min-width: 180px;
}
.blk-item__tenant {
font-size: 0.75rem;
color: var(--text-color-secondary);
width: 100%;
display: flex;
align-items: center;
gap: 0.25rem;
}
.blk-item__obs {
font-size: 0.75rem;
color: var(--text-color-secondary);
width: 100%;
font-style: italic;
}
.blk-item__actions { margin-left: auto; }
.dlg-label {
font-size: 0.75rem;
color: var(--text-color-secondary);
font-weight: 500;
}
</style>

View File

@@ -0,0 +1,564 @@
<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'
const toast = useToast()
const confirm = useConfirm()
// ─── Estado ───────────────────────────────────────────────────────────────────
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 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' },
]
// ─── 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)
// ─── 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,
}
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
}
}
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 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
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 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)
</script>
<template>
<Toast />
<ConfirmDialog />
<!-- 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
</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="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,
icon text not null default 'pi-star',
ordem integer not null default 0,
ativo boolean not null default true,
created_at timestamptz default now(),
updated_at timestamptz default now()
);
-- RLS: apenas saas_admin pode gerenciar
alter table public.login_carousel_slides enable row level security;
create policy "saas_admin_full" on public.login_carousel_slides
for all using (
exists (
select 1 from public.profiles
where id = auth.uid() and role = 'saas_admin'
)
);
-- 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>
<!-- /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>
<!-- 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>
<!-- Í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>
<!-- 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>
</template>
<style scoped>
.prev-fade-enter-active,
.prev-fade-leave-active {
transition: opacity 0.3s ease, transform 0.3s ease;
}
.prev-fade-enter-from {
opacity: 0;
transform: translateY(12px);
}
.prev-fade-leave-to {
opacity: 0;
transform: translateY(-8px);
}
</style>

View File

@@ -1,8 +1,25 @@
<template>
<div class="p-4">
<div class="text-xl font-semibold">Em construção</div>
<div class="text-color-secondary mt-2">
Esta área do Admin SaaS ainda será implementada.
<!-- 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="px-3 md:px-4 pb-8 flex flex-col gap-4">
<!-- conteúdo futuro -->
</div>
</template>

View File

@@ -398,57 +398,55 @@ onBeforeUnmount(() => {
<Toast />
<ConfirmDialog />
<div class="matrix-root">
<!-- Sentinel -->
<div ref="heroSentinelRef" class="h-px" />
<!-- Info decorativa (scrolls away naturalmente) -->
<div class="flex items-start gap-4 px-4 pb-3">
<div class="matrix-hero__icon-wrap">
<i class="pi pi-th-large matrix-hero__icon" />
</div>
<div class="matrix-hero__sub">
Defina quais recursos cada plano habilita. As mudanças ficam <b>pendentes</b> até clicar em <b>Salvar alterações</b>.
</div>
<!-- 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>
<!-- HERO -->
<div ref="heroSentinelRef" class="matrix-hero-sentinel" />
<div ref="heroEl" class="matrix-hero mb-4" :class="{ 'matrix-hero--stuck': heroStuck }">
<div class="matrix-hero__blobs" aria-hidden="true">
<div class="matrix-hero__blob matrix-hero__blob--1" />
<div class="matrix-hero__blob matrix-hero__blob--2" />
<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>
<div class="matrix-hero__inner">
<div class="matrix-hero__info min-w-0">
<div class="matrix-hero__title">Controle de Recursos</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 desktop ( 1200px) -->
<div class="matrix-hero__actions matrix-hero__actions--desktop">
<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="matrix-hero__actions--mobile">
<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>
<!-- 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>
<!-- Search sempre visível, fora do hero sticky -->
<div class="px-4 mb-4">
<!-- 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" />
@@ -458,8 +456,7 @@ onBeforeUnmount(() => {
</FloatLabel>
</div>
<div class="px-4 pb-4">
<div class="mb-3 surface-100 border-round p-3">
<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 />
@@ -467,13 +464,13 @@ onBeforeUnmount(() => {
<Tag v-if="hasPending" value="Alterações pendentes" severity="warn" icon="pi pi-clock" rounded />
</div>
<div class="text-color-secondary text-sm">
<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>
<Divider class="my-4" />
<Divider class="my-0" />
<DataTable
:value="filteredFeatures"
@@ -488,9 +485,9 @@ onBeforeUnmount(() => {
<template #body="{ data }">
<div class="flex flex-col">
<span class="font-medium">{{ data.key }}</span>
<small class="text-color-secondary leading-snug mt-1">
<div class="text-[1rem] text-[var(--text-color-secondary)] leading-snug mt-1">
{{ data.descricao || data.description || '—' }}
</small>
</div>
</div>
</template>
</Column>
@@ -506,7 +503,7 @@ onBeforeUnmount(() => {
{{ planTitle(p) }}
</div>
<div class="flex items-center justify-center gap-1 flex-wrap">
<small class="text-color-secondary truncate" :title="p.key">{{ p.key }}</small>
<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">
@@ -545,69 +542,5 @@ onBeforeUnmount(() => {
</template>
</Column>
</DataTable>
</div><!-- /px-4 pb-4 -->
</div>
</template>
<style scoped>
.matrix-root { padding: 1rem; }
@media (min-width: 768px) { .matrix-root { padding: 1.5rem; } }
.matrix-hero-sentinel { height: 1px; }
.matrix-hero {
position: sticky;
top: var(--layout-sticky-top, 56px);
z-index: 20;
overflow: hidden;
border-radius: 1.75rem;
border: 1px solid var(--surface-border);
background: var(--surface-card);
padding: 1.5rem;
}
.matrix-hero--stuck {
border-top-left-radius: 0;
border-top-right-radius: 0;
}
.matrix-hero__blobs {
position: absolute; inset: 0; pointer-events: none; overflow: hidden;
}
.matrix-hero__blob {
position: absolute; border-radius: 50%; filter: blur(70px);
}
.matrix-hero__blob--1 { width: 20rem; height: 20rem; top: -5rem; right: -4rem; background: rgba(52,211,153,0.12); }
.matrix-hero__blob--2 { width: 18rem; height: 18rem; top: 1rem; left: -5rem; background: rgba(99,102,241,0.09); }
.matrix-hero__inner {
position: relative; z-index: 1;
display: flex; align-items: center; gap: 1.25rem; flex-wrap: wrap;
}
.matrix-hero__icon-wrap {
flex-shrink: 0;
width: 4rem; height: 4rem; border-radius: 1.125rem;
border: 2px solid var(--surface-border);
background: var(--surface-ground);
display: grid; place-items: center;
}
.matrix-hero__icon { font-size: 1.5rem; color: var(--text-color); }
.matrix-hero__info { flex: 1; min-width: 0; }
.matrix-hero__title {
font-size: 1.25rem; font-weight: 700; letter-spacing: -0.025em;
color: var(--text-color); line-height: 1.2;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.matrix-hero__sub {
font-size: 0.78rem; color: var(--text-color-secondary); margin-top: 4px; line-height: 1.5;
}
.matrix-hero__actions--desktop {
display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap;
}
.matrix-hero__actions--mobile { display: none; }
@media (max-width: 1199px) {
.matrix-hero__actions--desktop { display: none; }
.matrix-hero__actions--mobile { display: flex; }
}
</style>
</template>

View File

@@ -329,56 +329,53 @@ onBeforeUnmount(() => {
<Toast />
<ConfirmDialog />
<div class="limits-root">
<!-- Sentinel -->
<div ref="heroSentinelRef" class="h-px" />
<!-- Info decorativa (scrolls away naturalmente) -->
<div class="flex items-start gap-4 px-4 pb-3">
<div class="limits-hero__icon-wrap">
<i class="pi pi-sliders-h limits-hero__icon" />
</div>
<div class="limits-hero__sub">
Configure os limites reais de cada feature por plano (ex: max_patients, max_sessions_per_month).
Esses valores são lidos pelo sistema para bloquear ações quando o limite é atingido.
</div>
<!-- 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-orange-400/10" />
<div class="absolute rounded-full blur-[70px] w-80 h-80 top-10 -left-24 bg-indigo-400/10" />
</div>
<!-- HERO -->
<div ref="heroSentinelRef" class="limits-hero-sentinel" />
<div ref="heroEl" class="limits-hero mb-4" :class="{ 'limits-hero--stuck': heroStuck }">
<div class="limits-hero__blobs" aria-hidden="true">
<div class="limits-hero__blob limits-hero__blob--1" />
<div class="limits-hero__blob limits-hero__blob--2" />
<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)]">Limites por Plano</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-0.5">Configure os limites reais de cada feature por plano.</div>
</div>
<div class="limits-hero__inner">
<div class="limits-hero__info min-w-0">
<div class="limits-hero__title">Limites por Plano</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" @click="fetchAll" />
</div>
<!-- Ações desktop ( 1200px) -->
<div class="limits-hero__actions limits-hero__actions--desktop">
<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" @click="fetchAll" />
</div>
<!-- Ações mobile (< 1200px) -->
<div class="limits-hero__actions--mobile">
<Button
label="Ações"
icon="pi pi-ellipsis-v"
severity="warn"
size="small"
aria-haspopup="true"
aria-controls="limits_hero_menu"
@click="(e) => heroMenuRef.toggle(e)"
/>
<Menu ref="heroMenuRef" id="limits_hero_menu" :model="heroMenuItems" :popup="true" />
</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="limits_hero_menu"
@click="(e) => heroMenuRef.toggle(e)"
/>
<Menu ref="heroMenuRef" id="limits_hero_menu" :model="heroMenuItems" :popup="true" />
</div>
</div>
</div>
<!-- Search sempre visível, fora do hero sticky -->
<div class="px-4 mb-4">
<!-- 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-80">
<IconField class="w-full">
<InputIcon class="pi pi-search" />
@@ -388,19 +385,18 @@ onBeforeUnmount(() => {
</FloatLabel>
</div>
<div class="px-4 pb-4">
<!-- Legenda rápida -->
<div class="surface-100 border-round p-3 mb-4">
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-3">
<div class="flex flex-wrap gap-4 items-center">
<div class="flex items-center gap-2 text-sm text-color-secondary">
<div class="flex items-center gap-2 text-[1rem] text-[var(--text-color-secondary)]">
<i class="pi pi-info-circle text-blue-400" />
<span><strong>Sem limites</strong> = acesso habilitado sem restrição de quantidade</span>
</div>
<div class="flex items-center gap-2 text-sm text-color-secondary">
<div class="flex items-center gap-2 text-[1rem] text-[var(--text-color-secondary)]">
<i class="pi pi-info-circle text-orange-400" />
<span><strong>-1</strong> = ilimitado (explícito no JSON, útil para planos PRO)</span>
</div>
<div class="flex items-center gap-2 text-sm text-color-secondary">
<div class="flex items-center gap-2 text-[1rem] text-[var(--text-color-secondary)]">
<i class="pi pi-info-circle text-red-400" />
<span><strong>0 ou N</strong> = limite máximo que o sistema vai verificar</span>
</div>
@@ -431,11 +427,11 @@ onBeforeUnmount(() => {
:severity="domainSeverity(featureDomain(data.feature.key))"
rounded
/>
<span class="font-medium font-mono text-sm">{{ data.feature.key }}</span>
<span class="font-medium font-mono text-[1rem]">{{ data.feature.key }}</span>
</div>
<small class="text-color-secondary mt-1 leading-snug">
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-1 leading-snug">
{{ data.feature.descricao || '—' }}
</small>
</div>
</div>
</template>
</Column>
@@ -452,7 +448,7 @@ onBeforeUnmount(() => {
<span class="font-semibold truncate" :title="plan.name">{{ plan.name || plan.key }}</span>
<Tag :value="targetLabel(plan.target)" :severity="targetSeverity(plan.target)" rounded />
</div>
<small class="text-color-secondary font-mono">{{ plan.key }}</small>
<div class="text-[1rem] text-[var(--text-color-secondary)] font-mono">{{ plan.key }}</div>
</div>
</template>
@@ -472,7 +468,7 @@ onBeforeUnmount(() => {
<!-- Limites atuais -->
<div
v-if="data.planCols[plan.id].limits"
class="text-xs text-color-secondary leading-relaxed bg-surface-100 border-round p-2"
class="text-[1rem] text-[var(--text-color-secondary)] leading-relaxed rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-2"
>
<div
v-for="(val, key) in data.planCols[plan.id].limits"
@@ -483,7 +479,7 @@ onBeforeUnmount(() => {
<span>{{ limitValueDisplay(val) }}</span>
</div>
</div>
<div v-else-if="data.planCols[plan.id].hasRecord" class="text-xs text-color-secondary">
<div v-else-if="data.planCols[plan.id].hasRecord" class="text-[1rem] text-[var(--text-color-secondary)]">
Sem limites definidos
</div>
@@ -508,7 +504,7 @@ onBeforeUnmount(() => {
@click="askClearLimits(plan, data.feature)"
/>
</div>
<div v-else class="text-xs text-color-secondary italic">
<div v-else class="text-[1rem] text-[var(--text-color-secondary)] italic">
Feature não vinculada a este plano.<br/>
Configure em <strong>Recursos por Plano</strong>.
</div>
@@ -516,260 +512,195 @@ onBeforeUnmount(() => {
</template>
</Column>
</DataTable>
</div>
</div><!-- /px-4 pb-4 -->
<!-- Dialog: editar limites de plan_features -->
<Dialog
v-model:visible="showDlg"
modal
:draggable="false"
:closable="!saving"
:dismissableMask="!saving"
:style="{ width: '680px' }"
>
<template #header>
<div class="flex flex-col gap-1">
<div class="text-lg font-semibold">Limites {{ dlgFeature?.key }}</div>
<div class="flex items-center gap-2 flex-wrap">
<Tag :value="dlgPlan?.name || dlgPlan?.key" severity="secondary" />
<Tag :value="targetLabel(dlgPlan?.target)" :severity="targetSeverity(dlgPlan?.target)" rounded />
</div>
<!-- Dialog: editar limites de plan_features -->
<Dialog
v-model:visible="showDlg"
modal
:draggable="false"
:closable="!saving"
:dismissableMask="!saving"
:style="{ width: '680px' }"
>
<template #header>
<div class="flex flex-col gap-1">
<div class="text-[1rem] font-semibold">Limites {{ dlgFeature?.key }}</div>
<div class="flex items-center gap-2 flex-wrap">
<Tag :value="dlgPlan?.name || dlgPlan?.key" severity="secondary" />
<Tag :value="targetLabel(dlgPlan?.target)" :severity="targetSeverity(dlgPlan?.target)" rounded />
</div>
</template>
</div>
</template>
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-4">
<!-- Campos existentes -->
<div v-if="limitFields.length">
<div class="font-semibold mb-2">Limites configurados</div>
<div class="flex flex-col gap-2">
<div
v-for="(field, idx) in limitFields"
:key="idx"
class="flex items-center gap-3 surface-100 border-round p-3"
>
<!-- Key (não editável) -->
<div class="flex-1 min-w-0">
<div class="font-mono font-medium text-sm">{{ field.key }}</div>
<small class="text-color-secondary">{{ field.type }}</small>
</div>
<!-- Valor -->
<div class="w-40 shrink-0">
<template v-if="field.type === 'number'">
<InputNumber
v-model="field.value"
class="w-full"
inputClass="w-full"
:disabled="saving"
:min="-1"
placeholder="-1 = ilimitado"
/>
</template>
<template v-else-if="field.type === 'boolean'">
<SelectButton
v-model="field.value"
:options="[{ label: 'Sim', value: true }, { label: 'Não', value: false }]"
optionLabel="label"
optionValue="value"
:disabled="saving"
/>
</template>
<template v-else>
<InputText v-model="field.value" class="w-full" :disabled="saving" />
</template>
</div>
<!-- Ações rápidas -->
<div class="flex gap-1 shrink-0">
<Button
icon="pi pi-infinity"
size="small"
severity="secondary"
outlined
v-tooltip.top="'Definir como ilimitado (-1)'"
:disabled="saving || field.type !== 'number'"
@click="setUnlimited(idx)"
/>
<Button
icon="pi pi-trash"
size="small"
severity="danger"
outlined
v-tooltip.top="'Remover este campo'"
:disabled="saving"
@click="removeLimitField(idx)"
/>
</div>
</div>
</div>
</div>
<div v-else class="text-sm text-color-secondary surface-100 border-round p-3 text-center">
Nenhum limite configurado. Adicione abaixo.
</div>
<Divider />
<!-- Adicionar novo campo -->
<div>
<div class="font-semibold mb-3">Adicionar campo de limite</div>
<div class="flex flex-col gap-3">
<!-- Nome -->
<div>
<label class="text-xs font-semibold text-color-secondary uppercase tracking-wide block mb-1">
Nome do campo *
</label>
<InputText
id="new-limit-key"
v-model="newLimitKey"
class="w-full"
variant="filled"
:disabled="saving"
autocomplete="off"
placeholder="ex: max_patients"
@keydown.enter.prevent="addLimitField"
/>
<small class="text-color-secondary mt-1 block">
Ex: <span class="font-mono">max_patients</span>, <span class="font-mono">max_sessions_per_month</span>
</small>
<!-- Campos existentes -->
<div v-if="limitFields.length">
<div class="font-semibold mb-2">Limites configurados</div>
<div class="flex flex-col gap-2">
<div
v-for="(field, idx) in limitFields"
:key="idx"
class="flex items-center gap-3 rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-3"
>
<!-- Key (não editável) -->
<div class="flex-1 min-w-0">
<div class="font-mono font-medium text-[1rem]">{{ field.key }}</div>
<div class="text-[1rem] text-[var(--text-color-secondary)]">{{ field.type }}</div>
</div>
<!-- Tipo + Valor + Botão -->
<div class="flex items-end gap-2 flex-wrap">
<div>
<label class="text-xs font-semibold text-color-secondary uppercase tracking-wide block mb-1">Tipo</label>
<SelectButton
v-model="newLimitType"
:options="limitTypeOptions"
optionLabel="label"
optionValue="value"
:disabled="saving"
/>
</div>
<div class="flex-1" style="min-width: 8rem;">
<label class="text-xs font-semibold text-color-secondary uppercase tracking-wide block mb-1">Valor inicial</label>
<!-- Valor -->
<div class="w-40 shrink-0">
<template v-if="field.type === 'number'">
<InputNumber
v-if="newLimitType === 'number'"
v-model="newLimitValue"
v-model="field.value"
class="w-full"
inputClass="w-full"
variant="filled"
:disabled="saving"
:min="-1"
placeholder="-1 = ilimitado"
/>
</template>
<template v-else-if="field.type === 'boolean'">
<SelectButton
v-else-if="newLimitType === 'boolean'"
v-model="newLimitValue"
v-model="field.value"
:options="[{ label: 'Sim', value: true }, { label: 'Não', value: false }]"
optionLabel="label"
optionValue="value"
:disabled="saving"
/>
<InputText
v-else
v-model="newLimitValue"
class="w-full"
variant="filled"
:disabled="saving"
/>
</div>
</template>
<template v-else>
<InputText v-model="field.value" class="w-full" :disabled="saving" />
</template>
</div>
<!-- Ações rápidas -->
<div class="flex gap-1 shrink-0">
<Button
icon="pi pi-plus"
label="Adicionar"
:disabled="saving || !newLimitKey?.trim()"
@click="addLimitField"
icon="pi pi-infinity"
size="small"
severity="secondary"
outlined
v-tooltip.top="'Definir como ilimitado (-1)'"
:disabled="saving || field.type !== 'number'"
@click="setUnlimited(idx)"
/>
<Button
icon="pi pi-trash"
size="small"
severity="danger"
outlined
v-tooltip.top="'Remover este campo'"
:disabled="saving"
@click="removeLimitField(idx)"
/>
</div>
</div>
</div>
</div>
<!-- Dica de boas práticas -->
<div class="surface-100 border-round p-3 text-xs text-color-secondary leading-relaxed">
<div class="font-semibold mb-1">Convenções recomendadas</div>
<div class="grid grid-cols-2 gap-x-4 gap-y-1">
<div><span class="font-mono">max_patients</span> número máximo de pacientes</div>
<div><span class="font-mono">max_sessions_per_month</span> sessões/mês</div>
<div><span class="font-mono">max_members</span> membros da clínica</div>
<div><span class="font-mono">max_therapists</span> terapeutas vinculados</div>
<div><span class="font-mono">-1</span> sem limite (planos PRO)</div>
<div><span class="font-mono">0</span> bloqueado completamente</div>
<div v-else class="text-[1rem] text-[var(--text-color-secondary)] rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-3 text-center">
Nenhum limite configurado. Adicione abaixo.
</div>
<Divider />
<!-- Adicionar novo campo -->
<div>
<div class="font-semibold mb-3">Adicionar campo de limite</div>
<div class="flex flex-col gap-3">
<!-- Nome -->
<div>
<label class="text-[1rem] font-semibold text-[var(--text-color-secondary)] uppercase tracking-wide block mb-1">
Nome do campo *
</label>
<InputText
id="new-limit-key"
v-model="newLimitKey"
class="w-full"
variant="filled"
:disabled="saving"
autocomplete="off"
placeholder="ex: max_patients"
@keydown.enter.prevent="addLimitField"
/>
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-1">
Ex: <span class="font-mono">max_patients</span>, <span class="font-mono">max_sessions_per_month</span>
</div>
</div>
<!-- Tipo + Valor + Botão -->
<div class="flex items-end gap-2 flex-wrap">
<div>
<label class="text-[1rem] font-semibold text-[var(--text-color-secondary)] uppercase tracking-wide block mb-1">Tipo</label>
<SelectButton
v-model="newLimitType"
:options="limitTypeOptions"
optionLabel="label"
optionValue="value"
:disabled="saving"
/>
</div>
<div class="flex-1" style="min-width: 8rem;">
<label class="text-[1rem] font-semibold text-[var(--text-color-secondary)] uppercase tracking-wide block mb-1">Valor inicial</label>
<InputNumber
v-if="newLimitType === 'number'"
v-model="newLimitValue"
class="w-full"
inputClass="w-full"
variant="filled"
:disabled="saving"
:min="-1"
placeholder="-1 = ilimitado"
/>
<SelectButton
v-else-if="newLimitType === 'boolean'"
v-model="newLimitValue"
:options="[{ label: 'Sim', value: true }, { label: 'Não', value: false }]"
optionLabel="label"
optionValue="value"
:disabled="saving"
/>
<InputText
v-else
v-model="newLimitValue"
class="w-full"
variant="filled"
:disabled="saving"
/>
</div>
<Button
icon="pi pi-plus"
label="Adicionar"
:disabled="saving || !newLimitKey?.trim()"
@click="addLimitField"
/>
</div>
</div>
</div>
<template #footer>
<Button label="Cancelar" severity="secondary" outlined :disabled="saving" @click="showDlg = false" />
<Button label="Salvar limites" icon="pi pi-check" :loading="saving" @click="saveLimits" />
</template>
</Dialog>
</div>
</template>
<!-- Dica de boas práticas -->
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-3 text-[1rem] text-[var(--text-color-secondary)] leading-relaxed">
<div class="font-semibold mb-1">Convenções recomendadas</div>
<div class="grid grid-cols-2 gap-x-4 gap-y-1">
<div><span class="font-mono">max_patients</span> número máximo de pacientes</div>
<div><span class="font-mono">max_sessions_per_month</span> sessões/mês</div>
<div><span class="font-mono">max_members</span> membros da clínica</div>
<div><span class="font-mono">max_therapists</span> terapeutas vinculados</div>
<div><span class="font-mono">-1</span> sem limite (planos PRO)</div>
<div><span class="font-mono">0</span> bloqueado completamente</div>
</div>
</div>
</div>
<style scoped>
.limits-root { padding: 1rem; }
@media (min-width: 768px) { .limits-root { padding: 1.5rem; } }
.limits-hero-sentinel { height: 1px; }
.limits-hero {
position: sticky;
top: var(--layout-sticky-top, 56px);
z-index: 20;
overflow: hidden;
border-radius: 1.75rem;
border: 1px solid var(--surface-border);
background: var(--surface-card);
padding: 1.5rem;
}
.limits-hero--stuck {
border-top-left-radius: 0;
border-top-right-radius: 0;
}
.limits-hero__blobs {
position: absolute; inset: 0; pointer-events: none; overflow: hidden;
}
.limits-hero__blob {
position: absolute; border-radius: 50%; filter: blur(70px);
}
.limits-hero__blob--1 { width: 20rem; height: 20rem; top: -5rem; right: -4rem; background: rgba(251,146,60,0.12); }
.limits-hero__blob--2 { width: 18rem; height: 18rem; top: 1rem; left: -5rem; background: rgba(99,102,241,0.09); }
.limits-hero__inner {
position: relative; z-index: 1;
display: flex; align-items: center; gap: 1.25rem; flex-wrap: wrap;
}
.limits-hero__icon-wrap {
flex-shrink: 0;
width: 4rem; height: 4rem; border-radius: 1.125rem;
border: 2px solid var(--surface-border);
background: var(--surface-ground);
display: grid; place-items: center;
}
.limits-hero__icon { font-size: 1.5rem; color: var(--text-color); }
.limits-hero__info { flex: 1; min-width: 0; }
.limits-hero__title {
font-size: 1.25rem; font-weight: 700; letter-spacing: -0.025em;
color: var(--text-color); line-height: 1.2;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.limits-hero__sub {
font-size: 0.78rem; color: var(--text-color-secondary); margin-top: 4px; line-height: 1.5;
}
.limits-hero__actions--desktop {
display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap;
}
.limits-hero__actions--mobile { display: none; }
@media (max-width: 1199px) {
.limits-hero__actions--desktop { display: none; }
.limits-hero__actions--mobile { display: flex; }
}
</style>
<template #footer>
<Button label="Cancelar" severity="secondary" outlined :disabled="saving" @click="showDlg = false" />
<Button label="Salvar limites" icon="pi pi-check" :loading="saving" @click="saveLimits" />
</template>
</Dialog>
</template>

View File

@@ -432,321 +432,231 @@ onBeforeUnmount(() => {
<Toast />
<ConfirmDialog />
<div class="plans-root">
<!-- Sentinel -->
<div ref=heroSentinelRef class=h-px />
<!-- Info decorativa (scrolls away naturalmente) -->
<div class="flex items-start gap-4 px-4 pb-3">
<div class="plans-hero__icon-wrap">
<i class="pi pi-list plans-hero__icon" />
</div>
<div class="plans-hero__sub">
Catálogo de planos do SaaS. A <b>key</b> é a referência técnica estável.
O <b>público</b> indica se o plano é para <b>Clínica</b> ou <b>Terapeuta</b>.
</div>
<!-- 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-indigo-400/10 />
<div class=absolute rounded-full blur-[70px] w-80 h-80 top-10 -left-24 bg-emerald-400/10 />
</div>
<!-- HERO -->
<div ref="heroSentinelRef" class="plans-hero-sentinel" />
<div ref="heroEl" class="plans-hero mb-5" :class="{ 'plans-hero--stuck': heroStuck }">
<div class="plans-hero__blobs" aria-hidden="true">
<div class="plans-hero__blob plans-hero__blob--1" />
<div class="plans-hero__blob plans-hero__blob--2" />
<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)]>Planos e preços</div>
<div class=text-[1rem] text-[var(--text-color-secondary)] mt-0.5>Catálogo de planos do SaaS.</div>
</div>
<div class="plans-hero__inner">
<!-- Título -->
<div class="plans-hero__info min-w-0">
<div class="plans-hero__title">Planos e preços</div>
</div>
<!-- Ações desktop ( 1200px) -->
<div class=hidden xl:flex items-center gap-2 flex-wrap>
<SelectButton
v-model=targetFilter
:options=targetFilterOptions
optionLabel=label
optionValue=value
size=small
/>
<Button label=Atualizar icon=pi pi-refresh severity=secondary outlined size=small :loading=loading :disabled=saving @click=fetchAll />
<Button label=Adicionar plano icon=pi pi-plus size=small :disabled=saving @click=openCreate />
</div>
<!-- Ações desktop ( 1200px) -->
<div class="plans-hero__actions plans-hero__actions--desktop">
<SelectButton
v-model="targetFilter"
:options="targetFilterOptions"
optionLabel="label"
optionValue="value"
size="small"
/>
<Button label="Atualizar" icon="pi pi-refresh" severity="secondary" outlined size="small" :loading="loading" :disabled="saving" @click="fetchAll" />
<Button label="Adicionar plano" icon="pi pi-plus" size="small" :disabled="saving" @click="openCreate" />
</div>
<!-- Ações mobile (< 1200px) -->
<div class="plans-hero__actions--mobile">
<Button
label="Ações"
icon="pi pi-ellipsis-v"
severity="warn"
size="small"
aria-haspopup="true"
aria-controls="plans_hero_menu"
@click="(e) => heroMenuRef.toggle(e)"
/>
<Menu ref="heroMenuRef" id="plans_hero_menu" :model="heroMenuItems" :popup="true" />
</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=plans_hero_menu
@click=(e) => heroMenuRef.toggle(e)
/>
<Menu ref=heroMenuRef id=plans_hero_menu :model=heroMenuItems :popup=true />
</div>
</div>
</div>
<div class="px-4 pb-4">
<DataTable :value="filteredRows" dataKey="id" :loading="loading" stripedRows responsiveLayout="scroll">
<Column field="name" header="Nome" sortable style="min-width: 14rem" />
<Column field="key" header="Key" sortable />
<!-- content -->
<div class=px-3 md:px-4 pb-8 flex flex-col gap-4>
<DataTable :value=filteredRows dataKey=id :loading=loading stripedRows responsiveLayout=scroll>
<Column field=name header=Nome sortable style=min-width: 14rem />
<Column field=key header=Key sortable />
<Column field="target" header="Público" sortable style="width: 10rem">
<template #body="{ data }">
<span class="font-medium">{{ formatTargetLabel(data.target) }}</span>
<Column field=target header=Público sortable style=width: 10rem>
<template #body={ data }>
<span class=font-medium>{{ formatTargetLabel(data.target) }}</span>
</template>
</Column>
<Column header="Mensal" sortable style="width: 12rem">
<template #body="{ data }">
<span class="font-medium">{{ formatBRLFromCents(data.monthly_cents) }}</span>
<Column header=Mensal sortable style=width: 12rem>
<template #body={ data }>
<span class=font-medium>{{ formatBRLFromCents(data.monthly_cents) }}</span>
</template>
</Column>
<Column header="Anual" sortable style="width: 12rem">
<template #body="{ data }">
<span class="font-medium">{{ formatBRLFromCents(data.yearly_cents) }}</span>
<Column header=Anual sortable style=width: 12rem>
<template #body={ data }>
<span class=font-medium>{{ formatBRLFromCents(data.yearly_cents) }}</span>
</template>
</Column>
<Column v-if="hasCreatedAt" field="created_at" header="Criado em" sortable />
<Column v-if=hasCreatedAt field=created_at header=Criado em sortable />
<Column header="Ações" style="width: 12rem">
<template #body="{ data }">
<div class="flex gap-2">
<Button icon="pi pi-pencil" severity="secondary" outlined @click="openEdit(data)" />
<Column header=Ações style=width: 12rem>
<template #body={ data }>
<div class=flex gap-2>
<Button icon=pi pi-pencil severity=secondary outlined @click=openEdit(data) />
<Button
icon="pi pi-trash"
severity="danger"
icon=pi pi-trash
severity=danger
outlined
:disabled="isDeleteLockedRow(data)"
:title="isDeleteLockedRow(data) ? 'Plano padrão do sistema não pode ser removido.' : 'Excluir plano'"
@click="askDelete(data)"
:disabled=isDeleteLockedRow(data)
:title=isDeleteLockedRow(data) ? 'Plano padrão do sistema não pode ser removido.' : 'Excluir plano'
@click=askDelete(data)
/>
</div>
</template>
</Column>
</DataTable>
</div>
</div>
<Dialog
v-model:visible="showDlg"
modal
:draggable="false"
:header="isEdit ? 'Editar plano' : 'Novo plano'"
:style="{ width: '620px' }"
class="plans-dialog"
>
<div class="flex flex-col gap-4">
<div>
<label class="block mb-2">Público do plano</label>
<SelectButton
v-model="form.target"
:options="targetOptions"
optionLabel="label"
optionValue="value"
class="w-full"
:disabled="isTargetLocked || saving"
/>
<small class="text-color-secondary">
Planos existentes não mudam de público. Isso evita inconsistência no catálogo.
</small>
</div>
<FloatLabel variant="on" class="w-full">
<IconField class="w-full">
<InputIcon class="pi pi-tag" />
<InputText
v-model="form.key"
id="plan_key"
class="w-full pr-10"
variant="filled"
placeholder="ex.: clinic_pro"
:disabled="(isCorePlanEditing || saving)"
@blur="form.key = slugifyKey(form.key)"
/>
</IconField>
<label for="plan_key">Key</label>
</FloatLabel>
<small class="text-color-secondary -mt-3">
Key é técnica e estável (slug). Planos padrão do sistema têm a key protegida.
</small>
<FloatLabel variant="on" class="w-full">
<IconField class="w-full">
<InputIcon class="pi pi-bookmark" />
<InputText
v-model="form.name"
id="plan_name"
class="w-full pr-10"
variant="filled"
placeholder="ex.: Clínica PRO"
:disabled="saving"
/>
</IconField>
<label for="plan_name">Nome</label>
</FloatLabel>
<small class="text-color-secondary -mt-3">
Nome interno para administração. (Nome público vem de <b>plan_public</b>.)
</small>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<FloatLabel variant="on" class="w-full">
<IconField class="w-full">
<InputIcon class="pi pi-money-bill" />
<InputNumber
v-model="form.price_monthly"
inputId="price_monthly"
class="w-full"
inputClass="w-full pr-10"
variant="filled"
mode="decimal"
:minFractionDigits="2"
:maxFractionDigits="2"
placeholder="ex.: 49,90"
:disabled="saving"
/>
</IconField>
<label for="price_monthly">Preço mensal (R$)</label>
</FloatLabel>
<small class="text-color-secondary">Deixe vazio para sem preço definido.</small>
</div>
<div>
<FloatLabel variant="on" class="w-full">
<IconField class="w-full">
<InputIcon class="pi pi-calendar" />
<InputNumber
v-model="form.price_yearly"
inputId="price_yearly"
class="w-full"
inputClass="w-full pr-10"
variant="filled"
mode="decimal"
:minFractionDigits="2"
:maxFractionDigits="2"
placeholder="ex.: 490,00"
:disabled="saving"
/>
</IconField>
<label for="price_yearly">Preço anual (R$)</label>
</FloatLabel>
<small class="text-color-secondary">Deixe vazio para sem preço definido.</small>
</div>
</div>
<!-- max_supervisees: para planos de supervisor -->
<div v-if="form.target === 'supervisor'">
<FloatLabel variant="on" class="w-full">
<IconField class="w-full">
<InputIcon class="pi pi-users" />
<InputNumber
v-model="form.max_supervisees"
inputId="max_supervisees"
class="w-full"
inputClass="w-full pr-10"
variant="filled"
:useGrouping="false"
:min="1"
placeholder="ex.: 3"
:disabled="saving"
/>
</IconField>
<label for="max_supervisees">Limite de supervisionados</label>
</FloatLabel>
<small class="text-color-secondary">Número máximo de terapeutas que podem ser supervisionados neste plano.</small>
<Dialog
v-model:visible=showDlg
modal
:draggable=false
:header=isEdit ? 'Editar plano' : 'Novo plano'
:style={ width: '620px' }
>
<div class=flex flex-col gap-4>
<div>
<label class=block mb-2>Público do plano</label>
<SelectButton
v-model=form.target
:options=targetOptions
optionLabel=label
optionValue=value
class=w-full
:disabled=isTargetLocked || saving
/>
<div class=text-[1rem] text-[var(--text-color-secondary)] mt-1>
Planos existentes não mudam de público. Isso evita inconsistência no catálogo.
</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>
</div>
</template>
<FloatLabel variant=on class=w-full>
<IconField class=w-full>
<InputIcon class=pi pi-tag />
<InputText
v-model=form.key
id=plan_key
class=w-full pr-10
variant=filled
placeholder=ex.: clinic_pro
:disabled=(isCorePlanEditing || saving)
@blur=form.key = slugifyKey(form.key)
/>
</IconField>
<label for=plan_key>Key</label>
</FloatLabel>
<div class=text-[1rem] text-[var(--text-color-secondary)] -mt-3>
Key é técnica e estável (slug). Planos padrão do sistema têm a key protegida.
</div>
<style scoped>
/* ─── Root ──────────────────────────────────────────────── */
.plans-root { padding: 1rem; }
@media (min-width: 768px) { .plans-root { padding: 1.5rem; } }
<FloatLabel variant=on class=w-full>
<IconField class=w-full>
<InputIcon class=pi pi-bookmark />
<InputText
v-model=form.name
id=plan_name
class=w-full pr-10
variant=filled
placeholder=ex.: Clínica PRO
:disabled=saving
/>
</IconField>
<label for=plan_name>Nome</label>
</FloatLabel>
<div class=text-[1rem] text-[var(--text-color-secondary)] -mt-3>
Nome interno para administração. (Nome público vem de <b>plan_public</b>.)
</div>
/* ─── Hero ──────────────────────────────────────────────── */
.plans-hero-sentinel { height: 1px; }
<div class=grid grid-cols-1 md:grid-cols-2 gap-4>
<div>
<FloatLabel variant=on class=w-full>
<IconField class=w-full>
<InputIcon class=pi pi-money-bill />
<InputNumber
v-model=form.price_monthly
inputId=price_monthly
class=w-full
inputClass=w-full pr-10
variant=filled
mode=decimal
:minFractionDigits=2
:maxFractionDigits=2
placeholder=ex.: 49,90
:disabled=saving
/>
</IconField>
<label for=price_monthly>Preço mensal (R$)</label>
</FloatLabel>
<div class=text-[1rem] text-[var(--text-color-secondary)] mt-1>Deixe vazio para sem preço definido.</div>
</div>
.plans-hero {
position: sticky;
top: var(--layout-sticky-top, 56px);
z-index: 20;
overflow: hidden;
border-radius: 1.75rem;
border: 1px solid var(--surface-border);
background: var(--surface-card);
padding: 1.5rem;
}
.plans-hero--stuck {
border-top-left-radius: 0;
border-top-right-radius: 0;
}
.plans-hero__blobs {
position: absolute; inset: 0; pointer-events: none; overflow: hidden;
}
.plans-hero__blob {
position: absolute; border-radius: 50%; filter: blur(70px);
}
.plans-hero__blob--1 { width: 20rem; height: 20rem; top: -5rem; right: -4rem; background: rgba(99,102,241,0.12); }
.plans-hero__blob--2 { width: 18rem; height: 18rem; top: 1rem; left: -5rem; background: rgba(52,211,153,0.09); }
<div>
<FloatLabel variant=on class=w-full>
<IconField class=w-full>
<InputIcon class=pi pi-calendar />
<InputNumber
v-model=form.price_yearly
inputId=price_yearly
class=w-full
inputClass=w-full pr-10
variant=filled
mode=decimal
:minFractionDigits=2
:maxFractionDigits=2
placeholder=ex.: 490,00
:disabled=saving
/>
</IconField>
<label for=price_yearly>Preço anual (R$)</label>
</FloatLabel>
<div class=text-[1rem] text-[var(--text-color-secondary)] mt-1>Deixe vazio para sem preço definido.</div>
</div>
</div>
.plans-hero__inner {
position: relative; z-index: 1;
display: flex; align-items: center; gap: 1.25rem; flex-wrap: wrap;
}
<!-- max_supervisees: para planos de supervisor -->
<div v-if=form.target === 'supervisor'>
<FloatLabel variant=on class=w-full>
<IconField class=w-full>
<InputIcon class=pi pi-users />
<InputNumber
v-model=form.max_supervisees
inputId=max_supervisees
class=w-full
inputClass=w-full pr-10
variant=filled
:useGrouping=false
:min=1
placeholder=ex.: 3
:disabled=saving
/>
</IconField>
<label for=max_supervisees>Limite de supervisionados</label>
</FloatLabel>
<div class=text-[1rem] text-[var(--text-color-secondary)] mt-1>Número máximo de terapeutas que podem ser supervisionados neste plano.</div>
</div>
</div>
/* Ícone */
.plans-hero__icon-wrap {
flex-shrink: 0;
width: 4rem; height: 4rem; border-radius: 1.125rem;
border: 2px solid var(--surface-border);
background: var(--surface-ground);
display: grid; place-items: center;
}
.plans-hero__icon { font-size: 1.5rem; color: var(--text-color); }
/* Info */
.plans-hero__info { flex: 1; min-width: 0; }
.plans-hero__title {
font-size: 1.25rem; font-weight: 700; letter-spacing: -0.025em;
color: var(--text-color); line-height: 1.2;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.plans-hero__sub {
font-size: 0.78rem; color: var(--text-color-secondary); margin-top: 4px; line-height: 1.5;
}
/* Ações */
.plans-hero__actions--desktop {
display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap;
}
.plans-hero__actions--mobile { display: none; }
@media (max-width: 1199px) {
.plans-hero__actions--desktop { display: none; }
.plans-hero__actions--mobile { display: flex; }
}
/* ─── Dialog: linhas divisórias no header e footer */
:deep(.plans-dialog .p-dialog-header) {
border-bottom: 1px solid var(--surface-border);
}
:deep(.plans-dialog .p-dialog-footer) {
border-top: 1px solid var(--surface-border);
}
/* Pequena melhoria de leitura */
small.text-color-secondary {
line-height: 1.35rem;
}
</style>
<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>

View File

@@ -449,152 +449,149 @@ onBeforeUnmount(() => {
<Toast />
<ConfirmDialog />
<div class="showcase-root">
<!-- Sentinel -->
<div ref=heroSentinelRef class=h-px />
<!-- Info decorativa (scrolls away naturalmente) -->
<div class="flex items-start gap-4 px-4 pb-3">
<div class="showcase-hero__icon-wrap">
<i class="pi pi-megaphone showcase-hero__icon" />
</div>
<div class="showcase-hero__sub">
Configure como os planos aparecem na página pública nome, descrição, badge, ordem e benefícios.
</div>
<!-- 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>
<!-- HERO -->
<div ref="heroSentinelRef" class="showcase-hero-sentinel" />
<div ref="heroEl" class="showcase-hero mb-4" :class="{ 'showcase-hero--stuck': heroStuck }">
<div class="showcase-hero__blobs" aria-hidden="true">
<div class="showcase-hero__blob showcase-hero__blob--1" />
<div class="showcase-hero__blob showcase-hero__blob--2" />
<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)]>Vitrine de Planos</div>
<div class=text-[1rem] text-[var(--text-color-secondary)] mt-0.5>Configure como os planos aparecem na página pública.</div>
</div>
<div class="showcase-hero__inner">
<div class="showcase-hero__info min-w-0">
<div class="showcase-hero__title">Vitrine de Planos</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 || bulletSaving />
<Button label=Recarregar icon=pi pi-refresh severity=secondary outlined size=small :loading=loading :disabled=saving || bulletSaving @click=fetchAll />
</div>
<!-- Ações desktop ( 1200px) -->
<div class="showcase-hero__actions showcase-hero__actions--desktop">
<SelectButton v-model="targetFilter" :options="targetOptions" optionLabel="label" optionValue="value" size="small" :disabled="loading || saving || bulletSaving" />
<Button label="Recarregar" icon="pi pi-refresh" severity="secondary" outlined size="small" :loading="loading" :disabled="saving || bulletSaving" @click="fetchAll" />
</div>
<!-- Ações mobile (< 1200px) -->
<div class="showcase-hero__actions--mobile">
<Button
label="Ações"
icon="pi pi-ellipsis-v"
severity="warn"
size="small"
aria-haspopup="true"
aria-controls="showcase_hero_menu"
@click="(e) => heroMenuRef.toggle(e)"
/>
<Menu ref="heroMenuRef" id="showcase_hero_menu" :model="heroMenuItems" :popup="true" />
</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=showcase_hero_menu
@click=(e) => heroMenuRef.toggle(e)
/>
<Menu ref=heroMenuRef id=showcase_hero_menu :model=heroMenuItems :popup=true />
</div>
</div>
</div>
<!-- Search sempre visível, fora do hero sticky -->
<div class="px-4 mb-4">
<FloatLabel variant="on" class="w-full md:w-80">
<IconField class="w-full">
<InputIcon class="pi pi-search" />
<InputText v-model="q" id="plans_public_search" class="w-full pr-10" variant="filled" :disabled="loading || saving || bulletSaving" />
<!-- 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-80>
<IconField class=w-full>
<InputIcon class=pi pi-search />
<InputText v-model=q id=plans_public_search class=w-full pr-10 variant=filled :disabled=loading || saving || bulletSaving />
</IconField>
<label for="plans_public_search">Buscar plano</label>
<label for=plans_public_search>Buscar plano</label>
</FloatLabel>
</div>
<!-- Popover global (reutilizado) -->
<Popover ref="bulletsPop">
<div class="w-[340px] max-w-[80vw]">
<div class="text-sm font-semibold mb-2">{{ popPlanTitle }}</div>
<Popover ref=bulletsPop>
<div class=w-[340px] max-w-[80vw]>
<div class=text-[1rem] font-semibold mb-2>{{ popPlanTitle }}</div>
<div v-if="!popBullets?.length" class="text-sm text-color-secondary">
<div v-if=!popBullets?.length class=text-[1rem] text-[var(--text-color-secondary)]>
Nenhum benefício configurado.
</div>
<ul v-else class="m-0 pl-4 space-y-2">
<li v-for="b in popBullets" :key="b.id" class="text-sm leading-snug">
<span :class="b.highlight ? 'font-semibold' : ''">
<ul v-else class=m-0 pl-4 space-y-2>
<li v-for=b in popBullets :key=b.id class=text-[1rem] leading-snug>
<span :class=b.highlight ? 'font-semibold' : ''>
{{ b.text }}
</span>
<small v-if="b.highlight" class="ml-2 text-color-secondary">(destaque)</small>
<div v-if=b.highlight class=inline ml-2 text-[1rem] text-[var(--text-color-secondary)]>(destaque)</div>
</li>
</ul>
</div>
</Popover>
<div class="px-4 pb-4">
<DataTable :value="tableRows" dataKey="plan_id" :loading="loading" stripedRows responsiveLayout="scroll">
<Column header="Plano" style="min-width: 18rem">
<template #body="{ data }">
<div class="flex flex-col">
<span class="font-semibold">{{ data.public_name || data.plan_name || data.plan_key }}</span>
<small class="text-color-secondary">
<DataTable :value=tableRows dataKey=plan_id :loading=loading stripedRows responsiveLayout=scroll>
<Column header=Plano style=min-width: 18rem>
<template #body={ data }>
<div class=flex flex-col>
<span class=font-semibold>{{ data.public_name || data.plan_name || data.plan_key }}</span>
<div class=text-[1rem] text-[var(--text-color-secondary)]>
{{ data.plan_key }} {{ data.plan_name || '—' }}
</small>
</div>
</div>
</template>
</Column>
<Column header="Público" style="width: 10rem">
<template #body="{ data }">
<Tag :value="targetLabel(normalizeTarget(data))" :severity="targetSeverity(normalizeTarget(data))" rounded />
<Column header=Público style=width: 10rem>
<template #body={ data }>
<Tag :value=targetLabel(normalizeTarget(data)) :severity=targetSeverity(normalizeTarget(data)) rounded />
</template>
</Column>
<Column header="Mensal" style="width: 12rem">
<template #body="{ data }">
<span class="font-medium">{{ formatBRLFromCents(data.monthly_cents) }}</span>
<Column header=Mensal style=width: 12rem>
<template #body={ data }>
<span class=font-medium>{{ formatBRLFromCents(data.monthly_cents) }}</span>
</template>
</Column>
<Column header="Anual" style="width: 12rem">
<template #body="{ data }">
<span class="font-medium">{{ formatBRLFromCents(data.yearly_cents) }}</span>
<Column header=Anual style=width: 12rem>
<template #body={ data }>
<span class=font-medium>{{ formatBRLFromCents(data.yearly_cents) }}</span>
</template>
</Column>
<Column field="badge" header="Badge" style="min-width: 12rem" />
<Column field=badge header=Badge style=min-width: 12rem />
<Column header="Visível" style="width: 8rem">
<template #body="{ data }">
<Column header=Visível style=width: 8rem>
<template #body={ data }>
<span>{{ data.is_visible ? 'Sim' : 'Não' }}</span>
</template>
</Column>
<Column header="Destaque" style="width: 9rem">
<template #body="{ data }">
<Column header=Destaque style=width: 9rem>
<template #body={ data }>
<span>{{ data.is_featured ? 'Sim' : 'Não' }}</span>
</template>
</Column>
<Column field="sort_order" header="Ordem" style="width: 8rem" />
<Column field=sort_order header=Ordem style=width: 8rem />
<Column header="Ações" style="width: 14rem">
<template #body="{ data }">
<div class="flex gap-2 justify-end">
<Column header=Ações style=width: 14rem>
<template #body={ data }>
<div class=flex gap-2 justify-end>
<Button
severity="secondary"
severity=secondary
outlined
size="small"
:disabled="loading || saving || bulletSaving"
@click="(e) => openBulletsPopover(e, data)"
size=small
:disabled=loading || saving || bulletSaving
@click=(e) => openBulletsPopover(e, data)
>
<i class="pi pi-list mr-2" />
<span class="font-medium">{{ data.bullets?.length || 0 }}</span>
<i class=pi pi-list mr-2 />
<span class=font-medium>{{ data.bullets?.length || 0 }}</span>
</Button>
<Button
icon="pi pi-pencil"
severity="secondary"
icon=pi pi-pencil
severity=secondary
outlined
size="small"
:disabled="loading || saving || bulletSaving"
@click="openEdit(data)"
size=small
:disabled=loading || saving || bulletSaving
@click=openEdit(data)
/>
</div>
</template>
@@ -602,407 +599,338 @@ onBeforeUnmount(() => {
</DataTable>
<!-- PREVIEW PÚBLICO (conceitual) -->
<div class="mt-10">
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] overflow-hidden">
<!-- Hero -->
<div class="relative p-6 md:p-10">
<div class="absolute inset-0 opacity-40 pointer-events-none bg-[radial-gradient(ellipse_at_top,rgba(16,185,129,0.18),transparent_55%)]" />
<div class="relative">
<div class="flex flex-col md:flex-row md:items-end md:justify-between gap-6">
<div class="max-w-2xl">
<div class="flex items-center gap-2 mb-3 flex-wrap">
<Tag
:value="targetFilter === 'all' ? 'Vitrine (Todos)' : `Vitrine (${targetLabel(targetFilter)})`"
:severity="targetFilter === 'therapist' ? 'success' : (targetFilter === 'clinic' ? 'info' : 'secondary')"
rounded
/>
<span class="text-sm text-color-secondary">
Ajuste nomes, descrições, badges e benefícios e veja o resultado aqui.
</span>
<div class=rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] overflow-hidden>
<!-- Hero -->
<div class=relative p-6 md:p-10>
<div class=absolute inset-0 opacity-40 pointer-events-none bg-[radial-gradient(ellipse_at_top,rgba(16,185,129,0.18),transparent_55%)] />
<div class=relative>
<div class=flex flex-col md:flex-row md:items-end md:justify-between gap-6>
<div class=max-w-2xl>
<div class=flex items-center gap-2 mb-3 flex-wrap>
<Tag
:value=targetFilter === 'all' ? 'Vitrine (Todos)' : `Vitrine (${targetLabel(targetFilter)})`
:severity=targetFilter === 'therapist' ? 'success' : (targetFilter === 'clinic' ? 'info' : 'secondary')
rounded
/>
<div class=text-[1rem] text-[var(--text-color-secondary)]>
Ajuste nomes, descrições, badges e benefícios e veja o resultado aqui.
</div>
<h2 class="text-3xl md:text-5xl font-semibold leading-tight">
Um plano não é preço.<br />
É promessa organizada.
</h2>
<p class="text-color-secondary mt-3">
A vitrine é o lugar onde o produto deixa de ser tabela e vira escolha.
Clareza, contraste e uma hierarquia que guia o olhar sem ruído.
</p>
</div>
<div class="flex flex-col items-start md:items-end gap-4">
<div class="flex flex-col gap-2">
<div class="text-sm text-color-secondary">Cobrança</div>
<SelectButton
v-model="billingInterval"
:options="intervalOptions"
optionLabel="label"
optionValue="value"
<div class=text-3xl md:text-5xl font-semibold leading-tight>
Um plano não é preço.<br />
É promessa organizada.
</div>
<div class=text-[var(--text-color-secondary)] mt-3>
A vitrine é o lugar onde o produto deixa de ser tabela e vira escolha.
Clareza, contraste e uma hierarquia que guia o olhar sem ruído.
</div>
</div>
<div class=flex flex-col items-start md:items-end gap-4>
<div class=flex flex-col gap-2>
<div class=text-[1rem] text-[var(--text-color-secondary)]>Cobrança</div>
<SelectButton
v-model=billingInterval
:options=intervalOptions
optionLabel=label
optionValue=value
/>
</div>
<div class=flex flex-col gap-2>
<div class=text-[1rem] text-[var(--text-color-secondary)]>Planos sem preço</div>
<SelectButton
v-model=previewPricePolicy
:options=previewPolicyOptions
optionLabel=label
optionValue=value
/>
</div>
</div>
</div>
</div>
</div>
<!-- Cards -->
<div class=p-6 md:p-10 pt-0>
<div v-if=!previewPlans.length class=text-[1rem] text-[var(--text-color-secondary)]>
Nenhum plano visível para este filtro.
</div>
<div v-else class=mt-6 grid grid-cols-1 md:grid-cols-3 gap-6>
<div
v-for=p in previewPlans
:key=p.plan_id
:class=[
'relative rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] overflow-hidden',
'shadow-sm transition-transform',
p.is_featured ? 'md:-translate-y-2 md:scale-[1.02] ring-1 ring-emerald-500/25' : ''
]
>
<div class=h-2 w-full opacity-50 bg-[var(--surface-100)] />
<div class=p-6>
<div class=flex items-center justify-between gap-3>
<div class=flex items-center gap-2 flex-wrap>
<Tag :value=targetLabel(normalizeTarget(p)) :severity=targetSeverity(normalizeTarget(p)) rounded />
<Tag
v-if=p.badge || p.is_featured
:value=p.badge || 'Destaque'
:severity=p.is_featured ? 'success' : 'secondary'
rounded
/>
</div>
<div class="flex flex-col gap-2">
<div class="text-sm text-color-secondary">Planos sem preço</div>
<SelectButton
v-model="previewPricePolicy"
:options="previewPolicyOptions"
optionLabel="label"
optionValue="value"
/>
</div>
<div class=text-[1rem] text-[var(--text-color-secondary)]>{{ p.plan_key }}</div>
</div>
<div class=mt-4>
<template v-if=priceDisplayForPreview(p).kind === 'paid'>
<div class=text-4xl font-semibold leading-none>
{{ priceDisplayForPreview(p).main }}
</div>
<div class=text-[1rem] text-[var(--text-color-secondary)] mt-1>
{{ priceDisplayForPreview(p).sub }}
</div>
</template>
<template v-else-if=priceDisplayForPreview(p).kind === 'free'>
<div class=text-4xl font-semibold leading-none>
{{ priceDisplayForPreview(p).main }}
</div>
<div class=text-[1rem] text-[var(--text-color-secondary)] mt-1>
{{ billingInterval === 'year' ? 'no anual' : 'no mensal' }}
</div>
</template>
<template v-else>
<div class=text-2xl font-semibold leading-none>
{{ priceDisplayForPreview(p).main }}
</div>
<div class=text-[1rem] text-[var(--text-color-secondary)] mt-1>
Fale com a equipe para montar o plano ideal.
</div>
</template>
</div>
<div class=text-[var(--text-color-secondary)] mt-3 min-h-[44px]>
{{ p.public_description || '—' }}
</div>
<Button
class=mt-5 w-full
:label=p.is_featured ? 'Começar agora' : 'Selecionar plano'
:severity=p.is_featured ? 'success' : 'secondary'
:outlined=!p.is_featured
/>
<div class=mt-6>
<div class=border-t border-dashed border-[var(--surface-border)] />
</div>
<ul v-if=p.bullets?.length class=mt-4 space-y-2>
<li v-for=b in p.bullets :key=b.id class=flex items-start gap-2>
<i class=pi pi-check mt-1 text-[1rem] text-[var(--text-color-secondary)]></i>
<span :class=['text-[1rem] leading-snug', b.highlight ? 'font-semibold' : '']>
{{ b.text }}
</span>
</li>
</ul>
<div v-else class=mt-4 text-[1rem] text-[var(--text-color-secondary)]>
Nenhum benefício configurado.
</div>
</div>
</div>
</div>
<!-- Cards -->
<div class="p-6 md:p-10 pt-0">
<div v-if="!previewPlans.length" class="text-sm text-color-secondary">
Nenhum plano visível para este filtro.
</div>
<div v-else class="mt-6 grid grid-cols-1 md:grid-cols-3 gap-6">
<div
v-for="p in previewPlans"
:key="p.plan_id"
:class="[
'relative rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] overflow-hidden',
'shadow-sm transition-transform',
p.is_featured ? 'md:-translate-y-2 md:scale-[1.02] ring-1 ring-emerald-500/25' : ''
]"
>
<div class="h-2 w-full opacity-50 bg-[var(--surface-100)]" />
<div class="p-6">
<div class="flex items-center justify-between gap-3">
<div class="flex items-center gap-2 flex-wrap">
<Tag :value="targetLabel(normalizeTarget(p))" :severity="targetSeverity(normalizeTarget(p))" rounded />
<Tag
v-if="p.badge || p.is_featured"
:value="p.badge || 'Destaque'"
:severity="p.is_featured ? 'success' : 'secondary'"
rounded
/>
</div>
<span class="text-xs text-color-secondary">{{ p.plan_key }}</span>
</div>
<div class="mt-4">
<template v-if="priceDisplayForPreview(p).kind === 'paid'">
<div class="text-4xl font-semibold leading-none">
{{ priceDisplayForPreview(p).main }}
</div>
<div class="text-sm text-color-secondary mt-1">
{{ priceDisplayForPreview(p).sub }}
</div>
</template>
<template v-else-if="priceDisplayForPreview(p).kind === 'free'">
<div class="text-4xl font-semibold leading-none">
{{ priceDisplayForPreview(p).main }}
</div>
<div class="text-sm text-color-secondary mt-1">
{{ billingInterval === 'year' ? 'no anual' : 'no mensal' }}
</div>
</template>
<template v-else>
<div class="text-2xl font-semibold leading-none">
{{ priceDisplayForPreview(p).main }}
</div>
<div class="text-sm text-color-secondary mt-1">
Fale com a equipe para montar o plano ideal.
</div>
</template>
</div>
<p class="text-color-secondary mt-3 min-h-[44px]">
{{ p.public_description || '—' }}
</p>
<Button
class="mt-5 w-full"
:label="p.is_featured ? 'Começar agora' : 'Selecionar plano'"
:severity="p.is_featured ? 'success' : 'secondary'"
:outlined="!p.is_featured"
/>
<div class="mt-6">
<div class="border-t border-dashed border-[var(--surface-border)]" />
</div>
<ul v-if="p.bullets?.length" class="mt-4 space-y-2">
<li v-for="b in p.bullets" :key="b.id" class="flex items-start gap-2">
<i class="pi pi-check mt-1 text-sm text-color-secondary"></i>
<span :class="['text-sm leading-snug', b.highlight ? 'font-semibold' : '']">
{{ b.text }}
</span>
</li>
</ul>
<div v-else class="mt-4 text-sm text-color-secondary">
Nenhum benefício configurado.
</div>
</div>
</div>
</div>
<div v-if="previewPricePolicy === 'hide'" class="mt-6 text-xs text-color-secondary">
Observação: planos sem preço não aparecem no preview (política atual).
Para exibir como Sob consulta, mude acima.
</div>
<div v-if=previewPricePolicy === 'hide' class=mt-6 text-[1rem] text-[var(--text-color-secondary)]>
Observação: planos sem preço não aparecem no preview (política atual).
Para exibir como Sob consulta, mude acima.
</div>
</div>
</div>
</div><!-- /px-4 pb-4 -->
</div><!-- /content -->
<!-- Dialog principal ( sem drag: removemos draggable) -->
<Dialog
v-model:visible="showDlg"
modal
header="Editar vitrine"
:style="{ width: '820px' }"
:closable="!saving"
:dismissableMask="!saving"
:draggable="false"
>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="flex flex-col gap-4">
<!-- Nome público (FloatLabel + Icon) -->
<FloatLabel variant="on">
<!-- Dialog principal ( sem drag: removemos draggable) -->
<Dialog
v-model:visible=showDlg
modal
header=Editar vitrine
:style={ width: '820px' }
:closable=!saving
:dismissableMask=!saving
:draggable=false
>
<div class=grid grid-cols-1 md:grid-cols-2 gap-6>
<div class=flex flex-col gap-4>
<!-- Nome público (FloatLabel + Icon) -->
<FloatLabel variant=on>
<IconField>
<InputIcon class=pi pi-tag />
<InputText
id=pp-public-name
v-model.trim=form.public_name
class=w-full
variant=filled
:disabled=saving
autocomplete=off
autofocus
@keydown.enter.prevent=save
/>
</IconField>
<label for=pp-public-name>Nome público *</label>
</FloatLabel>
<!-- Descrição pública -->
<FloatLabel variant=on>
<IconField>
<InputIcon class=pi pi-align-left />
<Textarea
id=pp-public-desc
v-model.trim=form.public_description
class=w-full
rows=3
autoResize
:disabled=saving
/>
</IconField>
<label for=pp-public-desc>Descrição pública</label>
</FloatLabel>
<!-- Badge -->
<FloatLabel variant=on>
<IconField>
<InputIcon class=pi pi-bookmark />
<InputText
id=pp-badge
v-model.trim=form.badge
class=w-full
variant=filled
:disabled=saving
autocomplete=off
@keydown.enter.prevent=save
/>
</IconField>
<label for=pp-badge>Badge (opcional)</label>
</FloatLabel>
<div class=grid grid-cols-1 md:grid-cols-2 gap-4>
<!-- Ordem -->
<FloatLabel variant=on>
<IconField>
<InputIcon class="pi pi-tag" />
<InputText
id="pp-public-name"
v-model.trim="form.public_name"
class="w-full"
variant="filled"
:disabled="saving"
autocomplete="off"
autofocus
@keydown.enter.prevent="save"
<InputIcon class=pi pi-sort-amount-up-alt />
<InputNumber
id=pp-sort
v-model=form.sort_order
class=w-full
inputClass=w-full
:disabled=saving
/>
</IconField>
<label for="pp-public-name">Nome público *</label>
<label for=pp-sort>Ordem</label>
</FloatLabel>
<!-- Descrição pública -->
<FloatLabel variant="on">
<IconField>
<InputIcon class="pi pi-align-left" />
<Textarea
id="pp-public-desc"
v-model.trim="form.public_description"
class="w-full"
rows="3"
autoResize
:disabled="saving"
/>
</IconField>
<label for="pp-public-desc">Descrição pública</label>
</FloatLabel>
<!-- Badge -->
<FloatLabel variant="on">
<IconField>
<InputIcon class="pi pi-bookmark" />
<InputText
id="pp-badge"
v-model.trim="form.badge"
class="w-full"
variant="filled"
:disabled="saving"
autocomplete="off"
@keydown.enter.prevent="save"
/>
</IconField>
<label for="pp-badge">Badge (opcional)</label>
</FloatLabel>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<!-- Ordem -->
<FloatLabel variant="on">
<IconField>
<InputIcon class="pi pi-sort-amount-up-alt" />
<InputNumber
id="pp-sort"
v-model="form.sort_order"
class="w-full"
inputClass="w-full"
:disabled="saving"
/>
</IconField>
<label for="pp-sort">Ordem</label>
</FloatLabel>
<div class="flex flex-col gap-3 pt-2">
<div class="flex items-center gap-2">
<Checkbox v-model="form.is_visible" :binary="true" :disabled="saving" />
<label>Visível no público</label>
</div>
<div class="flex items-center gap-2">
<Checkbox v-model="form.is_featured" :binary="true" :disabled="saving" />
<label>Destaque</label>
</div>
<div class=flex flex-col gap-3 pt-2>
<div class=flex items-center gap-2>
<Checkbox v-model=form.is_visible :binary=true :disabled=saving />
<label>Visível no público</label>
</div>
<div class=flex items-center gap-2>
<Checkbox v-model=form.is_featured :binary=true :disabled=saving />
<label>Destaque</label>
</div>
</div>
</div>
<!-- bullets -->
<div>
<div class="flex items-center justify-between mb-3">
<div class="font-semibold">Benefícios (bullets)</div>
<Button label="Adicionar" icon="pi pi-plus" size="small" :disabled="saving || bulletSaving" @click="openBulletCreate" />
</div>
<DataTable :value="bullets" dataKey="id" stripedRows responsiveLayout="scroll">
<Column field="text" header="Texto" />
<Column field="sort_order" header="Ordem" style="width: 7rem" />
<Column header="Destaque" style="width: 8rem">
<template #body="{ data }">
<span>{{ data.highlight ? 'Sim' : 'Não' }}</span>
</template>
</Column>
<Column header="Ações" style="width: 9rem">
<template #body="{ data }">
<div class="flex gap-2">
<Button icon="pi pi-pencil" severity="secondary" outlined size="small" :disabled="saving || bulletSaving" @click="openBulletEdit(data)" />
<Button icon="pi pi-trash" severity="danger" outlined size="small" :disabled="saving || bulletSaving" @click="askDeleteBullet(data)" />
</div>
</template>
</Column>
</DataTable>
</div>
</div>
<template #footer>
<Button label="Cancelar" severity="secondary" outlined :disabled="saving" @click="showDlg = false" />
<Button label="Salvar" icon="pi pi-check" :loading="saving" @click="save" />
</template>
</Dialog>
<!-- bullets -->
<div>
<div class=flex items-center justify-between mb-3>
<div class=font-semibold>Benefícios (bullets)</div>
<Button label=Adicionar icon=pi pi-plus size=small :disabled=saving || bulletSaving @click=openBulletCreate />
</div>
<!-- Dialog bullet ( sem drag + inputs padronizados) -->
<Dialog
v-model:visible="showBulletDlg"
modal
:header="bulletIsEdit ? 'Editar benefício' : 'Novo benefício'"
:style="{ width: '560px' }"
:closable="!bulletSaving"
:dismissableMask="!bulletSaving"
:draggable="false"
>
<div class="flex flex-col gap-4">
<FloatLabel variant="on">
<DataTable :value=bullets dataKey=id stripedRows responsiveLayout=scroll>
<Column field=text header=Texto />
<Column field=sort_order header=Ordem style=width: 7rem />
<Column header=Destaque style=width: 8rem>
<template #body={ data }>
<span>{{ data.highlight ? 'Sim' : 'Não' }}</span>
</template>
</Column>
<Column header=Ações style=width: 9rem>
<template #body={ data }>
<div class=flex gap-2>
<Button icon=pi pi-pencil severity=secondary outlined size=small :disabled=saving || bulletSaving @click=openBulletEdit(data) />
<Button icon=pi pi-trash severity=danger outlined size=small :disabled=saving || bulletSaving @click=askDeleteBullet(data) />
</div>
</template>
</Column>
</DataTable>
</div>
</div>
<template #footer>
<Button label=Cancelar severity=secondary outlined :disabled=saving @click=showDlg = false />
<Button label=Salvar icon=pi pi-check :loading=saving @click=save />
</template>
</Dialog>
<!-- Dialog bullet ( sem drag + inputs padronizados) -->
<Dialog
v-model:visible=showBulletDlg
modal
:header=bulletIsEdit ? 'Editar benefício' : 'Novo benefício'
:style={ width: '560px' }
:closable=!bulletSaving
:dismissableMask=!bulletSaving
:draggable=false
>
<div class=flex flex-col gap-4>
<FloatLabel variant=on>
<IconField>
<InputIcon class=pi pi-list />
<Textarea
id=pp-bullet-text
v-model.trim=bulletForm.text
class=w-full
rows=3
autoResize
:disabled=bulletSaving
/>
</IconField>
<label for=pp-bullet-text>Texto *</label>
</FloatLabel>
<div class=grid grid-cols-1 md:grid-cols-2 gap-4>
<FloatLabel variant=on>
<IconField>
<InputIcon class="pi pi-list" />
<Textarea
id="pp-bullet-text"
v-model.trim="bulletForm.text"
class="w-full"
rows="3"
autoResize
:disabled="bulletSaving"
<InputIcon class=pi pi-sort-numeric-up />
<InputNumber
id=pp-bullet-order
v-model=bulletForm.sort_order
class=w-full
inputClass=w-full
:disabled=bulletSaving
/>
</IconField>
<label for="pp-bullet-text">Texto *</label>
<label for=pp-bullet-order>Ordem</label>
</FloatLabel>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<FloatLabel variant="on">
<IconField>
<InputIcon class="pi pi-sort-numeric-up" />
<InputNumber
id="pp-bullet-order"
v-model="bulletForm.sort_order"
class="w-full"
inputClass="w-full"
:disabled="bulletSaving"
/>
</IconField>
<label for="pp-bullet-order">Ordem</label>
</FloatLabel>
<div class="flex items-center gap-2 pt-7">
<Checkbox v-model="bulletForm.highlight" :binary="true" :disabled="bulletSaving" />
<label>Destaque</label>
</div>
<div class=flex items-center gap-2 pt-7>
<Checkbox v-model=bulletForm.highlight :binary=true :disabled=bulletSaving />
<label>Destaque</label>
</div>
</div>
</div>
<template #footer>
<Button label="Cancelar" severity="secondary" outlined :disabled="bulletSaving" @click="showBulletDlg = false" />
<Button :label="bulletIsEdit ? 'Salvar' : 'Criar'" icon="pi pi-check" :loading="bulletSaving" @click="saveBullet" />
</template>
</Dialog>
</div>
</template>
<style scoped>
/* ─── Root ──────────────────────────────────────────────── */
.showcase-root { padding: 1rem; }
@media (min-width: 768px) { .showcase-root { padding: 1.5rem; } }
/* ─── Hero ──────────────────────────────────────────────── */
.showcase-hero-sentinel { height: 1px; }
.showcase-hero {
position: sticky;
top: var(--layout-sticky-top, 56px);
z-index: 20;
overflow: hidden;
border-radius: 1.75rem;
border: 1px solid var(--surface-border);
background: var(--surface-card);
padding: 1.5rem;
}
.showcase-hero--stuck {
border-top-left-radius: 0;
border-top-right-radius: 0;
}
.showcase-hero__blobs {
position: absolute; inset: 0; pointer-events: none; overflow: hidden;
}
.showcase-hero__blob {
position: absolute; border-radius: 50%; filter: blur(70px);
}
.showcase-hero__blob--1 { width: 20rem; height: 20rem; top: -5rem; right: -4rem; background: rgba(16,185,129,0.12); }
.showcase-hero__blob--2 { width: 18rem; height: 18rem; top: 1rem; left: -5rem; background: rgba(99,102,241,0.09); }
.showcase-hero__inner {
position: relative; z-index: 1;
display: flex; align-items: center; gap: 1.25rem; flex-wrap: wrap;
}
.showcase-hero__icon-wrap {
flex-shrink: 0;
width: 4rem; height: 4rem; border-radius: 1.125rem;
border: 2px solid var(--surface-border);
background: var(--surface-ground);
display: grid; place-items: center;
}
.showcase-hero__icon { font-size: 1.5rem; color: var(--text-color); }
.showcase-hero__info { flex: 1; min-width: 0; }
.showcase-hero__title {
font-size: 1.25rem; font-weight: 700; letter-spacing: -0.025em;
color: var(--text-color); line-height: 1.2;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.showcase-hero__sub {
font-size: 0.78rem; color: var(--text-color-secondary); margin-top: 4px; line-height: 1.5;
}
.showcase-hero__actions--desktop {
display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap;
}
.showcase-hero__actions--mobile { display: none; }
@media (max-width: 1199px) {
.showcase-hero__actions--desktop { display: none; }
.showcase-hero__actions--mobile { display: flex; }
}
</style>
<template #footer>
<Button label=Cancelar severity=secondary outlined :disabled=bulletSaving @click=showBulletDlg = false />
<Button :label=bulletIsEdit ? 'Salvar' : 'Criar' icon=pi pi-check :loading=bulletSaving @click=saveBullet />
</template>
</Dialog>
</template>

View File

@@ -316,41 +316,28 @@ onBeforeUnmount(() => {
<template>
<Toast />
<!-- Info decorativa (scrolls away naturalmente) -->
<div class="flex items-start gap-4 px-4 pb-3">
<div class="events-hero__icon-wrap">
<i class="pi pi-history events-hero__icon" />
</div>
<div class="events-hero__sub">
Auditoria read-only das mudanças de plano e status. Exibe até 500 eventos mais recentes.
<template v-if="!loading">
{{ totalCount }} evento(s) {{ changedCount }} troca(s) de plano
</template>
</div>
</div>
<!-- Sentinel -->
<div ref="sentinelRef" class="h-px" />
<!-- sentinel -->
<div ref="sentinelRef" style="height: 1px; pointer-events: none;" />
<!-- hero -->
<!-- Hero sticky -->
<div
ref="heroRef"
class="events-hero"
:class="{ 'events-hero--stuck': heroStuck }"
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="events-hero__blobs" aria-hidden="true">
<div class="events-hero__blob events-hero__blob--1" />
<div class="events-hero__blob events-hero__blob--2" />
<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="events-hero__inner">
<!-- Título -->
<div class="events-hero__info min-w-0">
<div class="events-hero__title">Histórico de assinaturas</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="events-hero__actions events-hero__actions--desktop">
<div class="hidden xl:flex items-center gap-2 flex-wrap">
<Button
label="Voltar para assinaturas"
icon="pi pi-arrow-left"
@@ -380,7 +367,7 @@ onBeforeUnmount(() => {
</div>
<!-- Ações mobile (< 1200px) -->
<div class="events-hero__actions--mobile">
<div class="flex xl:hidden">
<Button
label="Ações"
icon="pi pi-ellipsis-v"
@@ -394,20 +381,20 @@ onBeforeUnmount(() => {
</div>
<!-- content -->
<div class="px-4 pb-4">
<div class="px-3 md:px-4 pb-8 flex flex-col gap-4">
<!-- Card foco -->
<div
v-if="isFocused"
class="mb-3 overflow-hidden rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] shadow-sm"
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-4 md:p-5">
<div class="flex flex-wrap items-center justify-between gap-3 p-5">
<div class="min-w-0">
<div class="text-lg font-semibold leading-none">Eventos em foco</div>
<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 />
<small class="text-color-secondary break-all">
<div class="text-[1rem] text-[var(--text-color-secondary)] break-all">
{{ route.query.q }}
</small>
</div>
</div>
</div>
@@ -424,7 +411,7 @@ onBeforeUnmount(() => {
</div>
<!-- busca -->
<div class="mb-4">
<div>
<FloatLabel variant="on" class="w-full">
<IconField class="w-full">
<InputIcon class="pi pi-search" />
@@ -446,7 +433,6 @@ onBeforeUnmount(() => {
:loading="loading"
stripedRows
responsiveLayout="scroll"
class="events-table"
:rowHover="true"
paginator
:rows="15"
@@ -475,9 +461,9 @@ onBeforeUnmount(() => {
<template #body="{ data }">
<div class="flex flex-col">
<span class="font-medium">{{ ownerKeyFromEvent(data) }}</span>
<small class="text-color-secondary">
<div class="text-[1rem] text-[var(--text-color-secondary)]">
{{ displayOwner(data) }}
</small>
</div>
</div>
</template>
</Column>
@@ -501,7 +487,7 @@ onBeforeUnmount(() => {
<Column field="subscription_id" header="Subscription" style="min-width: 22rem">
<template #body="{ data }">
<span class="font-mono text-sm">{{ data.subscription_id }}</span>
<span class="font-mono text-[1rem]">{{ data.subscription_id }}</span>
</template>
</Column>
@@ -527,87 +513,14 @@ onBeforeUnmount(() => {
</Column>
<template #empty>
<div class="p-4 text-color-secondary">
<div class="p-4 text-[var(--text-color-secondary)]">
Nenhum evento encontrado com os filtros atuais.
</div>
</template>
</DataTable>
<div class="text-color-secondary mt-3 text-sm">
<div class="text-[1rem] text-[var(--text-color-secondary)]">
Mostrando até 500 eventos mais recentes.
</div>
</div>
</template>
<style scoped>
.events-table :deep(.p-paginator) {
border-top: 1px solid var(--surface-border);
}
.events-table :deep(.p-datatable-tbody > tr > td) {
vertical-align: top;
}
.font-mono {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
}
/* Hero */
.events-hero {
position: sticky;
top: var(--layout-sticky-top, 56px);
z-index: 20;
overflow: hidden;
border-radius: 1.75rem;
border: 1px solid var(--surface-border);
background: var(--surface-card);
padding: 1.5rem;
margin: 1rem;
}
.events-hero--stuck {
border-top-left-radius: 0;
border-top-right-radius: 0;
}
.events-hero__blobs {
position: absolute; inset: 0; pointer-events: none; overflow: hidden;
}
.events-hero__blob {
position: absolute; border-radius: 50%; filter: blur(70px);
}
.events-hero__blob--1 { width: 20rem; height: 20rem; top: -5rem; right: -4rem; background: rgba(251,191,36,0.12); }
.events-hero__blob--2 { width: 18rem; height: 18rem; top: 1rem; left: -5rem; background: rgba(249,115,22,0.09); }
.events-hero__inner {
position: relative; z-index: 1;
display: flex; align-items: center; gap: 1.25rem; flex-wrap: wrap;
}
.events-hero__icon-wrap {
flex-shrink: 0;
width: 4rem; height: 4rem; border-radius: 1.125rem;
border: 2px solid var(--surface-border);
background: var(--surface-ground);
display: grid; place-items: center;
}
.events-hero__icon { font-size: 1.5rem; color: var(--text-color); }
.events-hero__info { flex: 1; min-width: 0; }
.events-hero__title {
font-size: 1.25rem; font-weight: 700; letter-spacing: -0.025em;
color: var(--text-color); line-height: 1.2;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.events-hero__sub {
font-size: 0.78rem; color: var(--text-color-secondary); margin-top: 4px; line-height: 1.5;
}
.events-hero__actions--desktop {
display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap;
}
.events-hero__actions--mobile { display: none; }
@media (max-width: 1199px) {
.events-hero__actions--desktop { display: none; }
.events-hero__actions--mobile { display: flex; }
}
</style>

View File

@@ -401,39 +401,28 @@ onBeforeUnmount(() => {
<Toast />
<ConfirmDialog />
<!-- Info decorativa (scrolls away naturalmente) -->
<div class="flex items-start gap-4 px-4 pb-3">
<div class="health-hero__icon-wrap">
<i class="pi pi-shield health-hero__icon" />
</div>
<div class="health-hero__sub">
Terapeutas: divergências entre plano (esperado) e entitlements (atual).
Clínicas: exceções comerciais (features liberadas manualmente fora do plano).
</div>
</div>
<!-- Sentinel -->
<div ref="sentinelRef" class="h-px" />
<!-- sentinel -->
<div ref="sentinelRef" style="height: 1px; pointer-events: none;" />
<!-- hero -->
<!-- Hero sticky -->
<div
ref="heroRef"
class="health-hero"
:class="{ 'health-hero--stuck': heroStuck }"
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="health-hero__blobs" aria-hidden="true">
<div class="health-hero__blob health-hero__blob--1" />
<div class="health-hero__blob health-hero__blob--2" />
<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="health-hero__inner">
<!-- Título -->
<div class="health-hero__info min-w-0">
<div class="health-hero__title">Saúde das Assinaturas</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)]">Saúde das Assinaturas</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-0.5">Terapeutas: divergências entre plano (esperado) e entitlements (atual). Clínicas: exceções comerciais (features liberadas manualmente fora do plano).</div>
</div>
<!-- Ações desktop ( 1200px) -->
<div class="health-hero__actions health-hero__actions--desktop">
<div class="hidden xl:flex items-center gap-2 shrink-0">
<Button
label="Recarregar"
icon="pi pi-refresh"
@@ -467,7 +456,7 @@ onBeforeUnmount(() => {
</div>
<!-- Ações mobile (< 1200px) -->
<div class="health-hero__actions--mobile">
<div class="flex xl:hidden shrink-0">
<Button
label="Ações"
icon="pi pi-ellipsis-v"
@@ -481,9 +470,9 @@ onBeforeUnmount(() => {
</div>
<!-- content -->
<div class="px-4 pb-4">
<div class="px-3 md:px-4 pb-8 flex flex-col gap-4">
<!-- busca -->
<div class="mb-4">
<div>
<FloatLabel variant="on" class="w-full">
<IconField class="w-full">
<InputIcon class="pi pi-search" />
@@ -505,7 +494,7 @@ onBeforeUnmount(() => {
<!-- Terapeutas (Personal) -->
<!-- ===================================================== -->
<TabPanel header="Terapeutas (Pessoal)">
<div class="surface-100 border-round p-3 mb-4">
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-3 mb-4">
<div class="flex flex-wrap gap-2 items-center justify-content-between">
<div class="flex gap-2 items-center flex-wrap">
<Tag :value="`Divergências: ${totalPersonal}`" severity="secondary" />
@@ -514,7 +503,7 @@ onBeforeUnmount(() => {
<Tag v-if="totalPersonalWithoutOwner > 0" :value="`Sem owner: ${totalPersonalWithoutOwner}`" severity="warn" />
</div>
<div class="text-color-secondary text-sm">
<div class="text-[1rem] text-[var(--text-color-secondary)]">
<span class="font-medium">Faltando</span>: o plano exige, mas não está ativo ·
<span class="font-medium">Inesperado</span>: está ativo sem constar no plano
</div>
@@ -545,9 +534,9 @@ onBeforeUnmount(() => {
<template #body="{ data }">
<div class="flex flex-col">
<span class="font-medium">{{ data.feature_key }}</span>
<small class="text-color-secondary">
<div class="text-[1rem] text-[var(--text-color-secondary)]">
{{ helpForMismatch(data.mismatch_type) || '—' }}
</small>
</div>
</div>
</template>
</Column>
@@ -588,12 +577,12 @@ onBeforeUnmount(() => {
<Divider class="my-5" />
<Message severity="info" class="mt-4">
<div class="text-sm line-height-3">
<p class="mb-0">
<div class="text-[1rem] line-height-3">
<div class="mb-0">
<span class="font-semibold">Dica:</span>
Se você alterar o plano e o acesso não refletir imediatamente, esta aba exibirá as divergências entre o plano ativo e os entitlements atuais.
A ação <span class="font-medium">Corrigir</span> reconstrói os entitlements do owner com base no plano vigente e elimina inconsistências.
</p>
</div>
</div>
</Message>
</TabPanel>
@@ -602,13 +591,13 @@ onBeforeUnmount(() => {
<!-- Clínicas (Tenant) -->
<!-- ===================================================== -->
<TabPanel header="Clínicas (Exceções)">
<div class="surface-100 border-round p-3 mb-4">
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-3 mb-4">
<div class="flex flex-wrap gap-2 items-center justify-content-between">
<div class="flex gap-2 items-center flex-wrap">
<Tag :value="`Exceções ativas: ${totalClinic}`" severity="info" />
</div>
<div class="text-color-secondary text-sm">
<div class="text-[1rem] text-[var(--text-color-secondary)]">
Exceções comerciais: features liberadas manualmente fora do plano. Útil para testes, suporte e acordos.
</div>
</div>
@@ -627,7 +616,7 @@ onBeforeUnmount(() => {
<template #body="{ data }">
<div class="flex flex-col">
<span class="font-medium">{{ data.tenant_name || data.tenant_id }}</span>
<small class="text-color-secondary">{{ data.tenant_name ? data.tenant_id : '—' }}</small>
<div class="text-[1rem] text-[var(--text-color-secondary)]">{{ data.tenant_name ? data.tenant_id : '—' }}</div>
</div>
</template>
</Column>
@@ -642,9 +631,9 @@ onBeforeUnmount(() => {
<template #body="{ data }">
<div class="flex flex-col">
<span class="font-medium">{{ data.feature_key }}</span>
<small class="text-color-secondary">
<div class="text-[1rem] text-[var(--text-color-secondary)]">
{{ helpForException() }}
</small>
</div>
</div>
</template>
</Column>
@@ -684,83 +673,22 @@ onBeforeUnmount(() => {
<Divider class="my-5" />
<Message severity="info" class="mt-4">
<div class="text-sm line-height-3">
<p class="mb-2">
<div class="text-[1rem] line-height-3">
<div class="mb-2">
<span class="font-semibold">Observação:</span>
Exceção é uma escolha de negócio. Quando ativa, pode liberar acesso mesmo que o plano não permita.
Utilize <span class="font-medium">Remover exceção</span> quando a liberação deixar de fazer sentido.
</p>
</div>
<p class="mb-0">
<div class="mb-0">
<span class="font-semibold">Dica:</span>
Exceções comerciais liberam recursos fora do plano.
Se o acesso não refletir como esperado, verifique se existe uma exceção ativa para esta clínica.
A ação <span class="font-medium">Remover exceção</span> restaura o comportamento estritamente definido pelo plano.
</p>
</div>
</div>
</Message>
</TabPanel>
</TabView>
</div>
</template>
<style scoped>
/* Hero */
.health-hero {
position: sticky;
top: var(--layout-sticky-top, 56px);
z-index: 20;
overflow: hidden;
border-radius: 1.75rem;
border: 1px solid var(--surface-border);
background: var(--surface-card);
padding: 1.5rem;
margin: 1rem;
}
.health-hero--stuck {
border-top-left-radius: 0;
border-top-right-radius: 0;
}
.health-hero__blobs {
position: absolute; inset: 0; pointer-events: none; overflow: hidden;
}
.health-hero__blob {
position: absolute; border-radius: 50%; filter: blur(70px);
}
.health-hero__blob--1 { width: 20rem; height: 20rem; top: -5rem; right: -4rem; background: rgba(248,113,113,0.12); }
.health-hero__blob--2 { width: 18rem; height: 18rem; top: 1rem; left: -5rem; background: rgba(251,113,133,0.09); }
.health-hero__inner {
position: relative; z-index: 1;
display: flex; align-items: center; gap: 1.25rem; flex-wrap: wrap;
}
.health-hero__icon-wrap {
flex-shrink: 0;
width: 4rem; height: 4rem; border-radius: 1.125rem;
border: 2px solid var(--surface-border);
background: var(--surface-ground);
display: grid; place-items: center;
}
.health-hero__icon { font-size: 1.5rem; color: var(--text-color); }
.health-hero__info { flex: 1; min-width: 0; }
.health-hero__title {
font-size: 1.25rem; font-weight: 700; letter-spacing: -0.025em;
color: var(--text-color); line-height: 1.2;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.health-hero__sub {
font-size: 0.78rem; color: var(--text-color-secondary); margin-top: 4px; line-height: 1.5;
}
.health-hero__actions--desktop {
display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap;
}
.health-hero__actions--mobile { display: none; }
@media (max-width: 1199px) {
.health-hero__actions--desktop { display: none; }
.health-hero__actions--mobile { display: flex; }
}
</style>

View File

@@ -417,39 +417,28 @@ onBeforeUnmount(() => {
<Toast />
<ConfirmDialog />
<!-- Info decorativa (scrolls away naturalmente) -->
<div class="flex items-start gap-4 px-4 pb-3">
<div class="subs-hero__icon-wrap">
<i class="pi pi-credit-card subs-hero__icon" />
</div>
<div class="subs-hero__sub">
Painel operacional do SaaS: revise plano, status e período (Clínica x Terapeuta) com segurança.
<template v-if="!loading">
<br />{{ totalCount }} registro(s) {{ activeCount }} ativa(s)
</template>
</div>
</div>
<!-- Sentinel -->
<div ref="sentinelRef" class="h-px" />
<!-- sentinel -->
<div ref="sentinelRef" style="height: 1px; pointer-events: none;" />
<!-- hero -->
<!-- Hero sticky -->
<div
ref="heroRef"
class="subs-hero"
:class="{ 'subs-hero--stuck': heroStuck }"
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="subs-hero__blob subs-hero__blob--1" />
<div class="subs-hero__blob subs-hero__blob--2" />
<div class="subs-hero__inner">
<!-- Título -->
<div class="subs-hero__info min-w-0">
<div class="subs-hero__title">Assinaturas</div>
<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)]">Assinaturas</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-0.5">Painel operacional do SaaS: revise plano, status e período (Clínica x Terapeuta) com segurança.</div>
</div>
<!-- Ações desktop ( 1200px) -->
<div class="subs-hero__actions subs-hero__actions--desktop">
<div class="hidden xl:flex items-center gap-2 shrink-0">
<SelectButton
v-model="typeFilter"
:options="typeOptions"
@@ -471,7 +460,7 @@ onBeforeUnmount(() => {
</div>
<!-- Ações mobile (< 1200px) -->
<div class="subs-hero__actions--mobile">
<div class="flex xl:hidden shrink-0">
<Button
label="Ações"
icon="pi pi-ellipsis-v"
@@ -485,13 +474,13 @@ onBeforeUnmount(() => {
</div>
<!-- content -->
<div class="px-4 pb-4">
<div class="px-3 md:px-4 pb-8 flex flex-col gap-4">
<!-- Header foco -->
<div v-if="isFocused" class="mb-3 p-3 surface-100 border-round">
<div v-if="isFocused" class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-4">
<div class="flex align-items-center justify-content-between gap-3 flex-wrap">
<div>
<div class="text-lg font-semibold">Assinatura em foco</div>
<small class="text-color-secondary">Filtro: {{ route.query.q }}</small>
<div class="text-[1rem] font-semibold text-[var(--text-color)]">Assinatura em foco</div>
<div class="text-[1rem] text-[var(--text-color-secondary)]">Filtro: {{ route.query.q }}</div>
</div>
<Button
@@ -506,7 +495,7 @@ onBeforeUnmount(() => {
</div>
<!-- busca -->
<div class="mb-4">
<div>
<FloatLabel variant="on" class="w-full">
<IconField class="w-full">
<InputIcon class="pi pi-search" />
@@ -528,7 +517,6 @@ onBeforeUnmount(() => {
:loading="loading"
stripedRows
responsiveLayout="scroll"
class="subs-table"
:rowHover="true"
paginator
:rows="15"
@@ -546,9 +534,9 @@ onBeforeUnmount(() => {
<template #body="{ data }">
<div class="flex flex-col">
<span class="font-medium">{{ ownerKey(data) }}</span>
<small class="text-color-secondary">
<div class="text-[1rem] text-[var(--text-color-secondary)]">
{{ data.tenant_id ? `tenant_id: ${data.tenant_id}` : `user_id: ${data.user_id || '—'}` }}
</small>
</div>
</div>
</template>
</Column>
@@ -585,9 +573,9 @@ onBeforeUnmount(() => {
<template #body="{ data }">
<div>
<div>{{ fmtDate(data.current_period_start) }}</div>
<small class="text-color-secondary">
<div class="text-[1rem] text-[var(--text-color-secondary)]">
até {{ fmtDate(data.current_period_end) }}
</small>
</div>
</div>
</template>
</Column>
@@ -642,74 +630,10 @@ onBeforeUnmount(() => {
</Column>
<template #empty>
<div class="p-4 text-color-secondary">
<div class="p-4 text-[var(--text-color-secondary)]">
Nenhuma assinatura encontrada com os filtros atuais.
</div>
</template>
</DataTable>
</div>
</template>
<style scoped>
.subs-table :deep(.p-paginator) {
border-top: 1px solid var(--surface-border);
}
.subs-table :deep(.p-datatable-tbody > tr > td) {
vertical-align: top;
}
/* Hero */
.subs-hero {
position: sticky;
top: var(--layout-sticky-top, 56px);
z-index: 20;
overflow: hidden;
border-radius: 1.75rem;
border: 1px solid var(--surface-border);
background: var(--surface-card);
padding: 1.5rem;
margin: 1rem;
}
.subs-hero--stuck {
border-top-left-radius: 0;
border-top-right-radius: 0;
}
.subs-hero__blob {
position: absolute; border-radius: 50%; filter: blur(70px);
}
.subs-hero__blob--1 { width: 20rem; height: 20rem; top: -5rem; right: -4rem; background: rgba(96,165,250,0.12); }
.subs-hero__blob--2 { width: 18rem; height: 18rem; top: 1rem; left: -5rem; background: rgba(99,102,241,0.09); }
.subs-hero__inner {
position: relative; z-index: 1;
display: flex; align-items: center; gap: 1.25rem; flex-wrap: wrap;
}
.subs-hero__icon-wrap {
flex-shrink: 0;
width: 4rem; height: 4rem; border-radius: 1.125rem;
border: 2px solid var(--surface-border);
background: var(--surface-ground);
display: grid; place-items: center;
}
.subs-hero__icon { font-size: 1.5rem; color: var(--text-color); }
.subs-hero__info { flex: 1; min-width: 0; }
.subs-hero__title {
font-size: 1.25rem; font-weight: 700; letter-spacing: -0.025em;
color: var(--text-color); line-height: 1.2;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.subs-hero__sub {
font-size: 0.78rem; color: var(--text-color-secondary); margin-top: 4px; line-height: 1.5;
}
.subs-hero__actions--desktop {
display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap;
}
.subs-hero__actions--mobile { display: none; }
@media (max-width: 1199px) {
.subs-hero__actions--desktop { display: none; }
.subs-hero__actions--mobile { display: flex; }
}
</style>

View File

@@ -240,17 +240,29 @@ function sessionStatusLabel (session) {
</script>
<template>
<div class="saas-support p-4 md:p-6">
<Toast />
<Toast />
<!-- Cabeçalho -->
<div class="flex items-center gap-3 mb-5">
<div class="flex items-center justify-center w-10 h-10 rounded-xl bg-orange-100 dark:bg-orange-900/30">
<i class="pi pi-headphones text-orange-600 dark:text-orange-400 text-lg" />
</div>
<div class="flex-1">
<h1 class="text-xl font-bold m-0">Suporte Técnico</h1>
<p class="text-sm text-surface-500 m-0">Gere e gerencie links seguros de acesso em modo debug</p>
<!-- 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" />
</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"
@@ -258,6 +270,10 @@ function sessionStatusLabel (session) {
severity="warning"
/>
</div>
</div>
<!-- content -->
<div class="px-3 md:px-4 pb-8 flex flex-col gap-4">
<!-- Tabs -->
<TabView @tab-change="onTabChange">
@@ -267,15 +283,15 @@ function sessionStatusLabel (session) {
<div class="grid grid-cols-1 lg:grid-cols-2 gap-5 pt-2">
<!-- Formulário -->
<div class="card">
<h2 class="text-base font-semibold flex items-center gap-2 m-0 mb-4">
<i class="pi pi-plus-circle text-primary" />
<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
</h2>
</div>
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-1">
<label class="text-sm font-medium">Selecionar Cliente (Tenant)</label>
<label class="text-[1rem] font-medium">Selecionar Cliente (Tenant)</label>
<Select
v-model="selectedTenantId"
:options="tenants"
@@ -290,7 +306,7 @@ function sessionStatusLabel (session) {
</div>
<div class="flex flex-col gap-1">
<label class="text-sm font-medium">Duração do Acesso</label>
<label class="text-[1rem] font-medium">Duração do Acesso</label>
<Select
v-model="ttlMinutes"
:options="ttlOptions"
@@ -301,9 +317,9 @@ function sessionStatusLabel (session) {
</div>
<div class="flex flex-col gap-1">
<label class="text-sm font-medium">
<label class="text-[1rem] font-medium">
Nota / Motivo
<span class="text-surface-400 font-normal">(opcional)</span>
<span class="text-[var(--text-color-secondary)] font-normal">(opcional)</span>
</label>
<InputText
v-model="sessionNote"
@@ -325,45 +341,45 @@ function sessionStatusLabel (session) {
</div>
<!-- URL Gerada -->
<div class="card">
<h2 class="text-base font-semibold flex items-center gap-2 m-0 mb-4">
<i class="pi pi-link text-primary" />
<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
</h2>
</div>
<div v-if="generatedUrl" class="flex flex-col gap-4">
<div class="flex flex-col gap-1">
<label class="text-sm font-medium">Link de Acesso</label>
<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-xs" />
<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-sm">
<div class="flex items-center gap-2 text-[1rem]">
<i class="pi pi-clock text-orange-500" />
<span class="text-surface-500">Expira em:</span>
<span class="text-[var(--text-color-secondary)]">Expira em:</span>
<strong>{{ expiresLabel }}</strong>
</div>
<div class="flex items-center gap-2 text-xs text-surface-400 font-mono">
<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-sm text-surface-500">
<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" class="text-sm">
Envie este link ao terapeuta ou acesse diretamente para monitorar os logs da agenda em tempo real.
<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-surface-400 gap-3">
<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" />
<span class="text-sm">Nenhuma sessão gerada ainda</span>
<div class="text-[1rem]">Nenhuma sessão gerada ainda</div>
</div>
</div>
</div>
@@ -378,12 +394,12 @@ function sessionStatusLabel (session) {
</span>
</template>
<div class="card mt-2">
<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">
<h2 class="text-base font-semibold flex items-center gap-2 m-0">
<i class="pi pi-circle-fill text-green-500 text-xs" />
<div class="text-[1rem] font-semibold flex items-center gap-2">
<i class="pi pi-circle-fill text-green-500" />
Sessões em vigor
</h2>
</div>
<Button
icon="pi pi-refresh"
severity="secondary"
@@ -405,15 +421,15 @@ function sessionStatusLabel (session) {
<Column header="Tenant" style="min-width: 200px">
<template #body="{ data }">
<div class="flex flex-col gap-0.5">
<span class="font-medium text-sm">{{ tenantName(data.tenant_id) }}</span>
<span class="font-mono text-xs text-surface-400">{{ data.tenant_id }}</span>
<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-xs text-surface-400">{{ data.token.slice(0, 12) }}</span>
<span class="font-mono text-[1rem] text-[var(--text-color-secondary)]">{{ data.token.slice(0, 12) }}</span>
</template>
</Column>
@@ -427,14 +443,14 @@ function sessionStatusLabel (session) {
<Column header="Criada em">
<template #body="{ data }">
<span class="text-sm text-surface-500">{{ formatDate(data.created_at) }}</span>
<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-xs italic text-surface-500">{{ data._note }}</span>
<span v-else class="text-xs text-surface-300"></span>
<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>
@@ -452,12 +468,12 @@ function sessionStatusLabel (session) {
<!-- Tab 2: Histórico -->
<TabPanel header="Histórico">
<div class="card mt-2">
<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">
<h2 class="text-base font-semibold flex items-center gap-2 m-0">
<i class="pi pi-history text-primary" />
<div class="text-[1rem] font-semibold flex items-center gap-2">
<i class="pi pi-history text-[var(--primary-color)]" />
Últimas 100 sessões
</h2>
</div>
<Button
icon="pi pi-refresh"
severity="secondary"
@@ -480,41 +496,41 @@ function sessionStatusLabel (session) {
>
<Column header="Status" style="width: 110px">
<template #body="{ data }">
<Tag :value="sessionStatusLabel(data)" :severity="sessionStatusSeverity(data)" class="text-xs" />
<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-sm">{{ tenantName(data.tenant_id) }}</span>
<span class="font-mono text-xs text-surface-400">{{ data.tenant_id }}</span>
<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-xs text-surface-400">{{ data.token.slice(0, 12) }}</span>
<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-sm text-surface-500">{{ formatDate(data.created_at) }}</span>
<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-sm text-surface-500">{{ formatDate(data.expires_at) }}</span>
<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-xs italic text-surface-500">{{ data._note }}</span>
<span v-else class="text-xs text-surface-300"></span>
<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>

View File

@@ -581,35 +581,28 @@ onBeforeUnmount(() => {
<Toast />
<ConfirmDialog />
<!-- Info decorativa (scrolls away naturalmente) -->
<div class="flex items-start gap-4 px-4 pb-3">
<div class="intents-hero__icon-wrap">
<i class="pi pi-inbox intents-hero__icon" />
</div>
<div class="intents-hero__sub">
Caixa de entrada de pagamento manual (PIX/boleto). Marque como <b>pago</b> para ativar a assinatura ou
<b>cancele</b> quando o pagamento não será concluído.
</div>
</div>
<!-- Sentinel -->
<div ref="heroSentinelRef" class="h-px" />
<!-- sentinel -->
<div ref="heroSentinelRef" class="intents-hero-sentinel" />
<!-- hero -->
<div ref="heroEl" class="intents-hero mb-4" :class="{ 'intents-hero--stuck': heroStuck }">
<div class="intents-hero__blobs" aria-hidden="true">
<div class="intents-hero__blob intents-hero__blob--1" />
<div class="intents-hero__blob intents-hero__blob--2" />
<!-- 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-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="intents-hero__inner">
<!-- Título -->
<div class="intents-hero__info min-w-0">
<div class="intents-hero__title">Intenções de assinatura</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)]">Intenções de assinatura</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-0.5">Caixa de entrada de pagamento manual (PIX/boleto). Marque como <b>pago</b> para ativar a assinatura ou <b>cancele</b> quando o pagamento não será concluído.</div>
</div>
<!-- Ações desktop ( 1200px) -->
<div class="intents-hero__actions intents-hero__actions--desktop">
<div class="hidden xl:flex items-center gap-2 shrink-0">
<Button
label="Atualizar"
icon="pi pi-refresh"
@@ -633,7 +626,7 @@ onBeforeUnmount(() => {
</div>
<!-- Ações mobile (< 1200px) -->
<div class="intents-hero__actions--mobile">
<div class="flex xl:hidden shrink-0">
<Button
label="Ações"
icon="pi pi-ellipsis-v"
@@ -647,120 +640,116 @@ onBeforeUnmount(() => {
</div>
<!-- content -->
<div class="px-4 pb-4">
<div class="px-3 md:px-4 pb-8 flex flex-col gap-4">
<!-- Card: Resumo + Filtros -->
<Card class="mb-4">
<template #title>
<div class="flex items-center justify-between gap-3 flex-wrap">
<div class="flex items-center gap-2">
<i class="pi pi-filter text-color-secondary" />
<span>Busca & Filtros</span>
</div>
<!-- contagens -->
<div class="flex flex-wrap gap-2 items-center">
<Tag :value="`Total: ${totals.total}`" severity="secondary" rounded />
<Tag :value="`Novas: ${totals.new}`" severity="info" rounded />
<Tag :value="`Aguardando: ${totals.waiting}`" severity="warning" rounded />
<Tag :value="`Pagas: ${totals.paid}`" severity="success" rounded />
<Tag :value="`Canceladas: ${totals.canceled}`" severity="danger" rounded />
<span class="text-xs text-color-secondary">
<template v-if="lastRefreshAt">· {{ fmtDate(lastRefreshAt) }}</template>
</span>
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5">
<div class="flex items-center justify-between gap-3 flex-wrap mb-4">
<div class="flex items-center gap-2">
<i class="pi pi-filter text-[var(--text-color-secondary)]" />
<div class="text-[1rem] font-semibold text-[var(--text-color)]">Busca &amp; Filtros</div>
</div>
<!-- contagens -->
<div class="flex flex-wrap gap-2 items-center">
<Tag :value="`Total: ${totals.total}`" severity="secondary" rounded />
<Tag :value="`Novas: ${totals.new}`" severity="info" rounded />
<Tag :value="`Aguardando: ${totals.waiting}`" severity="warning" rounded />
<Tag :value="`Pagas: ${totals.paid}`" severity="success" rounded />
<Tag :value="`Canceladas: ${totals.canceled}`" severity="danger" rounded />
<div class="text-[1rem] text-[var(--text-color-secondary)]">
<template v-if="lastRefreshAt">· {{ fmtDate(lastRefreshAt) }}</template>
</div>
</div>
</template>
</div>
<template #content>
<div class="grid grid-cols-12 gap-3">
<!-- Busca -->
<div class="col-span-12 md:col-span-5">
<FloatLabel variant="on" class="w-full">
<IconField class="w-full">
<InputIcon class="pi pi-search" />
<InputText
v-model="q"
id="si-search"
class="w-full pr-10"
variant="filled"
:disabled="acting"
placeholder="ex.: email@dominio.com"
@keyup.enter="refresh"
/>
</IconField>
<label for="si-search">Buscar por e-mail / plano / tenant_id</label>
</FloatLabel>
</div>
<!-- Status -->
<div class="col-span-12 md:col-span-3">
<FloatLabel variant="on" class="w-full">
<Dropdown
v-model="status"
:options="statusOptions"
optionLabel="label"
optionValue="value"
class="w-full"
showClear
<div class="grid grid-cols-12 gap-3">
<!-- Busca -->
<div class="col-span-12 md:col-span-5">
<FloatLabel variant="on" class="w-full">
<IconField class="w-full">
<InputIcon class="pi pi-search" />
<InputText
v-model="q"
id="si-search"
class="w-full pr-10"
variant="filled"
:disabled="acting"
placeholder="ex.: email@dominio.com"
@keyup.enter="refresh"
/>
<label>Status</label>
</FloatLabel>
</div>
<!-- Intervalo -->
<div class="col-span-12 md:col-span-2">
<FloatLabel variant="on" class="w-full">
<Dropdown
v-model="interval"
:options="intervalOptions"
optionLabel="label"
optionValue="value"
class="w-full"
showClear
:disabled="acting"
/>
<label>Intervalo</label>
</FloatLabel>
</div>
<!-- Plano -->
<div class="col-span-12 md:col-span-2">
<FloatLabel variant="on" class="w-full">
<Dropdown
v-model="planKey"
:options="planOptions"
optionLabel="label"
optionValue="value"
class="w-full"
showClear
:disabled="acting"
/>
<label>Plano</label>
</FloatLabel>
</div>
<!-- Botões -->
<div class="col-span-12 flex gap-2 flex-wrap">
<Button
label="Aplicar"
icon="pi pi-filter"
@click="refresh"
:disabled="acting"
/>
<Button
v-if="hasAnyFilter"
label="Limpar filtros"
icon="pi pi-times"
severity="secondary"
outlined
@click="clearFilters"
:disabled="acting"
/>
</div>
</IconField>
<label for="si-search">Buscar por e-mail / plano / tenant_id</label>
</FloatLabel>
</div>
</template>
</Card>
<!-- Status -->
<div class="col-span-12 md:col-span-3">
<FloatLabel variant="on" class="w-full">
<Dropdown
v-model="status"
:options="statusOptions"
optionLabel="label"
optionValue="value"
class="w-full"
showClear
:disabled="acting"
/>
<label>Status</label>
</FloatLabel>
</div>
<!-- Intervalo -->
<div class="col-span-12 md:col-span-2">
<FloatLabel variant="on" class="w-full">
<Dropdown
v-model="interval"
:options="intervalOptions"
optionLabel="label"
optionValue="value"
class="w-full"
showClear
:disabled="acting"
/>
<label>Intervalo</label>
</FloatLabel>
</div>
<!-- Plano -->
<div class="col-span-12 md:col-span-2">
<FloatLabel variant="on" class="w-full">
<Dropdown
v-model="planKey"
:options="planOptions"
optionLabel="label"
optionValue="value"
class="w-full"
showClear
:disabled="acting"
/>
<label>Plano</label>
</FloatLabel>
</div>
<!-- Botões -->
<div class="col-span-12 flex gap-2 flex-wrap">
<Button
label="Aplicar"
icon="pi pi-filter"
@click="refresh"
:disabled="acting"
/>
<Button
v-if="hasAnyFilter"
label="Limpar filtros"
icon="pi pi-times"
severity="secondary"
outlined
@click="clearFilters"
:disabled="acting"
/>
</div>
</div>
</div>
<DataTable
:value="filteredRows"
@@ -768,7 +757,6 @@ onBeforeUnmount(() => {
paginator
:rows="20"
:rowsPerPageOptions="[10, 20, 50]"
class="text-sm intents-table"
responsiveLayout="scroll"
sortField="created_at"
:sortOrder="-1"
@@ -776,7 +764,7 @@ onBeforeUnmount(() => {
>
<Column field="id" header="Intent ID" style="min-width: 18rem">
<template #body="{ data }">
<span class="text-xs">{{ data.id }}</span>
<div class="text-[1rem]">{{ data.id }}</div>
</template>
</Column>
@@ -786,9 +774,9 @@ onBeforeUnmount(() => {
<template #body="{ data }">
<div class="flex flex-col">
<span class="font-medium">{{ data.plan_key || '—' }}</span>
<small class="text-color-secondary">
<div class="text-[1rem] text-[var(--text-color-secondary)]">
{{ intervalLabel(data.interval) }} {{ moneyBRL(data.amount_cents) }}
</small>
</div>
</div>
</template>
</Column>
@@ -803,9 +791,9 @@ onBeforeUnmount(() => {
:value="c.label"
rounded
/>
<span v-if="!diagChips(data).length" class="text-color-secondary"></span>
<span v-if="!diagChips(data).length" class="text-[var(--text-color-secondary)]"></span>
</div>
<div v-if="data.tenant_id" class="mt-1 text-xs text-color-secondary">
<div v-if="data.tenant_id" class="mt-1 text-[1rem] text-[var(--text-color-secondary)]">
tenant_id: {{ data.tenant_id }}
</div>
</template>
@@ -862,13 +850,13 @@ onBeforeUnmount(() => {
:dismissableMask="!acting"
:draggable="false"
>
<div v-if="selected" class="text-sm">
<div v-if="selected" class="text-[1rem]">
<div class="mb-3">
<div class="font-semibold">{{ selected.email }}</div>
<div class="text-color-secondary">
<div class="text-[var(--text-color-secondary)]">
Plano: {{ selected.plan_key }} Intervalo: {{ intervalLabel(selected.interval) }} Valor: {{ moneyBRL(selected.amount_cents) }}
</div>
<div class="text-color-secondary mt-1" v-if="selected.tenant_id">
<div class="text-[var(--text-color-secondary)] mt-1" v-if="selected.tenant_id">
tenant_id: {{ selected.tenant_id }}
</div>
</div>
@@ -901,21 +889,21 @@ onBeforeUnmount(() => {
:dismissableMask="!acting"
:draggable="false"
>
<div v-if="selectedSub" class="text-sm">
<div v-if="selectedSub" class="text-[1rem]">
<div class="mb-3">
<div class="flex items-center justify-between gap-3 flex-wrap">
<div>
<div class="font-semibold">
{{ selectedSub.plan_key || '—' }} {{ intervalLabel(selectedSub.interval) }}
</div>
<div class="text-color-secondary mt-1">
<div class="text-[var(--text-color-secondary)] mt-1">
Período: {{ fmtDate(selectedSub.current_period_start) }} {{ fmtDate(selectedSub.current_period_end) }}
</div>
<div class="text-color-secondary mt-1">
<div class="text-[var(--text-color-secondary)] mt-1">
owner(user_id): {{ selectedSub.user_id }}
<span v-if="selectedSub.tenant_id"> tenant_id: {{ selectedSub.tenant_id }}</span>
</div>
<div class="text-color-secondary mt-1">
<div class="text-[var(--text-color-secondary)] mt-1">
subscription_id: {{ selectedSub.id }}
</div>
</div>
@@ -975,68 +963,3 @@ onBeforeUnmount(() => {
</div>
</Dialog>
</template>
<style scoped>
.intents-hero-sentinel { height: 1px; }
.intents-hero {
position: sticky;
top: var(--layout-sticky-top, 56px);
z-index: 20;
overflow: hidden;
border-radius: 1.75rem;
border: 1px solid var(--surface-border);
background: var(--surface-card);
padding: 1.5rem;
margin: 1rem;
}
.intents-hero--stuck {
border-top-left-radius: 0;
border-top-right-radius: 0;
}
.intents-hero__blobs {
position: absolute; inset: 0; pointer-events: none; overflow: hidden;
}
.intents-hero__blob {
position: absolute; border-radius: 50%; filter: blur(70px);
}
.intents-hero__blob--1 { width: 20rem; height: 20rem; top: -5rem; right: -4rem; background: rgba(167,139,250,0.12); }
.intents-hero__blob--2 { width: 18rem; height: 18rem; top: 1rem; left: -5rem; background: rgba(52,211,153,0.09); }
.intents-hero__inner {
position: relative; z-index: 1;
display: flex; align-items: center; gap: 1.25rem; flex-wrap: wrap;
}
.intents-hero__icon-wrap {
flex-shrink: 0;
width: 4rem; height: 4rem; border-radius: 1.125rem;
border: 2px solid var(--surface-border);
background: var(--surface-ground);
display: grid; place-items: center;
}
.intents-hero__icon { font-size: 1.5rem; color: var(--text-color); }
.intents-hero__info { flex: 1; min-width: 0; }
.intents-hero__title {
font-size: 1.25rem; font-weight: 700; letter-spacing: -0.025em;
color: var(--text-color); line-height: 1.2;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.intents-hero__sub {
font-size: 0.78rem; color: var(--text-color-secondary); margin-top: 4px; line-height: 1.5;
}
.intents-hero__actions--desktop {
display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap;
}
.intents-hero__actions--mobile { display: none; }
@media (max-width: 1199px) {
.intents-hero__actions--desktop { display: none; }
.intents-hero__actions--mobile { display: flex; }
}
.intents-table :deep(.p-paginator) {
border-top: 1px solid var(--surface-border);
}
</style>

View File

@@ -7,11 +7,10 @@ import { useLayout } from '@/layout/composables/layout'
const { layoutConfig, isDarkTheme } = useLayout()
const tenantStore = useTenantStore()
// ─── período ─────────────────────────────────────────────────────────────────
// ── Período ───────────────────────────────────────────────
const PERIODS = [
{ label: 'Esta semana', value: 'week' },
{ label: 'Este mês', value: 'month' },
{ label: 'Esta semana', value: 'week' },
{ label: 'Este mês', value: 'month' },
{ label: 'Últimos 3 meses', value: '3months' },
{ label: 'Últimos 6 meses', value: '6months' },
]
@@ -21,44 +20,36 @@ const selectedPeriod = ref('month')
function periodRange (period) {
const now = new Date()
let start, end
if (period === 'week') {
const dow = now.getDay() // 0=Dom
start = new Date(now)
start.setDate(now.getDate() - dow)
start.setHours(0, 0, 0, 0)
end = new Date(now)
end.setHours(23, 59, 59, 999)
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)
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)
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)
end = new Date(now.getFullYear(), now.getMonth() + 1, 0, 23, 59, 59, 999)
}
return { start, end }
}
// ─── dados ───────────────────────────────────────────────────────────────────
const loading = ref(false)
const sessions = ref([])
// ── Dados ─────────────────────────────────────────────────
const loading = ref(false)
const sessions = ref([])
const loadError = ref('')
async function loadSessions () {
const uid = tenantStore.user?.id || null
const uid = tenantStore.user?.id || null
const tenantId = tenantStore.activeTenantId || null
if (!uid || !tenantId) return
const { start, end } = periodRange(selectedPeriod.value)
loading.value = true
loading.value = true
loadError.value = ''
sessions.value = []
sessions.value = []
try {
const { data, error } = await supabase
@@ -70,7 +61,6 @@ async function loadSessions () {
.lte('inicio_em', end.toISOString())
.order('inicio_em', { ascending: false })
.limit(500)
if (error) throw error
sessions.value = data || []
} catch (e) {
@@ -80,133 +70,108 @@ async function loadSessions () {
}
}
// ─── métricas ─────────────────────────────────────────────────────────────────
// ── 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 taxaRealizacao = computed(() => {
const denom = realizadas.value + faltas.value + canceladas.value
if (!denom) return null
return Math.round((realizadas.value / denom) * 100)
})
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)
// ── Filtro de status na tabela ────────────────────────────
const filtroTabela = ref(null) // null = todos
// ─── gráfico (sessions por semana/mês) ───────────────────────────────────────
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)
})
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)]' },
])
// ── Gráfico ───────────────────────────────────────────────
function isoWeek (d) {
const dt = new Date(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)
const yy = dt.getFullYear()
const mm = String(dt.getMonth() + 1).padStart(2, '0')
return `${yy}-${mm}`
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']
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 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++
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()
const labels = keys.map(labelFn)
const ds = getComputedStyle(document.documentElement)
return {
labels,
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: ds.getPropertyValue('--p-primary-300') || '#93c5fd',
data: keys.map(k => buckets[k].outros),
barThickness: 20,
},
{ 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 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 } }
},
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 }
}
x: { stacked: true, ticks: { color: textMutedColor }, grid: { color: 'transparent' } },
y: { stacked: true, ticks: { color: textMutedColor, precision: 0 }, grid: { color: borderColor, drawTicks: false } }
}
}
})
// ─── tabela ───────────────────────────────────────────────────────────────────
// ── 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) {
@@ -220,131 +185,252 @@ function fmtDateTimeBR (iso) {
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 || '—'
}
// taxa de realização
const taxaRealizacao = computed(() => {
const denom = realizadas.value + faltas.value + canceladas.value
if (!denom) return null
return Math.round((realizadas.value / denom) * 100)
})
// ─── watch & mount ────────────────────────────────────────────────────────────
watch(selectedPeriod, loadSessions)
// ── Watch & mount ─────────────────────────────────────────
watch(selectedPeriod, () => { filtroTabela.value = null; loadSessions() })
watch([() => layoutConfig.primary, () => layoutConfig.surface, isDarkTheme], () => {})
onMounted(loadSessions)
</script>
<template>
<div class="flex flex-col gap-6 p-4">
<!-- Cabeçalho -->
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<div>
<h1 class="text-2xl font-bold text-slate-800">Relatórios</h1>
<p class="text-sm text-slate-500 mt-1">Visão geral das suas sessões</p>
<!-- 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" />
</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>
<SelectButton
v-model="selectedPeriod"
:options="PERIODS"
option-label="label"
option-value="value"
:allow-empty="false"
class="shrink-0"
/>
<!-- 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>
<!-- 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">
<!-- Erro -->
<Message v-if="loadError" severity="error">{{ loadError }}</Message>
<!-- Loading -->
<div v-if="loading" class="flex items-center gap-2 text-slate-500">
<i class="pi pi-spin pi-spinner" /> Carregando
<!-- 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>
<template v-else>
<!-- Cards de resumo -->
<div class="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-6 gap-3">
<div class="rounded-2xl border border-slate-200 bg-white p-4 flex flex-col gap-1">
<span class="text-xs text-slate-500 uppercase tracking-wide">Total</span>
<span class="text-3xl font-bold text-slate-800">{{ total }}</span>
<!-- 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 class="rounded-2xl border border-green-100 bg-green-50 p-4 flex flex-col gap-1">
<span class="text-xs text-green-700 uppercase tracking-wide">Realizadas</span>
<span class="text-3xl font-bold text-green-700">{{ realizadas }}</span>
</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="rounded-2xl border border-red-100 bg-red-50 p-4 flex flex-col gap-1">
<span class="text-xs text-red-600 uppercase tracking-wide">Faltas</span>
<span class="text-3xl font-bold text-red-600">{{ faltas }}</span>
<div class="p-4">
<Chart type="bar" :data="chartData" :options="chartOptions" class="h-64" />
</div>
<div class="rounded-2xl border border-orange-100 bg-orange-50 p-4 flex flex-col gap-1">
<span class="text-xs text-orange-600 uppercase tracking-wide">Canceladas</span>
<span class="text-3xl font-bold text-orange-600">{{ canceladas }}</span>
</div>
<div class="rounded-2xl border border-blue-100 bg-blue-50 p-4 flex flex-col gap-1">
<span class="text-xs text-blue-600 uppercase tracking-wide">Agendadas</span>
<span class="text-3xl font-bold text-blue-600">{{ agendadas }}</span>
</div>
<div class="rounded-2xl border border-slate-200 bg-white p-4 flex flex-col gap-1">
<span class="text-xs text-slate-500 uppercase tracking-wide">Taxa realização</span>
<span class="text-3xl font-bold text-slate-800">
{{ taxaRealizacao != null ? `${taxaRealizacao}%` : '—' }}
</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>
</div>
<!-- Gráfico -->
<div v-if="total > 0" class="rounded-2xl border border-slate-200 bg-white p-4">
<h2 class="text-base font-semibold text-slate-700 mb-4">
Sessões por {{ selectedPeriod === 'week' ? 'semana' : 'mês' }}
</h2>
<Chart type="bar" :data="chartData" :options="chartOptions" class="h-64" />
</div>
<!-- Tabela -->
<div class="rounded-2xl border border-slate-200 bg-white overflow-hidden">
<div class="px-4 py-3 border-b border-slate-100 flex items-center justify-between">
<h2 class="text-base font-semibold text-slate-700">Sessões no período</h2>
<span class="text-sm text-slate-500">{{ total }} registro{{ total !== 1 ? 's' : '' }}</span>
<!-- 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>
<div v-if="!sessions.length" class="px-4 py-8 text-center text-slate-500 text-sm">
Nenhuma sessão encontrada para o período selecionado.
<!-- 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="sessions"
:value="sessionsFiltradas"
:rows="20"
paginator
:rows-per-page-options="[10, 20, 50]"
scrollable
scroll-height="480px"
class="text-sm"
class="rel-datatable"
>
<Column field="inicio_em" header="Data / Hora" :sortable="true" style="min-width: 140px">
<template #body="{ data }">{{ fmtDateTimeBR(data.inicio_em) }}</template>
<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
@@ -355,6 +441,13 @@ onMounted(loadSessions)
</Column>
</DataTable>
</div>
</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>

File diff suppressed because it is too large Load Diff