Files
COMERCIAL-GTI/public/js/app.js
T
Gustavo 18d427e2bd Add Configuracoes review section for shared numbers
Adds an admin-only "Numeros compartilhados entre leads" section to the
settings modal, wired to the Task 4 shared-numbers routes: lists
detected groups, lets an admin liberar (with an inline motivo input)
or bloquear de novo. Updates the two frontend VM-based test files'
default API mocks to answer GET /api/outreach/shared-numbers so
existing settings-modal tests keep passing, and adds a new
interaction test covering the liberar round trip.
2026-07-31 16:33:03 -03:00

487 lines
21 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}`; }
}
async function carregarNumerosCompartilhados() {
const { groups } = await API.get('/api/outreach/shared-numbers');
const list = el('shared-numbers-list');
if (!groups.length) {
list.replaceChildren(h('p', { class: 'muted' }, 'Nenhum número compartilhado detectado.'));
return;
}
list.replaceChildren(...groups.map((group) => {
const empresas = group.leads.map((lead) => lead.empresa).join(' · ');
if (group.liberado) {
let blockButton;
blockButton = h('button', {
class: 'btn-danger btn-sm', type: 'button',
onclick: () => comCarregando(blockButton, () => bloquearNumeroCompartilhado(group.numero))
}, 'Bloquear de novo');
return h('div', { class: 'item opt-out-item' },
h('div', {},
h('strong', {}, group.numero),
h('div', { class: 'sub' }, `${group.leads.length} leads: ${empresas}`),
h('div', { class: 'sub' }, `Liberado: ${group.motivo || 'sem motivo'}`)),
blockButton);
}
const motivoInput = h('input', { type: 'text', placeholder: 'Motivo da liberação' });
let liberarButton;
liberarButton = h('button', {
class: 'btn btn-sm', type: 'button',
onclick: () => comCarregando(liberarButton, () => liberarNumeroCompartilhado(group.numero, motivoInput.value.trim()))
}, 'Liberar');
return h('div', { class: 'item opt-out-item' },
h('div', {},
h('strong', {}, group.numero),
h('div', { class: 'sub' }, `${group.leads.length} leads: ${empresas}`)),
motivoInput, liberarButton);
}));
}
async function liberarNumeroCompartilhado(numero, motivo) {
if (!motivo) {
el('shared-numbers-status').textContent = 'Informe um motivo para liberar.';
return;
}
try {
await API.post(`/api/outreach/shared-numbers/${encodeURIComponent(numero)}/liberar`, { motivo });
el('shared-numbers-status').textContent = 'Número liberado.';
await carregarNumerosCompartilhados();
} catch (error) { el('shared-numbers-status').textContent = `Erro: ${error.message}`; }
}
async function bloquearNumeroCompartilhado(numero) {
try {
await API.delete(`/api/outreach/shared-numbers/${encodeURIComponent(numero)}/liberar`);
el('shared-numbers-status').textContent = 'Número bloqueado novamente.';
await carregarNumerosCompartilhados();
} catch (error) { el('shared-numbers-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(), carregarNumerosCompartilhados(), 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); }
})();