portal_publico/CLAUDE.md

183 KiB
Raw Blame History

CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

Contexto do projeto

Portal interno da De Paula Contadores ("Portal De Paula"). Portal/ é o próprio projeto Django — Python 3.13 + Django 6.0 + Django REST Framework + PostgreSQL 14 — organizado no padrão convencional de um projeto Django (manage.py na raiz, app portal_api/, templates/, static/), servindo tanto a API (/api/...) quanto o frontend HTML/CSS/JS (mesma origem — ver "Arquitetura" abaixo).

Até uma rodada anterior, todo o estado (sessão, usuários, perfis de acesso, favoritos, widgets, compromissos) vivia no localStorage do navegador — não havia backend. Isso mudou: o usuário decidiu a stack real e pediu a migração completa desses dados para o banco. Ver plano.md para o histórico de decisões rodada a rodada; consultar antes de mudar algo que pareça uma limitação (ex.: ausência de teste automatizado, remoção do calendário interno antigo) sem confirmar se foi decisão deliberada.

O que continua só no localStorage: apenas a preferência de tema (claro/escuro e cor do tema) — é preferência de navegador, não dado de negócio, e ficou fora do escopo da migração por decisão explícita do usuário.

Como rodar / testar localmente

Backend (obrigatório para qualquer teste agora — o frontend não funciona mais sozinho via file:///http.server)

cd Portal
python -m venv .venv
.venv\Scripts\activate          # Windows
pip install -r requirements.txt

Configurar as variáveis de ambiente do Postgres 14 antes de migrar — config/settings.py chama load_dotenv(BASE_DIR / ".env") e lê DB_NAME, DB_USER, DB_PASSWORD, DB_HOST, DB_PORT (o arquivo .env já existe na raiz de Portal/):

python manage.py makemigrations portal_api
python manage.py migrate
python manage.py seed_portal      # recria os 8 perfis + usuários gabriel/bruno
python manage.py runserver

Abrir http://localhost:8000/ — o Django serve index.html e as demais páginas do frontend diretamente (ver config/urls.py), então não há mais um segundo servidor (python -m http.server) para o frontend.Vamos desenvolver outra

Contas de demonstração (criadas por seed_portal, senha via set_password do Django — não é mais texto puro):

  • gabriel / gabriel — perfil "Integração e Inovação" (código 8), o único com gerencia_permissoes=True (acesso total + gerencia Perfis de Acesso/Usuários) e o único com os *-editar de links-ferramentas (cartões e Acessos Gerais) e de ramais True (único perfil que pode reordenar/incluir/remover cartões em Links & Ferramentas, criar/editar seções e acessos em Acessos Gerais, e adicionar/editar ramal/criar ausência em Ramais, até que outro perfil seja liberado em Perfis de Acesso — ver "Modelo de permissões" abaixo).
  • bruno / bruno — sem perfil vinculado, usado para testar o estado "sem acesso".

Não há suíte de testes, lint ou build configurados neste projeto.

Ambiente de desenvolvimento assistido

O .venv do projeto já tem Python 3.13 + Django 6.0 + DRF + psycopg + python-dotenv + Pillow + nh3 + reportlab + openpyxl + holidays + docling instalados, e há um Postgres local acessível via .env — dá pra rodar makemigrations/migrate/seed_portal/runserver normalmente por aqui usando .venv\Scripts\python.exe manage.py ... (ou ativando o venv primeiro). Isso deixou de ser uma limitação a partir da rodada em que o ambiente ganhou essas ferramentas (ver plano.md) — não assumir mais que só é possível revisar o backend estaticamente.

Arquitetura

Estrutura de pastas (padrão Django)

Portal/
├── manage.py
├── requirements.txt
├── .env
├── config/            # settings.py, urls.py, wsgi.py, asgi.py — pacote de configuração do projeto
├── portal_api/        # único app Django (models, serializers, views, admin, migrations, seed)
├── templates/         # as 8 páginas HTML (TEMPLATES[0]["DIRS"] em settings.py aponta pra cá)
├── static/             # css/, js/, img/ — STATICFILES_DIRS em settings.py aponta pra cá
├── media/             # upload de usuário (hoje só ícones de LinkFerramenta) — MEDIA_ROOT em settings.py
├── CLAUDE.md
└── plano.md

Logos em static/img/

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/regras-empresa/ GET [{key, label}] das regras especiais registradas em planos_saude.regras_empresa.REGRAS_EMPRESA — alimenta o modal "Selecionar regra" do checkbox "Regra empresa" (ver seção própria abaixo)
/api/importacoes-plano-saude/{id}/gerar/ POST monta o CSV (ou ZIP, se mais de um tipo de lançamento) a partir das linhas já revisadas/editadas e devolve como download binário; marca a importação como concluida
/api/importacoes-plano-saude-linhas/, /api/importacoes-plano-saude-linhas/{id}/ GET/POST/PATCH/DELETE edição/inclusão/exclusão de uma linha da revisão (todos os campos, não só valores); mesma permissão da importação, sem conceito de "dono"; as três operações também gravam um ImportacaoPlanoSaudeAlteracao (ver "Alterações" abaixo)
/api/importacoes-plano-saude-alteracoes/{id}/reverter/ POST desfaz uma alteração específica (edição/inclusão/exclusão de linha) registrada na aba "Alterações" da revisão — ver seção própria abaixo
/api/regras-custeio-plano-saude/, /api/regras-custeio-plano-saude/{id}/ GET/POST/PATCH/DELETE banco de regras de custeio 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):

