// 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 (
{/* 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 (
>
);
}
// 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 (
);
}
// ---------------------------------------------------------------------
// 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)}>ExcelPDF
}
/>
{linhas.length === 0
?
: (