Compare commits

...

2 Commits

49 changed files with 71311 additions and 3 deletions

View File

@ -23,6 +23,7 @@ Aplicações com pacote Python próprio (`CLAUDE.md` **carregado automaticamente
| Importação de Plano de Saúde | `portal_api/planos_saude/` — `CLAUDE.md` (técnico), `README.md`, `CHANGELOG.md` |
| Indicador de Desempenho | `portal_api/indicadores/` — `CLAUDE.md` (técnico), `README.md`, `CHANGELOG.md` |
| Simulação de Custo de Contratação | `portal_api/custo_contratacao/` — `CLAUDE.md` (técnico), `README.md`, `CHANGELOG.md` |
| Não Conformidades | `portal_api/nao_conformidades/` — `CLAUDE.md` (técnico), `README.md`, `CHANGELOG.md` |
Aplicações sem pacote Python dedicado (código ainda em `portal_api/models.py`/`views.py`/`serializers.py` — os arquivos em `docs/<app>/` **não** são carregados automaticamente, ler manualmente):
@ -112,7 +113,7 @@ Duas identidades visuais coexistem **de propósito** hoje: o logo cursivo "D De
### Backend serve o frontend (mesma origem)
`config/urls.py` registra `path("api/", include("portal_api.urls"))` e, para cada página HTML do frontend (`index.html`, `portal.html`, `perfis-acesso.html`, `usuarios.html`, `calendario-individual.html`, `links-ferramentas.html`, `acessos-gerais.html`, `ramais.html`), uma rota `TemplateView` que resolve o arquivo em `templates/`. Os estáticos (`static/css`, `static/js`, `static/img`) são servidos por `django.contrib.staticfiles` automaticamente em `DEBUG` (via `STATICFILES_DIRS`) — não há mais nenhum `re_path`/`static_serve` manual em `urls.py`. Cada template usa `{% load static %}` + `{% static 'css/tokens.css' %}` (nunca um caminho hardcoded tipo `assets/css/...`, que não existe mais). Essa escolha (Django servindo o próprio frontend) existe para evitar CORS/cookie cross-origin: autenticação é por **sessão/cookie do Django**, então frontend e API precisam estar na mesma origem.
`config/urls.py` registra `path("api/", include("portal_api.urls"))` e, para cada página HTML do frontend (`index.html`, `portal.html`, `perfis-acesso.html`, `usuarios.html`, `calendario-individual.html`, `links-ferramentas.html`, `acessos-gerais.html`, `ramais.html`, `importacao-plano-saude.html`, `custo-contratacao.html`, `indicador-desempenho.html`, `nao-conformidades.html`), uma rota `TemplateView` que resolve o arquivo em `templates/`. Os estáticos (`static/css`, `static/js`, `static/img`) são servidos por `django.contrib.staticfiles` automaticamente em `DEBUG` (via `STATICFILES_DIRS`) — não há mais nenhum `re_path`/`static_serve` manual em `urls.py`. Cada template usa `{% load static %}` + `{% static 'css/tokens.css' %}` (nunca um caminho hardcoded tipo `assets/css/...`, que não existe mais). Essa escolha (Django servindo o próprio frontend) existe para evitar CORS/cookie cross-origin: autenticação é por **sessão/cookie do Django**, então frontend e API precisam estar na mesma origem.
Em produção, rodar `python manage.py collectstatic` (junta tudo em `STATIC_ROOT = BASE_DIR / "staticfiles"`) e servir esse diretório via whitenoise/nginx — `django.contrib.staticfiles` só serve automaticamente quando `DEBUG=True`. Uploads de usuário (ícones de `LinkFerramenta`) são um mecanismo separado: `MEDIA_URL`/`MEDIA_ROOT` em `settings.py`, servidos por `config/urls.py` via `static()` só quando `DEBUG=True` (em produção, servir `media/` também por whitenoise/nginx, igual ao `STATIC_ROOT`).
@ -219,6 +220,7 @@ Um único app, `portal_api/`:
| `importacao-plano-saude.html` | Ferramenta de Utilitários: histórico + cadastro de regras de custeio por empresa (separado da execução) + nova importação (upload, só aplica uma regra já cadastrada) + revisão/geração do arquivo de lançamento de plano de saúde (ver `portal_api/planos_saude/CLAUDE.md`). |
| `custo-contratacao.html` | Ferramenta de Geradoc: formulário de simulação de custo de contratação (Empregado CLT) + painel colapsável de parâmetros fiscais, gera um PDF (ver `portal_api/custo_contratacao/CLAUDE.md`). |
| `indicador-desempenho.html` | Ferramenta de Geradoc: histórico de apurações + nova apuração (upload das 2 planilhas) + cadastro de critérios/percentuais + revisão/geração dos recibos em PDF do Indicador de Desempenho do Fiscontábil (ver `portal_api/indicadores/CLAUDE.md`). |
| `nao-conformidades.html` | Aplicação de Relatórios > Qualidade: gestão contínua das ocorrências/ações do Sigsistem, com dashboard e status interno de tratativa da Qualidade (ver `portal_api/nao_conformidades/CLAUDE.md`). |
O item "Calendário De Paula" no menu **não é uma página local** — é um `<a href="#" id="calendario-depaula-btn">` (continua favoritável, já que ainda é um `<a class="nav-item">` — ver `docs/favoritos/favoritos.md`) cujo clique é interceptado em `sidebar.js` (`PID_CALENDARIO_DEPAULA_URL`) pra abrir `https://depaula-tvcorporativa.lovable.app/calendario` num modal com `<iframe>` (`#calendario-depaula-modal`, presente em todo shell) em vez de navegar — mesmo padrão do modal "Novo Chamado" de Ramais (`.ram-chamado-*` em `ramais.css`/`ramais.js`), só que genérico o bastante (`.iframe-modal-*` em `components.css`) pra existir em todo shell, não só em `ramais.html`. Funciona porque esse host (mesmo domínio do "Novo Chamado") não bloqueia ser embutido via `X-Frame-Options`/CSP, ao contrário do Asana (ver `docs/solicitacoes/solicitacoes.md`) — só foi possível confirmar isso testando de fato, não é garantia geral por domínio. Não recriar um `calendario.html` interno sem confirmar com o usuário; o antigo foi removido de propósito.
@ -369,13 +371,19 @@ Importa o relatório de faturamento de uma operadora de plano de saúde/odontol
Ver `portal_api/planos_saude/CLAUDE.md`.
## Não Conformidades (Relatórios)
Gestão contínua das ocorrências e ações do Sistema de Gestão da Qualidade (Sigsistem, ISO 9001) — evolui uma skill que gerava um Excel estático sob demanda pra uma ferramenta persistente: cada ocorrência/ação é upsertada por código a cada importação, com um status interno de tratativa da Qualidade que reabre automaticamente quando algo muda desde o último tratamento, mais um dashboard com motivos de abertura e clientes/colaboradores com maior incidência.
Ver `portal_api/nao_conformidades/CLAUDE.md`.
## CSS — organização entre arquivos
| Arquivo | Contém |
|---|---|
| `tokens.css` | Variáveis (`:root`, tema claro em `:root[data-theme="light"]`). |
| `base.css` | Reset global, incluindo `[hidden] { display: none !important; }` — necessário porque vários componentes (`.no-access`, `.app-card`, `.notif-badge`) definem seu próprio `display`, o que sem o `!important` sobrescreveria o comportamento nativo de `hidden`. Também os `@keyframes` globais de animação (`pidFadeIn`/`pidFadeSlideUp`/`pidScaleIn`, ver "Animações" abaixo) e `.pid-icon-eye`/`pidIconBlink` (piscar de olho do ícone "P.I.D.", reaproveitado pela sidebar e pelo login — ver "Ícone do login e da sidebar são clicáveis" abaixo), já que é o único CSS carregado por **todas** as páginas sem exceção (inclusive `index.html`). |
| `layout.css` | Casca do shell: `.app-shell`, `.sidebar*`, `.nav-*`, `.fav-toggle`, `.topbar*`. A sidebar usa tokens **congelados**, independentes de tema (fundo sempre escuro em claro/escuro) — não trocar por variáveis que espelham `:root[data-theme="light"]`. Exceção deliberada: `--sidebar-text-primary`/`--sidebar-text-secondary`/`--sidebar-text-muted` (texto/ícone do menu) *são* sobrescritas em `:root[data-theme="light"]` (`tokens.css`) pra branco puro — pedido explícito do usuário pra melhorar a legibilidade; só o fundo/borda da sidebar continuam frozen. Também `.page-content` (largura do conteúdo de cada página, `max-width:1200px` centralizado por padrão) + o modificador `.page-content--wide` (`max-width:1600px`) — `portal.html` ("Principal") é a única página que usa só `.page-content` puro (grade de favoritos fica mais confortável de leitura mais estreita); as outras 7 páginas (`perfis-acesso.html`, `usuarios.html`, `links-ferramentas.html`, `acessos-gerais.html`, `ramais.html`, `calendario-individual.html`, `importacao-plano-saude.html`) usam `class="page-content page-content--wide"` no `<main>`, decisão explícita do usuário pra aproveitar melhor o espaço entre a sidebar e a borda da tela em telas de tabela/formulário. Uma página nova que seja mais "aplicação" (tabela, formulário, CRUD) do que "dashboard" deve nascer já com `page-content--wide`. |
| `layout.css` | Casca do shell: `.app-shell`, `.sidebar*`, `.nav-*`, `.fav-toggle`, `.topbar*`. A sidebar usa tokens **congelados**, independentes de tema (fundo sempre escuro em claro/escuro) — não trocar por variáveis que espelham `:root[data-theme="light"]`. Exceção deliberada: `--sidebar-text-primary`/`--sidebar-text-secondary`/`--sidebar-text-muted` (texto/ícone do menu) *são* sobrescritas em `:root[data-theme="light"]` (`tokens.css`) pra branco puro — pedido explícito do usuário pra melhorar a legibilidade; só o fundo/borda da sidebar continuam frozen. Também `.page-content` (largura do conteúdo de cada página, `max-width:1200px` centralizado por padrão) + o modificador `.page-content--wide` (`max-width:1600px`) — `portal.html` ("Principal") é a única página que usa só `.page-content` puro (grade de favoritos fica mais confortável de leitura mais estreita); as outras 10 páginas (`perfis-acesso.html`, `usuarios.html`, `links-ferramentas.html`, `acessos-gerais.html`, `ramais.html`, `calendario-individual.html`, `importacao-plano-saude.html`, `custo-contratacao.html`, `indicador-desempenho.html`, `nao-conformidades.html`) usam `class="page-content page-content--wide"` no `<main>`, decisão explícita do usuário pra aproveitar melhor o espaço entre a sidebar e a borda da tela em telas de tabela/formulário. Uma página nova que seja mais "aplicação" (tabela, formulário, CRUD) do que "dashboard" deve nascer já com `page-content--wide`. |
| `components.css` | UI genérica reutilizável: `.btn-solid/.btn-outline/.btn-ghost/.btn-danger-outline`, **`.modal-overlay`/`.modal-card`** (moldura genérica de modal, + o modificador `.modal-card--wide` pra quando precisa de mais espaço horizontal) **e também** `.modal-field`/`.modal-field-row`/`.modal-checkbox`/`.modal-error`/`.modal-actions` (campos internos do modal — moldura e campos vivem juntos aqui, apesar do nome sugerir só a moldura, incluindo `select`/`textarea` dentro de `.modal-field`, com seta customizada via `background-image` porque o nativo do browser destoa do tema escuro), `.app-card*`, `.no-access`, `.checklist-box`/`.checklist-item`/`.checklist-item__info`/`.checklist-item__nome`/`.checklist-item__departamento`/`.checklist-empty`/`.checklist-search`/`.checklist-select-all` (lista com checkbox, segunda linha de detalhe e busca — usada nos checklists de Perfis de Acesso/Departamento em `usuarios.html`), `.dual-select`/`.dual-select__*` (vinculação em duas tabelas — não vinculados/vinculados, ver `docs/perfis-usuarios/perfis-usuarios.md` — usada em `usuarios.html` e no modal "Gerenciar Usuário" de todo shell), `.info-tooltip`/`.info-tooltip__*`/`.ajuda-modal__*` (botão "?" + tooltip + modal de "Mais informações", ver seção própria abaixo — usados por `static/js/ajuda-aplicacao.js`) e `.modal-overlay--top` (empilha um modal por cima de outro já aberto — usado só pelo modal de confirmação genérico, `static/js/confirm-modal.js`, ver "Modal de confirmação genérico" abaixo). |
| `perfis-acesso.css` | `.pa-*` (tela de Perfis de Acesso), incluindo as seções (`.ua-section*`) e campos específicos (`.ua-inline-add`/`.ua-departamento-item`/`.ua-active-toggle`/`.ua-liderados-field`) do formulário de edição de `usuarios.html`. |
| `calendario.css` | Só `.calendar-*` (grade mensal, células de dia, nav do mês) — os campos do modal de compromisso usam as classes genéricas `.modal-field`/`.modal-checkbox`/`.modal-error`/`.modal-actions` de `components.css`. |
@ -388,6 +396,7 @@ Ver `portal_api/planos_saude/CLAUDE.md`.
| `importacao-plano-saude.css` | `.ips-*` — só usado em `importacao-plano-saude.html`; carrega `perfis-acesso.css` também, pra reaproveitar `.pa-table`/`.pa-tabs`/`.pa-table-wrap` na tabela editável da revisão e nas abas. |
| `custo-contratacao.css` | `.cc-*` — só usado em `custo-contratacao.html`. |
| `indicador-desempenho.css` | `.ind-*` — só usado em `indicador-desempenho.html`; carrega `perfis-acesso.css` também, pelo mesmo motivo de `importacao-plano-saude.css` (tabela/abas de revisão). |
| `nao-conformidades.css` | `.ncf-*` — só usado em `nao-conformidades.html`; carrega `perfis-acesso.css` também, pra reaproveitar `.pa-table`/`.pa-tabs`/`.status-pill` (modificadores `--ok`/`--danger`/`--warning`/`--neutral` novos, em cima do `.status-pill` já existente ali). |
Ao adicionar uma tela nova que precise de modal, reuse `.modal-overlay`/`.modal-card` de `components.css` e só crie estilos de campo próprios se o formulário não for um caso simples de texto/select (que já tem equivalente em `.pa-field` ou `.modal-field`).

View File

@ -24,6 +24,7 @@ PAGINAS_FRONTEND = [
"importacao-plano-saude.html",
"custo-contratacao.html",
"indicador-desempenho.html",
"nao-conformidades.html",
]
urlpatterns = [

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -377,6 +377,20 @@ Usuário viu, pelo dropdown do sino, notificações de ferramenta antigas (algum
- `toolNotificationsAgora` (recorte "elegível agora", análogo a `pidEventosElegiveisAgora()` pros compromissos) exclui as expiradas por prazo tanto na carga inicial quanto depois de um "Restaurar" — restaurar uma notificação de ferramenta com mais de 10 dias mantém o rastro no histórico, mas não a devolve ao sino.
- Ver `CLAUDE.md`, seção "Armazenamento" → "Histórico de notificações" pro detalhamento completo.
### 88. Não Conformidades (Relatórios > Qualidade) — nova aplicação
Pedido (2026-08-27): evoluir a skill do Claude `analise-ncs` (ver `projects/Controladoria - Analise NCs/`), que gerava sob demanda um Excel de 7 abas a partir de dois arquivos exportados do Sigsistem (ocorrências .xlsx + ações .xls, esse último HTML disfarçado), pra uma aplicação dentro do Portal com gestão **contínua**: identificar novas ocorrências sem análise, análises sem ação, acompanhamentos recentes sem tratativa da Qualidade, priorizar vencidas/vencendo em 7 dias, permitir marcar manualmente o que foi tratado, e um dashboard (ações abertas, motivos de abertura, clientes/colaboradores com maior incidência).
Decisões de negócio confirmadas antes de implementar: status de tratativa é controle interno do Portal (sem escrita de volta no Sigsistem), mas reabre sozinho quando uma importação nova traz algo diferente do que existia no momento do tratamento; guardar o histórico completo de acompanhamentos (não só o último, como a skill fazia); colaborador com maior incidência = `Indicado para Descrever Análise da Ocorrência`; motivo de abertura = `Tipo(s) de Causa(s) da Ocorrência`; permissão de toggle único **restrita por padrão** (só Integração e Inovação nasce com acesso — diferente de "Relatório Setorial", que herda automático via `SECTORAL_KEYS`).
Nova aplicação em Relatórios > Qualidade (subgrupo novo em `catalogo.py`, mesmo formato de subgrupo-com-uma-tool já usado em Auditorias) — pacote Python puro `portal_api/nao_conformidades/` (sem pandas, mesmo padrão de `planos_saude`/`indicadores`), 4 models novos, 3 `ModelViewSet` + dashboard, tela nova com 3 abas. Núcleo técnico: reabertura automática por diff (`nao_conformidades/diff.py`) — cada ocorrência/ação tratada guarda um snapshot congelado, comparado a cada importação nova contra o estado atual (ação por data do último acompanhamento; ocorrência por hash de texto da análise ou ação nova aberta). Validado de ponta a ponta com dados reais da amostra fornecida: totais batem com o relatório de referência da skill em tudo, exceto "Total de Ações Abertas" (a skill original contava também linhas sem nenhuma ação de fato aberta — artefato do script, decisão deliberada de não replicar); reimportação idempotente; dois bugs de robustez corrigidos (`assunto` virou `TextField` — um export real trouxe 395 caracteres; e um `SerializerMethodField` que chamava uma propriedade inexistente no model). Interação em navegador não testada nesta rodada (sem ferramenta de automação de browser disponível) — pipeline/API validados via chamadas HTTP diretas, tela construída seguindo os padrões visuais já validados de `importacao-plano-saude.html`/`indicador-desempenho.html`. Detalhe completo em `portal_api/nao_conformidades/CLAUDE.md`.
### 89. Não Conformidades — redesign de Dashboard/Gestão a partir de teste real na tela
Depois de testar a rodada 88 na tela, o usuário deu uma lista concreta de feedback de UX. Resumo (detalhe completo em `portal_api/nao_conformidades/CHANGELOG.md`): Dashboard virou a aba padrão ao abrir; 4 cards e as 3 tabelas de ranking ficaram clicáveis (drill-down direto pra Gestão já filtrada); rankings de Clientes/Colaboradores passaram a mostrar a contagem quebrada por tipo com cor (NC/Reclamação/Outros) em vez de restringir o filtro, a pedido explícito do usuário; dois KPIs de tempo médio novos (preenchimento de análise, abertura de ação) — um terceiro (resolução de ações) foi descartado antes de implementar porque o campo correspondente está 100% vazio no export real, e o usuário confirmou pra não criar um card sem dado; seletor de período virou chips em vez de `<select>`. Na Gestão: toda linha ganhou um botão de expandir (mesmo padrão de `.cc-painel-fiscal__toggle`) que revela ocorrência completa ou o histórico de acompanhamentos (mais recente primeiro) — substituiu o modal de histórico, que ficava colado no botão "Marcar como tratado"; a coluna única "Status" de Ações virou duas (Prazo e Tratativa), com filtros em chips combináveis; um campo de busca único passou a filtrar as 3 listas da Gestão de uma vez, por colaborador/empresa/assunto.
Dois bugs reais corrigidos durante o teste: uma corrida (`ativarTab()` tinha efeito colateral de carregamento que corria em paralelo com o carregamento explícito de um drill-down, e o que terminasse por último vencia — às vezes mostrando o filtro errado) e um alinhamento de texto (célula de painel de detalhe herdava `text-align:right` de `.pa-table td:last-child` por ser sempre "a única/última" célula da linha). Instalado Playwright ad-hoc neste ambiente pra validar a tela de ponta a ponta (login, drill-downs, expandir, alternar tratativa, filtros combinados) — não é dependência do projeto, só ferramenta de teste desta sessão.
## Limitações conhecidas / decisões assumidas
- Calendário De Paula (empresa) não tem mais versão interna — depende
@ -419,3 +433,11 @@ ponta com um arquivo real de cada operadora, Simulação de Custo de
Contratação conferindo o PDF gerado e a Lei 15.270/2025, e Indicador de
Desempenho com uma apuração completa nova — ver o `CHANGELOG.md` de cada
aplicação para o que já foi de fato testado rodada a rodada desde então.
A tela de Não Conformidades (rodadas 88–89) já foi validada de ponta a
ponta, inclusive em navegador de verdade via Playwright (login, drill-downs
do Dashboard, expandir ocorrência/ação, marcar tratado/reabrir, filtros
combinados) — ver `portal_api/nao_conformidades/CHANGELOG.md`. Falta só o
upload real do formulário "Nova Importação" pelo navegador (testado até
agora só via API), e a conferência do usuário sobre o resultado visual do
redesign da rodada 89.

View File

@ -43,6 +43,18 @@ MODULE_APPS = {
],
"relatorios": [
{"key": "relatorio-setorial", "label": "Relatório Setorial"},
# Subgrupo com uma única tool (toggle único, sem par visualizar/editar
# — mesmo formato já usado em Auditorias, ex. "Consultoria Tributária"
# → "Controle Simples Nacional") pra a ferramenta "Não Conformidades"
# ficar em Relatórios > Qualidade no menu. Restrita por padrão: ver o
# override em seed_portal.py (só "Integração e Inovação" nasce com
# acesso, diferente de "relatorio-setorial" que herda automático de
# SECTORAL_KEYS).
{
"key": "qualidade",
"label": "Qualidade",
"tools": [{"key": "nao-conformidades", "label": "Não Conformidades"}],
},
],
"relatorios-gerenciais": [
{"key": "visao-diretoria", "label": "Visão Diretoria"},

View File

@ -166,6 +166,13 @@ class Command(BaseCommand):
# todo perfil) — sem este override, todo perfil nasceria podendo criar
# evento de departamento/todos e cadastrar categoria de evento.
permissoes["calendario-individual"]["apps"]["calendario-individual-criar-evento"] = False
# "relatorios" está em SECTORAL_KEYS (habilita todos os apps do módulo
# de uma vez, incluindo "Não Conformidades") — mas essa ferramenta é de
# uma pessoa/área bem mais específica (responsável pela Qualidade) que
# "Relatório Setorial", então nasce restrita: só Integração e Inovação
# tem acesso por padrão, liberado manualmente pra outros perfis depois
# pela tela de Perfis de Acesso.
permissoes["relatorios"]["apps"]["nao-conformidades"] = False
# `nome` só é gravado na criação — reexecuções não devem reverter uma
# renomeação feita depois pela própria tela de Perfis de Acesso (mesmo
# espírito de "só inicializa na primeira criação" já usado pra

View File

@ -0,0 +1,133 @@
# Generated by Django 6.0.7 on 2026-08-27 17:32
import django.db.models.deletion
import portal_api.models
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('portal_api', '0051_alter_importacaoplanosaudealteracao_tipo_and_more'),
]
operations = [
migrations.CreateModel(
name='NaoConformidadeImportacao',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('arquivo_ocorrencias', models.FileField(max_length=255, upload_to='nao_conformidades/ocorrencias/', validators=[portal_api.models.validar_tamanho_arquivo_nao_conformidade], verbose_name='Arquivo de ocorrências (.xlsx)')),
('arquivo_acoes', models.FileField(max_length=255, upload_to='nao_conformidades/acoes/', validators=[portal_api.models.validar_tamanho_arquivo_nao_conformidade], verbose_name='Arquivo de ações (.xls / HTML)')),
('criado_em', models.DateTimeField(auto_now_add=True)),
('resumo', models.JSONField(blank=True, default=dict, verbose_name='Resumo do processamento')),
('avisos', models.JSONField(blank=True, default=list, verbose_name='Avisos do processamento')),
('criado_por', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='importacoes_nao_conformidade', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'Importação de Não Conformidades',
'verbose_name_plural': 'Importações de Não Conformidades',
'ordering': ['-criado_em'],
},
),
migrations.CreateModel(
name='NCOcorrencia',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('codigo', models.PositiveIntegerField(unique=True, verbose_name='Código da Ocorrência')),
('data_emissao', models.DateField(blank=True, null=True, verbose_name='Data de Emissão')),
('assunto', models.CharField(blank=True, max_length=255, verbose_name='Assunto')),
('tipo_ocorrencia', models.CharField(blank=True, max_length=100, verbose_name='Tipo de Ocorrência')),
('pessoas_relacionadas', models.TextField(blank=True, verbose_name='Pessoa(s) Relacionado(s)')),
('origem', models.CharField(blank=True, max_length=100, verbose_name='Origem da Ocorrência')),
('fornecedores_relacionados', models.TextField(blank=True, verbose_name='Fornecedor(es) Relacionado(s)')),
('clientes_relacionados', models.TextField(blank=True, verbose_name='Cliente(s) Relacionado(s)')),
('area', models.CharField(blank=True, max_length=100, verbose_name='Área')),
('setor', models.CharField(blank=True, max_length=100, verbose_name='Setor')),
('riscos_relacionados', models.TextField(blank=True, verbose_name='Risco(s) Relacionado(s)')),
('data_relato', models.DateField(blank=True, null=True, verbose_name='Data do Relato')),
('relato', models.TextField(blank=True, verbose_name='Relato')),
('emissor_relato', models.CharField(blank=True, max_length=150, verbose_name='Emissor Relato')),
('representante_gerente', models.CharField(blank=True, max_length=150, verbose_name='Representante / Gerente')),
('prazo_finalizar', models.DateField(blank=True, null=True, verbose_name='Prazo para Finalizar a Ocorrência')),
('indicado_analise', models.CharField(blank=True, max_length=150, verbose_name='Indicado para Descrever Análise da Ocorrência')),
('tipos_causa', models.TextField(blank=True, verbose_name='Tipo(s) de Causa(s) da Ocorrência')),
('descricao_analise', models.TextField(blank=True, verbose_name='Descrição da Análise da Ocorrência')),
('responsavel_analise', models.CharField(blank=True, max_length=150, verbose_name='Responsável pela Descrição da Análise')),
('data_analise', models.DateField(blank=True, null=True, verbose_name='Data da Análise da Ocorrência')),
('analise_sem_acao_detectada', models.BooleanField(default=False)),
('status_tratativa', models.CharField(choices=[('pendente', 'Pendente'), ('tratado', 'Tratado')], default='pendente', max_length=10, verbose_name='Status de tratativa')),
('tratado_em', models.DateTimeField(blank=True, null=True)),
('snapshot_tratativa', models.JSONField(blank=True, null=True)),
('reaberto_em', models.DateTimeField(blank=True, null=True)),
('reaberto_motivo', models.TextField(blank=True)),
('ultima_importacao_em', models.DateTimeField(blank=True, null=True)),
('tratado_por', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='ocorrencias_nc_tratadas', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'Ocorrência (Não Conformidade)',
'verbose_name_plural': 'Ocorrências (Não Conformidade)',
'ordering': ['-data_emissao', '-codigo'],
},
),
migrations.CreateModel(
name='NCAcao',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('codigo', models.PositiveIntegerField(verbose_name='Código da Ação')),
('data_emissao', models.DateField(blank=True, null=True)),
('tipo_acao', models.CharField(blank=True, max_length=100, verbose_name='Tipo de Ação')),
('acao_texto', models.TextField(blank=True, verbose_name='Ação')),
('data_conclusao', models.DateField(blank=True, null=True, verbose_name='Data da Conclusão da Ação')),
('prazo_prorrogado', models.DateField(blank=True, null=True, verbose_name='Prazo Prorrogado')),
('justificativa_prorrogacao', models.TextField(blank=True)),
('executor', models.CharField(blank=True, max_length=150, verbose_name='Executor da Ação')),
('emissor_acao', models.CharField(blank=True, max_length=150)),
('indicado_autorizar', models.CharField(blank=True, max_length=150, verbose_name='Indicado para Autorizar/Aprovar')),
('responsavel_autorizacao', models.CharField(blank=True, max_length=150)),
('data_finalizacao', models.DateField(blank=True, null=True)),
('dias_finalizacao', models.IntegerField(blank=True, null=True)),
('situacao', models.CharField(blank=True, max_length=100, verbose_name='Situação (Sigsistem)')),
('fase', models.CharField(blank=True, max_length=100, verbose_name='Fase (Sigsistem)')),
('vencimento_efetivo', models.DateField(blank=True, null=True)),
('ultimo_acompanhamento_em', models.DateField(blank=True, null=True)),
('ultimo_acompanhamento_eh_prorrogacao', models.BooleanField(default=False)),
('status_tratativa', models.CharField(choices=[('pendente', 'Pendente'), ('tratado', 'Tratado')], default='pendente', max_length=10, verbose_name='Status de tratativa')),
('tratado_em', models.DateTimeField(blank=True, null=True)),
('snapshot_tratativa', models.JSONField(blank=True, null=True)),
('reaberto_em', models.DateTimeField(blank=True, null=True)),
('reaberto_motivo', models.TextField(blank=True)),
('ultima_importacao_em', models.DateTimeField(blank=True, null=True)),
('tratado_por', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='acoes_nc_tratadas', to=settings.AUTH_USER_MODEL)),
('ocorrencia', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='acoes', to='portal_api.ncocorrencia')),
],
options={
'verbose_name': 'Ação (Não Conformidade)',
'verbose_name_plural': 'Ações (Não Conformidade)',
'ordering': ['vencimento_efetivo'],
},
),
migrations.CreateModel(
name='NCAcompanhamento',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('data', models.DateField()),
('autor', models.CharField(blank=True, max_length=150)),
('texto', models.TextField()),
('eh_prorrogacao', models.BooleanField(default=False)),
('hash_entrada', models.CharField(max_length=64)),
('criado_em', models.DateTimeField(auto_now_add=True)),
('acao', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='acompanhamentos', to='portal_api.ncacao')),
],
options={
'verbose_name': 'Acompanhamento (Não Conformidade)',
'verbose_name_plural': 'Acompanhamentos (Não Conformidade)',
'ordering': ['data', 'id'],
'constraints': [models.UniqueConstraint(fields=('acao', 'hash_entrada'), name='nc_acompanhamento_unico')],
},
),
migrations.AddConstraint(
model_name='ncacao',
constraint=models.UniqueConstraint(fields=('ocorrencia', 'codigo'), name='nc_acao_unica_por_ocorrencia'),
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 6.0.7 on 2026-08-27 17:48
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('portal_api', '0052_naoconformidadeimportacao_ncocorrencia_ncacao_and_more'),
]
operations = [
migrations.AlterField(
model_name='ncocorrencia',
name='assunto',
field=models.TextField(blank=True, verbose_name='Assunto'),
),
]

