Files
COMERCIAL-GTI/public/js/api.js
T
Gustavo d330bb5ff0 Baseline commit of the existing Maquina Comercial Pessoal project
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.
2026-07-31 10:54:07 -03:00

118 lines
4.3 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); },
delete(path, body) { return this.send('DELETE', 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', 'erro', 'sucesso');
box.classList.add(/^erro/i.test(message) ? 'erro' : 'sucesso');
clearTimeout(toastTimer);
toastTimer = setTimeout(() => box.classList.add('hidden'), ms);
}
// Envolve uma acao assincrona disparada por um botao: desabilita + mostra
// spinner (classe .loading, ver app.css) enquanto `fn` esta em andamento,
// restaura no final independente de sucesso ou erro. Se o botao ja estava
// desabilitado/carregando (chamada reentrante), nao mexe no estado dele —
// so quem entrou "limpo" e responsavel por restaurar.
async function comCarregando(button, fn) {
if (!button) return fn();
const jaCarregando = button.disabled;
if (!jaCarregando) { button.disabled = true; button.classList.add('loading'); }
try {
return await fn();
} finally {
if (!jaCarregando) { button.disabled = false; button.classList.remove('loading'); }
}
}
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' });
}