Tracks the app that was already in place (capture, treatment, outreach, WhatsApp integration, server, frontend, tests, docs) so future feature work can branch and use isolated worktrees.
677 lines
28 KiB
JavaScript
677 lines
28 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'
|
|
};
|
|
const WHATSAPP_STATUS_LABELS = {
|
|
nao_consultado: 'Não consultado',
|
|
confirmado: 'Confirmado',
|
|
nao_cadastrado: 'Não cadastrado',
|
|
erro_temporario: 'Erro temporário'
|
|
};
|
|
|
|
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 telefone' : 'sem telefone');
|
|
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 telefone', 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: '11', class: 'muted' }, 'Nenhum lead com este filtro.')));
|
|
return;
|
|
}
|
|
|
|
const abrirWhatsAppLead = async (event, lead) => {
|
|
event.stopPropagation();
|
|
if (!lead.whatsapp) {
|
|
toast('Este lead não tem telefone cadastrado');
|
|
return;
|
|
}
|
|
let leadCompleto = lead;
|
|
await comCarregando(event.currentTarget, async () => {
|
|
try {
|
|
leadCompleto = await API.get(`/api/leads/${lead.id}`);
|
|
} catch {
|
|
leadCompleto = lead;
|
|
}
|
|
});
|
|
const settingsButton = el('btn-settings');
|
|
settingsButton.click();
|
|
setTimeout(() => {
|
|
window.MaquinaSelecionarLeadManual?.(leadCompleto);
|
|
el('manual-numero').value = leadCompleto.whatsapp || lead.whatsapp;
|
|
const empresa = leadCompleto.empresa || lead.empresa;
|
|
const cidade = leadCompleto.cidade || lead.cidade;
|
|
el('manual-mensagem').value = `Olá! Encontrei a ${empresa}${cidade ? ` em ${cidade}` : ''} e gostaria de apresentar meu serviço. Podemos conversar?`;
|
|
el('manual-status').textContent = '';
|
|
}, 250);
|
|
};
|
|
|
|
tbody.replaceChildren(...data.rows.map((lead) => h('tr', { onclick: () => abrirLead(lead.id) },
|
|
h('td', { class: 'acao-col' }, h('button', {
|
|
class: 'whatsapp-action',
|
|
title: 'Enviar WhatsApp para este lead',
|
|
onclick: (event) => abrirWhatsAppLead(event, lead)
|
|
}, '☎')),
|
|
h('td', { class: 'empresa-cell', title: lead.legal_name || lead.empresa }, lead.empresa),
|
|
h('td', {}, lead.whatsapp || '—'),
|
|
h('td', {}, whatsappStatusCell(lead)),
|
|
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 whatsappCheckedAt(value) {
|
|
if (!value) return '';
|
|
const normalized = value.includes('T') ? value : `${value.replace(' ', 'T')}Z`;
|
|
return Number.isNaN(Date.parse(normalized)) ? '' : dataBr(value);
|
|
}
|
|
|
|
function whatsappStatusCell(lead) {
|
|
const status = lead.whatsapp_status || 'nao_consultado';
|
|
const detalhes = [];
|
|
if (lead.whatsapp_numero_resolvido) detalhes.push(`Número confirmado: ${lead.whatsapp_numero_resolvido}`);
|
|
const checkedAt = whatsappCheckedAt(lead.whatsapp_checked_at);
|
|
if (checkedAt) detalhes.push(`Consultado em ${checkedAt}`);
|
|
return h('div', { class: 'whatsapp-verification' },
|
|
h('span', { class: `badge whatsapp-${status}` }, WHATSAPP_STATUS_LABELS[status] || status),
|
|
detalhes.length ? h('div', { class: 'sub' }, detalhes.join(' · ')) : null);
|
|
}
|
|
|
|
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('Telefone 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, 'Telefone 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, 'Telefone 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('Telefone', lead.whatsapp),
|
|
linha('Status WhatsApp', WHATSAPP_STATUS_LABELS[lead.whatsapp_status] || lead.whatsapp_status || 'Não consultado'),
|
|
linha('Número WhatsApp confirmado', lead.whatsapp_numero_resolvido),
|
|
linha('WhatsApp consultado em', whatsappCheckedAt(lead.whatsapp_checked_at)),
|
|
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: '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) => {
|
|
const botao = h('button', {
|
|
type: 'button', class: 'chip', onclick: async () => {
|
|
await comCarregando(botao, async () => {
|
|
atalho.set();
|
|
await atualizar();
|
|
await atualizarRecapExport();
|
|
});
|
|
}
|
|
}, atalho.label);
|
|
return botao;
|
|
}));
|
|
}
|
|
|
|
async function abrirModalExport(event) {
|
|
const botao = event?.currentTarget || el('btn-exportar');
|
|
await comCarregando(botao, async () => {
|
|
const total = await atualizarRecapExport();
|
|
if (!total) {
|
|
toast('Nenhum lead com este filtro — ajuste os filtros antes de exportar');
|
|
return;
|
|
}
|
|
renderAtalhosExport();
|
|
|
|
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 }
|
|
};
|
|
|
|
try {
|
|
const { jobId, totalEstimado } = await comCarregando(el('btn-confirmar-export'), () => 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 CSV + XLS', legado: 'Importação antiga' };
|
|
|
|
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', (event) => {
|
|
comCarregando(event.currentTarget, async () => {
|
|
for (const input of document.querySelectorAll('#filtros [data-filtro]')) input.value = '';
|
|
await atualizar();
|
|
});
|
|
});
|
|
// pg-prev/pg-next: nao usam comCarregando de proposito. carregarTabela() ja
|
|
// controla o atributo disabled destes botoes conforme a pagina atual
|
|
// (limite inicio/fim); o finally do comCarregando reabilitaria o botao
|
|
// incondicionalmente ao final, conflitando com esse controle de borda.
|
|
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);
|
|
|
|
}
|
|
|
|
return { ativar, invalidar, bindEvents };
|
|
})();
|
|
|
|
window.Tratamento = Tratamento;
|