View File

@ -1461,3 +1461,220 @@ class ParametroFiscalCustoContratacao(models.Model):
def __str__(self) -> str:
return f"{self.importacao} → {self.nome} ({self.motivo})"
NAO_CONFORMIDADE_ARQUIVO_MAX_BYTES = 15 * 1024 * 1024
def validar_tamanho_arquivo_nao_conformidade(arquivo: File) -> None:
if arquivo.size > NAO_CONFORMIDADE_ARQUIVO_MAX_BYTES:
raise ValidationError("O arquivo deve ter no máximo 15MB.")
class NaoConformidadeImportacao(models.Model):
"""Uma execução da ferramenta "Não Conformidades" (Relatórios > Qualidade):
o responsável pela Qualidade anexa os dois arquivos exportados do Sigsistem
(ocorrências .xlsx + ações .xls) e o pipeline (`portal_api.nao_conformidades.
pipeline.processa_importacao`) faz o upsert de `NCOcorrencia`/`NCAcao`/
`NCAcompanhamento` a partir daqui — ver `NaoConformidadeImportacaoViewSet.
create()` em views.py. Este registro é só um LOG de auditoria de quando
cada importação rodou: ao contrário de `ImportacaoPlanoSaude`, ele não é
"dono" das ocorrências/ações (que são entidades contínuas, upsertadas por
código) — apagar uma importação remove só o log e os 2 arquivos, nunca as
ocorrências/ações já persistidas."""
arquivo_ocorrencias = models.FileField(
"Arquivo de ocorrências (.xlsx)",
upload_to="nao_conformidades/ocorrencias/",
max_length=255,
validators=[validar_tamanho_arquivo_nao_conformidade],
)
arquivo_acoes = models.FileField(
"Arquivo de ações (.xls / HTML)",
upload_to="nao_conformidades/acoes/",
max_length=255,
validators=[validar_tamanho_arquivo_nao_conformidade],
)
criado_por = models.ForeignKey(
Usuario, on_delete=models.SET_NULL, null=True, related_name="importacoes_nao_conformidade"
)
criado_em = models.DateTimeField(auto_now_add=True)
# Contadores de novas/atualizadas/reabertas, formato livre — ver
# `_aplica_upsert_nao_conformidades` em views.py.
resumo = models.JSONField("Resumo do processamento", default=dict, blank=True)
avisos = models.JSONField("Avisos do processamento", default=list, blank=True)
class Meta:
verbose_name = "Importação de Não Conformidades"
verbose_name_plural = "Importações de Não Conformidades"
ordering = ["-criado_em"]
def __str__(self) -> str:
return f"Importação de {self.criado_em:%d/%m/%Y %H:%M}"
class NCOcorrencia(models.Model):
"""Uma ocorrência do Sigsistem (Não Conformidade, Reclamação de Cliente,
Oportunidade de Melhoria etc.) — upsertada por `codigo` a cada nova
importação (ver `NaoConformidadeImportacaoViewSet.create()`). Os campos
espelham as colunas do .xlsx de ocorrências (ver
`portal_api.nao_conformidades.leiaute_ocorrencias.COLUNAS_ESPERADAS`).
`status_tratativa`/`snapshot_tratativa` são o controle interno de
tratativa da Qualidade — não existe no Sigsistem. Quando marcada como
tratada, `snapshot_tratativa` congela o estado relevante (via
`nao_conformidades.diff.snapshot_ocorrencia`); numa importação futura, se
o estado atual divergir do congelado (nova análise, análise alterada,
ação nova), o item reabre sozinho (`reaberto_em`/`reaberto_motivo`
preenchidos, `status_tratativa` volta a pendente) — a tratativa de fato
(decidir a análise, abrir uma ação) acontece no Sigsistem, sistema externo
sem integração; o Portal só monitora e reflete."""
STATUS_PENDENTE = "pendente"
STATUS_TRATADO = "tratado"
STATUS_CHOICES = [(STATUS_PENDENTE, "Pendente"), (STATUS_TRATADO, "Tratado")]
codigo = models.PositiveIntegerField("Código da Ocorrência", unique=True)
data_emissao = models.DateField("Data de Emissão", null=True, blank=True)
# TextField, não CharField: exports reais já trouxeram "Assunto" com mais
# de 390 caracteres (texto livre digitado no Sigsistem, sem limite
# aparente) — ver amostra `projects/Controladoria - Analise NCs/`.
assunto = models.TextField("Assunto", blank=True)
tipo_ocorrencia = models.CharField("Tipo de Ocorrência", max_length=100, blank=True)
pessoas_relacionadas = models.TextField("Pessoa(s) Relacionado(s)", blank=True)
origem = models.CharField("Origem da Ocorrência", max_length=100, blank=True)
fornecedores_relacionados = models.TextField("Fornecedor(es) Relacionado(s)", blank=True)
clientes_relacionados = models.TextField("Cliente(s) Relacionado(s)", blank=True)
area = models.CharField("Área", max_length=100, blank=True)
setor = models.CharField("Setor", max_length=100, blank=True)
riscos_relacionados = models.TextField("Risco(s) Relacionado(s)", blank=True)
data_relato = models.DateField("Data do Relato", null=True, blank=True)
relato = models.TextField("Relato", blank=True)
emissor_relato = models.CharField("Emissor Relato", max_length=150, blank=True)
representante_gerente = models.CharField("Representante / Gerente", max_length=150, blank=True)
prazo_finalizar = models.DateField("Prazo para Finalizar a Ocorrência", null=True, blank=True)
indicado_analise = models.CharField(
"Indicado para Descrever Análise da Ocorrência", max_length=150, blank=True
)
tipos_causa = models.TextField("Tipo(s) de Causa(s) da Ocorrência", blank=True)
descricao_analise = models.TextField("Descrição da Análise da Ocorrência", blank=True)
responsavel_analise = models.CharField(
"Responsável pela Descrição da Análise", max_length=150, blank=True
)
data_analise = models.DateField("Data da Análise da Ocorrência", null=True, blank=True)
# True quando alguma linha do export trouxe análise preenchida numa linha
# SEM código de ação (regra "Análise sem Ação" da skill original) — vem de
# `OcorrenciaExtraida.tem_linha_analise_sem_acao`, checado por linha
# durante o parsing (ver nao_conformidades/leiaute_ocorrencias.py), não
# re-derivável de forma confiável só a partir de `descricao_analise` +
# `acoes.count()` porque uma ocorrência pode ter linhas mistas.
analise_sem_acao_detectada = models.BooleanField(default=False)
status_tratativa = models.CharField(
"Status de tratativa", max_length=10, choices=STATUS_CHOICES, default=STATUS_PENDENTE
)
tratado_em = models.DateTimeField(null=True, blank=True)
tratado_por = models.ForeignKey(
Usuario, on_delete=models.SET_NULL, null=True, blank=True, related_name="ocorrencias_nc_tratadas"
)
snapshot_tratativa = models.JSONField(null=True, blank=True)
reaberto_em = models.DateTimeField(null=True, blank=True)
reaberto_motivo = models.TextField(blank=True)
ultima_importacao_em = models.DateTimeField(null=True, blank=True)
class Meta:
verbose_name = "Ocorrência (Não Conformidade)"
verbose_name_plural = "Ocorrências (Não Conformidade)"
ordering = ["-data_emissao", "-codigo"]
def __str__(self) -> str:
return f"Ocorrência {self.codigo} — {self.assunto}"
class NCAcao(models.Model):
"""Uma ação (Correção ou Ação Corretiva) vinculada a uma `NCOcorrencia` —
upsertada por `(ocorrencia, codigo)` a cada importação. `vencimento_efetivo`
(Prazo Prorrogado, senão Data da Conclusão da Ação) e
`ultimo_acompanhamento_em`/`ultimo_acompanhamento_eh_prorrogacao` só mudam
com uma importação nova, por isso são seguros de cachear aqui — o status
relativo a "hoje" (vencida/vence em breve) NUNCA é persistido, é sempre
calculado em tempo de leitura via
`portal_api.nao_conformidades.classificacao.status_prazo()` (ver
NCAcaoSerializer em serializers.py)."""
STATUS_PENDENTE = "pendente"
STATUS_TRATADO = "tratado"
STATUS_CHOICES = [(STATUS_PENDENTE, "Pendente"), (STATUS_TRATADO, "Tratado")]
ocorrencia = models.ForeignKey(NCOcorrencia, on_delete=models.CASCADE, related_name="acoes")
codigo = models.PositiveIntegerField("Código da Ação")
data_emissao = models.DateField(null=True, blank=True)
tipo_acao = models.CharField("Tipo de Ação", max_length=100, blank=True)
acao_texto = models.TextField("Ação", blank=True)
data_conclusao = models.DateField("Data da Conclusão da Ação", null=True, blank=True)
prazo_prorrogado = models.DateField("Prazo Prorrogado", null=True, blank=True)
justificativa_prorrogacao = models.TextField(blank=True)
executor = models.CharField("Executor da Ação", max_length=150, blank=True)
emissor_acao = models.CharField(max_length=150, blank=True)
indicado_autorizar = models.CharField("Indicado para Autorizar/Aprovar", max_length=150, blank=True)
responsavel_autorizacao = models.CharField(max_length=150, blank=True)
data_finalizacao = models.DateField(null=True, blank=True)
dias_finalizacao = models.IntegerField(null=True, blank=True)
situacao = models.CharField("Situação (Sigsistem)", max_length=100, blank=True)
fase = models.CharField("Fase (Sigsistem)", max_length=100, blank=True)
vencimento_efetivo = models.DateField(null=True, blank=True)
ultimo_acompanhamento_em = models.DateField(null=True, blank=True)
ultimo_acompanhamento_eh_prorrogacao = models.BooleanField(default=False)
status_tratativa = models.CharField(
"Status de tratativa", max_length=10, choices=STATUS_CHOICES, default=STATUS_PENDENTE
)
tratado_em = models.DateTimeField(null=True, blank=True)
tratado_por = models.ForeignKey(
Usuario, on_delete=models.SET_NULL, null=True, blank=True, related_name="acoes_nc_tratadas"
)
snapshot_tratativa = models.JSONField(null=True, blank=True)
reaberto_em = models.DateTimeField(null=True, blank=True)
reaberto_motivo = models.TextField(blank=True)
ultima_importacao_em = models.DateTimeField(null=True, blank=True)
class Meta:
verbose_name = "Ação (Não Conformidade)"
verbose_name_plural = "Ações (Não Conformidade)"
ordering = ["vencimento_efetivo"]
constraints = [
models.UniqueConstraint(fields=["ocorrencia", "codigo"], name="nc_acao_unica_por_ocorrencia")
]
def __str__(self) -> str:
return f"{self.ocorrencia.codigo}/{self.codigo}"
class NCAcompanhamento(models.Model):
"""Uma entrada de "Relato(s) do Acompanhamento" de uma `NCAcao`, extraída
do .xls (HTML) de ações — histórico completo (não só a última entrada,
diferente da skill original que gerou o relatório estático). Append-only:
`hash_entrada` (sha256 de data+autor+texto normalizado) deduplica entre
importações repetidas, nunca apaga uma entrada já persistida."""
acao = models.ForeignKey(NCAcao, on_delete=models.CASCADE, related_name="acompanhamentos")
data = models.DateField()
autor = models.CharField(max_length=150, blank=True)
texto = models.TextField()
eh_prorrogacao = models.BooleanField(default=False)
hash_entrada = models.CharField(max_length=64)
criado_em = models.DateTimeField(auto_now_add=True)
class Meta:
verbose_name = "Acompanhamento (Não Conformidade)"
verbose_name_plural = "Acompanhamentos (Não Conformidade)"
ordering = ["data", "id"]
constraints = [
models.UniqueConstraint(fields=["acao", "hash_entrada"], name="nc_acompanhamento_unico")
]
def __str__(self) -> str:
return f"{self.acao} — {self.data:%d/%m/%Y}"

View File

@ -0,0 +1,46 @@
# Changelog — Não Conformidades
> Histórico específico desta aplicação, extraído de `plano.md` (mesma numeração de rodada usada lá, para referência cruzada).
### Rodada 88 — Não Conformidades (Relatórios > Qualidade)
Pedido: a área de Qualidade tinha uma skill do Claude (`analise-ncs`, ver `projects/Controladoria - Analise NCs/`) que gerava, sob demanda, um Excel de 7 abas a partir de dois arquivos exportados do Sigsistem (ocorrências .xlsx + ações .xls, esse último na verdade HTML). O usuário pediu uma aplicação dentro do Portal que evoluísse essa skill pra uma gestão **contínua**: identificar com facilidade novas ocorrências sem análise (cobrança), análises novas sem ação aberta, acompanhamentos de ação recentes ainda sem tratativa da Qualidade, e priorizar ações vencidas/vencendo em 7 dias — com a possibilidade de marcar manualmente o que já foi tratado, e um dashboard quantitativo (ações abertas, motivos de abertura, clientes/colaboradores com maior incidência).
Decisões de negócio confirmadas com o usuário antes de implementar:
- O status de "tratado" pela Qualidade é um controle **interno do Portal**, sem escrita de volta no Sigsistem (sistema externo, sem integração) — mas precisa **reabrir sozinho** quando uma importação nova trouxer algo diferente do que existia no momento em que foi tratado (ex.: novo acompanhamento numa ação já marcada tratada).
- Guardar o **histórico completo** de acompanhamentos de cada ação, não só o último (diferente da skill original).
- "Colaborador com maior incidência" no dashboard = coluna `Indicado para Descrever Análise da Ocorrência`; "motivo de abertura" = `Tipo(s) de Causa(s) da Ocorrência` (não `Tipo de Ocorrência`).
- Permissão de toggle único (mesmo padrão de Indicador de Desempenho/Importação de Plano de Saúde), **restrita por padrão** — só "Integração e Inovação" nasce com acesso, diferente de "Relatório Setorial" (que herda automático via `SECTORAL_KEYS`).
O que foi construído: pacote Python puro `portal_api/nao_conformidades/` (parsing sem pandas, mesmo padrão de `planos_saude`/`indicadores` — validado que o volume real, ~236 ocorrências/290 ações/929 acompanhamentos, não justifica introduzir pandas como dependência direta), portando a lógica de referência do script `gerar_relatorio.py` da skill (inclusive o parsing por regex do `.xls`-que-é-HTML, com uma diferença: aqui se guarda TODAS as entradas de acompanhamento, não só a última). 4 models novos (`NaoConformidadeImportacao`, `NCOcorrencia`, `NCAcao`, `NCAcompanhamento`, migrações `0052`/`0053`), 3 `ModelViewSet` + 1 function view de dashboard, subgrupo `qualidade` novo em `catalogo.MODULE_APPS["relatorios"]` (mesmo formato de subgrupo-com-uma-tool já usado em Auditorias), override em `seed_portal.py` pra restringir o acesso por padrão, e uma tela nova com 3 abas (Gestão/Dashboard/Importar).
**Núcleo técnico — reabertura automática por diff** (`nao_conformidades/diff.py`): cada `NCOcorrencia`/`NCAcao` guarda um `snapshot_tratativa` (congelado no momento em que a Qualidade marca como tratada); a cada importação nova, se o item já estava tratado, o snapshot congelado é comparado contra o estado recém-extraído — ação reabre por comparação de **data** do último acompanhamento (robusto contra reformatação cosmética do export); ocorrência reabre por hash de texto da análise (normalizado) ou por uma ação nova ter sido aberta. Validado contra a amostra real (`projects/Controladoria - Analise NCs/ocorrencias_pa_27082026.xlsx`/`acoes_27082026.xls`) via chamada direta da função de diff simulando um acompanhamento mais recente — reproduziu o motivo textual esperado.
**Validação end-to-end com dados reais**: subiu o servidor local, logou como `gabriel`, fez upload dos dois arquivos de amostra pela API — os totais bateram com o relatório de referência da skill (`Relatorio_Qualidade_20260827.xlsx`) em todos os indicadores (236 ocorrências, 13 sem análise, 8 análise sem ação, 38 NCs sem ação corretiva, 2 ações vencidas, 130 vencendo em ≤7 dias), **exceto** "Total de Ações Abertas" (290 aqui vs. 311 na skill) — a skill original contava também linhas de ocorrência sem nenhuma ação de fato aberta (artefato do script de referência, `todas = v.copy()` sem filtrar por `Código da Ação` presente); esta ferramenta conta só ações reais, decisão deliberada de manter (documentada em `CLAUDE.md`). Reimportar o mesmo arquivo é idempotente (0 novos, tudo "atualizado", 0 acompanhamentos duplicados via hash). Endpoints de marcar-tratado/reabrir (ocorrência e ação) testados via API.
Dois bugs de robustez corrigidos durante a validação com dados reais: (1) `assunto` estourava `CharField(max_length=255)` — um export real trouxe um valor com 395 caracteres (texto livre do Sigsistem, sem limite aparente); virou `TextField`, migração `0053`. (2) o serializer de ocorrência tentava ler `obj.sem_analise` (propriedade que só existe no dataclass `OcorrenciaExtraida`, não no model Django `NCOcorrencia`) — corrigido pra computar `not obj.descricao_analise.strip()` direto no `SerializerMethodField`.
**Não testado nesta rodada**: a interação real em navegador (não havia ferramenta de automação de browser disponível ainda neste ambiente) — a tela foi construída seguindo rigorosamente os padrões visuais/estruturais já validados de `importacao-plano-saude.html`/`indicador-desempenho.html`, o parsing/pipeline/API foram validados de ponta a ponta com dados reais via chamadas HTTP diretas, e o HTML renderizado pelo Django foi conferido — mas o fluxo de clique/preenchimento na tela em si ainda precisava de uma primeira conferência visual. Resolvido na rodada seguinte (ver abaixo).
### Rodada 89 — Redesign de Dashboard e Gestão, a partir de teste real na tela
Depois de testar a tela construída na rodada 88, o usuário deu uma lista concreta de feedback de UX/design. Mudanças:
**Dashboard**:
- Vira a aba padrão ao abrir a tela (era Gestão) — dá o resumo geral primeiro.
- Cards ("Ações vencidas", "Vence em ≤7 dias", "Ocorrências sem análise", "Análises sem ação") viram clicáveis: levam pra Gestão já com o filtro/seção correspondente aplicado (drill-down). "Ocorrências abertas"/"Ações abertas" (totais) e "NCs sem ação corretiva" continuam só informativos — decisão do usuário, sem lista equivalente pra este último.
- Linhas dos 3 rankings (Motivos/Clientes/Colaboradores) também ficam clicáveis: preenchem a busca da Gestão (ver abaixo) com aquele valor.
- Rankings de Clientes/Colaboradores passam a mostrar 3 contagens (NC vermelho, Reclamação de Cliente dourado, Outros neutro) + um total, em vez de um número só — decisão explícita do usuário depois de perguntado se devia restringir o filtro a NC/Reclamação (como "Clientes" já fazia) ou mostrar tudo com clareza visual: "pode trazer tudo nos dois casos... tratar com cores o tipo de ocorrência e depois um totalizador". Um botão `?`/tooltip + legenda (3 bolinhas coloridas) explicam o critério. Endpoint `/dashboard/` mudou o formato de `clientes_maior_incidencia`/`colaboradores_maior_incidencia` de `{..., quantidade}` pra `{..., nc, reclamacao, outros, total}` (`Count` condicional por tipo na mesma `.values().annotate()`); o filtro `tipo_ocorrencia__in=TIPOS_NC` que só "Clientes" tinha antes foi removido (os dois rankings agora agrupam todos os tipos).
- Dois KPIs de tempo médio novos, não interativos, sobre todo o histórico (sem filtro de período): "Preenchimento da análise" (`Avg(data_analise - data_emissao)` em `NCOcorrencia`) e "Abertura da ação" (`Avg(acao.data_emissao - ocorrencia.data_analise)` em `NCAcao`), via `ExpressionWrapper`+`DurationField`+`Avg`. Validado contra a amostra real: ~2,7 e ~18,7 dias. **Sem um terceiro KPI de "tempo médio de resolução"** — checado antes de implementar: `Data de Finalização`/`Dias para Finalização` estão 100% em branco no export real (confirma o que já estava documentado — todas as ações exportadas estão em aberto), e o usuário confirmou explicitamente pra não criar um card pra um dado que não existe ("pode não fazer o card, pois não temos esta informação").
- Seletor de período virou uma fileira de chips (`.ncf-chip`/`.ncf-chip-row`, mesmo padrão visual de `.ind-departamento-chip` de `indicador-desempenho.css`, duplicado aqui porque essa folha não é carregada nesta página) — antes era um `<select>` nativo que destoava do resto do Portal.
**Gestão**:
- Cada linha das 3 listas (Sem Análise/Análise sem Ação/Ações) ganhou um botão de expandir (`.ncf-expand-btn`, chevron que gira 90°, mesmo padrão de `.cc-painel-fiscal__toggle` de `custo-contratacao.css`/`.js`) que revela um painel abaixo da linha (`.ncf-detail-row`), lazy — só busca `GET /ocorrencias/{id}/`ou`/acoes/{id}/` na primeira vez que é aberta.
- Ocorrência: mostra tudo que faltava na linha (relato completo, descrição da análise completa, cliente(s), pessoas/fornecedores relacionados, riscos relacionados, origem, prazo pra finalizar, ações vinculadas) — resolve os dois pedidos separados do usuário ("ver qual é aquela ocorrência" e "ver o texto todo da análise") com um único mecanismo, já que a maioria dos campos (causa, análise, cliente) já vem na própria listagem e só o resto (relato, origem etc.) precisa do fetch. `NCOcorrenciaDetailSerializer` ganhou os campos que faltavam (`origem`, `fornecedores_relacionados`, `riscos_relacionados`, `data_relato`, `representante_gerente`, `prazo_finalizar`); `NCOcorrenciaSerializer` (base, usado pelas listagens) ganhou `emissor_relato` (fixando uma coluna "Emissor" que sempre mostrava "—" desde a rodada 88, por não estar no serializer usado pela lista).
- Ação: removido o botão "Histórico" separado (ficava colado no "Marcar como tratado") e o modal `#ncf-acompanhamentos-modal` — agora é o mesmo painel de expandir, mostrando o texto completo da ação + o histórico de acompanhamentos ordenado do mais recente pro mais antigo, 3 primeiros visíveis com "Mostrar mais (N)" revelando o resto (tudo client-side, a partir do `GET /acoes/{id}/` que já devolve a lista completa).
- A coluna única "Status" das Ações (que misturava vencimento e tratativa, confuso — usuário perguntou "qual o critério de separação") virou duas colunas: **Prazo** (Vencida/Vence em ≤7/≤30 dias/No prazo/Sem vencimento) e **Tratativa** (Pendente/Tratado + selo Reaberto). O filtro virou dois grupos de chips combináveis (Prazo × Tratativa) em vez de uma lista só. Backend: `NCAcaoViewSet` trocou `?vencidas=`/`?vence_em=` por um único `?status_prazo=<bucket>` (`_filtra_status_prazo`, replica os cortes de `classificacao.status_prazo()` como filtro de data sobre `vencimento_efetivo`).
- Campo de busca único no topo da Gestão (`#ncf-gestao-search`) filtra as 3 listas ao mesmo tempo, **client-side** (sobre os dados já carregados, sem round-trip — volume atual não justifica), por colaborador/responsável/emissor/cliente/assunto. `NCAcaoSerializer` ganhou `ocorrencia_clientes`/`ocorrencia_area` (campos só-leitura via `source="ocorrencia.…"`) pra permitir buscar/exibir Ações por empresa — o segundo também corrigiu a coluna "Área" da tabela de Ações, que sempre mostrava "—" desde a rodada 88 (`NCAcao` não tem campo próprio de área, é da ocorrência).
**Bug de corrida corrigido durante o teste**: `ativarTab()` tinha um efeito colateral (carregava a Gestão sozinha ao trocar de aba); os drill-downs do Dashboard chamavam `ativarTab("gestao")` E também recarregavam explicitamente com o filtro escolhido — as duas chamadas concorrentes escreviam na mesma lista em memória, e a que terminasse por último vencia (às vezes a errada, mostrando "Todas" em vez do filtro clicado). Corrigido tornando `ativarTab()` puramente de UI (nunca dispara carregamento) — só quem clica explicitamente num botão de aba ou numa função de drill-down decide carregar, sempre com `await`, nunca em paralelo com outra chamada à mesma lista. Também corrigido um bug de CSS: o painel de detalhe (única `<td>` da linha, portanto sempre "a última") herdava `text-align:right` de `.pa-table td:last-child`, deixando o texto do painel alinhado à direita — resolvido com uma classe própria de maior especificidade (`.pa-table td.ncf-detail-cell`).
**Validado com Playwright** (instalado ad-hoc neste ambiente pra esta rodada — `npx playwright install chromium` + um script Node local, não é dependência do projeto): login, drill-down dos 4 cards, clique numa linha de ranking, expandir ocorrência e ação, alternar tratativa (marcar/reabrir) com o filtro "Tratadas" refletindo a mudança, filtro combinado Prazo+Tratativa+busca — sem erros de console/rede além do 401 esperado da checagem de sessão antes do login.

