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:f5af038e19git-subtree-split:a9ef4d776d
This commit is contained in:
@@ -0,0 +1,117 @@
|
||||
/* 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' });
|
||||
}
|
||||
Reference in New Issue
Block a user