/** * Controlador de "Não Conformidades" (Relatórios > Qualidade) — gestão * contínua das ocorrências/ações do Sigsistem, ver CLAUDE.md do pacote * `portal_api/nao_conformidades/`. Guarda de página no mesmo padrão de * `importacao-plano-saude.js`/`indicador-desempenho.js`: retorna cedo se o * elemento raiz da tela não existir (script incluído em todo shell) e some * atrás de `#ncf-no-access` se o usuário não tiver * `permissoes_efetivas.relatorios.apps["nao-conformidades"]`. */ function pidNcfFormatDate(iso) { if (!iso) return "—"; const [ano, mes, dia] = iso.split("-"); return `${dia}/${mes}/${ano}`; } function pidNcfFormatDateTime(iso) { if (!iso) return "—"; const data = new Date(iso); if (Number.isNaN(data.getTime())) return "—"; return data.toLocaleString("pt-BR", { day: "2-digit", month: "2-digit", year: "numeric", hour: "2-digit", minute: "2-digit" }); } function pidNcfEscapeHtml(texto) { if (texto == null) return ""; const div = document.createElement("div"); div.textContent = String(texto); return div.innerHTML; } function pidNcfAtualizarContador(el, quantidade) { el.textContent = quantidade ? `${quantidade}` : ""; el.hidden = quantidade === 0; } const PID_NCF_STATUS_PRAZO_CLASSES = { finalizada: "status-pill status-pill--ok", vencida: "status-pill status-pill--danger", vence_7_dias: "status-pill status-pill--warning", vence_30_dias: "status-pill status-pill--neutral", no_prazo: "status-pill status-pill--ok", sem_vencimento: "status-pill status-pill--neutral", }; // Compara duas strings tratando conteúdo numérico como número (ex.: "92" < // "150"), senão cai pra comparação alfabética pt-BR — mesmo critério de // comparaValoresUsuario() em users-admin.js/comparaValoresLista() em // importacao-plano-saude.js, copiado localmente porque cada tela do Portal // tem seu próprio arquivo JS autocontido, sem módulo compartilhado. function pidNcfCompara(a, b) { const an = Number(a); const bn = Number(b); const ambosNumeros = a !== "" && b !== "" && a != null && b != null && !Number.isNaN(an) && !Number.isNaN(bn); if (ambosNumeros) return an - bn; return String(a ?? "").localeCompare(String(b ?? ""), "pt-BR", { sensitivity: "base" }); } // Ordena por severidade, não alfabeticamente (ex.: "Vencida" precisa vir // antes de "No prazo", não é isso que localeCompare faria). const PID_NCF_STATUS_PRAZO_RANK = { vencida: 0, vence_7_dias: 1, vence_30_dias: 2, no_prazo: 3, sem_vencimento: 4, finalizada: 5, }; const PID_NCF_STATUS_TRATATIVA_RANK = { pendente: 0, tratado: 1 }; function pidNcfAplicarSort(itens, sortKey, sortDir, sortValorMap) { if (!sortKey) return itens; // preserva a ordem que já veio do servidor até o usuário clicar um cabeçalho const extrator = sortValorMap[sortKey]; if (!extrator) return itens; return itens.slice().sort((a, b) => pidNcfCompara(extrator(a), extrator(b)) * sortDir); } function pidNcfAtualizarSortHeaders(headers, sortKey, sortDir) { headers.forEach((th) => { const key = th.getAttribute("data-sort"); th.classList.toggle("is-sort-asc", key === sortKey && sortDir === 1); th.classList.toggle("is-sort-desc", key === sortKey && sortDir === -1); }); } // Liga o clique de ordenação nos de uma tabela — clicar // alterna asc/desc na mesma coluna ou reseta pra asc numa coluna nova. Ignora // cliques que vieram do ícone/popover de filtro de coluna (só a tabela de // Ações tem isso hoje), pra sort e filtro de coluna nunca disparar juntos. function pidNcfBindSortHeaders(tableId, getSortState, setSortState, rerender) { const headers = Array.from(document.querySelectorAll(`#${tableId} th[data-sort]`)); headers.forEach((th) => { th.addEventListener("click", (evento) => { if (evento.target.closest("[data-ncf-col-filtro], .ncf-col-filtro-popover")) return; const key = th.getAttribute("data-sort"); const { sortKey, sortDir } = getSortState(); if (sortKey === key) { setSortState(key, sortDir * -1); } else { setSortState(key, 1); } rerender(); }); }); return headers; } /** Botão de expandir (chevron que gira) + linha de detalhe associada, mesmo * padrão de `.cc-painel-fiscal__toggle` (custo-contratacao.js), adaptado pra * uma linha de tabela — `montarConteudo(painelEl)` só roda na primeira vez * que a linha é aberta (lazy). */ function pidNcfCriarLinhaExpandivel(colspan, montarConteudo) { const btn = document.createElement("button"); btn.type = "button"; btn.className = "ncf-expand-btn"; btn.setAttribute("aria-expanded", "false"); btn.setAttribute("aria-label", "Expandir detalhes"); btn.innerHTML = ''; const trDetalhe = document.createElement("tr"); trDetalhe.className = "ncf-detail-row"; trDetalhe.hidden = true; const td = document.createElement("td"); td.colSpan = colspan; // Classe própria (não só .ncf-detail-row td) pra vencer .pa-table // td:last-child (text-align:right) — como esta é a única célula da linha, // ela sempre "é" a última. td.className = "ncf-detail-cell"; const painel = document.createElement("div"); painel.className = "ncf-detail-panel"; td.appendChild(painel); trDetalhe.appendChild(td); btn.addEventListener("click", async () => { const aberto = btn.getAttribute("aria-expanded") === "true"; btn.setAttribute("aria-expanded", String(!aberto)); trDetalhe.hidden = aberto; if (!aberto && !painel.dataset.carregado) { painel.dataset.carregado = "1"; await montarConteudo(painel); } }); return { btn, trDetalhe }; } function pidNcfCriarBadgeTratativa(item) { const fragmento = document.createDocumentFragment(); const tratado = item.status_tratativa === "tratado"; const badge = document.createElement("span"); badge.className = `status-pill ${tratado ? "status-pill--ok" : "status-pill--neutral"}`; badge.textContent = tratado ? "Tratado" : "Pendente"; fragmento.appendChild(badge); if (item.reaberto_motivo) { const reaberto = document.createElement("span"); reaberto.className = "ncf-reaberto"; reaberto.style.marginLeft = "6px"; reaberto.title = item.reaberto_motivo; reaberto.textContent = "Reaberto"; fragmento.appendChild(reaberto); } return fragmento; } document.addEventListener("DOMContentLoaded", async () => { const contentEl = document.getElementById("ncf-content"); if (!contentEl) return; const me = await pidGetMe(); if (!me) return; const relatoriosPerms = (me.permissoes_efetivas && me.permissoes_efetivas["relatorios"]) || {}; const canUse = !!(relatoriosPerms.apps && relatoriosPerms.apps["nao-conformidades"]); const noAccessEl = document.getElementById("ncf-no-access"); if (noAccessEl) noAccessEl.hidden = canUse; contentEl.hidden = !canUse; if (!canUse) return; // ---------------- Tabs ---------------- const tabButtons = Array.from(document.querySelectorAll("[data-ncf-tab]")); const tabPanels = Array.from(document.querySelectorAll("[data-ncf-panel]")); let gestaoCarregada = false; let importarCarregado = false; // Só troca a UI — nunca dispara carregamento de dados sozinha. Isso evita // uma corrida real que existiu aqui: um drill-down do Dashboard chamava // `ativarTab("gestao")` (que carregava a Gestão com os filtros ainda // default) E, na sequência, também recarregava com o filtro escolhido — // as duas chamadas concorrentes escreviam em `acoesTodos`, e a que // terminasse por último vencia (às vezes a errada, mostrando "Todas" em // vez do filtro clicado). Quem precisa de dados chama // `garantirGestaoCarregada()`/`carregarAcoes()` explicitamente, sempre // com `await`, nunca em paralelo com outra chamada à mesma lista. function ativarTab(nome) { tabButtons.forEach((btn) => btn.classList.toggle("is-active", btn.dataset.ncfTab === nome)); tabPanels.forEach((panel) => { panel.hidden = panel.dataset.ncfPanel !== nome; }); } tabButtons.forEach((btn) => btn.addEventListener("click", async () => { const nome = btn.dataset.ncfTab; ativarTab(nome); if (nome === "gestao") await garantirGestaoCarregada(); if (nome === "importar" && !importarCarregado) { importarCarregado = true; await carregarImportacoes(); } }) ); // ---------------- Estado compartilhado da Gestão ---------------- let semAnaliseTodos = []; let analiseSemAcaoTodos = []; let acoesTodos = []; // Filtro de Prazo é múltiplo (pedido explícito do usuário — poder ver // "Vencidas" e "Vence em 7 dias" ao mesmo tempo, por exemplo): cada chip // liga/desliga independente, combinados com OR no servidor (ver // `_filtra_status_prazo` em views.py). Todos os buckets em aberto vêm // marcados por padrão — só "Finalizadas" começa desmarcado. const PID_NCF_PRAZO_BUCKETS_ABERTO = ["vencida", "vence_7_dias", "vence_30_dias", "no_prazo", "sem_vencimento"]; let prazoFiltrosSelecionados = new Set(PID_NCF_PRAZO_BUCKETS_ABERTO); let tratativaFiltroAtual = "pendente"; let semAnaliseTratativaFiltroAtual = "pendente"; let analiseSemAcaoTratativaFiltroAtual = "pendente"; let buscaGestao = ""; // Ordenação por clique no cabeçalho — estado independente por tabela. // sortKey=null preserva a ordenação vinda do servidor até o usuário clicar // um cabeçalho pela primeira vez. let semAnaliseSortKey = null; let semAnaliseSortDir = 1; let analiseSemAcaoSortKey = null; let analiseSemAcaoSortDir = 1; let acoesSortKey = null; let acoesSortDir = 1; // Filtros de coluna da tabela de Ações (só "Tipo de Ocorrência" e // "Vencimento" têm isso, e só nessa tabela) — sempre client-side, sobre o // `acoesTodos` já carregado (ver carregarAcoes()/renderAcoes()). let tipoOcorrenciaFiltroSelecionados = new Set(); // vazio = sem filtro (mostra tudo) let tipoOcorrenciaOpcoesDisponiveis = []; let vencimentoFiltroDe = ""; let vencimentoFiltroAte = ""; // Espelham exatamente o que está sendo mostrado em cada tabela (depois de // busca/chips/filtro de coluna/ordenação) — alimentam o Resumo copiável // (ver atualizarResumoGestao()), atualizados no fim de cada render*(). let acoesItensVisiveis = []; let semAnaliseItensVisiveis = []; let analiseSemAcaoItensVisiveis = []; const gestaoSearch = document.getElementById("ncf-gestao-search"); const gestaoSearchClearBtn = document.getElementById("ncf-gestao-search-clear-btn"); const filtroPrazoEl = document.getElementById("ncf-filtro-prazo"); const filtroTratativaEl = document.getElementById("ncf-filtro-tratativa"); const filtroTratativaSemAnaliseEl = document.getElementById("ncf-filtro-tratativa-sem-analise"); const filtroTratativaAnaliseSemAcaoEl = document.getElementById("ncf-filtro-tratativa-analise-sem-acao"); function filtrarPorBusca(itens, campos) { const termo = buscaGestao.trim().toLowerCase(); if (!termo) return itens; return itens.filter((item) => campos.some((campo) => (item[campo] || "").toString().toLowerCase().includes(termo)) ); } function renderTudoGestao() { renderSemAnalise(); renderAnaliseSemAcao(); renderAcoes(); } function aplicarBusca(valor) { buscaGestao = valor; gestaoSearch.value = valor; gestaoSearchClearBtn.hidden = !valor; renderTudoGestao(); } gestaoSearch.addEventListener("input", () => { aplicarBusca(gestaoSearch.value); }); gestaoSearchClearBtn.addEventListener("click", () => aplicarBusca("")); function atualizarChipsAtivos(container, datasetAttr, valor) { Array.from(container.children).forEach((btn) => btn.classList.toggle("is-active", btn.dataset[datasetAttr] === valor)); } // Mesma ideia de atualizarChipsAtivos(), mas pra um filtro de múltipla // escolha (Prazo de Ações) — ativo se o valor do chip estiver no Set. function atualizarChipsMultiAtivos(container, datasetAttr, selecionados) { Array.from(container.children).forEach((btn) => btn.classList.toggle("is-active", selecionados.has(btn.dataset[datasetAttr])) ); } filtroPrazoEl.addEventListener("click", (evento) => { const btn = evento.target.closest("[data-ncf-prazo]"); if (!btn) return; const valor = btn.dataset.ncfPrazo; if (prazoFiltrosSelecionados.has(valor)) { prazoFiltrosSelecionados.delete(valor); } else { prazoFiltrosSelecionados.add(valor); } atualizarChipsMultiAtivos(filtroPrazoEl, "ncfPrazo", prazoFiltrosSelecionados); carregarAcoes(); }); filtroTratativaEl.addEventListener("click", (evento) => { const btn = evento.target.closest("[data-ncf-tratativa]"); if (!btn) return; tratativaFiltroAtual = btn.dataset.ncfTratativa; atualizarChipsAtivos(filtroTratativaEl, "ncfTratativa", tratativaFiltroAtual); carregarAcoes(); }); filtroTratativaSemAnaliseEl.addEventListener("click", (evento) => { const btn = evento.target.closest("[data-ncf-tratativa]"); if (!btn) return; semAnaliseTratativaFiltroAtual = btn.dataset.ncfTratativa; atualizarChipsAtivos(filtroTratativaSemAnaliseEl, "ncfTratativa", semAnaliseTratativaFiltroAtual); carregarSemAnalise(); }); filtroTratativaAnaliseSemAcaoEl.addEventListener("click", (evento) => { const btn = evento.target.closest("[data-ncf-tratativa]"); if (!btn) return; analiseSemAcaoTratativaFiltroAtual = btn.dataset.ncfTratativa; atualizarChipsAtivos(filtroTratativaAnaliseSemAcaoEl, "ncfTratativa", analiseSemAcaoTratativaFiltroAtual); carregarAnaliseSemAcao(); }); async function garantirGestaoCarregada() { if (gestaoCarregada) return; gestaoCarregada = true; await Promise.all([carregarSemAnalise(), carregarAnaliseSemAcao(), carregarAcoes()]); } // ---------------- Gestão: Ocorrências sem análise ---------------- const semAnaliseBody = document.getElementById("ncf-sem-analise-body"); const semAnaliseEmpty = document.getElementById("ncf-sem-analise-empty"); const semAnaliseCount = document.getElementById("ncf-sem-analise-count"); const PID_NCF_SEM_ANALISE_SORT_VALOR = { codigo: (oc) => oc.codigo, data_emissao: (oc) => oc.data_emissao || "", area: (oc) => oc.area || "", emissor_relato: (oc) => oc.emissor_relato || "", indicado_analise: (oc) => oc.indicado_analise || "", status_tratativa: (oc) => PID_NCF_STATUS_TRATATIVA_RANK[oc.status_tratativa] ?? 0, }; const semAnaliseSortHeaders = pidNcfBindSortHeaders( "ncf-table-sem-analise", () => ({ sortKey: semAnaliseSortKey, sortDir: semAnaliseSortDir }), (key, dir) => { semAnaliseSortKey = key; semAnaliseSortDir = dir; }, renderSemAnalise ); async function carregarSemAnalise() { const params = new URLSearchParams({ sem_analise: "true" }); if (semAnaliseTratativaFiltroAtual !== "todas") params.set("status_tratativa", semAnaliseTratativaFiltroAtual); semAnaliseTodos = await pidApiRequest(`/nao-conformidades/ocorrencias/?${params.toString()}`); renderSemAnalise(); } function renderSemAnalise() { let itens = filtrarPorBusca(semAnaliseTodos, [ "indicado_analise", "responsavel_analise", "clientes_relacionados", "assunto", "emissor_relato", ]); itens = pidNcfAplicarSort(itens, semAnaliseSortKey, semAnaliseSortDir, PID_NCF_SEM_ANALISE_SORT_VALOR); pidNcfAtualizarSortHeaders(semAnaliseSortHeaders, semAnaliseSortKey, semAnaliseSortDir); semAnaliseItensVisiveis = itens; atualizarResumoGestao(); pidNcfAtualizarContador(semAnaliseCount, itens.length); semAnaliseBody.innerHTML = ""; semAnaliseEmpty.hidden = itens.length > 0; itens.forEach((oc) => { const tratado = oc.status_tratativa === "tratado"; const { btn, trDetalhe } = pidNcfCriarLinhaExpandivel(8, (painel) => montarPainelOcorrencia(painel, oc)); const tr = document.createElement("tr"); const tdBtn = document.createElement("td"); tdBtn.className = "ncf-expand-col"; tdBtn.appendChild(btn); tr.appendChild(tdBtn); tr.insertAdjacentHTML( "beforeend", `${oc.codigo} ${pidNcfFormatDate(oc.data_emissao)} ${pidNcfEscapeHtml(oc.area) || "—"} ${pidNcfEscapeHtml(oc.emissor_relato) || "—"} ${pidNcfEscapeHtml(oc.indicado_analise) || "—"}` ); const tdTratativa = document.createElement("td"); tdTratativa.appendChild(pidNcfCriarBadgeTratativa(oc)); tr.appendChild(tdTratativa); const tdAcao = document.createElement("td"); const tratarBtn = document.createElement("button"); tratarBtn.type = "button"; tratarBtn.className = tratado ? "btn-outline" : "btn-solid"; tratarBtn.textContent = tratado ? "Reabrir" : "Marcar como tratado"; tratarBtn.addEventListener("click", () => alternarTratativaOcorrencia(oc.id, tratado)); tdAcao.appendChild(tratarBtn); tr.appendChild(tdAcao); semAnaliseBody.appendChild(tr); semAnaliseBody.appendChild(trDetalhe); }); } // ---------------- Gestão: Análise sem ação ---------------- const analiseSemAcaoBody = document.getElementById("ncf-analise-sem-acao-body"); const analiseSemAcaoEmpty = document.getElementById("ncf-analise-sem-acao-empty"); const analiseSemAcaoCount = document.getElementById("ncf-analise-sem-acao-count"); const PID_NCF_ANALISE_SEM_ACAO_SORT_VALOR = { codigo: (oc) => oc.codigo, data_emissao: (oc) => oc.data_emissao || "", area: (oc) => oc.area || "", responsavel_analise: (oc) => oc.responsavel_analise || "", descricao_analise: (oc) => oc.descricao_analise || "", status_tratativa: (oc) => PID_NCF_STATUS_TRATATIVA_RANK[oc.status_tratativa] ?? 0, }; const analiseSemAcaoSortHeaders = pidNcfBindSortHeaders( "ncf-table-analise-sem-acao", () => ({ sortKey: analiseSemAcaoSortKey, sortDir: analiseSemAcaoSortDir }), (key, dir) => { analiseSemAcaoSortKey = key; analiseSemAcaoSortDir = dir; }, renderAnaliseSemAcao ); async function carregarAnaliseSemAcao() { const params = new URLSearchParams({ analise_sem_acao: "true" }); if (analiseSemAcaoTratativaFiltroAtual !== "todas") params.set("status_tratativa", analiseSemAcaoTratativaFiltroAtual); analiseSemAcaoTodos = await pidApiRequest(`/nao-conformidades/ocorrencias/?${params.toString()}`); renderAnaliseSemAcao(); } function renderAnaliseSemAcao() { let itens = filtrarPorBusca(analiseSemAcaoTodos, [ "indicado_analise", "responsavel_analise", "clientes_relacionados", "assunto", "descricao_analise", ]); itens = pidNcfAplicarSort(itens, analiseSemAcaoSortKey, analiseSemAcaoSortDir, PID_NCF_ANALISE_SEM_ACAO_SORT_VALOR); pidNcfAtualizarSortHeaders(analiseSemAcaoSortHeaders, analiseSemAcaoSortKey, analiseSemAcaoSortDir); analiseSemAcaoItensVisiveis = itens; atualizarResumoGestao(); pidNcfAtualizarContador(analiseSemAcaoCount, itens.length); analiseSemAcaoBody.innerHTML = ""; analiseSemAcaoEmpty.hidden = itens.length > 0; itens.forEach((oc) => { const tratado = oc.status_tratativa === "tratado"; const descricao = (oc.descricao_analise || "").slice(0, 140); const { btn, trDetalhe } = pidNcfCriarLinhaExpandivel(8, (painel) => montarPainelOcorrencia(painel, oc)); const tr = document.createElement("tr"); const tdBtn = document.createElement("td"); tdBtn.className = "ncf-expand-col"; tdBtn.appendChild(btn); tr.appendChild(tdBtn); tr.insertAdjacentHTML( "beforeend", `${oc.codigo} ${pidNcfFormatDate(oc.data_emissao)} ${pidNcfEscapeHtml(oc.area) || "—"} ${pidNcfEscapeHtml(oc.responsavel_analise) || "—"} ${pidNcfEscapeHtml(descricao)}${(oc.descricao_analise || "").length > 140 ? "…" : ""}` ); const tdTratativa = document.createElement("td"); tdTratativa.appendChild(pidNcfCriarBadgeTratativa(oc)); tr.appendChild(tdTratativa); const tdAcao = document.createElement("td"); const tratarBtn = document.createElement("button"); tratarBtn.type = "button"; tratarBtn.className = tratado ? "btn-outline" : "btn-solid"; tratarBtn.textContent = tratado ? "Reabrir" : "Marcar como tratado"; tratarBtn.addEventListener("click", () => alternarTratativaOcorrencia(oc.id, tratado)); tdAcao.appendChild(tratarBtn); tr.appendChild(tdAcao); analiseSemAcaoBody.appendChild(tr); analiseSemAcaoBody.appendChild(trDetalhe); }); } async function alternarTratativaOcorrencia(id, estaTratado) { const acao = estaTratado ? "reabrir" : "marcar-tratado"; try { await pidApiRequest(`/nao-conformidades/ocorrencias/${id}/${acao}/`, { method: "POST" }); await Promise.all([carregarSemAnalise(), carregarAnaliseSemAcao()]); } catch (e) { await pidAlert(e.message || "Não foi possível atualizar o status."); } } // ---------------- Painel de detalhe de uma ocorrência (lazy) ---------------- async function montarPainelOcorrencia(painel, item) { const idExtra = `ncf-extra-oc-${item.id}`; painel.innerHTML = `
Assunto${pidNcfEscapeHtml(item.assunto) || "—"}
Tipo${pidNcfEscapeHtml(item.tipo_ocorrencia) || "—"}
Área / Setor${pidNcfEscapeHtml(item.area) || "—"} / ${pidNcfEscapeHtml(item.setor) || "—"}
Cliente(s)${pidNcfEscapeHtml(item.clientes_relacionados) || "—"}
Tipo(s) de causa${pidNcfEscapeHtml(item.tipos_causa) || "—"}
Emissor${pidNcfEscapeHtml(item.emissor_relato) || "—"}

