Layout 100%, Notificações, SetupWizard
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 há 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>
|
||||
|
||||
@@ -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 há 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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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 cá.</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 cá.</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 é só o toggle.
|
||||
Depois a gente cria:
|
||||
<ul class="mt-2 list-disc pl-5 space-y-1">
|
||||
<li>role <span class="font-mono">secretary</span> em <span class="font-mono">tenant_members</span></li>
|
||||
<li>policies e telas para a secretária</li>
|
||||
<li>nível de visibilidade do paciente na agenda</li>
|
||||
</ul>
|
||||
</div>
|
||||
</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 é só o toggle.
|
||||
Depois a gente cria:
|
||||
<ul class=”mt-2 list-disc pl-5 space-y-1”>
|
||||
<li>role <span class=”font-mono”>secretary</span> em <span class=”font-mono”>tenant_members</span></li>
|
||||
<li>policies e telas para a secretária</li>
|
||||
<li>nível de visibilidade do paciente na agenda</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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ê já 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ê já tem páginas de link externo. Isso vira o controle fino: a clínica decide se usa ou não.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* (sem estilos adicionais por enquanto) */
|
||||
</style>
|
||||
@@ -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 já 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=<uuid></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=<uuid></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 já 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 só 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 só 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>
|
||||
@@ -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 só existir anual, MRR = anual/12; se só 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 só existir anual, MRR = anual/12; se só 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
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
564
src/views/pages/saas/SaasLoginCarousel.vue
Normal file
564
src/views/pages/saas/SaasLoginCarousel.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 já 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: só 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 já 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: só 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 & 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>
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user