775 lines
257 KiB
Markdown
775 lines
257 KiB
Markdown
# CLAUDE.md
|
||
|
||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||
|
||
## Contexto do projeto
|
||
|
||
Portal interno da De Paula Contadores ("Portal De Paula"). `Portal/` **é** o próprio projeto Django — **Python 3.13 + Django 6.0 + Django REST Framework + PostgreSQL 14** — organizado no padrão convencional de um projeto Django (`manage.py` na raiz, app `portal_api/`, `templates/`, `static/`), servindo tanto a API (`/api/...`) quanto o frontend HTML/CSS/JS (mesma origem — ver "Arquitetura" abaixo).
|
||
|
||
Até uma rodada anterior, todo o estado (sessão, usuários, perfis de acesso, favoritos, widgets, compromissos) vivia no `localStorage` do navegador — não havia backend. Isso mudou: o usuário decidiu a stack real e pediu a migração completa desses dados para o banco. Ver `plano.md` para o histórico de decisões rodada a rodada; consultar antes de mudar algo que pareça uma limitação (ex.: ausência de teste automatizado, remoção do calendário interno antigo) sem confirmar se foi decisão deliberada.
|
||
|
||
**O que continua só no `localStorage`**: apenas a preferência de tema (claro/escuro e cor do tema) — é preferência de navegador, não dado de negócio, e ficou fora do escopo da migração por decisão explícita do usuário.
|
||
|
||
## Como rodar / testar localmente
|
||
|
||
### Backend (obrigatório para qualquer teste agora — o frontend não funciona mais sozinho via `file://`/`http.server`)
|
||
|
||
```
|
||
cd Portal
|
||
python -m venv .venv
|
||
.venv\Scripts\activate # Windows
|
||
pip install -r requirements.txt
|
||
```
|
||
|
||
Configurar as variáveis de ambiente do Postgres 14 antes de migrar — `config/settings.py` chama `load_dotenv(BASE_DIR / ".env")` e lê `DB_NAME`, `DB_USER`, `DB_PASSWORD`, `DB_HOST`, `DB_PORT` (o arquivo `.env` já existe na raiz de `Portal/`):
|
||
|
||
```
|
||
python manage.py makemigrations portal_api
|
||
python manage.py migrate
|
||
python manage.py seed_portal # recria os 8 perfis + usuários gabriel/bruno
|
||
python manage.py runserver
|
||
```
|
||
|
||
Abrir `http://localhost:8000/` — o Django serve `index.html` e as demais páginas do frontend diretamente (ver `config/urls.py`), então **não há mais um segundo servidor** (`python -m http.server`) para o frontend.Vamos desenvolver outra
|
||
|
||
Contas de demonstração (criadas por `seed_portal`, senha via `set_password` do Django — não é mais texto puro):
|
||
|
||
- `gabriel` / `gabriel` — perfil "Integração e Inovação" (código 8), o único com `gerencia_permissoes=True` (acesso total + gerencia Perfis de Acesso/Usuários) e o único com os `*-editar` de `links-ferramentas` (cartões e Acessos Gerais) e de `ramais` `True` (único perfil que pode reordenar/incluir/remover cartões em Links & Ferramentas, criar/editar seções e acessos em Acessos Gerais, e adicionar/editar ramal/criar ausência em Ramais, até que outro perfil seja liberado em Perfis de Acesso — ver "Modelo de permissões" abaixo).
|
||
- `bruno` / `bruno` — sem perfil vinculado, usado para testar o estado "sem acesso".
|
||
|
||
Não há suíte de testes, lint ou build configurados neste projeto.
|
||
|
||
### Ambiente de desenvolvimento assistido
|
||
|
||
O `.venv` do projeto já tem Python 3.13 + Django 6.0 + DRF + psycopg + python-dotenv + Pillow + nh3 + reportlab + openpyxl + holidays + docling instalados, e há um Postgres local acessível via `.env` — dá pra rodar `makemigrations`/`migrate`/`seed_portal`/`runserver` normalmente por aqui usando `.venv\Scripts\python.exe manage.py ...` (ou ativando o venv primeiro). Isso deixou de ser uma limitação a partir da rodada em que o ambiente ganhou essas ferramentas (ver `plano.md`) — não assumir mais que só é possível revisar o backend estaticamente.
|
||
|
||
## Arquitetura
|
||
|
||
### Estrutura de pastas (padrão Django)
|
||
|
||
```
|
||
Portal/
|
||
├── manage.py
|
||
├── requirements.txt
|
||
├── .env
|
||
├── config/ # settings.py, urls.py, wsgi.py, asgi.py — pacote de configuração do projeto
|
||
├── portal_api/ # único app Django (models, serializers, views, admin, migrations, seed)
|
||
├── templates/ # as 8 páginas HTML (TEMPLATES[0]["DIRS"] em settings.py aponta pra cá)
|
||
├── static/ # css/, js/, img/ — STATICFILES_DIRS em settings.py aponta pra cá
|
||
├── media/ # upload de usuário (hoje só ícones de LinkFerramenta) — MEDIA_ROOT em settings.py
|
||
├── CLAUDE.md
|
||
└── plano.md
|
||
```
|
||
|
||
### Logos em `static/img/`
|
||
|
||
Duas identidades visuais coexistem **de propósito** hoje: o logo cursivo "D De Paula Contadores" (usado só nos documentos/PDFs que a aplicação gera, ver abaixo) e a marca nova "P.I.D." (`.svg`, ver `pid-marca-leiame.md` em `C:\Users\Depaula\Documents\Projetos\LOGOS\pid-marca`), adotada no favicon, no login e na sidebar do Portal. **Essa separação é deliberada, não uma migração incompleta**: um documento gerado pela aplicação (contrato, procuração, recibo do Indicador de Desempenho, PDF da Simulação de Custo de Contratação) é emitido como se o próprio escritório o tivesse gerado — carrega a identidade do escritório perante o cliente, não a identidade do Portal como ferramenta interna. A marca "P.I.D." é só pra UI do Portal em si. Não migrar o logo de um gerador de documento pra "P.I.D." (nem vice-versa numa tela do Portal) sem confirmar de novo com o usuário.
|
||
|
||
**Logo cursivo "D De Paula Contadores"** (D em degradê dourado/marrom + texto, PNG com fundo transparente) — não aparece em nenhum template HTML hoje, só nos PDFs gerados pela aplicação:
|
||
|
||
- `logo.png` — original, texto **preto**. Serve como fonte pra gerar as outras variantes e é usada diretamente no recibo do Indicador de Desempenho (`indicadores/recibo.py`, `LOGO_PATH`, redimensionada/recomprimida em memória pra impressão — ver "Indicador de Desempenho" abaixo).
|
||
- `logo-branco.png` — usada no cabeçalho do PDF de Simulação de Custo de Contratação (`custo_contratacao/pdf.py`, banner marrom escuro, ver "Simulação de Custo de Contratação" abaixo) — até uma rodada anterior também era usada no `sidebar__brand` dos 10 shells, migrada pra marca "P.I.D." (ver abaixo; a UI do Portal e os documentos gerados usam fontes de logo independentes agora). Mesmo D colorido de `logo.png`, mas com o texto recolorido pra branco; gerada programaticamente a partir de `logo.png` (script Python com Pillow: qualquer pixel opaco quase-neutro/escuro — `max(r,g,b) < 70` e `spread(r,g,b) < 12` — virou branco; o D nunca entra nesse filtro porque mesmo na sombra mais escura do degradê ele mantém um matiz quente nitidamente não-neutro). Se o logo oficial mudar, regerar `logo-branco.png` a partir do novo `logo.png` com o mesmo filtro, não editar à mão.
|
||
- `logo-mono.png` — versão totalmente monocromática (D **e** texto em branco/cinza claro). Não usada em nenhum consumidor hoje; existe como variante alternativa (útil se algum dia precisar de um logo "chapado" sem o dourado do D).
|
||
|
||
**Marca nova "P.I.D."**:
|
||
|
||
- `pid-icone.svg` — ícone quadrado, variante pra **fundo claro** (corpo roxo escuro `#4B2E75`); chegou a ser usada no login quando `data-theme="light"`, mas o usuário pediu pra usar sempre a mesma logo nos dois temas — **não tem mais nenhum consumidor** hoje (mesma situação de `pid-favicon.svg`/`pid-logo-horizontal.svg` abaixo).
|
||
- `pid-icone-escuro.svg` — ícone quadrado, variante pra **fundo escuro** (corpo roxo mais claro `#7B5BA8`, pra manter contraste); usada (a) no favicon (`<link rel="icon" type="image/svg+xml">` no `<head>` das 11 páginas) e (b) no login, **sempre**, nos dois temas — o card do login já é congelado escuro nos dois temas (ver "Card de login" abaixo), e agora o ícone também, pedido explícito do usuário ("deixe no tema claro a mesma logo usada no tema escuro"; antes alternava com `pid-icone.svg` conforme `data-theme`, mecanismo removido — ver "Ícone do login é clicável" abaixo). O ícone da sidebar (expandida e colapsada, 10 shells) e o do card de login usam o **mesmo desenho/cores** desse arquivo, mas como markup `<svg>` inline copiado direto no HTML, não uma referência a este arquivo — ver "Ícone do login/sidebar são clicáveis" abaixo pro motivo (precisa expor os olhos pro CSS/JS animar o piscar ao clicar).
|
||
- `pid-logo-horizontal-escuro.svg` — assinatura horizontal (ícone + "P.I.D." + "PORTAL INTERNO DA DE PAULA" em texto, viewBox `300×80`, texto claro `#F2EDE3`/dourado `#C6A24A` — variante pra fundo escuro). Mesmo caso do ícone acima: a sidebar expandida (10 shells) usa o mesmo desenho como `<svg>` inline, não uma referência a este arquivo.
|
||
- `pid-favicon.svg` (versão simplificada sem o sorriso, pro leiame recomendar pra 16–24px — não usada, o favicon usa `pid-icone-escuro.svg` mesmo), `pid-logo-horizontal.svg` (variante fundo claro da assinatura) e `pid-icone.svg` (acima) não têm nenhum consumidor no Portal hoje.
|
||
|
||
**`sidebar__brand` com duas marcas sobrepostas (crossfade), não uma redimensionada** (`layout.css`): a sidebar expandida mostra a assinatura com texto (tem texto, ilegível se só encolhida) e a colapsada mostra só o símbolo — são dois `<svg class="sidebar__logo sidebar__logo--full">`/`<svg class="sidebar__logo sidebar__logo--icon">` **sempre presentes no DOM**, sobrepostos via `position:absolute` dentro de `.sidebar__brand` (`position:relative; height:92px`, alto o bastante pra caber as duas sem depender da altura natural de nenhuma, já que filhos absolutos não contribuem pra altura do pai). `.sidebar__logo--full` é `width:250px; max-width:96%` (quase toda a largura útil da sidebar, `264px` menos o padding horizontal de `.sidebar`) — aumentado em duas rodadas a partir do tamanho inicial (`176px`/`70%` → `220px`/`92%` → `250px`/`96%`) porque o subtítulo "PORTAL INTERNO DA DE PAULA" (fonte pequena dentro do SVG, viewBox `300×80`) ficava ilegível menor; `height:92px` acompanhou cada aumento pra sobrar espaço vertical (proporção do SVG é `300:80`, então a altura renderizada escala junto com a largura). A transição entre elas é `opacity`+`transform:scale()` (`var(--transition-base)`, mesma duração das outras animações do sidebar) — pedido explícito do usuário pra não ser uma troca brusca; `.app-shell.is-collapsed` (toggle desktop) e o breakpoint mobile (`@media (max-width:1024px)`, onde o colapsado é o estado *default* e `.is-expanded-mobile` o inverte, mesmo padrão já usado pelos demais elementos do menu nesse breakpoint) alternam qual das duas fica com `opacity:1`.
|
||
|
||
**Ícone do login e da sidebar são clicáveis, com os olhos piscando** (pedido explícito do usuário, em duas rodadas — primeiro a sidebar, depois o ícone do login): tanto `.sidebar__brand` (10 shells) quanto `#login-logo-btn` (`index.html`) tiveram o ícone convertido de `<img src="...svg">` pra `<svg>` **inline**, com markup idêntico ao de `pid-icone-escuro.svg` copiado direto no HTML — um `<img>` não expõe seu conteúdo interno pro CSS/JS da página (é uma imagem opaca), então não dava pra animar só os olhos sem inlinear. Dentro de cada SVG, os dois olhos (círculo creme + glint escuro) ficam num `<g class="pid-icon-eye">` próprio, sem nenhum `transform` no XML (a posição já vem dos `cx`/`cy` dos círculos) — importante porque um `transform` de CSS aplicado num elemento que já tem um `transform` de atributo **substitui** o atributo inteiro (perderia a posição); mantendo os dois olhos "limpos" desse jeito, a única transformação deles é a que a animação de piscar aplica. `.pid-icon-eye`/`.is-blinking`/`@keyframes pidIconBlink` moram em `base.css` (`transform-box:fill-box; transform-origin:center` faz o `scaleY()` girar em torno do próprio olho, não da origem do SVG; `scaleY(1)→0.05→1`, 200ms, os dois olhos piscam juntos) — em `base.css`, não em `layout.css`, porque é carregado por **todas** as páginas, inclusive `index.html` (que não carrega `layout.css`).
|
||
|
||
- **Sidebar** (`static/js/sidebar-brand.js`, incluído logo depois de `api.js` nos 10 shells): `.sidebar__brand` deixou de ser `<div>` e virou `<a href="portal.html" id="sidebar-brand-link" aria-label="Ir para a tela Principal">` — a mudança de tag não afeta o CSS existente (todo seletor é por classe), então o crossfade descrito acima continua igual. O script escuta o clique: ignora cliques modificados (`ctrl`/`cmd`/`shift`/botão do meio — deixa abrir em nova aba normalmente), senão faz `preventDefault()`, adiciona `.is-blinking` em todo `.pid-icon-eye` dentro do link (pega os olhos das duas marcas — a visível e a escondida pelo crossfade, inofensivo já que a escondida tem `opacity:0`) e só navega pra `portal.html` depois de 260ms, tempo suficiente pra piscada terminar de tocar antes da página trocar — mesmo espírito de "deixar a transição ser vista antes de navegar" já usado na animação de intro do login.
|
||
- **Login** (`static/js/login-logo-blink.js`, novo): `#login-logo-btn` é um `<button type="button">` (não um link — não há pra onde navegar a partir do próprio login), sem `preventDefault`/delay nenhum, só dispara a piscada ao clicar; é puramente decorativo, sem efeito colateral. Também aqui foi a oportunidade de simplificar um mecanismo que existia só por causa do `<img>`: a logo do login **alternava** entre `pid-icone.svg`(claro)/`pid-icone-escuro.svg`(escuro) conforme `data-theme`, via `pidSyncLoginLogo()` em `theme.js` — removida junto com essa mudança, a pedido do usuário ("deixe no tema claro a mesma logo usada no tema escuro"), já que o card do login já era congelado escuro nos dois temas mesmo antes disso (ver "Card de login" abaixo) — a lógica de alternância nunca fazia muito sentido nesse contexto. Agora `#login-logo-btn` sempre usa as cores/desenho de `pid-icone-escuro.svg`, sem nenhuma checagem de tema.
|
||
|
||
### 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.
|
||
|
||
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`).
|
||
|
||
### Apps Django
|
||
|
||
Um único app, `portal_api/`:
|
||
|
||
| Arquivo | Conteúdo |
|
||
|---|---|
|
||
| `models.py` | `Usuario` (`AbstractUser` + `nome`, M2M `perfis`, M2M `departamentos` (pra `Departamento`, ver abaixo), campos cadastrais opcionais `codigo_folha`/`codigo_questor`/`codigo_tareffa`/`codigo_contabit`/`ramal` (`CharField`, `blank=True`) e `data_aniversario` (`DateField`, `null=True, blank=True`), `lideranca` (`BooleanField`, é gerente/coordenador) e M2M `liderados` (self-referential, `symmetrical=False`, `related_name="lideres"` — ver seção "Liderança" abaixo) — `email` já vem de `AbstractUser`, não precisou de campo novo; método `permissao_app(module_key, app_key)` — união genérica de um flag de `apps` entre os perfis vinculados; método `eh_perfil_inovacao()` — checagem de nome fixo pro perfil "Inovação", ver "Ajuda de aplicação" abaixo), `AjudaAplicacao` (texto de "Mais informações" de uma aplicação, chave natural `app_key`), `Departamento` (só `nome`, `unique=True` — cadastro inline pela própria tela de Usuários, sem tela de administração dedicada como `PerfilAcesso`), `PerfilAcesso` (`permissoes` em `JSONField`, mesmo formato aninhado do frontend; mais o booleano dedicado `gerencia_permissoes`), `CompromissoAgenda`, `Favorito`, `WidgetUsuario`, `NotificacaoDispensada`, `LinkFerramenta` (`icone` é `ImageField`, requer Pillow), `LinkFerramentaFavorito` (favorito por usuário de um cartão de Links & Ferramentas — não confundir com `Favorito`), `AcessoGeralSecao`/`AcessoGeral` (cadastro de logins/acessos compartilhados da aplicação "Acessos Gerais", ver seção própria abaixo), `Ramal` (linha **avulsa** da tela de Ramais, sem `Usuario` por trás — colaboradores de verdade aparecem automaticamente na listagem, sem precisar de uma linha aqui; ver seção "Ramais" abaixo), `RamalAusencia` (período de ausência de um colaborador, com `esta_ativa()` calculando "ausente agora" em vez de armazenar), `TelefoneExterno` (subtela "Telefones Externos" de Ramais, sem `Usuario` por trás), `FuncaoTelefonia` (subtela "Funções de Telefonia" de Ramais, `Meta.ordering` por `comando` reproduz a ordem esperada sem campo de ordem manual), `ImportacaoPlanoSaude`/`ImportacaoPlanoSaudeLinha`/`ImportacaoPlanoSaudeAuditoria`/`ImportacaoPlanoSaudeAlteracao`/`VinculoNomeOperadora` (ferramenta "Importação de Plano de Saúde" em Utilitários, ver seção própria abaixo, inclusive "Vínculos de nome salvos (DE/PARA)"). |
|
||
| `catalogo.py` | Fonte única da verdade do catálogo de módulos/aplicações do menu (`MODULES`, `MODULE_APPS`) — exposto só leitura via `GET /api/catalogo/`. Ao adicionar uma seção/aplicação nova ao menu, editar **aqui**, não em `static/js/profiles.js` (que só cacheia o payload recebido). |
|
||
| `serializers.py` | `PerfilAcessoSerializer`, `DepartamentoSerializer`, `UsuarioResumoSerializer` (`id`/`nome`/`departamentos`, usado nos dois lados de `liderados` e por `/api/usuarios-resumo/`), `UsuarioSerializer` (escrita, aceita `senha`+`perfis`+`departamentos`+`liderados`)/`UsuarioListSerializer` (leitura, `perfis`/`departamentos`/`liderados` aninhados), `CompromissoAgendaSerializer` (`sou_dono`, `dono_nome`, `dono_username`), `FavoritoSerializer`, `WidgetUsuarioSerializer`, `NotificacaoDispensadaSerializer`, `LinkFerramentaSerializer`, `LinkFerramentaFavoritoSerializer`, `AcessoGeralSecaoSerializer`, `AcessoGeralSerializer`, `RamalSerializer` (só das linhas avulsas — ver seção "Ramais"), `RamalAusenciaSerializer`, `TelefoneExternoSerializer`, `FuncaoTelefoniaSerializer`, `ImportacaoPlanoSaudeCreateSerializer`/`ImportacaoPlanoSaudeListSerializer`/`ImportacaoPlanoSaudeDetailSerializer`/`ImportacaoPlanoSaudeLinhaSerializer`/`ImportacaoPlanoSaudeAuditoriaSerializer` (ver seção "Importação de Plano de Saúde"). |
|
||
| `permissions.py` | `PodeGerenciarPermissoes` — gate único de `gerencia_permissoes()` para as telas administrativas; `PermissaoApp(module_key, app_key)` — classe genérica reutilizável que checa `Usuario.permissao_app()`, instanciada por view (ex.: Links & Ferramentas e Ramais, ver seção própria abaixo). |
|
||
| `views.py` | `login_view`/`logout_view`/`csrf_view` (auth por sessão), `me_view` (usuário + `permissoes_efetivas` já unidas no servidor + `lideranca`/`liderados`), `trocar_senha_view`, `usuarios_resumo_view`, `meus_liderados_view` (ver seção "Liderança"), `departamentos_resumo_view` (ver "Ramais"), `catalogo_view`, e os `ModelViewSet` de perfis/departamentos/usuários/compromissos/favoritos/widgets/notificações dispensadas/links e ferramentas/favoritos de links e ferramentas/seções e linhas de Acessos Gerais/ramais/ausências de ramal/importações de plano de saúde e suas linhas. |
|
||
| `admin.py` | Django admin básico para todos os models (uso interno, não é a UI do portal). |
|
||
| `management/commands/seed_portal.py` | Recria os 8 perfis padrão + `gabriel`/`bruno` + as 13 linhas de `FuncaoTelefonia` + o seed de `CategoriaEvento`; também realinha a sequence do Postgres por trás de `PerfilAcesso.codigo` (ver nota abaixo). |
|
||
| `management/commands/seed_indicador_desempenho.py` | Popula o primeiro histórico do Indicador de Desempenho (7 `IndicadorCriterio` + 5 `IndicadorPercentualTipo`, idempotente) com os valores da planilha antiga — ver seção "Indicador de Desempenho" abaixo. |
|
||
| `planos_saude/` | Pacote Python puro (sem ORM) com o pipeline de extração/casamento de "Importação de Plano de Saúde", portado de `projects/project/` — ver seção própria abaixo. |
|
||
| `custo_contratacao/` | Pacote Python puro (sem ORM) da ferramenta "Simulação de Custo de Contratação" (Geradoc) — `tabelas.py` (seed/default das faixas de INSS/IRRF, hoje editáveis via `ParametroFiscalCustoContratacao`), `calculo.py` (`ParametrosFiscais` dataclass + `calcula_custo_empregado`), `pdf.py` (`gera_pdf_simulacao`, via `reportlab`). Ver seção própria abaixo. |
|
||
| `indicadores/` | Pacote Python puro (sem ORM) da ferramenta "Indicador de Desempenho" (Geradoc) — `tipos.py` (deriva o tipo de colaborador por empresa via Tareffa), `leiaute.py` (leitura das planilhas Tareffa/Honorários via `openpyxl`), `pipeline.py` (orquestração, `processa_apuracao`), `entregas.py` (cálculo dos 3 critérios automáticos), `calculo.py` (composição dos percentuais Individual/Grupo/Departamento e valores em R$), `recibo.py` (PDF do recibo por colaborador, via `reportlab`). Ver seção própria abaixo. |
|
||
|
||
### API (sessão + CSRF, não token)
|
||
|
||
| Endpoint | Método | Uso |
|
||
|---|---|---|
|
||
| `/api/auth/csrf/` | GET | garante o cookie `csrftoken` |
|
||
| `/api/auth/login/` | POST | `{username, password}` → cria sessão |
|
||
| `/api/auth/logout/` | POST | encerra sessão |
|
||
| `/api/me/` | GET | usuário logado + `perfis` + `departamentos` (os próprios, pra alimentar o seletor de "Meu departamento" do Calendário Individual) + `gerencia_permissoes` + `eh_perfil_inovacao` (perfil "Inovação" vinculado, ver "Ajuda de aplicação" abaixo) + `permissoes_efetivas` (união já calculada no servidor) |
|
||
| `/api/me/senha/` | POST | `{senha_atual, nova_senha}` |
|
||
| `/api/me/liderados/` | PATCH | `{liderados: [id, ...]}` — só se `me.lideranca`; auto-gerenciamento de liderados (ver seção "Liderança") |
|
||
| `/api/catalogo/` | GET | módulos/aplicações/subgrupos do menu |
|
||
| `/api/feriados/?ano=AAAA` | GET | feriados nacionais + estaduais (PR) do ano pedido (default: ano atual), via lib `holidays` — ver "Feriados no Calendário Individual" abaixo |
|
||
| `/api/usuarios-resumo/` | GET | lista enxuta (`id`/`nome`) de usuários ativos — alimenta o seletor de liderados, sem exigir `gerencia_permissoes` (mesmo padrão de `/api/ramais/usuarios/`) |
|
||
| `/api/departamentos-resumo/` | GET | lista enxuta (`id`/`nome`) de departamentos — alimenta os botões de filtro do modal de consulta rápida de Ramais, exige só `ramais-visualizar` (não `gerencia_permissoes` como `/api/departamentos/`) |
|
||
| `/api/ajuda-aplicacoes/<app_key>/` | GET/PATCH | texto de "Mais informações" de uma aplicação (ver seção própria abaixo); GET livre a qualquer autenticado, PATCH exige `eh_perfil_inovacao` (perfil "Inovação", checagem de nome fixo, não uma flag em Perfis de Acesso) |
|
||
| `/api/perfis/`, `/api/perfis/{codigo}/` | GET/POST/PUT/DELETE | CRUD de perfil — só quem tem `gerencia_permissoes` |
|
||
| `/api/departamentos/`, `/api/departamentos/{id}/` | GET/POST/PUT/DELETE | CRUD de departamento — só quem tem `gerencia_permissoes`; usado pela tela de Usuários pra listar o checklist e cadastrar um novo departamento inline (sem tela própria) |
|
||
| `/api/usuarios/`, `/api/usuarios/{id}/` | GET/POST/PATCH/DELETE | CRUD de conta — só quem tem `gerencia_permissoes`; `departamentos` é M2M igual `perfis` (lista de ids na escrita, objetos aninhados na leitura); `is_active` é gravável via PATCH (inativar/reativar, ver "Inativar usuário" abaixo) |
|
||
| `/api/compromissos/`, `/api/compromissos/{id}/` | GET/POST/PATCH/DELETE | GET já retorna só o que o usuário logado pode ver (próprios + `visibilidade="todos"` + `visibilidade="departamento"` com departamento em comum); campo `notificar_em` (calculado, ver "Calendário Individual e Widgets") indica quando o lembrete passa a valer; criar/editar com `visibilidade` em `departamento`/`todos` (inclusive todo `eh_evento=True`, que força `visibilidade="todos"`) exige `apps["calendario-individual-criar-evento"]` (ver "Eventos Corporativos" abaixo) |
|
||
| `/api/categorias-evento/`, `/api/categorias-evento/{id}/` | GET/POST/PATCH/DELETE | cadastro de categorias de evento (`nome`+`cor`) usado pelo Calendário Individual; GET livre a qualquer autenticado, escrita exige `apps["calendario-individual-criar-evento"]` — ver "Eventos Corporativos" abaixo |
|
||
| `/api/favoritos/`, `/api/favoritos/{app_id}/` | GET/POST/PATCH/DELETE | chave natural é `app_id`, não um id numérico; `ordem` é gravável via PATCH (drag-and-drop na grade de favoritos, ver "Favoritos" abaixo) |
|
||
| `/api/widgets/`, `/api/widgets/{tipo}/` | GET/POST/PATCH/DELETE | chave natural é `tipo`; `ordem` (reordenar por drag-and-drop) e `largura`/`altura` em px (redimensionamento) também são graváveis via PATCH — ver "Calendário Individual e Widgets" abaixo |
|
||
| `/api/notificacoes-dispensadas/`, `/api/notificacoes-dispensadas/{notif_id}/` | GET/POST/DELETE | chave natural é `notif_id` (ex.: `"tool-widgets"`, `"event-42"`); `notifications.js` usa GET pra filtrar o que já foi dispensado e POST a cada X/"Limpar tudo" |
|
||
| `/api/links-ferramentas/`, `/api/links-ferramentas/{id}/` | GET/POST/PATCH/DELETE | lista **compartilhada** (não por usuário); leitura exige `apps["links-ferramentas-visualizar"]` e escrita exige `apps["links-ferramentas-editar"]` em `permissoes["links-ferramentas"]` (`PermissaoApp`, gate por método em `get_permissions()` — ver "Modelo de permissões" abaixo); POST é `multipart/form-data` (aceita upload de `icone`); `ordem` sempre é atribuída pelo servidor na criação (ignora o que vier no payload), reordenar é PATCH trocando o `ordem` de dois itens |
|
||
| `/api/links-ferramentas-favoritos/`, `/api/links-ferramentas-favoritos/{link_id}/` | GET/POST/DELETE | favorito **por usuário** de um cartão (chave natural é `link_id`, o id do `LinkFerramenta` — mesmo padrão de `app_id`/`notif_id`); exige só `apps["links-ferramentas-visualizar"]` (favoritar não precisa de editar); só afeta a ordem de exibição em Links & Ferramentas e o widget "Links Favoritos", nunca o `ordem` compartilhado (ver seção "Links & Ferramentas" abaixo) |
|
||
| `/api/acessos-gerais-secoes/`, `/api/acessos-gerais-secoes/{id}/` | GET/POST/PATCH/DELETE | seções do cadastro "Acessos Gerais" (aplicação dentro da seção Links & Ferramentas); leitura exige `apps["acessos-gerais-visualizar"]`, escrita exige `apps["acessos-gerais-editar"]`; excluir uma seção também exclui (`CASCADE`) os acessos dela; GET só lista seções sem `perfis_restritos` ou com interseção com os perfis do usuário (ver "Acessos Gerais" abaixo) |
|
||
| `/api/acessos-gerais/`, `/api/acessos-gerais/{id}/` | GET/POST/PATCH/DELETE | linhas (acessos/logins) dentro de uma seção; mesma permissão de `acessos-gerais-secoes`; `ordem` é escopada por `secao` (servidor calcula `max(ordem)` só entre as linhas da mesma seção) — ver seção "Acessos Gerais" abaixo |
|
||
| `/api/ramais/` | GET | diretório **mesclado**: todo usuário ativo (linha montada do cadastro, sem precisar de nenhum registro extra) + linhas avulsas de `Ramal`; leitura exige `apps.visualizar` — ver seção "Ramais" abaixo |
|
||
| `/api/ramais/`, `/api/ramais/{id}/` | POST/PATCH/DELETE | CRUD só das linhas avulsas (`Ramal`, sem `Usuario` por trás); escrita exige `apps.editar` |
|
||
| `/api/ramais/usuarios/` | GET | lista enxuta (`id`/`nome`) de usuários ativos pra alimentar o `<select>` "Lista de Usuários" do modal de Criar Ausência — não é `/api/usuarios/` de propósito (ver seção "Ramais") |
|
||
| `/api/ramais/usuarios/{usuario_id}/` | PATCH | `{numero}` — grava direto em `Usuario.ramal`; é como a tela edita o ramal de um colaborador de verdade (exige `apps.editar`) |
|
||
| `/api/ramais-ausencias/`, `/api/ramais-ausencias/{id}/` | GET/POST/PATCH/DELETE | períodos de ausência; "ausente agora" nunca é lido daqui direto pelo frontend, vem calculado em `usuario_ausente`/`usuario_ausencia_ativa_id` na listagem de `/api/ramais/`; `PATCH` com `{"encerrada_manualmente": true}` encerra antes do previsto |
|
||
| `/api/telefones-externos/`, `/api/telefones-externos/{id}/` | GET/POST/PATCH/DELETE | subtela "Telefones Externos" de `ramais.html`; mesma permissão `PermissaoApp("ramais", ...)` do diretório de Ramais |
|
||
| `/api/funcoes-telefonia/`, `/api/funcoes-telefonia/{id}/` | GET/POST/PATCH/DELETE | subtela "Funções de Telefonia" de `ramais.html`; idem, mesma permissão de `ramais`; as 13 linhas padrão vêm de `seed_portal` |
|
||
| `/api/importacoes-plano-saude/`, `/api/importacoes-plano-saude/{id}/` | GET/POST | histórico + criação (ver seção "Importação de Plano de Saúde"); `PermissaoApp("utilitarios", "importacao-plano-saude")` (toggle único) pra todos os métodos; POST é `multipart/form-data` (planilha padrão + arquivo da operadora) e roda o pipeline de forma síncrona antes de responder |
|
||
| `/api/importacoes-plano-saude/operadoras/` | GET | `[{key, label}]` das operadoras registradas em `planos_saude.pipeline.OPERADORAS` — alimenta o `<select>` do formulário |
|
||
| `/api/importacoes-plano-saude/regras-empresa/` | GET | `[{key, label}]` das regras especiais registradas em `planos_saude.regras_empresa.REGRAS_EMPRESA` — alimenta o modal "Selecionar regra" do checkbox "Regra empresa" (ver seção própria abaixo) |
|
||
| `/api/importacoes-plano-saude/{id}/gerar/` | POST | monta o CSV (ou ZIP, se mais de um tipo de lançamento) a partir das linhas já revisadas/editadas e devolve como download binário; marca a importação como `concluida` |
|
||
| `/api/importacoes-plano-saude-linhas/`, `/api/importacoes-plano-saude-linhas/{id}/` | GET/POST/PATCH/DELETE | edição/inclusão/exclusão de uma linha da revisão (todos os campos, não só valores); mesma permissão da importação, sem conceito de "dono"; as três operações também gravam um `ImportacaoPlanoSaudeAlteracao` (ver "Alterações" abaixo) |
|
||
| `/api/importacoes-plano-saude-alteracoes/{id}/reverter/` | POST | desfaz uma alteração específica (edição/inclusão/exclusão de linha) registrada na aba "Alterações" da revisão — ver seção própria abaixo |
|
||
| `/api/regras-custeio-plano-saude/`, `/api/regras-custeio-plano-saude/{id}/` | GET/POST/PATCH/DELETE | banco de regras de custeio por empresa+operadora (`codigo_empresa`+`operadora`, únicos juntos+`regra_empresa_chave`+`tipos_lancamento`+`custeio_por_tipo`+`observacoes`; `nome` é sempre derivado, nunca aceito do cliente — ver "Cadastro de Regras" abaixo) — mesma permissão de toggle único da ferramenta; lista compartilhada, sem "dono"; cadastro/edição só pela tela "Cadastro de Regras", nunca em "Nova Importação" |
|
||
| `/api/simulacao-custo-contratacao/gerar/` | POST | calcula (`portal_api.custo_contratacao.calculo.calcula_custo_empregado`) e devolve o PDF direto na resposta (`application/pdf`, sem persistir nada); `PermissaoApp`-like check manual via `permissao_app("geradoc", "simulacao-custo-contratacao")` — ver seção própria abaixo |
|
||
| `/api/parametros-fiscais-custo-contratacao/` | GET/PATCH | tabelas de INSS/IRRF + parâmetros da Lei 15.270/2025 usados pela simulação (`ParametroFiscalCustoContratacao`, singleton `pk=1`); mesma permissão da simulação, sem par visualizar/editar dedicado |
|
||
| `/api/indicadores-percentuais-tipo/` | GET/POST/DELETE | histórico de percentuais individual/grupo/departamento por tipo de colaborador (`IndicadorPercentualTipo`) — nunca editado in-place, só criado com `vigente_desde` novo; mesma permissão de toggle único `apps["indicador-desempenho"]` em `permissoes["geradoc"]` |
|
||
| `/api/indicadores-criterios/`, `/api/indicadores-criterios/{id}/` | GET/POST/PATCH/DELETE | CRUD do cadastro genérico de critérios (`IndicadorCriterio`) — nome/grupo/peso/período/papel/cálculo automático livres, editável pelo RH |
|
||
| `/api/indicadores-apuracoes/`, `/api/indicadores-apuracoes/{id}/` | GET/POST/DELETE | apuração mensal (`IndicadorApuracao`); POST é multipart (2 planilhas) e roda `indicadores.pipeline.processa_apuracao()` de forma síncrona dentro de um `transaction.atomic()`, persistindo colaboradores/empresas/respostas já calculados; DELETE também apaga os 2 arquivos de `MEDIA_ROOT` |
|
||
| `/api/indicadores-apuracoes/{id}/gerar/` | POST | gera um ZIP com um PDF de recibo por colaborador (`indicadores.recibo.gera_pdf_recibo`), a partir do que já está salvo (não reprocessa as planilhas); `colaborador_ids` opcional no corpo restringe a geração a só esses colaboradores (modal "Gerar Recibos" — um colaborador só, alguns específicos, por departamento ou todos); marca a apuração como `concluida` só quando a seleção cobre **todos** os colaboradores |
|
||
| `/api/indicadores-apuracoes/{id}/ajustar-grupo/`, `/recalcular-grupo/` | POST | ajusta (ou reverte) o `pct_grupo` de **todos** os colaboradores de um mesmo `gerente` na apuração de uma vez — "cada gerente representa um grupo" (ver seção própria abaixo) |
|
||
| `/api/indicadores-apuracoes/{id}/ajustar-departamento/`, `/recalcular-departamento/` | POST | idem, mas aplica a **todos** os colaboradores do `departamento` (id de um `IndicadorDepartamento`) informado no corpo (`{departamento, pct_departamento}`/`{departamento}`) — cada departamento tem sua própria meta de Departamento, ver "Departamento organizacional" abaixo |
|
||
| `/api/indicadores-departamentos/`, `/api/indicadores-departamentos/{id}/` | GET/POST/PATCH/DELETE | cadastro de departamentos (`IndicadorDepartamento`, nome/ativo) — mesma permissão de toggle único do Indicador de Desempenho, ver "Departamento organizacional" abaixo |
|
||
| `/api/indicadores-departamentos-gerentes/`, `/api/indicadores-departamentos-gerentes/{id}/` | GET/POST/PATCH/DELETE | relação gerente→departamento (`IndicadorDepartamentoGerente`, `nome_gerente` único) — mesma permissão, ver "Departamento organizacional" abaixo |
|
||
| `/api/indicadores-apuracoes/{id}/ajustar-honorario-empresa/` | POST | `{codigo_empresa, honorario}` — preenche (ou corrige) o honorário de uma empresa com `honorario_nao_encontrado=True` ou `honorario_ajustado_manualmente=True` de uma vez pra **todos** os colaboradores desta apuração que a têm (mesmo código), recalculando cada um (ver "Empresas sem Honorário"/"Empresas Ajustadas Manualmente" abaixo) |
|
||
| `/api/indicadores-apuracoes-colaboradores/{id}/` | GET/PATCH | ajuste manual do `pct_individual` de um colaborador (`pct_individual_ajustado_manualmente=True`); recalcula `valor_total` via `indicadores.calculo.recalcula_colaborador` |
|
||
| `/api/indicadores-apuracoes-colaboradores/{id}/recalcular/` | POST | reverte `pct_individual` pro modo automático (limpa o ajuste manual) e recalcula |
|
||
| `/api/indicadores-apuracoes-colaboradores/{id}/marcar-validado/` | POST | `{validado}` — checklist de revisão do RH, só grava o campo, sem recalcular nada (ver "Checklist de revisão do RH" abaixo) |
|
||
| `/api/indicadores-apuracoes-empresas/{id}/trocar-responsavel/` | POST | `{colaborador_id}` — reatribui essa linha (empresa+tipo) pra outro colaborador da mesma apuração, recalculando os dois (ver "Corrigir Responsável" abaixo) |
|
||
| `/api/indicadores-apuracoes-empresas/{id}/` | GET/PATCH | preenchimento manual do `honorario` de uma empresa com `honorario_nao_encontrado=True` (código não casou com a planilha de Honorários Por Cliente); zera essa flag e recalcula o colaborador |
|
||
| `/api/indicadores-apuracoes-respostas/{id}/` | GET/PATCH | edição de uma resposta de critério (SIM/NÃO/NÃO FAZ/NÃO SE APLICA) já existente; recalcula o colaborador |
|
||
| `/api/indicadores-apuracoes-respostas/aplicar-em-lote/` | POST | `{resposta_ids, valor}` — aplica o mesmo valor a várias respostas de uma vez (seleção múltipla da tela de revisão), recalculando todos os colaboradores afetados |
|
||
|
||
### Frontend consumindo a API
|
||
|
||
`static/js/api.js` é a base de tudo: `pidApiRequest(path, options)` faz `fetch` com `credentials:"include"`, injeta `X-CSRFToken` (lendo o cookie `csrftoken`, buscando-o via `/api/auth/csrf/` primeiro se ainda não existir) em métodos não seguros, e redireciona pra `index.html` em 401 por padrão (`redirectOn401: false` para os poucos casos onde 401 é esperado, como o próprio login).
|
||
|
||
`access.js` expõe `pidGetMe()` — chamada única e **cacheada por página** (`pidMePromise`) para `GET /api/me/`. Cada arquivo controlador (`account.js`, `favorites.js`, `widgets.js`, `profiles.js`, `users-admin.js`, `calendar-individual.js`, `links-ferramentas.js`, `acessos-gerais.js`, `ramais-lookup.js`) chama `pidGetMe()` no início do seu próprio `DOMContentLoaded`, mas como todos rodam antes do primeiro `await` resolver, a promise cacheada garante **uma única requisição de rede** por carregamento de página, não uma por arquivo.
|
||
|
||
`pidApiRequest` (em `api.js`) detecta `body instanceof FormData` e, nesse caso, **não** faz `JSON.stringify` nem define `Content-Type` manualmente — deixa o browser montar o `multipart/form-data` com o boundary certo. Usado pelo upload de `icone` em Links & Ferramentas e pelos dois arquivos anexados em "Nova Importação" de Plano de Saúde; todo o resto da API é JSON puro. A única resposta binária da API (`/importacoes-plano-saude/{id}/gerar/`, que devolve CSV/ZIP) não passa por `pidApiRequest` — usa um `fetch` manual dedicado (ver seção "Importação de Plano de Saúde").
|
||
|
||
`pidApplyAccessVisibility` não recalcula mais união de permissões no cliente — usa `me.permissoes_efetivas`, já unida no backend (`permissoes_efetivas()` em `views.py`).
|
||
|
||
## Páginas
|
||
|
||
| Página | Papel |
|
||
|---|---|
|
||
| `index.html` | Login. POST `/api/auth/login/`. |
|
||
| `portal.html` | Shell principal: busca de aplicações, grade de favoritos, seção de Widgets. |
|
||
| `calendario-individual.html` | Agenda pessoal: grade mensal + modal de criar/editar compromisso. |
|
||
| `perfis-acesso.html` | CRUD de perfis de acesso (lista + edição com abas Permissões/Usuários do Escritório). |
|
||
| `usuarios.html` | CRUD de contas de usuário (lista + edição com checklist de perfis). |
|
||
| `links-ferramentas.html` | Grade de cartões de atalho para ferramentas externas; reordenar/incluir/remover só com `apps["links-ferramentas-editar"]` (ver seção própria abaixo). |
|
||
| `acessos-gerais.html` | Cadastro de acessos/logins compartilhados organizados em seções e linhas, com popup de detalhes; criar/editar/excluir seções e linhas só com `apps["acessos-gerais-editar"]` (ver seção "Acessos Gerais" abaixo). |
|
||
| `ramais.html` | Diretório de ramais internos, filtros por nome/departamento; adicionar/editar ramal, criar ausência e excluir só com `apps.editar` em `ramais` (ver seção "Ramais" abaixo). |
|
||
| `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 seção "Importação de Plano de Saúde" abaixo). |
|
||
| `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 seção "Simulação de Custo de Contratação" abaixo). |
|
||
| `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 seção própria abaixo). |
|
||
|
||
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 "Favoritos" abaixo) 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 "Solicitações" abaixo) — 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.
|
||
|
||
## Ordem de `<script>` e por que ela não quebra nada
|
||
|
||
Todas as páginas-shell (todas exceto `index.html`) carregam o mesmo prefixo de scripts, na mesma ordem: `api.js → confirm-modal.js → dual-select.js → profiles.js → auth.js → access.js → account.js → favorites.js → events.js → ...`, seguido de scripts específicos da página. Nenhum script usa `defer` exceto `theme.js` (que fica no `<head>`). `confirm-modal.js` (`pidConfirm()`, ver "Modal de confirmação genérico" abaixo) é a única exceção que também é carregada em `index.html` (logo depois de `api.js` ali também) — é genérico o bastante pra fazer sentido em qualquer página, inclusive o login.
|
||
|
||
Isso importa porque, por exemplo, `access.js` chama `pidRequireAuth()` de `auth.js`, que só aparece **antes** dele na tag `<script>` — mas mesmo quando a ordem fosse invertida funcionaria, porque toda chamada cross-arquivo acontece dentro de um callback de `document.addEventListener("DOMContentLoaded", ...)`, nunca no nível superior do script. Como todo `<script>` sem `defer` executa (e portanto declara suas funções) antes do evento `DOMContentLoaded` disparar, a ordem relativa entre arquivos não importa — só importa que todos estejam presentes na página antes desse evento. Ao adicionar um novo arquivo JS compartilhado, não é preciso se preocupar em "colocá-lo antes de quem o usa", desde que toda chamada fique dentro de um handler de `DOMContentLoaded` (ou de uma função só invocada por um).
|
||
|
||
Padrão de guarda por página: `profiles.js`, `users-admin.js` e `widgets.js` verificam a existência do elemento raiz da própria tela (`#pa-list-view`, `#ua-list-view`, `#widgets-grid`) e retornam cedo se não estiverem na página certa — por isso são incluídos em todo shell mesmo quando só uma página usa a parte de "controlador" (`profiles.js` também expõe funções de dados — `pidFetchPerfis`, `pidFetchUsuarios`, `pidFetchCatalogo` — reaproveitadas por `users-admin.js`).
|
||
|
||
## Armazenamento
|
||
|
||
| Onde | O quê |
|
||
|---|---|
|
||
| `localStorage` (`pid_theme`, `pid_color_theme`) | Só preferência de tema — ver `theme.js`. |
|
||
| Postgres, via API | Tudo o mais: sessão (cookie do Django), usuários, perfis de acesso, favoritos, widgets, compromissos do Calendário Individual, notificações dispensadas. |
|
||
|
||
`notifications.js` usa uma lista mockada em memória (`PID_NEW_TOOLS_NOTIFICATIONS`) para os anúncios de "nova ferramenta" — os itens de compromisso vêm de `/api/compromissos/` de verdade. O conteúdo das notificações continua mockado/derivado a cada carregamento, mas **quais delas o usuário já dispensou** (X individual ou "Limpar tudo") persiste por usuário via `NotificacaoDispensada`/`/api/notificacoes-dispensadas/` — por isso um item dispensado não reaparece depois de um reload.
|
||
|
||
**Histórico de notificações** (botão "Histórico" ao lado de "Limpar tudo", em `#notif-history-modal` — presente nos 7 shells que têm o sino, tudo exceto `index.html`): não é um model novo, é uma segunda leitura da mesma tabela `NotificacaoDispensada` — o sino ativo mostra `!dismissedIds.includes(id)`, o histórico mostra o inverso (`dismissedIds.includes(id)`). A diferença entre os dois pools de candidatos usados (`notifications.js`) é proposital: o sino usa `pidEventosElegiveisAgora()` (só eventos com `data >= hoje` e `notificar_em` já atingido, capado em 5 pra não lotar o dropdown), enquanto o histórico usa `allEventNotifications` (todos os compromissos que o usuário pode ver, sem nenhuma das duas restrições) — um item dispensado pode não estar mais no recorte "elegível agora" (compromisso já passou, ou o lembrete não bateu ainda depois de uma edição), mas ainda precisa aparecer no histórico. Cada item do histórico tem um botão "Restaurar" (`DELETE /api/notificacoes-dispensadas/{notif_id}/`, já existia como endpoint, só não tinha consumidor no frontend) que remove o registro de dispensa; a notificação só volta a aparecer no sino de fato se ainda estiver no pool "elegível agora" (ex.: restaurar uma notificação de ferramenta sempre reaparece no sino, já que esse pool é estático; restaurar um compromisso muito antigo não reaparece, porque ele não está mais entre os 5 mais próximos elegíveis — o histórico continua mostrando, já que sua lista não tem esse recorte).
|
||
|
||
## Modelo de permissões (Perfis de Acesso)
|
||
|
||
O catálogo de módulos/aplicações do menu vive só no backend agora (`portal_api/catalogo.py`) e é buscado uma vez por `profiles.js` via `GET /api/catalogo/` — **não editar mais um `PID_MODULES`/`PID_MODULE_APPS` hardcoded no frontend**; a edição correta é em `catalogo.py`. 11 das 14 seções do menu têm sub-aplicações configuráveis individualmente (`portais`, `geradoc`, `relatorios`, `relatorios-gerenciais`, `utilitarios`, `integracoes`, `auditorias`, `solicitacoes`, `links-ferramentas`, `ramais`, `calendario-individual`); as outras 3 (`principal`, `calendario`, `administracao`) só têm um toggle de módulo, sem filhos. Em `auditorias`, cada entrada pode ser um subgrupo com `tools` aninhadas (ex.: "Consultoria Tributária" → "Controle Simples Nacional") — categorias reais que agrupam ferramentas reais. Em `ramais` e em `links-ferramentas`, o mesmo formato de subgrupo é reaproveitado por outro motivo: cada subgrupo ali **é** uma subtela/aplicação real da seção (ex.: "Ramais"/"Telefones Externos" dentro de `ramais`; "Links & Ferramentas"/"Acessos Gerais" dentro de `links-ferramentas`), e as `tools` dentro de cada subgrupo não são aplicações de verdade — são os dois níveis de acesso (visualizar/editar) daquela subtela (ver "Padrão visualizar/editar" abaixo). Em `calendario-individual`, o único subgrupo (`calendario-individual-eventos`) tem uma **única** `tool` (`calendario-individual-criar-evento`) em vez de um par visualizar/editar — a visualização do módulo em si já é liberada a todo perfil (está em `BASE_KEYS`), só a criação/gestão de eventos corporativos e categorias é restrita (ver "Eventos Corporativos" abaixo). Em `geradoc`, `simulacao-custo-contratacao` e `indicador-desempenho` são dois apps flat (sem subgrupo), cada um com permissão de **toggle único** — mesmo espírito de `importacao-plano-saude` em Utilitários (quem tem acesso faz o fluxo inteiro, sem par visualizar/editar).
|
||
|
||
Formato de perfil no banco (`PerfilAcesso.permissoes`, `JSONField`):
|
||
```json
|
||
{
|
||
"<moduleKey>": { "enabled": true, "apps": { "<appKey>": true } }
|
||
}
|
||
```
|
||
|
||
Um usuário pode estar vinculado a **mais de um perfil** (M2M `Usuario.perfis`). A união de permissões (`.some(...)`/`any(...)` sobre todos os perfis vinculados) agora é calculada **no servidor**, em `permissoes_efetivas()` (`portal_api/views.py`) — o frontend só consome o resultado já pronto via `me.permissoes_efetivas`. As duas exceções ao padrão "checar `permissoes_efetivas[chave].enabled`":
|
||
|
||
- `data-section="perfis-acesso"` e `data-section="usuarios"` (dentro de Administração) são gateados por `gerencia_permissoes`, não por `permissoes_efetivas.administracao.enabled`.
|
||
- O selo "Restrito" ao lado de Relatórios Gerenciais (`[data-restricted-badge]`) é mostrado se algum perfil vinculado tiver `nome === "Diretoria"` — um match de nome fixo, não uma flag de permissão.
|
||
|
||
### Padrão visualizar/editar (Links & Ferramentas, Acessos Gerais, Ramais e futuros módulos parecidos)
|
||
|
||
Alguns módulos não são só "lista de aplicações que aparecem ou não no menu" — têm uma ou mais telas próprias com conteúdo administrável (adicionar/remover/reordenar), que pedem dois **níveis** de acesso, não um: **visualizar** (ver a tela) e **editar** (mudar o conteúdo dela). Em vez de criar um `BooleanField` dedicado por módulo (o que foi tentado numa rodada e revertido — não escala pra "várias aplicações com a mesma funcionalidade"), o padrão adotado é modelar cada aplicação como um **subgrupo** com dois `tools` (visualizar/editar) dentro do módulo em `catalogo.MODULE_APPS` — mesmo formato de subgrupo já usado em Auditorias/Ramais:
|
||
|
||
```python
|
||
"links-ferramentas": [
|
||
{
|
||
"key": "links-ferramentas-cartoes",
|
||
"label": "Links & Ferramentas",
|
||
"tools": [
|
||
{"key": "links-ferramentas-visualizar", "label": "Visualizar"},
|
||
{"key": "links-ferramentas-editar", "label": "Editar (reordenar, incluir e remover cartões)"},
|
||
],
|
||
},
|
||
{
|
||
"key": "acessos-gerais",
|
||
"label": "Acessos Gerais",
|
||
"tools": [
|
||
{"key": "acessos-gerais-visualizar", "label": "Visualizar"},
|
||
{"key": "acessos-gerais-editar", "label": "Editar (criar/editar/excluir seções e acessos)"},
|
||
],
|
||
},
|
||
],
|
||
```
|
||
|
||
`links-ferramentas` nasceu com só um par `visualizar`/`editar` flat (sem subgrupo, já que só existia uma aplicação na seção); virou dois subgrupos quando "Acessos Gerais" foi adicionado como uma segunda aplicação dentro da mesma seção — a mesma evolução que `ramais` já tinha passado antes (ver "Navegação por abas em `ramais.html`" abaixo). Cada chave de `tool` é prefixada com o nome da aplicação (`links-ferramentas-visualizar`, não só `visualizar`) porque `permissoes[module_key]["apps"]` é um dict **achatado** — todas as `tools` de todos os subgrupos do módulo compartilham o mesmo namespace, então chaves genéricas colidiriam entre as duas aplicações.
|
||
|
||
Isso reaproveita 100% a árvore de permissões que já existe (`renderTree()`/`renderEntry()`/`renderLeaf()` em `profiles.js`, sem nenhum código de UI novo) — na tela de edição de perfil, "Links & Ferramentas" aparece expansível com "Links & Ferramentas" e "Acessos Gerais" como subgrupos, cada um expansível de novo em "Visualizar"/"Editar", do mesmo jeito que "Auditorias" mostra "Consultoria Tributária" → "Controle Simples Nacional". No backend, a checagem usa `Usuario.permissao_app(module_key, app_key)` (união entre os perfis vinculados, mesma lógica de `permissoes_efetivas()`) e a classe genérica `PermissaoApp(module_key, app_key)` em `permissions.py`, instanciada por view — nenhuma subclasse nova é necessária pra outro módulo/aplicação adotar o mesmo padrão, só instanciar com outra `app_key`.
|
||
|
||
**Cuidado com `seed_portal.py`**: o módulo continua tendo seu próprio `enabled` (visibilidade no menu) e, se estiver em `BASE_KEYS`/`SECTORAL_KEYS` etc., `catalogo.permissions_from_keys()` habilita **todos** os apps de um módulo enabled de uma vez — incluindo todo `*-editar`. Por isso `seed_portal.py` força `permissoes["links-ferramentas"]["apps"]["links-ferramentas-editar"] = False`, `permissoes["links-ferramentas"]["apps"]["acessos-gerais-editar"] = False` e `permissoes["ramais"]["apps"]["ramais-editar"] = False` (+ as outras duas subtelas administráveis de Ramais) explicitamente pra todo perfil que não seja "Integração e Inovação" (código 8, `chaves=None` → todos os apps `True` de propósito), depois de gerar o dict — sem esse override, qualquer perfil com o módulo habilitado nasceria com poder de editar tudo. Ao adicionar uma aplicação nova nesse padrão (dentro de um módulo existente ou não), replicar esse mesmo cuidado no seed.
|
||
|
||
**Bug real (rodada 69) — `IntegrityError: duplicate key value violates unique constraint "portal_api_perfilacesso_pkey"` ao criar um perfil pela tela**: `seed_portal.py` semeia `PerfilAcesso` com `codigo` **explícito** (`update_or_create(codigo=dado["codigo"], ...)`, já que os 8 códigos 1–8 são referenciados por número fixo em vários lugares do código — ex.: `codigo == 8` = "Integração e Inovação"). No Postgres, um `INSERT` com PK explícita **nunca avança a sequence** por trás do `AutoField` — então a sequence ficava parada em 1 (seu valor inicial), e o primeiro perfil criado pela tela (`POST /api/perfis/`, sem PK explícita) recebia `codigo=1` do `nextval()`, colidindo com um código já usado pelo seed. `_reset_sequence(model)` (função módulo-level em `seed_portal.py`, roda `SELECT setval(pg_get_serial_sequence(...), MAX(pk))` via SQL puro do Postgres) corrige isso, chamada logo depois do loop de `PERFIS_SEED` — toda vez que `seed_portal` roda, a sequence é realinhada de novo. Se esse erro voltar a aparecer no futuro (ex.: um `loaddata`/`RunPython` de migração também inserindo `PerfilAcesso` com PK explícita sem passar por `seed_portal.py` depois), o comando pra corrigir manualmente é `python manage.py seed_portal` (idempotente, seguro rodar de novo) — não precisa de acesso direto ao banco.
|
||
|
||
### Popup "Nova Aplicação" (gerenciar acesso por aplicação, entre perfis)
|
||
|
||
Botão `#pa-app-search-btn` ao lado de "Novo Perfil" (`perfis-acesso.html`) abre `#pa-app-search-modal` — o caminho inverso da árvore de permissões: em vez de abrir um perfil e marcar módulo por módulo, o usuário busca uma aplicação/ferramenta pelo nome e vê/gerencia **todos os perfis** que têm acesso a ela numa tabela só.
|
||
|
||
- **Fonte dos dados — 100% reaproveitado, sem endpoint novo**: `pidFlattenAplicacoes()` (`profiles.js`) achata `PID_MODULES` × `PID_MODULE_APPS` (já carregados de `GET /api/catalogo/` no load da página) numa lista plana de "aplicações" — uma por entrada de `MODULE_APPS`, seja ela um app simples ou um subgrupo com `tools` (mesma unidade usada em `renderEntry()` da árvore). A busca (`renderAppSearchResults()`) casa o termo contra o label da aplicação, o label do módulo **e** o label de cada `tool` aninhada — esse último é o que permite achar, por exemplo, "Controle Simples Nacional" (o `tool` real dentro do subgrupo "Consultoria Tributária" de Auditorias) mesmo a unidade selecionável sendo o subgrupo inteiro.
|
||
- **Painel de gerenciamento** (`renderAppManageTable()`): ao clicar num resultado, mostra uma tabela com uma linha por perfil (`profiles`, o mesmo array já carregado pela tela) e uma coluna de checkbox por `tool` — para app simples sem subgrupo, uma coluna única "Acesso". O cabeçalho de cada coluna usa `tool.label.split(" (")[0]` (corta o parêntese explicativo tipo "Editar (reordenar, incluir e remover cartões)" → "Editar"), sem precisar de um label curto dedicado no catálogo.
|
||
- **Salva na hora, por checkbox** (decisão explícita do usuário — sem botão "Salvar" no popup): cada `change` dispara `PATCH /api/perfis/{codigo}/` só com `{permissoes: perfil.permissions}` (`pidUpdatePerfilPermissoes`, PATCH parcial — o `ModelViewSet` já aceita, `PerfilAcessoSerializer` não exige os outros campos fora de `partial_update`). Erro de rede reverte o checkbox e o estado em memória, com um `alert()` simples (mesmo padrão de outros toggles imediatos do app, ex. inativar usuário).
|
||
- **Conceder acesso habilita o módulo automaticamente** (decisão explícita do usuário): se o checkbox marcado pertence a um módulo com `enabled=false` naquele perfil, o toggle também vira `perm.enabled = true` no mesmo PATCH — sem isso, o perfil ganharia a chave em `apps` mas o item continuaria escondido no menu (`access.js` esconde o `nav-group`/`nav-subitem` inteiro por `enabled`, não só por app). **Revogar não desabilita o módulo de volta** (outras aplicações dele podem seguir em uso por aquele perfil).
|
||
- Perfis inativos (`ativo=False`) aparecem na tabela com o selo `.status-pill--inativo` (mesmo componente da coluna "Status" de `usuarios.html`), sem serem excluídos da lista — nada nesse popup impede gerenciar o acesso deles.
|
||
|
||
### Aba "Usuários do Escritório" (dentro da edição de um perfil) — duas tabelas com seleção múltipla
|
||
|
||
Substituiu o antigo `<select>` + botão "Vincular" + lista simples com X pra remover — pedido explícito do usuário pra reestruturar visualmente no estilo de um componente de transferência dupla (referência: uma tela de outro sistema com duas grades lado a lado, cada uma com checkbox de seleção, busca por coluna, ordenação e um botão de ação em lote).
|
||
|
||
- **Duas tabelas** (`.pa-users-dual`, grid 2 colunas que colapsa pra 1 abaixo de 900px): à esquerda, `#pa-users-available-*` — todo usuário **ativo** ainda não vinculado a este perfil; à direita, `#pa-users-linked-*` — todo usuário **ativo** já vinculado. Usuário inativo nunca aparece em nenhum dos dois painéis (`listaParaPainelUsuarios()` filtra `usuariosCacheAtual` por `is_active` antes de separar entre vinculado/disponível) — decisão explícita do usuário; na prática, inativar já limpa os `perfis` de alguém no backend (`_revogar_acesso_se_inativo()`, ver "Inativar/reativar usuário" abaixo), então esse filtro no frontend é sobretudo defensivo pra dados legados. Cada tabela tem: checkbox de seleção por linha + "selecionar todos" no cabeçalho (`#pa-users-available-select-all`/`#pa-users-linked-select-all`, aplica só sobre as linhas **filtradas** visíveis, mesmo critério de `.checklist-select-all`), coluna "Nome Usuário" ordenável (clique alterna asc/desc, ícone `↕` que fica `--accent` quando ativo — mesmo padrão `.ua-sort-icon` já usado em `usuarios.html`) e coluna "E-mail" (não ordenável), com uma segunda linha de cabeçalho (`.pa-users-table__filters`) só com os campos de busca por nome/e-mail — filtro client-side sobre o array já carregado, sem debounce.
|
||
- **Botão de atualizar** (ícone circular, `#pa-users-available-refresh-btn`/`#pa-users-linked-refresh-btn`) refaz `GET /api/usuarios/` (`refreshUsuarios()`, `profiles.js`) e re-renderiza os dois painéis a partir do mesmo cache — as duas tabelas sempre refletem o mesmo snapshot de usuários, nunca buscam independentemente uma da outra.
|
||
- **Vincular/Desvincular em lote**: o botão de cada painel (`#pa-users-link-btn`/`#pa-users-unlink-btn`, desabilitado enquanto a seleção daquele painel estiver vazia) dispara `bulkAlterarVinculo(kind, vincular)` — um `PATCH /api/usuarios/{id}/` (`pidSetUsuarioPerfis`) por usuário selecionado, em paralelo (`Promise.all`), cada um recalculando a própria lista de `perfis` (adiciona ou remove só o `codigo` do perfil sendo editado, preservando os demais perfis do usuário). Ao terminar, a seleção é limpa e os dois painéis são recarregados do zero (`refreshUsuarios()`) — um usuário que acabou de ser vinculado desaparece da tabela da esquerda e aparece na da direita, e vice-versa.
|
||
- Abrir a aba de um perfil diferente (`renderUsers()`, chamada por `openEdit()`) sempre reseta os dois painéis: seleção limpa, ordenação de volta pra ascendente, campos de busca vazios — evita carregar o estado de filtro/seleção deixado num perfil anterior.
|
||
- Sem endpoint novo — 100% reaproveitamento de `GET /api/usuarios/` (`UsuarioListSerializer`, já expõe `email`) e `PATCH /api/usuarios/{id}/` (`pidSetUsuarioPerfis`, já existia).
|
||
|
||
## Ajuda de aplicação ("Mais informações")
|
||
|
||
Botão "?" (`.info-tooltip`, `components.css`) ao lado do nome de uma aplicação — ao passar o mouse mostra a dica "Mais informações" (tooltip CSS puro, sem JS de posicionamento, já que a posição relativa ao próprio botão nunca varia); ao clicar, abre um modal com um texto livre descrevendo objetivo/processo/cuidados/resultado esperado daquela ferramenta. Visualizar é liberado a qualquer usuário autenticado; **editar é restrito a quem tem o perfil "Inovação" vinculado** — uma checagem de **nome fixo** (`Usuario.eh_perfil_inovacao()`/`models.PERFIL_INOVACAO_NOME`, mesmo padrão já usado pro selo "Restrito" de Relatórios Gerenciais, nome `=== "Diretoria"`), **não** uma flag na árvore de permissões — decisão explícita do usuário, pra não precisar aparecer em Perfis de Acesso.
|
||
|
||
- **Model** (`AjudaAplicacao`): chave natural `app_key` (mesma ideia de `app_id`/`notif_id`/`tipo` de `Favorito`/`NotificacaoDispensada`/`WidgetUsuario` — sem FK pra `Usuario`, é um texto compartilhado, igual pra quem abrir o modal) + `texto` (`TextField`, `blank=True`, `validators=[validar_tamanho_texto_ajuda_aplicacao]` — mesmo teto de 2.000.000 caracteres de `AcessoGeral.observacoes`, generoso o bastante pra várias imagens embutidas) + `atualizado_em`/`atualizado_por`. `AjudaAplicacao.para_app(app_key)` faz `get_or_create` — mesmo padrão de "singleton por chave, criado sob demanda" já usado em `ParametroFiscalCustoContratacao.atual()`/`EmpresaQuestor`.
|
||
- **Endpoint** `GET`/`PATCH /api/ajuda-aplicacoes/<app_key>/` (`ajuda_aplicacao_view`, função simples — não é `ModelViewSet`, mesmo estilo de `parametros_fiscais_custo_contratacao_view`): GET só exige `IsAuthenticated`; PATCH também exige `request.user.eh_perfil_inovacao()`, senão `PermissionDenied`. `GET /api/me/` ganhou o campo `eh_perfil_inovacao` (calculado no servidor, igual a `gerencia_permissoes`) pra o frontend saber se mostra os botões "Editar" do modal.
|
||
- **Texto aceita imagens embutidas, igual às Observações de Acessos Gerais** (pedido explícito do usuário) — mesmo mecanismo, reaproveitado: `<div contenteditable>` no cliente com colar (`Ctrl+V`)/arrastar imagem (`insertImageFile()`, até 2MB), convertida em data URI e inserida via `document.execCommand("insertImage", ...)`; sanitizado no servidor com `nh3.clean()` antes de salvar (`AjudaAplicacaoSerializer.validate_texto`). As constantes de allowlist do nh3 (`RICHTEXT_ALLOWED_TAGS`/`_ATTRS`/`_SCHEMES`, `serializers.py`) foram **generalizadas** (antes prefixadas `ACESSO_GERAL_*`) pra serem compartilhadas pelos dois campos — mesmo allowlist estrito (texto básico + `<img>`, sem `<a>`/`<script>`/atributos de evento, `data:` liberado pra imagem embutida). A UI em si (`.ajuda-modal__editor`/`.ajuda-modal__editor-hint` em `components.css`) é uma **reimplementação** de `.ag-richtext` (não um reaproveitamento direto), porque este modal pode aparecer em qualquer página, e `.ag-richtext`/`acessos-gerais.js` são escopados só a `acessos-gerais.html`.
|
||
- **Frontend** (`static/js/ajuda-aplicacao.js`, reusável — igual ao espírito de `dual-select.js`): `pidCriarBotaoAjuda(botaoId, appKey, tituloApp)` liga o clique de um botão já existente no HTML da página; o modal em si (`#ajuda-aplicacao-modal`) é **criado sob demanda e injetado em `document.body` pelo próprio JS** (não precisa ser hand-authored em cada página) e reaproveitado por todos os botões dela — só um pode estar aberto por vez. Texto em modo leitura via `innerHTML` (seguro porque já vem sanitizado do backend, mesmo raciocínio de `.ag-view-observacoes`); quem tem `eh_perfil_inovacao` vê um botão "Editar" que troca pro editor rico in-place (mesmo padrão "Cancelar"/"Salvar" já usado em "Editar Ausência" de Ramais).
|
||
- **Confirmação ao sair sem salvar** (pedido explícito do usuário): enquanto o modal está em modo edição (`modal.dataset.editing = "true"`, setado por `entrarEdicao()`/limpo por `renderVisualizacao()`), fechar o modal por qualquer caminho — botão "Fechar", clicar fora (overlay) ou "Cancelar" — dispara `await pidConfirm("Sair sem salvar as alterações?", { perigoso: true })` (ver "Modal de confirmação genérico" abaixo); só fecha/descarta se confirmado. "Salvar" nunca pede confirmação (não há o que descartar). `pidFecharAjudaAplicacaoModal()` é a única função que fecha o modal de fato (agora `async`), então o botão "Fechar" e o clique no overlay (que já chamavam essa função) ganharam a checagem de graça; só "Cancelar" precisou de uma checagem própria antes de chamar `renderVisualizacao()`.
|
||
|
||
### Modal de confirmação genérico (nunca `window.confirm`/`window.alert`)
|
||
|
||
`static/js/confirm-modal.js` (incluído logo depois de `api.js` em **todo** shell, inclusive `index.html`) — `pidConfirm(mensagem, opcoes)` devolve uma `Promise<boolean>` (`true` = confirmado, `false` = cancelado ou fechado clicando fora), num modal `.modal-overlay`/`.modal-card` no padrão visual do Portal, nunca o diálogo nativo do navegador. **Decisão explícita do usuário**: o `window.confirm()` nativo do Chrome mostra o IP/porta do servidor na barra de título do popup e quebra a identidade visual do app — todo popup novo deve usar este modal em vez disso.
|
||
|
||
- `opcoes` (todas opcionais): `titulo` (default "Confirmar ação"), `textoConfirmar`/`textoCancelar` (defaults "Confirmar"/"Cancelar"), `perigoso` (troca o botão de confirmar pra `.btn-danger-outline`, mesmo estilo já usado em ações destrutivas como "Excluir selecionadas", em vez do `.btn-solid` padrão).
|
||
- Único modal (`#pid-confirm-modal`), criado sob demanda e reaproveitado — **empilha por cima** de qualquer modal já aberto via `.modal-overlay--top` (`components.css`, só `z-index` maior que o `.modal-overlay` padrão), então não precisa fechar o modal atual antes de perguntar; o modal por trás continua visível (dimmed), só o de confirmação recebe o clique.
|
||
- Reaproveita `.modal-card__title`/`.modal-card__subtitle` (`components.css`, já genéricos) pro título/mensagem — não precisou de classes novas de texto, só `.confirm-modal__cancelar-btn`/`.confirm-modal__confirmar-btn` como seletores de DOM pro próprio `confirm-modal.js`.
|
||
- **`pidAlert(mensagem, opcoes)`** é a contrapartida pra `window.alert()` — mesmo modal, um único botão (default "OK", sem "Cancelar"), devolve `Promise<void>`. Compartilha a mesma instância de `#pid-confirm-modal`/`pidConfirmOuAlerta()` internamente — `pidConfirm`/`pidAlert` só chamam essa função com `modoAlerta` diferente.
|
||
- **Todo `window.confirm()`/`window.alert()` do app foi migrado pra `pidConfirm()`/`pidAlert()`** (rodada de varredura completa, pedido explícito do usuário — "migre as demais"): `acessos-gerais.js` (excluir acesso, excluir seção), `calendar-individual.js` (excluir compromisso), `links-ferramentas.js` (remover link, erro ao favoritar), `importacao-plano-saude.js` (excluir regra de custeio, remover linha, reverter alteração, excluir importação — individual e em lote —, e o "Fechar sem salvar" do Cadastro de Regras, que **tinha um modal bespoke próprio pra esse mesmo motivo** — `#ips-regracad-confirm-fechar-modal`, criado numa rodada anterior só porque `window.confirm()` mostra a URL do servidor; removido e substituído por `pidConfirm()` nesta rodada, consolidando os dois em um único componente), `ramais.js` (remover telefone externo, remover função de telefonia, deletar ausência, remover ramal, erros), `profiles.js` (excluir perfil, erro ao salvar permissão), `indicador-desempenho.js` (excluir apuração/critério/registro de percentual/departamento), `users-admin.js` (excluir departamento/usuário, ativar/desativar usuário, avisos de "não pode excluir/desativar a si mesmo"). Ação destrutiva (excluir/remover/deletar) sempre usa `{ perigoso: true }`; ações não-destrutivas (reverter, ativar/desativar) não.
|
||
- **Não migrado**: `window.prompt()` em `users-admin.js` (renomear departamento, `data-dep-edit`) — é um tipo de popup diferente (pede texto, não só confirmar/cancelar) e não tinha um componente equivalente pronto; fica pra uma rodada futura se for pedido, junto com um modal de input genérico.
|
||
- **Só ligado em Importação de Plano de Saúde por ora** (`ips-ajuda-btn` em `importacao-plano-saude.html`, ao lado do `<h2>` dentro de `.ips-title-row`) — o mecanismo (model/endpoint/JS) já é genérico o bastante pra outra aplicação nova só precisar do botão+tooltip no HTML e uma chamada a `pidCriarBotaoAjuda()`, sem nenhum código novo no backend.
|
||
- Texto inicial de Importação de Plano de Saúde já estruturado (objetivo/como funciona/cuidados necessários/resultado esperado, em HTML simples — `<p>`/`<strong>`/`<ul>`/`<li>`) e salvo direto no banco, pronto pra revisão/edição do usuário pela própria tela (perfil Inovação).
|
||
|
||
**`seed_portal.py` não reseta mais `nome` de um perfil já existente** (bug real corrigido nesta rodada, motivado pelo usuário ter renomeado o perfil "Integração e Inovação" código 8 pra "Inovação" — e criado um perfil novo "Integração", código 9): `update_or_create(codigo=..., defaults={"nome": ..., ...})` reescrevia `nome` a cada execução, revertendo qualquer renomeação feita depois pela tela de Perfis de Acesso. Trocado por `get_or_create(codigo=..., defaults={"nome": ...})` (nome só gravado na criação) + atribuição direta de `ativo`/`gerencia_permissoes`/`permissoes` a cada execução (esses continuam sendo realinhados sempre — é assim que o seed serve pra corrigir uma árvore de permissões corrompida/desatualizada, só `nome` parou de ser tocado). Mesmo espírito de "só inicializa na primeira criação" já usado pra gabriel/bruno.
|
||
|
||
## Inativar/reativar usuário (usuarios.html)
|
||
|
||
Usa o campo `is_active` que já vem de `AbstractUser` — não foi criado nenhum campo/migração novo, só exposto em `UsuarioSerializer`/`UsuarioListSerializer` e ligado na UI. `is_active=False` já é suficiente pro Django bloquear o acesso sozinho, sem nenhum código extra de autenticação:
|
||
|
||
- **Login novo**: `authenticate()` (usado em `login_view`) roda via `django.contrib.auth.backends.ModelBackend`, que internamente chama `user_can_authenticate()` e recusa (`None`) qualquer usuário com `is_active=False`, mesmo com a senha certa. Como isso faz `authenticate()` retornar `None` tanto pra senha errada quanto pra usuário inativo, `login_view` faz uma checagem manual **só no caminho de falha** (`Usuario.objects.filter(username=username, is_active=False).first()` + `check_password()`) pra devolver uma mensagem diferente ("Este usuário está inativo...", 403) só quando a senha bate mas a conta está inativa — sem essa checagem extra, qualquer tentativa com credenciais erradas ou inexistentes continua caindo no genérico "Login ou senha inválidos." (401), pra não revelar se um username existe.
|
||
- **Sessão já aberta**: também não precisa de nenhum middleware/permissão customizado — `ModelBackend.get_user(user_id)` (chamado pelo Django a cada request pra popular `request.user` a partir da sessão) também recusa usuários inativos, então na próxima requisição depois de desativado o usuário vira `AnonymousUser` automaticamente e `IsAuthenticated`/`PodeGerenciarPermissoes` já barram sozinhos. Ou seja: desativar alguém já derruba o acesso na mesma hora, não só impede o próximo login.
|
||
|
||
**Inativar desvincula perfis de acesso e liderança automaticamente** (`_revogar_acesso_se_inativo()`, `serializers.py`, decisão explícita do usuário): sempre que `UsuarioSerializer.create()`/`update()` termina com `usuario.is_active=False`, `perfis` é limpo (`usuario.perfis.clear()`) e o usuário sai do `liderados` de qualquer gerente que o tivesse (`usuario.lideres.clear()` — `lideres` é a relação **reversa** de `Usuario.liderados`; limpar aqui remove `usuario` do lado de quem o lidera, sem afetar quem `usuario` eventualmente lidera, caso ele mesmo seja gerente). A chamada é sempre a **última** coisa em `create()`/`update()`, depois dos `.set()` de `perfis`/`departamentos`/`liderados` — colocar antes seria inútil, já que o formulário de edição de `usuarios.html` sempre reenvia o checklist de perfis inteiro junto com qualquer mudança no checkbox "Usuário ativo", e um `.set()` posterior desfaria uma limpeza feita cedo demais. Cobre os dois pontos de entrada reais (botão de alternar na lista + checkbox no formulário de edição), ambos passando por `PATCH /api/usuarios/{id}/`; não há um hook equivalente no `admin.py` (uso interno, fora de escopo). **Não é uma trava**: nada impede reativar alguém depois (ele volta sem nenhum perfil/liderança, precisa reconfigurar) nem impede — por ora — que um gerente adicione manualmente um usuário já inativo aos próprios `liderados` pela tela dele (o hook só dispara ao salvar o usuário inativo em si, não ao salvar o gerente).
|
||
|
||
Na UI (`users-admin.js`/`usuarios.html`): coluna "Status" na lista (`.status-pill`/`.status-pill--ativo`/`.status-pill--inativo`, mesmo componente que já existia pro `PerfilAcesso.ativo`) e um botão de alternar (ícone de "power") na linha, ao lado de editar/excluir — `PATCH /api/usuarios/{id}/` com `{is_active: !atual}`, com confirmação via `window.confirm`. O checkbox "Usuário ativo" no formulário de edição faz a mesma coisa (útil quando já se está editando outros campos). Os dois lugares bloqueiam **auto-desativação** (mesmo padrão de guarda já usado pra "não pode excluir a si mesmo": checagem só no frontend, `id === me.id`) — no formulário isso aparece como o checkbox desabilitado (`disabled`) quando `account.id === me.id`, em vez de um alerta.
|
||
|
||
**Filtro de status na lista** (`#ua-status-filtros`, chips "Ativos"/"Inativos"/"Todos" ao lado do título "Usuários" — mesma linguagem visual de `.ind-departamento-chip`): client-side, sobre o array `users` já carregado (`statusFiltro` em `users-admin.js`, aplicado em `renderList()` antes do filtro de busca por texto). Nasce em `"ativos"` por padrão (decisão explícita do usuário — a lista não deve abrir mostrando quem já foi desativado) e reseta pra `"ativos"` só no load da página, não a cada `renderList()`.
|
||
|
||
**Colunas de código cadastral + ordenação** (`#ua-table`): a lista também mostra `codigo_folha`/`codigo_questor`/`codigo_tareffa`/`codigo_contabit`/`ramal` como colunas próprias (antes só apareciam dentro do formulário de edição) — pedido explícito do usuário pra conseguir achar quem está sem algum desses códigos cadastrado antes de outras aplicações passarem a depender deles, mesmo eles sendo campos opcionais (`blank=True`) no model. Célula vazia renderiza `<span class="ua-campo-vazio">—</span>` (itálico, cor apagada) em vez de string vazia, pra ficar visualmente óbvio ao ordenar a coluna. Todo `<th data-sort="...">` (login, nome, os 4 códigos, ramal, status) é clicável e alterna asc/desc (`sortKey`/`sortDir` em `users-admin.js`, ícone `↕` que fica `--accent` quando ativo) — mesmo padrão de `#ips-list-table` (`importacao-plano-saude.js`) e do modal de Ramais (`ramais-lookup.js`), inclusive a mesma função de comparação (`comparaValoresUsuario`, número vs. número quando os dois convertem, senão `localeCompare` pt-BR) — cada arquivo mantém sua própria cópia da função, não foi extraída pra um utilitário compartilhado em `api.js`. "Perfil de Acesso" (junção de nomes) não é ordenável, mesmo critério das outras telas que não ordenam colunas agregadas.
|
||
|
||
**Botão "Vincular" (visual, sem funcionalidade ainda) nos campos Código da Folha/Questor/Tareffa do formulário de edição**: decisão explícita do usuário — esses 3 códigos vão futuramente ser buscados/vinculados a partir de uma ferramenta externa (ex.: `codigo_tareffa` via a view já existente em `portal_api/database/` que lê o Tareffa, ver [[project_database_package]] na memória) em vez de digitados à mão, mas essa vinculação de verdade **não foi implementada nesta rodada** — só a estrutura visual. Cada um dos 3 campos (`#ua-codigo-folha`/`#ua-codigo-questor`/`#ua-codigo-tareffa`) ganhou um input + botão "Vincular" (ícone de elo + texto) encostados numa única caixa (`.ua-field-link` — borda/raio únicos, botão separado por `border-left`, mesmo estilo de referência que o usuário mostrou de um campo de busca com botão "Buscar" atado à direita); passou por duas versões mais simples antes (botão solto ao lado do input, depois só o ícone sem texto no canto) até o usuário pedir essa terceira, "no estilo do botão de buscar". Sempre `disabled` com `title="Vinculação com sistema externo ainda não implementada"` — não tem nenhum handler de clique em `users-admin.js`. `codigo_contabit` e `ramal` (também campos cadastrais na mesma seção "Dados Cadastrais") **não** ganharam o botão — não fazem parte do conjunto de códigos com vinculação externa planejada, continuam sendo só texto livre. Ao implementar a busca de verdade num momento futuro, reaproveitar esse mesmo botão (tirar o `disabled`, adicionar o handler), não recriar o campo do zero.
|
||
|
||
## Liderança (gerente/coordenador) e o modal "Gerenciar Usuário"
|
||
|
||
`Usuario.lideranca` (booleano) marca um usuário como gerente/coordenador de outros; `Usuario.liderados` é um M2M **auto-referenciado** (`"self"`, `symmetrical=False`, `related_name="lideres"`) — ou seja, "A lidera B" não implica "B lidera A". Exemplo: marcar `lideranca=True` em "debora" e incluir "gabriel" em `liderados` representa "debora é gerente de gabriel".
|
||
|
||
Dois lugares gravam essa mesma relação:
|
||
|
||
- **Tela de Usuários** (`usuarios.html`/`users-admin.js`, só quem tem `gerencia_permissoes`): seção "Liderança" no formulário de edição — checkbox "É gerente ou coordenador de outros usuários" (`#ua-lideranca`) libera (`hidden`) o widget de vinculação dual descrito abaixo (a própria conta sendo editada é excluída da lista de candidatos — mesmo padrão de guarda "frontend-only" já usado pra "não pode excluir a si mesmo"/"não pode se auto-desativar", não há checagem equivalente no backend). Salvar envia `lideranca`+`liderados` (array de ids) no mesmo payload de `PATCH`/`POST /api/usuarios/`.
|
||
- **Modal "Gerenciar Usuário"** (`account.js`, disponível em todo shell via o item "Gerenciar Usuário" no dropdown da conta — substituiu o antigo botão direto "Alterar senha"): abre `#manage-account-modal`, que sempre tem um botão "Alterar senha" (que fecha esse modal e abre o `#password-modal` já existente, mesmo fluxo de antes) e, só se `me.lideranca` for `true`, o mesmo widget de vinculação dual — permitindo que o próprio gerente/coordenador se autogerencie sem precisar de acesso à tela administrativa de Usuários. Essa lista vem de `GET /api/usuarios-resumo/` (não de `/api/usuarios/`, que exige `gerencia_permissoes`) e salvar dispara `PATCH /api/me/liderados/`, que grava na mesma `Usuario.liderados` — `meus_liderados_view` recusa (403) se `request.user.lideranca` for `False`, já que só faz sentido pra quem tem o checkbox marcado.
|
||
|
||
**Widget "Usuários sob liderança" — duas tabelas, não vinculados à esquerda e vinculados à direita** (`.dual-select`, `static/js/dual-select.js` + estilos em `components.css`): substituiu o antigo `.checklist-box` de uma lista só (checkbox + busca + "marcar todos") — reestruturado a pedido do usuário no mesmo estilo do widget "Usuários do Escritório" de Perfis de Acesso (ver seção própria abaixo), só que genérico o bastante pra rodar tanto em `usuarios.html` quanto dentro do modal "Gerenciar Usuário" (presente em todo shell). `pidCriarSeletorDuplo(config)` (`dual-select.js`, incluído no prefixo de scripts de todo shell, logo depois de `api.js`) é a fábrica compartilhada — recebe as referências de DOM de cada painel (`available`/`linked`: checkbox "selecionar todos", cabeçalho ordenável, dois campos de busca, corpo da tabela, rodapé de contagem e o botão de ação) mais `secundariaValor(candidato)` (aqui, `departamentosTexto()`, unindo os nomes dos departamentos por vírgula) e devolve `{ setDados(candidatos, vinculadosIniciais), getVinculadosIds() }`. Cada tela (`users-admin.js`/`account.js`) só chama `setDados()` ao abrir o formulário/modal e `getVinculadosIds()` no momento de salvar — a vinculação em si é só em memória dentro do widget (nenhuma chamada de API própria), o "Vincular"/"Desvincular" só move ids entre os dois painéis local mente, igual ao checklist antigo (que também só populava um `Set` em memória até o "Salvar" de fora).
|
||
- Cada painel tem: checkbox de seleção múltipla + "selecionar todos" (sobre as linhas **filtradas** visíveis, mesmo critério do antigo `.checklist-select-all`), coluna "Nome" ordenável (clique alterna asc/desc) e coluna "Departamento", com uma segunda linha de cabeçalho só com os dois campos de busca (por nome e por departamento, independentes) — filtro client-side sobre o array já carregado. O botão de ação do painel ("Vincular" a esquerda/"Desvincular" a direita) fica desabilitado enquanto a seleção daquele painel estiver vazia, e mover usuários limpa a seleção e re-renderiza os dois painéis (quem saiu de um painel aparece no outro).
|
||
- `#manage-account-modal-card` (id novo no `.modal-card` do modal "Gerenciar Usuário") ganha a classe `.modal-card--wide` via JS (`account.js`) só quando `me.lideranca` é `true` — o modal volta ao tamanho padrão (420px) quando só tem o botão "Alterar senha", em vez de ficar largo à toa pra quem não lidera ninguém.
|
||
- `.dual-select`/`.dual-select__*` moram em `components.css` (não em `perfis-acesso.css`), pela mesma razão de `.checklist-box` — o modal "Gerenciar Usuário" existe em todo shell, e a maioria deles não carrega `perfis-acesso.css`.
|
||
- **Usuário inativo nunca aparece nos candidatos** — mesma decisão de "Usuários do Escritório" acima. Em `usuarios.html`, `fillLideradosChecklist()` (`users-admin.js`) filtra `users` por `is_active` antes de montar a lista de candidatos; no modal "Gerenciar Usuário", isso já vem de graça porque `GET /api/usuarios-resumo/` (`usuarios_resumo_view`) só devolve usuários ativos.
|
||
|
||
## Favoritos: como o ID de uma aplicação é derivado
|
||
|
||
`favorites.js` não depende de nenhum atributo `data-*` dedicado para identificar "o que é favoritável" — ele varre `.sidebar a.nav-item, .sidebar a.nav-subitem` e deriva um ID estável a partir da própria estrutura/texto do menu (`pidCollectFavoritableApps`), igual a antes da migração. Esse ID é o que vira `app_id` em `POST /api/favoritos/` e na URL de `DELETE /api/favoritos/{app_id}/`:
|
||
|
||
- Se o `<li>` do link já tem `data-section`, o ID é esse valor (ex.: `"ramais"`).
|
||
- Caso contrário (é um sub-item dentro de um `nav-group`), o ID é `"<data-section do grupo pai>__<slug do texto do link>"` (ex.: `"portais__portal-do-cliente"`).
|
||
|
||
O slug (`pidSlug`) normaliza acentos (NFD) e troca sequências de caracteres não `[a-z0-9]` por `-`. Se o texto de um label mudar, o `app_id` derivado muda junto (favoritos existentes referenciando o ID antigo deixam de casar).
|
||
|
||
**Reordenar os cards favoritos**: `Favorito.ordem` (`PositiveIntegerField`, `Meta.ordering = ["ordem", "id"]`) — mesmo padrão de `LinkFerramenta`/`WidgetUsuario`: `FavoritoViewSet.perform_create` atribui `ordem = max(ordem atual do usuário) + 1`, e reordenar é drag-and-drop nativo em `#app-card-grid` (`favorites.js`, `dragstart`/`dragover`/`drop`, `PATCH /api/favoritos/{app_id}/` só nos itens cujo `ordem` mudou) — mesma mecânica dos outros dois. `.app-card` inteiro é `draggable="true"` (não precisa de um handle separado como os widgets, já que não tem `resize` pra conflitar); o botão de remover (`.app-card__remove`) é `draggable="false"` pra não interferir.
|
||
|
||
## Calendário Individual e Widgets
|
||
|
||
Compromissos (`CompromissoAgenda`) têm um dono (`dono`, FK) e um campo `visibilidade` (`"somente_eu"`/`"departamento"`/`"todos"`, substituiu a antiga flag booleana `compartilhado_com_perfil` — perfil de acesso deixou de ser o critério de compartilhamento). `GET /api/compromissos/` (`CompromissoAgendaViewSet.get_queryset`) já retorna: (a) sempre os próprios compromissos do usuário; (b) todo compromisso com `visibilidade="todos"`, pra qualquer usuário do portal, sem checar perfil/departamento; (c) compromissos com `visibilidade="departamento"` só se `departamento_compartilhado` (FK, `on_delete=SET_NULL`) for um dos departamentos do usuário logado — a lógica de "visível para quem" mora só no backend. O campo `sou_dono` (calculado no serializer) substitui o antigo `ownerLogin === login` do cliente; só o dono edita/exclui (`CompromissoAgendaViewSet.get_object` levanta `PermissionDenied` se não for o dono tentando escrever).
|
||
|
||
Ao escolher `visibilidade="departamento"` no modal (`calendario-individual.html`/`calendar-individual.js`), um segundo campo aparece (`#ic-event-department-field`) pra escolher **qual** dos próprios departamentos do dono recebe o compartilhamento — decisão explícita do usuário, já que `Usuario.departamentos` é M2M (pode ter mais de um) e "meu departamento" sozinho seria ambíguo nesse caso. As opções desse `<select>` vêm de `me.departamentos` (adicionado a `/api/me/` só pra isso — antes esse endpoint não expunha os próprios departamentos do usuário logado, só o de outros via `UsuarioResumoSerializer`). `CompromissoAgendaSerializer.validate()` exige `departamento_compartilhado` quando `visibilidade="departamento"` e recusa qualquer departamento que não esteja entre os do próprio dono (`request.user.departamentos`) — mesmo que o cliente tente forçar um id de departamento alheio no payload; para as outras duas visibilidades, `departamento_compartilhado` é sempre zerado no servidor, ignorando o que vier no payload.
|
||
|
||
**Filtro por categoria e cores no calendário** (`.calendar-filters`/`.filter-chip` em `calendario.css` — já existiam no CSS sem nenhum consumidor antes desta funcionalidade): dentro de `.calendar-header`, ao lado do título do mês e do botão "Novo Compromisso" (mesma linha, não numa faixa própria abaixo — `flex-wrap: wrap` no header cobre o caso de não caber tudo numa linha só), uma barra de chips clicáveis (multi-seleção, sem exclusividade) filtra o que aparece no calendário por `pidEventoCategoria(ev)` (`calendar-individual.js`), que não é exatamente `ev.visibilidade` — um compromisso `"somente_eu"` que não é meu (`!ev.sou_dono`) só pode ter chegado pela regra de liderança abaixo, então vira a categoria `"equipe"`, distinta de `"somente_eu"` (meus próprios); e todo `ev.eh_evento` vira a categoria `"evento"` **antes** de qualquer outra checagem (ver "Eventos Corporativos" abaixo), mesmo já sendo sempre `visibilidade="todos"`. As 5 categorias (`somente_eu`/`departamento`/`todos`/`equipe`/`evento`) têm cor fixa própria (`.calendar-event--*` em `calendario.css`) e o chip ativo de cada uma usa a mesma cor — o próprio filtro funciona como legenda. `"somente_eu"` é a exceção: usa `--accent` (o tema de cor que o usuário escolheu, não uma cor fixa); as outras quatro usam tokens fixos novos (`--gold`, reaproveitado do antigo "compartilhado"; `--teal` e `--slate`, adicionados só pra isso em `tokens.css`; `--coral`, adicionado depois só pra `"evento"` — todos com variante mais escura no tema claro pro contraste do texto escuro fixo `#1a1721`) — escolhidos deliberadamente fora das cores de tema selecionáveis (roxo/azul/verde/âmbar/rosa/vermelho) pra nunca coincidir visualmente com o que `--accent` pode assumir — exceto `--coral`, que é laranja e portanto não colide mesmo com "vermelho" na lista. O chip "Minha equipe" (`#ic-filter-equipe`) só aparece (`hidden`) se `me.lideranca`; o chip "Eventos" (`data-filter="evento"`) é sempre visível, já que qualquer perfil pode ver eventos corporativos (só criar um é restrito). Por padrão todos os chips visíveis nascem `is-active` (mostra tudo que o usuário pode ver). Clique simples troca a seleção pra **só** aquele filtro (`activeFilters.clear()` + adiciona só o clicado), exceto se esse filtro já for o único ativo — nesse caso (`activeFilters.size === 1 && activeFilters.has(filtro)`) o clique volta pra visualização padrão (`selecionarTodosOsFiltros()`, todos os chips visíveis ativos), pra sempre existir um caminho de volta ao estado "ver tudo" sem precisar de Shift. `Shift`+clique acrescenta/remove esse filtro dos já selecionados (`event.shiftKey`, mesmo padrão de seleção de arquivos do SO) — é assim que dá pra combinar mais de uma categoria ao mesmo tempo.
|
||
|
||
**Eventos Corporativos** (`CategoriaEvento`, `CompromissoAgenda.eh_evento`/`categoria`/`local`/`modalidade`/`descricao`): compromissos com `visibilidade` em `departamento`/`todos` deixaram de ser livres pra qualquer usuário — criar ou editar um compromisso nesses dois níveis (o que inclui automaticamente todo `eh_evento=True`, já que a validação força `visibilidade="todos"` antes de checar permissão) agora exige `apps["calendario-individual-criar-evento"]` em `permissoes["calendario-individual"]` (`CompromissoAgendaSerializer.validate()`), permissão liberada só para "Integração e Inovação" no `seed_portal.py` por ora (mesmo cuidado de sempre: como `calendario-individual` está em `BASE_KEYS`, sem o override todo perfil nasceria podendo criar evento de departamento/todos e cadastrar categoria). Compromissos `"somente_eu"` continuam livres pra qualquer um, sem essa checagem.
|
||
- `CategoriaEvento` (`nome` único + `cor` hex, validada por `validar_cor_categoria_evento`) é um cadastro simples via `/api/categorias-evento/` — GET livre a qualquer autenticado (a cor/nome de uma categoria não é sigilosa), escrita restrita à mesma permissão acima. Não é uma lista fixa no código: quem tem a permissão cadastra categorias novas (ex.: "Reunião", "Treinamento") direto no modal de criar/editar compromisso (botão "+" ao lado do `<select>` de categoria, `#ic-event-categoria-add-btn`, que abre `#ic-categoria-modal`) — `seed_portal.py` popula `CATEGORIAS_EVENTO_SEED` como ponto de partida, mas a lista é editável dali em diante.
|
||
- `CompromissoAgenda.eh_evento` marca um compromisso como evento formal (não uma reunião pessoal marcada como "todos") — o checkbox correspondente (`#ic-event-eh-evento`, dentro de `#ic-event-eh-evento-field`) só aparece pra quem tem a permissão de criar evento; marcá-lo força a visibilidade pra "Todos" no próprio formulário. `local` (texto livre), `modalidade` (`presencial`/`remoto`/`hibrido`, `<select>` `#ic-event-modalidade`) e `descricao` (texto livre) são campos extras só relevantes pra evento, mas tecnicamente gravam em qualquer compromisso (o formulário só os expõe quando aplicável). No popup somente-leitura (`#ic-view-modal`), cada um aparece como campo próprio (`#ic-view-local-field`/`#ic-view-modalidade-field`/`#ic-view-descricao-field`), escondido (`hidden`) quando vazio — mesmo padrão dos demais campos condicionais desse popup (ver "Pill do compromisso" acima).
|
||
- Visualmente, um evento ganha um bucket de cor próprio (`--coral`) tanto no pill do calendário (`.calendar-event--evento`) quanto no chip de filtro "Eventos" — mesmo sendo sempre `visibilidade="todos"` por baixo, não se mistura visualmente com um "Todos" comum (ver parágrafo acima).
|
||
|
||
**Pill do compromisso: sempre "HH:MM Título", nada mais** — todo pill mostra só horário+título, nunca o nome do dono, pra manter o mesmo formato/tamanho independente da categoria ("simétrico", pedido explícito do usuário; a primeira versão acrescentava "— Nome do dono" direto no texto dos compromissos que não eram do usuário, o que descalibrava o visual porque nomes têm tamanhos bem diferentes). Todo pill é clicável (`cursor:pointer` na classe base `.calendar-event`, não só em `--somente-eu`): se `ev.sou_dono`, abre o modal de edição de sempre (`#ic-modal`, fecha também clicando fora — `event.target === modal`, mesmo padrão de `links-ferramentas.js`/`ramais-lookup.js`); senão, abre um modal novo, só leitura (`#ic-view-modal`/`openViewModal()`, mesmo fecha-ao-clicar-fora), com data/horário por extenso, `dono_nome` (rotulado "Agendado por:", não "Responsável" — mudança de nomenclatura pedida pelo usuário), o rótulo da visibilidade (`PID_IC_VISIBILIDADE_LABELS`, mapeia `ev.visibilidade` pro texto exibido nos chips) e, só quando `ev.visibilidade === "departamento"`, o nome do departamento (`ev.departamento_compartilhado_nome`, campo já vinha do serializer). É esse popup — não o texto do pill — que carrega toda a informação que antes tentava caber na própria pílula.
|
||
|
||
**Célula do dia com altura fixa, lista de compromissos rolável** (`.calendar-day`/`.calendar-day__events` em `calendario.css`): `.calendar-day` tem `height` fixo (108px desktop, 76px no breakpoint mobile — antes era `min-height`, o que deixava a linha inteira da grade crescer quando um dia tinha muitos compromissos, desalinhando a altura de todas as células daquela semana). Os pills não são mais filhos diretos de `.calendar-day` — `calendar-individual.js` (`render()`) os agrupa num `<div class="calendar-day__events">` (`flex:1; min-height:0; overflow-y:auto`) irmão de `.calendar-day__header`. **`.calendar-day` (o item de grid, não só o `__events` interno) também precisa de `min-height:0` + `overflow:hidden`** — sem isso, o "tamanho mínimo automático" que grid/flexbox calculam por padrão pra um item (baseado no conteúdo, ignorando `height` explícito) ainda fazia a *linha da grade* crescer pra caber todos os pills, mesmo com a célula e o `overflow-y:auto` do `__events` configurados certinho por dentro — o corte real só acontece quando o próprio item de grid para de contribuir com seu min-content pro cálculo da altura da linha (`min-height:0`/`overflow` não-visible fazem isso). Resultado: o cabeçalho (número do dia + botão de adicionar) fica sempre fixo, todas as linhas da grade têm a mesma altura sempre, e uma barra de rolagem aparece dentro da célula só quando os compromissos daquele dia não cabem nos 108px/76px disponíveis.
|
||
|
||
**Agenda completa do dia** (`#ic-day-modal`, `openDayModal()` em `calendar-individual.js`): clicar no número do dia (`.calendar-day__number`, `cursor:pointer` + destaque no hover) abre um popup com **todos** os compromissos do dia (respeitando os filtros ativos, mesmo `eventosDoDia` usado pra desenhar a célula) mais o feriado, se houver — sem o corte de altura/rolagem da célula, já que o `.day-modal-list` (`calendario.css`) tem `max-height:360px` próprio, bem maior que os 108px da grade, e os pills ali dentro voltam a ter `white-space:normal` (podem quebrar linha) em vez do `nowrap`+ellipsis da grade, então nada aparece cortado. Pra evitar duplicar a criação dos pills em dois lugares (grade e popup), `criarPillCompromisso(ev)`/`criarPillFeriado(iso, nome)` foram extraídas como funções reaproveitadas por `render()` **e** por `openDayModal()` — mesmo elemento, mesmo clique (editar/ver detalhes/ver nome do feriado), só muda o container onde entram. Clicar num item dentro do popup fecha o popup da agenda antes de abrir o modal de destino (edição/visualização/feriado), pra não empilhar dois overlays ao mesmo tempo. O botão "Novo Compromisso" do popup pré-preenche a data com o dia clicado (mesmo mecanismo do "+" de cada célula).
|
||
|
||
**Feriados no Calendário Individual** (`GET /api/feriados/?ano=AAAA`, `feriados_view` em `views.py`): usa a lib `holidays` (PyPI, `requirements.txt`) pra devolver os feriados **nacionais + estaduais do Paraná** (`holidays.Brazil(years=ano, subdiv="PR", language="pt_BR")`, categoria `public` — o default da lib, exclui pontos facultativos tipo Carnaval/Corpus Christi) do ano pedido, como `[{"data": "AAAA-MM-DD", "nome": "..."}]`. `language="pt_BR"` é passado explicitamente — sem isso, a lib pode cair pro locale do processo do servidor (que nem sempre é pt_BR, ex.: environment com `LANG`/`LANGUAGE` em inglês) em vez do `default_language` da classe `Brazil`, fazendo os nomes virem em inglês ("Independence Day" em vez de "Independência do Brasil") mesmo com o resto do portal em português. **De propósito não tem feriado municipal de Foz do Iguaçu aqui** — nenhuma lib de feriados cobre granularidade de município brasileiro (a `holidays` só tem um caso especial hardcoded pra "São Paulo Capital", nada além disso), e manter uma lista municipal certa exigiria curadoria manual + atualização por decreto da Prefeitura a cada ano; o usuário decidiu deixar de fora por enquanto, só nacional/estadual mesmo. Se algum dia precisar do municipal, a rota certa é o usuário fornecer a lista oficial (decreto da Prefeitura) pra virar uma tabela fixa no código, não tentar adivinhar/inferir datas.
|
||
|
||
No frontend (`calendar-individual.js`), `carregarFeriados(ano)` busca e cacheia por ano (`Map` em memória, só refaz a requisição ao trocar de ano); `render()` busca também o ano anterior/seguinte quando o mês exibido encosta na borda do ano (janeiro/dezembro), já que os dias "fora do mês" na grade podem pertencer a um ano diferente de `viewYear`. Cada célula de dia feriado ganha a classe `.is-feriado` (fundo tingido de vermelho, `rgba(var(--danger-rgb), 0.1)`) e um pill (`.calendar-day__holiday`, mesmo visual dos pills de compromisso — `.calendar-event`, só que com fundo `--danger` fixo, não uma cor por categoria) com o nome do feriado. Esse pill é irmão de `.calendar-day__header`, **fora** de `.calendar-day__events` — fica sempre fixo no topo da célula, não rola junto com os compromissos do dia. Truncado com `text-overflow:ellipsis` quando o nome não cabe (alguns feriados vêm com dois nomes concatenados por `;`, ex.: "Nossa Senhora do Rocio; Proclamação da República" — ver `feriados_view`); clicar no pill abre `#ic-holiday-modal` (`openHolidayModal()`, mesmo padrão dos outros modais — fecha clicando fora) mostrando a data e o nome completo sem corte. É só informativo, não bloqueia criar/editar compromisso nesse dia.
|
||
|
||
**Gerente/coordenador vê a agenda individual da equipe**: `CompromissoAgendaViewSet.get_queryset` acrescenta `Q(visibilidade="somente_eu", dono__in=usuario.liderados.all())` às regras de visibilidade — ou seja, além de "todos" e "departamento" (ver acima), quem tem gente em `Usuario.liderados` (ver seção "Liderança") também enxerga os compromissos privados (`"somente_eu"`) de cada liderado, mas sem poder editá-los (`sou_dono` continua `False` pra esses, `CompromissoAgendaViewSet.get_object` já barra escrita de quem não é dono). Não há necessidade de checar `usuario.lideranca` explicitamente na query — `liderados` só é populado através de fluxos que já exigem esse flag (ver "Liderança"), então a cláusula é inofensiva (não casa nada) pra quem não lidera ninguém.
|
||
|
||
**Lembrete com horário comercial** (`CompromissoAgenda.lembrete_antecedencia`, opcional — `""` = sem lembrete; `"1h"`/`"2h"`/`"4h"`/`"24h"`): não existe nenhum mecanismo de push/e-mail no projeto — o "lembrete" é só o momento a partir do qual o compromisso passa a aparecer no sino de notificações (`notif-bell`), que já era recalculado a cada carregamento de página (sem processo em segundo plano). `CompromissoAgenda.calcular_notificar_em()` (`models.py`) calcula esse horário contando `lembrete_antecedencia` horas **de expediente** (seg-sex, 8h-18h, `COMPROMISSO_HORARIO_COMERCIAL_INICIO`/`_FIM`) pra trás a partir de `data`+`horario` — fora do expediente não conta como antecedência "gasta", só é pulado de graça. Por isso um compromisso às 08h de segunda com lembrete de 4h não notifica às 04h (fora do expediente); o algoritmo (`_janela_comercial`/`_dia_util_anterior`, funções módulo-level) pula pro fechamento do expediente do dia útil anterior (sexta 18h) e só então desconta as 4h, resultando em sexta 14h. Sem `horario` definido no compromisso (evento de dia inteiro) ou sem `lembrete_antecedencia`, não há o que calcular e `calcular_notificar_em()` retorna `None`. O resultado é exposto só leitura via `notificar_em` no serializer (ISO datetime ou `null`); `pidBuildEventNotifications()` (`notifications.js`) só inclui um compromisso na lista do sino quando `notificar_em` não é nulo **e** já foi atingido (`now >= notificar_em`) — compromissos sem lembrete configurado simplesmente não aparecem no sino (comportamento diferente de antes da migração, quando todo compromisso futuro aparecia lá independente de qualquer configuração).
|
||
|
||
`widgets.js` mantém um registro extensível `PID_WIDGET_TYPES` (`{ "<chave>": { label, description, href, linkLabel, visibleIf? } }`) — hoje existem `"calendario-individual"` e `"links-favoritos"` (ver seção própria abaixo). `href`/`linkLabel` alimentam o link de rodapé do card ("Ver X completo →"); antes de existir um segundo tipo de widget esse link era hardcoded pra `calendario-individual.html`, então ao adicionar um tipo novo **sempre** preencher os dois, senão o rodapé de todos os widgets aponta pro lugar errado. `visibleIf(me)` é opcional — quando presente, filtra o tipo tanto do picker (`renderPicker()`) quanto da grade já adicionada (`renderWidgets()`), usado pra widgets que exponham dado de um módulo com permissão própria (ex.: `links-favoritos` só aparece pra quem tem `apps["links-ferramentas-visualizar"]` em `links-ferramentas`). Para adicionar um novo tipo de widget: registrar a entrada em `PID_WIDGET_TYPES` e adicionar um `case`/`if` em `widgetBodyFor()` que retorne o HTML do corpo do card; o picker (`#widget-picker-modal`) e a grade (`#widgets-grid`) já lidam com adicionar/remover genericamente via `/api/widgets/`.
|
||
|
||
**Reordenar e redimensionar widgets** (`WidgetUsuario.ordem`/`largura`/`altura`, por usuário): `.widgets-grid` é `display:flex; flex-wrap:wrap` (não mais CSS Grid — precisava permitir que cada `.widget-card` tivesse largura/altura próprias e livres, incompatível com colunas de grid uniformes). Reordenar é drag-and-drop nativo HTML5 igual ao de Links & Ferramentas (`dragstart`/`dragover`/`drop` em `#widgets-grid`, `PATCH /api/widgets/{tipo}/` só nos itens cujo `ordem` mudou) — a diferença é que o `draggable="true"` fica só em `.widget-card__header` (a barra de título), não no card inteiro, pra não conflitar com o handle nativo de resize (`resize: both` em `.widget-card`, ativo no canto inferior direito). Redimensionar usa esse `resize: both` do CSS (sem JS de arraste custom) — um `ResizeObserver` por card (`observeWidgetSizes()`) detecta a mudança de tamanho e salva `largura`/`altura` com debounce de 500ms; como o resize é 100% nativo do browser, não precisa nenhum cálculo manual de arraste. Como o conteúdo de um widget pode ficar maior que o espaço depois de encolhido, só `.widget-card__body` tem `overflow-y: auto` (o cabeçalho e o link de rodapé ficam fixos, só o corpo rola).
|
||
|
||
## Links & Ferramentas
|
||
|
||
"Links & Ferramentas" era uma seção do menu com uma única aplicação (a grade de cartões); virou uma seção com **duas** aplicações reais — a grade de cartões original e "Acessos Gerais" (ver seção própria abaixo) — quando essa segunda foi adicionada. Por isso o item do menu, que antes era um link direto (`<li data-section="links-ferramentas"><a href="links-ferramentas.html">`), agora é um `nav-group` expansível (mesmo padrão de "Portais"/"Auditorias") com dois `nav-subitem`: "Links & Ferramentas" (`data-app="links-ferramentas-visualizar"`, mesma URL de antes) e "Acessos Gerais" (`data-app="acessos-gerais-visualizar"`, `acessos-gerais.html`). Isso teve um efeito colateral em `favorites.js`: como o `<li data-section="links-ferramentas">` não tem mais um `<a class="nav-item">` direto (virou um `<button data-group-toggle>`), a seção como um todo deixou de ser favoritável — só os dois sub-itens são, cada um com seu próprio `app_id` derivado (`"links-ferramentas__links-ferramentas"`/`"links-ferramentas__acessos-gerais"`, ver "Favoritos" acima). Um favorito antigo com `app_id === "links-ferramentas"` (de antes dessa mudança) para de casar — mesma categoria de caveat já documentada em "Favoritos": mudar a forma como um item aparece no menu muda o `app_id` derivado.
|
||
|
||
`LinkFerramenta` é uma lista **global/compartilhada** (sem FK pra `Usuario`, ao contrário de `Favorito`/`WidgetUsuario`/`NotificacaoDispensada`) — todo usuário com `apps["links-ferramentas-visualizar"]=True` em `permissoes["links-ferramentas"]` vê os mesmos cartões via `GET /api/links-ferramentas/` (ver "Padrão visualizar/editar" acima).
|
||
|
||
`links-ferramentas.js` também gateia o **conteúdo da própria página** por `apps["links-ferramentas-visualizar"]` (`#lf-no-access`/`#lf-content` em `links-ferramentas.html`, mesmo padrão do `.no-access` de `portal.html`) — isso existe porque o sidebar (`data-section="links-ferramentas"` em `access.js`) só esconde o `nav-group` inteiro com base no `enabled` do módulo (e cada sub-item individualmente com base no seu `-visualizar`), então alguém sem `apps["links-ferramentas-visualizar"]` mas que navegue direto pra URL (ou tenha `enabled=true` sem essa flag, uma combinação tecnicamente possível já que são independentes) via GET no backend recebia 403 e via a tela renderizada com "Nenhum link cadastrado ainda." em vez de uma mensagem de acesso negado. Ao adicionar uma aplicação nova no padrão visualizar/editar, replicar esse gate (como `acessos-gerais.js` já faz) — não basta confiar em `access.js` escondendo o link do menu.
|
||
|
||
Só quem tem `apps["links-ferramentas-editar"]=True` (`me.permissoes_efetivas["links-ferramentas"].apps["links-ferramentas-editar"]`, já unido no servidor) vê em `links-ferramentas.html` os controles de administração: botão "Adicionar Link" no topo e, em cada cartão, setas de mover para cima/baixo + X de remover (`links-ferramentas.js`, gated no frontend por essa flag, e reforçado no servidor por `PermissaoApp("links-ferramentas", "links-ferramentas-editar")`/`PermissaoApp("links-ferramentas", "links-ferramentas-visualizar")` conforme o método HTTP).
|
||
|
||
- **Ordenação**: campo `ordem` (inteiro, sem `unique`) em `LinkFerramenta`, `Meta.ordering = ["ordem", "id"]`. Não existe endpoint de reorder em lote no backend — o cliente é quem decide quais `PATCH`es disparar. Duas formas de reordenar na UI, ambas em `links-ferramentas.js`: as setas (`swapOrdem()`) trocam o `ordem` de dois itens adjacentes com duas chamadas `PATCH`; arrastar um cartão (drag-and-drop nativo HTML5, `.lf-card--draggable`/`dragstart`/`dragover`/`drop` no `#lf-grid`) recalcula a lista inteira em memória e envia um `PATCH` só para os itens cujo `ordem` (índice na nova ordem) realmente mudou — como não há `unique` em `ordem`, não tem problema disparar essas chamadas em paralelo (`Promise.all`) mesmo que dois itens fiquem com o mesmo valor por um instante. Ao criar um link novo, o servidor sempre calcula `ordem = max(ordem atual) + 1` em `LinkFerramentaViewSet.perform_create` — qualquer `ordem` enviada pelo cliente no POST é ignorada.
|
||
- **Ícone**: `icone` é um `ImageField` opcional (upload real, não URL) — exige Pillow (`requirements.txt`) e `MEDIA_URL`/`MEDIA_ROOT` (`settings.py`, servido em `DEBUG` por `config/urls.py`). Sem ícone, o cartão cai num SVG de fallback (`PID_LINK_DEFAULT_ICON` em `links-ferramentas.js`, mesmo glifo do item "Links & Ferramentas" no menu). O upload viaja como `multipart/form-data` (`FormData`), não JSON — ver a nota sobre `pidApiRequest` acima. Limite de tamanho: **2MB**, checado em dois lugares — `validar_tamanho_icone_link` (validator do campo `icone` em `models.py`, é a checagem que vale de verdade, roda via `LinkFerramentaSerializer.is_valid()`) e uma checagem espelhada em `links-ferramentas.js` (`PID_LINK_ICON_MAX_BYTES`, no `change` do input e de novo antes do POST/PATCH) só para dar feedback sem esperar a resposta do servidor. Ao mudar o limite, atualizar os dois lados (e gerar migração — `validators` no campo entra no `deconstruct()`).
|
||
- Clicar num cartão sempre abre a URL numa aba nova (`target="_blank"`) — são links externos por definição, não faz sentido navegar embutido no portal.
|
||
- Editar um cartão existente (nome, URL e ícone) usa o mesmo modal de "Adicionar Link" (`#lf-add-modal`), reaproveitado em modo edição — o botão de lápis em cada cartão (visível só com `apps["links-ferramentas-editar"]`, ao lado das setas de mover) chama `openModal(link)` pré-preenchendo os campos; salvar despacha `PATCH /api/links-ferramentas/{id}/` (`pidUpdateLink`, multipart igual ao POST) em vez de criar um novo. O campo de ícone fica sempre vazio ao abrir em modo edição (input `type="file"` não aceita valor pré-preenchido por segurança do browser) — não enviar o campo `icone` no PATCH mantém o ícone atual; só enviar substitui.
|
||
- **Favoritos por link** (`LinkFerramentaFavorito`, model dedicado — não confundir com `Favorito`, que marca aplicações inteiras do menu): estrela em cada cartão (`.lf-card__favorite`, visível pra qualquer um com `apps["links-ferramentas-visualizar"]`, independente de editar) via `POST`/`DELETE /api/links-ferramentas-favoritos/{link_id}/` (natural key é o `id` do link, igual ao padrão `app_id`/`notif_id` de `Favorito`/`NotificacaoDispensada`). Só afeta a **ordem de exibição dentro da própria tela** — `links-ferramentas.js` busca `links` e favoritos em paralelo e reordena em memória (`sortFavoritesFirst()`) pra mostrar favoritos primeiro, preservando o `ordem` relativo dentro de cada grupo; o `ordem` compartilhado do `LinkFerramenta` nunca é tocado por favoritar/desfavoritar. Simplificação deliberada: as setas de mover e o drag-and-drop operam sobre esse mesmo array já reordenado (`links`), então se um usuário com `apps["links-ferramentas-editar"]` também tiver favoritos marcados, arrastar um cartão persiste a ordem "favoritos primeiro" que ele está vendo como o novo `ordem` compartilhado — aceitável pro tamanho do app, mas vale lembrar se o comportamento parecer estranho.
|
||
- **Widget "Links Favoritos"** (`links-favoritos` em `PID_WIDGET_TYPES`, `static/js/widgets.js`): lista em `portal.html` só os links favoritados, cada linha com ícone pequeno (`.widget-links-list__icon`, fallback `PID_WIDGET_LINK_DEFAULT_ICON` — cópia local do glifo de `PID_LINK_DEFAULT_ICON`, já que `widgets.css` não carrega `links-ferramentas.css`) + nome, a linha inteira é um `<a target="_blank">` pro mesmo destino do cartão original. Depende de `pidFetchLinks`/`pidFetchLinkFavoritos`, então `links-ferramentas.js` foi incluído em `portal.html` só por causa dessas funções de dados — seu handler de `DOMContentLoaded` retorna cedo lá (`if (!grid) return`, não existe `#lf-grid` em `portal.html`), mesmo padrão de guarda de `profiles.js`/`widgets.js`.
|
||
|
||
## Acessos Gerais
|
||
|
||
Segunda aplicação da seção "Links & Ferramentas" (ver acima) — um cadastro de acessos/logins compartilhados (ex.: "login geral de um site"), organizado em **seções e linhas** (inspirado numa tela do Asana que o usuário mostrou como referência): cada seção agrupa várias linhas, e clicar numa linha abre um popup com os detalhes daquele acesso. Dois models novos, sem relação com `LinkFerramenta`:
|
||
|
||
- `AcessoGeralSecao` (`nome`, `ordem`, `perfis_restritos` M2M pra `PerfilAcesso`, ver abaixo) — só a categoria (ex.: "Banco de Dados/API"). Lista compartilhada, sem FK pra `Usuario`.
|
||
- `AcessoGeral` (`secao` FK, `nome`, `url`, `usuario`, `senha`, `observacoes`, `ordem`) — a linha em si. `senha` é um `CharField` em texto puro (não há criptografia/hash — é um cadastro de referência entre a própria equipe, não um cofre de senhas robusto; se isso precisar mudar no futuro, confirmar com o usuário antes, já que envolve infraestrutura de chave/criptografia nova). `observacoes` guarda **HTML sanitizado** (ver "Observações ricas" abaixo), com um `validators=[validar_tamanho_observacoes_acesso]` (`models.py`) limitando a 2.000.000 caracteres — generoso o bastante pra várias imagens embutidas, mas evita um `TextField` sem limite nenhum crescer sem controle.
|
||
|
||
**Permissão**: mesmo padrão visualizar/editar de Links & Ferramentas, com chaves próprias (`acessos-gerais-visualizar`/`acessos-gerais-editar`, ver "Padrão visualizar/editar" acima) — `AcessoGeralSecaoViewSet`/`AcessoGeralViewSet` (`views.py`) instanciam `PermissaoApp("links-ferramentas", app_key)` com a chave certa por método HTTP. `acessos-gerais.js` gateia o conteúdo da própria página (`#ag-no-access`/`#ag-content`) por `acessos-gerais-visualizar`, mesmo raciocínio do gate de `links-ferramentas.js`.
|
||
|
||
**Restrição de seção por perfil** (`AcessoGeralSecao.perfis_restritos`): além da permissão de módulo, cada seção pode opcionalmente ser restrita a um subconjunto de `PerfilAcesso` — `perfis_restritos` vazio (padrão) = visível a qualquer um com `acessos-gerais-visualizar`; não vazio = só quem também tiver um desses perfis vinculado. Isso é uma restrição de **dado**, independente da árvore de permissões (não precisa mexer em Perfis de Acesso pra configurar) — é escolhida direto no modal "Adicionar Seção"/"Renomear Seção" (`#ag-secao-form-perfis`, um `.checklist-box` com todos os perfis cadastrados, populado via `pidFetchPerfis()`). O filtro é aplicado em dois lugares no backend, ambos em `views.py`:
|
||
- `AcessoGeralSecaoViewSet.get_queryset()` — só devolve seções sem restrição ou com interseção entre `perfis_restritos` e os perfis do usuário logado; `AcessoGeralViewSet.get_queryset()` aplica o mesmo filtro via `secao__perfis_restritos`, pra uma linha nunca vazar de uma seção que o usuário não veria.
|
||
- `AcessoGeralSerializer.__init__` também restringe o próprio campo `secao` (o `PrimaryKeyRelatedField` que valida o POST/PATCH) à mesma queryset filtrada — sem isso, alguém com `acessos-gerais-editar` mas sem o perfil exigido conseguiria criar uma linha dentro de uma seção restrita só sabendo o id dela, mesmo sem enxergá-la em nenhuma listagem.
|
||
|
||
Não há exceção pra `gerencia_permissoes`/perfil de acesso total — mesmo "Integração e Inovação" (código 8) fica de fora de uma seção restrita a outro perfil que não o seu, exatamente como qualquer outro perfil (é uma lista de permissão explícita, não um nível hierárquico).
|
||
|
||
**Ordenação por seção**: `AcessoGeral.ordem` é **escopada por `secao`** (ao contrário de `LinkFerramenta.ordem`, que é global) — `AcessoGeralViewSet.perform_create` calcula `max(ordem)` só entre as linhas da mesma seção. Reordenar (drag-and-drop nativo HTML5, mesma mecânica de `links-ferramentas.js` — `dragstart`/`dragover`/`drop` em `#ag-sections`, delegado num container que tem todas as seções) só é permitido **dentro de uma seção**: `dragover` ignora o alvo se `draggedAcesso.secao !== targetAcesso.secao`, então uma linha nunca muda de seção arrastando. As setas de mover para cima/baixo (`swapOrdem()`) seguem a mesma regra, já que operam sobre `acessosDaSecao(secao.id)`, nunca a lista inteira. Seções em si não têm drag-and-drop — só criar/renomear/excluir; a ordem entre seções é a de criação (`ordem` incrementado pelo servidor, sem UI de reordenar).
|
||
|
||
**Observações ricas (texto + imagens embutidas)**: o campo "Observações" do modal de acesso (`#ag-form-observacoes`) é um `<div contenteditable>`, não um `<textarea>` — permite formatar texto livremente e incluir imagens **sem nenhum botão dedicado**: colar (`Ctrl+V`, evento `paste`, lido de `event.clipboardData.items`) ou arrastar um arquivo de imagem pra dentro do campo (evento `drop`, com `dragover` chamando `preventDefault()` pra permitir o drop) — as duas vias caem na mesma função `insertImageFile()` em `acessos-gerais.js`. A imagem (até **2MB**, `PID_AG_IMAGE_MAX_BYTES`, checado antes de inserir) vira uma data URI via `FileReader.readAsDataURL` e é inserida com `document.execCommand("insertImage", ...)` — sem upload de arquivo separado, fica embutida no próprio HTML salvo em `observacoes`. No caminho de `paste` o cursor já está na posição certa (o navegador só troca o clipboard, não move o foco); no de `drop`, `placeCaretAtPoint()` usa `document.caretRangeFromPoint`/`caretPositionFromPoint` (conforme suporte do browser) pra posicionar o cursor exatamente onde o arquivo foi solto antes de inserir.
|
||
|
||
- **Sanitização (`nh3`)**: como esse HTML é gerado por quem tem `acessos-gerais-editar` mas renderizado via `innerHTML` pra qualquer um com `acessos-gerais-visualizar`, ele passa por um allowlist estrito no backend antes de salvar — `AcessoGeralSerializer.validate_observacoes()` roda `nh3.clean()` permitindo apenas tags de texto básicas + `<img>` (`RICHTEXT_ALLOWED_TAGS`/`_ATTRS`/`_SCHEMES` no topo de `serializers.py` — generalizadas nessas constantes desde que o texto de "Mais informações" de uma aplicação passou a reaproveitar o mesmo allowlist, ver "Ajuda de aplicação" abaixo) — **sem `<a>`/`<script>`/atributos de evento** (`onerror` etc. são descartados por não estarem na allowlist de atributos). `url_schemes` inclui `"data"` de propósito, já que as imagens embutidas são `data:image/...;base64,...`, não URLs externas. Isso significa que o campo é reprocessado no servidor mesmo que o cliente já não deixe inserir nada além de texto/imagem pela UI — defesa em profundidade contra alguém montando o payload na mão. `nh3` é o binding Python da lib Rust "ammonia" (`requirements.txt`) — foi escolhido no lugar do `bleach` (usado numa primeira versão desta funcionalidade) porque o `bleach` está oficialmente sem manutenção desde 2023 e parou de receber releases (inclusive de segurança) em 2026; `nh3` tem API quase idêntica (`clean(html, tags=set[...], attributes=dict[...], url_schemes=set[...])`, allowlist do mesmo jeito) e é o substituto recomendado pelos próprios mantenedores do bleach.
|
||
- Ao carregar um acesso existente pra editar, `formObservacoes.innerHTML = acesso.observacoes` repopula o editor com o HTML já sanitizado (imagens inclusas); salvar lê `formObservacoes.innerHTML` (função `observacoesValue()`, que retorna string vazia se não houver nem texto nem `<img>`, evitando salvar lixo tipo um `<br>` solto de um editor "vazio").
|
||
|
||
**Popup de detalhes** (`#ag-view-modal`, `acessos-gerais.js`): mostra nome, URL (link clicável), usuário, senha e observações — cada campo (`.ag-view-field`) só aparece se tiver valor (`hidden` quando vazio). A senha começa mascarada (`"••••••••"`, com o valor real guardado em `viewSenha.dataset.value`) e um botão de olho alterna pra o valor real — a máscara é feita trocando o próprio `textContent`, não com CSS (`-webkit-text-security` não é suportado em todos os browsers e deixaria a senha real exposta no DOM seletável mesmo "mascarada" visualmente nesses casos). As observações são renderizadas via `innerHTML` (não `textContent`, ao contrário dos outros campos) já que podem conter as imagens embutidas — seguro porque o HTML já veio sanitizado do backend; o container é uma `<div class="ag-view-observacoes">` (não `<p>`, que não pode conter `<img>`/`<div>` sem gerar HTML inválido). Com `acessos-gerais-editar`, o popup também mostra "Editar"/"Excluir"; "Editar" fecha o popup e abre o mesmo modal de formulário (`#ag-form-modal`) usado por "Adicionar Acesso", pré-preenchido.
|
||
|
||
Nenhuma tela recalcula união de departamentos/liderança aqui — é uma aplicação isolada, sem relação com `Usuario` além da permissão de quem pode ver/editar (e, agora, do `perfis_restritos` por seção).
|
||
|
||
## Ramais
|
||
|
||
O diretório de `ramais.html` é **automático**: `RamalViewSet.list()` (não o `RamalSerializer` — esse serializer só cobre as linhas avulsas via CRUD normal) mescla, a cada `GET /api/ramais/`, duas fontes numa lista só, ordenada por nome:
|
||
|
||
1. Todo `Usuario` ativo — a linha é montada direto do cadastro (`nome`, `Usuario.departamentos` juntados por vírgula, `Usuario.ramal`); se o colaborador ainda não tem ramal preenchido, `numero_exibicao` vem vazio e o frontend mostra um badge "Pendente" em vez de reclamar de campo faltando.
|
||
2. As linhas avulsas de `Ramal` (sem `Usuario` por trás — telefone de sala, recepção etc.), cadastradas pelo modal "Adicionar Ramal".
|
||
|
||
Cada item da lista mesclada tem um `id` sintético (`"usuario-<id>"` ou `"avulso-<id>"`) e um campo `tipo` (`"usuario"`/`"avulso"`) que o frontend usa pra decidir qual endpoint chamar ao editar/excluir — não existe mais um model unificando os dois casos com uma FK opcional (essa foi a primeira versão da tela; revertida a pedido do usuário pra eliminar o passo manual de "adicionar" alguém que já tem cadastro).
|
||
|
||
Segue o mesmo padrão visualizar/editar de Links & Ferramentas (ver acima): leitura exige `apps.visualizar` (liberado a todo perfil, já que `ramais` está em `BASE_KEYS`), escrita exige `apps.editar` — por ora só `True` pra "Integração e Inovação" no `seed_portal.py`, exatamente como pedido; liberar outro perfil não pede código novo, só marcar o app na árvore de Perfis de Acesso.
|
||
|
||
- **Editar o ramal de um colaborador de verdade**: não existe "criar" — a linha já aparece sozinha. O lápis na linha abre o mesmo modal de Ramal, mas com Nome/Departamento desabilitados (só leitura do cadastro) e só o campo Ramal editável; salvar chama `PATCH /api/ramais/usuarios/{usuario_id}/` (`RamalViewSet.atualizar_ramal_usuario`), que grava direto em `Usuario.ramal` — é assim que a tela demonstra a alteração refletindo no cadastro do usuário.
|
||
- **Linha avulsa**: "Adicionar Ramal" sempre cria uma linha avulsa (`POST /api/ramais/`, `nome`/`departamento`/`numero` livres — só `nome` é obrigatório, o resto pode ficar pendente igual a um colaborador de verdade). Editar/excluir uma linha avulsa usa `PATCH`/`DELETE /api/ramais/{avulso_id}/` normalmente; excluir só existe pra esse tipo (não dá pra "excluir" um colaborador daqui — isso é na tela de Usuários).
|
||
- **Lista de usuários do modal de Ausência**: `RamalViewSet.usuarios_disponiveis` (`GET /api/ramais/usuarios/`) devolve só `id`/`nome` de usuários ativos, pra alimentar o `<select>` "Lista de Usuários" do modal "Criar Ausência" (o único modal que ainda precisa escolher uma pessoa numa lista — o modal de Ramal não precisa mais, já que a linha do colaborador já existe). Não reaproveita `/api/usuarios/` de propósito — aquele endpoint é restrito a `gerencia_permissoes`, e a permissão de Ramais é deliberadamente desacoplada disso (hoje dá na mesma pessoa, mas não presume que sempre será assim).
|
||
- **Ausência** (`RamalAusencia`): um registro por período criado pelo modal "Criar Ausência"; "ausente agora" nunca é armazenado — `RamalAusencia.esta_ativa()` compara a hora atual (`timezone.localtime()`) contra `[data_inicio+hora_inicio, data_fim+hora_volta]` (hora ausente = considera o dia inteiro) toda vez que `RamalViewSet.list()` monta a resposta. Clicar no badge "(AUSENTE)" de uma linha (`data-ram-ver-ausencia`, qualquer um com `apps.visualizar` pode abrir) faz `GET /api/ramais-ausencias/{id}/` e abre o modal "Visualizar Ausência" — campos desabilitados (`<input type="date"/"time">` mostra a data/hora formatada mesmo `disabled`, sem precisar formatar manualmente), com "Editar Ausência"/"Deletar Ausência" visíveis só pra quem tem `apps.editar`. "Editar" habilita os mesmos campos in-place (sem modal novo) e troca os botões por "Cancelar"/"Salvar Ausência" (`PATCH /api/ramais-ausencias/{id}/`); "Deletar" remove o registro (`DELETE`) — não existe mais um botão de "encerrar antes do previsto" separado (a rodada anterior tinha isso via `encerrada_manualmente`; substituído por editar/excluir de verdade, mais simples e é o que foi pedido). O campo `encerrada_manualmente` continua no model (histórico/uso futuro via admin), só não tem mais UI própria.
|
||
- **Aniversariante**: comparação de `Usuario.data_aniversario` (mês/dia) com `timezone.localdate()`, feita no mesmo `list()` — mesmo campo que já existia no cadastro de Usuários, sem nada novo ali.
|
||
- **Selos de ausente/aniversariante**: `.ram-badge--ausente`/`.ram-badge--aniversario` (`ramais.css`) são selos (pill) com cor de texto/fundo ajustada por tema via `:root[data-theme="light"] .ram-badge--*` — não reaproveitam `--danger`/`--gold` crus porque esses tokens não foram pensados pra texto pequeno sobre um selo (contraste insuficiente). A linha inteira também é tingida (`.ram-row--ausente`/`.ram-row--aniversario` td, aplicado via classe no `<tr>` em `ramais.js`) com a mesma cor do selo, também ajustada por tema — pedido explícito do usuário pra facilitar notar a linha antes mesmo de ler o selo (a versão anterior sem tingimento de linha foi revertida).
|
||
- **Férias**: a aba existe (navegação por abas, ver abaixo) mas está **vazia de propósito** — o conteúdo foi adiado pra uma rodada futura; a limitação original ("depende de integração futura com outro banco") continua valendo, só a decisão de já reservar o espaço na navegação é nova.
|
||
- **Novo Chamado**: botão que abre um modal com um `<iframe>` apontando para a ferramenta externa de chamados (`https://depaula-tvcorporativa.lovable.app/chamar?token=...`) — decisão explícita de ficar embutido na própria tela em vez de nova aba (diferente do padrão dos demais links externos do portal). O `src` do iframe só é setado na abertura do modal e volta pra `about:blank` ao fechar, pra não deixar a ferramenta carregada em segundo plano.
|
||
- **Sem reordenação**: ao contrário de Links & Ferramentas/Widgets, a listagem é sempre alfabética (`sort()` em `list()`), sem `ordem`/drag-and-drop.
|
||
- Usuário inativo (`is_active=False`) não aparece mais no diretório (o `list()` filtra `Usuario.objects.filter(is_active=True)`) — diferença deliberada da primeira versão, que ainda mostrava inativos se tivessem uma linha vinculada.
|
||
|
||
### Navegação por abas em `ramais.html` (subtelas)
|
||
|
||
`ramais.html` deixou de ser uma tela única — é uma seção com 5 subtelas, navegáveis por abas logo abaixo do cabeçalho: **Ramais** (diretório descrito acima, ativa por padrão), **Responsável no Tareffa** (placeholder vazio), **Telefones Externos**, **Férias** (placeholder vazio) e **Funções de Telefonia**. As abas reaproveitam o CSS genérico `.pa-tabs`/`.pa-tab`/`.pa-tab-panel` (`perfis-acesso.css`, já carregado nesta página desde antes — mesmo padrão usado nas abas Permissões/Usuários de `perfis-acesso.html`), mas com atributos próprios (`data-ram-tab`/`data-ram-tab-panel`) e uma implementação independente em `ramais.js` (`activeRamTab`/`renderRamTabs()`), pra não colidir com `profiles.js`. Os botões "Adicionar Ramal"/"Novo Chamado"/"Criar Ausência" continuam só dentro do painel "Ramais" — cada subtela tem suas próprias ações.
|
||
|
||
**Permissão — uma dupla visualizar/(editar) por subtela**: cada uma das 5 abas tem sua própria permissão de visualização, e as 3 com conteúdo administrável (Ramais, Telefones Externos, Funções de Telefonia) também têm sua própria permissão de edição — não é mais um único par genérico `ramais.apps.visualizar`/`ramais.apps.editar` cobrindo tudo (esse desenho, usado na primeira versão da navegação por abas, foi revisto no mesmo dia a pedido do usuário: "deve haver permissão de visualização para cada um dos itens e edição para as de ramais, telefone externos e funções de telefonia"). Em `catalogo.MODULE_APPS["ramais"]`, isso é modelado como **5 subgrupos** (mesmo formato `{"key", "label", "tools": [...]}` já usado em Auditorias — reaproveita 100% a árvore de permissões genérica de `profiles.js`, sem UI nova):
|
||
|
||
```python
|
||
"ramais": [
|
||
{"key": "ramais-diretorio", "label": "Ramais", "tools": [
|
||
{"key": "ramais-visualizar", "label": "Visualizar"},
|
||
{"key": "ramais-editar", "label": "Editar (...)"},
|
||
]},
|
||
{"key": "responsavel-tareffa", "label": "Responsável no Tareffa", "tools": [
|
||
{"key": "responsavel-tareffa-visualizar", "label": "Visualizar"},
|
||
]},
|
||
{"key": "telefones-externos", "label": "Telefones Externos", "tools": [...]},
|
||
{"key": "ferias", "label": "Férias", "tools": [{"key": "ferias-visualizar", ...}]},
|
||
{"key": "funcoes-telefonia", "label": "Funções de Telefonia", "tools": [...]},
|
||
],
|
||
```
|
||
|
||
Cada `ModelViewSet` (`RamalViewSet`/`RamalAusenciaViewSet`, `TelefoneExternoViewSet`, `FuncaoTelefoniaViewSet`) instancia `PermissaoApp("ramais", app_key)` com a chave da própria subtela (ex.: `"telefones-externos-visualizar"`/`"telefones-externos-editar"`) — `RamalAusenciaViewSet` usa as mesmas chaves `ramais-visualizar`/`ramais-editar` do diretório de Ramais, já que ausência é parte dessa subtela, não uma quinta. No frontend, `ramais.js` calcula um `canView`/`canManage` por subtela a partir de `me.permissoes_efetivas.ramais.apps[chave]`, esconde (`hidden`) o botão de cada aba cujo `visualizar` for falso, e escolhe a primeira aba visível como ativa por padrão (em vez de sempre abrir em "Ramais", que pode estar oculta pra esse perfil). `ramais-lookup.js` (modal de consulta rápida no topbar) usa especificamente `ramais-visualizar`, já que só mostra o diretório de Ramais, não as outras subtelas.
|
||
|
||
**Cuidado com `seed_portal.py`** (mesmo princípio da nota geral em "Padrão visualizar/editar" acima): como `ramais` está em `BASE_KEYS`, `permissions_from_keys()` habilitaria os 8 apps (visualizar de todas as 5 + editar das 3) de uma vez — sem o override, todo perfil nasceria podendo editar. Por isso `seed_portal.py` força `ramais-editar`/`telefones-externos-editar`/`funcoes-telefonia-editar` para `False` explicitamente em todo perfil que não seja "Integração e Inovação", depois de montar o dict — os `*-visualizar` ficam `True` pra todo mundo de propósito ("os demais terão acesso para visualizar todas"). Qualquer mudança de nome/adição de subtela nesse padrão precisa replicar esse mesmo cuidado.
|
||
|
||
Um perfil só-visualizar vê as 5 abas e as tabelas, mas nunca os botões de Adicionar/editar/excluir em nenhuma delas; um perfil sem `visualizar` numa subtela específica não vê nem a aba dela.
|
||
|
||
**Telefones Externos** (`TelefoneExterno`, model dedicado sem FK — contatos de fornecedores/terceiros, não de `Usuario`): CRUD simples via `/api/telefones-externos/`, só `nome` obrigatório (`ramal`/`telefone`/`observacoes` opcionais, mesmo padrão de `Ramal` avulso). Dois filtros de busca (`ram-tel-search-nome`/`ram-tel-search-obs`, client-side sobre o array já carregado) — por nome e por observações, ao mesmo tempo, sem OR/AND configurável. A tabela começa vazia (nenhum seed) — o usuário cadastra pela própria tela.
|
||
|
||
**Funções de Telefonia** (`FuncaoTelefonia`) — comandos padrão da central telefônica (ex.: `*01 + Código de Agente` → LogOn). CRUD via `/api/funcoes-telefonia/`, só `comando` obrigatório. `Meta.ordering = ["comando"]` reproduz sozinho a ordem esperada (`*0, *01, ..., *5, *503, *8`) porque os códigos já nascem em ordem lexicográfica como string — não precisou de um campo `ordem` manual nem de endpoint de reorder, ao contrário de `LinkFerramenta`/`Favorito`/`WidgetUsuario`. Ao contrário de Telefones Externos, esta tabela **é seedada**: `seed_portal.py` popula as 13 linhas padrão (`FUNCOES_TELEFONIA_SEED`, `update_or_create` por `comando`) porque é documentação genérica de central telefônica, não dado específico da empresa — reexecutar `seed_portal` é seguro/idempotente, não duplica nem apaga linhas editadas manualmente (só atualiza `funcao`/`resumo` de um `comando` que já exista).
|
||
|
||
Nenhuma das duas subtelas tem endpoint de reorder — só criar/editar/excluir, mesmo escopo pedido.
|
||
|
||
### Modal de consulta rápida ("Ramais")
|
||
|
||
O botão "Ramais" do topbar (`#ramais-btn`, presente em `portal.html`/`links-ferramentas.html`/`calendario-individual.html` — as únicas 3 páginas que têm esse atalho; texto era "Acessar Ramais", encurtado depois) **não navega** para `ramais.html`; abre um modal somente-leitura (`ramais-lookup.js`/`ramais-lookup.css`) com a mesma listagem mesclada de `GET /api/ramais/`, inspirado numa tela do portal antigo (estilo DataTables: "Mostrar N registros", busca, colunas ordenáveis, paginação). Diferenças pro comportamento antigo do botão:
|
||
|
||
- Gate de acesso: some (`hidden`) se `permissoes_efetivas.ramais.apps["ramais-visualizar"]` for falso — mesmo padrão de qualquer UI gated por permissão no app.
|
||
- Busca é **uma só caixa** (não uma por coluna) que filtra por nome, departamento ou ramal ao mesmo tempo — mais simples que a paginação em duas caixas da própria `ramais.html`.
|
||
- **Botões de filtro por departamento** (`.ram-lookup-depto-filters`, acima da tabela): "Todos" + um botão por `Departamento` cadastrado, buscados de `GET /api/departamentos-resumo/` na primeira abertura (endpoint dedicado, `IsAuthenticated` + checagem manual de `permissao_app("ramais", "ramais-visualizar")` — não reaproveita `/api/departamentos/`, que exige `gerencia_permissoes` e bloquearia a maioria dos usuários que só têm acesso ao próprio Ramais). Clicar num botão filtra a listagem pra quem tem aquele departamento entre os seus (`departamento_exibicao.split(",")`, comparação exata após `trim` — não substring, pra não casar um departamento que seja prefixo de outro) e combina com a busca por texto (as duas condições precisam bater). Como os botões são gerados a partir da lista de departamentos vinda da API a cada abertura do modal, cadastrar um departamento novo em Usuários já basta pra ele aparecer aqui — não precisa mexer no frontend.
|
||
- Ordenação por coluna (clicar no cabeçalho alterna asc/desc) e paginação (`10`/`25`/`50`/`100` por página) são só client-side, sobre o array já carregado — sem endpoint novo, sem parâmetro de query; os `/api/ramais/`/`/api/departamentos-resumo/` são buscados uma única vez por abertura de página (cacheados em memória enquanto a página não recarrega) e refiltrados/reordenados em JS a cada tecla/clique.
|
||
- Botão "Ir para Controle de Ramais" no rodapé é o link de verdade pra `ramais.html` (tela completa, com edição) — o modal em si não tem nenhum controle de escrita, é só consulta.
|
||
|
||
## Solicitações
|
||
|
||
Os 6 tópicos do menu "Solicitações" (`catalogo.MODULE_APPS["solicitacoes"]`) não são telas próprias — cada um (exceto "Ordem de Serviço", que ainda não tem link definido e continua com `href="#"`, mesmo padrão de qualquer aplicação-placeholder do portal) é só um link externo (hoje, um formulário do Asana) aberto em **nova aba** (`target="_blank" rel="noopener noreferrer"`), igual ao padrão já usado nos cartões de Links & Ferramentas.
|
||
|
||
**Por que não embutido em iframe**: a primeira versão desta seção tentava centralizar os 5 links num popup com `<iframe>` (numa página dedicada `solicitacoes.html`), inspirado no "Novo Chamado" de Ramais. Revertido no mesmo dia: o Asana bloqueia ser carregado em iframe de outro domínio via `X-Frame-Options`/`Content-Security-Policy: frame-ancestors` (proteção padrão contra clickjacking), então o navegador recusa a conexão (`net::ERR_BLOCKED_BY_RESPONSE`/"A conexão com form.asana.com foi recusada"). Isso não tem workaround no frontend — não confundir com o iframe de "Novo Chamado" em Ramais ou o de "Calendário De Paula" (ver nota logo após a tabela de páginas, em "Páginas" acima), que funcionam porque aquela outra ferramenta (`depaula-tvcorporativa.lovable.app`) não bloqueia embed. Não reintroduzir esse padrão de iframe pra Solicitações sem confirmar com o usuário que o destino realmente permite ser embutido.
|
||
|
||
## Simulação de Custo de Contratação (Geradoc)
|
||
|
||
Ferramenta que substitui a planilha manual de custo de contratação (`projects/planilha de custo/*.xlsx`) por um formulário no Portal — calcula o custo de contratar um Empregado CLT e devolve um PDF pronto pra enviar ao cliente. Permissão de **toggle único** (`apps["simulacao-custo-contratacao"]` em `permissoes["geradoc"]`, sem par visualizar/editar), checada manualmente (`request.user.permissao_app("geradoc", "simulacao-custo-contratacao")`) nas duas views (não são `ModelViewSet` — são funções simples, `POST /api/simulacao-custo-contratacao/gerar/` e `GET`/`PATCH /api/parametros-fiscais-custo-contratacao/`).
|
||
|
||
- **Escopo v1: só Empregado CLT.** O pedido original mencionava 5 modalidades (Simples Nacional, Regime Normal, Pró-labore, Empregado, Empregado Doméstico), mas só havia planilha de referência validada pra Empregado CLT — as outras 4 ficam para quando houver uma fonte de regras equivalente confirmada pelo contador; não implementar "seguindo o mesmo padrão" por conta própria.
|
||
- **Sem persistência**: `POST /api/simulacao-custo-contratacao/gerar/` é um cálculo pontual — recebe os dados do formulário, calcula (`custo_contratacao.calculo.calcula_custo_empregado`) e devolve o PDF direto (`HttpResponse` binário, `Content-Disposition: inline`), nada é salvo no banco. Diferente do padrão "com histórico" de `ImportacaoPlanoSaude`/`IndicadorApuracao`.
|
||
- **Tabelas de INSS/IRRF editáveis pelo banco**: `ParametroFiscalCustoContratacao` (`models.py`) é um singleton (`atual()`, sempre `pk=1`, criado sob demanda via `get_or_create`) com `faixas_inss`/`faixas_irrf` em `JSONField` (lista de `{limite_superior, aliquota, deduzir}`) + escalares (teto de desconto de INSS, alíquota/dedução do IRRF acima da última faixa, desconto simplificado do IRRF, dedução por dependente, e os 3 parâmetros da redução da Lei 15.270/2025 — coeficientes A/B e limite de rendimento bruto), editáveis pelo painel colapsável da própria tela (`GET`/`PATCH /api/parametros-fiscais-custo-contratacao/`, mesma permissão de quem usa a simulação). `custo_contratacao/tabelas.py` continua existindo só como **seed/default** da primeira criação da linha (`_faixas_inss_padrao`/`_faixas_irrf_padrao` em `models.py`) — `calculo.py` nunca lê `tabelas.py` direto, sempre recebe um `ParametrosFiscais` (dataclass pura, sem ORM) montado por `ParametroFiscalCustoContratacao.para_calculo()`.
|
||
- **Redução de IRRF da Lei nº 15.270/2025** (art. 3º-A da Lei 9.250/1995, vigente desde jan/2026): isenção total até R$5.000 de rendimento bruto mensal, redução decrescente até zerar em R$7.350 — `redução = max(0, coeficiente_a − coeficiente_b × rendimento bruto)`, aplicada por cima do imposto já calculado pela tabela progressiva tradicional (que a lei não alterou), nunca deixando o imposto final negativo.
|
||
- **Correção deliberada em relação à planilha original**: a planilha nunca somava a dedução por dependente (R$189,59/dependente) à base do IRRF quando usava o desconto real de INSS — só quando usava o desconto simplificado (que por lei substitui os dois). Confirmado como gap com o usuário e corrigido: ao usar o desconto real de INSS, a dedução por dependente também é subtraída agora (`custo_contratacao/calculo.py`).
|
||
- **PDF via `reportlab`** (pure-Python, sem dependência nativa problemática no Windows) — cabeçalho é um banner marrom escuro com `logo-branco.png` + "De Paula Contadores", nas cores reais da marca (dourado `#D3AF4D`, marrom `#4A3C28`, amostradas do próprio `logo.png`), não o roxo do tema de interface do Portal.
|
||
- Localização no menu (dentro de **Geradoc**, ao lado de "Gerar Contrato"/"Gerar Procuração") foi escolha explícita do usuário, não Utilitários.
|
||
|
||
## Indicador de Desempenho (Geradoc)
|
||
|
||
Segunda ferramenta de Geradoc — substitui a apuração manual do indicador de desempenho do Fiscontábil (departamentos Contábil/Fiscal), antes feita numa planilha (`FISCO CONTABIL *.ods`, em `projects/Indicadores/`) com fórmulas quebradas por edições manuais acumuladas. Mesmo padrão de permissão de **toggle único** de Simulação de Custo de Contratação (`apps["indicador-desempenho"]` em `permissoes["geradoc"]`, checado por `PermissaoApp("geradoc", "indicador-desempenho")` em todos os `ModelViewSet` relacionados). Pacote de negócio em `portal_api/indicadores/` (sem ORM): `tipos.py`, `leiaute.py`, `pipeline.py`, `entregas.py`, `calculo.py`, `recibo.py`, `departamentos.py`.
|
||
|
||
- **Escopo v1: só o Fiscontábil**, papéis Balancete/Liberação Fiscal/Conciliação Financeira. Outros departamentos ficam pra rodada futura — **exceto pela estrutura de cadastro em si** (ver "Departamento organizacional" abaixo), que já suporta múltiplos departamentos com critérios/percentuais próprios, mesmo que só o Fisco/Contábil tenha regras cadastradas até agora.
|
||
- **8 models** (migrações `0024`–`0028`, `0033`–`0035`): `IndicadorDepartamento` (cadastro de departamentos — nome/ativo — usado pra escopar critérios, percentuais e metas de Departamento; ver "Departamento organizacional" abaixo), `IndicadorDepartamentoGerente` (relação gerente→departamento, mantida manualmente pela aplicação), `IndicadorPercentualTipo` (percentuais individual/grupo/departamento por tipo de colaborador **e por departamento**, histórico via `vigente_desde` — nunca editado in-place), `IndicadorCriterio` (cadastro genérico de critério: **departamento**/nome/grupo/peso/período/papel/`calculo_automatico`/`limiar_percentual`), `IndicadorApuracao` (uma apuração mensal — `competencia`, `status` `revisao`/`concluida`, as 2 planilhas anexadas, `avisos` de processamento), `IndicadorApuracaoColaborador` (um colaborador dentro de uma apuração, com `pct_individual`/`pct_grupo`/`pct_departamento` e respectivos flags `*_ajustado_manualmente`, mais `departamento` — FK pra `IndicadorDepartamento`, resolvida na criação via o gerente do colaborador, ver "Departamento organizacional" abaixo), `IndicadorApuracaoEmpresa` (uma empresa/honorário do colaborador naquele mês) e `IndicadorApuracaoResposta` (SIM/NÃO/NÃO FAZ/NÃO SE APLICA de um colaborador para um critério).
|
||
- **Tipo do colaborador é derivado por empresa, não é cadastro**: `TIPO_COLABORADOR_INDICADOR_CHOICES` (Contábil+Fiscal/Contador SC/Contador CC/Fiscal/Conciliador) — regra em `indicadores/tipos.py`, validada contra um recibo-modelo real (~99,99% de precisão no teste com 43 colaboradores).
|
||
- **3 critérios são calculados automaticamente** a partir da planilha "Serviços Tareffa" (`indicadores/entregas.py`/`pipeline.py`) — entrega de balancetes/liberações fiscais/conciliações no prazo, comparadas contra `IndicadorCriterio.limiar_percentual` pra decidir SIM/NÃO. Todo o resto é sempre marcação manual do RH (SIM/NÃO/NÃO FAZ/NÃO SE APLICA por critério, individual ou em lote). Um critério automático sem nenhum registro do serviço vira **NÃO SE APLICA**, não NÃO FAZ — permite deixar `papel_aplicavel` em branco nesses 3 critérios (um mesmo critério, ex. "balancete", se aplica a 3 tipos ao mesmo tempo, e `papel_aplicavel` só aceita um valor); quem não presta aquele serviço fica de fora do cálculo por conta própria (NÃO SE APLICA é excluído do denominador em `calculo.py`).
|
||
- **Fórmula**: `honorario_ajustado = honorario_empresa × pct_individual_do_colaborador`; `valor_individual = honorario_ajustado × percentual_individual(tipo)`; `valor_grupo`/`valor_departamento = valor_individual × percentual_grupo/departamento(tipo) × pct_grupo/departamento_do_colaborador`. `pct_individual` **não é só a média dos critérios Individual** — é a composição ponderada dos 3 níveis (Individual/Grupo/Departamento), cada um pesando conforme o peso médio dos seus próprios critérios aplicáveis na competência (`calculo._combina_niveis`/`_peso_medio_nivel`); só `pct_grupo`/`pct_departamento` continuam sendo a média simples dos próprios critérios, sem composição.
|
||
- **"Cada gerente representa um grupo", cada departamento representa um departamento** (não é redundante, ver abaixo): `pct_grupo` é conceitualmente compartilhado por todos os colaboradores com o mesmo `gerente` dentro da apuração, e `pct_departamento` é compartilhado por todos os colaboradores do mesmo `IndicadorDepartamento` (ver "Departamento organizacional" abaixo — não mais um valor único pra toda a apuração) — por isso não são ajustados colaborador a colaborador (`IndicadorApuracaoColaboradorViewSet` só cobre `pct_individual`); `IndicadorApuracaoViewSet.ajustar_grupo`/`recalcular_grupo`/`ajustar_departamento`/`recalcular_departamento` aplicam a mudança de uma vez a todo o grupo/departamento, mantendo os colaboradores em sincronia (`ajustar_departamento`/`recalcular_departamento` recebem `departamento` — o id do `IndicadorDepartamento` — no corpo, filtrando com um `.filter(departamento_id=...)` direto). A tela (`indicador-desempenho.js`) reflete isso com uma tabela de "Metas de Grupo e Departamento" no topo (uma linha de Departamento por `IndicadorDepartamento` + uma linha de Grupo por gerente dentro dele) separada da lista de colaboradores abaixo (que serve só pra revisão individual — percentual Individual, respostas de critério, recibo); botões de filtro por departamento (`#ind-filtro-departamento`, ver "Departamento organizacional" abaixo) restringem a tabela de Metas e a lista de colaboradores a um departamento de cada vez, sem afetar o cálculo de nenhuma meta. Colaborador cujo gerente não está mapeado a nenhum departamento cai num grupo "Sem departamento definido" (sem `<select>` de meta — não há `IndicadorDepartamento` pra aplicar). **A meta de Grupo/Departamento é sempre Sim/Não (100%/0%)**, nunca um percentual livre — decisão explícita do usuário ("será pago ou não") — por isso a coluna "Meta (%)" dessa tabela é um `<select class="ind-meta-select">` com só essas duas opções (`ehSim = valor >= 50` decide qual aparece selecionada ao carregar), não um campo de texto livre; o valor enviado a `ajustar-grupo`/`ajustar-departamento` é sempre `"100"` ou `"0"`. O percentual Individual de cada colaborador continua livre (é uma composição ponderada dos 3 níveis, pode legitimamente ser fracionário — ver acima).
|
||
- **Departamento organizacional** (`IndicadorDepartamento`/`IndicadorDepartamentoGerente`/`portal_api.indicadores.departamentos`) — substituiu, numa rodada posterior, o mecanismo de "setor" (coluna bruta "departamento" da planilha Tareffa + fusão automática Contabilidade/Fiscal→Fisco-Contábil + `IndicadorSetorApelido`, cadastro-exceção por colaborador). Agora **critérios e percentuais também são configurados por departamento** (não só as metas de Grupo/Departamento) — decisão explícita do usuário: a regra do Fisco/Contábil pode ser diferente da regra do Condomínio, por exemplo. `IndicadorDepartamento` (nome/ativo) é um cadastro simples, mantido pela própria aplicação (Configurações → Departamentos); a relação com gerentes (`IndicadorDepartamentoGerente`, `nome_gerente` único — um gerente pertence a só um departamento, mas um departamento pode ter vários gerentes, ex.: Fisco/Contábil tem "João Candido Rodrigues" **e** "Lhais Vergilio Delavy") também é mantida manualmente por ora — alimentar isso automaticamente a partir da planilha fica pra uma rodada futura (decisão explícita do usuário). Pra não obrigar o RH a redigitar nomes (arriscando um typo que faria uma apuração futura não casar com o departamento certo), o popup "Gerenciar Gerentes" (`indicador-desempenho.js`) mostra uma lista de **sugestões clicáveis** — `carregarGerentesSugeridos()` busca a apuração mais recente (`GET /api/indicadores-apuracoes/`, já ordenada por `-competencia`/`-criado_em`) e lista os nomes distintos de `colaborador.gerente` que ainda não estão em nenhum `IndicadorDepartamentoGerente`; clicar numa sugestão já cria a relação pra aquele departamento. É só um atalho de UI (não muda a origem do dado) — o campo de texto livre continua disponível pra gerentes que não apareceram na última apuração.
|
||
|
||
Resolução do departamento de um colaborador: `IndicadorApuracaoViewSet.create()` monta `mapa_gerentes` (`departamentos.carrega_mapa_gerentes()`, `{nome_gerente: departamento_id}`) uma vez e passa pro `pipeline.processa_apuracao()`, que resolve `departamento_id = mapa_gerentes.get(colaborador.gerente)` pra cada colaborador **antes** de decidir quais critérios automáticos calcular pra ele (críticos automáticos também são agrupados por `departamento_id` — `criterios_automaticos_por_departamento`, já que departamentos diferentes podem ter critérios/limiares diferentes). O resultado (`IndicadorApuracaoColaborador.departamento`, FK nullable) é um **retrato daquele momento** — mesmo espírito de `gerente`/`setor` antes dele: se a relação gerente→departamento mudar depois, apurações já criadas não mudam sozinhas. Colaborador cujo gerente não está mapeado a nenhum departamento fica com `departamento=None` e vira um aviso no processamento ("Gerente X não está associado a nenhum departamento..."), além de não ganhar nenhuma `IndicadorApuracaoResposta` (sem departamento, não há de onde vir nenhum critério). `IndicadorApuracaoColaboradorSerializer` expõe `departamento` (id) + `departamento_nome` (com fallback `None`, mesmo padrão de `criado_por_nome`).
|
||
|
||
**Migração em 3 passos** (mesmo padrão de qualquer FK obrigatória adicionada a uma tabela já populada): `0033` cria os 2 models novos + adiciona `departamento` nullable em `IndicadorCriterio`/`IndicadorPercentualTipo`/`IndicadorApuracaoColaborador` (e remove `setor`/`IndicadorSetorApelido`); `0034` (`RunPython`) cria o departamento "Fisco/Contábil" e aponta todo `IndicadorCriterio`/`IndicadorPercentualTipo` já existente pra ele (é literalmente o que a regra única representava até então); `0035` torna `departamento` obrigatório em `IndicadorCriterio`/`IndicadorPercentualTipo` (não em `IndicadorApuracaoColaborador`, que continua nullable). **Apurações criadas antes desta migração** (e qualquer apuração nova, até o admin mapear os gerentes relevantes em Configurações → Departamentos) ficam com `departamento` em branco em todos os colaboradores — precisam de um backfill pontual ou de serem reprocessadas depois que a relação gerente→departamento existir.
|
||
|
||
**Limitação conhecida, validada com dados reais**: como a resolução é por `gerente` (não por colaborador), dois subordinados diretos do mesmo gerente sempre caem no mesmo departamento — isso quebra o caso de uma gerente que supervisiona pessoas de **departamentos diferentes**. Ex. real: "Elizangela de Paula Kuhn" supervisiona diretamente os líderes de Fisco/Contábil (João Candido Rodrigues, Lhais Vergilio Delavy, Paloma Ramão, Elizangela dos Santos — departamento "Gerentes") **e** Luciane Gonzaga (que deveria cair em "Rocket", já que ela chefia esse outro departamento) — como todos compartilham o mesmo `gerente`, mapear "Elizangela de Paula Kuhn" → "Gerentes" também classifica Luciane Gonzaga como "Gerentes", não "Rocket". Não existe mais um mecanismo de exceção por colaborador individual (o antigo `IndicadorSetorApelido` cobria exatamente esse tipo de caso) — se isso for um problema real, precisa ser resolvido numa rodada futura (ex.: reintroduzindo uma exceção por nome de colaborador, por cima da relação gerente→departamento).
|
||
- **Detalhamento da composição no card do colaborador** (`portal_api.indicadores.calculo.composicao_individual`, exposto como o campo `composicao_individual` de `IndicadorApuracaoColaboradorSerializer`): reconstrói, só pra exibição, o percentual bruto de Individual (antes da composição) e o peso médio de cada um dos 3 níveis (`_peso_medio_nivel`) — dados que `recalcula_colaborador` calcula mas não persiste, por não precisar deles depois de gravar `pct_individual`. No cabeçalho do card (`indicador-desempenho.js`), essa linha ("Individual: X% (peso Y%) · Grupo: X% (peso Y%) · Departamento: X% (peso Y%)") fica ao lado do nome/gerente, numa coluna própria do grid centralizada — não embaixo — e cada um dos 3 níveis fica verde/vermelho conforme bateu 100% ou não; o "Total Indicador" (renomeado de "Individual", que é `pct_individual`, com o lápis de ajuste manual sempre ao lado do valor numa linha que não quebra) fica neutro, sem cor, pra não repetir a mesma informação 4 vezes. `composicao_individual()` usa `colaborador.respostas.all()` (não `.select_related("criterio")`) de propósito, pra reaproveitar o `prefetch_related("colaboradores__respostas__criterio")` que `IndicadorApuracaoViewSet.get_queryset()` aplica só na action `retrieve` — evita 1 query extra por colaborador ao abrir a tela de revisão.
|
||
- **Tabela "Metas de Grupo e Departamento" só tem uma forma de responder Sim/Não por critério** — a coluna "Meta (%)" (ajusta `pct_grupo`/`pct_departamento` direto). Existia um segundo `<select>` Sim/Não ao lado do texto de cada critério (bulk, via `aplicar-em-lote`), removido por ser redundante com o da direita; a lista de critérios ali agora é só informativa (nome + peso). Responder um critério específico continua possível por colaborador, dentro da lista de colaboradores abaixo (`renderRespostasGrupoHtml`).
|
||
- **"Corrigir Responsável"** (`#ind-corrigir-responsavel-btn`, popup próprio): busca uma empresa (por nome ou código, entre **todas** as empresas da apuração, não só as com problema de honorário — `empresasAgrupadasPorCodigo(() => true)`) e mostra, pra cada responsável dela (uma linha por `IndicadorApuracaoEmpresa`, ex.: "Valéria Bonete — Fiscal"), um `<select>` com os demais colaboradores da apuração pra reatribuir aquele papel. Reatribuir chama `POST /api/indicadores-apuracoes-empresas/{id}/trocar-responsavel/` (`IndicadorApuracaoEmpresaViewSet.trocar_responsavel`, serializer `IndicadorApuracaoEmpresaTrocarResponsavelSerializer` com `{colaborador_id}`), que só troca a FK `colaborador` da linha (`codigo_empresa`/`tipo`/honorário continuam os mesmos) e recalcula **os dois** colaboradores envolvidos (o que perdeu a empresa e o que ganhou) — validado no backend contra: colaborador de outra apuração, colaborador igual ao atual, e colaborador que já é responsável por essa mesma empresa/tipo (evitaria duas linhas duplicadas pra ele). O `<select>` exclui o colaborador atual das opções e nasce com um placeholder desabilitado ("Selecionar novo responsável...") pra nunca reatribuir sem escolha explícita.
|
||
- **Checklist de revisão do RH** (`IndicadorApuracaoColaborador.validado`, migração `0031`): um checkbox no início de cada card (`.ind-colaborador-card__validado`, primeira coluna do grid do cabeçalho), sem relação com nenhum cálculo — só ajuda o RH a controlar quem já conferiu numa apuração com muitos colaboradores. Marcado, a borda do card inteiro fica verde (`.ind-colaborador-card.is-validado`, mesma largura de sempre, só muda a cor, pra não deslocar layout). `POST /api/indicadores-apuracoes-colaboradores/{id}/marcar-validado/` (`marcar_validado`, serializer `IndicadorApuracaoColaboradorValidadoSerializer` com `{validado}`) só grava o campo, sem chamar `recalcula_colaborador`. Diferente dos outros ajustes desta tela, o frontend **não** recarrega a apuração inteira depois de marcar/desmarcar (`renderRevisao()`) — atualiza só o card clicado localmente, pra não fechar outros cards já expandidos nem perder a posição de rolagem no meio de uma conferência longa; erro de rede reverte o checkbox e o estado em memória (mesmo padrão de outros toggles imediatos do app, ex. inativar usuário). O `<label>` inteiro (não só o `<input>`) precisa ficar de fora do gate de clique que expande/recolhe o card no cabeçalho, senão um clique na área do label (fora do glifo do checkbox) expande/recolhe o card ao mesmo tempo que marca/desmarca o validado — resultado de como labels HTML disparam dois eventos de clique encadeados.
|
||
- **Forçar SIM num critério automático não vira 100% na média** — a média ponderada usa o percentual real medido (`_fracao_atingida` em `calculo.py`), mesmo que o RH marque SIM por cima; só critério manual (sem `percentual_calculado`) é binário SIM=100%/resto=0%. Pra dar crédito cheio apesar do percentual medido baixo, o RH ajusta o percentual agregado direto (nível 2 acima), não o critério.
|
||
- **`create()` é atômico**: `IndicadorApuracaoViewSet.create()` roda o pipeline inteiro (parse das 2 planilhas + persistência de colaboradores/empresas/respostas) dentro de `transaction.atomic()` — uma falha no meio (planilha fora do leiaute, overflow decimal) desfaz tudo no banco e apaga os 2 arquivos recém-gravados em `MEDIA_ROOT` (upload não é transacional), devolvendo 400 genérico.
|
||
- **`POST /api/indicadores-apuracoes/{id}/gerar/`** monta um **ZIP** com um PDF de recibo por colaborador (`indicadores/recibo.py`, `reportlab`) a partir do que já está salvo — não reprocessa as planilhas, reflete qualquer ajuste manual feito na revisão. Recibo é documento interno (só quem tem a permissão do RH acessa/baixa) — sem visão própria do colaborador no Portal nesta v1. Botão "Gerar Recibos" (`indicador-desempenho.js`) abre um modal antes de chamar o endpoint — mesmo componente de busca por nome + filtro por departamento + checklist (com "marcar todos os resultados da busca") do "Ajuste Indicador em Lote", só que já nasce com todo mundo marcado (reproduz o comportamento antigo de "gerar pra todos" sem precisar marcar um por um); desmarcar alguns permite gerar recibo avulso de um colaborador só, de alguns específicos, ou de um departamento inteiro. O endpoint recebe `colaborador_ids` (lista, opcional) e só marca a apuração como `concluida` quando o conjunto pedido bate com **todos** os colaboradores da apuração (sem `colaborador_ids`, ou uma seleção que cobre o total) — gerar um recibo avulso pra conferência não fecha a apuração inteira como se o mês estivesse todo revisado.
|
||
- **Layout do PDF do recibo** (`indicadores/recibo.py`): o banner "PERCENTUAL DO INDICADOR INDIVIDUAL" sempre mostra o percentual **efetivo/medido** (`calculo.composicao_individual()["total_calculado"]` — a composição dos 3 níveis recalculada na hora, ignorando qualquer ajuste manual), não `colaborador.pct_individual` puro — decisão explícita do usuário: se o RH/Diretoria sobrescreveu o Individual pra 100%, o banner precisa continuar mostrando o que o colaborador de fato atingiu (ex.: 74,29%), não o valor pago. Quando `pct_individual_ajustado_manualmente=True`, uma linha de detalhe abaixo do banner mostra "Percentual Individual Ajustado Pela Direção: **100,00%**." (rótulo renomeado de "ajustado manualmente pelo RH", com o valor ajustado ao lado — antes só dizia que tinha sido ajustado, sem mostrar pra quanto) — os dois números lado a lado deixam claro o que foi medido e o que foi pago. Tabela "Empresas": toda célula (antes só "Empresa" era `Paragraph`, o resto strings soltas) virou `Paragraph` com estilo de alinhamento próprio (`celula_centro`/`celula_direita`/`celula_negrito`/`celula_direita_negrito`, `_estilos()`) — string solta não quebra linha dentro da coluna, e com `ALIGN` à direita/centro um valor mais largo que a coluna (ex.: "Contador (com conciliador)" em Tipo, ou os totais em negrito, mais largos que a mesma string em peso normal) vazava visualmente por cima da célula vizinha em vez de quebrar linha — bug real visto com dados reais (coluna Tipo cobria "Hon. Ajustado"). Coluna "Tipo" ganhou um dicionário de labels curtos só pro PDF (`TIPO_LABEL_CURTO`: "Contador SC"/"Contador CC" em vez de "Contador (sem/com conciliador)" — mesma abreviação já usada informalmente neste documento) porque o label completo não cabia nem quebrando linha numa coluna estreita; a linha de total virou "Total do Indicador" (era "Total Resultado"). Larguras de coluna e padding lateral (`LEFTPADDING`/`RIGHTPADDING`, reduzidos de 6pt padrão do reportlab pra 3pt) ajustados pra caber os maiores valores reais vistos na apuração (ex.: R$ 28.023,16) numa linha só. `_moeda()` usa ` ` (não espaço comum) entre "R$" e o número — com espaço comum, quando o valor não cabia numa linha só, o reportlab quebrava exatamente ali, deixando "R$" sozinho numa linha acima do número; com espaço não separável, o "R$" fica sempre grudado à esquerda do número (mesmo que precise de mais espaço na coluna pra caber tudo numa linha, resolvido junto pelas larguras/padding acima). Rótulo da linha de detalhe é "Percentual individual ajustado pela direção" (minúsculo, só a primeira letra maiúscula — não "Percentual Individual Ajustado Pela Direção").
|
||
- **`aplicar_em_lote`** (`IndicadorApuracaoRespostaViewSet`, `POST /api/indicadores-apuracoes-respostas/aplicar-em-lote/`) aplica o mesmo valor a várias respostas de critério de uma vez — a "múltipla seleção" pedida pelo usuário na tela de revisão.
|
||
- **"Empresas sem Honorário"** (`#ind-empresas-sem-honorario-btn`, cor de atenção — `--danger`, mesma linguagem visual do input/selo de honorário não encontrado, **só enquanto houver alguma empresa pendente** — sem nada pra resolver, o botão perde a classe `.ind-empresas-sem-honorario-btn` (volta a `.btn-outline` neutro) e o texto vira "Visualizar Empresas com Honorário Ajustado (N)", apontando direto pra revisão do que já foi ajustado — substituiu o antigo checkbox "Só com honorário não encontrado" que filtrava a lista de colaboradores): abre um modal que agrupa por `codigo_empresa` todas as `IndicadorApuracaoEmpresa` com `honorario_nao_encontrado=True` da apuração — a mesma empresa pode aparecer sob mais de um colaborador (um responsável pelo balancete, outro pela liberação fiscal, outro pela conciliação financeira), mas o honorário é da empresa, não da pessoa. Preencher um valor ali chama `POST /api/indicadores-apuracoes/{id}/ajustar-honorario-empresa/` (`IndicadorApuracaoViewSet.ajustar_honorario_empresa`, serializer `IndicadorApuracaoAjusteHonorarioEmpresaSerializer` com `{codigo_empresa, honorario}`), que atualiza **todas** as linhas daquele código nesta apuração de uma vez (recalculando cada colaborador afetado) — diferente de `PATCH /api/indicadores-apuracoes-empresas/{id}/` (ainda existe, ajusta só uma linha por id, usado direto na tabela "Empresas" de dentro do card do colaborador). Os dois caminhos (linha única e em lote) marcam `honorario_ajustado_manualmente=True` na(s) linha(s) afetada(s) — fica permanente (sem UI de reverter, diferente de `pct_individual_ajustado_manualmente`/etc., já que não existe um "automático" pra voltar quando o código nunca casou com a planilha) e vira uma nota "honorário ajustado manualmente" (cor `--accent`) ao lado do valor, na tabela "Empresas" de dentro do card do colaborador — visível só depois que `honorario_nao_encontrado` já foi resolvido (os dois nunca aparecem juntos). Essa tabela também passou a listar por `codigo_empresa` (`IndicadorApuracaoEmpresa.Meta.ordering`, migração `0029`), não mais por nome. Como `codigo_empresa` é `CharField`, ordenar só por ele é ordem alfabética, não numérica — "80"/"503" apareciam depois de "2134" (o caractere `'8'`/`'5'` é "maior" que `'1'`/`'2'`, mesmo o número sendo menor). Corrigido (migração `0030`) ordenando primeiro pelo **tamanho** da string (`Length("codigo_empresa")`) e só depois pelo valor — reproduz a ordem numérica certa pra códigos sem zero à esquerda (string mais curta = número menor, sempre) sem converter pra inteiro, o que quebraria com erro de banco se algum código um dia não fosse só dígitos.
|
||
- **"Empresas ajustadas manualmente"** é uma **segunda seção dentro do mesmo popup** "Empresas sem Honorário" — não um segundo botão/modal (revertido de propósito: nasceu como um botão separado, "Verificar Empresas Ajustadas Manualmente", e o usuário pediu pra unificar num popup só, "facilitando a usabilidade da ferramenta"). Fica **escondida por padrão**, atrás de um botão de largura cheia no final da lista principal (`#ind-empresas-ajustadas-toggle-btn`, `.ind-esh-toggle-btn`, com contador — "Visualizar"/"Ocultar Empresas Ajustadas Manualmente (N)") — pedido explícito do usuário logo depois de testar a versão anterior (as duas seções sempre visíveis de uma vez): a lista secundária só deve aparecer sob demanda, no final do modal. Lista, também agrupada por `codigo_empresa`, as empresas com `honorario_ajustado_manualmente=True` — permite **corrigir** um valor já ajustado (campo já vem preenchido com o honorário atual, ao contrário da lista principal, que começa em branco). Reaproveita o mesmo endpoint `ajustar-honorario-empresa` — o filtro do backend cobre `Q(honorario_nao_encontrado=True) | Q(honorario_ajustado_manualmente=True)`, nunca uma empresa cujo honorário só veio certo da planilha e nunca foi mexido. As duas listas compartilham as funções de agrupamento/renderização/ordenação (`empresasAgrupadasPorCodigo`, `renderEmpresaGrupoItemHtml`) em `indicador-desempenho.js`, parametrizadas só pelo filtro; `renderEmpresasHonorario()` sempre re-renderiza a lista principal e só re-renderiza a de "ajustadas" **se a seção já estiver aberta** (ao abrir o popup, essa seção sempre volta a fechar) — necessário porque corrigir uma empresa "sem honorário" faz ela migrar pra lista de "ajustadas" na hora, e essa migração só precisa refletir de imediato se o usuário já estiver olhando pra ela. As duas ficam dentro de um único wrapper que rola (`.ind-esh-scroll`), com título e "Fechar" sempre visíveis fora dele (mesmo `max-height:85vh` do popup). Em cada item, o código aparece **antes** do nome da empresa no cabeçalho (`.ind-esh-codigo` seguido de `.ind-esh-nome`), mesma ordem da tabela "Empresas" do colaborador.
|
||
- **"Ajuste Indicador em Lote"** (`#ind-lote-global-btn`, `indicador-desempenho.js`): modal separado do anterior — ajusta `pct_individual` (não critérios) de vários colaboradores **selecionados por nome** de uma vez, pra dois casos binários só: "Ajustar" (`#ind-lote-global-ajustar-btn`, aplica `pct_individual=100` a todos, via `PATCH /api/indicadores-apuracoes-colaboradores/{id}/`) ou "Reverter" (chama a action `recalcular` de cada um, voltando ao cálculo automático) — sem campo de percentual livre. Os dois botões são `btn-solid` (mesma cor) — só "Cancelar" fica `btn-outline`, já que as duas ações são igualmente "reais", não uma primária e uma secundária. Não existe endpoint de lote dedicado pra isso; o frontend dispara um PATCH/POST por colaborador em paralelo (`Promise.all`).
|
||
- **Percentuais/critérios são cadastro editável pela tela**, não hardcoded — decisão explícita do usuário pra não fixar no código números incertos vindos da planilha antiga já quebrada. Primeiro histórico populado via `python manage.py seed_indicador_desempenho` (idempotente), com os valores exatos da planilha antiga (`vigente_desde` fixado em 01/01/2024 por falta de data documentada — ajustar se o usuário informar a data real).
|
||
- **Bugs de robustez corrigidos ao testar com 43 colaboradores reais**: `openpyxl.load_workbook(..., read_only=True)` precisa de `.close()` explícito (`indicadores/leiaute.py`), senão o Windows mantém o upload memory-mapped e bloqueia excluir a apuração depois; campos percentuais precisaram de `max_digits=7` (não 6) — qualquer `DecimalField` que representa um percentual "de 0 a 100" precisa de `max_digits >= decimal_places + 3` pra caber o "100" exato sem `DataError: numeric field overflow`.
|
||
|
||
## Importação de Plano de Saúde (Utilitários)
|
||
|
||
Primeira e única aplicação dentro de "Utilitários" (os placeholders "Conversor de Arquivos"/"Calculadora Fiscal" foram removidos do menu — decisão explícita do usuário, não recriar sem confirmar de novo) — importa o relatório de faturamento de uma operadora de plano de saúde/odontológico (Amil, Unimed, ...) e gera o arquivo de lançamento no leiaute fixo do Questor, mais um relatório de auditoria do que não pôde ser lançado automaticamente. Ao contrário dos módulos com tela administrável (Links & Ferramentas, Acessos Gerais, Ramais), essa ferramenta usa permissão de **toggle único** (`{"key": "importacao-plano-saude", "label": "..."}`, entrada flat em `catalogo.MODULE_APPS["utilitarios"]`, sem par visualizar/editar) — quem tem acesso pode fazer todo o fluxo (criar, revisar/editar, gerar), sem conceito de "dono" da importação (mesmo espírito compartilhado de `LinkFerramenta`/`AcessoGeral`). Por ser um app flat, não precisou de nenhum override em `seed_portal.py` (esse cuidado só existe pra pares visualizar/editar).
|
||
|
||
**A lógica de negócio em si não nasceu neste projeto** — veio de um pipeline Python já testado e documentado em `projects/importacao-planos-saude.skill` (arquivo `.skill`, é um zip — `SKILL.md` + `scripts/`), com um protótipo funcional em `projects/project/` (CLI `main.py`, nunca tocado pelo Portal, fica só como referência/histórico). Esse pipeline foi portado quase 1:1 para dentro do Django em **`portal_api/planos_saude/`** (pacote Python puro, sem depender do ORM):
|
||
|
||
```
|
||
portal_api/planos_saude/
|
||
├── modelos.py Lancamento, Individuo, LinhaSistema, ItemAuditoria (dataclasses)
|
||
├── matcher.py casa_individuos_com_planilha() — casamento por CPF ou por nome
|
||
├── leiaute_sistema.py CABECALHO, le_planilha_padrao(), formata_valor_br()
|
||
├── pipeline.py OPERADORAS (registro), processa_importacao() — orquestração, chamada pela view
|
||
└── operadoras/
|
||
├── base.py OperadoraParser (interface)
|
||
├── amil/odonto_mensalidade.py Amil Odonto (PDF via pdfplumber, só mensalidade, casamento por CPF)
|
||
├── unimed/saude.py Unimed Saúde — CSV (mensalidade+coparticipação no mesmo arquivo) **ou** 2 PDFs separados (um por tipo), detectados automaticamente pelo conteúdo; mensalidade por nome, coparticipação por CPF (ver "Múltiplos arquivos de operadora" abaixo)
|
||
├── itamed/saude.py Itamed Saúde (PDF via pdfplumber, mensalidade+coparticipação, casamento por nome)
|
||
├── dental_uni/odonto_mensalidade.py Dental Uni Odonto (PDF via pdfplumber, só mensalidade, casamento por nome)
|
||
├── unimed_oeste_pr/saude.py Unimed Oeste do Paraná (PDF via pdfplumber, mensalidade+coparticipação por texto da descrição, casamento por nome)
|
||
├── bradesco/saude.py Bradesco Saúde (PDF **sem texto selecionável** — OCR via `docling`, mensalidade+coparticipação, casamento por nome)
|
||
└── bradesco/odonto_mensalidade.py Bradesco Dental/Bradesaude Odonto — 3759 (PDF via pdfplumber, mensalidade+coparticipação, casamento por nome, ver nota abaixo)
|
||
```
|
||
|
||
Pra adicionar uma operadora nova: criar `operadoras/<nome>/<arquivo>.py` implementando `OperadoraParser.extrai()` (devolve `(List[Individuo], List[ItemAuditoria])`) e registrar em `pipeline.OPERADORAS`. **Antes de escrever o parser, ler `projects/importacao-planos-saude.skill`** — documenta decisões de negócio já validadas com o cliente (ex.: nome divergente nunca é resolvido por aproximação, valor final negativo vai pra auditoria, um mesmo beneficiário pode aparecer em várias linhas/rubricas e precisa ser somado) que não devem ser reinterpretadas sem confirmar de novo.
|
||
|
||
**PDF sem texto selecionável (ex.: Bradesco Saúde) precisa de OCR, não de `pdfplumber`**: confirmado rodando `pdfplumber` contra o arquivo real da Bradesco — `page.chars`/`page.extract_text()` vêm vazios em toda página, porque o documento é uma composição de imagens raster (cada linha da tabela é literalmente um bitmap), sem nenhuma camada de texto. Nesse caso o parser usa `docling` (biblioteca de OCR + reconstrução de estrutura de tabela, adicionada ao `requirements.txt` — pesada: traz `torch`/`transformers`/`opencv-python` como dependência transitiva, então o primeiro `pip install` baixa bem mais do que os parsers em `pdfplumber` exigiam) em vez de `pdfplumber`. Ver o docstring de `operadoras/bradesco/saude.py` para o motivo de usar reconstrução de tabela (`DocumentConverter().convert(...).document.tables`, cabeçalho identificado por texto normalizado via `_classifica_coluna`, não por posição fixa) e o contorno de um bug real de fronteira de célula do modelo de tabela (TableFormer) nas colunas numéricas estreitas — valor de uma linha "vazando" pra célula da linha vizinha, contornado extraindo todos os valores monetários da área em ordem de leitura e redistribuindo 1 por linha, em vez de confiar em qual célula específica o modelo atribuiu cada valor. Ao adicionar outra operadora nesse mesmo caso (PDF sem texto selecionável), reaproveitar essa técnica em vez de assumir que `pdfplumber` vai funcionar — testar primeiro com `page.chars`/`extract_text()` contra o arquivo real antes de escolher qual dos dois usar.
|
||
|
||
**Bradesco Dental / "Bradesaude" Odonto (3759)** — mesmo código de operadora (`CODIGOOUTEMP`) que já existia como "ODONTOPREV S.A." na planilha padrão; o boleto da própria operadora avisa que é o mesmo plano, "antes cobrado como Odontoprev e agora identificado temporariamente como Bradsaude". PDF "SPG/Grupos Especiais - Bradesco Dental - Fatura Técnica" — página 1 é sempre o boleto (sem beneficiário nenhum), a tabela de beneficiários vem a partir da página 2, páginas finais são só o texto legal "MENSAGENS". Titular/dependente vem da coluna "Certif." (`<família>/00` = titular, `<família>/01`, `/02`... = dependente), casamento por nome (sem CPF no arquivo) — mesmo desenho da Bradesco Saúde. Particularidade própria: um mesmo beneficiário pode gerar várias linhas de lançamento por movimentação retroativa (inclusão/cancelamento com efeito em meses anteriores, códigos CM/CR/IR/IM), cada uma com seu próprio Mês/Ano e Valor — todas somadas por indivíduo, igual à regra geral de "somar todas as rubricas do mesmo indivíduo". **Ressalva importante**: ao contrário dos demais parsers deste pacote, este foi escrito só a partir do texto de um PDF colado numa conversa (o arquivo nunca chegou a ficar disponível em disco pra rodar `pdfplumber`/`docling` de verdade) — a extração via `pdfplumber` foi validada batendo a soma dos valores e a contagem de lançamentos contra o resumo do próprio boleto (37 lançamentos, R$ 949,05), mas **ainda precisa ser confirmada rodando o parser contra o arquivo real** (botão "Selecionar arquivo" da tela de Nova Importação já faz isso antes de qualquer coisa ser persistida) — se a extração vier vazia, é sinal de que este PDF também precisa de OCR via `docling`, como a Bradesco Saúde.
|
||
|
||
**Diferença deliberada em relação ao pipeline original**: lá, o valor do mês sempre gravava na coluna `VALOR` (desconto do empregado), nunca em `VALOREMPRESA` — regra fixa. Aqui, o usuário escolhe na tela de nova importação, **por tipo de lançamento (mensalidade/coparticipação) e por tipo de beneficiário (titular/dependente)** — quatro combinações independentes, ex.: mensalidade do titular custeada pela empresa e mensalidade do dependente descontada do empregado —, uma de três regras de custeio: "Custeado pela empresa" (`{"modo": "empresa"}`), "Descontado do empregado" (`{"modo": "empregado"}`, o comportamento antigo — nomenclatura "empregado", não "funcionário", pra não confundir com `NOMEFUNC`/`CPFFUNC` do leiaute do Questor, que é outra coisa) ou "Regra específica" (`{"modo": "especifica", "limite_valor": float|None, "percentual": float|None}`). Na regra específica, `limite_valor` é um teto de quanto a empresa cobre (o excedente vira desconto do empregado) e `percentual` é a fração do valor do mês custeada pela empresa (o resto vira desconto) — o usuário pode preencher só um dos dois ou os dois juntos; quando os dois vêm preenchidos, prevalece o que resultar no **menor** valor custeado pela empresa (mais restritivo), decisão explícita do usuário. Essa divisão é calculada por `matcher._calcula_valores(valor_total, regra)` (chamada por `_aplica_regra_custeio`, que grava `valor_empresa`/`valor` **os dois juntos** a partir do mesmo `valor_total`) — note que `valor_empresa` é arredondado primeiro e `valor` é derivado como o complemento exato (`valor_total - valor_empresa`, também arredondado), nunca os dois arredondados de forma independente, senão a soma dos dois podia ficar 1 centavo a mais/menos que o valor original (ex.: 50% de 51,69 tem que fechar em 25,84 + 25,85 = 51,69, não 25,85 + 25,85). Qual das duas regras (titular ou dependente) usar em cada `Individuo`/`LinhaSistema` é resolvido por `matcher._regra_para_pessoa(regra_por_pessoa, tipo_pessoa)` — `tipo_pessoa` 'T' cai em "titular", 'D'/'A' caem em "dependente" (mesmo critério de "D e A tratados igual" já usado no resto do leiaute) — chamada nos dois pontos de aplicação de `_casa_por_cpf`/`_casa_por_nome` antes de `_aplica_regra_custeio`.
|
||
|
||
### Modelos (`portal_api/models.py`)
|
||
|
||
- `ImportacaoPlanoSaude`: uma execução da ferramenta — `operadora`/`nome_operadora`, `tipos_lancamento` (JSONField, lista), `custeio_por_tipo` (JSONField, `{"mensalidade": {"titular": {"modo": "empresa"|"empregado"|"especifica", "limite_valor": float|None, "percentual": float|None}, "dependente": {...}}, "coparticipacao": {...}}` — ver regra de custeio acima), `regra_empresa` (CharField, blank — chave de `planos_saude.regras_empresa.REGRAS_EMPRESA` quando "mensalidade" foi custeada por uma regra especial em vez do `custeio_por_tipo["mensalidade"]` normal, ver "Regra empresa" abaixo), `planilha_padrao` (`FileField`, mesmo padrão de validator de tamanho de `LinkFerramenta.icone`, só que 15MB em vez de 2MB — é `blank=True` desde que passou a poder vir de uma busca no Questor em vez de upload, ver "Planilha padrão via Questor (SQL)" abaixo), `competencia` (DateField, null — só preenchida quando a origem da planilha padrão foi essa busca no Questor), `status` (`revisao`/`concluida`), `criado_por`, `criado_em`/`concluida_em`. **Com histórico**: decisão explícita do usuário — cada importação fica salva (quem fez, quando, arquivos), não é um fluxo descartável. O arquivo (ou arquivos) da operadora vive num model relacionado separado, ver `ImportacaoPlanoSaudeArquivoOperadora` a seguir e "Múltiplos arquivos de operadora" abaixo.
|
||
- `ImportacaoPlanoSaudeArquivoOperadora`: um dos relatórios da operadora anexados a uma importação (FK `importacao`, `arquivo` FileField, `ordem`) — a maioria das operadoras manda só um, mas algumas (ex.: Unimed Saúde em PDF) mandam mensalidade e coparticipação em arquivos separados. Substituiu, numa rodada posterior, o antigo `FileField` único `ImportacaoPlanoSaude.arquivo_operadora` (migração em 3 passos — `0048` cria o model novo + torna o campo legado `blank=True`; `0049`, `RunPython`, cria uma linha por importação já existente reapontando pro mesmo caminho já salvo em `MEDIA_ROOT`, sem copiar bytes; `0050` remove o campo legado — mesmo padrão já usado em `IndicadorDepartamento`/`RegraCusteioPlanoSaude.codigo_empresa`).
|
||
- `ImportacaoPlanoSaudeLinha`: uma linha da planilha padrão já casada com o valor do mês (espelha `LinhaSistema` campo a campo) — na tela de revisão, uma linha que já veio do processamento (upload ou Questor) só edita **Valor Empresa/Valor**; os demais campos (cadastro da pessoa) só ficam editáveis numa linha incluída manualmente via "Adicionar linha" (regra revista — nasceu como "todos os campos editáveis em qualquer linha", decisão do usuário depois de ver dados reais na tela: só uma linha nova precisa editar o cadastro, uma linha já casada não devia arriscar um cadastro certo sendo alterado por engano). Essa restrição é só de UI (`importacao-plano-saude.js`, `linhasIncluidasManualmente()` — deriva de `ImportacaoPlanoSaudeAlteracao` já carregada, sem campo novo), o backend continua aceitando PATCH em qualquer campo. `valor`/`valor_empresa` ficam como `CharField` no mesmo formato string do pipeline (`"51,69"`/`"0"`), não `DecimalField`, pra manter fidelidade 1:1 com o CSV final sem risco de arredondamento.
|
||
- `ImportacaoPlanoSaudeAuditoria`: espelha `ItemAuditoria` — os campos extraídos do arquivo da operadora (`motivo`/`nome`/`valor`/`detalhe`...) são read-only na tela; `resolvida`/`linha_vinculada` são a exceção, graváveis via a resolução manual (ver "Resolução manual de auditoria por nome" abaixo). `MOTIVOS_RESOLVIVEIS = ("NOME_DIVERGENTE", "NAO_CADASTRADO")` (atributo de classe) é a lista dos dois motivos "de leitura/grafia de nome" que aceitam esse fluxo — `VALOR_NEGATIVO`/`TIPO_INVALIDO` são outra categoria de problema (valor real negativo, tipo de despesa não mapeado) e não têm solução por "essa é a mesma pessoa".
|
||
- `ImportacaoPlanoSaudeAlteracao`: log de cada edição de campo/inclusão/exclusão de linha feita manualmente na revisão — ver seção "Alterações" abaixo.
|
||
- `RegraCusteioPlanoSaude`: regra de custeio salva pra reaplicar em importações futuras (ex.: "092 - Unimed") — ver "Regras de custeio salvas" abaixo. Lista compartilhada, mesmo espírito de `LinkFerramenta`/`AcessoGeral` — o próprio model não tem FK pra nada; é `ImportacaoPlanoSaude.regra_custeio_salva` que aponta pra cá (opcional, `SET_NULL`), só como registro de qual regra (se alguma) foi aplicada pra preencher aquele formulário.
|
||
|
||
### Fluxo e endpoints
|
||
|
||
`ImportacaoPlanoSaudeViewSet` (`/api/importacoes-plano-saude/`, `PermissaoApp("utilitarios", "importacao-plano-saude")` pra todos os métodos):
|
||
- `create()` (multipart, `ImportacaoPlanoSaudeCreateSerializer` valida a entrada) resolve a planilha padrão (upload **ou** busca no Questor — ver "Planilha padrão via Questor (SQL)" abaixo), salva o model + um `ImportacaoPlanoSaudeArquivoOperadora` por arquivo em `arquivo_operadora` (lista, ver "Múltiplos arquivos de operadora" abaixo) e roda `pipeline.processa_importacao()` **de forma síncrona** usando os caminhos de todos os arquivos da operadora + a lista de `LinhaSistema` já resolvida — sem fila/Celery, o arquivo típico processa em menos de um request. Se o processamento falhar (PDF num layout desconhecido etc.), apaga os arquivos recém-salvos (planilha + todos os da operadora) + o registro órfão e devolve 400.
|
||
- `GET /operadoras/` (`@action` sem detail) devolve `pipeline.lista_operadoras()` — fonte única pro combobox pesquisável "Operadora" do formulário (`#ips-operadora-combo`, mesmo padrão de "Regra de custeio salva" — ver "Regras de custeio salvas" abaixo), sem duplicar a lista em JS. `label` já vem no formato `"<código> - <Nome>"` (ex.: `"3755 - Itamed Saúde"`) — o código é o de cadastro da operadora no Questor, pedido explícito do usuário pra identificar a operadora sem ambiguidade (útil quando duas operadoras têm nome parecido); editar em `pipeline.OPERADORAS`, não formatar o código separadamente no frontend.
|
||
- `POST /{id}/gerar/` monta o(s) CSV(s) a partir das **linhas já salvas** (isto é, já com qualquer edição feita na revisão — não reprocessa os arquivos originais) usando `leiaute_sistema.CABECALHO`; 1 tipo de lançamento vira um `.csv` direto, 2 tipos (mensalidade + coparticipação) viram um `.zip` com um `.csv` por tipo (`zipfile` em memória). Sempre marca `status="concluida"` (+ `concluida_em`) — pode ser chamada de novo enquanto `concluida` (regera o mesmo arquivo a partir do que já está salvo), mas a partir daí toda edição de linha/auditoria/alteração fica bloqueada até reabrir (ver `reabrir()` abaixo e "Trava de edição pós-conclusão").
|
||
- `POST /{id}/reabrir/` volta `status="revisao"` (zera `concluida_em`) — contrapartida de `gerar()`, é o único jeito de voltar a editar uma importação concluída. Botão "Editar" na tela de Revisão, visível só quando `status === "concluida"`.
|
||
|
||
`ImportacaoPlanoSaudeLinhaViewSet` (`/api/importacoes-plano-saude-linhas/{id}/`, só GET/PATCH): edição de uma linha por vez, disparada por `blur`/`change` de cada `<input>` na tela de revisão — mesma permissão de toggle único, sem checagem de "dono". `create()`/`partial_update()`/`destroy()` recusam (400) se a importação já estiver `concluida` — ver "Trava de edição pós-conclusão" abaixo.
|
||
|
||
`ImportacaoPlanoSaudeAuditoriaViewSet` (`/api/importacoes-plano-saude-auditoria/{id}/resolver/`, só `POST`) — ver seção própria abaixo; também recusa se a importação estiver `concluida`.
|
||
|
||
**Histórico (`#ips-list-table`): ordenação por coluna + filtro "estilo Excel" por coluna, os dois client-side** sobre o array já carregado (`GET /api/importacoes-plano-saude/`, sem paginação/filtro no servidor) — mesmo padrão de ordenação já usado em `#ua-table`/Ramais (`th[data-sort]`, ícone `↕`). O filtro (`criarFiltroColuna()`, `importacao-plano-saude.js`) nasceu como um segundo campo de texto por coluna, mas foi revisto a pedido do usuário pra imitar o filtro de planilha (Excel/Sheets): um botão de funil dentro do próprio `<th>` de cada coluna filtrável (Cód. Empresa/Operadora/Status/Criado por) abre um popup com busca + checklist dos **valores distintos daquela coluna** (reaproveita `.checklist-box`/`.checklist-search`/`.checklist-select-all`/`.modal-checkbox` de `components.css` — mesmo componente já usado nos checklists de Perfis de Acesso), tudo desmarcável/marcável, com "Aplicar"/"Limpar" no rodapé (só aplica no clique, não a cada checkbox — evita re-renderizar a lista principal a cada toque). Os filtros das 4 colunas combinam entre si (AND). `listFiltros[campo]` vale `null` (sem filtro) ou um `Set` dos valores brutos marcados; marcar **todos** os valores existentes equivale a `null` (sem filtro), pra um valor novo que apareça depois (operadora nova, por exemplo) não nascer excluído até o usuário marcá-lo manualmente. O botão de funil ganha `.is-active` (cor de destaque) enquanto a coluna tiver um filtro aplicado — mesmo sinal visual do funil "azul" do Excel. `.pa-table-wrap` normalmente usa `overflow:hidden` pra arredondar os cantos da tabela; só a tabela do histórico (`.ips-list-table-wrap`) sobrescreve pra `visible`, senão o popup (que precisa aparecer por cima das linhas, não só dentro do cabeçalho) seria cortado ali. Qualquer mudança de filtro (ou de ordenação) volta pra página 1. `listEmpty` mostra uma mensagem diferente conforme o caso: "Nenhuma importação realizada ainda." quando o histórico está mesmo vazio, "Nenhuma importação encontrada com esse filtro." quando o vazio é só resultado do filtro aplicado.
|
||
|
||
### Trava de edição pós-conclusão
|
||
|
||
Depois que `POST /{id}/gerar/` marca uma importação como `concluida`, editar/incluir/excluir uma linha (`ImportacaoPlanoSaudeLinhaViewSet`), resolver um item de auditoria (`ImportacaoPlanoSaudeAuditoriaViewSet.resolver`) ou reverter uma alteração (`ImportacaoPlanoSaudeAlteracaoViewSet.reverter`) passam a ser recusados (400) — decisão explícita do usuário, depois de ver que a tabela de revisão continuava 100% editável mesmo depois do arquivo já ter sido gerado e entregue. `_garante_importacao_em_revisao(importacao)` (função módulo-level em `views.py`, chamada no início de cada um desses pontos de escrita) é o único lugar que checa isso — levanta `ValidationError` com uma mensagem pedindo pra usar o botão "Editar" (`POST /{id}/reabrir/`) primeiro. `gerar()` em si nunca é bloqueado (pode ser chamado de novo com a importação já `concluida`, só regera o mesmo arquivo a partir do que está salvo).
|
||
|
||
No frontend (`importacao-plano-saude.js`), a tela de Revisão espelha essa trava puramente pra UX (a validação real é sempre a do backend acima): com `importacaoAtual.status === "concluida"`, toda célula da tabela de Mensalidade/Coparticipação vira texto (não `<input>`, nem Valor Empresa/Valor — a regra de "só Valor Empresa/Valor editáveis" descrita no bullet de `ImportacaoPlanoSaudeLinha` acima só se aplica quando a importação ainda está em revisão), o "×" de remover linha e o botão "Adicionar linha" somem, "Vincular pessoa" (Auditoria) e "Reverter" (Alterações) também somem. O botão "Editar" (`#ips-review-editar-btn`, ao lado de "Gerar Arquivo") aparece só nesse estado e chama `POST /{id}/reabrir/`, atualizando `importacaoAtual` e re-renderizando as abas.
|
||
|
||
Clicar em "Gerar Arquivo" (`gerarBtn`) sempre volta pro histórico (`showView("list")` + `refreshList()`) depois do download disparar — decisão explícita do usuário, já que a partir daí a importação está `concluida` e travada (ver acima), não há mais nada pra revisar de imediato na própria tela.
|
||
|
||
### Múltiplos arquivos de operadora
|
||
|
||
Até uma rodada anterior, "Arquivo da operadora" (passo 2 de "Nova Importação") era um único upload obrigatório — trocado por **1 ou mais arquivos** (pedido explícito do usuário): algumas operadoras mandam mensalidade e coparticipação em arquivos separados (a primeira real: Unimed Saúde, quando manda PDF em vez do CSV único — ver "Parser da Unimed Saúde" abaixo), em vez de um único arquivo com os dois tipos juntos.
|
||
|
||
- **Backend**: `ImportacaoPlanoSaudeCreateSerializer.arquivo_operadora` é um `ListField(child=FileField(), allow_empty=False)` — o DRF já lê múltiplos arquivos do mesmo campo em `multipart/form-data` via `request.data.getlist(...)` (mesma semântica do `QueryDict`), sem tratamento manual extra na view. `create()` cria um `ImportacaoPlanoSaudeArquivoOperadora` por arquivo (`ordem=índice`); `pipeline.processa_importacao(operadora_key, caminhos_arquivo_operadora: List[str], ...)` chama `OperadoraParser.extrai()` **uma vez por caminho** (nenhum parser existente muda de assinatura — quem ganha a responsabilidade de iterar é só o `pipeline.py`) e concatena os indivíduos/itens de auditoria de todos os arquivos antes de seguir com o casamento normal.
|
||
- **`_agrega_individuos_entre_arquivos()` (`pipeline.py`)** — bug real encontrado e corrigido ao testar esta funcionalidade de ponta a ponta: se dois arquivos contribuem indivíduos da MESMA pessoa e do MESMO `tipo_lancamento` (ex.: duas coparticipações do mesmo mês, separadas por período), só concatenar as duas listas não bastava — `casa_individuos_com_planilha`/`_aplica_regra_custeio` (matcher.py) **grava** o valor final na `LinhaSistema` por pessoa, não acumula, então o segundo arquivo processado sobrescrevia o valor do primeiro em vez de somar. Corrigido somando (`valor_total` e `rubricas`) os indivíduos de mesma chave (`numero_beneficiario`, `tipo_lancamento`) **entre arquivos**, logo depois de concatenar as listas — mesmo padrão que cada parser já faz **dentro** de um único arquivo (`_agrega_por_individuo_e_tipo`), só que agora entre arquivos também.
|
||
- `perform_destroy()`/os `except` de `create()` (arquivo ilegível, regra empresa incompatível, código de empresa não confere) apagam **todos** os arquivos de `importacao.arquivos_operadora.all()` de `MEDIA_ROOT`, não só um.
|
||
- **Frontend**: `<input type="file" multiple>` + uma lista dinâmica (`#ips-form-arquivo-list`/`.ips-arquivo-list`, `importacao-plano-saude.js`) no lugar do campo único de sempre — cada arquivo anexado é validado individualmente (mesmo endpoint `POST /.../validar-arquivo/` de sempre, chamado uma vez por arquivo, sem mudança nenhuma nele) e listado com seu próprio status + botão de remover; trocar a operadora revalida todos os arquivos já anexados. No submit, `formData.append("arquivo_operadora", file)` uma vez por arquivo.
|
||
|
||
#### Parser da Unimed Saúde (PDF): dois relatórios separados, tipo detectado automaticamente
|
||
|
||
`operadoras/unimed/saude.py` (`unimed_saude`, código 5060) ganhou um segundo formato de entrada, além do CSV único já existente: **dois PDFs** de um cliente real (mensalidade + coparticipação analítico), detectados automaticamente pelo **conteúdo** de cada arquivo — nunca pelo usuário escolhendo um "tipo de documento" (pedido explícito). `UnimedSaude.extrai()` abre o PDF com `pdfplumber` e olha a primeira página: `"BENEFICIARIOS COM FATURAMENTO NO MES"` → relatório de mensalidade (`_extrai_pdf_mensalidade`, uma linha por beneficiário, `extract_text()` simples já basta); `"SERVIÇOS PRESTADOS"`/`"ANALITICO"` → coparticipação analítica (`_extrai_pdf_coparticipacao`, várias linhas de serviço por beneficiário, somadas por pessoa).
|
||
|
||
- Confirmado com o usuário: a coparticipação devida por beneficiário é a **soma do "Vl Total" de cada linha de serviço** daquele beneficiário — a coluna "Tt Copar" (valor fixo, repetido em toda linha do documento) **não é usada**. Linhas "Pct:MED"/"Pct:HOS"/"Pct:MAT" (detalhamento informativo de um item, cuja soma já está no valor do item principal) são ignoradas — senão duplicariam o valor.
|
||
- `nome`/`Grau Dep.` (TITULAR/CONJUGE/FILHO(A)/...) só aparecem na primeira linha de cada bloco de atendimento — parsing com estado (mesmo padrão do `ItamedSaude`).
|
||
- Valores nos dois PDFs vêm em **formato americano** (ponto decimal, vírgula de milhar — ex. "6,061.74"), ao contrário do formato BR do resto do pipeline — `_valor_pdf_para_float`, função própria, separada de `_valor_para_float` (BR, só pro CSV).
|
||
- **`OperadoraParser` ganhou `chave_casamento_para_tipo(tipo_lancamento)`** (default: devolve `chave_casamento`, mesmo valor de sempre — método novo, backward-compatible pra todo outro parser) porque a coparticipação analítica da Unimed em PDF precisou de uma estratégia de casamento **diferente da mensalidade dentro da mesma operadora**: o "Beneficiario" desse relatório vem colado sem espaço com o nome e o grau de dependência (ex.: "0975.0167003824292ANDREIA STORMTITULAR") e o **nome sai truncado em ~13 caracteres** por largura de coluna ("ANDREIA STORMOSKI LARA" → "ANDREIA STORM") — inviabilizando casamento por nome. Como esse relatório traz CPF completo e confiável, `UnimedSaude` usa `"cpf"` só pra `tipo_lancamento="coparticipacao"` quando a origem foi esse PDF (rastreado numa flag de instância, `self._veio_de_pdf_coparticipacao`, setada em `extrai()`); mensalidade (sem CPF em nenhum dos dois formatos) continua em `"nome"`. `pipeline.processa_importacao` chama `chave_casamento_para_tipo(tipo_lancamento)` em vez do atributo fixo.
|
||
- **Validado contra os dois arquivos reais** (não só texto colado numa conversa — o texto que sai de um PDF colado no chat **não é** o que `pdfplumber.extract_text()` de fato produz, então não serve pra desenhar regex com confiança; só o arquivo real confirma). Bate exatamente com "Total da Familia"/"Total da Sequencia" impresso no próprio relatório (1.372,08 de coparticipação, 6.061,74 de mensalidade) e com o casamento por CPF contra a planilha padrão real da empresa — toda família presente na planilha bateu centavo a centavo; a família ausente da planilha de teste foi corretamente pra auditoria, não ignorada silenciosamente.
|
||
|
||
### Planilha padrão via Questor (SQL)
|
||
|
||
Até uma rodada anterior, a "planilha padrão" (cadastro dos beneficiários, sem valores — o mesmo que `le_planilha_padrao` lê de um CSV) só chegava por upload manual, exportado à mão do Questor. O usuário forneceu e validou uma consulta SQL equivalente contra o próprio banco do Questor, então "Nova Importação" ganhou um segundo caminho: buscar essa planilha automaticamente a partir de empresa (já resolvida pela `RegraCusteioPlanoSaude` escolhida) + operadora + competência (mês/ano digitado na tela) — **sem precisar mais exportar/anexar nada** nesse caso. O upload manual continua existindo como alternativa (Questor fora do ar, ou empresa ainda não migrada) — decisão explícita do usuário, não uma substituição total.
|
||
|
||
- **Toggle na tela** (`importacao-plano-saude.js`/`.html`, dentro do bloco "1. Planilha padrão"): dois radios, "Buscar automaticamente do Questor" (padrão) / "Anexar manualmente" — o primeiro revela um campo "Competência" **texto livre com máscara MM/AAAA** (`<input type="text" inputmode="numeric" placeholder="MM/AAAA" maxlength="7">` + `mascaraCompetencia()`/`competenciaParaIso()` em JS — deliberadamente **não** um `<input type="month">`: o seletor nativo do browser foi rejeitado pelo usuário como UX ruim; mesmo padrão de digitação livre já usado em `indicador-desempenho.js`/`pidIndMascaraCompetencia`, copiado aqui em vez de compartilhado, como as demais funções pequenas duplicadas entre telas), o segundo revela o `<input type="file">` de sempre. Trocar de modo limpa o outro campo, pra nunca mandar os dois juntos (o backend também recusa isso).
|
||
- **Backend**: `ImportacaoPlanoSaudeCreateSerializer.competencia` é um `serializers.DateField()` normal (mesmo padrão de `IndicadorApuracaoCreateSerializer.competencia`) — o frontend já manda o ISO `"AAAA-MM-01"` convertido a partir da máscara, nunca a string mascarada crua. `validate()` exige exatamente uma das duas origens (nunca as duas, nunca nenhuma).
|
||
- `ImportacaoPlanoSaudeViewSet.create()` (views.py): quando não veio arquivo, resolve a planilha **antes** de criar o registro — `portal_api.planos_saude.questor_planilha.busca_linhas_questor(codigo_empresa, codigo_operadora, competencia)` consulta `sqls.questor.QuestorSQL.consulta_planilha_plano_saude` (só leitura — `select_mappings_query`, nunca `execute`/`execute_returning`, ver [[feedback_bancos_externos_somente_leitura]]) via `DatabaseConnection("questor")`. Uma falha de conexão/consulta aqui devolve 400 direto, sem nada persistido ainda (diferente do caminho de upload, que só sabe se o arquivo é válido depois de já ter salvo o registro — por isso, nesse, o cleanup de arquivo/registro órfão continua sendo necessário). Zero linhas retornadas (empresa sem plano ativo na competência) também é 400. O resultado é serializado de volta pra CSV (`questor_planilha.linhas_para_csv_bytes`, mesmo formato de `leiaute_sistema.CABECALHO`) e salvo como `ContentFile` no próprio campo `planilha_padrao` — preserva o histórico completo mesmo pra importações que nunca tiveram upload. A "trava de conferência do código de empresa" (que confere que a planilha anexada tem alguma linha da empresa da regra) é pulada nesse caminho, redundante já que a consulta já filtrou por esse `codigo_empresa`.
|
||
- **`pipeline.processa_importacao`** deixou de ler o arquivo sozinho (não recebe mais `caminho_planilha_padrao: str`) — recebe `linhas_sistema_template: List[LinhaSistema]` já pronta, de qualquer uma das duas origens (`le_planilha_padrao(caminho)` pro upload, `busca_linhas_questor(...)` pro Questor) — a decisão de qual usar ficou inteiramente em `views.py create()`.
|
||
- **Código da operadora**: até então só existia embutido no `label` de `pipeline.OPERADORAS` (ex. `"5060 - Unimed Saúde"`, extraído por *string split* onde só o nome era preciso). Passou a existir como campo próprio (`OPERADORAS[chave]["codigo_operadora"]`, junto de `"nome"`) — é o valor usado pra filtrar a consulta por operadora (`codigooutemp` no Questor, código da OPERADORA, não confundir com `codigo_empresa` do cliente); `pipeline.label_operadora(chave)` calcula o `"<código> - <Nome>"` de exibição a partir desses dois campos onde ainda é preciso (`lista_operadoras()`, mensagens de erro, `nome_operadora` da importação).
|
||
- `ImportacaoPlanoSaude.competencia` (`DateField`, null) registra a competência usada — só preenchida quando a origem foi o Questor; exibida na tela de Revisão ao lado do nome da operadora.
|
||
|
||
### Resolução manual de auditoria por nome
|
||
|
||
Quando o casamento por nome falha (`NOME_DIVERGENTE`/`NAO_CADASTRADO` — ver `matcher.py`, "nunca resolvido por aproximação automática"), o colaborador pode confirmar manualmente que aquele item **é** uma pessoa específica já presente na planilha padrão, em vez de deixar o lançamento parado em auditoria pra sempre. Não é fuzzy matching nem aproximação automática — é sempre uma confirmação humana, explícita, item por item; a regra de "nome exato ou vai pra auditoria" do `matcher.py` continua intocada.
|
||
|
||
- **Endpoint**: `POST /api/importacoes-plano-saude-auditoria/{id}/resolver/` com `{"linha_id": <id>}`. Validações em `ImportacaoPlanoSaudeAuditoriaViewSet.resolver` (views.py): o item precisa ter um motivo em `MOTIVOS_RESOLVIVEIS` e ainda não estar `resolvida` (idempotente — não dá pra resolver de novo, nem trocar o vínculo depois); a linha escolhida precisa (a) ser da mesma importação e do mesmo `tipo_lancamento` do item; (b) ser do mesmo "lado" — titular pra item `tipo="T"`, dependente pra `tipo!="T"` (D/A) — comparando `linha.nome_dependente`/`cpf_dependente` vazios ou não; (c) **ainda estar em branco** (`valor == valor_empresa == "0"`), decisão explícita do usuário pra nunca sobrescrever sem querer um lançamento que já casou automaticamente com outra pessoa do arquivo da operadora.
|
||
- Ao vincular, o `valor` do item de auditoria é dividido em `valor_empresa`/`valor` pela mesma regra de custeio já salva em `ImportacaoPlanoSaude.custeio_por_tipo[tipo_lancamento]` para aquele tipo de pessoa (titular/dependente) — `matcher.valores_formatados_para_pessoa(valor_total, regra_por_pessoa, tipo_pessoa)` é o único ponto de entrada público do módulo pra isso, reaproveitando as mesmas `_regra_para_pessoa`/`_calcula_valores` do fluxo automático (não existe uma segunda fórmula "manual"). **Exceção**: quando a importação tem `regra_empresa` configurada (ver "Regra empresa" abaixo) e o item é de `tipo_lancamento="mensalidade"`, esse caminho por pessoa não se aplica — bug real visto com dados reais, o valor caía inteiro em desconto do empregado, ignorando a regra empresa. `resolver()` grava o valor bruto do item na linha (placeholder) e chama `_recalcula_familia_regra_empresa(importacao, linha)`, que reúne **todas** as linhas de mensalidade da mesma família (`nome_func` igual) — recuperando o valor bruto de cada uma como `valor_empresa + valor`, soma que preserva o total independente do split aplicado antes — e reaplica a regra empresa (`REGRAS_EMPRESA[chave]["aplica"]`) na família inteira de uma vez, salvando todas as linhas afetadas (`bulk_update`). Precisa reaplicar na família toda, não só na linha recém-vinculada, porque o valor novo muda o total da família e o teto (`_aplica_teto_familia`, priorização dependente→titular) precisa ser redistribuído do zero.
|
||
- O item **nunca é apagado nem some da lista**: fica marcado `resolvida=True` + `linha_vinculada` (FK), e a tela mostra um selo "Resolvido — <nome>" (verde, mesma linguagem visual de `.status-pill--ativo`) no lugar do botão "Vincular pessoa" — mantém o rastro de que aquele valor entrou por confirmação manual, não pelo casamento automático (mesma filosofia de histórico completo do resto do módulo). `get_resumo_por_tipo` (serializers.py) só conta itens **não resolvidos** em `total_auditoria`, pra não inflar o contador de pendências com algo que já foi lançado.
|
||
- **Frontend** (`importacao-plano-saude.js`): a coluna "Ação" da aba Auditoria (`panelHtmlAuditoria()`) mostra o botão "Vincular pessoa" só quando `PID_IPS_MOTIVOS_RESOLVIVEIS.includes(item.motivo)` e `!item.resolvida`. O modal `#ips-vincular-modal` lista candidatos **sem nenhuma chamada de API nova** — filtra em memória a partir de `importacaoAtual.linhas` (já carregado na revisão) por `tipo_lancamento` igual, "lado" (titular/dependente) igual e ainda em branco (`candidatosVincular()`), com uma caixa de busca por nome (`renderVincularLista()`, mesmo componente `.checklist-box`/`.checklist-search` de outras telas, aqui com `<input type="radio">` — seleção única, não múltipla). Confirmar chama `pidResolverAuditoriaPlanoSaude()` e refaz `pidFetchImportacaoPlanoSaude` pra recarregar `importacaoAtual` (mesmo padrão de "adicionar/remover linha" já usado na página) antes de re-renderizar as abas — a tabela do próprio tipo de lançamento também reflete o novo valor lançado, não só a aba Auditoria.
|
||
|
||
### Vínculos de nome salvos (DE/PARA)
|
||
|
||
Depois de "Vincular pessoa" resolver manualmente uma divergência de nome, o usuário perguntou se ela precisava ser refeita em toda execução futura ou se podia ficar guardada, "como se fosse um DE/PARA" — decisão explícita do usuário: sim, guardar e reaplicar automaticamente, mostrando cada aplicação automática na aba Alterações com um botão pra apagar o vínculo. Continua não sendo aproximação/fuzzy matching (ver `matcher.py`) — o DE/PARA só existe depois que um humano confirmou explicitamente aquela divergência específica uma vez; sem vínculo salvo, o comportamento é idêntico a antes (cai em auditoria).
|
||
|
||
- **Model** (`VinculoNomeOperadora`, migração `0051`): `operadora` (chave de `pipeline.OPERADORAS`), `codigo_empresa` (cru, sem normalizar — ver abaixo por quê), `nome_arquivo_operadora` (o nome divergente do arquivo da operadora, já normalizado via `matcher.normaliza_nome` — é a chave de busca), `nome_func_destino`/`nome_dependente_destino` (o nome real na planilha padrão — só um dos dois preenchido, conforme o vínculo seja de titular ou de dependente), `criado_em`/`criado_por`. `unique_together` em `(operadora, codigo_empresa, nome_arquivo_operadora)`.
|
||
- **`codigo_empresa` fica cru no model, normalizado só em `views.py`**: importar `empresas_questor.normalizar_codigo_empresa` dentro de `models.py` criaria um import circular (`empresas_questor.py` já importa `EmpresaQuestor` de `models.py`) — por isso a normalização acontece nos dois pontos de uso em `views.py` (`_carrega_vinculos_por_nome`, `resolver()`), que já importam essa função pra outros fins (ver "Nome da empresa (Questor)" acima).
|
||
- **Gravado em `ImportacaoPlanoSaudeAuditoriaViewSet.resolver()`** (mesma view de "Vincular pessoa" acima) — depois de aplicar a resolução manual, `update_or_create` um `VinculoNomeOperadora` com `nome_arquivo_operadora=normaliza_nome(item.nome)` e o destino (`linha.nome_func` se `item.tipo == "T"`, senão `linha.nome_dependente`). Só grava se `linha.codigo_empresa` normalizado não for vazio (sempre o caso na prática).
|
||
- **Aplicado em `matcher._casa_por_nome`** (não em `_casa_por_cpf` — CPF já é exato por natureza, nunca precisa de DE/PARA): recebe `vinculos_por_nome: Dict[str, VinculoNome]` (`nome normalizado -> VinculoNome`, dataclass "pura" sem ORM em `planos_saude/modelos.py`) e `tipo_lancamento` (só pra rotular o `VinculoAplicado` gerado, o dict em si não é escopado por tipo — o mesmo DE/PARA vale pra mensalidade e coparticipação da mesma operadora+empresa). Quando o titular ou o dependente não bate por nome exato, checa `vinculos_por_nome.get(nome_normalizado)` antes de cair em auditoria; se achar e a linha de destino existir na planilha, resolve normalmente (inclusive respeitando `regra_empresa_fn`, já que o vínculo só decide QUAL linha usar — o resto do fluxo de custeio é idêntico ao casamento por nome exato) e registra um `VinculoAplicado` (índice da linha dentro do `tipo_lancamento`, id do vínculo, nome do arquivo da operadora) — devolvido em `ResultadoProcessamento.vinculos_aplicados` (`pipeline.py`) pra `views.py` montar os registros de `ImportacaoPlanoSaudeAlteracao` depois que as linhas estiverem persistidas (no momento do casamento elas ainda não têm `id`).
|
||
- **`ImportacaoPlanoSaudeViewSet.create()`**: `_carrega_vinculos_por_nome(operadora_key, linhas_sistema_template)` (views.py) busca todo `VinculoNomeOperadora` da operadora cujo `codigo_empresa` normalizado apareça em algum `LinhaSistema` da planilha padrão desta importação, monta o dict e passa em `processa_importacao(vinculos_por_nome=...)`. Depois do `bulk_create` das linhas, correlaciona cada `VinculoAplicado.indice_linha` (índice dentro do `tipo_lancamento`, o mesmo usado como `ordem` na criação da linha) com a `ImportacaoPlanoSaudeLinha` já persistida e cria um `ImportacaoPlanoSaudeAlteracao` (`tipo=TIPO_VINCULO_AUTOMATICO`, `vinculo_nome=<vínculo>`, `valor_novo=<nome do arquivo da operadora>`) por vínculo aplicado.
|
||
- **`POST /.../reverter/` (botão "Apagar vínculo")**: mesmo endpoint de reverter uma alteração normal (ver "Alterações" abaixo) — pra `TIPO_VINCULO_AUTOMATICO`, zera `valor`/`valor_empresa` da linha (reaplicando a regra empresa da família, se houver, mesma lógica de `_recalcula_familia_regra_empresa`) **e** apaga o `VinculoNomeOperadora` (`SET_NULL` em qualquer outra `ImportacaoPlanoSaudeAlteracao` que o referenciasse) — pra essa divergência voltar a cair em auditoria numa importação futura em vez de ser reaplicada sozinha. Como o vínculo é global (não por importação), apagá-lo afeta todas as importações futuras da mesma operadora+empresa, não só a atual.
|
||
- **Frontend**: badge próprio (`.ips-alteracao-tipo--vinculo_automatico`, cor `--accent`) na aba Alterações, com o detalhe `"<nome do arquivo>" (arquivo da operadora) → <nome vinculado> (planilha padrão)` e o botão de ação lendo "Apagar vínculo" em vez de "Reverter" (mesmo endpoint, `pidReverterAlteracaoPlanoSaude`) — a confirmação (`pidConfirm`) também tem um texto próprio avisando que a divergência volta a cair em auditoria.
|
||
|
||
### Alterações (histórico de edição/inclusão/exclusão de linha, com reversão)
|
||
|
||
Quarta aba da revisão (ao lado de Mensalidade/Coparticipação/Auditoria) — mostra cada edição de campo, inclusão manual de linha ("Adicionar linha"), exclusão de linha e vínculo automático de nome (ver "Vínculos de nome salvos (DE/PARA)" acima) feitos na própria tela de revisão (ou, no caso do vínculo automático, aplicados por `create()` a partir de um DE/PARA já salvo), com um botão pra reverter/apagar cada um individualmente. Objetivo: dar visibilidade e uma saída fácil pra um erro de digitação, uma exclusão ou um vínculo automático indesejado, sem precisar reprocessar a importação do zero.
|
||
|
||
- **Model** (`ImportacaoPlanoSaudeAlteracao`, migração `0038`, `vinculo_nome` adicionado na `0051`): um registro por operação, nunca apagado (mesmo espírito de `resolvida` em `ImportacaoPlanoSaudeAuditoria` — histórico completo). `tipo` (`edicao`/`inclusao`/`exclusao`/`vinculo_automatico`), `linha` (FK `SET_NULL` — fica `null` quando a linha em si já não existe mais: foi excluída, ou era uma inclusão já revertida), `campo`/`valor_anterior`/`valor_novo` (só preenchidos em `edicao`; em `vinculo_automatico`, `valor_novo` guarda o nome do arquivo da operadora), `dados_linha` (JSONField — snapshot de todos os campos editáveis da linha **+** `ordem`, capturado no momento da operação; é o que permite recriar a linha ao reverter uma exclusão e identificar a linha na tela mesmo depois dela ter sido excluída), `vinculo_nome` (FK `SET_NULL`, só em `vinculo_automatico`), `usuario`, `criado_em`, `revertida`/`revertida_em`.
|
||
- **Fora de escopo de propósito**: o próprio ato de "Vincular pessoa" (resolução manual de um item de auditoria) não gera um registro aqui — já tem seu próprio rastro (o selo "Resolvido" na aba Auditoria); duplicar o registro nas duas abas só confundiria qual é a fonte da verdade. Só a reaplicação automática desse vínculo numa importação **futura** vira um registro do tipo `vinculo_automatico`.
|
||
- **Onde é gravado**: as três operações de `ImportacaoPlanoSaudeLinhaViewSet` (`perform_create`/`perform_update`/`perform_destroy`, `views.py`) — `perform_update` compara `serializer.validated_data` contra `serializer.instance` (os valores **antes** do `.save()`) e grava um `ImportacaoPlanoSaudeAlteracao` por campo que de fato mudou (o fluxo atual do frontend já só envia um campo por PATCH, por `change` de cada `<input>`, mas o backend não assume isso — trata qualquer PATCH multi-campo corretamente). `_snapshot_linha_plano_saude()` (módulo-level, reaproveitado nos três pontos) monta o `dados_linha`. `vinculo_automatico` é gravado em `ImportacaoPlanoSaudeViewSet.create()` (ver "Vínculos de nome salvos (DE/PARA)" acima), não no `LinhaViewSet`.
|
||
- **`POST /api/importacoes-plano-saude-alteracoes/{id}/reverter/`** (`ImportacaoPlanoSaudeAlteracaoViewSet.reverter`) — idempotente, recusa reverter de novo uma alteração já `revertida`. A própria reversão **não** gera um novo registro de alteração (evitaria um loop de "reverter a reversão"):
|
||
- `edicao`: só possível se `linha` ainda existir (não excluída depois); grava `valor_anterior` de volta no campo.
|
||
- `inclusao`: só possível se `linha` ainda existir; deleta a linha diretamente (bypassa `ImportacaoPlanoSaudeLinhaViewSet.perform_destroy`, então não cria um registro `exclusao` pra essa reversão).
|
||
- `exclusao`: sempre possível (a linha já está excluída por definição) — recria uma `ImportacaoPlanoSaudeLinha` nova a partir do snapshot em `dados_linha` (+ `tipo_lancamento` guardado à parte) e aponta `alteracao.linha` pra ela.
|
||
- `vinculo_automatico`: zera `valor`/`valor_empresa` da linha vinculada (reaplicando a regra empresa da família, se `tipo_lancamento == "mensalidade"` e a importação tiver `regra_empresa`) e apaga o `VinculoNomeOperadora` associado — ver "Vínculos de nome salvos (DE/PARA)" acima.
|
||
- **Frontend** (`importacao-plano-saude.js`, `panelHtmlAlteracoes()`): lista já vem do backend ordenada do mais recente pro mais antigo (`Meta.ordering = ["-criado_em"]`); sem ordenação/redimensionamento de coluna, ao contrário das abas de linha/auditoria — é um log, não uma planilha editável. Cada linha mostra data/hora, um badge de tipo (`.ips-alteracao-tipo--edicao/--inclusao/--exclusao/--vinculo_automatico`, cores dourado/teal/vermelho/`--accent`), o lançamento, o nome identificado pela linha (`linha_nome`, do serializer — usa o snapshot quando a linha já não existe mais), uma descrição da alteração (`"<campo>: "<anterior>" → "<novo>""` pra edição, texto fixo pra inclusão/exclusão, `"<nome do arquivo>" → <nome vinculado>` pra vínculo automático) e o usuário. A coluna "Ação" mostra "Reverter" ou "Apagar vínculo" (conforme o tipo, com `pidConfirm`, mesmo padrão de "Remover linha") ou o selo "Revertida" quando já foi desfeita; confirmar chama `pidReverterAlteracaoPlanoSaude()` e refaz `pidFetchImportacaoPlanoSaude()` (mesmo padrão de adicionar/remover linha e de "Vincular pessoa") antes de re-renderizar as abas.
|
||
|
||
### Pré-validação de arquivo ao anexar (tela de Nova Importação)
|
||
|
||
Antes de existir isso, os dois arquivos (planilha padrão + arquivo da operadora) só eram validados juntos, no `create()`, e um erro de formato virava a mensagem genérica "O formato de um dos arquivos não está conforme o esperado" — sem dizer qual dos dois. Agora cada anexo é validado sozinho, no momento em que é selecionado, reaproveitando exatamente o mesmo parser que `create()` usaria — sem duplicar nenhuma regra de leiaute em JS (o parsing de PDF/CSV é Python-only, então isso teria que ser uma chamada ao servidor de qualquer forma).
|
||
|
||
- **Endpoint**: `POST /api/importacoes-plano-saude/validar-arquivo/` (multipart `{tipo: "planilha"|"operadora", arquivo, operadora?}`) — sempre `200 {"valido": bool, "mensagem": str}`, nunca um erro HTTP pra "arquivo errado" (esse é um resultado esperado da validação, não uma falha de requisição; só falta de `arquivo`/`tipo` inválido/`operadora` ausente quando `tipo="operadora"` vira 400 de verdade). `_valida_planilha_padrao()` roda `leiaute_sistema.le_planilha_padrao()`; `_valida_arquivo_operadora()` roda `OPERADORAS[operadora_key]["parser"]().extrai()` — os dois gravam o upload num arquivo temporário (`_salva_arquivo_temporario`, `tempfile.NamedTemporaryFile`) só porque essas funções esperam um caminho de arquivo, não um objeto de upload em memória, e apagam o temporário no `finally`; **nada é persistido**. Qualquer exceção do parser (coluna faltando, layout de PDF não reconhecido, CSV com delimitador errado — inclusive o caso real já visto de export com `\t` em vez de `;`) vira `valido=False` com uma mensagem específica pra aquele arquivo; 0 linhas/indivíduos extraídos (arquivo no formato certo mas vazio) também vira `valido=False`.
|
||
- **Frontend** (`importacao-plano-saude.js`): `criarValidadorArquivo()` é a fábrica reaproveitada pelos dois campos (`validadorPlanilha`/`validadorArquivo`) — no `change` do `<input type="file">`, chama `pidValidarArquivoPlanoSaude()` e mostra o resultado abaixo do campo (`.ips-file-field__status`, cores diferentes pra pendente/ok/erro). Cada campo ganhou um botão de remover (`.ips-file-field__remove`, ícone X — só aparece com um arquivo anexado) que limpa o `<input>` e o estado de validação, pro colaborador poder tentar outro arquivo sem precisar recarregar a página quando o anexado voltar como divergente. Trocar a operadora depois de já ter anexado o arquivo dela (`formOperadora` `change`) reexecuta a validação automaticamente (`revalidarSeAnexado()`) — o parser usado depende de qual operadora está selecionada, então um arquivo validado contra a operadora errada precisa ser checado de novo. O botão "Processar" bloqueia (`ehInvalido()`) se qualquer um dos dois arquivos já voltou `valido=False` — mas isso é só uma segunda barreira de UX; o `create()` no servidor continua sendo a validação real e definitiva.
|
||
|
||
Gerar o arquivo é um download binário (CSV ou ZIP), não JSON — por isso `pidGerarArquivoPlanoSaude()` não usa `pidApiRequest` (que sempre tenta `JSON.parse`); faz um `fetch` manual reaproveitando `pidEnsureCsrfCookie`/`pidGetCookie`/`pidErrorMessageFrom` de `api.js` (funções globais na página) e dispara o download via `URL.createObjectURL`.
|
||
|
||
### Cadastro de Regras (separado da execução da importação)
|
||
|
||
Até uma rodada anterior, o custeio (mensalidade/coparticipação por titular/dependente) era configurado **na hora de importar**, em "Nova Importação" — mesmo aplicando uma regra salva, os campos continuavam livres pra edição ali mesmo. O usuário pediu mais segurança operacional: separar de vez o **cadastro** das regras da **execução**, e atrelar cada regra formalmente a uma empresa (antes era só uma convenção de texto livre no campo `nome`, ex. `"092 - Unimed"`, sem nenhum campo estruturado). Duas telas agora:
|
||
|
||
- **"Cadastro de Regras"** (botão na lista principal, ao lado de "+ Nova Importação", abre `#ips-regracad-modal`) — único lugar onde uma `RegraCusteioPlanoSaude` é criada ou editada. "Empresa" (`#ips-regracad-empresa-combo`, códigos distintos entre as regras já cadastradas, mostrando `"<código> - <nome>"` — ver "Nome da empresa (Questor)" abaixo) numa linha própria, com "Operadora" (`#ips-regracad-operadora-combo`, restrito às operadoras com regra pra a empresa escolhida) numa linha abaixo — decisão explícita do usuário, pra o nome da empresa não competir visualmente com a operadora. Os dois comboboxes têm dois botões embutidos na própria barra (ver detalhe em "Nova Importação" abaixo): o "x" pra limpar (`.ips-combo__clear`, só aparece com algo selecionado) e uma seta "▾" (`.ips-combo__toggle`, sempre visível) pra ver de novo a lista completa/as outras opções. Limpar Empresa também limpa Operadora automaticamente (dispara o mesmo `onChange` de quando a empresa é trocada). Como há **no máximo uma regra por combinação empresa+operadora** (`unique_together`, ver abaixo), escolher os dois já resolve a regra pra edição in-place, com "Salvar alterações"/"Excluir regra" — depois de salvar/excluir com sucesso, o modal **fecha** (decisão explícita do usuário; antes continuava mostrando a regra editada). Botão "+ Nova regra" (`#ips-regracad-nova-btn`, ao lado de Empresa) alterna pro modo criação: campo de texto livre "Código da empresa" (`#ips-regracad-novo-codigo-empresa`) com o nome resolvido do Questor ao lado (`#ips-regracad-novo-empresa-nome`, ver "Nome da empresa (Questor)" abaixo) + combobox "Operadora" sem restrição numa linha abaixo (`#ips-regracad-novo-operadora-combo`, catálogo completo de `pipeline.OPERADORAS`) + o mesmo bloco de custeio vazio + "Criar regra" (fecha o modal também, ao concluir). Um segundo botão "+ Nova operadora" (`#ips-regracad-nova-operadora-btn`, ao lado do combobox de Operadora da navegação, só visível quando uma empresa já está selecionada) atalha pro mesmo modo de criação, com o código da empresa já pré-preenchido — pensado pra "essa empresa já tem regra, mas não pra essa operadora".
|
||
- **Indicador de modo** (`#ips-regracad-modo`, pedido explícito do usuário pra nunca confundir "editando" com "criando"): mostra "Editando regra existente: `<código - nome>` · `<operadora>`" (`regracadCarregarParaEdicao()`) ou "Cadastrando regra nova" (`regracadEntrarModoNovo()`) — nada, no estado vazio (`regracadMostrarVazio()`).
|
||
- **A barra de navegação (Empresa/Operadora) some no modo "+ Nova regra"** (`#ips-regracad-toolbar`, `hidden` alternado por essas mesmas três funções) — evita mostrar as duas seções (navegação + criação) ao mesmo tempo, o que confundia qual das duas estava "valendo". Um botão **"Cancelar"** (`#ips-regracad-novo-cancelar-btn`, só visível nesse modo) volta pra navegação (`regracadCancelarNovo()` → `regracadMostrarConformeSelecaoAtual()`, que reexibe a regra que estava sendo vista antes, se alguma) sem fechar o modal inteiro — diferente de "Fechar".
|
||
- **Aviso de duplicidade em "+ Nova regra"** (`#ips-regracad-novo-operadora-duplicada`, `regracadAtualizarNovoOperadoraDuplicada()`, chamada a cada mudança de código ou de operadora): se a combinação já tiver uma regra cadastrada, mostra "Já existe uma regra cadastrada para esta empresa com esta operadora..." abaixo do combobox de Operadora e desabilita "Criar regra" — evita a viagem de ida e volta até a validação do backend (que também recusa, via `unique_together`) pra descobrir o mesmo problema.
|
||
- **Reabrir o modal nunca mostra o estado anterior por um instante**: `abrirCadastroRegras()` chama `regracadMostrarVazio()` de forma síncrona, antes de qualquer `await` (bug real corrigido — antes a limpeza só rodava depois das buscas de operadoras/regras, e o modal reabria mostrando por um instante o que estava na tela antes de ter sido fechado).
|
||
- **"Nova Importação"** (formulário de execução) ficou **só leitura** pra custeio: "Empresa" (`#ips-imp-empresa-combo`, mesma fonte do Cadastro, mesmo `"<código> - <nome>"`) e "Operadora" (`#ips-imp-operadora-combo`, restrito à empresa escolhida, cada um numa linha própria) resolvem a única regra da combinação (`regraResolvidaAtual`, JS) e mostram um **resumo só-leitura** (`#ips-imp-resumo` — tipos cobertos, custeio de mensalidade/coparticipação, observações), sem nenhum campo editável. Nenhuma empresa aparece nesse combobox sem já ter uma regra cadastrada — cadastrar/editar uma regra pra uma empresa nova é sempre um passo anterior, feito em "Cadastro de Regras". Na tela de Revisão, `#ips-review-empresa` (ao lado do título "Revisão") mostra `"<código> - <nome>"` da empresa sendo importada, pra identificar de cara sem precisar abrir a aba de linhas. Os dois comboboxes (aqui e nos três de "Cadastro de Regras") têm dois botões embutidos na própria barra: o "x" (`.ips-combo__clear`, só aparece com algo selecionado/digitado) e uma seta "▾" (`.ips-combo__toggle`, sempre visível, mesma posição de um `<select>` nativo) — clicar na seta mostra a lista completa de novo, ou, se já houver algo selecionado, as **outras** opções cadastradas (sem repetir a já escolhida). Existe porque só focar o campo com um valor já preenchido filtra a lista pelo texto atual, então só mostraria de novo o item já selecionado — a seta é o jeito de "trocar fácil" pedido pelo usuário, no mesmo espírito de um filtro de BI (clicar, ver todas as opções, escolher outra).
|
||
|
||
O bloco de checkboxes/radios de custeio (`.ips-tipo-field`, mensalidade/coparticipação × titular/dependente/regra específica) e as funções JS que o operam (`coletarCusteioAtual()`, `mensagemErroCusteio()`, `aplicarCusteio()`, `limparCusteioForm()`) foram **movidos** (não duplicados) de "Nova Importação" pro modal de Cadastro — mesmos ids de DOM, mesma lógica, só relocados; "Nova Importação" monta o `FormData` do submit direto a partir do objeto `regraResolvidaAtual` em memória (`montarFormDataDeRegra()`), não mais lendo inputs (que não existem mais ali).
|
||
|
||
- **Campos de `RegraCusteioPlanoSaude`**: `codigo_empresa` (obrigatório — o código do cliente/empresa; **não confundir** com o código de cadastro da operadora no Questor, que já aparece dentro do label de `pipeline.OPERADORAS`, ex. `"5060 - Unimed Saúde"` — são códigos diferentes), `operadora` (obrigatória agora, validada contra `pipeline.OPERADORAS`), `regra_empresa_chave` (ver "Regra empresa" abaixo), `tipos_lancamento`/`custeio_por_tipo` (mesmo formato dos campos homônimos de `ImportacaoPlanoSaude`) e `observacoes`. `Meta.unique_together = [["codigo_empresa", "operadora"]]` — validado contra os 12 registros reais existentes antes de impor a restrição (nenhuma combinação se repetia). `nome` **deixou de ser digitado** pelo usuário — é sempre derivado em `RegraCusteioPlanoSaudeSerializer.validate()` como `"<codigo_empresa> - <nome da operadora sem o código dela>"` (campo `read_only=True` na API); mantido como campo de model só pra não precisar tocar em todo lugar que já lê `.nome`/`regra_custeio_salva_nome`.
|
||
- **Migração em 3 passos** (mesmo padrão já usado pra `IndicadorDepartamento`, migrations `0033`/`0034`/`0035`): `0041` adiciona `codigo_empresa`/`regra_empresa_chave` (blank) + torna `operadora` obrigatória; `0042` (RunPython) faz o backfill de `codigo_empresa` a partir do `nome` existente (`nome.split(" - ", 1)[0].strip()`); `0043` torna `codigo_empresa` obrigatório e adiciona o `unique_together`. `Meta.ordering` usa `[Length("codigo_empresa"), "codigo_empresa", "operadora"]` (mesmo padrão de `IndicadorApuracaoEmpresa`) pra ordenar o código como número, não como string.
|
||
- **Validação reaproveitada, não duplicada**: `RegraCusteioPlanoSaudeSerializer.validate()` e `ImportacaoPlanoSaudeCreateSerializer.validate()` continuam chamando a mesma função módulo-level `_monta_regra_custeio()` (`serializers.py`) pra validar/parsear cada combinação tipo×pessoa. `UniqueTogetherValidator` é declarado explicitamente em `Meta.validators` (não só o automático do DRF), pra manter a mensagem de erro em português.
|
||
- **`ImportacaoPlanoSaude.regra_custeio_salva`** (FK opcional, `SET_NULL`) registra qual regra foi aplicada numa importação — agora praticamente sempre preenchida (já que "Nova Importação" só resolve custeio a partir de uma regra cadastrada), mas o campo continua opcional a nível de API (a garantia de "sempre passar por uma regra cadastrada" é uma trava de UI, não uma obrigatoriedade no backend). Alimenta `regra_custeio_salva_nome`/`regra_custeio_salva_observacoes` na tela de Revisão, como antes.
|
||
- **Trava de conferência do código de empresa** (`ImportacaoPlanoSaudeViewSet.create()`, depois do processamento e antes do `bulk_create` das linhas): se `regra_custeio_salva` está presente, confere que ao menos uma linha da planilha padrão processada tem `codigo_empresa` igual ao da regra; se não bater, desfaz a importação (mesmo padrão de cleanup dos outros `except` desse método) e devolve 400 com mensagem clara — evita aplicar a regra de uma empresa a uma planilha de outra por engano. Vale pra toda regra aplicada, inclusive as com `regra_empresa_chave` (onde é redundante com a checagem que `regras_empresa.valida_regra_empresa()` já faz — proteção extra contra o registro em `REGRAS_EMPRESA` ficar dessincronizado da `RegraCusteioPlanoSaude` correspondente).
|
||
|
||
**Nome da empresa (Questor)** — primeiro consumidor real do pacote `database/` (ver [[project_database_package]] na memória): resolve e cacheia localmente o nome de uma empresa a partir do seu `codigo_empresa`, pra mostrar `"<código> - <nome>"` em vez de só o código nas telas acima.
|
||
|
||
- **`EmpresaQuestor`** (models.py, migração `0044`): `codigo_empresa` (único) + `nome_empresa`, um cache local simples — sem relação de FK com `RegraCusteioPlanoSaude` (é uma propriedade da empresa, não da regra; várias regras podem compartilhar o mesmo `codigo_empresa` com operadoras diferentes, ex. "221" com Bradesco/Itamed/Unimed, e todas reaproveitam a mesma linha de `EmpresaQuestor`).
|
||
- **`portal_api/empresas_questor.py`, `resolve_nome_empresa(codigo_empresa)`**: olha o cache primeiro; só na ausência dele consulta o Questor (`database.connection.DatabaseConnection("questor")` — chave em **minúsculas**, `DatabaseSettings` normaliza as chaves de `DATABASE__<NOME>__*` do `.env` assim, ao contrário do que o padrão de nomenclatura das próprias env vars sugere) executando `sqls.questor.QuestorSQL.consulta_nome_empresa()` (`select codigoempresa, nomeempresa from empresa where codigoempresa = :codigo_empresa` — a consulta exata fornecida pelo usuário, só parametrizada), e persiste o resultado antes de devolver — nunca precisa repetir a consulta pro mesmo código depois. Qualquer falha (código inexistente, `codigoempresa` do Questor é `smallint` e um código fora da faixa numérica levanta `DataError`, banco inacessível) é capturada e devolve `None` — nunca propaga a exceção, já que isso é só informativo, nunca bloqueia cadastrar/editar/excluir uma regra.
|
||
- **`normalizar_codigo_empresa(valor)`** (mesmo arquivo): remove zero à esquerda (`"092"` → `"92"`) — decisão explícita do usuário, pra sempre ter um único código canônico por empresa (o `codigoempresa` do Questor é `smallint`, então "092"/"92" já eram a mesma linha lá; sem normalizar no Portal, apareciam como duas empresas "diferentes"). Aplicada em toda entrada de `codigo_empresa` vinda de fora: `resolve_nome_empresa()`, `RegraCusteioPlanoSaudeSerializer.validate_codigo_empresa()` (o que é de fato salvo em `RegraCusteioPlanoSaude.codigo_empresa`), a action `nome-empresa` (devolve o código já normalizado, pro frontend reescrever o campo), e a trava de conferência em `ImportacaoPlanoSaudeViewSet.create()` (normaliza os dois lados antes de comparar, já que o código bruto da planilha pode ter zero à esquerda enquanto o da regra não tem mais). **Nunca** aplicada a `ImportacaoPlanoSaudeLinha.codigo_empresa` em si (precisa continuar exatamente como veio da planilha, pra não alterar o que é reexportado) — só normalizada no momento de uma comparação/exibição pontual (ver `_nome_empresa_cacheado()`, que normaliza antes de consultar `EmpresaQuestor` a partir do código cru de uma linha).
|
||
- **`sqls/questor.py`** (pacote novo na raiz do projeto, ao lado de `database/` — seguindo a convenção "uma pasta `sqls/` por projeto consumidor, um arquivo por banco" já documentada na memória): classe `QuestorSQL`, hoje só `consulta_nome_empresa()`. Adicionar uma consulta nova ao Questor/Tareffa segue o mesmo padrão — método estático devolvendo `SQLQuery(sql=dedent(...), params={...})`; **nunca** usar `execute`/`execute_returning` desses bancos sem autorização explícita (ver [[feedback_bancos_externos_somente_leitura]]).
|
||
- **Correção em `database/settings.py`** (arquivo compartilhado, não específico desta ferramenta): `SUPPORTED_DRIVERS["postgresql"]` apontava pra `"postgresql+psycopg2"`, mas o `.venv` do Portal só tem `psycopg` (v3) instalado, não `psycopg2` — `ModuleNotFoundError` ao tentar conectar. Corrigido pra `"postgresql+psycopg"` (dialeto psycopg3 do SQLAlchemy), reaproveitando a dependência que já existe em vez de instalar `psycopg2-binary` à parte. Se `database/` for reaproveitado por outro projeto que dependa especificamente de `psycopg2` (comportamento antigo), essa mudança precisaria ser revisitada — não é o caso hoje.
|
||
- **Resolução automática pra regras já existentes**: `RegraCusteioPlanoSaudeSerializer.get_nome_empresa()` chama `resolve_nome_empresa()` a cada leitura (não só ao criar/editar) — então regras cadastradas antes deste campo existir tiveram o nome resolvido e cacheado sozinho, na primeira vez que a lista foi carregada depois do deploy, sem precisar de nenhum backfill manual. Já `ImportacaoPlanoSaudeDetailSerializer.get_nome_empresa()` (tela de Revisão) só lê o cache (`_nome_empresa_cacheado()`, sem chamar `resolve_nome_empresa()`) — essa tela é consultada com muito mais frequência, e o nome já deveria estar cacheado desde que a regra foi cadastrada/editada, então não vale pagar o custo de uma consulta ao Questor ali.
|
||
- **Frontend** (`importacao-plano-saude.js`): `GET /api/regras-custeio-plano-saude/nome-empresa/?codigo_empresa=X` (`pidBuscarNomeEmpresaPlanoSaude`) é chamado tanto num debounce de 350ms a cada tecla digitada no campo "Código da empresa" de "+ Nova regra" (`agendarAtualizarNomeEmpresaNovo()`, pedido explícito do usuário pra não precisar esperar o campo perder o foco) quanto no `blur` (imediato, cancela o debounce pendente) — a função de fato (`atualizarNomeEmpresaNovo()`) mostra "Buscando nome da empresa...", depois o nome resolvido ou "Empresa não encontrada no Questor.", e **reescreve o próprio campo** com o `codigo_empresa` normalizado devolvido pela resposta (ex.: usuário digita "092", campo passa a mostrar "92" assim que resolve). Os comboboxes de "Empresa" (Cadastro de Regras e Nova Importação) não fazem nenhuma chamada nova — `labelEmpresa()` monta `"<código> - <nome>"` direto do array `regras` já carregado, que já vem com `nome_empresa` resolvido pelo backend.
|
||
|
||
### Regra empresa (custeio especial de mensalidade por família)
|
||
|
||
Cobre regras de custeio negociadas com uma empresa específica que não cabem no desenho normal "por tipo de lançamento × titular/dependente", tipicamente porque são calculadas por **família inteira** (titular + todos os dependentes somados), não por pessoa. O algoritmo em si (`portal_api/planos_saude/regras_empresa.py`, `REGRAS_EMPRESA: Dict[str, dict]`) continua sendo um registro fixo no código, cadastrado pelo desenvolvedor quando o cliente repassa uma regra nova — mas **desde a rodada do Cadastro de Regras separado, a escolha de USAR uma regra especial deixou de ser um checkbox independente na tela de importação e passou a viver dentro do cadastro por empresa+operadora**: o campo `RegraCusteioPlanoSaude.regra_empresa_chave` (chave de `REGRAS_EMPRESA`) é configurado uma vez, junto do resto do custeio, no modal "Cadastro de Regras" — "Nova Importação" só resolve o que já foi cadastrado, sem checkbox próprio.
|
||
|
||
- **Mutuamente exclusivo com custeio manual de mensalidade, agora por campo da regra**: no formulário de Cadastro de Regras, marcar "Mensalidade usa regra especial da empresa" (`#ips-form-tipo-regra-empresa`, ids preservados do checkbox antigo) desmarca e esconde os radios titular/dependente de Mensalidade (e vice-versa) — mesma exclusividade de antes, só que dentro do cadastro em vez de na execução. No backend, `RegraCusteioPlanoSaudeSerializer.validate()` exige `"mensalidade"` em `tipos_lancamento` quando `regra_empresa_chave` é preenchida, confere que `REGRAS_EMPRESA[chave]["codigo_empresa"]`/`["operadora"]` batem com os da própria regra (trava contra vincular o algoritmo de uma empresa a outra por engano) e zera `custeio_por_tipo["mensalidade"]`. No submit de "Nova Importação", `montarFormDataDeRegra()` envia `regra_empresa=<chave>` a partir de `regraResolvidaAtual.regra_empresa_chave` — o restante do pipeline (`ImportacaoPlanoSaudeCreateSerializer.validate()`, `pipeline.processa_importacao`, `regras_empresa.valida_regra_empresa`) **não mudou**, só a origem do valor no frontend.
|
||
- **Registro** (`REGRAS_EMPRESA`): cada entrada tem `label`, `codigo_empresa` (código da empresa na planilha padrão pra qual a regra foi negociada), `operadora`, `aplica` (função que faz o cálculo) e `observacoes`. Pra cadastrar uma regra nova: escrever a função e registrar aqui — nada mais precisa mudar (`GET /api/importacoes-plano-saude/regras-empresa/` já reflete o registro, consumido tanto pelo seletor dentro do Cadastro de Regras quanto pelo resumo só-leitura de "Nova Importação").
|
||
- **Observações da regra, só-leitura na tela de Revisão** (`#ips-review-regra-empresa-obs`) — inalterado: `ImportacaoPlanoSaudeDetailSerializer.regra_empresa_observacoes` resolve `REGRAS_EMPRESA[obj.regra_empresa]["observacoes"]` a cada carregamento; o mesmo bloco cai pra `regra_custeio_salva_observacoes` quando não há regra empresa.
|
||
- **Primeira regra**: `unimed_1778_tecnomyl` — Tecnomyl (código 1778 na Unimed) tem ajuda de custo de até R$ 661,61 por família (titular + dependentes juntos, não por pessoa): família com mensalidade total acima do teto tem o excedente descontado do empregado; igual ou abaixo do teto, a empresa cobre 100%. Repassada pelo cliente em 08/2026.
|
||
- **Cálculo é por família, com prioridade explícita: dependentes primeiro, titular absorve o residual** (`regras_empresa._aplica_teto_familia`) — decisão explícita do cliente, e diferente de uma primeira versão (revertida) que distribuía o teto **proporcionalmente** entre todas as linhas. O algoritmo percorre primeiro os dependentes (na ordem em que aparecem no arquivo da operadora), cada um recebendo `valor_empresa = min(seu valor, o que sobrou do teto)`; só depois de todos os dependentes processados o titular absorve o que sobrou do teto (`teto_restante`), com o excedente (se houver) virando desconto do empregado nessa mesma linha. Ex.: família com dependente de R$559,57 e titular de R$314,12 (teto R$661,61) — dependente sai com `valor_empresa=559,57`/`valor=0` (coberto integralmente), sobra `661,61-559,57=102,04` de teto pro titular, que sai com `valor_empresa=102,04`/`valor=212,08`. Se os dependentes sozinhos já consumirem o teto inteiro, o titular fica com `valor_empresa=0` (desconto integral) e, se ainda sobrar dependente sem cobrir depois disso, esse dependente também é parcialmente descontado. Validado rodando o pipeline direto com os dois exemplos passados pelo cliente (família de R$800 → R$661,61 empresa/R$138,39 empregado no total; família abaixo do teto → 100% empresa) e reproduzindo exatamente um caso real reportado pelo usuário (família Caroline Fernandes/Luciano Ramos, R$873,69 no total) depois do ajuste de prioridade.
|
||
- **Só funciona com casamento por nome** (`chave_casamento == "nome"`, ex.: Unimed) — a agregação por família depende do agrupamento que `matcher._casa_por_nome` já faz (por `numero_titular`); `_casa_por_cpf` não tem esse agrupamento e não foi estendida pra suportar (não havia necessidade ainda). `regras_empresa.valida_regra_empresa()` recusa explicitamente (`RegraEmpresaIncompativelError`, capturada à parte em `views.py` pra devolver a mensagem certa, não o erro genérico de "formato de arquivo") se a operadora escolhida não for compatível, e também recusa se a planilha padrão anexada não tiver nenhuma linha com o `codigo_empresa` esperado pela regra — trava contra aplicar a regra da Tecnomyl na planilha de outra empresa por engano (a mesma ideia da trava geral descrita acima, só que específica pra este mecanismo e mais antiga).
|
||
- **`_aplica_teto_familia` é duck-typed de propósito** (`linhas_e_valores: List[Tuple[Any, float]]`, `_eh_linha_titular()` própria em vez de `LinhaSistema.eh_linha_titular()`): roda tanto contra `LinhaSistema` (pipeline, na criação da importação) quanto contra `ImportacaoPlanoSaudeLinha` (model Django, no recálculo pós "Vincular pessoa" — ver `views._recalcula_familia_regra_empresa` e "Resolução manual de auditoria por nome" acima) — as duas classes têm os mesmos atributos de string (`nome_dependente`/`cpf_dependente`/`valor_empresa`/`valor`), só a segunda não tem o método `eh_linha_titular()`.
|
||
- **Coparticipação nunca é afetada**: `regra_empresa_chave` só cobre `"mensalidade"`; se a regra também cobrir `"coparticipacao"`, ela segue o custeio normal configurado no mesmo cadastro (radios titular/dependente), sem nenhuma ligação com a regra empresa.
|
||
|
||
## CSS — organização entre arquivos
|
||
|
||
| Arquivo | Contém |
|
||
|---|---|
|
||
| `tokens.css` | Variáveis (`:root`, tema claro em `:root[data-theme="light"]`). |
|
||
| `base.css` | Reset global, incluindo `[hidden] { display: none !important; }` — necessário porque vários componentes (`.no-access`, `.app-card`, `.notif-badge`) definem seu próprio `display`, o que sem o `!important` sobrescreveria o comportamento nativo de `hidden`. Também os `@keyframes` globais de animação (`pidFadeIn`/`pidFadeSlideUp`/`pidScaleIn`, ver "Animações" abaixo) e `.pid-icon-eye`/`pidIconBlink` (piscar de olho do ícone "P.I.D.", reaproveitado pela sidebar e pelo login — ver "Ícone do login e da sidebar são clicáveis" abaixo), já que é o único CSS carregado por **todas** as páginas sem exceção (inclusive `index.html`). |
|
||
| `layout.css` | Casca do shell: `.app-shell`, `.sidebar*`, `.nav-*`, `.fav-toggle`, `.topbar*`. A sidebar usa tokens **congelados**, independentes de tema (fundo sempre escuro em claro/escuro) — não trocar por variáveis que espelham `:root[data-theme="light"]`. Exceção deliberada: `--sidebar-text-primary`/`--sidebar-text-secondary`/`--sidebar-text-muted` (texto/ícone do menu) *são* sobrescritas em `:root[data-theme="light"]` (`tokens.css`) pra branco puro — pedido explícito do usuário pra melhorar a legibilidade; só o fundo/borda da sidebar continuam frozen. Também `.page-content` (largura do conteúdo de cada página, `max-width:1200px` centralizado por padrão) + o modificador `.page-content--wide` (`max-width:1600px`) — `portal.html` ("Principal") é a única página que usa só `.page-content` puro (grade de favoritos fica mais confortável de leitura mais estreita); as outras 7 páginas (`perfis-acesso.html`, `usuarios.html`, `links-ferramentas.html`, `acessos-gerais.html`, `ramais.html`, `calendario-individual.html`, `importacao-plano-saude.html`) usam `class="page-content page-content--wide"` no `<main>`, decisão explícita do usuário pra aproveitar melhor o espaço entre a sidebar e a borda da tela em telas de tabela/formulário. Uma página nova que seja mais "aplicação" (tabela, formulário, CRUD) do que "dashboard" deve nascer já com `page-content--wide`. |
|
||
| `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 seção "Liderança" — usada em `usuarios.html` e no modal "Gerenciar Usuário" de todo shell), `.info-tooltip`/`.info-tooltip__*`/`.ajuda-modal__*` (botão "?" + tooltip + modal de "Mais informações", ver seção própria abaixo — usados por `static/js/ajuda-aplicacao.js`) e `.modal-overlay--top` (empilha um modal por cima de outro já aberto — usado só pelo modal de confirmação genérico, `static/js/confirm-modal.js`, ver "Modal de confirmação genérico" abaixo). |
|
||
| `perfis-acesso.css` | `.pa-*` (tela de Perfis de Acesso), incluindo as seções (`.ua-section*`) e campos específicos (`.ua-inline-add`/`.ua-departamento-item`/`.ua-active-toggle`/`.ua-liderados-field`) do formulário de edição de `usuarios.html`. |
|
||
| `calendario.css` | Só `.calendar-*` (grade mensal, células de dia, nav do mês) — os campos do modal de compromisso usam as classes genéricas `.modal-field`/`.modal-checkbox`/`.modal-error`/`.modal-actions` de `components.css`. |
|
||
| `widgets.css` | `.widgets-*`, `.widget-card*`, `.widget-picker-*` — só usado em `portal.html`. O topbar da tela inicial não tem mais título/slogan nenhum (`<h1 id="portal-title">` — chegou a existir brevemente com o slogan "Grandes aplicações de todos os tamanhos" em fonte "Pinyon Script"/dourado, removido a pedido do usuário na mesma rodada; ver `login.css` abaixo pra onde o slogan acabou indo) — o `<link>` do Google Fonts em `portal.html` também foi removido junto, já que não sobrou nenhum uso de fonte customizada nessa página. |
|
||
| `login.css` | Só usado em `index.html`; `.login-card__title` ("Portal Interno da De Paula") usa a fonte "Bree Serif" (importada só nesta página). O slogan da marca ("Grandes aplicações de todos os tamanhos.", `pid-marca-leiame.md` tem esse e um segundo, "Ainda funciona. Agora pensa.", não usado em lugar nenhum) mora aqui, não no topbar de `portal.html` (onde chegou a existir e foi removido, ver `widgets.css` acima) — passou primeiro pelo rodapé do card (abaixo de "Esqueceu sua senha?...") antes de subir pra logo abaixo do título, posição atual (pedido explícito do usuário). `.login-heading` (`display:flex; flex-direction:column; gap:var(--space-1)`) agrupa `.login-card__title` e `.login-slogan`, com um espaçamento bem menor entre os dois (`--space-1`) do que o `gap:var(--space-5)` que `.login-card` usa entre seus próprios filhos diretos — sem esse agrupamento, o slogan ficaria longe demais do título pra parecer uma assinatura. `.login-slogan` usa a fonte script "Pinyon Script" (mesmo `<link>` do Google Fonts de `.login-card__title`, agora com as duas famílias), `font-size:1.2rem` (menor que o `1.3rem` do título do card, pedido explícito do usuário) e cor dourada fixa `#c6a24a` (cor da marca, não um token de tema/`--accent`). O card do login é **congelado escuro nos dois temas** (`--login-card-bg`/`--login-field-bg`/`--login-border`/`--login-text-*`, definidos só em `:root` de `tokens.css`, nunca redefinidos em `:root[data-theme="light"]` — mesmo padrão de `--sidebar-*`, ver comentário ao lado deles em `tokens.css`) — pedido explícito do usuário; a logo dentro dele também não alterna mais por tema (ver "Ícone do login e da sidebar são clicáveis" acima — decisão de uma rodada seguinte, revertendo a alternância que existia antes). Só o fundo da própria página ao redor do card (`.login-page`) continua acompanhando o tema claro/escuro, mas de formas diferentes em cada um: no **escuro** (padrão), `background: linear-gradient(var(--overlay-scrim), var(--overlay-scrim)), radial-gradient(circle at 20% 20%, rgba(var(--accent-rgb), 0.25), transparent 45%), var(--bg-canvas)` — um scrim escuro + um brilho radial na cor do tema sobre o `--bg-canvas` escuro, pensados pra dar profundidade; no **claro**, `:root[data-theme="light"] .login-page` zera o scrim (deixava tudo acinzentado/amarronzado sobre um fundo já claro) e refinou o resto em duas rodadas: primeiro só `background: var(--bg-canvas)` liso (pedido explícito do usuário, "mesmo tom do fundo da tela principal"); depois, a pedido do usuário de novo ("tom de roxo um pouco mais claro e o fundo branco levemente escurecido"), voltou a ter um brilho radial (`rgba(accent, 0.12)` — bem mais sutil que o `0.25` do tema escuro, um glow forte fica turvo sobre fundo claro) sobre uma cor base levemente mais escura que o `--bg-canvas` puro (`#f3f1f7` → `#ece7f2`, só nesta tela — não altera o token `--bg-canvas`, então o resto do Portal no tema claro continua com o tom original). |
|
||
| `links-ferramentas.css` | `.lf-*` — só usado em `links-ferramentas.html`. |
|
||
| `acessos-gerais.css` | `.ag-*` — só usado em `acessos-gerais.html`. |
|
||
| `ramais.css` | `.ram-*` — só usado em `ramais.html`; a tabela em si reaproveita `.pa-table*`/`.pa-row-actions` de `perfis-acesso.css` (mesmo padrão que `usuarios.html` já usa sem CSS próprio). |
|
||
| `ramais-lookup.css` | `.ram-lookup-*` — modal de consulta rápida de ramais (ver "Ramais" abaixo), usado em `portal.html`/`links-ferramentas.html`/`calendario-individual.html`. Tabela **autocontida** (não reaproveita `.pa-table` porque essas 3 páginas não carregam `perfis-acesso.css`). |
|
||
| `importacao-plano-saude.css` | `.ips-*` — só usado em `importacao-plano-saude.html`; carrega `perfis-acesso.css` também, pra reaproveitar `.pa-table`/`.pa-tabs`/`.pa-table-wrap` na tabela editável da revisão e nas abas. |
|
||
| `custo-contratacao.css` | `.cc-*` — só usado em `custo-contratacao.html`. |
|
||
| `indicador-desempenho.css` | `.ind-*` — só usado em `indicador-desempenho.html`; carrega `perfis-acesso.css` também, pelo mesmo motivo de `importacao-plano-saude.css` (tabela/abas de revisão). |
|
||
|
||
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`).
|
||
|
||
## Animações
|
||
|
||
Três `@keyframes` genéricos em `base.css` (`pidFadeIn`, `pidFadeSlideUp`, `pidScaleIn`) — reutilizados via `animation` (não `transition`) em elementos que entram/saem do layout via `hidden`/`display:none` (modal, dropdown, `.page-content` a cada navegação, `.login-card`), porque só `animation` reinicia sozinho quando um elemento passa de `display:none` para visível; `transition` não anima essa troca (não há frame intermediário). Duração sempre curta (120–200ms) — pedido explícito do usuário: "fluidas, porém rápidas, otimizando o tempo". Botões (`.btn-solid`/`.btn-outline`/`.btn-ghost`/`.btn-danger-outline`/`.icon-btn`) ganharam `transform: scale()` no `:active` como feedback de clique.
|
||
|
||
**Nenhuma dessas animações respeita `prefers-reduced-motion`** — decisão deliberada do usuário ("as animações devem ignorar a preferência de não mostrar animações ou de acessibilidade do computador do usuário"), não um descuido. Não adicionar um bloco `@media (prefers-reduced-motion: reduce)` desativando isso sem confirmar de novo com o usuário, já que contraria um pedido explícito.
|
||
|
||
### Animação de intro do login
|
||
|
||
Ao apertar "Entrar" com sucesso, `index.html` toca uma animação de marca em tela cheia (~6,2s: 500ms de fade + ~5,7s de animação) antes de navegar pra `portal.html` — pedido explícito do usuário, inspirado num arquivo `pid-intro-escuro.html` que ele forneceu (mesma pasta de origem dos SVGs da marca "P.I.D.", `C:\Users\Depaula\Documents\Projetos\LOGOS\pid-marca`).
|
||
|
||
**Origem do arquivo — por que não foi só copiado**: `pid-intro-escuro.html` não é HTML/CSS simples, é um bundle auto-contido de um editor de "Design Canvas" (Anthropic) — um `<script type="__bundler/manifest">` com um JSON mapeando uuid → recurso (`{mime, compressed, data}`, `data` sendo base64 de um gzip), incluindo o JSX fonte de verdade (`pid-intro.jsx`, a composição em si) e a biblioteca de motion (`animations-v3.jsx`, easing/interpolação), montados em tempo real por um runtime React + motor de composição por "tempo autorado" (`useComposition`/`CompositionStage`) que não faz sentido carregar em produção (pesado, e pensado pra edição/export de vídeo, não pra rodar dentro de uma página de login). A coreografia foi **extraída** (decodificado gzip+base64 por uuid, script Python ad-hoc) e **portada fielmente** pra vanilla JS/CSS — mesmas durações de cena, mesmas curvas de easing (hand-rolled, estilo Popmotion) e mesmo timing de piscada dos olhos do original; só o destino final do "voo" do ícone foi recalibrado (ver "Cena Portal" abaixo).
|
||
|
||
**As 4 cenas** (`PID_INTRO_CUES`/`PID_INTRO_TOTAL` em `login-intro.js` — `Build:0, Face:1, Wordmark:2, Portal:4.8`, total `5.7`): durações **aceleradas** em relação ao arquivo original (`Build`/`Face` de 2,6s/2s pra 1s cada, `Portal` de 2,3s pra ~0,9s — pedido explícito do usuário, "acelere um pouco a velocidade na qual a logo é construída"), exceto `Wordmark` (2,8s, igual ao original) — "a parte onde aparece o nome do portal deve permanecer a mesma", pedido explícito também. Os deslocamentos internos de cada cena (quando cada elemento começa/termina de animar) foram reproporcionados pra caber na duração nova, não são mais os valores originais do arquivo — só a cena Wordmark manteve os originais, já que a duração dela não mudou.
|
||
- **Build** (1s): o contorno do corpo do ícone se desenha (`stroke-dashoffset`, `pathLength="100"` normaliza o path pra unidades 0–100 independente da geometria real), preenche a cor, a aba dourada "cai" (`easeOutBack`, dá um leve overshoot) e a "tela"/rosto do ícone abre (scale+opacity).
|
||
- **Face** (1s): os dois olhos aparecem com "pop" (`easeOutBack`) e piscam uma vez (`pidIntroBlinkAt()` — uma janela de 220ms em que a escala vertical do olho vai de 1 a 0 e volta a 1, formando o fecha-e-abre; a mesma função é reaproveitada em 2 momentos diferentes agora, ver abaixo — o terceiro blink do original, na cena Portal, foi removido: a cena ficou curta demais pra caber um blink visível com o ícone já encolhendo/voando), o sorriso dourado se desenha (mesma técnica de `stroke-dashoffset` do corpo).
|
||
- **Wordmark** (2,8s, **inalterada**): o ícone desliza pra esquerda (`SHIFT_X=-100px`) enquanto "P.I.D." (fonte "Space Grotesk" 700, cada letra com um "." dourado à parte) + uma régua dourada (`scaleX`) + a tagline "Portal Interno da De Paula" (fonte "DM Mono", uppercase, letter-spacing largo) aparecem — cada letra com seu próprio atraso escalonado (`CUES.Wordmark + 0.25 + i*0.13`).
|
||
- **Portal** (~0,9s): a wordmark esmaece quase na hora (`Portal` a `Portal+0.3`), o ícone encolhe (de 160px pra 40px — o mesmo tamanho de `.sidebar__logo--icon`, ver "Logos em `static/img/`" acima) e "voa" até o canto superior esquerdo em 0,5s (`Portal+0.05` a `Portal+0.55`), e o stage inteiro (ícone+wordmark) esmaece logo depois, sobrepondo o fim do voo (`Portal+0.5` a `Portal+0.9`) — sem pausa parada entre o ícone assentar e o fade começar (ajuste de uma rodada anterior, que já tinha comprimido essa cena de 2,3s pra ~0,95s; esta rodada só encurtou mais um pouco, até ~0,9s). **Diferença deliberada em relação ao original** (além do tempo): lá o destino é um pixel fixo dentro de um frame de vídeo de exportação 1920×1080 (`logoX:-892, logoY:-496`, coordenadas que não existem em página nenhuma); aqui, `pidIntroCornerTarget()` calcula o alvo em tempo real a partir de `window.innerWidth/innerHeight`, mirando um ponto perto do canto real da janela — a ideia de "o ícone termina indo pro cabeçalho/sidebar do app" só faz sentido revisitada assim, já que o original nunca foi pensado pra rodar dentro de uma página de verdade.
|
||
- As duas piscadas do olho (`Face+0.8`, `Wordmark+1.1`) e as três funções de easing usadas (`easeOutCubic`/`easeInOutQuad`/`easeOutBack`) seguem as fórmulas exatas extraídas de `animations-v3.jsx` — ver o código de `login-intro.js` se precisar ajustar timing, não redesenhar do zero.
|
||
|
||
**Fade de entrada, antes do ícone começar a se desenhar** (pedido explícito do usuário — a primeira versão fazia o overlay aparecer de repente por cima do card ainda visível, "de um modo bruto"; a duração foi ajustada de 350ms pra 500ms numa rodada seguinte, "está muito rápido... só pra ficar mais fluído"): `pidPlayLoginIntro()` aplica `.is-leaving` no `.login-card` (`login.css`, `opacity:0; transform:scale(0.98)`, transição de 500ms) e `.is-visible` no `#login-intro` (`opacity:0→1`, mesmos 500ms, `PID_INTRO_FADE_MS`) ao mesmo tempo — o card se dissolve enquanto o overlay (já na cor final sólida) sobe por cima, um cross-fade real, não uma troca instantânea. Só depois desses 500ms (`setTimeout`) é que o loop de `requestAnimationFrame` do ícone começa (`start = performance.now()` é atribuído só nesse momento, não antes).
|
||
|
||
**Arquivos**: `static/css/login-intro.css` (só `index.html`) — formas idênticas às já usadas em `pid-icone-escuro.svg`/`pid-logo-horizontal-escuro.svg` (hex hardcoded — `#7b5ba8`/`#c6a24a`/`#f2ede3`, não são tokens de tema, são a paleta fixa da marca, mesmo espírito de `.login-slogan` já hardcodar `#c6a24a`); a **exceção** é o fundo do overlay, que reage ao tema (ver "Tema claro" logo abaixo) em vez de ser um hex fixo da marca. `static/js/login-intro.js` expõe `pidPlayLoginIntro(onDone)` (global, chamada só por `auth.js`) — monta o loop de `requestAnimationFrame`, calcula cada valor (`bodyDraw`, `eyeL`, `shift`, `fly`, ...) a partir de `T` (segundos decorridos desde o início do loop, via `performance.now()`) e escreve direto nos atributos/estilo dos elementos do overlay (`#login-intro`, já presente e `hidden` no HTML de `index.html`); chama `onDone()` quando `T` atinge `PID_INTRO_TOTAL`. Fontes "Space Grotesk" (peso 700) e "DM Mono" adicionadas ao mesmo `<link>` do Google Fonts de `index.html`, junto de "Bree Serif"/"Pinyon Script" já usadas ali.
|
||
|
||
**Tema claro**: o fundo do overlay (`#121017`, o valor fixo de `--bg-canvas` no tema escuro) e o texto do wordmark (`.login-intro__letter`/`.login-intro__tagline`, cor clara — pensados pra contrastar com um fundo escuro) só faziam sentido enquanto o overlay era sempre escuro (decisão original, pra login → animação → portal ler como uma coisa só). O usuário pediu que, no tema claro, o fundo da animação também acompanhasse o `--bg-canvas` claro (mesmo raciocínio já aplicado a `.login-page` em `login.css`) — o que por sua vez tornou o texto claro do wordmark ilegível contra um fundo claro. `:root[data-theme="light"]` overrides em `login-intro.css` cobrem os dois: `.login-intro` vira `background: var(--bg-canvas)` e `.login-intro__letter`/`.login-intro__tagline` viram cores escuras (`#241c33`/`rgba(36, 28, 51, 0.62)`, os mesmos tons só invertidos). O **ícone não precisou de override** — o corpo roxo (`#7b5ba8`) tem contraste de sobra contra um fundo claro, e a "tela" do disquete (`#241c33`) já é escura por si só, então os olhos/sorriso continuam legíveis nos dois temas sem mudar nada. Nada disso afeta o tema escuro (padrão), que continua com os valores fixos originais.
|
||
|
||
**Fluxo de navegação**: `auth.js`, no sucesso do `POST /api/auth/login/`, chama `pidPlayLoginIntro(() => { sessionStorage.setItem("pid_reveal_portal", "1"); window.location.href = "portal.html"; })` em vez de navegar direto — a navegação só acontece depois da animação inteira (não há como "pular" a animação hoje, nem foi pedido).
|
||
|
||
**"A barra lateral surgindo da esquerda para a direita, e em seguida o resto da tela"** (pedido explícito do usuário sobre como a tela Principal deveria surgir depois da animação, refinado duas vezes: a primeira versão só escondia `.main-content` e deixava a sidebar sempre visível desde o início, sem nenhuma entrada própria; a segunda versão deu à sidebar um fade + deslize sutil de 16px, considerado "ainda não satisfatório" — pequeno demais pra ler como "surgindo da esquerda pra direita"): `static/js/portal-reveal.js` (só `portal.html`, incluído `defer` logo depois de `theme.js` — mesmo padrão de "script que roda como IIFE de topo antes da primeira pintura pra evitar flash", ver "Ordem de `<script>`" acima) checa a `sessionStorage` marcada por `auth.js`; se presente, remove a marca (não sobrevive a um F5) e aplica **duas** classes em `<html>` antes do `DOMContentLoaded`: `pid-entering-sidebar` (esconde só `.sidebar`, `opacity:0` + `transform:translateX(-100%)` — a largura inteira dela, não um deslize de poucos pixels, pra realmente ler como um slide de fora da tela) e `pid-entering` (esconde `.main-content`, o `<div>` que envolve topbar **e** conteúdo da página). As duas são removidas **em sequência**, não juntas: `pid-entering-sidebar` sai primeiro (120ms depois do `DOMContentLoaded`), a sidebar desliza da esquerda pra direita ao longo de 420ms (`.sidebar` em `layout.css` tem uma `transition` própria — `opacity 420ms ease, transform 420ms cubic-bezier(0.16, 1, 0.3, 1)`, mais longa e com uma curva de "chegada" suave, não `var(--transition-base)` — genérica demais pra um movimento desse tamanho); só depois de esperar essa mesma duração (420ms) é que `pid-entering` sai, revelando o resto do app num fade, garantindo que as duas entradas não se sobreponham. Acessar `portal.html` direto (sem passar pelo login) nunca aciona nada disso, já que a `sessionStorage` só é setada no caminho de login bem-sucedido.
|
||
|
||
Testado pelo usuário em três rodadas (funcional) — ajustes até agora: a cor do overlay (era `#241c33`, virou `#121017`), o fade de entrada antes do ícone começar a se desenhar (não existia, overlay aparecia de repente; a duração foi ajustada depois de 350ms pra 500ms, "muito rápido... só pra ficar mais fluído"), a compressão da cena Portal (era 2,3s com pausa parada, foi pra 0,95s corrida numa rodada e depois ~0,9s), a aceleração de Build/Face (de 2,6s/2s cada pra 1s cada, Wordmark mantida em 2,8s de propósito) e a entrada da sidebar em `portal.html` (de um fade sutil de 16px pra um slide de fora da tela inteiro, `translateX(-100%)`, 420ms). Ainda falta conferir o posicionamento do "voo" final em diferentes tamanhos de tela/com a sidebar colapsada.
|