Descrição da análise

${pidNcfEscapeHtml(item.descricao_analise) || "(sem análise registrada)"}

Carregando mais detalhes...

`; const extra = painel.querySelector(`#${idExtra}`); try { const detalhe = await pidApiRequest(`/nao-conformidades/ocorrencias/${item.id}/`); const acoesHtml = (detalhe.acoes || []).length ? `

Ações vinculadas

` : ""; extra.innerHTML = `
Origem${pidNcfEscapeHtml(detalhe.origem) || "—"}
Data do relato${pidNcfFormatDate(detalhe.data_relato)}
Prazo pra finalizar${pidNcfFormatDate(detalhe.prazo_finalizar)}
Pessoas relacionadas${pidNcfEscapeHtml(detalhe.pessoas_relacionadas) || "—"}
Fornecedores relacionados${pidNcfEscapeHtml(detalhe.fornecedores_relacionados) || "—"}
Riscos relacionados${pidNcfEscapeHtml(detalhe.riscos_relacionados) || "—"}
Representante/Gerente${pidNcfEscapeHtml(detalhe.representante_gerente) || "—"}

Relato

${pidNcfEscapeHtml(detalhe.relato) || "—"}

${acoesHtml} `; } catch (e) { extra.innerHTML = ``; } } // ---------------- Gestão: Ações ---------------- const acoesBody = document.getElementById("ncf-acoes-body"); const acoesEmpty = document.getElementById("ncf-acoes-empty"); const acoesCount = document.getElementById("ncf-acoes-count"); const PID_NCF_ACOES_SORT_VALOR = { ocorrencia_codigo: (a) => Number(a.ocorrencia_codigo) * 1000000 + Number(a.codigo || 0), ocorrencia_area: (a) => a.ocorrencia_area || "", tipo_acao: (a) => a.tipo_acao || "", ocorrencia_tipo_ocorrencia: (a) => a.ocorrencia_tipo_ocorrencia || "", executor: (a) => a.executor || "", vencimento_efetivo: (a) => a.vencimento_efetivo || "", ultimo_acompanhamento_em: (a) => a.ultimo_acompanhamento_em || "", status_prazo: (a) => PID_NCF_STATUS_PRAZO_RANK[a.status_prazo] ?? 99, status_tratativa: (a) => PID_NCF_STATUS_TRATATIVA_RANK[a.status_tratativa] ?? 0, }; const acoesSortHeaders = pidNcfBindSortHeaders( "ncf-table-acoes", () => ({ sortKey: acoesSortKey, sortDir: acoesSortDir }), (key, dir) => { acoesSortKey = key; acoesSortDir = dir; }, renderAcoes ); async function carregarAcoes() { if (prazoFiltrosSelecionados.size === 0) { // Nenhum chip de Prazo marcado — mesmo espírito de um checklist todo // desmarcado, não mostra nada (em vez de interpretar como "sem // filtro" e trazer tudo, o que seria surpreendente pro usuário). acoesTodos = []; tipoOcorrenciaOpcoesDisponiveis = []; renderTipoOcorrenciaChecklist(); renderAcoes(); return; } const params = new URLSearchParams(); prazoFiltrosSelecionados.forEach((bucket) => params.append("status_prazo", bucket)); if (tratativaFiltroAtual !== "todas") params.set("status_tratativa", tratativaFiltroAtual); const query = params.toString(); acoesTodos = await pidApiRequest(`/nao-conformidades/acoes/${query ? "?" + query : ""}`); // Opções do checklist "Tipo de Ocorrência" vêm do array cheio, ANTES de // qualquer filtro de coluna/busca — recalculado só aqui (uma vez por // carregamento do servidor), nunca dentro de renderAcoes(), pra // desmarcar um tipo nunca fazer os outros sumirem da lista. tipoOcorrenciaOpcoesDisponiveis = Array.from( new Set(acoesTodos.map((a) => a.ocorrencia_tipo_ocorrencia).filter(Boolean)) ).sort((a, b) => a.localeCompare(b, "pt-BR", { sensitivity: "base" })); tipoOcorrenciaFiltroSelecionados = new Set( Array.from(tipoOcorrenciaFiltroSelecionados).filter((v) => tipoOcorrenciaOpcoesDisponiveis.includes(v)) ); renderTipoOcorrenciaChecklist(); renderAcoes(); } function pidNcfFiltraColunaAcoes(itens) { let out = itens; if (tipoOcorrenciaFiltroSelecionados.size > 0) { out = out.filter((a) => tipoOcorrenciaFiltroSelecionados.has(a.ocorrencia_tipo_ocorrencia)); } if (vencimentoFiltroDe) out = out.filter((a) => a.vencimento_efetivo && a.vencimento_efetivo >= vencimentoFiltroDe); if (vencimentoFiltroAte) out = out.filter((a) => a.vencimento_efetivo && a.vencimento_efetivo <= vencimentoFiltroAte); return out; } function renderAcoes() { let itens = filtrarPorBusca(acoesTodos, ["executor", "ocorrencia_clientes", "ocorrencia_codigo", "acao_texto"]); itens = pidNcfFiltraColunaAcoes(itens); itens = pidNcfAplicarSort(itens, acoesSortKey, acoesSortDir, PID_NCF_ACOES_SORT_VALOR); pidNcfAtualizarSortHeaders(acoesSortHeaders, acoesSortKey, acoesSortDir); acoesItensVisiveis = itens; atualizarResumoGestao(); pidNcfAtualizarContador(acoesCount, itens.length); acoesBody.innerHTML = ""; acoesEmpty.hidden = itens.length > 0; itens.forEach((acaoItem) => { const tratado = acaoItem.status_tratativa === "tratado"; // 11 colunas — a descrição da Ação não é mais uma coluna própria (não // cabia mais na largura da tabela junto com as outras); vira uma linha // full-width logo abaixo, ver trDescricao mais adiante. const { btn, trDetalhe } = pidNcfCriarLinhaExpandivel(11, (painel) => montarPainelAcao(painel, acaoItem)); const tr = document.createElement("tr"); tr.className = "ncf-acao-main-row"; const tdBtn = document.createElement("td"); tdBtn.className = "ncf-expand-col"; tdBtn.appendChild(btn); tr.appendChild(tdBtn); const ultimoAcompHtml = acaoItem.ultimo_acompanhamento_eh_prorrogacao ? ` Prorrogação` : ""; tr.insertAdjacentHTML( "beforeend", `${acaoItem.ocorrencia_codigo}/${acaoItem.codigo} ${pidNcfEscapeHtml(acaoItem.ocorrencia_area) || "—"} ${pidNcfEscapeHtml(acaoItem.tipo_acao) || "—"} ${pidNcfEscapeHtml(acaoItem.ocorrencia_tipo_ocorrencia) || "—"} ${pidNcfEscapeHtml(acaoItem.executor) || "—"} ${pidNcfFormatDate(acaoItem.vencimento_efetivo)} ${pidNcfFormatDate(acaoItem.ultimo_acompanhamento_em)}${ultimoAcompHtml}` ); const tdPrazo = document.createElement("td"); const badgePrazo = document.createElement("span"); badgePrazo.className = PID_NCF_STATUS_PRAZO_CLASSES[acaoItem.status_prazo] || "status-pill status-pill--neutral"; badgePrazo.textContent = acaoItem.status_prazo_label; tdPrazo.appendChild(badgePrazo); tr.appendChild(tdPrazo); const tdTratativa = document.createElement("td"); tdTratativa.appendChild(pidNcfCriarBadgeTratativa(acaoItem)); tr.appendChild(tdTratativa); const tdAcao = document.createElement("td"); // Ação já finalizada no Sigsistem (data_finalizacao preenchida) — não // há nada pra Qualidade tratar aqui, então nem oferece o botão. if (acaoItem.status_prazo === "finalizada") { const semAcaoEl = document.createElement("span"); semAcaoEl.className = "ncf-sem-acao-necessaria"; semAcaoEl.textContent = "—"; tdAcao.appendChild(semAcaoEl); } else { const tratarBtn = document.createElement("button"); tratarBtn.type = "button"; tratarBtn.className = tratado ? "btn-outline" : "btn-solid"; tratarBtn.textContent = tratado ? "Reabrir" : "Marcar como tratado"; tratarBtn.addEventListener("click", () => alternarTratativaAcao(acaoItem.id, tratado)); tdAcao.appendChild(tratarBtn); } tr.appendChild(tdAcao); // Linha full-width com a descrição da Ação — não é mais uma coluna // própria (não cabia na largura da tabela junto com as outras 10 // colunas), mas o usuário precisa ver o texto sem abrir linha por // linha, então vira uma segunda linha logo abaixo da principal. const trDescricao = document.createElement("tr"); trDescricao.className = "ncf-acao-descricao-row"; const tdDescricao = document.createElement("td"); tdDescricao.colSpan = 11; tdDescricao.className = "ncf-acao-descricao-cell"; tdDescricao.innerHTML = `Ação: ${pidNcfEscapeHtml(acaoItem.acao_texto) || "—"}`; trDescricao.appendChild(tdDescricao); acoesBody.appendChild(tr); acoesBody.appendChild(trDescricao); acoesBody.appendChild(trDetalhe); }); } async function alternarTratativaAcao(id, estaTratado) { const acao = estaTratado ? "reabrir" : "marcar-tratado"; try { await pidApiRequest(`/nao-conformidades/acoes/${id}/${acao}/`, { method: "POST" }); await carregarAcoes(); } catch (e) { await pidAlert(e.message || "Não foi possível atualizar o status."); } } // ---------------- Painel de detalhe de uma ação (lazy) — histórico ---------------- async function montarPainelAcao(painel, item) { const idAcomp = `ncf-acomp-${item.id}`; const finalizadaHtml = item.data_finalizacao ? `

