/* 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 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' }); }