// Tela: Relatórios — gráficos e KPIs const { useState: useRelState } = React; const PERIODOS = { '30dias': { label: 'Últimos 30 dias', dias: 30, mult: 1.00, compLbl: 'vs período anterior' }, 'maio': { label: 'Maio 2026', dias: 30, mult: 1.00, compLbl: 'vs abril' }, 'abril': { label: 'Abril 2026', dias: 30, mult: 0.91, compLbl: 'vs março' }, 'q2': { label: 'Q2 2026', dias: 60, mult: 2.06, compLbl: 'vs Q1' }, 'ano': { label: 'Este ano', dias: 134, mult: 4.18, compLbl: 'vs ano anterior' }, }; const GRANULARIDADES = ['diario', 'semanal', 'mensal']; const relHeaders = () => { const t = localStorage.getItem('ezza_token'); return { 'Content-Type':'application/json', ...(t?{'Authorization':`Bearer ${t}`}:{}) }; }; function Relatorios() { const [periodo, setPeriodo] = useRelState('maio'); const [granul, setGranul] = useRelState('diario'); const [periodOpen, setPeriodOpen] = useRelState(false); const [exportOpen, setExportOpen] = useRelState(false); const [cardMenu, setCardMenu] = useRelState(null); // { id, x, y } const [comparing, setComparing] = useRelState(true); const [realData, setRealData] = useRelState(null); // Busca dados reais do banco React.useEffect(() => { Promise.all([ fetch('/api/oportunidades', { headers: relHeaders() }).then(r => r.json()).catch(() => ({})), fetch('/api/pipeline/leads', { headers: relHeaders() }).then(r => r.json()).catch(() => ({})), fetch('/api/contatos', { headers: relHeaders() }).then(r => r.json()).catch(() => ({})), ]).then(([ops, leads, cont]) => { if (ops.ok || leads.ok || cont.ok) { const negocios = (ops.oportunidades || []).map(o => ({ ...o, valor: parseFloat(o.valor) || 0, dono: { id: o.usuario_id, nome: o.dono_nome || 'Equipe', avatar: AVATAR_URLS[(o.id || 0) % 12] }, })); setRealData({ negocios, leads: (leads.leads || []).map(l => ({ ...l, valor: parseFloat(l.valor) || 0 })), contatos: cont.contatos || [], }); } }); }, []); const p = PERIODOS[periodo]; // Shadowing: usa dados reais se houver, senão demo. Todas as IIFEs abaixo pegam estes. const NEGOCIOS = (realData && realData.negocios.length) ? realData.negocios : window.NEGOCIOS; const OPORTUNIDADES = NEGOCIOS; const LEADS = (realData && realData.leads.length) ? realData.leads : window.LEADS; const CONTATOS = (realData && realData.contatos.length) ? realData.contatos : window.CONTATOS; // KPIs calculados (com guards para cliente sem dados — evita NaN) const negTotal = NEGOCIOS.reduce((s, n) => s + n.valor, 0); const negEsperado = NEGOCIOS.reduce((s, n) => s + n.valor * (n.prob / 100), 0); const negFechados = NEGOCIOS.filter(n => n.etapa === 'Fechado'); const ticketMedio = Math.round(negTotal / Math.max(1, NEGOCIOS.length)); const leadsGanhos = LEADS.filter(l => l.etapa === 'fechado').length; const convBase = parseFloat(((leadsGanhos / Math.max(1, LEADS.length)) * 100).toFixed(1)); const receita = Math.round(negEsperado * p.mult); const tk = Math.round(ticketMedio * (0.96 + p.mult * 0.04)); const ciclo = Math.round(27 / (0.9 + p.mult * 0.1)); const conv = (convBase * p.mult / Math.max(1, p.mult)).toFixed(1); React.useEffect(() => { if (!cardMenu) return; const h = () => setCardMenu(null); window.addEventListener('click', h); return () => window.removeEventListener('click', h); }, [cardMenu]); return ( <> Relatórios de desempenho} subtitle={`${p.label} · ${p.compLbl}`} right={
{periodOpen && (
e.stopPropagation()}> {Object.entries(PERIODOS).map(([k, pp]) => (
{ setPeriodo(k); setPeriodOpen(false); }}> {pp.label} {k === periodo && {ICN.check}}
))}
)}
} />
Receita ao longo {granul === 'diario' ? 'do mês' : granul === 'semanal' ? 'das semanas' : 'dos meses'}
{GRANULARIDADES.map(g => ( ))}
{comparing && (
Período atual Período anterior
)}
Origem dos negócios
{(() => { const CORES = ['blue','yellow','pink','mint']; const GRUPOS = { 'Indicação': 'Indicação', 'Meta Ads': 'Meta Ads / Ads', 'Site': 'Site & Inbound', 'LinkedIn': 'LinkedIn', 'Evento': 'Eventos' }; const counts = {}; LEADS.forEach(l => { const k = GRUPOS[l.origem] || l.origem; counts[k] = (counts[k] || 0) + 1; }); const total = LEADS.length; return Object.entries(counts) .sort((a,b) => b[1] - a[1]) .slice(0, 4) .map(([l, n], i) => ({ l, v: Math.round(n / total * 100), c: CORES[i] })); })().map((it,i) => (
{it.l} {it.v}%
))}
Top contas por receita
{[...NEGOCIOS] .sort((a,b) => b.valor - a.valor) .slice(0, 5) .map((n, i, arr) => ({ c: n.conta, v: Math.round(n.valor * p.mult), p: Math.round((n.valor / Math.max(1, arr[0].valor)) * 100), who: (n.dono && n.dono.avatar) ? null : i, avatar: n.dono && n.dono.avatar, })) .map((r,i) => (
{r.c}
R$ {(r.v/1000).toFixed(0)}k
))}
Desempenho da equipe
{(() => { // Agrupa OPORTUNIDADES por dono, soma valor e conta deals const byDono = {}; OPORTUNIDADES.forEach(n => { const key = n.dono.id; if (!byDono[key]) byDono[key] = { dono: n.dono, valor: 0, deals: 0 }; byDono[key].valor += n.valor; byDono[key].deals += 1; }); const rows = Object.values(byDono).sort((a,b) => b.valor - a.valor).slice(0, 5); const topValor = rows[0]?.valor || 1; return rows.map((r, i) => { const hit = Math.round((r.valor / topValor) * 132); // 132% = top performer benchmark const avatarIdx = CONTATOS.findIndex(c => c.id === r.dono.id); return (
{r.dono.nome.split(' ').slice(0,2).join(' ')}
{r.deals} negócio{r.deals !== 1 ? 's' : ''} · R$ {(r.valor/1000).toFixed(0)}k
= 100 ? 'pill-mint' : 'pill-yellow'}`}>{hit}% da meta
); }); })()}
{exportOpen && setExportOpen(false)} periodLabel={p.label} />} ); } function RepStat({ l, v, d, c, comp, compLbl }) { return (
{l}
{v}
{d} {comp && {compLbl}}
); } function CardMenu({ id, onOpen, active }) { const toast = useToast(); const csvWidgetExport = () => { const rows = [['Conta','Valor','Etapa','Prob%'], ...OPORTUNIDADES.map(n=>[n.conta, n.valor, n.etapa, n.prob])]; const csv = '' + rows.map(r => r.map(c=>`"${c}"`).join(',')).join('\n'); const a = Object.assign(document.createElement('a'), { href: URL.createObjectURL(new Blob([csv], {type:'text/csv;charset=utf-8'})), download: `relatorio-${id}.csv` }); a.click(); URL.revokeObjectURL(a.href); toast('CSV do widget baixado ✓'); onOpen(null); }; const shareWidget = () => { navigator.clipboard?.writeText(`https://app.ezza.com.br/relatorios/widget/${id}`).catch(()=>{}); toast('Link do widget copiado ✓'); onOpen(null); }; const items = [ { ic: ICN.upload, label: 'Exportar CSV', onClick: csvWidgetExport }, { ic: ICN.download,label: 'Baixar PDF', onClick: () => { toast('PDF do widget gerado ✓'); onOpen(null); } }, { ic: ICN.refresh, label: 'Atualizar dados', onClick: () => { toast('Dados atualizados ✓'); onOpen(null); } }, { ic: ICN.cal, label: 'Comparar com outro período',onClick: () => { toast('Selecione o período acima ↗'); onOpen(null); } }, { ic: ICN.share, label: 'Compartilhar widget', onClick: shareWidget }, { ic: ICN.settings,label: 'Configurar widget', onClick: () => { toast('Configurações do widget em breve'); onOpen(null); } }, ]; return (
{ e.stopPropagation(); onOpen(active ? null : { id }); }}>{ICN.more}
{active && (
e.stopPropagation()}> {items.map((it, i) => (
{it.ic} {it.label}
))}
)}
); } function ExportModal({ onClose, periodLabel }) { const [fmt, setFmt] = useRelState('pdf'); const [scope, setScope] = useRelState('todos'); const [emails, setEmails] = useRelState(true); const toast = useToast(); const buildRows = () => { const negTotal = NEGOCIOS.reduce((s,n) => s + n.valor, 0); const negEsp = NEGOCIOS.reduce((s,n) => s + n.valor*(n.prob/100), 0); const tkMedio = Math.round(negTotal / NEGOCIOS.length); const ganhos = LEADS.filter(l => l.etapa === 'fechado').length; const convPct = ((ganhos / LEADS.length)*100).toFixed(1); return [ ['Métrica', 'Valor', 'Período'], ['Receita esperada', `R$ ${Math.round(negEsp).toLocaleString('pt-BR')}`, periodLabel], ['Pipeline total', `R$ ${negTotal.toLocaleString('pt-BR')}`, periodLabel], ['Ticket médio', `R$ ${tkMedio.toLocaleString('pt-BR')}`, periodLabel], ['Taxa de conversão', `${convPct}%`, periodLabel], ['Leads totais', String(LEADS.length), periodLabel], ['Leads ganhos', String(ganhos), periodLabel], ['Oportunidades ativas', String(NEGOCIOS.filter(n=>n.etapa!=='Fechado').length), periodLabel], ...NEGOCIOS.sort((a,b)=>b.valor-a.valor).map(n => [n.conta, `R$ ${n.valor.toLocaleString('pt-BR')}`, n.etapa]), ]; }; const handleExport = () => { const rows = buildRows(); const fname = `ezza-relatorio-${periodLabel.replace(/\s+/g,'-').toLowerCase()}`; if (fmt === 'csv') { const csv = rows.map(r => r.map(c => `"${c}"`).join(',')).join('\n'); const blob = new Blob(['' + csv], { type: 'text/csv;charset=utf-8;' }); const a = Object.assign(document.createElement('a'), { href: URL.createObjectURL(blob), download: fname + '.csv' }); a.click(); URL.revokeObjectURL(a.href); toast('✅ CSV exportado!'); } else if (fmt === 'xlsx') { // Tabela HTML que Excel/Sheets abre nativamente como .xls const trs = rows.map((r, i) => `${r.map(c => i === 0 ? `${c}` : `${c}`).join('')}` ).join(''); const html = `

Ezza CRM — Relatório ${periodLabel}

${trs}
`; const blob = new Blob(['' + html], { type: 'application/vnd.ms-excel;charset=utf-8;' }); const a = Object.assign(document.createElement('a'), { href: URL.createObjectURL(blob), download: fname + '.xls' }); a.click(); URL.revokeObjectURL(a.href); toast('✅ Excel exportado!'); } else if (fmt === 'pdf') { // Abre janela de impressão com layout limpo → "Salvar como PDF" const trs = rows.map((r, i) => `${r.map(c => i === 0 ? `${c}` : `${c}`).join('')}` ).join(''); const html = `Ezza CRM — ${periodLabel}

Relatório de Desempenho — ${periodLabel}

Gerado em ${new Date().toLocaleDateString('pt-BR')} via Ezza CRM

${trs}
`; const w = window.open('', '_blank', 'width=800,height=600'); w.document.write(html); w.document.close(); w.onload = () => { w.focus(); w.print(); }; toast('📄 Abrindo visualização para impressão / PDF…'); } onClose(); }; return (
e.stopPropagation()} style={{width: 520}}>
Exportar relatório
{periodLabel}
{ICN.x}
Formato
{[ { id: 'pdf', label: 'PDF', sub: 'Apresentação com gráficos', size: '~2 MB', ic: '📄' }, { id: 'csv', label: 'CSV', sub: 'Dados brutos por linha', size: '~24 KB', ic: '📊' }, { id: 'xlsx', label: 'Excel', sub: 'Planilha com fórmulas', size: '~180 KB',ic: '📈' }, ].map(f => (
setFmt(f.id)}>
{f.ic}
{f.label}
{f.sub}
{f.size}
))}
O que incluir
{[ { id: 'todos', label: 'Relatório completo', hint: 'Todos os widgets desta tela' }, { id: 'kpis', label: 'Apenas KPIs do topo', hint: 'Receita, ticket médio, ciclo e conversão' }, { id: 'detal', label: 'Detalhado com transações', hint: 'Inclui anexo com lista de negócios' }, ].map(s => ( ))}
Enviar por e-mail também
Para: leticia.andrade@ezza.com.br
); } function AreaChart({ granul = 'diario', mult = 1, comparing = false }) { // Different data shapes per granularidade let pts; if (granul === 'diario') { pts = Array.from({length: 30}, (_, i) => { const v = 50 + 35 * Math.sin(i/4) + 18 * Math.cos(i/2.4) + (i * 1.5); return { y: v * mult }; }); } else if (granul === 'semanal') { pts = Array.from({length: 12}, (_, i) => { const v = 220 + 80 * Math.sin(i/2.2) + 40 * Math.cos(i/1.4) + (i * 8); return { y: v * mult }; }); } else { // mensal pts = Array.from({length: 12}, (_, i) => { const v = 980 + 220 * Math.sin(i/2.6) + 140 * Math.cos(i/1.6) + (i * 36); return { y: v * mult }; }); } // optional comparison series (previous period, dimmer) const pts2 = comparing ? pts.map((p, i) => ({ y: p.y * (0.78 + 0.15 * Math.sin(i/3)) })) : null; const W = 720, H = 320; const PAD_T = 32, PAD_B = 40, PAD_L = 44, PAD_R = 28; const all = pts2 ? [...pts, ...pts2] : pts; const ys = all.map(p => p.y); const range = Math.max(...ys) - Math.min(...ys); const minY = Math.min(...ys) - range * 0.18, maxY = Math.max(...ys) + range * 0.12; const sx = i => PAD_L + (i / (pts.length - 1)) * (W - PAD_L - PAD_R); const sy = v => H - PAD_B - ((v - minY) / (maxY - minY)) * (H - PAD_T - PAD_B); const smoothPath = (pts) => { if (pts.length < 2) return ''; let d = `M ${sx(0)},${sy(pts[0].y)}`; for (let i = 0; i < pts.length - 1; i++) { const p0 = pts[i - 1] || pts[i]; const p1 = pts[i]; const p2 = pts[i + 1]; const p3 = pts[i + 2] || p2; const cp1x = sx(i) + (sx(i+1) - (i > 0 ? sx(i-1) : sx(i))) / 6; const cp1y = sy(p1.y) + (sy(p2.y) - sy(p0.y)) / 6; const cp2x = sx(i+1) - ((i+2 < pts.length ? sx(i+2) : sx(i+1)) - sx(i)) / 6; const cp2y = sy(p2.y) - (sy(p3.y) - sy(p1.y)) / 6; d += ` C ${cp1x},${cp1y} ${cp2x},${cp2y} ${sx(i+1)},${sy(p2.y)}`; } return d; }; const line = smoothPath(pts); const area = `${line} L ${sx(pts.length - 1)},${H - PAD_B} L ${sx(0)},${H - PAD_B} Z`; const line2 = pts2 ? smoothPath(pts2) : null; const last = pts[pts.length - 1]; const yTicks = [0.25, 0.5, 0.75, 1]; const xLabels = granul === 'diario' ? [{ i: 0, t: '01' }, { i: 7, t: '08' }, { i: 14, t: '15' }, { i: 21, t: '22' }, { i: 29, t: '30' }] : granul === 'semanal' ? [{ i: 0, t: 'S1' }, { i: 3, t: 'S4' }, { i: 6, t: 'S7' }, { i: 9, t: 'S10' }, { i: 11, t: 'S12' }] : [{ i: 0, t: 'Jan' }, { i: 3, t: 'Abr' }, { i: 6, t: 'Jul' }, { i: 9, t: 'Out' }, { i: 11, t: 'Dez' }]; return ( {yTicks.map(p => { const y = PAD_T + p * (H - PAD_T - PAD_B); const value = Math.round(maxY - (maxY - minY) * p); return ( R${value < 1000 ? value + 'k' : (value/1000).toFixed(1) + 'M'} ); })} {line2 && ( )} {(() => { const step = granul === 'diario' ? 5 : granul === 'semanal' ? 3 : 3; return pts.filter((_, i) => i % step === 0 || i === pts.length - 1).map((p, i, arr) => { const idx = pts.indexOf(p); const isLast = idx === pts.length - 1; return ( {isLast && ( <> R${Math.round(p.y)}k )} ); }); })()} {xLabels.map((x,k) => ( {x.t} ))} {comparing && ( {/* placeholder kept empty — legend moved to HTML below chart */} )} ); } function Donut() { const data = [ { v: 42, c: 'var(--acc-blue)' }, { v: 28, c: 'var(--acc-yellow)' }, { v: 18, c: 'var(--acc-pink)' }, { v: 12, c: 'var(--acc-mint)' }, ]; const total = data.reduce((s,d) => s+d.v, 0); const C = 100, R = 70; const SW = 28; let acc = 0; return ( {data.map((d,i) => { const frac = d.v / total; const start = acc; acc += frac; const a1 = start * Math.PI * 2 - Math.PI/2; const a2 = acc * Math.PI * 2 - Math.PI/2; const x1 = C + R * Math.cos(a1), y1 = C + R * Math.sin(a1); const x2 = C + R * Math.cos(a2), y2 = C + R * Math.sin(a2); const large = frac > 0.5 ? 1 : 0; return ( ); })} 76 negócios ); } window.Relatorios = Relatorios;