ZERADO
This commit is contained in:
@@ -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 só existir anual, MRR = anual/12; se só 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>
|
||||
|
||||
Reference in New Issue
Block a user