View File

@ -0,0 +1,96 @@
# CLAUDE.md — Não Conformidades (Relatórios > Qualidade)
Gestão contínua das ocorrências e ações do Sistema de Gestão da Qualidade (Sigsistem, ISO 9001) da De Paula Contadores. Evolui uma skill do Claude (`projects/Controladoria - Analise NCs/analise-ncs.skill`) que gerava um Excel de 7 abas sob demanda, para uma ferramenta persistente dentro do Portal: cada Ocorrência/Ação é upsertada por código a cada importação, com um status interno de tratativa da Qualidade que reabre sozinho quando algo muda.
## Origem — a skill "analise-ncs"
`projects/Controladoria - Analise NCs/` guarda a fonte de verdade das regras de negócio originais:
- `analise-ncs_SKILL.md` — spec completa (conceitos, regras de classificação de cada aba do relatório antigo, formatação).
- `analise-ncs.skill` (zip) → `scripts/gerar_relatorio.py` — script de referência (pandas), `scripts/colunas_xlsx.txt` — as 36 colunas do `.xlsx` de ocorrências.
- `ocorrencias_pa_27082026.xlsx`/`acoes_27082026.xls` — amostras reais usadas para validar o parser novo contra o relatório de referência (`Relatorio_Qualidade_20260827.xlsx`) durante o desenvolvimento.
Todo o parsing deste pacote foi portado (sem pandas, ver "Por que sem pandas" abaixo) linha a linha da lógica desse script, validado batendo os mesmos totais do relatório de referência (ocorrências, sem análise, análise sem ação, NCs sem ação corretiva, ações vencidas) — **exceto** "Total de Ações Abertas", que a skill original contava incluindo linhas de ocorrência sem nenhuma ação de fato aberta (artefato do script — `todas = v.copy()` sem filtrar por `Código da Ação` presente); esta ferramenta conta só ações reais.
## O que MUDA em relação à skill original
1. **Persistência contínua**: `NCOcorrencia`/`NCAcao` (models em `portal_api/models.py`) são upsertadas por código a cada importação, não descartadas depois de gerar um arquivo.
2. **Histórico completo de acompanhamentos**: `NCAcompanhamento` guarda TODAS as entradas de "Relato(s) do Acompanhamento" parseadas do `.xls`, não só a última (que é tudo que a skill original guardava).
3. **Status interno de tratativa da Qualidade** (`status_tratativa`, `pendente`/`tratado`) — não existe no Sigsistem. Fica `tratado` quando a Qualidade marca manualmente (`marcar-tratado`); **reabre sozinho** (`reabertura automática`) se uma importação nova trouxer algo diferente do que existia no momento do tratamento — ver `diff.py`.
## Pacote `portal_api/nao_conformidades/` (Python puro, sem ORM)
| Arquivo | Conteúdo |
|---|---|
| `modelos.py` | Dataclasses `OcorrenciaExtraida`/`AcaoExtraida`/`AcompanhamentoExtraido`/`ResultadoProcessamento` — espelham os models Django campo a campo, sem nenhuma dependência de banco. |
| `leiaute_ocorrencias.py` | Lê o `.xlsx` de ocorrências via `openpyxl` (sem pandas). `COLUNAS_ESPERADAS` lista as 36 colunas — se alguma faltar, vira aviso (não erro), pra tolerar pequenas mudanças de leiaute do Sigsistem. Filtra linhas com `Código da Ocorrência` não-numérico (lixo de fim de export). Detecta "Análise sem Ação" por LINHA (`tem_linha_analise_sem_acao`), não pelo agregado da ocorrência — uma ocorrência pode ter uma linha sem ação com análise preenchida mesmo já tendo outras ações abertas noutra linha. |
| `leiaute_acoes.py` | Lê o `.xls` de ações — **é HTML, não Excel** (`encoding="latin-1"`, nunca abrir com `openpyxl`/leitor de planilha). Parseia por regex os blocos "Ocorrência N / Ação M" e a seção "Relato(s) do Acompanhamento" de cada um. `_separa_autor_texto()` é uma heurística (assume "DD/MM/AAAA - Nome - texto", nome ≤80 chars) — se o padrão não bater, `autor` fica vazio e `texto` guarda tudo, nunca perde informação. |
| `classificacao.py` | `vencimento_efetivo()` (Prazo Prorrogado senão Data da Conclusão da Ação — só muda com importação nova, seguro cachear), `categoria_acao()` (Correção/Ação Corretiva/Outros), `status_prazo()`/`dias_para_vencer()` (**nunca persistidos** — dependem de "hoje", recalculados a cada leitura). |
| `diff.py` | O núcleo da reabertura automática — ver seção própria abaixo. |
| `pipeline.py` | `processa_importacao()` — só parsing/junção dos dois arquivos, não toca no banco. O upsert propriamente dito mora na view (`_aplica_upsert_nao_conformidades` em `portal_api/views.py`), mesma separação de responsabilidade de `planos_saude`/`indicadores`. |
### Por que sem pandas
`planos_saude`/`indicadores` evitam pandas deliberadamente (parsing manual com `openpyxl`/`re`), apesar de `pandas` estar tecnicamente instalado no venv (dependência transitiva, provavelmente do `docling`). Este pacote segue o mesmo padrão — o volume real (amostra validada: 236 ocorrências, 290 ações, 929 entradas de acompanhamento) não justifica a complexidade de introduzir `pandas` como dependência direta de um pacote novo.
### Reabertura automática (`diff.py`)
Cada `NCOcorrencia`/`NCAcao` tem `snapshot_tratativa` (`JSONField`) — o estado relevante congelado no momento em que foi marcada como `tratado` (`snapshot_ocorrencia()`/`snapshot_acao()`). A cada importação nova, se o item já estava `tratado`, `decide_reabertura_ocorrencia()`/`decide_reabertura_acao()` comparam o snapshot congelado contra o estado atual recém-extraído:
- **Ação**: reabre se o **último acompanhamento tem data mais recente** que a congelada (comparação numérica de data, não de texto — robusto contra reformatação cosmética do export), ou se `situacao`/`fase`/`prazo_prorrogado` mudaram no Sigsistem.
- **Ocorrência**: reabre se uma análise foi preenchida pela primeira vez, se o texto da análise mudou (hash de texto normalizado — espaços/capitalização não geram falso positivo, mas uma reformatação mais profunda do mesmo conteúdo pode gerar; risco residual documentado, não eliminado), ou se uma ação nova foi aberta.
Quando reabre, `reaberto_em`/`reaberto_motivo` são preenchidos com o motivo textual (exibido na tela como selo "Reaberto" com tooltip) e `snapshot_tratativa` é limpo — o item volta a `pendente` até a Qualidade tratar de novo.
A tratativa de fato (prorrogar/finalizar uma ação, decidir sobre uma análise) **acontece no Sigsistem**, sistema externo sem integração automática — o Portal só monitora e sinaliza; não há nenhuma escrita de volta pro Sigsistem.
### Upsert (`_aplica_upsert_nao_conformidades`, em `portal_api/views.py`)
Fica na view, não no pacote puro (mesma separação de `planos_saude`/`indicadores`): `update_or_create` de `NCOcorrencia`/`NCAcao` por código, `get_or_create` de `NCAcompanhamento` deduplicado por `hash_entrada` (sha256 de data+autor+texto — idempotente entre reimportações do mesmo arquivo), e é aqui que a checagem de reabertura roda (só quando o item já estava `tratado`). Roda síncrono dentro de `transaction.atomic()`, mesmo padrão de `ImportacaoPlanoSaudeViewSet.create()`/`IndicadorApuracaoViewSet.create()` — volume real processa em bem menos de 1s.
## Models (`portal_api/models.py`)
- **`NaoConformidadeImportacao`** — log de auditoria de cada upload (`arquivo_ocorrencias`/`arquivo_acoes`, `resumo` JSONField com contadores, `avisos`). Diferente de `ImportacaoPlanoSaude`, **não é "dona"** das ocorrências/ações — excluir uma importação (`DELETE`) remove só o log e os 2 arquivos de `MEDIA_ROOT`, nunca `NCOcorrencia`/`NCAcao` (entidades contínuas, upsertadas por código, sobrevivem à importação que as trouxe).
- **`NCOcorrencia`** — upsert por `codigo` (único). Campos espelham as colunas do export + `status_tratativa`/`snapshot_tratativa`/`tratado_*`/`reaberto_*` (controle interno) + `analise_sem_acao_detectada` (persistido do parsing, não re-derivável de forma confiável só a partir de `descricao_analise`+`acoes.count()` porque uma ocorrência pode ter linhas mistas).
- **`NCAcao`** — FK `NCOcorrencia`, upsert por `(ocorrencia, codigo)`. `vencimento_efetivo`/`ultimo_acompanhamento_em`/`ultimo_acompanhamento_eh_prorrogacao` são cache seguro (só mudam com importação nova); `status_tratativa`/`snapshot_tratativa`/`tratado_*`/`reaberto_*` iguais à ocorrência.
- **`NCAcompanhamento`** — FK `NCAcao`, append-only (`UniqueConstraint(acao, hash_entrada)` deduplica reimportações, nunca apaga uma entrada já persistida).
`assunto` é `TextField`, não `CharField` — export real trouxe valores com mais de 390 caracteres (texto livre do Sigsistem, sem limite aparente).
## API (`portal_api/views.py`/`urls.py`)
Todos os endpoints sob `PermissaoApp("relatorios", "nao-conformidades")` (toggle único).
| Endpoint | Método | Uso |
|---|---|---|
| `/api/nao-conformidades/importacoes/` | GET/POST | histórico + nova importação (multipart, 2 arquivos); roda o pipeline síncrono + upsert, devolve o `resumo` |
| `/api/nao-conformidades/importacoes/{id}/` | DELETE | remove só o log + os 2 arquivos |
| `/api/nao-conformidades/ocorrencias/` | GET | filtros `?sem_analise=true`, `?analise_sem_acao=true`, `?status_tratativa=`, `?search=` |
| `/api/nao-conformidades/ocorrencias/{id}/marcar-tratado/`, `/reabrir/` | POST | congela snapshot / reabre manualmente |
| `/api/nao-conformidades/acoes/` | GET | filtros `?status_tratativa=`, `?status_prazo=<bucket>` (`vencida`/`vence_7_dias`/`vence_30_dias`/`no_prazo`/`sem_vencimento`, replica `classificacao.status_prazo()` como filtro de data via `_filtra_status_prazo`), `?search=`; inclui `status_prazo`/`status_prazo_label`/`dias_para_vencer`/`ocorrencia_area`/`ocorrencia_clientes` calculados no serializer (nunca persistidos) |
| `/api/nao-conformidades/acoes/{id}/` | GET | detalhe com `acompanhamentos` completo (histórico) |
| `/api/nao-conformidades/acoes/{id}/marcar-tratado/`, `/reabrir/` | POST | idem ocorrência |
| `/api/nao-conformidades/dashboard/` | GET | `?meses=` (default 6) ou `?data_inicio=&data_fim=`; indicadores "abertos agora" (nunca filtrados por período) + `tempos_medios` (2 KPIs, sobre todo o histórico, nunca filtrados por período) + rankings de motivo (`tipos_causa`)/cliente (`clientes_relacionados`)/colaborador (`indicado_analise`) filtrados pelo período — clientes/colaboradores vêm quebrados por tipo (`{nome, nc, reclamacao, outros, total}`) |
## Frontend (`templates/nao-conformidades.html`, `static/{css,js}/nao-conformidades.js`)
Uma página com 3 abas (`.pa-tabs`, reaproveitado de `perfis-acesso.css`): **Dashboard** (default — cards, KPIs de tempo, seletor de período em chips, 3 rankings), **Gestão** (Sem Análise/Análise sem Ação/Ações) e **Importar** (histórico + upload). Gestão/Importar carregam sob demanda (lazy, só na primeira vez que a aba é aberta ou via drill-down do Dashboard).
- **Drill-down Dashboard → Gestão**: os 4 cards com lista equivalente ("Ações vencidas", "Vence em ≤7 dias", "Ocorrências sem análise", "Análises sem ação") e as linhas dos 3 rankings são clicáveis — trocam pra Gestão já filtrada/rolada até a seção certa. Ver `irParaAcoesComPrazo`/`irParaSecaoGestao`/`irParaGestaoComBusca` em `nao-conformidades.js`. **Cuidado com corrida**: `ativarTab()` é **só UI** (nunca dispara carregamento) de propósito — um bug real já aconteceu aqui quando ela tinha esse efeito colateral e corria em paralelo com o carregamento explícito do drill-down, e a chamada que terminasse por último vencia (às vezes sobrescrevendo o filtro certo). Qualquer novo caminho que leve à Gestão deve sempre `await` o carregamento, nunca disparar dois carregamentos concorrentes da mesma lista.
- **Filtro combinável de Ações**: duas fileiras de chips independentes, Prazo (`?status_prazo=`) e Tratativa (`?status_tratativa=`), combinam com AND (dois `<select>` de fato seriam mais familiares, mas o padrão chip já é o mesmo do período do Dashboard). A tabela mostra as duas dimensões em colunas separadas ("Prazo" e "Tratativa") — nunca juntar num "Status" só, é exatamente essa mistura que gerou confusão antes.
- **Expandir linha** (`pidNcfCriarLinhaExpandivel`, mesmo padrão de `.cc-painel-fiscal__toggle` de `custo-contratacao.css`/`.js` — chevron que gira via `[aria-expanded="true"] svg{transform:rotate(90deg)}`) é o único mecanismo de "ver mais" das 3 listas: ocorrência (relato completo, causa, cliente, riscos etc. — parte já vem da própria listagem, o resto é buscado lazy em `GET /ocorrencias/{id}/`) e ação (texto completo + histórico de acompanhamentos, mais recente primeiro, 3 visíveis + "Mostrar mais", buscado lazy em `GET /acoes/{id}/`). Não usar modal pra isso — o modal de histórico existiu numa rodada anterior e foi removido a pedido do usuário (ficava colado no botão "Marcar como tratado").
- **Busca compartilhada da Gestão** (`#ncf-gestao-search`): filtra as 3 listas ao mesmo tempo, sempre client-side sobre os dados já carregados (sem round-trip) — colaborador/responsável/emissor/cliente/assunto.
Reaproveita `.status-pill` (definido em `perfis-acesso.css`) com modificadores novos (`--ok`/`--danger`/`--warning`/`--neutral`, em `nao-conformidades.css`) em vez de inventar um componente de badge próprio. Chips de filtro/período (`.ncf-chip`/`.ncf-chip-row`) duplicam o visual de `.ind-departamento-chip` (`indicador-desempenho.css`) porque essa folha não é carregada nesta página.
**Cuidado com especificidade CSS**: `.pa-table td:last-child{text-align:right}` (de `perfis-acesso.css`) pega qualquer `<td>` que seja a única célula de uma linha — inclusive a célula do painel de detalhe (que usa `colspan`, então é sempre "a última"). Um bug real de alinhamento já aconteceu por isso; a correção foi dar à célula do painel uma classe própria com especificidade maior (`.pa-table td.ncf-detail-cell`), não confiar em `.ncf-detail-row td` (mesma especificidade da regra original, perderia).
## Permissão e menu
`catalogo.py`: subgrupo `qualidade` dentro de `MODULE_APPS["relatorios"]`, com uma única tool `nao-conformidades` — mesmo formato de subgrupo com toggle único já usado em Auditorias (`consultoria-tributaria` → `controle-simples-nacional`). **Restrito por padrão**: como `relatorios` está em `SECTORAL_KEYS` (herdaria `True` automático pra todo perfil setorial), `seed_portal.py` força `permissoes["relatorios"]["apps"]["nao-conformidades"] = False` pra todo perfil que não seja "Integração e Inovação" (código 8) — liberar outros perfis é manual, pela tela de Perfis de Acesso.
## Riscos conhecidos
1. **Falso positivo na reabertura de ocorrência por texto**: reformatação profunda (não cosmética) do mesmo conteúdo de análise pelo Sigsistem pode gerar hash diferente e reabrir sem necessidade real. Não validado contra múltiplos exports reais consecutivos da mesma ocorrência tratada — se acontecer na prática, considerar comparação mais tolerante (ex.: similaridade de texto) em vez de hash exato.
2. **`NCOcorrencia.codigo`/`NCAcao.codigo` como `PositiveIntegerField`**: assumido do comportamento do parser de referência, validado só contra a amostra de 236 ocorrências/290 ações. Se um export real trouxer código não-numérico, a linha é descartada silenciosamente (mesmo comportamento de "linha inválida" da skill original) — considerar `CharField` se isso acontecer.
3. **Upload síncrono**: aceitável no volume validado (processa em bem menos de 1s); se o Sigsistem exportar volumes muito maiores no futuro, reavaliar fila assíncrona (nenhum outro pacote do Portal usa hoje).

View File

@ -0,0 +1,19 @@
# Não Conformidades (Relatórios > Qualidade)
> Ver `CLAUDE.md` nesta mesma pasta para o detalhamento técnico completo (models, pipeline, lógica de reabertura automática). Ver `CLAUDE.md` na raiz para arquitetura geral/transversal do Portal. Ver também `projects/Controladoria - Analise NCs/analise-ncs_SKILL.md` para a regra de negócio original (skill que esta ferramenta evolui).
Ferramenta da área de Qualidade — gestão contínua das ocorrências e ações do Sistema de Gestão da Qualidade (Sigsistem, ISO 9001), substituindo um relatório Excel estático que era gerado sob demanda por uma skill do Claude. A partir de dois arquivos exportados periodicamente do Sigsistem (ocorrências + ações), a ferramenta:
- Mantém cada ocorrência/ação como registro contínuo (upsert por código), não descarta nada entre importações.
- Sinaliza o que precisa de atenção: ocorrências sem análise, análises sem ação aberta, ações vencidas/vencendo em 7 dias, e acompanhamentos novos que a Qualidade ainda não tratou.
- Deixa a Qualidade marcar manualmente o que já foi tratado — e reabre sozinha se uma importação nova trouxer algo diferente do que existia quando foi tratado.
- Guarda o histórico completo de acompanhamentos de cada ação (não só o último).
- Oferece um dashboard com indicadores, motivos de abertura, e ranking de clientes/colaboradores com maior incidência.
Acesso restrito por padrão (só "Integração e Inovação" nasce com permissão) — é ferramenta de uma pessoa/área específica, liberada manualmente pra outros perfis pela tela de Perfis de Acesso.
## Onde mexer
- `portal_api/nao_conformidades/` — `modelos.py` (dataclasses), `leiaute_ocorrencias.py`/`leiaute_acoes.py` (parsing dos dois arquivos do Sigsistem), `classificacao.py` (vencimento/status de prazo), `diff.py` (reabertura automática), `pipeline.py` (orquestração).
- `NaoConformidadeImportacao`, `NCOcorrencia`, `NCAcao`, `NCAcompanhamento` (`portal_api/models.py`).
- `nao-conformidades.html`/`static/js/nao-conformidades.js`/`static/css/nao-conformidades.css`.

View File

View File

@ -0,0 +1,66 @@
"""Regras de classificação de vencimento/prazo/categoria de ação — porta as
regras de negócio da skill "analise-ncs" (ver
`projects/Controladoria - Analise NCs/analise-ncs_SKILL.md`).
IMPORTANTE: `status_prazo()`/`dias_para_vencer()` dependem da data de hoje —
NUNCA persistir o resultado no banco, sempre calcular em tempo de leitura
(serializer/dashboard). Só `vencimento_efetivo()` é seguro cachear num model
(só muda com uma importação nova)."""
from __future__ import annotations
from datetime import date
STATUS_VENCIDA = "vencida"
STATUS_VENCE_7 = "vence_7_dias"
STATUS_VENCE_30 = "vence_30_dias"
STATUS_NO_PRAZO = "no_prazo"
STATUS_SEM_VENCIMENTO = "sem_vencimento"
STATUS_PRAZO_LABELS = {
STATUS_VENCIDA: "Vencida",
STATUS_VENCE_7: "Vence em ≤7 dias",
STATUS_VENCE_30: "Vence em ≤30 dias",
STATUS_NO_PRAZO: "No prazo",
STATUS_SEM_VENCIMENTO: "Sem vencimento",
}
CATEGORIA_CORRETIVA = "Ação Corretiva"
CATEGORIA_CORRECAO = "Correção"
CATEGORIA_OUTROS = "Outros"
# Tipos de ocorrência que exigem Correção + Ação Corretiva (regra #9 da skill).
TIPOS_NC = ("Não Conformidade", "Reclamação de Cliente")
def vencimento_efetivo(prazo_prorrogado: date | None, data_conclusao: date | None) -> date | None:
"""Prazo Prorrogado se preenchido, senão Data da Conclusão da Ação (que no
export é a data-limite prevista, não a conclusão real)."""
return prazo_prorrogado or data_conclusao
def categoria_acao(tipo_acao: str) -> str:
tl = (tipo_acao or "").lower()
if "corretiva" in tl:
return CATEGORIA_CORRETIVA
if "correç" in tl or "correc" in tl:
return CATEGORIA_CORRECAO
return CATEGORIA_OUTROS
def status_prazo(venc: date | None, hoje: date) -> str:
if venc is None:
return STATUS_SEM_VENCIMENTO
if venc < hoje:
return STATUS_VENCIDA
dias = (venc - hoje).days
if dias <= 7:
return STATUS_VENCE_7
if dias <= 30:
return STATUS_VENCE_30
return STATUS_NO_PRAZO
def dias_para_vencer(venc: date | None, hoje: date) -> int | None:
if venc is None:
return None
return (venc - hoje).days

View File

@ -0,0 +1,79 @@
"""Lógica de reabertura automática: quando um item (Ocorrência ou Ação) já
marcado como "tratado" pela Qualidade recebe, numa importação nova, algo
diferente do que existia no momento do tratamento, ele volta sozinho para
"pendente" — porque a tratativa real (prorrogar/finalizar uma ação, decidir
sobre uma análise) acontece no Sigsistem, sistema externo sem integração; o
Portal só monitora e precisa saber quando "o que a Qualidade já viu" ficou
desatualizado.
`snapshot_*()` é o que fica congelado em `NCOcorrencia.snapshot_tratativa`/
`NCAcao.snapshot_tratativa` no momento em que o item é marcado como tratado.
`decide_reabertura_*()` compara esse snapshot contra o estado atual (recém
extraído de uma importação nova) e devolve o motivo textual da reabertura, ou
`None` se nada relevante mudou."""
from __future__ import annotations
import hashlib
import re
from datetime import date
from .modelos import AcaoExtraida, OcorrenciaExtraida
def _normaliza_texto(texto: str) -> str:
return re.sub(r"\s+", " ", (texto or "").strip().lower())
def hash_texto(texto: str) -> str:
return hashlib.sha256(_normaliza_texto(texto).encode("utf-8")).hexdigest()
def snapshot_ocorrencia(ocorrencia: OcorrenciaExtraida) -> dict:
return {
"tem_analise": bool(ocorrencia.descricao_analise.strip()),
"hash_analise": hash_texto(ocorrencia.descricao_analise),
"codigos_acao": sorted(a.codigo for a in ocorrencia.acoes),
}
def decide_reabertura_ocorrencia(snapshot_congelado: dict, snapshot_atual: dict) -> str | None:
if snapshot_atual["tem_analise"] and not snapshot_congelado.get("tem_analise"):
return "Análise foi preenchida após o tratamento."
if snapshot_congelado.get("tem_analise") and snapshot_atual["hash_analise"] != snapshot_congelado.get(
"hash_analise"
):
return "O texto da análise mudou após o tratamento."
novos = sorted(set(snapshot_atual["codigos_acao"]) - set(snapshot_congelado.get("codigos_acao", [])))
if novos:
return "Nova(s) ação(ões) aberta(s): " + ", ".join(str(c) for c in novos) + "."
return None
def snapshot_acao(acao: AcaoExtraida) -> dict:
ultimo = acao.ultimo_acompanhamento
return {
"ultimo_acompanhamento_em": ultimo.data.isoformat() if ultimo else None,
"situacao": acao.situacao,
"fase": acao.fase,
"prazo_prorrogado": acao.prazo_prorrogado.isoformat() if acao.prazo_prorrogado else None,
"data_conclusao": acao.data_conclusao.isoformat() if acao.data_conclusao else None,
}
def decide_reabertura_acao(snapshot_congelado: dict, snapshot_atual: dict) -> str | None:
congelado_str = snapshot_congelado.get("ultimo_acompanhamento_em")
atual_str = snapshot_atual.get("ultimo_acompanhamento_em")
congelado = date.fromisoformat(congelado_str) if congelado_str else None
atual = date.fromisoformat(atual_str) if atual_str else None
# Comparação por DATA (não texto) do último acompanhamento — robusto
# contra reformatação cosmética do export, mesmo raciocínio que a skill
# original já usa pra identificar "o último acompanhamento".
if atual and (not congelado or atual > congelado):
return f"Novo acompanhamento registrado em {atual:%d/%m/%Y}."
rotulos = {"situacao": "Situação", "fase": "Fase", "prazo_prorrogado": "Prazo Prorrogado"}
for campo, rotulo in rotulos.items():
valor_congelado = snapshot_congelado.get(campo)
valor_atual = snapshot_atual.get(campo)
if valor_atual != valor_congelado:
return f'{rotulo} mudou no Sigsistem (de "{valor_congelado or "—"}" para "{valor_atual or "—"}").'
return None

View File

