// Indicadores automáticos do CRM — calculados a partir dos registros reais // (events.client_id e budgets.client_id), nunca de campos digitados à mão. // // Regras combinadas com a Silvana (out/2026): // - Ação realizada = evento do cliente, não cancelado, que já começou. // Não dá para usar o status: quase todo evento fica "Planejamento" mesmo // depois de acontecer. // - Receita = soma dos orçamentos "Aprovado". O mesmo código de orçamento // aparece duplicado em alguns casos (#200, #219...): conta uma vez só, a // versão mais recente. // - Ticket médio = receita ÷ nº de ações (sem ação ainda, = receita). // - Última atividade = a mais recente entre a última ação realizada e a // última aprovação de orçamento. Evento agendado também conta como ativo. // - Próxima ação sugerida: orçamento "Enviado" há dias_followup+ dias → // follow-up; cliente Inativo → recuperação; dias_contato+ dias sem // contratar → contato. Evento agendado dispensa contato/recuperação. // - Status: Prospect (nunca contratou) / Ativo (atividade em até // dias_inativo ou evento agendado) / Inativo / Recuperado (voltou a // contratar depois de um intervalo maior que dias_inativo). // clients.status_manual, quando preenchido, vence o cálculo. const CRM_CONFIG_PADRAO = { dias_inativo: 90, dias_contato: 60, dias_followup: 5 }; const CRM_STATUS = ['Prospect', 'Ativo', 'Recuperado', 'Inativo']; const DIA_MS = 86400000; function crmHojeISO() { const d = new Date(); return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; } function crmDiasEntre(isoA, isoB) { return Math.round((Date.parse(isoB + 'T00:00:00') - Date.parse(isoA + 'T00:00:00')) / DIA_MS); } // Datas de um evento: as datas específicas, ou início/fim do período function crmDatasDoEvento(ev) { const especificas = Array.isArray(ev.datas_especificas) ? ev.datas_especificas.map(d => (typeof d === 'string' ? d : d && d.data)).filter(Boolean) : []; if (especificas.length) return especificas.map(d => String(d).slice(0, 10)); return [ev.event_date, ev.event_end_date].filter(Boolean).map(d => String(d).slice(0, 10)); } // Um orçamento por código (o mais recente), só os aprovados function crmAprovadosSemDuplicar(budgets) { const porCodigo = new Map(); budgets.filter(b => b.status === 'Aprovado').forEach(b => { const chave = b.code ? `${b.client_id}|${b.code}` : `id|${b.id}`; const atual = porCodigo.get(chave); if (!atual || String(b.created_at) > String(atual.created_at)) porCodigo.set(chave, b); }); return [...porCodigo.values()]; } function crmStatusCalculado(atividades, temFutura, hoje, diasInativo) { if (!atividades.length) return temFutura ? 'Ativo' : 'Prospect'; const ultima = atividades[atividades.length - 1]; if (crmDiasEntre(ultima, hoje) > diasInativo && !temFutura) return 'Inativo'; const anterior = atividades.length > 1 ? atividades[atividades.length - 2] : null; const voltou = anterior && crmDiasEntre(anterior, ultima) > diasInativo; return voltou ? 'Recuperado' : 'Ativo'; } // Próxima ação sugerida, por prioridade: orçamento sem resposta, cliente // inativo, cliente sem contratar há dias_contato. Nulo = nada a fazer. function crmSugestao(orcs, status, diasSem, temFutura, cfg, hoje) { const pendentes = orcs .filter(b => b.status === 'Enviado' && b.created_at) .map(b => ({ b, dias: crmDiasEntre(String(b.created_at).slice(0, 10), hoje) })) .filter(x => x.dias >= cfg.dias_followup) .sort((a, b) => a.dias - b.dias); if (pendentes.length) { const { b, dias } = pendentes[0]; const extra = pendentes.length > 1 ? ` (+${pendentes.length - 1})` : ''; return { tipo: 'followup', texto: `Follow-up do orçamento ${b.code || ''}`.trim() + extra, detalhe: `enviado há ${dias} dias sem resposta` }; } if (temFutura || diasSem === null) return null; if (status === 'Inativo') return { tipo: 'recuperacao', texto: 'Tentar recuperar o cliente', detalhe: `${diasSem} dias sem contratar` }; if (diasSem >= cfg.dias_contato) return { tipo: 'contato', texto: 'Entrar em contato', detalhe: `${diasSem} dias sem contratar` }; return null; } function crmIndicadoresDoCliente(cliente, eventos, orcs, cfg, hoje) { const realizados = []; const futuras = []; eventos.filter(e => e.status !== 'Cancelado').forEach(e => { const datas = crmDatasDoEvento(e).sort(); const passadas = datas.filter(d => d <= hoje); if (passadas.length) realizados.push({ e, data: passadas[passadas.length - 1] }); datas.filter(d => d > hoje).forEach(d => futuras.push(d)); }); realizados.sort((a, b) => a.data.localeCompare(b.data)); futuras.sort(); const aprovados = crmAprovadosSemDuplicar(orcs); const receita = aprovados.reduce((s, b) => s + (Number(b.value) || 0), 0); const acoes = realizados.length; const atividades = [ ...realizados.map(r => r.data), ...aprovados.map(b => String(b.aprovado_em || b.created_at || '').slice(0, 10)).filter(d => d && d <= hoje), ].sort(); const ultimaAtividade = atividades.length ? atividades[atividades.length - 1] : null; const temFutura = futuras.length > 0; const statusAuto = crmStatusCalculado(atividades, temFutura, hoje, cfg.dias_inativo); const diasSemContratar = ultimaAtividade ? crmDiasEntre(ultimaAtividade, hoje) : null; const ultimo = acoes ? realizados[acoes - 1] : null; const status = cliente.status_manual || statusAuto; const sugestao = crmSugestao(orcs, status, diasSemContratar, temFutura, cfg, hoje); return { acoes, receita, sugestao, ticket: acoes > 0 ? receita / acoes : receita, ultimoEvento: ultimo ? ultimo.data : null, ultimoEventoNome: ultimo ? ultimo.e.name : null, proximoEvento: temFutura ? futuras[0] : null, ultimaAtividade, diasSemContratar, statusAuto, status, statusManual: !!cliente.status_manual, // Tem algo a fazer: follow-up, recuperação ou contato precisaAtencao: !!sugestao, }; } /** * @param {{clients: Array, events: Array, budgets: Array, config?: Object}} dados * @returns {Object} indicadores por client.id */ function calcularIndicadoresCRM({ clients, events, budgets, config }) { const cfg = { ...CRM_CONFIG_PADRAO, ...(config || {}) }; const hoje = crmHojeISO(); const porCliente = {}; clients.forEach(c => { porCliente[c.id] = { eventos: [], orcs: [] }; }); events.forEach(e => { if (e.client_id && porCliente[e.client_id]) porCliente[e.client_id].eventos.push(e); }); budgets.forEach(b => { if (b.client_id && porCliente[b.client_id]) porCliente[b.client_id].orcs.push(b); }); const resultado = {}; clients.forEach(c => { resultado[c.id] = crmIndicadoresDoCliente(c, porCliente[c.id].eventos, porCliente[c.id].orcs, cfg, hoje); }); return resultado; } // Totais do topo do CRM: ticket médio = receita ÷ ações (não ÷ clientes) function totaisCRM(indicadores) { const lista = Object.values(indicadores); const receita = lista.reduce((s, i) => s + i.receita, 0); const acoes = lista.reduce((s, i) => s + i.acoes, 0); const conta = (st) => lista.filter(i => i.status === st).length; return { receita, acoes, ticket: acoes > 0 ? receita / acoes : 0, ativos: conta('Ativo') + conta('Recuperado'), inativos: conta('Inativo'), prospects: conta('Prospect'), atencao: lista.filter(i => i.precisaAtencao).length, }; } function formatarMoedaCurta(v) { if (!v) return 'R$ 0'; if (v >= 1000000) return 'R$ ' + (v / 1000000).toLocaleString('pt-BR', { maximumFractionDigits: 1 }) + 'M'; if (v >= 1000) return 'R$ ' + (v / 1000).toLocaleString('pt-BR', { maximumFractionDigits: 1 }) + 'k'; return 'R$ ' + Math.round(v).toLocaleString('pt-BR'); } Object.assign(window, { CRM_CONFIG_PADRAO, CRM_STATUS, calcularIndicadoresCRM, totaisCRM, formatarMoedaCurta, crmHojeISO });