chore: import maquina-comercial-pessoal history via subtree

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

git-subtree-dir: maquina-comercial-pessoal
git-subtree-mainline: f5af038e19
git-subtree-split: a9ef4d776d
This commit is contained in:
Gustavo
2026-09-28 15:50:17 -03:00
co-authored by Claude Sonnet 5
150 changed files with 33449 additions and 0 deletions
@@ -0,0 +1,427 @@
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;