@ -0,0 +1,90 @@
"""Leitura do .xls de Ações exportado do Sigsistem — única fonte confiável dos
relatos de acompanhamento (data do último andamento de cada ação).
**O arquivo NÃO é Excel de verdade, é HTML** (apesar da extensão `.xls`) —
nunca abrir com `openpyxl`/leitor de planilha. Porta quase 1:1 a lógica de
parsing por regex do script de referência
(`projects/Controladoria - Analise NCs/analise-ncs.skill` →
`scripts/gerar_relatorio.py`). Diferença chave em relação ao script original:
aqui devolvemos TODAS as entradas de acompanhamento por (ocorrência, ação),
não só a última — a skill original só guardava a última porque gerava um
relatório estático; esta ferramenta precisa do histórico completo (ver
CLAUDE.md do pacote)."""
from __future__ import annotations
import re
from datetime import datetime
from .modelos import AcompanhamentoExtraido
_BLOCO_REGEX = re.compile(r"Ocorr[êe]ncia\s*(\d+)\s*/\s*A[çc][ãa]o\s*(\d+)")
_RELATOS_REGEX = re.compile(r"Relato\(s\) do Acompanhamento(.*?)(Ocorr[êe]ncia\s*\d+\s*/|\Z)", re.S)
_TAG_REGEX = re.compile(r"<[^>]+>")
_ESPACOS_REGEX = re.compile(r"\s+")
_ENTRADA_REGEX = re.compile(r"(\d{2}/\d{2}/\d{4})\s*-\s*")
_ENTRADA_PREFIXO_REGEX = re.compile(r"^\d{2}/\d{2}/\d{4}\s*-\s*")
_PRORROGACAO_REGEX = re.compile(r"(Inclus[ãa]o|Altera[çc][ãa]o) de Prorroga[çc][ãa]o de Prazo", re.I)
def _eh_prorrogacao(texto: str) -> bool:
return bool(_PRORROGACAO_REGEX.search(texto))
def _separa_autor_texto(bruto: str) -> tuple[str, str]:
"""Cada entrada começa como "DD/MM/AAAA - Nome - texto". Separação
heurística (o nome nunca deveria ter mais de ~80 caracteres) — se não
achar o padrão, devolve autor vazio e o texto inteiro sem a data."""
sem_data = _ENTRADA_PREFIXO_REGEX.sub("", bruto, count=1).strip()
partes = sem_data.split(" - ", 1)
if len(partes) == 2 and len(partes[0]) <= 80:
return partes[0].strip(), partes[1].strip()
return "", sem_data
def _parse_entradas(corpo: str) -> list[tuple[datetime, str]]:
m = _RELATOS_REGEX.search(corpo)
segmento = _TAG_REGEX.sub(" ", m.group(1)) if m else ""
segmento = _ESPACOS_REGEX.sub(" ", segmento).strip()
marcadores = [(mt.start(), mt.group(1)) for mt in _ENTRADA_REGEX.finditer(segmento)]
entradas = []
for i, (pos, data_str) in enumerate(marcadores):
fim = marcadores[i + 1][0] if i + 1 < len(marcadores) else len(segmento)
try:
data = datetime.strptime(data_str, "%d/%m/%Y")
except ValueError:
continue
entradas.append((data, segmento[pos:fim]))
return entradas
def parse_acompanhamentos(caminho: str) -> dict[tuple[str, str], list[AcompanhamentoExtraido]]:
"""Devolve todas as entradas de acompanhamento, indexadas por
(código da ocorrência, código da ação) — chaves como string, mesmo
formato do script de referência, já que vêm direto do texto do bloco."""
with open(caminho, encoding="latin-1") as f:
html = f.read()
partes = _BLOCO_REGEX.split(html)
resultado: dict[tuple[str, str], list[AcompanhamentoExtraido]] = {}
for k in range(1, len(partes) - 2, 3):
codigo_ocorrencia = partes[k].strip()
codigo_acao = partes[k + 1].strip()
corpo = partes[k + 2]
entradas = _parse_entradas(corpo)
if not entradas:
continue
entradas.sort(key=lambda item: item[0])
acompanhamentos = []
for data, bruto in entradas:
autor, texto = _separa_autor_texto(bruto)
acompanhamentos.append(
AcompanhamentoExtraido(
data=data.date(),
autor=autor,
texto=texto,
eh_prorrogacao=_eh_prorrogacao(bruto),
)
)
resultado[(codigo_ocorrencia, codigo_acao)] = acompanhamentos
return resultado

View File

@ -0,0 +1,209 @@
"""Leitura do .xlsx de Ocorrências exportado do Sigsistem (dados estruturados,
36 colunas fixas — ver `colunas_xlsx.txt` dentro de
`projects/Controladoria - Analise NCs/analise-ncs.skill`).
Porta a lógica de limpeza/agrupamento do script de referência
(`gerar_relatorio.py`, dentro do mesmo `.skill`) para `openpyxl` puro, sem
pandas — mesma escolha das demais ferramentas do Portal (ver
`portal_api/planos_saude/`, `portal_api/indicadores/`)."""
from __future__ import annotations
import re
from datetime import date, datetime
from openpyxl import load_workbook
from .modelos import AcaoExtraida, OcorrenciaExtraida
COLUNAS_ESPERADAS = [
"Código da Ocorrência",
"Data de Emissão da Ocorrência",
"Assunto",
"Tipo de Ocorrência",
"Pessoa(s) Relacionado(s)",
"Origem da Ocorrência",
"Fornecedor(es) Relacionado(s)",
"Cliente(s) Relacionado(s)",
"Área",
"Setor",
"Risco(s) Relacionado(s)",
"Data do Relato",
"Relato",
"Emissor Relato",
"Representante / Gerente",
"Prazo para Finalizar a Ocorrência",
"Indicado para Descrever Análise da Ocorrência",
"Tipo(s) de Causa(s) da Ocorrência",
"Descrição da Análise da Ocorrência",
"Responsável pela Descrição da Análise da Ocorrência",
"Data da Análise da Ocorrência",
"Código da Ação",
"Data de Emissão da Ação",
"Tipo de Ação",
"Ação",
"Data da Conclusão da Ação",
"Prazo Prorrogado",
"Justificativa de Prorrogação",
"Executor da Ação",
"Emissor Ação",
"Indicado para Autorizar/Aprovar",
"Responsável pela Autorização/Aprovação",
"Data de Finalização",
"Dias para Finalização",
"Situação",
"Fase",
]
# Valores-lixo que aparecem em blocos de fim de export antigos (regra #1 da
# skill original) — tratados como nulos.
_VALORES_LIXO = {"------", "0", "0,0%", "0.0%", 0, 0.0}
_DATA_REGEX = re.compile(r"(\d{2})/(\d{2})/(\d{4})")
def _limpar(valor):
if valor is None:
return None
if isinstance(valor, str):
texto = valor.strip()
if not texto or texto in _VALORES_LIXO:
return None
return texto
if valor in _VALORES_LIXO:
return None
return valor
def _texto(valor) -> str:
limpo = _limpar(valor)
return "" if limpo is None else str(limpo).strip()
def _parse_data(valor) -> date | None:
limpo = _limpar(valor)
if limpo is None:
return None
if isinstance(limpo, datetime):
return limpo.date()
if isinstance(limpo, date):
return limpo
# A coluna de emissão vem com data+hora misturadas em texto — pegar só a
# data (regra #2 da skill original).
m = _DATA_REGEX.search(str(limpo))
if not m:
return None
dia, mes, ano = m.groups()
try:
return date(int(ano), int(mes), int(dia))
except ValueError:
return None
def _codigo_int(valor) -> int | None:
limpo = _limpar(valor)
if limpo is None:
return None
try:
return int(float(limpo))
except (TypeError, ValueError):
return None
def parse_ocorrencias(caminho: str) -> tuple[list[OcorrenciaExtraida], list[str]]:
"""Lê o .xlsx de Ocorrências e devolve as ocorrências agrupadas (com suas
ações aninhadas) na ordem em que apareceram no arquivo, mais uma lista de
avisos (leiaute inesperado, arquivo vazio etc.). O histórico de
acompanhamentos de cada ação é populado depois, por
`pipeline.processa_importacao` (vem do outro arquivo, o .xls de ações)."""
avisos: list[str] = []
wb = load_workbook(caminho, read_only=True, data_only=True)
try:
ws = wb.active
linhas = ws.iter_rows(values_only=True)
cabecalho = next(linhas, None)
if cabecalho is None:
return [], ["Planilha de ocorrências está vazia."]
colunas = [str(c).strip() if c is not None else "" for c in cabecalho]
indice = {nome: i for i, nome in enumerate(colunas) if nome}
faltando = [c for c in COLUNAS_ESPERADAS if c not in indice]
if faltando:
avisos.append(
"Colunas ausentes na planilha de ocorrências (leiaute pode ter mudado): " + ", ".join(faltando)
)
def campo(linha, nome):
i = indice.get(nome)
return linha[i] if i is not None and i < len(linha) else None
ocorrencias: dict[int, OcorrenciaExtraida] = {}
ordem_ocorrencias: list[int] = []
for linha in linhas:
if linha is None or all(v is None for v in linha):
continue
codigo = _codigo_int(campo(linha, "Código da Ocorrência"))
if codigo is None:
continue # linha inválida/lixo de fim de export (regra #1 da skill)
if codigo not in ocorrencias:
ocorrencias[codigo] = OcorrenciaExtraida(
codigo=codigo,
data_emissao=_parse_data(campo(linha, "Data de Emissão da Ocorrência")),
assunto=_texto(campo(linha, "Assunto")),
tipo_ocorrencia=_texto(campo(linha, "Tipo de Ocorrência")),
pessoas_relacionadas=_texto(campo(linha, "Pessoa(s) Relacionado(s)")),
origem=_texto(campo(linha, "Origem da Ocorrência")),
fornecedores_relacionados=_texto(campo(linha, "Fornecedor(es) Relacionado(s)")),
clientes_relacionados=_texto(campo(linha, "Cliente(s) Relacionado(s)")),
area=_texto(campo(linha, "Área")),
setor=_texto(campo(linha, "Setor")),
riscos_relacionados=_texto(campo(linha, "Risco(s) Relacionado(s)")),
data_relato=_parse_data(campo(linha, "Data do Relato")),
relato=_texto(campo(linha, "Relato")),
emissor_relato=_texto(campo(linha, "Emissor Relato")),
representante_gerente=_texto(campo(linha, "Representante / Gerente")),
prazo_finalizar=_parse_data(campo(linha, "Prazo para Finalizar a Ocorrência")),
indicado_analise=_texto(campo(linha, "Indicado para Descrever Análise da Ocorrência")),
tipos_causa=_texto(campo(linha, "Tipo(s) de Causa(s) da Ocorrência")),
descricao_analise=_texto(campo(linha, "Descrição da Análise da Ocorrência")),
responsavel_analise=_texto(campo(linha, "Responsável pela Descrição da Análise da Ocorrência")),
data_analise=_parse_data(campo(linha, "Data da Análise da Ocorrência")),
)
ordem_ocorrencias.append(codigo)
ocorrencia = ocorrencias[codigo]
codigo_acao = _codigo_int(campo(linha, "Código da Ação"))
if codigo_acao is None:
# Linha da ocorrência sem ação aberta ainda — se já tem análise
# 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
continue
ocorrencia.acoes.append(
AcaoExtraida(
codigo=codigo_acao,
data_emissao=_parse_data(campo(linha, "Data de Emissão da Ação")),
tipo_acao=_texto(campo(linha, "Tipo de Ação")),
acao_texto=_texto(campo(linha, "Ação")),
data_conclusao=_parse_data(campo(linha, "Data da Conclusão da Ação")),
prazo_prorrogado=_parse_data(campo(linha, "Prazo Prorrogado")),
justificativa_prorrogacao=_texto(campo(linha, "Justificativa de Prorrogação")),
executor=_texto(campo(linha, "Executor da Ação")),
emissor_acao=_texto(campo(linha, "Emissor Ação")),
indicado_autorizar=_texto(campo(linha, "Indicado para Autorizar/Aprovar")),
responsavel_autorizacao=_texto(campo(linha, "Responsável pela Autorização/Aprovação")),
data_finalizacao=_parse_data(campo(linha, "Data de Finalização")),
dias_finalizacao=_codigo_int(campo(linha, "Dias para Finalização")),
situacao=_texto(campo(linha, "Situação")),
fase=_texto(campo(linha, "Fase")),
)
)
if not ordem_ocorrencias:
avisos.append("Nenhuma linha válida encontrada (nenhum \"Código da Ocorrência\" numérico).")
return [ocorrencias[c] for c in ordem_ocorrencias], avisos
finally:
wb.close()

View File

@ -0,0 +1,88 @@
"""Dataclasses puras (sem ORM) do resultado de parsing dos dois arquivos do
Sigsistem — espelham 1:1 os campos dos models Django `NCOcorrencia`/`NCAcao`/
`NCAcompanhamento` (ver portal_api/models.py), mas sem nenhuma dependência de
banco. A view (`NaoConformidadeImportacaoViewSet.create`) é quem faz o upsert
a partir daqui."""
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import date
@dataclass
class AcompanhamentoExtraido:
data: date
autor: str
texto: str
eh_prorrogacao: bool
@dataclass
class AcaoExtraida:
codigo: int
data_emissao: date | None
tipo_acao: str
acao_texto: str
data_conclusao: date | None
prazo_prorrogado: date | None
justificativa_prorrogacao: str
executor: str
emissor_acao: str
indicado_autorizar: str
responsavel_autorizacao: str
data_finalizacao: date | None
dias_finalizacao: int | None
situacao: str
fase: str
acompanhamentos: list[AcompanhamentoExtraido] = field(default_factory=list)
@property
def vencimento_efetivo(self) -> date | None:
return self.prazo_prorrogado or self.data_conclusao
@property
def ultimo_acompanhamento(self) -> AcompanhamentoExtraido | None:
if not self.acompanhamentos:
return None
return max(self.acompanhamentos, key=lambda a: a.data)
@dataclass
class OcorrenciaExtraida:
codigo: int
data_emissao: date | None
assunto: str
tipo_ocorrencia: str
pessoas_relacionadas: str
origem: str
fornecedores_relacionados: str
clientes_relacionados: str
area: str
setor: str
riscos_relacionados: str
data_relato: date | None
relato: str
emissor_relato: str
representante_gerente: str
prazo_finalizar: date | None
indicado_analise: str
tipos_causa: str
descricao_analise: str
responsavel_analise: str
data_analise: date | None
acoes: list[AcaoExtraida] = field(default_factory=list)
# True quando alguma linha do export trouxe análise preenchida numa linha
# SEM código de ação (equivalente à aba "Análise sem Ação" da skill
# 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
@property
def sem_analise(self) -> bool:
return not self.descricao_analise.strip()
@dataclass
class ResultadoProcessamento:
ocorrencias: list[OcorrenciaExtraida]
avisos: list[str]

View File

@ -0,0 +1,22 @@
"""Orquestração: lê os dois arquivos exportados do Sigsistem e devolve as
ocorrências já com as ações e o histórico completo de acompanhamentos
aninhados. Não toca no banco — o upsert acontece na view
(`NaoConformidadeImportacaoViewSet.create`, em `portal_api/views.py`), mesma
separação de responsabilidade de `portal_api.planos_saude`/
`portal_api.indicadores`."""
from __future__ import annotations
from . import leiaute_acoes, leiaute_ocorrencias
from .modelos import ResultadoProcessamento
def processa_importacao(caminho_ocorrencias: str, caminho_acoes: str) -> ResultadoProcessamento:
ocorrencias, avisos = leiaute_ocorrencias.parse_ocorrencias(caminho_ocorrencias)
acompanhamentos_por_chave = leiaute_acoes.parse_acompanhamentos(caminho_acoes)
for ocorrencia in ocorrencias:
for acao in ocorrencia.acoes:
chave = (str(ocorrencia.codigo), str(acao.codigo))
acao.acompanhamentos = acompanhamentos_por_chave.get(chave, [])
return ResultadoProcessamento(ocorrencias=ocorrencias, avisos=avisos)

View File

