Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> git-subtree-dir: maquina-comercial-pessoal git-subtree-mainline:f5af038e19git-subtree-split:a9ef4d776d
428 lines
18 KiB
JavaScript
428 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() {
|
|
const container = el('pending-replies');
|
|
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');
|
|
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 (error) {
|
|
// Nunca deixar o placeholder de "nenhuma pendente" no lugar de um erro
|
|
// real de carregamento — os dois ficariam visualmente indistinguiveis e
|
|
// uma resposta de cliente poderia passar despercebida.
|
|
container.replaceChildren(h('p', { class: 'muted' }, 'Erro ao carregar respostas pendentes.'));
|
|
toast(`Erro ao carregar respostas pendentes: ${error.message}`);
|
|
}
|
|
}
|
|
|
|
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() {
|
|
const filters = el('metrics-filters');
|
|
const display = el('campaign-metrics');
|
|
try {
|
|
const campaigns = await API.get('/api/campaigns').then((r) => r.campaigns).catch(() => []);
|
|
|
|
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 (error) {
|
|
filters.replaceChildren(h('p', { class: 'muted' }, 'Erro ao carregar métricas.'));
|
|
display.replaceChildren();
|
|
toast(`Erro ao carregar métricas: ${error.message}`);
|
|
}
|
|
}
|
|
|
|
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 (error) {
|
|
el('campaign-metrics').replaceChildren(h('p', { class: 'muted' }, 'Erro ao carregar métricas.'));
|
|
toast(`Erro ao carregar métricas: ${error.message}`);
|
|
}
|
|
}
|
|
|
|
// 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;
|