first commit

This commit is contained in:
Leonardo
2026-02-18 22:36:45 -03:00
parent ec6b6ef53a
commit 676042268b
122 changed files with 26354 additions and 1615 deletions
+288
View File
@@ -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>