This commit is contained in:
Gabriel 2026-09-09 13:31:35 -03:00
commit 0f9358079e
14 changed files with 126925 additions and 317 deletions

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,23 @@
# Generated by Django 6.0.7 on 2026-09-08 19:12
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('portal_api', '0054_importacaoplanosaudedepaula_and_more'),
]
operations = [
migrations.AddField(
model_name='ncocorrencia',
name='data_finalizacao',
field=models.DateField(blank=True, null=True),
),
migrations.AddField(
model_name='ncocorrencia',
name='fase',
field=models.CharField(blank=True, max_length=100, verbose_name='Fase (Sigsistem)'),
),
]

View File

@ -1824,6 +1824,16 @@ class NCOcorrencia(models.Model):
# `acoes.count()` porque uma ocorrência pode ter linhas mistas.
analise_sem_acao_detectada = models.BooleanField(default=False)
# Só relevantes/preenchidos quando a ocorrência não tem nenhuma ação
# (linha do export sem "Código da Ação") e o Sigsistem fecha a própria
# ocorrência direto — ex.: tipo "Elogios de Clientes", que não exige
# análise/ação nenhuma. Vêm das MESMAS colunas "Data de Finalização"/
# "Fase" que, numa linha COM ação, descrevem a ação (ver
# NCAcao.data_finalizacao/fase) — o Sigsistem reaproveita as colunas
# conforme o que a linha representa; ver leiaute_ocorrencias.py.
data_finalizacao = models.DateField(null=True, blank=True)
fase = models.CharField("Fase (Sigsistem)", max_length=100, blank=True)
status_tratativa = models.CharField(
"Status de tratativa", max_length=10, choices=STATUS_CHOICES, default=STATUS_PENDENTE
)

View File

@ -35,6 +35,11 @@ STATUS_PRAZO_LABELS = {
STATUS_SEM_VENCIMENTO: "Sem vencimento",
}
# Valor de "Fase" (coluna do Sigsistem, texto livre) que indica que o item
# está fechado — usado tanto pra NCAcao.fase quanto pra NCOcorrencia.fase
# (ocorrência fechada sem precisar de ação, ex.: "Elogios de Clientes").
FASE_FINALIZADA = "Finalizada"
CATEGORIA_CORRETIVA = "Ação Corretiva"
CATEGORIA_CORRECAO = "Correção"
CATEGORIA_OUTROS = "Outros"

View File

