This commit is contained in:
Leonardo
2026-03-06 06:37:13 -03:00
parent d58dc21297
commit f733db8436
146 changed files with 43436 additions and 12779 deletions
+638 -96
View File
@@ -1,120 +1,295 @@
<script setup>
import { ref, computed, onMounted } from 'vue'
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { supabase } from '@/lib/supabase/client'
import { useRouter } from 'vue-router'
import { useToast } from 'primevue/usetoast'
import { useConfirm } from 'primevue/useconfirm'
import Card from 'primevue/card'
import Button from 'primevue/button'
import Tag from 'primevue/tag'
import Toast from 'primevue/toast'
import Chart from 'primevue/chart'
import DataTable from 'primevue/datatable'
import Column from 'primevue/column'
const router = useRouter()
const toast = useToast()
const confirm = useConfirm()
const loading = ref(false)
// KPIs
const totalActive = ref(0)
const totalCanceled = ref(0)
const totalMismatches = ref(0)
const plans = ref([])
const subs = ref([])
const plans = ref([]) // plans: id,key,target,is_active
const prices = ref([]) // v_plan_active_prices
const subs = ref([]) // subscriptions
// ------------------------------------
// Intenções de assinatura (subscription_intents)
// ------------------------------------
const intents = ref([])
const intentsLoading = ref(false)
const intentsLimit = ref(12) // quantas mostrar no card
const totalIntents = ref(0)
const totalIntentsNew = ref(0)
const totalIntentsPaid = ref(0)
// intervalo de exibição (MRR / ARPA)
const intervalView = ref('month') // 'month' | 'year'
const intervalOptions = [
{ label: 'Mensal (MRR)', value: 'month' },
{ label: 'Anual (ARR)', value: 'year' }
]
const lastUpdatedAt = ref(null)
// ------------------------------------
// Utils
// ------------------------------------
function moneyBRLFromCents (cents) {
const v = Number(cents || 0) / 100
return v.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' })
}
/**
* MRR = soma de price_cents do plano para subscriptions ativas (intervalo month).
* Se no futuro você tiver anual, dá pra normalizar (annual/12).
*/
const mrrCents = computed(() => {
const planById = new Map(plans.value.map(p => [p.id, p]))
let sum = 0
for (const s of subs.value) {
if (s.status !== 'active') continue
const p = planById.get(s.plan_id)
if (!p) continue
if ((p.billing_interval || 'month') !== 'month') continue
sum += Number(p.price_cents || 0)
function planTargetLabel (t) {
if (t === 'clinic') return 'Clínica'
if (t === 'therapist') return 'Terapeuta'
return '—'
}
function planTargetSeverity (t) {
if (t === 'clinic') return 'info'
if (t === 'therapist') return 'success'
return 'secondary'
}
function fmtDate (iso) {
if (!iso) return '—'
const d = new Date(iso)
if (Number.isNaN(d.getTime())) return String(iso)
return d.toLocaleString('pt-BR')
}
function intervalLabel (v) {
if (v === 'month') return 'Mensal'
if (v === 'year') return 'Anual'
return v || '—'
}
function statusSeverity (s) {
const st = String(s || '').toLowerCase()
if (st === 'new') return 'info'
if (st === 'paid') return 'success'
if (st === 'canceled') return 'danger'
if (st === 'expired') return 'warning'
return 'secondary'
}
function maskEmail (email) {
const e = String(email || '').trim()
if (!e.includes('@')) return e || '—'
const [u, d] = e.split('@')
if (!u) return `***@${d}`
const head = u.slice(0, 1)
return `${head}***@${d}`
}
// ------------------------------------
// Preços (view)
// ------------------------------------
const priceByPlanId = computed(() => {
const m = new Map()
for (const r of (prices.value || [])) m.set(r.plan_id, r)
return m
})
function priceCentsForPlan (planId, interval) {
const pr = priceByPlanId.value.get(planId)
if (!pr) return null
return interval === 'year' ? (pr.yearly_cents ?? null) : (pr.monthly_cents ?? null)
}
function normalizedRevenueCents (planId, viewInterval) {
const m = priceCentsForPlan(planId, 'month')
const y = priceCentsForPlan(planId, 'year')
if (viewInterval === 'month') {
if (m != null) return Number(m)
if (y != null) return Math.round(Number(y) / 12)
return 0
}
if (y != null) return Number(y)
if (m != null) return Math.round(Number(m) * 12)
return 0
}
// ------------------------------------
// KPIs
// ------------------------------------
const activeSubs = computed(() => (subs.value || []).filter(s => String(s.status) === 'active'))
const revenueCents = computed(() => {
let sum = 0
for (const s of activeSubs.value) sum += normalizedRevenueCents(s.plan_id, intervalView.value)
return sum
})
const arpaCents = computed(() => {
const act = subs.value.filter(s => s.status === 'active').length
return act ? Math.round(mrrCents.value / act) : 0
const act = activeSubs.value.length
return act ? Math.round(revenueCents.value / act) : 0
})
// Health UI
const healthSeverity = computed(() => {
if (loading.value) return 'secondary'
return totalMismatches.value > 0 ? 'danger' : 'success'
})
const healthLabel = computed(() => {
if (loading.value) return 'carregando'
return totalMismatches.value > 0 ? 'atenção' : 'ok'
})
const healthHint = computed(() => {
if (totalMismatches.value <= 0) return 'Tudo consistente: plano esperado = entitlements atuais.'
return 'Há divergências entre o plano esperado e os entitlements atuais.'
})
// ------------------------------------
// Breakdown por plano
// ------------------------------------
const breakdown = computed(() => {
const planById = new Map(plans.value.map(p => [p.id, p]))
const planById = new Map((plans.value || []).map(p => [p.id, p]))
const agg = new Map()
for (const s of subs.value) {
for (const s of (subs.value || [])) {
const p = planById.get(s.plan_id)
const key = p?.key || '(sem plano)'
if (!agg.has(key)) {
agg.set(key, {
plan_id: s.plan_id,
plan_key: key,
plan_target: p?.target || null,
plan_active: p?.is_active !== false,
active_count: 0,
canceled_count: 0,
price_cents: Number(p?.price_cents || 0),
mrr_cents: 0
price_cents: normalizedRevenueCents(s.plan_id, intervalView.value),
revenue_cents: 0
})
}
const row = agg.get(key)
if (s.status === 'active') {
if (String(s.status) === 'active') {
row.active_count += 1
// só soma MRR se for mensal
if ((p?.billing_interval || 'month') === 'month') row.mrr_cents += Number(p?.price_cents || 0)
} else if (s.status === 'canceled') {
row.revenue_cents += normalizedRevenueCents(s.plan_id, intervalView.value)
} else if (String(s.status) === 'canceled') {
row.canceled_count += 1
}
}
return Array.from(agg.values()).sort((a, b) => (b.mrr_cents - a.mrr_cents))
const out = Array.from(agg.values())
out.sort((a, b) => (b.revenue_cents - a.revenue_cents))
return out
})
// Chart
const chartData = computed(() => {
const labels = breakdown.value.map(r => r.plan_key)
const data = breakdown.value.map(r => Math.round((r.mrr_cents || 0) / 100))
const data = breakdown.value.map(r => Math.round((r.revenue_cents || 0) / 100))
return {
labels,
datasets: [{ label: 'MRR por plano (R$)', data }]
datasets: [
{
label: intervalView.value === 'year' ? 'ARR por plano (R$)' : 'MRR por plano (R$)',
data
}
]
}
})
const chartOptions = computed(() => ({
maintainAspectRatio: false,
plugins: {
legend: { display: true }
legend: { display: true },
tooltip: { mode: 'index', intersect: false }
},
scales: {
y: { beginAtZero: true }
}
scales: { y: { beginAtZero: true } }
}))
// ------------------------------------
// Navegação
// ------------------------------------
function openPlanPublic (planKey) {
if (!planKey || planKey === '(sem plano)') return
router.push({ path: '/saas/plans-public', query: { q: planKey } })
}
function openPlanCatalog (planKey) {
if (!planKey || planKey === '(sem plano)') return
router.push({ path: '/saas/plans', query: { q: planKey } })
}
function openIntentEvents () {
router.push('/saas/subscription-events')
}
// ------------------------------------
// Fetch
// ------------------------------------
async function loadIntents () {
intentsLoading.value = true
try {
const { data, error } = await supabase
.from('subscription_intents')
.select('created_at,email,plan_key,interval,status,tenant_id')
.order('created_at', { ascending: false })
.limit(intentsLimit.value)
if (error) throw error
intents.value = data || []
const [{ count: cAll, error: eAll }, { count: cNew, error: eNew }, { count: cPaid, error: ePaid }] = await Promise.all([
supabase.from('subscription_intents').select('id', { count: 'exact', head: true }),
supabase.from('subscription_intents').select('id', { count: 'exact', head: true }).eq('status', 'new'),
supabase.from('subscription_intents').select('id', { count: 'exact', head: true }).eq('status', 'paid')
])
if (eAll) throw eAll
if (eNew) throw eNew
if (ePaid) throw ePaid
totalIntents.value = Number(cAll || 0)
totalIntentsNew.value = Number(cNew || 0)
totalIntentsPaid.value = Number(cPaid || 0)
} catch (e) {
console.warn('[SAAS] loadIntents failed:', e)
intents.value = []
totalIntents.value = 0
totalIntentsNew.value = 0
totalIntentsPaid.value = 0
} finally {
intentsLoading.value = false
}
}
async function loadStats () {
loading.value = true
try {
const [{ data: p, error: ep }, { data: s, error: es }] = await Promise.all([
supabase.from('plans').select('id,key,price_cents,currency,billing_interval').order('key', { ascending: true }),
supabase.from('subscriptions').select('id,tenant_id,plan_id,status,updated_at').order('updated_at', { ascending: false })
const [{ data: p, error: ep }, { data: ap, error: eap }, { data: s, error: es }] = await Promise.all([
supabase.from('plans').select('id,key,target,is_active').order('key', { ascending: true }),
supabase.from('v_plan_active_prices').select('*'),
supabase.from('subscriptions').select('id,tenant_id,user_id,plan_id,status,updated_at').order('updated_at', { ascending: false })
])
if (ep) throw ep
if (eap) throw eap
if (es) throw es
plans.value = p || []
prices.value = ap || []
subs.value = s || []
totalActive.value = subs.value.filter(x => x.status === 'active').length
totalCanceled.value = subs.value.filter(x => x.status === 'canceled').length
totalActive.value = (subs.value || []).filter(x => x.status === 'active').length
totalCanceled.value = (subs.value || []).filter(x => x.status === 'canceled').length
const { data: mismatches, error: em } = await supabase
.from('v_subscription_feature_mismatch')
@@ -122,13 +297,35 @@ async function loadStats () {
if (em) throw em
totalMismatches.value = (mismatches || []).length
await loadIntents()
lastUpdatedAt.value = new Date().toISOString()
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e.message || String(e), life: 5000 })
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || String(e), life: 5200 })
} finally {
loading.value = false
}
}
// ------------------------------------
// Fix all (com confirmação)
// ------------------------------------
function askFixAll () {
if (loading.value) return
if (totalMismatches.value <= 0) return
confirm.require({
header: 'Confirmar correção geral',
message: `Reconstruir entitlements para todos os owners com divergência?\nTotal atual: ${totalMismatches.value}`,
icon: 'pi pi-exclamation-triangle',
acceptLabel: 'Corrigir agora',
rejectLabel: 'Voltar',
acceptClass: 'p-button-danger',
accept: () => fixAll()
})
}
async function fixAll () {
loading.value = true
try {
@@ -139,119 +336,368 @@ async function fixAll () {
severity: 'success',
summary: 'Sistema corrigido',
detail: 'Entitlements reconstruídos com sucesso.',
life: 3000
life: 3200
})
await loadStats()
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e.message || String(e), life: 5000 })
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || String(e), life: 5200 })
} finally {
loading.value = false
}
}
onMounted(loadStats)
// ------------------------------------
// Hero sticky
// ------------------------------------
const heroRef = ref(null)
const sentinelRef = ref(null)
const heroStuck = ref(false)
let heroObserver = null
const mobileMenuRef = ref(null)
const heroMenuItems = computed(() => [
{
label: 'Recarregar',
icon: 'pi pi-refresh',
command: loadStats,
disabled: loading.value
},
{
label: 'Assinaturas',
icon: 'pi pi-credit-card',
command: () => router.push('/saas/subscriptions'),
disabled: loading.value
},
{
label: 'Eventos',
icon: 'pi pi-history',
command: () => router.push('/saas/subscription-events'),
disabled: loading.value
},
{ separator: true },
{
label: 'Intervalo',
items: intervalOptions.map(o => ({
label: o.label,
icon: intervalView.value === o.value ? 'pi pi-check' : 'pi pi-circle',
command: () => { intervalView.value = o.value }
}))
}
])
onMounted(() => {
loadStats()
if (sentinelRef.value) {
heroObserver = new IntersectionObserver(
([entry]) => { heroStuck.value = !entry.isIntersecting },
{ rootMargin: `${document.querySelector('.l2-main') ? '0px' : '-56px'} 0px 0px 0px`, threshold: 0 }
)
heroObserver.observe(sentinelRef.value)
}
})
onBeforeUnmount(() => {
heroObserver?.disconnect()
})
</script>
<template>
<Toast />
<ConfirmDialog />
<div class="p-4">
<div class="flex items-center justify-content-between mb-4">
<div>
<div class="text-2xl font-semibold">SaaS Control Center</div>
<small class="text-color-secondary">Visão estratégica + saúde de consistência.</small>
</div>
<!-- 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" style="height: 1px; pointer-events: none;" />
<!-- hero -->
<div
ref="heroRef"
class="dash-hero"
:class="{ 'dash-hero--stuck': heroStuck }"
>
<div class="dash-hero__blob dash-hero__blob--1" />
<div class="dash-hero__blob dash-hero__blob--2" />
<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>
<div class="flex gap-2">
<Button label="Recarregar" icon="pi pi-refresh" severity="secondary" outlined :loading="loading" @click="loadStats" />
<Button label="Subscriptions" icon="pi pi-credit-card" severity="secondary" outlined @click="router.push('/saas/subscriptions')" />
<Button label="Histórico" icon="pi pi-history" severity="secondary" outlined @click="router.push('/saas/subscription-events')" />
</div>
</div>
</div>
<!-- content -->
<div class="px-4 pb-4">
<!-- KPIs -->
<div class="grid grid-cols-12 gap-4">
<div class="col-span-12 md:col-span-3">
<Card>
<template #title>Ativas</template>
<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-bold text-green-500">{{ totalActive }}</div>
<div class="text-4xl font-semibold">{{ totalActive }}</div>
<small class="text-color-secondary">assinaturas em status <b>active</b></small>
</template>
</Card>
</div>
<div class="col-span-12 md:col-span-3">
<Card>
<template #title>Canceladas</template>
<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-bold text-red-400">{{ totalCanceled }}</div>
<div class="text-4xl font-semibold">{{ totalCanceled }}</div>
<small class="text-color-secondary">assinaturas em status <b>canceled</b></small>
</template>
</Card>
</div>
<div class="col-span-12 md:col-span-3">
<Card>
<template #title>MRR</template>
<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-bold">{{ moneyBRLFromCents(mrrCents) }}</div>
<small class="text-color-secondary">somente planos mensais</small>
<div class="text-3xl font-semibold">{{ moneyBRLFromCents(revenueCents) }}</div>
<small class="text-color-secondary">normalizado (mensal anual)</small>
</template>
</Card>
</div>
<div class="col-span-12 md:col-span-3">
<Card>
<template #title>ARPA</template>
<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-bold">{{ moneyBRLFromCents(arpaCents) }}</div>
<small class="text-color-secondary">receita média por ativa</small>
<div class="text-3xl font-semibold">{{ moneyBRLFromCents(arpaCents) }}</div>
<small class="text-color-secondary">média por assinatura ativa</small>
</template>
</Card>
</div>
</div>
<!-- Health + Actions -->
<!-- Intenções + Health + Chart -->
<div class="grid grid-cols-12 gap-4 mt-4">
<!-- Intenções -->
<div class="col-span-12 md:col-span-4">
<Card>
<template #title>System Health</template>
<template #content>
<div class="flex items-center justify-content-between">
<div class="text-4xl font-bold" :class="totalMismatches > 0 ? 'text-red-500' : 'text-green-500'">
{{ totalMismatches }}
</div>
<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>
<Tag
:severity="totalMismatches > 0 ? 'danger' : 'success'"
:value="totalMismatches > 0 ? 'Inconsistências' : 'Saudável'"
/>
<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>
<div class="mt-3 flex gap-2 flex-wrap">
<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>
<div class="shrink-0">
<Tag :value="it.status || '—'" :severity="statusSeverity(it.status)" rounded />
</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>
</div>
</template>
</Card>
</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>
<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>
<div class="text-color-secondary text-sm mt-2">
{{ healthHint }}
</div>
<Divider class="my-3" />
<div class="flex gap-2 flex-wrap">
<Button
v-if="totalMismatches > 0"
label="Fix All"
label="Corrigir tudo"
icon="pi pi-refresh"
severity="danger"
:loading="loading"
@click="fixAll"
@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>
<div class="col-span-12 md:col-span-8">
<Card>
<template #title>MRR por plano</template>
<!-- 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" />
@@ -261,28 +707,124 @@ onMounted(loadStats)
</div>
</div>
<!-- Breakdown table -->
<!-- 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">
<Column field="plan_key" header="Plano" style="min-width: 12rem" />
<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>
<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="Canceladas" style="width: 10rem">
<template #body="{ data }">{{ data.canceled_count }}</template>
</Column>
<Column header="Preço" style="min-width: 12rem">
<Column header="Preço (ref.)" style="min-width: 12rem">
<template #body="{ data }">{{ moneyBRLFromCents(data.price_cents) }}</template>
</Column>
<Column header="MRR" style="min-width: 12rem">
<template #body="{ data }">{{ moneyBRLFromCents(data.mrr_cents) }}</template>
<Column :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>
<div class="text-color-secondary text-sm mt-3">
Nota: "Preço (ref.)" e "MRR/ARR" são normalizados usando o preço ativo.
Se existir anual, MRR = anual/12; se existir mensal, ARR = mensal*12.
</div>
</template>
</Card>
</div>
</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>