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.
431 lines
18 KiB
JavaScript
431 lines
18 KiB
JavaScript
/* 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); }
|
|
})();
|