Per-product message templates, official/não-oficial channel selection, manual per-number sending, and a 20-minute internal scheduler for the existing automatic outreach batch — all pre-existing local work being committed as-is.
100 lines
3.5 KiB
JavaScript
100 lines
3.5 KiB
JavaScript
/* Cliente da API local + utilitários compartilhados. */
|
|
// 401 fora da tela de login = sessao expirada/inexistente: manda pro login.
|
|
function redirecionaSeSessaoExpirou(path, status) {
|
|
if (status === 401 && path !== '/api/auth/login' && !location.pathname.endsWith('/login.html')) {
|
|
window.location.href = '/login.html';
|
|
}
|
|
}
|
|
|
|
const API = {
|
|
async get(path) {
|
|
const res = await fetch(path);
|
|
const data = await res.json();
|
|
if (!res.ok) {
|
|
redirecionaSeSessaoExpirou(path, res.status);
|
|
throw new Error(data.erro || `HTTP ${res.status}`);
|
|
}
|
|
return data;
|
|
},
|
|
|
|
async send(method, path, body) {
|
|
const res = await fetch(path, {
|
|
method,
|
|
headers: { 'content-type': 'application/json' },
|
|
body: body === undefined ? undefined : JSON.stringify(body)
|
|
});
|
|
const data = await res.json();
|
|
if (!res.ok) {
|
|
redirecionaSeSessaoExpirou(path, res.status);
|
|
const error = new Error(data.erro || `HTTP ${res.status}`);
|
|
error.data = data;
|
|
error.status = res.status;
|
|
throw error;
|
|
}
|
|
return data;
|
|
},
|
|
|
|
post(path, body) { return this.send('POST', path, body); },
|
|
put(path, body) { return this.send('PUT', path, body); },
|
|
|
|
// Acompanha um job via SSE; handlers: {progress, log, done, error}
|
|
watchJob(jobId, handlers) {
|
|
const source = new EventSource(`/api/jobs/${jobId}/events`);
|
|
const close = () => source.close();
|
|
|
|
source.addEventListener('progress', (e) => handlers.progress?.(JSON.parse(e.data)));
|
|
source.addEventListener('log', (e) => handlers.log?.(JSON.parse(e.data)));
|
|
source.addEventListener('done', (e) => {
|
|
handlers.done?.(JSON.parse(e.data));
|
|
close();
|
|
});
|
|
source.addEventListener('error', (e) => {
|
|
if (e.data) {
|
|
handlers.error?.(JSON.parse(e.data));
|
|
close();
|
|
}
|
|
// sem e.data = queda de conexão; EventSource tenta reconectar sozinho
|
|
});
|
|
|
|
return close;
|
|
}
|
|
};
|
|
|
|
const fmt = new Intl.NumberFormat('pt-BR');
|
|
|
|
function el(id) { return document.getElementById(id); }
|
|
|
|
function h(tag, attrs = {}, ...children) {
|
|
const node = document.createElement(tag);
|
|
for (const [key, value] of Object.entries(attrs)) {
|
|
if (key === 'class') node.className = value;
|
|
else if (key.startsWith('on')) node.addEventListener(key.slice(2), value);
|
|
else if (value !== null && value !== undefined) node.setAttribute(key, value);
|
|
}
|
|
for (const child of children.flat()) {
|
|
if (child === null || child === undefined) continue;
|
|
node.append(child.nodeType ? child : document.createTextNode(child));
|
|
}
|
|
return node;
|
|
}
|
|
|
|
let toastTimer = null;
|
|
// Aviso: um <dialog> aberto via showModal() fica acima de qualquer div normal
|
|
// (top layer do navegador, z-index nao ajuda — e testamos que nem a Popover
|
|
// API resolve, o dialog continua por cima). Por isso o toast NAO deve ser a
|
|
// unica forma de mostrar erro de uma acao disparada de dentro de um modal
|
|
// aberto: use tambem uma mensagem inline no proprio modal nesses casos.
|
|
function toast(message, ms = 3500) {
|
|
const box = el('toast');
|
|
box.textContent = message;
|
|
box.classList.remove('hidden');
|
|
clearTimeout(toastTimer);
|
|
toastTimer = setTimeout(() => box.classList.add('hidden'), ms);
|
|
}
|
|
|
|
function dataBr(iso) {
|
|
if (!iso) return '—';
|
|
const d = new Date(iso.includes('T') || iso.includes(' ') ? iso.replace(' ', 'T') + (iso.endsWith('Z') ? '' : 'Z') : iso);
|
|
return Number.isNaN(d.getTime()) ? iso : d.toLocaleString('pt-BR', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' });
|
|
}
|