Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> git-subtree-dir: gti-maquina-comercial git-subtree-mainline:93ca82979egit-subtree-split:b77484946f
406 lines
14 KiB
JavaScript
406 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 API.post('/api/extractions', { uf: u.uf });
|
|
toast(`Extração de ${u.uf} iniciada — pode levar até 1 hora`);
|
|
acompanharJob(jobId, `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 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() {
|
|
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`;
|
|
try {
|
|
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'
|
|
? `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 };
|
|
})();
|