@ -2,12 +2,14 @@ from typing import Any
import nh3
from django.db.models import Q
from django.utils import timezone
from rest_framework import serializers
from rest_framework.validators import UniqueTogetherValidator
from . import catalogo
from .empresas_questor import normalizar_codigo_empresa, resolve_nome_empresa
from .indicadores import calculo as indicadores_calculo
from .nao_conformidades import classificacao as nc_classificacao
from .models import (
AcessoGeral,
AcessoGeralSecao,
@ -32,6 +34,10 @@ from .models import (
IndicadorPercentualTipo,
LinkFerramenta,
LinkFerramentaFavorito,
NaoConformidadeImportacao,
NCAcao,
NCAcompanhamento,
NCOcorrencia,
NotificacaoDispensada,
ParametroFiscalCustoContratacao,
PerfilAcesso,
@ -1582,3 +1588,149 @@ class ParametroFiscalCustoContratacaoSerializer(serializers.ModelSerializer):
def validate_faixas_irrf(self, valor: Any) -> list[dict[str, float]]:
return _valida_faixas(valor, "IRRF")
class NCAcompanhamentoSerializer(serializers.ModelSerializer):
class Meta:
model = NCAcompanhamento
fields = ["id", "data", "autor", "texto", "eh_prorrogacao"]
class NCAcaoSerializer(serializers.ModelSerializer):
"""`status_prazo`/`dias_para_vencer` são sempre calculados em tempo de
leitura (nunca persistidos — ver NCAcao.vencimento_efetivo em models.py e
o motivo em nao_conformidades/classificacao.py)."""
ocorrencia_codigo = serializers.IntegerField(source="ocorrencia.codigo", read_only=True)
ocorrencia_assunto = serializers.CharField(source="ocorrencia.assunto", read_only=True)
# Permite filtrar/buscar Ações por empresa no frontend (busca compartilhada
# 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)
status_prazo = serializers.SerializerMethodField()
status_prazo_label = serializers.SerializerMethodField()
dias_para_vencer = serializers.SerializerMethodField()
class Meta:
model = NCAcao
fields = [
"id",
"ocorrencia",
"ocorrencia_codigo",
"ocorrencia_assunto",
"ocorrencia_clientes",
"ocorrencia_area",
"codigo",
"data_emissao",
"tipo_acao",
"acao_texto",
"data_conclusao",
"prazo_prorrogado",
"justificativa_prorrogacao",
"executor",
"emissor_acao",
"situacao",
"fase",
"vencimento_efetivo",
"ultimo_acompanhamento_em",
"ultimo_acompanhamento_eh_prorrogacao",
"status_tratativa",
"tratado_em",
"reaberto_em",
"reaberto_motivo",
"status_prazo",
"status_prazo_label",
"dias_para_vencer",
]
def get_status_prazo(self, obj: NCAcao) -> str:
return nc_classificacao.status_prazo(obj.vencimento_efetivo, timezone.localdate())
def get_status_prazo_label(self, obj: NCAcao) -> str:
return nc_classificacao.STATUS_PRAZO_LABELS[self.get_status_prazo(obj)]
def get_dias_para_vencer(self, obj: NCAcao) -> int | None:
return nc_classificacao.dias_para_vencer(obj.vencimento_efetivo, timezone.localdate())
class NCAcaoDetailSerializer(NCAcaoSerializer):
acompanhamentos = NCAcompanhamentoSerializer(many=True, read_only=True)
class Meta(NCAcaoSerializer.Meta):
fields = NCAcaoSerializer.Meta.fields + ["acompanhamentos"]
class NCOcorrenciaSerializer(serializers.ModelSerializer):
sem_analise = serializers.SerializerMethodField()
class Meta:
model = NCOcorrencia
fields = [
"id",
"codigo",
"data_emissao",
"assunto",
"tipo_ocorrencia",
"clientes_relacionados",
"area",
"setor",
"emissor_relato",
"indicado_analise",
"tipos_causa",
"descricao_analise",
"responsavel_analise",
"data_analise",
"analise_sem_acao_detectada",
"status_tratativa",
"tratado_em",
"reaberto_em",
"reaberto_motivo",
"sem_analise",
]
def get_sem_analise(self, obj: NCOcorrencia) -> bool:
return not obj.descricao_analise.strip()
class NCOcorrenciaDetailSerializer(NCOcorrenciaSerializer):
acoes = NCAcaoSerializer(many=True, read_only=True)
class Meta(NCOcorrenciaSerializer.Meta):
fields = NCOcorrenciaSerializer.Meta.fields + [
"relato",
"pessoas_relacionadas",
"origem",
"fornecedores_relacionados",
"riscos_relacionados",
"data_relato",
"representante_gerente",
"prazo_finalizar",
"acoes",
]
NAO_CONFORMIDADE_EXTENSOES_OCORRENCIAS = (".xlsx",)
NAO_CONFORMIDADE_EXTENSOES_ACOES = (".xls",)
class NaoConformidadeImportacaoCreateSerializer(serializers.Serializer):
arquivo_ocorrencias = serializers.FileField()
arquivo_acoes = serializers.FileField()
def validate_arquivo_ocorrencias(self, arquivo: Any) -> Any:
if not arquivo.name.lower().endswith(NAO_CONFORMIDADE_EXTENSOES_OCORRENCIAS):
raise serializers.ValidationError("O arquivo de ocorrências deve ser um .xlsx.")
return arquivo
def validate_arquivo_acoes(self, arquivo: Any) -> Any:
if not arquivo.name.lower().endswith(NAO_CONFORMIDADE_EXTENSOES_ACOES):
raise serializers.ValidationError("O arquivo de ações deve ser um .xls.")
return arquivo
class NaoConformidadeImportacaoSerializer(serializers.ModelSerializer):
criado_por_nome = serializers.CharField(source="criado_por.nome", read_only=True, default=None)
class Meta:
model = NaoConformidadeImportacao
fields = ["id", "criado_por_nome", "criado_em", "resumo", "avisos"]

View File

@ -65,6 +65,11 @@ router.register(
views.IndicadorApuracaoEmpresaViewSet,
basename="indicador-apuracao-empresa",
)
router.register(
"nao-conformidades/importacoes", views.NaoConformidadeImportacaoViewSet, basename="nao-conformidade-importacao"
)
router.register("nao-conformidades/ocorrencias", views.NCOcorrenciaViewSet, basename="nc-ocorrencia")
router.register("nao-conformidades/acoes", views.NCAcaoViewSet, basename="nc-acao")
urlpatterns = [
path("auth/csrf/", views.csrf_view, name="auth-csrf"),
@ -88,5 +93,10 @@ urlpatterns = [
path("ajuda-aplicacoes/<str:app_key>/", views.ajuda_aplicacao_view, name="ajuda-aplicacao"),
path("usuarios-resumo/", views.usuarios_resumo_view, name="usuarios-resumo"),
path("departamentos-resumo/", views.departamentos_resumo_view, name="departamentos-resumo"),
path(
"nao-conformidades/dashboard/",
views.nao_conformidades_dashboard_view,
name="nao-conformidades-dashboard",
),
path("", include(router.urls)),
]

View File

@ -1,21 +1,25 @@
import calendar
import csv
import hashlib
import io
import os
import re
import tempfile
import zipfile
from datetime import date, timedelta
from typing import Any, Iterable
import holidays
from django.core.files.base import ContentFile
from django.core.files.uploadedfile import UploadedFile
from django.db import transaction
from django.db.models import Max, Q, QuerySet
from django.db.models import Avg, Count, DurationField, ExpressionWrapper, F, Max, Q, QuerySet
from django.contrib.auth import authenticate, login, logout
from django.http import HttpResponse
from django.middleware.csrf import get_token
from django.shortcuts import get_object_or_404
from django.utils import timezone
from django.utils.dateparse import parse_date
from django.views.decorators.csrf import ensure_csrf_cookie
from rest_framework import status, viewsets
from rest_framework.decorators import action, api_view, permission_classes
@ -52,6 +56,10 @@ from .models import (
IndicadorPercentualTipo,
LinkFerramenta,
LinkFerramentaFavorito,
NaoConformidadeImportacao,
NCAcao,
NCAcompanhamento,
NCOcorrencia,
NotificacaoDispensada,
ParametroFiscalCustoContratacao,
PerfilAcesso,
@ -67,6 +75,9 @@ from .indicadores import calculo as indicadores_calculo
from .indicadores import departamentos as indicadores_departamentos
from .indicadores import pipeline as indicadores_pipeline
from .indicadores import recibo as indicadores_recibo
from .nao_conformidades import classificacao as nc_classificacao
from .nao_conformidades import diff as nc_diff
from .nao_conformidades import pipeline as nc_pipeline
from .permissions import PermissaoApp, PodeGerenciarPermissoes
from .empresas_questor import normalizar_codigo_empresa, resolve_nome_empresa
from .planos_saude import matcher as planos_saude_matcher
@ -117,6 +128,12 @@ from .serializers import (
IndicadorPercentualTipoSerializer,
LinkFerramentaFavoritoSerializer,
LinkFerramentaSerializer,
NaoConformidadeImportacaoCreateSerializer,
NaoConformidadeImportacaoSerializer,
NCAcaoDetailSerializer,
NCAcaoSerializer,
NCOcorrenciaDetailSerializer,
NCOcorrenciaSerializer,
NotificacaoDispensadaSerializer,
ParametroFiscalCustoContratacaoSerializer,
PerfilAcessoSerializer,
@ -2034,3 +2051,464 @@ class IndicadorApuracaoRespostaViewSet(viewsets.ModelViewSet):
indicadores_calculo.recalcula_colaborador(colaborador)
return Response({"atualizadas": len(respostas)})
# ===================== Não Conformidades (Relatórios > Qualidade) =====================
def _campos_nc_ocorrencia(ocorrencia_extraida: Any) -> dict[str, Any]:
"""Campos "crus" vindos do export — nunca inclui os campos de tratativa
interna (status_tratativa/tratado_*/snapshot_tratativa/reaberto_*), que só
mudam via as actions marcar-tratado/reabrir ou pela lógica de reabertura
automática abaixo."""
return {
"data_emissao": ocorrencia_extraida.data_emissao,
"assunto": ocorrencia_extraida.assunto,
"tipo_ocorrencia": ocorrencia_extraida.tipo_ocorrencia,
"pessoas_relacionadas": ocorrencia_extraida.pessoas_relacionadas,
"origem": ocorrencia_extraida.origem,
"fornecedores_relacionados": ocorrencia_extraida.fornecedores_relacionados,
"clientes_relacionados": ocorrencia_extraida.clientes_relacionados,
"area": ocorrencia_extraida.area,
"setor": ocorrencia_extraida.setor,
"riscos_relacionados": ocorrencia_extraida.riscos_relacionados,
"data_relato": ocorrencia_extraida.data_relato,
"relato": ocorrencia_extraida.relato,
"emissor_relato": ocorrencia_extraida.emissor_relato,
"representante_gerente": ocorrencia_extraida.representante_gerente,
"prazo_finalizar": ocorrencia_extraida.prazo_finalizar,
"indicado_analise": ocorrencia_extraida.indicado_analise,
"tipos_causa": ocorrencia_extraida.tipos_causa,
"descricao_analise": ocorrencia_extraida.descricao_analise,
"responsavel_analise": ocorrencia_extraida.responsavel_analise,
"data_analise": ocorrencia_extraida.data_analise,
"analise_sem_acao_detectada": ocorrencia_extraida.tem_linha_analise_sem_acao,
}
def _campos_nc_acao(acao_extraida: Any) -> dict[str, Any]:
ultimo = acao_extraida.ultimo_acompanhamento
return {
"data_emissao": acao_extraida.data_emissao,
"tipo_acao": acao_extraida.tipo_acao,
"acao_texto": acao_extraida.acao_texto,
"data_conclusao": acao_extraida.data_conclusao,
"prazo_prorrogado": acao_extraida.prazo_prorrogado,
"justificativa_prorrogacao": acao_extraida.justificativa_prorrogacao,
"executor": acao_extraida.executor,
"emissor_acao": acao_extraida.emissor_acao,
"indicado_autorizar": acao_extraida.indicado_autorizar,
"responsavel_autorizacao": acao_extraida.responsavel_autorizacao,
"data_finalizacao": acao_extraida.data_finalizacao,
"dias_finalizacao": acao_extraida.dias_finalizacao,
"situacao": acao_extraida.situacao,
"fase": acao_extraida.fase,
"vencimento_efetivo": acao_extraida.vencimento_efetivo,
"ultimo_acompanhamento_em": ultimo.data if ultimo else None,
"ultimo_acompanhamento_eh_prorrogacao": ultimo.eh_prorrogacao if ultimo else False,
}
def _hash_acompanhamento(entrada: Any) -> str:
bruto = f"{entrada.data.isoformat()}|{entrada.autor}|{entrada.texto}"
return hashlib.sha256(bruto.encode("utf-8")).hexdigest()
def _snapshot_nc_ocorrencia_persistida(ocorrencia: NCOcorrencia) -> dict[str, Any]:
"""Mesma forma de `nao_conformidades.diff.snapshot_ocorrencia()`, mas a
partir do que já está salvo no banco — usado quando a Qualidade marca uma
ocorrência como tratada manualmente pela tela (sem ter o dataclass
recém-extraído de uma importação à mão)."""
return {
"tem_analise": bool(ocorrencia.descricao_analise.strip()),
"hash_analise": nc_diff.hash_texto(ocorrencia.descricao_analise),
"codigos_acao": sorted(ocorrencia.acoes.values_list("codigo", flat=True)),
}
def _snapshot_nc_acao_persistida(acao: NCAcao) -> dict[str, Any]:
"""Mesma forma de `nao_conformidades.diff.snapshot_acao()`, a partir do
que já está salvo no banco (ver `_snapshot_nc_ocorrencia_persistida`)."""
return {
"ultimo_acompanhamento_em": acao.ultimo_acompanhamento_em.isoformat()
if acao.ultimo_acompanhamento_em
else None,
"situacao": acao.situacao,
"fase": acao.fase,
"prazo_prorrogado": acao.prazo_prorrogado.isoformat() if acao.prazo_prorrogado else None,
"data_conclusao": acao.data_conclusao.isoformat() if acao.data_conclusao else None,
}
def _aplica_upsert_nao_conformidades(resultado: Any, agora: Any) -> dict[str, Any]:
"""Upsert de NCOcorrencia/NCAcao/NCAcompanhamento a partir do resultado do
pipeline — e é aqui, não no pacote puro, que mora a decisão de reabrir um
item que já estava tratado (ver `nao_conformidades.diff`), porque só a
view sabe o que já está persistido."""
resumo: dict[str, Any] = {
"ocorrencias_novas": 0,
"ocorrencias_atualizadas": 0,
"acoes_novas": 0,
"acoes_atualizadas": 0,
"acompanhamentos_novos": 0,
"itens_reabertos": [],
}
for ocorrencia_extraida in resultado.ocorrencias:
ocorrencia, criada = NCOcorrencia.objects.update_or_create(
codigo=ocorrencia_extraida.codigo,
defaults={**_campos_nc_ocorrencia(ocorrencia_extraida), "ultima_importacao_em": agora},
)
resumo["ocorrencias_novas" if criada else "ocorrencias_atualizadas"] += 1
if not criada and ocorrencia.status_tratativa == NCOcorrencia.STATUS_TRATADO and ocorrencia.snapshot_tratativa:
motivo = nc_diff.decide_reabertura_ocorrencia(
ocorrencia.snapshot_tratativa, nc_diff.snapshot_ocorrencia(ocorrencia_extraida)
)
if motivo:
ocorrencia.status_tratativa = NCOcorrencia.STATUS_PENDENTE
ocorrencia.reaberto_em = agora
ocorrencia.reaberto_motivo = motivo
ocorrencia.snapshot_tratativa = None
resumo["itens_reabertos"].append(
{"tipo": "ocorrencia", "codigo": ocorrencia.codigo, "motivo": motivo}
)
ocorrencia.save()
for acao_extraida in ocorrencia_extraida.acoes:
acao, acao_criada = NCAcao.objects.update_or_create(
ocorrencia=ocorrencia,
codigo=acao_extraida.codigo,
defaults={**_campos_nc_acao(acao_extraida), "ultima_importacao_em": agora},
)
resumo["acoes_novas" if acao_criada else "acoes_atualizadas"] += 1
for entrada in acao_extraida.acompanhamentos:
_, criado_acomp = NCAcompanhamento.objects.get_or_create(
acao=acao,
hash_entrada=_hash_acompanhamento(entrada),
defaults={"data": entrada.data, "autor": entrada.autor, "texto": entrada.texto,
"eh_prorrogacao": entrada.eh_prorrogacao},
)
if criado_acomp:
resumo["acompanhamentos_novos"] += 1
if not acao_criada and acao.status_tratativa == NCAcao.STATUS_TRATADO and acao.snapshot_tratativa:
motivo = nc_diff.decide_reabertura_acao(
acao.snapshot_tratativa, nc_diff.snapshot_acao(acao_extraida)
)
if motivo:
acao.status_tratativa = NCAcao.STATUS_PENDENTE
acao.reaberto_em = agora
acao.reaberto_motivo = motivo
acao.snapshot_tratativa = None
resumo["itens_reabertos"].append(
{"tipo": "acao", "codigo": f"{ocorrencia.codigo}/{acao.codigo}", "motivo": motivo}
)
acao.save()
return resumo
class NaoConformidadeImportacaoViewSet(viewsets.ModelViewSet):
"""Ferramenta "Não Conformidades" (Relatórios > Qualidade) — permissão de
toggle único, mesmo espírito de ImportacaoPlanoSaudeViewSet/
IndicadorApuracaoViewSet. `create()` roda `portal_api.nao_conformidades.
pipeline` de forma síncrona sobre os dois arquivos exportados do Sigsistem
e faz o upsert de NCOcorrencia/NCAcao/NCAcompanhamento. Diferente das
outras duas ferramentas, este registro é só um log de auditoria — não é
"dono" das ocorrências/ações (entidades contínuas, upsertadas por código):
`destroy()` remove só o log e os 2 arquivos, nunca as ocorrências/ações já
persistidas (ver NaoConformidadeImportacao em models.py)."""
http_method_names = ["get", "post", "delete", "head", "options"]
queryset = NaoConformidadeImportacao.objects.all()
serializer_class = NaoConformidadeImportacaoSerializer
def get_permissions(self) -> list[BasePermission]:
return [PermissaoApp("relatorios", "nao-conformidades")]
def perform_destroy(self, instance: NaoConformidadeImportacao) -> None:
instance.arquivo_ocorrencias.delete(save=False)
instance.arquivo_acoes.delete(save=False)
instance.delete()
def create(self, request: Request, *args: Any, **kwargs: Any) -> Response:
entrada = NaoConformidadeImportacaoCreateSerializer(data=request.data)
entrada.is_valid(raise_exception=True)
dados = entrada.validated_data
with transaction.atomic():
importacao = NaoConformidadeImportacao.objects.create(
arquivo_ocorrencias=dados["arquivo_ocorrencias"],
arquivo_acoes=dados["arquivo_acoes"],
criado_por=request.user,
)
resultado = nc_pipeline.processa_importacao(
importacao.arquivo_ocorrencias.path, importacao.arquivo_acoes.path
)
resumo = _aplica_upsert_nao_conformidades(resultado, timezone.now())
importacao.resumo = resumo
importacao.avisos = resultado.avisos
importacao.save(update_fields=["resumo", "avisos"])
return Response(NaoConformidadeImportacaoSerializer(importacao).data, status=status.HTTP_201_CREATED)
class NCOcorrenciaViewSet(viewsets.ModelViewSet):
"""Ocorrências persistidas (upsertadas a cada importação, ver
NaoConformidadeImportacaoViewSet.create()) — só leitura + as duas actions
de tratativa da Qualidade; não há create()/edição de campo aqui, os dados
sempre vêm do Sigsistem via importação."""
http_method_names = ["get", "post", "head", "options"]
queryset = NCOcorrencia.objects.all()
def get_permissions(self) -> list[BasePermission]:
return [PermissaoApp("relatorios", "nao-conformidades")]
def get_serializer_class(self) -> type[ModelSerializer]:
if self.action == "retrieve":
return NCOcorrenciaDetailSerializer
return NCOcorrenciaSerializer
def get_queryset(self) -> QuerySet[NCOcorrencia]:
queryset = super().get_queryset()
params = self.request.query_params
if params.get("status_tratativa"):
queryset = queryset.filter(status_tratativa=params["status_tratativa"])
if params.get("sem_analise") == "true":
queryset = queryset.filter(descricao_analise="")
if params.get("analise_sem_acao") == "true":
queryset = queryset.filter(analise_sem_acao_detectada=True)
if params.get("search"):
termo = params["search"]
queryset = queryset.filter(Q(assunto__icontains=termo) | Q(codigo__icontains=termo))
if self.action == "retrieve":
queryset = queryset.prefetch_related("acoes")
return queryset
@action(detail=True, methods=["post"], url_path="marcar-tratado")
def marcar_tratado(self, request: Request, pk: str | None = None) -> Response:
ocorrencia = self.get_object()
ocorrencia.status_tratativa = NCOcorrencia.STATUS_TRATADO
ocorrencia.tratado_em = timezone.now()
ocorrencia.tratado_por = request.user
ocorrencia.snapshot_tratativa = _snapshot_nc_ocorrencia_persistida(ocorrencia)
ocorrencia.reaberto_em = None
ocorrencia.reaberto_motivo = ""
ocorrencia.save()
return Response(NCOcorrenciaSerializer(ocorrencia).data)
@action(detail=True, methods=["post"])
def reabrir(self, request: Request, pk: str | None = None) -> Response:
ocorrencia = self.get_object()
ocorrencia.status_tratativa = NCOcorrencia.STATUS_PENDENTE
ocorrencia.snapshot_tratativa = None
ocorrencia.reaberto_em = timezone.now()
ocorrencia.reaberto_motivo = "Reaberta manualmente."
ocorrencia.save()
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()`)."""
if bucket == nc_classificacao.STATUS_VENCIDA:
return queryset.filter(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))
if bucket == nc_classificacao.STATUS_VENCE_30:
return queryset.filter(
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))
if bucket == nc_classificacao.STATUS_SEM_VENCIMENTO:
return queryset.filter(vencimento_efetivo__isnull=True)
raise ValidationError({"status_prazo": "Valor inválido."})
class NCAcaoViewSet(viewsets.ModelViewSet):
"""Ações persistidas — só leitura + tratativa (mesmo espírito de
NCOcorrenciaViewSet)."""
http_method_names = ["get", "post", "head", "options"]
queryset = NCAcao.objects.select_related("ocorrencia")
def get_permissions(self) -> list[BasePermission]:
return [PermissaoApp("relatorios", "nao-conformidades")]
def get_serializer_class(self) -> type[ModelSerializer]:
if self.action == "retrieve":
return NCAcaoDetailSerializer
return NCAcaoSerializer
def get_queryset(self) -> QuerySet[NCAcao]:
queryset = super().get_queryset()
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())
if params.get("search"):
termo = params["search"]
queryset = queryset.filter(
Q(acao_texto__icontains=termo)
| Q(executor__icontains=termo)
| Q(ocorrencia__codigo__icontains=termo)
| Q(ocorrencia__clientes_relacionados__icontains=termo)
)
if self.action == "retrieve":
queryset = queryset.prefetch_related("acompanhamentos")
return queryset
@action(detail=True, methods=["post"], url_path="marcar-tratado")
def marcar_tratado(self, request: Request, pk: str | None = None) -> Response:
acao = self.get_object()
acao.status_tratativa = NCAcao.STATUS_TRATADO
acao.tratado_em = timezone.now()
acao.tratado_por = request.user
acao.snapshot_tratativa = _snapshot_nc_acao_persistida(acao)
acao.reaberto_em = None
acao.reaberto_motivo = ""
acao.save()
return Response(NCAcaoSerializer(acao).data)
@action(detail=True, methods=["post"])
def reabrir(self, request: Request, pk: str | None = None) -> Response:
acao = self.get_object()
acao.status_tratativa = NCAcao.STATUS_PENDENTE
acao.snapshot_tratativa = None
acao.reaberto_em = timezone.now()
acao.reaberto_motivo = "Reaberta manualmente."
acao.save()
return Response(NCAcaoSerializer(acao).data)
def _subtrai_meses(data_ref: date, meses: int) -> date:
mes_total = data_ref.month - 1 - meses
ano = data_ref.year + mes_total // 12
mes = mes_total % 12 + 1
dia = min(data_ref.day, calendar.monthrange(ano, mes)[1])
return date(ano, mes, dia)
@api_view(["GET"])
@permission_classes([IsAuthenticated])
def nao_conformidades_dashboard_view(request: Request) -> Response:
"""Indicadores quantitativos de Não Conformidades — contagens "abertas
agora" (ocorrências/ações/sem análise/análise sem ação/NCs sem ação
corretiva) nunca são filtradas por período (são sempre o estado atual);
só os rankings de motivo/cliente/colaborador aceitam `?meses=` (default 6,
generaliza a regra fixa "últimos 6 meses" da skill original) ou
`?data_inicio=&data_fim=` (AAAA-MM-DD) explícitos."""
if not request.user.permissao_app("relatorios", "nao-conformidades"):
raise PermissionDenied("Você não tem permissão para acessar Não Conformidades.")
hoje = timezone.localdate()
data_inicio_param = request.query_params.get("data_inicio")
data_fim_param = request.query_params.get("data_fim")
if data_inicio_param and data_fim_param:
data_inicio = parse_date(data_inicio_param)
data_fim = parse_date(data_fim_param)
if data_inicio is None or data_fim is None:
raise ValidationError({"detail": "data_inicio/data_fim devem estar no formato AAAA-MM-DD."})
else:
try:
meses = int(request.query_params.get("meses") or 6)
except ValueError:
raise ValidationError({"meses": "Informe um número inteiro de meses."})
data_fim = hoje
data_inicio = _subtrai_meses(hoje, meses)
ocorrencias_periodo = NCOcorrencia.objects.filter(data_emissao__gte=data_inicio, data_emissao__lte=data_fim)
status_counts = {chave: 0 for chave in nc_classificacao.STATUS_PRAZO_LABELS}
for venc in NCAcao.objects.values_list("vencimento_efetivo", flat=True):
status_counts[nc_classificacao.status_prazo(venc, hoje)] += 1
ncs_sem_corretiva = 0
for ocorrencia in NCOcorrencia.objects.filter(tipo_ocorrencia__in=nc_classificacao.TIPOS_NC).prefetch_related(
"acoes"
):
categorias = {nc_classificacao.categoria_acao(a.tipo_acao) for a in ocorrencia.acoes.all()}
if nc_classificacao.CATEGORIA_CORRETIVA not in categorias:
ncs_sem_corretiva += 1
motivos = (
ocorrencias_periodo.exclude(tipos_causa="")
.values("tipos_causa")
.annotate(quantidade=Count("id"))
.order_by("-quantidade")[:15]
)
# Clientes/Colaboradores: conta TODOS os tipos de ocorrência (decisão do
# usuário — a clareza vem de quebrar por tipo com cor no frontend, não de
# restringir o filtro a NC/Reclamação como a v1 fazia só pra clientes).
filtro_nc = Count("id", filter=Q(tipo_ocorrencia="Não Conformidade"))
filtro_reclamacao = Count("id", filter=Q(tipo_ocorrencia="Reclamação de Cliente"))
clientes = (
ocorrencias_periodo.exclude(clientes_relacionados="")
.values("clientes_relacionados")
.annotate(nc=filtro_nc, reclamacao=filtro_reclamacao, total=Count("id"))
.order_by("-total")[:15]
)
colaboradores = (
ocorrencias_periodo.exclude(indicado_analise="")
.values("indicado_analise")
.annotate(nc=filtro_nc, reclamacao=filtro_reclamacao, total=Count("id"))
.order_by("-total")[:15]
)
tempo_analise = NCOcorrencia.objects.filter(
data_analise__isnull=False, data_emissao__isnull=False
).aggregate(media=Avg(ExpressionWrapper(F("data_analise") - F("data_emissao"), output_field=DurationField())))[
"media"
]
tempo_abertura_acao = NCAcao.objects.filter(
data_emissao__isnull=False, ocorrencia__data_analise__isnull=False
).aggregate(
media=Avg(ExpressionWrapper(F("data_emissao") - F("ocorrencia__data_analise"), output_field=DurationField()))
)["media"]
def _dias(intervalo) -> float | None:
return round(intervalo.total_seconds() / 86400, 1) if intervalo is not None else None
return Response(
{
"ocorrencias_abertas": NCOcorrencia.objects.count(),
"acoes_abertas_por_status": status_counts,
"sem_analise": NCOcorrencia.objects.filter(descricao_analise="").count(),
"analise_sem_acao": NCOcorrencia.objects.filter(analise_sem_acao_detectada=True).count(),
"ncs_sem_acao_corretiva": ncs_sem_corretiva,
"motivos_abertura": [{"causa": m["tipos_causa"], "quantidade": m["quantidade"]} for m in motivos],
"clientes_maior_incidencia": [
{
"cliente": c["clientes_relacionados"],
"nc": c["nc"],
"reclamacao": c["reclamacao"],
"outros": c["total"] - c["nc"] - c["reclamacao"],
"total": c["total"],
}
for c in clientes
],
"colaboradores_maior_incidencia": [
{
"colaborador": c["indicado_analise"],
"nc": c["nc"],
"reclamacao": c["reclamacao"],
"outros": c["total"] - c["nc"] - c["reclamacao"],
"total": c["total"],
}
for c in colaboradores
],
"tempos_medios": {
"preenchimento_analise_dias": _dias(tempo_analise),
"abertura_acao_dias": _dias(tempo_abertura_acao),
},
"periodo": {"data_inicio": data_inicio.isoformat(), "data_fim": data_fim.isoformat()},
}
)

File diff suppressed because it is too large Load Diff

Binary file not shown.

View File

@ -0,0 +1,134 @@
---
name: analise-ncs
description: >
Gera o Relatório de Qualidade ISO 9001 multi-abas da De Paula Contadores a partir do PAR de
arquivos exportados do Sigsistem: o .xlsx de OCORRÊNCIAS (dados estruturados) e o .xls de AÇÕES
(HTML, fonte dos relatos de acompanhamento). Use SEMPRE que o usuário anexar esses dois arquivos
(nomes típicos: "ocorrencias_pa_DDMMAAAA.xlsx" e "acoes_DDMMAAAA.xls" / "acoesDDMMAAAA.xls") e
pedir para "gerar o relatório", "rodar o relatório de qualidade", "analisar as ocorrências",
"fazer a análise da planilha", "formatar a planilha de ocorrências", ou mencionar ações em atraso,
acompanhamentos, NCs sem ação corretiva, ocorrências sem análise ou auditoria do SGQ. O resultado
é um único .xlsx estilizado salvo em /mnt/user-data/outputs/ e apresentado via present_files.
---
# Análise NCs — Relatório de Qualidade ISO 9001 (De Paula Contadores)
Consultor de qualidade sênior ISO 9001. Recebe DOIS arquivos exportados do Sigsistem e produz um
relatório Excel multi-abas para acompanhamento do SGQ e preparação de auditoria.
## Arquivos de entrada (SEMPRE dois)
| Arquivo | Formato real | Papel |
|---|---|---|
| `ocorrencias_pa_DDMMAAAA.xlsx` | Excel 2007+ | Dados estruturados: ocorrências, ações, datas, análise, responsáveis. **Fonte principal.** |
| `acoes_DDMMAAAA.xls` (às vezes `acoesDDMMAAAA.xls`) | **HTML** (apesar da extensão `.xls`) | Blocos "Ocorrência N / Ação M" com a seção **"Relato(s) do Acompanhamento"**. **Única fonte confiável da data do último acompanhamento.** |
> O `.xls` NÃO é Excel — é HTML. Ler com `open(..., encoding='latin-1')` e parsear por regex.
> Nunca tente `pd.read_excel` nele.
## Conceitos-chave
- **Ocorrência**: registro com código único. 1 código = 1 ocorrência. Tipos: Não Conformidade,
Reclamação de Cliente, Oportunidade de Melhoria, Planejamento Estratégico, Mudança Planejada,
Gerenciamento de Riscos, Observação em Auditoria, etc.
- **NC** (Não Conformidade e Reclamação de Cliente): exige **Correção** (imediata) + **Ação
Corretiva** (causa raiz). NC só com Correção, sem Ação Corretiva = **erro grave** (aba NCs Críticas).
- **Ocorrência/Ação**: identificador exibido no formato `3856/5646` (código da ocorrência / código
da ação). Usar esse formato em TODAS as abas de ações.
- **Vencimento efetivo** de uma ação = `Prazo Prorrogado` se preenchido, senão `Data da Conclusão
da Ação` (que no export é a data-limite prevista, não a conclusão real — todas as ações do export
estão em aberto).
- **Último acompanhamento**: data da última ENTRADA de relato no `.xls`, extraída por Ocorrência/Ação.
## Regras de negócio (consolidadas — NÃO alterar sem pedido explícito)
1. **Linhas válidas**: apenas linhas cujo `Código da Ocorrência` é numérico. Exports antigos podem
trazer um bloco de lixo ao final (valores `0`, `0,0%`, `------`, "Mai/2023"); descartar.
Valores-lixo tratados como nulos: `------`, `0`, `0,0%`, `0.0%`.
2. **Datas**: extrair sempre o padrão `DD/MM/AAAA` (a coluna de emissão vem com data+hora
misturadas; pegar só a data). `dayfirst=True`.
3. **Data de referência (HOJE)**: verificar a data real do sistema (`date`) antes de rodar. Nunca
reutilizar data de ciclo anterior nem inventar. O script aceita `--hoje AAAA-MM-DD`; sem ela usa
a data atual do sistema.
4. **Último acompanhamento** vem do `.xls`, por `(Ocorrência, Ação)`. Cada entrada da seção
"Relato(s) do Acompanhamento" começa com `DD/MM/AAAA - Nome - texto`. Pegar a **última entrada
por data**. IGNORAR as datas embutidas em linhas de prorrogação ("Prazo Prorrogado Para: ...") —
elas são datas-alvo futuras, não datas de relato.
5. **Ações em Atraso**: vencimento efetivo < HOJE. Vão para cá **mesmo que tenham acompanhamento**.
Ordenar por maior atraso. Colunas incluem `Dias em Atraso`.
6. **Acompanhamentos Realizados** (ações a priorizar): têm último acompanhamento ANTES do
vencimento efetivo E o vencimento ainda não passou (`>= HOJE`) **E o último acompanhamento NÃO é
uma prorrogação de prazo**. Se a última entrada for "Inclusão/Alteração de Prorrogação de Prazo",
a ação é EXCLUÍDA desta aba (não há mais o que fazer até um novo relato de andamento real).
7. **Sem Análise** (por ocorrência): campo `Descrição da Análise da Ocorrência` vazio. Trazer código
da ocorrência, data de emissão, área, emissor, indicado para análise (usar "Indicado para
Descrever Análise" e, na ausência, o "Responsável pela Descrição da Análise").
8. **Análise sem Ação**: análise preenchida mas SEM código de ação (análise feita, ação não aberta).
Trazer ocorrência/ação, data de emissão, área, emissor, responsável p/ análise e descrição.
9. **NCs Críticas**: ocorrências do tipo NC/Reclamação que NÃO possuem nenhuma "Ação Corretiva"
(só têm Correção ou nenhuma ação). Coluna "Tipos de Ação Existentes" mostra o que há.
10. **Clientes com +1 NC nos últimos 6 meses** (no Resumo): ocorrências NC/Reclamação com emissão
nos últimos 6 meses a partir de HOJE, agrupadas por cliente, mostrando quem tem mais de uma.
11. **Todas as Ações**: TODAS as ações abertas (vencidas ou não), ordenadas por vencimento, com
coluna `Status` (VENCIDA / Vence em ≤7 dias / Vence em ≤30 dias / No prazo / Sem vencimento)
e `Dias p/ Vencer`. Serve para cobrança antecipada dos responsáveis.
12. **Colunas obrigatórias nas abas de ações**: sempre incluir **Tipo de Ação** e **Ação** (o texto
da ação em si), além de Ocorrência/Ação e Área, para facilitar a análise.
## Abas do relatório (nesta ordem)
1. **Resumo** — data, indicadores (ocorrências abertas, ações abertas, em atraso, sem análise,
análises sem ação, NCs sem ação corretiva, acompanhamentos a priorizar), ocorrências por tipo,
e clientes com +1 NC (6 meses).
2. **Ações em Atraso** — vermelho.
3. **Acompanhamentos Realizados** — vermelho (prioridade de andamento).
4. **Todas as Ações** — cor por status (vermelho=vencida, amarelo=vence em breve).
5. **Sem Análise**.
6. **Análise sem Ação**.
7. **NCs Críticas** — vermelho.
## Formatação
Cabeçalho azul-escuro (`1F4E79`) com texto branco; zebra sutil (`D6E4F0`); linhas de alerta em
vermelho claro (`F9D7D5`) com fonte vinho; "vence em breve" em amarelo (`FFF2CC`); bordas finas;
largura de coluna automática; `freeze_panes` na primeira linha de dados.
## Como executar
1. Rodar `date` para confirmar HOJE.
2. Identificar os dois arquivos anexados (o `.xlsx` de ocorrências e o `.xls` de ações).
3. Executar o script:
```bash
python scripts/gerar_relatorio.py \
--ocorrencias /mnt/user-data/uploads/ocorrencias_pa_DDMMAAAA.xlsx \
--acoes /mnt/user-data/uploads/acoes_DDMMAAAA.xls \
--hoje AAAA-MM-DD \
--saida /mnt/user-data/outputs
```
Dependências: `pandas openpyxl lxml html5lib` (instalar com `pip install ... --break-system-packages`
se necessário).
4. Chamar `present_files` com o `.xlsx` gerado.
5. Responder no chat de forma factual e enxuta: total de ações em atraso e principais responsáveis;
ocorrências sem análise; NCs sem ação corretiva (alerta de risco); e anomalias estruturais da
planilha, se houver.
## Notas de robustez
- O encoding do `.xls` pode variar entre exports ("ISO-8859" ou "Non-ISO extended-ASCII"); `latin-1`
decodifica ambos e preserva os marcadores de prorrogação.
- A coluna `Cliente(s) Relacionado(s)` pode estar vazia em muitas linhas — normal.
- O script é idempotente: reexecutar sobrescreve o arquivo do dia (`Relatorio_Qualidade_AAAAMMDD.xlsx`).

View File

@ -0,0 +1,505 @@
/* "Não Conformidades" (Relatórios > Qualidade) — .ncf-* — só usado em
nao-conformidades.html. Carrega perfis-acesso.css também, pra reaproveitar
.pa-table/.pa-tabs/.pa-table-wrap/.status-pill (mesmo padrão de
importacao-plano-saude.css/indicador-desempenho.css). */
.ncf-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: var(--space-4);
margin-bottom: var(--space-5);
}
.ncf-header h2,
.ncf-header h3 {
margin: 0 0 var(--space-1);
}
.ncf-subtitle,
.ncf-section__hint {
color: var(--text-secondary);
font-size: 0.9rem;
margin: 0;
}
.ncf-section {
margin-bottom: var(--space-6);
}
.ncf-section__header {
display: flex;
align-items: center;
gap: var(--space-3);
margin-bottom: var(--space-1);
}
.ncf-section__header h3 {
margin: 0;
}
.ncf-count {
font-size: 0.78rem;
font-weight: 600;
color: var(--accent);
background: rgba(var(--accent-rgb), 0.14);
border-radius: var(--radius-pill);
padding: 2px 10px;
min-width: 20px;
text-align: center;
}
.ncf-section__hint {
margin-bottom: var(--space-3);
}
.ncf-empty {
color: var(--text-muted);
font-style: italic;
padding: var(--space-4);
text-align: center;
}
/* Modificadores de .status-pill (base já vem de perfis-acesso.css, que
define .status-pill/.status-pill--ativo/.status-pill--inativo). */
.status-pill--ok {
color: #2f9e6e;
background: rgba(47, 158, 110, 0.14);
}
.status-pill--danger {
color: var(--danger);
background: rgba(var(--danger-rgb), 0.14);
}
.status-pill--warning {
color: var(--gold);
background: rgba(var(--gold-rgb), 0.18);
}
.status-pill--neutral {
color: var(--text-muted);
background: var(--bg-surface-raised);
}
.ncf-reaberto {
display: inline-flex;
align-items: center;
font-size: 0.72rem;
font-weight: 600;
color: var(--coral);
background: rgba(var(--coral-rgb), 0.16);
border-radius: var(--radius-pill);
padding: 2px 8px;
cursor: help;
}
.ncf-row-btn {
margin-left: var(--space-2);
}
/* Chips de filtro (período/prazo/tratativa) — mesmo padrão visual de
.ind-departamento-chip (indicador-desempenho.css), duplicado aqui porque
essa folha não é carregada nesta página. */
.ncf-filtro-grupo {
display: flex;
align-items: center;
gap: var(--space-3);
flex-wrap: wrap;
margin-bottom: var(--space-3);
}
.ncf-filtro-grupo__label {
font-size: 0.8rem;
font-weight: 600;
color: var(--text-secondary);
white-space: nowrap;
}
.ncf-chip-row {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
}
.ncf-chip {
padding: var(--space-2) var(--space-4);
border-radius: var(--radius-pill);
border: 1px solid var(--border-subtle);
font-size: 0.8rem;
font-weight: 600;
color: var(--text-secondary);
background: var(--bg-surface);
transition: border-color var(--transition-fast), color var(--transition-fast), background var(--transition-fast);
}
.ncf-chip:hover {
border-color: var(--accent);
color: var(--text-primary);
}
.ncf-chip.is-active {
background: var(--accent);
border-color: var(--accent);
color: var(--accent-contrast);
}
/* Dashboard */
.ncf-dashboard-toolbar {
display: flex;
align-items: center;
gap: var(--space-3);
margin-bottom: var(--space-4);
flex-wrap: wrap;
}
.ncf-kpis-section {
margin-bottom: var(--space-6);
}
.ncf-kpis-title {
font-size: 0.95rem;
margin: 0 0 var(--space-3);
}
.ncf-kpis {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: var(--space-4);
}
.ncf-kpi {
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
padding: var(--space-4);
}
.ncf-kpi__label {
display: block;
font-size: 0.78rem;
color: var(--text-secondary);
margin-bottom: var(--space-2);
}
.ncf-kpi__value {
font-size: 1.5rem;
font-weight: 700;
color: var(--text-primary);
}
.ncf-kpi__value--vazio {
font-size: 0.95rem;
font-weight: 600;
color: var(--text-muted);
font-style: italic;
}
.ncf-dash-legend {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--space-4);
margin-bottom: var(--space-4);
font-size: 0.8rem;
color: var(--text-secondary);
}
.ncf-legend-item {
display: inline-flex;
align-items: center;
gap: var(--space-2);
}
.ncf-legend-dot {
width: 10px;
height: 10px;
border-radius: 50%;
display: inline-block;
}
.ncf-legend-dot--nc {
background: var(--danger);
}
.ncf-legend-dot--reclamacao {
background: var(--gold);
}
.ncf-legend-dot--outros {
background: var(--text-muted);
}
/* Especificidade extra (elemento+classe) pra vencer .pa-table td, que já
define color/font-weight próprios com a mesma especificidade de uma
classe sozinha. */
.pa-table td.ncf-num--nc {
color: var(--danger);
font-weight: 600;
}
.pa-table td.ncf-num--reclamacao {
color: var(--gold);
font-weight: 600;
}
.pa-table td.ncf-num--total {
font-weight: 700;
color: var(--text-primary);
}
.ncf-clicavel {
cursor: pointer;
}
.ncf-clicavel:hover td {
background: var(--card-bg-hover);
}
.ncf-card--clicavel {
cursor: pointer;
text-align: left;
font: inherit;
transition: border-color var(--transition-fast), background var(--transition-fast);
}
.ncf-card--clicavel:hover {
border-color: var(--accent);
background: var(--card-bg-hover);
}
.ncf-cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: var(--space-4);
margin-bottom: var(--space-6);
}
.ncf-card {
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
padding: var(--space-4);
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.ncf-card__label {
font-size: 0.78rem;
color: var(--text-secondary);
}
.ncf-card__value {
font-size: 1.8rem;
font-weight: 700;
color: var(--text-primary);
}
.ncf-card--danger .ncf-card__value {
color: var(--danger);
}
.ncf-card--warning .ncf-card__value {
color: var(--gold);
}
.ncf-dash-rankings {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: var(--space-5);
}
.ncf-dash-ranking h3 {
font-size: 0.95rem;
margin: 0 0 var(--space-3);
}
/* Importar */
.ncf-file-field {
display: flex;
align-items: center;
gap: var(--space-3);
}
.ncf-file-field__btn {
display: inline-flex;
align-items: center;
gap: var(--space-2);
cursor: pointer;
white-space: nowrap;
}
.ncf-file-field__input {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
}
.ncf-file-field__name {
font-size: 0.85rem;
color: var(--text-secondary);
}
.ncf-import-form {
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
padding: var(--space-5);
margin-bottom: var(--space-5);
max-width: 560px;
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.ncf-import-resultado {
background: rgba(var(--accent-rgb), 0.12);
border: 1px solid rgba(var(--accent-rgb), 0.3);
border-radius: var(--radius-md);
padding: var(--space-4);
margin-bottom: var(--space-5);
font-size: 0.88rem;
color: var(--text-primary);
}
/* Busca compartilhada da Gestão */
.ncf-gestao-toolbar {
display: flex;
align-items: center;
gap: var(--space-3);
margin-bottom: var(--space-5);
}
.ncf-gestao-search {
flex: 1;
max-width: 420px;
padding: var(--space-2) var(--space-3);
border-radius: var(--radius-sm);
border: 1px solid var(--border-subtle);
background: var(--bg-surface);
color: var(--text-primary);
font-size: 0.88rem;
}
.ncf-gestao-search:focus {
outline: none;
border-color: var(--accent);
}
/* Expandir linha (chevron que gira, corpo revelado abaixo) — mesmo padrão de
.cc-painel-fiscal__toggle (custo-contratacao.css), adaptado pra dentro de
uma célula de tabela. */
.ncf-expand-col {
width: 32px;
}
.ncf-expand-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: var(--radius-sm);
color: var(--text-secondary);
transition: background var(--transition-fast), color var(--transition-fast);
}
.ncf-expand-btn:hover {
background: var(--bg-surface-raised);
color: var(--text-primary);
}
.ncf-expand-chevron {
transition: transform var(--transition-fast);
}
.ncf-expand-btn[aria-expanded="true"] .ncf-expand-chevron {
transform: rotate(90deg);
}
.pa-table td.ncf-detail-cell {
background: var(--bg-surface-raised);
padding: var(--space-4) var(--space-5);
text-align: left;
}
.ncf-detail-panel {
font-size: 0.85rem;
color: var(--text-secondary);
}
.ncf-detail-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: var(--space-3) var(--space-5);
margin-bottom: var(--space-4);
}
.ncf-detail-field__label {
display: block;
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.03em;
color: var(--text-muted);
margin-bottom: 2px;
}
.ncf-detail-field__value {
color: var(--text-primary);
white-space: pre-wrap;
}
.ncf-detail-panel__texto {
margin: 0 0 var(--space-4);
color: var(--text-primary);
white-space: pre-wrap;
}
.ncf-detail-panel__loading {
color: var(--text-muted);
font-style: italic;
}
.ncf-detail-panel h4 {
font-size: 0.82rem;
color: var(--text-primary);
margin: 0 0 var(--space-3);
}
.ncf-mostrar-mais-btn {
margin-top: var(--space-2);
}
/* Histórico de acompanhamentos (agora dentro do painel inline, não modal) */
.ncf-acompanhamentos-list {
display: flex;
flex-direction: column;
gap: var(--space-3);
margin: 0 0 var(--space-4);
}
.ncf-acompanhamento-item {
border: 1px solid var(--border-subtle);
border-radius: var(--radius-sm);
padding: var(--space-3);
background: var(--bg-surface-raised);
}
.ncf-acompanhamento-item__header {
display: flex;
align-items: center;
gap: var(--space-3);
font-size: 0.82rem;
color: var(--text-secondary);
margin-bottom: var(--space-2);
}
.ncf-acompanhamento-item p {
margin: 0;
font-size: 0.88rem;
color: var(--text-primary);
white-space: pre-wrap;
}

View File

@ -0,0 +1,778 @@
/**
* Controlador de "Não Conformidades" (Relatórios > Qualidade) — gestão
* contínua das ocorrências/ações do Sigsistem, ver CLAUDE.md do pacote
* `portal_api/nao_conformidades/`. Guarda de página no mesmo padrão de
* `importacao-plano-saude.js`/`indicador-desempenho.js`: retorna cedo se o
* elemento raiz da tela não existir (script incluído em todo shell) e some
* atrás de `#ncf-no-access` se o usuário não tiver
* `permissoes_efetivas.relatorios.apps["nao-conformidades"]`.
*/
function pidNcfFormatDate(iso) {
if (!iso) return "—";
const [ano, mes, dia] = iso.split("-");
return `${dia}/${mes}/${ano}`;
}
function pidNcfFormatDateTime(iso) {
if (!iso) return "—";
const data = new Date(iso);
if (Number.isNaN(data.getTime())) return "—";
return data.toLocaleString("pt-BR", { day: "2-digit", month: "2-digit", year: "numeric", hour: "2-digit", minute: "2-digit" });
}
function pidNcfEscapeHtml(texto) {
if (texto == null) return "";
const div = document.createElement("div");
div.textContent = String(texto);
return div.innerHTML;
}
function pidNcfAtualizarContador(el, quantidade) {
el.textContent = quantidade ? `${quantidade}` : "";
el.hidden = quantidade === 0;
}
const PID_NCF_STATUS_PRAZO_CLASSES = {
vencida: "status-pill status-pill--danger",
vence_7_dias: "status-pill status-pill--warning",
vence_30_dias: "status-pill status-pill--neutral",
no_prazo: "status-pill status-pill--ok",
sem_vencimento: "status-pill status-pill--neutral",
};
/** Botão de expandir (chevron que gira) + linha de detalhe associada, mesmo
* padrão de `.cc-painel-fiscal__toggle` (custo-contratacao.js), adaptado pra
* uma linha de tabela — `montarConteudo(painelEl)` só roda na primeira vez
* que a linha é aberta (lazy). */
function pidNcfCriarLinhaExpandivel(colspan, montarConteudo) {
const btn = document.createElement("button");
btn.type = "button";
btn.className = "ncf-expand-btn";
btn.setAttribute("aria-expanded", "false");
btn.setAttribute("aria-label", "Expandir detalhes");
btn.innerHTML =
'<svg class="ncf-expand-chevron" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 6l6 6-6 6" stroke-linecap="round" stroke-linejoin="round"/></svg>';
const trDetalhe = document.createElement("tr");
trDetalhe.className = "ncf-detail-row";
trDetalhe.hidden = true;
const td = document.createElement("td");
td.colSpan = colspan;
// Classe própria (não só .ncf-detail-row td) pra vencer .pa-table
// td:last-child (text-align:right) — como esta é a única célula da linha,
// ela sempre "é" a última.
td.className = "ncf-detail-cell";
const painel = document.createElement("div");
painel.className = "ncf-detail-panel";
td.appendChild(painel);
trDetalhe.appendChild(td);
btn.addEventListener("click", async () => {
const aberto = btn.getAttribute("aria-expanded") === "true";
btn.setAttribute("aria-expanded", String(!aberto));
trDetalhe.hidden = aberto;
if (!aberto && !painel.dataset.carregado) {
painel.dataset.carregado = "1";
await montarConteudo(painel);
}
});
return { btn, trDetalhe };
}
function pidNcfCriarBadgeTratativa(item) {
const fragmento = document.createDocumentFragment();
const tratado = item.status_tratativa === "tratado";
const badge = document.createElement("span");
badge.className = `status-pill ${tratado ? "status-pill--ok" : "status-pill--neutral"}`;
badge.textContent = tratado ? "Tratado" : "Pendente";
fragmento.appendChild(badge);
if (item.reaberto_motivo) {
const reaberto = document.createElement("span");
reaberto.className = "ncf-reaberto";
reaberto.style.marginLeft = "6px";
reaberto.title = item.reaberto_motivo;
reaberto.textContent = "Reaberto";
fragmento.appendChild(reaberto);
}
return fragmento;
}
document.addEventListener("DOMContentLoaded", async () => {
const contentEl = document.getElementById("ncf-content");
if (!contentEl) return;
const me = await pidGetMe();
if (!me) return;
const relatoriosPerms = (me.permissoes_efetivas && me.permissoes_efetivas["relatorios"]) || {};
const canUse = !!(relatoriosPerms.apps && relatoriosPerms.apps["nao-conformidades"]);
const noAccessEl = document.getElementById("ncf-no-access");
if (noAccessEl) noAccessEl.hidden = canUse;
contentEl.hidden = !canUse;
if (!canUse) return;
// ---------------- Tabs ----------------
const tabButtons = Array.from(document.querySelectorAll("[data-ncf-tab]"));
const tabPanels = Array.from(document.querySelectorAll("[data-ncf-panel]"));
let gestaoCarregada = false;
let importarCarregado = false;
// Só troca a UI — nunca dispara carregamento de dados sozinha. Isso evita
// uma corrida real que existiu aqui: um drill-down do Dashboard chamava
// `ativarTab("gestao")` (que carregava a Gestão com os filtros ainda
// default) E, na sequência, também recarregava com o filtro escolhido —
// as duas chamadas concorrentes escreviam em `acoesTodos`, e a que
// terminasse por último vencia (às vezes a errada, mostrando "Todas" em
// vez do filtro clicado). Quem precisa de dados chama
// `garantirGestaoCarregada()`/`carregarAcoes()` explicitamente, sempre
// com `await`, nunca em paralelo com outra chamada à mesma lista.
function ativarTab(nome) {
tabButtons.forEach((btn) => btn.classList.toggle("is-active", btn.dataset.ncfTab === nome));
tabPanels.forEach((panel) => {
panel.hidden = panel.dataset.ncfPanel !== nome;
});
}
tabButtons.forEach((btn) =>
btn.addEventListener("click", async () => {
const nome = btn.dataset.ncfTab;
ativarTab(nome);
if (nome === "gestao") await garantirGestaoCarregada();
if (nome === "importar" && !importarCarregado) {
importarCarregado = true;
await carregarImportacoes();
}
})
);
// ---------------- Estado compartilhado da Gestão ----------------
let semAnaliseTodos = [];
let analiseSemAcaoTodos = [];
let acoesTodos = [];
let prazoFiltroAtual = "todas";
let tratativaFiltroAtual = "pendente";
let buscaGestao = "";
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");
function filtrarPorBusca(itens, campos) {
const termo = buscaGestao.trim().toLowerCase();
if (!termo) return itens;
return itens.filter((item) =>
campos.some((campo) => (item[campo] || "").toString().toLowerCase().includes(termo))
);
}
function renderTudoGestao() {
renderSemAnalise();
renderAnaliseSemAcao();
renderAcoes();
}
function aplicarBusca(valor) {
buscaGestao = valor;
gestaoSearch.value = valor;
gestaoSearchClearBtn.hidden = !valor;
renderTudoGestao();
}
gestaoSearch.addEventListener("input", () => {
aplicarBusca(gestaoSearch.value);
});
gestaoSearchClearBtn.addEventListener("click", () => aplicarBusca(""));
function atualizarChipsAtivos(container, datasetAttr, valor) {
Array.from(container.children).forEach((btn) => btn.classList.toggle("is-active", btn.dataset[datasetAttr] === valor));
}
filtroPrazoEl.addEventListener("click", (evento) => {
const btn = evento.target.closest("[data-ncf-prazo]");
if (!btn) return;
prazoFiltroAtual = btn.dataset.ncfPrazo;
atualizarChipsAtivos(filtroPrazoEl, "ncfPrazo", prazoFiltroAtual);
carregarAcoes();
});
filtroTratativaEl.addEventListener("click", (evento) => {
const btn = evento.target.closest("[data-ncf-tratativa]");
if (!btn) return;
tratativaFiltroAtual = btn.dataset.ncfTratativa;
atualizarChipsAtivos(filtroTratativaEl, "ncfTratativa", tratativaFiltroAtual);
carregarAcoes();
});
async function garantirGestaoCarregada() {
if (gestaoCarregada) return;
gestaoCarregada = true;
await Promise.all([carregarSemAnalise(), carregarAnaliseSemAcao(), carregarAcoes()]);
}
// ---------------- Gestão: Ocorrências sem análise ----------------
const semAnaliseBody = document.getElementById("ncf-sem-analise-body");
const semAnaliseEmpty = document.getElementById("ncf-sem-analise-empty");
const semAnaliseCount = document.getElementById("ncf-sem-analise-count");
async function carregarSemAnalise() {
semAnaliseTodos = await pidApiRequest("/nao-conformidades/ocorrencias/?sem_analise=true");
renderSemAnalise();
}
function renderSemAnalise() {
const itens = filtrarPorBusca(semAnaliseTodos, [
"indicado_analise",
"responsavel_analise",
"clientes_relacionados",
"assunto",
"emissor_relato",
]);
pidNcfAtualizarContador(semAnaliseCount, itens.length);
semAnaliseBody.innerHTML = "";
semAnaliseEmpty.hidden = itens.length > 0;
itens.forEach((oc) => {
const tratado = oc.status_tratativa === "tratado";
const { btn, trDetalhe } = pidNcfCriarLinhaExpandivel(8, (painel) => montarPainelOcorrencia(painel, oc));
const tr = document.createElement("tr");
const tdBtn = document.createElement("td");
tdBtn.className = "ncf-expand-col";
tdBtn.appendChild(btn);
tr.appendChild(tdBtn);
tr.insertAdjacentHTML(
"beforeend",
`<td>${oc.codigo}</td>
<td>${pidNcfFormatDate(oc.data_emissao)}</td>
<td>${pidNcfEscapeHtml(oc.area) || "—"}</td>
<td>${pidNcfEscapeHtml(oc.emissor_relato) || "—"}</td>
<td>${pidNcfEscapeHtml(oc.indicado_analise) || "—"}</td>`
);
const tdTratativa = document.createElement("td");
tdTratativa.appendChild(pidNcfCriarBadgeTratativa(oc));
tr.appendChild(tdTratativa);
const tdAcao = document.createElement("td");
const tratarBtn = document.createElement("button");
tratarBtn.type = "button";
tratarBtn.className = tratado ? "btn-outline" : "btn-solid";
tratarBtn.textContent = tratado ? "Reabrir" : "Marcar como tratado";
tratarBtn.addEventListener("click", () => alternarTratativaOcorrencia(oc.id, tratado));
tdAcao.appendChild(tratarBtn);
tr.appendChild(tdAcao);
semAnaliseBody.appendChild(tr);
semAnaliseBody.appendChild(trDetalhe);
});
}
// ---------------- Gestão: Análise sem ação ----------------
const analiseSemAcaoBody = document.getElementById("ncf-analise-sem-acao-body");
const analiseSemAcaoEmpty = document.getElementById("ncf-analise-sem-acao-empty");
const analiseSemAcaoCount = document.getElementById("ncf-analise-sem-acao-count");
async function carregarAnaliseSemAcao() {
analiseSemAcaoTodos = await pidApiRequest("/nao-conformidades/ocorrencias/?analise_sem_acao=true");
renderAnaliseSemAcao();
}
function renderAnaliseSemAcao() {
const itens = filtrarPorBusca(analiseSemAcaoTodos, [
"indicado_analise",
"responsavel_analise",
"clientes_relacionados",
"assunto",
"descricao_analise",
]);
pidNcfAtualizarContador(analiseSemAcaoCount, itens.length);
analiseSemAcaoBody.innerHTML = "";
analiseSemAcaoEmpty.hidden = itens.length > 0;
itens.forEach((oc) => {
const tratado = oc.status_tratativa === "tratado";
const descricao = (oc.descricao_analise || "").slice(0, 140);
const { btn, trDetalhe } = pidNcfCriarLinhaExpandivel(8, (painel) => montarPainelOcorrencia(painel, oc));
const tr = document.createElement("tr");
const tdBtn = document.createElement("td");
tdBtn.className = "ncf-expand-col";
tdBtn.appendChild(btn);
tr.appendChild(tdBtn);
tr.insertAdjacentHTML(
"beforeend",
`<td>${oc.codigo}</td>
<td>${pidNcfFormatDate(oc.data_emissao)}</td>
<td>${pidNcfEscapeHtml(oc.area) || "—"}</td>
<td>${pidNcfEscapeHtml(oc.responsavel_analise) || "—"}</td>
<td>${pidNcfEscapeHtml(descricao)}${(oc.descricao_analise || "").length > 140 ? "…" : ""}</td>`
);
const tdTratativa = document.createElement("td");
tdTratativa.appendChild(pidNcfCriarBadgeTratativa(oc));
tr.appendChild(tdTratativa);
const tdAcao = document.createElement("td");
const tratarBtn = document.createElement("button");
tratarBtn.type = "button";
tratarBtn.className = tratado ? "btn-outline" : "btn-solid";
tratarBtn.textContent = tratado ? "Reabrir" : "Marcar como tratado";
tratarBtn.addEventListener("click", () => alternarTratativaOcorrencia(oc.id, tratado));
tdAcao.appendChild(tratarBtn);
tr.appendChild(tdAcao);
analiseSemAcaoBody.appendChild(tr);
analiseSemAcaoBody.appendChild(trDetalhe);
});
}
async function alternarTratativaOcorrencia(id, estaTratado) {
const acao = estaTratado ? "reabrir" : "marcar-tratado";
try {
await pidApiRequest(`/nao-conformidades/ocorrencias/${id}/${acao}/`, { method: "POST" });
await Promise.all([carregarSemAnalise(), carregarAnaliseSemAcao()]);
} catch (e) {
await pidAlert(e.message || "Não foi possível atualizar o status.");
}
}
// ---------------- Painel de detalhe de uma ocorrência (lazy) ----------------
async function montarPainelOcorrencia(painel, item) {
const idExtra = `ncf-extra-oc-${item.id}`;
painel.innerHTML = `
<div class="ncf-detail-grid">
<div><span class="ncf-detail-field__label">Assunto</span><span class="ncf-detail-field__value">${pidNcfEscapeHtml(item.assunto) || "—"}</span></div>
<div><span class="ncf-detail-field__label">Tipo</span><span class="ncf-detail-field__value">${pidNcfEscapeHtml(item.tipo_ocorrencia) || "—"}</span></div>
<div><span class="ncf-detail-field__label">Área / Setor</span><span class="ncf-detail-field__value">${pidNcfEscapeHtml(item.area) || "—"} / ${pidNcfEscapeHtml(item.setor) || "—"}</span></div>
<div><span class="ncf-detail-field__label">Cliente(s)</span><span class="ncf-detail-field__value">${pidNcfEscapeHtml(item.clientes_relacionados) || "—"}</span></div>
<div><span class="ncf-detail-field__label">Tipo(s) de causa</span><span class="ncf-detail-field__value">${pidNcfEscapeHtml(item.tipos_causa) || "—"}</span></div>
<div><span class="ncf-detail-field__label">Emissor</span><span class="ncf-detail-field__value">${pidNcfEscapeHtml(item.emissor_relato) || "—"}</span></div>
</div>
<h4>Descrição da análise</h4>
<p class="ncf-detail-panel__texto">${pidNcfEscapeHtml(item.descricao_analise) || "(sem análise registrada)"}</p>
<div id="${idExtra}"><p class="ncf-detail-panel__loading">Carregando mais detalhes...</p></div>
`;
const extra = painel.querySelector(`#${idExtra}`);
try {
const detalhe = await pidApiRequest(`/nao-conformidades/ocorrencias/${item.id}/`);
const acoesHtml = (detalhe.acoes || []).length
? `<h4>Ações vinculadas</h4><ul class="ncf-detail-lista">${detalhe.acoes
.map((a) => `<li>${a.ocorrencia_codigo}/${a.codigo} — ${pidNcfEscapeHtml((a.acao_texto || "").slice(0, 80))}</li>`)
.join("")}</ul>`
: "";
extra.innerHTML = `
<div class="ncf-detail-grid">
<div><span class="ncf-detail-field__label">Origem</span><span class="ncf-detail-field__value">${pidNcfEscapeHtml(detalhe.origem) || "—"}</span></div>
<div><span class="ncf-detail-field__label">Data do relato</span><span class="ncf-detail-field__value">${pidNcfFormatDate(detalhe.data_relato)}</span></div>
<div><span class="ncf-detail-field__label">Prazo pra finalizar</span><span class="ncf-detail-field__value">${pidNcfFormatDate(detalhe.prazo_finalizar)}</span></div>
<div><span class="ncf-detail-field__label">Pessoas relacionadas</span><span class="ncf-detail-field__value">${pidNcfEscapeHtml(detalhe.pessoas_relacionadas) || "—"}</span></div>
<div><span class="ncf-detail-field__label">Fornecedores relacionados</span><span class="ncf-detail-field__value">${pidNcfEscapeHtml(detalhe.fornecedores_relacionados) || "—"}</span></div>
<div><span class="ncf-detail-field__label">Riscos relacionados</span><span class="ncf-detail-field__value">${pidNcfEscapeHtml(detalhe.riscos_relacionados) || "—"}</span></div>
<div><span class="ncf-detail-field__label">Representante/Gerente</span><span class="ncf-detail-field__value">${pidNcfEscapeHtml(detalhe.representante_gerente) || "—"}</span></div>
</div>
<h4>Relato</h4>
<p class="ncf-detail-panel__texto">${pidNcfEscapeHtml(detalhe.relato) || "—"}</p>
${acoesHtml}
`;
} catch (e) {
extra.innerHTML = `<p class="modal-error">${pidNcfEscapeHtml(e.message || "Erro ao carregar detalhes.")}</p>`;
}
}
// ---------------- Gestão: Ações ----------------
const acoesBody = document.getElementById("ncf-acoes-body");
const acoesEmpty = document.getElementById("ncf-acoes-empty");
const acoesCount = document.getElementById("ncf-acoes-count");
async function carregarAcoes() {
const params = new URLSearchParams();
if (prazoFiltroAtual !== "todas") params.set("status_prazo", prazoFiltroAtual);
if (tratativaFiltroAtual !== "todas") params.set("status_tratativa", tratativaFiltroAtual);
const query = params.toString();
acoesTodos = await pidApiRequest(`/nao-conformidades/acoes/${query ? "?" + query : ""}`);
renderAcoes();
}
function renderAcoes() {
const itens = filtrarPorBusca(acoesTodos, ["executor", "ocorrencia_clientes", "ocorrencia_codigo", "acao_texto"]);
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));
const tr = document.createElement("tr");
const tdBtn = document.createElement("td");
tdBtn.className = "ncf-expand-col";
tdBtn.appendChild(btn);
tr.appendChild(tdBtn);
tr.insertAdjacentHTML(
"beforeend",
`<td>${acaoItem.ocorrencia_codigo}/${acaoItem.codigo}</td>
<td>${pidNcfEscapeHtml(acaoItem.ocorrencia_area) || "—"}</td>
<td>${pidNcfEscapeHtml(acaoItem.tipo_acao) || "—"}</td>
<td>${pidNcfEscapeHtml((acaoItem.acao_texto || "").slice(0, 90))}</td>
<td>${pidNcfEscapeHtml(acaoItem.executor) || "—"}</td>
<td>${pidNcfFormatDate(acaoItem.vencimento_efetivo)}</td>`
);
const tdPrazo = document.createElement("td");
const badgePrazo = document.createElement("span");
badgePrazo.className = PID_NCF_STATUS_PRAZO_CLASSES[acaoItem.status_prazo] || "status-pill status-pill--neutral";
badgePrazo.textContent = acaoItem.status_prazo_label;
tdPrazo.appendChild(badgePrazo);
tr.appendChild(tdPrazo);
const tdTratativa = document.createElement("td");
tdTratativa.appendChild(pidNcfCriarBadgeTratativa(acaoItem));
tr.appendChild(tdTratativa);
const tdAcao = document.createElement("td");
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);
acoesBody.appendChild(tr);
acoesBody.appendChild(trDetalhe);
});
}
async function alternarTratativaAcao(id, estaTratado) {
const acao = estaTratado ? "reabrir" : "marcar-tratado";
try {
await pidApiRequest(`/nao-conformidades/acoes/${id}/${acao}/`, { method: "POST" });
await carregarAcoes();
} catch (e) {
await pidAlert(e.message || "Não foi possível atualizar o status.");
}
}
// ---------------- Painel de detalhe de uma ação (lazy) — histórico ----------------
async function montarPainelAcao(painel, item) {
const idAcomp = `ncf-acomp-${item.id}`;
painel.innerHTML = `
<p class="ncf-detail-panel__texto"><strong>Ação:</strong> ${pidNcfEscapeHtml(item.acao_texto) || "—"}</p>
<h4>Histórico de acompanhamentos</h4>
<div id="${idAcomp}"><p class="ncf-detail-panel__loading">Carregando...</p></div>
`;
const container = painel.querySelector(`#${idAcomp}`);
try {
const detalhe = await pidApiRequest(`/nao-conformidades/acoes/${item.id}/`);
const entradas = (detalhe.acompanhamentos || []).slice().sort((a, b) => b.data.localeCompare(a.data));
if (!entradas.length) {
container.innerHTML = "<p>Nenhum acompanhamento registrado.</p>";
return;
}
container.innerHTML = "";
const lista = document.createElement("div");
lista.className = "ncf-acompanhamentos-list";
container.appendChild(lista);
function renderizarEntrada(entrada) {
const el = document.createElement("div");
el.className = "ncf-acompanhamento-item";
el.innerHTML = `
<div class="ncf-acompanhamento-item__header">
<strong>${pidNcfFormatDate(entrada.data)}</strong>
<span>${pidNcfEscapeHtml(entrada.autor) || "—"}</span>
${entrada.eh_prorrogacao ? '<span class="status-pill status-pill--warning">Prorrogação</span>' : ""}
</div>
<p></p>
`;
el.querySelector("p").textContent = entrada.texto;
lista.appendChild(el);
}
entradas.slice(0, 3).forEach(renderizarEntrada);
if (entradas.length > 3) {
const maisBtn = document.createElement("button");
maisBtn.type = "button";
maisBtn.className = "btn-outline ncf-mostrar-mais-btn";
maisBtn.textContent = `Mostrar mais (${entradas.length - 3})`;
maisBtn.addEventListener("click", () => {
entradas.slice(3).forEach(renderizarEntrada);
maisBtn.remove();
});
container.appendChild(maisBtn);
}
} catch (e) {
container.innerHTML = `<p class="modal-error">${pidNcfEscapeHtml(e.message || "Erro ao carregar histórico.")}</p>`;
}
}
// ---------------- Drill-down do Dashboard pra Gestão ----------------
async function irParaGestaoComBusca(valor) {
ativarTab("gestao");
await garantirGestaoCarregada();
aplicarBusca(valor);
}
async function irParaSecaoGestao(sectionId) {
ativarTab("gestao");
await garantirGestaoCarregada();
const el = document.getElementById(sectionId);
if (el) el.scrollIntoView({ behavior: "smooth", block: "start" });
}
async function irParaAcoesComPrazo(bucket) {
// Define o filtro ANTES de carregar — se a Gestão ainda não tinha sido
// aberta, o primeiro carregamento já sai com o filtro certo; se já
// tinha, recarrega só as Ações (sem repetir Sem Análise/Análise sem Ação).
prazoFiltroAtual = bucket;
tratativaFiltroAtual = "todas";
atualizarChipsAtivos(filtroPrazoEl, "ncfPrazo", bucket);
atualizarChipsAtivos(filtroTratativaEl, "ncfTratativa", "todas");
ativarTab("gestao");
if (gestaoCarregada) {
await carregarAcoes();
} else {
await garantirGestaoCarregada();
}
const el = document.getElementById("ncf-section-acoes");
if (el) el.scrollIntoView({ behavior: "smooth", block: "start" });
}
// ---------------- Dashboard ----------------
const dashCards = document.getElementById("ncf-dash-cards");
const dashKpis = document.getElementById("ncf-dash-kpis");
const dashMotivosBody = document.getElementById("ncf-dash-motivos-body");
const dashClientesBody = document.getElementById("ncf-dash-clientes-body");
const dashColaboradoresBody = document.getElementById("ncf-dash-colaboradores-body");
const periodoChipsEl = document.getElementById("ncf-dash-periodo-chips");
let periodoAtual = "6";
periodoChipsEl.addEventListener("click", (evento) => {
const btn = evento.target.closest("[data-ncf-periodo]");
if (!btn) return;
periodoAtual = btn.dataset.ncfPeriodo;
atualizarChipsAtivos(periodoChipsEl, "ncfPeriodo", periodoAtual);
carregarDashboard();
});
function preencheRankingMotivos(tbody, itens) {
tbody.innerHTML = "";
if (!itens.length) {
tbody.innerHTML = '<tr><td colspan="2">Nenhum registro no período.</td></tr>';
return;
}
itens.forEach((item) => {
const tr = document.createElement("tr");
tr.className = "ncf-clicavel";
tr.innerHTML = `<td>${pidNcfEscapeHtml(item.causa)}</td><td>${item.quantidade}</td>`;
tr.addEventListener("click", () => irParaGestaoComBusca(item.causa));
tbody.appendChild(tr);
});
}
function preencheRankingTipo(tbody, itens, chaveNome) {
tbody.innerHTML = "";
if (!itens.length) {
tbody.innerHTML = '<tr><td colspan="5">Nenhum registro no período.</td></tr>';
return;
}
itens.forEach((item) => {
const tr = document.createElement("tr");
tr.className = "ncf-clicavel";
tr.innerHTML = `
<td>${pidNcfEscapeHtml(item[chaveNome])}</td>
<td class="ncf-num--nc">${item.nc}</td>
<td class="ncf-num--reclamacao">${item.reclamacao}</td>
<td>${item.outros}</td>
<td class="ncf-num--total">${item.total}</td>
`;
tr.addEventListener("click", () => irParaGestaoComBusca(item[chaveNome]));
tbody.appendChild(tr);
});
}
function pidNcfFormatDias(valor) {
if (valor == null) return null;
return valor.toString().replace(".", ",");
}
async function carregarDashboard() {
const dados = await pidApiRequest(`/nao-conformidades/dashboard/?meses=${encodeURIComponent(periodoAtual)}`);
const totalAcoes = Object.values(dados.acoes_abertas_por_status || {}).reduce((a, b) => a + b, 0);
const cartoes = [
{ rotulo: "Ocorrências abertas", valor: dados.ocorrencias_abertas, tom: "" },
{ rotulo: "Ações abertas", valor: totalAcoes, tom: "" },
{
rotulo: "Ações vencidas",
valor: dados.acoes_abertas_por_status?.vencida || 0,
tom: "ncf-card--danger",
acao: () => irParaAcoesComPrazo("vencida"),
},
{
rotulo: "Vencem em ≤7 dias",
valor: dados.acoes_abertas_por_status?.vence_7_dias || 0,
tom: "ncf-card--warning",
acao: () => irParaAcoesComPrazo("vence_7_dias"),
},
{
rotulo: "Ocorrências sem análise",
valor: dados.sem_analise,
tom: "ncf-card--warning",
acao: () => irParaSecaoGestao("ncf-section-sem-analise"),
},
{
rotulo: "Análises sem ação",
valor: dados.analise_sem_acao,
tom: "ncf-card--warning",
acao: () => irParaSecaoGestao("ncf-section-analise-sem-acao"),
},
{ rotulo: "NCs sem ação corretiva", valor: dados.ncs_sem_acao_corretiva, tom: "ncf-card--danger" },
];
dashCards.innerHTML = "";
cartoes.forEach((cartao) => {
const el = document.createElement(cartao.acao ? "button" : "div");
el.className = cartao.acao ? `ncf-card ncf-card--clicavel ${cartao.tom}` : `ncf-card ${cartao.tom}`;
if (cartao.acao) {
el.type = "button";
el.addEventListener("click", cartao.acao);
}
el.innerHTML = `<span class="ncf-card__label">${cartao.rotulo}</span><span class="ncf-card__value">${cartao.valor}</span>`;
dashCards.appendChild(el);
});
const tempos = dados.tempos_medios || {};
const kpis = [
{ rotulo: "Preenchimento da análise (emissão → análise)", dias: pidNcfFormatDias(tempos.preenchimento_analise_dias) },
{ rotulo: "Abertura da ação (análise → ação aberta)", dias: pidNcfFormatDias(tempos.abertura_acao_dias) },
];
dashKpis.innerHTML = "";
kpis.forEach((kpi) => {
const el = document.createElement("div");
el.className = "ncf-kpi";
const valorHtml =
kpi.dias == null
? '<span class="ncf-kpi__value ncf-kpi__value--vazio">Sem dados suficientes</span>'
: `<span class="ncf-kpi__value">${kpi.dias} dias</span>`;
el.innerHTML = `<span class="ncf-kpi__label">${kpi.rotulo}</span>${valorHtml}`;
dashKpis.appendChild(el);
});
preencheRankingMotivos(dashMotivosBody, dados.motivos_abertura || []);
preencheRankingTipo(dashClientesBody, dados.clientes_maior_incidencia || [], "cliente");
preencheRankingTipo(dashColaboradoresBody, dados.colaboradores_maior_incidencia || [], "colaborador");
}
// ---------------- Importar ----------------
const importToggleBtn = document.getElementById("ncf-import-toggle-btn");
const importForm = document.getElementById("ncf-import-form");
const formOcorrencias = document.getElementById("ncf-form-ocorrencias");
const formOcorrenciasName = document.getElementById("ncf-form-ocorrencias-name");
const formAcoes = document.getElementById("ncf-form-acoes");
const formAcoesName = document.getElementById("ncf-form-acoes-name");
const formError = document.getElementById("ncf-form-error");
const formCancelBtn = document.getElementById("ncf-form-cancel-btn");
const formSubmitBtn = document.getElementById("ncf-form-submit-btn");
const importResultado = document.getElementById("ncf-import-resultado");
const importListBody = document.getElementById("ncf-import-list-body");
const importListEmpty = document.getElementById("ncf-import-list-empty");
importToggleBtn.addEventListener("click", () => {
importForm.hidden = !importForm.hidden;
});
formCancelBtn.addEventListener("click", () => {
importForm.hidden = true;
formError.textContent = "";
});
formOcorrencias.addEventListener("change", () => {
formOcorrenciasName.textContent = formOcorrencias.files[0]?.name || "Nenhum arquivo selecionado";
});
formAcoes.addEventListener("change", () => {
formAcoesName.textContent = formAcoes.files[0]?.name || "Nenhum arquivo selecionado";
});
formSubmitBtn.addEventListener("click", async () => {
formError.textContent = "";
if (!formOcorrencias.files[0] || !formAcoes.files[0]) {
formError.textContent = "Anexe os dois arquivos antes de processar.";
return;
}
const dados = new FormData();
dados.append("arquivo_ocorrencias", formOcorrencias.files[0]);
dados.append("arquivo_acoes", formAcoes.files[0]);
formSubmitBtn.disabled = true;
formSubmitBtn.textContent = "Processando...";
try {
const importacao = await pidApiRequest("/nao-conformidades/importacoes/", { method: "POST", body: dados });
importForm.hidden = true;
formOcorrencias.value = "";
formAcoes.value = "";
formOcorrenciasName.textContent = "Nenhum arquivo selecionado";
formAcoesName.textContent = "Nenhum arquivo selecionado";
mostrarResultadoImportacao(importacao);
await carregarImportacoes();
gestaoCarregada = false;
await garantirGestaoCarregada();
await carregarDashboard();
} catch (e) {
formError.textContent = e.message || "Não foi possível processar a importação.";
} finally {
formSubmitBtn.disabled = false;
formSubmitBtn.textContent = "Processar";
}
});
function mostrarResultadoImportacao(importacao) {
const resumo = importacao.resumo || {};
const avisos = importacao.avisos || [];
const partes = [
`${resumo.ocorrencias_novas || 0} ocorrência(s) nova(s), ${resumo.ocorrencias_atualizadas || 0} atualizada(s)`,
`${resumo.acoes_novas || 0} ação(ões) nova(s), ${resumo.acoes_atualizadas || 0} atualizada(s)`,
`${resumo.acompanhamentos_novos || 0} acompanhamento(s) novo(s)`,
`${(resumo.itens_reabertos || []).length} item(ns) reaberto(s) automaticamente`,
];
let texto = "Importação processada: " + partes.join(" · ") + ".";
if (avisos.length) texto += " Avisos: " + avisos.join(" ");
importResultado.textContent = texto;
importResultado.hidden = false;
}
async function carregarImportacoes() {
const itens = await pidApiRequest("/nao-conformidades/importacoes/");
importListBody.innerHTML = "";
importListEmpty.hidden = itens.length > 0;
itens.forEach((importacao) => {
const resumo = importacao.resumo || {};
const avisos = importacao.avisos || [];
const tr = document.createElement("tr");
tr.innerHTML = `
<td>${pidNcfFormatDateTime(importacao.criado_em)}</td>
<td>${pidNcfEscapeHtml(importacao.criado_por_nome) || "—"}</td>
<td>${resumo.ocorrencias_novas || 0} / ${resumo.ocorrencias_atualizadas || 0}</td>
<td>${resumo.acoes_novas || 0} / ${resumo.acoes_atualizadas || 0}</td>
<td>${(resumo.itens_reabertos || []).length}</td>
<td>${avisos.length ? avisos.length + " aviso(s)" : "—"}</td>
<td></td>
`;
const acaoTd = tr.lastElementChild;
const excluirBtn = document.createElement("button");
excluirBtn.type = "button";
excluirBtn.className = "btn-danger-outline";
excluirBtn.textContent = "Excluir";
excluirBtn.addEventListener("click", async () => {
const confirmado = await pidConfirm(
"Excluir este registro de importação? As ocorrências/ações já importadas não são afetadas.",
{ perigoso: true }
);
if (!confirmado) return;
try {
await pidApiRequest(`/nao-conformidades/importacoes/${importacao.id}/`, { method: "DELETE" });
await carregarImportacoes();
} catch (e) {
await pidAlert(e.message || "Não foi possível excluir.");
}
});
acaoTd.appendChild(excluirBtn);
importListBody.appendChild(tr);
});
}
// Carga inicial — a aba Dashboard já vem visível por padrão.
await carregarDashboard();
});

