portal_publico/CLAUDE.md
2026-09-25 15:45:38 -03:00

399 lines
68 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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.
## Documentação dividida por aplicação
Este arquivo cobre o que é **transversal** ao Portal (arquitetura, modelo de permissões, API, CSS, animações). A partir de 2026-08-26, a documentação detalhada de cada aplicação foi movida pra fora daqui, pra reduzir conflito de edição quando mais de uma pessoa mexe em aplicações diferentes ao mesmo tempo. Ver `prd.md` pra visão de produto (o quê/pra quem), `README.md` na raiz pro mapa de todas as aplicações, e `plano.md` pro histórico de decisões **estruturais/transversais** (o histórico específico de cada aplicação vive no `CHANGELOG.md` dela, ver abaixo).
Cada aplicação tem uma pasta própria com (a) o detalhamento técnico do estado atual, (b) um `README.md` (resumo enxuto — o quê/pra quem, sem o detalhe técnico) e (c) um `CHANGELOG.md` (histórico rodada a rodada, extraído de `plano.md`).
**A numeração de rodada não é global**: cada aplicação conta as próprias, e o mesmo número designa trabalhos diferentes em arquivos diferentes (ex.: "rodada 93" é uma coisa em `plano.md` e outra em `portal_api/dashboard_contabil/CHANGELOG.md`). Ao citar uma rodada, **sempre nomear o arquivo** — "ver rodada 45 em `portal_api/indicadores/CHANGELOG.md`", nunca só o número. Ver o topo de `plano.md` para o detalhamento.
Aplicações com pacote Python próprio (`CLAUDE.md` **carregado automaticamente** pelo Claude Code ao trabalhar dentro da pasta):
| Aplicação | Onde |
|---|---|
| Importação de Plano de Saúde | `portal_api/planos_saude/` — `CLAUDE.md` (técnico), `README.md`, `CHANGELOG.md` |
| Indicador de Desempenho | `portal_api/indicadores/` — `CLAUDE.md` (técnico), `README.md`, `CHANGELOG.md` |
| Simulação de Custo de Contratação | `portal_api/custo_contratacao/` — `CLAUDE.md` (técnico), `README.md`, `CHANGELOG.md` |
| Não Conformidades | `portal_api/nao_conformidades/` — `CLAUDE.md` (técnico), `README.md`, `CHANGELOG.md` |
| Relatório Contábil | `portal_api/dashboard_contabil/` — `CLAUDE.md` (técnico), `README.md`, `CHANGELOG.md` |
| Conciliação de Fornecedores | `portal_api/conciliacao_fornecedores/` — `CLAUDE.md` (técnico), `README.md`, `CHANGELOG.md` |
Aplicações sem pacote Python dedicado (código ainda em `portal_api/models.py`/`views.py`/`serializers.py` — os arquivos em `docs/<app>/` **não** são carregados automaticamente, ler manualmente):
| Aplicação | Onde |
|---|---|
| Ramais (diretório, Telefones Externos, Funções de Telefonia, modal de consulta rápida) | `docs/ramais/` — `ramais.md` (técnico), `README.md`, `CHANGELOG.md` |
| Links & Ferramentas / Acessos Gerais | `docs/links-ferramentas-acessos-gerais/` — `links-ferramentas-acessos-gerais.md` (técnico), `README.md`, `CHANGELOG.md` |
| Calendário Individual e Widgets (incl. Eventos Corporativos, feriados, widgets de `portal.html`) | `docs/calendario-individual/` — `calendario-individual.md` (técnico), `README.md`, `CHANGELOG.md` |
| Favoritos (grade de `portal.html`) | `docs/favoritos/` — `favoritos.md` (técnico), `README.md`, `CHANGELOG.md` |
| Perfis de Acesso / Usuários (telas administrativas, Liderança, inativação) | `docs/perfis-usuarios/` — `perfis-usuarios.md` (técnico), `README.md`, `CHANGELOG.md` |
| Solicitações | `docs/solicitacoes/` — `solicitacoes.md` (técnico), `README.md`, `CHANGELOG.md` |
| Temas Sazonais (marca P.I.D. sazonal — hoje só Halloween) | `docs/temas-sazonais/` — `temas-sazonais.md` (técnico), `README.md`, `CHANGELOG.md` |
| Identidade visual (logos, marca P.I.D. na UI, animações genéricas, intro pós-login) — não é aplicação do menu, é camada transversal | `docs/identidade-visual/` — `identidade-visual.md` (técnico), `README.md`, `CHANGELOG.md` |
**Cada endpoint mora na doc da sua aplicação.** A tabela de API mais abaixo tem só os transversais (auth, `/api/me/`, catálogo, perfis, usuários, favoritos, widgets, compromissos, notificações). Ao criar uma aplicação nova, documentar os endpoints dela na pasta dela.
## 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 runserver
```
**`python manage.py seed_portal` é só para ambiente novo/vazio (primeira criação do banco)** — não rodar mais neste ambiente. **O Portal já está em produção**: os 8 perfis de acesso e todas as contas de usuário (incluindo `gabriel`/`bruno`) já existem de verdade, com senhas e permissões mantidas pelos próprios usuários direto pela tela (Perfis de Acesso/Usuários) — não mais pelo seed. `seed_portal` resincroniza incondicionalmente `permissoes`/`ativo`/`gerencia_permissoes` dos 8 perfis de código fixo a partir de `catalogo.py` a cada execução (ver "Cuidado com `seed_portal.py`" mais abaixo) — rodar isso contra o banco já em uso reverteria qualquer permissão que um admin tenha customizado manualmente para um desses 8 perfis, sem nenhum aviso. Ao adicionar uma aplicação nova ao catálogo (novo `app_key`), a chave nova ainda entra automaticamente pros perfis certos na próxima vez que `seed_portal` rodar — mas evitar rodar só por causa disso; se for mesmo necessário, avisar o usuário antes e confirmar, e idealmente conferir os 8 perfis em Perfis de Acesso depois pra garantir que nenhuma customização foi perdida.
Não há suíte de testes, lint ou build configurados neste projeto.
### Ambiente de desenvolvimento assistido
O `.venv` do projeto já tem Python 3.13 + Django 6.0 + DRF + psycopg + python-dotenv + Pillow + nh3 + reportlab + openpyxl + holidays + docling instalados, e o Postgres acessível via `.env` é o **banco de produção** (não uma cópia de desenvolvimento) — dá pra rodar `makemigrations`/`migrate`/`runserver` normalmente por aqui usando `.venv\Scripts\python.exe manage.py ...` (ou ativando o venv primeiro), mas **nunca `seed_portal`** (ver "Como rodar / testar localmente" acima — recria/ressincroniza perfis já em uso de verdade). 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 15 páginas HTML (13 shells + index.html + o relatório do Relatório Contábil) (TEMPLATES[0]["DIRS"] em settings.py aponta pra cá)
├── static/ # css/, js/, img/ — STATICFILES_DIRS em settings.py aponta pra cá
├── media/ # upload de usuário (hoje só ícones de LinkFerramenta) — MEDIA_ROOT em settings.py
├── CLAUDE.md
└── plano.md
```
### Logos em `static/img/`
**Duas identidades visuais coexistem de propósito**: o logo cursivo "D De Paula Contadores" (`logo.png`/`logo-branco.png`/`logo-mono.png`), usado **só nos documentos e PDFs que a aplicação gera**, e a marca "P.I.D." (`pid-*.svg`), usada **só na UI do Portal** (favicon, login, sidebar). A separação é deliberada, não uma migração incompleta: um documento gerado (contrato, procuração, recibo do Indicador de Desempenho, PDF da Simulação de Custo, relatório do Relatório Contábil) é emitido como se o próprio escritório o tivesse gerado, e carrega a identidade dele perante o cliente. **Não migrar o logo de um gerador de documento para "P.I.D." (nem o contrário numa tela do Portal) sem confirmar com o usuário.** Ver `[[feedback_logos_documentos_vs_portal]]` na memória.
Ver `docs/identidade-visual/identidade-visual.md` para o inventário de cada arquivo, quem consome cada um, o crossfade de `sidebar__brand` e o mecanismo de piscar os olhos do ícone (que exige `<svg>` inline, não `<img>`).
### Backend serve o frontend (mesma origem)
`config/urls.py` registra `path("api/", include("portal_api.urls"))` e, para cada uma das **15 páginas HTML** do frontend (`index.html` mais os 14 shells — ver a tabela em "Páginas" abaixo), uma rota `TemplateView` que resolve o arquivo em `templates/`. O 15º template, `dashboard-contabil-relatorio.html`, não tem rota própria: é renderizado por uma view do Relatório Contábil, não navegável pela URL. 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 Django, `portal_api/`. **Os models, serializers e views de todas as aplicações vivem nos arquivos compartilhados** (`models.py` com ~2.600 linhas, `views.py` com ~5.100, `serializers.py` com ~2.800) — os pacotes abaixo contêm só lógica pura, sem ORM. Consequência prática: mexer nos models ou nas views de uma aplicação **não** carrega o `CLAUDE.md` dela automaticamente, porque esses arquivos não estão dentro do pacote. Ao trabalhar num model `Contabil*`, `NaoConformidade*`, `Indicador*`, `ImportacaoPlanoSaude*`, `Ramal*`, `AcessoGeral*` ou `LinkFerramenta*`, abrir a doc da aplicação correspondente (tabela em "Documentação dividida por aplicação" acima).
| Arquivo | Conteúdo |
|---|---|
| `models.py` | Todos os models do projeto. Os transversais: `Usuario` (`AbstractUser` + `nome`, M2M `perfis`, M2M `departamentos`, campos cadastrais opcionais `codigo_folha`/`codigo_questor`/`codigo_tareffa`/`codigo_contabit`/`ramal`, `data_aniversario`, `lideranca` e M2M `liderados` self-referential com `related_name="lideres"`; método `permissao_app(module_key, app_key)` — união genérica de um flag de `apps` entre os perfis vinculados; método `eh_perfil_inovacao()` — checagem de nome fixo), `PerfilAcesso` (`permissoes` em `JSONField` + o booleano dedicado `gerencia_permissoes`), `Departamento` (só `nome`, cadastrado inline pela tela de Usuários, sem tela própria), `AjudaAplicacao`, `CompromissoAgenda`, `Favorito`, `WidgetUsuario`, `NotificacaoDispensada`. Os models de cada aplicação estão documentados na doc dela. |
| `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**, nunca em `static/js/profiles.js` (que só cacheia o payload recebido). |
| `serializers.py` | Idem: todos os serializers. Os transversais são `PerfilAcessoSerializer`, `DepartamentoSerializer`, `UsuarioResumoSerializer` (`id`/`nome`/`departamentos`, usado nos dois lados de `liderados` e por `/api/usuarios-resumo/`), `UsuarioSerializer` (escrita) / `UsuarioListSerializer` (leitura, aninhados), `CompromissoAgendaSerializer`, `FavoritoSerializer`, `WidgetUsuarioSerializer`, `NotificacaoDispensadaSerializer`, `AjudaAplicacaoSerializer`. Também as constantes de allowlist do `nh3` (`RICHTEXT_ALLOWED_TAGS`/`_ATTRS`/`_SCHEMES`), compartilhadas por todos os campos de texto rico do projeto. |
| `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. Nenhuma subclasse nova é necessária para uma aplicação adotar o padrão, só instanciar com outra `app_key`. |
| `views.py` | Idem: todas as views. As transversais são `login_view`/`logout_view`/`csrf_view`, `me_view` (usuário + `permissoes_efetivas` já unidas no servidor + `lideranca`/`liderados` + `eh_perfil_inovacao`), `trocar_senha_view`, `usuarios_resumo_view`, `meus_liderados_view`, `departamentos_resumo_view`, `catalogo_view`, `ajuda_aplicacao_view`, e os `ModelViewSet` de perfis/departamentos/usuários/compromissos/favoritos/widgets/notificações dispensadas. |
| `admin.py` | Django admin básico para todos os models (uso interno, não é a UI do portal). |
| `templatetags/contabil_extras.py` | Filtros de template (`moeda`/`percentual`/`indice`/`competencia`/`mes_curto`/`moeda_av`/`percentual_av`/`numero_bruto`) — único uso de template tags customizadas no projeto, só pelo relatório do Relatório Contábil. |
| `management/commands/seed_portal.py` | Cria os 8 perfis padrão + `gabriel`/`bruno` + as 13 linhas de `FuncaoTelefonia` + o seed de `CategoriaEvento`; também realinha a sequence do Postgres por trás de `PerfilAcesso.codigo`. **Não rodar neste ambiente** — ver "Como rodar / testar localmente" acima. |
| `management/commands/seed_indicador_desempenho.py` | Popula o primeiro histórico do Indicador de Desempenho (7 `IndicadorCriterio` + 5 `IndicadorPercentualTipo`, idempotente). |
Pacotes Python puros (sem ORM), um por ferramenta — cada um com seu próprio `CLAUDE.md`/`README.md`/`CHANGELOG.md`:
| Pacote | Ferramenta |
|---|---|
| `planos_saude/` | Pipeline de extração/casamento de "Importação de Plano de Saúde" (parsers por operadora, `matcher`, leiaute do Questor, regras de custeio). Serve as **duas** instâncias da ferramenta (clientes e De Paula). |
| `custo_contratacao/` | "Simulação de Custo de Contratação" (Geradoc) — `tabelas.py` (faixas de INSS/IRRF), `calculo.py`, `pdf.py` via `reportlab`. |
| `indicadores/` | "Indicador de Desempenho" (Geradoc) — `tipos.py`, `leiaute.py` (`openpyxl`), `pipeline.py`, `entregas.py`, `calculo.py`, `recibo.py` (PDF via `reportlab`), `departamentos.py`. |
| `nao_conformidades/` | "Não Conformidades" (Relatórios > Qualidade) — leiautes dos exports do Sigsistem, `diff.py` (reabertura automática), `classificacao.py`, `pipeline.py`. |
| `conciliacao_fornecedores/` | "Conciliação de Fornecedores" (Utilitários) — `parser.py` (razão do Questor em XLSX/CSV), `motor.py` (vínculos automáticos débito × crédito), `alertas.py` (situação/alertas recalculados a cada leitura), `exportacao.py` (XLSX). |
| `dashboard_contabil/` | "Relatório Contábil" (Relatórios > Contabilidade) — `parser.py` (extração do PDF), `regras.py` (motor de auditoria), `formula.py` (avaliador de fórmula por `ast`), `indicadores.py`, `chaves.py` (chave natural de conta/linha, compartilhada por sincronização, observações e relatório), `exportacao.py` (XLSX), `resumo_pdf.py`. |
### API (sessão + CSRF, não token)
| Endpoint | Método | Uso |
|---|---|---|
| `/api/auth/csrf/` | GET | garante o cookie `csrftoken` |
| `/api/auth/login/` | POST | `{username, password}` → cria sessão |
| `/api/auth/logout/` | POST | encerra sessão |
| `/api/me/` | GET | usuário logado + `perfis` + `departamentos` (os próprios, pra alimentar o seletor de "Meu departamento" do Calendário Individual) + `gerencia_permissoes` + `eh_perfil_inovacao` (perfil "Inovação" vinculado, ver "Ajuda de aplicação" abaixo) + `permissoes_efetivas` (união já calculada no servidor) |
| `/api/me/senha/` | POST | `{senha_atual, nova_senha}` |
| `/api/me/liderados/` | PATCH | `{liderados: [id, ...]}` — só se `me.lideranca`; auto-gerenciamento de liderados (ver `docs/perfis-usuarios/perfis-usuarios.md`) |
| `/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 `docs/calendario-individual/calendario-individual.md` |
| `/api/usuarios-resumo/` | GET | lista enxuta (`id`/`nome`) de usuários ativos — alimenta o seletor de liderados, sem exigir `gerencia_permissoes` (mesmo padrão de `/api/ramais/usuarios/`) |
| `/api/departamentos-resumo/` | GET | lista enxuta (`id`/`nome`) de departamentos — alimenta os botões de filtro do modal de consulta rápida de Ramais, exige só `ramais-visualizar` (não `gerencia_permissoes` como `/api/departamentos/`) |
| `/api/ajuda-aplicacoes/<app_key>/` | GET/PATCH | texto de "Mais informações" de uma aplicação (ver seção própria abaixo); GET livre a qualquer autenticado, PATCH exige `eh_perfil_inovacao` (perfil "Inovação", checagem de nome fixo, não uma flag em Perfis de Acesso) |
| `/api/perfis/`, `/api/perfis/{codigo}/` | GET/POST/PUT/DELETE | CRUD de perfil — só quem tem `gerencia_permissoes` |
| `/api/departamentos/`, `/api/departamentos/{id}/` | GET/POST/PUT/DELETE | CRUD de departamento — só quem tem `gerencia_permissoes`; usado pela tela de Usuários pra listar o checklist e cadastrar um novo departamento inline (sem tela própria) |
| `/api/usuarios/`, `/api/usuarios/{id}/` | GET/POST/PATCH/DELETE | CRUD de conta — só quem tem `gerencia_permissoes`; `departamentos` é M2M igual `perfis` (lista de ids na escrita, objetos aninhados na leitura); `is_active` é gravável via PATCH (inativar/reativar, ver `docs/perfis-usuarios/perfis-usuarios.md`) |
| `/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 `docs/calendario-individual/calendario-individual.md`) 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 `docs/calendario-individual/calendario-individual.md` |
| `/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 `docs/favoritos/favoritos.md`) |
| `/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 `docs/calendario-individual/calendario-individual.md` |
| `/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" |
**Esta tabela lista só os endpoints transversais.** Os de cada aplicação moram na doc dela (ver "Documentação dividida por aplicação" acima): Links & Ferramentas/Acessos Gerais, Ramais/Telefones Externos/Funções de Telefonia, Importação de Plano de Saúde (as duas instâncias), Simulação de Custo de Contratação, Indicador de Desempenho, Não Conformidades e Relatório Contábil. Ao criar uma aplicação nova, documentar os endpoints dela lá, não aqui.
### 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").
**Escape de HTML obrigatório (`pidEscapeHtml`, `api.js`)**: todo dado vindo da API, do usuário ou de arquivo anexado que entra em HTML montado por template string (`innerHTML`, `insertAdjacentHTML`) passa por `pidEscapeHtml(texto)`, que trata `& < > " '` e serve para conteúdo **e** atributo. Sem isso, um texto com HTML (título de compromisso, nome de link, observação, dado do arquivo da operadora) vira código executado no navegador de quem abre a tela, inclusive de outros usuários (XSS armazenado). Corrigido em todo o frontend na revisão de interface de 2026-09-25 (ver `plano.md`). Regras: escapar o **dado**, num único ponto (sem escape duplo), nunca o HTML que o próprio código monta (ícones, fragmentos); dado atribuído por `.textContent`/`.value` já é seguro e não se escapa; **texto rico sanitizado no servidor com `nh3`** (Ajuda, observações de Acessos Gerais, Resumo do Fechamento) é HTML intencional e **não** se escapa. As funções locais de escape que ainda existem (`pidDcEscapeHtml`, `pidNcfEscapeHtml`, `pidIndEscapeHtml`, `pidConcEscape`, `escapeHtml`/`escapeAttr` do Plano de Saúde) delegam a ela; a versão antiga por `textContent`/`innerHTML` não escapava aspas e não deve voltar a ser usada.
`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 `docs/links-ferramentas-acessos-gerais/links-ferramentas-acessos-gerais.md`). |
| `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 `docs/links-ferramentas-acessos-gerais/links-ferramentas-acessos-gerais.md`). |
| `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 `docs/ramais/ramais.md`). |
| `importacao-plano-saude.html` | Ferramenta de Utilitários: histórico + cadastro de regras de custeio por empresa (separado da execução) + nova importação (upload, só aplica uma regra já cadastrada) + revisão/geração do arquivo de lançamento de plano de saúde (ver `portal_api/planos_saude/CLAUDE.md`). |
| `importacao-plano-saude-de-paula.html` | Segunda instância da ferramenta acima, para o plano de saúde dos **próprios colaboradores** do escritório: tabelas, endpoints e permissão próprios, mas mesmo JS/CSS (parametrizados por `window.PID_IPS_CONFIG`) e mesmo pipeline de extração. Ver "Importação de Plano de Saúde - De Paula" em `portal_api/planos_saude/CLAUDE.md`. |
| `custo-contratacao.html` | Ferramenta de Geradoc: formulário de simulação de custo de contratação (Empregado CLT) + painel colapsável de parâmetros fiscais, gera um PDF (ver `portal_api/custo_contratacao/CLAUDE.md`). |
| `indicador-desempenho.html` | Ferramenta de Geradoc: histórico de apurações + nova apuração (upload das 2 planilhas) + cadastro de critérios/percentuais + revisão/geração dos recibos em PDF do Indicador de Desempenho do Fiscontábil (ver `portal_api/indicadores/CLAUDE.md`). |
| `nao-conformidades.html` | Aplicação de Relatórios > Qualidade: gestão contínua das ocorrências/ações do Sigsistem, com dashboard e status interno de tratativa da Qualidade (ver `portal_api/nao_conformidades/CLAUDE.md`). |
| `conciliacao-fornecedores.html` | Ferramenta de Utilitários: histórico + nova conciliação (código da empresa com nome buscado no Questor + razão de fornecedores .xlsx/.csv) + detalhe com cards de alerta e tabela de fornecedores expansível, vínculo manual, validação e exportação XLSX (ver `portal_api/conciliacao_fornecedores/CLAUDE.md`). |
| `dashboard-contabil.html` | Aplicação de Relatórios > Contabilidade ("Relatório Contábil" na UI): histórico de análises + nova análise (upload do PDF de Balancete + DRE) + revisão de achados de auditoria (com observações por conta) + botão "Gerar Relatório" (relatório HTML autocontido, ver `portal_api/dashboard_contabil/CLAUDE.md`). |
O item "Calendário De Paula" no menu **não é uma página local** — é um `<a href="#" id="calendario-depaula-btn">` (continua favoritável, já que ainda é um `<a class="nav-item">` — ver `docs/favoritos/favoritos.md`) cujo clique é interceptado em `sidebar.js` (`PID_CALENDARIO_DEPAULA_URL`) pra abrir `https://depaula-tvcorporativa.lovable.app/calendario` num modal com `<iframe>` (`#calendario-depaula-modal`, presente em todo shell) em vez de navegar — mesmo padrão do modal "Novo Chamado" de Ramais (`.ram-chamado-*` em `ramais.css`/`ramais.js`), só que genérico o bastante (`.iframe-modal-*` em `components.css`) pra existir em todo shell, não só em `ramais.html`. Funciona porque esse host (mesmo domínio do "Novo Chamado") não bloqueia ser embutido via `X-Frame-Options`/CSP, ao contrário do Asana (ver `docs/solicitacoes/solicitacoes.md`) — só foi possível confirmar isso testando de fato, não é garantia geral por domínio. Não recriar um `calendario.html` interno sem confirmar com o usuário; o antigo foi removido de propósito.
## Ordem de `<script>` e por que ela não quebra nada
Todas as páginas-shell (todas exceto `index.html`) carregam o mesmo prefixo de scripts, na mesma ordem: `api.js → confirm-modal.js → dual-select.js → profiles.js → auth.js → access.js → account.js → favorites.js → events.js → ...`, seguido de scripts específicos da página. Nenhum script usa `defer` exceto `theme.js` (que fica no `<head>`). `confirm-modal.js` (`pidConfirm()`, ver "Modal de confirmação genérico" abaixo) é a única exceção que também é carregada em `index.html` (logo depois de `api.js` ali também) — é genérico o bastante pra fazer sentido em qualquer página, inclusive o login.
Isso importa porque, por exemplo, `access.js` chama `pidRequireAuth()` de `auth.js`, que só aparece **antes** dele na tag `<script>` — mas mesmo quando a ordem fosse invertida funcionaria, porque toda chamada cross-arquivo acontece dentro de um callback de `document.addEventListener("DOMContentLoaded", ...)`, nunca no nível superior do script. Como todo `<script>` sem `defer` executa (e portanto declara suas funções) antes do evento `DOMContentLoaded` disparar, a ordem relativa entre arquivos não importa — só importa que todos estejam presentes na página antes desse evento. Ao adicionar um novo arquivo JS compartilhado, não é preciso se preocupar em "colocá-lo antes de quem o usa", desde que toda chamada fique dentro de um handler de `DOMContentLoaded` (ou de uma função só invocada por um).
Padrão de guarda por página: `profiles.js`, `users-admin.js` e `widgets.js` verificam a existência do elemento raiz da própria tela (`#pa-list-view`, `#ua-list-view`, `#widgets-grid`) e retornam cedo se não estiverem na página certa — por isso são incluídos em todo shell mesmo quando só uma página usa a parte de "controlador" (`profiles.js` também expõe funções de dados — `pidFetchPerfis`, `pidFetchUsuarios`, `pidFetchCatalogo` — reaproveitadas por `users-admin.js`).
## Armazenamento
| Onde | O quê |
|---|---|
| `localStorage` (`pid_theme`, `pid_color_theme`, `pid_seasonal_theme_opt_out`, `pid_seasonal_dead_spiders`) | Preferência de tema (`theme.js`) + preferências de Temas Sazonais (`seasonal-theme.js`, ver `docs/temas-sazonais/`). |
| 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.
**Notificação de ferramenta é gateada por permissão** (`n.access`, cada entrada de `PID_NEW_TOOLS_NOTIFICATIONS`) — decisão explícita do usuário, pra nunca anunciar uma aplicação que o usuário não pode acessar. `pidNotifToolElegivel()` reproduz o mesmo critério que já esconde o item correspondente no menu (`pidApplyAccessVisibility` em `access.js`): `{type:"gerencia"}` espelha o gate de `gerencia_permissoes` (usado por "Perfis de Acesso"/"Usuários", que não têm chave em `permissoes_efetivas`), `{type:"module", module:"..."}` espelha `permissoes_efetivas[module].enabled` (usado por "Calendário Individual"/"Widgets", que vivem dentro de `calendario-individual`/`principal`). O filtro roda uma vez, antes de montar `toolNotifications` — vale tanto pro sino quanto pro histórico, então uma notificação sem permissão nunca aparece em lugar nenhum, nem mesmo depois de dispensada. Ao adicionar uma entrada nova em `PID_NEW_TOOLS_NOTIFICATIONS`, sempre preencher `access` com o módulo/gate real da aplicação anunciada (ou omitir só se for algo que todo usuário autenticado pode ver, sem exceção).
**Notificação de ferramenta expira em 10 dias** (`PID_NOTIF_TOOL_EXPIRA_DIAS`, `pidNotifToolExpirada()`, comparando `dataIso` da entrada contra a data de hoje): passado esse prazo, `notifications.js` dispensa a notificação sozinho no próprio carregamento da página (`POST /api/notificacoes-dispensadas/`, mesma chamada de quando o usuário clica no X) — daí em diante ela segue as mesmas regras de qualquer notificação dispensada manualmente (some do sino, aparece no histórico, pode ser restaurada). `toolNotificationsAgora` (o recorte usado pro sino, tanto na carga inicial quanto depois de um "Restaurar") já exclui as expiradas por prazo — restaurar uma notificação de ferramenta com mais de 10 dias mantém o rastro no histórico mas não a traz de volta ao sino, mesmo espírito de "restaurar um compromisso antigo não garante reaparecer no sino" (ver abaixo). Ao adicionar uma entrada nova, usar `dataIso` no formato `"AAAA-MM-DD"` (não `date` pré-formatado como antes) — `date`/`pidFormatNotifDate()` derivam o `"DD/MM"` de exibição a partir dele, mesmo padrão já usado pelos eventos do Calendário Individual.
**Histórico de notificações** (botão "Histórico" ao lado de "Limpar tudo", em `#notif-history-modal` — presente nos 13 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()`/`toolNotificationsAgora` (só eventos com `data >= hoje` e `notificar_em` já atingido, capado em 5 pra não lotar o dropdown; só notificações de ferramenta dentro dos 10 dias de prazo), enquanto o histórico usa `allEventNotifications`/`toolNotifications` (todos os compromissos que o usuário pode ver e toda notificação de ferramenta que ele tem permissão de ver, sem o recorte de "agora") — um item dispensado pode não estar mais no recorte "elegível agora" (compromisso já passou, lembrete não bateu ainda depois de uma edição, ou notificação de ferramenta já passou dos 10 dias), 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" (restaurar um compromisso muito antigo, ou uma notificação de ferramenta com mais de 10 dias, não reaparece no sino — 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`" em `docs/ramais/ramais.md`). 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.
**Convenção pra aplicação nova que lide com dado sensível/pessoal (não dado de cliente comum)**: decisão explícita do usuário (rodada em que "Importação de Plano de Saúde - De Paula" foi criada, ver `portal_api/planos_saude/CLAUDE.md`) — daqui pra frente, toda aplicação nova adicionada ao catálogo que trate dado pessoal de colaborador do próprio escritório (em vez de dado de cliente, que é o caso comum do resto do Portal) deve **nascer restrita só ao perfil "Inovação"** (código 8), mesmo padrão já usado por "Não Conformidades" (`permissoes["relatorios"]["apps"]["nao-conformidades"] = False` no bloco de overrides) — nunca herdar a visibilidade ampla padrão de `BASE_KEYS`/`SECTORAL_KEYS`. Outros perfis que precisarem ganham acesso depois, manualmente, pela tela de Perfis de Acesso. **Não retroagir** essa convenção nas aplicações de toggle único que já existiam antes desta decisão (Importação de Plano de Saúde original, Simulação de Custo de Contratação, Indicador de Desempenho) — essas continuam com a visibilidade ampla de sempre, decisão explícita do usuário de não mexer no que já está em uso.
**Bug real (rodada 69) — `IntegrityError: duplicate key value violates unique constraint "portal_api_perfilacesso_pkey"` ao criar um perfil pela tela**: `seed_portal.py` semeia `PerfilAcesso` com `codigo` **explícito** (`update_or_create(codigo=dado["codigo"], ...)`, já que os 8 códigos 1–8 são referenciados por número fixo em vários lugares do código — ex.: `codigo == 8` = "Integração e Inovação"). No Postgres, um `INSERT` com PK explícita **nunca avança a sequence** por trás do `AutoField` — então a sequence ficava parada em 1 (seu valor inicial), e o primeiro perfil criado pela tela (`POST /api/perfis/`, sem PK explícita) recebia `codigo=1` do `nextval()`, colidindo com um código já usado pelo seed. `_reset_sequence(model)` (função módulo-level em `seed_portal.py`, roda `SELECT setval(pg_get_serial_sequence(...), MAX(pk))` via SQL puro do Postgres) corrige isso, chamada logo depois do loop de `PERFIS_SEED` — toda vez que `seed_portal` roda, a sequence é realinhada de novo. Se esse erro voltar a aparecer no futuro (ex.: um `loaddata`/`RunPython` de migração também inserindo `PerfilAcesso` com PK explícita sem passar por `seed_portal.py` depois), o comando pra corrigir manualmente é `python manage.py seed_portal` (idempotente, seguro rodar de novo) — não precisa de acesso direto ao banco.
Duas telas administrativas por cima desse modelo (popup "Nova Aplicação" de `perfis-acesso.html`, aba "Usuários do Escritório") estão documentadas em `docs/perfis-usuarios/perfis-usuarios.md`, não aqui.
## Ajuda de aplicação ("Mais informações")
Botão "?" (`.info-tooltip`, `components.css`) ao lado do nome de uma aplicação: no hover mostra "Mais informações" (tooltip CSS puro, sem JS de posicionamento, já que a posição relativa ao próprio botão nunca varia); no clique abre um modal com um texto livre descrevendo objetivo/processo/cuidados/resultado esperado daquela ferramenta.
**Visualizar é livre a qualquer autenticado; editar é restrito a quem tem o perfil "Inovação" vinculado** — uma checagem de **nome fixo** (`Usuario.eh_perfil_inovacao()`/`models.PERFIL_INOVACAO_NOME`), **não** uma flag na árvore de permissões. Decisão explícita do usuário, pra não precisar aparecer em Perfis de Acesso. Mesmo padrão do selo "Restrito" de Relatórios Gerenciais (nome `=== "Diretoria"`).
- **Model `AjudaAplicacao`**: chave natural `app_key` (mesma ideia de `app_id`/`notif_id`/`tipo` — sem FK pra `Usuario`, é um texto compartilhado) + `texto` + `atualizado_em`/`atualizado_por`. `AjudaAplicacao.para_app(app_key)` faz `get_or_create`, padrão "singleton por chave criado sob demanda" já usado em `ParametroFiscalCustoContratacao.atual()`.
- **Endpoint** `GET`/`PATCH /api/ajuda-aplicacoes/<app_key>/` (função simples, não `ModelViewSet`): GET exige só `IsAuthenticated`, PATCH também exige `eh_perfil_inovacao()`. `GET /api/me/` expõe `eh_perfil_inovacao` pro frontend decidir se mostra o botão "Editar".
- **Texto aceita imagens embutidas**: `<div contenteditable>` com colar/arrastar imagem (até 2MB), convertida em data URI, sanitizada no servidor com `nh3.clean()`. As constantes de allowlist (`RICHTEXT_ALLOWED_TAGS`/`_ATTRS`/`_SCHEMES` em `serializers.py`) são **compartilhadas** por este campo, por `AcessoGeral.observacoes` e por `ContabilApuracao.resumo_fechamento` — allowlist estrito (texto básico + `<img>`, sem `<a>`/`<script>`/atributos de evento, `data:` liberado só pra imagem embutida).
- **Frontend** (`static/js/ajuda-aplicacao.js`, reusable no espírito de `dual-select.js`): `pidCriarBotaoAjuda(botaoId, appKey, tituloApp)` liga o clique de um botão já existente no HTML. O modal (`#ajuda-aplicacao-modal`) é **criado sob demanda e injetado em `document.body` pelo próprio JS**, não precisa ser escrito à mão em cada página. Fechar em modo edição (por qualquer caminho: "Fechar", overlay ou "Cancelar") dispara `pidConfirm("Sair sem salvar as alterações?", { perigoso: true })`.
- **Ligado hoje só em Importação de Plano de Saúde** (`ips-ajuda-btn`). O mecanismo já é genérico: outra aplicação precisa só do botão no HTML e de uma chamada a `pidCriarBotaoAjuda()`, sem nenhum código novo no backend.
### Modal de confirmação genérico (nunca `window.confirm`/`window.alert`)
`static/js/confirm-modal.js` (incluído logo depois de `api.js` em **todo** shell, inclusive `index.html`): `pidConfirm(mensagem, opcoes)` devolve `Promise<boolean>` e `pidAlert(mensagem, opcoes)` devolve `Promise<void>`, os dois num modal `.modal-overlay`/`.modal-card` no padrão visual do Portal.
**Decisão explícita do usuário**: o `window.confirm()` nativo do Chrome mostra o IP/porta do servidor na barra de título e quebra a identidade visual do app. **Todo popup novo usa este modal.** Ver `[[feedback_popups_no_padrao_do_portal]]` na memória.
- `opcoes` (todas opcionais): `titulo` (default "Confirmar ação"), `textoConfirmar`/`textoCancelar`, `perigoso` (troca o botão de confirmar pra `.btn-danger-outline`). **Ação destrutiva sempre usa `{ perigoso: true }`**; ação reversível (reverter, ativar/desativar) não.
- Um único modal (`#pid-confirm-modal`), criado sob demanda e reaproveitado; `pidConfirm`/`pidAlert` são duas chamadas da mesma função interna com `modoAlerta` diferente. **Empilha por cima** de qualquer modal já aberto via `.modal-overlay--top` (só um `z-index` maior), então não é preciso fechar o modal atual antes de perguntar — o de trás continua visível, dimmed.
- Reaproveita `.modal-card__title`/`.modal-card__subtitle` pro título/mensagem; só `.confirm-modal__cancelar-btn`/`.confirm-modal__confirmar-btn` existem como seletores de DOM.
- **Nenhum `window.confirm()`/`window.alert()` restou no app.** A única exceção é um `window.prompt()` em `users-admin.js` (renomear departamento) — pede texto, e ainda não existe um modal de input genérico. Se for pedido, criar junto.
A inativação/reativação de usuário (`is_active`, incluindo o desvínculo automático de perfis/liderança), a Liderança (gerente/coordenador) e o bug do `seed_portal.py` não resetar mais `nome` de um perfil já existente (ver `[[feedback_seed_nao_reseta_gabriel_bruno]]` na memória) estão documentados em `docs/perfis-usuarios/perfis-usuarios.md`.
## Favoritos
Grade de aplicações favoritadas na tela Principal (`portal.html`) — o usuário marca itens do menu como favoritos e reordena os cards por drag-and-drop. O ID de cada favorito é derivado da própria estrutura do menu, não de um cadastro à parte.
Ver `docs/favoritos/favoritos.md`.
## Calendário Individual e Widgets
Agenda pessoal de cada usuário (compromissos privados, de departamento ou de todos), com eventos corporativos, feriados nacionais/estaduais do Paraná e lembretes calculados em horário comercial. Inclui também o sistema genérico de widgets configuráveis da tela Principal (drag-and-drop, redimensionamento).
Ver `docs/calendario-individual/calendario-individual.md`.
## Links & Ferramentas / Acessos Gerais
Duas aplicações na mesma seção do menu: "Links & Ferramentas" é uma grade de cartões de atalho para ferramentas externas; "Acessos Gerais" é um cadastro de logins/acessos compartilhados da equipe, organizado em seções e linhas.
Ver `docs/links-ferramentas-acessos-gerais/links-ferramentas-acessos-gerais.md`.
## Ramais
Diretório de ramais internos — mescla automaticamente todo colaborador ativo (a partir do cadastro de Usuários) com linhas avulsas (telefone de sala, recepção etc.). Inclui também Telefones Externos, Funções de Telefonia e controle de ausência.
Ver `docs/ramais/ramais.md`.
## Solicitações
Os itens do menu "Solicitações" não são telas próprias — cada um é um link externo (hoje, um formulário do Asana) aberto em nova aba; não dá pra embutir em iframe porque o Asana bloqueia.
Ver `docs/solicitacoes/solicitacoes.md`.
## Simulação de Custo de Contratação (Geradoc)
Calcula o custo de contratar um Empregado CLT (v1: só essa modalidade) a partir de tabelas fiscais de INSS/IRRF editáveis pelo banco, e devolve um PDF pronto pra enviar ao cliente.
Ver `portal_api/custo_contratacao/CLAUDE.md`.
## Indicador de Desempenho (Geradoc)
Apuração mensal do indicador de desempenho do Fiscontábil (departamentos Contábil/Fiscal), a partir de planilhas do Tareffa (Serviços/Honorários), com critérios/percentuais configuráveis pela própria tela e geração de recibo em PDF por colaborador.
Ver `portal_api/indicadores/CLAUDE.md`.
## Importação de Plano de Saúde (Utilitários)
Importa o relatório de faturamento de uma operadora de plano de saúde/odontológico (Amil, Unimed, SulAmérica...) e gera o arquivo de lançamento no leiaute do Questor, com auditoria do que não pôde ser casado automaticamente contra a planilha padrão.
Ver `portal_api/planos_saude/CLAUDE.md`.
## Não Conformidades (Relatórios)
Gestão contínua das ocorrências e ações do Sistema de Gestão da Qualidade (Sigsistem, ISO 9001) — evolui uma skill que gerava um Excel estático sob demanda pra uma ferramenta persistente: cada ocorrência/ação é upsertada por código a cada importação, com um status interno de tratativa da Qualidade que reabre automaticamente quando algo muda desde o último tratamento, mais um dashboard com motivos de abertura e clientes/colaboradores com maior incidência.
Ver `portal_api/nao_conformidades/CLAUDE.md`.
## Conciliação de Fornecedores (Utilitários)
O contador informa o código da empresa (nome buscado no Questor) e anexa o razão contábil de fornecedores exportado do Questor; a ferramenta vincula pagamentos e títulos de cada fornecedor por valor e data (inclusive combinações e parcelas) e aponta o que está pendente: títulos em aberto há mais de 2 meses, pagamentos sem título (divergência), diferenças de juros/desconto e conta transitória com saldo. Vínculo manual, validação por conta, histórico salvo e exportação XLSX. Restrita ao perfil Inovação.
Ver `portal_api/conciliacao_fornecedores/CLAUDE.md`.
## Relatório Contábil (Relatórios > Contabilidade)
Chamado de "Dashboard Contábil" até uma rodada anterior — renomeado pra "Relatório Contábil" a pedido explícito do usuário, pra soar como um aliado do trabalho do contador em vez de mais um processo/sistema novo (rename só de rótulo visível: menu, título da página, cabeçalhos e o botão que gera o relatório; nomes técnicos internos — pasta do pacote, arquivos, classes de model, rotas — continuam `dashboard_contabil`/`dashboard-contabil.*`/`Contabil*`/`/contabil-*`, sem nenhuma mudança).
Otimiza a conferência de balancetes hoje feita manualmente pelo Fisco/Contábil (`ITD-FISCO-7513`): o contador anexa o PDF de Balancete + DRE (modelo Questor ou Contabit, detectado sozinho, mesmo relatório hoje enviado ao cliente; no Contabit o código da empresa vem do nome do arquivo e a seção de indicadores fica oculta), a ferramenta extrai as contas e roda um motor de regras de auditoria (saldos negativos, contas transitórias/genéricas com saldo, contas que deveriam ficar zeradas, débito ≠ crédito, variações atípicas mês a mês contra o histórico já processado no Portal), com revisão de achados e observações por conta antes da conclusão (a observação fica no histórico da empresa + conta e reaparece assinada nas competências seguintes, até ser encerrada) — cada conta/linha também ganha um checkbox de "validado" (marcador informativo de que o contador já conferiu aquele item) e um editor de observação inline na própria tabela (não mais um popup), com um toggle "mostrar ao cliente" que nasce desmarcado por padrão. O botão "Gerar Relatório" gera um relatório HTML autocontido (indicadores financeiros, gráfico de evolução, DRE/Balancete agrupados, observações do contador — com a marca do escritório, não a "P.I.D." do Portal), com exportação de Balancete/DRE em XLSX a partir dele. **Ainda fora de escopo**: consolidação entre várias empresas/competências ao mesmo tempo (substituiria o BI Contábil por completo).
Ver `portal_api/dashboard_contabil/CLAUDE.md`.
## CSS — organização entre arquivos
| Arquivo | Contém |
|---|---|
| `tokens.css` | Variáveis (`:root`, tema claro em `:root[data-theme="light"]`). |
| `base.css` | Reset global, incluindo `[hidden] { display: none !important; }` — necessário porque vários componentes (`.no-access`, `.app-card`, `.notif-badge`) definem seu próprio `display`, o que sem o `!important` sobrescreveria o comportamento nativo de `hidden`. Também os `@keyframes` globais de animação (`pidFadeIn`/`pidFadeSlideUp`/`pidScaleIn`, ver "Animações" abaixo) e `.pid-icon-eye`/`pidIconBlink` (piscar de olho do ícone "P.I.D.", reaproveitado pela sidebar e pelo login — ver "Ícone do login e da sidebar são clicáveis" abaixo), já que é o único CSS carregado por **todas** as páginas sem exceção (inclusive `index.html`). |
| `layout.css` | Casca do shell: `.app-shell`, `.sidebar*`, `.nav-*`, `.fav-toggle`, `.topbar*`. A sidebar usa tokens **congelados**, independentes de tema (fundo sempre escuro em claro/escuro) — não trocar por variáveis que espelham `:root[data-theme="light"]`. Exceção deliberada: `--sidebar-text-primary`/`--sidebar-text-secondary`/`--sidebar-text-muted` (texto/ícone do menu) *são* sobrescritas em `:root[data-theme="light"]` (`tokens.css`) pra branco puro — pedido explícito do usuário pra melhorar a legibilidade; só o fundo/borda da sidebar continuam frozen. Também `.page-content` (largura do conteúdo de cada página, `max-width:1200px` centralizado por padrão) + o modificador `.page-content--wide` (`max-width:1600px`) — `portal.html` ("Principal") é a única página que usa só `.page-content` puro (grade de favoritos fica mais confortável de leitura mais estreita); os outros 12 shells (`perfis-acesso.html`, `usuarios.html`, `links-ferramentas.html`, `acessos-gerais.html`, `ramais.html`, `calendario-individual.html`, `importacao-plano-saude.html`, `importacao-plano-saude-de-paula.html`, `custo-contratacao.html`, `indicador-desempenho.html`, `nao-conformidades.html`, `dashboard-contabil.html`) usam `class="page-content page-content--wide"` no `<main>`, decisão explícita do usuário pra aproveitar melhor o espaço entre a sidebar e a borda da tela em telas de tabela/formulário. Uma página nova que seja mais "aplicação" (tabela, formulário, CRUD) do que "dashboard" deve nascer já com `page-content--wide`. |
| `components.css` | UI genérica reutilizável: `.btn-solid/.btn-outline/.btn-ghost/.btn-danger-outline`, **`.modal-overlay`/`.modal-card`** (moldura genérica de modal, + o modificador `.modal-card--wide` pra quando precisa de mais espaço horizontal) **e também** `.modal-field`/`.modal-field-row`/`.modal-checkbox`/`.modal-error`/`.modal-actions` (campos internos do modal — moldura e campos vivem juntos aqui, apesar do nome sugerir só a moldura, incluindo `select`/`textarea` dentro de `.modal-field`, com seta customizada via `background-image` porque o nativo do browser destoa do tema escuro), `.app-card*`, `.no-access`, `.checklist-box`/`.checklist-item`/`.checklist-item__info`/`.checklist-item__nome`/`.checklist-item__departamento`/`.checklist-empty`/`.checklist-search`/`.checklist-select-all` (lista com checkbox, segunda linha de detalhe e busca — usada nos checklists de Perfis de Acesso/Departamento em `usuarios.html`), `.dual-select`/`.dual-select__*` (vinculação em duas tabelas — não vinculados/vinculados, ver `docs/perfis-usuarios/perfis-usuarios.md` — usada em `usuarios.html` e no modal "Gerenciar Usuário" de todo shell), `.info-tooltip`/`.info-tooltip__*`/`.ajuda-modal__*` (botão "?" + tooltip + modal de "Mais informações", ver seção própria abaixo — usados por `static/js/ajuda-aplicacao.js`) e `.modal-overlay--top` (empilha um modal por cima de outro já aberto — usado só pelo modal de confirmação genérico, `static/js/confirm-modal.js`, ver "Modal de confirmação genérico" abaixo). |
| `perfis-acesso.css` | `.pa-*` (tela de Perfis de Acesso), incluindo as seções (`.ua-section*`) e campos específicos (`.ua-inline-add`/`.ua-departamento-item`/`.ua-active-toggle`/`.ua-liderados-field`) do formulário de edição de `usuarios.html`. |
| `calendario.css` | Só `.calendar-*` (grade mensal, células de dia, nav do mês) — os campos do modal de compromisso usam as classes genéricas `.modal-field`/`.modal-checkbox`/`.modal-error`/`.modal-actions` de `components.css`. |
| `widgets.css` | `.widgets-*`, `.widget-card*`, `.widget-picker-*` — só usado em `portal.html`. O topbar da tela inicial não tem mais título/slogan nenhum (`<h1 id="portal-title">` — chegou a existir brevemente com o slogan "Grandes aplicações de todos os tamanhos" em fonte "Pinyon Script"/dourado, removido a pedido do usuário na mesma rodada; ver `login.css` abaixo pra onde o slogan acabou indo) — o `<link>` do Google Fonts em `portal.html` também foi removido junto, já que não sobrou nenhum uso de fonte customizada nessa página. |
| `login.css` | Só usado em `index.html`; `.login-card__title` ("Portal Interno da De Paula") usa a fonte "Bree Serif" (importada só nesta página). O slogan da marca ("Grandes aplicações de todos os tamanhos.", `pid-marca-leiame.md` tem esse e um segundo, "Ainda funciona. Agora pensa.", não usado em lugar nenhum) mora aqui, não no topbar de `portal.html` (onde chegou a existir e foi removido, ver `widgets.css` acima) — passou primeiro pelo rodapé do card (abaixo de "Esqueceu sua senha?...") antes de subir pra logo abaixo do título, posição atual (pedido explícito do usuário). `.login-heading` (`display:flex; flex-direction:column; gap:var(--space-1)`) agrupa `.login-card__title` e `.login-slogan`, com um espaçamento bem menor entre os dois (`--space-1`) do que o `gap:var(--space-5)` que `.login-card` usa entre seus próprios filhos diretos — sem esse agrupamento, o slogan ficaria longe demais do título pra parecer uma assinatura. `.login-slogan` usa a fonte script "Pinyon Script" (mesmo `<link>` do Google Fonts de `.login-card__title`, agora com as duas famílias), `font-size:1.2rem` (menor que o `1.3rem` do título do card, pedido explícito do usuário) e cor dourada fixa `#c6a24a` (cor da marca, não um token de tema/`--accent`). O card do login é **congelado escuro nos dois temas** (`--login-card-bg`/`--login-field-bg`/`--login-border`/`--login-text-*`, definidos só em `:root` de `tokens.css`, nunca redefinidos em `:root[data-theme="light"]` — mesmo padrão de `--sidebar-*`, ver comentário ao lado deles em `tokens.css`) — pedido explícito do usuário; a logo dentro dele também não alterna mais por tema (ver "Ícone do login e da sidebar são clicáveis" acima — decisão de uma rodada seguinte, revertendo a alternância que existia antes). Só o fundo da própria página ao redor do card (`.login-page`) continua acompanhando o tema claro/escuro, mas de formas diferentes em cada um: no **escuro** (padrão), `background: linear-gradient(var(--overlay-scrim), var(--overlay-scrim)), radial-gradient(circle at 20% 20%, rgba(var(--accent-rgb), 0.25), transparent 45%), var(--bg-canvas)` — um scrim escuro + um brilho radial na cor do tema sobre o `--bg-canvas` escuro, pensados pra dar profundidade; no **claro**, `:root[data-theme="light"] .login-page` zera o scrim (deixava tudo acinzentado/amarronzado sobre um fundo já claro) e refinou o resto em duas rodadas: primeiro só `background: var(--bg-canvas)` liso (pedido explícito do usuário, "mesmo tom do fundo da tela principal"); depois, a pedido do usuário de novo ("tom de roxo um pouco mais claro e o fundo branco levemente escurecido"), voltou a ter um brilho radial (`rgba(accent, 0.12)` — bem mais sutil que o `0.25` do tema escuro, um glow forte fica turvo sobre fundo claro) sobre uma cor base levemente mais escura que o `--bg-canvas` puro (`#f3f1f7` → `#ece7f2`, só nesta tela — não altera o token `--bg-canvas`, então o resto do Portal no tema claro continua com o tom original). |
| `links-ferramentas.css` | `.lf-*` — só usado em `links-ferramentas.html`. |
| `acessos-gerais.css` | `.ag-*` — só usado em `acessos-gerais.html`. |
| `ramais.css` | `.ram-*` — só usado em `ramais.html`; a tabela em si reaproveita `.pa-table*`/`.pa-row-actions` de `perfis-acesso.css` (mesmo padrão que `usuarios.html` já usa sem CSS próprio). |
| `ramais-lookup.css` | `.ram-lookup-*` — modal de consulta rápida de ramais (ver `docs/ramais/ramais.md`), 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). |
| `nao-conformidades.css` | `.ncf-*` — só usado em `nao-conformidades.html`; carrega `perfis-acesso.css` também, pra reaproveitar `.pa-table`/`.pa-tabs`/`.status-pill` (modificadores `--ok`/`--danger`/`--warning`/`--neutral` novos, em cima do `.status-pill` já existente ali). |
| `conciliacao-fornecedores.css` | `.conc-*` — só usado em `conciliacao-fornecedores.html`; carrega `perfis-acesso.css` também, pra reaproveitar `.pa-table`/`.pa-search`/`.status-pill`. |
| `dashboard-contabil.css` | `.dc-*` — só usado em `dashboard-contabil.html`; carrega `perfis-acesso.css` também, pra reaproveitar `.pa-table`/`.pa-tabs` na revisão de achados/Balancete/DRE. |
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`), aplicados via `animation` e **nunca via `transition`** em elementos que entram/saem do layout por `hidden`/`display:none` (modal, dropdown, `.page-content` a cada navegação, `.login-card`) — só `animation` reinicia sozinha quando um elemento passa de `display:none` para visível; `transition` não anima essa troca, porque não há frame intermediário. Duração sempre curta (120–200ms), a pedido do usuário: "fluidas, porém rápidas, otimizando o tempo". Botões têm `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**, já que contraria um pedido explícito.
Ao apertar "Entrar" com sucesso, toca uma animação de marca em tela cheia (~6,2s) antes de navegar para `portal.html`, e a tela Principal entra pela barra lateral primeiro. Ver `docs/identidade-visual/identidade-visual.md` para as 4 cenas, os timings, as curvas de easing e o mecanismo de revelação em `portal-reveal.js`.
## Temas Sazonais
Troca sazonal da marca P.I.D. (logo, ícone da sidebar/login, favicon, intro de abertura pós-login) por uma variante temática durante uma janela de datas — hoje dois: Halloween (mascote "vampiro", 16 a 31/10) e Aniversário (56 anos da De Paula Contadores, mascote com balões, só 15/10) — as duas janelas nunca coincidem por desenho. Mecanismo pensado pra outro tema sazonal futuro reaproveitar o mesmo esqueleto. Durante o Halloween, clicar na logo dispara um "susto" (substitui a piscada normal) e há teias de aranha decorativas nos cantos do login/tela Principal (aranha "matável"); durante o Aniversário, clicar na logo dispara um "clique de festa" (pula + joga confete + língua-de-sogra, convive com a piscada normal em vez de substituí-la), mais um bolo com velinhas (apagáveis ao clicar) fixo no canto da tela Principal. Um toggle "Temas sazonais" no menu da conta deixa o próprio usuário desligar isso (preferência pessoal ou acessibilidade, ex.: aracnofobia). 100% runtime — fora da janela ativa (ou com o opt-out ligado), tudo volta sozinho ao P.I.D. de sempre, sem nenhuma ação manual.
Ver `docs/temas-sazonais/temas-sazonais.md`.