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;