/**
* 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" });
}
// Delegado ao `pidEscapeHtml` (api.js), que também escapa aspas (a versão
// antiga por `textContent` não escapava, e a função é usada em `value="..."`).
function pidNcfEscapeHtml(texto) {
return pidEscapeHtml(texto);
}
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). `aberto` já cria a linha expandida (reabre o
* que estava aberto antes de um redesenho) e `aoAlternar(aberto)` avisa quem
* guarda esse estado. */
function pidNcfCriarLinhaExpandivel(colspan, montarConteudo, { aberto: abertoInicial = false, aoAlternar } = {}) {
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);
async function abrirOuFechar(abrir) {
btn.setAttribute("aria-expanded", String(abrir));
trDetalhe.hidden = !abrir;
if (abrir && !painel.dataset.carregado) {
painel.dataset.carregado = "1";
await montarConteudo(painel);
}
}
btn.addEventListener("click", async () => {
const abrir = btn.getAttribute("aria-expanded") !== "true";
if (aoAlternar) aoAlternar(abrir);
await abrirOuFechar(abrir);
});
if (abertoInicial) abrirOuFechar(true);
return { btn, trDetalhe };
}
const PID_NCF_MSG_ERRO_CARGA = "Não foi possível carregar. Recarregue a página; se persistir, contate a Inovação.";
// Dias médios do Dashboard (o backend arredonda pra 1 casa) em pt-BR.
const PID_NCF_DIAS = new Intl.NumberFormat("pt-BR", { maximumFractionDigits: 1 });
/** Trava o botão "Marcar como tratado"/"Reabrir" até a resposta (sem envio
* duplo); a lista é redesenhada depois, então só restaura se o botão
* ainda estiver na tela. */
async function pidNcfComBotaoOcupado(btn, textoOcupado, acao) {
if (btn.disabled) return;
const textoOriginal = btn.textContent;
btn.disabled = true;
btn.textContent = textoOcupado;
try {
await acao();
} finally {
if (btn.isConnected) {
btn.disabled = false;
btn.textContent = textoOriginal;
}
}
}
/** Guarda os ids das linhas abertas de uma tabela, pra reabri-las depois de
* um redesenho (busca, ordenação, marcar como tratado). */
function pidNcfOpcoesExpansao(conjunto, id) {
return {
aberto: conjunto.has(id),
aoAlternar: (aberto) => {
if (aberto) conjunto.add(id);
else conjunto.delete(id);
},
};
}
/** Texto do estado vazio de uma lista: a mensagem de "nenhum item" original
* (guardada na primeira chamada) ou a de erro de carga, que nunca pode
* parecer lista vazia. */
function pidNcfEstadoVazio(emptyEl, erroCarga, temItens) {
if (emptyEl.dataset.textoVazio == null) emptyEl.dataset.textoVazio = emptyEl.textContent;
emptyEl.textContent = erroCarga ? PID_NCF_MSG_ERRO_CARGA : emptyEl.dataset.textoVazio;
emptyEl.classList.toggle("ncf-empty--erro", erroCarga);
emptyEl.hidden = temItens && !erroCarga;
}
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();
// Só marca como carregado depois do sucesso: se falhar, abrir a aba de
// novo tenta outra vez (antes ficava vazia até recarregar a página).
if (nome === "importar" && !importarCarregado) {
importarCarregado = 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 = "";
// Contador por lista: cada carga leva um número e só a mais recente
// escreve na tela. Cliques rápidos nos chips disparam várias cargas e,
// sem isso, a resposta antiga que chegasse por último vencia.
let seqSemAnalise = 0;
let seqAnaliseSemAcao = 0;
let seqAcoes = 0;
let seqDashboard = 0;
// Erro de carga por lista: mostrado no lugar da mensagem de lista vazia.
let erroCargaSemAnalise = false;
let erroCargaAnaliseSemAcao = false;
let erroCargaAcoes = false;
// Linhas abertas de cada tabela (ids), reabertas a cada redesenho.
const expandidosSemAnalise = new Set();
const expandidosAnaliseSemAcao = new Set();
const expandidosAcoes = new Set();
// 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();
}
let buscaTimer = null;
function aplicarBusca(valor) {
clearTimeout(buscaTimer);
buscaGestao = valor;
gestaoSearch.value = valor;
gestaoSearchClearBtn.hidden = !valor;
renderTudoGestao();
}
// Digitação redesenha as 3 tabelas só depois de ~200 ms sem tecla nova
// (antes era a cada tecla). Limpar e o drill-down aplicam na hora.
gestaoSearch.addEventListener("input", () => {
gestaoSearchClearBtn.hidden = !gestaoSearch.value;
clearTimeout(buscaTimer);
buscaTimer = setTimeout(() => aplicarBusca(gestaoSearch.value), 200);
});
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();
});
// As cargas abaixo (carregarSemAnalise/carregarAnaliseSemAcao/carregarAcoes)
// nunca rejeitam: tratam o próprio erro na tela e devolvem true/false. Por
// isso os chips podem chamá-las sem await. `gestaoCarregada` só vira true
// se as três deram certo; uma carga já em andamento é reaproveitada.
let gestaoCarregandoPromise = null;
async function garantirGestaoCarregada() {
if (gestaoCarregada) return true;
if (!gestaoCarregandoPromise) {
gestaoCarregandoPromise = Promise.all([carregarSemAnalise(), carregarAnaliseSemAcao(), carregarAcoes()])
.then((resultados) => {
gestaoCarregada = resultados.every(Boolean);
return gestaoCarregada;
})
.finally(() => {
gestaoCarregandoPromise = null;
});
}
return gestaoCarregandoPromise;
}
// ---------------- 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 seq = ++seqSemAnalise;
const params = new URLSearchParams({ sem_analise: "true" });
if (semAnaliseTratativaFiltroAtual !== "todas") params.set("status_tratativa", semAnaliseTratativaFiltroAtual);
let itens;
try {
itens = await pidApiRequest(`/nao-conformidades/ocorrencias/?${params.toString()}`);
} catch (e) {
if (seq !== seqSemAnalise) return true; // carga mais nova já assumiu
semAnaliseTodos = [];
erroCargaSemAnalise = true;
renderSemAnalise();
return false;
}
if (seq !== seqSemAnalise) return true;
semAnaliseTodos = itens;
erroCargaSemAnalise = false;
renderSemAnalise();
return true;
}
function renderSemAnalise() {
let itens = filtrarPorBusca(semAnaliseTodos, [
"indicado_analise",
"responsavel_analise",
"clientes_relacionados",
"assunto",
"emissor_relato",
"tipos_causa",
]);
itens = pidNcfAplicarSort(itens, semAnaliseSortKey, semAnaliseSortDir, PID_NCF_SEM_ANALISE_SORT_VALOR);
pidNcfAtualizarSortHeaders(semAnaliseSortHeaders, semAnaliseSortKey, semAnaliseSortDir);
semAnaliseItensVisiveis = itens;
atualizarResumoGestao();
pidNcfAtualizarContador(semAnaliseCount, itens.length);
semAnaliseBody.innerHTML = "";
pidNcfEstadoVazio(semAnaliseEmpty, erroCargaSemAnalise, itens.length > 0);
itens.forEach((oc) => {
const tratado = oc.status_tratativa === "tratado";
const { btn, trDetalhe } = pidNcfCriarLinhaExpandivel(
8,
(painel) => montarPainelOcorrencia(painel, oc),
pidNcfOpcoesExpansao(expandidosSemAnalise, oc.id)
);
const tr = document.createElement("tr");
const tdBtn = document.createElement("td");
tdBtn.className = "ncf-expand-col";
tdBtn.appendChild(btn);
tr.appendChild(tdBtn);
tr.insertAdjacentHTML(
"beforeend",
`
${acoesHtml}
`;
} catch (e) {
// Libera a próxima abertura pra tentar de novo (o painel é lazy).
delete painel.dataset.carregado;
extra.innerHTML = `
${pidNcfEscapeHtml(
`${(e.message || "Não foi possível carregar os detalhes").replace(/\.?$/, ".")} Feche e abra a linha de novo; se persistir, recarregue a página.`
)}
`;
}
}
// ---------------- 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() {
const seq = ++seqAcoes;
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 = [];
erroCargaAcoes = false;
tipoOcorrenciaOpcoesDisponiveis = [];
renderTipoOcorrenciaChecklist();
renderAcoes();
return true;
}
const params = new URLSearchParams();
prazoFiltrosSelecionados.forEach((bucket) => params.append("status_prazo", bucket));
if (tratativaFiltroAtual !== "todas") params.set("status_tratativa", tratativaFiltroAtual);
const query = params.toString();
let itens;
try {
itens = await pidApiRequest(`/nao-conformidades/acoes/${query ? "?" + query : ""}`);
} catch (e) {
if (seq !== seqAcoes) return true; // carga mais nova já assumiu
acoesTodos = [];
erroCargaAcoes = true;
renderAcoes();
return false;
}
if (seq !== seqAcoes) return true;
acoesTodos = itens;
erroCargaAcoes = false;
// 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();
return true;
}
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", "ocorrencia_tipos_causa"]);
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 = "";
pidNcfEstadoVazio(acoesEmpty, erroCargaAcoes, 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),
pidNcfOpcoesExpansao(expandidosAcoes, acaoItem.id)
);
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",
`
`;
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) {
// Libera a próxima abertura pra tentar de novo (o painel é lazy).
delete painel.dataset.carregado;
container.innerHTML = `
${pidNcfEscapeHtml(
`${(e.message || "Não foi possível carregar o histórico").replace(/\.?$/, ".")} Feche e abra a linha de novo; se persistir, recarregue a página.`
)}
`;
}
}
// ---------------- 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.
// A rolagem dentro do próprio popover (lista de tipos, Resumo longo) não
// fecha: antes fechava e deixava o fim da lista inalcançável.
window.addEventListener(
"scroll",
(evento) => {
const alvo = evento.target;
if (alvo instanceof Element && alvo.closest(".ncf-col-filtro-popover, .ncf-resumo-popover")) return;
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}`;
// finalizada: a data é a da finalização, não a do vencimento
if (acaoItem.status_prazo === "finalizada") {
return { texto: `${codigo} - Finalizada em ${pidNcfFormatDate(acaoItem.data_finalizacao)}`, cor: "normal" };
}
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" };
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));
});
function pidNcfCopiarTextoFallback(texto) {
const textarea = document.createElement("textarea");
textarea.value = texto;
textarea.style.position = "fixed";
textarea.style.top = "-1000px";
textarea.style.left = "-1000px";
document.body.appendChild(textarea);
textarea.focus();
textarea.select();
let ok = false;
try {
ok = document.execCommand("copy");
} catch (e) {
ok = false;
}
document.body.removeChild(textarea);
return ok;
}
async function pidNcfCopiarTexto(texto) {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(texto);
return true;
}
return pidNcfCopiarTextoFallback(texto);
}
resumoCopiarBtn.addEventListener("click", async () => {
try {
const copiado = await pidNcfCopiarTexto(resumoTextoAtual);
if (!copiado) throw new Error("copy fallback failed");
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 = '
`;
tr.addEventListener("click", () => irParaGestaoComBusca(item[chaveNome]));
tbody.appendChild(tr);
});
}
function pidNcfFormatDias(valor) {
if (valor == null) return null;
return PID_NCF_DIAS.format(Number(valor));
}
// Erro de carga do Dashboard: mensagem no lugar dos cards e dos rankings,
// nunca "Nenhum registro no período" (que daria a entender zero itens).
function mostrarErroDashboard() {
dashCards.innerHTML = `
`;
dashMotivosBody.innerHTML = linhaErro(2);
dashClientesBody.innerHTML = linhaErro(5);
dashColaboradoresBody.innerHTML = linhaErro(5);
}
// Nunca rejeita: com o contador, só a carga mais recente (último chip de
// período clicado) escreve na tela; erro vira mensagem. Devolve true/false.
async function carregarDashboard() {
const seq = ++seqDashboard;
let dados;
try {
dados = await pidApiRequest(`/nao-conformidades/dashboard/?meses=${encodeURIComponent(periodoAtual)}`);
} catch (e) {
if (seq === seqDashboard) mostrarErroDashboard();
return seq !== seqDashboard;
}
if (seq !== seqDashboard) return true;
renderDashboard(dados);
return true;
}
function renderDashboard(dados) {
// "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
? `