/* ============================================================ 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 }) => {children}; const SecHead = ({ titulo, onExport }) => (

{titulo}

{onExport && CSV}
); const BarraR = ({ label, frac, extra, cor }) => (
{label} {extra}
0 ? 3 : 0, Math.round((frac || 0) * 100)) + "%", background: cor || "var(--kes-red-400)", borderRadius: 999 }} />
); return (

Relatórios

{aba === "vendas" ? "Vendas e funil do CRM no período" : "Desempenho da equipe no período · " + linhas.length + " pessoa(s)"}

setP(np)} /> Exportar
{[["vendas", "Vendas (CRM)"], ["produtividade", "Produtividade & Atendimento"]].map(([k, lbl]) => ( ))}
{aba === "produtividade" && (<> {/* Cartões-resumo do time */}
{[ { rot: "Leads criados", v: tot.leadsCriados, ic: "funnel" }, { rot: "Reuniões realizadas", v: tot.realizadas, ic: "handshake" }, { rot: "Vendas", v: tot.vendas, ic: "trophy" }, { rot: "Valor (1º mês)", v: K.fmtMoeda(tot.valor), ic: "banknote" }, { rot: "Tarefas concluídas", v: tot.tarefasFeitas, ic: "checkCircle" }, { rot: "Conversão", v: convTot.toFixed(0) + "%", ic: "trendingUp" }, ].map((c) => (
{c.rot}
{c.v}
))}
{/* Atendimento no WhatsApp (equipe toda no período) */}

Atendimento no WhatsApp

{waErro && · sem acesso às mensagens (regras pendentes)}
{[ { rot: "Taxa de resposta (nossa)", v: wa.comIn ? wa.taxa.toFixed(0) + "%" : "—", ic: "check", hint: "conversas que respondemos ÷ conversas recebidas", cor: wa.taxa >= 80 ? "var(--ok)" : wa.taxa >= 50 ? "var(--warn)" : (wa.comIn ? "var(--kes-red-400)" : "var(--fg)") }, { rot: "Taxa de resposta dos clientes", v: wa.comOut ? wa.taxaCliente.toFixed(0) + "%" : "—", ic: "message", hint: "clientes que responderam ÷ conversas que abordamos", cor: wa.taxaCliente >= 60 ? "var(--ok)" : wa.taxaCliente >= 30 ? "var(--warn)" : (wa.comOut ? "var(--kes-red-400)" : "var(--fg)") }, { rot: "Tempo médio de resposta", v: wa.temTempo ? fmtDuracao(wa.tempoMedioMin) : "—", ic: "clock", hint: "quanto o cliente espera até ser respondido" }, { rot: "Conversas novas", v: wa.novas, ic: "message", hint: "contatos cuja 1ª mensagem caiu no período" }, { rot: "Follow-ups feitos", v: wa.followups, ic: "zap", hint: "mensagens de follow-up enviadas pelo motor" }, ].map((c) => (
{c.rot}
{c.v}
{c.hint}
))}
{/* Tabela por pessoa */}
{ranking.map((l, i) => ( {l.leadsCriados}{l.agendadas}{l.realizadas}{l.vendas}{K.fmtMoeda(l.valor)}{l.tarefasFeitas}= 30 ? "var(--ok)" : l.conversao > 0 ? "var(--warn)" : "var(--fg3)" }}>{l.conversao.toFixed(0)}% ))} {ranking.length > 1 && ( {tot.leadsCriados}{tot.agendadas}{tot.realizadas}{tot.vendas}{K.fmtMoeda(tot.valor)}{tot.tarefasFeitas}{convTot.toFixed(0)}% )}
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
{linhas.length === 0 &&

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 */}
{[ { rot: "Ganhos", v: vendas.ganhosQ, sub: K.fmtMoeda(vendas.totalGanho), ic: "trophy", cor: "var(--ok)" }, { rot: "Perdidos", v: vendas.perdidosQ, sub: K.fmtMoeda(vendas.totalPerdido), ic: "x", cor: "var(--kes-red-400)" }, { rot: "Conversão", v: vendas.conv == null ? "—" : vendas.conv + "%", sub: "ganhos ÷ decididos", ic: "trendingUp" }, { rot: "Ticket médio", v: K.fmtMoeda(vendas.ticket), sub: "por ganho", ic: "banknote" }, { rot: "Ciclo de vendas", v: vendas.cicloMedio == null ? "—" : vendas.cicloMedio + " d", sub: "criação → ganho", ic: "clock" }, { rot: "Em aberto", v: vendas.abertosQ, sub: K.fmtMoeda(vendas.totalAberto), ic: "funnel" }, { rot: "Leads novos", v: vendas.novosPer, sub: "criados no período", ic: "target" }, ].map((c) => (
{c.rot}
{c.v}
{c.sub}
))}
{/* Funil agora */}
{vendas.funil.every((x) => x.qtd === 0) ? Nenhum negócio em aberto. : vendas.funil.map((x) => 0 ? " · " + K.fmtMoeda(x.valor) : "")} cor="var(--warn)" />)}
{/* Desempenho por responsável */} {vendas.resps.length === 0 ?

Sem dados no período.

: (
{vendas.resps.map((r) => ( {r.leads}{r.aberto}{r.ganho}{K.fmtMoeda(r.valGanho)}{r.perdido}= 30 ? "var(--ok)" : r.taxa > 0 ? "var(--warn)" : "var(--fg3)" }}>{r.taxa == null ? "—" : r.taxa + "%"}{r.ganho ? K.fmtMoeda(r.ticket) : "—"}{r.ciclo == null ? "—" : r.ciclo + "d"} ))}
ResponsávelLeadsAberto GanhosValor ganhoPerd. Conv.TicketCiclo
{r.nome}
)} {/* Origem dos leads */} {vendas.origs.length === 0 ?

Sem leads no período.

: (
{vendas.origs.map((o) => ( {o.total}{o.ganho}{o.perdido}{o.taxa == null ? "—" : o.taxa + "%"}{K.fmtMoeda(o.valGanho)} ))}
OrigemTotalGanhos PerdidosConv.Valor ganhoVolume
{o.origem}
)} {/* Motivos de perda */} {vendas.motivos.length === 0 ?

Nenhuma perda no período.

: (
{vendas.motivos.map((m) => ( {m.qtd}{K.fmtMoeda(m.valor)} ))}
MotivoQtdValor em negociaçõesPeso
{m.motivo}
)} {/* Ganhos × Perdidos × Novos — 12 meses */}
{vendas.trend.map((x) => ( {x.gQ}{x.gV > 0 ? K.fmtMoeda(x.gV) : "—"}{x.pQ}{x.novos} ))}
MêsGanhosValor PerdidosLeads novosGanhos × 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."}

)}
); } window.RelatoriosScreen = RelatoriosScreen;