Dashboard SaaS ganha seção de receita de créditos WhatsApp (Asaas)
Fecha o gap de analytics que faltava: MRR/ARR de assinatura já existia, mas não havia visão de receita dos créditos WhatsApp comprados via Asaas. Banco (migration 20260423000011) — 4 RPCs saas_admin only: - saas_wa_credits_revenue_stats(from, to): total arrecadado, count de compras, tenants únicos, créditos vendidos, ticket médio. - saas_wa_credits_top_packages(from, to): ranking top 10 pacotes por revenue, consolida nome atual se pacote foi renomeado. - saas_wa_credits_usage_summary(): snapshot atual de lifetime_purchased vs lifetime_used vs current_balance + taxa de consumo. - saas_wa_credits_revenue_evolution(from, to, bucket_days): série temporal pra sparkline. Todas com check is_saas_admin() no início + SECURITY DEFINER. Frontend: - useSaasCreditsAnalytics composable orquestra as 4 RPCs em paralelo com seleção de período (30d/90d/6m/12m) que ajusta bucket_days automaticamente. - SaasCreditsRevenueCard.vue: 4 KPIs (receita + ticket médio, compras + tenants, créditos vendidos, % consumo global), sparkline SVG com indicador de tendência, ranking top 5 pacotes. - Integrado no SaasDashboard logo antes da tabela "Distribuição por plano". Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,209 @@
|
|||||||
|
-- ==========================================================================
|
||||||
|
-- Agencia PSI — Migracao: Analytics SaaS de receita WhatsApp (Grupo 5)
|
||||||
|
-- ==========================================================================
|
||||||
|
-- 4 funcoes RPC pra popular cards novos no SaasDashboard:
|
||||||
|
-- - saas_wa_credits_revenue_stats(from, to) → agregados globais
|
||||||
|
-- - saas_wa_credits_top_packages(from, to) → ranking de pacotes
|
||||||
|
-- - saas_wa_credits_usage_summary(from, to) → vendidos vs usados
|
||||||
|
-- - saas_wa_credits_revenue_evolution(from,to,bucket) → serie temporal
|
||||||
|
--
|
||||||
|
-- Fonte: whatsapp_credit_purchases (paid) + whatsapp_credits_balance/
|
||||||
|
-- whatsapp_credits_transactions.
|
||||||
|
--
|
||||||
|
-- Apenas saas_admin pode chamar (RLS da policy + SECURITY DEFINER check).
|
||||||
|
-- ==========================================================================
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- saas_wa_credits_revenue_stats: totais do periodo
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
CREATE OR REPLACE FUNCTION public.saas_wa_credits_revenue_stats(
|
||||||
|
p_from TIMESTAMPTZ DEFAULT (now() - interval '30 days'),
|
||||||
|
p_to TIMESTAMPTZ DEFAULT now()
|
||||||
|
)
|
||||||
|
RETURNS TABLE (
|
||||||
|
revenue_brl NUMERIC,
|
||||||
|
purchases_count INT,
|
||||||
|
tenants_count INT,
|
||||||
|
credits_sold INT,
|
||||||
|
avg_ticket_brl NUMERIC
|
||||||
|
)
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
STABLE
|
||||||
|
SECURITY DEFINER
|
||||||
|
SET search_path = public
|
||||||
|
AS $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT public.is_saas_admin() THEN
|
||||||
|
RAISE EXCEPTION 'permission_denied';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
RETURN QUERY
|
||||||
|
SELECT
|
||||||
|
COALESCE(SUM(p.amount_brl), 0)::NUMERIC AS revenue_brl,
|
||||||
|
COUNT(*)::INT AS purchases_count,
|
||||||
|
COUNT(DISTINCT p.tenant_id)::INT AS tenants_count,
|
||||||
|
COALESCE(SUM(p.credits), 0)::INT AS credits_sold,
|
||||||
|
CASE WHEN COUNT(*) = 0 THEN 0
|
||||||
|
ELSE ROUND(COALESCE(AVG(p.amount_brl), 0), 2)
|
||||||
|
END AS avg_ticket_brl
|
||||||
|
FROM public.whatsapp_credit_purchases p
|
||||||
|
WHERE p.status = 'paid'
|
||||||
|
AND p.paid_at >= p_from
|
||||||
|
AND p.paid_at <= p_to;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
REVOKE ALL ON FUNCTION public.saas_wa_credits_revenue_stats(TIMESTAMPTZ, TIMESTAMPTZ) FROM PUBLIC;
|
||||||
|
GRANT EXECUTE ON FUNCTION public.saas_wa_credits_revenue_stats(TIMESTAMPTZ, TIMESTAMPTZ) TO authenticated, service_role;
|
||||||
|
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- saas_wa_credits_top_packages: ranking dos pacotes mais vendidos
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
CREATE OR REPLACE FUNCTION public.saas_wa_credits_top_packages(
|
||||||
|
p_from TIMESTAMPTZ DEFAULT (now() - interval '30 days'),
|
||||||
|
p_to TIMESTAMPTZ DEFAULT now()
|
||||||
|
)
|
||||||
|
RETURNS TABLE (
|
||||||
|
package_id UUID,
|
||||||
|
package_name TEXT,
|
||||||
|
purchases_count INT,
|
||||||
|
revenue_brl NUMERIC,
|
||||||
|
credits_sold INT
|
||||||
|
)
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
STABLE
|
||||||
|
SECURITY DEFINER
|
||||||
|
SET search_path = public
|
||||||
|
AS $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT public.is_saas_admin() THEN
|
||||||
|
RAISE EXCEPTION 'permission_denied';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
RETURN QUERY
|
||||||
|
SELECT
|
||||||
|
p.package_id,
|
||||||
|
-- Nome snapshot do momento da compra; se tem package_id, usa o nome
|
||||||
|
-- atual pra consolidar pacotes renomeados
|
||||||
|
COALESCE(
|
||||||
|
(SELECT pk.name FROM public.whatsapp_credit_packages pk WHERE pk.id = p.package_id),
|
||||||
|
p.package_name
|
||||||
|
) AS package_name,
|
||||||
|
COUNT(*)::INT AS purchases_count,
|
||||||
|
SUM(p.amount_brl)::NUMERIC AS revenue_brl,
|
||||||
|
SUM(p.credits)::INT AS credits_sold
|
||||||
|
FROM public.whatsapp_credit_purchases p
|
||||||
|
WHERE p.status = 'paid'
|
||||||
|
AND p.paid_at >= p_from
|
||||||
|
AND p.paid_at <= p_to
|
||||||
|
GROUP BY p.package_id, p.package_name
|
||||||
|
ORDER BY revenue_brl DESC
|
||||||
|
LIMIT 10;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
REVOKE ALL ON FUNCTION public.saas_wa_credits_top_packages(TIMESTAMPTZ, TIMESTAMPTZ) FROM PUBLIC;
|
||||||
|
GRANT EXECUTE ON FUNCTION public.saas_wa_credits_top_packages(TIMESTAMPTZ, TIMESTAMPTZ) TO authenticated, service_role;
|
||||||
|
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- saas_wa_credits_usage_summary: vendidos vs usados (snapshot atual)
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
CREATE OR REPLACE FUNCTION public.saas_wa_credits_usage_summary()
|
||||||
|
RETURNS TABLE (
|
||||||
|
lifetime_purchased INT,
|
||||||
|
lifetime_used INT,
|
||||||
|
current_balance INT,
|
||||||
|
usage_rate NUMERIC,
|
||||||
|
tenants_with_balance INT
|
||||||
|
)
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
STABLE
|
||||||
|
SECURITY DEFINER
|
||||||
|
SET search_path = public
|
||||||
|
AS $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT public.is_saas_admin() THEN
|
||||||
|
RAISE EXCEPTION 'permission_denied';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
RETURN QUERY
|
||||||
|
SELECT
|
||||||
|
COALESCE(SUM(lifetime_purchased), 0)::INT AS lifetime_purchased,
|
||||||
|
COALESCE(SUM(lifetime_used), 0)::INT AS lifetime_used,
|
||||||
|
COALESCE(SUM(balance), 0)::INT AS current_balance,
|
||||||
|
CASE WHEN COALESCE(SUM(lifetime_purchased), 0) = 0 THEN 0
|
||||||
|
ELSE ROUND(100.0 * COALESCE(SUM(lifetime_used), 0) / SUM(lifetime_purchased), 1)
|
||||||
|
END AS usage_rate,
|
||||||
|
COUNT(*)::INT AS tenants_with_balance
|
||||||
|
FROM public.whatsapp_credits_balance;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
REVOKE ALL ON FUNCTION public.saas_wa_credits_usage_summary() FROM PUBLIC;
|
||||||
|
GRANT EXECUTE ON FUNCTION public.saas_wa_credits_usage_summary() TO authenticated, service_role;
|
||||||
|
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- saas_wa_credits_revenue_evolution: serie temporal
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
CREATE OR REPLACE FUNCTION public.saas_wa_credits_revenue_evolution(
|
||||||
|
p_from TIMESTAMPTZ DEFAULT (now() - interval '30 days'),
|
||||||
|
p_to TIMESTAMPTZ DEFAULT now(),
|
||||||
|
p_bucket_days INT DEFAULT 7
|
||||||
|
)
|
||||||
|
RETURNS TABLE (
|
||||||
|
bucket_start TIMESTAMPTZ,
|
||||||
|
purchases_count INT,
|
||||||
|
revenue_brl NUMERIC
|
||||||
|
)
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
STABLE
|
||||||
|
SECURITY DEFINER
|
||||||
|
SET search_path = public
|
||||||
|
AS $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT public.is_saas_admin() THEN
|
||||||
|
RAISE EXCEPTION 'permission_denied';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
RETURN QUERY
|
||||||
|
WITH purchases AS (
|
||||||
|
SELECT p.paid_at, p.amount_brl
|
||||||
|
FROM public.whatsapp_credit_purchases p
|
||||||
|
WHERE p.status = 'paid'
|
||||||
|
AND p.paid_at >= p_from
|
||||||
|
AND p.paid_at <= p_to
|
||||||
|
),
|
||||||
|
bucketed AS (
|
||||||
|
SELECT
|
||||||
|
p_from + (
|
||||||
|
FLOOR(EXTRACT(EPOCH FROM (paid_at - p_from)) / (p_bucket_days * 86400))::INT
|
||||||
|
* p_bucket_days * interval '1 day'
|
||||||
|
) AS bucket_start,
|
||||||
|
amount_brl
|
||||||
|
FROM purchases
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
bucket_start,
|
||||||
|
COUNT(*)::INT AS purchases_count,
|
||||||
|
SUM(amount_brl)::NUMERIC AS revenue_brl
|
||||||
|
FROM bucketed
|
||||||
|
GROUP BY bucket_start
|
||||||
|
ORDER BY bucket_start;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
REVOKE ALL ON FUNCTION public.saas_wa_credits_revenue_evolution(TIMESTAMPTZ, TIMESTAMPTZ, INT) FROM PUBLIC;
|
||||||
|
GRANT EXECUTE ON FUNCTION public.saas_wa_credits_revenue_evolution(TIMESTAMPTZ, TIMESTAMPTZ, INT) TO authenticated, service_role;
|
||||||
|
|
||||||
|
|
||||||
|
COMMENT ON FUNCTION public.saas_wa_credits_revenue_stats(TIMESTAMPTZ, TIMESTAMPTZ) IS
|
||||||
|
'Totais de receita WhatsApp creditos no periodo (saas_admin).';
|
||||||
|
COMMENT ON FUNCTION public.saas_wa_credits_top_packages(TIMESTAMPTZ, TIMESTAMPTZ) IS
|
||||||
|
'Ranking pacotes mais vendidos no periodo (saas_admin).';
|
||||||
|
COMMENT ON FUNCTION public.saas_wa_credits_usage_summary() IS
|
||||||
|
'Snapshot atual: vendidos lifetime vs usados vs saldo (saas_admin).';
|
||||||
|
COMMENT ON FUNCTION public.saas_wa_credits_revenue_evolution(TIMESTAMPTZ, TIMESTAMPTZ, INT) IS
|
||||||
|
'Serie temporal de receita em buckets de N dias (saas_admin).';
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
<!--
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Agência PSI — Dashboard SaaS: Receita de créditos WhatsApp
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Usado em SaasDashboard pra visualizar receita Asaas de créditos +
|
||||||
|
| pacotes mais vendidos + consumo global vs vendido + sparkline mensal.
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
-->
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, onMounted, watch } from 'vue';
|
||||||
|
import { useSaasCreditsAnalytics } from '@/composables/useSaasCreditsAnalytics';
|
||||||
|
|
||||||
|
const api = useSaasCreditsAnalytics();
|
||||||
|
|
||||||
|
const period = ref('30d');
|
||||||
|
const PERIOD_OPTIONS = [
|
||||||
|
{ label: '30 dias', value: '30d' },
|
||||||
|
{ label: '90 dias', value: '90d' },
|
||||||
|
{ label: '6 meses', value: '180d' },
|
||||||
|
{ label: '12 meses', value: '365d' }
|
||||||
|
];
|
||||||
|
|
||||||
|
async function reload() {
|
||||||
|
await api.loadAll({ period: period.value });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sparkline de receita por bucket
|
||||||
|
const sparkPoints = computed(() => {
|
||||||
|
const buckets = api.evolution.value || [];
|
||||||
|
if (buckets.length < 2) return '';
|
||||||
|
const values = buckets.map((b) => Number(b.revenue_brl) || 0);
|
||||||
|
const max = Math.max(...values);
|
||||||
|
const min = Math.min(...values);
|
||||||
|
const range = max - min || 1;
|
||||||
|
const width = 100;
|
||||||
|
const height = 32;
|
||||||
|
return buckets
|
||||||
|
.map((b, i) => {
|
||||||
|
const x = (i / (buckets.length - 1)) * width;
|
||||||
|
const normalized = ((Number(b.revenue_brl) || 0) - min) / range;
|
||||||
|
const y = height - (normalized * (height - 4)) - 2;
|
||||||
|
return `${x.toFixed(2)},${y.toFixed(2)}`;
|
||||||
|
})
|
||||||
|
.join(' ');
|
||||||
|
});
|
||||||
|
|
||||||
|
const sparkTrend = computed(() => {
|
||||||
|
const buckets = api.evolution.value || [];
|
||||||
|
if (buckets.length < 2) return null;
|
||||||
|
const first = Number(buckets[0].revenue_brl) || 0;
|
||||||
|
const last = Number(buckets[buckets.length - 1].revenue_brl) || 0;
|
||||||
|
if (first === 0) return null;
|
||||||
|
const pct = ((last - first) / first) * 100;
|
||||||
|
return { pct: Math.round(pct), up: last > first };
|
||||||
|
});
|
||||||
|
|
||||||
|
onMounted(reload);
|
||||||
|
watch(period, reload);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5 flex flex-col gap-4">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex items-center justify-between gap-3 flex-wrap">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<i class="pi pi-whatsapp text-emerald-500 text-lg" />
|
||||||
|
<div>
|
||||||
|
<div class="text-[1rem] font-semibold text-[var(--text-color)]">Receita de créditos WhatsApp</div>
|
||||||
|
<div class="text-xs text-[var(--text-color-secondary)]">Asaas · compras pagas no período</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2 items-center">
|
||||||
|
<Select v-model="period" :options="PERIOD_OPTIONS" optionLabel="label" optionValue="value" class="!text-xs" :pt="{ root: { style: 'width: 7.5rem' } }" />
|
||||||
|
<Button icon="pi pi-refresh" severity="secondary" outlined size="small" :loading="api.loading.value" @click="reload" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="api.loading.value" class="flex justify-center py-8">
|
||||||
|
<ProgressSpinner style="width: 36px; height: 36px" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<!-- Sem vendas no período -->
|
||||||
|
<div v-if="!api.hasRevenue.value" class="text-center py-6 text-sm text-[var(--text-color-secondary)]">
|
||||||
|
<i class="pi pi-inbox text-2xl opacity-40 block mb-2" />
|
||||||
|
Nenhuma compra Asaas no período.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<!-- KPIs -->
|
||||||
|
<div class="grid grid-cols-2 md:grid-cols-4 gap-2">
|
||||||
|
<div class="rounded-md border border-[var(--surface-border)] p-3 flex flex-col">
|
||||||
|
<span class="text-[0.65rem] uppercase tracking-wide text-[var(--text-color-secondary)]">Receita</span>
|
||||||
|
<span class="text-lg font-bold text-emerald-600">{{ api.formatBRL(api.stats.value?.revenue_brl) }}</span>
|
||||||
|
<span class="text-[0.65rem] text-[var(--text-color-secondary)]">ticket médio {{ api.formatBRL(api.stats.value?.avg_ticket_brl) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-md border border-[var(--surface-border)] p-3 flex flex-col">
|
||||||
|
<span class="text-[0.65rem] uppercase tracking-wide text-[var(--text-color-secondary)]">Compras</span>
|
||||||
|
<span class="text-lg font-bold">{{ api.stats.value?.purchases_count || 0 }}</span>
|
||||||
|
<span class="text-[0.65rem] text-[var(--text-color-secondary)]">{{ api.stats.value?.tenants_count || 0 }} tenant(s)</span>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-md border border-[var(--surface-border)] p-3 flex flex-col">
|
||||||
|
<span class="text-[0.65rem] uppercase tracking-wide text-[var(--text-color-secondary)]">Créditos vendidos</span>
|
||||||
|
<span class="text-lg font-bold">{{ api.stats.value?.credits_sold || 0 }}</span>
|
||||||
|
<span class="text-[0.65rem] text-[var(--text-color-secondary)]">no período</span>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-md border border-[var(--surface-border)] p-3 flex flex-col">
|
||||||
|
<span class="text-[0.65rem] uppercase tracking-wide text-[var(--text-color-secondary)]">Consumo global</span>
|
||||||
|
<span class="text-lg font-bold">{{ api.usage.value?.usage_rate ?? 0 }}%</span>
|
||||||
|
<span class="text-[0.65rem] text-[var(--text-color-secondary)]">{{ api.usage.value?.lifetime_used || 0 }} / {{ api.usage.value?.lifetime_purchased || 0 }} lifetime</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sparkline -->
|
||||||
|
<div v-if="sparkPoints" class="flex items-center gap-3 border-t border-[var(--surface-border)] pt-3">
|
||||||
|
<svg viewBox="0 0 100 32" preserveAspectRatio="none" class="h-8 flex-1">
|
||||||
|
<polyline
|
||||||
|
:points="sparkPoints"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.5"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
:class="sparkTrend?.up ? 'text-emerald-500' : 'text-orange-500'"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<div v-if="sparkTrend" class="flex flex-col items-end text-[0.7rem] leading-tight">
|
||||||
|
<span :class="sparkTrend.up ? 'text-emerald-600' : 'text-orange-600'" class="font-semibold">
|
||||||
|
{{ sparkTrend.up ? '↑' : '↓' }} {{ Math.abs(sparkTrend.pct) }}%
|
||||||
|
</span>
|
||||||
|
<span class="text-[var(--text-color-secondary)]">
|
||||||
|
vs início do período
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Top pacotes -->
|
||||||
|
<div v-if="api.topPackages.value.length > 0" class="border-t border-[var(--surface-border)] pt-3">
|
||||||
|
<div class="text-[0.65rem] uppercase tracking-wide text-[var(--text-color-secondary)] mb-2">Pacotes mais vendidos</div>
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<div v-for="(p, i) in api.topPackages.value.slice(0, 5)" :key="p.package_id || p.package_name"
|
||||||
|
class="grid grid-cols-[auto_1fr_auto_auto] items-center gap-3 px-2 py-1.5 rounded hover:bg-[var(--surface-hover)] text-xs">
|
||||||
|
<span class="font-mono text-[0.65rem] text-[var(--text-color-secondary)] w-4">{{ i + 1 }}.</span>
|
||||||
|
<span class="font-semibold truncate">{{ p.package_name }}</span>
|
||||||
|
<span class="text-[var(--text-color-secondary)] font-mono whitespace-nowrap">{{ p.purchases_count }}×</span>
|
||||||
|
<span class="font-mono text-emerald-600 whitespace-nowrap">{{ api.formatBRL(p.revenue_brl) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Agência PSI
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Arquivo: src/composables/useSaasCreditsAnalytics.js
|
||||||
|
|
|
||||||
|
| Wrapper das RPCs do Dashboard SaaS pra analytics de créditos WhatsApp:
|
||||||
|
| - saas_wa_credits_revenue_stats (agregados do período)
|
||||||
|
| - saas_wa_credits_top_packages (ranking)
|
||||||
|
| - saas_wa_credits_usage_summary (vendidos vs usados — snapshot)
|
||||||
|
| - saas_wa_credits_revenue_evolution (série temporal)
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ref, computed } from 'vue';
|
||||||
|
import { supabase } from '@/lib/supabase/client';
|
||||||
|
|
||||||
|
const PERIOD_DAYS = { '30d': 30, '90d': 90, '180d': 180, '365d': 365 };
|
||||||
|
const BUCKET_BY_PERIOD = { '30d': 7, '90d': 15, '180d': 30, '365d': 30 };
|
||||||
|
|
||||||
|
export function useSaasCreditsAnalytics() {
|
||||||
|
const stats = ref(null);
|
||||||
|
const topPackages = ref([]);
|
||||||
|
const usage = ref(null);
|
||||||
|
const evolution = ref([]);
|
||||||
|
const loading = ref(false);
|
||||||
|
const error = ref(null);
|
||||||
|
|
||||||
|
async function loadAll({ period = '30d' } = {}) {
|
||||||
|
const days = PERIOD_DAYS[period] || 30;
|
||||||
|
const bucketDays = BUCKET_BY_PERIOD[period] || 7;
|
||||||
|
const to = new Date();
|
||||||
|
const from = new Date(Date.now() - days * 24 * 3600 * 1000);
|
||||||
|
|
||||||
|
loading.value = true;
|
||||||
|
error.value = null;
|
||||||
|
try {
|
||||||
|
const [sRes, tRes, uRes, eRes] = await Promise.all([
|
||||||
|
supabase.rpc('saas_wa_credits_revenue_stats', { p_from: from.toISOString(), p_to: to.toISOString() }),
|
||||||
|
supabase.rpc('saas_wa_credits_top_packages', { p_from: from.toISOString(), p_to: to.toISOString() }),
|
||||||
|
supabase.rpc('saas_wa_credits_usage_summary'),
|
||||||
|
supabase.rpc('saas_wa_credits_revenue_evolution', { p_from: from.toISOString(), p_to: to.toISOString(), p_bucket_days: bucketDays })
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (sRes.error) throw sRes.error;
|
||||||
|
if (tRes.error) throw tRes.error;
|
||||||
|
if (uRes.error) throw uRes.error;
|
||||||
|
if (eRes.error) throw eRes.error;
|
||||||
|
|
||||||
|
stats.value = Array.isArray(sRes.data) ? (sRes.data[0] || null) : sRes.data;
|
||||||
|
topPackages.value = tRes.data || [];
|
||||||
|
usage.value = Array.isArray(uRes.data) ? (uRes.data[0] || null) : uRes.data;
|
||||||
|
evolution.value = eRes.data || [];
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e;
|
||||||
|
stats.value = null;
|
||||||
|
topPackages.value = [];
|
||||||
|
usage.value = null;
|
||||||
|
evolution.value = [];
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBRL(v) {
|
||||||
|
return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(Number(v) || 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasRevenue = computed(() => (Number(stats.value?.revenue_brl) || 0) > 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
stats,
|
||||||
|
topPackages,
|
||||||
|
usage,
|
||||||
|
evolution,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
hasRevenue,
|
||||||
|
loadAll,
|
||||||
|
formatBRL
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -23,6 +23,7 @@ import { useToast } from 'primevue/usetoast';
|
|||||||
import { useConfirm } from 'primevue/useconfirm';
|
import { useConfirm } from 'primevue/useconfirm';
|
||||||
|
|
||||||
import Chart from 'primevue/chart';
|
import Chart from 'primevue/chart';
|
||||||
|
import SaasCreditsRevenueCard from '@/components/dashboard/SaasCreditsRevenueCard.vue';
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
@@ -604,6 +605,9 @@ onBeforeUnmount(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Receita de créditos WhatsApp (Asaas) -->
|
||||||
|
<SaasCreditsRevenueCard />
|
||||||
|
|
||||||
<!-- Breakdown table (com ações) -->
|
<!-- Breakdown table (com ações) -->
|
||||||
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5">
|
<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>
|
<div class="text-[1rem] font-semibold text-[var(--text-color)] mb-3">Distribuição por plano</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user