Finalizada em: ${pidNcfFormatDate(item.data_finalizacao)}

` : ""; painel.innerHTML = `

Ação: ${pidNcfEscapeHtml(item.acao_texto) || "—"}

${finalizadaHtml}

Histórico de acompanhamentos

Carregando...

`; const container = painel.querySelector(`#${idAcomp}`); try { const detalhe = await pidApiRequest(`/nao-conformidades/acoes/${item.id}/`); const entradas = (detalhe.acompanhamentos || []).slice().sort((a, b) => b.data.localeCompare(a.data)); if (!entradas.length) { container.innerHTML = "

Nenhum acompanhamento registrado.

"; return; } container.innerHTML = ""; const lista = document.createElement("div"); lista.className = "ncf-acompanhamentos-list"; container.appendChild(lista); function renderizarEntrada(entrada) { const el = document.createElement("div"); el.className = "ncf-acompanhamento-item"; el.innerHTML = `
${pidNcfFormatDate(entrada.data)} ${pidNcfEscapeHtml(entrada.autor) || "—"} ${entrada.eh_prorrogacao ? 'Prorrogação' : ""}

`; el.querySelector("p").textContent = entrada.texto; lista.appendChild(el); } entradas.slice(0, 3).forEach(renderizarEntrada); if (entradas.length > 3) { const maisBtn = document.createElement("button"); maisBtn.type = "button"; maisBtn.className = "btn-outline ncf-mostrar-mais-btn"; maisBtn.textContent = `Mostrar mais (${entradas.length - 3})`; maisBtn.addEventListener("click", () => { entradas.slice(3).forEach(renderizarEntrada); maisBtn.remove(); }); container.appendChild(maisBtn); } } catch (e) { container.innerHTML = ``; } } // ---------------- Filtro de coluna (Tipo de Ocorrência / Vencimento) ---------------- // Só existe na tabela de Ações, client-side, sobre `acoesTodos` já // carregado — ver pidNcfFiltraColunaAcoes()/carregarAcoes() acima. function renderTipoOcorrenciaChecklist() { const lista = document.getElementById("ncf-col-filtro-tipo-lista"); const empty = document.getElementById("ncf-col-filtro-tipo-empty"); empty.hidden = tipoOcorrenciaOpcoesDisponiveis.length > 0; lista.innerHTML = tipoOcorrenciaOpcoesDisponiveis .map( (tipo) => ` ` ) .join(""); } const acoesTableEl = document.getElementById("ncf-table-acoes"); function pidNcfFecharPopoversColuna() { acoesTableEl.querySelectorAll(".ncf-col-filtro-popover").forEach((p) => (p.hidden = true)); acoesTableEl.querySelectorAll("[data-ncf-col-filtro]").forEach((b) => b.setAttribute("aria-expanded", "false")); // Também fecha o popover do Resumo (ver seção própria mais abaixo) — // reaproveitado aqui pra não duplicar a lógica de "só um popover aberto // por vez" em dois lugares. if (resumoPopover && !resumoPopover.hidden) { resumoPopover.hidden = true; resumoBtn.setAttribute("aria-expanded", "false"); } } acoesTableEl.addEventListener("click", (evento) => { const filtroBtn = evento.target.closest("[data-ncf-col-filtro]"); if (filtroBtn) { const popover = filtroBtn.nextElementSibling; // .ncf-col-filtro-popover const jaAberto = !popover.hidden; pidNcfFecharPopoversColuna(); if (!jaAberto) { // position:fixed calculado a partir do botão — escapa do // overflow:hidden de .pa-table-wrap sem precisar reabri-lo (ver // nao-conformidades.css). const rect = filtroBtn.getBoundingClientRect(); // Impede o popover (240px) de vazar pra fora da borda direita da // janela quando o botão está perto dela. const left = Math.min(rect.left, window.innerWidth - 256); popover.style.top = `${rect.bottom + 6}px`; popover.style.left = `${Math.max(left, 8)}px`; } popover.hidden = jaAberto; filtroBtn.setAttribute("aria-expanded", String(!jaAberto)); return; } const aplicarBtn = evento.target.closest("[data-ncf-col-filtro-aplicar]"); if (aplicarBtn) { const alvo = aplicarBtn.getAttribute("data-ncf-col-filtro-aplicar"); if (alvo === "tipo_ocorrencia") { const marcados = Array.from(document.querySelectorAll("#ncf-col-filtro-tipo-lista input:checked")).map( (cb) => cb.value ); tipoOcorrenciaFiltroSelecionados = new Set(marcados); } else if (alvo === "vencimento") { vencimentoFiltroDe = document.getElementById("ncf-col-filtro-vencimento-de").value; vencimentoFiltroAte = document.getElementById("ncf-col-filtro-vencimento-ate").value; } aplicarBtn.closest(".ncf-col-filtro-popover").hidden = true; renderAcoes(); return; } const limparBtn = evento.target.closest("[data-ncf-col-filtro-limpar]"); if (limparBtn) { const alvo = limparBtn.getAttribute("data-ncf-col-filtro-limpar"); if (alvo === "tipo_ocorrencia") { tipoOcorrenciaFiltroSelecionados = new Set(); renderTipoOcorrenciaChecklist(); } else if (alvo === "vencimento") { vencimentoFiltroDe = ""; vencimentoFiltroAte = ""; document.getElementById("ncf-col-filtro-vencimento-de").value = ""; document.getElementById("ncf-col-filtro-vencimento-ate").value = ""; } limparBtn.closest(".ncf-col-filtro-popover").hidden = true; renderAcoes(); } }); document.addEventListener("click", (evento) => { if (evento.target.closest(".ncf-th-filterable") || evento.target.closest(".ncf-resumo-wrap")) return; pidNcfFecharPopoversColuna(); }); document.addEventListener("keydown", (evento) => { if (evento.key !== "Escape") return; pidNcfFecharPopoversColuna(); }); // position:fixed não acompanha o scroll sozinho — mais simples fechar o // popover do que recalcular a posição a cada evento de scroll/resize. window.addEventListener("scroll", () => pidNcfFecharPopoversColuna(), true); window.addEventListener("resize", () => pidNcfFecharPopoversColuna()); // ---------------- Resumo copiável (Ações em aberto / Sem análise / Análise sem ação) ---------------- // Reflete exatamente o que está visível em cada uma das 3 tabelas da // Gestão no momento (depois de busca/chips/filtro de coluna/ordenação) — // ver os `*ItensVisiveis` atualizados no fim de cada render*() acima. const resumoBtn = document.getElementById("ncf-resumo-btn"); const resumoPopover = document.getElementById("ncf-resumo-popover"); const resumoPreview = document.getElementById("ncf-resumo-preview"); const resumoCount = document.getElementById("ncf-resumo-count"); const resumoCopiarBtn = document.getElementById("ncf-resumo-copiar-btn"); let resumoTextoAtual = ""; function pidNcfDescricaoLinhaAcao(acaoItem) { const data = pidNcfFormatDate(acaoItem.vencimento_efetivo); const codigo = `${acaoItem.ocorrencia_codigo}/${acaoItem.codigo}`; if (acaoItem.status_prazo === "vencida") return { texto: `${codigo} - Vencida em ${data}`, cor: "vencida" }; if (acaoItem.status_prazo === "vence_7_dias") return { texto: `${codigo} - Vencerá em ${data}`, cor: "vence7" }; if (acaoItem.status_prazo === "sem_vencimento") return { texto: `${codigo} - Sem vencimento definido`, cor: "normal" }; if (acaoItem.status_prazo === "finalizada") return { texto: `${codigo} - Finalizada em ${data}`, cor: "normal" }; return { texto: `${codigo} - Vencerá em ${data}`, cor: "normal" }; // vence_30_dias / no_prazo } function pidNcfDescricaoLinhaOcorrencia(oc) { return { texto: `nº ${oc.codigo} - emitida em ${pidNcfFormatDate(oc.data_emissao)}`, cor: "normal" }; } function montarBlocosResumo() { const blocos = []; if (acoesItensVisiveis.length) { blocos.push({ titulo: "Ações em aberto:", linhas: acoesItensVisiveis.map(pidNcfDescricaoLinhaAcao) }); } if (semAnaliseItensVisiveis.length) { blocos.push({ titulo: "Ocorrência sem análise:", linhas: semAnaliseItensVisiveis.map(pidNcfDescricaoLinhaOcorrencia) }); } if (analiseSemAcaoItensVisiveis.length) { blocos.push({ titulo: "Análise sem ação:", linhas: analiseSemAcaoItensVisiveis.map(pidNcfDescricaoLinhaOcorrencia) }); } return blocos; } function pidNcfResumoTextoPlano(blocos) { return blocos.map((bloco) => `${bloco.titulo}\n${bloco.linhas.map((l) => l.texto).join("\n")}`).join("\n\n"); } function pidNcfResumoHtml(blocos) { if (!blocos.length) return '

Nada pendente com os filtros atuais.

'; return blocos .map( (bloco) => `

