/* ============================================================ KeS Metas — RELATÓRIOS de produtividade da equipe Uma tabela por pessoa no período: reuniões, vendas, tarefas, WhatsApp e conversão. Só admin/líder. Exporta CSV. ============================================================ */ /* ---- Métricas de ATENDIMENTO no WhatsApp a partir das mensagens ---- Cada mensagem tem { ts (ms), dir "in"/"out", contato, origem }. Follow-up = mensagem out com origem "followup" (feita pelo motor). ---- */ function whatsMetrics(msgs, startMs, endMs) { const porContato = {}; (msgs || []).forEach((m) => { const k = String(m.contato || "").replace(/\D/g, ""); if (!k) return; (porContato[k] = porContato[k] || []).push(m); }); let novas = 0, followups = 0, comIn = 0, respondidos = 0, somaMs = 0, nTempo = 0; let comOut = 0, clienteRespondeu = 0; // engajamento: cliente respondeu às NOSSAS mensagens Object.keys(porContato).forEach((k) => { const arr = porContato[k].slice().sort((a, b) => (a.ts || 0) - (b.ts || 0)); // Conversa nova = a PRIMEIRA mensagem daquele contato caiu dentro do período. const pr = arr[0]; if (pr && pr.ts >= startMs && pr.ts <= endMs) novas += 1; let temIn = false, respondida = false; // NÓS respondemos o cliente? let temOut = false, clienteResp = false; // o CLIENTE respondeu a gente? for (let i = 0; i < arr.length; i++) { const m = arr[i]; if (m.dir === "out" && m.origem === "followup" && m.ts >= startMs && m.ts <= endMs) followups += 1; if (m.dir === "in" && m.ts >= startMs && m.ts <= endMs) { temIn = true; // primeira resposta nossa depois dessa mensagem do cliente (antes da próxima dele). // Follow-up é envio proativo do motor — NÃO conta como resposta ao cliente. for (let j = i + 1; j < arr.length; j++) { if (arr[j].dir === "in") break; if (arr[j].dir === "out") { if (arr[j].origem === "followup") continue; respondida = true; const dt = (arr[j].ts || 0) - (m.ts || 0); if (dt >= 0 && dt < 7 * 86400000) { somaMs += dt; nTempo += 1; } // ignora > 7d (outlier) break; } } } if (m.dir === "out" && m.ts >= startMs && m.ts <= endMs) { temOut = true; // o cliente respondeu ALGUMA das nossas mensagens? (msg dele depois de uma nossa) for (let j = i + 1; j < arr.length; j++) { if (arr[j].dir === "out") break; if (arr[j].dir === "in") { clienteResp = true; break; } } } } if (temIn) { comIn += 1; if (respondida) respondidos += 1; } if (temOut) { comOut += 1; if (clienteResp) clienteRespondeu += 1; } }); return { novas, followups, comIn, comOut, taxa: comIn ? (respondidos / comIn) * 100 : 0, taxaCliente: comOut ? (clienteRespondeu / comOut) * 100 : 0, tempoMedioMin: nTempo ? somaMs / nTempo / 60000 : 0, temTempo: nTempo > 0, }; } function fmtDuracao(min) { if (!min || min <= 0) return "—"; if (min < 1) return "< 1 min"; if (min < 60) return Math.round(min) + " min"; const h = Math.floor(min / 60), m = Math.round(min % 60); return h + "h" + (m ? " " + m + "min" : ""); } function RelatoriosScreen({ state, dispatch, viewer }) { const { useMemo, useState, useEffect } = React; const K = window.KES; const KM = window.KESM; const DB = window.KESDB; // Período PRÓPRIO do relatório: começa no MÊS ATUAL (não mexe nas outras telas). const [p, setP] = useState(() => window.buildPreset("mes")); const isGestor = viewer.papel === "admin" || viewer.cargo === "lider"; // Mensagens do WhatsApp da equipe (para taxa/tempo de resposta, conversas novas, follow-ups). const [waMsgs, setWaMsgs] = useState([]); const [waErro, setWaErro] = useState(false); const tenantId = tenantIdAtual(state); useEffect(() => { setWaMsgs([]); setWaErro(false); if (!DB || !DB.waListen || !tenantId || !isGestor) return; return DB.waListen(tenantId, { all: true }, (lista, err) => { if (err) { setWaErro(true); return; } setWaMsgs(lista || []); }); }, [tenantId, isGestor]); const wa = useMemo(() => { const startMs = Date.parse(p.start + "T00:00:00"); const endMs = Date.parse(p.end + "T23:59:59.999"); return whatsMetrics(waMsgs, startMs, endMs); }, [waMsgs, p.start, p.end]); // Blindagem: garante os campos que os seletores usam (evita quebra em tenants novos). const metasSeguras = {}; (KM.INDICADORES || []).forEach((ind) => { metasSeguras[ind.key] = (state.metas && state.metas[ind.key]) || { alvo: 0, unidade: "mes" }; }); state = Object.assign({}, state, { activities: state.activities || [], meetings: state.meetings || [], leads: state.leads || [], tarefas: state.tarefas || [], metas: metasSeguras, }); // Pessoas com cargo (quem produz). Não-gestor vê só a si. const pessoas = (state.users || []).filter((u) => u.cargo && (isGestor || u.id === viewer.id)); const dentro = (iso) => iso && iso >= p.start && iso <= p.end; // Métricas por pessoa (CRM/agenda vêm do KESM; tarefas e leads criados aqui). const linhas = useMemo(() => pessoas.map((u) => { const m = KM.userMetrics(state, u.id, p); const leadsCriados = (state.leads || []).filter((l) => l.responsavelId === u.id && dentro(l.createdAt)).length; const tarefasFeitas = (state.tarefas || []).filter((t) => (t.responsavelId === u.id || t.donoId === u.id) && t.concluida && dentro(t.concluidaEm)).length; const realizadas = m.reunioesRealizadas.realizado; const vendas = m.vendas.realizado; return { u, leadsCriados, agendadas: m.reunioesAgendadas.realizado, realizadas, vendas, valor: m.valorVendas.realizado, tarefasFeitas, conversao: realizadas > 0 ? (vendas / realizadas) * 100 : 0, // vendas por reunião realizada }; }), [state.leads, state.meetings, state.tarefas, state.users, p.start, p.end]); const tot = linhas.reduce((t, l) => ({ leadsCriados: t.leadsCriados + l.leadsCriados, agendadas: t.agendadas + l.agendadas, realizadas: t.realizadas + l.realizadas, vendas: t.vendas + l.vendas, valor: t.valor + l.valor, tarefasFeitas: t.tarefasFeitas + l.tarefasFeitas, }), { leadsCriados: 0, agendadas: 0, realizadas: 0, vendas: 0, valor: 0, tarefasFeitas: 0 }); const convTot = tot.realizadas > 0 ? (tot.vendas / tot.realizadas) * 100 : 0; // ranking por vendas (destaque) const ranking = [...linhas].sort((a, b) => b.vendas - a.vendas || b.valor - a.valor); function exportar() { const cols = ["Pessoa", "Cargo", "Leads criados", "Reuniões agendadas", "Reuniões realizadas", "Vendas", "Valor (1º mês)", "Tarefas concluídas", "Conversão %"]; const rows = linhas.map((l) => [l.u.nome, K.rotuloCargo ? K.rotuloCargo(l.u.cargo) : l.u.cargo, l.leadsCriados, l.agendadas, l.realizadas, l.vendas, l.valor, l.tarefasFeitas, l.conversao.toFixed(0)]); window.CSVUtil.download("relatorio-produtividade.csv", window.CSVUtil.serialize(cols, rows, ";")); dispatch({ type: "TOAST", msg: "Relatório exportado (CSV)." }); } /* ===== Relatórios de VENDAS (CRM) — todas as empresas, exportáveis ===== */ const [aba, setAba] = useState("vendas"); const diffDias = (a, b) => Math.max(0, Math.round((new Date(String(b).slice(0, 10) + "T00:00:00") - new Date(String(a).slice(0, 10) + "T00:00:00")) / 86400000)); const mesAbrev = (ym) => { const ab = ["jan", "fev", "mar", "abr", "mai", "jun", "jul", "ago", "set", "out", "nov", "dez"]; return (ab[(+ym.slice(5, 7)) - 1] || "?") + "/" + ym.slice(2, 4); }; const vendas = useMemo(() => { const stagesP = state.stages || []; const leadsAll = (state.leads || []).filter((l) => isGestor || l.responsavelId === viewer.id); const usersById = {}; (state.users || []).forEach((u) => usersById[u.id] = u); const dataRes = (l) => (l.desfecho && l.desfecho.data) ? String(l.desfecho.data).slice(0, 10) : (l.noShowData ? String(l.noShowData).slice(0, 10) : String(l.createdAt || "").slice(0, 10)); const finalizado = (l) => !!(l.desfecho && (l.desfecho.resultado === "ganho" || l.desfecho.resultado === "perdido")); const noPeriodo = (l) => { const d = dataRes(l); return d >= p.start && d <= p.end; }; const noPer = leadsAll.filter(noPeriodo); const ganhos = noPer.filter((l) => l.desfecho && l.desfecho.resultado === "ganho" && l.desfecho.pago === true); const perdidos = noPer.filter((l) => l.desfecho && l.desfecho.resultado === "perdido"); const totalGanho = ganhos.reduce((t, l) => t + (l.desfecho.valorPrimeiroMes || 0), 0); const totalPerdido = perdidos.reduce((t, l) => t + (l.desfecho.valorNegociacao || 0), 0); const decididos = ganhos.length + perdidos.length; const conv = decididos > 0 ? Math.round(ganhos.length / decididos * 100) : null; const ticket = ganhos.length ? Math.round(totalGanho / ganhos.length) : 0; const ciclos = ganhos.map((l) => (l.createdAt && l.desfecho.data) ? diffDias(l.createdAt, l.desfecho.data) : null).filter((x) => x != null); const cicloMedio = ciclos.length ? Math.round(ciclos.reduce((t, n) => t + n, 0) / ciclos.length) : null; const abertos = leadsAll.filter((l) => !finalizado(l) && !l.arquivado); const totalAberto = abertos.reduce((t, l) => t + (l.valor || 0), 0); const novosPer = leadsAll.filter((l) => { const c = String(l.createdAt || "").slice(0, 10); return c >= p.start && c <= p.end; }).length; const funil = stagesP.filter((s) => s.tipo !== "won" && s.tipo !== "lost").map((s) => { const da = abertos.filter((l) => l.stageId === s.id); return { nome: s.nome, qtd: da.length, valor: da.reduce((t, l) => t + (l.valor || 0), 0) }; }); const funilMax = Math.max(1, ...funil.map((x) => x.qtd)); const rMap = {}; const gR = (id) => rMap[id] || (rMap[id] = { id, leads: 0, aberto: 0, ganho: 0, perdido: 0, valGanho: 0, cs: 0, cn: 0 }); noPer.forEach((l) => { const r = gR(l.responsavelId || "__"); r.leads++; if (l.desfecho && l.desfecho.resultado === "ganho" && l.desfecho.pago === true) { r.ganho++; r.valGanho += l.desfecho.valorPrimeiroMes || 0; if (l.createdAt && l.desfecho.data) { r.cs += diffDias(l.createdAt, l.desfecho.data); r.cn++; } } else if (l.desfecho && l.desfecho.resultado === "perdido") { r.perdido++; } else if (!finalizado(l)) { r.aberto++; } }); const resps = Object.values(rMap).map((r) => Object.assign({}, r, { nome: r.id === "__" ? "Sem responsável" : (usersById[r.id] ? usersById[r.id].nome : "—"), taxa: (r.ganho + r.perdido) > 0 ? Math.round(r.ganho / (r.ganho + r.perdido) * 100) : null, ticket: r.ganho ? Math.round(r.valGanho / r.ganho) : 0, ciclo: r.cn ? Math.round(r.cs / r.cn) : null })).sort((a, b) => b.valGanho - a.valGanho || b.ganho - a.ganho); const oMap = {}; const gO = (o) => oMap[o] || (oMap[o] = { origem: o, total: 0, ganho: 0, perdido: 0, valGanho: 0 }); noPer.forEach((l) => { const o = gO(l.origem || "—"); o.total++; if (l.desfecho && l.desfecho.resultado === "ganho" && l.desfecho.pago === true) { o.ganho++; o.valGanho += l.desfecho.valorPrimeiroMes || 0; } else if (l.desfecho && l.desfecho.resultado === "perdido") { o.perdido++; } }); const origs = Object.values(oMap).map((o) => Object.assign({}, o, { taxa: (o.ganho + o.perdido) > 0 ? Math.round(o.ganho / (o.ganho + o.perdido) * 100) : null })).sort((a, b) => b.total - a.total); const origMax = Math.max(1, ...origs.map((x) => x.total)); const mMap = {}; perdidos.forEach((l) => { const m = l.desfecho.motivo === "Outro" ? (l.desfecho.motivoOutro || "Outro") : (l.desfecho.motivo || "—"); if (!mMap[m]) mMap[m] = { motivo: m, qtd: 0, valor: 0 }; mMap[m].qtd++; mMap[m].valor += l.desfecho.valorNegociacao || 0; }); const motivos = Object.values(mMap).sort((a, b) => b.qtd - a.qtd); const motMax = Math.max(1, ...motivos.map((x) => x.qtd)); const base = K.iso(K.SIM_TODAY); const yy = +base.slice(0, 4), mm = +base.slice(5, 7); const meses = []; for (let i = 11; i >= 0; i--) { let m = mm - i, y = yy; while (m <= 0) { m += 12; y--; } meses.push(y + "-" + String(m).padStart(2, "0")); } const trend = meses.map((ym) => { const g = leadsAll.filter((l) => l.desfecho && l.desfecho.resultado === "ganho" && l.desfecho.pago === true && String(l.desfecho.data || "").slice(0, 7) === ym); const pl = leadsAll.filter((l) => l.desfecho && l.desfecho.resultado === "perdido" && String(l.desfecho.data || "").slice(0, 7) === ym); const nv = leadsAll.filter((l) => String(l.createdAt || "").slice(0, 7) === ym).length; return { ym, gQ: g.length, gV: g.reduce((t, l) => t + (l.desfecho.valorPrimeiroMes || 0), 0), pQ: pl.length, novos: nv }; }); const trendMax = Math.max(1, ...trend.map((x) => Math.max(x.gQ, x.pQ, x.novos))); return { ganhosQ: ganhos.length, totalGanho, perdidosQ: perdidos.length, totalPerdido, conv, ticket, cicloMedio, abertosQ: abertos.length, totalAberto, novosPer, funil, funilMax, resps, origs, origMax, motivos, motMax, trend, trendMax }; }, [state.leads, state.stages, state.users, p.start, p.end, isGestor]); function exportarVendas() { const cols = ["Responsável", "Leads (período)", "Em aberto", "Ganhos", "Valor ganho", "Perdidos", "Taxa conversão %", "Ticket médio", "Ciclo médio (dias)"]; const rows = vendas.resps.map((r) => [r.nome, r.leads, r.aberto, r.ganho, r.valGanho, r.perdido, r.taxa == null ? "" : r.taxa, r.ticket, r.ciclo == null ? "" : r.ciclo]); window.CSVUtil.download("vendas-por-responsavel.csv", window.CSVUtil.serialize(cols, rows, ";")); dispatch({ type: "TOAST", msg: "Relatório de vendas exportado (CSV)." }); } function exportarOrigem() { const rows = vendas.origs.map((o) => [o.origem, o.total, o.ganho, o.perdido, o.taxa == null ? "" : o.taxa, o.valGanho]); window.CSVUtil.download("vendas-por-origem.csv", window.CSVUtil.serialize(["Origem", "Total leads", "Ganhos", "Perdidos", "Taxa %", "Valor ganho"], rows, ";")); dispatch({ type: "TOAST", msg: "Exportado (CSV)." }); } function exportarMotivos() { const rows = vendas.motivos.map((m) => [m.motivo, m.qtd, m.valor]); window.CSVUtil.download("motivos-de-perda.csv", window.CSVUtil.serialize(["Motivo da perda", "Quantidade", "Valor em negociações"], rows, ";")); dispatch({ type: "TOAST", msg: "Exportado (CSV)." }); } function exportarMensal() { const rows = vendas.trend.map((x) => [x.ym, x.gQ, x.gV, x.pQ, x.novos]); window.CSVUtil.download("vendas-mensal.csv", window.CSVUtil.serialize(["Mês", "Ganhos (qtd)", "Ganhos (valor)", "Perdidos (qtd)", "Leads novos"], rows, ";")); dispatch({ type: "TOAST", msg: "Exportado (CSV)." }); } const Cel = ({ children, forte }) =>
{aba === "vendas" ? "Vendas e funil do CRM no período" : "Desempenho da equipe no período · " + linhas.length + " pessoa(s)"}
| Pessoa | Leads criados | Reuniões agend. | Reuniões realiz. | Vendas | Valor (1º mês) | Tarefas | Conversão |
|---|---|---|---|---|---|---|---|
|
{i === 0 && l.vendas > 0 && 🏆}
{l.u.nome}
{window.KESCOMISSAO ? window.KESCOMISSAO.rotuloCargo(l.u.cargo) : l.u.cargo} |
|||||||
| Total do time |
Nenhuma pessoa com cargo comercial neste contexto.
}Conversão = vendas ÷ reuniões realizadas no período. Reuniões atribuídas a quem marcou; vendas a quem fechou.
>)} {aba === "vendas" && (<> {/* KPIs de vendas */}Sem dados no período.
: (| Responsável | Leads | Aberto | Ganhos | Valor ganho | Perd. | Conv. | Ticket | Ciclo |
|---|---|---|---|---|---|---|---|---|
| {r.nome} |
Sem leads no período.
: (| Origem | Total | Ganhos | Perdidos | Conv. | Valor ganho | Volume |
|---|---|---|---|---|---|---|
| {o.origem} |
Nenhuma perda no período.
: (| Motivo | Qtd | Valor em negociações | Peso |
|---|---|---|---|
| {m.motivo} |
| Mês | Ganhos | Valor | Perdidos | Leads novos | Ganhos × Perdidos |
|---|---|---|---|---|---|
| {mesAbrev(x.ym)} |
|
Ganhos e valores contam vendas pagas. Ciclo de vendas = dias entre a criação do lead e o ganho. Cada tabela tem seu botão CSV; o botão Exportar no topo baixa o desempenho por responsável.{isGestor ? "" : " Você vê apenas os seus leads."}
>)}