Files
agenciapsilmno/src/views/pages/saas/SaasDashboard.vue
T

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 existir anual, MRR = anual/12; se existir mensal, ARR = mensal*12.</div>
</div>
</div>
</template>