Files
COMERCIAL-GTI/public/js/captura.js
T
Gustavo d330bb5ff0 Baseline commit of the existing Maquina Comercial Pessoal project
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.
2026-07-31 10:54:07 -03:00

408 lines
14 KiB
JavaScript

/* Aba Captura: wizard UF → cidades → nichos → fontes → rodar, com progresso SSE. */
const Captura = (() => {
let ufs = [];
let municipios = []; // da UF selecionada
let segments = [];
let sources = [];
const cidadesEscolhidas = new Set();
const nichosEscolhidos = new Set();
const cnaesAvulsos = new Map(); // codigo -> descricao
let stopWatcher = null;
// ---------- carregamento ----------
async function init() {
const [ufsData, segData, srcData] = await Promise.all([
API.get('/api/meta/ufs'),
API.get('/api/meta/segments'),
API.get('/api/meta/sources')
]);
ufs = ufsData.ufs;
segments = segData.segments;
sources = srcData.sources;
renderUfs();
renderNichos();
renderFontes();
bindEvents();
await onUfChange();
await carregarHistorico();
await retomarJobAtivo();
}
function renderUfs() {
const sel = el('sel-uf');
sel.replaceChildren(...ufs.map((u) =>
h('option', { value: u.uf, selected: u.uf === 'BA' ? '' : null },
`${u.uf} — ${u.nome}${u.extraida ? ' ✓' : ''}`)
));
}
function renderNichos(filtro = '') {
const grid = el('nichos-grid');
const termo = filtro.toLowerCase();
grid.replaceChildren(...segments
.filter((s) => !termo || s.label.toLowerCase().includes(termo))
.map((s) => h('label', {},
h('input', {
type: 'checkbox', value: s.name,
checked: nichosEscolhidos.has(s.name) ? '' : null,
onchange: (e) => {
if (e.target.checked) nichosEscolhidos.add(s.name);
else nichosEscolhidos.delete(s.name);
atualizarContagem();
}
}),
s.label
)));
el('nichos-contagem').textContent = `(${segments.length} disponíveis)`;
}
function atualizarContagem() {
const modo = document.querySelector('input[name="nichos-modo"]:checked').value;
el('nichos-contagem').textContent = modo === 'todos'
? `(${segments.length} disponíveis)`
: `(${nichosEscolhidos.size} de ${segments.length} selecionados)`;
}
function renderFontes() {
const box = el('fontes-lista');
box.replaceChildren(...sources.map((s) => {
const input = h('input', {
type: 'checkbox', value: s.id,
checked: s.id === 'cnpj-base' ? '' : null,
disabled: s.disponivel ? null : ''
});
return h('label', { class: `fonte${s.disponivel ? '' : ' indisponivel'}` },
input,
h('div', {},
h('div', {}, h('strong', {}, s.label), s.recomendada ? ' ⭐' : '', s.gratuita ? '' : ' 💰'),
h('div', { class: 'desc' }, s.descricao + (s.disponivel ? '' : ` — ${s.motivo}`))
)
);
}));
}
// ---------- UF / extração ----------
function ufAtual() {
return ufs.find((u) => u.uf === el('sel-uf').value);
}
async function onUfChange() {
const u = ufAtual();
const status = el('uf-status');
if (u.extraindo) {
status.textContent = '⏳ extraindo base...';
status.className = 'chip warn';
} else if (u.extraida) {
status.textContent = '✓ base CNPJ pronta';
status.className = 'chip ok';
} else {
status.textContent = 'base CNPJ não extraída';
status.className = 'chip warn';
}
el('extracao-aviso').classList.toggle('hidden', u.extraida || u.extraindo);
el('extracao-uf').textContent = `${u.uf} — ${u.nome}`;
cidadesEscolhidas.clear();
renderChips();
const data = await API.get(`/api/meta/municipios?uf=${u.uf}`);
municipios = data.municipios;
}
async function extrairBase() {
const u = ufAtual();
try {
const { jobId } = await comCarregando(el('btn-extrair'), () => API.post('/api/extractions', { uf: u.uf }));
toast(`Download e extração de ${u.uf} iniciados — a primeira execução usa cerca de 6 GB`);
acompanharJob(jobId, `Baixando e extraindo base CNPJ de ${u.uf}`);
u.extraindo = true;
await onUfChange();
} catch (error) {
toast(`Erro: ${error.message}`);
}
}
// ---------- cidades ----------
function renderChips() {
el('cidades-chips').replaceChildren(...[...cidadesEscolhidas].map((nome) =>
h('span', {
class: 'chip', title: 'remover',
onclick: () => { cidadesEscolhidas.delete(nome); renderChips(); }
}, nome)
));
}
function buscarCidade(termo) {
const box = el('cidades-sugestoes');
if (!termo || termo.length < 2) {
box.replaceChildren();
return;
}
const t = termo.toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '');
const achados = municipios
.filter((m) => m.nome.toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '').includes(t))
.slice(0, 12);
box.replaceChildren(...achados.map((m) =>
h('button', {
type: 'button',
onclick: () => {
cidadesEscolhidas.add(m.nome);
renderChips();
el('busca-cidade').value = '';
box.replaceChildren();
}
}, m.nome)
));
}
// ---------- CNAE avulso (qualquer atividade da Receita, alem dos 57 nichos) ----------
function renderCnaeChips() {
el('cnae-chips').replaceChildren(...[...cnaesAvulsos].map(([codigo, descricao]) =>
h('span', {
class: 'chip', title: 'remover',
onclick: () => { cnaesAvulsos.delete(codigo); renderCnaeChips(); }
}, descricao)
));
}
let buscaCnaeTimer = null;
function buscarCnae(termo) {
clearTimeout(buscaCnaeTimer);
const box = el('cnae-sugestoes');
if (!termo || termo.trim().length < 2) {
box.replaceChildren();
return;
}
buscaCnaeTimer = setTimeout(async () => {
const { cnaes } = await API.get(`/api/meta/cnaes?q=${encodeURIComponent(termo)}`);
box.replaceChildren(...cnaes.map((c) =>
h('button', {
type: 'button',
onclick: () => {
cnaesAvulsos.set(c.codigo, c.descricao);
renderCnaeChips();
el('busca-cnae').value = '';
box.replaceChildren();
}
}, h('span', {}, c.descricao), h('span', { class: 'cod' }, c.codigo))
));
if (!cnaes.length) {
box.replaceChildren(h('div', { class: 'muted' }, 'Nenhuma atividade encontrada'));
}
}, 250);
}
// ---------- captura ----------
function montarPayload() {
const fontes = [...document.querySelectorAll('#fontes-lista input:checked')].map((i) => i.value);
const cidadesModo = document.querySelector('input[name="cidades-modo"]:checked').value;
const nichosModo = document.querySelector('input[name="nichos-modo"]:checked').value;
const cnaeIds = [...cnaesAvulsos.keys()].map((codigo) => `cnae:${codigo}`);
return {
sources: fontes,
uf: el('sel-uf').value,
cities: cidadesModo === 'todas' ? ['all'] : [...cidadesEscolhidas],
segments: [...(nichosModo === 'todos' ? ['all'] : [...nichosEscolhidos]), ...cnaeIds],
limits: {
maxPerQuery: Number(el('inp-max').value) || 10,
queryLimit: Number(el('inp-query-limit').value) || 0
}
};
}
async function iniciarCaptura() {
const payload = montarPayload();
if (!payload.sources.length) {
toast('Selecione pelo menos uma fonte de busca');
return;
}
if (payload.cities.length === 0) {
toast('Escolha pelo menos uma cidade (ou marque "todas")');
return;
}
if (payload.segments.length === 0) {
toast('Escolha pelo menos um nicho (ou marque "todos")');
return;
}
try {
const { jobId } = await comCarregando(el('btn-capturar'), () => API.post('/api/capture/runs', payload));
acompanharJob(jobId, 'Captura em andamento');
} catch (error) {
if (error.data?.precisaExtracao) {
el('extracao-aviso').classList.remove('hidden');
toast(error.message);
} else {
toast(`Erro: ${error.message}`);
}
}
}
// ---------- progresso ----------
function acompanharJob(jobId, titulo) {
const card = el('progresso-card');
card.classList.remove('hidden');
card.dataset.jobId = jobId;
el('progresso-titulo').textContent = titulo;
el('progresso-msg').textContent = 'iniciando...';
el('progresso-log').textContent = '';
el('progresso-fill').style.width = '3%';
el('progresso-stats').replaceChildren();
el('btn-cancelar').disabled = false;
el('btn-capturar').disabled = true;
if (stopWatcher) stopWatcher();
stopWatcher = API.watchJob(jobId, {
progress(p) {
if (p.message) el('progresso-msg').textContent = p.message;
if (typeof p.pct === 'number') el('progresso-fill').style.width = `${Math.max(3, p.pct)}%`;
if (p.counts) {
el('progresso-fill').style.width = '50%';
el('progresso-stats').replaceChildren(
mini(p.counts.raw, 'encontrados'),
mini(p.counts.new, 'novos'),
mini(p.counts.dup, 'já capturados')
);
}
},
log(l) {
const box = el('progresso-log');
box.textContent += `${l.message}\n`;
box.scrollTop = box.scrollHeight;
},
done(d) {
el('progresso-fill').style.width = '100%';
const s = d.summary || {};
el('progresso-msg').textContent = s.cancelled
? 'Cancelado.'
: s.totalRows !== undefined
? `✅ Base extraída: ${fmt.format(s.totalRows)} empresas (${fmt.format(s.withPhone)} com telefone)`
: `✅ Concluído: ${fmt.format(s.new ?? 0)} leads novos (${fmt.format(s.dup ?? 0)} já capturados antes)`;
finalizar();
},
error(e) {
el('progresso-msg').textContent = `❌ Erro: ${e.message}`;
finalizar();
}
});
function mini(valor, rotulo) {
return h('div', { class: 'mini' }, h('b', {}, fmt.format(valor ?? 0)), h('span', {}, rotulo));
}
async function finalizar() {
el('btn-cancelar').disabled = true;
el('btn-capturar').disabled = false;
await Promise.all([carregarHistorico(), recarregarUfs()]);
window.Tratamento?.invalidar();
}
}
async function recarregarUfs() {
ufs = (await API.get('/api/meta/ufs')).ufs;
const atual = el('sel-uf').value;
renderUfs();
el('sel-uf').value = atual;
await onUfChange();
}
async function cancelarJob() {
try {
await comCarregando(el('btn-cancelar'), async () => {
const jobId = el('progresso-card').dataset.jobId;
const ativos = (await API.get('/api/jobs/active')).jobs;
const job = ativos.find((j) => j.jobId === jobId);
if (!job) {
toast('Nenhum job ativo para cancelar');
return;
}
const rota = job.type === 'extracao'
? `/api/extractions/${job.meta.extractionId}/cancel`
: `/api/capture/runs/${job.meta.runId}/cancel`;
await API.post(rota, {});
toast('Cancelamento solicitado — aguardando o processo parar...');
});
} catch (error) {
toast(`Erro: ${error.message}`);
}
}
async function retomarJobAtivo() {
const ativos = (await API.get('/api/jobs/active')).jobs;
if (!ativos.length) return;
const job = ativos[0];
acompanharJob(job.jobId, job.type === 'extracao'
? `Baixando e extraindo base CNPJ de ${job.meta.uf}`
: 'Captura em andamento');
}
// ---------- histórico ----------
async function carregarHistorico() {
const { runs } = await API.get('/api/capture/runs?limit=8');
el('btn-ver-historico').textContent = runs.length ? `🕓 Histórico de capturas (${runs.length})` : '🕓 Histórico de capturas';
const box = el('historico-capturas');
if (!runs.length) {
box.replaceChildren(h('div', { class: 'muted' }, 'Nenhuma captura ainda. Configure ao lado e clique em Iniciar.'));
return;
}
const statusLabel = {
done: '✅ concluída', running: '⏳ rodando', pending: '⏳ na fila',
cancelled: '⚪ cancelada', error: '❌ erro'
};
box.replaceChildren(...runs.map((r) => h('div', { class: 'item' },
h('div', {},
h('div', {}, h('strong', {}, `#${r.id} · ${(r.params.sources || []).join(', ')} · ${r.params.uf || ''}`)),
h('div', { class: 'sub' },
`${dataBr(r.created_at)} · ${statusLabel[r.status] || r.status}`,
r.error ? ` · ${r.error.slice(0, 80)}` : '')
),
h('div', { class: 'sub' },
r.status === 'done' ? `${fmt.format(r.new_count)} novos / ${fmt.format(r.dup_count)} repetidos` : '')
)));
}
// ---------- eventos ----------
function bindEvents() {
el('sel-uf').addEventListener('change', onUfChange);
el('btn-extrair').addEventListener('click', extrairBase);
el('btn-capturar').addEventListener('click', iniciarCaptura);
el('btn-cancelar').addEventListener('click', cancelarJob);
el('btn-ver-historico').addEventListener('click', () => el('modal-historico').showModal());
el('btn-fechar-historico').addEventListener('click', () => el('modal-historico').close());
for (const radio of document.querySelectorAll('input[name="cidades-modo"]')) {
radio.addEventListener('change', () => {
el('cidades-picker').classList.toggle('hidden', radio.value !== 'escolher' || !radio.checked);
});
}
for (const radio of document.querySelectorAll('input[name="nichos-modo"]')) {
radio.addEventListener('change', () => {
el('nichos-picker').classList.toggle('hidden', radio.value !== 'escolher' || !radio.checked);
atualizarContagem();
});
}
el('busca-cidade').addEventListener('input', (e) => buscarCidade(e.target.value));
el('busca-nicho').addEventListener('input', (e) => renderNichos(e.target.value));
el('busca-cnae').addEventListener('input', (e) => buscarCnae(e.target.value));
el('btn-nichos-todos').addEventListener('click', () => {
for (const input of document.querySelectorAll('#nichos-grid input')) {
input.checked = true;
nichosEscolhidos.add(input.value);
}
atualizarContagem();
});
el('btn-nichos-nenhum').addEventListener('click', () => {
nichosEscolhidos.clear();
renderNichos(el('busca-nicho').value);
atualizarContagem();
});
}
return { init };
})();