View File

@ -113,6 +113,20 @@
<ul class="nav-group__list">
<li data-app="relatorio-setorial"><a href="#" class="nav-subitem">Relatório Setorial</a></li>
<li data-app="indicadores"><a href="#" class="nav-subitem">Indicadores</a></li>
<li class="nav-subgroup" data-subgroup="qualidade">
<button type="button" class="nav-subitem nav-subgroup__toggle" data-subgroup-toggle>
<span>Qualidade</span>
<svg class="nav-item__chevron" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 6l6 6-6 6" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
<ul class="nav-subgroup__list">
<li data-app="nao-conformidades">
<a href="nao-conformidades.html" class="nav-subitem nav-subitem--tool">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 2h9l3 3v15a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V3a1 1 0 0 1 1-1z"/><path d="M14 2v4h4" stroke-linejoin="round"/></svg>
Não Conformidades
</a>
</li>
</ul>
</li>
</ul>
</li>

View File

@ -114,6 +114,20 @@
<ul class="nav-group__list">
<li data-app="relatorio-setorial"><a href="#" class="nav-subitem">Relatório Setorial</a></li>
<li data-app="indicadores"><a href="#" class="nav-subitem">Indicadores</a></li>
<li class="nav-subgroup" data-subgroup="qualidade">
<button type="button" class="nav-subitem nav-subgroup__toggle" data-subgroup-toggle>
<span>Qualidade</span>
<svg class="nav-item__chevron" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 6l6 6-6 6" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
<ul class="nav-subgroup__list">
<li data-app="nao-conformidades">
<a href="nao-conformidades.html" class="nav-subitem nav-subitem--tool">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 2h9l3 3v15a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V3a1 1 0 0 1 1-1z"/><path d="M14 2v4h4" stroke-linejoin="round"/></svg>
Não Conformidades
</a>
</li>
</ul>
</li>
</ul>
</li>

