Redator de SD - preparado para deploy
This commit is contained in:
@@ -0,0 +1,980 @@
|
||||
"use strict";
|
||||
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const esc = (s) =>
|
||||
String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
|
||||
const fmtD = (iso) => {
|
||||
const m = String(iso || "").match(/^(\d{4})-(\d{2})-(\d{2})/);
|
||||
return m ? `${m[3]}/${m[2]}/${m[1]}` : "—";
|
||||
};
|
||||
const dayNum = (iso) => {
|
||||
const m = String(iso || "").match(/^(\d{4})-(\d{2})-(\d{2})/);
|
||||
return m ? Date.UTC(+m[1], +m[2] - 1, +m[3]) / 86400000 : NaN;
|
||||
};
|
||||
|
||||
// Preços do claude-opus-5 (USD por 1M tokens) para o custo estimado no rodapé.
|
||||
const PRECO = { input: 5, output: 25, cache_read: 0.5, cache_write: 6.25 };
|
||||
|
||||
let statusApp = null;
|
||||
let sdAtual = null; // { id, numero, status, chat, rascunho, validacao, bloqueios, uso }
|
||||
let ocupado = false;
|
||||
let modoExterno = false; // tela "Validar JSON externo"
|
||||
let externo = null; // { sd, exportado, validacao, bloqueios }
|
||||
let ultimosBloqueios = [];
|
||||
|
||||
// ---------- Markdown mínimo (negrito, código, títulos, listas, OPCAO:) ----------
|
||||
function renderMd(texto) {
|
||||
const linhas = String(texto || "").split("\n");
|
||||
const out = [];
|
||||
let lista = null; // "ul" | "ol"
|
||||
const fechaLista = () => { if (lista) { out.push(`</${lista}>`); lista = null; } };
|
||||
const inline = (s) =>
|
||||
esc(s)
|
||||
.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
|
||||
.replace(/`([^`]+)`/g, "<code>$1</code>");
|
||||
for (const raw of linhas) {
|
||||
const l = raw.trimEnd();
|
||||
if (/^OPCAO:/i.test(l.trim())) continue; // vira botão, não texto
|
||||
const h = l.match(/^(#{1,4})\s+(.*)/);
|
||||
const ul = l.match(/^\s*[-•]\s+(.*)/);
|
||||
const ol = l.match(/^\s*\d+[.)]\s+(.*)/);
|
||||
if (h) { fechaLista(); out.push(`<h3>${inline(h[2])}</h3>`); }
|
||||
else if (ul) { if (lista !== "ul") { fechaLista(); out.push("<ul>"); lista = "ul"; } out.push(`<li>${inline(ul[1])}</li>`); }
|
||||
else if (ol) { if (lista !== "ol") { fechaLista(); out.push("<ol>"); lista = "ol"; } out.push(`<li>${inline(ol[1])}</li>`); }
|
||||
else if (l.trim() === "") { fechaLista(); }
|
||||
else { fechaLista(); out.push(`<p>${inline(l)}</p>`); }
|
||||
}
|
||||
fechaLista();
|
||||
return out.join("");
|
||||
}
|
||||
|
||||
function extrairOpcoes(texto) {
|
||||
return String(texto || "")
|
||||
.split("\n")
|
||||
.map((l) => l.trim())
|
||||
.filter((l) => /^OPCAO:/i.test(l))
|
||||
.map((l) => l.replace(/^OPCAO:\s*/i, "").trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
// ---------- Tema (automático → claro → escuro; escolha guardada no navegador) ----------
|
||||
const TEMAS = ["auto", "claro", "escuro"];
|
||||
const TEMA_ICONE = { auto: "🌗", claro: "☀️", escuro: "🌙" };
|
||||
const TEMA_ROTULO = { auto: "automático (segue o sistema)", claro: "claro", escuro: "escuro" };
|
||||
let temaAtual = localStorage.getItem("sd_tema");
|
||||
if (!TEMAS.includes(temaAtual)) temaAtual = "auto";
|
||||
|
||||
function aplicarTema(t) {
|
||||
const raiz = document.documentElement;
|
||||
if (t === "claro") raiz.dataset.theme = "light";
|
||||
else if (t === "escuro") raiz.dataset.theme = "dark";
|
||||
else delete raiz.dataset.theme; // automático: quem manda é o sistema
|
||||
$("btnTema").textContent = TEMA_ICONE[t];
|
||||
$("btnTema").title = `Tema: ${TEMA_ROTULO[t]} — clique para alternar`;
|
||||
}
|
||||
aplicarTema(temaAtual);
|
||||
|
||||
$("btnTema").addEventListener("click", () => {
|
||||
temaAtual = TEMAS[(TEMAS.indexOf(temaAtual) + 1) % TEMAS.length];
|
||||
localStorage.setItem("sd_tema", temaAtual);
|
||||
aplicarTema(temaAtual);
|
||||
});
|
||||
|
||||
// ---------- Autenticação + navegação ----------
|
||||
window.addEventListener("hashchange", rotear);
|
||||
window.addEventListener("DOMContentLoaded", iniciar);
|
||||
|
||||
async function iniciar() {
|
||||
let sess = null;
|
||||
try { sess = await (await fetch("/api/sessao")).json(); } catch {}
|
||||
if (!sess) { $("statusChip").textContent = "servidor indisponível"; return; }
|
||||
$("btnSair").hidden = !sess.precisa_login;
|
||||
if (sess.precisa_login && !sess.logado) { mostrarLogin(); return; }
|
||||
await entrar();
|
||||
}
|
||||
|
||||
async function entrar() {
|
||||
esconderTodas();
|
||||
await carregarStatus();
|
||||
rotear();
|
||||
}
|
||||
|
||||
function esconderTodas() {
|
||||
for (const id of ["telaLogin", "telaHome", "telaConversa", "telaValidar", "telaBase"]) $(id).hidden = true;
|
||||
}
|
||||
|
||||
function mostrarLogin() {
|
||||
esconderTodas();
|
||||
$("telaLogin").hidden = false;
|
||||
$("loginErro").hidden = true;
|
||||
$("loginUsuario").focus();
|
||||
}
|
||||
|
||||
$("formLogin").addEventListener("submit", async (ev) => {
|
||||
ev.preventDefault();
|
||||
$("btnLogin").disabled = true;
|
||||
const r = await fetch("/api/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ usuario: $("loginUsuario").value, senha: $("loginSenha").value }),
|
||||
}).catch(() => null);
|
||||
$("btnLogin").disabled = false;
|
||||
if (r?.ok) { $("loginSenha").value = ""; await entrar(); }
|
||||
else {
|
||||
$("loginErro").textContent = "Usuário ou senha inválidos.";
|
||||
$("loginErro").hidden = false;
|
||||
}
|
||||
});
|
||||
|
||||
$("btnSair").addEventListener("click", async () => {
|
||||
await fetch("/api/logout", { method: "POST" }).catch(() => {});
|
||||
location.hash = "#/";
|
||||
mostrarLogin();
|
||||
});
|
||||
|
||||
function exigirLogin(resp) {
|
||||
// Sessão expirada/servidor reiniciado: volta para a tela de login.
|
||||
if (resp && resp.status === 401) { mostrarLogin(); return true; }
|
||||
return false;
|
||||
}
|
||||
|
||||
async function carregarStatus() {
|
||||
try {
|
||||
const r = await fetch("/api/status");
|
||||
if (exigirLogin(r)) return;
|
||||
statusApp = await r.json();
|
||||
$("statusChip").textContent = statusApp.configurado
|
||||
? `${statusApp.provider} · ${statusApp.model} · próxima: SD-${statusApp.proximo_numero}`
|
||||
: "sem chave de API";
|
||||
$("avisoConfig").hidden = statusApp.configurado;
|
||||
popularSeletoresModelo();
|
||||
} catch {
|
||||
$("statusChip").textContent = "servidor indisponível";
|
||||
}
|
||||
}
|
||||
|
||||
// Seletores de modelo (Nova SD e Base de Conhecimento) — lista do .env (LLM_MODEL + LLM_MODELOS).
|
||||
function popularSeletoresModelo() {
|
||||
const modelos = statusApp?.modelos || [];
|
||||
for (const id of ["selModelo", "selModeloBase"]) {
|
||||
const sel = $(id);
|
||||
const anterior = sel.value;
|
||||
sel.innerHTML = modelos
|
||||
.map((m) => `<option value="${esc(m)}">${esc(m)}${m === statusApp.model ? " (padrão)" : ""}</option>`)
|
||||
.join("");
|
||||
// preserva a escolha do usuário entre recargas de status; senão, usa o padrão
|
||||
sel.value = modelos.includes(anterior) ? anterior : statusApp.model;
|
||||
}
|
||||
}
|
||||
|
||||
function moverPainel(slotId) {
|
||||
const painel = $("painel");
|
||||
const slot = $(slotId);
|
||||
if (painel.parentElement !== slot) slot.appendChild(painel);
|
||||
}
|
||||
|
||||
function rotear() {
|
||||
if ($("telaLogin") && !$("telaLogin").hidden) return; // aguardando login
|
||||
const m = location.hash.match(/^#\/sd\/(.+)$/);
|
||||
if (location.hash === "#/validar") mostrarValidar();
|
||||
else if (location.hash === "#/base") mostrarBase();
|
||||
else if (m) abrirConversa(m[1]);
|
||||
else mostrarHome();
|
||||
}
|
||||
|
||||
// ---------- Home ----------
|
||||
async function mostrarHome() {
|
||||
esconderTodas();
|
||||
$("telaHome").hidden = false;
|
||||
sdAtual = null;
|
||||
modoExterno = false;
|
||||
const rList = await fetch("/api/sds");
|
||||
if (exigirLogin(rList)) return;
|
||||
const lista = await rList.json();
|
||||
$("listaSds").innerHTML = lista.length
|
||||
? lista
|
||||
.sort((a, b) => (a.criadaEm < b.criadaEm ? 1 : -1))
|
||||
.map(
|
||||
(s) => `<a class="sd-item" href="#/sd/${s.id}">
|
||||
<span class="num">SD-${s.numero}</span>
|
||||
<span class="tit">${esc(s.titulo)}</span>
|
||||
<span class="st st-${s.status}">${s.status}</span>
|
||||
<button type="button" class="btn-excluir" data-id="${s.id}" data-status="${s.status}"
|
||||
data-titulo="${esc(s.titulo)}" title="Excluir da lista">✕</button></a>`
|
||||
)
|
||||
.join("")
|
||||
: '<p class="hint">Nenhuma SD ainda — cole a primeira demanda acima.</p>';
|
||||
$("listaSds").querySelectorAll(".btn-excluir").forEach((b) =>
|
||||
b.addEventListener("click", async (ev) => {
|
||||
ev.preventDefault(); // o botão vive dentro do link da SD
|
||||
ev.stopPropagation();
|
||||
const { id, status, titulo } = ev.currentTarget.dataset;
|
||||
const aviso = status === "enviada"
|
||||
? `Excluir a conversa de "${titulo}" da lista?\n\nEla já foi ENVIADA: o registro na base de conhecimento (saldos e precedentes) NÃO será apagado — só a conversa aqui do app.`
|
||||
: `Excluir o rascunho "${titulo}"?\n\nA conversa e o rascunho serão apagados de vez.`;
|
||||
if (!confirm(aviso)) return;
|
||||
const r = await fetch(`/api/sds/${id}`, { method: "DELETE" });
|
||||
if (exigirLogin(r)) return;
|
||||
if (!r.ok) { alert("Não foi possível excluir."); return; }
|
||||
await carregarStatus(); // numeração provisória pode mudar
|
||||
await mostrarHome();
|
||||
})
|
||||
);
|
||||
if (statusApp?.saldos) {
|
||||
$("saldosBox").innerHTML = Object.entries(statusApp.saldos)
|
||||
.map(
|
||||
([item, v]) => `<div class="saldo-chip">
|
||||
<div class="label">${esc(item)}</div>
|
||||
<div class="value">${v.saldo.toLocaleString("pt-BR")} UST</div>
|
||||
<div class="sub">de ${v.pool_total.toLocaleString("pt-BR")} · consumido ${v.consumido.toLocaleString("pt-BR")}</div>
|
||||
</div>`
|
||||
)
|
||||
.join("");
|
||||
}
|
||||
}
|
||||
|
||||
$("btnIniciar").addEventListener("click", async () => {
|
||||
const demanda = $("demandaInput").value.trim();
|
||||
if (!demanda || ocupado) return;
|
||||
$("btnIniciar").disabled = true;
|
||||
// Prepara a tela de conversa e transmite o primeiro turno
|
||||
esconderTodas();
|
||||
$("telaConversa").hidden = false;
|
||||
modoExterno = false;
|
||||
moverPainel("slotPainelConversa");
|
||||
$("mensagens").innerHTML = "";
|
||||
$("opcoes").innerHTML = "";
|
||||
resetPainel();
|
||||
addMsgUser(demanda);
|
||||
const resp = await fetch("/api/sds", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ demanda, modelo: $("selModelo").value }),
|
||||
});
|
||||
if (exigirLogin(resp)) return;
|
||||
const sdId = resp.headers.get("X-Sd-Id");
|
||||
history.replaceState(null, "", `#/sd/${sdId}`);
|
||||
sdAtual = { id: sdId, chat: [], rascunho: null };
|
||||
$("demandaInput").value = "";
|
||||
$("btnIniciar").disabled = false;
|
||||
await consumirStream(resp);
|
||||
await recarregarSd(); // pega numero/status/uso consolidados
|
||||
});
|
||||
|
||||
// ---------- Conversa ----------
|
||||
async function abrirConversa(id) {
|
||||
if (sdAtual?.id === id && ocupado) return; // já está na tela, streaming em curso
|
||||
esconderTodas();
|
||||
$("telaConversa").hidden = false;
|
||||
modoExterno = false;
|
||||
moverPainel("slotPainelConversa");
|
||||
sdAtual = { id };
|
||||
await recarregarSd(true);
|
||||
}
|
||||
|
||||
async function recarregarSd(renderChat = false) {
|
||||
const r = await fetch(`/api/sds/${sdAtual.id}`);
|
||||
if (exigirLogin(r)) return;
|
||||
if (!r.ok) { location.hash = "#/"; return; }
|
||||
const d = await r.json();
|
||||
sdAtual = d;
|
||||
if (renderChat) {
|
||||
$("mensagens").innerHTML = "";
|
||||
$("opcoes").innerHTML = "";
|
||||
for (const m of d.chat || []) {
|
||||
if (m.role === "user") addMsgUser(m.texto);
|
||||
else addMsgAssistant(m.texto, true);
|
||||
}
|
||||
const ultimo = (d.chat || []).filter((m) => m.role === "assistant").pop();
|
||||
if (ultimo) renderOpcoes(extrairOpcoes(ultimo.texto));
|
||||
}
|
||||
renderPainel(d.rascunho, d.validacao, d.bloqueios);
|
||||
renderUso(d.uso);
|
||||
const soLeitura = d.status === "enviada";
|
||||
$("msgInput").disabled = soLeitura;
|
||||
$("btnEnviarMsg").disabled = soLeitura;
|
||||
if (soLeitura) $("msgInput").placeholder = "SD enviada — somente leitura.";
|
||||
rolarChat();
|
||||
}
|
||||
|
||||
function addMsgUser(texto) {
|
||||
const div = document.createElement("div");
|
||||
div.className = "msg user";
|
||||
div.textContent = texto;
|
||||
$("mensagens").appendChild(div);
|
||||
rolarChat();
|
||||
}
|
||||
|
||||
function addMsgAssistant(texto, pronto = false) {
|
||||
const div = document.createElement("div");
|
||||
div.className = "msg assistant";
|
||||
div.innerHTML = pronto ? renderMd(texto) : '<span class="digitando">▍</span>';
|
||||
$("mensagens").appendChild(div);
|
||||
rolarChat();
|
||||
return div;
|
||||
}
|
||||
|
||||
function renderOpcoes(opcoes) {
|
||||
$("opcoes").innerHTML = "";
|
||||
for (const o of opcoes) {
|
||||
const b = document.createElement("button");
|
||||
b.type = "button";
|
||||
b.textContent = o;
|
||||
b.addEventListener("click", () => enviarMensagem(o));
|
||||
$("opcoes").appendChild(b);
|
||||
}
|
||||
}
|
||||
|
||||
function rolarChat() {
|
||||
const el = $("mensagens");
|
||||
el.scrollTop = el.scrollHeight;
|
||||
}
|
||||
|
||||
$("formMsg").addEventListener("submit", (ev) => {
|
||||
ev.preventDefault();
|
||||
const t = $("msgInput").value.trim();
|
||||
if (t) enviarMensagem(t);
|
||||
});
|
||||
$("msgInput").addEventListener("keydown", (ev) => {
|
||||
if (ev.key === "Enter" && !ev.shiftKey) {
|
||||
ev.preventDefault();
|
||||
$("formMsg").requestSubmit();
|
||||
}
|
||||
});
|
||||
|
||||
async function enviarMensagem(texto) {
|
||||
if (ocupado || !sdAtual?.id) return;
|
||||
$("msgInput").value = "";
|
||||
$("opcoes").innerHTML = "";
|
||||
addMsgUser(texto);
|
||||
const resp = await fetch(`/api/sds/${sdAtual.id}/mensagem`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ texto }),
|
||||
});
|
||||
if (exigirLogin(resp)) return;
|
||||
if (!resp.ok && !resp.body) {
|
||||
addMsgAssistant("⚠ Erro ao enviar a mensagem.", true);
|
||||
return;
|
||||
}
|
||||
await consumirStream(resp);
|
||||
}
|
||||
|
||||
async function consumirStream(resp) {
|
||||
ocupado = true;
|
||||
$("btnEnviarMsg").disabled = true;
|
||||
const bolha = addMsgAssistant("");
|
||||
let texto = "";
|
||||
// "pensando…" cobre o silêncio do raciocínio interno do modelo: antes da
|
||||
// primeira palavra e entre as chamadas de ferramenta (validação do rascunho).
|
||||
let pensando = true;
|
||||
const pinta = () => {
|
||||
bolha.innerHTML =
|
||||
renderMd(texto) +
|
||||
(pensando ? '<span class="pensando">pensando…</span>' : '<span class="digitando">▍</span>');
|
||||
rolarChat();
|
||||
};
|
||||
pinta();
|
||||
try {
|
||||
const reader = resp.body.getReader();
|
||||
const dec = new TextDecoder();
|
||||
let buf = "";
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buf += dec.decode(value, { stream: true });
|
||||
let nl;
|
||||
while ((nl = buf.indexOf("\n")) >= 0) {
|
||||
const linha = buf.slice(0, nl).trim();
|
||||
buf = buf.slice(nl + 1);
|
||||
if (!linha) continue;
|
||||
let ev;
|
||||
try { ev = JSON.parse(linha); } catch { continue; }
|
||||
if (ev.t === "delta") {
|
||||
texto += ev.text;
|
||||
pensando = false;
|
||||
pinta();
|
||||
} else if (ev.t === "rascunho") {
|
||||
renderPainel(ev.sd, ev.validacao, ev.bloqueios);
|
||||
pensando = true; // o modelo volta a raciocinar sobre o resultado da validação
|
||||
pinta();
|
||||
} else if (ev.t === "uso") {
|
||||
renderUso(ev.uso);
|
||||
} else if (ev.t === "erro") {
|
||||
texto += `\n\n⚠ ${ev.msg}`;
|
||||
pensando = false;
|
||||
pinta();
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
texto += "\n\n⚠ Conexão interrompida — recarregue a página para retomar.";
|
||||
}
|
||||
bolha.innerHTML = renderMd(texto);
|
||||
renderOpcoes(extrairOpcoes(texto));
|
||||
ocupado = false;
|
||||
$("btnEnviarMsg").disabled = false;
|
||||
rolarChat();
|
||||
}
|
||||
|
||||
// ---------- Painel do documento ----------
|
||||
function resetPainel() {
|
||||
$("painelVazio").hidden = false;
|
||||
$("painelConteudo").hidden = true;
|
||||
}
|
||||
|
||||
function renderPainel(sd, validacao, bloqueios) {
|
||||
if (!sd) { resetPainel(); return; }
|
||||
ultimosBloqueios = bloqueios || [];
|
||||
if (!modoExterno && sdAtual) { sdAtual.rascunho = sd; }
|
||||
$("painelVazio").hidden = true;
|
||||
$("painelConteudo").hidden = false;
|
||||
const E = sd.entregaveis || [];
|
||||
const cab = sd.cabecalho || {};
|
||||
const R = validacao?.resultados || {};
|
||||
const totais = validacao?.totais || {};
|
||||
|
||||
// Resumo
|
||||
$("pResumo").innerHTML = `
|
||||
<div class="ptitulo">${esc(cab.titulo || cab.projeto || "SD em elaboração")}</div>
|
||||
<div class="resumo-chips">
|
||||
<div class="rchip"><div class="label">Nº</div><div class="value">${esc(cab.numero_sd || (sdAtual?.numero ? "SD-" + sdAtual.numero : "—"))}</div></div>
|
||||
<div class="rchip"><div class="label">Total</div><div class="value">${validacao?.soma_ust ?? "—"} UST</div></div>
|
||||
<div class="rchip"><div class="label">Prazo</div><div class="value">${esc(cab.prazo_execucao_semanas ?? "—")} sem.</div></div>
|
||||
<div class="rchip"><div class="label">Itens</div><div class="value" style="font-size:12px">${(cab.itens_tr || []).join(" + ") || "—"}</div></div>
|
||||
<div class="rchip"><div class="label">✓/!/✗/○</div><div class="value" style="font-size:12px">${totais.ok ?? 0}/${totais.alerta ?? 0}/${totais.falha ?? 0}/${totais.pendente ?? 0}</div></div>
|
||||
</div>`;
|
||||
|
||||
// Quadro
|
||||
let linhas = E.map((e) => {
|
||||
const par = (e.paralelo_com || []).length ? " ∥" : "";
|
||||
return `<tr><td class="num">${e.n ?? ""}</td><td>${esc(e.nome)}${par}</td><td>${esc(e.tipo || "")}</td>
|
||||
<td>${esc(e.item_tr || "")}</td><td class="num">${e.semanas ?? ""}</td><td class="num">${e.ust ?? ""}</td>
|
||||
<td>${fmtD(e.periodo?.inicio)}–${fmtD(e.periodo?.fim)}</td></tr>`;
|
||||
}).join("");
|
||||
const porItem = validacao?.soma_por_item || {};
|
||||
if (Object.keys(porItem).length > 1) {
|
||||
linhas += Object.keys(porItem).sort()
|
||||
.map((i) => `<tr class="subtotal"><td colspan="5">Subtotal ${esc(i)}</td><td class="num">${porItem[i]}</td><td></td></tr>`)
|
||||
.join("");
|
||||
}
|
||||
linhas += `<tr class="total"><td colspan="5">TOTAL</td><td class="num">${validacao?.soma_ust ?? ""}</td><td></td></tr>`;
|
||||
$("pQuadro").innerHTML = `<table class="quadro">
|
||||
<tr><th>#</th><th>Entregável</th><th>Tipo</th><th>Item</th><th>Sem</th><th>UST</th><th>Período</th></tr>${linhas}</table>`;
|
||||
|
||||
// Artefatos (trava de integridade)
|
||||
$("pArtefatos").innerHTML = E.map((e, ei) => {
|
||||
const arts = (e.artefatos || []).map((a, ai) => {
|
||||
const bad = a.data && e.periodo?.fim && dayNum(a.data) > dayNum(e.periodo.fim);
|
||||
return `<div class="art-row ${a.confirmado_pelo_usuario ? "confirmed" : "unconfirmed"}">
|
||||
<input type="checkbox" data-e="${ei}" data-a="${ai}" ${a.confirmado_pelo_usuario ? "checked" : ""} aria-label="Confirmar artefato">
|
||||
<div class="art-main">
|
||||
<label>${esc(a.nome)}</label>
|
||||
<input type="date" data-e="${ei}" data-a="${ai}" value="${esc(a.data || "")}" aria-label="Data do artefato">
|
||||
${bad ? `<div class="flag bad">data posterior ao fim do entregável (${fmtD(e.periodo.fim)})</div>`
|
||||
: a.confirmado_pelo_usuario && !a.data ? `<div class="flag miss">confirmado sem data</div>` : ""}
|
||||
</div></div>`;
|
||||
}).join("");
|
||||
return arts ? `<div class="art-entr"><h4>E${e.n} — ${esc(e.nome)}</h4>${arts}</div>` : "";
|
||||
}).join("") || '<p class="hint">Ainda sem artefatos registrados.</p>';
|
||||
|
||||
$("pArtefatos").querySelectorAll("input[type=checkbox]").forEach((cb) =>
|
||||
cb.addEventListener("change", (ev) =>
|
||||
atualizarArtefato(+ev.target.dataset.e, +ev.target.dataset.a, { confirmado: ev.target.checked })
|
||||
)
|
||||
);
|
||||
$("pArtefatos").querySelectorAll("input[type=date]").forEach((di) =>
|
||||
di.addEventListener("change", (ev) =>
|
||||
atualizarArtefato(+ev.target.dataset.e, +ev.target.dataset.a, { data: ev.target.value || "" })
|
||||
)
|
||||
);
|
||||
|
||||
// Checklist
|
||||
const ICONS = { ok: "✓", alerta: "!", falha: "✗", pendente: "○" };
|
||||
const checklist = statusApp?.checklist || [];
|
||||
$("pChecklist").innerHTML = checklist.map((c) => {
|
||||
const r = R[c.id] || { status: "pendente", obs: "" };
|
||||
return `<div class="check-item st-${r.status}">
|
||||
<span class="icon">${ICONS[r.status]}</span>
|
||||
<span class="id">${c.id}</span>
|
||||
<span>${esc(c.regra)}${r.obs ? `<span class="obs">${esc(r.obs)}</span>` : ""}</span></div>`;
|
||||
}).join("");
|
||||
|
||||
// Pendências
|
||||
const pend = [...(sd.pendencias || [])];
|
||||
(bloqueios || []).forEach((b) => pend.push("Bloqueante: " + b));
|
||||
$("pPendencias").innerHTML = pend.length
|
||||
? pend.map((p) => `<li>${esc(p)}</li>`).join("")
|
||||
: "<li>Nenhuma pendência registrada.</li>";
|
||||
|
||||
// Fechamento
|
||||
const bloqueado = (bloqueios || []).length > 0;
|
||||
const lock = $("pBloqueios");
|
||||
lock.className = "lock-msg " + (bloqueado ? "locked" : "open");
|
||||
lock.textContent = bloqueado
|
||||
? "Exportação bloqueada — " + bloqueios.join("; ") + "."
|
||||
: "SD liberada: artefatos confirmados e nenhuma falha de código.";
|
||||
$("btnBaixar").disabled = bloqueado;
|
||||
$("btnEnviarSd").hidden = modoExterno;
|
||||
$("btnEnviarSd").disabled = bloqueado || sdAtual?.status === "enviada";
|
||||
}
|
||||
|
||||
async function atualizarArtefato(e, a, corpo) {
|
||||
if (modoExterno) {
|
||||
const art = externo?.sd?.entregaveis?.[e]?.artefatos?.[a];
|
||||
if (!art) return;
|
||||
if (typeof corpo.confirmado === "boolean") art.confirmado_pelo_usuario = corpo.confirmado;
|
||||
if (corpo.data !== undefined) {
|
||||
if (corpo.data) art.data = corpo.data;
|
||||
else delete art.data;
|
||||
}
|
||||
await validarExterno(false);
|
||||
return;
|
||||
}
|
||||
const r = await fetch(`/api/sds/${sdAtual.id}/artefato`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ e, a, ...corpo }),
|
||||
});
|
||||
if (exigirLogin(r)) return;
|
||||
if (r.ok) {
|
||||
const d = await r.json();
|
||||
renderPainel(d.sd, d.validacao, d.bloqueios);
|
||||
}
|
||||
}
|
||||
|
||||
$("btnBaixar").addEventListener("click", () => {
|
||||
if (modoExterno) {
|
||||
if (externo?.exportado) baixarJsonLocal(externo.exportado);
|
||||
return;
|
||||
}
|
||||
window.location.href = `/api/sds/${sdAtual.id}/export`;
|
||||
});
|
||||
|
||||
function baixarJsonLocal(obj) {
|
||||
const nome = (obj.cabecalho?.numero_sd || "sd").toLowerCase().replace(/[^a-z0-9-]+/g, "-") + ".json";
|
||||
const blob = new Blob([JSON.stringify(obj, null, 2)], { type: "application/json" });
|
||||
const a = document.createElement("a");
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = nome;
|
||||
a.click();
|
||||
URL.revokeObjectURL(a.href);
|
||||
}
|
||||
|
||||
$("btnEnviarSd").addEventListener("click", async () => {
|
||||
if (!confirm("Marcar como enviada? O número definitivo será atribuído e a SD entra na base de conhecimento.")) return;
|
||||
const r = await fetch(`/api/sds/${sdAtual.id}/enviar`, { method: "POST" });
|
||||
const d = await r.json();
|
||||
if (r.ok) {
|
||||
alert(`SD registrada na base como ${d.registro_na_base.id}.`);
|
||||
await carregarStatus();
|
||||
await recarregarSd();
|
||||
} else {
|
||||
alert(d.erro + (d.bloqueios ? "\n- " + d.bloqueios.join("\n- ") : ""));
|
||||
}
|
||||
});
|
||||
|
||||
// ---------- Documento formatado + impressão/PDF ----------
|
||||
const CSS_IMPRESSAO = `
|
||||
body{margin:0; padding:32px 40px; background:#fff; color:#000;
|
||||
font-family:Georgia,"Times New Roman",serif; font-size:12.5pt; line-height:1.6;}
|
||||
h1{font-size:16pt; margin:0 0 2px; line-height:1.3;}
|
||||
.doc-sub{font-family:"Segoe UI",Arial,sans-serif; font-size:8.5pt; color:#555;
|
||||
text-transform:uppercase; letter-spacing:.08em; margin-bottom:18px;}
|
||||
.marca-rascunho{font-family:"Segoe UI",Arial,sans-serif; font-size:9pt; color:#B3362B;
|
||||
border:1px solid #B3362B; display:inline-block; padding:2px 10px; margin-bottom:14px;}
|
||||
h2{font-size:12.5pt; margin:22px 0 8px; border-bottom:2px solid #0B6B70; padding-bottom:3px;}
|
||||
h3{font-size:11pt; margin:16px 0 5px;}
|
||||
p{margin:0 0 10px;}
|
||||
table{border-collapse:collapse; width:100%; font-size:9.5pt;}
|
||||
th,td{border:1px solid #999; padding:4px 7px; text-align:left; vertical-align:top;}
|
||||
th{font-family:"Segoe UI",Arial,sans-serif; font-size:8pt; text-transform:uppercase;
|
||||
letter-spacing:.04em; background:#E1EFEF;}
|
||||
tr.subtotal td{font-weight:700; background:#E1EFEF;}
|
||||
tr.total td{font-weight:700; border-top:2px solid #0B6B70;}
|
||||
td.num,th.num{text-align:right;}
|
||||
.nota{font-size:10pt; font-style:italic; color:#444; margin-top:6px;}
|
||||
ul,ol{margin:0 0 10px; padding-left:20px;}
|
||||
li{margin-bottom:3px;}
|
||||
.cab-table td:first-child{font-family:"Segoe UI",Arial,sans-serif; font-size:8.5pt;
|
||||
text-transform:uppercase; letter-spacing:.04em; color:#555; width:170px;}
|
||||
.art-status{font-family:"Segoe UI",Arial,sans-serif; font-size:8pt; border-radius:8px;
|
||||
padding:1px 7px; margin-left:6px; white-space:nowrap; border:1px solid #999;}
|
||||
.art-status.sim{background:#E3F2E9; color:#1E7B47;}
|
||||
.art-status.nao{background:#F8EFDB; color:#A8730B;}
|
||||
.enq-desc{font-size:10pt; color:#444;}
|
||||
h2,h3{page-break-after:avoid;} tr{page-break-inside:avoid;}
|
||||
@page{margin:18mm 15mm;}`;
|
||||
|
||||
function minArtCli(ust) {
|
||||
const faixas = statusApp?.regras?.artefatos_minimos || [];
|
||||
const f = faixas.find((b) => b.ust_max === null || ust <= b.ust_max);
|
||||
return f ? f.artefatos : 1;
|
||||
}
|
||||
|
||||
function documentoHtml(sdX, rascunho) {
|
||||
const cab = sdX.cabecalho || {};
|
||||
const E = sdX.entregaveis || [];
|
||||
const infoItem = (i) => statusApp?.regras?.itens?.[i] || {};
|
||||
const soma = E.reduce((a, e) => a + (e.ust || 0), 0);
|
||||
const porItem = {};
|
||||
E.forEach((e) => (porItem[e.item_tr] = (porItem[e.item_tr] || 0) + (e.ust || 0)));
|
||||
const multi = Object.keys(porItem).length > 1;
|
||||
|
||||
let quadro = E.map((e) =>
|
||||
`<tr><td class="num">${e.n ?? ""}</td><td>${esc(e.nome)}</td><td>${esc(e.tipo || "")}</td>
|
||||
<td>${esc(e.item_tr || "")}</td><td class="num">${e.semanas ?? ""}</td>
|
||||
<td class="num">${e.timebox_h ?? ""}h</td><td class="num">${e.ust ?? ""}</td>
|
||||
<td>${fmtD(e.periodo?.inicio)} – ${fmtD(e.periodo?.fim)}</td></tr>`
|
||||
).join("");
|
||||
if (multi)
|
||||
quadro += Object.keys(porItem).sort()
|
||||
.map((i) => `<tr class="subtotal"><td colspan="6">Subtotal ${esc(i)}</td><td class="num">${porItem[i]}</td><td></td></tr>`)
|
||||
.join("");
|
||||
quadro += `<tr class="total"><td colspan="6">TOTAL</td><td class="num">${soma}</td><td></td></tr>`;
|
||||
|
||||
const notas = E.filter((e) => (e.nota_paralelismo || "").trim())
|
||||
.map((e) => `<p class="nota"><strong>Nota de paralelismo (E${e.n}):</strong> ${esc(e.nota_paralelismo)}</p>`)
|
||||
.join("");
|
||||
|
||||
const det = E.map((e) =>
|
||||
`<h3>E${e.n} · ${esc(e.tipo || "")} — ${esc(e.nome)} (${e.ust ?? "?"} UST)</h3>
|
||||
<p><strong>Resumo:</strong> ${esc(e.resumo || "")}</p>
|
||||
<p style="margin-bottom:4px"><strong>Atividades:</strong></p>
|
||||
<ol>${(e.atividades || []).map((a) => `<li>${esc(a)}</li>`).join("")}</ol>
|
||||
<p style="margin-bottom:4px"><strong>Artefatos</strong> (mínimo ${minArtCli(e.ust || 0)} pela faixa de UST):</p>
|
||||
<ul>${(e.artefatos || []).map((a) =>
|
||||
`<li>${esc(a.nome)}${a.data ? ` — ${fmtD(a.data)}` : ""}
|
||||
<span class="art-status ${a.confirmado_pelo_usuario ? "sim" : "nao"}">${a.confirmado_pelo_usuario ? "confirmado" : "a confirmar"}</span></li>`
|
||||
).join("")}</ul>
|
||||
<p style="margin-bottom:4px"><strong>Critérios de aceite:</strong></p>
|
||||
<ul>${(e.criterios_aceite || []).map((c) => `<li>${esc(c)}</li>`).join("")}</ul>`
|
||||
).join("");
|
||||
|
||||
const enq = (sdX.enquadramento_tr || []).map((q) => {
|
||||
const inf = infoItem(q.item_tr);
|
||||
return `<h3>Item ${inf.numero ?? "?"} — “${esc(q.titulo_literal)}” (CATMAS ${esc(inf.catmas || "—")}) — entregáveis ${(q.entregaveis_n || []).join(", ")}</h3>
|
||||
<p class="enq-desc">${esc(q.descricao_item || "")}</p>
|
||||
<ul>${(q.aderencia || []).map((b) => `<li>${esc(b)}</li>`).join("")}</ul>`;
|
||||
}).join("");
|
||||
|
||||
const titulo = esc(cab.numero_sd || "SD") + " — " + esc(cab.projeto || "Solicitação de Demanda");
|
||||
return `<!DOCTYPE html><html lang="pt-BR"><head><meta charset="utf-8"><title>${titulo}</title>` +
|
||||
`<style>${CSS_IMPRESSAO}</style></head><body>` +
|
||||
`<h1>${esc(cab.titulo || cab.projeto || "Solicitação de Demanda")}</h1>` +
|
||||
`<div class="doc-sub">Solicitação de Demanda · ${esc(cab.numero_sd || "SD-__")} · ${esc(cab.versao || "V1")} · RMDS / SES-MG</div>` +
|
||||
(rascunho ? `<div class="marca-rascunho">RASCUNHO — há pendências bloqueantes abertas</div>` : "") +
|
||||
`<table class="cab-table">
|
||||
<tr><td>Projeto</td><td>${esc(cab.projeto || "")}</td></tr>
|
||||
<tr><td>Data de abertura</td><td>${fmtD(cab.data_abertura)}</td></tr>
|
||||
<tr><td>Período de execução</td><td>${fmtD(cab.periodo_execucao?.inicio)} a ${fmtD(cab.periodo_execucao?.fim)}</td></tr>
|
||||
<tr><td>Itens do TR</td><td>${(cab.itens_tr || []).map(esc).join(" e ")}</td></tr>
|
||||
<tr><td>Total</td><td>${soma} UST${cab.total_ust_extenso ? ` (${esc(cab.total_ust_extenso)})` : ""}</td></tr>
|
||||
<tr><td>Prazo de execução</td><td>${esc(cab.prazo_execucao_semanas ?? "—")} semanas (calendário)</td></tr>
|
||||
<tr><td>Entregáveis</td><td>${E.length}</td></tr>
|
||||
</table>
|
||||
<h2>1. Objetivo</h2><p>${esc(sdX.objetivo || "")}</p>
|
||||
<h2>2. Contexto e escopo</h2>${(sdX.contexto || []).map((p) => `<p>${esc(p)}</p>`).join("")}
|
||||
<h2>3. Quadro de entregáveis</h2><table>
|
||||
<tr><th class="num">#</th><th>Entregável</th><th>Tipo</th><th>Item</th><th class="num">Sem.</th><th class="num">Timebox</th><th class="num">UST</th><th>Período</th></tr>
|
||||
${quadro}</table>${notas}
|
||||
<h2>4. Detalhamento por entregável</h2>${det}
|
||||
<h2>5. Enquadramento no TR</h2>${enq}` +
|
||||
`<scr` + `ipt>window.addEventListener("load",()=>setTimeout(()=>window.print(),350));</scr` + `ipt></body></html>`;
|
||||
}
|
||||
|
||||
function abrirDocumento(sdX, rascunho) {
|
||||
const html = documentoHtml(sdX, rascunho);
|
||||
try {
|
||||
const w = window.open("", "_blank");
|
||||
if (w && w.document) {
|
||||
w.document.open();
|
||||
w.document.write(html);
|
||||
w.document.close();
|
||||
return;
|
||||
}
|
||||
} catch { /* pop-up bloqueado */ }
|
||||
const nome = (sdX.cabecalho?.numero_sd || "sd").toLowerCase().replace(/[^a-z0-9-]+/g, "-") + "-impressao.html";
|
||||
const blob = new Blob([html], { type: "text/html" });
|
||||
const a = document.createElement("a");
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = nome;
|
||||
a.click();
|
||||
URL.revokeObjectURL(a.href);
|
||||
}
|
||||
|
||||
$("btnDocumento").addEventListener("click", async () => {
|
||||
if (modoExterno) {
|
||||
if (externo?.exportado) abrirDocumento(externo.exportado, (externo.bloqueios || []).length > 0);
|
||||
return;
|
||||
}
|
||||
if (!sdAtual?.id) return;
|
||||
const r = await fetch(`/api/sds/${sdAtual.id}/export?forcar=1`);
|
||||
if (exigirLogin(r)) return;
|
||||
if (!r.ok) { alert("Não foi possível gerar o documento."); return; }
|
||||
abrirDocumento(await r.json(), ultimosBloqueios.length > 0);
|
||||
});
|
||||
|
||||
// ---------- Validar JSON externo ----------
|
||||
function mostrarValidar() {
|
||||
esconderTodas();
|
||||
$("telaValidar").hidden = false;
|
||||
modoExterno = true;
|
||||
sdAtual = null;
|
||||
moverPainel("slotPainelValidar");
|
||||
if (externo?.exportado) renderPainel(externo.exportado, externo.validacao, externo.bloqueios);
|
||||
else resetPainel();
|
||||
}
|
||||
|
||||
async function validarExterno(lerTextarea = true) {
|
||||
const erroBox = $("validarErro");
|
||||
erroBox.hidden = true;
|
||||
let sdIn = externo?.sd;
|
||||
if (lerTextarea) {
|
||||
try {
|
||||
sdIn = JSON.parse($("jsonExterno").value);
|
||||
} catch (e) {
|
||||
erroBox.textContent = "JSON inválido: " + e.message;
|
||||
erroBox.hidden = false;
|
||||
return;
|
||||
}
|
||||
}
|
||||
const r = await fetch("/api/validar", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ sd: sdIn }),
|
||||
});
|
||||
if (exigirLogin(r)) return;
|
||||
const d = await r.json();
|
||||
if (!r.ok) {
|
||||
erroBox.textContent = d.erro || "Erro ao validar.";
|
||||
erroBox.hidden = false;
|
||||
return;
|
||||
}
|
||||
externo = { sd: sdIn, exportado: d.exportado, validacao: d.validacao, bloqueios: d.bloqueios };
|
||||
renderPainel(d.exportado, d.validacao, d.bloqueios);
|
||||
}
|
||||
|
||||
$("btnValidarJson").addEventListener("click", () => validarExterno(true));
|
||||
|
||||
$("btnImportarJson").addEventListener("click", () => $("arquivoJson").click());
|
||||
$("arquivoJson").addEventListener("change", async (ev) => {
|
||||
const arquivo = ev.target.files?.[0];
|
||||
ev.target.value = ""; // permite reimportar o mesmo arquivo depois
|
||||
if (!arquivo) return;
|
||||
const erroBox = $("validarErro");
|
||||
if (arquivo.size > 2 * 1024 * 1024) {
|
||||
erroBox.textContent = "Arquivo muito grande (máx. 2 MB).";
|
||||
erroBox.hidden = false;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const texto = await arquivo.text();
|
||||
JSON.parse(texto); // valida a sintaxe antes de preencher
|
||||
$("jsonExterno").value = texto;
|
||||
erroBox.hidden = true;
|
||||
await validarExterno(true); // já valida a SD importada
|
||||
} catch (e) {
|
||||
erroBox.textContent = `O arquivo "${arquivo.name}" não contém JSON válido: ${e.message}`;
|
||||
erroBox.hidden = false;
|
||||
}
|
||||
});
|
||||
|
||||
$("btnLimparJson").addEventListener("click", () => {
|
||||
$("jsonExterno").value = "";
|
||||
externo = null;
|
||||
$("validarErro").hidden = true;
|
||||
resetPainel();
|
||||
});
|
||||
|
||||
$("btnVoltarValidar").addEventListener("click", () => { location.hash = "#/"; });
|
||||
|
||||
// ---------- Alimentar base de conhecimento (upload de SD real) ----------
|
||||
|
||||
function mostrarBase() {
|
||||
esconderTodas();
|
||||
$("telaBase").hidden = false;
|
||||
modoExterno = false;
|
||||
sdAtual = null;
|
||||
}
|
||||
|
||||
$("btnVoltarBase").addEventListener("click", () => { location.hash = "#/"; });
|
||||
$("btnEscolherDoc").addEventListener("click", () => $("arquivoDoc").click());
|
||||
|
||||
function baseErro(msg) {
|
||||
const box = $("baseErro");
|
||||
box.textContent = msg || "";
|
||||
box.hidden = !msg;
|
||||
}
|
||||
|
||||
$("arquivoDoc").addEventListener("change", async (ev) => {
|
||||
const arquivo = ev.target.files?.[0];
|
||||
ev.target.value = ""; // permite reenviar o mesmo arquivo
|
||||
if (!arquivo) return;
|
||||
baseErro("");
|
||||
$("baseOk").hidden = true;
|
||||
const ext = (arquivo.name.toLowerCase().match(/\.([a-z0-9]+)$/) || [])[1] || "";
|
||||
if (!["pdf", "md", "txt", "markdown"].includes(ext)) {
|
||||
baseErro(`Formato .${ext || "?"} não suportado — envie .pdf, .md ou .txt (para .docx, exporte como PDF).`);
|
||||
return;
|
||||
}
|
||||
if (arquivo.size > 15 * 1024 * 1024) {
|
||||
baseErro("Arquivo muito grande (máx. 15 MB).");
|
||||
return;
|
||||
}
|
||||
$("btnEscolherDoc").disabled = true;
|
||||
$("baseExtraindo").hidden = false;
|
||||
$("baseRevisao").hidden = true;
|
||||
try {
|
||||
const base64 = await new Promise((resolve, reject) => {
|
||||
const fr = new FileReader();
|
||||
fr.onload = () => resolve(String(fr.result).split(",")[1] || "");
|
||||
fr.onerror = () => reject(new Error("Falha ao ler o arquivo."));
|
||||
fr.readAsDataURL(arquivo);
|
||||
});
|
||||
const r = await fetch("/api/base/extrair", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ nome: arquivo.name, base64, modelo: $("selModeloBase").value }),
|
||||
});
|
||||
if (exigirLogin(r)) return;
|
||||
const d = await r.json();
|
||||
if (!r.ok) { baseErro(d.erro || "Erro na extração."); return; }
|
||||
renderRevisaoBase(d);
|
||||
if (d.uso) {
|
||||
const custo = ((d.uso.input_tokens || 0) * PRECO.input + (d.uso.output_tokens || 0) * PRECO.output) / 1e6;
|
||||
$("baseUso").textContent =
|
||||
`extração: ${(d.uso.input_tokens || 0).toLocaleString("pt-BR")} tokens lidos · ` +
|
||||
`${(d.uso.output_tokens || 0).toLocaleString("pt-BR")} gerados · custo estimado US$ ${custo.toFixed(3)} ` +
|
||||
`(estimativa válida para o modelo Opus)`;
|
||||
}
|
||||
} catch (e) {
|
||||
baseErro("Erro na extração: " + e.message);
|
||||
} finally {
|
||||
$("btnEscolherDoc").disabled = false;
|
||||
$("baseExtraindo").hidden = true;
|
||||
}
|
||||
});
|
||||
|
||||
function renderRevisaoBase(d) {
|
||||
$("baseRevisao").hidden = false;
|
||||
$("baseSdJson").value = JSON.stringify(d.sd || {}, null, 2);
|
||||
renderConferenciaBase(d.conferencia || []);
|
||||
|
||||
const obs = d.observacoes || [];
|
||||
$("baseObsBloco").hidden = !obs.length;
|
||||
$("baseObs").innerHTML = obs.map((o) => `<li>${esc(o)}</li>`).join("");
|
||||
|
||||
$("baseCaps").innerHTML = (d.capacidades || []).length
|
||||
? (d.capacidades || []).map((c, i) => `
|
||||
<div class="cap-card" data-i="${i}">
|
||||
<input type="checkbox" checked aria-label="Incluir capacidade">
|
||||
<div class="cap-main">
|
||||
<input type="text" class="cap-titulo" value="${esc(c.titulo || "")}" aria-label="Título da capacidade">
|
||||
<textarea class="cap-desc" rows="3" aria-label="Descrição da capacidade">${esc(c.descricao || "")}</textarea>
|
||||
<div class="cap-meta">
|
||||
<input type="text" class="cap-item" value="${esc(c.item_tr || "")}" aria-label="Item do TR" placeholder="item do TR (ex.: I-02)">
|
||||
<input type="text" class="cap-tags" value="${esc((c.tags || []).join(", "))}" aria-label="Tags" placeholder="tags separadas por vírgula">
|
||||
</div>
|
||||
</div>
|
||||
</div>`).join("")
|
||||
: '<p class="hint">Nenhuma capacidade reutilizável identificada no documento.</p>';
|
||||
$("baseCaps").querySelectorAll(".cap-card input[type=checkbox]").forEach((cb) =>
|
||||
cb.addEventListener("change", (ev) =>
|
||||
ev.target.closest(".cap-card").classList.toggle("excluida", !ev.target.checked)
|
||||
)
|
||||
);
|
||||
$("baseRevisao").scrollIntoView({ behavior: "smooth" });
|
||||
}
|
||||
|
||||
function renderConferenciaBase(lista) {
|
||||
const temErro = lista.some((c) => c.nivel === "erro");
|
||||
$("baseConferencia").innerHTML = lista.length
|
||||
? lista.map((c) => `<div class="check-item st-${c.nivel === "erro" ? "falha" : "alerta"}">
|
||||
<span class="icon">${c.nivel === "erro" ? "✗" : "!"}</span>
|
||||
<span class="id">${c.nivel}</span><span>${esc(c.msg)}</span></div>`).join("")
|
||||
: '<div class="check-item st-ok"><span class="icon">✓</span><span class="id">ok</span><span>Nenhuma inconsistência encontrada.</span></div>';
|
||||
$("btnGravarBase").disabled = temErro;
|
||||
return temErro;
|
||||
}
|
||||
|
||||
function lerSdRevisada() {
|
||||
try {
|
||||
return JSON.parse($("baseSdJson").value);
|
||||
} catch (e) {
|
||||
baseErro("JSON do registro inválido: " + e.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function lerCapsRevisadas() {
|
||||
return [...$("baseCaps").querySelectorAll(".cap-card")]
|
||||
.filter((card) => card.querySelector("input[type=checkbox]").checked)
|
||||
.map((card) => ({
|
||||
titulo: card.querySelector(".cap-titulo").value.trim(),
|
||||
descricao: card.querySelector(".cap-desc").value.trim(),
|
||||
item_tr: card.querySelector(".cap-item").value.trim(),
|
||||
tags: card.querySelector(".cap-tags").value.split(",").map((t) => t.trim()).filter(Boolean),
|
||||
}))
|
||||
.filter((c) => c.titulo);
|
||||
}
|
||||
|
||||
$("btnReconferir").addEventListener("click", async () => {
|
||||
baseErro("");
|
||||
const sd = lerSdRevisada();
|
||||
if (!sd) return;
|
||||
const r = await fetch("/api/base/conferir", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ sd }),
|
||||
});
|
||||
if (exigirLogin(r)) return;
|
||||
const d = await r.json();
|
||||
renderConferenciaBase(d.conferencia || []);
|
||||
});
|
||||
|
||||
$("btnGravarBase").addEventListener("click", async () => {
|
||||
baseErro("");
|
||||
const sd = lerSdRevisada();
|
||||
if (!sd) return;
|
||||
const caps = lerCapsRevisadas();
|
||||
if (!confirm(`Gravar ${sd.id || "a SD"} na base de conhecimento` +
|
||||
(caps.length ? ` com ${caps.length} capacidade(s)` : "") +
|
||||
"? Saldos de pool e precedentes passam a valer nas próximas entrevistas.")) return;
|
||||
$("btnGravarBase").disabled = true;
|
||||
try {
|
||||
const r = await fetch("/api/base/gravar", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ sd, capacidades: caps }),
|
||||
});
|
||||
if (exigirLogin(r)) return;
|
||||
const d = await r.json();
|
||||
if (!r.ok) {
|
||||
if (d.conferencia) renderConferenciaBase(d.conferencia);
|
||||
baseErro(d.erro || "Erro ao gravar.");
|
||||
return;
|
||||
}
|
||||
const okBox = $("baseOk");
|
||||
okBox.textContent = `✓ ${d.sd.id} gravada na base` +
|
||||
(d.capacidades.length ? ` com ${d.capacidades.map((c) => c.id).join(", ")}` : "") +
|
||||
". Vale a partir da próxima conversa.";
|
||||
okBox.hidden = false;
|
||||
await carregarStatus(); // atualiza próxima numeração e saldos no topo
|
||||
} finally {
|
||||
$("btnGravarBase").disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
function renderUso(uso) {
|
||||
if (!uso) return;
|
||||
const custo =
|
||||
(uso.input_tokens * PRECO.input +
|
||||
uso.output_tokens * PRECO.output +
|
||||
uso.cache_read_input_tokens * PRECO.cache_read +
|
||||
uso.cache_creation_input_tokens * PRECO.cache_write) / 1e6;
|
||||
$("usoBox").textContent =
|
||||
`tokens: ${uso.input_tokens.toLocaleString("pt-BR")} in · ${uso.output_tokens.toLocaleString("pt-BR")} out · ` +
|
||||
`cache ${uso.cache_read_input_tokens.toLocaleString("pt-BR")} lidos / ${uso.cache_creation_input_tokens.toLocaleString("pt-BR")} gravados · ` +
|
||||
`custo estimado US$ ${custo.toFixed(3)} (estimativa válida para o modelo Opus)`;
|
||||
}
|
||||
Reference in New Issue
Block a user