665 lines
27 KiB
Vue
665 lines
27 KiB
Vue
<!--
|
|
|--------------------------------------------------------------------------
|
|
| Agência PSI
|
|
|--------------------------------------------------------------------------
|
|
| Criado e desenvolvido por Leonardo Nohama
|
|
|
|
|
| Tecnologia aplicada à escuta.
|
|
| Estrutura para o cuidado.
|
|
|
|
|
| Arquivo: src/views/pages/saas/SaasDashboard.vue
|
|
| Data: 2026
|
|
| Local: São Carlos/SP — Brasil
|
|
|--------------------------------------------------------------------------
|
|
| © 2026 — Todos os direitos reservados
|
|
|--------------------------------------------------------------------------
|
|
-->
|
|
<script setup>
|
|
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 Chart from 'primevue/chart';
|
|
|
|
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([]); // 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' });
|
|
}
|
|
|
|
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 = 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 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_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: normalizedRevenueCents(s.plan_id, intervalView.value),
|
|
revenue_cents: 0
|
|
});
|
|
}
|
|
|
|
const row = agg.get(key);
|
|
|
|
if (String(s.status) === 'active') {
|
|
row.active_count += 1;
|
|
row.revenue_cents += normalizedRevenueCents(s.plan_id, intervalView.value);
|
|
} else if (String(s.status) === 'canceled') {
|
|
row.canceled_count += 1;
|
|
}
|
|
}
|
|
|
|
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.revenue_cents || 0) / 100));
|
|
return {
|
|
labels,
|
|
datasets: [
|
|
{
|
|
label: intervalView.value === 'year' ? 'ARR por plano (R$)' : 'MRR por plano (R$)',
|
|
data
|
|
}
|
|
]
|
|
};
|
|
});
|
|
|
|
const chartOptions = computed(() => ({
|
|
maintainAspectRatio: false,
|
|
plugins: {
|
|
legend: { display: true },
|
|
tooltip: { mode: 'index', intersect: false }
|
|
},
|
|
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: 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;
|
|
|
|
const { data: mismatches, error: em } = await supabase.from('v_subscription_feature_mismatch').select('*');
|
|
|
|
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: 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 {
|
|
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: 3200
|
|
});
|
|
|
|
await loadStats();
|
|
} catch (e) {
|
|
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || String(e), life: 5200 });
|
|
} finally {
|
|
loading.value = false;
|
|
}
|
|
}
|
|
|
|
// ------------------------------------
|
|
// 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>
|
|
<ConfirmDialog />
|
|
|
|
<!-- Sentinel -->
|
|
<div ref="sentinelRef" class="h-px" />
|
|
|
|
<!-- Hero sticky -->
|
|
<div ref="heroRef" 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)]">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>
|
|
|
|
<!-- 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-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">
|
|
<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">
|
|
<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">
|
|
<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">
|
|
<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">
|
|
<!-- Intenções -->
|
|
<div class="col-span-12 md:col-span-4">
|
|
<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>
|
|
|
|
<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>
|
|
|
|
<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="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 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-[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">
|
|
<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>
|
|
|
|
<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-[1rem] text-[var(--text-color-secondary)] mt-2">
|
|
{{ healthHint }}
|
|
</div>
|
|
|
|
<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="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">
|
|
<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="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="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 (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="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-[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>
|