// Comissoes e Metas — os dois paineis de gestao dos Relatorios Comerciais // // Moram fora de RelatoriosComerciais.jsx so por tamanho: aquele arquivo ja // carrega os dados e os filtros, e estes dois paineis sao telas de escrita, // nao de leitura. Quem busca do banco continua sendo o pai; aqui so chegam // props e callbacks. // --------------------------------------------------------------------- // Comissoes // --------------------------------------------------------------------- // A comissao nasce Pendente quando o orcamento e aprovado (trigger da // migration 056) e caminha Pendente -> Liberada -> Paga na mao. O valor // fica congelado no momento da aprovacao de proposito: mudar o percentual // amanha nao pode reescrever o que ja foi pago ontem. const COMISSAO_VARIANT = { 'Pendente': 'warning', 'Liberada': 'primary', 'Paga': 'success', }; function PainelComissoes(p) { const [salvando, setSalvando] = React.useState(false); const [editRegras, setEditRegras] = React.useState(null); // null = nao esta editando // Data da comissao e a data da venda, nao a do registro: os dois coincidem // hoje, mas o backfill da migration criou comissoes com created_at de // agora para vendas antigas, e por created_at elas cairiam no mes errado. const dataVenda = (c) => (c.budget?.aprovado_em || c.created_at || '').slice(0, 10); const lista = React.useMemo(() => p.comissoes.filter(c => { const d = dataVenda(c); if (p.ini && d && d < p.ini) return false; if (p.fim && d && d > p.fim) return false; if (p.fResponsavel !== 'todos' && String(c.responsavel_id || '') !== p.fResponsavel) return false; return true; }), [p.comissoes, p.ini, p.fim, p.fResponsavel]); // Comissao viva de orcamento que nao esta mais aprovado. Depois da // migration 057 isso so acontece com comissao ja liberada ou paga: o // dinheiro saiu, a venda caiu, e alguem precisa resolver na mao. const divergente = (c) => !!c.budget && c.budget.status !== 'Aprovado'; const divergentes = lista.filter(divergente); const soma = (st) => lista .filter(c => (st ? c.status === st : true)) .reduce((s, c) => s + (Number(c.valor_comissao) || 0), 0); const nomeCli = (c) => c.cliente?.name || c.budget?.client_name || 'Sem cliente'; // Aviso honesto: com percentual 0 a tela mostra "R$ 0,00" em tudo e // parece quebrada. O problema nao e a tela, e a regra nao configurada. const semRegra = p.regras.length === 0 || p.regras.every(r => !Number(r.percentual)); // Comissao pendente cujo cliente continua sem tipo de venda no CRM: nenhuma // regra casa e ela vale 0% para sempre. Nao da para consertar aqui — o // campo mora no cadastro do cliente — entao a tela avisa e diz onde ir. const semTipo = lista.filter(c => c.status === 'Pendente' && !(c.cliente?.tipo_venda)); const clientesSemTipo = [...new Set(semTipo.map(nomeCli))]; const mudarStatus = async (c, novo) => { // Voltar para Pendente uma comissao cuja venda foi desfeita nao e // "voltar um passo": e desfazer a comissao. Manter a linha recriaria o // estado que a migration 057 existe para impedir — pendente sem venda // aprovada. O trigger do banco nao alcanca este caminho, porque quem // muda aqui e a comissao, nao o status do orcamento. if (novo === 'Pendente' && divergente(c)) { if (!window.confirm('A venda deste orçamento foi desfeita.\n\nReverter aqui APAGA a comissão. Confirmar?')) return; const { error } = await sb.from('comissoes').delete().eq('id', c.id); if (error) { p.onToast('Não consegui remover a comissão.', 'error'); return; } p.onRecarregar(); p.onToast('Comissão removida — o orçamento não está mais aprovado.'); return; } const patch = { status: novo }; if (novo === 'Liberada') { patch.liberada_em = new Date().toISOString(); patch.pago_em = null; patch.pago_por = null; } if (novo === 'Paga') { patch.pago_em = new Date().toISOString(); patch.pago_por = p.currentUser?.id || null; } if (novo === 'Pendente') { patch.liberada_em = null; patch.pago_em = null; patch.pago_por = null; } const { error } = await sb.from('comissoes').update(patch).eq('id', c.id); if (error) { p.onToast('Não consegui atualizar a comissão.', 'error'); return; } p.onMudou(c.id, patch); p.onToast(`Comissão marcada como ${novo}.`); }; const salvarRegras = async () => { setSalvando(true); const erros = []; for (const [tipo, valor] of Object.entries(editRegras)) { const pct = Math.min(100, Math.max(0, parseFloat(String(valor).replace(',', '.')) || 0)); const existente = p.regras.find(r => r.tipo_venda === tipo); const res = existente ? await sb.from('comissao_regras').update({ percentual: pct, updated_at: new Date().toISOString() }).eq('id', existente.id) : await sb.from('comissao_regras').insert({ tipo_venda: tipo, percentual: pct }); if (res.error) erros.push(tipo); } setSalvando(false); if (erros.length) { p.onToast(`Não consegui salvar: ${erros.join(', ')}.`, 'error'); return; } setEditRegras(null); p.onRecarregar(); p.onToast('Regras de comissão salvas.'); }; // As comissoes ja geradas guardam o percentual antigo. Quando a regra e // definida pela primeira vez, todas elas ficariam em zero para sempre — // por isso este botao. So mexe nas Pendentes: liberada ou paga e passado. // // O tipo de venda tambem e relido do CRM, nao so o percentual. A comissao // nasce com o tipo congelado na aprovacao, e uma venda aprovada antes de o // cliente ser classificado nasce "Nao classificado" — sem isso ela ficaria // presa em 0% para sempre, porque nenhuma regra casa com tipo nulo. // Classificar o cliente no CRM e clicar aqui e o caminho de conserto. const recalcularPendentes = async () => { if (!window.confirm('Recalcular todas as comissões PENDENTES?\n\nO tipo de venda é relido do cadastro atual do cliente no CRM, e o percentual, das regras atuais.\n\nComissões liberadas ou pagas não são alteradas.')) return; setSalvando(true); const pendentes = p.comissoes.filter(c => c.status === 'Pendente'); let alteradas = 0; for (const c of pendentes) { // Cliente apagado depois da venda: sem cadastro para reler, o tipo // congelado na comissao e a melhor informacao que existe. const tipo = c.cliente ? (c.cliente.tipo_venda || null) : (c.tipo_venda || null); const regra = p.regras.find(r => r.tipo_venda === tipo && r.ativo); const pct = Number(regra?.percentual) || 0; if (pct === Number(c.percentual) && tipo === (c.tipo_venda || null)) continue; const { error } = await sb.from('comissoes') .update({ tipo_venda: tipo, percentual: pct }).eq('id', c.id); if (!error) alteradas++; } setSalvando(false); p.onRecarregar(); p.onToast(alteradas > 0 ? `${alteradas} comissão(ões) recalculada(s).` : 'Nada a recalcular.'); }; const colunas = [ { label: 'Data da venda', get: c => (dataVenda(c) || '').split('-').reverse().join('/') }, { label: 'Orçamento', get: c => c.budget?.code || '' }, { label: 'Cliente', get: c => nomeCli(c) }, { label: 'Projeto/Ação', get: c => c.budget?.event_name || '' }, { label: 'Responsável', get: c => c.responsavel?.name || 'Sem responsável' }, { label: 'Tipo de venda', get: c => c.tipo_venda || 'Não classificado' }, { label: 'Valor da venda', get: c => (Number(c.valor_venda) || 0).toFixed(2).replace('.', ','), show: c => fmtBRL(Number(c.valor_venda) || 0) }, { label: '%', get: c => (Number(c.percentual) || 0).toFixed(2).replace('.', ','), show: c => (Number(c.percentual) || 0).toFixed(2).replace('.', ',') + '%' }, { label: 'Comissão', get: c => (Number(c.valor_comissao) || 0).toFixed(2).replace('.', ','), show: c => fmtBRL(Number(c.valor_comissao) || 0) }, { label: 'Situação', get: c => c.status || '' }, ]; return (
{divergentes.length > 0 && (
{divergentes.length} comissão(ões) de orçamento que não está mais aprovado. {' '} A venda foi desfeita depois que a comissão já havia sido liberada ou paga ({divergentes.map(c => c.budget?.code).filter(Boolean).join(', ')}). Use Reverter para apagar a comissão, ou combine o acerto com a pessoa.
)} {semRegra && (
Nenhum percentual definido ainda. Enquanto as regras estiverem em 0%, toda comissão calculada vale R$ 0,00. Defina os percentuais no card abaixo e use Recalcular pendentes para aplicar às comissões que já foram geradas.
)} {semTipo.length > 0 && (
{semTipo.length} comissão(ões) pendente(s) sem tipo de venda. {' '} O percentual vem do Tipo de venda do cliente, e {clientesSemTipo.length === 1 ? 'este cliente ainda não foi classificado' : 'estes clientes ainda não foram classificados'} no CRM:{' '} {clientesSemTipo.join(', ')}. Classifique {clientesSemTipo.length === 1 ? 'o cliente' : 'os clientes'} no CRM e volte aqui em Recalcular pendentes — o tipo é relido do cadastro e o percentual, aplicado.
)} {/* Regras de comissão — percentual por origem da venda (item 4) */} {editRegras === null ? ( <> Recalcular pendentes setEditRegras( Object.fromEntries(TIPOS_VENDA.map(t => [ t, String(p.regras.find(r => r.tipo_venda === t)?.percentual ?? 0), ])) )}>Editar ) : ( <> setEditRegras(null)} disabled={salvando}>Cancelar {salvando ? 'Salvando…' : 'Salvar'} )}
} />
{TIPOS_VENDA.map(t => { const atual = Number(p.regras.find(r => r.tipo_venda === t)?.percentual) || 0; return (
{t}
{editRegras === null ?
0 ? DS.colors.text : DS.colors.textMuted }}> {atual.toFixed(2).replace('.', ',')}%
: setEditRegras(r => ({ ...r, [t]: v }))} placeholder="0" />}
); })}
{ if (col.label !== 'Situação') return null; return (
{c.status} {divergente(c) && venda desfeita} {c.status === 'Pendente' && ( mudarStatus(c, 'Liberada')}>Liberar )} {c.status === 'Liberada' && ( mudarStatus(c, 'Paga')}>Marcar paga )} {c.status !== 'Pendente' && ( mudarStatus(c, 'Pendente')}>Reverter )}
); }} /> ); } // --------------------------------------------------------------------- // Metas comerciais (item 6) // --------------------------------------------------------------------- // Uma meta por pessoa por mes, mais a meta da empresa (responsavel_id nulo). // A meta da empresa nao e a soma das individuais de proposito: a gestao pode // querer um numero de time maior do que a soma dos compromissos de cada um. const MESES_NOMES = ['Janeiro', 'Fevereiro', 'Março', 'Abril', 'Maio', 'Junho', 'Julho', 'Agosto', 'Setembro', 'Outubro', 'Novembro', 'Dezembro']; function PainelMetas(p) { const hoje = new Date(); const [ano, setAno] = React.useState(hoje.getFullYear()); const [mes, setMes] = React.useState(hoje.getMonth() + 1); const [rascunho, setRascunho] = React.useState(null); // null = nada editado ainda const [salvando, setSalvando] = React.useState(false); const doMes = React.useMemo( () => p.metas.filter(m => Number(m.ano) === Number(ano) && Number(m.mes) === Number(mes)), [p.metas, ano, mes] ); // 'empresa' e a chave da meta sem responsavel. Usar a string evita o // tropeco classico de null virar "null" como chave de objeto. const chaveDe = (respId) => (respId ? String(respId) : 'empresa'); const valorDe = (respId) => { const k = chaveDe(respId); if (rascunho && k in rascunho) return rascunho[k]; const m = doMes.find(x => chaveDe(x.responsavel_id) === k); return m ? String(m.valor) : ''; }; const setValor = (respId, v) => setRascunho(r => ({ ...(r || {}), [chaveDe(respId)]: v })); const num = (v) => { const n = parseFloat(String(v ?? '').replace(',', '.')); return Number.isFinite(n) && n >= 0 ? n : 0; }; const somaIndividuais = p.usuarios.reduce((s, u) => s + num(valorDe(u.id)), 0); const metaEmpresa = num(valorDe(null)); // Sem upsert: o indice unico da tabela e por expressao (coalesce do // responsavel_id), e o PostgREST so sabe resolver conflito em coluna. const salvar = async () => { if (!rascunho) return; setSalvando(true); const erros = []; for (const [k, bruto] of Object.entries(rascunho)) { const respId = k === 'empresa' ? null : k; const valor = num(bruto); const existente = doMes.find(x => chaveDe(x.responsavel_id) === k); let res; if (existente) { res = await sb.from('metas_comerciais') .update({ valor, updated_at: new Date().toISOString() }).eq('id', existente.id); } else if (valor > 0) { // Meta zerada que nunca existiu nao vira linha: o banco ficaria cheio // de zeros sem significado, e "sem meta" ja e o estado natural. res = await sb.from('metas_comerciais') .insert({ ano: Number(ano), mes: Number(mes), responsavel_id: respId, valor }); } else { continue; } if (res.error) erros.push(k); } setSalvando(false); if (erros.length) { p.onToast('Não consegui salvar todas as metas.', 'error'); return; } setRascunho(null); p.onRecarregar(); p.onToast('Metas salvas.'); }; const anos = []; for (let a = hoje.getFullYear() - 2; a <= hoje.getFullYear() + 2; a++) anos.push(a); const trocarPeriodo = (aplicar) => { // Trocar de mes com rascunho aberto perderia o que foi digitado sem aviso. if (rascunho && !window.confirm('Você tem metas não salvas. Trocar o período vai descartá-las.')) return; setRascunho(null); aplicar(); }; return (
trocarPeriodo(() => setAno(Number(v)))} options={anos.map(a => ({ value: String(a), label: String(a) }))} />
0 ? fmtBRL(metaEmpresa) : 'Não definida'} icon="award" color={DS.colors.warning} subtitle={`${MESES_NOMES[mes - 1]} de ${ano}`} />
{rascunho && setRascunho(null)} disabled={salvando}>Descartar} {salvando ? 'Salvando…' : 'Salvar metas'}
} />
Meta da empresa
setValor(null, v)} placeholder="0,00" style={{ width: 170 }} />
{p.usuarios.length === 0 ? : p.usuarios.map(u => (
{u.name || u.email}
setValor(u.id, v)} placeholder="0,00" style={{ width: 170 }} />
))}
); }