Files
RedatorSD/app/public/app.js
T

981 lines
38 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use strict";
const $ = (id) => document.getElementById(id);
const esc = (s) =>
String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[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)`;
}