// Relatorios Comerciais — painel de gestao comercial // // Tudo aqui deriva de budgets. Nada e digitado duas vezes: o orcamento // guarda responsavel, cliente e data de aprovacao (migration 056), e os // numeros desta tela sao leitura disso. // // VENDA = orcamento com status 'Aprovado'. A data da venda e aprovado_em, // nao issued_date: um orcamento emitido em janeiro e aprovado em marco e // venda de marco. Essa distincao e o que faz "vendas do mes" bater com o // caixa. // --------------------------------------------------------------------- // Funil — de-para entre os status reais do banco e as etapas da spec // --------------------------------------------------------------------- // A spec desenha Emitidos -> Enviados -> Em negociacao -> Aprovados -> // Perdidos. O banco tem oito status (shared.jsx:814), herdados do portal // do cliente. Renomear quebraria Budgets.jsx e a policy da migration 019, // entao o de-para mora aqui. const FUNIL_ETAPAS = [ { key: 'emitidos', label: 'Emitidos', status: null, color: DS.colors.textSec }, { key: 'enviados', label: 'Enviados', status: ['Enviado', 'Visualizado'], color: DS.colors.primary }, { key: 'negociacao', label: 'Em negociação', status: ['Em análise', 'Solicitou ajustes'], color: DS.colors.warning }, { key: 'aprovados', label: 'Aprovados', status: ['Aprovado'], color: DS.colors.success }, { key: 'perdidos', label: 'Perdidos', status: ['Recusado', 'Rejeitado'], color: DS.colors.danger }, ]; const STATUS_APROVADO = 'Aprovado'; const STATUS_PERDIDOS = ['Recusado', 'Rejeitado']; // --------------------------------------------------------------------- // Atalhos de periodo (item 5 da spec) // --------------------------------------------------------------------- // Devolve [inicio, fim] em ISO (YYYY-MM-DD). Construidos com getFullYear/ // getMonth em vez de toISOString para nao cair no fuso: toISOString de // 1o de setembro em Fortaleza volta 31 de agosto. function isoData(d) { return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; } function periodoRange(key) { const hoje = new Date(); const y = hoje.getFullYear(); const m = hoje.getMonth(); switch (key) { case 'hoje': return [isoData(hoje), isoData(hoje)]; case 'semana': { const ini = new Date(hoje); ini.setDate(hoje.getDate() - hoje.getDay()); // domingo const fim = new Date(ini); fim.setDate(ini.getDate() + 6); return [isoData(ini), isoData(fim)]; } case 'mes': return [isoData(new Date(y, m, 1)), isoData(new Date(y, m + 1, 0))]; case 'mes_anterior': return [isoData(new Date(y, m - 1, 1)), isoData(new Date(y, m, 0))]; case 'ano': return [isoData(new Date(y, 0, 1)), isoData(new Date(y, 11, 31))]; default: return [null, null]; } } const PERIODO_OPCOES = [ { value: 'mes', label: 'Este mês' }, { value: 'hoje', label: 'Hoje' }, { value: 'semana', label: 'Esta semana' }, { value: 'mes_anterior', label: 'Mês anterior' }, { value: 'ano', label: 'Este ano' }, { value: 'custom', label: 'Personalizado' }, ]; // --------------------------------------------------------------------- // Exportacao (item 7) // --------------------------------------------------------------------- // CSV com BOM e separador ponto-e-virgula: e o que o Excel em portugues // abre sem passar pelo assistente de importacao. function baixarCSV(nomeArquivo, colunas, linhas) { const escapa = (v) => { const s = v === null || v === undefined ? '' : String(v); return /[";\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s; }; const corpo = [ colunas.map(c => escapa(c.label)).join(';'), ...linhas.map(l => colunas.map(c => escapa(c.get(l))).join(';')), ].join('\r\n'); const blob = new Blob(['' + corpo], { type: 'text/csv;charset=utf-8;' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `${nomeArquivo}.csv`; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); } function baixarPDF(elementoId, nomeArquivo) { const el = document.getElementById(elementoId); if (!el || typeof html2pdf === 'undefined') return false; html2pdf().set({ margin: 8, filename: `${nomeArquivo}.pdf`, image: { type: 'jpeg', quality: 0.95 }, html2canvas: { scale: 2, useCORS: true }, jsPDF: { unit: 'mm', format: 'a4', orientation: 'landscape' }, }).from(el).save(); return true; } // --------------------------------------------------------------------- // Tela // --------------------------------------------------------------------- function RelatoriosComerciais({ currentUser }) { const [aba, setAba] = React.useState('dashboard'); const [loading, setLoading] = React.useState(true); const [erro, setErro] = React.useState(null); const [budgets, setBudgets] = React.useState([]); const [usuarios, setUsuarios] = React.useState([]); const [metas, setMetas] = React.useState([]); const [comissoes, setComissoes] = React.useState([]); const [regras, setRegras] = React.useState([]); const [toast, setToast] = React.useState(null); // Filtros (item 5) const [periodo, setPeriodo] = React.useState('mes'); const [de, setDe] = React.useState(''); const [ate, setAte] = React.useState(''); const [fResponsavel, setResponsavel] = React.useState('todos'); const [fCliente, setCliente] = React.useState('todos'); const [fStatus, setStatus] = React.useState('todos'); const [fTipoVenda, setTipoVenda] = React.useState('todos'); const [fProjeto, setProjeto] = React.useState(''); const showToast = (msg, type = 'success') => { setToast({ msg, type }); setTimeout(() => setToast(null), 3200); }; // Uma carga so para a tela inteira. Os paineis de escrita (Comissoes e // Metas) chamam carregar() de novo depois de salvar, para a tela refletir // o banco sem o usuario precisar dar F5. const carregar = async () => { const [bq, uq, mq, cq, rq] = await Promise.all([ sb.from('budgets').select( '*, responsavel:responsavel_id(id,name), cliente:client_id(id,name,tipo_venda)' ), sb.from('app_users').select('id,name,email,photo_url').order('name'), sb.from('metas_comerciais').select('*'), sb.from('comissoes').select( '*, responsavel:responsavel_id(id,name), cliente:client_id(id,name,tipo_venda), budget:budget_id(id,code,client_name,event_name,aprovado_em,status)' ), sb.from('comissao_regras').select('*'), ]); // Se a migration 056 ainda nao foi aplicada, a query do join falha. // Dizer isso e melhor do que mostrar uma tela vazia sem explicacao. const falha = bq.error || uq.error || cq.error || rq.error || mq.error; if (falha) { setErro(falha.message || 'Falha ao carregar os dados comerciais.'); setLoading(false); return; } setErro(null); setBudgets(bq.data || []); setUsuarios(uq.data || []); setMetas(mq.data || []); setComissoes(cq.data || []); setRegras(rq.data || []); setLoading(false); }; React.useEffect(() => { carregar(); }, []); // Atualiza uma comissao na memoria depois do update no banco. Evita // recarregar a tela toda so para trocar um badge de situacao. const comissaoMudou = (id, patch) => setComissoes(prev => prev.map(c => (c.id === id ? { ...c, ...patch } : c))); // ------------------------------------------------------------------- // Filtragem // ------------------------------------------------------------------- const [ini, fim] = React.useMemo( () => (periodo === 'custom' ? [de || null, ate || null] : periodoRange(periodo)), [periodo, de, ate] ); // Data que define em qual periodo o orcamento cai. Para venda e a // aprovacao; para o resto e a emissao. const dataRef = (b, comoVenda) => { if (comoVenda) return (b.aprovado_em || '').slice(0, 10) || null; return (b.created_at || '').slice(0, 10) || null; }; const dentroDoPeriodo = (data) => { if (!data) return false; if (ini && data < ini) return false; if (fim && data > fim) return false; return true; }; const casaProjeto = (b) => { if (!fProjeto.trim()) return true; return `${b.event_name || ''} ${b.code || ''}`.toLowerCase().includes(fProjeto.trim().toLowerCase()); }; // Orcamentos do periodo (pela emissao) const orcamentos = React.useMemo( () => budgets.filter(b => dentroDoPeriodo(dataRef(b, false)) && (fResponsavel === 'todos' || String(b.responsavel_id || '') === fResponsavel) && (fCliente === 'todos' || String(b.client_id || '') === fCliente) && (fStatus === 'todos' || b.status === fStatus) && (fTipoVenda === 'todos' || (b.cliente?.tipo_venda || '') === fTipoVenda) && casaProjeto(b) ), [budgets, ini, fim, fResponsavel, fCliente, fStatus, fTipoVenda, fProjeto] ); // Vendas do periodo (pela aprovacao) — ignora o filtro de status de // proposito: venda e sempre Aprovado. const vendas = React.useMemo( () => budgets.filter(b => b.status === STATUS_APROVADO && dentroDoPeriodo(dataRef(b, true)) && (fResponsavel === 'todos' || String(b.responsavel_id || '') === fResponsavel) && (fCliente === 'todos' || String(b.client_id || '') === fCliente) && (fTipoVenda === 'todos' || (b.cliente?.tipo_venda || '') === fTipoVenda) && casaProjeto(b) ), [budgets, ini, fim, fResponsavel, fCliente, fTipoVenda, fProjeto] ); const val = (b) => Number(b.value) || 0; const soma = (lista) => lista.reduce((s, b) => s + val(b), 0); // ------------------------------------------------------------------- // KPIs do dashboard (item 6) // ------------------------------------------------------------------- const valorOrcado = soma(orcamentos); const aprovadosNoLote = orcamentos.filter(b => b.status === STATUS_APROVADO); const valorAprovado = soma(aprovadosNoLote); const perdidos = orcamentos.filter(b => STATUS_PERDIDOS.includes(b.status)); const valorPerdido = soma(perdidos); // Conversao por QUANTIDADE, sobre o que ja foi decidido (aprovado + // perdido). Incluir os em aberto no denominador faria a taxa despencar // so porque ha proposta em negociacao, o que nao e perda. const decididos = aprovadosNoLote.length + perdidos.length; const conversao = decididos > 0 ? (aprovadosNoLote.length / decididos) * 100 : 0; const totalVendido = soma(vendas); const ticketMedio = vendas.length > 0 ? totalVendido / vendas.length : 0; const metaPeriodo = React.useMemo(() => { // Meta do mes de referencia do periodo. responsavel_id nulo na tabela // = meta da empresa; sem ela, soma as individuais. const base = ini ? new Date(ini + 'T12:00:00') : new Date(); const ano = base.getFullYear(); const mes = base.getMonth() + 1; const doMes = metas.filter(m => m.ano === ano && m.mes === mes); if (fResponsavel !== 'todos') { const m = doMes.find(x => String(x.responsavel_id || '') === fResponsavel); return Number(m?.valor) || 0; } const empresa = doMes.find(x => !x.responsavel_id); if (empresa) return Number(empresa.valor) || 0; return doMes.reduce((s, m) => s + (Number(m.valor) || 0), 0); }, [metas, ini, fResponsavel]); const pctMeta = metaPeriodo > 0 ? (totalVendido / metaPeriodo) * 100 : 0; // ------------------------------------------------------------------- // Agrupamentos // ------------------------------------------------------------------- const nomeResp = (b) => b.responsavel?.name || 'Sem responsável'; const nomeCli = (b) => b.cliente?.name || b.client_name || 'Sem cliente'; const agrupa = (lista, chave) => { const mapa = new Map(); lista.forEach(b => { const k = chave(b); const at = mapa.get(k) || { chave: k, qtd: 0, total: 0 }; at.qtd += 1; at.total += val(b); mapa.set(k, at); }); return [...mapa.values()].sort((a, b) => b.total - a.total); }; const porResponsavel = React.useMemo(() => agrupa(vendas, nomeResp), [vendas]); const porCliente = React.useMemo(() => agrupa(vendas, nomeCli), [vendas]); // Orcamentos (nao vendas) por responsavel: a spec pede os dois cortes, e // eles contam coisas diferentes — quem EMITE muito e quem FECHA muito nao // sao a mesma pessoa, e a distancia entre as duas listas e o diagnostico. const orcPorResponsavel = React.useMemo(() => agrupa(orcamentos, nomeResp), [orcamentos]); // "Pendente" = ainda em jogo: nem aprovado, nem perdido. E o oposto de // "decidido", a mesma divisao que a taxa de conversao usa. const pendentes = React.useMemo( () => orcamentos.filter(b => b.status !== STATUS_APROVADO && !STATUS_PERDIDOS.includes(b.status)), [orcamentos] ); const valorPendente = soma(pendentes); // Comissoes a pagar = tudo que ainda nao foi pago (pendente + liberada). // Recorta pelo mesmo periodo do resto da tela, pela data da venda. const comissaoAPagar = React.useMemo(() => comissoes .filter(c => { if (c.status === 'Paga') return false; const d = (c.budget?.aprovado_em || c.created_at || '').slice(0, 10); if (ini && d && d < ini) return false; if (fim && d && d > fim) return false; if (fResponsavel !== 'todos' && String(c.responsavel_id || '') !== fResponsavel) return false; return true; }) .reduce((s, c) => s + (Number(c.valor_comissao) || 0), 0), [comissoes, ini, fim, fResponsavel] ); // Ranking com meta individual do mes de referencia. const ranking = React.useMemo(() => { const base = ini ? new Date(ini + 'T12:00:00') : new Date(); const ano = base.getFullYear(), mes = base.getMonth() + 1; return porResponsavel.map(r => { const u = usuarios.find(x => x.name === r.chave); const m = metas.find(x => x.ano === ano && x.mes === mes && x.responsavel_id === u?.id); const meta = Number(m?.valor) || 0; return { ...r, meta, pct: meta > 0 ? (r.total / meta) * 100 : null }; }); }, [porResponsavel, usuarios, metas, ini]); // Vendas por mes — 12 meses ate o fim do periodo. Nao usa o filtro de // periodo de proposito: e a serie historica, o resto da tela e o recorte. const vendasPorMes = React.useMemo(() => { const fimRef = fim ? new Date(fim + 'T12:00:00') : new Date(); const out = []; for (let i = 11; i >= 0; i--) { const d = new Date(fimRef.getFullYear(), fimRef.getMonth() - i, 1); const chave = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`; const total = budgets .filter(b => b.status === STATUS_APROVADO && (b.aprovado_em || '').slice(0, 7) === chave) .reduce((s, b) => s + val(b), 0); out.push({ label: d.toLocaleDateString('pt-BR', { month: 'short' }).replace('.', ''), value: total }); } return out; }, [budgets, fim]); const funil = React.useMemo(() => FUNIL_ETAPAS.map(et => { const lista = et.status === null ? orcamentos : orcamentos.filter(b => et.status.includes(b.status)); return { ...et, qtd: lista.length, total: soma(lista) }; }), [orcamentos]); const clientesOpcoes = React.useMemo(() => { const mapa = new Map(); budgets.forEach(b => { if (b.client_id) mapa.set(String(b.client_id), b.cliente?.name || b.client_name || '—'); }); return [...mapa.entries()].sort((a, b) => String(a[1]).localeCompare(String(b[1]))); }, [budgets]); // ------------------------------------------------------------------- // Render // ------------------------------------------------------------------- if (loading) return
; if (erro) { return (
Não consegui carregar os relatórios
{erro}
Se a mensagem fala em coluna ou relação que não existe, a migration{' '} 056_relatorios_comerciais.sql {' '} ainda não foi aplicada. Rode-a no Supabase → SQL Editor e recarregue esta página.
); } // "Geral" e o relatorio operacional que ja existia (Reports.jsx): KPIs // de eventos e freelancers. Fica aqui como aba para nao sumir do menu. const ABAS = [ { id: 'dashboard', label: 'Dashboard', icon: 'dashboard' }, { id: 'vendas', label: 'Vendas', icon: 'trending' }, { id: 'orcamentos', label: 'Orçamentos', icon: 'budget' }, { id: 'comissoes', label: 'Comissões', icon: 'wallet' }, { id: 'metas', label: 'Metas', icon: 'award' }, { id: 'geral', label: 'Geral', icon: 'reports' }, ]; return (
{toast && setToast(null)} />}
{ABAS.map(a => (
setAba(a.id)} style={{ display: 'flex', alignItems: 'center', gap: 7, cursor: 'pointer', padding: '9px 16px', borderRadius: DS.radius.full, fontSize: 13, fontWeight: 600, background: aba === a.id ? DS.colors.primary : DS.colors.card, color: aba === a.id ? '#fff' : DS.colors.textSec, border: `1px solid ${aba === a.id ? DS.colors.primary : DS.colors.border}`, transition: 'all .15s', }}> {a.label}
))}
{/* Metas escolhe o proprio mes e Geral e o relatorio operacional antigo — nos dois a barra de periodo so confundiria. */} {aba !== 'geral' && aba !== 'metas' && ( )} {aba === 'geral' && } {aba === 'dashboard' && ( )} {aba === 'vendas' && ( )} {aba === 'comissoes' && ( )} {aba === 'metas' && ( )} {aba === 'orcamentos' && ( )}
); } // --------------------------------------------------------------------- // Barra de filtros // --------------------------------------------------------------------- function FiltrosBar(p) { return (
)} ({ value: id, label: nome }))]} /> {p.mostrarStatus && ( ({ value: t, label: t }))]} />
); } // --------------------------------------------------------------------- // Aba Dashboard // --------------------------------------------------------------------- function PainelDashboard(p) { const cards = [ { label: 'Valor orçado', value: fmtBRL(p.valorOrcado), icon: 'budget', color: DS.colors.textSec }, { label: 'Valor aprovado', value: fmtBRL(p.valorAprovado), icon: 'check', color: DS.colors.success }, { label: 'Taxa de conversão', value: `${p.conversao.toFixed(1)}%`, icon: 'trending', color: DS.colors.purple }, { label: 'Vendas do período', value: fmtBRL(p.totalVendido), icon: 'wallet', color: DS.colors.primary }, { label: 'Meta do período', value: p.metaPeriodo > 0 ? fmtBRL(p.metaPeriodo) : 'Não definida', icon: 'award', color: DS.colors.warning, subtitle: p.metaPeriodo > 0 ? `${p.pctMeta.toFixed(0)}% atingido` : 'Cadastre a meta do mês' }, { label: 'Comissões a pagar', value: fmtBRL(p.comissaoAPagar), icon: 'users', color: DS.colors.purple, subtitle: 'Pendentes e liberadas' }, { label: 'Valor perdido', value: fmtBRL(p.valorPerdido), icon: 'alert', color: DS.colors.danger }, ]; const maxResp = Math.max(1, ...p.porResponsavel.map(r => r.total)); return ( <>
{cards.map(c => ( ))}
{p.porResponsavel.length === 0 ? : p.porResponsavel.slice(0, 8).map(r => (
{r.chave} {fmtBRL(r.total)} · {r.qtd}
))}
); } // Barras horizontais simples — o MiniBarChart do shared.jsx e para serie // temporal; aqui sao tres categorias com escala propria. function BarrasComparativas({ itens }) { const max = Math.max(1, ...itens.map(i => i.value)); return (
{itens.map(i => (
{i.label} {fmtBRL(i.value)}
))}
); } function Funil({ etapas }) { const max = Math.max(1, ...etapas.map(e => e.qtd)); return (
{etapas.map(e => (
{e.label} {e.qtd} · {fmtBRL(e.total)}
))}
); } // --------------------------------------------------------------------- // Aba Vendas // --------------------------------------------------------------------- function PainelVendas(p) { const colunas = [ { label: 'Data da venda', get: b => (b.aprovado_em || '').slice(0, 10).split('-').reverse().join('/') }, { label: 'Orçamento', get: b => b.code || '' }, { label: 'Cliente', get: b => p.nomeCli(b) }, { label: 'Projeto/Ação', get: b => b.event_name || '' }, { label: 'Responsável', get: b => p.nomeResp(b) }, { label: 'Tipo cliente', get: b => b.cliente?.tipo_venda || '' }, { label: 'Valor', get: b => (Number(b.value) || 0).toFixed(2).replace('.', ','), show: b => fmtBRL(Number(b.value) || 0) }, ]; return (
0 ? fmtBRL(p.metaPeriodo) : '—'} icon="award" color={DS.colors.warning} subtitle={p.metaPeriodo > 0 ? `${p.pctMeta.toFixed(0)}% atingido` : 'Não definida'} />
{p.ranking.length === 0 ? : (
{p.ranking.map((r, i) => ( ))}
# Responsável Vendas Valor % meta
{i + 1} {r.chave} {r.qtd} {fmtBRL(r.total)} {r.pct === null ? — : = 100 ? 'success' : r.pct >= 70 ? 'warning' : 'danger'}> {r.pct.toFixed(0)}% }
)}
{p.porCliente.length === 0 ? : p.porCliente.slice(0, 10).map(c => (
{c.chave} {fmtBRL(c.total)} · {c.qtd}
))}
); } // --------------------------------------------------------------------- // Aba Orcamentos // --------------------------------------------------------------------- function PainelOrcamentos(p) { const colunas = [ { label: 'Emissão', get: b => b.issued_date || (b.created_at || '').slice(0, 10).split('-').reverse().join('/') }, { label: 'Orçamento', get: b => b.code || '' }, { label: 'Cliente', get: b => p.nomeCli(b) }, { label: 'Projeto/Ação', get: b => b.event_name || '' }, { label: 'Responsável', get: b => p.nomeResp(b) }, { label: 'Status', get: b => b.status || '' }, { label: 'Motivo perda', get: b => b.motivo_perda || '' }, { label: 'Valor', get: b => (Number(b.value) || 0).toFixed(2).replace('.', ','), show: b => fmtBRL(Number(b.value) || 0) }, ]; return (
{p.funil.map(e => (
{e.label}
{e.qtd}
{fmtBRL(e.total)}
))}
{p.orcPorResponsavel.length === 0 ? : (
{p.orcPorResponsavel.map(r => { // Recontado aqui, e nao no agrupamento, porque o agrupa() // e generico: soma quantidade e valor, nao conhece status. const doResp = p.orcamentos.filter(b => p.nomeResp(b) === r.chave); const aprov = doResp.filter(b => b.status === 'Aprovado').length; const perd = doResp.filter(b => ['Recusado', 'Rejeitado'].includes(b.status)).length; const decid = aprov + perd; return ( ); })}
Responsável Emitidos Valor orçado Aprovados Conversão
{r.chave} {r.qtd} {fmtBRL(r.total)} {aprov} {decid === 0 ? — : `${((aprov / decid) * 100).toFixed(0)}%`}
)}
); } // --------------------------------------------------------------------- // Tabela com exportacao (item 7) // --------------------------------------------------------------------- // renderCelula deixa um painel desenhar uma celula por conta propria (as // Comissoes poem botoes de acao na coluna de situacao). Devolver null cai // no comportamento padrao, entao quem nao passa nada nao muda de forma. // // Cada coluna pode ainda trazer um show(linha) para a versao de tela. O // get(linha) fica sendo so o do CSV: o Excel em portugues le "1330,00" // como numero e "R$ 1.330,00" como texto, e quem soma a coluna quer numero. function TabelaRelatorio({ titulo, subtitulo, colunas, linhas, nomeArquivo, elementoId, onExportar, renderCelula }) { const exportarPDF = () => { const ok = baixarPDF(elementoId, nomeArquivo); if (!ok) onExportar('Não foi possível gerar o PDF nesta tela.', 'error'); }; return ( baixarCSV(nomeArquivo, colunas, linhas)}>Excel PDF } /> {linhas.length === 0 ? : (
{colunas.map(c => ( ))} {linhas.map(l => ( {colunas.map(c => ( ))} ))}
{c.label}
{(renderCelula ? renderCelula(l, c) : null) ?? (c.label === 'Status' ? {l.status} : (c.show ? c.show(l) : c.get(l)))}
)}
); }