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.
415 lines
18 KiB
JavaScript
415 lines
18 KiB
JavaScript
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;
|