first commit
This commit is contained in:
@@ -0,0 +1,288 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { supabase } from '@/lib/supabase/client'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
|
||||
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 loading = ref(false)
|
||||
|
||||
const totalActive = ref(0)
|
||||
const totalCanceled = ref(0)
|
||||
const totalMismatches = ref(0)
|
||||
|
||||
const plans = ref([])
|
||||
const subs = ref([])
|
||||
|
||||
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)
|
||||
}
|
||||
return sum
|
||||
})
|
||||
|
||||
const arpaCents = computed(() => {
|
||||
const act = subs.value.filter(s => s.status === 'active').length
|
||||
return act ? Math.round(mrrCents.value / act) : 0
|
||||
})
|
||||
|
||||
const breakdown = computed(() => {
|
||||
const planById = new Map(plans.value.map(p => [p.id, p]))
|
||||
const agg = new Map()
|
||||
|
||||
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_key: key,
|
||||
active_count: 0,
|
||||
canceled_count: 0,
|
||||
price_cents: Number(p?.price_cents || 0),
|
||||
mrr_cents: 0
|
||||
})
|
||||
}
|
||||
const row = agg.get(key)
|
||||
if (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.canceled_count += 1
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(agg.values()).sort((a, b) => (b.mrr_cents - a.mrr_cents))
|
||||
})
|
||||
|
||||
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))
|
||||
return {
|
||||
labels,
|
||||
datasets: [{ label: 'MRR por plano (R$)', data }]
|
||||
}
|
||||
})
|
||||
|
||||
const chartOptions = computed(() => ({
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: { display: true }
|
||||
},
|
||||
scales: {
|
||||
y: { beginAtZero: true }
|
||||
}
|
||||
}))
|
||||
|
||||
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 })
|
||||
])
|
||||
|
||||
if (ep) throw ep
|
||||
if (es) throw es
|
||||
|
||||
plans.value = p || []
|
||||
subs.value = s || []
|
||||
|
||||
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')
|
||||
.select('*')
|
||||
|
||||
if (em) throw em
|
||||
totalMismatches.value = (mismatches || []).length
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e.message || String(e), life: 5000 })
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function fixAll () {
|
||||
loading.value = true
|
||||
try {
|
||||
const { error } = await supabase.rpc('fix_all_subscription_mismatches')
|
||||
if (error) throw error
|
||||
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: 'Sistema corrigido',
|
||||
detail: 'Entitlements reconstruídos com sucesso.',
|
||||
life: 3000
|
||||
})
|
||||
|
||||
await loadStats()
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e.message || String(e), life: 5000 })
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadStats)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Toast />
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
<!-- KPIs -->
|
||||
<div class="grid grid-cols-12 gap-4">
|
||||
<div class="col-span-12 md:col-span-3">
|
||||
<Card>
|
||||
<template #title>Ativas</template>
|
||||
<template #content>
|
||||
<div class="text-4xl font-bold text-green-500">{{ totalActive }}</div>
|
||||
</template>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div class="col-span-12 md:col-span-3">
|
||||
<Card>
|
||||
<template #title>Canceladas</template>
|
||||
<template #content>
|
||||
<div class="text-4xl font-bold text-red-400">{{ totalCanceled }}</div>
|
||||
</template>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div class="col-span-12 md:col-span-3">
|
||||
<Card>
|
||||
<template #title>MRR</template>
|
||||
<template #content>
|
||||
<div class="text-3xl font-bold">{{ moneyBRLFromCents(mrrCents) }}</div>
|
||||
<small class="text-color-secondary">somente planos mensais</small>
|
||||
</template>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div class="col-span-12 md:col-span-3">
|
||||
<Card>
|
||||
<template #title>ARPA</template>
|
||||
<template #content>
|
||||
<div class="text-3xl font-bold">{{ moneyBRLFromCents(arpaCents) }}</div>
|
||||
<small class="text-color-secondary">receita média por ativa</small>
|
||||
</template>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Health + Actions -->
|
||||
<div class="grid grid-cols-12 gap-4 mt-4">
|
||||
<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>
|
||||
|
||||
<Tag
|
||||
:severity="totalMismatches > 0 ? 'danger' : 'success'"
|
||||
:value="totalMismatches > 0 ? 'Inconsistências' : 'Saudável'"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 flex gap-2 flex-wrap">
|
||||
<Button
|
||||
v-if="totalMismatches > 0"
|
||||
label="Fix All"
|
||||
icon="pi pi-refresh"
|
||||
severity="danger"
|
||||
:loading="loading"
|
||||
@click="fixAll"
|
||||
/>
|
||||
<Button
|
||||
label="Ver divergências"
|
||||
icon="pi pi-search"
|
||||
severity="secondary"
|
||||
outlined
|
||||
@click="router.push('/saas/subscription-health')"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div class="col-span-12 md:col-span-8">
|
||||
<Card>
|
||||
<template #title>MRR por plano</template>
|
||||
<template #content>
|
||||
<div style="height: 260px;">
|
||||
<Chart type="bar" :data="chartData" :options="chartOptions" />
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Breakdown table -->
|
||||
<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" />
|
||||
<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">
|
||||
<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>
|
||||
</DataTable>
|
||||
</template>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user