@ -179,6 +179,17 @@ def parse_ocorrencias(caminho: str) -> tuple[list[OcorrenciaExtraida], list[str]
# preenchida, é o caso "Análise sem Ação" (regra #8 da skill).
if _texto(campo(linha, "Descrição da Análise da Ocorrência")):
ocorrencia.tem_linha_analise_sem_acao = True
# Sem ação, "Data de Finalização"/"Fase" descrevem o
# fechamento da própria ocorrência (não de uma ação) — ex.:
# "Elogios de Clientes", que o Sigsistem finaliza direto sem
# exigir análise/ação nenhuma (bug real: antes disso, essas
# ocorrências ficavam "Sem Análise" pra sempre no Portal).
data_fin = _parse_data(campo(linha, "Data de Finalização"))
if data_fin:
ocorrencia.data_finalizacao = data_fin
fase_linha = _texto(campo(linha, "Fase"))
if fase_linha:
ocorrencia.fase = fase_linha
continue
ocorrencia.acoes.append(

View File

@ -76,6 +76,11 @@ class OcorrenciaExtraida:
# original) — checado por linha durante o parsing, não pelo agregado
# `descricao_analise` (que só reflete a primeira linha da ocorrência).
tem_linha_analise_sem_acao: bool = False
# Vêm de uma linha SEM ação onde o Sigsistem fecha a própria ocorrência
# direto (ex.: "Elogios de Clientes") — ver NCOcorrencia.data_finalizacao/
# fase em models.py pro porquê de reaproveitar essas colunas.
data_finalizacao: date | None = None
fase: str = ""
@property
def sem_analise(self) -> bool:

View File

@ -2654,6 +2654,8 @@ def _campos_nc_ocorrencia(ocorrencia_extraida: Any) -> dict[str, Any]:
"responsavel_analise": ocorrencia_extraida.responsavel_analise,
"data_analise": ocorrencia_extraida.data_analise,
"analise_sem_acao_detectada": ocorrencia_extraida.tem_linha_analise_sem_acao,
"data_finalizacao": ocorrencia_extraida.data_finalizacao,
"fase": ocorrencia_extraida.fase,
}
@ -2976,9 +2978,34 @@ class NCOcorrenciaViewSet(viewsets.ModelViewSet):
if params.get("status_tratativa"):
queryset = queryset.filter(status_tratativa=params["status_tratativa"])
if params.get("sem_analise") == "true":
queryset = queryset.filter(descricao_analise="")
# Exclui ocorrência que o Sigsistem já fechou sem exigir análise
# (ex.: "Elogios de Clientes") — bug real: sem isso, essas
# ficavam "Sem Análise" pra sempre, exigindo "Marcar como
# tratado" manual pra algo que já está resolvido no Sigsistem.
# Duas formas de "já fechada" (a mesma ocorrência real já
# apareceu nas duas): (a) não tem nenhuma ação — nesse caso quem
# carrega "Fase" é a própria ocorrência (ver
# nao_conformidades/leiaute_ocorrencias.py); (b) tem ação(ões), e
# TODAS elas já têm `data_finalizacao` preenchida — nesse caso é
# a ação que carrega a finalização, não a ocorrência.
queryset = (
queryset.filter(descricao_analise="")
.annotate(
total_acoes=Count("acoes", distinct=True),
acoes_abertas=Count("acoes", filter=Q(acoes__data_finalizacao__isnull=True), distinct=True),
)
.exclude(Q(fase=nc_classificacao.FASE_FINALIZADA) | Q(total_acoes__gt=0, acoes_abertas=0))
)
if params.get("analise_sem_acao") == "true":
queryset = queryset.filter(analise_sem_acao_detectada=True)
# Exclui ocorrência já finalizada no Sigsistem sem nunca ter tido
# uma ação (decisão explícita do usuário, mesmo sabendo que boa
# parte são NC/Reclamação de Cliente reais sem Ação Corretiva
# formal — confia que o indicador "NCs sem ação corretiva" do
# dashboard, que não filtra por finalizada, continua cobrindo
# esses casos pra quem olha o Dashboard).
queryset = queryset.filter(analise_sem_acao_detectada=True).exclude(
fase=nc_classificacao.FASE_FINALIZADA
)
if params.get("search"):
termo = params["search"]
queryset = queryset.filter(Q(assunto__icontains=termo) | Q(codigo__icontains=termo))
@ -3009,10 +3036,13 @@ class NCOcorrenciaViewSet(viewsets.ModelViewSet):
return Response(NCOcorrenciaSerializer(ocorrencia).data)
def _filtra_status_prazo(queryset: QuerySet[NCAcao], bucket: str, hoje: date) -> QuerySet[NCAcao]:
"""Replica os mesmos cortes de `nao_conformidades.classificacao.status_prazo()`
como filtros de data sobre `vencimento_efetivo`, pra poder filtrar no banco
(o cálculo em si roda em Python, não dá pra usar direto num `.filter()`).
def _status_prazo_q(bucket: str, hoje: date) -> Q:
"""Devolve a condição de UM bucket como `Q`, pra poder combinar vários
buckets com OR (`_filtra_status_prazo` abaixo) — o frontend permite
marcar mais de um ao mesmo tempo (ex.: "Vencidas" + "Vence em 7 dias").
Replica os mesmos cortes de `nao_conformidades.classificacao.status_prazo()`
como filtros de data sobre `vencimento_efetivo` (o cálculo em si roda em
Python, não dá pra usar direto num `.filter()`).
Todo bucket de prazo (vencida/vence em X dias/no prazo/sem vencimento)
exclui ações já finalizadas — bug real corrigido: um export com
@ -3021,26 +3051,35 @@ def _filtra_status_prazo(queryset: QuerySet[NCAcao], bucket: str, hoje: date) ->
esmagadora maioria aparecer como "vencida" sem estar realmente em
aberto."""
if bucket == nc_classificacao.STATUS_FINALIZADA:
return queryset.filter(data_finalizacao__isnull=False)
return Q(data_finalizacao__isnull=False)
queryset = queryset.filter(data_finalizacao__isnull=True)
aberta = Q(data_finalizacao__isnull=True)
if bucket == nc_classificacao.STATUS_NAO_FINALIZADA:
return queryset
return aberta
if bucket == nc_classificacao.STATUS_VENCIDA:
return queryset.filter(vencimento_efetivo__lt=hoje)
return aberta & Q(vencimento_efetivo__lt=hoje)
if bucket == nc_classificacao.STATUS_VENCE_7:
return queryset.filter(vencimento_efetivo__gte=hoje, vencimento_efetivo__lte=hoje + timedelta(days=7))
return aberta & Q(vencimento_efetivo__gte=hoje, vencimento_efetivo__lte=hoje + timedelta(days=7))
if bucket == nc_classificacao.STATUS_VENCE_30:
return queryset.filter(
return aberta & Q(
vencimento_efetivo__gt=hoje + timedelta(days=7), vencimento_efetivo__lte=hoje + timedelta(days=30)
)
if bucket == nc_classificacao.STATUS_NO_PRAZO:
return queryset.filter(vencimento_efetivo__gt=hoje + timedelta(days=30))
return aberta & Q(vencimento_efetivo__gt=hoje + timedelta(days=30))
if bucket == nc_classificacao.STATUS_SEM_VENCIMENTO:
return queryset.filter(vencimento_efetivo__isnull=True)
return aberta & Q(vencimento_efetivo__isnull=True)
raise ValidationError({"status_prazo": "Valor inválido."})
def _filtra_status_prazo(queryset: QuerySet[NCAcao], buckets: list[str], hoje: date) -> QuerySet[NCAcao]:
"""Combina um ou mais buckets de prazo com OR (filtro múltiplo do
frontend — ver `?status_prazo=` repetido em `NCAcaoViewSet.get_queryset`)."""
combinado = Q()
for bucket in buckets:
combinado |= _status_prazo_q(bucket, hoje)
return queryset.filter(combinado)
class NCAcaoViewSet(viewsets.ModelViewSet):
"""Ações persistidas — só leitura + tratativa (mesmo espírito de
NCOcorrenciaViewSet)."""
@ -3061,9 +3100,9 @@ class NCAcaoViewSet(viewsets.ModelViewSet):
params = self.request.query_params
if params.get("status_tratativa"):
queryset = queryset.filter(status_tratativa=params["status_tratativa"])
status_prazo = params.get("status_prazo")
if status_prazo:
queryset = _filtra_status_prazo(queryset, status_prazo, timezone.localdate())
status_prazo_list = params.getlist("status_prazo")
if status_prazo_list:
queryset = _filtra_status_prazo(queryset, status_prazo_list, timezone.localdate())
if params.get("search"):
termo = params["search"]
queryset = queryset.filter(

View File

@ -256,6 +256,73 @@
color: var(--text-muted);
}
/* Resumo copiável (canto superior direito da Gestão) — mesmo mecanismo de
popover position:fixed do filtro de coluna acima (ver
pidNcfFecharPopoversColuna() em nao-conformidades.js, que fecha os dois
tipos de popover de um jeito só). */
.ncf-resumo-wrap {
position: relative;
flex-shrink: 0;
}
.ncf-resumo-btn {
display: inline-flex;
align-items: center;
gap: var(--space-2);
}
.ncf-resumo-popover {
position: fixed;
z-index: 30;
width: 320px;
max-height: 70vh;
overflow-y: auto;
padding: var(--space-4);
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;
}
.ncf-resumo-popover__preview {
font-size: 0.85rem;
line-height: 1.5;
}
.ncf-resumo-titulo {
font-weight: 700;
margin: var(--space-3) 0 var(--space-1);
}
.ncf-resumo-titulo:first-child {
margin-top: 0;
}
.ncf-resumo-linha {
margin: 0;
color: var(--text-primary);
}
.ncf-resumo-linha--vencida {
color: var(--danger);
}
.ncf-resumo-linha--vence7 {
color: var(--gold);
}
.ncf-resumo-vazio {
color: var(--text-muted);
margin: 0;
}
.ncf-resumo-popover__actions {
display: flex;
justify-content: flex-end;
margin-top: var(--space-3);
}
/* Dashboard */
.ncf-dashboard-toolbar {
display: flex;
@ -501,12 +568,22 @@
color: var(--text-primary);
}
/* Busca compartilhada da Gestão */
/* Busca compartilhada da Gestão + Resumo copiável (canto superior direito) */
.ncf-gestao-toolbar {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--space-3);
flex-wrap: wrap;
margin-bottom: var(--space-5);
}
.ncf-gestao-toolbar__busca {
display: flex;
align-items: center;
gap: var(--space-3);
margin-bottom: var(--space-5);
flex: 1;
min-width: 260px;
}
.ncf-gestao-search {

View File

@ -215,10 +215,13 @@ document.addEventListener("DOMContentLoaded", async () => {
let semAnaliseTodos = [];
let analiseSemAcaoTodos = [];
let acoesTodos = [];
// "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";
// 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";
@ -242,6 +245,13 @@ document.addEventListener("DOMContentLoaded", async () => {
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");
@ -279,11 +289,24 @@ document.addEventListener("DOMContentLoaded", async () => {
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;
prazoFiltroAtual = btn.dataset.ncfPrazo;
atualizarChipsAtivos(filtroPrazoEl, "ncfPrazo", prazoFiltroAtual);
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) => {
@ -353,6 +376,8 @@ document.addEventListener("DOMContentLoaded", async () => {
]);
itens = pidNcfAplicarSort(itens, semAnaliseSortKey, semAnaliseSortDir, PID_NCF_SEM_ANALISE_SORT_VALOR);
pidNcfAtualizarSortHeaders(semAnaliseSortHeaders, semAnaliseSortKey, semAnaliseSortDir);
semAnaliseItensVisiveis = itens;
atualizarResumoGestao();
pidNcfAtualizarContador(semAnaliseCount, itens.length);
semAnaliseBody.innerHTML = "";
semAnaliseEmpty.hidden = itens.length > 0;
@ -429,6 +454,8 @@ document.addEventListener("DOMContentLoaded", async () => {
]);
itens = pidNcfAplicarSort(itens, analiseSemAcaoSortKey, analiseSemAcaoSortDir, PID_NCF_ANALISE_SEM_ACAO_SORT_VALOR);
pidNcfAtualizarSortHeaders(analiseSemAcaoSortHeaders, analiseSemAcaoSortKey, analiseSemAcaoSortDir);
analiseSemAcaoItensVisiveis = itens;
atualizarResumoGestao();
pidNcfAtualizarContador(analiseSemAcaoCount, itens.length);
analiseSemAcaoBody.innerHTML = "";
analiseSemAcaoEmpty.hidden = itens.length > 0;
@ -546,8 +573,18 @@ document.addEventListener("DOMContentLoaded", async () => {
);
async function carregarAcoes() {
if (prazoFiltrosSelecionados.size === 0) {
// Nenhum chip de Prazo marcado — mesmo espírito de um checklist todo
// desmarcado, não mostra nada (em vez de interpretar como "sem
// filtro" e trazer tudo, o que seria surpreendente pro usuário).
acoesTodos = [];
tipoOcorrenciaOpcoesDisponiveis = [];
renderTipoOcorrenciaChecklist();
renderAcoes();
return;
}
const params = new URLSearchParams();
if (prazoFiltroAtual !== "todas") params.set("status_prazo", prazoFiltroAtual);
prazoFiltrosSelecionados.forEach((bucket) => params.append("status_prazo", bucket));
if (tratativaFiltroAtual !== "todas") params.set("status_tratativa", tratativaFiltroAtual);
const query = params.toString();
acoesTodos = await pidApiRequest(`/nao-conformidades/acoes/${query ? "?" + query : ""}`);
@ -582,6 +619,8 @@ document.addEventListener("DOMContentLoaded", async () => {
itens = pidNcfFiltraColunaAcoes(itens);
itens = pidNcfAplicarSort(itens, acoesSortKey, acoesSortDir, PID_NCF_ACOES_SORT_VALOR);
pidNcfAtualizarSortHeaders(acoesSortHeaders, acoesSortKey, acoesSortDir);
acoesItensVisiveis = itens;
atualizarResumoGestao();
pidNcfAtualizarContador(acoesCount, itens.length);
acoesBody.innerHTML = "";
acoesEmpty.hidden = itens.length > 0;
@ -748,6 +787,13 @@ document.addEventListener("DOMContentLoaded", async () => {
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) => {
@ -807,7 +853,7 @@ document.addEventListener("DOMContentLoaded", async () => {
});
document.addEventListener("click", (evento) => {
if (evento.target.closest(".ncf-th-filterable")) return;
if (evento.target.closest(".ncf-th-filterable") || evento.target.closest(".ncf-resumo-wrap")) return;
pidNcfFecharPopoversColuna();
});
document.addEventListener("keydown", (evento) => {
@ -819,6 +865,96 @@ document.addEventListener("DOMContentLoaded", async () => {
window.addEventListener("scroll", () => pidNcfFecharPopoversColuna(), true);
window.addEventListener("resize", () => pidNcfFecharPopoversColuna());
// ---------------- Resumo copiável (Ações em aberto / Sem análise / Análise sem ação) ----------------
// Reflete exatamente o que está visível em cada uma das 3 tabelas da
// Gestão no momento (depois de busca/chips/filtro de coluna/ordenação) —
// ver os `*ItensVisiveis` atualizados no fim de cada render*() acima.
const resumoBtn = document.getElementById("ncf-resumo-btn");
const resumoPopover = document.getElementById("ncf-resumo-popover");
const resumoPreview = document.getElementById("ncf-resumo-preview");
const resumoCount = document.getElementById("ncf-resumo-count");
const resumoCopiarBtn = document.getElementById("ncf-resumo-copiar-btn");
let resumoTextoAtual = "";
function pidNcfDescricaoLinhaAcao(acaoItem) {
const data = pidNcfFormatDate(acaoItem.vencimento_efetivo);
const codigo = `${acaoItem.ocorrencia_codigo}/${acaoItem.codigo}`;
if (acaoItem.status_prazo === "vencida") return { texto: `${codigo} - Vencida em ${data}`, cor: "vencida" };
if (acaoItem.status_prazo === "vence_7_dias") return { texto: `${codigo} - Vencerá em ${data}`, cor: "vence7" };
if (acaoItem.status_prazo === "sem_vencimento") return { texto: `${codigo} - Sem vencimento definido`, cor: "normal" };
if (acaoItem.status_prazo === "finalizada") return { texto: `${codigo} - Finalizada em ${data}`, cor: "normal" };
return { texto: `${codigo} - Vencerá em ${data}`, cor: "normal" }; // vence_30_dias / no_prazo
}
function pidNcfDescricaoLinhaOcorrencia(oc) {
return { texto: `nº ${oc.codigo} - emitida em ${pidNcfFormatDate(oc.data_emissao)}`, cor: "normal" };
}
function montarBlocosResumo() {
const blocos = [];
if (acoesItensVisiveis.length) {
blocos.push({ titulo: "Ações em aberto:", linhas: acoesItensVisiveis.map(pidNcfDescricaoLinhaAcao) });
}
if (semAnaliseItensVisiveis.length) {
blocos.push({ titulo: "Ocorrência sem análise:", linhas: semAnaliseItensVisiveis.map(pidNcfDescricaoLinhaOcorrencia) });
}
if (analiseSemAcaoItensVisiveis.length) {
blocos.push({ titulo: "Análise sem ação:", linhas: analiseSemAcaoItensVisiveis.map(pidNcfDescricaoLinhaOcorrencia) });
}
return blocos;
}
function pidNcfResumoTextoPlano(blocos) {
return blocos.map((bloco) => `${bloco.titulo}\n${bloco.linhas.map((l) => l.texto).join("\n")}`).join("\n\n");
}
function pidNcfResumoHtml(blocos) {
if (!blocos.length) return '<p class="ncf-resumo-vazio">Nada pendente com os filtros atuais.</p>';
return blocos
.map(
(bloco) => `
<p class="ncf-resumo-titulo">${pidNcfEscapeHtml(bloco.titulo)}</p>
${bloco.linhas
.map((l) => `<p class="ncf-resumo-linha ncf-resumo-linha--${l.cor}">${pidNcfEscapeHtml(l.texto)}</p>`)
.join("")}`
)
.join("");
}
function atualizarResumoGestao() {
const blocos = montarBlocosResumo();
resumoTextoAtual = pidNcfResumoTextoPlano(blocos);
const totalLinhas = blocos.reduce((soma, bloco) => soma + bloco.linhas.length, 0);
pidNcfAtualizarContador(resumoCount, totalLinhas);
resumoPreview.innerHTML = pidNcfResumoHtml(blocos);
}
resumoBtn.addEventListener("click", () => {
const jaAberto = !resumoPopover.hidden;
pidNcfFecharPopoversColuna();
if (!jaAberto) {
const rect = resumoBtn.getBoundingClientRect();
const left = Math.min(rect.right - 320, window.innerWidth - 328);
resumoPopover.style.top = `${rect.bottom + 6}px`;
resumoPopover.style.left = `${Math.max(left, 8)}px`;
}
resumoPopover.hidden = jaAberto;
resumoBtn.setAttribute("aria-expanded", String(!jaAberto));
});
resumoCopiarBtn.addEventListener("click", async () => {
try {
await navigator.clipboard.writeText(resumoTextoAtual);
const textoOriginal = resumoCopiarBtn.textContent;
resumoCopiarBtn.textContent = "Copiado!";
setTimeout(() => {
resumoCopiarBtn.textContent = textoOriginal;
}, 1500);
} catch (e) {
await pidAlert("Não foi possível copiar o texto.");
}
});
// ---------------- Drill-down do Dashboard pra Gestão ----------------
async function irParaGestaoComBusca(valor) {
ativarTab("gestao");
@ -837,7 +973,7 @@ document.addEventListener("DOMContentLoaded", async () => {
// 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).
prazoFiltroAtual = bucket;
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
@ -849,7 +985,7 @@ document.addEventListener("DOMContentLoaded", async () => {
const vencimentoAteInput = document.getElementById("ncf-col-filtro-vencimento-ate");
if (vencimentoDeInput) vencimentoDeInput.value = "";
if (vencimentoAteInput) vencimentoAteInput.value = "";
atualizarChipsAtivos(filtroPrazoEl, "ncfPrazo", bucket);
atualizarChipsMultiAtivos(filtroPrazoEl, "ncfPrazo", prazoFiltrosSelecionados);
atualizarChipsAtivos(filtroTratativaEl, "ncfTratativa", "todas");
ativarTab("gestao");
if (gestaoCarregada) {

View File

@ -399,9 +399,101 @@
<div class="pa-tab-panel" data-ncf-panel="gestao" hidden>
<div class="ncf-gestao-toolbar">
<div class="ncf-gestao-toolbar__busca">
<input type="search" id="ncf-gestao-search" class="ncf-gestao-search" placeholder="Buscar por colaborador, cliente/empresa ou assunto..." autocomplete="off" />
<button type="button" class="btn-outline" id="ncf-gestao-search-clear-btn" hidden>Limpar</button>
</div>
<div class="ncf-resumo-wrap">
<button type="button" class="btn-outline ncf-resumo-btn" id="ncf-resumo-btn" aria-expanded="false">
Resumo <span class="ncf-count" id="ncf-resumo-count"></span>
</button>
<div class="ncf-resumo-popover" id="ncf-resumo-popover" hidden>
<div class="ncf-resumo-popover__preview" id="ncf-resumo-preview"></div>
<div class="ncf-resumo-popover__actions">
<button type="button" class="btn-solid" id="ncf-resumo-copiar-btn">Copiar texto</button>
</div>
</div>
</div>
</div>
<section class="ncf-section" id="ncf-section-acoes">
<div class="ncf-section__header">
<h3>Ações</h3>
<span class="ncf-count" id="ncf-acoes-count"></span>
</div>
<div class="ncf-filtro-grupo">
<span class="ncf-filtro-grupo__label">Prazo:</span>
<div class="ncf-chip-row" id="ncf-filtro-prazo">
<button type="button" class="ncf-chip is-active" data-ncf-prazo="vencida">Vencidas</button>
<button type="button" class="ncf-chip is-active" data-ncf-prazo="vence_7_dias">Vence em 7 dias</button>
<button type="button" class="ncf-chip is-active" data-ncf-prazo="vence_30_dias">Vence em 30 dias</button>
<button type="button" class="ncf-chip is-active" data-ncf-prazo="no_prazo">No prazo</button>
<button type="button" class="ncf-chip is-active" data-ncf-prazo="sem_vencimento">Sem vencimento</button>
<button type="button" class="ncf-chip" data-ncf-prazo="finalizada">Finalizadas</button>
</div>
</div>
<div class="ncf-filtro-grupo">
<span class="ncf-filtro-grupo__label">Tratativa:</span>
<div class="ncf-chip-row" id="ncf-filtro-tratativa">
<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">
<table class="pa-table ncf-table-sortable" id="ncf-table-acoes">
<thead>
<tr>
<th></th>
<th data-sort="ocorrencia_codigo">Ocorrência/Ação <span class="ncf-sort-icon">↕</span></th>
<th data-sort="ocorrencia_area">Área <span class="ncf-sort-icon">↕</span></th>
<th data-sort="tipo_acao">Tipo <span class="ncf-sort-icon">↕</span></th>
<th class="ncf-th-filterable" data-sort="ocorrencia_tipo_ocorrencia">
<span class="ncf-th-sort-label">Tipo de Ocorrência <span class="ncf-sort-icon">↕</span></span>
<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">
<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="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>
</tr>
</thead>
<tbody id="ncf-acoes-body"></tbody>
</table>
</div>
<p class="ncf-empty" id="ncf-acoes-empty" hidden>Nenhuma ação encontrada para este filtro.</p>
</section>
<section class="ncf-section" id="ncf-section-sem-analise">
<div class="ncf-section__header">
@ -470,87 +562,6 @@
</div>
<p class="ncf-empty" id="ncf-analise-sem-acao-empty" hidden>Nenhuma análise sem ação no momento.</p>
</section>
<section class="ncf-section" id="ncf-section-acoes">
<div class="ncf-section__header">
<h3>Ações</h3>
<span class="ncf-count" id="ncf-acoes-count"></span>
</div>
<div class="ncf-filtro-grupo">
<span class="ncf-filtro-grupo__label">Prazo:</span>
<div class="ncf-chip-row" id="ncf-filtro-prazo">
<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="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="no_prazo">No prazo</button>
<button type="button" class="ncf-chip" data-ncf-prazo="sem_vencimento">Sem vencimento</button>
<button type="button" class="ncf-chip" data-ncf-prazo="finalizada">Finalizadas</button>
</div>
</div>
<div class="ncf-filtro-grupo">
<span class="ncf-filtro-grupo__label">Tratativa:</span>
<div class="ncf-chip-row" id="ncf-filtro-tratativa">
<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">
<table class="pa-table ncf-table-sortable" id="ncf-table-acoes">
<thead>
<tr>
<th></th>
<th data-sort="ocorrencia_codigo">Ocorrência/Ação <span class="ncf-sort-icon">↕</span></th>
<th data-sort="ocorrencia_area">Área <span class="ncf-sort-icon">↕</span></th>
<th data-sort="tipo_acao">Tipo <span class="ncf-sort-icon">↕</span></th>
<th class="ncf-th-filterable" data-sort="ocorrencia_tipo_ocorrencia">
<span class="ncf-th-sort-label">Tipo de Ocorrência <span class="ncf-sort-icon">↕</span></span>
<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">
<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="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>
</tr>
</thead>
<tbody id="ncf-acoes-body"></tbody>
</table>
</div>
<p class="ncf-empty" id="ncf-acoes-empty" hidden>Nenhuma ação encontrada para este filtro.</p>
</section>
</div>
<div class="pa-tab-panel" data-ncf-panel="importar" hidden>