View File

@ -113,6 +113,20 @@
<ul class="nav-group__list">
<li data-app="relatorio-setorial"><a href="#" class="nav-subitem">Relatório Setorial</a></li>
<li data-app="indicadores"><a href="#" class="nav-subitem">Indicadores</a></li>
<li class="nav-subgroup" data-subgroup="qualidade">
<button type="button" class="nav-subitem nav-subgroup__toggle" data-subgroup-toggle>
<span>Qualidade</span>
<svg class="nav-item__chevron" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 6l6 6-6 6" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
<ul class="nav-subgroup__list">
<li data-app="nao-conformidades">
<a href="nao-conformidades.html" class="nav-subitem nav-subitem--tool">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 2h9l3 3v15a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V3a1 1 0 0 1 1-1z"/><path d="M14 2v4h4" stroke-linejoin="round"/></svg>
Não Conformidades
</a>
</li>
</ul>
</li>
</ul>
</li>

View File

@ -114,6 +114,20 @@
<ul class="nav-group__list">
<li data-app="relatorio-setorial"><a href="#" class="nav-subitem">Relatório Setorial</a></li>
<li data-app="indicadores"><a href="#" class="nav-subitem">Indicadores</a></li>
<li class="nav-subgroup" data-subgroup="qualidade">
<button type="button" class="nav-subitem nav-subgroup__toggle" data-subgroup-toggle>
<span>Qualidade</span>
<svg class="nav-item__chevron" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 6l6 6-6 6" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
<ul class="nav-subgroup__list">
<li data-app="nao-conformidades">
<a href="nao-conformidades.html" class="nav-subitem nav-subitem--tool">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 2h9l3 3v15a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V3a1 1 0 0 1 1-1z"/><path d="M14 2v4h4" stroke-linejoin="round"/></svg>
Não Conformidades
</a>
</li>
</ul>
</li>
</ul>
</li>

View File

@ -114,6 +114,20 @@
<ul class="nav-group__list">
<li data-app="relatorio-setorial"><a href="#" class="nav-subitem">Relatório Setorial</a></li>
<li data-app="indicadores"><a href="#" class="nav-subitem">Indicadores</a></li>
<li class="nav-subgroup" data-subgroup="qualidade">
<button type="button" class="nav-subitem nav-subgroup__toggle" data-subgroup-toggle>
<span>Qualidade</span>
<svg class="nav-item__chevron" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 6l6 6-6 6" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
<ul class="nav-subgroup__list">
<li data-app="nao-conformidades">
<a href="nao-conformidades.html" class="nav-subitem nav-subitem--tool">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 2h9l3 3v15a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V3a1 1 0 0 1 1-1z"/><path d="M14 2v4h4" stroke-linejoin="round"/></svg>
Não Conformidades
</a>
</li>
</ul>
</li>
</ul>
</li>

View File

@ -114,6 +114,20 @@
<ul class="nav-group__list">
<li data-app="relatorio-setorial"><a href="#" class="nav-subitem">Relatório Setorial</a></li>
<li data-app="indicadores"><a href="#" class="nav-subitem">Indicadores</a></li>
<li class="nav-subgroup" data-subgroup="qualidade">
<button type="button" class="nav-subitem nav-subgroup__toggle" data-subgroup-toggle>
<span>Qualidade</span>
<svg class="nav-item__chevron" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 6l6 6-6 6" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
<ul class="nav-subgroup__list">
<li data-app="nao-conformidades">
<a href="nao-conformidades.html" class="nav-subitem nav-subitem--tool">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 2h9l3 3v15a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V3a1 1 0 0 1 1-1z"/><path d="M14 2v4h4" stroke-linejoin="round"/></svg>
Não Conformidades
</a>
</li>
</ul>
</li>
</ul>
</li>

View File

