App local (Node + SQLite) para captura, tratamento e exportacao de leads comerciais, com dashboard de gestao, login/usuarios e integracao Z-PRO. Co-Authored-By: Claude <noreply@anthropic.com>
630 lines
26 KiB
JavaScript
630 lines
26 KiB
JavaScript
/* Aba Tratamento: dashboard, navegador de leads com filtros e exportações. */
|
|
const Tratamento = (() => {
|
|
let page = 1;
|
|
let totalPages = 1;
|
|
let carregado = false;
|
|
let refreshTimer = null;
|
|
|
|
const CLASSIFICACAO_LABELS = {
|
|
excelente: 'Excelente', bom: 'Bom', medio: 'Médio', baixo: 'Baixo', muito_baixo: 'Muito Baixo'
|
|
};
|
|
const PRIORIDADE_LABELS = {
|
|
1: 'P1 — atacar imediatamente', 2: 'P2 — trabalhar durante a semana',
|
|
3: 'P3 — quando não houver prioritários', 4: 'P4 — só em campanhas específicas'
|
|
};
|
|
|
|
function estrelas(n) {
|
|
const cheias = Math.max(0, Math.min(5, Number(n) || 0));
|
|
return '★'.repeat(cheias) + '☆'.repeat(5 - cheias);
|
|
}
|
|
|
|
// ---------- filtros ----------
|
|
function filtroAtual() {
|
|
const filtro = {};
|
|
for (const input of document.querySelectorAll('#filtros [data-filtro]')) {
|
|
const value = input.value.trim();
|
|
if (value) filtro[input.dataset.filtro] = value;
|
|
}
|
|
return filtro;
|
|
}
|
|
|
|
function filtroQuery(extra = {}) {
|
|
return new URLSearchParams({ ...filtroAtual(), ...extra }).toString();
|
|
}
|
|
|
|
// Clique nos cards/barras do dashboard: aplica o filtro somando aos ja
|
|
// ativos; clicar de novo no mesmo valor remove so aquele filtro.
|
|
function toggleFiltro(inputId, valor) {
|
|
const input = el(inputId);
|
|
const novo = String(valor ?? '');
|
|
input.value = input.value === novo ? '' : novo;
|
|
atualizar();
|
|
}
|
|
|
|
function descreverFiltro() {
|
|
const f = filtroAtual();
|
|
const partes = [];
|
|
if (f.uf) partes.push(`UF ${f.uf}`);
|
|
if (f.cidade) partes.push(f.cidade);
|
|
if (f.segmento) partes.push(el('f-segmento').selectedOptions[0]?.textContent || f.segmento);
|
|
if (f.classificacao) partes.push(CLASSIFICACAO_LABELS[f.classificacao] || f.classificacao);
|
|
if (f.prioridade) partes.push(`P${f.prioridade}`);
|
|
if (f.produto) partes.push(`produto: ${f.produto}`);
|
|
if (f.interesse) partes.push(f.interesse);
|
|
if (f.telefone_valido) partes.push(f.telefone_valido === 'sim' ? 'com WhatsApp' : 'sem WhatsApp');
|
|
if (f.website) partes.push(f.website === 'sim' ? 'com website' : 'sem website');
|
|
if (f.email) partes.push(f.email === 'sim' ? 'com e-mail' : 'sem e-mail');
|
|
if (f.exportado) partes.push(f.exportado === 'nao' ? 'ainda não exportados' : 'já exportados');
|
|
if (f.q) partes.push(`busca "${f.q}"`);
|
|
return partes.length ? partes.join(' · ') : 'todos os leads';
|
|
}
|
|
|
|
// ---------- carregamento ----------
|
|
async function atualizar() {
|
|
page = 1;
|
|
const [summary, evolutionRes, oportunidadeRes] = await Promise.all([
|
|
API.get(`/api/dashboard/summary?${filtroQuery()}`),
|
|
API.get(`/api/dashboard/evolution?dias=14&${filtroQuery()}`),
|
|
API.get(`/api/dashboard/oportunidade?${filtroQuery()}`)
|
|
]);
|
|
await Promise.all([carregarTiles(summary), carregarRankings(summary), carregarFunil(summary), carregarTabela()]);
|
|
carregarEvolucaoDiaria(evolutionRes.evolution);
|
|
carregarMaiorOportunidade(oportunidadeRes.oportunidade);
|
|
}
|
|
|
|
function invalidar() {
|
|
carregado = false;
|
|
clearTimeout(refreshTimer);
|
|
refreshTimer = setTimeout(() => {
|
|
if (document.querySelector('#tab-tratamento.active')) ativar();
|
|
}, 800);
|
|
}
|
|
|
|
async function ativar() {
|
|
if (carregado) return;
|
|
carregado = true;
|
|
await Promise.all([carregarFacets(), atualizar(), carregarExports()]);
|
|
}
|
|
|
|
async function carregarFacets() {
|
|
const facets = await API.get('/api/leads/facets');
|
|
|
|
const fill = (select, valores, prefixo, formatar = (v) => v) => {
|
|
const atual = select.value;
|
|
const primeira = select.options[0];
|
|
select.replaceChildren(primeira, ...valores.map((item) =>
|
|
h('option', { value: item.value }, `${formatar(item.value)} (${fmt.format(item.total)})`)
|
|
));
|
|
select.value = atual;
|
|
};
|
|
|
|
fill(el('f-uf'), facets.ufs);
|
|
fill(el('f-interesse'), facets.interesses);
|
|
fill(el('f-produto'), facets.produtos);
|
|
|
|
const segSelect = el('f-segmento');
|
|
const atual = segSelect.value;
|
|
segSelect.replaceChildren(segSelect.options[0], ...facets.segmentos.map((s) =>
|
|
h('option', { value: s.value }, `${s.value.replace(/_/g, ' ')} (${fmt.format(s.total)})`)
|
|
));
|
|
segSelect.value = atual;
|
|
|
|
el('dl-cidades').replaceChildren(...facets.cidades.map((c) => h('option', { value: c.value })));
|
|
}
|
|
|
|
async function salvarMetaDiaria(valor) {
|
|
const meta = Math.max(1, Number(valor) || 100);
|
|
await API.put('/api/settings', { meta_diaria: meta });
|
|
const summary = await API.get(`/api/dashboard/summary?${filtroQuery()}`);
|
|
carregarTiles(summary);
|
|
}
|
|
|
|
async function carregarTiles(s) {
|
|
const pct = (n) => s.total ? ` (${((n / s.total) * 100).toFixed(1)}%)` : '';
|
|
|
|
el('stat-tiles').replaceChildren(
|
|
tile(s.total, 'Leads no filtro', ''),
|
|
tile(s.com_whatsapp, 'Com WhatsApp', pct(s.com_whatsapp), 't-good'),
|
|
tile(s.p1, '🎯 Potencial Comercial Imediato', `${s.potencial_pct}% da base — atacar agora`, 't-good'),
|
|
tile(s.p4, '📦 P4 — campanhas específicas', pct(s.p4))
|
|
);
|
|
|
|
el('stat-tiles').append(
|
|
cardCobertura(s),
|
|
cardPrioridades(s),
|
|
cardMeta(s)
|
|
);
|
|
|
|
function tile(valor, rotulo, detalhe, cls = '') {
|
|
return h('div', { class: `tile ${cls}` },
|
|
h('div', { class: 'valor' }, fmt.format(valor)),
|
|
h('div', { class: 'rotulo' }, rotulo),
|
|
h('div', { class: 'detalhe' }, detalhe)
|
|
);
|
|
}
|
|
|
|
function cardCobertura(s) {
|
|
const cidadeAtiva = filtroAtual().cidade;
|
|
const titulo = cidadeAtiva ? `Cobertura em ${cidadeAtiva}` : 'Base Comercial — cobertura';
|
|
return h('div', { class: 'tile tile-wide' },
|
|
h('div', { class: 'rotulo' }, titulo),
|
|
h('div', { class: 'progress-bar' },
|
|
h('div', { class: 'progress-fill', style: `width:${s.cobertura_pct}%` })),
|
|
h('div', { class: 'detalhe' },
|
|
`${s.cobertura_pct}% trabalhada · ${fmt.format(s.exportados)} exportadas · ${fmt.format(s.disponiveis)} disponíveis`)
|
|
);
|
|
}
|
|
|
|
function cardPrioridades(s) {
|
|
const ativa = filtroAtual().prioridade;
|
|
const linhas = [
|
|
['P1', s.p1, s.p1_pct], ['P2', s.p2, s.p2_pct], ['P3', s.p3, s.p3_pct], ['P4', s.p4, s.p4_pct]
|
|
];
|
|
return h('div', { class: 'tile tile-wide' },
|
|
h('div', { class: 'rotulo' }, 'Distribuição por prioridade'),
|
|
h('div', { class: 'ranking', style: 'margin-top:6px' }, ...linhas.map(([label, total, p], i) =>
|
|
h('div', {
|
|
class: `rank-row${ativa === String(i + 1) ? ' ativo' : ''}`,
|
|
title: `${label}: ${fmt.format(total)} leads — clique para filtrar`,
|
|
onclick: () => toggleFiltro('f-prioridade', i + 1)
|
|
},
|
|
h('div', { class: 'rank-label' }, label),
|
|
h('div', { class: 'rank-track' }, h('div', { class: 'rank-fill', style: `width:${Math.max(1.5, p)}%` })),
|
|
h('div', { class: 'rank-value' }, `${p}%`)
|
|
)))
|
|
);
|
|
}
|
|
|
|
function cardMeta(s) {
|
|
return h('div', { class: 'tile tile-wide' },
|
|
h('div', { class: 'row space-between' },
|
|
h('div', { class: 'rotulo' }, '🎯 Meta diária'),
|
|
h('input', {
|
|
type: 'number', min: '1', value: String(s.meta_diaria), class: 'meta-input',
|
|
onchange: (e) => salvarMetaDiaria(e.target.value)
|
|
})
|
|
),
|
|
h('div', { class: 'progress-bar' },
|
|
h('div', { class: 'progress-fill', style: `width:${Math.min(100, s.meta_pct)}%` })),
|
|
h('div', { class: 'detalhe' },
|
|
`${fmt.format(s.hoje_exportados)} de ${fmt.format(s.meta_diaria)} hoje (${s.meta_pct}%) · ~${fmt.format(s.dias_estimados)} dias úteis restantes no ritmo atual`)
|
|
);
|
|
}
|
|
}
|
|
|
|
async function carregarRankings(summary) {
|
|
const cidadeAtiva = filtroAtual().cidade;
|
|
el('rank-produtos-titulo').textContent = cidadeAtiva ? `Produtos recomendados em ${cidadeAtiva}` : 'Top produto recomendado';
|
|
|
|
const [cidades, segmentos, produtos] = await Promise.all([
|
|
API.get(`/api/dashboard/rankings?by=cidade&limit=10&${filtroQuery()}`),
|
|
API.get(`/api/dashboard/rankings?by=segmento&limit=10&${filtroQuery()}`),
|
|
API.get(`/api/dashboard/rankings?by=produto&limit=10&${filtroQuery()}`)
|
|
]);
|
|
|
|
const f = filtroAtual();
|
|
const segmentoValuePorLabel = (label) => {
|
|
const opts = [...el('f-segmento').options];
|
|
const porValor = opts.find((o) => o.value && o.value.replace(/_/g, ' ').toLowerCase() === label.toLowerCase());
|
|
const porTexto = opts.find((o) => o.textContent.toLowerCase().startsWith(label.toLowerCase()));
|
|
return (porValor || porTexto)?.value || '';
|
|
};
|
|
|
|
renderRanking(el('rank-cidades'), cidades.ranking,
|
|
(label) => toggleFiltro('f-cidade', label),
|
|
null,
|
|
(label) => f.cidade === label);
|
|
renderRanking(el('rank-segmentos'), segmentos.ranking,
|
|
(label) => toggleFiltro('f-segmento', segmentoValuePorLabel(label)),
|
|
null,
|
|
(label) => !!f.segmento && f.segmento === segmentoValuePorLabel(label));
|
|
renderRanking(el('rank-produtos'), produtos.ranking,
|
|
(label) => toggleFiltro('f-produto', label),
|
|
summary.total,
|
|
(label) => f.produto === label);
|
|
|
|
carregarOportunidadesDoDia(produtos.ranking, summary.total);
|
|
}
|
|
|
|
function carregarOportunidadesDoDia(produtosRanking, totalGeral) {
|
|
const top3 = produtosRanking.slice(0, 3);
|
|
if (!top3.length) {
|
|
el('card-oportunidades-dia').replaceChildren(
|
|
h('h2', {}, '🔥 Oportunidades do Dia'),
|
|
h('div', { class: 'muted' }, 'Sem dados para este filtro.')
|
|
);
|
|
return;
|
|
}
|
|
const produtoAtivo = filtroAtual().produto;
|
|
el('card-oportunidades-dia').replaceChildren(
|
|
h('h2', {}, '🔥 Oportunidades do Dia'),
|
|
h('div', { class: 'ranking', style: 'margin-top:6px' }, ...top3.map((r) => {
|
|
const p = totalGeral ? Math.round((r.total / totalGeral) * 100) : 0;
|
|
return h('div', {
|
|
class: `rank-row${produtoAtivo === r.label ? ' ativo' : ''}`,
|
|
title: `${r.label}: ${fmt.format(r.total)} leads — clique para filtrar`,
|
|
onclick: () => toggleFiltro('f-produto', r.label)
|
|
},
|
|
h('div', { class: 'rank-label' }, r.label),
|
|
h('div', { class: 'rank-track' }, h('div', { class: 'rank-fill', style: `width:${Math.max(1.5, p)}%` })),
|
|
h('div', { class: 'rank-value' }, `${p}%`)
|
|
);
|
|
}))
|
|
);
|
|
}
|
|
|
|
function dataBrCurta(diaIso) {
|
|
const [, mes, dia] = diaIso.split('-');
|
|
return `${dia}/${mes}`;
|
|
}
|
|
|
|
function carregarEvolucaoDiaria(evolution) {
|
|
if (!evolution.length) {
|
|
el('card-evolucao-diaria').replaceChildren(
|
|
h('h2', {}, '📈 Evolução Diária'),
|
|
h('div', { class: 'muted' }, 'Nenhuma exportação registrada ainda.')
|
|
);
|
|
return;
|
|
}
|
|
const max = Math.max(...evolution.map((d) => d.total));
|
|
el('card-evolucao-diaria').replaceChildren(
|
|
h('h2', {}, '📈 Evolução Diária'),
|
|
h('div', { class: 'muted', style: 'margin:2px 0 8px' }, 'Exportações por dia — única atividade com data registrada hoje.'),
|
|
h('div', { class: 'ranking' }, ...evolution.map((d) => h('div', { class: 'rank-row nao-clicavel' },
|
|
h('div', { class: 'rank-label' }, dataBrCurta(d.dia)),
|
|
h('div', { class: 'rank-track' }, h('div', { class: 'rank-fill', style: `width:${Math.max(1.5, (d.total / max) * 100)}%` })),
|
|
h('div', { class: 'rank-value' }, fmt.format(d.total))
|
|
)))
|
|
);
|
|
}
|
|
|
|
function carregarMaiorOportunidade(oportunidade) {
|
|
if (!oportunidade) {
|
|
el('card-maior-oportunidade').replaceChildren(
|
|
h('h2', {}, '🏆 Maior Oportunidade'),
|
|
h('div', { class: 'muted' }, 'Sem dados suficientes para este filtro.')
|
|
);
|
|
return;
|
|
}
|
|
const f = filtroAtual();
|
|
const jaAplicado = f.cidade === oportunidade.cidade && f.produto === oportunidade.produto;
|
|
el('card-maior-oportunidade').replaceChildren(
|
|
h('h2', {}, '🏆 Maior Oportunidade'),
|
|
h('div', {
|
|
class: `row space-between oportunidade-clicavel${jaAplicado ? ' ativo' : ''}`,
|
|
style: 'margin-top:6px',
|
|
title: jaAplicado ? 'Clique para remover o filtro cidade + produto' : 'Clique para filtrar por esta cidade + produto',
|
|
onclick: () => {
|
|
el('f-cidade').value = jaAplicado ? '' : oportunidade.cidade;
|
|
el('f-produto').value = jaAplicado ? '' : oportunidade.produto;
|
|
atualizar();
|
|
}
|
|
},
|
|
h('div', {}, h('div', { class: 'rotulo' }, 'Cidade'), h('div', { class: 'valor', style: 'font-size:20px' }, oportunidade.cidade)),
|
|
h('div', {}, h('div', { class: 'rotulo' }, 'Produto'), h('div', { class: 'valor', style: 'font-size:20px' }, oportunidade.produto)),
|
|
h('div', {}, h('div', { class: 'rotulo' }, 'Empresas'), h('div', { class: 'valor', style: 'font-size:20px' }, fmt.format(oportunidade.total)))
|
|
)
|
|
);
|
|
}
|
|
|
|
function renderRanking(box, ranking, onClick, totalGeral, isAtivo = () => false) {
|
|
if (!ranking.length) {
|
|
box.replaceChildren(h('div', { class: 'muted' }, 'Sem dados para este filtro.'));
|
|
return;
|
|
}
|
|
const max = ranking[0].total;
|
|
box.replaceChildren(...ranking.map((r) => {
|
|
const pctGeral = totalGeral ? ` · ${Math.round((r.total / totalGeral) * 100)}%` : '';
|
|
const ativo = isAtivo(r.label);
|
|
return h('div', {
|
|
class: `rank-row${ativo ? ' ativo' : ''}`,
|
|
title: `${r.label}: ${fmt.format(r.total)} leads (${fmt.format(r.p1)} prioridade 1) — ${ativo ? 'clique para remover o filtro' : 'clique para filtrar'}`,
|
|
onclick: () => onClick(r.label)
|
|
},
|
|
h('div', { class: 'rank-label' }, r.label),
|
|
h('div', { class: 'rank-track' },
|
|
h('div', { class: 'rank-fill', style: `width:${Math.max(1.5, (r.total / max) * 100)}%` })),
|
|
h('div', { class: 'rank-value' }, `${fmt.format(r.total)}${pctGeral}`)
|
|
);
|
|
}));
|
|
}
|
|
|
|
const FUNIL_ETAPAS = [
|
|
{ key: 'total', label: 'Leads capturados' },
|
|
{ key: 'disponiveis', label: 'Leads disponíveis' },
|
|
{ key: 'exportados', label: 'Leads exportados' },
|
|
{ key: null, label: 'Empresas contatadas' },
|
|
{ key: null, label: 'Empresas interessadas' },
|
|
{ key: null, label: 'Propostas enviadas' },
|
|
{ key: null, label: 'Clientes convertidos' }
|
|
];
|
|
|
|
function carregarFunil(s) {
|
|
const base = s.total || 1;
|
|
el('card-funil').replaceChildren(
|
|
h('h2', {}, 'Evolução Comercial'),
|
|
h('div', { class: 'funil' }, ...FUNIL_ETAPAS.map((etapa) => {
|
|
const pendente = etapa.key === null;
|
|
const valor = pendente ? null : s[etapa.key];
|
|
const largura = pendente ? 0 : Math.max(1.5, (valor / base) * 100);
|
|
return h('div', { class: `funil-etapa${pendente ? ' pendente' : ''}`,
|
|
title: pendente ? 'Aguardando integração com CRM' : '' },
|
|
h('div', { class: 'funil-label' }, etapa.label),
|
|
h('div', { class: 'funil-barra' }, h('div', { class: 'funil-fill', style: `width:${largura}%` })),
|
|
h('div', { class: 'funil-valor' }, pendente ? '—' : fmt.format(valor))
|
|
);
|
|
}))
|
|
);
|
|
}
|
|
|
|
async function carregarTabela() {
|
|
const data = await API.get(`/api/leads?${filtroQuery({ page, pageSize: 50 })}`);
|
|
totalPages = Math.max(1, Math.ceil(data.total / data.pageSize));
|
|
el('leads-total').textContent = `— ${fmt.format(data.total)} encontrados`;
|
|
el('pg-info').textContent = `página ${data.page} de ${fmt.format(totalPages)}`;
|
|
el('pg-prev').disabled = page <= 1;
|
|
el('pg-next').disabled = page >= totalPages;
|
|
|
|
const tbody = document.querySelector('#tabela-leads tbody');
|
|
if (!data.rows.length) {
|
|
tbody.replaceChildren(h('tr', {}, h('td', { colspan: '9', class: 'muted' }, 'Nenhum lead com este filtro.')));
|
|
return;
|
|
}
|
|
|
|
tbody.replaceChildren(...data.rows.map((lead) => h('tr', { onclick: () => abrirLead(lead.id) },
|
|
h('td', { class: 'empresa-cell', title: lead.legal_name || lead.empresa }, lead.empresa),
|
|
h('td', {}, lead.whatsapp || '—'),
|
|
h('td', {}, lead.cidade),
|
|
h('td', {}, lead.segmento_label || lead.segmento),
|
|
h('td', {}, h('span', { class: `badge prioridade-${lead.prioridade}` }, `P${lead.prioridade}`)),
|
|
h('td', { class: 'num' }, String(lead.score)),
|
|
h('td', {}, h('span', { class: `badge ${lead.classificacao}` }, CLASSIFICACAO_LABELS[lead.classificacao] || lead.classificacao)),
|
|
h('td', { class: 'produto-cell' }, produtoCell(lead.produto_principal, lead.afinidade)),
|
|
h('td', {}, lead.exported_at ? '📤 sim' : '—')
|
|
)));
|
|
}
|
|
|
|
const AFINIDADE_TABELA_LABELS = { alta: 'Alta aderência', media: 'Média aderência', baixa: 'Baixa aderência' };
|
|
|
|
function produtoCell(produto, afinidade) {
|
|
if (!produto) return '—';
|
|
const label = AFINIDADE_TABELA_LABELS[afinidade];
|
|
return label ? `${produto} · ${label}` : produto;
|
|
}
|
|
|
|
const OBSERVACAO_POR_PRIORIDADE = {
|
|
1: 'Empresa apta para abordagem imediata.',
|
|
2: 'Aguardar a janela de trabalho desta semana.',
|
|
3: 'Abordar quando não houver leads de prioridade mais alta.',
|
|
4: 'Reservar para campanhas específicas — baixa prioridade comercial.'
|
|
};
|
|
const AFINIDADE_LABELS = { alta: 'nicho de alta aderência', media: 'nicho de média aderência', baixa: 'nicho de baixa aderência' };
|
|
|
|
function montarResumoComercial(lead) {
|
|
const potencial = Math.max(1, Math.min(5, Math.round((lead.score_comercial || 0) / 20)));
|
|
const pontosFortes = [];
|
|
if (lead.telefone_valido) pontosFortes.push('WhatsApp válido');
|
|
if (lead.cnpj_valido) pontosFortes.push('CNPJ válido');
|
|
if (lead.website) pontosFortes.push('Website');
|
|
if (lead.email) pontosFortes.push('E-mail cadastrado');
|
|
if (lead.afinidade) pontosFortes.push(AFINIDADE_LABELS[lead.afinidade] || 'nicho identificado');
|
|
|
|
return h('div', { class: 'resumo-comercial' },
|
|
h('h3', {}, 'Resumo comercial'),
|
|
h('div', { class: 'row space-between' },
|
|
h('span', { class: 'estrelas', title: `Potencial comercial: ${potencial}/5` }, estrelas(potencial)),
|
|
h('span', { class: `badge prioridade-${lead.prioridade}` }, PRIORIDADE_LABELS[lead.prioridade] || `P${lead.prioridade}`)
|
|
),
|
|
lead.produtos && lead.produtos.length
|
|
? h('div', {}, h('div', { class: 'muted', style: 'margin:8px 0 4px' }, 'Produtos recomendados'),
|
|
...lead.produtos.map((p) => h('div', {}, h('span', { class: 'estrelas' }, estrelas(p.estrelas)), ` ${p.nome}`)))
|
|
: h('div', { class: 'muted', style: 'margin-top:8px' }, 'Nenhum produto sugerido para este nicho.'),
|
|
pontosFortes.length
|
|
? h('ul', { class: 'pontos-fortes', style: 'margin-top:10px' }, ...pontosFortes.map((p) => h('li', {}, `✓ ${p}`)))
|
|
: null,
|
|
h('p', { class: 'muted', style: 'margin:10px 0 0' }, OBSERVACAO_POR_PRIORIDADE[lead.prioridade] || '')
|
|
);
|
|
}
|
|
|
|
function montarScoreBreakdown(lead) {
|
|
const nichoPontos = lead.afinidade === 'alta' ? '+40' : lead.afinidade === 'baixa' ? '+20' : '+30';
|
|
const qualidade = [
|
|
[lead.telefone_valido, 'WhatsApp válido', '+30'],
|
|
[lead.cnpj_valido, 'CNPJ válido', '+20'],
|
|
[lead.empresa_valida, 'Empresa válida', '+20'],
|
|
[!!lead.cidade, 'Cidade identificada', '+5'],
|
|
[!!lead.segmento, 'Nicho identificado', '+10'],
|
|
[!!lead.cnae, 'CNAE identificado', '+10'],
|
|
[!!lead.website, 'Website informado', '+5']
|
|
];
|
|
const comercial = [
|
|
[true, `Nicho ${AFINIDADE_TABELA_LABELS[lead.afinidade] || 'de aderência não identificada'}`, nichoPontos],
|
|
[!!lead.website, 'Website informado', '+15'],
|
|
[!!lead.email, 'E-mail informado', '+10'],
|
|
[lead.telefone_valido, 'WhatsApp válido', '+15'],
|
|
[lead.cnpj_valido, 'CNPJ válido', '+20']
|
|
];
|
|
|
|
const bloco = (itens, titulo, valorTotal) => h('div', {},
|
|
h('div', { class: 'row space-between', style: 'margin-top:10px' },
|
|
h('strong', {}, titulo), h('span', {}, String(valorTotal))),
|
|
h('ul', { class: 'score-breakdown' }, ...itens.map(([ok, label, pontos]) =>
|
|
h('li', { class: ok ? 'ok' : 'nok' }, `${ok ? '✓' : '✗'} ${label} (${pontos})`)))
|
|
);
|
|
|
|
return h('div', { class: 'score-breakdown-wrap' },
|
|
bloco(qualidade, 'Score Qualidade', lead.score_qualidade),
|
|
bloco(comercial, 'Score Comercial', lead.score_comercial)
|
|
);
|
|
}
|
|
|
|
async function abrirLead(id) {
|
|
const lead = await API.get(`/api/leads/${id}`);
|
|
const linha = (dt, dd) => [h('dt', {}, dt), h('dd', {}, dd || '—')];
|
|
el('lead-detalhe').replaceChildren(
|
|
h('h2', {}, lead.empresa),
|
|
montarResumoComercial(lead),
|
|
montarScoreBreakdown(lead),
|
|
h('dl', { class: 'detalhe-grid', style: 'margin-top:14px' },
|
|
linha('Razão social', lead.legal_name),
|
|
linha('WhatsApp', lead.whatsapp),
|
|
linha('Cidade/UF', `${lead.cidade}/${lead.uf}`),
|
|
linha('Endereço', lead.endereco),
|
|
linha('Nicho', lead.segmento_label || lead.segmento),
|
|
linha('Interesse', lead.interesse),
|
|
linha('CNPJ', lead.cnpj),
|
|
linha('CNAE', lead.cnae),
|
|
linha('Score geral', `${lead.score} (${CLASSIFICACAO_LABELS[lead.classificacao] || lead.classificacao})`),
|
|
linha('Dores', (lead.pains || []).join(', ')),
|
|
linha('Origem', lead.origem),
|
|
linha('Capturado em', dataBr(lead.captured_at || lead.created_at)),
|
|
linha('Exportado em', lead.exported_at ? dataBr(lead.exported_at) : 'ainda não'),
|
|
linha('Exportações', lead.exports.length
|
|
? lead.exports.map((e) => `#${e.id} ${e.tipo}`).join(', ')
|
|
: 'nenhuma')
|
|
),
|
|
h('div', { class: 'row space-between', style: 'margin-top:14px' },
|
|
h('button', { class: 'btn-ghost', onclick: () => el('modal-lead').close() }, 'Fechar')
|
|
)
|
|
);
|
|
el('modal-lead').showModal();
|
|
}
|
|
|
|
// ---------- exportação ----------
|
|
const EXPORT_ATALHOS = [
|
|
{ label: 'Só P1', set: () => { el('f-prioridade').value = '1'; } },
|
|
{ label: 'Só ERP', set: () => { el('f-produto').value = 'ERP'; } },
|
|
{ label: 'Só G-Chat', set: () => { el('f-produto').value = 'G-Chat'; } },
|
|
{ label: 'Só Sistema de Ponto', set: () => { el('f-produto').value = 'Sistema de Ponto'; } },
|
|
{ label: 'Não exportados', set: () => { el('f-exportado').value = 'nao'; } }
|
|
];
|
|
|
|
async function atualizarRecapExport() {
|
|
const { total } = await API.get(`/api/dashboard/summary?${filtroQuery()}`);
|
|
el('export-recap').textContent = `${fmt.format(total)} leads · ${descreverFiltro()}`;
|
|
return total;
|
|
}
|
|
|
|
function renderAtalhosExport() {
|
|
el('export-atalhos').replaceChildren(...EXPORT_ATALHOS.map((atalho) =>
|
|
h('button', {
|
|
type: 'button', class: 'chip', onclick: async () => {
|
|
atalho.set();
|
|
await atualizar();
|
|
await atualizarRecapExport();
|
|
}
|
|
}, atalho.label)));
|
|
}
|
|
|
|
async function abrirModalExport() {
|
|
const total = await atualizarRecapExport();
|
|
if (!total) {
|
|
toast('Nenhum lead com este filtro — ajuste os filtros antes de exportar');
|
|
return;
|
|
}
|
|
renderAtalhosExport();
|
|
|
|
const { settings } = await API.get('/api/settings');
|
|
el('zp-pipeline').value = settings['zpro.pipelineId'] || 1;
|
|
el('zp-stage').value = settings['zpro.stageId'] || 1;
|
|
el('zp-responsible').value = settings['zpro.responsibleId'] || 15;
|
|
|
|
el('modal-export').showModal();
|
|
}
|
|
|
|
async function confirmarExport(event) {
|
|
event.preventDefault();
|
|
const tipo = document.querySelector('input[name="export-tipo"]:checked').value;
|
|
|
|
const body = {
|
|
tipo,
|
|
filtro: filtroAtual(),
|
|
params: {
|
|
marcarExportado: el('export-marcar').checked,
|
|
pipelineId: Number(el('zp-pipeline').value) || undefined,
|
|
stageId: Number(el('zp-stage').value) || undefined,
|
|
responsibleId: Number(el('zp-responsible').value) || undefined,
|
|
mensagem: tipo === 'zpro-bulk' ? el('export-mensagem').value : undefined
|
|
}
|
|
};
|
|
|
|
try {
|
|
const { jobId, totalEstimado } = await API.post('/api/exports', body);
|
|
el('modal-export').close();
|
|
toast(`Gerando arquivos para ${fmt.format(totalEstimado)} leads...`);
|
|
|
|
API.watchJob(jobId, {
|
|
done() {
|
|
toast('✅ Exportação pronta! Baixe os arquivos na lista abaixo.');
|
|
carregarExports();
|
|
atualizar();
|
|
},
|
|
error(e) {
|
|
toast(`❌ Erro na exportação: ${e.message}`);
|
|
carregarExports();
|
|
}
|
|
});
|
|
} catch (error) {
|
|
toast(`Erro: ${error.message}`);
|
|
}
|
|
}
|
|
|
|
async function carregarExports() {
|
|
const { exports } = await API.get('/api/exports?limit=12');
|
|
const box = el('historico-exports');
|
|
if (!exports.length) {
|
|
box.replaceChildren(h('div', { class: 'muted' }, 'Nenhuma exportação gerada ainda.'));
|
|
return;
|
|
}
|
|
|
|
const tipoLabel = {
|
|
excel: '📊 Planilha', 'zpro-oportunidades': '🗂️ Z-PRO Oportunidades',
|
|
'zpro-bulk': '📨 Z-PRO Disparo em massa', legado: '📦 Importação antiga (Z-PRO)'
|
|
};
|
|
|
|
box.replaceChildren(...exports.map((e) => h('div', { class: 'item' },
|
|
h('div', {},
|
|
h('div', {}, h('strong', {}, `#${e.id} · ${tipoLabel[e.tipo] || e.tipo}`),
|
|
` · ${fmt.format(e.total_leads)} leads`),
|
|
h('div', { class: 'sub' }, `${dataBr(e.created_at)} · ${e.status}${e.error ? ` · ${e.error.slice(0, 60)}` : ''}`)
|
|
),
|
|
h('div', { class: 'row' }, ...(e.status === 'done' && e.tipo !== 'legado'
|
|
? e.arquivos.map((nome) =>
|
|
h('a', { class: 'btn-ghost', href: `/api/exports/${e.id}/download?arquivo=${encodeURIComponent(nome)}` },
|
|
`⬇ ${nome.split('.').pop().toUpperCase()}`))
|
|
: []))
|
|
)));
|
|
}
|
|
|
|
// ---------- eventos ----------
|
|
function bindEvents() {
|
|
let debounce = null;
|
|
for (const input of document.querySelectorAll('#filtros [data-filtro]')) {
|
|
input.addEventListener(input.tagName === 'SELECT' ? 'change' : 'input', () => {
|
|
clearTimeout(debounce);
|
|
debounce = setTimeout(atualizar, input.tagName === 'SELECT' ? 0 : 350);
|
|
});
|
|
}
|
|
el('btn-limpar-filtros').addEventListener('click', () => {
|
|
for (const input of document.querySelectorAll('#filtros [data-filtro]')) input.value = '';
|
|
atualizar();
|
|
});
|
|
el('pg-prev').addEventListener('click', () => { page -= 1; carregarTabela(); });
|
|
el('pg-next').addEventListener('click', () => { page += 1; carregarTabela(); });
|
|
el('btn-exportar').addEventListener('click', abrirModalExport);
|
|
el('form-export').addEventListener('submit', confirmarExport);
|
|
|
|
for (const radio of document.querySelectorAll('input[name="export-tipo"]')) {
|
|
radio.addEventListener('change', () => {
|
|
el('export-mensagem-wrap').classList.toggle('hidden', radio.value !== 'zpro-bulk');
|
|
el('export-zpro-params').classList.toggle('hidden', radio.value === 'excel');
|
|
});
|
|
}
|
|
el('export-zpro-params').classList.add('hidden');
|
|
}
|
|
|
|
return { ativar, invalidar, bindEvents };
|
|
})();
|
|
|
|
window.Tratamento = Tratamento;
|