diff --git a/portal_api/nao_conformidades/classificacao.py b/portal_api/nao_conformidades/classificacao.py index 41b996f..ec1ed66 100644 --- a/portal_api/nao_conformidades/classificacao.py +++ b/portal_api/nao_conformidades/classificacao.py @@ -17,6 +17,15 @@ STATUS_VENCE_30 = "vence_30_dias" STATUS_NO_PRAZO = "no_prazo" STATUS_SEM_VENCIMENTO = "sem_vencimento" +# Não é uma classificação por-ação real (status_prazo() nunca retorna isto) — +# é só um nome de bucket agregado usado por `_filtra_status_prazo()` +# (portal_api/views.py) e pelo chip "Em aberto" do frontend, equivalente a +# "qualquer bucket exceto finalizada". NUNCA adicionar em STATUS_PRAZO_LABELS: +# esse dict é iterado pra montar os contadores do dashboard, uma contagem por +# classificação mutuamente exclusiva de ação — "não finalizada" não é +# mutuamente exclusiva com vencida/vence_7_dias/etc, é a união delas. +STATUS_NAO_FINALIZADA = "nao_finalizada" + STATUS_PRAZO_LABELS = { STATUS_FINALIZADA: "Finalizada", STATUS_VENCIDA: "Vencida", diff --git a/portal_api/serializers.py b/portal_api/serializers.py index 8eb1410..7529ec6 100644 --- a/portal_api/serializers.py +++ b/portal_api/serializers.py @@ -2085,6 +2085,7 @@ class NCAcaoSerializer(serializers.ModelSerializer): # da Gestão e drill-down dos rankings do Dashboard) sem duplicar o dado. ocorrencia_clientes = serializers.CharField(source="ocorrencia.clientes_relacionados", read_only=True) ocorrencia_area = serializers.CharField(source="ocorrencia.area", read_only=True) + ocorrencia_tipo_ocorrencia = serializers.CharField(source="ocorrencia.tipo_ocorrencia", read_only=True) status_prazo = serializers.SerializerMethodField() status_prazo_label = serializers.SerializerMethodField() dias_para_vencer = serializers.SerializerMethodField() @@ -2098,6 +2099,7 @@ class NCAcaoSerializer(serializers.ModelSerializer): "ocorrencia_assunto", "ocorrencia_clientes", "ocorrencia_area", + "ocorrencia_tipo_ocorrencia", "codigo", "data_emissao", "tipo_acao", diff --git a/portal_api/views.py b/portal_api/views.py index 9d6c0fe..a12caf4 100644 --- a/portal_api/views.py +++ b/portal_api/views.py @@ -3020,6 +3020,8 @@ def _filtra_status_prazo(queryset: QuerySet[NCAcao], bucket: str, hoje: date) -> return queryset.filter(data_finalizacao__isnull=False) queryset = queryset.filter(data_finalizacao__isnull=True) + if bucket == nc_classificacao.STATUS_NAO_FINALIZADA: + return queryset if bucket == nc_classificacao.STATUS_VENCIDA: return queryset.filter(vencimento_efetivo__lt=hoje) if bucket == nc_classificacao.STATUS_VENCE_7: diff --git a/static/css/nao-conformidades.css b/static/css/nao-conformidades.css index cf64f37..0d72ef7 100644 --- a/static/css/nao-conformidades.css +++ b/static/css/nao-conformidades.css @@ -144,6 +144,118 @@ color: var(--accent-contrast); } +/* Ordenação por clique no cabeçalho — mesma linguagem visual de #ua-table + (perfis-acesso.css, th[data-sort]/.ua-sort-icon), duplicado aqui porque + essa folha não compartilha código com a de usuários. Escopado pela classe + compartilhada .ncf-table-sortable (as 3 tabelas da Gestão), não por ID + individual. */ +.ncf-table-sortable th[data-sort] { + cursor: pointer; + user-select: none; + white-space: nowrap; +} + +.ncf-sort-icon { + color: var(--text-muted); + font-size: 0.75rem; +} + +.ncf-table-sortable th[data-sort].is-sort-asc .ncf-sort-icon, +.ncf-table-sortable th[data-sort].is-sort-desc .ncf-sort-icon { + color: var(--accent); +} + +/* Filtro por coluna (só "Tipo de Ocorrência"/"Vencimento", tabela de Ações) — + mesmo espírito de .ind-info-wrap/.ind-info-popover (indicador-desempenho.css), + mas com uma diferença importante: aqui o popover é position:fixed (não + absolute), com top/left calculados em JS a partir do + getBoundingClientRect() do botão (ver pidNcfAbrirPopoverColuna() em + nao-conformidades.js). position:fixed escapa do overflow:hidden de + .pa-table-wrap (perfis-acesso.css, usado pra arredondar o canto da + tabela) sem precisar reabrir esse overflow — uma primeira versão fazia + isso com overflow:visible no wrapper, mas isso quebrava a contenção da + tabela (12 colunas já é bem larga) e estourava a largura da página. */ +.ncf-th-filterable { + position: relative; +} + +.ncf-th-sort-label { + display: inline-flex; + align-items: center; + gap: 4px; +} + +.ncf-col-filtro-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + margin-left: var(--space-2); + padding: 0; + border-radius: var(--radius-sm); + border: 1px solid var(--border-subtle); + color: var(--text-muted); + background: transparent; + cursor: pointer; + transition: color var(--transition-fast), border-color var(--transition-fast); +} + +.ncf-col-filtro-btn:hover, +.ncf-col-filtro-btn[aria-expanded="true"] { + color: var(--accent); + border-color: var(--accent); +} + +.ncf-col-filtro-popover { + position: fixed; + z-index: 30; + width: 240px; + padding: var(--space-3); + border-radius: var(--radius-md); + border: 1px solid var(--border-subtle); + background: var(--bg-surface-raised); + box-shadow: var(--shadow-lg); + animation: pidFadeSlideUp 140ms ease; + /* .pa-table th força uppercase/letter-spacing/tamanho pequeno no + cabeçalho — resetado aqui pra virar texto normal de novo. */ + font-size: 0.82rem; + font-weight: 400; + text-transform: none; + letter-spacing: normal; + line-height: 1.4; + color: var(--text-primary); + white-space: normal; + text-align: left; +} + +.ncf-col-filtro-popover .checklist-box { + max-height: 160px; +} + +.ncf-col-filtro-popover .modal-field { + margin-bottom: var(--space-2); +} + +.ncf-col-filtro-popover__actions { + display: flex; + justify-content: flex-end; + gap: var(--space-2); + margin-top: var(--space-3); +} + +.ncf-col-filtro-popover__actions .btn-outline, +.ncf-col-filtro-popover__actions .btn-solid { + padding: 4px 12px; + font-size: 0.78rem; +} + +/* Ação já finalizada no Sigsistem — não há nada pra Qualidade tratar, então + a coluna de ação mostra só um traço em vez do botão "Marcar como tratado". */ +.ncf-sem-acao-necessaria { + color: var(--text-muted); +} + /* Dashboard */ .ncf-dashboard-toolbar { display: flex; @@ -450,6 +562,32 @@ text-align: left; } +/* A linha principal de uma Ação não fecha com borda embaixo — quem fecha é + a linha de descrição logo abaixo (ver .ncf-acao-descricao-row), pra as + duas lerem como um bloco só, não duas linhas de tabela separadas. */ +.pa-table tr.ncf-acao-main-row td { + border-bottom: none; +} + +/* Linha full-width com a descrição da Ação, logo abaixo da linha principal + (não é mais uma coluna própria — não cabia na largura da tabela). Classe + própria com maior especificidade que .pa-table td:last-child + (text-align:right) pelo mesmo motivo de .ncf-detail-cell acima: como usa + colspan, é sempre "a última" célula da linha. */ +.pa-table tr.ncf-acao-descricao-row td.ncf-acao-descricao-cell { + padding-top: 0; + padding-bottom: var(--space-3); + text-align: left; + font-size: 0.82rem; + color: var(--text-secondary); +} + +.ncf-acao-descricao-label { + font-weight: 600; + color: var(--text-muted); + margin-right: 4px; +} + .ncf-detail-panel { font-size: 0.85rem; color: var(--text-secondary); diff --git a/static/js/nao-conformidades.js b/static/js/nao-conformidades.js index 08fd21b..5a40328 100644 --- a/static/js/nao-conformidades.js +++ b/static/js/nao-conformidades.js @@ -42,6 +42,68 @@ const PID_NCF_STATUS_PRAZO_CLASSES = { 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 @@ -153,14 +215,39 @@ document.addEventListener("DOMContentLoaded", async () => { let semAnaliseTodos = []; let analiseSemAcaoTodos = []; let acoesTodos = []; - let prazoFiltroAtual = "todas"; + // "nao_finalizada" (não "todas") é o padrão — a tela deve abrir só com o + // que ainda está em aberto (data_finalizacao vazia no Sigsistem), pedido + // explícito do usuário; "Todas" continua disponível como chip separado. + let prazoFiltroAtual = "nao_finalizada"; 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 = ""; + 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(); @@ -206,6 +293,20 @@ document.addEventListener("DOMContentLoaded", async () => { 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; @@ -217,20 +318,41 @@ document.addEventListener("DOMContentLoaded", async () => { 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() { - semAnaliseTodos = await pidApiRequest("/nao-conformidades/ocorrencias/?sem_analise=true"); + 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() { - const itens = filtrarPorBusca(semAnaliseTodos, [ + 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); pidNcfAtualizarContador(semAnaliseCount, itens.length); semAnaliseBody.innerHTML = ""; semAnaliseEmpty.hidden = itens.length > 0; @@ -272,20 +394,41 @@ document.addEventListener("DOMContentLoaded", async () => { 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() { - analiseSemAcaoTodos = await pidApiRequest("/nao-conformidades/ocorrencias/?analise_sem_acao=true"); + 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() { - const itens = filtrarPorBusca(analiseSemAcaoTodos, [ + 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); pidNcfAtualizarContador(analiseSemAcaoCount, itens.length); analiseSemAcaoBody.innerHTML = ""; analiseSemAcaoEmpty.hidden = itens.length > 0; @@ -381,6 +524,26 @@ document.addEventListener("DOMContentLoaded", async () => { 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 params = new URLSearchParams(); @@ -388,31 +551,65 @@ document.addEventListener("DOMContentLoaded", async () => { 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() { - const itens = filtrarPorBusca(acoesTodos, ["executor", "ocorrencia_clientes", "ocorrencia_codigo", "acao_texto"]); + 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); pidNcfAtualizarContador(acoesCount, itens.length); acoesBody.innerHTML = ""; acoesEmpty.hidden = itens.length > 0; itens.forEach((acaoItem) => { const tratado = acaoItem.status_tratativa === "tratado"; - const { btn, trDetalhe } = pidNcfCriarLinhaExpandivel(10, (painel) => montarPainelAcao(painel, acaoItem)); + // 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.acao_texto || "").slice(0, 90))} + ${pidNcfEscapeHtml(acaoItem.ocorrencia_tipo_ocorrencia) || "—"} ${pidNcfEscapeHtml(acaoItem.executor) || "—"} - ${pidNcfFormatDate(acaoItem.vencimento_efetivo)}` + ${pidNcfFormatDate(acaoItem.vencimento_efetivo)} + ${pidNcfFormatDate(acaoItem.ultimo_acompanhamento_em)}${ultimoAcompHtml}` ); const tdPrazo = document.createElement("td"); const badgePrazo = document.createElement("span"); @@ -426,15 +623,37 @@ document.addEventListener("DOMContentLoaded", async () => { 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", () => alternarTratativaAcao(acaoItem.id, tratado)); - tdAcao.appendChild(tratarBtn); + // 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); }); } @@ -506,6 +725,100 @@ document.addEventListener("DOMContentLoaded", async () => { } } + // ---------------- 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")); + } + + 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")) 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()); + // ---------------- Drill-down do Dashboard pra Gestão ---------------- async function irParaGestaoComBusca(valor) { ativarTab("gestao"); @@ -526,6 +839,16 @@ document.addEventListener("DOMContentLoaded", async () => { // tinha, recarrega só as Ações (sem repetir Sem Análise/Análise sem Ação). prazoFiltroAtual = 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 = ""; atualizarChipsAtivos(filtroPrazoEl, "ncfPrazo", bucket); atualizarChipsAtivos(filtroTratativaEl, "ncfTratativa", "todas"); ativarTab("gestao"); diff --git a/templates/nao-conformidades.html b/templates/nao-conformidades.html index 041de08..3ecbe39 100644 --- a/templates/nao-conformidades.html +++ b/templates/nao-conformidades.html @@ -409,17 +409,25 @@

Ocorrências abertas sem nenhuma análise registrada — cobrar o responsável indicado.

+
+ Tratativa: +
+ + + +
+
- +
- - - - - - + + + + + + @@ -435,17 +443,25 @@

Análise já registrada, mas nenhuma ação foi aberta ainda.

+
+ Tratativa: +
+ + + +
+
-
CódigoEmissãoÁreaEmissorIndicado para análiseTratativaCódigo ↕Emissão ↕Área ↕Emissor ↕Indicado para análise ↕Tratativa ↕
+
- - - - - - + + + + + + @@ -463,7 +479,8 @@
Prazo:
- + + @@ -481,18 +498,51 @@
-
CódigoEmissãoÁreaResponsável pela análiseDescrição da análiseTratativaCódigo ↕Emissão ↕Área ↕Responsável pela análise ↕Descrição da análise ↕Tratativa ↕
+
- - - - - - - - + + + + + + + + +
Ocorrência/AçãoÁreaTipoAçãoResponsávelVencimentoPrazoTratativaOcorrência/Ação ↕Área ↕Tipo ↕ + Tipo de Ocorrência ↕ + + + Responsável ↕ + Vencimento ↕ + + + Último Acompanhamento ↕Prazo ↕Tratativa ↕