{
  "<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.

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:

"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 cores de tema selecionáveis (roxo/azul/verde/âmbar/rosa/vermelho) pra nunca coincidir visualmente com o que --accent pode assumir — exceto --coral, que é laranja e portanto não colide mesmo com "vermelho" na lista. O chip "Minha equipe" (#ic-filter-equipe) só aparece (hidden) se me.lideranca; o chip "Eventos" (data-filter="evento") é sempre visível, já que qualquer perfil pode ver eventos corporativos (só criar um é restrito). Por padrão todos os chips visíveis nascem is-active (mostra tudo que o usuário pode ver). Clique simples troca a seleção pra só aquele filtro (activeFilters.clear() + adiciona só o clicado), exceto se esse filtro já for o único ativo — nesse caso (activeFilters.size === 1 && activeFilters.has(filtro)) o clique volta pra visualização padrão (selecionarTodosOsFiltros(), todos os chips visíveis ativos), pra sempre existir um caminho de volta ao estado "ver tudo" sem precisar de Shift. Shift+clique acrescenta/remove esse filtro dos já selecionados (event.shiftKey, mesmo padrão de seleção de arquivos do SO) — é assim que dá pra combinar mais de uma categoria ao mesmo tempo.

Eventos Corporativos (CategoriaEvento, CompromissoAgenda.eh_evento/categoria/local/modalidade/descricao): compromissos com visibilidade em departamento/todos deixaram de ser livres pra qualquer usuário — criar ou editar um compromisso nesses dois níveis (o que inclui automaticamente todo eh_evento=True, já que a validação força visibilidade="todos" antes de checar permissão) agora exige apps["calendario-individual-criar-evento"] em permissoes["calendario-individual"] (CompromissoAgendaSerializer.validate()), permissão liberada só para "Integração e Inovação" no seed_portal.py por ora (mesmo cuidado de sempre: como calendario-individual está em BASE_KEYS, sem o override todo perfil nasceria podendo criar evento de departamento/todos e cadastrar categoria). Compromissos "somente_eu" continuam livres pra qualquer um, sem essa checagem.

  • CategoriaEvento (nome único + cor hex, validada por validar_cor_categoria_evento) é um cadastro simples via /api/categorias-evento/ — GET livre a qualquer autenticado (a cor/nome de uma categoria não é sigilosa), escrita restrita à mesma permissão acima. Não é uma lista fixa no código: quem tem a permissão cadastra categorias novas (ex.: "Reunião", "Treinamento") direto no modal de criar/editar compromisso (botão "+" ao lado do <select> de categoria, #ic-event-categoria-add-btn, que abre #ic-categoria-modal) — seed_portal.py popula CATEGORIAS_EVENTO_SEED como ponto de partida, mas a lista é editável dali em diante.
  • CompromissoAgenda.eh_evento marca um compromisso como evento formal (não uma reunião pessoal marcada como "todos") — o checkbox correspondente (#ic-event-eh-evento, dentro de #ic-event-eh-evento-field) só aparece pra quem tem a permissão de criar evento; marcá-lo força a visibilidade pra "Todos" no próprio formulário. local (texto livre), modalidade (presencial/remoto/hibrido, <select> #ic-event-modalidade) e descricao (texto livre) são campos extras só relevantes pra evento, mas tecnicamente gravam em qualquer compromisso (o formulário só os expõe quando aplicável). No popup somente-leitura (#ic-view-modal), cada um aparece como campo próprio (#ic-view-local-field/#ic-view-modalidade-field/#ic-view-descricao-field), escondido (hidden) quando vazio — mesmo padrão dos demais campos condicionais desse popup (ver "Pill do compromisso" acima).
  • Visualmente, um evento ganha um bucket de cor próprio (--coral) tanto no pill do calendário (.calendar-event--evento) quanto no chip de filtro "Eventos" — mesmo sendo sempre visibilidade="todos" por baixo, não se mistura visualmente com um "Todos" comum (ver parágrafo acima).

Pill do compromisso: sempre "HH:MM Título", nada mais — todo pill mostra só horário+título, nunca o nome do dono, pra manter o mesmo formato/tamanho independente da categoria ("simétrico", pedido explícito do usuário; a primeira versão acrescentava "— Nome do dono" direto no texto dos compromissos que não eram do usuário, o que descalibrava o visual porque nomes têm tamanhos bem diferentes). Todo pill é clicável (cursor:pointer na classe base .calendar-event, não só em --somente-eu): se ev.sou_dono, abre o modal de edição de sempre (#ic-modal, fecha também clicando fora — event.target === modal, mesmo padrão de links-ferramentas.js/ramais-lookup.js); senão, abre um modal novo, só leitura (#ic-view-modal/openViewModal(), mesmo fecha-ao-clicar-fora), com data/horário por extenso, dono_nome (rotulado "Agendado por:", não "Responsável" — mudança de nomenclatura pedida pelo usuário), o rótulo da visibilidade (PID_IC_VISIBILIDADE_LABELS, mapeia ev.visibilidade pro texto exibido nos chips) e, só quando ev.visibilidade === "departamento", o nome do departamento (ev.departamento_compartilhado_nome, campo já vinha do serializer). É esse popup — não o texto do pill — que carrega toda a informação que antes tentava caber na própria pílula.

Célula do dia com altura fixa, lista de compromissos rolável (.calendar-day/.calendar-day__events em calendario.css): .calendar-day tem height fixo (108px desktop, 76px no breakpoint mobile — antes era min-height, o que deixava a linha inteira da grade crescer quando um dia tinha muitos compromissos, desalinhando a altura de todas as células daquela semana). Os pills não são mais filhos diretos de .calendar-day — calendar-individual.js (render()) os agrupa num <div class="calendar-day__events"> (flex:1; min-height:0; overflow-y:auto) irmão de .calendar-day__header. .calendar-day (o item de grid, não só o __events interno) também precisa de min-height:0 + overflow:hidden — sem isso, o "tamanho mínimo automático" que grid/flexbox calculam por padrão pra um item (baseado no conteúdo, ignorando height explícito) ainda fazia a linha da grade crescer pra caber todos os pills, mesmo com a célula e o overflow-y:auto do __events configurados certinho por dentro — o corte real só acontece quando o próprio item de grid para de contribuir com seu min-content pro cálculo da altura da linha (min-height:0/overflow não-visible fazem isso). Resultado: o cabeçalho (número do dia + botão de adicionar) fica sempre fixo, todas as linhas da grade têm a mesma altura sempre, e uma barra de rolagem aparece dentro da célula só quando os compromissos daquele dia não cabem nos 108px/76px disponíveis.

Agenda completa do dia (#ic-day-modal, openDayModal() em calendar-individual.js): clicar no número do dia (.calendar-day__number, cursor:pointer + destaque no hover) abre um popup com todos os compromissos do dia (respeitando os filtros ativos, mesmo eventosDoDia usado pra desenhar a célula) mais o feriado, se houver — sem o corte de altura/rolagem da célula, já que o .day-modal-list (calendario.css) tem max-height:360px próprio, bem maior que os 108px da grade, e os pills ali dentro voltam a ter white-space:normal (podem quebrar linha) em vez do nowrap+ellipsis da grade, então nada aparece cortado. Pra evitar duplicar a criação dos pills em dois lugares (grade e popup), criarPillCompromisso(ev)/criarPillFeriado(iso, nome) foram extraídas como funções reaproveitadas por render() e por openDayModal() — mesmo elemento, mesmo clique (editar/ver detalhes/ver nome do feriado), só muda o container onde entram. Clicar num item dentro do popup fecha o popup da agenda antes de abrir o modal de destino (edição/visualização/feriado), pra não empilhar dois overlays ao mesmo tempo. O botão "Novo Compromisso" do popup pré-preenche a data com o dia clicado (mesmo mecanismo do "+" de cada célula).

Feriados no Calendário Individual (GET /api/feriados/?ano=AAAA, feriados_view em views.py): usa a lib holidays (PyPI, requirements.txt) pra devolver os feriados nacionais + estaduais do Paraná (holidays.Brazil(years=ano, subdiv="PR", language="pt_BR"), categoria public — o default da lib, exclui pontos facultativos tipo Carnaval/Corpus Christi) do ano pedido, como [{"data": "AAAA-MM-DD", "nome": "..."}]. language="pt_BR" é passado explicitamente — sem isso, a lib pode cair pro locale do processo do servidor (que nem sempre é pt_BR, ex.: environment com LANG/LANGUAGE em inglês) em vez do default_language da classe Brazil, fazendo os nomes virem em inglês ("Independence Day" em vez de "Independência do Brasil") mesmo com o resto do portal em português. De propósito não tem feriado municipal de Foz do Iguaçu aqui — nenhuma lib de feriados cobre granularidade de município brasileiro (a holidays só tem um caso especial hardcoded pra "São Paulo Capital", nada além disso), e manter uma lista municipal certa exigiria curadoria manual + atualização por decreto da Prefeitura a cada ano; o usuário decidiu deixar de fora por enquanto, só nacional/estadual mesmo. Se algum dia precisar do municipal, a rota certa é o usuário fornecer a lista oficial (decreto da Prefeitura) pra virar uma tabela fixa no código, não tentar adivinhar/inferir datas.

No frontend (calendar-individual.js), carregarFeriados(ano) busca e cacheia por ano (Map em memória, só refaz a requisição ao trocar de ano); render() busca também o ano anterior/seguinte quando o mês exibido encosta na borda do ano (janeiro/dezembro), já que os dias "fora do mês" na grade podem pertencer a um ano diferente de viewYear. Cada célula de dia feriado ganha a classe .is-feriado (fundo tingido de vermelho, rgba(var(--danger-rgb), 0.1)) e um pill (.calendar-day__holiday, mesmo visual dos pills de compromisso — .calendar-event, só que com fundo --danger fixo, não uma cor por categoria) com o nome do feriado. Esse pill é irmão de .calendar-day__header, fora de .calendar-day__events — fica sempre fixo no topo da célula, não rola junto com os compromissos do dia. Truncado com text-overflow:ellipsis quando o nome não cabe (alguns feriados vêm com dois nomes concatenados por ;, ex.: "Nossa Senhora do Rocio; Proclamação da República" — ver feriados_view); clicar no pill abre #ic-holiday-modal (openHolidayModal(), mesmo padrão dos outros modais — fecha clicando fora) mostrando a data e o nome completo sem corte. É só informativo, não bloqueia criar/editar compromisso nesse dia.

Gerente/coordenador vê a agenda individual da equipe: CompromissoAgendaViewSet.get_queryset acrescenta Q(visibilidade="somente_eu", dono__in=usuario.liderados.all()) às regras de visibilidade — ou seja, além de "todos" e "departamento" (ver acima), quem tem gente em Usuario.liderados (ver seção "Liderança") também enxerga os compromissos privados ("somente_eu") de cada liderado, mas sem poder editá-los (sou_dono continua False pra esses, CompromissoAgendaViewSet.get_object já barra escrita de quem não é dono). Não há necessidade de checar usuario.lideranca explicitamente na query — liderados só é populado através de fluxos que já exigem esse flag (ver "Liderança"), então a cláusula é inofensiva (não casa nada) pra quem não lidera ninguém.

Lembrete com horário comercial (CompromissoAgenda.lembrete_antecedencia, opcional — "" = sem lembrete; "1h"/"2h"/"4h"/"24h"): não existe nenhum mecanismo de push/e-mail no projeto — o "lembrete" é só o momento a partir do qual o compromisso passa a aparecer no sino de notificações (notif-bell), que já era recalculado a cada carregamento de página (sem processo em segundo plano). CompromissoAgenda.calcular_notificar_em() (models.py) calcula esse horário contando lembrete_antecedencia horas de expediente (seg-sex, 8h-18h, COMPROMISSO_HORARIO_COMERCIAL_INICIO/_FIM) pra trás a partir de data+horario — fora do expediente não conta como antecedência "gasta", só é pulado de graça. Por isso um compromisso às 08h de segunda com lembrete de 4h não notifica às 04h (fora do expediente); o algoritmo (_janela_comercial/_dia_util_anterior, funções módulo-level) pula pro fechamento do expediente do dia útil anterior (sexta 18h) e só então desconta as 4h, resultando em sexta 14h. Sem horario definido no compromisso (evento de dia inteiro) ou sem lembrete_antecedencia, não há o que calcular e calcular_notificar_em() retorna None. O resultado é exposto só leitura via notificar_em no serializer (ISO datetime ou null); pidBuildEventNotifications() (notifications.js) só inclui um compromisso na lista do sino quando notificar_em não é nulo e já foi atingido (now >= notificar_em) — compromissos sem lembrete configurado simplesmente não aparecem no sino (comportamento diferente de antes da migração, quando todo compromisso futuro aparecia lá independente de qualquer configuração).

widgets.js mantém um registro extensível PID_WIDGET_TYPES ({ "<chave>": { label, description, href, linkLabel, visibleIf? } }) — hoje existem "calendario-individual" e "links-favoritos" (ver seção própria abaixo). href/linkLabel alimentam o link de rodapé do card ("Ver X completo →"); antes de existir um segundo tipo de widget esse link era hardcoded pra calendario-individual.html, então ao adicionar um tipo novo sempre preencher os dois, senão o rodapé de todos os widgets aponta pro lugar errado. visibleIf(me) é opcional — quando presente, filtra o tipo tanto do picker (renderPicker()) quanto da grade já adicionada (renderWidgets()), usado pra widgets que exponham dado de um módulo com permissão própria (ex.: links-favoritos só aparece pra quem tem apps["links-ferramentas-visualizar"] em links-ferramentas). Para adicionar um novo tipo de widget: registrar a entrada em PID_WIDGET_TYPES e adicionar um case/if em widgetBodyFor() que retorne o HTML do corpo do card; o picker (#widget-picker-modal) e a grade (#widgets-grid) já lidam com adicionar/remover genericamente via /api/widgets/.

Reordenar e redimensionar widgets (WidgetUsuario.ordem/largura/altura, por usuário): .widgets-grid é display:flex; flex-wrap:wrap (não mais CSS Grid — precisava permitir que cada .widget-card tivesse largura/altura próprias e livres, incompatível com colunas de grid uniformes). Reordenar é drag-and-drop nativo HTML5 igual ao de Links & Ferramentas (dragstart/dragover/drop em #widgets-grid, PATCH /api/widgets/{tipo}/ só nos itens cujo ordem mudou) — a diferença é que o draggable="true" fica só em .widget-card__header (a barra de título), não no card inteiro, pra não conflitar com o handle nativo de resize (resize: both em .widget-card, ativo no canto inferior direito). Redimensionar usa esse resize: both do CSS (sem JS de arraste custom) — um ResizeObserver por card (observeWidgetSizes()) detecta a mudança de tamanho e salva largura/altura com debounce de 500ms; como o resize é 100% nativo do browser, não precisa nenhum cálculo manual de arraste. Como o conteúdo de um widget pode ficar maior que o espaço depois de encolhido, só .widget-card__body tem overflow-y: auto (o cabeçalho e o link de rodapé ficam fixos, só o corpo rola).

"Links & Ferramentas" 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 PATCHes 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):

"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 &nbsp; (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)
    └── bradesco/saude.py                Bradesco Saúde (PDF **sem texto selecionável** — OCR via `docling`, mensalidade+coparticipaçã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.

PDF sem texto selecionável (ex.: Bradesco Saúde) precisa de OCR, não de pdfplumber: confirmado rodando pdfplumber contra o arquivo real da Bradesco — page.chars/page.extract_text() vêm vazios em toda página, porque o documento é uma composição de imagens raster (cada linha da tabela é literalmente um bitmap), sem nenhuma camada de texto. Nesse caso o parser usa docling (biblioteca de OCR + reconstrução de estrutura de tabela, adicionada ao requirements.txt — pesada: traz torch/transformers/opencv-python como dependência transitiva, então o primeiro pip install baixa bem mais do que os parsers em pdfplumber exigiam) em vez de pdfplumber. Ver o docstring de operadoras/bradesco/saude.py para o motivo de usar reconstrução de tabela (DocumentConverter().convert(...).document.tables, cabeçalho identificado por texto normalizado via _classifica_coluna, não por posição fixa) e o contorno de um bug real de fronteira de célula do modelo de tabela (TableFormer) nas colunas numéricas estreitas — valor de uma linha "vazando" pra célula da linha vizinha, contornado extraindo todos os valores monetários da área em ordem de leitura e redistribuindo 1 por linha, em vez de confiar em qual célula específica o modelo atribuiu cada valor. Ao adicionar outra operadora nesse mesmo caso (PDF sem texto selecionável), reaproveitar essa técnica em vez de assumir que pdfplumber vai funcionar — testar primeiro com page.chars/extract_text() contra o arquivo real antes de escolher qual dos dois usar.

Diferença deliberada em relação ao pipeline original: lá, o valor do mês sempre gravava na coluna VALOR (desconto do empregado), nunca em VALOREMPRESA — regra fixa. Aqui, o usuário escolhe na tela de nova importação, por tipo de lançamento (mensalidade/coparticipação) e por tipo de beneficiário (titular/dependente) — quatro combinações independentes, ex.: mensalidade do titular custeada pela empresa e mensalidade do dependente descontada do empregado —, uma de três regras de custeio: "Custeado pela empresa" ({"modo": "empresa"}), "Descontado do empregado" ({"modo": "empregado"}, o comportamento antigo — nomenclatura "empregado", não "funcionário", pra não confundir com NOMEFUNC/CPFFUNC do leiaute do Questor, que é outra coisa) ou "Regra específica" ({"modo": "especifica", "limite_valor": float|None, "percentual": float|None}). Na regra específica, limite_valor é um teto de quanto a empresa cobre (o excedente vira desconto do empregado) e percentual é a fração do valor do mês custeada pela empresa (o resto vira desconto) — o usuário pode preencher só um dos dois ou os dois juntos; quando os dois vêm preenchidos, prevalece o que resultar no menor valor custeado pela empresa (mais restritivo), decisão explícita do usuário. Essa divisão é calculada por matcher._calcula_valores(valor_total, regra) (chamada por _aplica_regra_custeio, que grava valor_empresa/valor os dois juntos a partir do mesmo valor_total) — note que valor_empresa é arredondado primeiro e valor é derivado como o complemento exato (valor_total - valor_empresa, também arredondado), nunca os dois arredondados de forma independente, senão a soma dos dois podia ficar 1 centavo a mais/menos que o valor original (ex.: 50% de 51,69 tem que fechar em 25,84 + 25,85 = 51,69, não 25,85 + 25,85). Qual das duas regras (titular ou dependente) usar em cada Individuo/LinhaSistema é resolvido por matcher._regra_para_pessoa(regra_por_pessoa, tipo_pessoa) — tipo_pessoa 'T' cai em "titular", 'D'/'A' caem em "dependente" (mesmo critério de "D e A tratados igual" já usado no resto do leiaute) — chamada nos dois pontos de aplicação de _casa_por_cpf/_casa_por_nome antes de _aplica_regra_custeio.

Modelos (portal_api/models.py)

  • ImportacaoPlanoSaude: uma execução da ferramenta — operadora/nome_operadora, tipos_lancamento (JSONField, lista), custeio_por_tipo (JSONField, {"mensalidade": {"titular": {"modo": "empresa"|"empregado"|"especifica", "limite_valor": float|None, "percentual": float|None}, "dependente": {...}}, "coparticipacao": {...}} — ver regra de custeio acima), regra_empresa (CharField, blank — chave de planos_saude.regras_empresa.REGRAS_EMPRESA quando "mensalidade" foi custeada por uma regra especial em vez do custeio_por_tipo["mensalidade"] normal, ver "Regra empresa" abaixo), 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".
  • ImportacaoPlanoSaudeAlteracao: log de cada edição de campo/inclusão/exclusão de linha feita manualmente na revisão — ver seção "Alterações" abaixo.
  • RegraCusteioPlanoSaude: regra de custeio salva pra reaplicar em importações futuras (ex.: "092 - Unimed") — ver "Regras de custeio salvas" abaixo. Lista compartilhada, mesmo espírito de LinkFerramenta/AcessoGeral — o próprio model não tem FK pra nada; é ImportacaoPlanoSaude.regra_custeio_salva que aponta pra cá (opcional, SET_NULL), só como registro de qual regra (se alguma) foi aplicada pra preencher aquele formulário.

Fluxo e endpoints

ImportacaoPlanoSaudeViewSet (/api/importacoes-plano-saude/, PermissaoApp("utilitarios", "importacao-plano-saude") pra todos os métodos):

  • create() (multipart, ImportacaoPlanoSaudeCreateSerializer valida a entrada) 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 combobox pesquisável "Operadora" do formulário (#ips-operadora-combo, mesmo padrão de "Regra de custeio salva" — ver "Regras de custeio salvas" abaixo), sem duplicar a lista em JS. label já vem no formato "<código> - <Nome>" (ex.: "3755 - Itamed Saúde") — o código é o de cadastro da operadora no Questor, pedido explícito do usuário pra identificar a operadora sem ambiguidade (útil quando duas operadoras têm nome parecido); editar em pipeline.OPERADORAS, não formatar o código separadamente no frontend.
  • POST /{id}/gerar/ monta o(s) CSV(s) a partir das linhas já salvas (isto é, já com qualquer edição feita na revisão — não reprocessa os arquivos originais) usando leiaute_sistema.CABECALHO; 1 tipo de lançamento vira um .csv direto, 2 tipos (mensalidade + coparticipação) viram um .zip com um .csv por tipo (zipfile em memória). 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"). Exceção: quando a importação tem regra_empresa configurada (ver "Regra empresa" abaixo) e o item é de tipo_lancamento="mensalidade", esse caminho por pessoa não se aplica — bug real visto com dados reais, o valor caía inteiro em desconto do empregado, ignorando a regra empresa. resolver() grava o valor bruto do item na linha (placeholder) e chama _recalcula_familia_regra_empresa(importacao, linha), que reúne todas as linhas de mensalidade da mesma família (nome_func igual) — recuperando o valor bruto de cada uma como valor_empresa + valor, soma que preserva o total independente do split aplicado antes — e reaplica a regra empresa (REGRAS_EMPRESA[chave]["aplica"]) na família inteira de uma vez, salvando todas as linhas afetadas (bulk_update). Precisa reaplicar na família toda, não só na linha recém-vinculada, porque o valor novo muda o total da família e o teto (_aplica_teto_familia, priorização dependente→titular) precisa ser redistribuído do zero.
  • O item nunca é apagado nem some da lista: fica marcado resolvida=True + linha_vinculada (FK), e a tela mostra um selo "Resolvido — " (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.

Alterações (histórico de edição/inclusão/exclusão de linha, com reversão)

Quarta aba da revisão (ao lado de Mensalidade/Coparticipação/Auditoria) — mostra cada edição de campo, inclusão manual de linha ("Adicionar linha") e exclusão de linha feitas na própria tela de revisão, com um botão pra reverter cada uma individualmente. Objetivo: dar visibilidade e uma saída fácil pra um erro de digitação ou uma exclusão feita sem querer, sem precisar reprocessar a importação do zero.

  • Model (ImportacaoPlanoSaudeAlteracao, migração 0038): um registro por operação, nunca apagado (mesmo espírito de resolvida em ImportacaoPlanoSaudeAuditoria — histórico completo). tipo (edicao/inclusao/exclusao), linha (FK SET_NULL — fica null quando a linha em si já não existe mais: foi excluída, ou era uma inclusão já revertida), campo/valor_anterior/valor_novo (só preenchidos em edicao), dados_linha (JSONField — snapshot de todos os campos editáveis da linha + ordem, capturado no momento da operação; é o que permite recriar a linha ao reverter uma exclusão e identificar a linha na tela mesmo depois dela ter sido excluída), usuario, criado_em, revertida/revertida_em.
  • Fora de escopo de propósito: o valor lançado por "Vincular pessoa" (resolução manual de auditoria, ver acima) não gera um registro aqui — já tem seu próprio rastro (o selo "Resolvido" na aba Auditoria); duplicar o registro nas duas abas só confundiria qual é a fonte da verdade.
  • Onde é gravado: as três operações de ImportacaoPlanoSaudeLinhaViewSet (perform_create/perform_update/perform_destroy, views.py) — perform_update compara serializer.validated_data contra serializer.instance (os valores antes do .save()) e grava um ImportacaoPlanoSaudeAlteracao por campo que de fato mudou (o fluxo atual do frontend já só envia um campo por PATCH, por change de cada <input>, mas o backend não assume isso — trata qualquer PATCH multi-campo corretamente). _snapshot_linha_plano_saude() (módulo-level, reaproveitado nos três pontos) monta o dados_linha.
  • POST /api/importacoes-plano-saude-alteracoes/{id}/reverter/ (ImportacaoPlanoSaudeAlteracaoViewSet.reverter) — idempotente, recusa reverter de novo uma alteração já revertida. A própria reversão não gera um novo registro de alteração (evitaria um loop de "reverter a reversão"):
    • edicao: só possível se linha ainda existir (não excluída depois); grava valor_anterior de volta no campo.
    • inclusao: só possível se linha ainda existir; deleta a linha diretamente (bypassa ImportacaoPlanoSaudeLinhaViewSet.perform_destroy, então não cria um registro exclusao pra essa reversão).
    • exclusao: sempre possível (a linha já está excluída por definição) — recria uma ImportacaoPlanoSaudeLinha nova a partir do snapshot em dados_linha (+ tipo_lancamento guardado à parte) e aponta alteracao.linha pra ela.
  • Frontend (importacao-plano-saude.js, panelHtmlAlteracoes()): lista já vem do backend ordenada do mais recente pro mais antigo (Meta.ordering = ["-criado_em"]); sem ordenação/redimensionamento de coluna, ao contrário das abas de linha/auditoria — é um log, não uma planilha editável. Cada linha mostra data/hora, um badge de tipo (.ips-alteracao-tipo--edicao/--inclusao/--exclusao, cores dourado/teal/vermelho), o lançamento, o nome identificado pela linha (linha_nome, do serializer — usa o snapshot quando a linha já não existe mais), uma descrição da alteração ("<campo>: "<anterior>" → "<novo>"" pra edição, texto fixo pra inclusão/exclusão) e o usuário. A coluna "Ação" mostra "Reverter" (com window.confirm, mesmo padrão de "Remover linha") ou o selo "Revertida" quando já foi desfeita; confirmar chama pidReverterAlteracaoPlanoSaude() e refaz pidFetchImportacaoPlanoSaude() (mesmo padrão de adicionar/remover linha e de "Vincular pessoa") antes de re-renderizar as abas.

Pré-validação de arquivo ao anexar (tela de Nova Importação)

Antes de existir isso, os dois arquivos (planilha padrão + arquivo da operadora) só eram validados juntos, no create(), e um erro de formato virava a mensagem genérica "O formato de um dos arquivos não está conforme o esperado" — sem dizer qual dos dois. Agora cada anexo é validado sozinho, no momento em que é selecionado, reaproveitando exatamente o mesmo parser que create() usaria — sem duplicar nenhuma regra de leiaute em JS (o parsing de PDF/CSV é Python-only, então isso teria que ser uma chamada ao servidor de qualquer forma).

  • Endpoint: POST /api/importacoes-plano-saude/validar-arquivo/ (multipart {tipo: "planilha"|"operadora", arquivo, operadora?}) — sempre 200 {"valido": bool, "mensagem": str}, nunca um erro HTTP pra "arquivo errado" (esse é um resultado esperado da validação, não uma falha de requisição; só falta de arquivo/tipo inválido/operadora ausente quando tipo="operadora" vira 400 de verdade). _valida_planilha_padrao() roda leiaute_sistema.le_planilha_padrao(); _valida_arquivo_operadora() roda OPERADORAS[operadora_key]["parser"]().extrai() — os dois gravam o upload num arquivo temporário (_salva_arquivo_temporario, tempfile.NamedTemporaryFile) só porque essas funções esperam um caminho de arquivo, não um objeto de upload em memória, e apagam o temporário no finally; nada é persistido. Qualquer exceção do parser (coluna faltando, layout de PDF não reconhecido, CSV com delimitador errado — inclusive o caso real já visto de export com \t em vez de ;) vira valido=False com uma mensagem específica pra aquele arquivo; 0 linhas/indivíduos extraídos (arquivo no formato certo mas vazio) também vira valido=False.
  • Frontend (importacao-plano-saude.js): criarValidadorArquivo() é a fábrica reaproveitada pelos dois campos (validadorPlanilha/validadorArquivo) — no change do <input type="file">, chama pidValidarArquivoPlanoSaude() e mostra o resultado abaixo do campo (.ips-file-field__status, cores diferentes pra pendente/ok/erro). Cada campo ganhou um botão de remover (.ips-file-field__remove, ícone X — só aparece com um arquivo anexado) que limpa o <input> e o estado de validação, pro colaborador poder tentar outro arquivo sem precisar recarregar a página quando o anexado voltar como divergente. Trocar a operadora depois de já ter anexado o arquivo dela (formOperadora change) reexecuta a validação automaticamente (revalidarSeAnexado()) — o parser usado depende de qual operadora está selecionada, então um arquivo validado contra a operadora errada precisa ser checado de novo. O botão "Processar" bloqueia (ehInvalido()) se qualquer um dos dois arquivos já voltou valido=False — mas isso é só uma segunda barreira de UX; o create() no servidor continua sendo a validação real e definitiva.

Gerar o arquivo é um download binário (CSV ou ZIP), não JSON — por isso pidGerarArquivoPlanoSaude() não usa pidApiRequest (que sempre tenta JSON.parse); faz um fetch manual reaproveitando pidEnsureCsrfCookie/pidGetCookie/pidErrorMessageFrom de api.js (funções globais na página) e dispara o download via URL.createObjectURL.

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. "Limpar seleção" (#ips-regra-limpar-btn, ao lado de "Aplicar" — adicionado pro caso de aplicar a regra errada por engano) chama limparRegraSelecionada() (zera o rastreamento — combobox, regraSelecionadaId/regraAplicadaId, observações), limparCusteioForm() (desfaz o que a regra preencheu: desmarca tipos de lançamento, radios de custeio e campos de limite/percentual de cada combinação tipo×pessoa) e limparOperadoraSelecionada() (limpa também a Operadora, já que aplicar uma regra pode ter preenchido esse campo junto — ver combobox de Operadora abaixo) — as duas primeiras foram extraídas de dentro de resetForm() justamente pra serem reaproveitadas aqui, e as três juntas são exatamente o que resetForm() também chama; não mexe nos arquivos já anexados, só no que uma regra aplicada de fato preenche em massa. "Ver regras salvas" (#ips-regras-modal) continua no topo, junto de Aplicar/Limpar; "Salvar regra atual..." (#ips-regra-salvar-btn) foi movido pro final do formulário (depois de "Tipo de importação", antes do botão "Processar" — .ips-regra-salvar-field em importacao-plano-saude.css), decisão explícita do usuário: salvar só faz sentido depois de parametrizar o custeio, é o último passo do fluxo de criar/editar uma regra, não algo que deveria ficar ao lado de Aplicar/Ver regras salvas no topo. Continua abrindo o mesmo 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" 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.
  • Classes .ips-combo/.ips-combo__list/.ips-combo__item/.ips-combo__empty (importacao-plano-saude.css) são genéricas, não específicas de "Regra de custeio salva" — reaproveitadas também pelo campo "Operadora" (#ips-operadora-combo/#ips-operadora-search/#ips-operadora-combo-list), que virou o mesmo tipo de combobox pesquisável (era um <select> simples) pra permitir buscar pelo código/nome da operadora, já que label agora vem prefixado com o código de cadastro no Questor (ver GET /operadoras/ acima). Diferença de implementação: como Operadora não tem um botão "Aplicar" separado (é o próprio campo do formulário, não uma configuração aplicada em massa), o valor de fato submetido viaja num <input type="hidden" id="ips-form-operadora"> — escolher um item da lista (selecionarOperadora()) já grava o valor na hora e revalida o arquivo da operadora já anexado (validadorArquivo.revalidarSeAnexado()), mesmo efeito que o antigo evento change do <select> disparava. Mesmo cuidado de invalidar a seleção ao digitar de novo, até escolher um item da lista. limparOperadoraSelecionada() (limpa o <input type="hidden"> + o texto de busca) é chamada tanto por resetForm() quanto por "Limpar seleção" da regra — decisão explícita do usuário: como aplicar uma regra pode ter preenchido a Operadora junto, desfazer a seleção da regra também desfaz o que ela preencheu ali.
  • ImportacaoPlanoSaude.regra_custeio_salva (FK opcional, SET_NULL, pra RegraCusteioPlanoSaude) registra qual regra (se alguma) estava aplicada no formulário no momento do "Processar" — só informativo, não influencia custeio_por_tipo (que já é o que de fato vale pro processamento) nem o processamento em si. Preenchido no submit com regraAplicadaId (JS) quando "Regra empresa" não está marcada (as duas rastreiam coisas diferentes e nunca são enviadas juntas — marcar "Regra empresa" já limpa regraAplicadaId via limparRegraSelecionada(), e aplicar uma regra de custeio salva já limpa "Regra empresa" via limparRegraEmpresa() dentro de aplicarCusteio()). É o que alimenta regra_custeio_salva_nome/regra_custeio_salva_observacoes na tela de Revisão (ver "Observações da regra, só-leitura na tela de Revisão" acima) — pedido do usuário depois de notar que a observação de uma regra de custeio salva (ex.: "092 - Unimed") não aparecia lá, só a de "Regra empresa".

Regra empresa (custeio especial de mensalidade por família)

Terceiro checkbox de "Tipo de importação" (ao lado de Mensalidade/Coparticipação) — cobre regras de custeio negociadas com uma empresa específica que não cabem no desenho normal "por tipo de lançamento × titular/dependente" (ver "Regras de custeio salvas" acima), tipicamente porque são calculadas por família inteira (titular + todos os dependentes somados), não por pessoa. Sem relação nenhuma com RegraCusteioPlanoSaude — decisão explícita do usuário: é um registro fixo no código (portal_api/planos_saude/regras_empresa.py), cadastrado pelo desenvolvedor quando o cliente repassa uma regra nova, nunca pela tela.

  • Mutuamente exclusivo com "Mensalidade": as duas são formas alternativas de configurar o mesmo tipo de lançamento "mensalidade" — marcar "Regra empresa" desmarca e esconde os radios de Mensalidade (e vice-versa), tanto no frontend (importacao-plano-saude.js, handlers de change dos dois checkboxes + aplicarCusteio()/limparRegraEmpresa()) quanto implicitamente no backend (ImportacaoPlanoSaudeCreateSerializer.validate() grava custeio_por_tipo["mensalidade"] = {} quando regra_empresa vem preenchido, ignorando custeio_mensalidade_titular/dependente). O tipo_lancamento persistido na ImportacaoPlanoSaudeLinha continua sendo "mensalidade" de qualquer forma — o CSV gerado (mensalidade.csv) não muda de nome nem de formato, já que é isso que o Questor espera importar; "Regra empresa" é só uma forma alternativa de calcular o mesmo valor, não um tipo de lançamento novo.
  • Registro (portal_api/planos_saude/regras_empresa.py, REGRAS_EMPRESA: Dict[str, dict]): cada entrada tem label (exibido no modal "Selecionar regra"), codigo_empresa (código da empresa na planilha padrão pra qual a regra foi negociada — trava contra aplicar a regra errada numa planilha de outra empresa, ver validação abaixo), operadora (só informativo), aplica (a função que faz o cálculo) e observacoes (opcional, texto livre explicando a regra). Pra cadastrar uma regra nova: escrever a função e registrar aqui — nada mais precisa mudar (GET /api/importacoes-plano-saude/regras-empresa/ já reflete o registro).
  • Observações da regra, só-leitura na tela de Revisão (#ips-review-regra-empresa-obs, entre o cabeçalho "Revisão" e as abas Mensalidade/Coparticipação/Auditoria/Alterações — decisão explícita do usuário sobre onde posicionar): ImportacaoPlanoSaudeDetailSerializer.regra_empresa_observacoes (SerializerMethodField) resolve REGRAS_EMPRESA[obj.regra_empresa]["observacoes"] a cada carregamento da importação — não é um campo persistido na ImportacaoPlanoSaude, sempre reflete o texto atual do registro no código (se a observação do registro mudar depois, importações antigas passam a mostrar o texto novo também, já que não há snapshot). abrirRevisao() (importacao-plano-saude.js) só mostra o bloco (hidden por padrão) quando o campo vem preenchido — a maioria das regras pode não ter observacoes cadastrada. Mesmo bloco reaproveitado pra "Regra de custeio salva" (ver ImportacaoPlanoSaude.regra_custeio_salva e "Regras de custeio salvas" abaixo): se não há regra_empresa_observacoes mas a importação tem regra_custeio_salva_observacoes (a observação real da RegraCusteioPlanoSaude aplicada, via CharField(source="regra_custeio_salva.observacoes", default=None)), o mesmo bloco exibe essa observação, com o rótulo trocado pra "Observações da regra de custeio salva — <nome>". As duas fontes nunca vêm preenchidas ao mesmo tempo (aplicar uma regra de custeio salva sempre desliga "Regra empresa" e vice-versa, ver aplicarCusteio()/handler de formTipoRegraEmpresa acima).
  • Primeira regra: unimed_1778_tecnomyl — Tecnomyl (código 1778 na Unimed) tem ajuda de custo de até R$ 661,61 por família (titular + dependentes juntos, não por pessoa): família com mensalidade total acima do teto tem o excedente descontado do empregado; igual ou abaixo do teto, a empresa cobre 100%. Repassada pelo cliente em 08/2026.
  • Cálculo é por família, com prioridade explícita: dependentes primeiro, titular absorve o residual (regras_empresa._aplica_teto_familia) — decisão explícita do cliente, e diferente de uma primeira versão (revertida) que distribuía o teto proporcionalmente entre todas as linhas. O algoritmo percorre primeiro os dependentes (na ordem em que aparecem no arquivo da operadora), cada um recebendo valor_empresa = min(seu valor, o que sobrou do teto); só depois de todos os dependentes processados o titular absorve o que sobrou do teto (teto_restante), com o excedente (se houver) virando desconto do empregado nessa mesma linha. Ex.: família com dependente de R$559,57 e titular de R$314,12 (teto R$661,61) — dependente sai com valor_empresa=559,57/valor=0 (coberto integralmente), sobra 661,61-559,57=102,04 de teto pro titular, que sai com valor_empresa=102,04/valor=212,08. Se os dependentes sozinhos já consumirem o teto inteiro, o titular fica com valor_empresa=0 (desconto integral) e, se ainda sobrar dependente sem cobrir depois disso, esse dependente também é parcialmente descontado. Validado rodando o pipeline direto com os dois exemplos passados pelo cliente (família de R$800 → R$661,61 empresa/R$138,39 empregado no total; família abaixo do teto → 100% empresa) e reproduzindo exatamente um caso real reportado pelo usuário (família Caroline Fernandes/Luciano Ramos, R$873,69 no total) depois do ajuste de prioridade.
  • Só funciona com casamento por nome (chave_casamento == "nome", ex.: Unimed) — a agregação por família depende do agrupamento que matcher._casa_por_nome já faz (por numero_titular); _casa_por_cpf não tem esse agrupamento e não foi estendida pra suportar (não havia necessidade ainda). regras_empresa.valida_regra_empresa() recusa explicitamente (RegraEmpresaIncompativelError, capturada à parte em views.py pra devolver a mensagem certa, não o erro genérico de "formato de arquivo") se a operadora escolhida não for compatível, e também recusa se a planilha padrão anexada não tiver nenhuma linha com o codigo_empresa esperado pela regra — trava contra aplicar a regra da Tecnomyl na planilha de outra empresa por engano.
  • _aplica_teto_familia é duck-typed de propósito (linhas_e_valores: List[Tuple[Any, float]], _eh_linha_titular() própria em vez de LinhaSistema.eh_linha_titular()): roda tanto contra LinhaSistema (pipeline, na criação da importação) quanto contra ImportacaoPlanoSaudeLinha (model Django, no recálculo pós "Vincular pessoa" — ver views._recalcula_familia_regra_empresa e "Resolução manual de auditoria por nome" acima) — as duas classes têm os mesmos atributos de string (nome_dependente/cpf_dependente/valor_empresa/valor), só a segunda não tem o método eh_linha_titular().
  • Coparticipação nunca é afetada: "Regra empresa" só cobre "mensalidade"; se o usuário também marcar "Coparticipação", ela segue o custeio normal configurado na própria tela (radios titular/dependente), sem nenhuma ligação com a regra empresa.
  • Frontend (importacao-plano-saude.js): checkbox "Regra empresa" revela uma caixa (#ips-regra-empresa-box) com o nome da regra atualmente escolhida + botão "Selecionar regra", que abre um modal simples (#ips-regra-empresa-modal, lista .ips-regra-row sem "Excluir"/"Editar" — é um registro fixo) buscado de GET /operadoras/regras-empresa/ uma vez por abertura do formulário (regrasEmpresaCache). regraEmpresaSelecionada (JS, {key, label}) é lido no submit (formData.append("regra_empresa", ...)) e em mensagemErroCusteio() (exige uma regra escolhida se o checkbox estiver marcado). limparCusteioForm()/limparRegraEmpresa() resetam o checkbox/caixa/seleção junto com o resto do custeio — inclusive quando "Limpar seleção" da regra de custeio salva é clicado, ou quando uma regra de custeio salva é aplicada (aplicarCusteio() sempre desliga "Regra empresa" antes de configurar mensalidade manualmente).

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.