import React, { useState, useEffect, useMemo, useRef, Fragment } from 'react'; import { createRoot } from 'react-dom/client'; import { Plus, Trash2, ChevronLeft, ChevronRight, ChevronDown, ChevronUp, TrendingUp, TrendingDown, Wallet, BarChart3, Megaphone, Check, X, Receipt, Zap, Loader2, RefreshCw, PieChart, Users, Percent, Sparkles, Pencil, Package, Truck, Award, Calendar, Save, Download, Upload, } from 'https://esm.sh/lucide-react@0.468.0?external=react'; /* ---------- helpers ---------- */ const brl = (n) => new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(Number(n) || 0); const num = (n, d = 1) => new Intl.NumberFormat('pt-BR', { minimumFractionDigits: d, maximumFractionDigits: d }).format(Number(n) || 0); const pct = (n) => new Intl.NumberFormat('pt-BR', { style: 'percent', minimumFractionDigits: 0, maximumFractionDigits: 0 }).format(Number(n) || 0); const parseBRL = (s) => { if (typeof s === 'number') return s; if (!s) return 0; let t = String(s).trim().replace(/[^\d.,-]/g, ''); if (t.includes('.') && t.includes(',')) t = t.replace(/\./g, '').replace(',', '.'); else if (t.includes(',')) t = t.replace(',', '.'); const v = parseFloat(t); return isNaN(v) ? 0 : v; }; const MESES = ['Janeiro', 'Fevereiro', 'Março', 'Abril', 'Maio', 'Junho', 'Julho', 'Agosto', 'Setembro', 'Outubro', 'Novembro', 'Dezembro']; const monthLabel = (ym) => { const [y, m] = ym.split('-').map(Number); return `${MESES[m - 1]} ${y}`; }; const shiftMonth = (ym, d) => { let [y, m] = ym.split('-').map(Number); m += d; while (m < 1) { m += 12; y--; } while (m > 12) { m -= 12; y++; } return `${y}-${String(m).padStart(2, '0')}`; }; const lastDay = (ym) => { const [y, m] = ym.split('-').map(Number); return String(new Date(y, m, 0).getDate()).padStart(2, '0'); }; const todayISO = () => { const d = new Date(); return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; }; const currentMonth = () => todayISO().slice(0, 7); const dISO = (d) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; const brDate = (iso) => iso ? `${iso.slice(8, 10)}/${iso.slice(5, 7)}/${iso.slice(0, 4)}` : ''; const uid = () => Math.random().toString(36).slice(2, 10); const norm = (s) => (s || '').toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/\s+/g, ' ').trim(); const mesesEntre = (from, to) => { const out = []; let cur = from.slice(0, 7); const end = to.slice(0, 7); let guard = 0; while (cur <= end && guard < 240) { out.push(cur); cur = shiftMonth(cur, 1); guard++; } return out; }; const LOJAS_SUGERIDAS = ['Essenza Estofados', 'Luna Comfort', 'Empório Sofás']; const PLATAFORMAS = ['Instagram', 'Facebook', 'WhatsApp', 'Outro']; const ORIGENS = ['Indicação', 'Cliente antigo', 'Passou na loja', 'Outro']; const ADX = '__ad_nao_rastreado__'; const ORG = '__organico__'; const DEFAULT_SEC = ['desempenho', 'ajustes', 'windsor', 'comparativo', 'vendas', 'campanhas']; /* ---------- storage ---------- */ const KEY = 'cpa_whatsapp_v2'; const OLDKEY = 'cpa_whatsapp_v1'; async function readKey(k) { try { if (typeof window === 'undefined' || !window.localStorage) return null; const raw = window.localStorage.getItem(k); return raw ? JSON.parse(raw) : null; } catch { return null; } } const mergeById = (a, b) => { const m = new Map(); [...(b || []), ...(a || [])].forEach((it) => { if (it && it.id) m.set(it.id, it); }); return [...m.values()]; }; const mergeGastos = (a, b) => { const m = new Map(); [...(b || []), ...(a || [])].forEach((g) => { if (!g || !g.campanhaId) return; const periodo = g.date || g.mes; if (!periodo) return; m.set(`${g.campanhaId}|${periodo}`, g); }); return [...m.values()]; }; async function loadData() { const v2 = await readKey(KEY); const v1 = await readKey(OLDKEY); if (!v2 && !v1) return null; const a = v2 || {}, b = v1 || {}; return { campanhas: mergeById(a.campanhas, b.campanhas), gastos: mergeGastos(a.gastos, b.gastos), vendas: mergeById(a.vendas, b.vendas), modelos: mergeById(a.modelos, b.modelos), config: a.config || b.config || { impostoAdPct: 0 }, }; } async function saveData(data) { try { if (typeof window === 'undefined' || !window.localStorage) return; window.localStorage.setItem(KEY, JSON.stringify(data)); } catch (e) { console.error('Erro ao salvar:', e); } } function App() { const [campanhas, setCampanhas] = useState([]); const [gastos, setGastos] = useState([]); const [vendas, setVendas] = useState([]); const [modelos, setModelos] = useState([]); const [config, setConfig] = useState({ impostoAdPct: 0 }); const [loaded, setLoaded] = useState(false); const [periodo, setPeriodo] = useState({ tipo: 'mes', mesRef: currentMonth(), from: todayISO(), to: todayISO() }); const [lojaFiltro, setLojaFiltro] = useState('todas'); const [aba, setAba] = useState('anuncio'); const [expandedCamp, setExpandedCamp] = useState(null); const [ordenacao, setOrdenacao] = useState('lucro'); // 'lucro' | 'manual' const dragCampIdx = useRef(null); const dragSecKey = useRef(null); const [vData, setVData] = useState(todayISO()); const [vCampanha, setVCampanha] = useState(''); const [vOrigem, setVOrigem] = useState(ORIGENS[0]); const [vModelo, setVModelo] = useState(''); const [vCliente, setVCliente] = useState(''); const [vReceita, setVReceita] = useState(''); const [vCusto, setVCusto] = useState(''); const [vFrete, setVFrete] = useState(''); const [cNome, setCNome] = useState(''); const [cLoja, setCLoja] = useState(''); const [cPlataforma, setCPlataforma] = useState('Instagram'); const [mNome, setMNome] = useState(''); const [mPreco, setMPreco] = useState(''); const [mCusto, setMCusto] = useState(''); const [mFrete, setMFrete] = useState(''); const [mFornecedor, setMFornecedor] = useState(''); const [editModelo, setEditModelo] = useState(null); const [confirmDel, setConfirmDel] = useState(null); const [mergePending, setMergePending] = useState(null); // { from, to } const [editId, setEditId] = useState(null); const [editDraft, setEditDraft] = useState(null); const [fetchingMeta, setFetchingMeta] = useState(false); const [metaRows, setMetaRows] = useState([]); // [{campanha, gastoTotal, mapId}] const [metaDetail, setMetaDetail] = useState([]); // [{campanha, mes, gasto}] const [metaError, setMetaError] = useState(''); const [metaPaste, setMetaPaste] = useState(''); const [metaMsg, setMetaMsg] = useState(''); const [backupMsg, setBackupMsg] = useState(''); useEffect(() => { (async () => { const d = await loadData(); if (d) { setCampanhas(d.campanhas || []); setGastos(d.gastos || []); setVendas(d.vendas || []); setModelos(d.modelos || []); setConfig(d.config || { impostoAdPct: 0 }); } setLoaded(true); })(); }, []); useEffect(() => { if (loaded) saveData({ campanhas, gastos, vendas, modelos, config }); }, [campanhas, gastos, vendas, modelos, config, loaded]); /* ---------- período ---------- */ const per = useMemo(() => { const p = periodo; const now = new Date(); if (p.tipo === 'mes') return { from: `${p.mesRef}-01`, to: `${p.mesRef}-${lastDay(p.mesRef)}`, label: monthLabel(p.mesRef) }; if (p.tipo === 'mes_passado') { const mr = shiftMonth(currentMonth(), -1); return { from: `${mr}-01`, to: `${mr}-${lastDay(mr)}`, label: monthLabel(mr) }; } if (p.tipo === 'ano') { const y = now.getFullYear(); return { from: `${y}-01-01`, to: `${y}-12-31`, label: `Ano de ${y}` }; } if (p.tipo === 'custom') return { from: p.from, to: p.to, label: `${brDate(p.from)} – ${brDate(p.to)}` }; return { from: `${currentMonth()}-01`, to: `${currentMonth()}-${lastDay(currentMonth())}`, label: monthLabel(currentMonth()) }; }, [periodo]); const mesesPeriodo = useMemo(() => mesesEntre(per.from, per.to), [per]); const mesUnico = per.from.slice(0, 7) === per.to.slice(0, 7) ? per.from.slice(0, 7) : null; const mesRefAtual = periodo.tipo === 'mes' ? periodo.mesRef : currentMonth(); const shiftPeriodo = (delta) => setPeriodo({ ...periodo, tipo: 'mes', mesRef: shiftMonth(mesRefAtual, delta) }); const impPct = Number(config.impostoAdPct) || 0; const comImp = (g) => g * (1 + impPct / 100); /* ---------- ações ---------- */ const dailyMonths = useMemo(() => { const s = new Set(); for (const g of gastos) if (g.date) s.add(g.date.slice(0, 7)); return s; }, [gastos]); const getGastoMes = (cid, m) => { if (dailyMonths.has(m)) return gastos.filter((g) => g.campanhaId === cid && g.date && g.date.slice(0, 7) === m).reduce((s, g) => s + g.valor, 0); return gastos.filter((g) => g.campanhaId === cid && g.mes === m).reduce((s, g) => s + g.valor, 0); }; const getGastoPeriodo = (cid) => { let sum = 0; for (const g of gastos) { if (g.campanhaId !== cid) continue; if (g.date) { if (g.date >= per.from && g.date <= per.to) sum += g.valor; } else if (g.mes && !dailyMonths.has(g.mes)) { const ms = `${g.mes}-01`, me = `${g.mes}-${lastDay(g.mes)}`; if (per.from <= me && per.to >= ms) sum += g.valor; } } return sum; }; const upsertGasto = (cid, m, valor) => setGastos((prev) => { const rest = prev.filter((g) => !(g.campanhaId === cid && (g.mes === m || (g.date && g.date.slice(0, 7) === m)))); return valor > 0 ? [...rest, { campanhaId: cid, mes: m, valor }] : rest; }); const isOrg = vCampanha === ORG; const onModelo = (id) => { setVModelo(id); const m = modelos.find((x) => x.id === id); if (m) { setVReceita(m.preco ? String(m.preco) : ''); setVCusto(m.custo ? String(m.custo) : ''); setVFrete(m.frete ? String(m.frete) : ''); } }; const addVenda = () => { const receita = parseBRL(vReceita); if (!vCampanha || receita <= 0) return; const base = { id: uid(), data: vData, cliente: vCliente.trim(), receita, custo: parseBRL(vCusto), frete: parseBRL(vFrete), modeloId: vModelo || null }; let nova; if (vCampanha === ORG) nova = { ...base, campanhaId: ORG, origem: vOrigem }; else if (vCampanha === ADX) nova = { ...base, campanhaId: ADX }; else nova = { ...base, campanhaId: vCampanha }; setVendas((p) => [...p, nova]); setVCliente(''); setVReceita(''); setVCusto(''); setVFrete(''); setVModelo(''); }; const addCampanha = () => { if (!cNome.trim()) return; setCampanhas((p) => [...p, { id: uid(), nome: cNome.trim(), loja: cLoja.trim(), plataforma: cPlataforma }]); setCNome(''); setCLoja(''); }; const delVenda = (id) => setVendas((p) => p.filter((v) => v.id !== id)); const delCampanha = (id) => { setCampanhas((p) => p.filter((c) => c.id !== id)); setGastos((p) => p.filter((g) => g.campanhaId !== id)); setVendas((p) => p.filter((v) => v.campanhaId !== id)); if (vCampanha === id) setVCampanha(''); }; const mergeCampanha = (fromId, toId) => { if (!fromId || !toId || fromId === toId) { setMergePending(null); return; } setGastos((prev) => { const reassigned = prev.map((g) => (g.campanhaId === fromId ? { ...g, campanhaId: toId } : g)); const map = new Map(); reassigned.forEach((g) => { const k = `${g.campanhaId}|${g.date || g.mes}`; if (map.has(k)) map.get(k).valor += g.valor; else map.set(k, { ...g }); }); return [...map.values()]; }); setVendas((prev) => prev.map((v) => (v.campanhaId === fromId ? { ...v, campanhaId: toId } : v))); setCampanhas((prev) => prev.filter((c) => c.id !== fromId)); if (vCampanha === fromId) setVCampanha(toId); setMergePending(null); }; const salvarModelo = () => { if (!mNome.trim()) return; const rec = { nome: mNome.trim(), preco: parseBRL(mPreco), custo: parseBRL(mCusto), frete: parseBRL(mFrete), fornecedor: mFornecedor.trim() }; if (editModelo) setModelos((p) => p.map((m) => (m.id === editModelo ? { ...m, ...rec } : m))); else setModelos((p) => [...p, { id: uid(), ...rec }]); setMNome(''); setMPreco(''); setMCusto(''); setMFrete(''); setMFornecedor(''); setEditModelo(null); }; const startEditModelo = (m) => { setEditModelo(m.id); setMNome(m.nome); setMPreco(m.preco ? String(m.preco) : ''); setMCusto(m.custo ? String(m.custo) : ''); setMFrete(m.frete ? String(m.frete) : ''); setMFornecedor(m.fornecedor || ''); }; const delModelo = (id) => { setModelos((p) => p.filter((m) => m.id !== id)); setVendas((p) => p.map((v) => (v.modeloId === id ? { ...v, modeloId: null } : v))); }; const exportBackup = () => { try { const dump = { app: 'metricas-campanhas-whatsapp', versao: 2, exportadoEm: new Date().toISOString(), campanhas, gastos, vendas, modelos, config }; const blob = new Blob([JSON.stringify(dump, null, 2)], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `metricas-backup-${todayISO()}.json`; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); setBackupMsg('Backup baixado ✓'); } catch { setBackupMsg('Não consegui gerar o backup.'); } }; const importBackup = (e) => { const file = e.target.files && e.target.files[0]; if (!file) return; const reader = new FileReader(); reader.onload = () => { try { const d = JSON.parse(String(reader.result)); setCampanhas(mergeById(d.campanhas, campanhas)); setGastos(mergeGastos(d.gastos, gastos)); setVendas(mergeById(d.vendas, vendas)); setModelos(mergeById(d.modelos, modelos)); if (d.config) setConfig(d.config); setBackupMsg('Backup restaurado e mesclado ✓'); } catch { setBackupMsg('Arquivo inválido — não consegui ler o backup.'); } }; reader.readAsText(file); e.target.value = ''; }; const startEdit = (v) => { setEditId(v.id); setEditDraft({ data: v.data || todayISO(), tipo: v.campanhaId, origem: v.origem || ORIGENS[0], modeloId: v.modeloId || '', cliente: v.cliente || '', receita: String(v.receita ?? ''), custo: String(v.custo ?? ''), frete: String(v.frete ?? '') }); }; const cancelEdit = () => { setEditId(null); setEditDraft(null); }; const saveEdit = () => { if (!editDraft) return; const receita = parseBRL(editDraft.receita); if (receita <= 0) return; setVendas((p) => p.map((v) => { if (v.id !== editId) return v; const base = { id: v.id, data: editDraft.data, cliente: editDraft.cliente.trim(), receita, custo: parseBRL(editDraft.custo), frete: parseBRL(editDraft.frete), modeloId: editDraft.modeloId || null }; if (editDraft.tipo === ORG) return { ...base, campanhaId: ORG, origem: editDraft.origem }; if (editDraft.tipo === ADX) return { ...base, campanhaId: ADX }; return { ...base, campanhaId: editDraft.tipo }; })); cancelEdit(); }; /* ---------- Windsor.ai ---------- */ const autoMatch = (name) => { const n = norm(name); if (!n) return ''; const exact = campanhas.find((c) => norm(c.nome) === n); if (exact) return exact.id; const part = campanhas.find((c) => norm(c.nome) && (n.includes(norm(c.nome)) || norm(c.nome).includes(n))); return part ? part.id : ''; }; const loadMetaArray = (arr) => { if (!Array.isArray(arr) || arr.length === 0) { setMetaError('Nenhum dado válido encontrado.'); return false; } const detail = arr.map((r) => ({ campanha: String(r.campanha ?? r.campaign ?? '—'), date: String(r.data ?? r.date ?? '').slice(0, 10), gasto: parseBRL(r.gasto ?? r.spend ?? 0) })).filter((d) => d.gasto > 0 && d.date.length === 10); if (detail.length === 0) { setMetaError('Nenhum registro válido (esperado: campanha, data YYYY-MM-DD e gasto).'); return false; } const byName = {}; detail.forEach((d) => { byName[d.campanha] = (byName[d.campanha] || 0) + d.gasto; }); setMetaError(''); setMetaMsg(''); setMetaDetail(detail); setMetaRows(Object.keys(byName).map((nome) => ({ campanha: nome, gastoTotal: byName[nome], mapId: autoMatch(nome) || '__new__' }))); return true; }; const carregarColado = () => { setMetaMsg(''); try { const t = (metaPaste || '').trim(); const a = t.indexOf('['), b = t.lastIndexOf(']'); if (a === -1 || b === -1) throw new Error('sem json'); const arr = JSON.parse(t.slice(a, b + 1)); if (loadMetaArray(arr)) setMetaPaste(''); } catch { setMetaError('Não consegui ler o texto colado. Cole o JSON exatamente como recebeu.'); } }; const fetchMeta = async () => { setFetchingMeta(true); setMetaError(''); setMetaMsg(''); setMetaRows([]); setMetaDetail([]); try { const qs = new URLSearchParams({ from: per.from, to: per.to }); const res = await fetch(`/api/windsor-meta.php?${qs.toString()}`, { headers: { Accept: 'application/json' } }); const payload = await res.json().catch(() => ({})); if (!res.ok) throw new Error(payload.error || `Falha HTTP ${res.status}`); const arr = Array.isArray(payload) ? payload : (payload.data || []); if (!Array.isArray(arr) || arr.length === 0) { setMetaError('Nenhum gasto retornado para este período. Confirme no Windsor.ai se a conta do Meta está conectada e com dados nas datas escolhidas.'); } else { loadMetaArray(arr); } } catch (e) { setMetaError(e?.message || 'Não consegui puxar do Windsor.ai agora. Use a opção de colar o gasto abaixo ou tente novamente.'); } finally { setFetchingMeta(false); } }; const applyMeta = () => { let newCamps = [...campanhas]; const nameToCid = {}; metaRows.forEach((r) => { let cid = r.mapId; if (!cid) { nameToCid[r.campanha] = null; return; } if (cid === '__new__') { cid = uid(); newCamps.push({ id: cid, nome: r.campanha, loja: lojaFiltro !== 'todas' ? lojaFiltro : '', plataforma: 'Facebook' }); } nameToCid[r.campanha] = cid; }); const touched = new Set(Object.values(nameToCid).filter(Boolean)); const mesesPuxados = new Set(metaDetail.map((d) => d.date.slice(0, 7))); // remove registros antigos (diários no intervalo ou mensais legado dos meses puxados) das campanhas atualizadas let newGastos = gastos.filter((g) => { if (!touched.has(g.campanhaId)) return true; if (g.date && g.date >= per.from && g.date <= per.to) return false; if (g.mes && mesesPuxados.has(g.mes)) return false; return true; }); let n = 0; metaDetail.forEach((d) => { const cid = nameToCid[d.campanha]; if (!cid) return; newGastos.push({ campanhaId: cid, date: d.date, valor: d.gasto }); n++; }); setCampanhas(newCamps); setGastos(newGastos); setMetaRows([]); setMetaDetail([]); setMetaMsg(n > 0 ? `Gasto aplicado (${n} registro(s)) ✓` : 'Nada aplicado — tudo estava como "Ignorar".'); }; /* ---------- dados calculados ---------- */ const lojas = useMemo(() => [...new Set(campanhas.map((c) => c.loja).filter(Boolean))], [campanhas]); const emPeriodo = (v) => (v.data || '') >= per.from && (v.data || '') <= per.to; const vendasPer = useMemo(() => vendas.filter(emPeriodo), [vendas, per]); const hasOrg = useMemo(() => vendas.some((v) => v.campanhaId === ORG), [vendas]); const nomeModelo = (id) => modelos.find((m) => m.id === id)?.nome || null; const custoTot = (v) => (v.custo || 0) + (v.frete || 0); const secOrder = useMemo(() => { const saved = Array.isArray(config.secOrder) ? config.secOrder.filter((k) => DEFAULT_SEC.includes(k)) : []; return [...saved, ...DEFAULT_SEC.filter((k) => !saved.includes(k))]; }, [config.secOrder]); const visibleSecKeys = () => secOrder.filter((k) => k !== 'vendas' || recentesAba.length > 0); const moveSection = (key, dir) => { const vis = visibleSecKeys(); const target = vis[vis.indexOf(key) + dir]; if (target == null) return; const order = secOrder.slice(); const a = order.indexOf(key), b = order.indexOf(target); [order[a], order[b]] = [order[b], order[a]]; setConfig((c) => ({ ...c, secOrder: order })); }; const secUp = (key) => (visibleSecKeys().indexOf(key) > 0 ? () => moveSection(key, -1) : null); const secDown = (key) => { const vis = visibleSecKeys(); return vis.indexOf(key) < vis.length - 1 ? () => moveSection(key, 1) : null; }; const moveCamp = (i, dir) => { const j = i + dir; if (j < 0 || j >= campanhas.length) return; setCampanhas((prev) => { const a = [...prev]; [a[i], a[j]] = [a[j], a[i]]; return a; }); }; const campDetalhe = (cid) => { const vs = vendasPer.filter((v) => v.campanhaId === cid); const byModel = {}; vs.forEach((v) => { const k = v.modeloId || '__sem__'; if (!byModel[k]) byModel[k] = { id: k, nome: v.modeloId ? (nomeModelo(v.modeloId) || 'Modelo removido') : 'Sem modelo', n: 0, receita: 0, custo: 0 }; byModel[k].n++; byModel[k].receita += v.receita; byModel[k].custo += custoTot(v); }); const modelosList = Object.values(byModel).map((m) => ({ ...m, lucro: m.receita - m.custo })).sort((a, b) => b.n - a.n); return { vs: vs.slice().sort((a, b) => (b.data > a.data ? 1 : -1)), modelosList }; }; const rows = useMemo(() => { const filt = campanhas.filter((c) => lojaFiltro === 'todas' || c.loja === lojaFiltro); const out = filt.map((c) => { const vs = vendasPer.filter((v) => v.campanhaId === c.id); const nVendas = vs.length; const receita = vs.reduce((s, v) => s + v.receita, 0); const custoProd = vs.reduce((s, v) => s + custoTot(v), 0); const gastoMidia = getGastoPeriodo(c.id); const gasto = comImp(gastoMidia); const cpa = nVendas > 0 ? gasto / nVendas : null; const ticket = nVendas > 0 ? receita / nVendas : null; const roas = gasto > 0 ? receita / gasto : null; const lucro = receita - custoProd - gasto; return { c, nVendas, receita, custoProd, gastoMidia, gasto, cpa, ticket, roas, lucro }; }); return ordenacao === 'manual' ? out : out.sort((a, b) => b.lucro - a.lucro); }, [campanhas, vendasPer, gastos, per, lojaFiltro, impPct, ordenacao]); const adReal = useMemo(() => { const tracked = vendasPer.filter((v) => v.campanhaId !== ORG && v.campanhaId !== ADX); const untracked = vendasPer.filter((v) => v.campanhaId === ADX); const ad = [...tracked, ...untracked]; const gastoMidia = campanhas.reduce((s, c) => s + getGastoPeriodo(c.id), 0); const imposto = gastoMidia * impPct / 100; const gastoTotal = gastoMidia + imposto; const receita = ad.reduce((s, v) => s + v.receita, 0); const custo = ad.reduce((s, v) => s + custoTot(v), 0); const n = ad.length; return { gastoMidia, imposto, gastoTotal, receita, custo, n, nTracked: tracked.length, nUntracked: untracked.length, cpa: n > 0 ? gastoTotal / n : null, roas: gastoTotal > 0 ? receita / gastoTotal : null, lucro: receita - custo - gastoTotal, recUntracked: untracked.reduce((s, v) => s + v.receita, 0), custoUntracked: untracked.reduce((s, v) => s + custoTot(v), 0), }; }, [vendasPer, campanhas, gastos, per, impPct]); const trackedTot = useMemo(() => rows.reduce((s, r) => ({ gasto: s.gasto + r.gasto, receita: s.receita + r.receita, nVendas: s.nVendas + r.nVendas, lucro: s.lucro + r.lucro }), { gasto: 0, receita: 0, nVendas: 0, lucro: 0 }), [rows]); const vendasOrg = useMemo(() => vendasPer.filter((v) => v.campanhaId === ORG), [vendasPer]); const orgResumo = useMemo(() => { const receita = vendasOrg.reduce((s, v) => s + v.receita, 0); const custo = vendasOrg.reduce((s, v) => s + custoTot(v), 0); return { n: vendasOrg.length, receita, custo, lucro: receita - custo }; }, [vendasOrg]); const orgPorOrigem = useMemo(() => { const map = {}; vendasOrg.forEach((v) => { const k = v.origem || 'Outro'; if (!map[k]) map[k] = { origem: k, n: 0, receita: 0, custo: 0 }; map[k].n++; map[k].receita += v.receita; map[k].custo += custoTot(v); }); return Object.values(map).map((o) => ({ ...o, lucro: o.receita - o.custo })).sort((a, b) => b.lucro - a.lucro); }, [vendasOrg]); const geral = useMemo(() => { const receitaTotal = adReal.receita + orgResumo.receita; const lucroTotal = adReal.lucro + orgResumo.lucro; return { receitaTotal, gastoTotal: adReal.gastoTotal, lucroTotal, share: receitaTotal > 0 ? adReal.receita / receitaTotal : null }; }, [adReal, orgResumo]); const modelosRank = useMemo(() => { const map = {}; vendasPer.forEach((v) => { if (!v.modeloId) return; if (!map[v.modeloId]) map[v.modeloId] = { n: 0, receita: 0, custo: 0 }; map[v.modeloId].n++; map[v.modeloId].receita += v.receita; map[v.modeloId].custo += custoTot(v); }); const semModelo = vendasPer.filter((v) => !v.modeloId).length; const list = modelos.map((m) => { const a = map[m.id] || { n: 0, receita: 0, custo: 0 }; return { m, n: a.n, receita: a.receita, custo: a.custo, lucro: a.receita - a.custo }; }) .sort((a, b) => b.n - a.n || b.receita - a.receita); return { list, semModelo }; }, [vendasPer, modelos]); const nomeVenda = (v) => { if (v.campanhaId === ORG) return `Orgânico · ${v.origem || 'Outro'}`; if (v.campanhaId === ADX) return 'Anúncio (não rastreado)'; return campanhas.find((c) => c.id === v.campanhaId)?.nome || '—'; }; const recentesAba = useMemo(() => { const src = aba === 'organico' ? vendasOrg : vendasPer.filter((v) => v.campanhaId !== ORG); return src.slice().sort((a, b) => (b.data > a.data ? 1 : b.data < a.data ? -1 : 0)).slice(0, 20); }, [aba, vendasPer, vendasOrg]); const modeloOptions = ( <> {modelos.map((m) => )} ); const renderVenda = (v) => { if (editId === v.id && editDraft) { return (
setEditDraft((d) => ({ ...d, data: e.target.value }))} className="inp" /> {editDraft.tipo === ORG && } setEditDraft((d) => ({ ...d, cliente: e.target.value }))} className="inp" /> setEditDraft((d) => ({ ...d, receita: e.target.value }))} inputMode="decimal" className="inp" /> setEditDraft((d) => ({ ...d, custo: e.target.value }))} inputMode="decimal" className="inp" /> setEditDraft((d) => ({ ...d, frete: e.target.value }))} inputMode="decimal" className="inp" />
); } return (
{(v.data || '').slice(8, 10)}/{(v.data || '').slice(5, 7)} {nomeVenda(v)} {v.modeloId && · {nomeModelo(v.modeloId)}} {v.cliente && · {v.cliente}}
{brl(v.receita)} custo {brl(custoTot(v))}{v.frete ? ` (frete ${brl(v.frete)})` : ''}
); }; const font = { fontFamily: 'ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif' }; const presetBtn = (tipo, txt) => ( ); return (
Lucro real por origem

Métricas de Campanhas · WhatsApp

{per.label}

{monthLabel(mesRefAtual)}
{/* Filtro de data */}
Período {presetBtn('mes', 'Este mês')} {presetBtn('mes_passado', 'Mês passado')} {presetBtn('ano', 'Este ano')} {presetBtn('custom', 'Personalizado')} {periodo.tipo === 'custom' && (
setPeriodo((p) => ({ ...p, from: e.target.value }))} className="inp !w-auto text-sm" /> até setPeriodo((p) => ({ ...p, to: e.target.value }))} className="inp !w-auto text-sm" />
)}
{/* Resumo do mês */}
} label="Receita total" value={brl(geral.receitaTotal)} /> } label="Gasto anúncios (c/ imposto)" value={brl(geral.gastoTotal)} /> = 0 ? : } label="Lucro total" value={brl(geral.lucroTotal)} tone={geral.lucroTotal > 0 ? 'emerald' : geral.lucroTotal < 0 ? 'rose' : 'slate'} /> } label="Veio de anúncio" value={geral.share != null ? pct(geral.share) : '—'} />
{/* Registrar venda */} } title="Registrar venda">
setVData(e.target.value)} className="inp" /> {isOrg && } setVCliente(e.target.value)} placeholder="Nome" className="inp" /> setVReceita(e.target.value)} placeholder="0,00" inputMode="decimal" className="inp" /> setVCusto(e.target.value)} placeholder="0,00" inputMode="decimal" className="inp" /> setVFrete(e.target.value)} placeholder="0,00" inputMode="decimal" className="inp" />

