Nova tela de Qualidade - NCs
This commit is contained in:
parent
15f53faa33
commit
b0181b6f1f
13
CLAUDE.md
13
CLAUDE.md
@ -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` |
|
| 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` |
|
| 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` |
|
| 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):
|
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)
|
### 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`).
|
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`). |
|
| `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`). |
|
| `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`). |
|
| `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.
|
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`.
|
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
|
## CSS — organização entre arquivos
|
||||||
|
|
||||||
| Arquivo | Contém |
|
| Arquivo | Contém |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `tokens.css` | Variáveis (`:root`, tema claro em `:root[data-theme="light"]`). |
|
| `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`). |
|
| `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). |
|
| `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`. |
|
| `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`. |
|
| `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. |
|
| `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`. |
|
| `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). |
|
| `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`).
|
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`).
|
||||||
|
|
||||||
|
|||||||
@ -24,6 +24,7 @@ PAGINAS_FRONTEND = [
|
|||||||
"importacao-plano-saude.html",
|
"importacao-plano-saude.html",
|
||||||
"custo-contratacao.html",
|
"custo-contratacao.html",
|
||||||
"indicador-desempenho.html",
|
"indicador-desempenho.html",
|
||||||
|
"nao-conformidades.html",
|
||||||
]
|
]
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
|
|||||||
11215
media/nao_conformidades/acoes/acoes_27082026.xls
Normal file
11215
media/nao_conformidades/acoes/acoes_27082026.xls
Normal file
File diff suppressed because it is too large
Load Diff
11215
media/nao_conformidades/acoes/acoes_27082026_85afN4i.xls
Normal file
11215
media/nao_conformidades/acoes/acoes_27082026_85afN4i.xls
Normal file
File diff suppressed because it is too large
Load Diff
11215
media/nao_conformidades/acoes/acoes_27082026_PneJG9w.xls
Normal file
11215
media/nao_conformidades/acoes/acoes_27082026_PneJG9w.xls
Normal file
File diff suppressed because it is too large
Load Diff
11215
media/nao_conformidades/acoes/acoes_27082026_YoiiMJa.xls
Normal file
11215
media/nao_conformidades/acoes/acoes_27082026_YoiiMJa.xls
Normal file
File diff suppressed because it is too large
Load Diff
11215
media/nao_conformidades/acoes/acoes_27082026_u5PN6hH.xls
Normal file
11215
media/nao_conformidades/acoes/acoes_27082026_u5PN6hH.xls
Normal file
File diff suppressed because it is too large
Load Diff
BIN
media/nao_conformidades/ocorrencias/ocorrencias_pa_27082026.xlsx
Normal file
BIN
media/nao_conformidades/ocorrencias/ocorrencias_pa_27082026.xlsx
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
22
plano.md
22
plano.md
@ -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.
|
- `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.
|
- 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
|
## Limitações conhecidas / decisões assumidas
|
||||||
|
|
||||||
- Calendário De Paula (empresa) não tem mais versão interna — depende
|
- 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
|
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
|
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.
|
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.
|
||||||
|
|||||||
@ -43,6 +43,18 @@ MODULE_APPS = {
|
|||||||
],
|
],
|
||||||
"relatorios": [
|
"relatorios": [
|
||||||
{"key": "relatorio-setorial", "label": "Relatório Setorial"},
|
{"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": [
|
"relatorios-gerenciais": [
|
||||||
{"key": "visao-diretoria", "label": "Visão Diretoria"},
|
{"key": "visao-diretoria", "label": "Visão Diretoria"},
|
||||||
|
|||||||
@ -166,6 +166,13 @@ class Command(BaseCommand):
|
|||||||
# todo perfil) — sem este override, todo perfil nasceria podendo criar
|
# todo perfil) — sem este override, todo perfil nasceria podendo criar
|
||||||
# evento de departamento/todos e cadastrar categoria de evento.
|
# evento de departamento/todos e cadastrar categoria de evento.
|
||||||
permissoes["calendario-individual"]["apps"]["calendario-individual-criar-evento"] = False
|
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
|
# `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
|
# 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
|
# espírito de "só inicializa na primeira criação" já usado pra
|
||||||
|
|||||||
@ -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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
18
portal_api/migrations/0053_alter_ncocorrencia_assunto.py
Normal file
18
portal_api/migrations/0053_alter_ncocorrencia_assunto.py
Normal 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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -1461,3 +1461,220 @@ class ParametroFiscalCustoContratacao(models.Model):
|
|||||||
|
|
||||||
def __str__(self) -> str:
|
def __str__(self) -> str:
|
||||||
return f"{self.importacao} → {self.nome} ({self.motivo})"
|
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}"
|
||||||
|
|||||||
46
portal_api/nao_conformidades/CHANGELOG.md
Normal file
46
portal_api/nao_conformidades/CHANGELOG.md
Normal 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.
|
||||||
96
portal_api/nao_conformidades/CLAUDE.md
Normal file
96
portal_api/nao_conformidades/CLAUDE.md
Normal 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).
|
||||||
19
portal_api/nao_conformidades/README.md
Normal file
19
portal_api/nao_conformidades/README.md
Normal 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`.
|
||||||
0
portal_api/nao_conformidades/__init__.py
Normal file
0
portal_api/nao_conformidades/__init__.py
Normal file
66
portal_api/nao_conformidades/classificacao.py
Normal file
66
portal_api/nao_conformidades/classificacao.py
Normal 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
|
||||||
79
portal_api/nao_conformidades/diff.py
Normal file
79
portal_api/nao_conformidades/diff.py
Normal 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
|
||||||
90
portal_api/nao_conformidades/leiaute_acoes.py
Normal file
90
portal_api/nao_conformidades/leiaute_acoes.py
Normal 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
|
||||||
209
portal_api/nao_conformidades/leiaute_ocorrencias.py
Normal file
209
portal_api/nao_conformidades/leiaute_ocorrencias.py
Normal 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()
|
||||||
88
portal_api/nao_conformidades/modelos.py
Normal file
88
portal_api/nao_conformidades/modelos.py
Normal 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]
|
||||||
22
portal_api/nao_conformidades/pipeline.py
Normal file
22
portal_api/nao_conformidades/pipeline.py
Normal 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)
|
||||||
@ -2,12 +2,14 @@ from typing import Any
|
|||||||
|
|
||||||
import nh3
|
import nh3
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
|
from django.utils import timezone
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
from rest_framework.validators import UniqueTogetherValidator
|
from rest_framework.validators import UniqueTogetherValidator
|
||||||
|
|
||||||
from . import catalogo
|
from . import catalogo
|
||||||
from .empresas_questor import normalizar_codigo_empresa, resolve_nome_empresa
|
from .empresas_questor import normalizar_codigo_empresa, resolve_nome_empresa
|
||||||
from .indicadores import calculo as indicadores_calculo
|
from .indicadores import calculo as indicadores_calculo
|
||||||
|
from .nao_conformidades import classificacao as nc_classificacao
|
||||||
from .models import (
|
from .models import (
|
||||||
AcessoGeral,
|
AcessoGeral,
|
||||||
AcessoGeralSecao,
|
AcessoGeralSecao,
|
||||||
@ -32,6 +34,10 @@ from .models import (
|
|||||||
IndicadorPercentualTipo,
|
IndicadorPercentualTipo,
|
||||||
LinkFerramenta,
|
LinkFerramenta,
|
||||||
LinkFerramentaFavorito,
|
LinkFerramentaFavorito,
|
||||||
|
NaoConformidadeImportacao,
|
||||||
|
NCAcao,
|
||||||
|
NCAcompanhamento,
|
||||||
|
NCOcorrencia,
|
||||||
NotificacaoDispensada,
|
NotificacaoDispensada,
|
||||||
ParametroFiscalCustoContratacao,
|
ParametroFiscalCustoContratacao,
|
||||||
PerfilAcesso,
|
PerfilAcesso,
|
||||||
@ -1535,3 +1541,149 @@ class ParametroFiscalCustoContratacaoSerializer(serializers.ModelSerializer):
|
|||||||
|
|
||||||
def validate_faixas_irrf(self, valor: Any) -> list[dict[str, float]]:
|
def validate_faixas_irrf(self, valor: Any) -> list[dict[str, float]]:
|
||||||
return _valida_faixas(valor, "IRRF")
|
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"]
|
||||||
|
|||||||
@ -65,6 +65,11 @@ router.register(
|
|||||||
views.IndicadorApuracaoEmpresaViewSet,
|
views.IndicadorApuracaoEmpresaViewSet,
|
||||||
basename="indicador-apuracao-empresa",
|
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 = [
|
urlpatterns = [
|
||||||
path("auth/csrf/", views.csrf_view, name="auth-csrf"),
|
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("ajuda-aplicacoes/<str:app_key>/", views.ajuda_aplicacao_view, name="ajuda-aplicacao"),
|
||||||
path("usuarios-resumo/", views.usuarios_resumo_view, name="usuarios-resumo"),
|
path("usuarios-resumo/", views.usuarios_resumo_view, name="usuarios-resumo"),
|
||||||
path("departamentos-resumo/", views.departamentos_resumo_view, name="departamentos-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)),
|
path("", include(router.urls)),
|
||||||
]
|
]
|
||||||
|
|||||||
@ -1,21 +1,25 @@
|
|||||||
|
import calendar
|
||||||
import csv
|
import csv
|
||||||
|
import hashlib
|
||||||
import io
|
import io
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import tempfile
|
import tempfile
|
||||||
import zipfile
|
import zipfile
|
||||||
|
from datetime import date, timedelta
|
||||||
from typing import Any, Iterable
|
from typing import Any, Iterable
|
||||||
|
|
||||||
import holidays
|
import holidays
|
||||||
from django.core.files.base import ContentFile
|
from django.core.files.base import ContentFile
|
||||||
from django.core.files.uploadedfile import UploadedFile
|
from django.core.files.uploadedfile import UploadedFile
|
||||||
from django.db import transaction
|
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.contrib.auth import authenticate, login, logout
|
||||||
from django.http import HttpResponse
|
from django.http import HttpResponse
|
||||||
from django.middleware.csrf import get_token
|
from django.middleware.csrf import get_token
|
||||||
from django.shortcuts import get_object_or_404
|
from django.shortcuts import get_object_or_404
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
from django.utils.dateparse import parse_date
|
||||||
from django.views.decorators.csrf import ensure_csrf_cookie
|
from django.views.decorators.csrf import ensure_csrf_cookie
|
||||||
from rest_framework import status, viewsets
|
from rest_framework import status, viewsets
|
||||||
from rest_framework.decorators import action, api_view, permission_classes
|
from rest_framework.decorators import action, api_view, permission_classes
|
||||||
@ -52,6 +56,10 @@ from .models import (
|
|||||||
IndicadorPercentualTipo,
|
IndicadorPercentualTipo,
|
||||||
LinkFerramenta,
|
LinkFerramenta,
|
||||||
LinkFerramentaFavorito,
|
LinkFerramentaFavorito,
|
||||||
|
NaoConformidadeImportacao,
|
||||||
|
NCAcao,
|
||||||
|
NCAcompanhamento,
|
||||||
|
NCOcorrencia,
|
||||||
NotificacaoDispensada,
|
NotificacaoDispensada,
|
||||||
ParametroFiscalCustoContratacao,
|
ParametroFiscalCustoContratacao,
|
||||||
PerfilAcesso,
|
PerfilAcesso,
|
||||||
@ -67,6 +75,9 @@ from .indicadores import calculo as indicadores_calculo
|
|||||||
from .indicadores import departamentos as indicadores_departamentos
|
from .indicadores import departamentos as indicadores_departamentos
|
||||||
from .indicadores import pipeline as indicadores_pipeline
|
from .indicadores import pipeline as indicadores_pipeline
|
||||||
from .indicadores import recibo as indicadores_recibo
|
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 .permissions import PermissaoApp, PodeGerenciarPermissoes
|
||||||
from .empresas_questor import normalizar_codigo_empresa, resolve_nome_empresa
|
from .empresas_questor import normalizar_codigo_empresa, resolve_nome_empresa
|
||||||
from .planos_saude import matcher as planos_saude_matcher
|
from .planos_saude import matcher as planos_saude_matcher
|
||||||
@ -117,6 +128,12 @@ from .serializers import (
|
|||||||
IndicadorPercentualTipoSerializer,
|
IndicadorPercentualTipoSerializer,
|
||||||
LinkFerramentaFavoritoSerializer,
|
LinkFerramentaFavoritoSerializer,
|
||||||
LinkFerramentaSerializer,
|
LinkFerramentaSerializer,
|
||||||
|
NaoConformidadeImportacaoCreateSerializer,
|
||||||
|
NaoConformidadeImportacaoSerializer,
|
||||||
|
NCAcaoDetailSerializer,
|
||||||
|
NCAcaoSerializer,
|
||||||
|
NCOcorrenciaDetailSerializer,
|
||||||
|
NCOcorrenciaSerializer,
|
||||||
NotificacaoDispensadaSerializer,
|
NotificacaoDispensadaSerializer,
|
||||||
ParametroFiscalCustoContratacaoSerializer,
|
ParametroFiscalCustoContratacaoSerializer,
|
||||||
PerfilAcessoSerializer,
|
PerfilAcessoSerializer,
|
||||||
@ -2020,3 +2037,464 @@ class IndicadorApuracaoRespostaViewSet(viewsets.ModelViewSet):
|
|||||||
indicadores_calculo.recalcula_colaborador(colaborador)
|
indicadores_calculo.recalcula_colaborador(colaborador)
|
||||||
|
|
||||||
return Response({"atualizadas": len(respostas)})
|
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()},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|||||||
Binary file not shown.
11215
projects/Controladoria - Analise NCs/acoes_27082026.xls
Normal file
11215
projects/Controladoria - Analise NCs/acoes_27082026.xls
Normal file
File diff suppressed because it is too large
Load Diff
BIN
projects/Controladoria - Analise NCs/analise-ncs.skill
Normal file
BIN
projects/Controladoria - Analise NCs/analise-ncs.skill
Normal file
Binary file not shown.
134
projects/Controladoria - Analise NCs/analise-ncs_SKILL.md
Normal file
134
projects/Controladoria - Analise NCs/analise-ncs_SKILL.md
Normal 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`).
|
||||||
Binary file not shown.
505
static/css/nao-conformidades.css
Normal file
505
static/css/nao-conformidades.css
Normal 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;
|
||||||
|
}
|
||||||
778
static/js/nao-conformidades.js
Normal file
778
static/js/nao-conformidades.js
Normal 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();
|
||||||
|
});
|
||||||
@ -113,6 +113,20 @@
|
|||||||
<ul class="nav-group__list">
|
<ul class="nav-group__list">
|
||||||
<li data-app="relatorio-setorial"><a href="#" class="nav-subitem">Relatório Setorial</a></li>
|
<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 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>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
|
|||||||
@ -114,6 +114,20 @@
|
|||||||
<ul class="nav-group__list">
|
<ul class="nav-group__list">
|
||||||
<li data-app="relatorio-setorial"><a href="#" class="nav-subitem">Relatório Setorial</a></li>
|
<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 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>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
|
|||||||
@ -113,6 +113,20 @@
|
|||||||
<ul class="nav-group__list">
|
<ul class="nav-group__list">
|
||||||
<li data-app="relatorio-setorial"><a href="#" class="nav-subitem">Relatório Setorial</a></li>
|
<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 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>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
|
|||||||
@ -114,6 +114,20 @@
|
|||||||
<ul class="nav-group__list">
|
<ul class="nav-group__list">
|
||||||
<li data-app="relatorio-setorial"><a href="#" class="nav-subitem">Relatório Setorial</a></li>
|
<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 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>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
|
|||||||
@ -114,6 +114,20 @@
|
|||||||
<ul class="nav-group__list">
|
<ul class="nav-group__list">
|
||||||
<li data-app="relatorio-setorial"><a href="#" class="nav-subitem">Relatório Setorial</a></li>
|
<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 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>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
|
|||||||
@ -114,6 +114,20 @@
|
|||||||
<ul class="nav-group__list">
|
<ul class="nav-group__list">
|
||||||
<li data-app="relatorio-setorial"><a href="#" class="nav-subitem">Relatório Setorial</a></li>
|
<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 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>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
|
|||||||
687
templates/nao-conformidades.html
Normal file
687
templates/nao-conformidades.html
Normal 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 & 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 & 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&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&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&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&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&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>
|
||||||
@ -113,6 +113,20 @@
|
|||||||
<ul class="nav-group__list">
|
<ul class="nav-group__list">
|
||||||
<li data-app="relatorio-setorial"><a href="#" class="nav-subitem">Relatório Setorial</a></li>
|
<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 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>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
|
|||||||
@ -115,6 +115,20 @@
|
|||||||
<ul class="nav-group__list">
|
<ul class="nav-group__list">
|
||||||
<li data-app="relatorio-setorial"><a href="#" class="nav-subitem">Relatório Setorial</a></li>
|
<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 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>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
|
|||||||
@ -114,6 +114,20 @@
|
|||||||
<ul class="nav-group__list">
|
<ul class="nav-group__list">
|
||||||
<li data-app="relatorio-setorial"><a href="#" class="nav-subitem">Relatório Setorial</a></li>
|
<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 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>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
|
|||||||
@ -113,6 +113,20 @@
|
|||||||
<ul class="nav-group__list">
|
<ul class="nav-group__list">
|
||||||
<li data-app="relatorio-setorial"><a href="#" class="nav-subitem">Relatório Setorial</a></li>
|
<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 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>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user