${pidNcfEscapeHtml(bloco.titulo)}

${bloco.linhas .map((l) => `

${pidNcfEscapeHtml(l.texto)}

`) .join("")}` ) .join(""); } function atualizarResumoGestao() { const blocos = montarBlocosResumo(); resumoTextoAtual = pidNcfResumoTextoPlano(blocos); const totalLinhas = blocos.reduce((soma, bloco) => soma + bloco.linhas.length, 0); pidNcfAtualizarContador(resumoCount, totalLinhas); resumoPreview.innerHTML = pidNcfResumoHtml(blocos); } resumoBtn.addEventListener("click", () => { const jaAberto = !resumoPopover.hidden; pidNcfFecharPopoversColuna(); if (!jaAberto) { const rect = resumoBtn.getBoundingClientRect(); const left = Math.min(rect.right - 320, window.innerWidth - 328); resumoPopover.style.top = `${rect.bottom + 6}px`; resumoPopover.style.left = `${Math.max(left, 8)}px`; } resumoPopover.hidden = jaAberto; resumoBtn.setAttribute("aria-expanded", String(!jaAberto)); }); resumoCopiarBtn.addEventListener("click", async () => { try { await navigator.clipboard.writeText(resumoTextoAtual); const textoOriginal = resumoCopiarBtn.textContent; resumoCopiarBtn.textContent = "Copiado!"; setTimeout(() => { resumoCopiarBtn.textContent = textoOriginal; }, 1500); } catch (e) { await pidAlert("Não foi possível copiar o texto."); } }); // ---------------- Drill-down do Dashboard pra Gestão ---------------- async function irParaGestaoComBusca(valor) { ativarTab("gestao"); await garantirGestaoCarregada(); aplicarBusca(valor); } async function irParaSecaoGestao(sectionId) { ativarTab("gestao"); await garantirGestaoCarregada(); const el = document.getElementById(sectionId); if (el) el.scrollIntoView({ behavior: "smooth", block: "start" }); } async function irParaAcoesComPrazo(bucket) { // Define o filtro ANTES de carregar — se a Gestão ainda não tinha sido // aberta, o primeiro carregamento já sai com o filtro certo; se já // tinha, recarrega só as Ações (sem repetir Sem Análise/Análise sem Ação). prazoFiltrosSelecionados = new Set([bucket]); tratativaFiltroAtual = "todas"; // Zera os filtros de coluna (Tipo de Ocorrência/Vencimento) — é uma // navegação "leve a tela pra este estado", não deveria carregar filtro // residual de uma sessão anterior escondendo o que o card prometeu mostrar. tipoOcorrenciaFiltroSelecionados = new Set(); vencimentoFiltroDe = ""; vencimentoFiltroAte = ""; const vencimentoDeInput = document.getElementById("ncf-col-filtro-vencimento-de"); const vencimentoAteInput = document.getElementById("ncf-col-filtro-vencimento-ate"); if (vencimentoDeInput) vencimentoDeInput.value = ""; if (vencimentoAteInput) vencimentoAteInput.value = ""; atualizarChipsMultiAtivos(filtroPrazoEl, "ncfPrazo", prazoFiltrosSelecionados); atualizarChipsAtivos(filtroTratativaEl, "ncfTratativa", "todas"); ativarTab("gestao"); if (gestaoCarregada) { await carregarAcoes(); } else { await garantirGestaoCarregada(); } const el = document.getElementById("ncf-section-acoes"); if (el) el.scrollIntoView({ behavior: "smooth", block: "start" }); } // ---------------- Dashboard ---------------- const dashCards = document.getElementById("ncf-dash-cards"); const dashKpis = document.getElementById("ncf-dash-kpis"); const dashExecucaoCategoria = document.getElementById("ncf-dash-execucao-categoria"); const dashMotivosBody = document.getElementById("ncf-dash-motivos-body"); const dashClientesBody = document.getElementById("ncf-dash-clientes-body"); const dashColaboradoresBody = document.getElementById("ncf-dash-colaboradores-body"); const periodoChipsEl = document.getElementById("ncf-dash-periodo-chips"); let periodoAtual = "6"; periodoChipsEl.addEventListener("click", (evento) => { const btn = evento.target.closest("[data-ncf-periodo]"); if (!btn) return; periodoAtual = btn.dataset.ncfPeriodo; atualizarChipsAtivos(periodoChipsEl, "ncfPeriodo", periodoAtual); carregarDashboard(); }); function preencheRankingMotivos(tbody, itens) { tbody.innerHTML = ""; if (!itens.length) { tbody.innerHTML = 'Nenhum registro no período.'; return; } itens.forEach((item) => { const tr = document.createElement("tr"); tr.className = "ncf-clicavel"; tr.innerHTML = `${pidNcfEscapeHtml(item.causa)}${item.quantidade}`; tr.addEventListener("click", () => irParaGestaoComBusca(item.causa)); tbody.appendChild(tr); }); } function preencheRankingTipo(tbody, itens, chaveNome) { tbody.innerHTML = ""; if (!itens.length) { tbody.innerHTML = 'Nenhum registro no período.'; return; } itens.forEach((item) => { const tr = document.createElement("tr"); tr.className = "ncf-clicavel"; tr.innerHTML = ` ${pidNcfEscapeHtml(item[chaveNome])} ${item.nc} ${item.reclamacao} ${item.outros} ${item.total} `; tr.addEventListener("click", () => irParaGestaoComBusca(item[chaveNome])); tbody.appendChild(tr); }); } function pidNcfFormatDias(valor) { if (valor == null) return null; return valor.toString().replace(".", ","); } async function carregarDashboard() { const dados = await pidApiRequest(`/nao-conformidades/dashboard/?meses=${encodeURIComponent(periodoAtual)}`); // "Ações abertas" exclui quem já foi finalizado no Sigsistem — uma ação // concluída há anos não é mais um item de trabalho em aberto, mesmo que // o prazo dela (congelado) já tenha passado há muito tempo. const statusPorPrazo = dados.acoes_abertas_por_status || {}; const totalFinalizadas = statusPorPrazo.finalizada || 0; const totalAcoes = Object.entries(statusPorPrazo).reduce( (soma, [chave, valor]) => (chave === "finalizada" ? soma : soma + valor), 0 ); const cartoes = [ { rotulo: "Ocorrências abertas", valor: dados.ocorrencias_abertas, tom: "" }, { rotulo: "Ações abertas", valor: totalAcoes, tom: "" }, { rotulo: "Ações finalizadas", valor: totalFinalizadas, tom: "", acao: () => irParaAcoesComPrazo("finalizada"), }, { rotulo: "Ações vencidas", valor: statusPorPrazo.vencida || 0, tom: "ncf-card--danger", acao: () => irParaAcoesComPrazo("vencida"), }, { rotulo: "Vencem em ≤7 dias", valor: statusPorPrazo.vence_7_dias || 0, tom: "ncf-card--warning", acao: () => irParaAcoesComPrazo("vence_7_dias"), }, { rotulo: "Ocorrências sem análise", valor: dados.sem_analise, tom: "ncf-card--warning", acao: () => irParaSecaoGestao("ncf-section-sem-analise"), }, { rotulo: "Análises sem ação", valor: dados.analise_sem_acao, tom: "ncf-card--warning", acao: () => irParaSecaoGestao("ncf-section-analise-sem-acao"), }, { rotulo: "NCs sem ação corretiva", valor: dados.ncs_sem_acao_corretiva, tom: "ncf-card--danger" }, ]; dashCards.innerHTML = ""; cartoes.forEach((cartao) => { const el = document.createElement(cartao.acao ? "button" : "div"); el.className = cartao.acao ? `ncf-card ncf-card--clicavel ${cartao.tom}` : `ncf-card ${cartao.tom}`; if (cartao.acao) { el.type = "button"; el.addEventListener("click", cartao.acao); } el.innerHTML = `${cartao.rotulo}${cartao.valor}`; dashCards.appendChild(el); }); const tempos = dados.tempos_medios || {}; const kpis = [ { rotulo: "Preenchimento da análise (emissão → análise)", dias: pidNcfFormatDias(tempos.preenchimento_analise_dias), n: tempos.preenchimento_analise_n, }, { rotulo: "Abertura da ação (análise → ação aberta)", dias: pidNcfFormatDias(tempos.abertura_acao_dias), n: tempos.abertura_acao_n, }, { rotulo: "Execução da ação (ação aberta → ação finalizada)", dias: pidNcfFormatDias(tempos.execucao_acao_dias), n: tempos.execucao_acao_n, }, { rotulo: "Ciclo completo (emissão da ocorrência → ação finalizada)", dias: pidNcfFormatDias(tempos.ciclo_completo_dias), n: tempos.ciclo_completo_n, }, ]; dashKpis.innerHTML = ""; kpis.forEach((kpi) => { const el = document.createElement("div"); el.className = "ncf-kpi"; const valorHtml = kpi.dias == null ? 'Sem dados suficientes' : `${kpi.dias} diasn=${kpi.n}`; el.innerHTML = `${kpi.rotulo}${valorHtml}`; dashKpis.appendChild(el); }); const execucaoPorCategoria = tempos.execucao_por_categoria || []; if (!execucaoPorCategoria.length) { dashExecucaoCategoria.innerHTML = ""; } else { const linhaHtml = (item) => `${pidNcfEscapeHtml(item.categoria)}${pidNcfFormatDias(item.media_dias)} dias${item.n}`; // Duas colunas lado a lado em vez de uma lista comprida — metade das // categorias em cada uma (pedido do usuário: "4 de cada lado"). const metade = Math.ceil(execucaoPorCategoria.length / 2); const colunas = [execucaoPorCategoria.slice(0, metade), execucaoPorCategoria.slice(metade)]; const tabelaHtml = (itens) => itens.length ? `
${itens.map(linhaHtml).join("")}
CategoriaMédiaQtd.
` : ""; dashExecucaoCategoria.innerHTML = `

