This commit is contained in:
Gabriel 2026-09-09 08:18:17 -03:00
commit aa417661f0
6 changed files with 563 additions and 39 deletions

View File

@ -17,6 +17,15 @@ STATUS_VENCE_30 = "vence_30_dias"
STATUS_NO_PRAZO = "no_prazo" STATUS_NO_PRAZO = "no_prazo"
STATUS_SEM_VENCIMENTO = "sem_vencimento" 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_PRAZO_LABELS = {
STATUS_FINALIZADA: "Finalizada", STATUS_FINALIZADA: "Finalizada",
STATUS_VENCIDA: "Vencida", STATUS_VENCIDA: "Vencida",

View File

@ -2085,6 +2085,7 @@ class NCAcaoSerializer(serializers.ModelSerializer):
# da Gestão e drill-down dos rankings do Dashboard) sem duplicar o dado. # 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_clientes = serializers.CharField(source="ocorrencia.clientes_relacionados", read_only=True)
ocorrencia_area = serializers.CharField(source="ocorrencia.area", 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 = serializers.SerializerMethodField()
status_prazo_label = serializers.SerializerMethodField() status_prazo_label = serializers.SerializerMethodField()
dias_para_vencer = serializers.SerializerMethodField() dias_para_vencer = serializers.SerializerMethodField()
@ -2098,6 +2099,7 @@ class NCAcaoSerializer(serializers.ModelSerializer):
"ocorrencia_assunto", "ocorrencia_assunto",
"ocorrencia_clientes", "ocorrencia_clientes",
"ocorrencia_area", "ocorrencia_area",
"ocorrencia_tipo_ocorrencia",
"codigo", "codigo",
"data_emissao", "data_emissao",
"tipo_acao", "tipo_acao",

View File

@ -3020,6 +3020,8 @@ def _filtra_status_prazo(queryset: QuerySet[NCAcao], bucket: str, hoje: date) ->
return queryset.filter(data_finalizacao__isnull=False) return queryset.filter(data_finalizacao__isnull=False)
queryset = queryset.filter(data_finalizacao__isnull=True) queryset = queryset.filter(data_finalizacao__isnull=True)
if bucket == nc_classificacao.STATUS_NAO_FINALIZADA:
return queryset
if bucket == nc_classificacao.STATUS_VENCIDA: if bucket == nc_classificacao.STATUS_VENCIDA:
return queryset.filter(vencimento_efetivo__lt=hoje) return queryset.filter(vencimento_efetivo__lt=hoje)
if bucket == nc_classificacao.STATUS_VENCE_7: if bucket == nc_classificacao.STATUS_VENCE_7:

View File

@ -144,6 +144,118 @@
color: var(--accent-contrast); 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 */ /* Dashboard */
.ncf-dashboard-toolbar { .ncf-dashboard-toolbar {
display: flex; display: flex;
@ -450,6 +562,32 @@
text-align: left; 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 { .ncf-detail-panel {
font-size: 0.85rem; font-size: 0.85rem;
color: var(--text-secondary); color: var(--text-secondary);

View File

@ -42,6 +42,68 @@ const PID_NCF_STATUS_PRAZO_CLASSES = {
sem_vencimento: "status-pill status-pill--neutral", 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 <th data-sort> 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 /** Botão de expandir (chevron que gira) + linha de detalhe associada, mesmo
* padrão de `.cc-painel-fiscal__toggle` (custo-contratacao.js), adaptado pra * padrão de `.cc-painel-fiscal__toggle` (custo-contratacao.js), adaptado pra
* uma linha de tabela — `montarConteudo(painelEl)` só roda na primeira vez * uma linha de tabela — `montarConteudo(painelEl)` só roda na primeira vez
@ -153,14 +215,39 @@ document.addEventListener("DOMContentLoaded", async () => {
let semAnaliseTodos = []; let semAnaliseTodos = [];
let analiseSemAcaoTodos = []; let analiseSemAcaoTodos = [];
let acoesTodos = []; 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 tratativaFiltroAtual = "pendente";
let semAnaliseTratativaFiltroAtual = "pendente";
let analiseSemAcaoTratativaFiltroAtual = "pendente";
let buscaGestao = ""; 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 gestaoSearch = document.getElementById("ncf-gestao-search");
const gestaoSearchClearBtn = document.getElementById("ncf-gestao-search-clear-btn"); const gestaoSearchClearBtn = document.getElementById("ncf-gestao-search-clear-btn");
const filtroPrazoEl = document.getElementById("ncf-filtro-prazo"); const filtroPrazoEl = document.getElementById("ncf-filtro-prazo");
const filtroTratativaEl = document.getElementById("ncf-filtro-tratativa"); 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) { function filtrarPorBusca(itens, campos) {
const termo = buscaGestao.trim().toLowerCase(); const termo = buscaGestao.trim().toLowerCase();
@ -206,6 +293,20 @@ document.addEventListener("DOMContentLoaded", async () => {
atualizarChipsAtivos(filtroTratativaEl, "ncfTratativa", tratativaFiltroAtual); atualizarChipsAtivos(filtroTratativaEl, "ncfTratativa", tratativaFiltroAtual);
carregarAcoes(); 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() { async function garantirGestaoCarregada() {
if (gestaoCarregada) return; if (gestaoCarregada) return;
@ -217,20 +318,41 @@ document.addEventListener("DOMContentLoaded", async () => {
const semAnaliseBody = document.getElementById("ncf-sem-analise-body"); const semAnaliseBody = document.getElementById("ncf-sem-analise-body");
const semAnaliseEmpty = document.getElementById("ncf-sem-analise-empty"); const semAnaliseEmpty = document.getElementById("ncf-sem-analise-empty");
const semAnaliseCount = document.getElementById("ncf-sem-analise-count"); 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() { 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(); renderSemAnalise();
} }
function renderSemAnalise() { function renderSemAnalise() {
const itens = filtrarPorBusca(semAnaliseTodos, [ let itens = filtrarPorBusca(semAnaliseTodos, [
"indicado_analise", "indicado_analise",
"responsavel_analise", "responsavel_analise",
"clientes_relacionados", "clientes_relacionados",
"assunto", "assunto",
"emissor_relato", "emissor_relato",
]); ]);
itens = pidNcfAplicarSort(itens, semAnaliseSortKey, semAnaliseSortDir, PID_NCF_SEM_ANALISE_SORT_VALOR);
pidNcfAtualizarSortHeaders(semAnaliseSortHeaders, semAnaliseSortKey, semAnaliseSortDir);
pidNcfAtualizarContador(semAnaliseCount, itens.length); pidNcfAtualizarContador(semAnaliseCount, itens.length);
semAnaliseBody.innerHTML = ""; semAnaliseBody.innerHTML = "";
semAnaliseEmpty.hidden = itens.length > 0; semAnaliseEmpty.hidden = itens.length > 0;
@ -272,20 +394,41 @@ document.addEventListener("DOMContentLoaded", async () => {
const analiseSemAcaoBody = document.getElementById("ncf-analise-sem-acao-body"); const analiseSemAcaoBody = document.getElementById("ncf-analise-sem-acao-body");
const analiseSemAcaoEmpty = document.getElementById("ncf-analise-sem-acao-empty"); const analiseSemAcaoEmpty = document.getElementById("ncf-analise-sem-acao-empty");
const analiseSemAcaoCount = document.getElementById("ncf-analise-sem-acao-count"); 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() { 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(); renderAnaliseSemAcao();
} }
function renderAnaliseSemAcao() { function renderAnaliseSemAcao() {
const itens = filtrarPorBusca(analiseSemAcaoTodos, [ let itens = filtrarPorBusca(analiseSemAcaoTodos, [
"indicado_analise", "indicado_analise",
"responsavel_analise", "responsavel_analise",
"clientes_relacionados", "clientes_relacionados",
"assunto", "assunto",
"descricao_analise", "descricao_analise",
]); ]);
itens = pidNcfAplicarSort(itens, analiseSemAcaoSortKey, analiseSemAcaoSortDir, PID_NCF_ANALISE_SEM_ACAO_SORT_VALOR);
pidNcfAtualizarSortHeaders(analiseSemAcaoSortHeaders, analiseSemAcaoSortKey, analiseSemAcaoSortDir);
pidNcfAtualizarContador(analiseSemAcaoCount, itens.length); pidNcfAtualizarContador(analiseSemAcaoCount, itens.length);
analiseSemAcaoBody.innerHTML = ""; analiseSemAcaoBody.innerHTML = "";
analiseSemAcaoEmpty.hidden = itens.length > 0; analiseSemAcaoEmpty.hidden = itens.length > 0;
@ -381,6 +524,26 @@ document.addEventListener("DOMContentLoaded", async () => {
const acoesBody = document.getElementById("ncf-acoes-body"); const acoesBody = document.getElementById("ncf-acoes-body");
const acoesEmpty = document.getElementById("ncf-acoes-empty"); const acoesEmpty = document.getElementById("ncf-acoes-empty");
const acoesCount = document.getElementById("ncf-acoes-count"); 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() { async function carregarAcoes() {
const params = new URLSearchParams(); const params = new URLSearchParams();
@ -388,31 +551,65 @@ document.addEventListener("DOMContentLoaded", async () => {
if (tratativaFiltroAtual !== "todas") params.set("status_tratativa", tratativaFiltroAtual); if (tratativaFiltroAtual !== "todas") params.set("status_tratativa", tratativaFiltroAtual);
const query = params.toString(); const query = params.toString();
acoesTodos = await pidApiRequest(`/nao-conformidades/acoes/${query ? "?" + query : ""}`); 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(); 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() { 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); pidNcfAtualizarContador(acoesCount, itens.length);
acoesBody.innerHTML = ""; acoesBody.innerHTML = "";
acoesEmpty.hidden = itens.length > 0; acoesEmpty.hidden = itens.length > 0;
itens.forEach((acaoItem) => { itens.forEach((acaoItem) => {
const tratado = acaoItem.status_tratativa === "tratado"; 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"); const tr = document.createElement("tr");
tr.className = "ncf-acao-main-row";
const tdBtn = document.createElement("td"); const tdBtn = document.createElement("td");
tdBtn.className = "ncf-expand-col"; tdBtn.className = "ncf-expand-col";
tdBtn.appendChild(btn); tdBtn.appendChild(btn);
tr.appendChild(tdBtn); tr.appendChild(tdBtn);
const ultimoAcompHtml = acaoItem.ultimo_acompanhamento_eh_prorrogacao
? ` <span class="status-pill status-pill--warning">Prorrogação</span>`
: "";
tr.insertAdjacentHTML( tr.insertAdjacentHTML(
"beforeend", "beforeend",
`<td>${acaoItem.ocorrencia_codigo}/${acaoItem.codigo}</td> `<td>${acaoItem.ocorrencia_codigo}/${acaoItem.codigo}</td>
<td>${pidNcfEscapeHtml(acaoItem.ocorrencia_area) || "—"}</td> <td>${pidNcfEscapeHtml(acaoItem.ocorrencia_area) || "—"}</td>
<td>${pidNcfEscapeHtml(acaoItem.tipo_acao) || "—"}</td> <td>${pidNcfEscapeHtml(acaoItem.tipo_acao) || "—"}</td>
<td>${pidNcfEscapeHtml((acaoItem.acao_texto || "").slice(0, 90))}</td> <td>${pidNcfEscapeHtml(acaoItem.ocorrencia_tipo_ocorrencia) || "—"}</td>
<td>${pidNcfEscapeHtml(acaoItem.executor) || "—"}</td> <td>${pidNcfEscapeHtml(acaoItem.executor) || "—"}</td>
<td>${pidNcfFormatDate(acaoItem.vencimento_efetivo)}</td>` <td>${pidNcfFormatDate(acaoItem.vencimento_efetivo)}</td>
<td>${pidNcfFormatDate(acaoItem.ultimo_acompanhamento_em)}${ultimoAcompHtml}</td>`
); );
const tdPrazo = document.createElement("td"); const tdPrazo = document.createElement("td");
const badgePrazo = document.createElement("span"); const badgePrazo = document.createElement("span");
@ -426,15 +623,37 @@ document.addEventListener("DOMContentLoaded", async () => {
tr.appendChild(tdTratativa); tr.appendChild(tdTratativa);
const tdAcao = document.createElement("td"); const tdAcao = document.createElement("td");
const tratarBtn = document.createElement("button"); // Ação já finalizada no Sigsistem (data_finalizacao preenchida) — não
tratarBtn.type = "button"; // há nada pra Qualidade tratar aqui, então nem oferece o botão.
tratarBtn.className = tratado ? "btn-outline" : "btn-solid"; if (acaoItem.status_prazo === "finalizada") {
tratarBtn.textContent = tratado ? "Reabrir" : "Marcar como tratado"; const semAcaoEl = document.createElement("span");
tratarBtn.addEventListener("click", () => alternarTratativaAcao(acaoItem.id, tratado)); semAcaoEl.className = "ncf-sem-acao-necessaria";
tdAcao.appendChild(tratarBtn); 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); 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 = `<span class="ncf-acao-descricao-label">Ação:</span> ${pidNcfEscapeHtml(acaoItem.acao_texto) || "—"}`;
trDescricao.appendChild(tdDescricao);
acoesBody.appendChild(tr); acoesBody.appendChild(tr);
acoesBody.appendChild(trDescricao);
acoesBody.appendChild(trDetalhe); 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) => `
<label class="checklist-item">
<input type="checkbox" value="${pidNcfEscapeHtml(tipo)}" ${tipoOcorrenciaFiltroSelecionados.has(tipo) ? "checked" : ""} />
<span>${pidNcfEscapeHtml(tipo)}</span>
</label>`
)
.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 ---------------- // ---------------- Drill-down do Dashboard pra Gestão ----------------
async function irParaGestaoComBusca(valor) { async function irParaGestaoComBusca(valor) {
ativarTab("gestao"); 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). // tinha, recarrega só as Ações (sem repetir Sem Análise/Análise sem Ação).
prazoFiltroAtual = bucket; prazoFiltroAtual = bucket;
tratativaFiltroAtual = "todas"; 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(filtroPrazoEl, "ncfPrazo", bucket);
atualizarChipsAtivos(filtroTratativaEl, "ncfTratativa", "todas"); atualizarChipsAtivos(filtroTratativaEl, "ncfTratativa", "todas");
ativarTab("gestao"); ativarTab("gestao");

View File

@ -409,17 +409,25 @@
<span class="ncf-count" id="ncf-sem-analise-count"></span> <span class="ncf-count" id="ncf-sem-analise-count"></span>
</div> </div>
<p class="ncf-section__hint">Ocorrências abertas sem nenhuma análise registrada — cobrar o responsável indicado.</p> <p class="ncf-section__hint">Ocorrências abertas sem nenhuma análise registrada — cobrar o responsável indicado.</p>
<div class="ncf-filtro-grupo">
<span class="ncf-filtro-grupo__label">Tratativa:</span>
<div class="ncf-chip-row" id="ncf-filtro-tratativa-sem-analise">
<button type="button" class="ncf-chip" data-ncf-tratativa="todas">Todas</button>
<button type="button" class="ncf-chip is-active" data-ncf-tratativa="pendente">Pendentes</button>
<button type="button" class="ncf-chip" data-ncf-tratativa="tratado">Tratadas</button>
</div>
</div>
<div class="pa-table-wrap"> <div class="pa-table-wrap">
<table class="pa-table"> <table class="pa-table ncf-table-sortable" id="ncf-table-sem-analise">
<thead> <thead>
<tr> <tr>
<th></th> <th></th>
<th>Código</th> <th data-sort="codigo">Código <span class="ncf-sort-icon">↕</span></th>
<th>Emissão</th> <th data-sort="data_emissao">Emissão <span class="ncf-sort-icon">↕</span></th>
<th>Área</th> <th data-sort="area">Área <span class="ncf-sort-icon">↕</span></th>
<th>Emissor</th> <th data-sort="emissor_relato">Emissor <span class="ncf-sort-icon">↕</span></th>
<th>Indicado para análise</th> <th data-sort="indicado_analise">Indicado para análise <span class="ncf-sort-icon">↕</span></th>
<th>Tratativa</th> <th data-sort="status_tratativa">Tratativa <span class="ncf-sort-icon">↕</span></th>
<th></th> <th></th>
</tr> </tr>
</thead> </thead>
@ -435,17 +443,25 @@
<span class="ncf-count" id="ncf-analise-sem-acao-count"></span> <span class="ncf-count" id="ncf-analise-sem-acao-count"></span>
</div> </div>
<p class="ncf-section__hint">Análise já registrada, mas nenhuma ação foi aberta ainda.</p> <p class="ncf-section__hint">Análise já registrada, mas nenhuma ação foi aberta ainda.</p>
<div class="ncf-filtro-grupo">
<span class="ncf-filtro-grupo__label">Tratativa:</span>
<div class="ncf-chip-row" id="ncf-filtro-tratativa-analise-sem-acao">
<button type="button" class="ncf-chip" data-ncf-tratativa="todas">Todas</button>
<button type="button" class="ncf-chip is-active" data-ncf-tratativa="pendente">Pendentes</button>
<button type="button" class="ncf-chip" data-ncf-tratativa="tratado">Tratadas</button>
</div>
</div>
<div class="pa-table-wrap"> <div class="pa-table-wrap">
<table class="pa-table"> <table class="pa-table ncf-table-sortable" id="ncf-table-analise-sem-acao">
<thead> <thead>
<tr> <tr>
<th></th> <th></th>
<th>Código</th> <th data-sort="codigo">Código <span class="ncf-sort-icon">↕</span></th>
<th>Emissão</th> <th data-sort="data_emissao">Emissão <span class="ncf-sort-icon">↕</span></th>
<th>Área</th> <th data-sort="area">Área <span class="ncf-sort-icon">↕</span></th>
<th>Responsável pela análise</th> <th data-sort="responsavel_analise">Responsável pela análise <span class="ncf-sort-icon">↕</span></th>
<th>Descrição da análise</th> <th data-sort="descricao_analise">Descrição da análise <span class="ncf-sort-icon">↕</span></th>
<th>Tratativa</th> <th data-sort="status_tratativa">Tratativa <span class="ncf-sort-icon">↕</span></th>
<th></th> <th></th>
</tr> </tr>
</thead> </thead>
@ -463,7 +479,8 @@
<div class="ncf-filtro-grupo"> <div class="ncf-filtro-grupo">
<span class="ncf-filtro-grupo__label">Prazo:</span> <span class="ncf-filtro-grupo__label">Prazo:</span>
<div class="ncf-chip-row" id="ncf-filtro-prazo"> <div class="ncf-chip-row" id="ncf-filtro-prazo">
<button type="button" class="ncf-chip is-active" data-ncf-prazo="todas">Todas</button> <button type="button" class="ncf-chip" data-ncf-prazo="todas">Todas</button>
<button type="button" class="ncf-chip is-active" data-ncf-prazo="nao_finalizada">Em aberto</button>
<button type="button" class="ncf-chip" data-ncf-prazo="vencida">Vencidas</button> <button type="button" class="ncf-chip" data-ncf-prazo="vencida">Vencidas</button>
<button type="button" class="ncf-chip" data-ncf-prazo="vence_7_dias">Vence em 7 dias</button> <button type="button" class="ncf-chip" data-ncf-prazo="vence_7_dias">Vence em 7 dias</button>
<button type="button" class="ncf-chip" data-ncf-prazo="vence_30_dias">Vence em 30 dias</button> <button type="button" class="ncf-chip" data-ncf-prazo="vence_30_dias">Vence em 30 dias</button>
@ -481,18 +498,51 @@
</div> </div>
</div> </div>
<div class="pa-table-wrap"> <div class="pa-table-wrap">
<table class="pa-table"> <table class="pa-table ncf-table-sortable" id="ncf-table-acoes">
<thead> <thead>
<tr> <tr>
<th></th> <th></th>
<th>Ocorrência/Ação</th> <th data-sort="ocorrencia_codigo">Ocorrência/Ação <span class="ncf-sort-icon">↕</span></th>
<th>Área</th> <th data-sort="ocorrencia_area">Área <span class="ncf-sort-icon">↕</span></th>
<th>Tipo</th> <th data-sort="tipo_acao">Tipo <span class="ncf-sort-icon">↕</span></th>
<th>Ação</th> <th class="ncf-th-filterable" data-sort="ocorrencia_tipo_ocorrencia">
<th>Responsável</th> <span class="ncf-th-sort-label">Tipo de Ocorrência <span class="ncf-sort-icon">↕</span></span>
<th>Vencimento</th> <button type="button" class="ncf-col-filtro-btn" data-ncf-col-filtro="tipo_ocorrencia" aria-label="Filtrar por tipo de ocorrência" aria-expanded="false">
<th>Prazo</th> <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 5h16l-6 8v5l-4 2v-7z" stroke-linecap="round" stroke-linejoin="round"/></svg>
<th>Tratativa</th> </button>
<div class="ncf-col-filtro-popover" data-ncf-col-filtro-popover="tipo_ocorrencia" hidden>
<div class="checklist-box" id="ncf-col-filtro-tipo-lista"></div>
<p class="checklist-empty" id="ncf-col-filtro-tipo-empty" hidden>Nenhum tipo carregado.</p>
<div class="ncf-col-filtro-popover__actions">
<button type="button" class="btn-outline" data-ncf-col-filtro-limpar="tipo_ocorrencia">Limpar</button>
<button type="button" class="btn-solid" data-ncf-col-filtro-aplicar="tipo_ocorrencia">Aplicar</button>
</div>
</div>
</th>
<th data-sort="executor">Responsável <span class="ncf-sort-icon">↕</span></th>
<th class="ncf-th-filterable" data-sort="vencimento_efetivo">
<span class="ncf-th-sort-label">Vencimento <span class="ncf-sort-icon">↕</span></span>
<button type="button" class="ncf-col-filtro-btn" data-ncf-col-filtro="vencimento" aria-label="Filtrar por período de vencimento" aria-expanded="false">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 5h16l-6 8v5l-4 2v-7z" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
<div class="ncf-col-filtro-popover" data-ncf-col-filtro-popover="vencimento" hidden>
<div class="modal-field">
<label for="ncf-col-filtro-vencimento-de">De</label>
<input type="date" id="ncf-col-filtro-vencimento-de" />
</div>
<div class="modal-field">
<label for="ncf-col-filtro-vencimento-ate">Até</label>
<input type="date" id="ncf-col-filtro-vencimento-ate" />
</div>
<div class="ncf-col-filtro-popover__actions">
<button type="button" class="btn-outline" data-ncf-col-filtro-limpar="vencimento">Limpar</button>
<button type="button" class="btn-solid" data-ncf-col-filtro-aplicar="vencimento">Aplicar</button>
</div>
</div>
</th>
<th data-sort="ultimo_acompanhamento_em">Último Acompanhamento <span class="ncf-sort-icon">↕</span></th>
<th data-sort="status_prazo">Prazo <span class="ncf-sort-icon">↕</span></th>
<th data-sort="status_tratativa">Tratativa <span class="ncf-sort-icon">↕</span></th>
<th></th> <th></th>
</tr> </tr>
</thead> </thead>