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.
This commit is contained in:
@@ -0,0 +1,117 @@
|
||||
/* Cliente da API local + utilitários compartilhados. */
|
||||
// 401 fora da tela de login = sessao expirada/inexistente: manda pro login.
|
||||
function redirecionaSeSessaoExpirou(path, status) {
|
||||
if (status === 401 && path !== '/api/auth/login' && !location.pathname.endsWith('/login.html')) {
|
||||
window.location.href = '/login.html';
|
||||
}
|
||||
}
|
||||
|
||||
const API = {
|
||||
async get(path) {
|
||||
const res = await fetch(path);
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
redirecionaSeSessaoExpirou(path, res.status);
|
||||
throw new Error(data.erro || `HTTP ${res.status}`);
|
||||
}
|
||||
return data;
|
||||
},
|
||||
|
||||
async send(method, path, body) {
|
||||
const res = await fetch(path, {
|
||||
method,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: body === undefined ? undefined : JSON.stringify(body)
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
redirecionaSeSessaoExpirou(path, res.status);
|
||||
const error = new Error(data.erro || `HTTP ${res.status}`);
|
||||
error.data = data;
|
||||
error.status = res.status;
|
||||
throw error;
|
||||
}
|
||||
return data;
|
||||
},
|
||||
|
||||
post(path, body) { return this.send('POST', path, body); },
|
||||
put(path, body) { return this.send('PUT', path, body); },
|
||||
delete(path, body) { return this.send('DELETE', path, body); },
|
||||
|
||||
// Acompanha um job via SSE; handlers: {progress, log, done, error}
|
||||
watchJob(jobId, handlers) {
|
||||
const source = new EventSource(`/api/jobs/${jobId}/events`);
|
||||
const close = () => source.close();
|
||||
|
||||
source.addEventListener('progress', (e) => handlers.progress?.(JSON.parse(e.data)));
|
||||
source.addEventListener('log', (e) => handlers.log?.(JSON.parse(e.data)));
|
||||
source.addEventListener('done', (e) => {
|
||||
handlers.done?.(JSON.parse(e.data));
|
||||
close();
|
||||
});
|
||||
source.addEventListener('error', (e) => {
|
||||
if (e.data) {
|
||||
handlers.error?.(JSON.parse(e.data));
|
||||
close();
|
||||
}
|
||||
// sem e.data = queda de conexão; EventSource tenta reconectar sozinho
|
||||
});
|
||||
|
||||
return close;
|
||||
}
|
||||
};
|
||||
|
||||
const fmt = new Intl.NumberFormat('pt-BR');
|
||||
|
||||
function el(id) { return document.getElementById(id); }
|
||||
|
||||
function h(tag, attrs = {}, ...children) {
|
||||
const node = document.createElement(tag);
|
||||
for (const [key, value] of Object.entries(attrs)) {
|
||||
if (key === 'class') node.className = value;
|
||||
else if (key.startsWith('on')) node.addEventListener(key.slice(2), value);
|
||||
else if (value !== null && value !== undefined) node.setAttribute(key, value);
|
||||
}
|
||||
for (const child of children.flat()) {
|
||||
if (child === null || child === undefined) continue;
|
||||
node.append(child.nodeType ? child : document.createTextNode(child));
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
let toastTimer = null;
|
||||
// Aviso: um <dialog> aberto via showModal() fica acima de qualquer div normal
|
||||
// (top layer do navegador, z-index nao ajuda — e testamos que nem a Popover
|
||||
// API resolve, o dialog continua por cima). Por isso o toast NAO deve ser a
|
||||
// unica forma de mostrar erro de uma acao disparada de dentro de um modal
|
||||
// aberto: use tambem uma mensagem inline no proprio modal nesses casos.
|
||||
function toast(message, ms = 3500) {
|
||||
const box = el('toast');
|
||||
box.textContent = message;
|
||||
box.classList.remove('hidden', 'erro', 'sucesso');
|
||||
box.classList.add(/^erro/i.test(message) ? 'erro' : 'sucesso');
|
||||
clearTimeout(toastTimer);
|
||||
toastTimer = setTimeout(() => box.classList.add('hidden'), ms);
|
||||
}
|
||||
|
||||
// Envolve uma acao assincrona disparada por um botao: desabilita + mostra
|
||||
// spinner (classe .loading, ver app.css) enquanto `fn` esta em andamento,
|
||||
// restaura no final independente de sucesso ou erro. Se o botao ja estava
|
||||
// desabilitado/carregando (chamada reentrante), nao mexe no estado dele —
|
||||
// so quem entrou "limpo" e responsavel por restaurar.
|
||||
async function comCarregando(button, fn) {
|
||||
if (!button) return fn();
|
||||
const jaCarregando = button.disabled;
|
||||
if (!jaCarregando) { button.disabled = true; button.classList.add('loading'); }
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
if (!jaCarregando) { button.disabled = false; button.classList.remove('loading'); }
|
||||
}
|
||||
}
|
||||
|
||||
function dataBr(iso) {
|
||||
if (!iso) return '—';
|
||||
const d = new Date(iso.includes('T') || iso.includes(' ') ? iso.replace(' ', 'T') + (iso.endsWith('Z') ? '' : 'Z') : iso);
|
||||
return Number.isNaN(d.getTime()) ? iso : d.toLocaleString('pt-BR', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
@@ -0,0 +1,430 @@
|
||||
/* Shell da Máquina Comercial Pessoal: sessão, abas, WhatsApp e configurações. */
|
||||
(async function init() {
|
||||
let usuarioAtual;
|
||||
try {
|
||||
({ user: usuarioAtual } = await API.get('/api/auth/me'));
|
||||
} catch {
|
||||
window.location.href = '/login.html';
|
||||
return;
|
||||
}
|
||||
|
||||
const isAdmin = usuarioAtual.role === 'admin';
|
||||
let manualLeadContext = null;
|
||||
let whatsappPollTimer = null;
|
||||
let whatsappGeneration = 0;
|
||||
let whatsappPollBusyGeneration = null;
|
||||
let lastQrExpiry = null;
|
||||
const pollingStates = new Set(['STARTING', 'QR_REQUIRED', 'AUTHENTICATING', 'RECONNECTING']);
|
||||
const stateLabels = {
|
||||
STOPPED: 'Parado', STARTING: 'Iniciando', QR_REQUIRED: 'QR necessário',
|
||||
AUTHENTICATING: 'Autenticando', READY: 'Conectado', RECONNECTING: 'Reconectando',
|
||||
DISCONNECTED: 'Desconectado', ERROR: 'Erro'
|
||||
};
|
||||
|
||||
el('usuario-logado').textContent = `${usuarioAtual.username} (${isAdmin ? 'admin' : 'comercial'})`;
|
||||
el('btn-usuarios').classList.toggle('hidden', !isAdmin);
|
||||
for (const control of document.querySelectorAll('.admin-only')) control.classList.toggle('hidden', !isAdmin);
|
||||
|
||||
el('btn-sair').addEventListener('click', async () => {
|
||||
const button = el('btn-sair');
|
||||
await comCarregando(button, async () => {
|
||||
await API.post('/api/auth/logout');
|
||||
window.location.href = '/login.html';
|
||||
});
|
||||
});
|
||||
|
||||
async function carregarListaUsuarios() {
|
||||
const { users } = await API.get('/api/users');
|
||||
el('lista-usuarios').replaceChildren(...users.map((user) => h('div', { class: 'row space-between' },
|
||||
h('span', {}, user.username),
|
||||
h('span', { class: 'muted' }, user.role === 'admin' ? 'Administrador' : 'Comercial'))));
|
||||
}
|
||||
|
||||
el('btn-usuarios').addEventListener('click', async () => {
|
||||
const button = el('btn-usuarios');
|
||||
el('modal-usuarios').showModal();
|
||||
await comCarregando(button, async () => {
|
||||
try { await carregarListaUsuarios(); } catch (error) { toast(`Erro: ${error.message}`); }
|
||||
});
|
||||
});
|
||||
el('btn-fechar-usuarios').addEventListener('click', () => el('modal-usuarios').close());
|
||||
el('form-novo-usuario').addEventListener('submit', async (event) => {
|
||||
event.preventDefault();
|
||||
const form = el('form-novo-usuario');
|
||||
const button = form.querySelector('button[type=submit]');
|
||||
await comCarregando(button, async () => {
|
||||
try {
|
||||
await API.post('/api/users', {
|
||||
username: el('novo-usuario-username').value,
|
||||
password: el('novo-usuario-password').value,
|
||||
role: el('novo-usuario-role').value
|
||||
});
|
||||
el('novo-usuario-username').value = '';
|
||||
el('novo-usuario-password').value = '';
|
||||
await carregarListaUsuarios();
|
||||
toast('Usuário criado');
|
||||
} catch (error) { toast(`Erro: ${error.message}`); }
|
||||
});
|
||||
});
|
||||
|
||||
for (const tab of document.querySelectorAll('.tab')) {
|
||||
tab.addEventListener('click', () => {
|
||||
for (const item of document.querySelectorAll('.tab')) {
|
||||
item.classList.toggle('active', item === tab);
|
||||
item.setAttribute('aria-selected', item === tab ? 'true' : 'false');
|
||||
}
|
||||
for (const panel of document.querySelectorAll('.tab-panel')) {
|
||||
panel.classList.toggle('active', panel.id === `tab-${tab.dataset.tab}`);
|
||||
}
|
||||
if (tab.dataset.tab === 'tratamento') Tratamento.ativar();
|
||||
if (tab.dataset.tab === 'campanhas') Campanhas.ativar();
|
||||
});
|
||||
}
|
||||
|
||||
function stopWhatsAppPolling() {
|
||||
clearTimeout(whatsappPollTimer);
|
||||
whatsappPollTimer = null;
|
||||
}
|
||||
|
||||
function ownsWhatsAppGeneration(generation) {
|
||||
return generation === whatsappGeneration && el('modal-settings').open;
|
||||
}
|
||||
|
||||
function nextWhatsAppGeneration() {
|
||||
stopWhatsAppPolling();
|
||||
whatsappGeneration += 1;
|
||||
return whatsappGeneration;
|
||||
}
|
||||
|
||||
function scheduleWhatsAppPolling(state, generation) {
|
||||
stopWhatsAppPolling();
|
||||
if (!ownsWhatsAppGeneration(generation)) return;
|
||||
const delay = pollingStates.has(state) ? 1000 : 5000;
|
||||
whatsappPollTimer = setTimeout(() => refreshWhatsAppStatus(true, generation), delay);
|
||||
}
|
||||
|
||||
function renderWhatsAppStatus(status, generation) {
|
||||
if (!ownsWhatsAppGeneration(generation)) return false;
|
||||
const state = status?.state || 'STOPPED';
|
||||
const stateEl = el('whatsapp-state');
|
||||
stateEl.textContent = stateLabels[state] || state;
|
||||
stateEl.className = `chip connection-state state-${state.toLowerCase()}`;
|
||||
const connectedNumber = status?.number || status?.numero || status?.phone || status?.connectedNumber;
|
||||
el('whatsapp-number').textContent = connectedNumber ? `Número conectado: ${connectedNumber}` : '';
|
||||
el('whatsapp-number').classList.toggle('hidden', !connectedNumber);
|
||||
el('whatsapp-error').textContent = status?.error || status?.erro || '';
|
||||
el('whatsapp-error').classList.toggle('hidden', !(status?.error || status?.erro));
|
||||
if (state !== 'QR_REQUIRED') hideQr();
|
||||
return true;
|
||||
}
|
||||
|
||||
function hideQr() {
|
||||
el('whatsapp-qr').removeAttribute('src');
|
||||
el('whatsapp-qr').classList.add('hidden');
|
||||
el('whatsapp-qr-placeholder').classList.remove('hidden');
|
||||
el('whatsapp-qr-expiry').textContent = '';
|
||||
lastQrExpiry = null;
|
||||
}
|
||||
|
||||
async function loadQr(generation) {
|
||||
if (!isAdmin || !ownsWhatsAppGeneration(generation)) return;
|
||||
try {
|
||||
const { qr, expiresAt } = await API.get('/api/whatsapp/qr');
|
||||
if (!ownsWhatsAppGeneration(generation)) return;
|
||||
if (expiresAt !== lastQrExpiry || !el('whatsapp-qr').getAttribute('src')) {
|
||||
el('whatsapp-qr').src = qr;
|
||||
lastQrExpiry = expiresAt;
|
||||
}
|
||||
el('whatsapp-qr').classList.remove('hidden');
|
||||
el('whatsapp-qr-placeholder').classList.add('hidden');
|
||||
el('whatsapp-qr-expiry').textContent = expiresAt ? `Expira em ${dataBr(expiresAt)}` : '';
|
||||
} catch (error) {
|
||||
if (!ownsWhatsAppGeneration(generation)) return;
|
||||
el('whatsapp-qr-expiry').textContent = error.message;
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshWhatsAppStatus(fromPoll = false, generation = whatsappGeneration) {
|
||||
if (!ownsWhatsAppGeneration(generation) || whatsappPollBusyGeneration === generation) return;
|
||||
whatsappPollBusyGeneration = generation;
|
||||
try {
|
||||
const status = await API.get('/api/whatsapp/status');
|
||||
if (!renderWhatsAppStatus(status, generation)) return;
|
||||
if (status.state === 'QR_REQUIRED') await loadQr(generation);
|
||||
scheduleWhatsAppPolling(status.state || 'STOPPED', generation);
|
||||
} catch (error) {
|
||||
if (!renderWhatsAppStatus({ state: 'ERROR', error: error.message }, generation)) return;
|
||||
if (!fromPoll) toast(`WhatsApp: ${error.message}`);
|
||||
scheduleWhatsAppPolling('ERROR', generation);
|
||||
} finally {
|
||||
if (whatsappPollBusyGeneration === generation) whatsappPollBusyGeneration = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function whatsappAction(path) {
|
||||
const generation = nextWhatsAppGeneration();
|
||||
try {
|
||||
const result = await API.post(path);
|
||||
if (!ownsWhatsAppGeneration(generation)) return;
|
||||
const status = result.status || await API.get('/api/whatsapp/status');
|
||||
if (!renderWhatsAppStatus(status, generation)) return;
|
||||
if (status.state === 'QR_REQUIRED') await loadQr(generation);
|
||||
scheduleWhatsAppPolling(status.state || 'STOPPED', generation);
|
||||
} catch (error) {
|
||||
if (!ownsWhatsAppGeneration(generation)) return;
|
||||
toast(`WhatsApp: ${error.message}`);
|
||||
await refreshWhatsAppStatus(false, generation);
|
||||
}
|
||||
}
|
||||
|
||||
el('btn-whatsapp-connect').addEventListener('click', () => comCarregando(el('btn-whatsapp-connect'), () => whatsappAction('/api/whatsapp/connect')));
|
||||
el('btn-whatsapp-refresh-qr').addEventListener('click', () => comCarregando(el('btn-whatsapp-refresh-qr'), () => whatsappAction('/api/whatsapp/refresh-qr')));
|
||||
el('btn-whatsapp-disconnect').addEventListener('click', () => comCarregando(el('btn-whatsapp-disconnect'), () => whatsappAction('/api/whatsapp/disconnect')));
|
||||
el('btn-whatsapp-logout').addEventListener('click', async () => {
|
||||
if (!window.confirm('Sair do WhatsApp e apagar a sessão vinculada neste computador?')) return;
|
||||
await comCarregando(el('btn-whatsapp-logout'), () => whatsappAction('/api/whatsapp/logout'));
|
||||
});
|
||||
|
||||
function renderLeadContext() {
|
||||
if (!manualLeadContext) {
|
||||
el('manual-lead-context').textContent = 'Envio avulso, sem lead selecionado.';
|
||||
return;
|
||||
}
|
||||
const lead = manualLeadContext;
|
||||
el('manual-lead-context').textContent = [
|
||||
`Lead: ${lead.empresa || lead.legal_name || 'Empresa não informada'}`,
|
||||
lead.cidade ? `${lead.cidade}${lead.uf ? `/${lead.uf}` : ''}` : '',
|
||||
lead.segmento_label || lead.segmento || '',
|
||||
lead.cnpj || ''
|
||||
].filter(Boolean).join(' · ');
|
||||
}
|
||||
|
||||
window.MaquinaSelecionarLeadManual = (lead) => {
|
||||
manualLeadContext = lead || null;
|
||||
renderLeadContext();
|
||||
};
|
||||
|
||||
window.MaquinaLimparLeadManual = () => {
|
||||
manualLeadContext = null;
|
||||
renderLeadContext();
|
||||
};
|
||||
el('btn-limpar-lead-manual').addEventListener('click', window.MaquinaLimparLeadManual);
|
||||
|
||||
async function carregarOutreach() {
|
||||
const [{ settings }, { templates }, status] = await Promise.all([
|
||||
API.get('/api/settings'), API.get('/api/outreach/templates'), API.get('/api/outreach/status')
|
||||
]);
|
||||
el('outreach-ativo').checked = settings['outreach.ativo'] === 'true';
|
||||
el('outreach-limite').value = settings['outreach.mensagensPorDia'] || 10;
|
||||
el('outreach-status').textContent = `Consumidos do limite hoje: ${status.enviadosHoje}/${status.mensagensPorDia}`;
|
||||
|
||||
if (!templates.length) {
|
||||
el('outreach-templates').replaceChildren(h('p', { class: 'muted' },
|
||||
'Nenhum template disponível. Eles aparecerão após configurar seus serviços.'));
|
||||
} else {
|
||||
el('outreach-templates').replaceChildren(...templates.map((template) => h('div', {
|
||||
class: 'item outreach-template', 'data-produto': template.produto
|
||||
},
|
||||
h('div', { class: 'row space-between' },
|
||||
h('strong', {}, template.produto),
|
||||
h('label', { class: 'radio' },
|
||||
h('input', { type: 'checkbox', class: 'outreach-template-ativo', checked: template.ativo ? '' : null }), ' Ativo')),
|
||||
h('textarea', { class: 'outreach-template-mensagem', rows: 3 }, template.mensagem || ''))));
|
||||
}
|
||||
|
||||
const sendStatusLabels = { pendente: 'Preparando', aceito: 'Aguardando confirmação', enviado: 'Enviado ao servidor', entregue: 'Entregue', lido: 'Lido', desconhecido: 'Resultado desconhecido', erro: 'Falhou' };
|
||||
el('outreach-ultimos').replaceChildren(...status.ultimos.map((send) => h('div', { class: 'item' },
|
||||
h('div', {}, `${sendStatusLabels[send.status] || send.status} · ${send.numero || send.empresa || 'número não informado'}`),
|
||||
h('div', { class: 'sub' }, `${dataBr(send.sent_at)}${send.erro ? ` · ${send.erro}` : ''}`))));
|
||||
if (!status.ultimos.length) el('outreach-ultimos').replaceChildren(h('p', { class: 'muted' }, 'Nenhum envio ainda.'));
|
||||
}
|
||||
|
||||
async function carregarOptOuts() {
|
||||
const { optOuts } = await API.get('/api/outreach/opt-outs');
|
||||
const list = el('optout-list');
|
||||
if (!optOuts.length) {
|
||||
list.replaceChildren(h('p', { class: 'muted' }, 'Nenhum número bloqueado.'));
|
||||
return;
|
||||
}
|
||||
list.replaceChildren(...optOuts.map((item) => {
|
||||
let rowButton;
|
||||
rowButton = h('button', {
|
||||
class: 'btn-danger btn-sm', type: 'button',
|
||||
onclick: () => comCarregando(rowButton, () => removerOptOut(item.numero))
|
||||
}, 'Remover');
|
||||
return h('div', { class: 'item opt-out-item' },
|
||||
h('div', {}, h('strong', {}, item.numero),
|
||||
h('div', { class: 'sub' }, `${item.motivo || 'Sem motivo'} · ${dataBr(item.created_at)}`)),
|
||||
rowButton);
|
||||
}));
|
||||
}
|
||||
|
||||
async function removerOptOut(numero) {
|
||||
try {
|
||||
await API.delete(`/api/outreach/opt-outs/${encodeURIComponent(numero)}`);
|
||||
el('optout-status').textContent = 'Número removido da lista.';
|
||||
await carregarOptOuts();
|
||||
} catch (error) { el('optout-status').textContent = `Erro: ${error.message}`; }
|
||||
}
|
||||
|
||||
el('btn-optout-add').addEventListener('click', async () => {
|
||||
const button = el('btn-optout-add');
|
||||
await comCarregando(button, async () => {
|
||||
try {
|
||||
await API.post('/api/outreach/opt-outs', {
|
||||
numero: el('optout-numero').value.trim(), motivo: el('optout-motivo').value.trim()
|
||||
});
|
||||
el('optout-numero').value = '';
|
||||
el('optout-motivo').value = '';
|
||||
el('optout-status').textContent = 'Número adicionado à lista.';
|
||||
await carregarOptOuts();
|
||||
} catch (error) { el('optout-status').textContent = `Erro: ${error.message}`; }
|
||||
});
|
||||
});
|
||||
|
||||
el('btn-settings').addEventListener('click', async () => {
|
||||
const button = el('btn-settings');
|
||||
el('modal-settings').showModal();
|
||||
const generation = nextWhatsAppGeneration();
|
||||
await comCarregando(button, async () => {
|
||||
try {
|
||||
await Promise.all([carregarOutreach(), carregarOptOuts(), refreshWhatsAppStatus(false, generation)]);
|
||||
} catch (error) { toast(`Erro ao carregar configurações: ${error.message}`); }
|
||||
});
|
||||
});
|
||||
|
||||
el('btn-enviar-manual').addEventListener('click', async () => {
|
||||
const button = el('btn-enviar-manual');
|
||||
if (button.disabled) return;
|
||||
button.disabled = true;
|
||||
const statusEl = el('manual-status');
|
||||
statusEl.classList.remove('status-ok', 'status-error');
|
||||
statusEl.textContent = 'Enviando...';
|
||||
try {
|
||||
const result = await API.post('/api/outreach/enviar-manual', {
|
||||
numero: el('manual-numero').value.trim(),
|
||||
mensagem: el('manual-mensagem').value,
|
||||
leadId: manualLeadContext?.id || null,
|
||||
lead: manualLeadContext
|
||||
});
|
||||
const uncertain = result.status === 'resultado_desconhecido' || result.status === 'persistencia_incerta';
|
||||
const warning = Boolean(result.aviso);
|
||||
statusEl.textContent = uncertain || warning
|
||||
? result.aviso
|
||||
: result.ok
|
||||
? `Aceito pelo WhatsApp; aguardando confirmação${result.messageId ? ` · ID ${result.messageId}` : ''}`
|
||||
: result.erro;
|
||||
statusEl.classList.toggle('status-ok', Boolean(result.ok) && !warning);
|
||||
statusEl.classList.toggle('status-error', !result.ok || warning);
|
||||
await carregarOutreach();
|
||||
} catch (error) {
|
||||
statusEl.textContent = `Erro: ${error.message}`;
|
||||
statusEl.classList.add('status-error');
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
el('btn-toggle-outreach-log').addEventListener('click', () => {
|
||||
const hidden = el('outreach-ultimos').classList.toggle('hidden');
|
||||
el('btn-toggle-outreach-log').textContent = hidden ? 'Ver log de envios' : 'Ocultar log de envios';
|
||||
});
|
||||
|
||||
el('btn-salvar-outreach').addEventListener('click', async () => {
|
||||
const button = el('btn-salvar-outreach');
|
||||
await comCarregando(button, async () => {
|
||||
try {
|
||||
for (const item of el('outreach-templates').querySelectorAll('.outreach-template')) {
|
||||
await API.put(`/api/outreach/templates/${encodeURIComponent(item.dataset.produto)}`, {
|
||||
mensagem: item.querySelector('.outreach-template-mensagem').value,
|
||||
ativo: item.querySelector('.outreach-template-ativo').checked
|
||||
});
|
||||
}
|
||||
await API.put('/api/settings', {
|
||||
'outreach.ativo': el('outreach-ativo').checked ? 'true' : 'false',
|
||||
'outreach.mensagensPorDia': el('outreach-limite').value
|
||||
});
|
||||
await carregarOutreach();
|
||||
toast('Templates salvos');
|
||||
} catch (error) { toast(`Erro: ${error.message}`); }
|
||||
});
|
||||
});
|
||||
|
||||
el('btn-alterar-senha').addEventListener('click', async () => {
|
||||
const button = el('btn-alterar-senha');
|
||||
const status = el('senha-status');
|
||||
const novaSenha = el('senha-nova').value;
|
||||
if (novaSenha !== el('senha-confirmacao').value) {
|
||||
status.textContent = 'A confirmação não corresponde à nova senha.';
|
||||
return;
|
||||
}
|
||||
await comCarregando(button, async () => {
|
||||
try {
|
||||
const result = await API.put('/api/auth/password', { senhaAtual: el('senha-atual').value, novaSenha });
|
||||
el('senha-atual').value = '';
|
||||
el('senha-nova').value = '';
|
||||
el('senha-confirmacao').value = '';
|
||||
status.textContent = 'Senha alterada. Entre novamente.';
|
||||
if (result.relogin) window.location.href = '/login.html';
|
||||
} catch (error) {
|
||||
status.textContent = `Erro: ${error.message}`;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
el('btn-salvar-settings').addEventListener('click', async (event) => {
|
||||
event.preventDefault();
|
||||
const button = el('btn-salvar-settings');
|
||||
await comCarregando(button, async () => {
|
||||
try {
|
||||
await API.put('/api/settings', {
|
||||
'outreach.ativo': el('outreach-ativo').checked ? 'true' : 'false',
|
||||
'outreach.mensagensPorDia': el('outreach-limite').value
|
||||
});
|
||||
stopWhatsAppPolling();
|
||||
el('modal-settings').close();
|
||||
toast('Configurações salvas');
|
||||
} catch (error) { toast(`Erro: ${error.message}`); }
|
||||
});
|
||||
});
|
||||
|
||||
el('modal-settings').addEventListener('close', nextWhatsAppGeneration);
|
||||
el('btn-limpar-base').addEventListener('click', () => {
|
||||
el('reset-base-senha').value = '';
|
||||
el('reset-base-erro').classList.add('hidden');
|
||||
stopWhatsAppPolling();
|
||||
el('modal-settings').close();
|
||||
el('modal-reset-base').showModal();
|
||||
});
|
||||
el('btn-cancelar-reset-base').addEventListener('click', () => el('modal-reset-base').close());
|
||||
el('btn-confirmar-reset-base').addEventListener('click', async () => {
|
||||
const button = el('btn-confirmar-reset-base');
|
||||
const erroEl = el('reset-base-erro');
|
||||
const senha = el('reset-base-senha').value;
|
||||
if (!senha) {
|
||||
erroEl.textContent = 'Informe a senha de segurança';
|
||||
erroEl.classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
await comCarregando(button, async () => {
|
||||
try {
|
||||
await API.post('/api/admin/reset', { senha });
|
||||
el('modal-reset-base').close();
|
||||
toast('Base de leads limpa com sucesso. Recarregando...');
|
||||
setTimeout(() => window.location.reload(), 1200);
|
||||
} catch (error) {
|
||||
erroEl.textContent = `Erro: ${error.message}`;
|
||||
erroEl.classList.remove('hidden');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
for (const dialog of document.querySelectorAll('dialog')) {
|
||||
dialog.addEventListener('click', (event) => { if (event.target === dialog) dialog.close(); });
|
||||
}
|
||||
|
||||
renderLeadContext();
|
||||
Tratamento.bindEvents();
|
||||
Campanhas.bindEvents();
|
||||
try { await Captura.init(); } catch (error) { toast(`Erro ao carregar: ${error.message}`, 8000); }
|
||||
})();
|
||||
@@ -0,0 +1,414 @@
|
||||
window.campanhasInit = window.campanhasInit || (() => {
|
||||
let carregado = false;
|
||||
let refreshTimer = null;
|
||||
let selectedCampaignId = null;
|
||||
let currentQueue = [];
|
||||
let startCampaignId = null;
|
||||
let stageLeadId = null;
|
||||
|
||||
const STATUS_LABELS = {
|
||||
draft: 'Rascunho', paused: 'Pausada', ready: 'Pronta',
|
||||
running: 'Ativa', completed: 'Concluída'
|
||||
};
|
||||
|
||||
const QUEUE_STATUS_LABELS = {
|
||||
preview: 'Pré-visualização', approved: 'Aprovado', scheduled: 'Agendado',
|
||||
reserved: 'Reservado', sent: 'Enviado', unknown: 'Resultado desconhecido',
|
||||
error: 'Falhou', cancelled: 'Cancelado'
|
||||
};
|
||||
const REMOVABLE_QUEUE_STATUSES = new Set(['preview', 'approved', 'scheduled']);
|
||||
|
||||
// Cada valor precisa existir em CAMPAIGN_STAGES (src/outreach/campaign-catalog.mjs) —
|
||||
// o backend rejeita qualquer outro com 400. De propósito NÃO inclui os estágios
|
||||
// automáticos do pipeline anteriores a uma resposta (fila_preparada,
|
||||
// aprovado_para_envio, enviado): este dialogo so abre para leads que ja
|
||||
// responderam (stage 'respondeu'), entao esses 3 nunca se aplicam aqui.
|
||||
const STAGE_OPTIONS = [
|
||||
['respondeu', 'Respondeu'],
|
||||
['interessado_gti_vendas', 'Interessado — GTI Vendas'],
|
||||
['interessado_g_delivery', 'Interessado — G-Delivery'],
|
||||
['demonstracao_agendada', 'Demonstração agendada'],
|
||||
['teste_g_delivery_iniciado', 'Teste G-Delivery iniciado'],
|
||||
['venda_fechada', 'Venda fechada'],
|
||||
['sem_interesse', 'Sem interesse'],
|
||||
['opt_out', 'Opt-out']
|
||||
];
|
||||
|
||||
function hojeLocalYMD() {
|
||||
const now = new Date();
|
||||
const month = String(now.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(now.getDate()).padStart(2, '0');
|
||||
return `${now.getFullYear()}-${month}-${day}`;
|
||||
}
|
||||
|
||||
function modalErro(id, message) {
|
||||
const erroEl = el(id);
|
||||
if (!erroEl) return;
|
||||
erroEl.textContent = message || '';
|
||||
erroEl.classList.toggle('hidden', !message);
|
||||
}
|
||||
|
||||
function invalidar() {
|
||||
carregado = false;
|
||||
clearTimeout(refreshTimer);
|
||||
refreshTimer = setTimeout(() => {
|
||||
if (document.querySelector('#tab-campanhas.active')) ativar();
|
||||
}, 800);
|
||||
}
|
||||
|
||||
async function ativar() {
|
||||
if (carregado) return;
|
||||
carregado = true;
|
||||
await Promise.all([carregarCampanhas(), carregarReplies(), carregarStageClassification(), carregarMetrics()]);
|
||||
}
|
||||
|
||||
async function carregarCampanhas() {
|
||||
const { campaigns } = await API.get('/api/campaigns');
|
||||
el('campaign-cards').replaceChildren(...campaigns.map(renderCard));
|
||||
}
|
||||
|
||||
function renderCard(c) {
|
||||
const statusClass = c.status === 'running' ? 'chip ok' : c.status === 'paused' ? 'chip warn' : 'chip';
|
||||
const actions = [];
|
||||
if (c.status === 'ready' || c.status === 'paused') {
|
||||
actions.push(h('button', { class: 'btn btn-primary btn-sm', id: `btn-preview-${c.id}`, onclick: () => prepararFila(c.id) }, 'Preparar fila'));
|
||||
}
|
||||
if (c.status === 'ready' || c.status === 'paused') {
|
||||
actions.push(h('button', { class: 'btn btn-primary btn-sm', id: `btn-start-${c.id}`, onclick: () => iniciarCampanha(c.id) }, 'Iniciar'));
|
||||
}
|
||||
if (c.status === 'running') {
|
||||
actions.push(h('button', { class: 'btn-ghost btn-sm', id: `btn-pause-${c.id}`, onclick: () => pausarCampanha(c.id) }, 'Pausar'));
|
||||
}
|
||||
if (c.status === 'running' || c.status === 'ready' || c.status === 'paused') {
|
||||
actions.push(h('button', { class: 'btn-ghost btn-sm', id: `btn-queue-${c.id}`, onclick: () => carregarFila(c.id) }, 'Ver fila'));
|
||||
}
|
||||
if (c.week_number < 4) {
|
||||
actions.push(h('button', { class: 'btn-ghost btn-sm', id: `btn-advance-week-${c.id}`, onclick: () => avancarSemana(c.id, c.week_number) }, 'Avançar semana'));
|
||||
}
|
||||
|
||||
return h('div', { class: 'card campaign-card', 'data-campaign-id': c.id },
|
||||
h('div', { class: 'row space-between' },
|
||||
h('h3', { style: 'margin:0;font-size:16px' }, c.name),
|
||||
h('span', { class: statusClass }, STATUS_LABELS[c.status] || c.status)
|
||||
),
|
||||
h('div', { class: 'campaign-details row', style: 'margin-top:8px' },
|
||||
h('span', { class: 'campaign-week' }, `Semana ${c.week_number}`),
|
||||
h('span', { class: 'campaign-limit' }, `Limite: ${c.approved_daily_limit}/dia`),
|
||||
c.pause_reason ? h('span', { class: 'pause-reason muted', style: 'color:var(--critical);font-weight:750' }, `Pausado: ${c.pause_reason}`) : null
|
||||
),
|
||||
h('div', { class: 'campaign-usage row', style: 'margin-top:6px;font-size:12.5px;color:var(--ink-2)' },
|
||||
h('span', { class: 'manual-usage' }, `Manual: ${c.usage?.manual?.used || 0}/${c.usage?.manual?.limit || 0}`),
|
||||
h('span', { class: 'auto-usage' }, `Automático: ${c.usage?.campaign?.used || 0}/${c.approved_daily_limit}`)
|
||||
),
|
||||
actions.length ? h('div', { class: 'campaign-actions row', style: 'margin-top:8px' }, ...actions) : null
|
||||
);
|
||||
}
|
||||
|
||||
async function prepararFila(campaignId) {
|
||||
const button = el(`btn-preview-${campaignId}`);
|
||||
return comCarregando(button, async () => {
|
||||
try {
|
||||
const result = await API.post(`/api/campaigns/${campaignId}/prepare`);
|
||||
toast(`Preparados: ${result.summary.previewed} fila, ${result.summary.checked} verificados, ${result.summary.skipped} pulados`);
|
||||
await carregarFila(campaignId);
|
||||
} catch (error) { toast(`Erro: ${error.message}`); }
|
||||
});
|
||||
}
|
||||
|
||||
async function carregarFila(campaignId) {
|
||||
const button = el(`btn-queue-${campaignId}`);
|
||||
return comCarregando(button, async () => {
|
||||
try {
|
||||
selectedCampaignId = campaignId;
|
||||
// Sem filtro de status: uma campanha em andamento ja tem itens em scheduled/sent/etc,
|
||||
// nao so preview, e o operador precisa poder ver o que esta acontecendo com eles.
|
||||
const { queue } = await API.get(`/api/campaigns/${campaignId}/queue`);
|
||||
currentQueue = queue || [];
|
||||
const container = el('campaign-queue');
|
||||
if (!queue || !queue.length) {
|
||||
container.replaceChildren(h('p', { class: 'muted' }, 'Fila vazia. Clique em "Preparar fila" para gerar itens.'));
|
||||
return;
|
||||
}
|
||||
const previewCount = queue.filter((r) => r.status === 'preview').length;
|
||||
container.replaceChildren(
|
||||
h('div', { class: 'table-wrap' },
|
||||
h('table', { id: 'campaign-queue-table' },
|
||||
h('thead', {},
|
||||
h('tr', {},
|
||||
h('th', {}, 'Remover'), h('th', {}, 'Empresa'),
|
||||
h('th', {}, 'Mensagem'), h('th', {}, 'Segmento'),
|
||||
h('th', {}, 'Status'), h('th', {}, 'Motivo de seleção')
|
||||
)
|
||||
),
|
||||
h('tbody', {}, ...queue.map((row) =>
|
||||
h('tr', { class: 'queue-row', 'data-queue-id': row.id },
|
||||
h('td', {}, REMOVABLE_QUEUE_STATUSES.has(row.status) ? h('button', {
|
||||
class: 'btn-danger btn-sm btn-remove-queue',
|
||||
id: `btn-remove-queue-${row.id}`,
|
||||
'data-queue-id': row.id,
|
||||
onclick: () => removerItemFila(row.id, campaignId)
|
||||
}, 'Remover') : null),
|
||||
h('td', {}, row.empresa || '—'),
|
||||
h('td', { style: 'white-space:normal;max-width:400px' }, row.rendered_message || '—'),
|
||||
h('td', {}, row.segmento || '—'),
|
||||
h('td', {}, QUEUE_STATUS_LABELS[row.status] || row.status),
|
||||
h('td', { style: 'font-size:12px;color:var(--muted)' }, row.selection_reason || '—')
|
||||
)
|
||||
))
|
||||
)
|
||||
),
|
||||
h('div', { class: 'row space-between', style: 'margin-top:12px' },
|
||||
previewCount
|
||||
? h('button', { class: 'btn btn-primary', id: 'btn-approve', onclick: () => aprovarFila(campaignId) },
|
||||
`Aprovar fila (${previewCount} itens)`)
|
||||
: null,
|
||||
h('span', { class: 'muted', id: 'queue-count' }, `${queue.length} itens na fila`)
|
||||
)
|
||||
);
|
||||
} catch (error) { toast(`Erro ao carregar fila: ${error.message}`); }
|
||||
});
|
||||
}
|
||||
|
||||
async function removerItemFila(queueId, campaignId) {
|
||||
const button = el(`btn-remove-queue-${queueId}`);
|
||||
return comCarregando(button, async () => {
|
||||
try {
|
||||
await API.delete(`/api/campaigns/${campaignId}/queue/${queueId}`);
|
||||
toast('Item removido da fila');
|
||||
await carregarFila(campaignId);
|
||||
} catch (error) { toast(`Erro: ${error.message}`); }
|
||||
});
|
||||
}
|
||||
|
||||
async function aprovarFila(campaignId) {
|
||||
const queueIds = currentQueue.filter((row) => row.status === 'preview').map((row) => row.id);
|
||||
if (!queueIds.length) { toast('Nenhum item na fila para aprovar'); return; }
|
||||
const button = el('btn-approve');
|
||||
return comCarregando(button, async () => {
|
||||
try {
|
||||
const result = await API.post(`/api/campaigns/${campaignId}/approve`, { queueIds, date: hojeLocalYMD() });
|
||||
toast(`Fila aprovada: ${result.scheduled || 0} itens agendados`);
|
||||
await carregarFila(campaignId);
|
||||
await carregarCampanhas();
|
||||
} catch (error) { toast(`Erro: ${error.message}`); }
|
||||
});
|
||||
}
|
||||
|
||||
function iniciarCampanha(campaignId) {
|
||||
startCampaignId = campaignId;
|
||||
modalErro('modal-campaign-start-erro', '');
|
||||
el('modal-campaign-start').showModal();
|
||||
}
|
||||
|
||||
function pausarCampanha(campaignId) {
|
||||
const reason = window.prompt('Motivo da pausa:');
|
||||
if (!reason || !reason.trim()) return;
|
||||
const button = el(`btn-pause-${campaignId}`);
|
||||
comCarregando(button, () => API.post(`/api/campaigns/${campaignId}/pause`, { reason: reason.trim() })
|
||||
.then(() => { toast('Campanha pausada'); carregarCampanhas(); })
|
||||
.catch((error) => toast(`Erro: ${error.message}`)));
|
||||
}
|
||||
|
||||
function avancarSemana(campaignId, currentWeek) {
|
||||
const nextWeek = currentWeek + 1;
|
||||
if (!window.confirm(`Avançar de semana ${currentWeek} para semana ${nextWeek}? O limite diário aprovado muda para o novo patamar.`)) return;
|
||||
const button = el(`btn-advance-week-${campaignId}`);
|
||||
comCarregando(button, () => API.put(`/api/campaigns/${campaignId}/week`, { week: nextWeek })
|
||||
.then(() => { toast(`Campanha avançou para semana ${nextWeek}`); carregarCampanhas(); })
|
||||
.catch((error) => toast(`Erro: ${error.message}`)));
|
||||
}
|
||||
|
||||
async function carregarReplies() {
|
||||
try {
|
||||
const campaigns = await API.get('/api/campaigns').then((r) => r.campaigns).catch(() => []);
|
||||
const campaignNames = new Map(campaigns.map((c) => [c.id, c.name]));
|
||||
const { pending } = await API.get('/api/campaigns/replies/pending');
|
||||
const container = el('pending-replies');
|
||||
if (!pending || !pending.length) {
|
||||
container.replaceChildren(h('p', { class: 'muted' }, 'Nenhuma resposta pendente.'));
|
||||
return;
|
||||
}
|
||||
container.replaceChildren(...pending.map((r) =>
|
||||
h('div', { class: 'item' },
|
||||
h('div', {},
|
||||
h('strong', {}, r.empresa || r.whatsapp || '—'),
|
||||
h('div', { class: 'sub' }, `${campaignNames.get(r.campaign_id) || ''} · ${dataBr(r.updated_at)}`)
|
||||
),
|
||||
h('div', { style: 'font-size:12.5px;color:var(--ink-2);flex:1' }, r.note || ''),
|
||||
h('button', {
|
||||
class: 'btn-ghost btn-sm', onclick: () => abrirStageDialog(r.lead_id)
|
||||
}, 'Classificar')
|
||||
)
|
||||
));
|
||||
} catch { /* silêncio */ }
|
||||
}
|
||||
|
||||
function abrirStageDialog(leadId) {
|
||||
stageLeadId = leadId;
|
||||
const inp = el('inp-stage');
|
||||
if (inp) {
|
||||
inp.replaceChildren(...STAGE_OPTIONS.map(([value, label]) => h('option', { value }, label)));
|
||||
inp.value = 'respondeu';
|
||||
}
|
||||
modalErro('modal-campaign-stage-erro', '');
|
||||
el('modal-campaign-stage').showModal();
|
||||
}
|
||||
|
||||
async function carregarStageClassification() {
|
||||
el('stage-classification').replaceChildren(
|
||||
h('p', { class: 'muted' }, 'Use a seção de respostas pendentes para classificar leads por estágio.')
|
||||
);
|
||||
}
|
||||
|
||||
async function carregarMetrics() {
|
||||
try {
|
||||
const campaigns = await API.get('/api/campaigns').then((r) => r.campaigns).catch(() => []);
|
||||
const filters = el('metrics-filters');
|
||||
const display = el('campaign-metrics');
|
||||
|
||||
if (!campaigns.length) {
|
||||
filters.replaceChildren(h('p', { class: 'muted' }, 'Sem campanhas para métricas.'));
|
||||
display.replaceChildren();
|
||||
return;
|
||||
}
|
||||
|
||||
const segmentos = [...new Set(campaigns.flatMap((c) => c.segments || []))];
|
||||
const variants = ['A', 'B', 'C'];
|
||||
|
||||
filters.replaceChildren(
|
||||
h('label', {}, 'Segmento',
|
||||
h('select', { id: 'filtro-metrics-segmento', onchange: () => carregarMetricsData() },
|
||||
h('option', { value: '' }, 'Todos'),
|
||||
...segmentos.map((s) => h('option', { value: s }, s))
|
||||
)
|
||||
),
|
||||
h('label', {}, 'Variante',
|
||||
h('select', { id: 'filtro-metrics-variant', onchange: () => carregarMetricsData() },
|
||||
h('option', { value: '' }, 'Todas'),
|
||||
...variants.map((v) => h('option', { value: v }, v))
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
display.replaceChildren(h('p', { class: 'muted' }, 'Selecione uma campanha e clique em "Ver fila" para ver métricas detalhadas.'));
|
||||
} catch { /* silêncio */ }
|
||||
}
|
||||
|
||||
async function carregarMetricsData() {
|
||||
if (!selectedCampaignId) return;
|
||||
const segmento = (el('filtro-metrics-segmento') || {}).value || '';
|
||||
const variant = (el('filtro-metrics-variant') || {}).value || '';
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (segmento) params.set('segmento', segmento);
|
||||
if (variant) params.set('variant', variant);
|
||||
const { metrics } = await API.get(`/api/campaigns/${selectedCampaignId}/metrics?${params}`);
|
||||
// Node.replaceChildren() stringifies non-Node args (null -> the text "null"),
|
||||
// unlike h() which filters null children. Build the list and drop nulls first.
|
||||
el('campaign-metrics').replaceChildren(
|
||||
...[
|
||||
h('div', { class: 'stat-row' },
|
||||
h('div', { class: 'mini' },
|
||||
h('b', {}, String(metrics.previewed || 0)),
|
||||
h('span', {}, 'Visualizados')
|
||||
),
|
||||
h('div', { class: 'mini' },
|
||||
h('b', {}, String(metrics.approved || 0)),
|
||||
h('span', {}, 'Aprovados')
|
||||
),
|
||||
h('div', { class: 'mini' },
|
||||
h('b', {}, String(metrics.sent || 0)),
|
||||
h('span', {}, 'Enviados')
|
||||
),
|
||||
h('div', { class: 'mini' },
|
||||
h('b', {}, String(metrics.replies || 0)),
|
||||
h('span', {}, 'Responderam')
|
||||
)
|
||||
),
|
||||
metrics.firstResponseTimeAvg != null
|
||||
? h('p', { class: 'muted' }, `Tempo médio de primeira resposta: ${Math.round(metrics.firstResponseTimeAvg)} min`)
|
||||
: null
|
||||
].filter(Boolean)
|
||||
);
|
||||
} catch { /* silêncio */ }
|
||||
}
|
||||
|
||||
// Todos os listeners abaixo sao anexados UMA VEZ, aqui, sobre botoes fixos e
|
||||
// compartilhados do HTML (os modais nao sao recriados por campanha/lead).
|
||||
// Anexar de novo a cada abertura de modal (como antes) acumula handlers com
|
||||
// closures antigas e dispara acoes duplicadas/erradas ao confirmar.
|
||||
function bindEvents() {
|
||||
const confirmStartBtn = el('btn-confirm-start');
|
||||
if (confirmStartBtn) {
|
||||
confirmStartBtn.addEventListener('click', async () => {
|
||||
if (!startCampaignId) return;
|
||||
await comCarregando(confirmStartBtn, async () => {
|
||||
try {
|
||||
await API.post(`/api/campaigns/${startCampaignId}/start`);
|
||||
el('modal-campaign-start').close();
|
||||
toast('Campanha iniciada');
|
||||
await carregarCampanhas();
|
||||
} catch (error) {
|
||||
toast(`Erro: ${error.message}`);
|
||||
modalErro('modal-campaign-start-erro', error.message);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const saveStageBtn = el('btn-save-stage');
|
||||
if (saveStageBtn) {
|
||||
saveStageBtn.addEventListener('click', async () => {
|
||||
if (!stageLeadId) return;
|
||||
const stage = (el('inp-stage') || {}).value;
|
||||
if (!stage) return;
|
||||
await comCarregando(saveStageBtn, async () => {
|
||||
try {
|
||||
await API.put(`/api/campaigns/leads/${stageLeadId}/stage`, { stage });
|
||||
el('modal-campaign-stage').close();
|
||||
toast('Estágio atualizado');
|
||||
carregarReplies();
|
||||
} catch (error) {
|
||||
toast(`Erro: ${error.message}`);
|
||||
modalErro('modal-campaign-stage-erro', error.message);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const scheduleBtn = el('btn-schedule-appointment');
|
||||
if (scheduleBtn) {
|
||||
scheduleBtn.addEventListener('click', () => {
|
||||
modalErro('modal-campaign-appointment-erro', '');
|
||||
el('modal-campaign-appointment').showModal();
|
||||
});
|
||||
}
|
||||
|
||||
const confirmAppointmentBtn = el('btn-confirm-appointment');
|
||||
if (confirmAppointmentBtn) {
|
||||
confirmAppointmentBtn.addEventListener('click', async () => {
|
||||
const leadId = Number((el('inp-appointment-lead-id') || {}).value || 0);
|
||||
const kind = (el('inp-appointment-kind') || {}).value || 'demonstracao';
|
||||
const participant = (el('inp-appointment-participant') || {}).value || '';
|
||||
const company = (el('inp-appointment-company') || {}).value || '';
|
||||
const day = (el('inp-appointment-day') || {}).value || '';
|
||||
const time = (el('inp-appointment-time') || {}).value || '';
|
||||
if (!leadId) { toast('Selecione um lead'); return; }
|
||||
await comCarregando(confirmAppointmentBtn, async () => {
|
||||
try {
|
||||
await API.post(`/api/campaigns/leads/${leadId}/appointments`, { kind, participantName: participant, companyName: company, preferredDay: day, preferredTime: time });
|
||||
el('modal-campaign-appointment').close();
|
||||
toast('Agendamento salvo');
|
||||
} catch (error) {
|
||||
toast(`Erro: ${error.message}`);
|
||||
modalErro('modal-campaign-appointment-erro', error.message);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { ativar, invalidar, bindEvents };
|
||||
})();
|
||||
|
||||
window.Campanhas = window.campanhasInit;
|
||||
@@ -0,0 +1,407 @@
|
||||
/* 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 };
|
||||
})();
|
||||
@@ -0,0 +1,16 @@
|
||||
/* Tela de login: autentica e redireciona para o app. */
|
||||
el('form-login').addEventListener('submit', async (event) => {
|
||||
event.preventDefault();
|
||||
const erroBox = el('login-erro');
|
||||
erroBox.textContent = '';
|
||||
|
||||
try {
|
||||
await API.post('/api/auth/login', {
|
||||
username: el('login-username').value,
|
||||
password: el('login-password').value
|
||||
});
|
||||
window.location.href = '/';
|
||||
} catch (error) {
|
||||
erroBox.textContent = error.message;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,676 @@
|
||||
/* 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;
|
||||
Reference in New Issue
Block a user