Execução por categoria de ação

${colunas.map(tabelaHtml).join("")}
`; } preencheRankingMotivos(dashMotivosBody, dados.motivos_abertura || []); preencheRankingTipo(dashClientesBody, dados.clientes_maior_incidencia || [], "cliente"); preencheRankingTipo(dashColaboradoresBody, dados.colaboradores_maior_incidencia || [], "colaborador"); } // ---------------- Importar ---------------- const importToggleBtn = document.getElementById("ncf-import-toggle-btn"); const importForm = document.getElementById("ncf-import-form"); const formOcorrencias = document.getElementById("ncf-form-ocorrencias"); const formOcorrenciasName = document.getElementById("ncf-form-ocorrencias-name"); const formAcoes = document.getElementById("ncf-form-acoes"); const formAcoesName = document.getElementById("ncf-form-acoes-name"); const formError = document.getElementById("ncf-form-error"); const formCancelBtn = document.getElementById("ncf-form-cancel-btn"); const formSubmitBtn = document.getElementById("ncf-form-submit-btn"); const importResultado = document.getElementById("ncf-import-resultado"); const importListBody = document.getElementById("ncf-import-list-body"); const importListEmpty = document.getElementById("ncf-import-list-empty"); importToggleBtn.addEventListener("click", () => { importForm.hidden = !importForm.hidden; }); formCancelBtn.addEventListener("click", () => { importForm.hidden = true; formError.textContent = ""; }); formOcorrencias.addEventListener("change", () => { formOcorrenciasName.textContent = formOcorrencias.files[0]?.name || "Nenhum arquivo selecionado"; }); formAcoes.addEventListener("change", () => { formAcoesName.textContent = formAcoes.files[0]?.name || "Nenhum arquivo selecionado"; }); formSubmitBtn.addEventListener("click", async () => { formError.textContent = ""; if (!formOcorrencias.files[0] || !formAcoes.files[0]) { formError.textContent = "Anexe os dois arquivos antes de processar."; return; } const dados = new FormData(); dados.append("arquivo_ocorrencias", formOcorrencias.files[0]); dados.append("arquivo_acoes", formAcoes.files[0]); formSubmitBtn.disabled = true; formSubmitBtn.textContent = "Processando..."; try { const importacao = await pidApiRequest("/nao-conformidades/importacoes/", { method: "POST", body: dados }); importForm.hidden = true; formOcorrencias.value = ""; formAcoes.value = ""; formOcorrenciasName.textContent = "Nenhum arquivo selecionado"; formAcoesName.textContent = "Nenhum arquivo selecionado"; mostrarResultadoImportacao(importacao); await carregarImportacoes(); gestaoCarregada = false; await garantirGestaoCarregada(); await carregarDashboard(); } catch (e) { formError.textContent = e.message || "Não foi possível processar a importação."; } finally { formSubmitBtn.disabled = false; formSubmitBtn.textContent = "Processar"; } }); function mostrarResultadoImportacao(importacao) { const resumo = importacao.resumo || {}; const avisos = importacao.avisos || []; const partes = [ `${resumo.ocorrencias_novas || 0} ocorrência(s) nova(s), ${resumo.ocorrencias_atualizadas || 0} atualizada(s)`, `${resumo.acoes_novas || 0} ação(ões) nova(s), ${resumo.acoes_atualizadas || 0} atualizada(s)`, `${resumo.acompanhamentos_novos || 0} acompanhamento(s) novo(s)`, `${(resumo.itens_reabertos || []).length} item(ns) reaberto(s) automaticamente`, ]; let texto = "Importação processada: " + partes.join(" · ") + "."; if (avisos.length) texto += " Avisos: " + avisos.join(" "); importResultado.textContent = texto; importResultado.hidden = false; } async function carregarImportacoes() { const itens = await pidApiRequest("/nao-conformidades/importacoes/"); importListBody.innerHTML = ""; importListEmpty.hidden = itens.length > 0; itens.forEach((importacao) => { const resumo = importacao.resumo || {}; const avisos = importacao.avisos || []; const tr = document.createElement("tr"); tr.innerHTML = ` ${pidNcfFormatDateTime(importacao.criado_em)} ${pidNcfEscapeHtml(importacao.criado_por_nome) || "—"} ${resumo.ocorrencias_novas || 0} / ${resumo.ocorrencias_atualizadas || 0} ${resumo.acoes_novas || 0} / ${resumo.acoes_atualizadas || 0} ${(resumo.itens_reabertos || []).length} ${avisos.length ? avisos.length + " aviso(s)" : "—"} `; const acaoTd = tr.lastElementChild; const excluirBtn = document.createElement("button"); excluirBtn.type = "button"; excluirBtn.className = "btn-danger-outline"; excluirBtn.textContent = "Excluir"; excluirBtn.addEventListener("click", async () => { const confirmado = await pidConfirm( "Excluir este registro de importação? As ocorrências/ações já importadas não são afetadas.", { perigoso: true } ); if (!confirmado) return; try { await pidApiRequest(`/nao-conformidades/importacoes/${importacao.id}/`, { method: "DELETE" }); await carregarImportacoes(); } catch (e) { await pidAlert(e.message || "Não foi possível excluir."); } }); acaoTd.appendChild(excluirBtn); importListBody.appendChild(tr); }); } // Carga inicial — a aba Dashboard já vem visível por padrão. await carregarDashboard(); });