582 lines
163 KiB
Markdown
582 lines
163 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 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/`
|
||
|
||
`static/img/` tem três variantes do logo "D De Paula Contadores" (D em degradê dourado/marrom + texto), todas PNG com fundo transparente:
|
||
|
||
- `logo.png` — original, texto **preto**. Serve como fonte pra gerar as outras variantes e também é usada diretamente em `index.html` (login) quando `data-theme="light"` — o card do login usa `--bg-surface` (claro nesse tema), e o texto branco de `logo-branco.png` ficava ilegível contra ele.
|
||
- `logo-branco.png` — usada em `index.html` no login quando `data-theme="dark"` (default) e sempre no `sidebar__brand` dos 5 shells (a sidebar usa fundo frozen sempre escuro, independente do tema — ver "Sidebar" em `layout.css` — então não precisa alternar) — 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.
|
||
- A troca da logo do login por tema é feita em `theme.js` (`pidSyncLoginLogo()`): o `<img id="login-logo">` de `index.html` carrega os dois caminhos resolvidos por `{% static %}` em `data-logo-dark`/`data-logo-light`, e o JS só troca o `src` conforme `data-theme` atual — chamado no load e no evento `pid:theme-changed`.
|
||
- `logo-mono.png` — versão totalmente monocromática (D **e** texto em branco/cinza claro). Não usada em nenhum template hoje; existe como variante alternativa (útil se algum dia precisar de um logo "chapado" sem o dourado do D).
|
||
- `favicon.png` — só o D (sem o texto "De Paula Contadores"), quadrado, 192×192, usado como ícone da aba do navegador (`<link rel="icon" type="image/png">` no `<head>` das 7 páginas). Gerado a partir de `logo.png` com o mesmo filtro de `logo-branco.png` (pixel opaco quase-neutro/escuro — `max(r,g,b) < 70` e `spread(r,g,b) < 12` — é texto, não o D), mas em vez de recolorir esses pixels pra branco, eles são apagados (`alpha = 0`); o resultado é recortado pelo bounding box do que sobrou opaco e centralizado num canvas quadrado transparente (o D é mais alto que largo). Se o logo oficial mudar, regerar a partir do novo `logo.png` com o mesmo processo, não editar à mão.
|
||
|
||
Tamanho: `.sidebar__logo` é `width: 200px; height: auto` (era 56×56 fixo, esmagava o logo — a arte é bem mais larga que alta, ~1.41:1 — e ficava pequena demais); encolhe pra `44px` quando a sidebar colapsa (desktop `.is-collapsed` e o breakpoint mobile), senão o logo vaza da faixa de 76px.
|
||
|
||
### 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), `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` (ferramenta "Importação de Plano de Saúde" em Utilitários, ver seção própria abaixo). |
|
||
| `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`. |
|
||
| `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` + `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/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/{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/{id}/` | GET/PATCH | edição de uma linha da revisão (todos os campos, não só valores); mesma permissão da importação, sem conceito de "dono" |
|
||
| `/api/regras-custeio-plano-saude/`, `/api/regras-custeio-plano-saude/{id}/` | GET/POST/PATCH/DELETE | banco de regras de custeio salvas (`nome`+`operadora`+`tipos_lancamento`+`custeio_por_tipo`+`observacoes`, ver "Regras de custeio salvas" abaixo) — mesma permissão de toggle único da ferramenta; lista compartilhada, sem "dono" |
|
||
| `/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 + nova importação (upload) + 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 → 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>`).
|
||
|
||
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.
|
||
|
||
### 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.
|
||
|
||
## 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.
|
||
|
||
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.
|
||
|
||
## 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`) uma `.checklist-box` (`#ua-liderados-checklist`) com todos os outros usuários cadastrados (a própria conta sendo editada é excluída da lista — 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`, uma segunda seção com a mesma `.checklist-box` de liderados — 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.
|
||
|
||
**Busca por nome/departamento e layout em duas linhas**: `UsuarioResumoSerializer` (usado por `/api/usuarios-resumo/` e por `liderados` aninhado em `UsuarioListSerializer`/`/api/me/`) expõe também `departamentos` (nested `DepartamentoSerializer`), não só `id`/`nome`. Os dois pontos que renderizam o checklist de liderados (`users-admin.js`/`account.js`) usam isso pra: (a) cada item (`.checklist-item`) mostrar o nome e, numa segunda linha abaixo (`.checklist-item__info` empilha os dois em coluna), os departamentos do candidato unidos por vírgula (`—` se nenhum) — layout em duas linhas com quebra normal (não uma só linha com `text-overflow: ellipsis`) porque truncar cortava departamentos no meio quando o usuário tinha mais de um vinculado; (b) um campo de busca (`.checklist-search`, `#ua-liderados-search`/`#manage-account-liderados-search`) que filtra a lista pelo nome do usuário **ou** pelo texto dos departamentos (`String.includes`, case-insensitive) — qualquer um dos dois casa. A seleção de checkboxes é mantida em memória num `Set` (`lideradosSelecionados`, um por tela) em vez de lida direto do DOM, porque filtrar re-renderiza `innerHTML` e apagaria o estado de quem estivesse marcado mas momentaneamente fora do filtro; um listener de `change` delegado no container (`.checklist-box`) atualiza esse `Set` a cada clique, e o array final enviado à API vem de `Array.from(set)`, não de uma querystring nos checkboxes visíveis.
|
||
|
||
**"Marcar todos os resultados da busca"** (`.checklist-select-all`, `#ua-liderados-select-all`/`#manage-account-liderados-select-all`): opera só sobre o subconjunto **filtrado** pela busca no momento (não sobre todos os candidatos) — marcar adiciona o id de cada usuário filtrado ao `Set`, desmarcar remove; a cada re-render, o próprio estado desse checkbox é recalculado a partir do filtro atual (`checked` se todos os filtrados estão no `Set`, `indeterminate` se só parte, `disabled` se a busca não retornou ninguém), então ele reflete o resultado visível, não um total geral fixo.
|
||
|
||
`.checklist-box`/`.checklist-item` (+ `.checklist-item__nome`/`.checklist-item__departamento`/`.checklist-empty`/`.checklist-search`, `components.css`) é o componente genérico de "lista com checkbox, coluna secundária e busca" reaproveitado pelos três checklists de `usuarios.html` (Perfis de Acesso, Departamento, Liderados — os dois primeiros sem coluna/busca, só o layout base) **e** pelo checklist de liderados do modal "Gerenciar Usuário" — por isso mora em `components.css` (carregado em todo shell), não em `perfis-acesso.css` (só carregado em `usuarios.html`/`perfis-acesso.html`/`ramais.html`), onde vivia antes de existir um segundo consumidor fora dessas três páginas.
|
||
|
||
## 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 5 cores de tema selecionáveis (roxo/azul/verde/âmbar/rosa) pra nunca coincidir visualmente com o que `--accent` pode assumir. 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>` (`ACESSO_GERAL_OBSERVACOES_ALLOWED_TAGS`/`_ALLOWED_ATTRS`/`_ALLOWED_SCHEMES` no topo de `serializers.py`) — **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 (CSV, mensalidade+coparticipação, casamento por nome)
|
||
├── 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)
|
||
```
|
||
|
||
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.
|
||
|
||
**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), os dois arquivos anexados (`planilha_padrao`/`arquivo_operadora`, `FileField` com o mesmo padrão de validator de tamanho de `LinkFerramenta.icone`, só que 15MB em vez de 2MB — são documentos reais, não ícones), `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.
|
||
- `ImportacaoPlanoSaudeLinha`: uma linha da planilha padrão já casada com o valor do mês (espelha `LinhaSistema` campo a campo) — **todos os campos são editáveis** na tela de revisão antes de gerar o CSV (decisão explícita do usuário, não só os valores). `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".
|
||
- `RegraCusteioPlanoSaude`: regra de custeio salva pra reaplicar em importações futuras (ex.: "092 - Unimed") — ver "Regras de custeio salvas" abaixo. Lista compartilhada (sem FK pra `ImportacaoPlanoSaude`), mesmo espírito de `LinkFerramenta`/`AcessoGeral`.
|
||
|
||
### Fluxo e endpoints
|
||
|
||
`ImportacaoPlanoSaudeViewSet` (`/api/importacoes-plano-saude/`, `PermissaoApp("utilitarios", "importacao-plano-saude")` pra todos os métodos):
|
||
- `create()` (multipart, `ImportacaoPlanoSaudeCreateSerializer` valida a entrada) salva o model (isso já grava os 2 arquivos em `MEDIA_ROOT`) e roda `pipeline.processa_importacao()` **de forma síncrona** usando `instance.arquivo_operadora.path`/`instance.planilha_padrao.path` — 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 + o registro órfão e devolve 400.
|
||
- `GET /operadoras/` (`@action` sem detail) devolve `pipeline.lista_operadoras()` — fonte única pro `<select>` do formulário, sem duplicar a lista em JS.
|
||
- `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). Pode ser chamada de novo pra regerar depois de mais edições — não bloqueia edição subsequente.
|
||
|
||
`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".
|
||
|
||
`ImportacaoPlanoSaudeAuditoriaViewSet` (`/api/importacoes-plano-saude-auditoria/{id}/resolver/`, só `POST`) — ver seção própria abaixo.
|
||
|
||
### 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").
|
||
- 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.
|
||
|
||
### 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`.
|
||
|
||
### Regras de custeio salvas
|
||
|
||
Substituiu o antigo par de botões "Exportar regra"/"Importar regra" (baixava/lia um `.json` manualmente, sem nenhuma persistência) por um banco de regras de verdade no Postgres (`RegraCusteioPlanoSaude`) — pedido explícito do usuário pra poder nomear uma regra (ex.: "092 - Unimed"), escolhê-la numa lista em importações futuras, editá-la depois e anotar uma observação livre (ex.: "Empresa não desconta plano do empregado XX").
|
||
|
||
- **Campos**: `nome` (obrigatório), `operadora` (opcional — a `key` de `planos_saude.pipeline.OPERADORAS`, não o label; só usada pra pré-selecionar o `<select>` de operadora ao aplicar a regra, nunca bloqueia aplicar uma regra com uma operadora diferente da atual), `tipos_lancamento`/`custeio_por_tipo` (exatamente o mesmo formato dos campos homônimos de `ImportacaoPlanoSaude`, ver acima) e `observacoes` (texto livre).
|
||
- **Validação reaproveitada, não duplicada**: `RegraCusteioPlanoSaudeSerializer.validate()` e `ImportacaoPlanoSaudeCreateSerializer.validate()` chamam a mesma função módulo-level `_monta_regra_custeio()` (`serializers.py`) pra validar/parsear cada combinação tipo×pessoa — sem isso, a regra de negócio de custeio (parsing BR, faixa 0–100 do percentual, "ao menos um de limite/percentual") viveria duplicada em dois serializers e podia divergir com o tempo. A única diferença entre os dois pontos de entrada é o formato de payload: `ImportacaoPlanoSaudeCreateSerializer` recebe campos multipart achatados (`custeio_mensalidade_titular`, `limite_valor_mensalidade_titular`...), `RegraCusteioPlanoSaudeSerializer` recebe o `custeio_por_tipo` já aninhado como JSON puro.
|
||
- **`limite_valor`/`percentual` sempre em texto BR na entrada, float|None persistido**: igual ao resto do módulo, esses dois campos chegam como string BR (`"150,00"`) tanto no create de uma importação quanto no banco de regras — mas uma regra salva, uma vez lida de volta pelo `GET`, já vem com esses valores como `float` (formato final persistido). Pra `RegraCusteioPlanoSaudeSerializer` aceitar os dois formatos sem corromper o valor (`"150.0"` seria lido errado como 15000 por `parse_valor_br`, que só entende separador de milhar `.`/decimal `,`), `_valor_custeio_para_texto_br()` normaliza um float de volta pra texto BR (`formata_valor_br`) antes de repassar pro parser — isso é o que permite reenviar uma regra sem edição (ex.: só mudando o nome) sem precisar reformatar nada no frontend.
|
||
- **Frontend** (`importacao-plano-saude.js`, seção "Regra de custeio salva" — **primeiro** campo do formulário de Nova Importação, antes até de "Operadora": decisão explícita do usuário, já que aplicar uma regra já preenche a operadora junto, então escolher a regra é o primeiro passo natural do fluxo, não um apêndice no final): um combobox pesquisável (`#ips-regra-combo` — `<input>` `#ips-regra-search` + lista flutuante `#ips-regra-combo-list`, filtra por nome a cada tecla; era um `<select>` simples, trocado quando o banco de regras cresceu o bastante pra não caber numa lista sem busca) + "Aplicar" preenche o formulário inteiro (tipos de lançamento + custeio de cada combinação + operadora, se ainda existir na lista) a partir de uma regra salva — os campos continuam 100% editáveis depois, é só um preenchimento em massa (`aplicarCusteio()`/`aplicarRegraNoFormulario()`), mesmo espírito do antigo "Importar regra". `regraSelecionadaId` (JS) rastreia o que está de fato escolhido no combobox — digitar de novo no campo invalida a seleção anterior até o usuário clicar numa regra da lista, pra "Aplicar" nunca usar uma regra desatualizada em relação ao texto exibido. "Salvar regra atual..." (`#ips-regra-salvar-btn`) abre um modal (`#ips-regra-save-modal`) pra nomear/descrever a configuração atualmente preenchida (validada antes com a mesma `mensagemErroCusteio()` usada pelo botão "Processar", reaproveitada pelas duas ações) — **editar uma regra existente é literalmente aplicá-la, ajustar o que quiser no formulário, e salvar de novo**: o modal nasce em modo "atualizar a regra selecionada" sempre que a regra atualmente refletida no formulário (`regraAplicadaId`) ainda existir, com uma checkbox pra optar por "criar uma nova regra" em vez de sobrescrever. "Ver regras salvas" (`#ips-regras-modal`) lista todas as regras (nome, operadora, tipos, observações) com ações de Aplicar/Excluir — não duplica a grade de custeio num modal separado, de propósito, pra não manter dois lugares editáveis da mesma coisa.
|
||
|
||
## 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), 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) **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 em `usuarios.html` e no modal "Gerenciar Usuário" de todo shell, ver seção "Liderança"). |
|
||
| `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`; também tem `#portal-title` (fonte "Bree Serif" do título "Portal De Paula" no topbar), que não é widget mas mora aqui por ser o único CSS próprio da página. |
|
||
| `login.css` | Só usado em `index.html`; `.login-card__title` ("Portal De Paula") usa a mesma fonte "Bree Serif" do `#portal-title` de `portal.html`. |
|
||
| `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.
|