From 18d427e2bd692e3ce78cc2b8dd0060b616295ca4 Mon Sep 17 00:00:00 2001 From: Gustavo Date: Fri, 31 Jul 2026 16:33:03 -0300 Subject: [PATCH] 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. --- public/index.html | 7 ++++ public/js/app.js | 58 +++++++++++++++++++++++++++++++- tests/campaign-ui.test.mjs | 2 ++ tests/personal-branding.test.mjs | 39 +++++++++++++++++++++ 4 files changed, 105 insertions(+), 1 deletion(-) diff --git a/public/index.html b/public/index.html index 720b96b..3456204 100644 --- a/public/index.html +++ b/public/index.html @@ -419,6 +419,13 @@
+
+ Números compartilhados entre leads +

Detectados automaticamente quando o mesmo número aparece em mais de um lead (ex.: contabilidade que registra o próprio telefone para clientes). Envio automático e manual ficam bloqueados até você revisar e liberar.

+

+
+
+
Zona de risco

Apaga permanentemente todos os leads, exportações, capturas e extrações. Configurações e nichos não são afetados.

diff --git a/public/js/app.js b/public/js/app.js index c564774..31ce6b0 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -267,6 +267,62 @@ } 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 () => { @@ -288,7 +344,7 @@ const generation = nextWhatsAppGeneration(); await comCarregando(button, async () => { try { - await Promise.all([carregarOutreach(), carregarOptOuts(), refreshWhatsAppStatus(false, generation)]); + await Promise.all([carregarOutreach(), carregarOptOuts(), carregarNumerosCompartilhados(), refreshWhatsAppStatus(false, generation)]); } catch (error) { toast(`Erro ao carregar configurações: ${error.message}`); } }); }); diff --git a/tests/campaign-ui.test.mjs b/tests/campaign-ui.test.mjs index 8b49944..e1bc2f1 100644 --- a/tests/campaign-ui.test.mjs +++ b/tests/campaign-ui.test.mjs @@ -78,6 +78,7 @@ async function executeApp({ get, post, patch, del, put } = {}) { if (url === '/api/outreach/templates') return { templates: [] }; if (url === '/api/outreach/status') return { enviadosHoje: 0, mensagensPorDia: 10, ultimos: [] }; if (url === '/api/outreach/opt-outs') return { optOuts: [] }; + if (url === '/api/outreach/shared-numbers') return { groups: [] }; if (url === '/api/whatsapp/status') return { state: 'READY' }; if (get) return get(url); throw new Error(`GET inesperado: ${url}`); @@ -177,6 +178,7 @@ async function renderCampaign(app, campaigns, options = {}) { if (url === '/api/outreach/templates') return { templates: [] }; if (url === '/api/outreach/status') return { enviadosHoje: 0, mensagensPorDia: 10, ultimos: [] }; if (url === '/api/outreach/opt-outs') return { optOuts: [] }; + if (url === '/api/outreach/shared-numbers') return { groups: [] }; if (url === '/api/whatsapp/status') return { state: 'READY' }; if (origGet) return origGet(url); throw new Error(`GET inesperado (campaign): ${url}`); diff --git a/tests/personal-branding.test.mjs b/tests/personal-branding.test.mjs index ca91488..8f57763 100644 --- a/tests/personal-branding.test.mjs +++ b/tests/personal-branding.test.mjs @@ -83,6 +83,7 @@ async function executeApp({ whatsappState = 'READY', statusResponses = [], qrRes if (url === '/api/outreach/templates') return { templates: [] }; if (url === '/api/outreach/status') return { enviadosHoje: 0, mensagensPorDia: 10, ultimos: [] }; if (url === '/api/outreach/opt-outs') return { optOuts: [] }; + if (url === '/api/outreach/shared-numbers') return { groups: [] }; if (url === '/api/whatsapp/status') return statusResponses.length ? statusResponses.shift() : { state: whatsappState }; if (url === '/api/whatsapp/qr') return qrResponses.length ? qrResponses.shift() : { qr: 'data:image/png;base64,qr', expiresAt: null }; if (get) return get(url); @@ -521,6 +522,44 @@ test('tabela de tratamento sinaliza numero compartilhado por mais de um lead', a assert.doesNotMatch(texts[1], /compartilhado/i); }); +test('secao de numeros compartilhados lista grupos e libera/bloqueia via API', async () => { + const app = await executeApp(); + const groups = [{ + numero: '5511999990009', + leads: [{ id: 1, empresa: 'Empresa A', cnpj: '' }, { id: 2, empresa: 'Empresa B', cnpj: '' }], + liberado: false, + motivo: null + }]; + app.context.API.get = async (url) => { + if (url === '/api/outreach/shared-numbers') return { groups }; + if (url === '/api/outreach/opt-outs') return { optOuts: [] }; + if (url === '/api/outreach/status') return { enviadosHoje: 0, mensagensPorDia: 10, ultimos: [] }; + if (url === '/api/outreach/templates') return { templates: [] }; + if (url === '/api/whatsapp/status') return { state: 'READY' }; + throw new Error(`GET inesperado: ${url}`); + }; + + await app.element('btn-settings').click(); + await new Promise((resolve) => setImmediate(resolve)); + await new Promise((resolve) => setImmediate(resolve)); + + const list = app.element('shared-numbers-list'); + assert.match(nodeText(list), /5511999990009/); + assert.match(nodeText(list), /Empresa A/); + assert.match(nodeText(list), /Empresa B/); + + const row = list.children[0]; + const motivoInput = row.children.find((child) => child.tagName === 'INPUT'); + const liberarButton = row.children.find((child) => child.tagName === 'BUTTON'); + motivoInput.value = 'confirmado contato direto'; + await liberarButton.click(); + + assert.deepEqual(JSON.parse(JSON.stringify(app.posts.at(-1))), { + url: '/api/outreach/shared-numbers/5511999990009/liberar', + body: { motivo: 'confirmado contato direto' } + }); +}); + test('controle de limpar contexto e um botao acessivel', async () => { const html = await read('public/index.html'); assert.match(html, /]+id="btn-limpar-lead-manual"[^>]+type="button"[^>]*>\s*Limpar contexto do lead\s*<\/button>/i);