Escolha um modelo pra puxar receita e custo automaticamente (dá pra editar). Se a pessoa viu um anúncio mas não sabe qual, use “Anúncio não rastreado”.

{/* Abas */}
setAba('anuncio')}>Anúncios setAba('modelos')}>Modelos {modelos.length > 0 && {modelos.length}} {hasOrg && setAba('organico')}>Orgânicas {orgResumo.n > 0 && {orgResumo.n}}}
{aba === 'anuncio' && (
{lojas.length > 0 && (
Loja {lojas.map((l) => )}
)}

Use as setas ao lado de cada título para reordenar os blocos. Clique num anúncio no comparativo para ver detalhes.

{/* desempenho */}
} title={`Desempenho real dos anúncios · ${per.label}`}>
0 ? `${brl(adReal.gastoMidia)} + ${brl(adReal.imposto)} imp.` : 'sem imposto'} /> = 1 ? 'emerald' : 'rose') : 'slate'} /> 0 ? 'emerald' : adReal.lucro < 0 ? 'rose' : 'slate'} />

CPA e ROAS reais: gasto (com imposto) contra todas as vendas de anúncio, inclusive as {adReal.nUntracked} não rastreadas ({brl(adReal.recUntracked)}).

{/* ajustes */}
} title="Ajustes" defaultOpen={false}>
setConfig((c) => ({ ...c, impostoAdPct: parseBRL(e.target.value) }))} inputMode="decimal" placeholder="0" className="inp" />

Aplicado sobre o gasto de cada anúncio para chegar no CPA real. Ex.: paguei R$ 100 de mídia + 20% de imposto = R$ 120 de custo real.

Backup dos dados

Seus dados já ficam salvos no navegador automaticamente e não dependem do Windsor. Ainda assim, baixe um backup de vez em quando — é um arquivo seu, que restaura tudo em qualquer navegador.

{campanhas.length} anúncios · {vendas.length} vendas · {modelos.length} modelos guardados
{backupMsg &&
{backupMsg}
}
{/* windsor */}
} title="Gasto do Meta · automático" right={}>

Puxa o gasto diário de cada campanha do Meta (via Windsor.ai) no período selecionado.

ou colar o gasto (quando o botão falha)

Peça no chat “puxa o gasto diário” e cole aqui o texto (JSON) que eu te mandar. Depois é só mapear e aplicar.