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.
This commit is contained in:
@@ -419,6 +419,13 @@
|
|||||||
<div id="optout-list" class="lista"></div>
|
<div id="optout-list" class="lista"></div>
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
<details class="settings-section admin-only">
|
||||||
|
<summary>Números compartilhados entre leads</summary>
|
||||||
|
<p class="muted">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.</p>
|
||||||
|
<p id="shared-numbers-status" class="muted"></p>
|
||||||
|
<div id="shared-numbers-list" class="lista"></div>
|
||||||
|
</details>
|
||||||
|
|
||||||
<details class="settings-section zona-risco admin-only">
|
<details class="settings-section zona-risco admin-only">
|
||||||
<summary>Zona de risco</summary>
|
<summary>Zona de risco</summary>
|
||||||
<p class="muted">Apaga permanentemente todos os leads, exportações, capturas e extrações. Configurações e nichos não são afetados.</p>
|
<p class="muted">Apaga permanentemente todos os leads, exportações, capturas e extrações. Configurações e nichos não são afetados.</p>
|
||||||
|
|||||||
+57
-1
@@ -267,6 +267,62 @@
|
|||||||
} catch (error) { el('optout-status').textContent = `Erro: ${error.message}`; }
|
} 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 () => {
|
el('btn-optout-add').addEventListener('click', async () => {
|
||||||
const button = el('btn-optout-add');
|
const button = el('btn-optout-add');
|
||||||
await comCarregando(button, async () => {
|
await comCarregando(button, async () => {
|
||||||
@@ -288,7 +344,7 @@
|
|||||||
const generation = nextWhatsAppGeneration();
|
const generation = nextWhatsAppGeneration();
|
||||||
await comCarregando(button, async () => {
|
await comCarregando(button, async () => {
|
||||||
try {
|
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}`); }
|
} catch (error) { toast(`Erro ao carregar configurações: ${error.message}`); }
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ async function executeApp({ get, post, patch, del, put } = {}) {
|
|||||||
if (url === '/api/outreach/templates') return { templates: [] };
|
if (url === '/api/outreach/templates') return { templates: [] };
|
||||||
if (url === '/api/outreach/status') return { enviadosHoje: 0, mensagensPorDia: 10, ultimos: [] };
|
if (url === '/api/outreach/status') return { enviadosHoje: 0, mensagensPorDia: 10, ultimos: [] };
|
||||||
if (url === '/api/outreach/opt-outs') return { optOuts: [] };
|
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 (url === '/api/whatsapp/status') return { state: 'READY' };
|
||||||
if (get) return get(url);
|
if (get) return get(url);
|
||||||
throw new Error(`GET inesperado: ${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/templates') return { templates: [] };
|
||||||
if (url === '/api/outreach/status') return { enviadosHoje: 0, mensagensPorDia: 10, ultimos: [] };
|
if (url === '/api/outreach/status') return { enviadosHoje: 0, mensagensPorDia: 10, ultimos: [] };
|
||||||
if (url === '/api/outreach/opt-outs') return { optOuts: [] };
|
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 (url === '/api/whatsapp/status') return { state: 'READY' };
|
||||||
if (origGet) return origGet(url);
|
if (origGet) return origGet(url);
|
||||||
throw new Error(`GET inesperado (campaign): ${url}`);
|
throw new Error(`GET inesperado (campaign): ${url}`);
|
||||||
|
|||||||
@@ -83,6 +83,7 @@ async function executeApp({ whatsappState = 'READY', statusResponses = [], qrRes
|
|||||||
if (url === '/api/outreach/templates') return { templates: [] };
|
if (url === '/api/outreach/templates') return { templates: [] };
|
||||||
if (url === '/api/outreach/status') return { enviadosHoje: 0, mensagensPorDia: 10, ultimos: [] };
|
if (url === '/api/outreach/status') return { enviadosHoje: 0, mensagensPorDia: 10, ultimos: [] };
|
||||||
if (url === '/api/outreach/opt-outs') return { optOuts: [] };
|
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/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 (url === '/api/whatsapp/qr') return qrResponses.length ? qrResponses.shift() : { qr: 'data:image/png;base64,qr', expiresAt: null };
|
||||||
if (get) return get(url);
|
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);
|
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 () => {
|
test('controle de limpar contexto e um botao acessivel', async () => {
|
||||||
const html = await read('public/index.html');
|
const html = await read('public/index.html');
|
||||||
assert.match(html, /<button[^>]+id="btn-limpar-lead-manual"[^>]+type="button"[^>]*>\s*Limpar contexto do lead\s*<\/button>/i);
|
assert.match(html, /<button[^>]+id="btn-limpar-lead-manual"[^>]+type="button"[^>]*>\s*Limpar contexto do lead\s*<\/button>/i);
|
||||||
|
|||||||
Reference in New Issue
Block a user