metaads-pro/src/app/dashboard/page.tsx
Marcio Bevervanso 4bde1cc5c8 feat: posicionamentos restritos + stats completas + auditoria IA melhorada
- Posicionamentos: novas campanhas saem só com Feed+Stories+Reels (FB+IG),
  sem Audience Network, Messenger, coluna direita ou Marketplace
- Botão "Corrigir Posicionamentos" (ícone Target) em cada ad set existente —
  busca targeting atual, preserva geo/idade/interesses e aplica as restrições
- Stats: compras, alcance, CPA e reach nos cards do dashboard e na tabela
  (campanha, ad set e anúncio); bug de leads/ROAS sempre 0 no nível campanha corrigido
- Auditoria IA: prompt ciente do objetivo (Leads/Vendas/Tráfego), passa todas
  as métricas relevantes (CTR, CPC, CPL, CPA, alcance, impressões); hero da
  tela mostra cards dinâmicos conforme o tipo de campanha
- strategist.ts: corrigido acesso indevido a actions/purchase_roas nos insights mapeados

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-17 14:34:47 -03:00

237 lines
8.2 KiB
TypeScript

'use client';
import React, { useState, useEffect, useCallback } from 'react';
import { MetricsCard } from '@/components/dashboard/MetricsCard';
import { CampaignTable } from '@/components/dashboard/CampaignTable';
import { DateFilter } from '@/components/dashboard/DateFilter';
import { Spinner } from '@/components/ui/Spinner';
import { RefreshCw } from 'lucide-react';
import type { CampaignWithInsights, AccountOverview, DatePreset } from '@/lib/meta/types';
export default function DashboardPage() {
const [datePreset, setDatePreset] = useState<DatePreset>('last_30d');
const [campaigns, setCampaigns] = useState<CampaignWithInsights[]>([]);
const [overview, setOverview] = useState<AccountOverview | null>(null);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState('');
const fetchData = useCallback(async (showLoading = true) => {
if (showLoading) setLoading(true);
else setRefreshing(true);
setError('');
try {
const [campaignsRes, overviewRes] = await Promise.all([
fetch(`/api/campaigns?date_preset=${datePreset}`),
fetch(`/api/campaigns/insights?date_preset=${datePreset}`),
]);
if (!campaignsRes.ok || !overviewRes.ok) {
throw new Error('Erro ao carregar dados');
}
const campaignsData = await campaignsRes.json();
const overviewData = await overviewRes.json();
if (campaignsData.success) setCampaigns(campaignsData.data);
if (overviewData.success) setOverview(overviewData.data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Erro ao carregar dados');
} finally {
setLoading(false);
setRefreshing(false);
}
}, [datePreset]);
useEffect(() => {
fetchData();
}, [fetchData]);
// Auto-refresh every 60s
useEffect(() => {
const interval = setInterval(() => fetchData(false), 60000);
return () => clearInterval(interval);
}, [fetchData]);
const handleStatusChange = async (campaignId: string, newStatus: 'ACTIVE' | 'PAUSED') => {
const res = await fetch(`/api/campaigns/${campaignId}/status`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: newStatus }),
});
if (!res.ok) throw new Error('Erro ao atualizar status');
// Update local state
setCampaigns(prev =>
prev.map(c =>
c.campaign.id === campaignId
? { ...c, campaign: { ...c.campaign, status: newStatus } }
: c
)
);
};
const handleBudgetChange = async (campaignId: string, newBudget: number) => {
const res = await fetch(`/api/campaigns/${campaignId}/budget`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ daily_budget: newBudget }),
});
if (!res.ok) throw new Error('Erro ao atualizar orçamento');
// Update local state
setCampaigns(prev =>
prev.map(c =>
c.campaign.id === campaignId
? { ...c, campaign: { ...c.campaign, daily_budget: String(newBudget) } }
: c
)
);
};
const formatCurrency = (val: any) => `R$ ${Number(val || 0).toFixed(2)}`;
const formatNumber = (val: any) => Number(val || 0).toLocaleString('pt-BR');
return (
<div className="flex flex-col gap-6">
{/* Header */}
<div className="flex items-center justify-between animate-fade-in-up">
<div>
<h1 className="text-2xl font-bold text-white">Dashboard</h1>
<p className="text-sm mt-1" style={{ color: 'var(--muted-fg)' }}>
Visão geral das suas campanhas de Facebook Ads
</p>
</div>
<div className="flex items-center gap-4">
<DateFilter selected={datePreset} onChange={setDatePreset} />
<button
onClick={() => fetchData(false)}
disabled={refreshing}
className="btn-ghost flex items-center gap-2"
>
<RefreshCw size={14} className={refreshing ? 'animate-spin-slow' : ''} />
Atualizar
</button>
</div>
</div>
{/* Error */}
{error && (
<div className="px-5 py-4 rounded-xl animate-fade-in" style={{ background: 'rgba(239,68,68,0.08)', border: '1px solid rgba(239,68,68,0.15)' }}>
<p className="text-sm" style={{ color: '#f87171' }}>{error}</p>
<p className="text-xs mt-1" style={{ color: 'var(--muted-fg)' }}>
Verifique se as credenciais do Meta estão configuradas no .env.local
</p>
</div>
)}
{/* Metrics Cards */}
{loading ? (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="shimmer h-[120px]" />
))}
</div>
) : overview ? (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<MetricsCard
title="Investido"
value={formatCurrency(overview.total_spend)}
subtitle={`${overview.campaigns_active} campanhas ativas`}
icon="dollar"
color="purple"
delay={1}
/>
<MetricsCard
title="Compras"
value={formatNumber(overview.total_purchases)}
subtitle={overview.avg_cpa > 0 ? `CPA: ${formatCurrency(overview.avg_cpa)}` : 'Custo por compra'}
icon="trending"
color="green"
delay={2}
/>
<MetricsCard
title="ROAS"
value={overview.total_roas > 0 ? `${overview.total_roas.toFixed(2)}x` : '—'}
subtitle="Retorno sobre investimento"
icon="trending"
color="blue"
delay={3}
/>
<MetricsCard
title="Leads"
value={formatNumber(overview.total_leads)}
subtitle={overview.total_leads > 0 ? `CPL: ${formatCurrency(overview.total_spend / overview.total_leads)}` : 'Custo por lead'}
icon="users"
color="amber"
delay={4}
/>
<MetricsCard
title="Alcance"
value={formatNumber(overview.total_reach)}
subtitle={`${formatNumber(overview.total_impressions)} impressões`}
icon="chart"
color="cyan"
delay={5}
/>
<MetricsCard
title="CTR"
value={`${overview.avg_ctr.toFixed(2)}%`}
subtitle={`${formatNumber(overview.total_clicks)} cliques`}
icon="target"
color="pink"
delay={6}
/>
<MetricsCard
title="CPC"
value={formatCurrency(overview.avg_cpc)}
subtitle="Custo por clique"
icon="click"
color="purple"
delay={7}
/>
<MetricsCard
title="CPM"
value={formatCurrency(overview.avg_cpm)}
subtitle="Custo por mil impressões"
icon="chart"
color="amber"
delay={8}
/>
</div>
) : null}
{/* Campaigns Table */}
<CampaignTable
campaigns={campaigns}
loading={loading}
onStatusChange={handleStatusChange}
onBudgetChange={handleBudgetChange}
onAdCreated={() => fetchData(false)}
/>
{/* Footer */}
{!loading && (
<div className="flex items-center justify-between animate-fade-in">
<p className="text-xs" style={{ color: 'var(--muted-fg)' }}>
{campaigns.length} campanha{campaigns.length !== 1 ? 's' : ''} encontrada{campaigns.length !== 1 ? 's' : ''}
</p>
<p className="text-xs" style={{ color: 'var(--muted-fg)' }}>
Auto-refresh: 60s Dados via Meta Marketing API v20.0
</p>
</div>
)}
{/* Loading overlay on refresh */}
{refreshing && (
<div className="fixed bottom-6 right-6 flex items-center gap-2 px-4 py-2.5 rounded-xl animate-fade-in" style={{ background: 'rgba(99,102,241,0.15)', border: '1px solid rgba(99,102,241,0.25)' }}>
<Spinner size="sm" className="text-[var(--accent)]" />
<span className="text-xs font-medium" style={{ color: 'var(--accent-soft-fg)' }}>Atualizando...</span>
</div>
)}
</div>
);
}