@ -0,0 +1,687 @@
{% load static %}
<!DOCTYPE html>
<html lang="pt-BR" data-theme="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>P.I.D. — Não Conformidades</title>
<link rel="icon" type="image/svg+xml" href="{% static 'img/pid-icone-escuro.svg' %}" />
<link rel="stylesheet" href="{% static 'css/tokens.css' %}" />
<link rel="stylesheet" href="{% static 'css/base.css' %}" />
<link rel="stylesheet" href="{% static 'css/layout.css' %}" />
<link rel="stylesheet" href="{% static 'css/components.css' %}" />
<link rel="stylesheet" href="{% static 'css/perfis-acesso.css' %}" />
<link rel="stylesheet" href="{% static 'css/nao-conformidades.css' %}" />
<script src="{% static 'js/theme.js' %}" defer></script>
</head>
<body>
<div class="app-shell" id="app-shell">
<aside class="sidebar">
<a class="sidebar__brand" href="portal.html" id="sidebar-brand-link" aria-label="Ir para a tela Principal">
<svg class="sidebar__logo sidebar__logo--full" viewBox="0 0 300 80" aria-hidden="true" focusable="false">
<g transform="translate(8,8) scale(1)">
<path d="M10 4 h34 l14 14 v40 a2 2 0 0 1 -2 2 H8 a2 2 0 0 1 -2 -2 V6 a2 2 0 0 1 2 -2 z" fill="#7B5BA8"></path>
<rect x="22" y="4" width="20" height="16" fill="#C6A24A"></rect>
<rect x="34" y="7" width="5" height="10" fill="#7B5BA8"></rect>
<rect x="12" y="26" width="40" height="30" rx="2" fill="#241C33"></rect>
<g class="pid-icon-eye">
<circle cx="24" cy="38" r="5" fill="#F2EDE3"></circle>
<circle cx="26" cy="36.2" r="1.6" fill="#241C33"></circle>
</g>
<g class="pid-icon-eye">
<circle cx="40" cy="38" r="5" fill="#F2EDE3"></circle>
<circle cx="42" cy="36.2" r="1.6" fill="#241C33"></circle>
</g>
<path d="M25 47 q7 6 14 0" stroke="#C6A24A" stroke-width="3" fill="none" stroke-linecap="round"></path>
</g>
<text x="86" y="42" font-family="'Space Grotesk',Helvetica,Arial,sans-serif" font-size="34" font-weight="700" letter-spacing="1.4" fill="#F2EDE3">P.I.D.</text>
<text x="88" y="61" font-family="'DM Mono',ui-monospace,monospace" font-size="9.5" letter-spacing="2.6" fill="#C6A24A">PORTAL INTERNO DA DE PAULA</text>
</svg>
<svg class="sidebar__logo sidebar__logo--icon" viewBox="0 0 64 64" aria-hidden="true" focusable="false">
<path d="M10 4 h34 l14 14 v40 a2 2 0 0 1 -2 2 H8 a2 2 0 0 1 -2 -2 V6 a2 2 0 0 1 2 -2 z" fill="#7B5BA8"></path>
<rect x="22" y="4" width="20" height="16" fill="#C6A24A"></rect>
<rect x="34" y="7" width="5" height="10" fill="#7B5BA8"></rect>
<rect x="12" y="26" width="40" height="30" rx="2" fill="#241C33"></rect>
<g class="pid-icon-eye">
<circle cx="24" cy="38" r="5" fill="#F2EDE3"></circle>
<circle cx="26" cy="36.2" r="1.6" fill="#241C33"></circle>
</g>
<g class="pid-icon-eye">
<circle cx="40" cy="38" r="5" fill="#F2EDE3"></circle>
<circle cx="42" cy="36.2" r="1.6" fill="#241C33"></circle>
</g>
<path d="M25 47 q7 6 14 0" stroke="#C6A24A" stroke-width="3" fill="none" stroke-linecap="round"></path>
</svg>
</a>
<nav class="sidebar__nav">
<ul class="nav-list">
<li data-section="principal">
<a href="portal.html" class="nav-item">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 11l9-7 9 7v9a1 1 0 0 1-1 1h-5v-6H9v6H4a1 1 0 0 1-1-1z" stroke-linejoin="round"/></svg>
<span class="nav-item__label">Principal</span>
</a>
</li>
<li data-section="ramais">
<a href="ramais.html" class="nav-item">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 4c0 9 7 16 16 16l3-4-6-3-2 2c-3-1.5-5-3.5-6-6l2-2-3-6z" stroke-linejoin="round"/></svg>
<span class="nav-item__label">Ramais</span>
</a>
</li>
<li data-section="calendario">
<a href="#" class="nav-item" id="calendario-depaula-btn">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="5" width="18" height="16" rx="2"/><path d="M3 9h18M8 3v4M16 3v4" stroke-linecap="round"/></svg>
<span class="nav-item__label">Calendário De Paula</span>
</a>
</li>
<li data-section="calendario-individual">
<a href="calendario-individual.html" class="nav-item">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="5" width="18" height="16" rx="2"/><path d="M3 9h18M8 3v4M16 3v4" stroke-linecap="round"/><path d="M9 15l2 2 4-4" stroke-linecap="round" stroke-linejoin="round"/></svg>
<span class="nav-item__label">Calendário Individual</span>
</a>
</li>
<li data-section="portais" class="nav-group">
<button type="button" class="nav-item nav-group__toggle" data-group-toggle>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/></svg>
<span class="nav-item__label">Portais</span>
<svg class="nav-item__chevron" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 6l6 6-6 6" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
<ul class="nav-group__list">
<li data-app="portal-do-cliente"><a href="#" class="nav-subitem">Portal do Cliente</a></li>
<li data-app="portal-fiscal"><a href="#" class="nav-subitem">Portal Fiscal</a></li>
</ul>
</li>
<li data-section="geradoc" class="nav-group">
<button type="button" class="nav-item nav-group__toggle" data-group-toggle>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 3h9l3 3v15a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1z"/><path d="M14 3v4h4" stroke-linejoin="round"/></svg>
<span class="nav-item__label">Geradoc</span>
<svg class="nav-item__chevron" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 6l6 6-6 6" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
<ul class="nav-group__list">
<li data-app="simulacao-custo-contratacao"><a href="custo-contratacao.html" class="nav-subitem">Simulação de Custo de Contratação</a></li>
<li data-app="indicador-desempenho"><a href="indicador-desempenho.html" class="nav-subitem">Indicador de Desempenho</a></li>
</ul>
</li>
<li data-section="relatorios" class="nav-group">
<button type="button" class="nav-item nav-group__toggle" data-group-toggle>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 20V10M12 20V4M20 20v-7" stroke-linecap="round"/></svg>
<span class="nav-item__label">Relatórios</span>
<svg class="nav-item__chevron" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 6l6 6-6 6" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
<ul class="nav-group__list">
<li data-app="relatorio-setorial"><a href="#" class="nav-subitem">Relatório Setorial</a></li>
<li data-app="indicadores"><a href="#" class="nav-subitem">Indicadores</a></li>
<li class="nav-subgroup" data-subgroup="qualidade">
<button type="button" class="nav-subitem nav-subgroup__toggle" data-subgroup-toggle>
<span>Qualidade</span>
<svg class="nav-item__chevron" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 6l6 6-6 6" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
<ul class="nav-subgroup__list">
<li data-app="nao-conformidades">
<a href="nao-conformidades.html" class="nav-subitem nav-subitem--tool is-active">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 2h9l3 3v15a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V3a1 1 0 0 1 1-1z"/><path d="M14 2v4h4" stroke-linejoin="round"/></svg>
Não Conformidades
</a>
</li>
</ul>
</li>
</ul>
</li>
<li data-section="relatorios-gerenciais" class="nav-group">
<button type="button" class="nav-item nav-group__toggle" data-group-toggle>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 3v18h18M7 15l4-5 3 3 5-7" stroke-linecap="round" stroke-linejoin="round"/></svg>
<span class="nav-item__label">Relatórios Gerenciais</span>
<span class="badge-restricted" data-restricted-badge hidden>Restrito</span>
<svg class="nav-item__chevron" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 6l6 6-6 6" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
<ul class="nav-group__list">
<li data-app="visao-diretoria"><a href="#" class="nav-subitem">Visão Diretoria</a></li>
<li data-app="visao-gerencial"><a href="#" class="nav-subitem">Visão Gerencial</a></li>
</ul>
</li>
<li data-section="utilitarios" class="nav-group">
<button type="button" class="nav-item nav-group__toggle" data-group-toggle>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 7l3 3-8 8-4 1 1-4z" stroke-linejoin="round"/><path d="M12 9l3 3" stroke-linecap="round"/></svg>
<span class="nav-item__label">Utilitários</span>
<svg class="nav-item__chevron" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 6l6 6-6 6" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
<ul class="nav-group__list">
<li data-app="importacao-plano-saude"><a href="importacao-plano-saude.html" class="nav-subitem">Importação de Plano de Saúde</a></li>
</ul>
</li>
<li data-section="integracoes" class="nav-group">
<button type="button" class="nav-item nav-group__toggle" data-group-toggle>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="6" cy="12" r="3"/><circle cx="18" cy="6" r="3"/><circle cx="18" cy="18" r="3"/><path d="M8.6 10.6L15.4 7.4M8.6 13.4l6.8 3.2" stroke-linecap="round"/></svg>
<span class="nav-item__label">Integrações</span>
<svg class="nav-item__chevron" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 6l6 6-6 6" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
<ul class="nav-group__list">
<li data-app="questor"><a href="#" class="nav-subitem">Questor</a></li>
</ul>
</li>
<li data-section="auditorias" class="nav-group">
<button type="button" class="nav-item nav-group__toggle" data-group-toggle>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 3l7 4v5c0 5-3.5 8-7 9-3.5-1-7-4-7-9V7z" stroke-linejoin="round"/><path d="M9 12l2 2 4-4" stroke-linecap="round" stroke-linejoin="round"/></svg>
<span class="nav-item__label">Auditorias</span>
<svg class="nav-item__chevron" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 6l6 6-6 6" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
<ul class="nav-group__list">
<li class="nav-subgroup" data-subgroup="consultoria-tributaria">
<button type="button" class="nav-subitem nav-subgroup__toggle" data-subgroup-toggle>
<span>Consultoria Tributária</span>
<svg class="nav-item__chevron" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 6l6 6-6 6" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
<ul class="nav-subgroup__list">
<li data-app="controle-simples-nacional">
<a href="#" class="nav-subitem nav-subitem--tool">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 2h9l3 3v15a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V3a1 1 0 0 1 1-1z"/><path d="M14 2v4h4" stroke-linejoin="round"/></svg>
Controle Simples Nacional
</a>
</li>
</ul>
</li>
<li class="nav-subgroup" data-subgroup="fisco-contabil">
<button type="button" class="nav-subitem nav-subgroup__toggle" data-subgroup-toggle>
<span>Fisco/Contábil</span>
<svg class="nav-item__chevron" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 6l6 6-6 6" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
<ul class="nav-subgroup__list">
<li data-app="auditoria-de-balancetes">
<a href="#" class="nav-subitem nav-subitem--tool">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 2h9l3 3v15a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V3a1 1 0 0 1 1-1z"/><path d="M14 2v4h4" stroke-linejoin="round"/></svg>
Auditoria de Balancetes
</a>
</li>
</ul>
</li>
</ul>
</li>
<li data-section="links-ferramentas" class="nav-group">
<button type="button" class="nav-item nav-group__toggle" data-group-toggle>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10 14a4 4 0 0 0 5.7 0l3-3a4 4 0 0 0-5.7-5.7l-1 1"/><path d="M14 10a4 4 0 0 0-5.7 0l-3 3a4 4 0 0 0 5.7 5.7l1-1"/></svg>
<span class="nav-item__label">Links &amp; Ferramentas</span>
<svg class="nav-item__chevron" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 6l6 6-6 6" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
<ul class="nav-group__list">
<li data-app="links-ferramentas-visualizar"><a href="links-ferramentas.html" class="nav-subitem">Links &amp; Ferramentas</a></li>
<li data-app="acessos-gerais-visualizar"><a href="acessos-gerais.html" class="nav-subitem">Acessos Gerais</a></li>
</ul>
</li>
<li data-section="solicitacoes" class="nav-group">
<button type="button" class="nav-item nav-group__toggle" data-group-toggle>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M8 10h.01M12 10h.01M16 10h.01M21 12a9 9 0 1 1-4.4-7.7L21 3l-1.2 4.5A8.96 8.96 0 0 1 21 12z"/></svg>
<span class="nav-item__label">Solicitações</span>
<svg class="nav-item__chevron" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 6l6 6-6 6" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
<ul class="nav-group__list">
<li data-app="controle-notificacoes"><a href="https://form.asana.com/?k=PcELrDLzN969CwpiRBLtFA&amp;d=1207308016523218" target="_blank" rel="noopener noreferrer" class="nav-subitem">Controle de Notificações</a></li>
<li data-app="ordem-servico"><a href="#" class="nav-subitem">Ordem de Serviço</a></li>
<li data-app="pimi"><a href="https://form.asana.com/?k=XfBww25lGUyPVrInNcLyuQ&amp;d=1207308016523218" target="_blank" rel="noopener noreferrer" class="nav-subitem">P.I.M.I.</a></li>
<li data-app="programa-indicacoes"><a href="https://form.asana.com/?k=y0mTeaRTOAoC0r0XP8ee0Q&amp;d=1207308016523218" target="_blank" rel="noopener noreferrer" class="nav-subitem">Programa de Indicações</a></li>
<li data-app="pos-vendas"><a href="https://form.asana.com/?k=jRAztLip-_BknWPsGmEcZA&amp;d=1207308016523218" target="_blank" rel="noopener noreferrer" class="nav-subitem">Pós-Vendas</a></li>
<li data-app="sugestao-inovacao"><a href="https://form.asana.com/?k=Zn8cLeT97R7FTUrsNB8Yaw&amp;d=1207308016523218" target="_blank" rel="noopener noreferrer" class="nav-subitem">Sugestão de Inovação</a></li>
</ul>
</li>
<li data-section="administracao" class="nav-group">
<button type="button" class="nav-item nav-group__toggle" data-group-toggle>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6z"/><path d="M19.4 15a1.7 1.7 0 0 0 .3 1.9l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.9-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1-1.6 1.7 1.7 0 0 0-1.9.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.9 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1 1.7 1.7 0 0 0-.3-1.9l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.9.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.9-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.9V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1z" stroke-linejoin="round"/></svg>
<span class="nav-item__label">Administração</span>
<svg class="nav-item__chevron" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 6l6 6-6 6" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
<ul class="nav-group__list">
<li data-section="perfis-acesso"><a href="perfis-acesso.html" class="nav-subitem">Perfis de Acesso</a></li>
<li data-section="usuarios"><a href="usuarios.html" class="nav-subitem">Usuários</a></li>
</ul>
</li>
</ul>
</nav>
</aside>
<div class="main-content">
<header class="topbar">
<button type="button" class="icon-btn" id="sidebar-toggle" aria-label="Alternar menu">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 6h16M4 12h16M4 18h16" stroke-linecap="round"/></svg>
</button>
<h1 class="topbar__title">Não Conformidades</h1>
<div class="topbar__actions">
<button type="button" class="icon-btn" id="theme-toggle" aria-label="Ativar modo claro">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4" stroke-linecap="round"/></svg>
</button>
<div class="notifications">
<button type="button" class="icon-btn" id="notif-bell" aria-label="Notificações">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 8a6 6 0 0 1 12 0c0 5 2 6 2 6H4s2-1 2-6" stroke-linejoin="round"/><path d="M9.5 20a2.5 2.5 0 0 0 5 0"/></svg>
<span class="notif-badge" id="notif-badge" hidden>0</span>
</button>
<div class="notif-dropdown" id="notif-dropdown" hidden>
<div class="notif-dropdown__header">
<span>Notificações</span>
<div class="notif-dropdown__header-actions">
<button type="button" id="notif-history-btn">Histórico</button>
<button type="button" id="notif-clear-all">Limpar tudo</button>
</div>
</div>
<ul class="notif-list" id="notif-list"></ul>
</div>
</div>
<div class="account-menu">
<button type="button" class="persona-chip" id="persona-chip">
<span class="persona-chip__icon">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 20v-1a6 6 0 0 1 6-6h4a6 6 0 0 1 6 6v1" stroke-linecap="round" stroke-linejoin="round"/><circle cx="12" cy="7" r="4"/></svg>
</span>
<span id="persona-chip-label">Usuário</span>
</button>
<div class="account-dropdown" id="account-dropdown" hidden>
<div class="account-dropdown__header">
<p class="account-dropdown__name" id="account-name"></p>
<p class="account-dropdown__profile" id="account-profile"></p>
</div>
<button type="button" class="account-dropdown__item" id="account-manage-account-btn">Gerenciar Usuário</button>
<a href="perfis-acesso.html" class="account-dropdown__item" id="account-manage-link" hidden>Gerenciar Perfis de Acesso</a>
<div class="account-dropdown__section">
<p class="account-dropdown__section-title">Tema de cores</p>
<div class="theme-swatches" id="theme-swatches"></div>
</div>
<button type="button" class="account-dropdown__item account-dropdown__logout" data-logout>Sair</button>
</div>
</div>
</div>
</header>
<main class="page-content page-content--wide">
<div id="ncf-no-access" class="no-access" hidden>
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"><rect x="4" y="10" width="16" height="10" rx="2"/><path d="M8 10V7a4 4 0 0 1 8 0v3" stroke-linecap="round" stroke-linejoin="round"/></svg>
<h2>Sem acesso a Não Conformidades</h2>
<p>Seu perfil de acesso não tem permissão para visualizar esta tela. Solicite à Inovação a liberação em Perfis de Acesso.</p>
</div>
<div id="ncf-content">
<div class="ncf-header">
<div>
<h2>Não Conformidades</h2>
<p class="ncf-subtitle">Gestão contínua das ocorrências e ações do Sistema de Gestão da Qualidade (Sigsistem) — importe periodicamente os dois arquivos exportados para manter o acompanhamento atualizado.</p>
</div>
</div>
<div class="pa-tabs" id="ncf-tabs">
<button type="button" class="pa-tab is-active" data-ncf-tab="dashboard">Dashboard</button>
<button type="button" class="pa-tab" data-ncf-tab="gestao">Gestão</button>
<button type="button" class="pa-tab" data-ncf-tab="importar">Importar</button>
</div>
<div class="pa-tab-panel" data-ncf-panel="dashboard">
<div class="ncf-cards" id="ncf-dash-cards"></div>
<div class="ncf-kpis-section">
<h3 class="ncf-kpis-title">Tempos médios</h3>
<div class="ncf-kpis" id="ncf-dash-kpis"></div>
</div>
<div class="ncf-dashboard-toolbar">
<span class="ncf-filtro-grupo__label">Período (motivos/clientes/colaboradores):</span>
<div class="ncf-chip-row" id="ncf-dash-periodo-chips">
<button type="button" class="ncf-chip is-active" data-ncf-periodo="6">6 meses</button>
<button type="button" class="ncf-chip" data-ncf-periodo="12">12 meses</button>
<button type="button" class="ncf-chip" data-ncf-periodo="24">24 meses</button>
<button type="button" class="ncf-chip" data-ncf-periodo="1200">Todo o histórico</button>
</div>
</div>
<div class="ncf-dash-legend">
<span class="info-tooltip">
<button type="button" class="info-tooltip__trigger" aria-label="Como os rankings de clientes/colaboradores são calculados">?</button>
<span class="info-tooltip__text" role="tooltip">Cada ranking conta ocorrências de qualquer tipo relacionadas àquele cliente/indicadas àquele colaborador para análise, no período selecionado, quebradas por tipo.</span>
</span>
<span class="ncf-legend-item"><span class="ncf-legend-dot ncf-legend-dot--nc"></span>Não Conformidade</span>
<span class="ncf-legend-item"><span class="ncf-legend-dot ncf-legend-dot--reclamacao"></span>Reclamação de Cliente</span>
<span class="ncf-legend-item"><span class="ncf-legend-dot ncf-legend-dot--outros"></span>Outros tipos</span>
</div>
<div class="ncf-dash-rankings">
<div class="ncf-dash-ranking">
<h3>Motivos de abertura</h3>
<div class="pa-table-wrap">
<table class="pa-table">
<thead><tr><th>Causa</th><th>Qtd.</th></tr></thead>
<tbody id="ncf-dash-motivos-body"></tbody>
</table>
</div>
</div>
<div class="ncf-dash-ranking">
<h3>Clientes com maior incidência</h3>
<div class="pa-table-wrap">
<table class="pa-table">
<thead><tr><th>Cliente</th><th>NC</th><th>Recl.</th><th>Outros</th><th>Total</th></tr></thead>
<tbody id="ncf-dash-clientes-body"></tbody>
</table>
</div>
</div>
<div class="ncf-dash-ranking">
<h3>Colaboradores com maior incidência</h3>
<div class="pa-table-wrap">
<table class="pa-table">
<thead><tr><th>Colaborador</th><th>NC</th><th>Recl.</th><th>Outros</th><th>Total</th></tr></thead>
<tbody id="ncf-dash-colaboradores-body"></tbody>
</table>
</div>
</div>
</div>
</div>
<div class="pa-tab-panel" data-ncf-panel="gestao" hidden>
<div class="ncf-gestao-toolbar">
<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>
<section class="ncf-section" id="ncf-section-sem-analise">
<div class="ncf-section__header">
<h3>Ocorrências sem análise</h3>
<span class="ncf-count" id="ncf-sem-analise-count"></span>
</div>
<p class="ncf-section__hint">Ocorrências abertas sem nenhuma análise registrada — cobrar o responsável indicado.</p>
<div class="pa-table-wrap">
<table class="pa-table">
<thead>
<tr>
<th></th>
<th>Código</th>
<th>Emissão</th>
<th>Área</th>
<th>Emissor</th>
<th>Indicado para análise</th>
<th>Tratativa</th>
<th></th>
</tr>
</thead>
<tbody id="ncf-sem-analise-body"></tbody>
</table>
</div>
<p class="ncf-empty" id="ncf-sem-analise-empty" hidden>Nenhuma ocorrência sem análise no momento.</p>
</section>
<section class="ncf-section" id="ncf-section-analise-sem-acao">
<div class="ncf-section__header">
<h3>Análise sem ação</h3>
<span class="ncf-count" id="ncf-analise-sem-acao-count"></span>
</div>
<p class="ncf-section__hint">Análise já registrada, mas nenhuma ação foi aberta ainda.</p>
<div class="pa-table-wrap">
<table class="pa-table">
<thead>
<tr>
<th></th>
<th>Código</th>
<th>Emissão</th>
<th>Área</th>
<th>Responsável pela análise</th>
<th>Descrição da análise</th>
<th>Tratativa</th>
<th></th>
</tr>
</thead>
<tbody id="ncf-analise-sem-acao-body"></tbody>
</table>
</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 is-active" data-ncf-prazo="todas">Todas</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>
</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">
<thead>
<tr>
<th></th>
<th>Ocorrência/Ação</th>
<th>Área</th>
<th>Tipo</th>
<th>Ação</th>
<th>Responsável</th>
<th>Vencimento</th>
<th>Prazo</th>
<th>Tratativa</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>
<div class="ncf-header">
<div>
<h3>Nova importação</h3>
<p class="ncf-section__hint">Anexe os dois arquivos exportados do Sigsistem (ocorrências .xlsx + ações .xls) — o processamento atualiza as ocorrências/ações já existentes e cria as novas.</p>
</div>
<button type="button" class="btn-solid" id="ncf-import-toggle-btn">Nova Importação</button>
</div>
<div class="ncf-import-form" id="ncf-import-form" hidden>
<div class="modal-field">
<label>Arquivo de ocorrências (.xlsx)</label>
<div class="ncf-file-field">
<label class="btn-outline ncf-file-field__btn" for="ncf-form-ocorrencias">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 16V4M12 4l4 4M12 4L8 8" stroke-linecap="round" stroke-linejoin="round"/><path d="M4 16v3a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-3" stroke-linecap="round" stroke-linejoin="round"/></svg>
Selecionar arquivo
</label>
<input type="file" id="ncf-form-ocorrencias" accept=".xlsx" class="ncf-file-field__input" />
<span class="ncf-file-field__name" id="ncf-form-ocorrencias-name">Nenhum arquivo selecionado</span>
</div>
</div>
<div class="modal-field">
<label>Arquivo de ações (.xls)</label>
<div class="ncf-file-field">
<label class="btn-outline ncf-file-field__btn" for="ncf-form-acoes">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 16V4M12 4l4 4M12 4L8 8" stroke-linecap="round" stroke-linejoin="round"/><path d="M4 16v3a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-3" stroke-linecap="round" stroke-linejoin="round"/></svg>
Selecionar arquivo
</label>
<input type="file" id="ncf-form-acoes" accept=".xls" class="ncf-file-field__input" />
<span class="ncf-file-field__name" id="ncf-form-acoes-name">Nenhum arquivo selecionado</span>
</div>
</div>
<p class="modal-error" id="ncf-form-error"></p>
<div class="modal-actions">
<button type="button" class="btn-outline" id="ncf-form-cancel-btn">Cancelar</button>
<button type="button" class="btn-solid" id="ncf-form-submit-btn">Processar</button>
</div>
</div>
<p class="ncf-import-resultado" id="ncf-import-resultado" hidden></p>
<div class="pa-table-wrap">
<table class="pa-table">
<thead>
<tr>
<th>Data</th>
<th>Criado por</th>
<th>Ocorrências (novas/atualizadas)</th>
<th>Ações (novas/atualizadas)</th>
<th>Reabertos</th>
<th>Avisos</th>
<th></th>
</tr>
</thead>
<tbody id="ncf-import-list-body"></tbody>
</table>
</div>
<p class="ncf-empty" id="ncf-import-list-empty" hidden>Nenhuma importação realizada ainda.</p>
</div>
</div>
</main>
</div>
</div>
<div class="modal-overlay" id="manage-account-modal" hidden>
<div class="modal-card" id="manage-account-modal-card">
<h2 class="modal-card__title">Gerenciar Usuário</h2>
<div class="modal-field">
<button type="button" class="btn-outline" id="manage-account-change-password-btn">Alterar senha</button>
</div>
<div class="modal-field" id="manage-account-liderados-field" hidden>
<label>Usuários sob sua liderança</label>
<div class="dual-select">
<div class="dual-select__panel">
<div class="dual-select__panel-header">
<button type="button" class="btn-solid" id="manage-account-liderados-available-link-btn" disabled>Vincular</button>
</div>
<div class="dual-select__table-wrap">
<table class="dual-select__table">
<thead>
<tr>
<th><input type="checkbox" id="manage-account-liderados-available-select-all" aria-label="Selecionar todos" /></th>
<th class="dual-select__sort" id="manage-account-liderados-available-sort-nome">Nome <span class="dual-select__sort-icon">↕</span></th>
<th>Departamento</th>
</tr>
<tr class="dual-select__filters">
<th></th>
<th><input type="text" id="manage-account-liderados-available-search-nome" placeholder="Buscar por nome..." autocomplete="off" /></th>
<th><input type="text" id="manage-account-liderados-available-search-departamento" placeholder="Buscar por departamento..." autocomplete="off" /></th>
</tr>
</thead>
<tbody id="manage-account-liderados-available-body"></tbody>
</table>
</div>
<div class="dual-select__footer" id="manage-account-liderados-available-footer"></div>
</div>
<div class="dual-select__panel">
<div class="dual-select__panel-header">
<button type="button" class="btn-danger-outline" id="manage-account-liderados-linked-unlink-btn" disabled>Desvincular</button>
</div>
<div class="dual-select__table-wrap">
<table class="dual-select__table">
<thead>
<tr>
<th><input type="checkbox" id="manage-account-liderados-linked-select-all" aria-label="Selecionar todos" /></th>
<th class="dual-select__sort" id="manage-account-liderados-linked-sort-nome">Nome <span class="dual-select__sort-icon">↕</span></th>
<th>Departamento</th>
</tr>
<tr class="dual-select__filters">
<th></th>
<th><input type="text" id="manage-account-liderados-linked-search-nome" placeholder="Buscar por nome..." autocomplete="off" /></th>
<th><input type="text" id="manage-account-liderados-linked-search-departamento" placeholder="Buscar por departamento..." autocomplete="off" /></th>
</tr>
</thead>
<tbody id="manage-account-liderados-linked-body"></tbody>
</table>
</div>
<div class="dual-select__footer" id="manage-account-liderados-linked-footer"></div>
</div>
</div>
<p class="modal-error" id="manage-account-liderados-error"></p>
</div>
<div class="modal-actions">
<button type="button" class="btn-outline" id="manage-account-close-btn">Fechar</button>
<button type="button" class="btn-solid" id="manage-account-save-btn" hidden>Salvar liderados</button>
</div>
</div>
</div>
<div class="modal-overlay" id="password-modal" hidden>
<div class="modal-card">
<h2 class="modal-card__title">Alterar senha</h2>
<div class="modal-field">
<label for="pwd-current">Senha atual</label>
<div class="password-field">
<input type="password" id="pwd-current" autocomplete="current-password" />
<button type="button" class="password-field__toggle" id="pwd-current-toggle" aria-label="Mostrar senha"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-7 11-7 11 7 11 7-4 7-11 7-11-7-11-7Z" stroke-linecap="round" stroke-linejoin="round"/><circle cx="12" cy="12" r="3"/></svg></button>
</div>
</div>
<div class="modal-field">
<label for="pwd-new">Nova senha</label>
<div class="password-field">
<input type="password" id="pwd-new" autocomplete="new-password" />
<button type="button" class="password-field__toggle" id="pwd-new-toggle" aria-label="Mostrar senha"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-7 11-7 11 7 11 7-4 7-11 7-11-7-11-7Z" stroke-linecap="round" stroke-linejoin="round"/><circle cx="12" cy="12" r="3"/></svg></button>
</div>
</div>
<div class="modal-field">
<label for="pwd-confirm">Confirmar nova senha</label>
<div class="password-field">
<input type="password" id="pwd-confirm" autocomplete="new-password" />
<button type="button" class="password-field__toggle" id="pwd-confirm-toggle" aria-label="Mostrar senha"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-7 11-7 11 7 11 7-4 7-11 7-11-7-11-7Z" stroke-linecap="round" stroke-linejoin="round"/><circle cx="12" cy="12" r="3"/></svg></button>
</div>
</div>
<p class="modal-error" id="pwd-error"></p>
<div class="modal-actions">
<button type="button" class="btn-outline" id="pwd-cancel-btn">Cancelar</button>
<button type="button" class="btn-solid" id="pwd-save-btn">Salvar</button>
</div>
</div>
</div>
<div class="modal-overlay" id="notif-history-modal" hidden>
<div class="modal-card">
<h2 class="modal-card__title">Histórico de Notificações</h2>
<ul class="notif-list" id="notif-history-list"></ul>
<div class="modal-actions">
<button type="button" class="btn-outline" id="notif-history-close-btn">Fechar</button>
</div>
</div>
</div>
<div class="modal-overlay" id="calendario-depaula-modal" hidden>
<div class="modal-card iframe-modal-card">
<div class="iframe-modal-header">
<h2 class="modal-card__title">Calendário De Paula</h2>
<button type="button" class="icon-btn" id="calendario-depaula-close-btn" aria-label="Fechar">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 6L6 18M6 6l12 12" stroke-linecap="round"/></svg>
</button>
</div>
<iframe id="calendario-depaula-iframe" class="iframe-modal-iframe" title="Calendário De Paula"></iframe>
</div>
</div>
<script src="{% static 'js/api.js' %}"></script>
<script src="{% static 'js/confirm-modal.js' %}"></script>
<script src="{% static 'js/sidebar-brand.js' %}"></script>
<script src="{% static 'js/dual-select.js' %}"></script>
<script src="{% static 'js/profiles.js' %}"></script>
<script src="{% static 'js/auth.js' %}"></script>
<script src="{% static 'js/access.js' %}"></script>
<script src="{% static 'js/account.js' %}"></script>
<script src="{% static 'js/favorites.js' %}"></script>
<script src="{% static 'js/events.js' %}"></script>
<script src="{% static 'js/sidebar.js' %}"></script>
<script src="{% static 'js/notifications.js' %}"></script>
<script src="{% static 'js/nao-conformidades.js' %}"></script>
</body>
</html>

View File

@ -113,6 +113,20 @@
<ul class="nav-group__list">
<li data-app="relatorio-setorial"><a href="#" class="nav-subitem">Relatório Setorial</a></li>
<li data-app="indicadores"><a href="#" class="nav-subitem">Indicadores</a></li>
<li class="nav-subgroup" data-subgroup="qualidade">
<button type="button" class="nav-subitem nav-subgroup__toggle" data-subgroup-toggle>
<span>Qualidade</span>
<svg class="nav-item__chevron" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 6l6 6-6 6" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
<ul class="nav-subgroup__list">
<li data-app="nao-conformidades">
<a href="nao-conformidades.html" class="nav-subitem nav-subitem--tool">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 2h9l3 3v15a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V3a1 1 0 0 1 1-1z"/><path d="M14 2v4h4" stroke-linejoin="round"/></svg>
Não Conformidades
</a>
</li>
</ul>
</li>
</ul>
</li>

View File

@ -115,6 +115,20 @@
<ul class="nav-group__list">
<li data-app="relatorio-setorial"><a href="#" class="nav-subitem">Relatório Setorial</a></li>
<li data-app="indicadores"><a href="#" class="nav-subitem">Indicadores</a></li>
<li class="nav-subgroup" data-subgroup="qualidade">
<button type="button" class="nav-subitem nav-subgroup__toggle" data-subgroup-toggle>
<span>Qualidade</span>
<svg class="nav-item__chevron" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 6l6 6-6 6" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
<ul class="nav-subgroup__list">
<li data-app="nao-conformidades">
<a href="nao-conformidades.html" class="nav-subitem nav-subitem--tool">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 2h9l3 3v15a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V3a1 1 0 0 1 1-1z"/><path d="M14 2v4h4" stroke-linejoin="round"/></svg>
Não Conformidades
</a>
</li>
</ul>
</li>
</ul>
</li>

View File

@ -114,6 +114,20 @@
<ul class="nav-group__list">
<li data-app="relatorio-setorial"><a href="#" class="nav-subitem">Relatório Setorial</a></li>
<li data-app="indicadores"><a href="#" class="nav-subitem">Indicadores</a></li>
<li class="nav-subgroup" data-subgroup="qualidade">
<button type="button" class="nav-subitem nav-subgroup__toggle" data-subgroup-toggle>
<span>Qualidade</span>
<svg class="nav-item__chevron" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 6l6 6-6 6" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
<ul class="nav-subgroup__list">
<li data-app="nao-conformidades">
<a href="nao-conformidades.html" class="nav-subitem nav-subitem--tool">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 2h9l3 3v15a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V3a1 1 0 0 1 1-1z"/><path d="M14 2v4h4" stroke-linejoin="round"/></svg>
Não Conformidades
</a>
</li>
</ul>
</li>
</ul>
</li>

View File

@ -113,6 +113,20 @@
<ul class="nav-group__list">
<li data-app="relatorio-setorial"><a href="#" class="nav-subitem">Relatório Setorial</a></li>
<li data-app="indicadores"><a href="#" class="nav-subitem">Indicadores</a></li>
<li class="nav-subgroup" data-subgroup="qualidade">
<button type="button" class="nav-subitem nav-subgroup__toggle" data-subgroup-toggle>
<span>Qualidade</span>
<svg class="nav-item__chevron" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 6l6 6-6 6" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
<ul class="nav-subgroup__list">
<li data-app="nao-conformidades">
<a href="nao-conformidades.html" class="nav-subitem nav-subitem--tool">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 2h9l3 3v15a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V3a1 1 0 0 1 1-1z"/><path d="M14 2v4h4" stroke-linejoin="round"/></svg>
Não Conformidades
</a>
</li>
</ul>
</li>
</ul>
</li>